@sagentlab/navarch-runtime 0.1.126 → 0.1.128

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.
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Git credential helper for sessions that use a Navarch git grant. The runtime
4
+ // parent process refreshes the grant file; this helper only reads it, so it
5
+ // makes no network call and holds no machine token. Usage (as configured in
6
+ // GIT_CONFIG_*): `node git-credential-navarch-grant.cjs <grant-file> get`.
7
+
8
+ const fs = require("node:fs");
9
+
10
+ const grantFile = process.argv[2];
11
+ const operation = process.argv[3];
12
+ if (operation !== "get") process.exit(0);
13
+
14
+ if (!grantFile) {
15
+ console.error("navarch git grant helper is missing the grant file path");
16
+ process.exit(1);
17
+ }
18
+
19
+ let grant;
20
+ try {
21
+ grant = fs.readFileSync(grantFile, "utf8").trim();
22
+ } catch (error) {
23
+ console.error(`navarch git grant is unavailable: ${error instanceof Error ? error.message : String(error)}`);
24
+ process.exit(1);
25
+ }
26
+ if (!grant || /[\r\n\0]/.test(grant)) {
27
+ console.error("navarch git grant file is empty or malformed");
28
+ process.exit(1);
29
+ }
30
+ process.stdout.write(`username=navarch\npassword=${grant}\n`);
package/dist/api.cjs CHANGED
@@ -158,6 +158,17 @@ class NavarchApiClient {
158
158
  throw new Error("issueSecrets: empty response from control plane.");
159
159
  return result;
160
160
  }
161
+ /**
162
+ * `POST /api/broker/grant {lease_id}`: a signed, short-lived git grant for the
163
+ * Navarch git/gh proxy. A project in mode `off` answers 409 `git_proxy_disabled`.
164
+ */
165
+ async issueGitGrant(leaseId) {
166
+ const result = await this.request("POST", "/api/broker/grant", { lease_id: leaseId });
167
+ if (!result || typeof result.grant !== "string" || !result.proxy_host || !result.proxy_base_url) {
168
+ throw new Error("issueGitGrant: incomplete response from control plane.");
169
+ }
170
+ return result;
171
+ }
161
172
  /** `POST /api/broker/mcp-servers {lease_id}`: the user-attached MCP servers this lease's session should connect to. */
