@sagentlab/navarch-runtime 0.1.17 → 0.1.19
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 +11 -4
- package/dist/config.cjs +1 -1
- package/dist/git-worktree.cjs +30 -5
- package/dist/session.cjs +10 -3
- package/dist/worktree-guard.cjs +25 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -264,7 +264,7 @@ unchanged across the deployment.
|
|
|
264
264
|
| `NAVARCH_MAX_SESSIONS` | `5` | Local concurrent-session capacity cap — see `src/capacity.cts`. |
|
|
265
265
|
| `NAVARCH_CAPABILITIES` | `shell,browser-use` (`docker-sandbox,shell` in Docker mode) | Comma list reported at heartbeat/claim time. Docker mode does not advertise browser use until the configured image provides it. |
|
|
266
266
|
| `NAVARCH_OWNER_ZONE` | `sagentlab` | `sagentlab` or `customer-<slug>-premises` (project-plan.md §3.11). |
|
|
267
|
-
| `NAVARCH_POLL_INTERVAL_MS` | `
|
|
267
|
+
| `NAVARCH_POLL_INTERVAL_MS` | `30000` | Claim-loop poll interval. |
|
|
268
268
|
| `NAVARCH_HEARTBEAT_INTERVAL_MS` | `60000` | Machine-level heartbeat interval. |
|
|
269
269
|
| `NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS` | `300000` | Per-lease heartbeat interval; must stay well under the 15-minute lease TTL (schema-design.md §4). |
|
|
270
270
|
| `NAVARCH_SESSION_TIMEOUT_MS` | `2700000` (45 min) | Hard kill timeout for a single session. |
|
|
@@ -305,7 +305,12 @@ supported coding agents, using each CLI's native enforcement point:
|
|
|
305
305
|
- **Codex:** the runtime passes a one-off native permission profile with
|
|
306
306
|
`approval_policy="on-request"` and `approvals_reviewer="auto_review"`.
|
|
307
307
|
Codex's OS sandbox grants read/write access only to the allowed roots and
|
|
308
|
-
denies the surrounding multi-session workspace
|
|
308
|
+
denies the surrounding multi-session workspace. Its network policy allows
|
|
309
|
+
GitHub and GitHub-hosted content so authenticated `gh` and git operations
|
|
310
|
+
required by the task stay inside the sandbox. `gh` reads from an empty,
|
|
311
|
+
session-owned `GH_CONFIG_DIR` and authenticates with the lease-scoped token,
|
|
312
|
+
so the operator's GitHub configuration and unrelated credentials remain
|
|
313
|
+
inaccessible. Other destinations remain blocked and eligible escalations
|
|
309
314
|
are decided by the automatic reviewer rather than waiting for human input.
|
|
310
315
|
`--ignore-user-config` and an untrusted project-config override prevent a
|
|
311
316
|
user or checked-in legacy `sandbox_mode` from silently disabling the
|
|
@@ -330,8 +335,10 @@ The resulting boundary is:
|
|
|
330
335
|
system prefixes.
|
|
331
336
|
- The **rest of the workspace root** — sibling sessions' worktrees, other
|
|
332
337
|
projects' bare repos, and the session's own metadata dir (lease-scoped MCP
|
|
333
|
-
config, the guard files themselves) — is denied outright
|
|
334
|
-
|
|
338
|
+
config, the guard files themselves) — is denied outright. The only metadata
|
|
339
|
+
exception is the empty, read-only `gh` config directory described above, so
|
|
340
|
+
an agent can neither read another agent's checkout nor rewrite its own guard
|
|
341
|
+
policy.
|
|
335
342
|
|
|
336
343
|
The Claude hook is a strong guardrail rather than a hard security boundary
|
|
337
344
|
because shell paths are screened lexically. Codex's permission profile is
|
package/dist/config.cjs
CHANGED
|
@@ -61,7 +61,7 @@ function loadRuntimeConfig(env = process.env) {
|
|
|
61
61
|
maxSessions: envInt(env, "NAVARCH_MAX_SESSIONS", 5),
|
|
62
62
|
capabilities: envList(env, "NAVARCH_CAPABILITIES", defaultCapabilities),
|
|
63
63
|
ownerZone: env.NAVARCH_OWNER_ZONE ?? "sagentlab",
|
|
64
|
-
pollIntervalMs: envInt(env, "NAVARCH_POLL_INTERVAL_MS",
|
|
64
|
+
pollIntervalMs: envInt(env, "NAVARCH_POLL_INTERVAL_MS", 30_000),
|
|
65
65
|
machineHeartbeatIntervalMs: envInt(env, "NAVARCH_HEARTBEAT_INTERVAL_MS", 60_000),
|
|
66
66
|
// leases.expires_at = claimed_at + 15 min (schema-design.md §4) — default renewal
|
|
67
67
|
// interval must stay comfortably under that TTL.
|
package/dist/git-worktree.cjs
CHANGED
|
@@ -23,13 +23,19 @@ class GitWorktree {
|
|
|
23
23
|
runner;
|
|
24
24
|
cloneUrl;
|
|
25
25
|
githubToken;
|
|
26
|
+
taskBranchSuffix;
|
|
26
27
|
constructor(options) {
|
|
27
28
|
const projectKey = safePathSegment(options.projectId);
|
|
28
29
|
const sessionKey = safePathSegment(options.sessionId);
|
|
30
|
+
const taskKey = safePathSegment(options.taskId);
|
|
29
31
|
this.sessionRoot = node_path_1.default.join(options.workspaceRoot, "sessions", sessionKey);
|
|
30
32
|
this.worktreePath = node_path_1.default.join(this.sessionRoot, "repo");
|
|
31
33
|
this.repositoryPath = node_path_1.default.join(options.workspaceRoot, "repositories", `${projectKey}.git`);
|
|
32
|
-
|
|
34
|
+
// One delivery task owns one remote branch across every retry. A
|
|
35
|
+
// session-scoped branch lets a failed attempt push useful work, then makes
|
|
36
|
+
// the retry start from main and open a second PR for the same task.
|
|
37
|
+
this.taskBranchSuffix = `-${taskKey.toLowerCase()}`;
|
|
38
|
+
this.branch = `navarch/${branchSlug(options)}${this.taskBranchSuffix}`;
|
|
33
39
|
this.runner = options.runner ?? sandbox_cjs_1.nodeCommandRunner;
|
|
34
40
|
this.cloneUrl = options.cloneUrl;
|
|
35
41
|
this.githubToken = options.githubToken;
|
|
@@ -75,7 +81,10 @@ class GitWorktree {
|
|
|
75
81
|
], true);
|
|
76
82
|
}
|
|
77
83
|
async addSessionWorktree() {
|
|
78
|
-
|
|
84
|
+
// A failed session may have pushed before it could report completion. The
|
|
85
|
+
// next session must resume that task-owned branch, not fork again from the
|
|
86
|
+
// default branch and create a competing delivery PR.
|
|
87
|
+
const startRef = (await this.resolveTaskBranchRef()) ?? (await this.resolveStartRef());
|
|
79
88
|
if (!startRef) {
|
|
80
89
|
// Empty remote (no commits yet): there is nothing to base the session on,
|
|
81
90
|
// so bootstrap an orphan branch the session can push as the first commit.
|
|
@@ -93,6 +102,22 @@ class GitWorktree {
|
|
|
93
102
|
startRef,
|
|
94
103
|
], false);
|
|
95
104
|
}
|
|
105
|
+
/** Returns the fetched task-owned remote branch when an earlier attempt pushed it. */
|
|
106
|
+
async resolveTaskBranchRef() {
|
|
107
|
+
const refs = await this.runGit(["--git-dir", this.repositoryPath, "for-each-ref", "--format=%(refname)", "refs/remotes/origin/navarch"], false);
|
|
108
|
+
const matches = refs.stdout
|
|
109
|
+
.split("\n")
|
|
110
|
+
.map((line) => line.trim())
|
|
111
|
+
.filter((ref) => ref.endsWith(this.taskBranchSuffix));
|
|
112
|
+
if (matches.length === 0)
|
|
113
|
+
return null;
|
|
114
|
+
if (matches.length > 1) {
|
|
115
|
+
throw new Error(`Multiple remote branches claim task suffix ${this.taskBranchSuffix}; refusing to fork another delivery branch.`);
|
|
116
|
+
}
|
|
117
|
+
const remoteRef = matches[0];
|
|
118
|
+
this.branch = remoteRef.replace(/^refs\/remotes\/origin\//, "");
|
|
119
|
+
return remoteRef;
|
|
120
|
+
}
|
|
96
121
|
/**
|
|
97
122
|
* Resolves the remote-tracking ref new session branches start from, or null
|
|
98
123
|
* when the remote has no branches at all (a freshly provisioned empty repo).
|
|
@@ -211,8 +236,8 @@ async function pathExists(value) {
|
|
|
211
236
|
}
|
|
212
237
|
}
|
|
213
238
|
/**
|
|
214
|
-
* Kebab-case slug for the
|
|
215
|
-
* branch names read like `navarch/fix-login-redirect-
|
|
239
|
+
* Kebab-case slug for the task branch, derived from the task summary so
|
|
240
|
+
* branch names read like `navarch/fix-login-redirect-<task-id>` instead of a
|
|
216
241
|
* UUID mash. Falls back to the task type, then a task-id prefix, when the
|
|
217
242
|
* summary yields nothing. Output contains only [a-z0-9-] with no leading or
|
|
218
243
|
* trailing dash, so it is always a valid git ref component (no `..`, no
|
|
@@ -239,7 +264,7 @@ function branchSlug(options) {
|
|
|
239
264
|
function safePathSegment(value) {
|
|
240
265
|
const safe = value.replace(/[^A-Za-z0-9_.-]/g, "-").replace(/^-+|-+$/g, "");
|
|
241
266
|
if (!safe)
|
|
242
|
-
throw new Error("Cannot create a git worktree without a valid
|
|
267
|
+
throw new Error("Cannot create a git worktree without a valid identifier.");
|
|
243
268
|
return safe;
|
|
244
269
|
}
|
|
245
270
|
function normalizeCloneUrl(value) {
|
package/dist/session.cjs
CHANGED
|
@@ -95,6 +95,7 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
95
95
|
leaseId,
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
|
+
const sessionEnv = toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail);
|
|
98
99
|
const cloneUrl = bundle.repository?.clone_url ??
|
|
99
100
|
(task.repo ? `https://github.com/${task.repo.replace(/\.git$/, "")}.git` : null);
|
|
100
101
|
if (!cloneUrl) {
|
|
@@ -241,6 +242,9 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
241
242
|
}
|
|
242
243
|
}
|
|
243
244
|
else if (runtime === "codex") {
|
|
245
|
+
const ghConfigDir = (0, worktree_guard_cjs_1.codexGhConfigDir)(workDir);
|
|
246
|
+
await node_fs_1.promises.mkdir(ghConfigDir, { recursive: true, mode: 0o700 });
|
|
247
|
+
sessionEnv.GH_CONFIG_DIR = ghConfigDir;
|
|
244
248
|
codexGuardArgs = (0, worktree_guard_cjs_1.codexWorktreeGuardArgs)({
|
|
245
249
|
workDir,
|
|
246
250
|
worktreePath: gitWorktree.worktreePath,
|
|
@@ -266,7 +270,7 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
266
270
|
await gitWorktree.prepare();
|
|
267
271
|
if (sandbox) {
|
|
268
272
|
await sandbox.create();
|
|
269
|
-
await sandbox.injectEnv(
|
|
273
|
+
await sandbox.injectEnv(sessionEnv);
|
|
270
274
|
}
|
|
271
275
|
// The control plane returns the agent type selected for this worker.
|
|
272
276
|
// Any agent type may run any task; capabilities remain the task-level gate.
|
|
@@ -300,7 +304,7 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
300
304
|
model: execution.model,
|
|
301
305
|
reasoningEffort: execution.reasoning_effort,
|
|
302
306
|
timeoutMs: config.sessionTimeoutMs,
|
|
303
|
-
env:
|
|
307
|
+
env: sessionEnv,
|
|
304
308
|
settingsPath: claudeSettingsPath,
|
|
305
309
|
codexGuardArgs,
|
|
306
310
|
geminiSandboxMounts: geminiGuardMounts,
|
|
@@ -468,7 +472,10 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
468
472
|
// every other body we surface) so a 4xx/5xx completion stays diagnosable.
|
|
469
473
|
const crashDetail = (0, redact_cjs_1.redactText)(describeCompletionError(err), knownSecrets);
|
|
470
474
|
log.error(`session ${leaseId} threw before completing: ${crashDetail}`);
|
|
471
|
-
|
|
475
|
+
// Send the same bounded, redacted detail to the control plane that we log
|
|
476
|
+
// locally. NavarchApiError.message contains only the HTTP status line; its
|
|
477
|
+
// response body carries the actionable server error shown in session UI.
|
|
478
|
+
const failureSummary = `Session crashed: ${crashDetail}`;
|
|
472
479
|
await api
|
|
473
480
|
.completeLease(leaseId, {
|
|
474
481
|
status: "failed",
|
package/dist/worktree-guard.cjs
CHANGED
|
@@ -7,12 +7,18 @@ exports.guardHookScriptPath = guardHookScriptPath;
|
|
|
7
7
|
exports.readClaudeApiKeyHelper = readClaudeApiKeyHelper;
|
|
8
8
|
exports.prepareWorktreeGuard = prepareWorktreeGuard;
|
|
9
9
|
exports.codexToolReadRoots = codexToolReadRoots;
|
|
10
|
+
exports.codexGhConfigDir = codexGhConfigDir;
|
|
10
11
|
exports.codexWorktreeGuardArgs = codexWorktreeGuardArgs;
|
|
11
12
|
exports.geminiSandboxMounts = geminiSandboxMounts;
|
|
12
13
|
const node_path_1 = __importDefault(require("node:path"));
|
|
13
14
|
const node_fs_1 = require("node:fs");
|
|
14
15
|
const node_os_1 = __importDefault(require("node:os"));
|
|
15
16
|
const CODEX_GUARD_PROFILE = "navarch-worktree";
|
|
17
|
+
const CODEX_GH_CONFIG_DIRNAME = "gh-config";
|
|
18
|
+
const CODEX_GITHUB_NETWORK_DOMAINS = {
|
|
19
|
+
"**.github.com": "allow",
|
|
20
|
+
"**.githubusercontent.com": "allow",
|
|
21
|
+
};
|
|
16
22
|
const CLAUDE_USER_SETTINGS_FILENAME = "settings.json";
|
|
17
23
|
/**
|
|
18
24
|
* Tools the hook screens. Everything else — the lease-scoped Navarch MCP
|
|
@@ -122,6 +128,17 @@ function codexToolReadRoots(env = process.env) {
|
|
|
122
128
|
node_path_1.default.join(homeDir, ".cache", "ms-playwright"),
|
|
123
129
|
];
|
|
124
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Isolated gh configuration root for one Codex session.
|
|
133
|
+
*
|
|
134
|
+
* `gh` reads its config file before it considers `GITHUB_TOKEN`. Pointing it
|
|
135
|
+
* at this empty, session-owned directory lets the CLI use the lease token
|
|
136
|
+
* without granting the agent read access to the operator's ~/.config/gh,
|
|
137
|
+
* which may contain unrelated account credentials.
|
|
138
|
+
*/
|
|
139
|
+
function codexGhConfigDir(workDir) {
|
|
140
|
+
return node_path_1.default.join(workDir, CODEX_GH_CONFIG_DIRNAME);
|
|
141
|
+
}
|
|
125
142
|
/**
|
|
126
143
|
* Builds one-off Codex permission-profile arguments for a host session.
|
|
127
144
|
*
|
|
@@ -144,6 +161,7 @@ function codexWorktreeGuardArgs(options) {
|
|
|
144
161
|
[node_path_1.default.resolve(options.workspaceRoot)]: "deny",
|
|
145
162
|
[node_path_1.default.resolve(options.worktreePath)]: "write",
|
|
146
163
|
[node_path_1.default.resolve(options.repositoryPath)]: "write",
|
|
164
|
+
[codexGhConfigDir(options.workDir)]: "read",
|
|
147
165
|
};
|
|
148
166
|
for (const root of codexToolReadRoots()) {
|
|
149
167
|
const resolved = node_path_1.default.resolve(root);
|
|
@@ -173,10 +191,15 @@ function codexWorktreeGuardArgs(options) {
|
|
|
173
191
|
`default_permissions=${tomlString(CODEX_GUARD_PROFILE)}`,
|
|
174
192
|
"-c",
|
|
175
193
|
`permissions.${CODEX_GUARD_PROFILE}.filesystem=${tomlInlineTable(filesystem)}`,
|
|
176
|
-
//
|
|
177
|
-
//
|
|
194
|
+
// A network-enabled permission profile still blocks every destination
|
|
195
|
+
// until it has at least one allow rule. Keep routine authenticated GitHub
|
|
196
|
+
// CLI and git traffic inside the sandbox so it does not become an
|
|
197
|
+
// Auto-review escalation, while leaving every non-GitHub destination
|
|
198
|
+
// blocked. The profile still constrains filesystem access independently.
|
|
178
199
|
"-c",
|
|
179
200
|
`permissions.${CODEX_GUARD_PROFILE}.network.enabled=true`,
|
|
201
|
+
"-c",
|
|
202
|
+
`permissions.${CODEX_GUARD_PROFILE}.network.domains=${tomlInlineTable(CODEX_GITHUB_NETWORK_DOMAINS)}`,
|
|
180
203
|
];
|
|
181
204
|
}
|
|
182
205
|
/**
|
package/package.json
CHANGED