@sagentlab/navarch-runtime 0.1.133 → 0.1.135

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.
package/README.md CHANGED
@@ -71,6 +71,35 @@ with another agent such as Claude Code, or use Docker mode. Docker mode is not
71
71
  probed because its image supplies the browser. Set `NAVARCH_BROWSER_PROBE=off`
72
72
  to trust the configured capability list without a check.
73
73
 
74
+ #### Project Playwright suites under Codex on macOS
75
+
76
+ The same denial stops a project's own `playwright test` from launching
77
+ Chromium inside a Codex session, and the sandbox cannot download the suite's
78
+ browser either. On macOS, every Codex build, verify, and QA session therefore
79
+ gets `navarch_browser.start_playwright_browser`, with or without `browser-use`.
80
+ The tool:
81
+
82
+ 1. reads the Playwright version the checkout installed (from
83
+ `node_modules/playwright-core`, `playwright` or `@playwright/test`
84
+ `package.json`; nothing in the checkout is executed);
85
+ 2. installs that exact `playwright-core` with `npm install --ignore-scripts`,
86
+ plus its Chromium, into `<workspace>/playwright-server/<version>`, once per
87
+ machine;
88
+ 3. starts that version's browser server under `/usr/bin/sandbox-exec` with
89
+ Navarch's own profile (`playwrightServerProfile` in
90
+ `src/playwright-server.cts`), and returns `PW_TEST_CONNECT_WS_ENDPOINT`.
91
+
92
+ The agent runs the repository's usual `playwright test` command with that
93
+ variable set. The test code stays inside Codex's sandbox, and only the browser
94
+ runs outside it. The browser's sandbox allows Chromium's
95
+ `MachPortRendezvousServer` registration, reads of system paths and its own
96
+ install, writes to a per-lease directory, and connections to loopback only.
97
+ So `file://` reads of the user's files, other hosts, and the server-side
98
+ `request` API to other hosts all fail, even through CDP. DNS lookups are not
99
+ blocked. The endpoint path is a per-lease secret, and the server stops with
100
+ the lease. Playwright's connect mode ignores config launch options (headed,
101
+ channel, `executablePath`, launch args) and serves Chromium projects only.
102
+
74
103
  Call the tool with `url`, `source_commit_sha`, and `evidence_role` (`before` or
75
104
  `after`). Optional `wait_for_selector` and `settle_ms` let a scene finish loading.
76
105
  The URL's deployed SHA must be checked by the agent: the supplied SHA is metadata,
@@ -1,9 +1,14 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.acpAdapter = void 0;
4
7
  exports.runAcpAdapter = runAcpAdapter;
8
+ exports.probeAcpSteering = probeAcpSteering;
5
9
  const node_child_process_1 = require("node:child_process");
6
10
  const node_fs_1 = require("node:fs");
11
+ const node_os_1 = __importDefault(require("node:os"));
7
12
  const node_stream_1 = require("node:stream");
8
13
  const kill_agent_cjs_1 = require("../kill-agent.cjs");
9
14
  const types_cjs_1 = require("./types.cjs");
@@ -16,6 +21,21 @@ const types_cjs_1 = require("./types.cjs");
16
21
  * gets no chance to release what it is holding.
17
22
  */
18
23
  const CANCEL_GRACE_MS = 2_000;
