@q-agent/agent 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,180 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.browserHarnessBinDir = browserHarnessBinDir;
37
+ exports.ensureBrowserHarness = ensureBrowserHarness;
38
+ /**
39
+ * Ensure the `browser-harness` CLI is available for live spec-authoring (#400),
40
+ * provisioning it on first run so the user never installs anything by hand —
41
+ * the same "download once, then it's ready" contract as `ensureChromium`.
42
+ *
43
+ * browser-harness is a Python CLI, so we can't just add an npm dep. Instead we
44
+ * self-provision it with `uv` (a single static binary that also installs its own
45
+ * Python): find `uv` on PATH, else download the platform build once into the
46
+ * agent's config dir, then `uv tool install browser-harness` into a known bin dir
47
+ * we can resolve and prepend to the authoring subprocess PATH.
48
+ *
49
+ * Everything is best-effort and heavily logged: a failure returns `{ok:false}`
50
+ * with a message the caller surfaces, rather than throwing.
51
+ */
52
+ const child_process_1 = require("child_process");
53
+ const fs = __importStar(require("fs"));
54
+ const path = __importStar(require("path"));
55
+ const config_1 = require("./config");
56
+ const TOOLS_DIR = path.join((0, config_1.configDir)(), "tools");
57
+ const UV_DIR = path.join(TOOLS_DIR, "uv"); // downloaded uv binary lives here
58
+ const TOOL_BIN = path.join(TOOLS_DIR, "bin"); // UV_TOOL_BIN_DIR — browser-harness lands here
59
+ const TOOL_DATA = path.join(TOOLS_DIR, "data"); // UV_TOOL_DIR — tool venvs
60
+ const isWin = process.platform === "win32";
61
+ const exe = (n) => (isWin ? `${n}.exe` : n);
62
+ /** Directory holding the installed `browser-harness` executable (prepended to the
63
+ * authoring subprocess PATH so the agent's `claude` can run `browser-harness`). */
64
+ function browserHarnessBinDir() {
65
+ return TOOL_BIN;
66
+ }
67
+ /** Run a command to completion, returning its exit code (null on spawn error).
68
+ * Inherits stdio so first-run downloads/installs are visible in the agent log. */
69
+ function run(cmd, args, env) {
70
+ return new Promise((resolve) => {
71
+ const child = (0, child_process_1.spawn)(cmd, args, { stdio: "inherit", env: { ...process.env, ...env } });
72
+ child.on("close", resolve);
73
+ child.on("error", () => resolve(null));
74
+ });
75
+ }
76
+ /** True if `browser-harness` is already runnable (in our bin dir or on PATH). */
77
+ async function harnessPresent() {
78
+ if (fs.existsSync(path.join(TOOL_BIN, exe("browser-harness"))))
79
+ return true;
80
+ const code = await run(exe("browser-harness"), ["--version"]);
81
+ return code === 0;
82
+ }
83
+ /** Resolve a usable `uv`: our downloaded copy, then PATH, else download it once. */
84
+ async function ensureUv() {
85
+ const local = path.join(UV_DIR, exe("uv"));
86
+ if (fs.existsSync(local))
87
+ return local;
88
+ if ((await run(exe("uv"), ["--version"])) === 0)
89
+ return exe("uv"); // on PATH
90
+ const asset = uvAsset();
91
+ if (!asset) {
92
+ console.error(`ensureUv: unsupported platform ${process.platform}/${process.arch}`);
93
+ return null;
94
+ }
95
+ fs.mkdirSync(UV_DIR, { recursive: true });
96
+ const url = `https://github.com/astral-sh/uv/releases/latest/download/${asset}`;
97
+ const archive = path.join(UV_DIR, asset);
98
+ console.log(`Downloading uv (one-time) from ${url} ...`);
99
+ try {
100
+ const res = await fetch(url);
101
+ if (!res.ok)
102
+ throw new Error(`HTTP ${res.status}`);
103
+ fs.writeFileSync(archive, Buffer.from(await res.arrayBuffer()));
104
+ }
105
+ catch (e) {
106
+ console.error("ensureUv: download failed:", e.message);
107
+ return null;
108
+ }
109
+ // Extract with the system `tar` (bsdtar on Win10+ handles .zip too; unix uses .tar.gz).
110
+ if ((await run("tar", ["-xf", archive, "-C", UV_DIR])) !== 0) {
111
+ console.error("ensureUv: extract failed (need `tar` on PATH)");
112
+ return null;
113
+ }
114
+ // Archives may nest the binary in a subdir — find it.
115
+ const found = findFile(UV_DIR, exe("uv"));
116
+ if (found && found !== local) {
117
+ try {
118
+ fs.copyFileSync(found, local);
119
+ fs.chmodSync(local, 0o755);
120
+ }
121
+ catch { /* best-effort */ }
122
+ }
123
+ return fs.existsSync(local) ? local : found;
124
+ }
125
+ /** The uv release asset name for this platform/arch, or null if unsupported. */
126
+ function uvAsset() {
127
+ const a = process.arch;
128
+ if (isWin)
129
+ return a === "arm64" ? "uv-aarch64-pc-windows-msvc.zip" : "uv-x86_64-pc-windows-msvc.zip";
130
+ if (process.platform === "darwin")
131
+ return a === "arm64" ? "uv-aarch64-apple-darwin.tar.gz" : "uv-x86_64-apple-darwin.tar.gz";
132
+ if (process.platform === "linux")
133
+ return a === "arm64" ? "uv-aarch64-unknown-linux-gnu.tar.gz" : "uv-x86_64-unknown-linux-gnu.tar.gz";
134
+ return null;
135
+ }
136
+ /** Shallow recursive search for a file named `name` under `dir`. */
137
+ function findFile(dir, name) {
138
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
139
+ const full = path.join(dir, entry.name);
140
+ if (entry.isDirectory()) {
141
+ const hit = findFile(full, name);
142
+ if (hit)
143
+ return hit;
144
+ }
145
+ else if (entry.name === name) {
146
+ return full;
147
+ }
148
+ }
149
+ return null;
150
+ }
151
+ /**
152
+ * Ensure `browser-harness` is installed. Returns the bin dir to prepend to PATH.
153
+ * Fast no-op once present; on first run downloads uv (if needed) and installs
154
+ * browser-harness (uv fetches its own Python). Never throws.
155
+ */
156
+ async function ensureBrowserHarness() {
157
+ try {
158
+ if (await harnessPresent())
159
+ return { ok: true, binDir: TOOL_BIN };
160
+ const uv = await ensureUv();
161
+ if (!uv) {
162
+ return { ok: false, binDir: TOOL_BIN, error: "could not obtain `uv` to install browser-harness" };
163
+ }
164
+ fs.mkdirSync(TOOL_BIN, { recursive: true });
165
+ fs.mkdirSync(TOOL_DATA, { recursive: true });
166
+ console.log("Installing browser-harness (one-time) via uv ...");
167
+ const code = await run(uv, ["tool", "install", "--force", "browser-harness"], {
168
+ UV_TOOL_BIN_DIR: TOOL_BIN,
169
+ UV_TOOL_DIR: TOOL_DATA,
170
+ });
171
+ if (code !== 0 || !(await harnessPresent())) {
172
+ return { ok: false, binDir: TOOL_BIN, error: "browser-harness install via uv failed" };
173
+ }
174
+ console.log("browser-harness ready.");
175
+ return { ok: true, binDir: TOOL_BIN };
176
+ }
177
+ catch (e) {
178
+ return { ok: false, binDir: TOOL_BIN, error: e.message };
179
+ }
180
+ }
package/dist/src/paths.js CHANGED
@@ -40,6 +40,7 @@ exports.packagedRoot = packagedRoot;
40
40
  exports.nodeBin = nodeBin;
