@sagentlab/navarch-runtime 0.1.92 → 0.1.95
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 +23 -6
- package/dist/browser-evidence.cjs +9 -1
- package/dist/browser-probe.cjs +20 -5
- package/dist/cli.cjs +1 -1
- package/dist/exit-conditions.cjs +4 -1
- package/dist/local-browser.cjs +279 -0
- package/dist/session.cjs +42 -21
- package/dist/worktree-guard.cjs +37 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,6 +9,16 @@ environment, outside the coding agent's OS sandbox, with a fresh browser
|
|
|
9
9
|
context for desktop and touch-enabled mobile captures. It neither uses the
|
|
10
10
|
operator's browser profile nor executes agent-provided scripts.
|
|
11
11
|
|
|
12
|
+
The same server offers interactive `local_browser_open`, `local_browser_click`,
|
|
13
|
+
`local_browser_fill`, `local_browser_press`, `local_browser_snapshot`, and
|
|
14
|
+
`local_browser_screenshot` tools for walking through a service the agent starts
|
|
15
|
+
on this machine. That browser is limited to loopback (`localhost`,
|
|
16
|
+
`*.localhost`, `127.0.0.0/8`, `::1`). Every request, WebSocket, or navigation
|
|
17
|
+
to any other host is aborted and listed in the tool result. The agent's own
|
|
18
|
+
sandbox can already reach these services, so the tools add a browser without
|
|
19
|
+
adding network reach. Each lease gets one tab at a time, a fresh context per
|
|
20
|
+
viewport, and at most 300 actions.
|
|
21
|
+
|
|
12
22
|
Install the runtime's pinned browser once on the agent host (and after a
|
|
13
23
|
Playwright version update):
|
|
14
24
|
|
|
@@ -36,9 +46,13 @@ lifetime, so restart the agent after fixing the browser to re-probe.
|
|
|
36
46
|
`navarch-runtime doctor` runs the same check.
|
|
37
47
|
|
|
38
48
|
Codex's macOS seatbelt denies Chromium's Mach port registration
|
|
39
|
-
(`bootstrap_check_in ... MachPortRendezvousServer: Permission denied`),
|
|
40
|
-
|
|
41
|
-
|
|
49
|
+
(`bootstrap_check_in ... MachPortRendezvousServer: Permission denied`), and
|
|
50
|
+
`--single-process` crashes once a second page opens. When Node runs in the
|
|
51
|
+
sandbox and only Chromium fails, the probe still passes, with that reason. Codex
|
|
52
|
+
sessions then browse local services through the `local_browser_*` tools above,
|
|
53
|
+
and the session prompt tells them to. With an operator `NAVARCH_MCP_CONFIG_PATH`
|
|
54
|
+
those tools are absent, so the machine does not take browser tasks; run those
|
|
55
|
+
with another agent such as Claude Code, or use Docker mode. Docker mode is not
|
|
42
56
|
probed because its image supplies the browser. Set `NAVARCH_BROWSER_PROBE=off`
|
|
43
57
|
to trust the configured capability list without a check.
|
|
44
58
|
|
|
@@ -316,13 +330,14 @@ own budgets. An absolute limit covers all guidance/remediation turns within the
|
|
|
316
330
|
claim, rather than resetting with each turn. These are runtime limits, not
|
|
317
331
|
provider spending caps.
|
|
318
332
|
|
|
319
|
-
After a watchdog stop
|
|
333
|
+
After a watchdog stop, lease loss during a turn, or exhausted delivery correction
|
|
334
|
+
turns, before worktree cleanup, the runtime attempts a private
|
|
320
335
|
local recovery checkpoint at
|
|
321
336
|
`<workspaceRoot>/checkpoints/<project>/<task>/<session>/`. It contains a Git
|
|
322
337
|
bundle of committed history, a binary patch for dirty tracked files, untracked
|
|
323
338
|
non-ignored files, and recovery instructions. The bundle excludes repository
|
|
324
339
|
configuration and broker credentials. Checkpoints are not pushed or uploaded;
|
|
325
|
-
|
|
340
|
+
failure reports and runtime logs name their location. They survive session cleanup and need
|
|
326
341
|
manual recovery and removal on that machine. Ignored files, agent conversation
|
|
327
342
|
state, and checkpoints across machine/disk loss are not provided.
|
|
328
343
|
|
|
@@ -490,7 +505,9 @@ as an explicit read-only mount.
|
|
|
490
505
|
- **Codex:** the runtime passes a one-off native permission profile with
|
|
491
506
|
`approval_policy="on-request"` and `approvals_reviewer="auto_review"`.
|
|
492
507
|
Codex's OS sandbox grants read/write access only to the allowed roots and
|
|
493
|
-
denies the surrounding multi-session workspace.
|
|
508
|
+
denies the surrounding multi-session workspace. After preparing the worktree,
|
|
509
|
+
the runtime explicitly grants its resolved linked Git metadata directory write
|
|
510
|
+
access so staging and committing work inside the sandbox. Its network policy allows
|
|
494
511
|
GitHub and GitHub-hosted content so authenticated `gh` and git operations
|
|
495
512
|
required by the task stay inside the sandbox. Other destinations remain
|
|
496
513
|
blocked and eligible escalations are decided by the automatic reviewer
|
|
@@ -13,6 +13,7 @@ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
|
13
13
|
const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
14
14
|
const playwright_1 = require("playwright");
|
|
15
15
|
const zod_1 = require("zod");
|
|
16
|
+
const local_browser_cjs_1 = require("./local-browser.cjs");
|
|
16
17
|
const node_child_process_1 = require("node:child_process");
|
|
17
18
|
const node_util_1 = require("node:util");
|
|
18
19
|
const node_path_1 = __importDefault(require("node:path"));
|
|
@@ -46,7 +47,8 @@ const captureShape = {
|
|
|
46
47
|
exports.captureSchema = zod_1.z.object(captureShape);
|
|
47
48
|
/** Runs in the supervisor, outside the coding agent's OS sandbox. Fresh
|
|
48
49
|
* contexts have no user cookies, arbitrary script execution, or file access.
|
|
49
|
-
*
|
|
50
|
+
* The lease gets the narrow capture operation plus the loopback-only
|
|
51
|
+
* local_browser_* tools (local-browser.cts). */
|
|
50
52
|
async function startBrowserEvidence(options) {
|
|
51
53
|
const token = (0, node_crypto_1.randomBytes)(32).toString("hex");
|
|
52
54
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
@@ -57,6 +59,10 @@ async function startBrowserEvidence(options) {
|
|
|
57
59
|
let browser;
|
|
58
60
|
let activeCapture;
|
|
59
61
|
let attempts = 0;
|
|
62
|
+
const localBrowser = (0, local_browser_cjs_1.createLocalBrowser)({
|
|
63
|
+
launch: options.launch ?? launchCaptureBrowser,
|
|
64
|
+
isActive: () => !closed && options.isActive(),
|
|
65
|
+
});
|
|
60
66
|
const assertActive = () => {
|
|
61
67
|
if (closed || !options.isActive())
|
|
62
68
|
throw new Error("Browser evidence lease is no longer active.");
|
|
@@ -204,6 +210,7 @@ async function startBrowserEvidence(options) {
|
|
|
204
210
|
return { isError: true, content: [{ type: "text", text: String(error) }] };
|
|
205
211
|
}
|
|
206
212
|
});
|
|
213
|
+
(0, local_browser_cjs_1.registerLocalBrowserTools)(server, localBrowser);
|
|
207
214
|
res.on("close", () => { void transport.close(); void server.close(); });
|
|
208
215
|
try {
|
|
209
216
|
await server.connect(transport);
|
|
@@ -253,6 +260,7 @@ async function startBrowserEvidence(options) {
|
|
|
253
260
|
const stopped = new Promise((resolve) => http.close(() => resolve()));
|
|
254
261
|
http.closeAllConnections();
|
|
255
262
|
await browser?.close().catch(() => undefined);
|
|
263
|
+
await localBrowser.close();
|
|
256
264
|
await activeCapture?.catch(() => undefined);
|
|
257
265
|
await stopped;
|
|
258
266
|
},
|
package/dist/browser-probe.cjs
CHANGED
|
@@ -74,8 +74,19 @@ async function probeBrowserCapability(config, deps = {}) {
|
|
|
74
74
|
const platform = deps.platform ?? process.platform;
|
|
75
75
|
if (codexSandboxNeedsProbe(config, platform)) {
|
|
76
76
|
const sandboxFailure = await probeCodexSandbox(config, deps);
|
|
77
|
+
if (sandboxFailure?.browserOnly && !config.mcpConfigPath) {
|
|
78
|
+
// Node runs in the sandbox, so the agent can start its services there;
|
|
79
|
+
// only Chromium cannot. Sessions get the supervisor's local_browser_*
|
|
80
|
+
// tools (local-browser.cts) instead, which reach the same loopback.
|
|
81
|
+
return {
|
|
82
|
+
status: "passed",
|
|
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."),
|
|
85
|
+
checked_at: now().toISOString(),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
77
88
|
if (sandboxFailure) {
|
|
78
|
-
return { status: "failed", reason: truncate(sandboxFailure), checked_at: now().toISOString() };
|
|
89
|
+
return { status: "failed", reason: truncate(sandboxFailure.reason), checked_at: now().toISOString() };
|
|
79
90
|
}
|
|
80
91
|
}
|
|
81
92
|
return { status: "passed", checked_at: now().toISOString() };
|
|
@@ -215,8 +226,9 @@ async function probeCodexSandbox(config, deps) {
|
|
|
215
226
|
// sandbox view: a path outside the granted read roots, not an old CLI.
|
|
216
227
|
const hidden = /bwrap: execvp .*No such file or directory/.test(output);
|
|
217
228
|
const opensslConfig = /OpenSSL configuration error/.test(output);
|
|
218
|
-
|
|
219
|
-
|
|
229
|
+
const detail = result.timedOut ? "timed out" : output.trim() ? summarizeLaunchFailure(output) : `exit ${result.code}`;
|
|
230
|
+
const reason = ("Could not verify a browser inside the Codex sandbox: `codex sandbox` did not run the probe " +
|
|
231
|
+
`(${detail}). ` +
|
|
220
232
|
(hidden
|
|
221
233
|
? "The sandbox cannot see that executable; add its install directory to NAVARCH_GUARD_EXTRA_ROOTS, " +
|
|
222
234
|
"or run browser tasks with another agent."
|
|
@@ -224,19 +236,22 @@ async function probeCodexSandbox(config, deps) {
|
|
|
224
236
|
? "Node cannot read its OpenSSL config inside the sandbox, so Codex sessions cannot run node either; " +
|
|
225
237
|
"update the Navarch runtime, or add that file's directory to NAVARCH_GUARD_EXTRA_ROOTS."
|
|
226
238
|
: "Update the Codex CLI, or run browser tasks with another agent."));
|
|
239
|
+
return { reason, detail, browserOnly: false };
|
|
227
240
|
}
|
|
228
241
|
const detail = result.timedOut ? "the launch timed out." : summarizeLaunchFailure(output);
|
|
229
242
|
const machPort = /bootstrap_check_in|MachPortRendezvous|mach_port_rendezvous/i.test(output);
|
|
230
|
-
|
|
243
|
+
const reason = (`Headless Chromium cannot start inside the Codex sandbox: ${detail} ` +
|
|
231
244
|
(machPort
|
|
232
245
|
? "Codex's macOS sandbox denies Chromium's Mach port registration. "
|
|
233
246
|
: "") +
|
|
234
247
|
"Run browser tasks with another agent (for example Claude Code), use NAVARCH_SANDBOX_MODE=docker, " +
|
|
235
248
|
"or run Codex without its sandbox.");
|
|
249
|
+
return { reason, detail, browserOnly: true };
|
|
236
250
|
}
|
|
237
251
|
catch (error) {
|
|
238
252
|
log.warn(`codex sandbox browser probe could not run: ${String(error)}`);
|
|
239
|
-
|
|
253
|
+
const detail = summarizeLaunchFailure(String(error));
|
|
254
|
+
return { reason: `Could not verify a browser inside the Codex sandbox: ${detail}`, detail, browserOnly: false };
|
|
240
255
|
}
|
|
241
256
|
finally {
|
|
242
257
|
await node_fs_1.promises.rm(probeRoot, { recursive: true, force: true }).catch(() => undefined);
|
package/dist/cli.cjs
CHANGED
|
@@ -248,7 +248,7 @@ async function startCommand(flags) {
|
|
|
248
248
|
log.warn(`not advertising browser-use: ${browserProbe.reason}`);
|
|
249
249
|
}
|
|
250
250
|
else if (browserProbe.status === "passed") {
|
|
251
|
-
log.info(
|
|
251
|
+
log.info(`browser probe passed; advertising browser-use${browserProbe.reason ? ` (${browserProbe.reason})` : ""}`);
|
|
252
252
|
}
|
|
253
253
|
const config = {
|
|
254
254
|
...configuredConfig,
|
package/dist/exit-conditions.cjs
CHANGED
|
@@ -359,7 +359,10 @@ function mapExitCondition(result) {
|
|
|
359
359
|
// including when that response says the task could not start. Treat an
|
|
360
360
|
// explicit leading blocked verdict as a failed lease so the dispatcher can
|
|
361
361
|
// retry/escalate it instead of falsely moving unfinished work to Done.
|
|
362
|
-
|
|
362
|
+
// Build agents also use an explicit outcome marker. A zero process exit
|
|
363
|
+
// means the conversation finished, not that the requested build succeeded.
|
|
364
|
+
if (/^blocked(?:\s|:|$)/i.test(reportText.trim()) ||
|
|
365
|
+
/^Build outcome:\s*(?:FAILED|BLOCKED)\b/i.test(reportText.trim())) {
|
|
363
366
|
return {
|
|
364
367
|
leaseOutcome: "failed",
|
|
365
368
|
exitStatus: "failed",
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.localBrowserShapes = void 0;
|
|
4
|
+
exports.isLoopbackUrl = isLoopbackUrl;
|
|
5
|
+
exports.createLocalBrowser = createLocalBrowser;
|
|
6
|
+
exports.registerLocalBrowserTools = registerLocalBrowserTools;
|
|
7
|
+
const node_net_1 = require("node:net");
|
|
8
|
+
const playwright_1 = require("playwright");
|
|
9
|
+
const zod_1 = require("zod");
|
|
10
|
+
/**
|
|
11
|
+
* Interactive browser for services the agent starts on this machine.
|
|
12
|
+
*
|
|
13
|
+
* Codex's macOS seatbelt denies Chromium's Mach port registration
|
|
14
|
+
* (`bootstrap_check_in ... MachPortRendezvousServer: Permission denied`), so
|
|
15
|
+
* no browser can start inside a Codex session there, and `--single-process`
|
|
16
|
+
* crashes as soon as a second page opens. This Chromium runs in the
|
|
17
|
+
* supervisor instead, next to capture_visual_evidence, and is limited to
|
|
18
|
+
* loopback: the agent's own sandbox can already reach localhost, so these
|
|
19
|
+
* tools add a browser without adding network reach. Every request, WebSocket,
|
|
20
|
+
* and navigation to any other host is aborted and reported back.
|
|
21
|
+
*/
|
|
22
|
+
const MAX_ACTIONS = 300;
|
|
23
|
+
const ACTION_TIMEOUT_MS = 10_000;
|
|
24
|
+
const NAVIGATION_TIMEOUT_MS = 30_000;
|
|
25
|
+
const MAX_SNAPSHOT_CHARS = 12_000;
|
|
26
|
+
const MAX_LOG_LINES = 15;
|
|
27
|
+
/** http(s)/ws(s) on localhost, *.localhost, 127.0.0.0/8, or ::1, without embedded credentials. */
|
|
28
|
+
function isLoopbackUrl(value) {
|
|
29
|
+
let url;
|
|
30
|
+
try {
|
|
31
|
+
url = new URL(value);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (!["http:", "https:", "ws:", "wss:"].includes(url.protocol) || url.username || url.password)
|
|
37
|
+
return false;
|
|
38
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
39
|
+
if (host === "localhost" || host.endsWith(".localhost"))
|
|
40
|
+
return true;
|
|
41
|
+
if ((0, node_net_1.isIP)(host) === 4)
|
|
42
|
+
return host.split(".")[0] === "127";
|
|
43
|
+
return host === "::1";
|
|
44
|
+
}
|
|
45
|
+
const loopbackUrl = zod_1.z.string().refine(isLoopbackUrl, "Only services on this machine are reachable: use http://localhost:<port> or http://127.0.0.1:<port>.");
|
|
46
|
+
const selector = zod_1.z.string().min(1).max(500).describe("Playwright selector, e.g. `text=Sign in`, `role=button[name=\"Save\"]`, or CSS.");
|
|
47
|
+
exports.localBrowserShapes = {
|
|
48
|
+
open: {
|
|
49
|
+
url: loopbackUrl,
|
|
50
|
+
viewport: zod_1.z.enum(["desktop", "mobile"]).default("desktop"),
|
|
51
|
+
},
|
|
52
|
+
click: { selector },
|
|
53
|
+
fill: { selector, value: zod_1.z.string().max(10_000) },
|
|
54
|
+
press: {
|
|
55
|
+
key: zod_1.z.string().min(1).max(50).describe("Key or chord, e.g. `Enter`, `Tab`, `Control+A`."),
|
|
56
|
+
selector: selector.optional(),
|
|
57
|
+
},
|
|
58
|
+
snapshot: {},
|
|
59
|
+
screenshot: { full_page: zod_1.z.boolean().default(false) },
|
|
60
|
+
};
|
|
61
|
+
function createLocalBrowser(options) {
|
|
62
|
+
let browser;
|
|
63
|
+
let context;
|
|
64
|
+
let page;
|
|
65
|
+
let viewport;
|
|
66
|
+
let actions = 0;
|
|
67
|
+
let closed = false;
|
|
68
|
+
let creatingPage = false;
|
|
69
|
+
let queue = Promise.resolve();
|
|
70
|
+
let notes = [];
|
|
71
|
+
const blocked = new Set();
|
|
72
|
+
const logs = [];
|
|
73
|
+
const log = (line) => {
|
|
74
|
+
logs.push(line.slice(0, 500));
|
|
75
|
+
if (logs.length > MAX_LOG_LINES)
|
|
76
|
+
logs.shift();
|
|
77
|
+
};
|
|
78
|
+
const block = (url) => {
|
|
79
|
+
try {
|
|
80
|
+
blocked.add(new URL(url).origin);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
blocked.add(url.slice(0, 100));
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
function track(next) {
|
|
87
|
+
page = next;
|
|
88
|
+
next.on("console", (message) => {
|
|
89
|
+
if (["error", "warning"].includes(message.type()))
|
|
90
|
+
log(`console.${message.type()}: ${message.text()}`);
|
|
91
|
+
});
|
|
92
|
+
next.on("pageerror", (error) => log(`pageerror: ${error.message}`));
|
|
93
|
+
}
|
|
94
|
+
async function ensureContext(wanted) {
|
|
95
|
+
if (context && viewport === wanted)
|
|
96
|
+
return context;
|
|
97
|
+
await context?.close().catch(() => undefined);
|
|
98
|
+
context = undefined;
|
|
99
|
+
page = undefined;
|
|
100
|
+
try {
|
|
101
|
+
browser ??= await options.launch();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
throw new Error("The local browser could not start. On the agent host run `navarch-runtime browser-install`.");
|
|
105
|
+
}
|
|
106
|
+
const created = await browser.newContext({
|
|
107
|
+
...(wanted === "mobile" ? playwright_1.devices["iPhone 13"] : { viewport: { width: 1440, height: 900 } }),
|
|
108
|
+
acceptDownloads: false,
|
|
109
|
+
// Service workers would answer fetches before the route below sees them.
|
|
110
|
+
serviceWorkers: "block",
|
|
111
|
+
});
|
|
112
|
+
await created.route("**/*", (route) => {
|
|
113
|
+
const url = route.request().url();
|
|
114
|
+
if (isLoopbackUrl(url))
|
|
115
|
+
return route.continue();
|
|
116
|
+
block(url);
|
|
117
|
+
return route.abort("blockedbyclient");
|
|
118
|
+
});
|
|
119
|
+
await created.routeWebSocket(() => true, (socket) => {
|
|
120
|
+
if (isLoopbackUrl(socket.url()))
|
|
121
|
+
return socket.connectToServer();
|
|
122
|
+
block(socket.url());
|
|
123
|
+
return socket.close({ code: 1008, reason: "Only loopback services are reachable." });
|
|
124
|
+
});
|
|
125
|
+
created.on("page", (opened) => {
|
|
126
|
+
if (creatingPage || opened === page)
|
|
127
|
+
return;
|
|
128
|
+
track(opened);
|
|
129
|
+
notes.push("A new tab opened; it is now the active page.");
|
|
130
|
+
});
|
|
131
|
+
context = created;
|
|
132
|
+
viewport = wanted;
|
|
133
|
+
return created;
|
|
134
|
+
}
|
|
135
|
+
function currentPage() {
|
|
136
|
+
if (!page || page.isClosed())
|
|
137
|
+
throw new Error("No page is open. Call local_browser_open first.");
|
|
138
|
+
return page;
|
|
139
|
+
}
|
|
140
|
+
async function snapshot(heading) {
|
|
141
|
+
const active = currentPage();
|
|
142
|
+
let tree;
|
|
143
|
+
try {
|
|
144
|
+
tree = await active.locator("body").ariaSnapshot({ timeout: 5_000 });
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
tree = `(accessibility snapshot unavailable: ${String(error).split("\n")[0]})`;
|
|
148
|
+
}
|
|
149
|
+
if (tree.length > MAX_SNAPSHOT_CHARS)
|
|
150
|
+
tree = `${tree.slice(0, MAX_SNAPSHOT_CHARS)}\n… (truncated)`;
|
|
151
|
+
const lines = [
|
|
152
|
+
...(heading ? [heading] : []),
|
|
153
|
+
...notes,
|
|
154
|
+
...(active.url().startsWith("chrome-error://")
|
|
155
|
+
? ["The last navigation failed or tried to leave this machine and was blocked. Use local_browser_open to continue."]
|
|
156
|
+
: []),
|
|
157
|
+
`URL: ${active.url()}`,
|
|
158
|
+
`Title: ${await active.title().catch(() => "")}`,
|
|
159
|
+
`Viewport: ${viewport}`,
|
|
160
|
+
...(logs.length ? ["Recent console/page errors:", ...logs.map((line) => ` ${line}`)] : []),
|
|
161
|
+
...(blocked.size
|
|
162
|
+
? [`Blocked non-loopback requests (this browser reaches only this machine): ${[...blocked].slice(-10).join(", ")}`]
|
|
163
|
+
: []),
|
|
164
|
+
"",
|
|
165
|
+
tree,
|
|
166
|
+
];
|
|
167
|
+
notes = [];
|
|
168
|
+
return [{ type: "text", text: lines.join("\n") }];
|
|
169
|
+
}
|
|
170
|
+
/** Runs tool calls one at a time; a lease ending stops further use. */
|
|
171
|
+
function run(action) {
|
|
172
|
+
const next = queue.then(async () => {
|
|
173
|
+
if (closed || !options.isActive())
|
|
174
|
+
throw new Error("The local browser lease is no longer active.");
|
|
175
|
+
if (++actions > MAX_ACTIONS)
|
|
176
|
+
throw new Error(`This lease reached its ${MAX_ACTIONS} local browser actions.`);
|
|
177
|
+
return action();
|
|
178
|
+
});
|
|
179
|
+
queue = next.catch(() => undefined);
|
|
180
|
+
return next;
|
|
181
|
+
}
|
|
182
|
+
async function settle(active) {
|
|
183
|
+
await active.waitForLoadState("load", { timeout: 5_000 }).catch(() => undefined);
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
open(args) {
|
|
187
|
+
return run(async () => {
|
|
188
|
+
if (!isLoopbackUrl(args.url))
|
|
189
|
+
throw new Error("Only loopback URLs are reachable.");
|
|
190
|
+
const created = await ensureContext(args.viewport ?? "desktop");
|
|
191
|
+
if (!page || page.isClosed()) {
|
|
192
|
+
creatingPage = true;
|
|
193
|
+
try {
|
|
194
|
+
track(await created.newPage());
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
creatingPage = false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const response = await currentPage().goto(args.url, { waitUntil: "load", timeout: NAVIGATION_TIMEOUT_MS });
|
|
201
|
+
return snapshot(`HTTP ${response?.status() ?? "(no response)"}`);
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
click(args) {
|
|
205
|
+
return run(async () => {
|
|
206
|
+
const active = currentPage();
|
|
207
|
+
await active.locator(args.selector).first().click({ timeout: ACTION_TIMEOUT_MS });
|
|
208
|
+
await settle(active);
|
|
209
|
+
return snapshot();
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
fill(args) {
|
|
213
|
+
return run(async () => {
|
|
214
|
+
await currentPage().locator(args.selector).first().fill(args.value, { timeout: ACTION_TIMEOUT_MS });
|
|
215
|
+
return snapshot();
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
press(args) {
|
|
219
|
+
return run(async () => {
|
|
220
|
+
const active = currentPage();
|
|
221
|
+
if (args.selector)
|
|
222
|
+
await active.locator(args.selector).first().press(args.key, { timeout: ACTION_TIMEOUT_MS });
|
|
223
|
+
else
|
|
224
|
+
await active.keyboard.press(args.key);
|
|
225
|
+
await settle(active);
|
|
226
|
+
return snapshot();
|
|
227
|
+
});
|
|
228
|
+
},
|
|
229
|
+
snapshot() {
|
|
230
|
+
return run(() => snapshot());
|
|
231
|
+
},
|
|
232
|
+
screenshot(args) {
|
|
233
|
+
return run(async () => {
|
|
234
|
+
const bytes = await currentPage().screenshot({ type: "png", fullPage: args.full_page ?? false, timeout: ACTION_TIMEOUT_MS });
|
|
235
|
+
return [
|
|
236
|
+
{ type: "text", text: `Screenshot of ${currentPage().url()} (${viewport}). Not uploaded; use capture_visual_evidence for PR evidence.` },
|
|
237
|
+
{ type: "image", data: bytes.toString("base64"), mimeType: "image/png" },
|
|
238
|
+
];
|
|
239
|
+
});
|
|
240
|
+
},
|
|
241
|
+
async close() {
|
|
242
|
+
closed = true;
|
|
243
|
+
await queue.catch(() => undefined);
|
|
244
|
+
await context?.close().catch(() => undefined);
|
|
245
|
+
await browser?.close().catch(() => undefined);
|
|
246
|
+
browser = undefined;
|
|
247
|
+
context = undefined;
|
|
248
|
+
page = undefined;
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
const toolResult = (call) => call().then((content) => ({ content }), (error) => ({ isError: true, content: [{ type: "text", text: String(error) }] }));
|
|
253
|
+
/** Registers the local_browser_* tools on a lease's navarch-browser MCP server. */
|
|
254
|
+
function registerLocalBrowserTools(server, browser) {
|
|
255
|
+
server.registerTool("local_browser_open", {
|
|
256
|
+
description: "Open a page served on this machine (http://localhost:<port> or 127.0.0.1) in Navarch's browser, which runs outside the coding-agent sandbox. Start your dev server first. Reuses one tab; switching viewport starts a fresh session. Returns the page's accessibility snapshot, console errors, and any blocked non-loopback requests.",
|
|
257
|
+
inputSchema: exports.localBrowserShapes.open,
|
|
258
|
+
}, (args) => toolResult(() => browser.open(args)));
|
|
259
|
+
server.registerTool("local_browser_click", {
|
|
260
|
+
description: "Click the first element matching a Playwright selector on the open local page, then return the updated snapshot.",
|
|
261
|
+
inputSchema: exports.localBrowserShapes.click,
|
|
262
|
+
}, (args) => toolResult(() => browser.click(args)));
|
|
263
|
+
server.registerTool("local_browser_fill", {
|
|
264
|
+
description: "Replace the value of an input, textarea, or contenteditable on the open local page, then return the snapshot.",
|
|
265
|
+
inputSchema: exports.localBrowserShapes.fill,
|
|
266
|
+
}, (args) => toolResult(() => browser.fill(args)));
|
|
267
|
+
server.registerTool("local_browser_press", {
|
|
268
|
+
description: "Press a key (optionally focused on a selector) on the open local page, then return the snapshot.",
|
|
269
|
+
inputSchema: exports.localBrowserShapes.press,
|
|
270
|
+
}, (args) => toolResult(() => browser.press(args)));
|
|
271
|
+
server.registerTool("local_browser_snapshot", {
|
|
272
|
+
description: "Return the open local page's URL, title, accessibility snapshot, console errors, and blocked requests.",
|
|
273
|
+
inputSchema: exports.localBrowserShapes.snapshot,
|
|
274
|
+
}, () => toolResult(() => browser.snapshot()));
|
|
275
|
+
server.registerTool("local_browser_screenshot", {
|
|
276
|
+
description: "Return a PNG of the open local page for inspection. Not uploaded as evidence.",
|
|
277
|
+
inputSchema: exports.localBrowserShapes.screenshot,
|
|
278
|
+
}, (args) => toolResult(() => browser.screenshot(args)));
|
|
279
|
+
}
|
package/dist/session.cjs
CHANGED
|
@@ -232,13 +232,14 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
232
232
|
let retainWorktree = false;
|
|
233
233
|
let heartbeatInFlight = null;
|
|
234
234
|
let heartbeatWorkspaceUpdate = null;
|
|
235
|
+
let completingLease = false;
|
|
235
236
|
const sessionOutcome = {};
|
|
236
|
-
const checkpointForRecovery = async (result) => {
|
|
237
|
+
const checkpointForRecovery = async (result, reason) => {
|
|
237
238
|
if (result.checkpointPath || result.retainedWorktreePath)
|
|
238
239
|
return;
|
|
239
240
|
try {
|
|
240
241
|
result.checkpointPath = await gitWorktree.saveCheckpoint();
|
|
241
|
-
log.warn(`session ${leaseId} stopped for ${leaseLost ? "lease loss" : result.capacityError ? "provider capacity" : result.timeoutReason ?? "duration"}; recovery checkpoint: ${result.checkpointPath}`);
|
|
242
|
+
log.warn(`session ${leaseId} stopped for ${reason ?? (leaseLost ? "lease loss" : result.capacityError ? "provider capacity" : result.timeoutReason ?? "duration")}; recovery checkpoint: ${result.checkpointPath}`);
|
|
242
243
|
}
|
|
243
244
|
catch (error) {
|
|
244
245
|
retainWorktree = true;
|
|
@@ -285,7 +286,15 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
285
286
|
log.warn(`lease heartbeat failed for ${leaseId}: ${String(err)} — retrying on the next heartbeat while the confirmed lease remains valid.`);
|
|
286
287
|
return;
|
|
287
288
|
}
|
|
288
|
-
|
|
289
|
+
if (completingLease && isTerminalLeaseHeartbeatError(err)) {
|
|
290
|
+
// Completion can release the lease before its HTTP response arrives.
|
|
291
|
+
// Keep awaiting/re-sending that report to resolve the ambiguous write;
|
|
292
|
+
// there is no agent turn to kill during this phase.
|
|
293
|
+
log.info(`lease ${leaseId} is no longer active while completion is pending; awaiting completion acknowledgement.`);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
log.warn(`lease heartbeat failed for ${leaseId}: ${String(err)} — killing session.`);
|
|
297
|
+
}
|
|
289
298
|
leaseLost = true;
|
|
290
299
|
leaseGone = isTerminalLeaseHeartbeatError(err);
|
|
291
300
|
heartbeatGuard.stop();
|
|
@@ -385,14 +394,6 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
385
394
|
await node_fs_1.promises.mkdir(tmpDir, { recursive: true, mode: 0o700 });
|
|
386
395
|
Object.assign(sessionEnv, { TMPDIR: tmpDir, TMP: tmpDir, TEMP: tmpDir });
|
|
387
396
|
}
|
|
388
|
-
codexGuardArgs = (0, worktree_guard_cjs_1.codexWorktreeGuardArgs)({
|
|
389
|
-
workDir,
|
|
390
|
-
worktreePath: gitWorktree.worktreePath,
|
|
391
|
-
repositoryPath: gitWorktree.repositoryPath,
|
|
392
|
-
workspaceRoot: config.workspaceRoot,
|
|
393
|
-
extraRoots: config.guardExtraRoots,
|
|
394
|
-
codexBin: config.codexBin,
|
|
395
|
-
});
|
|
396
397
|
}
|
|
397
398
|
else if (runtime === "gemini") {
|
|
398
399
|
geminiGuardMounts = (0, worktree_guard_cjs_1.geminiSandboxMounts)({
|
|
@@ -459,12 +460,22 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
459
460
|
const mcp = JSON.parse(await node_fs_1.promises.readFile(mcpConfigPath, "utf8"));
|
|
460
461
|
mcp.mcpServers.navarch_browser = browserEvidence.config;
|
|
461
462
|
await node_fs_1.promises.writeFile(mcpConfigPath, JSON.stringify(mcp), { mode: 0o600 });
|
|
462
|
-
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.
|
|
463
|
+
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";
|
|
463
464
|
}
|
|
464
465
|
if (gitWorktree.hostedCheckpointRestoreFailure) {
|
|
465
466
|
log.warn(`hosted checkpoint for ${leaseId} could not be restored: ` +
|
|
466
467
|
`${(0, redact_cjs_1.redactText)(gitWorktree.hostedCheckpointRestoreFailure, registry.list())}; starting without it.`);
|
|
467
468
|
}
|
|
469
|
+
if (config.sandboxMode === "host" && config.worktreeGuard && runtime === "codex") {
|
|
470
|
+
codexGuardArgs = (0, worktree_guard_cjs_1.codexWorktreeGuardArgs)({
|
|
471
|
+
workDir,
|
|
472
|
+
worktreePath: gitWorktree.worktreePath,
|
|
473
|
+
repositoryPath: gitWorktree.repositoryPath,
|
|
474
|
+
workspaceRoot: config.workspaceRoot,
|
|
475
|
+
extraRoots: config.guardExtraRoots,
|
|
476
|
+
codexBin: config.codexBin,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
468
479
|
const restoredCheckpoint = gitWorktree.restoredHostedCheckpoint;
|
|
469
480
|
if (restoredCheckpoint) {
|
|
470
481
|
log.info(`resumed hosted checkpoint ${restoredCheckpoint.sha.slice(0, 12)} for ${leaseId} ` +
|
|
@@ -701,11 +712,14 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
701
712
|
});
|
|
702
713
|
continue;
|
|
703
714
|
}
|
|
715
|
+
await checkpointForRecovery(result, "incomplete delivery");
|
|
704
716
|
deliveryFailureSummary =
|
|
705
717
|
`Session ended without delivering the task: ${delivery.reason}. ` +
|
|
706
718
|
`${deliveryCorrectionRetries} correction turn(s) did not recover it. ` +
|
|
707
|
-
|
|
708
|
-
|
|
719
|
+
(result.checkpointPath
|
|
720
|
+
? `Local recovery checkpoint: ${result.checkpointPath}. `
|
|
721
|
+
: `Recovery worktree retained: ${result.retainedWorktreePath}. `) +
|
|
722
|
+
"The task needs another attempt.";
|
|
709
723
|
log.warn(`failing ${leaseId}: ${deliveryFailureSummary}`);
|
|
710
724
|
mapping.leaseOutcome = "failed";
|
|
711
725
|
mapping.exitStatus = "failed";
|
|
@@ -757,6 +771,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
757
771
|
transcript_url: completion.transcript_url,
|
|
758
772
|
};
|
|
759
773
|
try {
|
|
774
|
+
completingLease = true;
|
|
760
775
|
await (0, completion_retry_cjs_1.completeWithTransientRetry)(() => api.completeLease(leaseId, completion), {
|
|
761
776
|
onRetry: (attempt, delayMs, err) => log.warn(`completion for ${leaseId} did not reach the control plane (${(0, redact_cjs_1.redactText)(String(err), knownSecrets)}); re-sending in ${delayMs / 1000}s (${attempt}).`),
|
|
762
777
|
});
|
|
@@ -778,10 +793,11 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
778
793
|
if (!rejection)
|
|
779
794
|
throw err;
|
|
780
795
|
const redactedRejection = (0, redact_cjs_1.redactText)(rejection.message, knownSecrets);
|
|
796
|
+
const rejectionDetail = describeResponseBody(`${(0, redact_cjs_1.redactText)(rejection.code, knownSecrets)}: ${redactedRejection}`);
|
|
781
797
|
const remediable = mapping.leaseOutcome === "completed" && rejection.remediable;
|
|
782
798
|
if (remediable && completionRemediationRetries < COMPLETION_REMEDIATION_RETRIES) {
|
|
783
799
|
completionRemediationRetries += 1;
|
|
784
|
-
log.warn(`completion for ${leaseId} needs more work; restarting agent turn ${completionRemediationRetries}/${COMPLETION_REMEDIATION_RETRIES} in the same worktree.`);
|
|
800
|
+
log.warn(`completion for ${leaseId} needs more work (${rejectionDetail}); restarting agent turn ${completionRemediationRetries}/${COMPLETION_REMEDIATION_RETRIES} in the same worktree.`);
|
|
785
801
|
// A new turn is a fresh agent invocation: the rejection alone would
|
|
786
802
|
// reach an agent that has never seen the task.
|
|
787
803
|
nextPrompt = (0, prompt_cjs_1.renderCompletionRemediationPrompt)({
|
|
@@ -794,22 +810,24 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
794
810
|
continue;
|
|
795
811
|
}
|
|
796
812
|
if (remediable) {
|
|
797
|
-
log.warn(`completion for ${leaseId} is still not ready after ${COMPLETION_REMEDIATION_RETRIES} retries; failing with the control-plane rejection.`);
|
|
813
|
+
log.warn(`completion for ${leaseId} is still not ready after ${COMPLETION_REMEDIATION_RETRIES} retries (${rejectionDetail}); failing with the control-plane rejection.`);
|
|
798
814
|
}
|
|
799
815
|
else {
|
|
800
816
|
// Not something another turn can fix (e.g. review_evidence_tampered).
|
|
801
817
|
// Fail the lease once, keeping this attempt's real report, cost and
|
|
802
818
|
// evidence — this is a rejected attempt, not a crashed session.
|
|
803
|
-
log.error(`completion for ${leaseId} was rejected
|
|
819
|
+
log.error(`completion for ${leaseId} was rejected (${rejectionDetail}), which is not remediable; failing the lease.`);
|
|
804
820
|
}
|
|
805
821
|
try {
|
|
806
822
|
await (0, completion_retry_cjs_1.completeWithTransientRetry)(() => api.completeLease(leaseId, {
|
|
807
823
|
...completion,
|
|
808
824
|
status: "failed",
|
|
809
|
-
report:
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
failure_summary:
|
|
825
|
+
report: `${redactedRejection}\n\n---\n\n${completion.report}`,
|
|
826
|
+
// Keep the agent's blocker first: the API bounds failure_summary,
|
|
827
|
+
// and retry attribution must not lose it behind a delivery error.
|
|
828
|
+
failure_summary: remediable
|
|
829
|
+
? `${completion.report.slice(0, 1000)}\n\nControl-plane rejection:\n${redactedRejection.slice(0, 800)}`
|
|
830
|
+
: redactedRejection,
|
|
813
831
|
exit_status: "failed",
|
|
814
832
|
}));
|
|
815
833
|
}
|
|
@@ -824,6 +842,9 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
824
842
|
}
|
|
825
843
|
break;
|
|
826
844
|
}
|
|
845
|
+
finally {
|
|
846
|
+
completingLease = false;
|
|
847
|
+
}
|
|
827
848
|
}
|
|
828
849
|
}
|
|
829
850
|
catch (err) {
|
package/dist/worktree-guard.cjs
CHANGED
|
@@ -291,6 +291,13 @@ function codexWorktreeGuardArgs(options, platform = process.platform) {
|
|
|
291
291
|
[node_path_1.default.resolve(options.repositoryPath)]: "write",
|
|
292
292
|
[sessionGhConfigDir(options.workDir)]: "read",
|
|
293
293
|
};
|
|
294
|
+
// Codex protects a linked worktree's gitdir with a more specific read-only
|
|
295
|
+
// rule. A write grant on the bare repository alone does not override it:
|
|
296
|
+
// fetch/checkout/commit fail on FETCH_HEAD, HEAD.lock, or index.lock. Grant
|
|
297
|
+
// the runtime-created gitdir explicitly, without reopening session metadata.
|
|
298
|
+
const gitdir = linkedWorktreeGitDir(options.worktreePath, options.repositoryPath);
|
|
299
|
+
if (gitdir)
|
|
300
|
+
filesystem[gitdir] = "write";
|
|
294
301
|
const overlapsWorkspace = (root) => isPathInside(root, workspaceRoot) || (linux && isPathInside(workspaceRoot, root));
|
|
295
302
|
const readRoots = [
|
|
296
303
|
...codexToolReadRoots(process.env, platform),
|
|
@@ -333,6 +340,36 @@ function codexWorktreeGuardArgs(options, platform = process.platform) {
|
|
|
333
340
|
`permissions.${CODEX_GUARD_PROFILE}.network.domains=${tomlInlineTable(CODEX_GITHUB_NETWORK_DOMAINS)}`,
|
|
334
341
|
];
|
|
335
342
|
}
|
|
343
|
+
function linkedWorktreeGitDir(worktreePath, repositoryPath) {
|
|
344
|
+
let dotGit;
|
|
345
|
+
try {
|
|
346
|
+
dotGit = (0, node_fs_1.readFileSync)(node_path_1.default.join(worktreePath, ".git"), "utf8");
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
// A regular checkout has a .git directory; tests may use virtual paths.
|
|
350
|
+
const code = error.code;
|
|
351
|
+
if (code === "ENOENT" || code === "EISDIR")
|
|
352
|
+
return undefined;
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
const match = /^gitdir: (.+)\s*$/m.exec(dotGit);
|
|
356
|
+
if (!match)
|
|
357
|
+
return undefined;
|
|
358
|
+
const canonical = (root) => {
|
|
359
|
+
try {
|
|
360
|
+
return (0, node_fs_1.realpathSync)(root);
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
if (error.code === "ENOENT")
|
|
364
|
+
return node_path_1.default.resolve(root);
|
|
365
|
+
throw error;
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
const gitdir = canonical(node_path_1.default.resolve(worktreePath, match[1].trim()));
|
|
369
|
+
const repository = canonical(repositoryPath);
|
|
370
|
+
// Never turn a pointer outside the runtime's repository into a new grant.
|
|
371
|
+
return gitdir !== repository && isPathInside(gitdir, repository) ? gitdir : undefined;
|
|
372
|
+
}
|
|
336
373
|
/**
|
|
337
374
|
* Mounts only the current session roots into Gemini CLI's native sandbox.
|
|
338
375
|
* Gemini mounts cwd itself; explicit same-path mounts keep the shared gitdir,
|
package/package.json
CHANGED