@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.
- package/bin/git-credential-navarch-grant.cjs +30 -0
- package/dist/api.cjs +11 -0
- package/dist/config.cjs +6 -2
- package/dist/git-grant.cjs +128 -0
- package/dist/git-worktree.cjs +30 -3
- package/dist/github-credential.cjs +43 -0
- package/dist/github-pr.cjs +3 -2
- package/dist/sandbox-profile.cjs +3 -3
- package/dist/sandbox.cjs +12 -0
- package/dist/session.cjs +129 -7
- package/package.json +1 -1
|
@@ -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"
|
|
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;
|
package/dist/git-worktree.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.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.
|
|
81
|
-
this.
|
|
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
|
+
}
|
package/dist/github-pr.cjs
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
package/dist/sandbox-profile.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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