41
41
  exports.agentNodeModules = agentNodeModules;
42
42
  exports.playwrightCli = playwrightCli;
43
+ exports.claudeCli = claudeCli;
43
44
  exports.vendorCaptureScript = vendorCaptureScript;
44
45
  exports.vendorExploreScript = vendorExploreScript;
45
46
  exports.vendorAuthoringScript = vendorAuthoringScript;
@@ -140,6 +141,31 @@ function playwrightCli() {
140
141
  // Resolve via Node so it works whether npm nested or hoisted `playwright`.
141
142
  return path.join(path.dirname(require.resolve("playwright/package.json")), "cli.js");
142
143
  }
144
+ /** The bundled Claude Code CLI (`@anthropic-ai/claude-code`), so no separate
145
+ * `claude` install / PATH shim is needed (#400 — live-authoring drives it on the
146
+ * agent). NOTE: the package ships a NATIVE binary (`bin/claude[.exe]`), not a JS
147
+ * entry — spawn it DIRECTLY (not via node). Resolves the package's `bin` entry
148
+ * across source/npx and the packaged bundle. */
149
+ function claudeCli() {
150
+ const relFromDir = (dir) => {
151
+ const fallback = path.join(dir, "bin", process.platform === "win32" ? "claude.exe" : "claude");
152
+ try {
153
+ const bin = require(path.join(dir, "package.json")).bin;
154
+ const rel = typeof bin === "string" ? bin : bin ? Object.values(bin)[0] : null;
155
+ return rel ? path.join(dir, rel) : fallback;
156
+ }
157
+ catch {
158
+ return fallback;
159
+ }
160
+ };
161
+ const root = packagedRoot();
162
+ if (root) {
163
+ const dir = path.join(root, "node_modules", "@anthropic-ai", "claude-code");
164
+ if (fs.existsSync(dir))
165
+ return relFromDir(dir);
166
+ }
167
+ return relFromDir(path.dirname(require.resolve("@anthropic-ai/claude-code/package.json")));
168
+ }
143
169
  /** The vendored headed-login capture script (`capture_auth.cjs`). */
