@sagentlab/navarch-runtime 0.1.32 → 0.1.34
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/README.md +2 -0
- package/dist/adapters/index.cjs +9 -1
- package/dist/adapters/pi.cjs +321 -0
- package/dist/config.cjs +8 -0
- package/dist/redact.cjs +5 -1
- package/dist/sandbox-profile.cjs +223 -0
- package/dist/sandbox.cjs +98 -17
- package/dist/session.cjs +19 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -299,6 +299,8 @@ unchanged across the deployment.
|
|
|
299
299
|
| `NAVARCH_WORKTREE_STALE_AFTER_MS` | `86400000` (24 hours) | Minimum inactivity age before an abandoned session worktree is removed. Active sessions are always protected. |
|
|
300
300
|
| `NAVARCH_GIT_AUTHOR_NAME` / `NAVARCH_GIT_AUTHOR_EMAIL` | `sagentlab` / `z@sagentlab.com` | Git identity forced into session commits so host-level personal config is not inherited; override both for a project-authorized bot. |
|
|
301
301
|
| `NAVARCH_SANDBOX_MODE` | `host` | `host` uses the resources already available to the agent process. Set `docker` explicitly for container isolation. |
|
|
302
|
+
| `NAVARCH_SANDBOX_PROFILE` | `trusted-development` | Named security profile for Docker sessions (`src/sandbox-profile.cts`): `trusted-development` (image-default user, uncapped, open egress), `untrusted-code` (non-root, 2 CPU / 4g / 512 PIDs, deny-by-default egress, read-only shared git), `elevated-verification` (non-root, 4 CPU / 8g / 2048 PIDs, egress limited to GitHub plus package registries). The default is the exact pre-profile flag set. |
|
|
303
|
+
| `NAVARCH_SANDBOX_EGRESS_NETWORK` | _(unset)_ | Docker network that enforces a profile's egress allowlist. Docker cannot filter by domain itself, so an allowlist profile without this fails closed to `--network=none` and records the denial. |
|
|
302
304
|
| `NAVARCH_DOCKER_IMAGE` | `ghcr.io/sagentlab/navarch-sandbox-agent:0.1.0` | Version-pinned per-session image with Node 20, git, GitHub CLI, ripgrep, jq, SSH, and Claude Code 2.1.218. Override with an image tag or digest you control. |
|
|
303
305
|
| `NAVARCH_AGENT` | saved choice, then `claude-code` | Local choice of agent CLI: `claude-code`, `codex`, `gemini`, or `opencode`. Overrides the choice saved by `connect`/`register`; `start --agent` has highest priority. |
|
|
304
306
|
| `NAVARCH_RUNTIMES` | selected `NAVARCH_AGENT` | Comma list of installed/authenticated adapters advertised to dispatch. The control plane chooses among these per project/task. |
|
package/dist/adapters/index.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.runOpenCodeAdapter = exports.openCodeAdapter = exports.runGeminiAdapter = exports.geminiAdapter = exports.runCodexAdapter = exports.codexAdapter = exports.runClaudeCodeAdapter = exports.claudeCodeAdapter = void 0;
|
|
3
|
+
exports.runPiAdapter = exports.piAdapter = exports.runOpenCodeAdapter = exports.openCodeAdapter = exports.runGeminiAdapter = exports.geminiAdapter = exports.runCodexAdapter = exports.codexAdapter = exports.runClaudeCodeAdapter = exports.claudeCodeAdapter = void 0;
|
|
4
4
|
exports.selectAdapter = selectAdapter;
|
|
5
5
|
const claude_cjs_1 = require("./claude.cjs");
|
|
6
6
|
Object.defineProperty(exports, "claudeCodeAdapter", { enumerable: true, get: function () { return claude_cjs_1.claudeCodeAdapter; } });
|
|
@@ -14,6 +14,9 @@ Object.defineProperty(exports, "runGeminiAdapter", { enumerable: true, get: func
|
|
|
14
14
|
const opencode_cjs_1 = require("./opencode.cjs");
|
|
15
15
|
Object.defineProperty(exports, "openCodeAdapter", { enumerable: true, get: function () { return opencode_cjs_1.openCodeAdapter; } });
|
|
16
16
|
Object.defineProperty(exports, "runOpenCodeAdapter", { enumerable: true, get: function () { return opencode_cjs_1.runOpenCodeAdapter; } });
|
|
17
|
+
const pi_cjs_1 = require("./pi.cjs");
|
|
18
|
+
Object.defineProperty(exports, "piAdapter", { enumerable: true, get: function () { return pi_cjs_1.piAdapter; } });
|
|
19
|
+
Object.defineProperty(exports, "runPiAdapter", { enumerable: true, get: function () { return pi_cjs_1.runPiAdapter; } });
|
|
17
20
|
/**
|
|
18
21
|
* Picks the AgentAdapter (adapters/types.cts) session.cts should run a
|
|
19
22
|
* session with, keyed off config.cts's `agentType` (NAVARCH_AGENT). This is
|
|
@@ -31,6 +34,11 @@ function selectAdapter(agentType) {
|
|
|
31
34
|
return opencode_cjs_1.openCodeAdapter;
|
|
32
35
|
case "claude-code":
|
|
33
36
|
return claude_cjs_1.claudeCodeAdapter;
|
|
37
|
+
case "pi":
|
|
38
|
+
// Managed hosted runtime (#885): reachable only on a managed runner
|
|
39
|
+
// executing a `runtime: "pi"` claim — config.cts never produces "pi"
|
|
40
|
+
// from NAVARCH_AGENT, so a BYO machine cannot select it locally.
|
|
41
|
+
return pi_cjs_1.piAdapter;
|
|
34
42
|
default: {
|
|
35
43
|
// Exhaustiveness guard: config.cts only ever produces the values
|
|
36
44
|
// above, but fall back to Claude Code rather than throwing if this
|
|
@@ -0,0 +1,321 @@
|
|
|
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.piAdapter = exports.DEFAULT_HOSTED_PI_MODEL = exports.HOSTED_PI_MODELS = exports.DEEPSEEK_API_KEY_ENV = exports.PI_PROVIDER_NAME = void 0;
|
|
7
|
+
exports.resolveHostedPiModel = resolveHostedPiModel;
|
|
8
|
+
exports.buildPiProviderConfig = buildPiProviderConfig;
|
|
9
|
+
exports.parsePiJsonEvents = parsePiJsonEvents;
|
|
10
|
+
exports.runPiAdapter = runPiAdapter;
|
|
11
|
+
const node_child_process_1 = require("node:child_process");
|
|
12
|
+
const node_fs_1 = require("node:fs");
|
|
13
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
14
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
15
|
+
/**
|
|
16
|
+
* Headless Pi coding-agent adapter (#885) — the process-side twin of the
|
|
17
|
+
* Cloudflare hosted executor (worker/hosted-pi/lifecycle.ts). It invokes
|
|
18
|
+
* `pi --mode json -p <prompt>` against DeepSeek V4 through a generated
|
|
19
|
+
* OpenAI-compatible custom-provider config and normalizes the JSON event
|
|
20
|
+
* stream into the common AdapterResult boundary.
|
|
21
|
+
*
|
|
22
|
+
* Hosted Pi is control-plane provisioned (docs/navarch/runtime-adapter-contract.md
|
|
23
|
+
* — "Hosted Pi is not a fifth AgentAdapter" for BYO workers): config.cts
|
|
24
|
+
* never selects this adapter from NAVARCH_AGENT, and dispatch never returns
|
|
25
|
+
* `runtime: "pi"` to a BYO machine. This module exists so the managed runner
|
|
26
|
+
* has the same normalized process boundary as every other CLI, with one
|
|
27
|
+
* shared parsing/provider-config contract.
|
|
28
|
+
*
|
|
29
|
+
* Secret hygiene: the generated models.json references the DeepSeek key ONLY
|
|
30
|
+
* as a `$DEEPSEEK_API_KEY` environment interpolation (Pi expands it from the
|
|
31
|
+
* child environment at load time). The literal key never appears in argv, in
|
|
32
|
+
* the config file, or in any file that survives cleanup. A missing
|
|
33
|
+
* DEEPSEEK_API_KEY in the session environment is a hard configuration
|
|
34
|
+
* failure — the adapter refuses to launch rather than fall back to an
|
|
35
|
+
* ambient credential.
|
|
36
|
+
*
|
|
37
|
+
* MCP: Pi has no MCP client, so `mcpConfigPath` is a documented unsupported
|
|
38
|
+
* control for this adapter (contract: "document an unsupported control
|
|
39
|
+
* instead of inventing one"); platform MCP tools are unavailable to hosted
|
|
40
|
+
* Pi sessions.
|
|
41
|
+
*/
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Provider config — MIRROR of worker/hosted-pi/provider-config.ts. Both
|
|
44
|
+
// sides must keep the same provider name, env reference, and model ids.
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
exports.PI_PROVIDER_NAME = "deepseek";
|
|
47
|
+
exports.DEEPSEEK_API_KEY_ENV = "DEEPSEEK_API_KEY";
|
|
48
|
+
exports.HOSTED_PI_MODELS = ["deepseek-v4-flash", "deepseek-v4-pro"];
|
|
49
|
+
exports.DEFAULT_HOSTED_PI_MODEL = "deepseek-v4-flash";
|
|
50
|
+
function resolveHostedPiModel(configured) {
|
|
51
|
+
const trimmed = (configured ?? "").trim();
|
|
52
|
+
const bare = trimmed.startsWith(`${exports.PI_PROVIDER_NAME}/`)
|
|
53
|
+
? trimmed.slice(exports.PI_PROVIDER_NAME.length + 1)
|
|
54
|
+
: trimmed;
|
|
55
|
+
return exports.HOSTED_PI_MODELS.includes(bare)
|
|
56
|
+
? bare
|
|
57
|
+
: exports.DEFAULT_HOSTED_PI_MODEL;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Pi custom-provider models.json. Cost figures mirror the OFF-PEAK DeepSeek
|
|
61
|
+
* prices from lib/navarch/hosted-credits.ts and only drive Pi's local cost
|
|
62
|
+
* display; billing authority is the control plane's immutable price
|
|
63
|
+
* snapshot, never this file.
|
|
64
|
+
*/
|
|
65
|
+
function buildPiProviderConfig() {
|
|
66
|
+
const models = [
|
|
67
|
+
{
|
|
68
|
+
id: "deepseek-v4-flash",
|
|
69
|
+
name: "DeepSeek V4 Flash",
|
|
70
|
+
reasoning: true,
|
|
71
|
+
input: ["text"],
|
|
72
|
+
cost: { input: 0.22, output: 0.66, cacheRead: 0.007, cacheWrite: 0.22 },
|
|
73
|
+
contextWindow: 128_000,
|
|
74
|
+
maxTokens: 16_384,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "deepseek-v4-pro",
|
|
78
|
+
name: "DeepSeek V4 Pro",
|
|
79
|
+
reasoning: true,
|
|
80
|
+
input: ["text"],
|
|
81
|
+
cost: { input: 0.66, output: 1.98, cacheRead: 0.022, cacheWrite: 0.66 },
|
|
82
|
+
contextWindow: 128_000,
|
|
83
|
+
maxTokens: 16_384,
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
return `${JSON.stringify({
|
|
87
|
+
providers: {
|
|
88
|
+
[exports.PI_PROVIDER_NAME]: {
|
|
89
|
+
baseUrl: "https://api.deepseek.com",
|
|
90
|
+
api: "openai-completions",
|
|
91
|
+
apiKey: `$${exports.DEEPSEEK_API_KEY_ENV}`,
|
|
92
|
+
models,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
}, null, 2)}\n`;
|
|
96
|
+
}
|
|
97
|
+
function tokenCount(value) {
|
|
98
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
99
|
+
? Math.floor(value)
|
|
100
|
+
: undefined;
|
|
101
|
+
}
|
|
102
|
+
function usdAmount(value) {
|
|
103
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
104
|
+
}
|
|
105
|
+
function extractText(content) {
|
|
106
|
+
if (typeof content === "string")
|
|
107
|
+
return content;
|
|
108
|
+
if (!Array.isArray(content))
|
|
109
|
+
return "";
|
|
110
|
+
return content
|
|
111
|
+
.map((block) => {
|
|
112
|
+
if (typeof block === "string")
|
|
113
|
+
return block;
|
|
114
|
+
if (block &&
|
|
115
|
+
typeof block === "object" &&
|
|
116
|
+
block.type === "text" &&
|
|
117
|
+
typeof block.text === "string") {
|
|
118
|
+
return block.text;
|
|
119
|
+
}
|
|
120
|
+
return "";
|
|
121
|
+
})
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
.join("\n");
|
|
124
|
+
}
|
|
125
|
+
function parsePiJsonEvents(stdout) {
|
|
126
|
+
const events = [];
|
|
127
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
128
|
+
const trimmed = line.trim();
|
|
129
|
+
if (!trimmed.startsWith("{"))
|
|
130
|
+
continue;
|
|
131
|
+
try {
|
|
132
|
+
const parsed = JSON.parse(trimmed);
|
|
133
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
134
|
+
events.push(parsed);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// Unknown lines remain available in the raw transcript.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return events;
|
|
142
|
+
}
|
|
143
|
+
function attachPiOutput(result) {
|
|
144
|
+
const events = parsePiJsonEvents(result.stdout);
|
|
145
|
+
if (events.length === 0)
|
|
146
|
+
return result;
|
|
147
|
+
let cacheHit = 0;
|
|
148
|
+
let cacheMiss = 0;
|
|
149
|
+
let output = 0;
|
|
150
|
+
let costUsd;
|
|
151
|
+
let sawUsage = false;
|
|
152
|
+
let reportText = "";
|
|
153
|
+
for (const event of events) {
|
|
154
|
+
const message = event.message;
|
|
155
|
+
if (event.type !== "message_end" || !message || message.role !== "assistant")
|
|
156
|
+
continue;
|
|
157
|
+
const text = extractText(message.content);
|
|
158
|
+
if (text.trim())
|
|
159
|
+
reportText = text.trim();
|
|
160
|
+
const usage = message.usage;
|
|
161
|
+
if (usage && typeof usage === "object") {
|
|
162
|
+
const input = tokenCount(usage.input);
|
|
163
|
+
const read = tokenCount(usage.cacheRead);
|
|
164
|
+
const write = tokenCount(usage.cacheWrite);
|
|
165
|
+
const out = tokenCount(usage.output);
|
|
166
|
+
if (input !== undefined || read !== undefined || write !== undefined || out !== undefined) {
|
|
167
|
+
sawUsage = true;
|
|
168
|
+
cacheHit += read ?? 0;
|
|
169
|
+
// DeepSeek bills cache writes at the miss rate; cache-miss input is
|
|
170
|
+
// regular input plus cache writes (docs/navarch/hosted-prepaid-credits.md).
|
|
171
|
+
cacheMiss += (input ?? 0) + (write ?? 0);
|
|
172
|
+
output += out ?? 0;
|
|
173
|
+
const total = usage.cost ? usdAmount(usage.cost.total) : undefined;
|
|
174
|
+
if (total !== undefined)
|
|
175
|
+
costUsd = (costUsd ?? 0) + total;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
...result,
|
|
181
|
+
...(sawUsage
|
|
182
|
+
? {
|
|
183
|
+
// AdapterResult semantics: tokensIn folds every input category
|
|
184
|
+
// together and cacheHitTokensIn is its cache-read component.
|
|
185
|
+
tokensIn: cacheHit + cacheMiss,
|
|
186
|
+
tokensOut: output,
|
|
187
|
+
cacheHitTokensIn: cacheHit,
|
|
188
|
+
}
|
|
189
|
+
: {}),
|
|
190
|
+
...(costUsd !== undefined ? { costUsd } : {}),
|
|
191
|
+
...(reportText ? { reportText } : {}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Invocation
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
const CONTAINER_PI_DIR = "/tmp/navarch-pi-agent";
|
|
198
|
+
function hasArg(args, ...flags) {
|
|
199
|
+
return args.some((arg) => flags.some((flag) => arg === flag || arg.startsWith(`${flag}=`)));
|
|
200
|
+
}
|
|
201
|
+
async function runPiAdapter(options) {
|
|
202
|
+
if (!options.env[exports.DEEPSEEK_API_KEY_ENV]) {
|
|
203
|
+
// Fail closed: launching without the referenced managed credential would
|
|
204
|
+
// let Pi fall back to ambient/operator auth outside the session boundary.
|
|
205
|
+
throw new Error(`pi adapter: ${exports.DEEPSEEK_API_KEY_ENV} is missing from the session environment; ` +
|
|
206
|
+
"the managed provider credential must be injected by the hosted runner");
|
|
207
|
+
}
|
|
208
|
+
const args = ["--mode", "json"];
|
|
209
|
+
if (!hasArg(options.extraArgs, "--no-session")) {
|
|
210
|
+
// Ephemeral by default: session persistence would leave conversation
|
|
211
|
+
// files (which may quote repository content) behind after cleanup.
|
|
212
|
+
args.push("--no-session");
|
|
213
|
+
}
|
|
214
|
+
args.push(...options.extraArgs);
|
|
215
|
+
const model = resolveHostedPiModel(options.model);
|
|
216
|
+
args.push("--model", `${exports.PI_PROVIDER_NAME}/${model}`);
|
|
217
|
+
if (options.reasoningEffort) {
|
|
218
|
+
// Pi's --thinking accepts off|minimal|low|medium|high|xhigh|max; the
|
|
219
|
+
// resolved efforts are a subset, so they map directly.
|
|
220
|
+
args.push("--thinking", options.reasoningEffort);
|
|
221
|
+
}
|
|
222
|
+
args.push("-p", options.prompt);
|
|
223
|
+
const raw = options.dockerExec
|
|
224
|
+
? await runViaDocker(options, args)
|
|
225
|
+
: await runOnHost(options, args);
|
|
226
|
+
return attachPiOutput(raw);
|
|
227
|
+
}
|
|
228
|
+
async function runOnHost(options, args) {
|
|
229
|
+
// Isolated agent dir so the generated provider config never touches (or
|
|
230
|
+
// reads) an operator's ~/.pi configuration/credentials.
|
|
231
|
+
const agentDir = await node_fs_1.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), "navarch-pi-agent-"));
|
|
232
|
+
await node_fs_1.promises.writeFile(node_path_1.default.join(agentDir, "models.json"), buildPiProviderConfig(), { mode: 0o600 });
|
|
233
|
+
const env = { ...options.env, PI_CODING_AGENT_DIR: agentDir };
|
|
234
|
+
try {
|
|
235
|
+
return await new Promise((resolve) => {
|
|
236
|
+
let stdout = "";
|
|
237
|
+
let stderr = "";
|
|
238
|
+
let timedOut = false;
|
|
239
|
+
let killedByLeaseLoss = false;
|
|
240
|
+
const child = (0, node_child_process_1.spawn)(options.bin, args, {
|
|
241
|
+
cwd: options.cwd,
|
|
242
|
+
env: { ...process.env, ...env },
|
|
243
|
+
});
|
|
244
|
+
child.stdin?.end();
|
|
245
|
+
const timer = setTimeout(() => {
|
|
246
|
+
timedOut = true;
|
|
247
|
+
child.kill("SIGKILL");
|
|
248
|
+
}, options.timeoutMs);
|
|
249
|
+
const onAbort = () => {
|
|
250
|
+
killedByLeaseLoss = true;
|
|
251
|
+
child.kill("SIGKILL");
|
|
252
|
+
};
|
|
253
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
254
|
+
child.stdout.on("data", (data) => {
|
|
255
|
+
stdout += data.toString();
|
|
256
|
+
});
|
|
257
|
+
child.stderr.on("data", (data) => {
|
|
258
|
+
stderr += data.toString();
|
|
259
|
+
});
|
|
260
|
+
child.on("error", (err) => {
|
|
261
|
+
clearTimeout(timer);
|
|
262
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
263
|
+
stderr += `\n${String(err)}`;
|
|
264
|
+
resolve({ exitCode: null, timedOut, killedByLeaseLoss, stdout, stderr });
|
|
265
|
+
});
|
|
266
|
+
child.on("close", (code) => {
|
|
267
|
+
clearTimeout(timer);
|
|
268
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
269
|
+
resolve({ exitCode: code, timedOut, killedByLeaseLoss, stdout, stderr });
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
await node_fs_1.promises.rm(agentDir, { recursive: true, force: true }).catch(() => undefined);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async function runViaDocker(options, args) {
|
|
278
|
+
const { containerName, runner } = options.dockerExec;
|
|
279
|
+
// The provider config carries no secret values (only the $DEEPSEEK_API_KEY
|
|
280
|
+
// env reference), so materializing it through the container shell is safe.
|
|
281
|
+
const command = `[ -f /tmp/session.env ] && . /tmp/session.env; cd repo 2>/dev/null; ` +
|
|
282
|
+
`mkdir -p ${CONTAINER_PI_DIR} && cat > ${CONTAINER_PI_DIR}/models.json <<'NAVARCH_PI_EOF'\n` +
|
|
283
|
+
`${buildPiProviderConfig()}NAVARCH_PI_EOF\n` +
|
|
284
|
+
`PI_CODING_AGENT_DIR=${CONTAINER_PI_DIR} ${[options.bin, ...args].map(shellQuote).join(" ")}`;
|
|
285
|
+
let killedByLeaseLoss = false;
|
|
286
|
+
const onAbort = () => {
|
|
287
|
+
killedByLeaseLoss = true;
|
|
288
|
+
runner.run("docker", ["kill", containerName]).catch(() => undefined);
|
|
289
|
+
};
|
|
290
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
291
|
+
try {
|
|
292
|
+
const forwardedEnv = Object.keys(options.env).flatMap((name) => ["--env", name]);
|
|
293
|
+
const result = await runner.run("docker", ["exec", ...forwardedEnv, containerName, "sh", "-c", command], { timeoutMs: options.timeoutMs, env: { ...process.env, ...options.env } });
|
|
294
|
+
return {
|
|
295
|
+
exitCode: result.code,
|
|
296
|
+
timedOut: false,
|
|
297
|
+
killedByLeaseLoss,
|
|
298
|
+
stdout: result.stdout,
|
|
299
|
+
stderr: result.stderr,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
return {
|
|
304
|
+
exitCode: null,
|
|
305
|
+
timedOut: false,
|
|
306
|
+
killedByLeaseLoss,
|
|
307
|
+
stdout: "",
|
|
308
|
+
stderr: String(err),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function shellQuote(value) {
|
|
316
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
317
|
+
}
|
|
318
|
+
exports.piAdapter = {
|
|
319
|
+
agentType: "pi",
|
|
320
|
+
run: runPiAdapter,
|
|
321
|
+
};
|
package/dist/config.cjs
CHANGED
|
@@ -8,6 +8,7 @@ exports.isRuntimeAgentType = isRuntimeAgentType;
|
|
|
8
8
|
exports.loadRuntimeConfig = loadRuntimeConfig;
|
|
9
9
|
const node_path_1 = __importDefault(require("node:path"));
|
|
10
10
|
const node_os_1 = __importDefault(require("node:os"));
|
|
11
|
+
const sandbox_profile_cjs_1 = require("./sandbox-profile.cjs");
|
|
11
12
|
/** Published image containing git, GitHub CLI, and the pinned Claude Code CLI. */
|
|
12
13
|
exports.DEFAULT_SANDBOX_IMAGE = "ghcr.io/sagentlab/navarch-sandbox-agent:0.1.0";
|
|
13
14
|
function isRuntimeAgentType(value) {
|
|
@@ -86,6 +87,13 @@ function loadRuntimeConfig(env = process.env) {
|
|
|
86
87
|
gitAuthorEmail: env.NAVARCH_GIT_AUTHOR_EMAIL ?? "z@sagentlab.com",
|
|
87
88
|
mcpConfigPath: env.NAVARCH_MCP_CONFIG_PATH ?? null,
|
|
88
89
|
sandboxMode,
|
|
90
|
+
// An unrecognized profile name falls back to the default rather than
|
|
91
|
+
// failing startup: a typo must not silently drop a machine out of the
|
|
92
|
+
// dispatch pool, and the default is the posture sessions already had.
|
|
93
|
+
sandboxProfile: (0, sandbox_profile_cjs_1.isSandboxProfileId)(env.NAVARCH_SANDBOX_PROFILE)
|
|
94
|
+
? env.NAVARCH_SANDBOX_PROFILE
|
|
95
|
+
: sandbox_profile_cjs_1.DEFAULT_SANDBOX_PROFILE_ID,
|
|
96
|
+
sandboxEgressNetwork: env.NAVARCH_SANDBOX_EGRESS_NETWORK?.trim() || null,
|
|
89
97
|
dockerImage: env.NAVARCH_DOCKER_IMAGE ?? exports.DEFAULT_SANDBOX_IMAGE,
|
|
90
98
|
// Multiple sessions share one machine; keeping each agent inside its own
|
|
91
99
|
// worktree is the safe default, so disabling is the explicit opt-out.
|
package/dist/redact.cjs
CHANGED
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
8
|
exports.SecretRegistry = void 0;
|
|
9
9
|
exports.redactText = redactText;
|
|
10
|
+
// Keep in sync with lib/navarch/redaction.ts's SECRET_SHAPE_PATTERNS — the
|
|
11
|
+
// hosted Pi executor (worker/hosted-pi) shares these shapes for its own
|
|
12
|
+
// transcript/progress scrubbing.
|
|
10
13
|
const SECRET_PATTERNS = [
|
|
11
14
|
/gh[pousr]_[A-Za-z0-9]{20,}/g, // GitHub PAT / OAuth / user-to-server / refresh tokens
|
|
12
15
|
/github_pat_[A-Za-z0-9_]{20,}/g, // GitHub fine-grained PAT
|
|
13
|
-
/sk-[A-Za-z0-9-]{20,}/g, //
|
|
16
|
+
/sk-[A-Za-z0-9-]{20,}/g, // OpenAI-compatible secret keys, incl. DeepSeek `sk-…` (hosted Pi provider)
|
|
17
|
+
/\/\/[^/\s:@]+:[^/\s@]+@/g, // basic-auth userinfo in URLs (e.g. x-access-token:…@github.com clones)
|
|
14
18
|
/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z0-9 ]*PRIVATE KEY-----/g,
|
|
15
19
|
];
|
|
16
20
|
const REDACTED = "[REDACTED]";
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Named sandbox security profiles (issue #660).
|
|
4
|
+
*
|
|
5
|
+
* A profile is the *declared* security posture of one session: which user the
|
|
6
|
+
* agent runs as, what resources it may consume, which host paths it may see,
|
|
7
|
+
* and where it may reach on the network. `dockerRunFlags()` /
|
|
8
|
+
* `resolveMounts()` / `resolveNetwork()` turn that declaration into concrete
|
|
9
|
+
* `docker run` arguments, so the policy is written once and audited in one
|
|
10
|
+
* place rather than being spread across ad-hoc flags in sandbox.cts.
|
|
11
|
+
*
|
|
12
|
+
* NOTE ON NAMING: `SandboxPolicyDenial` here is a *security policy* denial (a
|
|
13
|
+
* mount outside the allowlist, an unenforceable egress allowlist). It is
|
|
14
|
+
* deliberately NOT the same concept as `SandboxDenialReason` in
|
|
15
|
+
* lib/navarch/sandbox.ts, which is free-tier billing/eligibility. Do not
|
|
16
|
+
* conflate the two.
|
|
17
|
+
*
|
|
18
|
+
* DEFAULT: `trusted-development` reproduces the pre-#660 Docker flags exactly
|
|
19
|
+
* — root user, no resource caps, Docker's default bridge network — so
|
|
20
|
+
* adopting profiles is a pure refactor for every existing session. The
|
|
21
|
+
* hardened postures are opt-in via NAVARCH_SANDBOX_PROFILE until the default
|
|
22
|
+
* flip is validated on real workloads.
|
|
23
|
+
*/
|
|
24
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
25
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
26
|
+
};
|
|
27
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
+
exports.DEFAULT_SANDBOX_PROFILE_ID = exports.GITHUB_EGRESS_ALLOWLIST = exports.SANDBOX_PROFILE_IDS = void 0;
|
|
29
|
+
exports.isSandboxProfileId = isSandboxProfileId;
|
|
30
|
+
exports.resolveSandboxProfile = resolveSandboxProfile;
|
|
31
|
+
exports.resolveMounts = resolveMounts;
|
|
32
|
+
exports.resolveNetwork = resolveNetwork;
|
|
33
|
+
exports.dockerRunFlags = dockerRunFlags;
|
|
34
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
35
|
+
exports.SANDBOX_PROFILE_IDS = [
|
|
36
|
+
"trusted-development",
|
|
37
|
+
"untrusted-code",
|
|
38
|
+
"elevated-verification",
|
|
39
|
+
];
|
|
40
|
+
function isSandboxProfileId(value) {
|
|
41
|
+
return exports.SANDBOX_PROFILE_IDS.includes(value ?? "");
|
|
42
|
+
}
|
|
43
|
+
/** tmpfs mounts every profile shares: injected secrets must never hit a disk. */
|
|
44
|
+
const SECRET_TMPFS = ["/tmp", "/run"];
|
|
45
|
+
const BASE_SECRETS = {
|
|
46
|
+
allowEnvFlagInjection: false,
|
|
47
|
+
tmpfs: SECRET_TMPFS,
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* The GitHub egress worktree-guard.cts already grants host-mode Codex
|
|
51
|
+
* sessions (`**.github.com` / `**.githubusercontent.com`). Container profiles
|
|
52
|
+
* reuse the same set so a session cannot reach more from inside the sandbox
|
|
53
|
+
* than the host-mode guard would have allowed.
|
|
54
|
+
*/
|
|
55
|
+
exports.GITHUB_EGRESS_ALLOWLIST = [
|
|
56
|
+
"**.github.com",
|
|
57
|
+
"**.githubusercontent.com",
|
|
58
|
+
];
|
|
59
|
+
/** Registries an elevated verification run needs to install dependencies from. */
|
|
60
|
+
const PACKAGE_REGISTRY_ALLOWLIST = [
|
|
61
|
+
"registry.npmjs.org",
|
|
62
|
+
"**.pypi.org",
|
|
63
|
+
"**.crates.io",
|
|
64
|
+
];
|
|
65
|
+
const PROFILES = {
|
|
66
|
+
// Byte-for-byte the pre-#660 flag set. Changing anything here changes the
|
|
67
|
+
// behavior of every existing dispatch — see sandbox.test.cts, which pins
|
|
68
|
+
// the resulting argv exactly.
|
|
69
|
+
"trusted-development": {
|
|
70
|
+
id: "trusted-development",
|
|
71
|
+
description: "First-party repositories run by the operator's own agents. Image-default user, no resource caps, unrestricted egress — the pre-#660 Docker posture.",
|
|
72
|
+
user: null,
|
|
73
|
+
memory: null,
|
|
74
|
+
cpus: null,
|
|
75
|
+
pidsLimit: null,
|
|
76
|
+
capDrop: ["ALL"],
|
|
77
|
+
securityOpt: ["no-new-privileges"],
|
|
78
|
+
network: { mode: "open", allow: [] },
|
|
79
|
+
mounts: { allow: ["workspace", "shared-git"], readOnly: [] },
|
|
80
|
+
secrets: BASE_SECRETS,
|
|
81
|
+
},
|
|
82
|
+
// Tightest posture: non-root, capped, GitHub-only egress that fails closed,
|
|
83
|
+
// and the shared bare repo mounted read-only so a hostile checkout cannot
|
|
84
|
+
// rewrite objects other sessions' worktrees depend on.
|
|
85
|
+
"untrusted-code": {
|
|
86
|
+
id: "untrusted-code",
|
|
87
|
+
description: "Third-party or unreviewed code. Non-root, hard CPU/memory/PID caps, deny-by-default egress with a GitHub allowlist, read-only shared git.",
|
|
88
|
+
user: "1000:1000",
|
|
89
|
+
memory: "4g",
|
|
90
|
+
cpus: "2",
|
|
91
|
+
pidsLimit: 512,
|
|
92
|
+
capDrop: ["ALL"],
|
|
93
|
+
securityOpt: ["no-new-privileges"],
|
|
94
|
+
network: { mode: "allowlist", allow: exports.GITHUB_EGRESS_ALLOWLIST },
|
|
95
|
+
mounts: { allow: ["workspace", "shared-git"], readOnly: ["shared-git"] },
|
|
96
|
+
secrets: BASE_SECRETS,
|
|
97
|
+
},
|
|
98
|
+
// Verification workloads (build + full test suite) need real resources and
|
|
99
|
+
// package registries, but stay non-root and stay off the open internet.
|
|
100
|
+
"elevated-verification": {
|
|
101
|
+
id: "elevated-verification",
|
|
102
|
+
description: "Build/test verification of reviewed code. Non-root with raised CPU/memory/PID caps and egress limited to GitHub plus package registries.",
|
|
103
|
+
user: "1000:1000",
|
|
104
|
+
memory: "8g",
|
|
105
|
+
cpus: "4",
|
|
106
|
+
pidsLimit: 2048,
|
|
107
|
+
capDrop: ["ALL"],
|
|
108
|
+
securityOpt: ["no-new-privileges"],
|
|
109
|
+
network: {
|
|
110
|
+
mode: "allowlist",
|
|
111
|
+
allow: [...exports.GITHUB_EGRESS_ALLOWLIST, ...PACKAGE_REGISTRY_ALLOWLIST],
|
|
112
|
+
},
|
|
113
|
+
mounts: { allow: ["workspace", "shared-git"], readOnly: [] },
|
|
114
|
+
secrets: BASE_SECRETS,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
exports.DEFAULT_SANDBOX_PROFILE_ID = "trusted-development";
|
|
118
|
+
/**
|
|
119
|
+
* The profile for `id`, with the operator's egress network (if any) bound in.
|
|
120
|
+
* Returns a fresh object each call so callers cannot mutate the shared table.
|
|
121
|
+
*/
|
|
122
|
+
function resolveSandboxProfile(id = exports.DEFAULT_SANDBOX_PROFILE_ID, options = {}) {
|
|
123
|
+
const base = PROFILES[id];
|
|
124
|
+
return {
|
|
125
|
+
...base,
|
|
126
|
+
network: { ...base.network, egressNetwork: options.egressNetwork ?? null },
|
|
127
|
+
mounts: { ...base.mounts },
|
|
128
|
+
secrets: { ...base.secrets },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* `-v` arguments for the requests this profile permits, plus a denial for
|
|
133
|
+
* each one it refuses. Denied mounts are dropped, not fatal: the session
|
|
134
|
+
* still starts with the mounts it is entitled to, and the refusal is recorded.
|
|
135
|
+
*/
|
|
136
|
+
function resolveMounts(profile, requests) {
|
|
137
|
+
const args = [];
|
|
138
|
+
const denials = [];
|
|
139
|
+
for (const request of requests) {
|
|
140
|
+
if (!profile.mounts.allow.includes(request.kind)) {
|
|
141
|
+
denials.push({
|
|
142
|
+
policy: "mount",
|
|
143
|
+
code: "mount_kind_not_allowed",
|
|
144
|
+
detail: `profile ${profile.id} does not allow "${request.kind}" mounts (${request.hostPath})`,
|
|
145
|
+
});
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (!isInside(request.hostPath, request.allowedRoot)) {
|
|
149
|
+
denials.push({
|
|
150
|
+
policy: "mount",
|
|
151
|
+
code: "mount_outside_allowed_root",
|
|
152
|
+
detail: `${request.hostPath} is outside the allowed root ${request.allowedRoot} for "${request.kind}" mounts`,
|
|
153
|
+
});
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const suffix = profile.mounts.readOnly.includes(request.kind) ? ":ro" : "";
|
|
157
|
+
args.push("-v", `${request.hostPath}:${request.containerPath}${suffix}`);
|
|
158
|
+
}
|
|
159
|
+
return { args, denials };
|
|
160
|
+
}
|
|
161
|
+
/** Same containment test worktree-guard.cts uses for its allowed roots. */
|
|
162
|
+
function isInside(candidate, root) {
|
|
163
|
+
const relative = node_path_1.default.relative(node_path_1.default.resolve(root), node_path_1.default.resolve(candidate));
|
|
164
|
+
return relative === "" || (!relative.startsWith("..") && !node_path_1.default.isAbsolute(relative));
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* `--network` argument for this policy. An `allowlist` policy with no
|
|
168
|
+
* enforcing network degrades to `--network=none` (deny-by-default) and
|
|
169
|
+
* records the denial — it never degrades to open egress.
|
|
170
|
+
*/
|
|
171
|
+
function resolveNetwork(policy) {
|
|
172
|
+
switch (policy.mode) {
|
|
173
|
+
case "open":
|
|
174
|
+
// No flag at all: Docker's default bridge, identical to pre-#660.
|
|
175
|
+
return { args: [], denials: [] };
|
|
176
|
+
case "isolated":
|
|
177
|
+
return { args: ["--network=none"], denials: [] };
|
|
178
|
+
case "allowlist": {
|
|
179
|
+
const network = policy.egressNetwork?.trim();
|
|
180
|
+
if (network)
|
|
181
|
+
return { args: ["--network", network], denials: [] };
|
|
182
|
+
return {
|
|
183
|
+
args: ["--network=none"],
|
|
184
|
+
denials: [
|
|
185
|
+
{
|
|
186
|
+
policy: "network",
|
|
187
|
+
code: "egress_allowlist_unenforceable",
|
|
188
|
+
detail: `no egress network configured (NAVARCH_SANDBOX_EGRESS_NETWORK); denying all egress instead of allowing ${policy.allow.join(", ")}`,
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Hardening + resource + network flags for `docker run`, in a fixed order.
|
|
197
|
+
* Mounts are resolved separately (they need per-session paths) and appended
|
|
198
|
+
* by the backend.
|
|
199
|
+
*
|
|
200
|
+
* Order matters only for the equivalence test that pins the default profile's
|
|
201
|
+
* argv against the pre-#660 command line; Docker itself is order-insensitive
|
|
202
|
+
* among these.
|
|
203
|
+
*/
|
|
204
|
+
function dockerRunFlags(profile) {
|
|
205
|
+
const args = [];
|
|
206
|
+
for (const cap of profile.capDrop)
|
|
207
|
+
args.push(`--cap-drop=${cap}`);
|
|
208
|
+
for (const opt of profile.securityOpt)
|
|
209
|
+
args.push(`--security-opt=${opt}`);
|
|
210
|
+
for (const mount of profile.secrets.tmpfs)
|
|
211
|
+
args.push("--tmpfs", mount);
|
|
212
|
+
if (profile.user)
|
|
213
|
+
args.push("--user", profile.user);
|
|
214
|
+
if (profile.memory)
|
|
215
|
+
args.push("--memory", profile.memory);
|
|
216
|
+
if (profile.cpus)
|
|
217
|
+
args.push("--cpus", profile.cpus);
|
|
218
|
+
if (profile.pidsLimit !== null)
|
|
219
|
+
args.push("--pids-limit", String(profile.pidsLimit));
|
|
220
|
+
const network = resolveNetwork(profile.network);
|
|
221
|
+
args.push(...network.args);
|
|
222
|
+
return { args, denials: network.denials };
|
|
223
|
+
}
|
package/dist/sandbox.cjs
CHANGED
|
@@ -8,6 +8,7 @@ exports.isDockerAvailable = isDockerAvailable;
|
|
|
8
8
|
const node_child_process_1 = require("node:child_process");
|
|
9
9
|
const node_fs_1 = require("node:fs");
|
|
10
10
|
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const sandbox_profile_cjs_1 = require("./sandbox-profile.cjs");
|
|
11
12
|
class SandboxUnavailableError extends Error {
|
|
12
13
|
}
|
|
13
14
|
exports.SandboxUnavailableError = SandboxUnavailableError;
|
|
@@ -85,45 +86,92 @@ function containerName(sessionId) {
|
|
|
85
86
|
* helper, so the literal secret value never appears in any argv the host's
|
|
86
87
|
* `ps` can see and is never written to the container's persistent layer
|
|
87
88
|
* (tmpfs only) — it disappears with the container on wipe().
|
|
89
|
+
*
|
|
90
|
+
* Since #660 every one of those flags comes from a named security profile
|
|
91
|
+
* (sandbox-profile.cts) rather than being hard-coded here, and this class is
|
|
92
|
+
* the `SandboxBackend` implementation for Docker rather than the only sandbox
|
|
93
|
+
* there can be. The default profile (`trusted-development`) emits exactly the
|
|
94
|
+
* flag set above, so nothing about an existing session changed.
|
|
88
95
|
*/
|
|
89
96
|
class DockerSandbox {
|
|
97
|
+
id = "docker";
|
|
90
98
|
name;
|
|
99
|
+
image;
|
|
100
|
+
profile;
|
|
91
101
|
runner;
|
|
102
|
+
workspaceRoot;
|
|
92
103
|
workDir;
|
|
93
|
-
image;
|
|
94
104
|
containerWorkDir;
|
|
95
105
|
sharedGitDir;
|
|
106
|
+
appliedFlags = [];
|
|
107
|
+
denials = [];
|
|
96
108
|
constructor(opts) {
|
|
97
109
|
this.name = containerName(opts.sessionId);
|
|
98
110
|
this.runner = opts.runner ?? exports.nodeCommandRunner;
|
|
111
|
+
this.workspaceRoot = opts.workspaceRoot;
|
|
99
112
|
this.workDir = node_path_1.default.join(opts.workspaceRoot, opts.sessionId);
|
|
100
113
|
this.image = opts.image;
|
|
101
114
|
this.containerWorkDir = opts.containerWorkDir ?? null;
|
|
102
115
|
this.sharedGitDir = opts.sharedGitDir ?? null;
|
|
116
|
+
this.profile = opts.profile ?? (0, sandbox_profile_cjs_1.resolveSandboxProfile)(sandbox_profile_cjs_1.DEFAULT_SANDBOX_PROFILE_ID);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Host paths this session wants mounted, each pinned to the root it must
|
|
120
|
+
* stay under so resolveMounts() can refuse anything else.
|
|
121
|
+
*
|
|
122
|
+
* - workspace ⊂ the sessions root this sandbox was constructed with, so a
|
|
123
|
+
* crafted session id containing `..` cannot mount a sibling session or
|
|
124
|
+
* escape the workspace entirely.
|
|
125
|
+
* - shared-git ⊂ the Navarch workspace root (the sessions root's parent).
|
|
126
|
+
* session.cts passes `<workspaceRoot>/sessions` here and GitWorktree puts
|
|
127
|
+
* the bare repo cache at the sibling `<workspaceRoot>/repositories/...`
|
|
128
|
+
* (or inside the session root for repo-local-token sessions), so both
|
|
129
|
+
* legitimate locations are covered while `~/.ssh` or `/` are not.
|
|
130
|
+
*/
|
|
131
|
+
mountRequests() {
|
|
132
|
+
const sessionsRoot = node_path_1.default.resolve(this.workspaceRoot);
|
|
133
|
+
const navarchRoot = node_path_1.default.dirname(sessionsRoot);
|
|
134
|
+
if (!this.containerWorkDir) {
|
|
135
|
+
return [
|
|
136
|
+
{
|
|
137
|
+
kind: "workspace",
|
|
138
|
+
hostPath: this.workDir,
|
|
139
|
+
containerPath: "/workspace",
|
|
140
|
+
allowedRoot: sessionsRoot,
|
|
141
|
+
},
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
const requests = [
|
|
145
|
+
{
|
|
146
|
+
kind: "workspace",
|
|
147
|
+
hostPath: this.workDir,
|
|
148
|
+
containerPath: this.workDir,
|
|
149
|
+
allowedRoot: sessionsRoot,
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
if (this.sharedGitDir) {
|
|
153
|
+
requests.push({
|
|
154
|
+
kind: "shared-git",
|
|
155
|
+
hostPath: this.sharedGitDir,
|
|
156
|
+
containerPath: this.sharedGitDir,
|
|
157
|
+
allowedRoot: navarchRoot,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return requests;
|
|
103
161
|
}
|
|
104
162
|
async create() {
|
|
105
163
|
await node_fs_1.promises.mkdir(this.workDir, { recursive: true });
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
"-w",
|
|
112
|
-
this.containerWorkDir,
|
|
113
|
-
]
|
|
114
|
-
: ["-v", `${this.workDir}:/workspace`, "-w", "/workspace"];
|
|
164
|
+
const policy = (0, sandbox_profile_cjs_1.dockerRunFlags)(this.profile);
|
|
165
|
+
const mounts = (0, sandbox_profile_cjs_1.resolveMounts)(this.profile, this.mountRequests());
|
|
166
|
+
this.denials.push(...policy.denials, ...mounts.denials);
|
|
167
|
+
const workdir = this.containerWorkDir ?? "/workspace";
|
|
168
|
+
this.appliedFlags = [...policy.args, ...mounts.args, "-w", workdir];
|
|
115
169
|
const result = await this.runner.run("docker", [
|
|
116
170
|
"run",
|
|
117
171
|
"-d",
|
|
118
172
|
"--name",
|
|
119
173
|
this.name,
|
|
120
|
-
|
|
121
|
-
"--security-opt=no-new-privileges",
|
|
122
|
-
"--tmpfs",
|
|
123
|
-
"/tmp",
|
|
124
|
-
"--tmpfs",
|
|
125
|
-
"/run",
|
|
126
|
-
...mounts,
|
|
174
|
+
...this.appliedFlags,
|
|
127
175
|
this.image,
|
|
128
176
|
"tail",
|
|
129
177
|
"-f",
|
|
@@ -164,6 +212,39 @@ class DockerSandbox {
|
|
|
164
212
|
: `git clone --depth 1 ${shellQuote(url)} repo`;
|
|
165
213
|
return this.exec(command);
|
|
166
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Copies one container path out to `<workDir>/artifacts/<artifactName>` and
|
|
217
|
+
* returns that host path, or null when it could not be retrieved.
|
|
218
|
+
* Best-effort by contract: evidence collection never fails a session, and
|
|
219
|
+
* the artifact root lives beside the workspace so wipe() removes it too.
|
|
220
|
+
*/
|
|
221
|
+
async collectArtifact(containerPath, artifactName) {
|
|
222
|
+
const artifactRoot = node_path_1.default.join(this.workDir, "artifacts");
|
|
223
|
+
const destination = node_path_1.default.join(artifactRoot, artifactName);
|
|
224
|
+
try {
|
|
225
|
+
await node_fs_1.promises.mkdir(artifactRoot, { recursive: true });
|
|
226
|
+
const result = await this.runner.run("docker", [
|
|
227
|
+
"cp",
|
|
228
|
+
`${this.name}:${containerPath}`,
|
|
229
|
+
destination,
|
|
230
|
+
]);
|
|
231
|
+
return result.code === 0 ? destination : null;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/** Profile/flag/denial metadata for run evidence and the sessions audit columns. */
|
|
238
|
+
describe() {
|
|
239
|
+
return {
|
|
240
|
+
backend: this.id,
|
|
241
|
+
profile: this.profile.id,
|
|
242
|
+
image: this.image,
|
|
243
|
+
name: this.name,
|
|
244
|
+
appliedFlags: [...this.appliedFlags],
|
|
245
|
+
denials: [...this.denials],
|
|
246
|
+
};
|
|
247
|
+
}
|
|
167
248
|
/** Force-removes the container and the host-side workspace mount. Best-effort: never throws. */
|
|
168
249
|
async wipe() {
|
|
169
250
|
await this.stop();
|
package/dist/session.cjs
CHANGED
|
@@ -9,6 +9,7 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
9
9
|
const node_fs_1 = require("node:fs");
|
|
10
10
|
const api_cjs_1 = require("./api.cjs");
|
|
11
11
|
const sandbox_cjs_1 = require("./sandbox.cjs");
|
|
12
|
+
const sandbox_profile_cjs_1 = require("./sandbox-profile.cjs");
|
|
12
13
|
const index_cjs_1 = require("./adapters/index.cjs");
|
|
13
14
|
const exit_conditions_cjs_1 = require("./exit-conditions.cjs");
|
|
14
15
|
const redact_cjs_1 = require("./redact.cjs");
|
|
@@ -109,10 +110,19 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
109
110
|
: "best",
|
|
110
111
|
reasoning_effort: "medium",
|
|
111
112
|
};
|
|
113
|
+
// Isolation posture reported on every completion so `sessions` records what
|
|
114
|
+
// a run actually executed under (#660). Derived from config alone, so the
|
|
115
|
+
// pre-start failure paths below can stamp it too. Host mode records
|
|
116
|
+
// "host" — an honest answer to "what isolated this run?", not a profile id.
|
|
117
|
+
const sandboxReport = {
|
|
118
|
+
sandbox_profile: config.sandboxMode === "docker" ? config.sandboxProfile : "host",
|
|
119
|
+
...(config.sandboxMode === "docker" ? { sandbox_image: config.dockerImage } : {}),
|
|
120
|
+
};
|
|
112
121
|
const executionReport = {
|
|
113
122
|
model: execution.model,
|
|
114
123
|
execution_profile: execution.profile,
|
|
115
124
|
reasoning_effort: execution.reasoning_effort,
|
|
125
|
+
...sandboxReport,
|
|
116
126
|
};
|
|
117
127
|
// The session's identity is the pre-allocated session id sent at claim time
|
|
118
128
|
// (recorded on the lease by the dispatcher). Lease-scoped API calls
|
|
@@ -252,6 +262,9 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
252
262
|
image: config.dockerImage,
|
|
253
263
|
containerWorkDir: gitWorktree.worktreePath,
|
|
254
264
|
sharedGitDir: gitWorktree.repositoryPath,
|
|
265
|
+
profile: (0, sandbox_profile_cjs_1.resolveSandboxProfile)(config.sandboxProfile, {
|
|
266
|
+
egressNetwork: config.sandboxEgressNetwork,
|
|
267
|
+
}),
|
|
255
268
|
})
|
|
256
269
|
: null;
|
|
257
270
|
// Platform MCP config (implementation-plan.md WP-07: "--mcp-config
|
|
@@ -333,6 +346,12 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
333
346
|
await gitWorktree.prepare();
|
|
334
347
|
if (sandbox) {
|
|
335
348
|
await sandbox.create();
|
|
349
|
+
// Security-policy refusals (a mount outside the allowlist, an egress
|
|
350
|
+
// allowlist with no network able to enforce it). Unrelated to the
|
|
351
|
+
// billing-tier SandboxDenialReason in lib/navarch/sandbox.ts.
|
|
352
|
+
for (const denial of sandbox.describe().denials) {
|
|
353
|
+
log.warn(`sandbox policy denial [${denial.policy}/${denial.code}]: ${denial.detail}`);
|
|
354
|
+
}
|
|
336
355
|
await sandbox.injectEnv(sessionEnv);
|
|
337
356
|
}
|
|
338
357
|
// The control plane returns the agent type selected for this worker.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sagentlab/navarch-runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.34",
|
|
4
4
|
"description": "Navarch machine-side session manager: claims delivery tasks and runs them through Claude Code, Codex, Gemini, or OpenCode.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"license": "MIT",
|