24
+ /** How long the steering probe waits for the agent to answer `initialize`. */
25
+ const PROBE_TIMEOUT_MS = 10_000;
26
+ /**
27
+ * What an ACP agent that answered the probe's `initialize` with protocol v1
28
+ * can do: `session/cancel` and a further `session/prompt` on the same session
29
+ * are core v1 methods, so an interrupt stops the running prompt and starts the
30
+ * next one on the held connection. ACP has no input into a running prompt, so
31
+ * steer stays `none`, and a queued message still waits for the session loop's
32
+ * turn boundary.
33
+ */
34
+ const ACP_NATIVE_STEERING = Object.freeze({
35
+ steer: "none",
36
+ queue: "boundary",
37
+ interrupt: "native",
38
+ });
19
39
  /**
20
40
  * Generic Agent Client Protocol v1 adapter. DeepSeek Harness is the default
21
41
  * implementation (`dsh --profile acp`), while NAVARCH_ACP_BIN and
@@ -36,19 +56,33 @@ async function runAcpAdapter(options) {
36
56
  const rawStdout = [];
37
57
  const stderr = [];
38
58
  const protocolInput = new node_stream_1.PassThrough();
39
- const messages = [];
59
+ // Agent messages of the current prompt only: after an interrupt, the report
60
+ // is what the last prompt produced, not the text of the turn it cut short.
61
+ let messages = [];
40
62
  const thoughts = [];
41
63
  let costUsd;
42
64
  let timedOut = false;
43
65
  let killedByLeaseLoss = false;
44
66
  let sessionId;
45
67
  let stopReason;
46
- // Resolved once the in-flight prompt request settles, however it settles, so
68
+ // Settles once the in-flight prompt request settles, however it settles, so
47
69
  // cancellation can wait for the agent instead of racing the kill.
48
- let settlePrompt = () => undefined;
49
- const promptSettled = new Promise((resolve) => {
50
- settlePrompt = resolve;
51
- });
70
+ let promptSettled = Promise.resolve();
71
+ // Native interrupt (docs/navarch/live-steering.md section 5.3, row `acp`).
72
+ const live = options.liveInput;
73
+ let attached = false;
74
+ let promptRunning = false;
75
+ // This adapter sent `session/cancel` to stop the running prompt for an interrupt.
76
+ let interruptCancelSent = false;
77
+ // Interrupt inputs that wait for the running prompt to return, in the order they were sent.
78
+ const held = [];
79
+ const detach = () => {
80
+ if (!attached)
81
+ return;
82
+ attached = false;
83
+ held.length = 0;
84
+ live?.attach(null);
85
+ };
52
86
  child.stdout.on("data", (chunk) => {
53
87
  options.onActivity?.();
54
88
  rawStdout.push(chunk);
@@ -86,6 +120,9 @@ async function runAcpAdapter(options) {
86
120
  const controller = new AbortController();
87
121
  let abortProtocol = Promise.resolve();
88
122
  controller.signal.addEventListener("abort", () => {
123
+ // A timeout, lease loss or task cancel ends the session: no interrupt may
124
+ // start another prompt, and the session takes back what was not sent.
125
+ detach();
89
126
  abortProtocol = (async () => {
90
127
  if (sessionId) {
91
128
  await connection.agent.notify(acp.methods.agent.session.cancel, { sessionId }).catch(() => undefined);
@@ -110,7 +147,7 @@ async function runAcpAdapter(options) {
110
147
  onAbort();
111
148
  try {
112
149
  const context = connection.agent;
113
- await context.request(acp.methods.agent.initialize, {
150
+ const initialized = await context.request(acp.methods.agent.initialize, {
114
151
  protocolVersion: acp.PROTOCOL_VERSION,
115
152
  clientCapabilities: {},
116
153
  });
@@ -122,17 +159,68 @@ async function runAcpAdapter(options) {
122
159
  let configOptions = created.configOptions ?? [];
123
160
  configOptions = await setConfigOption(context, acp.methods.agent.session.setConfigOption, sessionId, configOptions, "model", options.model === "default" ? undefined : options.model);
124
161
  await setConfigOption(context, acp.methods.agent.session.setConfigOption, sessionId, configOptions, "reasoning_effort", options.reasoningEffort);
125
- let response;
126
- try {
127
- response = await context.request(acp.methods.agent.session.prompt, {
128
- sessionId,
129
- prompt: [{ type: "text", text: options.prompt }],
162
+ // The probe saw protocol v1; an agent that now answers another version
163
+ // takes no interrupt, which then waits for the turn boundary.
164
+ if (live && initialized.protocolVersion === acp.PROTOCOL_VERSION && !controller.signal.aborted) {
165
+ const activeSessionId = sessionId;
166
+ attached = true;
167
+ live.attach({
168
+ // ACP has no input into a running prompt.
169
+ send: () => false,
170
+ interrupt: (input) => {
171
+ if (!attached)
172
+ return false;
173
+ held.push(input);
174
+ if (promptRunning && !interruptCancelSent) {
175
+ interruptCancelSent = true;
176
+ void connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: activeSessionId })
177
+ .catch(() => undefined);
178
+ }
179
+ return true;
180
+ },
130
181
  });
131
182
  }
132
- finally {
133
- settlePrompt();
183
+ let prompt = [{ type: "text", text: options.prompt }];
184
+ // The interrupt inputs `prompt` carries, and whether the first one stopped a prompt.
185
+ let carried = null;
186
+ for (;;) {
187
+ messages = [];
188
+ promptRunning = true;
189
+ interruptCancelSent = false;
190
+ const request = context.request(acp.methods.agent.session.prompt, { sessionId, prompt });
191
+ promptSettled = request.then(() => undefined, () => undefined);
192
+ // An interrupt that was waiting when the channel attached (steering hands
193
+ // it over at once) arrived before the first prompt ran: stop that prompt.
194
+ if (held.length > 0 && !interruptCancelSent) {
195
+ interruptCancelSent = true;
196
+ void context.notify(acp.methods.agent.session.cancel, { sessionId }).catch(() => undefined);
197
+ }
198
+ if (carried) {
199
+ // The agent reads it as the next prompt on the same session. ACP has no
200
+ // read receipt, and the ack cannot wait for the whole prompt, so the
201
+ // message counts as taken once its prompt is sent. The first input owns
202
+ // the stop; the rest were sent while it was pending.
203
+ carried.inputs.forEach((input, index) => live?.consumed(input.id, true, index === 0 && carried.stoppedTurn));
204
+ carried = null;
205
+ }
206
+ let response;
207
+ try {
208
+ response = await request;
209
+ }
210
+ finally {
211
+ promptRunning = false;
212
+ }
213
+ stopReason = response.stopReason;
214
+ // A `cancelled` prompt that an interrupt asked for is not the end of the
215
+ // session: the interrupt's prompt follows. One that a timeout, lease loss
216
+ // or task cancel asked for is.
217
+ if (controller.signal.aborted || held.length === 0)
218
+ break;
219
+ const inputs = held.splice(0);
220
+ carried = { inputs, stoppedTurn: interruptCancelSent && stopReason === "cancelled" };
221
+ prompt = inputs.map((input) => ({ type: "text", text: input.text }));
134
222
  }
135
- stopReason = response.stopReason;
223
+ detach();
136
224
  await Promise.race([
137
225
  context.request(acp.methods.agent.session.close, { sessionId }).catch(() => undefined),
138
226
  new Promise((resolve) => setTimeout(resolve, options.timeoutMs > 0 ? Math.min(5_000, options.timeoutMs) : 5_000)),
@@ -143,6 +231,7 @@ async function runAcpAdapter(options) {
143
231
  stderr.push(`\n${describeError(error)}`);
144
232
  }
145
233
  finally {
234
+ detach();
146
235
  clearTimeout(timer);
147
236
  options.signal?.removeEventListener("abort", onAbort);
148
237
  await abortProtocol;
@@ -274,8 +363,87 @@ async function terminateChild(child, options, forceContainerKill) {
274
363
  function describeError(error) {
275
364
  return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
276
365
  }
366
+ /**
367
+ * Probe results by command line. A pass is kept for the life of the runtime. A
368
+ * failure is kept for a while only, because the agent may be installed or fixed
369
+ * later, and probing it again for every claim would delay each session by up
370
+ * to the probe timeout.
371
+ */
372
+ const acpProbeCache = new Map();
373
+ const PROBE_FAILURE_TTL_MS = 10 * 60_000;
374
+ /**
375
+ * Native interrupt needs an agent that speaks ACP v1, where `session/cancel`
376
+ * and a further `session/prompt` on the same session are core methods. The
377
+ * probe starts the agent, sends `initialize`, and stops it. Only the host is
378
+ * probed: the probe has no sandbox container to reach the Docker agent, so
379
+ * Docker keeps `interrupt: restart`, as does an agent that fails to start,
380
+ * answers late, or reports another protocol version.
381
+ */
382
+ async function probeAcpSteering(context) {
383
+ if (context.sandboxMode !== "host")
384
+ return null;
385
+ const key = JSON.stringify([context.bin, ...context.extraArgs]);
386
+ let probe = acpProbeCache.get(key);
387
+ if (!probe) {
388
+ probe = speaksAcpV1(context).catch(() => false);
389
+ acpProbeCache.set(key, probe);
390
+ const cached = probe;
391
+ void cached.then((passed) => {
392
+ if (passed)
393
+ return;
394
+ setTimeout(() => {
395
+ if (acpProbeCache.get(key) === cached)
396
+ acpProbeCache.delete(key);
397
+ }, PROBE_FAILURE_TTL_MS).unref();
398
+ });
399
+ }
400
+ return (await probe) ? ACP_NATIVE_STEERING : null;
401
+ }
402
+ async function speaksAcpV1(context) {
403
+ const acp = await import("@agentclientprotocol/sdk");
404
+ const child = (0, node_child_process_1.spawn)(context.bin, context.extraArgs, {
405
+ detached: process.platform !== "win32",
406
+ // No session exists yet, so an agent that writes state at startup writes it
407
+ // in the temporary directory, not in the runtime's working directory.
408
+ cwd: node_os_1.default.tmpdir(),
409
+ env: { ...process.env, ...context.env },
410
+ stdio: ["pipe", "pipe", "ignore"],
411
+ });
412
+ const exited = new Promise((resolve) => {
413
+ child.once("error", () => resolve());
414
+ child.once("close", () => resolve());
415
+ });
416
+ child.stdin.on("error", () => undefined);
417
+ const connection = acp.client({ name: "navarch-runtime" }).connect(acp.ndJsonStream(node_stream_1.Writable.toWeb(child.stdin), node_stream_1.Readable.toWeb(child.stdout)));
418
+ let timer;
419
+ try {
420
+ return await Promise.race([
421
+ connection.agent.request(acp.methods.agent.initialize, {
422
+ protocolVersion: acp.PROTOCOL_VERSION,
423
+ clientCapabilities: {},
424
+ }).then((response) => response.protocolVersion === acp.PROTOCOL_VERSION, () => false),
425
+ exited.then(() => false),
426
+ new Promise((resolve) => {
427
+ timer = setTimeout(() => resolve(false), PROBE_TIMEOUT_MS);
428
+ }),
429
+ ]);
430
+ }
431
+ finally {
432
+ clearTimeout(timer);
433
+ connection.close();
434
+ if (child.exitCode === null && child.signalCode === null) {
435
+ (0, kill_agent_cjs_1.killAgent)(child, "SIGTERM");
436
+ await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 1_000))]);
437
+ if (child.exitCode === null && child.signalCode === null)
438
+ (0, kill_agent_cjs_1.killAgent)(child);
439
+ }
440
+ }
441
+ }
277
442
  exports.acpAdapter = {
278
443
  agentType: "acp",
279
444
  run: runAcpAdapter,
445
+ // The claim's baseline record. The probe upgrades it to ACP_NATIVE_STEERING
446
+ // on a host whose agent speaks ACP v1.
280
447
  steering: types_cjs_1.TURN_RESTART_STEERING,
448
+ probeSteering: probeAcpSteering,
281
449
  };