144
170
  function vendorCaptureScript() {
145
171
  const root = packagedRoot();
@@ -56,6 +56,7 @@ const api = __importStar(require("./api"));
56
56
  const bus_1 = require("./bus");
57
57
  const ensureBrowser_1 = require("./ensureBrowser");
58
58
  const paths_1 = require("./paths");
59
+ const ensureTooling_1 = require("./ensureTooling");
59
60
  const playwrightConfig_1 = require("./playwrightConfig");
60
61
  const report_1 = require("./report");
61
62
  const session_1 = require("./session");
@@ -935,22 +936,6 @@ async function getFreePort() {
935
936
  });
936
937
  });
937
938
  }
938
- /** Poll Chrome's /json/version until it responds (CDP is up) or we time out. */
939
- async function waitForCdp(port, timeoutMs) {
940
- const deadline = Date.now() + timeoutMs;
941
- while (Date.now() < deadline) {
942
- try {
943
- const r = await fetch(`http://127.0.0.1:${port}/json/version`);
944
- if (r.ok)
945
- return true;
946
- }
947
- catch {
948
- /* endpoint not up yet */
949
- }
950
- await new Promise((r) => setTimeout(r, 300));
951
- }
952
- return false;
953
- }
954
939
  /**
955
940
  * Author one spec live on this machine (#403): launch the dedicated,
956
941
  * pre-authenticated Chrome (vendored launcher), point the local `browser-harness`
@@ -996,16 +981,37 @@ async function processAuthoringJob(cfg, job) {
996
981
  case: job.caseId, phase: "launching", message: "Starting authenticated browser",
997
982
  })
998
983
  .catch(() => { });
999
- // 1) Dedicated pre-auth Chrome (vendored launcher Node built-ins only).
1000
- launcher = (0, child_process_1.spawn)((0, paths_1.nodeBin)(), [(0, paths_1.vendorAuthoringScript)(), job.baseUrl, String(port), profileDir], {
1001
- env: { ...process.env, ...(0, paths_1.childNodeEnv)() },
1002
- stdio: ["pipe", "pipe", "pipe"],
1003
- });
984
+ // 1) Dedicated pre-auth Chrome (vendored launcher). Pass the saved
985
+ // sessionStorage path so the launcher replays MSAL/SPA tokens into the
986
+ // Chrome browser-harness attaches to; NODE_PATH lets the launcher resolve
987
+ // the bundled Playwright it uses for that replay. Wait for its READY line
988
+ // (emitted AFTER the replay is armed) so browser-harness never navigates
989
+ // before the session is restored.
990
+ const nm = (0, paths_1.agentNodeModules)();
991
+ launcher = (0, child_process_1.spawn)((0, paths_1.nodeBin)(), [(0, paths_1.vendorAuthoringScript)(), job.baseUrl, String(port), profileDir, sess ? sess.sessionStoragePath : ""], { env: nodePathEnv(nm), stdio: ["pipe", "pipe", "pipe"] });
1004
992
  activeChild = launcher;
1005
993
  let launcherErr = "";
1006
994
  launcher.stderr?.on("data", (d) => { launcherErr += String(d); });
1007
- if (!(await waitForCdp(port, AUTHORING_CDP_READY_TIMEOUT_MS))) {
1008
- await finalize("", { routes: [], selectors: [] }, `Chrome CDP did not come up on port ${port}. ${launcherErr.trim()}`.trim(), false);
995
+ const ready = await new Promise((resolve) => {
996
+ const to = setTimeout(() => resolve(false), AUTHORING_CDP_READY_TIMEOUT_MS);
997
+ launcher.stdout?.on("data", (d) => {
998
+ if (String(d).includes("AUTHORING_BROWSER_READY")) {
999
+ clearTimeout(to);
1000
+ resolve(true);
1001
+ }
1002
+ });
1003
+ launcher.on("exit", () => { clearTimeout(to); resolve(false); });
1004
+ });
1005
+ if (!ready) {
1006
+ await finalize("", { routes: [], selectors: [] }, `Authoring browser did not become ready on port ${port}. ${launcherErr.trim()}`.trim(), false);
1007
+ return;
1008
+ }
1009
+ // Ensure browser-harness is installed (first run provisions it via uv). Its
1010
+ // bin dir is prepended to the claude subprocess PATH so `browser-harness`
1011
+ // resolves for the Bash calls Claude makes.
1012
+ const bh = await (0, ensureTooling_1.ensureBrowserHarness)();
1013
+ if (!bh.ok) {
1014
+ await finalize("", { routes: [], selectors: [] }, `browser-harness unavailable: ${bh.error || "unknown"}`, false);
1009
1015
  return;
1010
1016
  }
1011
1017
  await api
@@ -1015,28 +1021,51 @@ async function processAuthoringJob(cfg, job) {
1015
1021
  .catch(() => { });
1016
1022
  // 2) Local agentic Claude drives browser-harness (BU_CDP_URL → our Chrome),
1017
1023
  // writing the spec + discovered.json into workDir. System prompt via a
1018
- // file (avoids a huge argv); task prompt via stdin.
1024
+ // file (avoids a huge argv); task prompt via stdin. Invoked as
1025
+ // `nodeBin() <bundled claude cli.js>` so no `claude` install/PATH shim is
1026
+ // needed (and no Windows .cmd shell hack).
1019
1027
  const systemFile = path.join(workDir, "system-prompt.txt");
1020
1028
  fs.writeFileSync(systemFile, job.systemPrompt, "utf-8");
1021
- // On Windows `claude` is a .cmd shim, which Node can only launch via a shell;
1022
- // quote the path-bearing args there. On POSIX we spawn without a shell.
1023
- const useShell = process.platform === "win32";
1024
- const q = (s) => (useShell ? `"${s}"` : s);
1029
+ // Use the app's saved Claude credential (shipped in the claim) so we don't
1030
+ // need a separate `claude login` on this machine. Write it locked-down into
1031
+ // the temp workspace (removed with workDir); empty ⇒ fall back to the agent's
1032
+ // own login (no CLAUDE_CONFIG_DIR override).
1033
+ let claudeConfigDir = "";
1034
+ if (job.claudeCredentials) {
1035
+ claudeConfigDir = path.join(workDir, ".claude-config");
1036
+ fs.mkdirSync(claudeConfigDir, { recursive: true });
1037
+ const credFile = path.join(claudeConfigDir, ".credentials.json");
1038
+ fs.writeFileSync(credFile, job.claudeCredentials, "utf-8");
1039
+ try {
1040
+ fs.chmodSync(credFile, 0o600);
1041
+ }
1042
+ catch { /* best-effort on Windows */ }
1043
+ }
1025
1044
  const claudeArgs = [
1026
1045
  "-p",
1027
1046
  "--output-format", "json",
1028
1047
  "--model", job.model,
1029
- "--append-system-prompt-file", q(systemFile),
1048
+ "--append-system-prompt-file", systemFile,
1030
1049
  "--allowedTools", "Bash", "Read", "Write", "Glob", "Grep",
1031
1050
  "--dangerously-skip-permissions",
1032
- "--add-dir", q(workDir),
1051
+ "--add-dir", workDir,
1033
1052
  "--max-budget-usd", String(job.maxBudgetUsd),
1034
1053
  ];