162
173
  async fetchUserMcpServers(leaseId) {
163
174
  const result = await this.request("POST", "/api/broker/mcp-servers", { lease_id: leaseId });
package/dist/config.cjs CHANGED
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.OPENCODE_HOST_GUARD_MESSAGE = exports.ANTIGRAVITY_HOST_GUARD_MESSAGE = exports.isRuntimeAgentType = exports.RUNTIME_AGENT_TYPES = exports.DEFAULT_SANDBOX_IMAGE = void 0;
6
+ exports.OPENCODE_HOST_GUARD_MESSAGE = exports.ANTIGRAVITY_HOST_GUARD_MESSAGE = exports.isRuntimeAgentType = exports.RUNTIME_AGENT_TYPES = exports.GIT_GRANT_CAPABILITY = exports.DEFAULT_SANDBOX_IMAGE = void 0;
7
7
  exports.unknownAgentId = unknownAgentId;
8
8
  exports.unknownRuntimeIds = unknownRuntimeIds;
9
9
  exports.opencodeBlockedOnHost = opencodeBlockedOnHost;
@@ -15,6 +15,8 @@ const agent_types_cjs_1 = require("./agent-types.cjs");
15
15
  const sandbox_profile_cjs_1 = require("./sandbox-profile.cjs");
16
16
  /** Published image containing git, GitHub CLI, and the pinned Claude Code CLI. */
17
17
  exports.DEFAULT_SANDBOX_IMAGE = "ghcr.io/sagentlab/navarch-sandbox-agent:0.1.0";
18
+ // Capability advertised so projects in git proxy mode `required` can dispatch here.
19
+ exports.GIT_GRANT_CAPABILITY = "git_grant_v1";
18
20
  var agent_types_cjs_2 = require("./agent-types.cjs");
19
21
  Object.defineProperty(exports, "RUNTIME_AGENT_TYPES", { enumerable: true, get: function () { return agent_types_cjs_2.RUNTIME_AGENT_TYPES; } });
20
22
  Object.defineProperty(exports, "isRuntimeAgentType", { enumerable: true, get: function () { return agent_types_cjs_2.isRuntimeAgentType; } });
@@ -96,7 +98,9 @@ function loadRuntimeConfig(env = process.env) {
96
98
  // default. Docker isolation is an explicit operator opt-in, not a
97
99
  // prerequisite for claiming ordinary shell work.
98
100
  const sandboxMode = env.NAVARCH_SANDBOX_MODE === "docker" ? "docker" : "host";
99
- const defaultCapabilities = sandboxMode === "docker" ? ["docker-sandbox", "shell"] : ["shell", "browser-use"];
101
+ const defaultCapabilities = sandboxMode === "docker"
102
+ ? ["docker-sandbox", "shell", exports.GIT_GRANT_CAPABILITY]
103
+ : ["shell", "browser-use", exports.GIT_GRANT_CAPABILITY];
100
104
  const requestedAgentType = (0, agent_types_cjs_1.isRuntimeAgentType)(env.NAVARCH_AGENT) ? env.NAVARCH_AGENT : "claude-code";
101
105
  // Multiple sessions share one machine; keeping each agent inside its own
102
106
  // worktree is the safe default, so disabling is the explicit opt-out.
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.GitGrantRefresher = exports.GIT_GRANT_HELPER_FILENAME = exports.GIT_GRANT_FILENAME = exports.GIT_GRANT_DIRNAME = exports.GIT_GRANT_RETRY_MS = exports.GIT_GRANT_REFRESH_MS = void 0;
7
+ exports.gitGrantPaths = gitGrantPaths;
8
+ exports.isGitProxyDisabled = isGitProxyDisabled;
9
+ exports.writePrivateFile = writePrivateFile;
10
+ exports.renderGhHostsYml = renderGhHostsYml;
11
+ const promises_1 = __importDefault(require("node:fs/promises"));
12
+ const node_path_1 = __importDefault(require("node:path"));
13
+ exports.GIT_GRANT_REFRESH_MS = 5 * 60 * 1000;
14
+ exports.GIT_GRANT_RETRY_MS = 60 * 1000;
15
+ exports.GIT_GRANT_DIRNAME = ".navarch";
16
+ exports.GIT_GRANT_FILENAME = "git-grant";
17
+ exports.GIT_GRANT_HELPER_FILENAME = "git-credential-navarch-grant.cjs";
18
+ function gitGrantPaths(workDir) {
19
+ const dir = node_path_1.default.join(workDir, exports.GIT_GRANT_DIRNAME);
20
+ return { dir, file: node_path_1.default.join(dir, exports.GIT_GRANT_FILENAME) };
21
+ }
22
+ /** True when the broker refused because the project is in mode `off`. */
23
+ function isGitProxyDisabled(error) {
24
+ const failure = error;
25
+ if (failure?.status !== 409)
26
+ return false;
27
+ const body = failure.body;
28
+ return body?.code === "git_proxy_disabled";
29
+ }
30
+ /** Writes `content` to `file` with mode 0600 via a same-directory rename, so readers never see a partial value. */
31
+ async function writePrivateFile(file, content) {
32
+ const tmp = `${file}.${process.pid}.tmp`;
33
+ await promises_1.default.writeFile(tmp, content, { encoding: "utf8", mode: 0o600 });
34
+ await promises_1.default.chmod(tmp, 0o600);
35
+ await promises_1.default.rename(tmp, file);
36
+ }
37
+ /** gh `hosts.yml` for the proxy host. gh sends `oauth_token` as the Bearer credential. */
38
+ function renderGhHostsYml(proxyHost, grant) {
39
+ return `${proxyHost}:\n oauth_token: ${grant}\n user: navarch\n git_protocol: https\n`;
40
+ }
41
+ /**
42
+ * Owns the grant file for one session. `refresh()` fetches and writes a grant;
43
+ * `start()` does the first refresh (throwing on failure) and then schedules
44
+ * refreshes every 5 minutes, or every 60 seconds after a failure.
45
+ */
46
+ class GitGrantRefresher {
47
+ options;
48
+ timer = null;
49
+ stopped = false;
50
+ inflight = null;
51
+ issued = null;
52
+ now;
53
+ constructor(options) {
54
+ this.options = options;
55
+ this.now = options.now ?? Date.now;
56
+ }
57
+ get current() {
58
+ return this.issued;
59
+ }
60
+ async refresh() {
61
+ const issue = await this.options.api.issueGitGrant(this.options.leaseId);
62
+ if (this.stopped)
63
+ throw new Error("git grant refresher stopped");
64
+ // Register before writing so a redaction pass never misses a live value.
65
+ this.options.registry.register(issue.grant);
66
+ const expiresAt = Date.parse(issue.expires_at);
67
+ if (!Number.isFinite(expiresAt) || expiresAt <= this.now()) {
68
+ throw new Error("git grant is already expired; not writing it");
69
+ }
70
+ if (/[\r\n\0]/.test(issue.grant)) {
71
+ throw new Error("git grant contains unsupported control characters");
72
+ }
73
+ await promises_1.default.mkdir(this.options.grantDir, { recursive: true, mode: 0o700 });
74
+ await promises_1.default.mkdir(this.options.ghConfigDir, { recursive: true, mode: 0o700 });
75
+ await writePrivateFile(this.options.grantFile, `${issue.grant}\n`);
76
+ await writePrivateFile(node_path_1.default.join(this.options.ghConfigDir, "hosts.yml"), renderGhHostsYml(issue.proxy_host, issue.grant));
77
+ this.issued = issue;
78
+ return issue;
79
+ }
80
+ /** First refresh must succeed; later failures are logged and retried. */
81
+ async start() {
82
+ const issue = await this.refresh();
83
+ this.schedule(this.options.refreshMs ?? exports.GIT_GRANT_REFRESH_MS);
84
+ return issue;
85
+ }
86
+ /** Stops, waits for an in-flight refresh, then removes the grant files. */
87
+ async stopAndClean() {
88
+ this.stop();
89
+ await this.inflight?.catch(() => undefined);
90
+ await this.removeFiles();
91
+ }
92
+ async removeFiles() {
93
+ await promises_1.default.rm(this.options.grantDir, { recursive: true, force: true }).catch(() => undefined);
94
+ await promises_1.default.rm(node_path_1.default.join(this.options.ghConfigDir, "hosts.yml"), { force: true }).catch(() => undefined);
95
+ }
96
+ stop() {
97
+ this.stopped = true;
98
+ if (this.timer)
99
+ clearTimeout(this.timer);
100
+ this.timer = null;
101
+ }
102
+ schedule(delayMs) {
103
+ if (this.stopped)
104
+ return;
105
+ this.timer = setTimeout(() => {
106
+ this.inflight = this.tick();
107
+ }, delayMs);
108
+ this.timer.unref?.();
109
+ }
110
+ async tick() {
111
+ if (this.stopped)
112
+ return;
113
+ try {
114
+ await this.refresh();
115
+ this.schedule(this.options.refreshMs ?? exports.GIT_GRANT_REFRESH_MS);
116
+ }
117
+ catch (error) {
118
+ // Never leave an expired grant for the agent to use: remove it.
119
+ const expiresAt = this.issued ? Date.parse(this.issued.expires_at) : 0;
120
+ if (expiresAt <= this.now()) {
121
+ await this.removeFiles();
122
+ }
123
+ this.options.log.warn(`git grant refresh failed for ${this.options.leaseId}; retrying in ${(this.options.retryMs ?? exports.GIT_GRANT_RETRY_MS) / 1000}s: ${String(error)}`);
124
+ this.schedule(this.options.retryMs ?? exports.GIT_GRANT_RETRY_MS);
125
+ }
126
+ }
127
+ }
128
+ exports.GitGrantRefresher = GitGrantRefresher;
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.GitWorktree = void 0;
7
+ exports.proxyCloneUrl = proxyCloneUrl;
7
8
  exports.branchSlug = branchSlug;
8
9
  exports.hostedCheckpointRef = hostedCheckpointRef;
9
10
  const node_path_1 = __importDefault(require("node:path"));
@@ -11,6 +12,12 @@ const worktree_janitor_cjs_1 = require("./worktree-janitor.cjs");
11
12
  const node_fs_1 = require("node:fs");
12
13
  const sandbox_cjs_1 = require("./sandbox.cjs");
13
14
  const repositoryLocks = new Map();
15
+ /** Rewrites a github.com HTTPS clone URL onto the proxy; other URLs are returned unchanged. */
16
+ function proxyCloneUrl(cloneUrl, proxyBaseUrl) {
17
+ const base = proxyBaseUrl.replace(/\/+$/, "");
18
+ const match = /^https:\/\/github\.com\/(.+)$/i.exec(cloneUrl);
19
+ return match ? `${base}/${match[1]}` : cloneUrl;
20
+ }
14
21
  /**
15
22
  * A freshly minted installation token can be rejected for a second or two
16
23
  * before GitHub propagates it, and git reports that 404 on a private repo as
@@ -40,6 +47,7 @@ class GitWorktree {
40
47
  checkpointRoot;
41
48
  cloneUrl;
42
49
  githubToken;
50
+ gitGrant;
43
51
  workingBranch;
44
52
  repoLocalGithubToken;
45
53
  prContinuation;
@@ -77,8 +85,11 @@ class GitWorktree {
77
85
  this.runner = options.runner ?? sandbox_cjs_1.nodeCommandRunner;
78
86
  this.authRetryDelaysMs = options.authRetryDelaysMs ?? DEFAULT_AUTH_RETRY_DELAYS_MS;
79
87
  this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
80
- this.cloneUrl = options.cloneUrl;
81
- this.githubToken = options.githubToken;
88
+ this.gitGrant = options.gitGrant;
89
+ this.cloneUrl = options.gitGrant
90
+ ? proxyCloneUrl(options.cloneUrl, options.gitGrant.proxyBaseUrl)
91
+ : options.cloneUrl;
92
+ this.githubToken = options.gitGrant ? undefined : options.githubToken;
82
93
  this.workingBranch = options.workingBranch?.trim() || null;
83
94
  this.repoLocalGithubToken = options.repoLocalGithubToken;
84
95
  this.prContinuation = options.prContinuation ?? false;
@@ -181,7 +192,7 @@ class GitWorktree {
181
192
  }
182
193
  catch (error) {
183
194
  const delay = this.authRetryDelaysMs[attempt];
184
- if (!this.githubToken || delay === undefined || !TRANSIENT_GIT_AUTH_FAILURE.test(errorMessage(error))) {
195
+ if ((!this.githubToken && !this.gitGrant) || delay === undefined || !TRANSIENT_GIT_AUTH_FAILURE.test(errorMessage(error))) {
185
196
  throw error;
186
197
  }
187
198
  await this.sleep(delay);
@@ -684,6 +695,15 @@ class GitWorktree {
684
695
  }
685
696
  }
686
697
  async runGit(args, authenticated) {
698
+ if (authenticated && this.gitGrant) {
699
+ // The helper reads the grant file the runtime refreshes; no token enters
700
+ // argv or the environment.
701
+ const grantResult = await this.runner.run("git", ["-c", "credential.helper=", "-c", `credential.helper=${this.gitGrant.credentialHelper}`, ...args], { env: gitEnvWithoutGithubTokens() });
702
+ if (grantResult.code !== 0) {
703
+ throw new Error(`git ${args[0] ?? "command"} failed: ${grantResult.stderr || grantResult.stdout}`);
704
+ }
705
+ return grantResult;
706
+ }
687
707
  const credentialArgs = authenticated && this.githubToken
688
708
  ? [
689
709
  "-c",
@@ -704,6 +724,13 @@ class GitWorktree {
704
724
  }
705
725
  }
706
726
  exports.GitWorktree = GitWorktree;
727
+ /** Parent env for grant-mode git: prompts off and no inherited GitHub token. */
728
+ function gitEnvWithoutGithubTokens() {
729
+ const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
730
+ delete env.GITHUB_TOKEN;
731
+ delete env.GH_TOKEN;
732
+ return env;
733
+ }
707
734
  function repoLocalGithubTokenBlock(marker, token) {
708
735
  const escaped = token
709
736
  .replace(/\\/g, "\\\\")
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.GITHUB_CREDENTIAL_SECRET_NAME = void 0;
4
+ exports.githubCredentialDenialSummary = githubCredentialDenialSummary;
5
+ /** Wire name the session requests its repository credential under. */
6
+ exports.GITHUB_CREDENTIAL_SECRET_NAME = "github-pat";
7
+ /**
8
+ * Denials that mean no repository credential exists for this lease, rather
9
+ * than one being refused: the project has neither a GitHub App installation
10
+ * nor a `github-pat` secret (`not_found`), or a standalone PR review on a
11
+ * PAT-connected project, which deliberately gets none
12
+ * (`standalone_review_read_only`). The machine's own git login has always
13
+ * been the clone credential then, so those sessions keep running.
14
+ */
15
+ const NO_CREDENTIAL_REASONS = new Set(["not_found", "standalone_review_read_only"]);
16
+ const MEMBER_FIX = "or have a project owner move this agent to the project GitHub identity, then resume the agent on the Agents page.";
17
+ const REASON_TEXT = {
18
+ member_github_missing: `this agent acts as a project member whose GitHub account is not connected. Connect GitHub on that member's Account page, ${MEMBER_FIX}`,
19
+ member_github_revoked: `this agent acts as a project member whose GitHub authorization was revoked. Reconnect GitHub on that member's Account page, ${MEMBER_FIX}`,
20
+ member_github_unavailable: `this agent acts as a project member whose GitHub credential could not be read. Reconnect GitHub on that member's Account page, ${MEMBER_FIX}`,
21
+ member_not_in_project: `this agent acts as a member who does not belong to this project. Add them to the project, ${MEMBER_FIX}`,
22
+ };
23
+ /**
24
+ * The failure summary for a lease whose repository credential the broker
25
+ * refused, or null when the session may proceed.
26
+ *
27
+ * Without this check a refused credential left the clone to whatever git
28
+ * credential helper the host had configured — on pebble-agent-1721
29
+ * (2026-10-21) that was `gh auth git-credential` for an account that cannot
30
+ * see the repository, so every claim failed with an unexplained "Repository
31
+ * not found". The summary leads with `GitHub credential denied (<reason>):`,
32
+ * which the control plane's failure attribution anchors on to pause the
33
+ * machine instead of spending the task's retries
34
+ * (lib/navarch/failure-attribution.ts MEMBER_GITHUB_RULE).
35
+ */
36
+ function githubCredentialDenialSummary(denied) {
37
+ const denial = denied?.find((entry) => entry.name === exports.GITHUB_CREDENTIAL_SECRET_NAME);
38
+ if (!denial || NO_CREDENTIAL_REASONS.has(denial.reason))
39
+ return null;
40
+ const explanation = REASON_TEXT[denial.reason] ?? "the control plane refused the repository credential for this lease.";
41
+ return (`GitHub credential denied (${denial.reason}): ${explanation} ` +
42
+ "The session stopped before cloning instead of falling back to this machine's own git login.");
43
+ }
@@ -63,7 +63,8 @@ async function findHeadBranchPullRequestUrl(options) {
63
63
  if (!owner || !name || extra.length > 0) {
64
64
  throw new Error(`Invalid GitHub repository name: ${options.repository}`);
65
65
  }
66
- const branchUrl = new URL(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls`);
66
+ const apiBase = (options.apiBaseUrl ?? "https://api.github.com").replace(/\/+$/, "");
67
+ const branchUrl = new URL(`${apiBase}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls`);
67
68
  branchUrl.searchParams.set("state", "all");
68
69
  branchUrl.searchParams.set("head", `${owner}:${options.headBranch}`);
69
70
  branchUrl.searchParams.set("per_page", "10");
@@ -87,7 +88,7 @@ async function findHeadBranchPullRequestUrl(options) {
87
88
  candidate.head.sha.toLowerCase() === normalizedHeadSha)));
88
89
  if (branchMatch || !normalizedHeadSha || options.allowShaFallback === false)
89
90
  return branchMatch;
90
- const commitUrl = new URL(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/commits/${normalizedHeadSha}/pulls`);
91
+ const commitUrl = new URL(`${apiBase}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/commits/${normalizedHeadSha}/pulls`);
91
92
  commitUrl.searchParams.set("per_page", "10");
92
93
  return matchingPullRequestUrl(
93
94
  // GitHub answers 422 ("No commit found for SHA") when the local head was
@@ -76,7 +76,7 @@ const PROFILES = {
76
76
  capDrop: ["ALL"],
77
77
  securityOpt: ["no-new-privileges"],
78
78
  network: { mode: "open", allow: [] },
79
- mounts: { allow: ["workspace", "shared-git"], readOnly: [] },
79
+ mounts: { allow: ["workspace", "shared-git", "git-grant"], readOnly: ["git-grant"] },
80
80
  secrets: BASE_SECRETS,
81
81
  },
82
82
  // Tightest posture: non-root, capped, GitHub-only egress that fails closed,
@@ -92,7 +92,7 @@ const PROFILES = {
92
92
  capDrop: ["ALL"],
93
93
  securityOpt: ["no-new-privileges"],
94
94
  network: { mode: "allowlist", allow: exports.GITHUB_EGRESS_ALLOWLIST },
95
- mounts: { allow: ["workspace", "shared-git"], readOnly: ["shared-git"] },
95
+ mounts: { allow: ["workspace", "shared-git", "git-grant"], readOnly: ["shared-git", "git-grant"] },
96
96
  secrets: BASE_SECRETS,
97
97
  },
98
98
  // Verification workloads (build + full test suite) need real resources and
@@ -110,7 +110,7 @@ const PROFILES = {
110
110
  mode: "allowlist",
111
111
  allow: [...exports.GITHUB_EGRESS_ALLOWLIST, ...PACKAGE_REGISTRY_ALLOWLIST],
112
112
  },
113
- mounts: { allow: ["workspace", "shared-git"], readOnly: [] },
113
+ mounts: { allow: ["workspace", "shared-git", "git-grant"], readOnly: ["git-grant"] },
114
114
  secrets: BASE_SECRETS,
115
115
  },
116
116
  };
package/dist/sandbox.cjs CHANGED
@@ -120,6 +120,7 @@ class DockerSandbox {
120
120
  workDir;
121
121
  containerWorkDir;
122
122
  sharedGitDir;
123
+ gitGrantDir;
123
124
  appliedFlags = [];
124
125
  denials = [];
125
126
  constructor(opts) {
@@ -130,6 +131,7 @@ class DockerSandbox {
130
131
  this.image = opts.image;
131
132
  this.containerWorkDir = opts.containerWorkDir ?? null;
132
133
  this.sharedGitDir = opts.sharedGitDir ?? null;
134
+ this.gitGrantDir = opts.gitGrantDir ?? null;
133
135
  this.profile = opts.profile ?? (0, sandbox_profile_cjs_1.resolveSandboxProfile)(sandbox_profile_cjs_1.DEFAULT_SANDBOX_PROFILE_ID);
134
136
  }
135
137
  /**
@@ -166,6 +168,16 @@ class DockerSandbox {
166
168
  allowedRoot: sessionsRoot,
167
169
  },
168
170
  ];
171
+ if (this.gitGrantDir) {
172
+ // Read-only overlay: the runtime parent rewrites the file on the host;
173
+ // nothing in the container can change or delete it.
174
+ requests.push({
175
+ kind: "git-grant",
176
+ hostPath: this.gitGrantDir,
177
+ containerPath: this.gitGrantDir,
178
+ allowedRoot: sessionsRoot,
179
+ });
180
+ }
169
181
  if (this.sharedGitDir) {
170
182
  requests.push({
171
183
  kind: "shared-git",
package/dist/session.cjs CHANGED
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.runSession = runSession;
7
+ exports.gitGrantCredentialHelper = gitGrantCredentialHelper;
7
8
  exports.toEnvMap = toEnvMap;
8
9
  const muse_cjs_1 = require("./adapters/muse.cjs");
9
10
  const node_path_1 = __importDefault(require("node:path"));
@@ -34,10 +35,13 @@ const completion_retry_cjs_1 = require("./completion-retry.cjs");
34
35
  const adapter_capacity_cjs_1 = require("./adapter-capacity.cjs");
35
36
  const agent_auth_cjs_1 = require("./agent-auth.cjs");
36
37
  const agent_model_cjs_1 = require("./agent-model.cjs");
38
+ const github_credential_cjs_1 = require("./github-credential.cjs");
37
39
  const browser_evidence_cjs_1 = require("./browser-evidence.cjs");
40
+ const git_grant_cjs_1 = require("./git-grant.cjs");
38
41
  /** Filename the generated platform MCP config is written under inside the session metadata directory. */
39
42
  const MCP_CONFIG_FILENAME = "mcp-config.json";
40
43
  const GIT_CREDENTIAL_HELPER_FILENAME = "git-credential-navarch.cjs";
44
+ const GIT_GRANT_HELPER_FILENAME = "git-credential-navarch-grant.cjs";
41
45
  const COMPLETION_REMEDIATION_RETRIES = 2;
42
46
  /**
43
47
  * Turns the delivery gate (delivery.cjs) may spend on a session that finished
@@ -158,15 +162,82 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
158
162
  registry.register(api.getToken());
159
163
  let secrets = {};
160
164
  let managedGithubCredential = false;
161
- const secretNames = bundle.secret_manifest.map((s) => s.name);
165
+ // Git grant (CX-3.7). The bundle may carry the project's git proxy mode;
166
+ // otherwise the grant endpoint decides (409 git_proxy_disabled = mode off).
167
+ // The runtime parent fetches and refreshes the grant; the agent never sees
168
+ // the machine token or a GitHub token in these modes.
169
+ const bundleGitMode = bundle.repository?.git_proxy_mode;
170
+ let gitGrantRefresher;
171
+ let gitGrantEnv;
172
+ if (bundleGitMode !== "off") {
173
+ const grantPaths = (0, git_grant_cjs_1.gitGrantPaths)(workDir);
174
+ const refresher = new git_grant_cjs_1.GitGrantRefresher({
175
+ api,
176
+ leaseId,
177
+ grantDir: grantPaths.dir,
178
+ grantFile: grantPaths.file,
179
+ ghConfigDir: (0, worktree_guard_cjs_1.sessionGhConfigDir)(workDir),
180
+ registry,
181
+ log,
182
+ });
183
+ try {
184
+ const issue = await refresher.start();
185
+ const helperPath = node_path_1.default.join(workDir, GIT_GRANT_HELPER_FILENAME);
186
+ await node_fs_1.promises.copyFile(node_path_1.default.resolve(__dirname, "../bin/git-credential-navarch-grant.cjs"), helperPath);
187
+ gitGrantRefresher = refresher;
188
+ gitGrantEnv = {
189
+ proxyHost: issue.proxy_host,
190
+ proxyBaseUrl: issue.proxy_base_url,
191
+ grantFile: grantPaths.file,
192
+ helperPath,
193
+ };
194
+ }
195
+ catch (error) {
196
+ await refresher.stopAndClean();
197
+ if (bundleGitMode !== undefined)
198
+ throw error;
199
+ // Mode unknown: only an explicit 409 git_proxy_disabled (mode off) or a
200
+ // 404 (control plane without the endpoint) selects the legacy path.
201
+ // Any other failure must not fall back, because the legacy path would put
202
+ // the raw token and machine token in the agent env of an optional or
203
+ // required project.
204
+ const noEndpoint = error instanceof api_cjs_1.NavarchApiError && error.status === 404;
205
+ if (!(0, git_grant_cjs_1.isGitProxyDisabled)(error) && !noEndpoint)
206
+ throw error;
207
+ }
208
+ }
209
+ const gitGrantMode = gitGrantEnv !== undefined;
210
+ const githubSecretNames = new Set(["github-pat", bundle.repository?.credential_secret_name ?? "github-pat"]);
211
+ const secretNames = bundle.secret_manifest
212
+ .map((s) => s.name)
213
+ .filter((name) => !(gitGrantMode && githubSecretNames.has(name)));
162
214
  if (secretNames.length > 0) {
163
215
  const issued = await api.issueSecrets({ lease_id: leaseId, secret_names: secretNames });
164
216
  secrets = issued.secrets;
165
217
  managedGithubCredential = issued.github_app_issued === true;
166
218
  registry.registerAll(secrets);
219
+ const credentialDenial = (0, github_credential_cjs_1.githubCredentialDenialSummary)(issued.denied);
220
+ if (credentialDenial) {
221
+ log.error(`session ${leaseId}: ${credentialDenial}`);
222
+ await api.completeLease(leaseId, {
223
+ status: "failed",
224
+ report: credentialDenial,
225
+ failure_summary: credentialDenial,
226
+ failed_before_start: true,
227
+ evidence_urls: [],
228
+ cost: { tokens_in: 0, tokens_out: 0, cost_usd: 0 },
229
+ exit_status: "crashed",
230
+ agent_type: runtime,
231
+ ...executionReport,
232
+ });
233
+ secrets = {};
234
+ await gitGrantRefresher?.stopAndClean();
235
+ await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
236
+ return;
237
+ }
167
238
  }
168
239
  let gitCredentialRefresh;
169
- if (managedGithubCredential && secrets["github-pat"]) {
240
+ if (!gitGrantMode && managedGithubCredential && secrets["github-pat"]) {
170
241
  const helperPath = node_path_1.default.join(workDir, GIT_CREDENTIAL_HELPER_FILENAME);
171
242
  await node_fs_1.promises.copyFile(node_path_1.default.resolve(__dirname, "../bin/git-credential-navarch.cjs"), helperPath);
172
243
  gitCredentialRefresh = {
@@ -176,10 +247,16 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
176
247
  leaseId,
177
248
  };
178
249
  }
179
- const sessionEnv = toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail);
250
+ const sessionEnv = toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail, gitGrantEnv);
180
251
  const ghConfigDir = (0, worktree_guard_cjs_1.sessionGhConfigDir)(workDir);
181
252
  await node_fs_1.promises.mkdir(ghConfigDir, { recursive: true, mode: 0o700 });
182
253
  sessionEnv.GH_CONFIG_DIR = ghConfigDir;
254
+ if (gitGrantMode) {
255
+ // Adapters spread process.env under this map. Blank any GitHub token the
256
+ // runtime process itself holds so the agent cannot inherit it.
257
+ sessionEnv.GITHUB_TOKEN = "";
258
+ sessionEnv.GH_TOKEN = "";
259
+ }
183
260
  const cloneUrl = bundle.repository?.clone_url ??
184
261
  (task.repo ? `https://github.com/${task.repo.replace(/\.git$/, "")}.git` : null);
185
262
  if (!cloneUrl) {
@@ -196,10 +273,11 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
196
273
  ...executionReport,
197
274
  });
198
275
  secrets = {};
276
+ await gitGrantRefresher?.stopAndClean();
199
277
  await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
200
278
  return;
201
279
  }
202
- const githubToken = secrets[bundle.repository?.credential_secret_name ?? "github-pat"];
280
+ const githubToken = gitGrantMode ? undefined : secrets[bundle.repository?.credential_secret_name ?? "github-pat"];
203
281
  // A root task owns one delivery branch, so the pre-push guard and the
204
282
  // completion-time reconciliation keep the agent on the allocated
205
283
  // `navarch/...` branch. PR-continuation tasks are the exception: their agent
@@ -216,7 +294,15 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
216
294
  cloneUrl,
217
295
  githubToken,
218
296
  workingBranch: bundle.repository?.working_branch,
219
- repoLocalGithubToken: secrets["github-pat"],
297
+ // Grant modes never expose a GitHub token, so the Codex repo-local
298
+ // github-pat bootstrap is not installed.
299
+ repoLocalGithubToken: gitGrantMode ? undefined : secrets["github-pat"],
300
+ gitGrant: gitGrantEnv
301
+ ? {
302
+ proxyBaseUrl: gitGrantEnv.proxyBaseUrl,
303
+ credentialHelper: gitGrantCredentialHelper(gitGrantEnv),
304
+ }
305
+ : undefined,
220
306
  // Hosted runs skip checkpoints for verify tasks, which deliver a verdict.
221
307
  restoreHostedCheckpoint: !allowBranchRename && task.task_type !== "verify",
222
308
  });
@@ -340,6 +426,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
340
426
  image: config.dockerImage,
341
427
  containerWorkDir: gitWorktree.worktreePath,
342
428
  sharedGitDir: gitWorktree.repositoryPath,
429
+ gitGrantDir: gitGrantMode ? (0, git_grant_cjs_1.gitGrantPaths)(workDir).dir : undefined,
343
430
  profile: (0, sandbox_profile_cjs_1.resolveSandboxProfile)(config.sandboxProfile, {
344
431
  egressNetwork: config.sandboxEgressNetwork,
345
432
  }),
@@ -832,7 +919,10 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
832
919
  // Keep SHA verification on the exact branch, but do not adopt a
833
920
  // foreign-branch PR as delivery evidence for a root task.
834
921
  allowShaFallback: allowBranchRename,
835
- githubToken,
922
+ githubToken: gitGrantMode ? gitGrantRefresher?.current?.grant : githubToken,
923
+ // Grant modes read through the proxy and never use the host's own gh login.
924
+ apiBaseUrl: gitGrantEnv ? `${gitGrantEnv.proxyBaseUrl.replace(/\/+$/, "")}/api/v3` : undefined,
925
+ hostTokenProvider: gitGrantMode ? async () => undefined : undefined,
836
926
  });
837
927
  if (pullRequestUrl)
838
928
  mapping.evidenceUrls.push(pullRequestUrl);
@@ -1083,6 +1173,9 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
1083
1173
  // request. A later heartbeat response sees sessionClosing and does nothing.
1084
1174
  await Promise.resolve(heartbeatWorkspaceUpdate).catch(() => undefined);
1085
1175
  secrets = {};
1176
+ // Waits for an in-flight refresh, then removes the grant files so none
1177
+ // survives in a retained workspace.
1178
+ await gitGrantRefresher?.stopAndClean();
1086
1179
  // A sandbox teardown failure must not skip worktree cleanup: a leftover
1087
1180
  // task branch in the shared cache blocks the task's next retry.
1088
1181
  try {
@@ -1271,8 +1364,14 @@ function isAlreadyReleasedCompletionError(err) {
1271
1364
  const body = err.body;
1272
1365
  return body.code === "already_released" || body.error === "lease already released";
1273
1366
  }
1367
+ /** The `credential.helper` value that prints the grant from its file. */
1368
+ function gitGrantCredentialHelper(config) {
1369
+ return `!node ${shellQuote(config.helperPath)} ${shellQuote(config.grantFile)}`;
1370
+ }
1371
+ /** Secret names (lowercased, non-alphanumerics as `_`) that are raw GitHub credentials. */
1372
+ const GITHUB_CREDENTIAL_SECRETS = new Set(["github_pat", "github_token", "gh_token"]);
1274
1373
  /** Uppercases + sanitizes secret names into shell-safe env var names for injectEnv(). */
1275
- function toEnvMap(secrets, credentialRefreshOrAuthorName, gitAuthorNameOrEmail = "sagentlab", gitAuthorEmail = "z@sagentlab.com") {
1374
+ function toEnvMap(secrets, credentialRefreshOrAuthorName, gitAuthorNameOrEmail = "sagentlab", gitAuthorEmail = "z@sagentlab.com", gitGrant) {
1276
1375
  // Keep the existing `(secrets, authorName, authorEmail)` call shape while
1277
1376
  // accepting a refresh config before the author identity for managed repos.
1278
1377
  const gitCredentialRefresh = typeof credentialRefreshOrAuthorName === "string"
@@ -1286,8 +1385,31 @@ function toEnvMap(secrets, credentialRefreshOrAuthorName, gitAuthorNameOrEmail =
1286
1385
  : gitAuthorEmail;
1287
1386
  const out = {};
1288
1387
  for (const [name, value] of Object.entries(secrets)) {
1388
+ if (gitGrant && GITHUB_CREDENTIAL_SECRETS.has(name.toLowerCase().replace(/[^a-z0-9]/g, "_")))
1389
+ continue;
1289
1390
  out[name.toUpperCase().replace(/[^A-Z0-9_]/g, "_")] = value;
1290
1391
  }
1392
+ if (gitGrant) {
1393
+ // Grant modes: git and gh authenticate with the grant file. No machine
1394
+ // token, lease id, GitHub token or github-pat value reaches the agent.
1395
+ const proxyBase = gitGrant.proxyBaseUrl.replace(/\/+$/, "");
1396
+ const grantConfig = [
1397
+ ["user.name", gitAuthorName],
1398
+ ["user.email", resolvedGitAuthorEmail],
1399
+ [`url.${proxyBase}/.insteadOf`, "https://github.com/"],
1400
+ [`url.${proxyBase}/.insteadOf`, "git@github.com:"],
1401
+ [`credential.${proxyBase}.helper`, ""],
1402
+ [`credential.${proxyBase}.helper`, gitGrantCredentialHelper(gitGrant)],
1403
+ ];
1404
+ out.GIT_TERMINAL_PROMPT = "0";
1405
+ out.GH_HOST = gitGrant.proxyHost;
1406
+ out.GIT_CONFIG_COUNT = String(grantConfig.length);
1407
+ grantConfig.forEach(([key, value], index) => {
1408
+ out[`GIT_CONFIG_KEY_${index}`] = key;
1409
+ out[`GIT_CONFIG_VALUE_${index}`] = value;
1410
+ });
1411
+ return out;
1412
+ }
1291
1413
  const gitConfig = [
1292
1414
  ["user.name", gitAuthorName],
1293
1415
  ["user.email", resolvedGitAuthorEmail],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.126",
3
+ "version": "0.1.128",
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",