@sagentlab/navarch-runtime 0.1.91 → 0.1.93

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,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/prompt.cjs CHANGED
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.renderPrompt = renderPrompt;
4
4
  exports.renderGuidanceCorrectionPrompt = renderGuidanceCorrectionPrompt;
5
+ exports.renderCompletionRemediationPrompt = renderCompletionRemediationPrompt;
5
6
  exports.renderHostedCheckpointNote = renderHostedCheckpointNote;
6
7
  const CODE_GRAPH_GUIDANCE = "For JS/TS dependency inspection, the runtime provides `navarch-runtime code-graph --query <symbol-or-file> --mode search|callers|callees|impact --commit HEAD`. If the runtime is not on PATH, use its installed entry point; do not install another copy mid-task. Results describe committed source and include commit/freshness warnings; inspect current files for uncommitted changes and unresolved dependencies.";
7
8
  const PROCESS_GUIDANCE = "Process management: this host may run other agent sessions. Keep the PID or tool handle of each preview server you start and stop only that owned process. Never use broad process-name or command-line matching (pkill, killall) to clean up servers: the agent launcher carries the task prompt in its arguments and can match those patterns, terminating your own session. Use a different port when a port is occupied by a process you do not own. Keep scratch files in the current worktree; paths in previous transcripts belong to earlier sessions.";
@@ -74,6 +75,40 @@ function renderGuidanceCorrectionPrompt(originalPrompt, guidance) {
74
75
  "",
75
76
  ].join("\n");
76
77
  }