1035
- claude = (0, child_process_1.spawn)("claude", claudeArgs, {
1054
+ // Prepend browser-harness's bin dir to PATH (overwrite the same-case key so
1055
+ // Windows doesn't end up with both Path and PATH).
1056
+ const pathVar = Object.keys(process.env).find((k) => k.toLowerCase() === "path") || "PATH";
1057
+ const claudeEnv = {
1058
+ ...process.env,
1059
+ BU_CDP_URL: `http://127.0.0.1:${port}`,
1060
+ [pathVar]: `${bh.binDir}${path.delimiter}${process.env[pathVar] || ""}`,
1061
+ };
1062
+ if (claudeConfigDir)
1063
+ claudeEnv.CLAUDE_CONFIG_DIR = claudeConfigDir;
1064
+ // Spawn the native `claude` binary directly (it is not a JS entry).
1065
+ claude = (0, child_process_1.spawn)((0, paths_1.claudeCli)(), claudeArgs, {
1036
1066
  cwd: workDir,
1037
- env: { ...process.env, BU_CDP_URL: `http://127.0.0.1:${port}` },
1067
+ env: claudeEnv,
1038
1068
  stdio: ["pipe", "pipe", "pipe"],
1039
- shell: useShell,
1040
1069
  });
1041
1070
  activeChild = claude;
1042
1071
  let cout = "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@q-agent/agent",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Q-Agent Local Agent — claims execution jobs from the Q-Agent server and runs Playwright locally, so manual login/MFA happens on the user's own machine and session credentials never leave it.",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
@@ -32,6 +32,7 @@
32
32
  "dist:desktop": "npm run build && electron-builder --win"
33
33
  },
34
34
  "dependencies": {
35
+ "@anthropic-ai/claude-code": "^2.1.0",
35
36
  "@playwright/test": "^1.61.1",
36
37
  "commander": "^12.1.0",
37
38
  "electron-updater": "^6.3.9",
@@ -1,5 +1,5 @@
1
1
  // Long-lived, pre-authenticated automation Chrome for live spec-authoring (#400).
2
- // Args: baseUrl, port, profileDir.
2
+ // Args: baseUrl, port, profileDir, [sessionStoragePath].
3
3
  //
4
4
  // Launches a real Chrome/Edge (NOT via Playwright's launcher, so no automation
5
5
  // fingerprint) on a FIXED --remote-debugging-port using a DEDICATED, persistent
@@ -8,7 +8,16 @@
8
8
  // the Chrome "Allow remote debugging" popup / default-profile lockdown (see
9
9
  // browser_harness/daemon.py:128-131,148). Auth is inherited from the persistent
10
10
  // profile — reuse the capture `browser-profile` dir (already logged in via the
11
- // manual-login capture flow), so the session is present without any injection.
11
+ // manual-login capture flow), so cookies + localStorage are present.
12
+ //
13
+ // sessionStorage (where MSAL/SPA auth tokens live) is NEVER persisted to a Chrome
14
+ // profile on disk, so a profile-only relaunch lands unauthenticated for such apps.
15
+ // When a `sessionStoragePath` is given AND Playwright is resolvable (agent side —
16
+ // the API container ships no Playwright and passes no path), we attach over CDP
17
+ // and register an init script that replays the saved sessionStorage for the
18
+ // matching origin before app code runs — the same trick the run/explore paths use.
19
+ // The Playwright connection is kept alive for the whole session so the init-script
20
+ // registration persists for the tabs browser-harness opens.
12
21
  //
13
22
  // Unlike capture_auth.cjs (a short snapshot loop) this stays ALIVE for the whole
14
23
  // authoring session and only tears Chrome down when the parent closes our stdin
@@ -19,7 +28,7 @@
19
28
  const { spawn } = require('child_process');
20
29
  const fs = require('fs');
21
30
  const path = require('path');
22
- const [, , baseUrl, portArg, profileDir] = process.argv;
31
+ const [, , baseUrl, portArg, profileDir, sessionStoragePath] = process.argv;
23
32
  const PORT = parseInt(portArg, 10);
24
33
 
25
34
  process.on('unhandledRejection', (e) => console.error('authoring_browser unhandledRejection:', e && (e.message || e)));
@@ -66,6 +75,38 @@ async function waitForCDP(port, timeoutMs) {
66
75
  return false;
67
76
  }
68
77
 
78
+ // Replay saved sessionStorage into the running Chrome so MSAL/SPA apps that keep
79
+ // their token in sessionStorage are authenticated. Playwright is OPTIONAL: if it
80
+ // can't be required (the API container ships none) or no path was given, this is
81
+ // a no-op. Returns the connected Playwright browser (kept alive so the init-script
82
+ // registration survives) or null.
83
+ async function replaySessionStorage(port) {
84
+ if (!sessionStoragePath) return null;
85
+ let byOrigin;
86
+ try { byOrigin = JSON.parse(fs.readFileSync(sessionStoragePath, 'utf-8')); }
87
+ catch { return null; }
88
+ if (!byOrigin || typeof byOrigin !== 'object' || !Object.keys(byOrigin).length) return null;
89
+ let chromium;
90
+ try { ({ chromium } = require('playwright')); } catch { return null; }
91
+ try {
92
+ const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`);
93
+ const ctx = browser.contexts()[0];
94
+ if (ctx) {
95
+ await ctx.addInitScript((data) => {
96
+ try {
97
+ const o = data && data[location.origin];
98
+ if (o) for (const k of Object.keys(o)) window.sessionStorage.setItem(k, o[k]);
99
+ } catch (e) {}
100
+ }, byOrigin);
101
+ console.error('authoring_browser: sessionStorage replay armed for', Object.keys(byOrigin).join(','));
102
+ }
103
+ return browser;
104
+ } catch (e) {
105
+ console.error('authoring_browser: sessionStorage replay failed:', e && e.message);
106
+ return null;
107
+ }
108
+ }
109
+
69
110
  (async () => {
70
111
  if (!PORT || Number.isNaN(PORT)) { console.error('authoring_browser: invalid port', portArg); process.exit(1); }
71
112
  const exe = findBrowser();
@@ -101,14 +142,19 @@ async function waitForCDP(port, timeoutMs) {
101
142
  process.exit(1);
102
143
  }
103
144
 
104
- // Signal readiness on stdout so the parent can proceed (it also polls
105
- // /json/version independently). The daemon resolves BU_CDP_URL to the WS.
145
+ // Arm sessionStorage replay (best-effort) BEFORE signalling readiness, so the
146
+ // token is restored before browser-harness navigates.
147
+ const pw = await replaySessionStorage(PORT);
148
+
149
+ // Signal readiness on stdout so the parent proceeds. The daemon resolves
150
+ // BU_CDP_URL to the WS.
106
151
  console.log(`AUTHORING_BROWSER_READY ${PORT}`);
107
152
 
108
153
  let shuttingDown = false;
109
154
  const shutdown = (code) => {
110
155
  if (shuttingDown) return;
111
156
  shuttingDown = true;
157
+ try { if (pw) pw.close(); } catch {}
112
158
  try { child.kill(); } catch {}
113
159
  process.exit(code || 0);
114
160
  };