feat: session bundle import/export to skip Playwright auto-login

Adds a lightweight way to pre-seed a Lingma workDir with an existing
logged-in session:

- New module session_bundle.py packs/unpacks only the four cache files
  that make up a Lingma login (id, user, quota, config.json). Everything
  else (db, logs, index, diagnosis) stays local so bundles stay tiny
  and never leak session-specific artefacts.
- Safety: path-traversal/symlink members are rejected; size is capped;
  refuses to export from a workDir that isn't actually logged in;
  sensitive cache/user is chmod'd 0600 on restore.
- LingmaAccount gains optional session_bundle_b64 / session_bundle_file;
  LINGMA_SESSION_BUNDLE[_FILE] env provide the singleton fallback.
  Credentials become optional when a bundle is supplied.
- LingmaPool.start() restores the bundle into each instance workDir
  only if it isn't already logged in, so persistent volumes aren't
  clobbered and a corrupt bundle falls back to Playwright gracefully.
- POST /internal/session/export returns the bundle as base64; ?instance=
  selects a specific pool instance. Requires an authed, already-logged-in
  instance to prevent exporting empties.
- README + .env.example document the end-to-end flow.

Made-with: Cursor
This commit is contained in:
GitHub Actions
2026-04-18 09:39:58 +08:00
parent ba865f3be0
commit 4e08d1af36
6 changed files with 386 additions and 3 deletions

View File

@@ -9,6 +9,11 @@ from dataclasses import dataclass, field
class LingmaAccount:
username: str
password: str
# Optional: pre-captured Lingma session to skip Playwright auto-login.
# Either inline base64 of a tar.gz bundle, or a path on disk holding the
# same. Inline wins if both are set.
session_bundle_b64: str = ""
session_bundle_file: str = ""
@dataclass
@@ -72,8 +77,19 @@ def _parse_accounts(raw: str) -> list[LingmaAccount]:
if isinstance(item, dict):
u = str(item.get("username", "")).strip()
p = str(item.get("password", "")).strip()
if u and p:
out.append(LingmaAccount(u, p))
bundle = str(item.get("session_bundle", "")).strip()
bundle_file = str(item.get("session_bundle_file", "")).strip()
# Username/password become optional when a bundle is supplied:
# Playwright login is only needed if there's no pre-captured session.
if (u and p) or bundle or bundle_file:
out.append(
LingmaAccount(
username=u,
password=p,
session_bundle_b64=bundle,
session_bundle_file=bundle_file,
)
)
return out
out: list[LingmaAccount] = []
@@ -97,11 +113,29 @@ def load_settings() -> Settings:
)
accounts = _parse_accounts(os.getenv("LINGMA_ACCOUNTS", ""))
# LINGMA_SESSION_BUNDLE / LINGMA_SESSION_BUNDLE_FILE are singleton envs
# that attach a session to the first account (or implicitly create one
# when neither LINGMA_ACCOUNTS nor LINGMA_USERNAME is provided -- common
# "I just want to skip Playwright" case).
fallback_bundle = os.getenv("LINGMA_SESSION_BUNDLE", "").strip()
fallback_bundle_file = os.getenv("LINGMA_SESSION_BUNDLE_FILE", "").strip()
if not accounts:
u = os.getenv("LINGMA_USERNAME", "").strip()
p = os.getenv("LINGMA_PASSWORD", "").strip()
if u and p:
accounts.append(LingmaAccount(u, p))
elif fallback_bundle or fallback_bundle_file:
# Bundle-only login: no creds needed.
accounts.append(LingmaAccount(username="", password=""))
if accounts and (fallback_bundle or fallback_bundle_file):
# Only fill on account[0] if it doesn't already carry one (accounts
# loaded from LINGMA_ACCOUNTS JSON may have per-entry bundles).
if not accounts[0].session_bundle_b64 and not accounts[0].session_bundle_file:
accounts[0].session_bundle_b64 = fallback_bundle
accounts[0].session_bundle_file = fallback_bundle_file
explicit_count = os.getenv("LINGMA_INSTANCE_COUNT", "").strip()
if explicit_count: