@sagentlab/navarch-runtime 0.1.135 → 0.1.137

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();
package/dist/cli.cjs CHANGED
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.main = main;
7
7
  const config_cjs_1 = require("./config.cjs");
8
8
  const node_crypto_1 = require("node:crypto");
9
+ const node_os_1 = __importDefault(require("node:os"));
9
10
  const node_path_1 = __importDefault(require("node:path"));
10
11
  const machine_store_cjs_1 = require("./machine-store.cjs");
11
12
  const api_cjs_1 = require("./api.cjs");
@@ -181,6 +182,7 @@ async function registerCommand(flags) {
181
182
  owner_zone: ownerZone,
182
183
  capabilities,
183
184
  max_sessions: Number.isFinite(maxSessions) && maxSessions > 0 ? maxSessions : config.maxSessions,
185
+ hostname: node_os_1.default.hostname(),
184
186
  });
185
187
  await (0, machine_store_cjs_1.saveMachineIdentity)(config.configDir, {
186
188
  machine_id: result.machine_id,
@@ -232,6 +234,7 @@ async function connectCommand(flags) {
232
234
  capabilities,
233
235
  max_sessions: Number.isFinite(maxSessions) && maxSessions > 0 ? maxSessions : config.maxSessions,
234
236
  project_id: projectId,
237
+ hostname: node_os_1.default.hostname(),
235
238
  });
236
239
  await (0, machine_store_cjs_1.saveMachineIdentity)(config.configDir, {
237
240
  machine_id: result.machine_id,
@@ -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,80 @@ 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 a managed repository's installation token in a session file for
56
+ * agents whose OS sandbox cannot reach the Navarch API (the Codex worktree
57
+ * guard allows only GitHub hosts). The runtime parent fetches from the broker,
58
+ * so the machine token never enters the agent environment, and the credential
59
+ * helper only reads the file. Mirrors GitGrantRefresher.
60
+ */
61
+ class GithubTokenFileRefresher {
62
+ options;
63
+ timer = null;
64
+ stopped = false;
65
+ inflight = null;
66
+ constructor(options) {
67
+ this.options = options;
68
+ }
69
+ /** Writes the session-start token and schedules broker refreshes. */
70
+ async start(initialToken) {
71
+ await this.write(initialToken);
72
+ this.schedule(this.options.refreshMs ?? exports.GITHUB_TOKEN_FILE_REFRESH_MS);
73
+ }
74
+ async refresh() {
75
+ const issued = await this.options.api.issueSecrets({
76
+ lease_id: this.options.leaseId,
77
+ secret_names: [exports.GITHUB_CREDENTIAL_SECRET_NAME],
78
+ });
79
+ const token = issued.secrets[exports.GITHUB_CREDENTIAL_SECRET_NAME];
80
+ if (typeof token !== "string" || token.length === 0)
81
+ throw new Error("broker did not issue github-pat");
82
+ if (this.stopped)
83
+ throw new Error("github token refresher stopped");
84
+ await this.write(token);
85
+ }
86
+ /** Stops, waits for an in-flight refresh, then removes the token file. */
87
+ async stopAndClean() {
88
+ this.stopped = true;
89
+ if (this.timer)
90
+ clearTimeout(this.timer);
91
+ this.timer = null;
92
+ await this.inflight?.catch(() => undefined);
93
+ await promises_1.default.rm(this.options.tokenFile, { force: true }).catch(() => undefined);
94
+ }
95
+ async write(token) {
96
+ // Register before writing so a redaction pass never misses a live value.
97
+ this.options.registry.register(token);
98
+ if (/[\r\n\0]/.test(token))
99
+ throw new Error("github token contains unsupported control characters");
100
+ await promises_1.default.mkdir(node_path_1.default.dirname(this.options.tokenFile), { recursive: true, mode: 0o700 });
101
+ await (0, git_grant_cjs_1.writePrivateFile)(this.options.tokenFile, `${token}\n`);
102
+ }
103
+ schedule(delayMs) {
104
+ if (this.stopped)
105
+ return;
106
+ this.timer = setTimeout(() => {
107
+ this.inflight = this.tick();
108
+ }, delayMs);
109
+ this.timer.unref?.();
110
+ }
111
+ async tick() {
112
+ if (this.stopped)
113
+ return;
114
+ try {
115
+ await this.refresh();
116
+ this.schedule(this.options.refreshMs ?? exports.GITHUB_TOKEN_FILE_REFRESH_MS);
117
+ }
118
+ catch (error) {
119
+ // The previous token stays until its own expiry; GitHub rejects it after.
120
+ const retryMs = this.options.retryMs ?? exports.GITHUB_TOKEN_FILE_RETRY_MS;
121
+ this.options.log.warn(`github token refresh failed for ${this.options.leaseId}; retrying in ${retryMs / 1000}s: ${String(error)}`);
122
+ this.schedule(retryMs);
123
+ }
124
+ }
125
+ }
126
+ 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
  /**
@@ -185,6 +186,12 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
185
186
  // The runtime parent fetches and refreshes the grant; the agent never sees
186
187
  // the machine token or a GitHub token in these modes.
187
188
  const bundleGitMode = bundle.repository?.git_proxy_mode;
189
+ // Codex's worktree guard is an OS sandbox around the whole agent, git's
190
+ // credential helper included: it denies the session dir and allows only
191
+ // GitHub hosts. Helpers and their token files go in a dir it may read.
192
+ const codexGuarded = config.sandboxMode === "host" && config.worktreeGuard && runtime === "codex";
193
+ const gitCredentialDir = (0, worktree_guard_cjs_1.sessionGitCredentialDir)(workDir);
194
+ let githubTokenRefresher;
188
195
  let gitGrantRefresher;
189
196
  let gitGrantEnv;
190
197
  if (bundleGitMode !== "off") {
@@ -200,7 +207,8 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
200
207
  });
201
208
  try {
202
209
  const issue = await refresher.start();
203
- const helperPath = node_path_1.default.join(workDir, GIT_GRANT_HELPER_FILENAME);
210
+ await node_fs_1.promises.mkdir(gitCredentialDir, { recursive: true, mode: 0o700 });
211
+ const helperPath = node_path_1.default.join(gitCredentialDir, GIT_GRANT_HELPER_FILENAME);
204
212
  await node_fs_1.promises.copyFile(node_path_1.default.resolve(__dirname, "../bin/git-credential-navarch-grant.cjs"), helperPath);
205
213
  gitGrantRefresher = refresher;
206
214
  gitGrantEnv = {
@@ -256,14 +264,23 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
256
264
  }
257
265
  let gitCredentialRefresh;
258
266
  if (!gitGrantMode && managedGithubCredential && secrets["github-pat"]) {
259
- const helperPath = node_path_1.default.join(workDir, GIT_CREDENTIAL_HELPER_FILENAME);
267
+ await node_fs_1.promises.mkdir(gitCredentialDir, { recursive: true, mode: 0o700 });
268
+ const helperPath = node_path_1.default.join(gitCredentialDir, GIT_CREDENTIAL_HELPER_FILENAME);
260
269
  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
- };
270
+ if (codexGuarded) {
271
+ const tokenFile = node_path_1.default.join(gitCredentialDir, github_credential_cjs_1.GITHUB_TOKEN_FILENAME);
272
+ githubTokenRefresher = new github_credential_cjs_1.GithubTokenFileRefresher({ api, leaseId, tokenFile, registry, log });
273
+ await githubTokenRefresher.start(secrets["github-pat"]);
274
+ gitCredentialRefresh = { helperPath, tokenFile };
275
+ }
276
+ else {
277
+ gitCredentialRefresh = {
278
+ helperPath,
279
+ apiBase: api.getBaseUrl(),
280
+ machineToken: api.getToken() ?? "",
281
+ leaseId,
282
+ };
283
+ }
267
284
  }
268
285
  const sessionEnv = toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail, gitGrantEnv);
269
286
  // Proxied projects (CX-3.7): the control-plane identity replaces the
@@ -274,6 +291,15 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
274
291
  const ghConfigDir = (0, worktree_guard_cjs_1.sessionGhConfigDir)(workDir);
275
292
  await node_fs_1.promises.mkdir(ghConfigDir, { recursive: true, mode: 0o700 });
276
293
  sessionEnv.GH_CONFIG_DIR = ghConfigDir;
294
+ if (codexGuarded) {
295
+ // The sandbox cannot read ~/.gitconfig, and git exits on an unreadable
296
+ // global config, so every agent git command failed until the agent found
297
+ // GIT_CONFIG_GLOBAL=/dev/null itself. The session file also replaces the
298
+ // default ~/.config/git/ignore, whose denial warns on every status.
299
+ await node_fs_1.promises.mkdir(gitCredentialDir, { recursive: true, mode: 0o700 });
300
+ sessionEnv.GIT_CONFIG_GLOBAL = node_path_1.default.join(gitCredentialDir, SESSION_GIT_GLOBAL_CONFIG_FILENAME);
301
+ await node_fs_1.promises.writeFile(sessionEnv.GIT_CONFIG_GLOBAL, "[core]\n\texcludesFile = /dev/null\n", { mode: 0o600 });
302
+ }
277
303
  if (gitGrantMode) {
278
304
  // Adapters spread process.env under this map. Blank any GitHub token the
279
305
  // runtime process itself holds so the agent cannot inherit it.
@@ -297,6 +323,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
297
323
  });
298
324
  secrets = {};
299
325
  await gitGrantRefresher?.stopAndClean();
326
+ await githubTokenRefresher?.stopAndClean();
300
327
  await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
301
328
  return;
302
329
  }
@@ -730,6 +757,11 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
730
757
  workspaceRoot: config.workspaceRoot,
731
758
  extraRoots: config.guardExtraRoots,
732
759
  codexBin: config.codexBin,
760
+ credentialReadRoots: [
761
+ gitCredentialDir,
762
+ ...(gitGrantEnv ? [(0, git_grant_cjs_1.gitGrantPaths)(workDir).dir] : []),
763
+ ],
764
+ networkHosts: gitGrantEnv ? [gitGrantEnv.proxyHost] : [],
733
765
  });
734
766
  }
735
767
  const restoredCheckpoint = gitWorktree.restoredHostedCheckpoint;
@@ -1424,9 +1456,10 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
1424
1456
  // request. A later heartbeat response sees sessionClosing and does nothing.
1425
1457
  await Promise.resolve(heartbeatWorkspaceUpdate).catch(() => undefined);
1426
1458
  secrets = {};
1427
- // Waits for an in-flight refresh, then removes the grant files so none
1428
- // survives in a retained workspace.
1459
+ // Waits for an in-flight refresh, then removes the grant and token files
1460
+ // so none survives in a retained workspace.
1429
1461
  await gitGrantRefresher?.stopAndClean();
1462
+ await githubTokenRefresher?.stopAndClean();
1430
1463
  // A sandbox teardown failure must not skip worktree cleanup: a leftover
1431
1464
  // task branch in the shared cache blocks the task's next retry.
1432
1465
  try {
@@ -1668,9 +1701,14 @@ function toEnvMap(secrets, credentialRefreshOrAuthorName, gitAuthorNameOrEmail =
1668
1701
  if (secrets["github-pat"] && !out.GITHUB_TOKEN) {
1669
1702
  out.GITHUB_TOKEN = secrets["github-pat"];
1670
1703
  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;
1704
+ if ("tokenFile" in gitCredentialRefresh) {
1705
+ out.NAVARCH_GIT_CREDENTIAL_TOKEN_FILE = gitCredentialRefresh.tokenFile;
1706
+ }
1707
+ else {
1708
+ out.NAVARCH_GIT_CREDENTIAL_API_BASE = gitCredentialRefresh.apiBase;
1709
+ out.NAVARCH_GIT_CREDENTIAL_MACHINE_TOKEN = gitCredentialRefresh.machineToken;
1710
+ out.NAVARCH_GIT_CREDENTIAL_LEASE_ID = gitCredentialRefresh.leaseId;
1711
+ }
1674
1712
  gitConfig.unshift(["credential.helper", ""], ["credential.helper", `!node ${shellQuote(gitCredentialRefresh.helperPath)}`]);
1675
1713
  out.GIT_TERMINAL_PROMPT = "0";
1676
1714
  }
@@ -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.135",
3
+ "version": "0.1.137",
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",