@sagentlab/navarch-runtime 0.1.112 → 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 +13 -1
- package/dist/cli.cjs +4 -1
- package/dist/config.cjs +1 -0
- package/dist/conversation/loop.cjs +16 -0
- package/dist/conversation/mirror.cjs +225 -0
- package/dist/conversation/workspace.cjs +11 -4
- package/dist/worktree-guard.cjs +137 -3
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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) {
|
|
@@ -213,6 +228,7 @@ class ConversationLoop {
|
|
|
213
228
|
const workspace = await (0, workspace_cjs_1.prepareConversationWorkspace)({
|
|
214
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
|
+
}
|
|
@@ -79,6 +79,8 @@ function conversationRoot(workspaceRoot, conversationId) {
|
|
|
79
79
|
* The clone uses the machine's own git credentials (the same ones a local
|
|
80
80
|
* `git clone` would use), never a Navarch server-side provider credential.
|
|
81
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).
|
|
82
84
|
*/
|
|
83
85
|
async function prepareConversationWorkspace(options) {
|
|
84
86
|
const url = validRepositoryUrl(options.repositoryUrl);
|
|
@@ -126,10 +128,15 @@ async function prepareConversationWorkspace(options) {
|
|
|
126
128
|
GIT_CONFIG_KEY_0: "core.hooksPath",
|
|
127
129
|
GIT_CONFIG_VALUE_0: process.platform === "win32" ? "NUL" : "/dev/null",
|
|
128
130
|
};
|
|
129
|
-
const
|
|
130
|
-
if (
|
|
131
|
-
await
|
|
132
|
-
|
|
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);
|
|
133
140
|
}
|
|
134
141
|
await node_fs_1.promises.writeFile(marker, identity, { encoding: "utf8", mode: 0o600 });
|
|
135
142
|
return workspace;
|
package/dist/worktree-guard.cjs
CHANGED
|
@@ -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
|
|
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(
|
|
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