@sagentlab/navarch-runtime 0.1.127 → 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-pr.cjs +3 -2
- package/dist/sandbox-profile.cjs +3 -3
- package/dist/sandbox.cjs +12 -0
- package/dist/session.cjs +110 -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, "\\\\")
|
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"));
|
|
@@ -36,9 +37,11 @@ const agent_auth_cjs_1 = require("./agent-auth.cjs");
|
|
|
36
37
|
const agent_model_cjs_1 = require("./agent-model.cjs");
|
|
37
38
|
const github_credential_cjs_1 = require("./github-credential.cjs");
|
|
38
39
|
const browser_evidence_cjs_1 = require("./browser-evidence.cjs");
|
|
40
|
+
const git_grant_cjs_1 = require("./git-grant.cjs");
|
|
39
41
|
/** Filename the generated platform MCP config is written under inside the session metadata directory. */
|
|
40
42
|
const MCP_CONFIG_FILENAME = "mcp-config.json";
|
|
41
43
|
const GIT_CREDENTIAL_HELPER_FILENAME = "git-credential-navarch.cjs";
|
|
44
|
+
const GIT_GRANT_HELPER_FILENAME = "git-credential-navarch-grant.cjs";
|
|
42
45
|
const COMPLETION_REMEDIATION_RETRIES = 2;
|
|
43
46
|
/**
|
|
44
47
|
* Turns the delivery gate (delivery.cjs) may spend on a session that finished
|
|
@@ -159,7 +162,55 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
159
162
|
registry.register(api.getToken());
|
|
160
163
|
let secrets = {};
|
|
161
164
|
let managedGithubCredential = false;
|
|
162
|
-
|
|
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)));
|
|
163
214
|
if (secretNames.length > 0) {
|
|
164
215
|
const issued = await api.issueSecrets({ lease_id: leaseId, secret_names: secretNames });
|
|
165
216
|
secrets = issued.secrets;
|
|
@@ -180,12 +231,13 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
180
231
|
...executionReport,
|
|
181
232
|
});
|
|
182
233
|
secrets = {};
|
|
234
|
+
await gitGrantRefresher?.stopAndClean();
|
|
183
235
|
await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
|
|
184
236
|
return;
|
|
185
237
|
}
|
|
186
238
|
}
|
|
187
239
|
let gitCredentialRefresh;
|
|
188
|
-
if (managedGithubCredential && secrets["github-pat"]) {
|
|
240
|
+
if (!gitGrantMode && managedGithubCredential && secrets["github-pat"]) {
|
|
189
241
|
const helperPath = node_path_1.default.join(workDir, GIT_CREDENTIAL_HELPER_FILENAME);
|
|
190
242
|
await node_fs_1.promises.copyFile(node_path_1.default.resolve(__dirname, "../bin/git-credential-navarch.cjs"), helperPath);
|
|
191
243
|
gitCredentialRefresh = {
|
|
@@ -195,10 +247,16 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
195
247
|
leaseId,
|
|
196
248
|
};
|
|
197
249
|
}
|
|
198
|
-
const sessionEnv = toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail);
|
|
250
|
+
const sessionEnv = toEnvMap(secrets, gitCredentialRefresh, config.gitAuthorName, config.gitAuthorEmail, gitGrantEnv);
|
|
199
251
|
const ghConfigDir = (0, worktree_guard_cjs_1.sessionGhConfigDir)(workDir);
|
|
200
252
|
await node_fs_1.promises.mkdir(ghConfigDir, { recursive: true, mode: 0o700 });
|
|
201
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
|
+
}
|
|
202
260
|
const cloneUrl = bundle.repository?.clone_url ??
|
|
203
261
|
(task.repo ? `https://github.com/${task.repo.replace(/\.git$/, "")}.git` : null);
|
|
204
262
|
if (!cloneUrl) {
|
|
@@ -215,10 +273,11 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
215
273
|
...executionReport,
|
|
216
274
|
});
|
|
217
275
|
secrets = {};
|
|
276
|
+
await gitGrantRefresher?.stopAndClean();
|
|
218
277
|
await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
|
|
219
278
|
return;
|
|
220
279
|
}
|
|
221
|
-
const githubToken = secrets[bundle.repository?.credential_secret_name ?? "github-pat"];
|
|
280
|
+
const githubToken = gitGrantMode ? undefined : secrets[bundle.repository?.credential_secret_name ?? "github-pat"];
|
|
222
281
|
// A root task owns one delivery branch, so the pre-push guard and the
|
|
223
282
|
// completion-time reconciliation keep the agent on the allocated
|
|
224
283
|
// `navarch/...` branch. PR-continuation tasks are the exception: their agent
|
|
@@ -235,7 +294,15 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
235
294
|
cloneUrl,
|
|
236
295
|
githubToken,
|
|
237
296
|
workingBranch: bundle.repository?.working_branch,
|
|
238
|
-
|
|
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,
|
|
239
306
|
// Hosted runs skip checkpoints for verify tasks, which deliver a verdict.
|
|
240
307
|
restoreHostedCheckpoint: !allowBranchRename && task.task_type !== "verify",
|
|
241
308
|
});
|
|
@@ -359,6 +426,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
359
426
|
image: config.dockerImage,
|
|
360
427
|
containerWorkDir: gitWorktree.worktreePath,
|
|
361
428
|
sharedGitDir: gitWorktree.repositoryPath,
|
|
429
|
+
gitGrantDir: gitGrantMode ? (0, git_grant_cjs_1.gitGrantPaths)(workDir).dir : undefined,
|
|
362
430
|
profile: (0, sandbox_profile_cjs_1.resolveSandboxProfile)(config.sandboxProfile, {
|
|
363
431
|
egressNetwork: config.sandboxEgressNetwork,
|
|
364
432
|
}),
|
|
@@ -851,7 +919,10 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
851
919
|
// Keep SHA verification on the exact branch, but do not adopt a
|
|
852
920
|
// foreign-branch PR as delivery evidence for a root task.
|
|
853
921
|
allowShaFallback: allowBranchRename,
|
|
854
|
-
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,
|
|
855
926
|
});
|
|
856
927
|
if (pullRequestUrl)
|
|
857
928
|
mapping.evidenceUrls.push(pullRequestUrl);
|
|
@@ -1102,6 +1173,9 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
1102
1173
|
// request. A later heartbeat response sees sessionClosing and does nothing.
|
|
1103
1174
|
await Promise.resolve(heartbeatWorkspaceUpdate).catch(() => undefined);
|
|
1104
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();
|
|
1105
1179
|
// A sandbox teardown failure must not skip worktree cleanup: a leftover
|
|
1106
1180
|
// task branch in the shared cache blocks the task's next retry.
|
|
1107
1181
|
try {
|
|
@@ -1290,8 +1364,14 @@ function isAlreadyReleasedCompletionError(err) {
|
|
|
1290
1364
|
const body = err.body;
|
|
1291
1365
|
return body.code === "already_released" || body.error === "lease already released";
|
|
1292
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"]);
|
|
1293
1373
|
/** Uppercases + sanitizes secret names into shell-safe env var names for injectEnv(). */
|
|
1294
|
-
function toEnvMap(secrets, credentialRefreshOrAuthorName, gitAuthorNameOrEmail = "sagentlab", gitAuthorEmail = "z@sagentlab.com") {
|
|
1374
|
+
function toEnvMap(secrets, credentialRefreshOrAuthorName, gitAuthorNameOrEmail = "sagentlab", gitAuthorEmail = "z@sagentlab.com", gitGrant) {
|
|
1295
1375
|
// Keep the existing `(secrets, authorName, authorEmail)` call shape while
|
|
1296
1376
|
// accepting a refresh config before the author identity for managed repos.
|
|
1297
1377
|
const gitCredentialRefresh = typeof credentialRefreshOrAuthorName === "string"
|
|
@@ -1305,8 +1385,31 @@ function toEnvMap(secrets, credentialRefreshOrAuthorName, gitAuthorNameOrEmail =
|
|
|
1305
1385
|
: gitAuthorEmail;
|
|
1306
1386
|
const out = {};
|
|
1307
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;
|
|
1308
1390
|
out[name.toUpperCase().replace(/[^A-Z0-9_]/g, "_")] = value;
|
|
1309
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
|
+
}
|
|
1310
1413
|
const gitConfig = [
|
|
1311
1414
|
["user.name", gitAuthorName],
|
|
1312
1415
|
["user.email", resolvedGitAuthorEmail],
|
package/package.json
CHANGED