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

@@ -8,6 +8,12 @@ from .auto_login import AutoLoginManager
from .config import LingmaAccount
from .lingma_client import LingmaGatewayClient
from .logging_config import get_logger
from .session_bundle import (
apply_bundle_to_workdir,
decode_bundle,
is_logged_in_workdir,
resolve_bundle_b64,
)
logger = get_logger("lingma_gateway.pool")
@@ -183,20 +189,67 @@ class LingmaPool:
pool-mode we skip it anyway, but Lingma may still write there internally)
and keeps docker logs readable. Failures are non-fatal; per-instance
reconnect loops will take over.
Before spawning each Lingma process we optionally restore a pre-captured
session bundle into the workDir, which lets us skip Playwright login
entirely on a fresh volume.
"""
for inst in self._instances:
self._maybe_apply_session_bundle(inst)
logger.info(
"pool starting %s (workDir=%s port=%d account=%s)",
"pool starting %s (workDir=%s port=%d account=%s bundle=%s logged_in=%s)",
inst.name,
inst.cfg.work_dir,
inst.cfg.socket_port,
inst.cfg.account.username or "<empty>",
bool(
inst.cfg.account.session_bundle_b64
or inst.cfg.account.session_bundle_file
),
is_logged_in_workdir(inst.cfg.work_dir),
)
try:
await inst.client.start()
except Exception as exc:
logger.warning("pool start %s failed: %s", inst.name, exc)
@staticmethod
def _maybe_apply_session_bundle(inst: "PoolInstance") -> None:
"""Restore an exported Lingma session into inst.work_dir, if needed.
Skipped when:
- the workDir already looks logged in (persistent volume case);
- no bundle is configured.
"""
acc = inst.cfg.account
if is_logged_in_workdir(inst.cfg.work_dir):
return
b64 = resolve_bundle_b64(
inline=acc.session_bundle_b64 or None,
file_path=acc.session_bundle_file or None,
)
if not b64:
return
try:
raw = decode_bundle(b64)
restored = apply_bundle_to_workdir(inst.cfg.work_dir, raw)
except Exception as exc:
logger.warning(
"pool %s: failed to apply session bundle, will fall back to auto-login: %s",
inst.name,
exc,
)
return
logger.info(
"pool %s: applied session bundle (%d files: %s)",
inst.name,
len(restored),
",".join(restored),
)
async def close(self) -> None:
tasks = [asyncio.create_task(inst.client.close()) for inst in self._instances]
for t in tasks: