@sagentlab/navarch-runtime 0.1.18 → 0.1.20

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
@@ -290,9 +290,16 @@ unchanged across the deployment.
290
290
  ## Worktree boundary guard (host mode)
291
291
 
292
292
  A machine typically runs several sessions concurrently (`NAVARCH_MAX_SESSIONS`),
293
- each in its own git worktree. The runtime enforces the same boundary for both
293
+ each in its own git worktree. The runtime enforces the same boundary for all
294
294
  supported coding agents, using each CLI's native enforcement point:
295
295
 
296
+ Every adapter receives an empty, session-owned `GH_CONFIG_DIR` and, when the
297
+ project supplies GitHub credentials, the lease-scoped `GITHUB_TOKEN`. This
298
+ keeps `gh` independent of the operator's selected account and prevents tasks
299
+ from reading unrelated GitHub credentials. Docker already mounts the session
300
+ metadata directory; Gemini's nested host sandbox receives the `gh` directory
301
+ as an explicit read-only mount.
302
+
296
303
  - **Claude Code:** the runtime uses `--permission-mode auto`, which sends
297
304
  approval decisions through Claude Code's background safety classifier. It
298
305
  does not blanket-preapprove the lease-scoped Navarch MCP tools. A generated
@@ -307,19 +314,17 @@ supported coding agents, using each CLI's native enforcement point:
307
314
  Codex's OS sandbox grants read/write access only to the allowed roots and
308
315
  denies the surrounding multi-session workspace. Its network policy allows
309
316
  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
314
- are decided by the automatic reviewer rather than waiting for human input.
317
+ required by the task stay inside the sandbox. Other destinations remain
318
+ blocked and eligible escalations are decided by the automatic reviewer
319
+ rather than waiting for human input.
315
320
  `--ignore-user-config` and an untrusted project-config override prevent a
316
321
  user or checked-in legacy `sandbox_mode` from silently disabling the
317
322
  generated profile; Codex authentication still comes from `CODEX_HOME`, and
318
323
  repository instructions such as `AGENTS.md` still load.
319
324
  - **Gemini:** the runtime uses Gemini CLI's native sandbox with only the
320
- current worktree, shared gitdir, and approved extra roots mounted. The
321
- lease MCP config is translated to a read-only temporary settings file;
322
- header values remain environment-variable references rather than literals.
325
+ current worktree, shared gitdir, session `gh` config, and approved extra
326
+ roots mounted. The `gh` config and lease MCP settings are read-only; header
327
+ values remain environment-variable references rather than literals.
323
328
  Docker-mode sessions disable Gemini's implicit YOLO sandbox to avoid nesting
324
329
  it inside Navarch's already isolated session container.
325
330
 
@@ -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
@@ -96,6 +96,9 @@ async function runSession(deps, claimed, sessionId) {
96
96
  };
97
97
  }
98
98
  const sessionEnv = toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail);
99
+ const ghConfigDir = (0, worktree_guard_cjs_1.sessionGhConfigDir)(workDir);
100
+ await node_fs_1.promises.mkdir(ghConfigDir, { recursive: true, mode: 0o700 });
101
+ sessionEnv.GH_CONFIG_DIR = ghConfigDir;
99
102
  const cloneUrl = bundle.repository?.clone_url ??
100
103
  (task.repo ? `https://github.com/${task.repo.replace(/\.git$/, "")}.git` : null);
101
104
  if (!cloneUrl) {
@@ -242,9 +245,6 @@ async function runSession(deps, claimed, sessionId) {
242
245
  }
243
246
  }
244
247
  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;
