@sagentlab/navarch-runtime 0.1.111 → 0.1.116

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
@@ -1,5 +1,7 @@
1
1
  # navarch-runtime
2
2
 
3
+ Conversation mirror isolation and lifecycle fixes are included in runtime 0.1.115.
4
+
3
5
  ## Managed visual evidence
4
6
 
5
7
  Host-mode build, verify, and QA leases requiring `browser-use` receive a
@@ -478,6 +480,7 @@ unchanged across the deployment.
478
480
  | `NAVARCH_NAVI_CONVERSATIONS` | on | Serve read-only Navi repository conversations on this machine. Only the `claude-code` adapter on the host sandbox with the worktree guard on is supported; `navarch-runtime doctor` prints the `navi_chat:` state and reason. Set `off` to stop polling for conversations. |
479
481
  | `NAVARCH_NAVI_WORKSPACE_CHAT` | off | Explicitly enable writable Navi chat on a supported Claude Code host runtime. Owners can choose **Edit and run commands**; automatic permission review and the worktree guard remain active. Commands run on this host, not in a hard sandbox. Changes stay under `<workspaceRoot>/chat-workspaces/<conversationId>/repo` until the operator removes them. See [workspace chat](../docs/navarch/navi-workspace-chat.md). |
480
482
  | `NAVARCH_CONVERSATION_POLL_MS` | `5000` | Idle poll interval for queued conversation turns (1000–30000). |
483
+ | `NAVARCH_CONVERSATION_MIRROR` | on | Seed new conversation checkouts from a per-repository mirror under `<workspaceRoot>/conversation-mirrors/` (fetch + local clone with independent object files) instead of a full network clone. Set `off` to always clone directly. |
481
484
  | `NAVARCH_CONVERSATION_TURN_TIMEOUT_MS` | `540000` | Time limit for one conversation turn's agent process (at most nine minutes). |
482
485
  | `NAVARCH_GUARD_EXTRA_ROOTS` | — | `path.delimiter`-separated (`:` on POSIX) extra directories the worktree guard allows beyond the session worktree, shared bare repo, and temp dirs. |
483
486
 
@@ -578,7 +581,16 @@ Because bubblewrap starts from an empty `/` and Codex re-executes its own binary
578
581
  inside it, the profile also grants read access to the Codex executable's
579
582
  install directory (resolved from `NAVARCH_CODEX_BIN` or `PATH`, following
580
583
  symlinks) plus mise and nvm tool installs, so a Codex or Node installed under
581
- `$HOME` still starts. `$HOME` and `CODEX_HOME` themselves are never granted.
584
+ `$HOME` still starts. Codex also keeps per-run `codex-linux-sandbox` helper links in
585
+ `$CODEX_HOME/tmp/arg0` and re-executes them by name inside that view; without a
586
+ read grant on that one directory every sandboxed command fails with
587
+ `bwrap: execvp codex-linux-sandbox: No such file or directory` (reproduced on
588
+ Codex 0.158.0, 0.160.0, and 0.162.1). For hoisted npm installs the sibling
589
+ `@openai/codex-<platform>` package holding the native binary is granted too.
590
+ `$HOME` and `CODEX_HOME` themselves are never granted. Helper-directory aliases
591
+ and unresolvable paths are refused; native-package grants require the official
592
+ package layout, and canonical workspace overlap is rejected even through aliases.
593
+ These checks assume the operator trusts the installed CLI/package contents.
582
594
  To verify the linked-worktree boundary on a Linux host with Codex installed,
583
595
  run `NAVARCH_TEST_REAL_CODEX=1 npm test -- tests/codex-linux-sandbox.test.cts`
584
596
  from `runtime/`. The smoke test uses local shell commands and makes no model calls.
package/dist/cli.cjs CHANGED
@@ -303,7 +303,10 @@ async function startCommand(flags) {
303
303
  // runSession checkpoints or retains the worktree before releasing its
304
304
  // capacity slot.
305
305
  runtimeShutdown.abort();
306
- void capacity.waitForIdle().then(() => process.exit(CREDENTIALS_REJECTED_EXIT_CODE));
306
+ // Mirror warming can outlive its conversation's capacity slot. Wait for
307
+ // its killed process and private-cache cleanup before exiting too.
308
+ void Promise.all([capacity.waitForIdle(), conversationLoop.drain()])
309
+ .then(() => process.exit(CREDENTIALS_REJECTED_EXIT_CODE));
307
310
  }, browserProbe);