78
+ /** Keeps a long previous report from crowding the brief out of the prompt. */
79
+ const REMEDIATION_REPORT_LIMIT = 8_000;
80
+ /**
81
+ * Builds the turn that follows a remediable completion rejection (for example
82
+ * `verification_verdict_invalid` or `pr_required`). Each turn is a fresh agent
83
+ * invocation with no memory of the last one, so the rejection alone gave the
84
+ * agent nothing to act on: a verifier answered BLOCKED because it had "no
85
+ * acceptance criteria or task to verify". The turn therefore carries the full
86
+ * task brief, the rejection, and the previous turn's report.
87
+ */
88
+ function renderCompletionRemediationPrompt(args) {
89
+ const report = args.previousReport.trim();
90
+ const clipped = report.length > REMEDIATION_REPORT_LIMIT
91
+ ? `${report.slice(0, REMEDIATION_REPORT_LIMIT)}\n\n[... truncated]`
92
+ : report;
93
+ return [
94
+ args.originalPrompt.trim(),
95
+ "",
96
+ "## Completion was rejected",
97
+ "",
98
+ "Your previous turn on this task finished, but the Navarch control plane rejected its completion:",
99
+ "",
100
+ ...args.rejection.trim().split("\n").map((line) => `> ${line}`),
101
+ "",
102
+ `This is correction turn ${args.attempt} of ${args.maxAttempts}. The task above is unchanged, and you are in the`,
103
+ "same worktree, so earlier edits, commits, and branches are still here. Reuse the work and findings from",
104
+ "the previous turn instead of starting over, fix what the rejection names, and finish the task.",
105
+ "Your final report replaces the previous one, so restate it in full in the required format.",
106
+ ...(clipped
107
+ ? ["", "### Previous turn's final report", "", clipped]
108
+ : []),
109
+ "",
110
+ ].join("\n");
111
+ }
77
112
  /**
78
113
  * Tells the agent its worktree starts from a hosted attempt's checkpoint
79
114
  * (GitWorktree.restoredHostedCheckpoint), mirroring the hosted side's own
package/dist/session.cjs CHANGED
@@ -459,7 +459,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
459
459
  const mcp = JSON.parse(await node_fs_1.promises.readFile(mcpConfigPath, "utf8"));
460
460
  mcp.mcpServers.navarch_browser = browserEvidence.config;
461
461
  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. Authentication or interactive walkthroughs may require the existing browser tools.\n";
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. 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
463
  }
464
464
  if (gitWorktree.hostedCheckpointRestoreFailure) {
465
465
  log.warn(`hosted checkpoint for ${leaseId} could not be restored: ` +
@@ -523,10 +523,12 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
523
523
  activeAbortController = new AbortController();
524
524
  if (leaseLost || deps.shutdownSignal?.aborted)
525
525
  activeAbortController.abort();
526
- const runPrompt = nextPrompt ??
527
- (deliveredGuidance.length > (bundle.guidance?.length ?? 0)
528
- ? (0, prompt_cjs_1.renderGuidanceCorrectionPrompt)(promptText, deliveredGuidance)
529
- : promptText);
526
+ // The task brief plus any mid-flight guidance. Correction turns build on
527
+ // this rather than on the previous turn's prompt so they never nest.
528
+ const briefPrompt = deliveredGuidance.length > (bundle.guidance?.length ?? 0)
529
+ ? (0, prompt_cjs_1.renderGuidanceCorrectionPrompt)(promptText, deliveredGuidance)
530
+ : promptText;
531
+ const runPrompt = nextPrompt ?? briefPrompt;
530
532
  nextPrompt = null;
531
533
  await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, "prompt.md"), runPrompt, "utf8");
532
534
  adapterStarted = true;
@@ -780,7 +782,15 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
780
782
  if (remediable && completionRemediationRetries < COMPLETION_REMEDIATION_RETRIES) {
781
783
  completionRemediationRetries += 1;
782
784
  log.warn(`completion for ${leaseId} needs more work; restarting agent turn ${completionRemediationRetries}/${COMPLETION_REMEDIATION_RETRIES} in the same worktree.`);
783
- nextPrompt = redactedRejection;
785
+ // A new turn is a fresh agent invocation: the rejection alone would
786
+ // reach an agent that has never seen the task.
787
+ nextPrompt = (0, prompt_cjs_1.renderCompletionRemediationPrompt)({
788
+ originalPrompt: briefPrompt,
789
+ rejection: redactedRejection,
790
+ previousReport: redactedReport,
791
+ attempt: completionRemediationRetries,
792
+ maxAttempts: COMPLETION_REMEDIATION_RETRIES,
793
+ });
784
794
  continue;
785
795
  }
786
796
  if (remediable) {
@@ -53,11 +53,12 @@ class RuntimeUpdateCoordinator {
53
53
  void this.applyRestart(directive);
54
54
  }
55
55
  async applyRestart(directive) {
56
- const { heartbeat, claimLoop, capacity } = this.options;
56
+ const { heartbeat, claimLoop, conversationLoop, capacity } = this.options;
57
57
  try {
58
58
  heartbeat.setUpdateState("draining");
59
59
  log.info(`remote restart ${directive.command_id} received; draining active sessions`);
60
60
  await claimLoop.drain();
61
+ await conversationLoop?.drain();
61
62
  await capacity.waitForIdle();
62
63
  log.info(`remote restart ${directive.command_id} ready; handing off to supervisor`);
63
64
  (this.options.requestRestart ?? ((code) => process.exit(code)))(supervisor_cjs_1.REMOTE_RESTART_EXIT_CODE);
@@ -67,6 +68,7 @@ class RuntimeUpdateCoordinator {
67
68
  log.error(`remote restart failed: ${message}`);
68
69
  heartbeat.setUpdateState("failed", message.slice(0, 500));
69
70
  claimLoop.start();
71
+ conversationLoop?.start();
70
72
  this.restartInProgress = null;
71
73
  }
72
74
  }
@@ -74,7 +76,7 @@ class RuntimeUpdateCoordinator {
74
76
  return (this.options.now ?? Date.now)();
75
77
  }
76
78
  async apply(directive) {
77
- const { config, heartbeat, claimLoop, capacity } = this.options;
79
+ const { config, heartbeat, claimLoop, conversationLoop, capacity } = this.options;
78
80
  try {
79
81
  heartbeat.setUpdateState("staging");
80
82
  log.info(`staging runtime ${directive.target_version}`);
@@ -82,6 +84,7 @@ class RuntimeUpdateCoordinator {
82
84
  heartbeat.setUpdateState("draining");
83
85
  log.info("runtime update staged; draining active sessions before restart");
84
86
  await claimLoop.drain();
87
+ await conversationLoop?.drain();
85
88
  await capacity.waitForIdle();
86
89
  await (0, update_installer_cjs_1.writePendingUpdate)(config.configDir, staged, directive.rollout_id);
87
90
  log.info(`runtime ${directive.target_version} ready; handing off to supervisor`);
@@ -96,6 +99,7 @@ class RuntimeUpdateCoordinator {
96
99
  log.error(`runtime update failed: ${message} (retrying ${directive.target_version} in ${Math.round(delayMs / 1000)}s)`);
97
100
  heartbeat.setUpdateState("failed", message.slice(0, 500));
98
101
  claimLoop.start();
102
+ conversationLoop?.start();
99
103
  this.targetInProgress = null;
100
104
  }
101
105
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.91",
3
+ "version": "0.1.93",
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",