248
248
  codexGuardArgs = (0, worktree_guard_cjs_1.codexWorktreeGuardArgs)({
249
249
  workDir,
250
250
  worktreePath: gitWorktree.worktreePath,
@@ -7,14 +7,14 @@ exports.guardHookScriptPath = guardHookScriptPath;
7
7
  exports.readClaudeApiKeyHelper = readClaudeApiKeyHelper;
8
8
  exports.prepareWorktreeGuard = prepareWorktreeGuard;
9
9
  exports.codexToolReadRoots = codexToolReadRoots;
10
- exports.codexGhConfigDir = codexGhConfigDir;
10
+ exports.sessionGhConfigDir = sessionGhConfigDir;
11
11
  exports.codexWorktreeGuardArgs = codexWorktreeGuardArgs;
12
12
  exports.geminiSandboxMounts = geminiSandboxMounts;
13
13
  const node_path_1 = __importDefault(require("node:path"));
14
14
  const node_fs_1 = require("node:fs");
15
15
  const node_os_1 = __importDefault(require("node:os"));
16
16
  const CODEX_GUARD_PROFILE = "navarch-worktree";
17
- const CODEX_GH_CONFIG_DIRNAME = "gh-config";
17
+ const SESSION_GH_CONFIG_DIRNAME = "gh-config";
18
18
  const CODEX_GITHUB_NETWORK_DOMAINS = {
19
19
  "**.github.com": "allow",
20
20
  "**.githubusercontent.com": "allow",
@@ -129,15 +129,15 @@ function codexToolReadRoots(env = process.env) {
129
129
  ];
130
130
  }
131
131
  /**
132
- * Isolated gh configuration root for one Codex session.
132
+ * Isolated gh configuration root for one coding-agent session.
133
133
  *
134
134
  * `gh` reads its config file before it considers `GITHUB_TOKEN`. Pointing it
135
135
  * at this empty, session-owned directory lets the CLI use the lease token
136
136
  * without granting the agent read access to the operator's ~/.config/gh,
137
137
  * which may contain unrelated account credentials.
138
138
  */
139
- function codexGhConfigDir(workDir) {
140
- return node_path_1.default.join(workDir, CODEX_GH_CONFIG_DIRNAME);
139
+ function sessionGhConfigDir(workDir) {
140
+ return node_path_1.default.join(workDir, SESSION_GH_CONFIG_DIRNAME);
141
141
  }
142
142
  /**
143
143
  * Builds one-off Codex permission-profile arguments for a host session.
@@ -161,7 +161,7 @@ function codexWorktreeGuardArgs(options) {
161
161
  [node_path_1.default.resolve(options.workspaceRoot)]: "deny",
162
162
  [node_path_1.default.resolve(options.worktreePath)]: "write",
163
163
  [node_path_1.default.resolve(options.repositoryPath)]: "write",
164
- [codexGhConfigDir(options.workDir)]: "read",
164
+ [sessionGhConfigDir(options.workDir)]: "read",
165
165
  };
166
166
  for (const root of codexToolReadRoots()) {
167
167
  const resolved = node_path_1.default.resolve(root);
@@ -204,8 +204,9 @@ function codexWorktreeGuardArgs(options) {
204
204
  }
205
205
  /**
206
206
  * Mounts only the current session roots into Gemini CLI's native sandbox.
207
- * Gemini mounts cwd itself; explicit same-path mounts keep the shared gitdir
208
- * and approved external roots reachable without exposing sibling sessions.
207
+ * Gemini mounts cwd itself; explicit same-path mounts keep the shared gitdir,
208
+ * isolated gh config, and approved external roots reachable without exposing
209
+ * sibling sessions.
209
210
  */
210
211
  function geminiSandboxMounts(options) {
211
212
  const workspaceRoot = node_path_1.default.resolve(options.workspaceRoot);
@@ -215,7 +216,9 @@ function geminiSandboxMounts(options) {
215
216
  if (!isPathInside(resolved, workspaceRoot))
216
217
  roots.push(resolved);
217
218
  }
218
- return [...new Set(roots.map((root) => node_path_1.default.resolve(root)))].map((root) => `${root}:${root}:rw`);
219
+ const writableMounts = [...new Set(roots.map((root) => node_path_1.default.resolve(root)))].map((root) => `${root}:${root}:rw`);
220
+ const ghConfigDir = sessionGhConfigDir(options.workDir);
221
+ return [...writableMounts, `${ghConfigDir}:${ghConfigDir}:ro`];
219
222
  }
220
223
  function isPathInside(candidate, root) {
221
224
  const relative = node_path_1.default.relative(root, candidate);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
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",