@@ -200,6 +200,10 @@ async function codexMcpArgs(path, env) {
200
200
  continue;
201
201
  args.push("-c", `mcp_servers.${key}.url=${tomlString(server.url)}`);
202
202
  args.push("-c", `mcp_servers.${key}.required=${isPlatformServer(name)}`);
203
+ // start_playwright_browser installs a Playwright version and its Chromium
204
+ // on first use, which can outlast Codex's 60 s default.
205
+ if (name === "navarch_browser")
206
+ args.push("-c", `mcp_servers.${key}.tool_timeout_sec=600`);
203
207
  const envHeaders = {};
204
208
  let headerIndex = 0;
205
209
  for (const [header, value] of Object.entries(server.headers ?? {})) {
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.captureSchema = void 0;
7
+ exports.navarchBrowserTools = navarchBrowserTools;
7
8
  exports.installEvidenceBrowser = installEvidenceBrowser;
8
9
  exports.launchCaptureBrowser = launchCaptureBrowser;
9
10
  exports.startBrowserEvidence = startBrowserEvidence;
@@ -14,9 +15,27 @@ const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamable
14
15
  const playwright_1 = require("playwright");
15
16
  const zod_1 = require("zod");
16
17
  const local_browser_cjs_1 = require("./local-browser.cjs");
18
+ const playwright_server_cjs_1 = require("./playwright-server.cjs");
17
19
  const node_child_process_1 = require("node:child_process");
18
20
  const node_util_1 = require("node:util");
19
21
  const node_path_1 = __importDefault(require("node:path"));
22
+ const BROWSER_USE_RUNTIMES = ["codex", "claude-code", "antigravity", "opencode", "acp", "muse"];
23
+ /**
24
+ * Which navarch_browser tools a host session gets. `browserUse` adds capture
25
+ * and local_browser_* for browser-use tasks. `playwrightSuites` adds
26
+ * start_playwright_browser to every macOS Codex build/verify/qa session,
27
+ * because Codex's seatbelt cannot launch the project's own Playwright suite's
28
+ * Chromium whether or not the task asked for browser-use.
29
+ */
30
+ function navarchBrowserTools(input) {
31
+ if (input.mcpConfigPath || input.sandboxMode !== "host" || !["build", "verify", "qa"].includes(input.taskType)) {
32
+ return { browserUse: false, playwrightSuites: false };
33
+ }
34
+ return {
35
+ browserUse: input.requiredCapabilities?.includes("browser-use") === true && BROWSER_USE_RUNTIMES.includes(input.runtime),
36
+ playwrightSuites: input.runtime === "codex" && input.platform === "darwin",
37
+ };
38
+ }
20
39
  async function installEvidenceBrowser() {
21
40
  const cli = node_path_1.default.join(node_path_1.default.dirname(require.resolve("playwright/package.json")), "cli.js");
22
41
  await (0, node_util_1.promisify)(node_child_process_1.execFile)(process.execPath, [cli, "install", "chromium"], {
@@ -48,7 +67,8 @@ exports.captureSchema = zod_1.z.object(captureShape);
48
67
  /** Runs in the supervisor, outside the coding agent's OS sandbox. Fresh
49
68
  * contexts have no user cookies, arbitrary script execution, or file access.
50
69
  * The lease gets the narrow capture operation plus the loopback-only
51
- * local_browser_* tools (local-browser.cts). */
70
+ * local_browser_* tools (local-browser.cts), and on macOS Codex hosts the
71
+ * project's own Playwright browser server (playwright-server.cts). */
52
72
  async function startBrowserEvidence(options) {
53
73
  const token = (0, node_crypto_1.randomBytes)(32).toString("hex");
54
74
  const fetchImpl = options.fetchImpl ?? fetch;
@@ -211,6 +231,8 @@ async function startBrowserEvidence(options) {
211
231
  }
212
232
  });
213
233
  (0, local_browser_cjs_1.registerLocalBrowserTools)(server, localBrowser);
234
+ if (options.playwrightServer)
235
+ (0, playwright_server_cjs_1.registerPlaywrightServerTool)(server, options.playwrightServer);
214
236
  res.on("close", () => { void transport.close(); void server.close(); });
215
237
  try {
216
238
  await server.connect(transport);
@@ -261,6 +283,7 @@ async function startBrowserEvidence(options) {
261
283
  http.closeAllConnections();
262
284
  await browser?.close().catch(() => undefined);
263
285
  await localBrowser.close();
286
+ await options.playwrightServer?.close();
264
287
  await activeCapture?.catch(() => undefined);
265
288
  await stopped;
266
289
  },
@@ -81,7 +81,8 @@ async function probeBrowserCapability(config, deps = {}) {
81
81
  return {
82
82
  status: "passed",
83
83
  reason: truncate(`Headless Chromium cannot start inside the Codex sandbox (${sandboxFailure.detail}); ` +
84
- "Codex sessions browse local services through Navarch's local_browser tools, which run outside it."),
84
+ "Codex sessions browse local services through Navarch's local_browser tools and run Playwright suites " +
85
+ "against start_playwright_browser, both outside it."),
85
86
  checked_at: now().toISOString(),
86
87
  };
87
88
  }
@@ -0,0 +1,388 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.playwrightServerShape = exports.PLAYWRIGHT_SERVER_DIRNAME = void 0;
7
+ exports.playwrightServerProfile = playwrightServerProfile;
8
+ exports.projectPlaywrightVersion = projectPlaywrightVersion;
9
+ exports.createPlaywrightServer = createPlaywrightServer;
10
+ exports.registerPlaywrightServerTool = registerPlaywrightServerTool;
11
+ const node_child_process_1 = require("node:child_process");
12
+ const node_crypto_1 = require("node:crypto");
13
+ const node_fs_1 = require("node:fs");
14
+ const node_os_1 = __importDefault(require("node:os"));
15
+ const node_path_1 = __importDefault(require("node:path"));
16
+ const zod_1 = require("zod");
17
+ const dependency_cache_cjs_1 = require("./dependency-cache.cjs");
18
+ const kill_agent_cjs_1 = require("./kill-agent.cjs");
19
+ const logger_cjs_1 = require("./logger.cjs");
20
+ const sandbox_cjs_1 = require("./sandbox.cjs");
21
+ const log = (0, logger_cjs_1.createLogger)("playwright-server");
22
+ /**
23
+ * Chromium for the project's own Playwright suite, outside Codex's seatbelt.
24
+ *
25
+ * Codex's macOS seatbelt denies Chromium's Mach port registration
26
+ * (`bootstrap_check_in ... MachPortRendezvousServer: Permission denied
27
+ * (1100)`) and has no setting that allows it, so a verify session could run
28
+ * every project check except `playwright test` and ended BLOCKED (ShiftSwap
29
+ * task da9bc52c, 2026-10-27). The agent cannot install the suite's browser
30
+ * either: the sandbox has no CDN egress.
31
+ *
32
+ * Only the browser moves. The supervisor installs the exact `playwright-core`
33
+ * version the checkout uses, plus its Chromium, into a runtime cache, and
34
+ * starts that version's browser server under Navarch's own seatbelt
35
+ * (`playwrightServerProfile`). The suite itself, test code included, still
36
+ * runs in the agent's shell inside Codex's sandbox and reaches the server
37
+ * through Playwright's `PW_TEST_CONNECT_WS_ENDPOINT`. A connected client can
38
+ * drive the browser (CDP included), so the browser's own sandbox is the
39
+ * boundary: it may register Chromium's rendezvous port, read system paths and
40
+ * its own install, write only a private directory, and connect only to
41
+ * loopback. `file://` reads of the user's files, other hosts, and the
42
+ * server-side `request` API to other hosts all fail there.
43
+ */
44
+ exports.PLAYWRIGHT_SERVER_DIRNAME = "playwright-server";
45
+ const READY_MARKER = "NAVARCH_PLAYWRIGHT_SERVER";
46
+ const START_TIMEOUT_MS = 60_000;
47
+ const NPM_TIMEOUT_MS = 5 * 60_000;
48
+ const BROWSER_INSTALL_TIMEOUT_MS = 10 * 60_000;
49
+ const MAX_PACKAGE_JSON_BYTES = 1024 * 1024;
50
+ const COMPLETE_MARKER = ".navarch-complete";
51
+ // Stable and pre-release versions such as 1.57.0-alpha-1760000000000.
52
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]{0,48})?$/;
53
+ /**
54
+ * Runs inside the sandbox with the cached playwright-core. Exits when the
55
+ * supervisor's end of stdin closes, so a crashed runtime leaves no browser.
56
+ */
57
+ const LAUNCHER_SCRIPT = `
58
+ const { chromium } = require(process.argv[2]);
59
+ chromium.launchServer({ host: "127.0.0.1", port: 0, wsPath: "/" + process.argv[3], headless: true }).then((server) => {
60
+ process.stdout.write(${JSON.stringify(READY_MARKER)} + " " + server.wsEndpoint() + "\\n");
61
+ const stop = () => server.close().finally(() => process.exit(0));
62
+ process.stdin.on("end", stop);
63
+ process.stdin.on("error", stop);
64
+ process.stdin.resume();
65
+ process.on("SIGTERM", stop);
66
+ }, (error) => {
67
+ process.stderr.write(String((error && error.stack) || error));
68
+ process.exit(1);
69
+ });
70
+ `;
71
+ /**
72
+ * Seatbelt for the browser server and its Chromium. Parameters are canonical
73
+ * paths: NODE_ROOT (the Node install), PLAYWRIGHT_CORE (the cached package),
74
+ * BROWSERS (Playwright's browser cache), PRIVATE (this lease's scratch dir).
75
+ * The user's home is readable only through those, and nothing else is
76
+ * writable. Chromium's own helper sandboxes nest inside this one.
77
+ */
78
+ function playwrightServerProfile() {
79
+ return [
80
+ "(version 1)",
81
+ "(deny default)",
82
+ '(import "system.sb")',
83
+ "(allow process-fork)",
84
+ "(allow process-exec)",
85
+ "(allow signal (target same-sandbox))",
86
+ "(allow process-info* (target same-sandbox))",
87
+ "(allow sysctl-read)",
88
+ "(allow mach-lookup)",
89
+ // The one grant Codex's seatbelt lacks: the browser process registering
90
+ // <bundle id>.MachPortRendezvousServer.<pid> for its child processes.
91
+ '(allow mach-register (global-name-regex #"\\.MachPortRendezvousServer\\.[0-9]+$"))',
92
+ "(allow ipc-posix-shm*)",
93
+ "(allow iokit-open)",
94
+ "(allow file-read-metadata)",
95
+ "(allow file-read*",
96
+ ' (literal "/") (subpath "/System") (subpath "/usr") (subpath "/bin") (subpath "/sbin") (subpath "/opt")',
97
+ ' (subpath "/Library") (subpath "/private/etc") (subpath "/private/var/db") (subpath "/dev")',
98
+ ' (subpath (param "NODE_ROOT")) (subpath (param "PLAYWRIGHT_CORE")) (subpath (param "BROWSERS"))',
99
+ ' (subpath (param "PRIVATE")))',
100
+ '(allow file-write* (subpath (param "PRIVATE")) (literal "/dev/null"))',
101
+ '(allow network-bind (local ip "localhost:*"))',
102
+ '(allow network-inbound (local ip "localhost:*"))',
103
+ '(allow network-outbound (remote ip "localhost:*"))',
104
+ "",
105
+ ].join("\n");
106
+ }
107
+ /**
108
+ * The Playwright version a checkout's suite uses, from the installed
109
+ * package.json files under `directory` (relative to the worktree). Only the
110
+ * version string is read; nothing from the checkout is executed.
111
+ */
112
+ async function projectPlaywrightVersion(worktreePath, directory = ".") {
113
+ const root = node_path_1.default.resolve(worktreePath);
114
+ const projectDir = node_path_1.default.resolve(root, directory);
115
+ if (projectDir !== root && !projectDir.startsWith(`${root}${node_path_1.default.sep}`)) {
116
+ throw new Error(`directory must stay inside the checkout: ${directory}`);
117
+ }
118
+ const shown = node_path_1.default.relative(root, projectDir) || ".";
119
+ for (const name of ["playwright-core", "playwright", "@playwright/test"]) {
120
+ const file = node_path_1.default.join(projectDir, "node_modules", name, "package.json");
121
+ let raw;
122
+ try {
123
+ const stat = await node_fs_1.promises.stat(file);
124
+ if (!stat.isFile() || stat.size > MAX_PACKAGE_JSON_BYTES)
125
+ continue;
126
+ raw = await node_fs_1.promises.readFile(file, "utf8");
127
+ }
128
+ catch {
129
+ continue;
130
+ }
131
+ let version;
132
+ try {
133
+ version = JSON.parse(raw).version;
134
+ }
135
+ catch {
136
+ continue;
137
+ }
138
+ if (typeof version === "string" && VERSION_PATTERN.test(version))
139
+ return version;
140
+ throw new Error(`${shown}/node_modules/${name}/package.json has no usable version.`);
141
+ }
142
+ throw new Error(`No Playwright install under ${shown}/node_modules. Use the directory whose prepared dependencies include ` +
143
+ "@playwright/test (pass it as `directory`).");
144
+ }
145
+ const installs = new Map();
146
+ /** One sandboxed browser server per lease, started on first use. */
147
+ function createPlaywrightServer(options) {
148
+ const runner = options.runner ?? sandbox_cjs_1.nodeCommandRunner;
149
+ const env = options.env ?? process.env;
150
+ const token = (0, node_crypto_1.randomBytes)(24).toString("hex");
151
+ let running;
152
+ let queue = Promise.resolve();
153
+ let closed = false;
154
+ const assertActive = () => {
155
+ if (closed || !options.isActive())
156
+ throw new Error("The Playwright browser server's lease is no longer active.");
157
+ };
158
+ async function stop() {
159
+ const child = running?.child;
160
+ running = undefined;
161
+ if (!child || child.exitCode !== null || child.signalCode !== null)
162
+ return;
163
+ const exited = new Promise((resolve) => child.once("exit", () => resolve()));
164
+ child.stdin?.end();
165
+ const timer = setTimeout(() => (0, kill_agent_cjs_1.killAgent)(child), 5_000);
166
+ await exited;
167
+ clearTimeout(timer);
168
+ }
169
+ /** Installs playwright-core@version and its Chromium once per machine; returns the package dir. */
170
+ function ensureInstalled(version) {
171
+ const key = `${node_path_1.default.resolve(options.cacheRoot)}\0${version}`;
172
+ let pending = installs.get(key);
173
+ if (!pending) {
174
+ pending = install(version).finally(() => installs.delete(key));
175
+ installs.set(key, pending);
176
+ }
177
+ return pending;
178
+ }
179
+ async function install(version) {
180
+ const entry = node_path_1.default.join(options.cacheRoot, version);
181
+ const coreDir = node_path_1.default.join(entry, "node_modules", "playwright-core");
182
+ const npmEnv = (0, dependency_cache_cjs_1.npmEnvironment)(env);
183
+ if (env.PLAYWRIGHT_BROWSERS_PATH)
184
+ npmEnv.PLAYWRIGHT_BROWSERS_PATH = env.PLAYWRIGHT_BROWSERS_PATH;
185
+ if (!(await exists(node_path_1.default.join(entry, COMPLETE_MARKER)))) {
186
+ await node_fs_1.promises.mkdir(options.cacheRoot, { recursive: true, mode: 0o700 });
187
+ const staging = node_path_1.default.join(options.cacheRoot, `.tmp-${version}-${(0, node_crypto_1.randomBytes)(4).toString("hex")}`);
188
+ await node_fs_1.promises.mkdir(staging, { mode: 0o700 });
189
+ try {
190
+ await node_fs_1.promises.writeFile(node_path_1.default.join(staging, "package.json"), '{"private":true}\n');
191
+ const result = await runner.run(options.npmBin ?? "npm", [
192
+ "install",
193
+ "--ignore-scripts",
194
+ "--no-audit",
195
+ "--no-fund",
196
+ "--no-package-lock",
197
+ "--strict-ssl=true",
198
+ "--cache",
199
+ node_path_1.default.join(options.cacheRoot, ".npm"),
200
+ "--logs-dir",
201
+ node_path_1.default.join(options.cacheRoot, ".logs"),
202
+ `playwright-core@${version}`,
203
+ ], { cwd: staging, env: npmEnv, timeoutMs: NPM_TIMEOUT_MS });
204
+ if (result.code !== 0) {
205
+ log.warn(`npm install playwright-core@${version} exited ${result.code}: ${tail(result.stderr || result.stdout)}`);
206
+ throw new Error(`The runtime could not install playwright-core@${version} (npm exited ${result.code}).`);
207
+ }
208
+ await node_fs_1.promises.writeFile(node_path_1.default.join(staging, COMPLETE_MARKER), `${new Date().toISOString()}\n`);
209
+ await node_fs_1.promises.rename(staging, entry).catch(async (error) => {
210
+ // Another runtime process finished the same version first.
211
+ if (!(await exists(node_path_1.default.join(entry, COMPLETE_MARKER))))
212
+ throw error;
213
+ });
214
+ }
215
+ finally {
216
+ await node_fs_1.promises.rm(staging, { recursive: true, force: true }).catch(() => undefined);
217
+ }
218
+ }
219
+ // Idempotent; also restores a browser removed from Playwright's cache.
220
+ const browser = await runner.run(process.execPath, [node_path_1.default.join(coreDir, "cli.js"), "install", "chromium"], {
221
+ cwd: entry,
222
+ env: npmEnv,
223
+ timeoutMs: BROWSER_INSTALL_TIMEOUT_MS,
224
+ });
225
+ if (browser.code !== 0) {
226
+ log.warn(`playwright-core@${version} install chromium exited ${browser.code}: ${tail(browser.stderr || browser.stdout)}`);
227
+ throw new Error(`The runtime could not install Chromium for Playwright ${version}.`);
228
+ }
229
+ return coreDir;
230
+ }
231
+ async function launch(version, coreDir) {
232
+ const privateDir = node_path_1.default.join(options.workDir, exports.PLAYWRIGHT_SERVER_DIRNAME);
233
+ await node_fs_1.promises.rm(privateDir, { recursive: true, force: true });
234
+ await node_fs_1.promises.mkdir(privateDir, { recursive: true, mode: 0o700 });
235
+ const launcher = node_path_1.default.join(privateDir, "launcher.cjs");
236
+ await node_fs_1.promises.writeFile(launcher, LAUNCHER_SCRIPT, "utf8");
237
+ const nodePath = await node_fs_1.promises.realpath(process.execPath);
238
+ const canonicalCore = await node_fs_1.promises.realpath(coreDir);
239
+ const browsersPath = playwrightBrowsersPath(env, canonicalCore);
240
+ await node_fs_1.promises.mkdir(browsersPath, { recursive: true });
241
+ // <prefix>/bin/node: the prefix holds Node's own files. Never widen that
242
+ // to the home directory itself (for example ~/bin/node).
243
+ let nodeRoot = node_path_1.default.dirname(node_path_1.default.dirname(nodePath));
244
+ const home = node_path_1.default.resolve(env.HOME ?? node_os_1.default.homedir());
245
+ if (home === nodeRoot || home.startsWith(`${nodeRoot}${node_path_1.default.sep}`) || nodeRoot === node_path_1.default.sep)
246
+ nodeRoot = node_path_1.default.dirname(nodePath);
247
+ const params = {
248
+ NODE_ROOT: nodeRoot,
249
+ PLAYWRIGHT_CORE: canonicalCore,
250
+ BROWSERS: await node_fs_1.promises.realpath(browsersPath),
251
+ PRIVATE: await node_fs_1.promises.realpath(privateDir),
252
+ };
253
+ const args = [
254
+ "-p",
255
+ playwrightServerProfile(),
256
+ ...Object.entries(params).flatMap(([name, value]) => ["-D", `${name}=${value}`]),
257
+ nodePath,
258
+ node_path_1.default.join(params.PRIVATE, "launcher.cjs"),
259
+ canonicalCore,
260
+ token,
261
+ ];
262
+ const childEnv = { TMPDIR: params.PRIVATE };
263
+ for (const name of ["HOME", "PATH", "LANG", "PLAYWRIGHT_BROWSERS_PATH"]) {
264
+ if (env[name] !== undefined)
265
+ childEnv[name] = env[name];
266
+ }
267
+ const child = (options.spawnServer ?? defaultSpawn)("/usr/bin/sandbox-exec", args, { cwd: params.PRIVATE, env: childEnv });
268
+ return new Promise((resolve, reject) => {
269
+ let stdout = "";
270
+ let stderr = "";
271
+ const timer = setTimeout(() => fail(new Error("did not start within 60 s")), START_TIMEOUT_MS);
272
+ const fail = (error) => {
273
+ clearTimeout(timer);
274
+ (0, kill_agent_cjs_1.killAgent)(child);
275
+ reject(new Error(`The Playwright ${version} browser server could not start: ${error.message}${launchDetail(stderr)}`));
276
+ };
277
+ child.stdout?.on("data", (chunk) => {
278
+ stdout += chunk.toString();
279
+ const match = new RegExp(`${READY_MARKER} (ws://127\\.0\\.0\\.1:\\d+/${token})`).exec(stdout);
280
+ if (!match)
281
+ return;
282
+ clearTimeout(timer);
283
+ running = { child, endpoint: match[1], version };
284
+ resolve(match[1]);
285
+ });
286
+ child.stderr?.on("data", (chunk) => {
287
+ if (stderr.length < 64_000)
288
+ stderr += chunk.toString();
289
+ });
290
+ child.on("error", fail);
291
+ child.on("exit", (code, signal) => {
292
+ if (running?.child === child)
293
+ running = undefined;
294
+ fail(new Error(`exited ${signal ?? code}`));
295
+ });
296
+ });
297
+ }
298
+ return {
299
+ /** Secret part of the endpoint, for redaction. */
300
+ token,
301
+ start(args) {
302
+ const next = queue.then(async () => {
303
+ assertActive();
304
+ if ((options.platform ?? process.platform) !== "darwin") {
305
+ throw new Error("Navarch's Playwright browser server is only needed and supported on macOS hosts.");
306
+ }
307
+ const version = await projectPlaywrightVersion(options.worktreePath, args.directory);
308
+ const live = running;
309
+ if (live && live.version === version && live.child.exitCode === null && live.child.signalCode === null) {
310
+ return { endpoint: live.endpoint, version, reused: true };
311
+ }
312
+ await stop();
313
+ const coreDir = await ensureInstalled(version);
314
+ assertActive();
315
+ const endpoint = await launch(version, coreDir);
316
+ if (closed || !options.isActive()) {
317
+ await stop();
318
+ assertActive();
319
+ }
320
+ log.info(`playwright-core ${version} browser server started for ${options.worktreePath}`);
321
+ return { endpoint, version, reused: false };
322
+ });
323
+ queue = next.catch(() => undefined);
324
+ return next;
325
+ },
326
+ async close() {
327
+ closed = true;
328
+ await queue.catch(() => undefined);
329
+ await stop();
330
+ },
331
+ };
332
+ }
333
+ exports.playwrightServerShape = {
334
+ directory: zod_1.z.string().min(1).max(500).default(".")
335
+ .describe("Directory inside the checkout whose node_modules holds @playwright/test (where you run `playwright test`)."),
336
+ };
337
+ /** Registers start_playwright_browser on a lease's navarch-browser MCP server. */
338
+ function registerPlaywrightServerTool(server, playwright) {
339
+ server.registerTool("start_playwright_browser", {
340
+ description: "Start Chromium for this checkout's own Playwright test suite outside the coding-agent sandbox, where Codex's macOS sandbox cannot launch it. Navarch installs the browser for the exact Playwright version the checkout uses. Returns PW_TEST_CONNECT_WS_ENDPOINT: run the project's usual `playwright test` command in your shell with it set. The suite still runs in your sandbox; the browser reaches only this machine's loopback services.",
341
+ inputSchema: exports.playwrightServerShape,
342
+ }, async (args) => {
343
+ try {
344
+ const { endpoint, version, reused } = await playwright.start(args);
345
+ return { content: [{ type: "text", text: [
346
+ `Playwright browser server ${reused ? "already running" : "ready"}: Playwright ${version} with its own Chromium, outside the coding-agent sandbox.`,
347
+ "Run the project's suite exactly as the repository runs it, with this variable set in the same command:",
348
+ ` PW_TEST_CONNECT_WS_ENDPOINT=${endpoint}`,
349
+ `Example: PW_TEST_CONNECT_WS_ENDPOINT=${endpoint} node node_modules/@playwright/test/cli.js test`,
350
+ "- This is the same suite, not a substitute: report the command and its result as the e2e check.",
351
+ "- Pages reach only loopback services on this machine (the config's webServer, http://localhost:<port>); other hosts and file:// URLs are refused.",
352
+ "- The config's browser launch options (headed, channel, executablePath, launch args) do not apply, and only Chromium projects can connect.",
353
+ ].join("\n") }] };
354
+ }
355
+ catch (error) {
356
+ return { isError: true, content: [{ type: "text", text: String(error) }] };
357
+ }
358
+ });
359
+ }
360
+ function playwrightBrowsersPath(env, coreDir) {
361
+ const configured = env.PLAYWRIGHT_BROWSERS_PATH;
362
+ // "0" keeps browsers inside the package (readable through PLAYWRIGHT_CORE).
363
+ if (configured === "0")
364
+ return node_path_1.default.join(coreDir, ".local-browsers");
365
+ if (configured)
366
+ return node_path_1.default.resolve(configured);
367
+ return node_path_1.default.join(env.HOME ?? node_os_1.default.homedir(), "Library", "Caches", "ms-playwright");
368
+ }
369
+ function defaultSpawn(bin, args, options) {
370
+ return (0, node_child_process_1.spawn)(bin, args, { cwd: options.cwd, env: options.env, stdio: ["pipe", "pipe", "pipe"], detached: true });
371
+ }
372
+ function launchDetail(stderr) {
373
+ const lines = stderr.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
374
+ const line = lines.find((item) => /FATAL:|Error:|Executable doesn't exist/.test(item)) ?? lines.at(-1);
375
+ return line ? ` (${line.slice(0, 300)})` : "";
376
+ }
377
+ function tail(text) {
378
+ return text.trim().split(/\r?\n/).slice(-3).join(" | ").slice(0, 500);
379
+ }
380
+ async function exists(file) {
381
+ try {
382
+ await node_fs_1.promises.access(file);
383
+ return true;
384
+ }
385
+ catch {
386
+ return false;
387
+ }
388
+ }
package/dist/session.cjs CHANGED
@@ -39,6 +39,7 @@ const agent_auth_cjs_1 = require("./agent-auth.cjs");
39
39
  const agent_model_cjs_1 = require("./agent-model.cjs");
40
40
  const github_credential_cjs_1 = require("./github-credential.cjs");
41
41
  const browser_evidence_cjs_1 = require("./browser-evidence.cjs");
42
+ const playwright_server_cjs_1 = require("./playwright-server.cjs");
42
43
  const git_grant_cjs_1 = require("./git-grant.cjs");
43
44
  const commit_attribution_cjs_1 = require("./commit-attribution.cjs");
44
45
  /** Filename the generated platform MCP config is written under inside the session metadata directory. */
@@ -680,13 +681,25 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
680
681
  }
681
682
  steering?.start();
682
683
  await gitWorktree.prepare();
683
- if (!config.mcpConfigPath && config.sandboxMode === "host"
684
- && ["build", "verify", "qa"].includes(task.task_type)
685
- && task.required_capabilities?.includes("browser-use")
686
- && ["codex", "claude-code", "antigravity", "opencode", "acp", "muse"].includes(runtime)) {
684
+ const { browserUse, playwrightSuites } = (0, browser_evidence_cjs_1.navarchBrowserTools)({
685
+ taskType: task.task_type, requiredCapabilities: task.required_capabilities, runtime,
686
+ platform: process.platform, sandboxMode: config.sandboxMode, mcpConfigPath: config.mcpConfigPath,
687
+ });
688
+ if (browserUse || playwrightSuites) {
689
+ const isActive = () => !leaseLost && !sessionClosing && !deps.shutdownSignal?.aborted;
690
+ const playwrightServer = playwrightSuites
691
+ ? (0, playwright_server_cjs_1.createPlaywrightServer)({
692
+ worktreePath: gitWorktree.worktreePath,
693
+ workDir,
694
+ cacheRoot: node_path_1.default.join(config.workspaceRoot, playwright_server_cjs_1.PLAYWRIGHT_SERVER_DIRNAME),
695
+ isActive,
696
+ })
697
+ : undefined;
698
+ if (playwrightServer)
699
+ registry.register(playwrightServer.token);
687
700
  browserEvidence = await (0, browser_evidence_cjs_1.startBrowserEvidence)({
688
701
  apiBase: api.getBaseUrl(), machineToken: api.getToken() ?? "", leaseId,
689
- isActive: () => !leaseLost && !sessionClosing && !deps.shutdownSignal?.aborted,
702
+ isActive, playwrightServer,
690
703
  });
691
704
  registry.register(browserEvidence.token);
692
705
  const mcp = JSON.parse(await node_fs_1.promises.readFile(mcpConfigPath, "utf8"));
@@ -700,7 +713,10 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
700
713
  }
701
714
  mcp.mcpServers.navarch_browser = browserConfig;
702
715
  await node_fs_1.promises.writeFile(mcpConfigPath, JSON.stringify(mcp), { mode: 0o600 });
703
- promptText += "\n\nNavarch supplies capture_visual_evidence through navarch_browser. Use it first for desktop/mobile screenshots of the verified preview URL and source commit. It runs outside the coding-agent sandbox, uploads images as task artifacts, and the runtime attaches them to the delivery PR before completion. Inspect the returned images before accepting the UI. Do not use GitHub's upload UI or add capture tools to the target repository. If the base commit has no runnable UI, report before evidence as not applicable with the base SHA and reason; still capture the current head. For interactive walkthroughs of a service you start on this machine, use navarch_browser's local_browser_open, local_browser_click, local_browser_fill, local_browser_press, local_browser_snapshot, and local_browser_screenshot with http://localhost:<port> or http://127.0.0.1:<port>. That browser also runs outside the sandbox and reaches only this machine's loopback services; prefer it over launching Chromium yourself, which Codex's macOS sandbox kills at startup.\n";
716
+ if (browserUse)
717
+ promptText += "\n\nNavarch supplies capture_visual_evidence through navarch_browser. Use it first for desktop/mobile screenshots of the verified preview URL and source commit. It runs outside the coding-agent sandbox, uploads images as task artifacts, and the runtime attaches them to the delivery PR before completion. Inspect the returned images before accepting the UI. Do not use GitHub's upload UI or add capture tools to the target repository. If the base commit has no runnable UI, report before evidence as not applicable with the base SHA and reason; still capture the current head. For interactive walkthroughs of a service you start on this machine, use navarch_browser's local_browser_open, local_browser_click, local_browser_fill, local_browser_press, local_browser_snapshot, and local_browser_screenshot with http://localhost:<port> or http://127.0.0.1:<port>. That browser also runs outside the sandbox and reaches only this machine's loopback services; prefer it over launching Chromium yourself, which Codex's macOS sandbox kills at startup.\n";
718
+ if (playwrightServer)
719
+ promptText += "\n\nCodex's macOS sandbox kills Chromium at startup (`bootstrap_check_in ... MachPortRendezvousServer: Permission denied`), so the project's own Playwright suite cannot launch a browser in your shell, and you cannot install Playwright's browsers there either. When a task needs that suite, call navarch_browser's start_playwright_browser (pass `directory` when @playwright/test is installed below the checkout root), then run the repository's usual `playwright test` command with the returned PW_TEST_CONNECT_WS_ENDPOINT set. The suite still runs in your sandbox; only Chromium runs outside it, installed by Navarch for the checkout's exact Playwright version and limited to this machine's loopback services. Never skip, waive, or replace the suite because of the sandbox; report the command you ran and its result.\n";
704
720
  }
705
721
  if (gitWorktree.hostedCheckpointRestoreFailure) {
706
722
  log.warn(`hosted checkpoint for ${leaseId} could not be restored: ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.133",
3
+ "version": "0.1.135",
4
4
  "description": "Navarch machine-side session manager: claims delivery tasks and runs them through local coding agents.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",