@sagentlab/navarch-runtime 0.1.136 → 0.1.138

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.
@@ -4,19 +4,43 @@
4
4
  // for every remote operation, so each lookup gets a fresh installation token
5
5
  // from the lease-scoped broker instead of relying on the session-start token.
6
6
 
7
+ const fs = require("node:fs");
8
+
7
9
  const operation = process.argv[2];
8
10
  if (operation !== "get") process.exit(0);
9
11
 
10
- const apiBase = process.env.NAVARCH_GIT_CREDENTIAL_API_BASE?.replace(/\/+$/, "");
11
- const machineToken = process.env.NAVARCH_GIT_CREDENTIAL_MACHINE_TOKEN;
12
- const leaseId = process.env.NAVARCH_GIT_CREDENTIAL_LEASE_ID;
13
-
14
- if (!apiBase || !machineToken || !leaseId) {
15
- console.error("navarch git credential helper is missing broker configuration");
16
- process.exit(1);
12
+ // Sandboxed sessions (Codex guard) cannot reach the Navarch API, so the
13
+ // runtime parent refreshes the token into this file and the helper only
14
+ // reads it.
15
+ const tokenFile = process.env.NAVARCH_GIT_CREDENTIAL_TOKEN_FILE;
16
+ if (tokenFile) {
17
+ let token;
18
+ try {
19
+ token = fs.readFileSync(tokenFile, "utf8").trim();
20
+ } catch (error) {
21
+ console.error(`navarch git credential is unavailable: ${error instanceof Error ? error.message : String(error)}`);
22
+ process.exit(1);
23
+ }
24
+ if (!token || /[\r\n\0]/.test(token)) {
25
+ console.error("navarch git credential file is empty or malformed");
26
+ process.exit(1);
27
+ }
28
+ // No process.exit here: on macOS a piped stdout write is asynchronous.
29
+ process.stdout.write(`username=x-access-token\npassword=${token}\n`);
30
+ } else {
31
+ void main();
17
32
  }
18
33
 
19
34
  async function main() {
35
+ const apiBase = process.env.NAVARCH_GIT_CREDENTIAL_API_BASE?.replace(/\/+$/, "");
36
+ const machineToken = process.env.NAVARCH_GIT_CREDENTIAL_MACHINE_TOKEN;
37
+ const leaseId = process.env.NAVARCH_GIT_CREDENTIAL_LEASE_ID;
38
+
39
+ if (!apiBase || !machineToken || !leaseId) {
40
+ console.error("navarch git credential helper is missing broker configuration");
41
+ process.exit(1);
42
+ }
43
+
20
44
  try {
21
45
  const response = await fetch(`${apiBase}/api/broker/issue`, {
22
46
  method: "POST",
@@ -42,5 +66,3 @@ async function main() {
42
66
  process.exitCode = 1;
43
67
  }
44
68
  }
45
-
46
- void main();
@@ -48,6 +48,8 @@ class GitWorktree {
48
48
  checkpointRoot;
49
49
  cloneUrl;
50
50
  githubToken;
51
+ githubTokenProvider;
52
+ repoLocalTokenInstalled = false;
51
53
  gitGrant;
52
54
  workingBranch;
53
55
  repoLocalGithubToken;
@@ -92,6 +94,7 @@ class GitWorktree {
92
94
  ? proxyCloneUrl(options.cloneUrl, options.gitGrant.proxyBaseUrl)
93
95
  : options.cloneUrl;
94
96
  this.githubToken = options.gitGrant ? undefined : options.githubToken;
97
+ this.githubTokenProvider = options.gitGrant ? undefined : options.githubTokenProvider;
95
98
  this.workingBranch = options.workingBranch?.trim() || null;
96
99
  this.repoLocalGithubToken = options.repoLocalGithubToken;
97
100
  this.prContinuation = options.prContinuation ?? false;
@@ -135,13 +138,44 @@ class GitWorktree {
135
138
  async installRepoLocalGithubToken() {
136
139
  if (!this.repoLocalGithubToken)
137
140
  return;
138
- if (/[\0\r]/.test(this.repoLocalGithubToken)) {
141
+ const token = this.githubTokenProvider?.() ?? this.repoLocalGithubToken;
142
+ if (/[\0\r\n]/.test(token)) {
139
143
  throw new Error("Broker-issued GitHub credential contains unsupported control characters.");
140
144
  }
141
145
  const configPath = node_path_1.default.join(this.repositoryPath, "config");
142
- const block = repoLocalGithubTokenBlock(this.sessionTokenMarker, this.repoLocalGithubToken);
146
+ const block = repoLocalGithubTokenBlock(this.sessionTokenMarker, token);
143
147
  await node_fs_1.promises.chmod(configPath, 0o600);
144
148
  await node_fs_1.promises.appendFile(configPath, block, { encoding: "utf8", mode: 0o600 });
149
+ this.repoLocalTokenInstalled = true;
150
+ }
151
+ /** Serializes rotation with prepare/cleanup; never recreates a removed repository. */
152
+ async refreshRepoLocalGithubToken(token) {
153
+ if (!this.repoLocalGithubToken)
154
+ return;
155
+ if (/[\0\r\n]/.test(token))
156
+ throw new Error("Invalid refreshed GitHub credential");
157
+ await withRepositoryLock(this.repositoryPath, async () => {
158
+ if (!this.repoLocalTokenInstalled)
159
+ return;
160
+ const configPath = node_path_1.default.join(this.repositoryPath, "config");
161
+ // Cooperate with git config writers in the agent as well as parent operations.
162
+ const lockPath = `${configPath}.lock`;
163
+ const lock = await node_fs_1.promises.open(lockPath, "wx", 0o600);
164
+ let renamed = false;
165
+ try {
166
+ const config = await node_fs_1.promises.readFile(configPath, "utf8");
167
+ const clean = removeMarkedConfigBlock(config, this.sessionTokenMarker);
168
+ await lock.writeFile(clean + repoLocalGithubTokenBlock(this.sessionTokenMarker, token), "utf8");
169
+ await lock.close();
170
+ await node_fs_1.promises.rename(lockPath, configPath);
171
+ renamed = true;
172
+ }
173
+ finally {
174
+ await lock.close().catch(() => undefined);
175
+ if (!renamed)
176
+ await node_fs_1.promises.rm(lockPath, { force: true });
177
+ }
178
+ });
145
179
  }
146
180
  /**
147
181
  * Installs the delivery-branch guard shared by every worktree of this cache.
@@ -688,6 +722,7 @@ class GitWorktree {
688
722
  async removeRepoLocalGithubToken() {
689
723
  if (!this.repoLocalGithubToken)
690
724
  return;
725
+ this.repoLocalTokenInstalled = false;
691
726
  const configPath = node_path_1.default.join(this.repositoryPath, "config");
692
727
  try {
693
728
  const config = await node_fs_1.promises.readFile(configPath, "utf8");
@@ -710,7 +745,8 @@ class GitWorktree {
710
745
  }
711
746
  return grantResult;
712
747
  }
713
- const credentialArgs = authenticated && this.githubToken
748
+ const githubToken = this.githubTokenProvider ? this.githubTokenProvider() : this.githubToken;
749
+ const credentialArgs = authenticated && githubToken
714
750
  ? [
715
751
  "-c",
716
752
  "credential.helper=",
@@ -719,8 +755,8 @@ class GitWorktree {
719
755
  ]
720
756
  : [];
721
757
  const result = await this.runner.run("git", [...credentialArgs, ...args], {
722
- env: this.githubToken
723
- ? { ...process.env, GITHUB_TOKEN: this.githubToken, GIT_TERMINAL_PROMPT: "0" }
758
+ env: githubToken
759
+ ? { ...process.env, GITHUB_TOKEN: githubToken, GIT_TERMINAL_PROMPT: "0" }
724
760
  : { ...process.env, GIT_TERMINAL_PROMPT: "0" },
725
761
  });
726
762
  if (result.code !== 0) {
@@ -1,7 +1,13 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.GITHUB_CREDENTIAL_SECRET_NAME = void 0;
6
+ exports.GithubTokenFileRefresher = exports.GITHUB_TOKEN_FILENAME = exports.GITHUB_TOKEN_FILE_RETRY_MS = exports.GITHUB_TOKEN_FILE_REFRESH_MS = exports.GITHUB_CREDENTIAL_SECRET_NAME = void 0;
4
7
  exports.githubCredentialDenialSummary = githubCredentialDenialSummary;
8
+ const promises_1 = __importDefault(require("node:fs/promises"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const git_grant_cjs_1 = require("./git-grant.cjs");
5
11
  /** Wire name the session requests its repository credential under. */
6
12
  exports.GITHUB_CREDENTIAL_SECRET_NAME = "github-pat";
7
13
  /**
@@ -41,3 +47,95 @@ function githubCredentialDenialSummary(denied) {
41
47
  return (`GitHub credential denied (${denial.reason}): ${explanation} ` +
42
48
  "The session stopped before cloning instead of falling back to this machine's own git login.");
43
49
  }
50
+ /** GitHub App installation tokens live an hour; refresh well inside that. */
51
+ exports.GITHUB_TOKEN_FILE_REFRESH_MS = 10 * 60 * 1000;
52
+ exports.GITHUB_TOKEN_FILE_RETRY_MS = 60 * 1000;
53
+ exports.GITHUB_TOKEN_FILENAME = "github-token";
54
+ /**
55
+ * Keeps managed repository credentials current for git, gh, and parent operations.
56
+ * The runtime parent fetches from the broker, so the machine token never enters
57
+ * the agent environment. Helpers and gh only read session-owned files, including
58
+ * under the Codex worktree guard. Mirrors GitGrantRefresher.
59
+ */
60
+ class GithubTokenFileRefresher {
61
+ options;
62
+ token;
63
+ get current() { return this.token; }
64
+ timer = null;
65
+ stopped = false;
66
+ inflight = null;
67
+ constructor(options) {
68
+ this.options = options;
69
+ }
70
+ /** Writes the session-start token and schedules broker refreshes. */
71
+ async start(initialToken) {
72
+ await this.write(initialToken);
73
+ this.schedule(this.options.refreshMs ?? exports.GITHUB_TOKEN_FILE_REFRESH_MS);
74
+ }
75
+ async refresh() {
76
+ const issued = await this.options.api.issueSecrets({
77
+ lease_id: this.options.leaseId,
78
+ secret_names: [exports.GITHUB_CREDENTIAL_SECRET_NAME],
79
+ });
80
+ const token = issued.secrets[exports.GITHUB_CREDENTIAL_SECRET_NAME];
81
+ if (typeof token !== "string" || token.length === 0)
82
+ throw new Error("broker did not issue github-pat");
83
+ if (this.stopped)
84
+ throw new Error("github token refresher stopped");
85
+ await this.write(token);
86
+ }
87
+ /** Stops, waits for an in-flight refresh, then removes the token file. */
88
+ async stopAndClean() {
89
+ this.stopped = true;
90
+ if (this.timer)
91
+ clearTimeout(this.timer);
92
+ this.timer = null;
93
+ await this.inflight?.catch(() => undefined);
94
+ await promises_1.default.rm(this.options.tokenFile, { force: true }).catch(() => undefined);
95
+ if (this.options.ghConfigDir) {
96
+ await promises_1.default.rm(node_path_1.default.join(this.options.ghConfigDir, "hosts.yml"), { force: true }).catch(() => undefined);
97
+ }
98
+ this.token = undefined;
99
+ }
100
+ async write(token) {
101
+ // Register before writing so a redaction pass never misses a live value.
102
+ this.options.registry.register(token);
103
+ if (/[\r\n\0]/.test(token))
104
+ throw new Error("github token contains unsupported control characters");
105
+ await promises_1.default.mkdir(node_path_1.default.dirname(this.options.tokenFile), { recursive: true, mode: 0o700 });
106
+ await (0, git_grant_cjs_1.writePrivateFile)(this.options.tokenFile, `${token}\n`);
107
+ if (this.options.ghConfigDir) {
108
+ await promises_1.default.mkdir(this.options.ghConfigDir, { recursive: true, mode: 0o700 });
109
+ // gh otherwise tries to migrate (and rewrite) hosts.yml inside the read-only sandbox.
110
+ await promises_1.default.writeFile(node_path_1.default.join(this.options.ghConfigDir, "config.yml"), "version: 1\n", { mode: 0o600, flag: "wx" })
111
+ .catch((error) => { if (error.code !== "EEXIST")
112
+ throw error; });
113
+ await (0, git_grant_cjs_1.writePrivateFile)(node_path_1.default.join(this.options.ghConfigDir, "hosts.yml"), (0, git_grant_cjs_1.renderGhHostsYml)("github.com", token));
114
+ }
115
+ this.token = token;
116
+ await this.options.onToken?.(token);
117
+ }
118
+ schedule(delayMs) {
119
+ if (this.stopped)
120
+ return;
121
+ this.timer = setTimeout(() => {
122
+ this.inflight = this.tick();
123
+ }, delayMs);
124
+ this.timer.unref?.();
125
+ }
126
+ async tick() {
127
+ if (this.stopped)
128
+ return;
129
+ try {
130
+ await this.refresh();
131
+ this.schedule(this.options.refreshMs ?? exports.GITHUB_TOKEN_FILE_REFRESH_MS);
132
+ }
133
+ catch (error) {
134
+ // The previous token stays until its own expiry; GitHub rejects it after.
135
+ const retryMs = this.options.retryMs ?? exports.GITHUB_TOKEN_FILE_RETRY_MS;
136
+ this.options.log.warn(`github token refresh failed for ${this.options.leaseId}; retrying in ${retryMs / 1000}s: ${String(error)}`);
137
+ this.schedule(retryMs);
138
+ }
139
+ }
140
+ }
141
+ exports.GithubTokenFileRefresher = GithubTokenFileRefresher;
package/dist/session.cjs CHANGED
@@ -45,6 +45,7 @@ const commit_attribution_cjs_1 = require("./commit-attribution.cjs");
45
45
  /** Filename the generated platform MCP config is written under inside the session metadata directory. */
46
46
  const MCP_CONFIG_FILENAME = "mcp-config.json";
47
47
  const GIT_CREDENTIAL_HELPER_FILENAME = "git-credential-navarch.cjs";
48
+ const SESSION_GIT_GLOBAL_CONFIG_FILENAME = "gitconfig";
48
49
  const GIT_GRANT_HELPER_FILENAME = "git-credential-navarch-grant.cjs";
49
50
  const COMPLETION_REMEDIATION_RETRIES = 2;
50
51
  /**
@@ -105,13 +106,19 @@ function resolveSessionExecution(config, claimed) {
105
106
  * isolation instead; see runtime/README.md.
106
107
  */
107
108
  async function runSession(deps, claimed, sessionId) {
108
- const lifecycle = { enteredMainSessionTry: false };
109
+ const lifecycle = { enteredMainSessionTry: false, refreshers: [] };
109
110
  try {
110
111
  return await runClaimedSession(deps, claimed, sessionId, lifecycle);
111
112
  }
112
113
  catch (err) {
113
114
  if (lifecycle.enteredMainSessionTry)
114
115
  throw err;
116
+ // Stop credential refreshers first: they would otherwise keep calling the
117
+ // broker for the lease completed below and recreate the removed files.
118
+ // stopAndClean waits for an in-flight refresh and is idempotent.
119
+ for (const refresher of lifecycle.refreshers) {
120
+ await refresher.stopAndClean().catch((stopErr) => log.warn(`credential refresher cleanup failed after initialization crash: ${String(stopErr)}`));
121
+ }
115
122
  // Initialization before the main worktree/adapter try (prompt creation,
116
123
  // broker access, MCP/guard setup) must still release the lease with the
117
124
  // explicit pre-start signal. Otherwise it waits for expiry and bypasses
@@ -185,6 +192,13 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
185
192
  // The runtime parent fetches and refreshes the grant; the agent never sees
186
193
  // the machine token or a GitHub token in these modes.
187
194
  const bundleGitMode = bundle.repository?.git_proxy_mode;
195
+ // Codex's worktree guard is an OS sandbox around the whole agent, git's
196
+ // credential helper included: it denies the session dir and allows only
197
+ // GitHub hosts. Helpers and their token files go in a dir it may read.
198
+ const codexGuarded = config.sandboxMode === "host" && config.worktreeGuard && runtime === "codex";
199
+ const gitCredentialDir = (0, worktree_guard_cjs_1.sessionGitCredentialDir)(workDir);
200
+ let githubTokenRefresher;
201
+ const credentialConsumer = {};
188
202
  let gitGrantRefresher;
189
203
  let gitGrantEnv;
190
204
  if (bundleGitMode !== "off") {
@@ -200,9 +214,11 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
200
214
  });
201
215
  try {
202
216
  const issue = await refresher.start();
203
- const helperPath = node_path_1.default.join(workDir, GIT_GRANT_HELPER_FILENAME);
217
+ await node_fs_1.promises.mkdir(gitCredentialDir, { recursive: true, mode: 0o700 });
218
+ const helperPath = node_path_1.default.join(gitCredentialDir, GIT_GRANT_HELPER_FILENAME);
204
219
  await node_fs_1.promises.copyFile(node_path_1.default.resolve(__dirname, "../bin/git-credential-navarch-grant.cjs"), helperPath);
205
220
  gitGrantRefresher = refresher;
221
+ lifecycle.refreshers.push(refresher);
206
222
  gitGrantEnv = {
207
223
  proxyHost: issue.proxy_host,
208
224
  proxyBaseUrl: issue.proxy_base_url,
@@ -256,14 +272,18 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
256
272
  }
257
273
  let gitCredentialRefresh;
258
274
  if (!gitGrantMode && managedGithubCredential && secrets["github-pat"]) {
259
- const helperPath = node_path_1.default.join(workDir, GIT_CREDENTIAL_HELPER_FILENAME);
275
+ await node_fs_1.promises.mkdir(gitCredentialDir, { recursive: true, mode: 0o700 });
276
+ const helperPath = node_path_1.default.join(gitCredentialDir, GIT_CREDENTIAL_HELPER_FILENAME);
260
277
  await node_fs_1.promises.copyFile(node_path_1.default.resolve(__dirname, "../bin/git-credential-navarch.cjs"), helperPath);
261
- gitCredentialRefresh = {
262
- helperPath,
263
- apiBase: api.getBaseUrl(),
264
- machineToken: api.getToken() ?? "",
265
- leaseId,
266
- };
278
+ const tokenFile = node_path_1.default.join(gitCredentialDir, github_credential_cjs_1.GITHUB_TOKEN_FILENAME);
279
+ githubTokenRefresher = new github_credential_cjs_1.GithubTokenFileRefresher({
280
+ api, leaseId, tokenFile, registry, log,
281
+ ghConfigDir: (0, worktree_guard_cjs_1.sessionGhConfigDir)(workDir),
282
+ onToken: async (token) => { await credentialConsumer.worktree?.refreshRepoLocalGithubToken(token); },
283
+ });
284
+ lifecycle.refreshers.push(githubTokenRefresher);
285
+ await githubTokenRefresher.start(secrets["github-pat"]);
286
+ gitCredentialRefresh = { helperPath, tokenFile };
267
287
  }
268
288
  const sessionEnv = toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail, gitGrantEnv);
269
289
  // Proxied projects (CX-3.7): the control-plane identity replaces the
@@ -274,6 +294,15 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
274
294
  const ghConfigDir = (0, worktree_guard_cjs_1.sessionGhConfigDir)(workDir);
275
295
  await node_fs_1.promises.mkdir(ghConfigDir, { recursive: true, mode: 0o700 });
276
296
  sessionEnv.GH_CONFIG_DIR = ghConfigDir;
297
+ if (codexGuarded) {
298
+ // The sandbox cannot read ~/.gitconfig, and git exits on an unreadable
299
+ // global config, so every agent git command failed until the agent found
300
+ // GIT_CONFIG_GLOBAL=/dev/null itself. The session file also replaces the
301
+ // default ~/.config/git/ignore, whose denial warns on every status.
302
+ await node_fs_1.promises.mkdir(gitCredentialDir, { recursive: true, mode: 0o700 });
303
+ sessionEnv.GIT_CONFIG_GLOBAL = node_path_1.default.join(gitCredentialDir, SESSION_GIT_GLOBAL_CONFIG_FILENAME);
304
+ await node_fs_1.promises.writeFile(sessionEnv.GIT_CONFIG_GLOBAL, "[core]\n\texcludesFile = /dev/null\n", { mode: 0o600 });
305
+ }
277
306
  if (gitGrantMode) {
278
307
  // Adapters spread process.env under this map. Blank any GitHub token the
279
308
  // runtime process itself holds so the agent cannot inherit it.
@@ -297,6 +326,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
297
326
  });
298
327
  secrets = {};
299
328
  await gitGrantRefresher?.stopAndClean();
329
+ await githubTokenRefresher?.stopAndClean();
300
330
  await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
301
331
  return;
302
332
  }
@@ -316,6 +346,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
316
346
  sessionId,
317
347
  cloneUrl,
318
348
  githubToken,
349
+ githubTokenProvider: githubTokenRefresher ? () => githubTokenRefresher.current : undefined,
319
350
  workingBranch: bundle.repository?.working_branch,
320
351
  // Grant modes never expose a GitHub token, so the Codex repo-local
321
352
  // github-pat bootstrap is not installed.
@@ -330,6 +361,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
330
361
  restoreHostedCheckpoint: !allowBranchRename && task.task_type !== "verify",
331
362
  ...(commitIdentity ? { ownershipCommitIdentity: commitIdentity.committer } : {}),
332
363
  });
364
+ credentialConsumer.worktree = gitWorktree;
333
365
  const knownGuidanceIds = new Set((bundle.guidance ?? []).map((entry) => entry.id));
334
366
  const deliveredGuidance = [...(bundle.guidance ?? [])];
335
367
  let guidanceCursor = bundle.guidance_cursor ?? null;
@@ -730,6 +762,11 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
730
762
  workspaceRoot: config.workspaceRoot,
731
763
  extraRoots: config.guardExtraRoots,
732
764
  codexBin: config.codexBin,
765
+ credentialReadRoots: [
766
+ gitCredentialDir,
767
+ ...(gitGrantEnv ? [(0, git_grant_cjs_1.gitGrantPaths)(workDir).dir] : []),
768
+ ],
769
+ networkHosts: gitGrantEnv ? [gitGrantEnv.proxyHost] : [],
733
770
  });
734
771
  }
735
772
  const restoredCheckpoint = gitWorktree.restoredHostedCheckpoint;
@@ -1127,7 +1164,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
1127
1164
  // Keep SHA verification on the exact branch, but do not adopt a
1128
1165
  // foreign-branch PR as delivery evidence for a root task.
1129
1166
  allowShaFallback: allowBranchRename,
1130
- githubToken: gitGrantMode ? gitGrantRefresher?.current?.grant : githubToken,
1167
+ githubToken: gitGrantMode ? gitGrantRefresher?.current?.grant : githubTokenRefresher?.current ?? githubToken,
1131
1168
  // Grant modes read through the proxy and never use the host's own gh login.
1132
1169
  apiBaseUrl: gitGrantEnv ? `${gitGrantEnv.proxyBaseUrl.replace(/\/+$/, "")}/api/v3` : undefined,
1133
1170
  hostTokenProvider: gitGrantMode ? async () => undefined : undefined,
@@ -1424,9 +1461,10 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
1424
1461
  // request. A later heartbeat response sees sessionClosing and does nothing.
1425
1462
  await Promise.resolve(heartbeatWorkspaceUpdate).catch(() => undefined);
1426
1463
  secrets = {};
1427
- // Waits for an in-flight refresh, then removes the grant files so none
1428
- // survives in a retained workspace.
1464
+ // Waits for an in-flight refresh, then removes the grant and token files
1465
+ // so none survives in a retained workspace.
1429
1466
  await gitGrantRefresher?.stopAndClean();
1467
+ await githubTokenRefresher?.stopAndClean();
1430
1468
  // A sandbox teardown failure must not skip worktree cleanup: a leftover
1431
1469
  // task branch in the shared cache blocks the task's next retry.
1432
1470
  try {
@@ -1665,12 +1703,21 @@ function toEnvMap(secrets, credentialRefreshOrAuthorName, gitAuthorNameOrEmail =
1665
1703
  ["user.name", gitAuthorName],
1666
1704
  ["user.email", resolvedGitAuthorEmail],
1667
1705
  ];
1668
- if (secrets["github-pat"] && !out.GITHUB_TOKEN) {
1706
+ if (secrets["github-pat"] && (!out.GITHUB_TOKEN || (gitCredentialRefresh && "tokenFile" in gitCredentialRefresh))) {
1669
1707
  out.GITHUB_TOKEN = secrets["github-pat"];
1670
1708
  if (gitCredentialRefresh) {
1671
- out.NAVARCH_GIT_CREDENTIAL_API_BASE = gitCredentialRefresh.apiBase;
1672
- out.NAVARCH_GIT_CREDENTIAL_MACHINE_TOKEN = gitCredentialRefresh.machineToken;
1673
- out.NAVARCH_GIT_CREDENTIAL_LEASE_ID = gitCredentialRefresh.leaseId;
1709
+ if ("tokenFile" in gitCredentialRefresh) {
1710
+ out.NAVARCH_GIT_CREDENTIAL_TOKEN_FILE = gitCredentialRefresh.tokenFile;
1711
+ // gh must read the rotating hosts.yml, including when a parent has GH_TOKEN.
1712
+ out.GITHUB_TOKEN = "";
1713
+ out.GH_TOKEN = "";
1714
+ out.GH_HOST = "github.com";
1715
+ }
1716
+ else {
1717
+ out.NAVARCH_GIT_CREDENTIAL_API_BASE = gitCredentialRefresh.apiBase;
1718
+ out.NAVARCH_GIT_CREDENTIAL_MACHINE_TOKEN = gitCredentialRefresh.machineToken;
1719
+ out.NAVARCH_GIT_CREDENTIAL_LEASE_ID = gitCredentialRefresh.leaseId;
1720
+ }
1674
1721
  gitConfig.unshift(["credential.helper", ""], ["credential.helper", `!node ${shellQuote(gitCredentialRefresh.helperPath)}`]);
1675
1722
  out.GIT_TERMINAL_PROMPT = "0";
1676
1723
  }
@@ -11,6 +11,7 @@ exports.codexExecutableReadRoots = codexExecutableReadRoots;
11
11
  exports.codexHelperReadRoots = codexHelperReadRoots;
12
12
  exports.macosDeveloperWriteRoots = macosDeveloperWriteRoots;
13
13
  exports.sessionGhConfigDir = sessionGhConfigDir;
14
+ exports.sessionGitCredentialDir = sessionGitCredentialDir;
14
15
  exports.sessionCodexTmpDir = sessionCodexTmpDir;
15
16
  exports.codexWorktreeGuardArgs = codexWorktreeGuardArgs;
16
17
  exports.geminiSandboxMounts = geminiSandboxMounts;
@@ -19,6 +20,7 @@ const node_fs_1 = require("node:fs");
19
20
  const node_os_1 = __importDefault(require("node:os"));
20
21
  const CODEX_GUARD_PROFILE = "navarch-worktree";
21
22
  const SESSION_GH_CONFIG_DIRNAME = "gh-config";
23
+ const SESSION_GIT_CREDENTIAL_DIRNAME = "git-credential";
22
24
  const CODEX_GITHUB_NETWORK_DOMAINS = {
23
25
  "**.github.com": "allow",
24
26
  "**.githubusercontent.com": "allow",
@@ -378,6 +380,14 @@ function macosDeveloperWriteRoots(env = process.env, platform = process.platform
378
380
  function sessionGhConfigDir(workDir) {
379
381
  return node_path_1.default.join(workDir, SESSION_GH_CONFIG_DIRNAME);
380
382
  }
383
+ /**
384
+ * Session-owned directory for git's credential helpers and the token file
385
+ * they read. It sits beside the session metadata, which the Codex profile
386
+ * denies, so it is granted read-only by name (credentialReadRoots).
387
+ */
388
+ function sessionGitCredentialDir(workDir) {
389
+ return node_path_1.default.join(workDir, SESSION_GIT_CREDENTIAL_DIRNAME);
390
+ }
381
391
  function sessionCodexTmpDir(workDir) {
382
392
  return node_path_1.default.resolve(workDir, "tmp");
383
393
  }
@@ -417,6 +427,16 @@ function codexWorktreeGuardArgs(options, platform = process.platform) {
417
427
  [node_path_1.default.resolve(options.repositoryPath)]: "write",
418
428
  [sessionGhConfigDir(options.workDir)]: "read",
419
429
  };
430
+ // git runs the credential helper inside the sandbox. Without these grants
431
+ // the helper copied into the denied session dir looks missing, and every
432
+ // authenticated fetch or push fails (ShiftSwap, 2026-10-27).
433
+ // Only strict subdirectories of this session: never the metadata dir itself.
434
+ const sessionDir = node_path_1.default.resolve(options.workDir);
435
+ for (const root of options.credentialReadRoots ?? []) {
436
+ const resolved = node_path_1.default.resolve(root);
437
+ if (resolved !== sessionDir && isPathInside(resolved, sessionDir))
438
+ filesystem[resolved] = "read";
439
+ }
420
440
  // Codex protects a linked worktree's gitdir with a more specific read-only
421
441
  // rule. A write grant on the bare repository alone does not override it:
422
442
  // fetch/checkout/commit fail on FETCH_HEAD, HEAD.lock, or index.lock. Grant
@@ -471,7 +491,10 @@ function codexWorktreeGuardArgs(options, platform = process.platform) {
471
491
  "-c",
472
492
  `permissions.${CODEX_GUARD_PROFILE}.network.enabled=true`,
473
493
  "-c",
474
- `permissions.${CODEX_GUARD_PROFILE}.network.domains=${tomlInlineTable(CODEX_GITHUB_NETWORK_DOMAINS)}`,
494
+ `permissions.${CODEX_GUARD_PROFILE}.network.domains=${tomlInlineTable({
495
+ ...CODEX_GITHUB_NETWORK_DOMAINS,
496
+ ...Object.fromEntries((options.networkHosts ?? []).map((host) => [host, "allow"])),
497
+ })}`,
475
498
  ];
476
499
  }
477
500
  function linkedWorktreeGitDir(worktreePath, repositoryPath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.136",
3
+ "version": "0.1.138",
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",