@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 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` | `5000` | Claim-loop poll interval. |
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, while eligible escalations
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, so an agent can
334
- neither read another agent's checkout nor rewrite its own guard policy.
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", 5000),
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.
@@ -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
- this.branch = `navarch/${branchSlug(options)}-${sessionKey.toLowerCase().slice(0, 8)}`;
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
- const startRef = await this.resolveStartRef();
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 session branch, derived from the task summary so
215
- * branch names read like `navarch/fix-login-redirect-a1b2c3d4` instead of a
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 project/session identifier.");
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(toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail));
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: toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail),
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
- const failureSummary = (0, redact_cjs_1.redactText)(`Session crashed: ${String(err)}`, knownSecrets);
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",
@@ -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
- // Navarch coding tasks must be able to fetch dependencies and push their
177
- // branch. The profile still constrains filesystem access independently.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Navarch machine-side session manager: claims delivery tasks and runs them through Claude Code, Codex, or Gemini CLI.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",