308
311
  updateCoordinatorRef.current = new update_coordinator_cjs_1.RuntimeUpdateCoordinator({
309
312
  config,
package/dist/config.cjs CHANGED
@@ -195,6 +195,7 @@ function loadRuntimeConfig(env = process.env) {
195
195
  browserProbe: !["off", "false", "0"].includes(env.NAVARCH_BROWSER_PROBE ?? ""),
196
196
  naviWorkspaceChat: ["on", "true", "1"].includes(env.NAVARCH_NAVI_WORKSPACE_CHAT ?? ""),
197
197
  naviConversations: !["off", "false", "0"].includes(env.NAVARCH_NAVI_CONVERSATIONS ?? ""),
198
+ conversationMirror: !["off", "false", "0"].includes(env.NAVARCH_CONVERSATION_MIRROR ?? ""),
198
199
  // The server stops waiting for a capability advertisement after 90 s and
199
200
  // asks for a poll at least every 30 s; the floor only bounds control-plane load.
200
201
  conversationPollIntervalMs: Math.min(30_000, Math.max(1_000, envInt(env, "NAVARCH_CONVERSATION_POLL_MS", 5_000))),
@@ -7,6 +7,7 @@ const worktree_guard_cjs_1 = require("../worktree-guard.cjs");
7
7
  const capability_cjs_1 = require("./capability.cjs");
8
8
  const journal_cjs_1 = require("./journal.cjs");
9
9
  const turn_runner_cjs_1 = require("./turn-runner.cjs");
10
+ const mirror_cjs_1 = require("./mirror.cjs");
10
11
  const workspace_cjs_1 = require("./workspace.cjs");
11
12
  const MAX_BACKOFF_MS = 60_000;
12
13
  /** After a turn, poll fast for a while: a follow-up is likely and the poll is the only delivery path. */
@@ -16,6 +17,8 @@ const SWEEP_INTERVAL_MS = 30_000;
16
17
  const PRUNE_INTERVAL_MS = 6 * 60 * 60_000;
17
18
  /** The server keeps idempotency records for 24 hours; keep ours longer. */
18
19
  const JOURNAL_RETENTION_MS = 48 * 60 * 60_000;
20
+ /** A repository mirror nobody has asked about for this long is removed; the next conversation rebuilds it. */
21
+ const MIRROR_RETENTION_MS = 14 * 24 * 60 * 60_000;
19
22
  /**
20
23
  * Polls for queued Navi conversation turns and runs each on this machine's
21
24
  * configured adapter. Polling is also the capability advertisement: this loop
@@ -34,6 +37,7 @@ class ConversationLoop {
34
37
  lastPruneAt = -Infinity;
35
38
  active = new Set();
36
39
  journal;
40
+ mirrors;
37
41
  log;
38
42
  now;
39
43
  outcomes = [];
@@ -42,6 +46,13 @@ class ConversationLoop {
42
46
  this.journal = options.journal ?? new journal_cjs_1.ConversationJournal(options.config.workspaceRoot);
43
47
  this.log = options.log ?? (0, logger_cjs_1.createLogger)("navi");
44
48
  this.now = options.now ?? Date.now;
49
+ this.mirrors = options.config.conversationMirror
50
+ ? new mirror_cjs_1.ConversationMirrors(options.config.workspaceRoot, {
51
+ ...(options.gitRunner ? { runner: options.gitRunner } : {}),
52
+ ...(options.shutdownSignal ? { signal: options.shutdownSignal } : {}),
53
+ log: this.log,
54
+ })
55
+ : null;
45
56
  }
46
57
  /** Outcomes of turns this process ran, newest last; used for latency reporting and tests. */
47
58
  get recentOutcomes() {
@@ -59,6 +70,7 @@ class ConversationLoop {
59
70
  return;
60
71
  }
61
72
  this.stopped = false;
73
+ this.mirrors?.resume();
62
74
  this.failures = 0;
63
75
  this.log.info(`Navi conversations advertised on the ${this.options.config.agentType} adapter (${this.options.config.naviWorkspaceChat ? "read-only + workspace" : "read-only"}).`);
64
76
  this.schedule(0);
@@ -68,12 +80,14 @@ class ConversationLoop {
68
80
  if (this.timer)
69
81
  clearTimeout(this.timer);
70
82
  this.timer = null;
83
+ this.mirrors?.stop();
71
84
  }
72
85
  /** Stops new polls and waits for one already on the wire. Running turns finish on their own slots. */
73
86
  async drain() {
74
87
  this.stop();
75
88
  await this.polling;
76
89
  await this.housekeeping_;
90
+ await this.mirrors?.drain();
77
91
  }
78
92
  schedule(delayMs) {
79
93
  if (this.stopped)
@@ -173,6 +187,7 @@ class ConversationLoop {
173
187
  if (at - this.lastPruneAt >= PRUNE_INTERVAL_MS) {
174
188
  this.lastPruneAt = at;
175
189
  await this.journal.prune(JOURNAL_RETENTION_MS, this.options.config.workspaceRoot).catch(() => undefined);
190
+ await this.mirrors?.prune(MIRROR_RETENTION_MS, at).catch(() => undefined);
176
191
  }
177
192
  }
178
193
  async settle(journal) {
@@ -209,10 +224,11 @@ class ConversationLoop {
209
224
  ...(shutdownSignal ? { signal: shutdownSignal } : {}),
210
225
  ...this.options.turnOverrides,
211
226
  log: this.log,
212
- prepareWorkspace: async ({ conversationId, repositoryUrl, mode, signal }) => {
227
+ prepareWorkspace: async ({ conversationId, repositoryUrl, workingBranch, mode, signal }) => {
213
228
  const workspace = await (0, workspace_cjs_1.prepareConversationWorkspace)({
214
- workspaceRoot: config.workspaceRoot, conversationId, repositoryUrl, env, signal, mode,
229
+ workspaceRoot: config.workspaceRoot, conversationId, repositoryUrl, workingBranch, env, signal, mode,
215
230
  ...(this.options.gitRunner ? { runner: this.options.gitRunner } : {}),
231
+ ...(this.mirrors ? { mirrors: this.mirrors } : {}),
216
232
  });
217
233
  const guard = await (0, worktree_guard_cjs_1.prepareWorktreeGuard)({
218
234
  workDir: workspace.metaDir,
@@ -0,0 +1,225 @@
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.ConversationMirrors = void 0;
7
+ const node_crypto_1 = require("node:crypto");
8
+ const node_fs_1 = require("node:fs");
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const sandbox_cjs_1 = require("../sandbox.cjs");
11
+ /** Incremental fetch of an existing mirror; a full first clone runs in the background instead. */
12
+ const MIRROR_FETCH_TIMEOUT_MS = 60_000;
13
+ const MIRROR_CLONE_TIMEOUT_MS = 15 * 60_000;
14
+ const LOCAL_CLONE_TIMEOUT_MS = 3 * 60_000;
15
+ const MIRRORS_DIR = "conversation-mirrors";
16
+ /**
17
+ * Machine-level bare mirrors that seed Navi conversation checkouts.
18
+ *
19
+ * A fresh network clone of a large repository took ~35 s per new conversation,
20
+ * which was nearly all of the first answer's latency. With a mirror, a new
21
+ * conversation costs one incremental `git fetch` from GitHub plus a local,
22
+ * local `git clone` without a second network transfer.
23
+ *
24
+ * - The fetch uses the machine's own git credentials on every new
25
+ * conversation, so a machine that lost access still fails the turn instead
26
+ * of answering from a stale copy.
27
+ * - Checkouts copy their objects, without hardlinks or alternates. Writable
28
+ * chat must not be able to change another conversation's object inodes.
29
+ * - The first conversation of a repository on a machine keeps the direct
30
+ * clone; the mirror is built afterwards in the background and published by
31
+ * an atomic rename, so an interrupted build is never mistaken for a mirror.
32
+ */
33
+ class ConversationMirrors {
34
+ options;
35
+ root;
36
+ runner;
37
+ /** Serializes git operations per mirror within this process. */
38
+ locks = new Map();
39
+ building = new Map();
40
+ warming = new AbortController();
41
+ paused = false;
42
+ constructor(workspaceRoot, options = {}) {
43
+ this.options = options;
44
+ this.root = node_path_1.default.join(workspaceRoot, MIRRORS_DIR);
45
+ this.runner = options.runner ?? sandbox_cjs_1.nodeCommandRunner;
46
+ }
47
+ /** Mirror location for a validated https repository URL. */
48
+ mirrorPath(url) {
49
+ return node_path_1.default.join(this.root, `${(0, node_crypto_1.createHash)("sha256").update(url).digest("hex").slice(0, 32)}.git`);
50
+ }
51
+ /**
52
+ * Refreshes the repository's mirror and clones `checkout` from it. Returns
53
+ * false (leaving no checkout behind) when there is no usable mirror, so the
54
+ * caller clones from the network instead.
55
+ */
56
+ async seedCheckout(options) {
57
+ const mirror = this.mirrorPath(options.url);
58
+ if (!(await this.usableMirror(mirror)))
59
+ return false;
60
+ return this.locked(mirror, () => this.withDiskLock(mirror, false, async () => {
61
+ if (!(await this.usableMirror(mirror)))
62
+ return false;
63
+ const fetched = await this.runner.run("git", ["--git-dir", mirror, "fetch", "--quiet", "--prune", "--no-tags", "--", options.url, "+refs/heads/*:refs/heads/*"], { cwd: this.root, env: options.env, timeoutMs: MIRROR_FETCH_TIMEOUT_MS, signal: options.signal });
64
+ if (fetched.code !== 0) {
65
+ if (options.signal?.aborted)
66
+ return false;
67
+ // Lost access, or a damaged mirror. Either way it must not seed this
68
+ // conversation; the direct clone decides whether the turn can run and
69
+ // a successful one rebuilds the mirror.
70
+ this.options.log?.warn(`conversation mirror fetch failed with exit ${fetched.code}; cloning directly`);
71
+ await node_fs_1.promises.rm(mirror, { recursive: true, force: true }).catch(() => undefined);
72
+ return false;
73
+ }
74
+ const now = new Date();
75
+ await node_fs_1.promises.utimes(mirror, now, now).catch(() => undefined);
76
+ let branch = options.branch;
77
+ if (!branch) {
78
+ // Fetch updates refs, not the bare repository's symbolic HEAD. Query
79
+ // the current remote default instead of silently using its old value.
80
+ const remoteHead = await this.runner.run("git", ["ls-remote", "--symref", "--exit-code", "--", options.url, "HEAD"], { cwd: this.root, env: options.env, timeoutMs: MIRROR_FETCH_TIMEOUT_MS, signal: options.signal });
81
+ const ref = /^ref: (refs\/heads\/[A-Za-z0-9][A-Za-z0-9._/-]*)\tHEAD$/m.exec(remoteHead.stdout)?.[1];
82
+ if (remoteHead.code !== 0 || !ref || ref.includes(".."))
83
+ return false;
84
+ branch = ref.slice("refs/heads/".length);
85
+ const updated = await this.runner.run("git", ["--git-dir", mirror, "symbolic-ref", "HEAD", ref], { cwd: this.root, env: options.env, timeoutMs: 30_000, signal: options.signal });
86
+ if (updated.code !== 0)
87
+ return false;
88
+ }
89
+ const cloned = await this.runner.run("git", ["clone", "--quiet", "--local", "--no-hardlinks", "--no-tags", "--branch", branch, "--", mirror, options.checkout],
90
+ // Only this clone reads a local path, and only the mirror this runtime built.
91
+ { cwd: node_path_1.default.dirname(options.checkout), env: { ...options.env, GIT_ALLOW_PROTOCOL: "file" }, timeoutMs: LOCAL_CLONE_TIMEOUT_MS, signal: options.signal });
92
+ if (cloned.code === 0) {
93
+ // The agent's `git fetch`/`git log origin/...` must see GitHub, not this machine's mirror path.
94
+ const repointed = await this.runner.run("git", ["-C", options.checkout, "remote", "set-url", "origin", options.url], { cwd: options.checkout, env: options.env, timeoutMs: 30_000, signal: options.signal });
95
+ if (repointed.code === 0)
96
+ return true;
97
+ }
98
+ this.options.log?.warn(`conversation checkout from mirror failed with exit ${cloned.code}; cloning directly`);
99
+ await node_fs_1.promises.rm(options.checkout, { recursive: true, force: true }).catch(() => undefined);
100
+ return false;
101
+ }));
102
+ }
103
+ /**
104
+ * Builds the repository's mirror in the background if it does not exist.
105
+ * Called after a direct clone succeeded with the same credentials, so it
106
+ * never delays a turn and never runs for a repository this machine cannot read.
107
+ */
108
+ warm(url, env, signal) {
109
+ if (this.paused || signal?.aborted || this.options.signal?.aborted)
110
+ return Promise.resolve();
111
+ const mirror = this.mirrorPath(url);
112
+ const pending = this.building.get(mirror);
113
+ if (pending)
114
+ return pending;
115
+ const controller = new AbortController();
116
+ const signals = [this.warming.signal, signal, this.options.signal].filter((s) => !!s);
117
+ const abort = () => controller.abort();
118
+ for (const source of signals) {
119
+ source.addEventListener("abort", abort, { once: true });
120
+ if (source.aborted)
121
+ abort();
122
+ }
123
+ const build = this.locked(mirror, () => this.withDiskLock(mirror, undefined, async () => {
124
+ if (controller.signal.aborted || await this.usableMirror(mirror))
125
+ return;
126
+ const partial = await node_fs_1.promises.mkdtemp(`${mirror}.partial-`);
127
+ try {
128
+ const result = await this.runner.run("git", ["clone", "--quiet", "--bare", "--no-tags", "--", url, partial], { cwd: this.root, env, timeoutMs: MIRROR_CLONE_TIMEOUT_MS, signal: controller.signal });
129
+ if (controller.signal.aborted)
130
+ return;
131
+ if (result.code !== 0 || !(await regularFile(node_path_1.default.join(partial, "HEAD")))) {
132
+ throw new Error(`git clone --bare failed with exit ${result.code}`);
133
+ }
134
+ await node_fs_1.promises.rename(partial, mirror);
135
+ }
136
+ finally {
137
+ await node_fs_1.promises.rm(partial, { recursive: true, force: true }).catch(() => undefined);
138
+ }
139
+ }))
140
+ // A credential helper can put secrets in stderr or spawn errors. Cache
141
+ // diagnostics must not copy those values into persistent runtime logs.
142
+ .catch(() => this.options.log?.warn("conversation mirror build failed; direct cloning remains available"))
143
+ .finally(() => {
144
+ for (const source of signals)
145
+ source.removeEventListener("abort", abort);
146
+ this.building.delete(mirror);
147
+ });
148
+ this.building.set(mirror, build);
149
+ return build;
150
+ }
151
+ resume() {
152
+ this.paused = false;
153
+ if (this.warming.signal.aborted)
154
+ this.warming = new AbortController();
155
+ }
156
+ stop() {
157
+ this.paused = true;
158
+ this.warming.abort();
159
+ }
160
+ async drain() {
161
+ this.stop();
162
+ await Promise.allSettled([...this.building.values()]);
163
+ }
164
+ /** Removes mirrors (and abandoned partial builds) no conversation has used for `maxAgeMs`. */
165
+ async prune(maxAgeMs, now = Date.now()) {
166
+ if (!(await directory(this.root)))
167
+ return;
168
+ const entries = await node_fs_1.promises.readdir(this.root, { withFileTypes: true }).catch(() => []);
169
+ for (const entry of entries) {
170
+ if (!entry.isDirectory() || !/^[0-9a-f]{32}\.git(\.partial(?:-[A-Za-z0-9]+)?)?$/.test(entry.name))
171
+ continue;
172
+ const mirror = node_path_1.default.join(this.root, entry.name).replace(/\.partial(?:-[A-Za-z0-9]+)?$/, "");
173
+ if (this.locks.has(mirror))
174
+ continue;
175
+ await this.locked(mirror, () => this.withDiskLock(mirror, undefined, async () => {
176
+ const target = node_path_1.default.join(this.root, entry.name);
177
+ const stat = await node_fs_1.promises.lstat(target).catch(() => null);
178
+ if (!stat?.isDirectory() || now - stat.mtimeMs < maxAgeMs)
179
+ return;
180
+ await node_fs_1.promises.rm(target, { recursive: true, force: true }).catch(() => undefined);
181
+ }));
182
+ }
183
+ }
184
+ async usableMirror(mirror) {
185
+ return await directory(this.root) && await directory(mirror) && await regularFile(node_path_1.default.join(mirror, "HEAD"));
186
+ }
187
+ /** Other runtime processes fall back to direct clones while this cache is busy.
188
+ * A crashed holder leaves a stale lock; never steal it based only on age. */
189
+ async withDiskLock(mirror, unavailable, work) {
190
+ await node_fs_1.promises.mkdir(this.root, { recursive: true, mode: 0o700 });
191
+ if (!(await directory(this.root)))
192
+ return unavailable;
193
+ const lock = `${mirror}.lock`;
194
+ try {
195
+ await node_fs_1.promises.mkdir(lock, { mode: 0o700 });
196
+ }
197
+ catch (error) {
198
+ if (error.code === "EEXIST")
199
+ return unavailable;
200
+ throw error;
201
+ }
202
+ try {
203
+ return await work();
204
+ }
205
+ finally {
206
+ await node_fs_1.promises.rm(lock, { recursive: true, force: true });
207
+ }
208
+ }
209
+ locked(mirror, work) {
210
+ const previous = this.locks.get(mirror) ?? Promise.resolve();
211
+ const next = previous.catch(() => undefined).then(work);
212
+ const tail = next.catch(() => undefined);
213
+ this.locks.set(mirror, tail);
214
+ void tail.then(() => { if (this.locks.get(mirror) === tail)
215
+ this.locks.delete(mirror); });
216
+ return next;
217
+ }
218
+ }
219
+ exports.ConversationMirrors = ConversationMirrors;
220
+ async function directory(file) {
221
+ return node_fs_1.promises.lstat(file).then((stat) => stat.isDirectory(), () => false);
222
+ }
223
+ async function regularFile(file) {
224
+ return node_fs_1.promises.lstat(file).then((stat) => stat.isFile(), () => false);
225
+ }
@@ -298,7 +298,9 @@ async function runConversationTurn(deps, conversationId, turnId) {
298
298
  if (snapshot.mode === "workspace" && !deps.workspaceEnabled)
299
299
  throw new Error("Workspace chat is disabled on this machine");
300
300
  prompt = (0, prompt_cjs_1.buildConversationPrompt)(snapshot, journal.turnId);
301
- prepared = await deps.prepareWorkspace({ conversationId, repositoryUrl, mode: snapshot.mode ?? "read_only", signal: controller.signal });
301
+ // Older servers omit the working branch: the checkout stays on the default branch.
302
+ const workingBranch = (0, workspace_cjs_1.validWorkingBranch)(claim.workingBranch);
303
+ prepared = await deps.prepareWorkspace({ conversationId, repositoryUrl, workingBranch, mode: snapshot.mode ?? "read_only", signal: controller.signal });
302
304
  }
303
305
  catch (error) {
304
306
  if (!run.lost)
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.conversationEnv = conversationEnv;
7
7
  exports.validRepositoryUrl = validRepositoryUrl;
8
+ exports.validWorkingBranch = validWorkingBranch;
8
9
  exports.conversationRoot = conversationRoot;
9
10
  exports.prepareConversationWorkspace = prepareConversationWorkspace;
10
11
  exports.removeConversationWorkspace = removeConversationWorkspace;
@@ -50,6 +51,22 @@ function validRepositoryUrl(value) {
50
51
  return null;
51
52
  return `https://github.com/${fullName}.git`;
52
53
  }
54
+ /**
55
+ * The project's working branch from the claim, checked as strictly as the
56
+ * hosted path's `buildWorkingBranchCheckoutCommand`
57
+ * (worker/hosted-agent/delivery.ts): a plain ref name that cannot be read as a
58
+ * git option. Absent (an older server, or no working branch) is null; anything
59
+ * else that is not a plain name throws, so the turn never runs on a branch it
60
+ * was not given.
61
+ */
62
+ function validWorkingBranch(value) {
63
+ if (value === undefined || value === null)
64
+ return null;
65
+ if (typeof value !== "string" || value.length > 255 || !/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value) || value.includes("..")) {
66
+ throw new Error("working branch is not a valid branch name");
67
+ }
68
+ return value;
69
+ }
53
70
  function conversationRoot(workspaceRoot, conversationId) {
54
71
  return node_path_1.default.join(workspaceRoot, "conversations", (0, journal_cjs_1.assertUuid)(conversationId, "conversation id"));
55
72
  }
@@ -62,11 +79,18 @@ function conversationRoot(workspaceRoot, conversationId) {
62
79
  * The clone uses the machine's own git credentials (the same ones a local
63
80
  * `git clone` would use), never a Navarch server-side provider credential.
64
81
  * A repository this machine cannot read fails the turn instead of falling back.
82
+ * With `mirrors`, a new checkout is seeded from this machine's refreshed
83
+ * mirror of the repository instead of a full network clone (see mirror.cts).
65
84
  */
66
85
  async function prepareConversationWorkspace(options) {
67
86
  const url = validRepositoryUrl(options.repositoryUrl);
68
87
  if (!url)
69
88
  throw new Error("repository URL is not a plain https URL");
89
+ const branch = validWorkingBranch(options.workingBranch);
90
+ // The marker records what the checkout was cloned from, so a changed
91
+ // repository or working branch is noticed. Default-branch checkouts keep the
92
+ // bare URL that earlier runtimes wrote.
93
+ const identity = branch ? `${url}\nbranch=${branch}` : url;
70
94
  const runner = options.runner ?? sandbox_cjs_1.nodeCommandRunner;
71
95
  const root = options.mode === "workspace"
72
96
  ? node_path_1.default.join(options.workspaceRoot, "chat-workspaces", (0, journal_cjs_1.assertUuid)(options.conversationId, "conversation id"))
@@ -83,7 +107,7 @@ async function prepareConversationWorkspace(options) {
83
107
  if (options.mode === "workspace") {
84
108
  await node_fs_1.promises.writeFile(node_path_1.default.join(workspace.metaDir, "preserve-workspace"), "Workspace chat: retain until the operator removes it.\n", { mode: 0o600 });
85
109
  }
86
- if (hasCheckout && recorded === url)
110
+ if (hasCheckout && recorded === identity)
87
111
  return workspace;
88
112
  if (options.mode === "workspace") {
89
113
  const exists = await node_fs_1.promises.lstat(workspace.checkout).then(() => true, (error) => {
@@ -94,7 +118,7 @@ async function prepareConversationWorkspace(options) {
94
118
  if (exists)
95
119
  throw new Error("Checkout identity changed; preserving the existing workspace. Start a new conversation.");
96
120
  }
97
- // New conversation, a changed repository URL, or an interrupted clone.
121
+ // New conversation, a changed repository URL or working branch, or an interrupted clone.
98
122
  await node_fs_1.promises.rm(workspace.checkout, { recursive: true, force: true });
99
123
  const env = {
100
124
  ...options.env,
@@ -104,12 +128,17 @@ async function prepareConversationWorkspace(options) {
104
128
  GIT_CONFIG_KEY_0: "core.hooksPath",
105
129
  GIT_CONFIG_VALUE_0: process.platform === "win32" ? "NUL" : "/dev/null",
106
130
  };
107
- const result = await runner.run("git", ["clone", "--quiet", "--filter=blob:none", "--no-tags", "--", url, workspace.checkout], { cwd: root, env, timeoutMs: CLONE_TIMEOUT_MS, signal: options.signal });
108
- if (result.code !== 0) {
109
- await node_fs_1.promises.rm(workspace.checkout, { recursive: true, force: true }).catch(() => undefined);
110
- throw new Error(`git clone failed with exit ${result.code}: ${result.stderr.slice(-500)}`);
131
+ const seeded = await options.mirrors?.seedCheckout({ url, branch, checkout: workspace.checkout, env, signal: options.signal }) ?? false;
132
+ if (!seeded) {
133
+ const result = await runner.run("git", ["clone", "--quiet", "--filter=blob:none", "--no-tags", ...(branch ? ["--branch", branch] : []), "--", url, workspace.checkout], { cwd: root, env, timeoutMs: CLONE_TIMEOUT_MS, signal: options.signal });
134
+ if (result.code !== 0) {
135
+ await node_fs_1.promises.rm(workspace.checkout, { recursive: true, force: true }).catch(() => undefined);
136
+ throw new Error(`git clone failed with exit ${result.code}: ${result.stderr.slice(-500)}`);
137
+ }
138
+ // This machine can read the repository: build the mirror for the next conversation.
139
+ void options.mirrors?.warm(url, env, options.signal);
111
140
  }
112
- await node_fs_1.promises.writeFile(marker, url, { encoding: "utf8", mode: 0o600 });
141
+ await node_fs_1.promises.writeFile(marker, identity, { encoding: "utf8", mode: 0o600 });
113
142
  return workspace;
114
143
  }
115
144
  async function removeConversationWorkspace(workspaceRoot, conversationId) {
@@ -8,6 +8,7 @@ exports.readClaudeApiKeyHelper = readClaudeApiKeyHelper;
8
8
  exports.prepareWorktreeGuard = prepareWorktreeGuard;
9
9
  exports.codexToolReadRoots = codexToolReadRoots;
10
10
  exports.codexExecutableReadRoots = codexExecutableReadRoots;
11
+ exports.codexHelperReadRoots = codexHelperReadRoots;
11
12
  exports.macosDeveloperWriteRoots = macosDeveloperWriteRoots;
12
13
  exports.sessionGhConfigDir = sessionGhConfigDir;
13
14
  exports.sessionCodexTmpDir = sessionCodexTmpDir;
@@ -175,7 +176,7 @@ function codexExecutableReadRoots(bin, env = process.env) {
175
176
  const roots = [node_path_1.default.dirname(resolved), node_path_1.default.dirname(real)];
176
177
  const packageRoot = /^(.*\/node_modules\/(?:@[^/]+\/)?[^/]+)\//.exec(real)?.[1];
177
178
  if (packageRoot)
178
- roots.push(packageRoot);
179
+ roots.push(packageRoot, ...siblingPlatformPackages(packageRoot, env));
179
180
  const homeDir = env.HOME?.trim() || node_os_1.default.homedir();
180
181
  const sensitive = [homeDir, env.CODEX_HOME?.trim() || node_path_1.default.join(homeDir, ".codex")].flatMap((dir) => {
181
182
  try {
@@ -187,6 +188,131 @@ function codexExecutableReadRoots(bin, env = process.env) {
187
188
  });
188
189
  return [...new Set(roots.map((root) => node_path_1.default.resolve(root)))].filter((root) => !sensitive.some((target) => isPathInside(target, root)));
189
190
  }
191
+ /**
192
+ * npm installs the vendored native binary as a sibling package
193
+ * (`@openai/codex-linux-x64`) when the layout is hoisted (a local or
194
+ * `--prefix` install) and nests it under the wrapper only for some global
195
+ * installs. The wrapper's own root alone then leaves the binary unmounted.
196
+ */
197
+ function siblingPlatformPackages(packageRoot, env) {
198
+ const scopeDir = node_path_1.default.dirname(packageRoot);
199
+ if (node_path_1.default.basename(packageRoot) !== "codex" || node_path_1.default.basename(scopeDir) !== "@openai")
200
+ return [];
201
+ // These are npm aliases of @openai/codex, not arbitrary similarly named siblings.
202
+ // See codex-cli/scripts/build_npm_package.py and bin/codex.js in Codex 0.162.1.
203
+ const targets = {
204
+ "codex-linux-x64": ["linux", "x64", "x86_64-unknown-linux-musl"],
205
+ "codex-linux-arm64": ["linux", "arm64", "aarch64-unknown-linux-musl"],
206
+ "codex-darwin-x64": ["darwin", "x64", "x86_64-apple-darwin"],
207
+ "codex-darwin-arm64": ["darwin", "arm64", "aarch64-apple-darwin"],
208
+ "codex-win32-x64": ["win32", "x64", "x86_64-pc-windows-msvc"],
209
+ "codex-win32-arm64": ["win32", "arm64", "aarch64-pc-windows-msvc"],
210
+ };
211
+ const homeDir = env.HOME?.trim() || node_os_1.default.homedir();
212
+ const codexHome = canonicalPathWithMissingTail(env.CODEX_HOME?.trim() || node_path_1.default.join(homeDir, ".codex"));
213
+ if (!codexHome)
214
+ return [];
215
+ try {
216
+ return (0, node_fs_1.readdirSync)(scopeDir).flatMap((name) => {
217
+ const target = targets[name];
218
+ if (!target)
219
+ return [];
220
+ try {
221
+ const real = (0, node_fs_1.realpathSync)(node_path_1.default.join(scopeDir, name));
222
+ if (!(0, node_fs_1.statSync)(real).isDirectory())
223
+ return [];
224
+ // Existing managed package roots are already allowed, but credentials,
225
+ // config and arbitrary other CODEX_HOME descendants must not be added.
226
+ if (isPathInside(real, codexHome) && !isPathInside(real, node_path_1.default.join(codexHome, "packages")))
227
+ return [];
228
+ const manifestPath = (0, node_fs_1.realpathSync)(node_path_1.default.join(real, "package.json"));
229
+ const manifestStat = (0, node_fs_1.statSync)(manifestPath);
230
+ if (!isPathInside(manifestPath, real) || !manifestStat.isFile() || manifestStat.size > 64 * 1024)
231
+ return [];
232
+ const manifest = JSON.parse((0, node_fs_1.readFileSync)(manifestPath, "utf8"));
233
+ const [platform, arch, triple] = target;
234
+ if (manifest.name !== "@openai/codex" || !Array.isArray(manifest.os) || !manifest.os.includes(platform)
235
+ || !Array.isArray(manifest.cpu) || !manifest.cpu.includes(arch))
236
+ return [];
237
+ const native = (0, node_fs_1.realpathSync)(node_path_1.default.join(real, "vendor", triple, "bin", platform === "win32" ? "codex.exe" : "codex"));
238
+ if (!isPathInside(native, real) || !(0, node_fs_1.statSync)(native).isFile())
239
+ return [];
240
+ return [real];
241
+ }
242
+ catch {
243
+ return [];
244
+ }
245
+ });
246
+ }
247
+ catch {
248
+ return [];
249
+ }
250
+ }
251
+ /** Resolve existing ancestors without treating a dangling link as a missing directory. */
252
+ function canonicalPathWithMissingTail(root) {
253
+ let current = node_path_1.default.resolve(root);
254
+ const missing = [];
255
+ for (;;) {
256
+ try {
257
+ return node_path_1.default.join((0, node_fs_1.realpathSync)(current), ...missing.reverse());
258
+ }
259
+ catch (error) {
260
+ if (error.code !== "ENOENT")
261
+ return undefined;
262
+ try {
263
+ (0, node_fs_1.lstatSync)(current);
264
+ return undefined; // Existing but unresolvable (for example a dangling symlink).
265
+ }
266
+ catch (statError) {
267
+ if (statError.code !== "ENOENT")
268
+ return undefined;
269
+ }
270
+ const parent = node_path_1.default.dirname(current);
271
+ if (parent === current)
272
+ return undefined;
273
+ missing.push(node_path_1.default.basename(current));
274
+ current = parent;
275
+ }
276
+ }
277
+ }
278
+ /**
279
+ * Where Codex keeps its per-run `codex-linux-sandbox` helper links.
280
+ *
281
+ * Codex creates `$CODEX_HOME/tmp/arg0/<run>/codex-linux-sandbox` (a link
282
+ * to its own binary) and puts that directory first on PATH. The Linux sandbox
283
+ * re-executes the helper by that bare name inside bwrap's empty `/`, so
284
+ * without this read grant every sandboxed command fails with
285
+ * `bwrap: execvp codex-linux-sandbox: No such file or directory`. The grant
286
+ * is limited to `tmp/arg0`, which holds only those links; auth.json and the
287
+ * rest of CODEX_HOME stay unmounted.
288
+ */
289
+ function codexHelperReadRoots(env = process.env) {
290
+ const homeDir = env.HOME?.trim() || node_os_1.default.homedir();
291
+ const codexHome = env.CODEX_HOME?.trim() || node_path_1.default.join(homeDir, ".codex");
292
+ // CODEX_HOME itself may be an operator-owned alias. Below it, only the
293
+ // actual helper directories are accepted; aliases must not broaden this grant.
294
+ try {
295
+ if (!(0, node_fs_1.statSync)(codexHome).isDirectory())
296
+ return [];
297
+ }
298
+ catch (error) {
299
+ if (error.code !== "ENOENT")
300
+ return [];
301
+ }
302
+ for (const root of [node_path_1.default.join(codexHome, "tmp"), node_path_1.default.join(codexHome, "tmp", "arg0")]) {
303
+ try {
304
+ const info = (0, node_fs_1.lstatSync)(root);
305
+ if (info.isSymbolicLink() || !info.isDirectory())
306
+ return [];
307
+ }
308
+ catch (error) {
309
+ if (error.code !== "ENOENT")
310
+ return [];
311
+ }
312
+ }
313
+ const helperRoot = node_path_1.default.join(codexHome, "tmp", "arg0");
314
+ return canonicalPathWithMissingTail(helperRoot) ? [helperRoot] : [];
315
+ }
190
316
  function resolveExecutable(bin, env) {
191
317
  const candidates = bin.includes("/")
192
318
  ? [node_path_1.default.resolve(bin)]
@@ -298,10 +424,18 @@ function codexWorktreeGuardArgs(options, platform = process.platform) {
298
424
  const gitdir = linkedWorktreeGitDir(options.worktreePath, options.repositoryPath);
299
425
  if (gitdir)
300
426
  filesystem[gitdir] = "write";
301
- const overlapsWorkspace = (root) => isPathInside(root, workspaceRoot) || (linux && isPathInside(workspaceRoot, root));
427
+ const canonicalWorkspace = canonicalPathWithMissingTail(workspaceRoot);
428
+ const overlapsWorkspace = (root) => {
429
+ const canonicalRoot = canonicalPathWithMissingTail(root);
430
+ if (!canonicalRoot || !canonicalWorkspace)
431
+ return true;
432
+ return isPathInside(root, workspaceRoot) || (linux && isPathInside(workspaceRoot, root))
433
+ || isPathInside(canonicalRoot, canonicalWorkspace) || (linux && isPathInside(canonicalWorkspace, canonicalRoot));
434
+ };
302
435
  const readRoots = [
303
436
  ...codexToolReadRoots(process.env, platform),
304
437
  ...(linux && options.codexBin ? codexExecutableReadRoots(options.codexBin) : []),
438
+ ...(linux ? codexHelperReadRoots() : []),
305
439
  ];
306
440
  for (const root of readRoots) {
307
441
  const resolved = node_path_1.default.resolve(root);
@@ -390,7 +524,7 @@ function geminiSandboxMounts(options) {
390
524
  }
391
525
  function isPathInside(candidate, root) {
392
526
  const relative = node_path_1.default.relative(root, candidate);
393
- return relative === "" || (!relative.startsWith("..") && !node_path_1.default.isAbsolute(relative));
527
+ return relative === "" || (relative !== ".." && !relative.startsWith(`..${node_path_1.default.sep}`) && !node_path_1.default.isAbsolute(relative));
394
528
  }
395
529
  function tomlInlineTable(table) {
396
530
  return `{${Object.entries(table)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.111",
3
+ "version": "0.1.116",
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",