jeo-code 0.12.13 → 0.12.16
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/CHANGELOG.md +55 -1
- package/README.ja.md +33 -9
- package/README.ko.md +33 -9
- package/README.md +33 -9
- package/README.zh.md +33 -9
- package/package.json +4 -2
- package/src/agent/browser-tool.ts +9 -1
- package/src/agent/config-schema.ts +8 -0
- package/src/agent/context-files.ts +19 -0
- package/src/agent/engine.ts +25 -6
- package/src/agent/eval-tool.ts +6 -0
- package/src/agent/goal-verifier.ts +30 -6
- package/src/agent/hooks.ts +52 -4
- package/src/agent/job-registry.ts +110 -18
- package/src/agent/job-tool.ts +43 -9
- package/src/agent/loop-guards.ts +17 -2
- package/src/agent/memory.ts +151 -112
- package/src/agent/monitor-registry.ts +142 -30
- package/src/agent/monitor-tool.ts +34 -6
- package/src/agent/notify/discord-daemon.ts +135 -42
- package/src/agent/notify/session-endpoint.ts +27 -5
- package/src/agent/sandbox/assets.d.ts +4 -0
- package/src/agent/sandbox/authority.ts +358 -0
- package/src/agent/sandbox/bundle.ts +40 -0
- package/src/agent/sandbox/control.ts +49 -0
- package/src/agent/sandbox/docker.ts +293 -0
- package/src/agent/sandbox/hands-source.text +14548 -0
- package/src/agent/sandbox/hands-worker.ts +168 -0
- package/src/agent/sandbox/policy.ts +172 -0
- package/src/agent/sandbox/rpc.ts +90 -0
- package/src/agent/sandbox/supervisor.ts +210 -0
- package/src/agent/sandbox/types.ts +77 -0
- package/src/agent/shell-lease.ts +190 -0
- package/src/agent/state.ts +167 -0
- package/src/agent/subagents.ts +4 -3
- package/src/agent/task-tool.ts +8 -0
- package/src/agent/todo-tool.ts +30 -1
- package/src/agent/tool-schemas.ts +13 -0
- package/src/ai/model-discovery.ts +12 -0
- package/src/ai/providers/anthropic.ts +27 -2
- package/src/ai/providers/antigravity.ts +10 -2
- package/src/ai/providers/gemini.ts +20 -16
- package/src/ai/providers/ollama.ts +14 -0
- package/src/ai/providers/openai-responses.ts +22 -5
- package/src/ai/providers/openai.ts +23 -40
- package/src/cli/runner.ts +3 -0
- package/src/commands/approve.ts +37 -13
- package/src/commands/launch/flags.ts +2 -1
- package/src/commands/launch/jobs-slash.ts +1 -1
- package/src/commands/launch.ts +275 -35
- package/src/commands/messenger.ts +107 -39
- package/src/commands/notify.ts +2 -2
- package/src/commands/team.ts +44 -12
- package/src/commands/ultragoal.ts +6 -16
- package/src/messenger/activity.ts +2 -1
- package/src/messenger/config.ts +78 -10
- package/src/messenger/discord-setup.ts +216 -34
- package/src/messenger/discord.ts +37 -43
- package/src/messenger/gateway.ts +85 -37
- package/src/messenger/model-selection.ts +101 -0
- package/src/messenger/session-pool.ts +189 -35
- package/src/messenger/types.ts +5 -2
- package/src/skills/catalog.ts +207 -0
- package/src/util/retry.ts +12 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { Subprocess } from "bun";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { DEFAULT_TOOLS } from "../engine";
|
|
4
|
+
import type { ToolHandler } from "../engine";
|
|
5
|
+
import { executeComputerAction } from "../../commands/computer";
|
|
6
|
+
import { computerSupervisor } from "../computer-supervisor";
|
|
7
|
+
import { assertBashAllowed } from "../tools";
|
|
8
|
+
import { JobRegistry } from "../job-registry";
|
|
9
|
+
import { MonitorRegistry } from "../monitor-registry";
|
|
10
|
+
import { createJobTool } from "../job-tool";
|
|
11
|
+
import { createMonitorTool } from "../monitor-tool";
|
|
12
|
+
import { createGoalTool } from "../goal-tool";
|
|
13
|
+
import { createApproveTool } from "../approve-tool";
|
|
14
|
+
import { createWorkflowStateTool } from "../state";
|
|
15
|
+
import type { ToolResult } from "../tools";
|
|
16
|
+
import { encodeHandsFrame, MAX_FRAME_BYTES, parseHandsRequest, parseHandsResponse, readHandsFrames } from "./rpc";
|
|
17
|
+
import type { SandboxExecResult } from "./types";
|
|
18
|
+
|
|
19
|
+
// This kernel is not a sandbox. Its process and every descendant live INSIDE the OS container.
|
|
20
|
+
const EVAL_BOOTSTRAP = `
|
|
21
|
+
import {createInterface} from "node:readline";
|
|
22
|
+
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
|
23
|
+
const format = value => typeof value === "string" ? value : (() => { try { return JSON.stringify(value) ?? String(value); } catch { return String(value); } })();
|
|
24
|
+
for await (const line of createInterface({input:process.stdin,crlfDelay:Infinity})) {
|
|
25
|
+
const {id,code,cwd,limit} = JSON.parse(line);
|
|
26
|
+
const logs=[];
|
|
27
|
+
let bytes=0;
|
|
28
|
+
const log=(...values)=>{const text=values.map(format).join(" ");bytes+=Buffer.byteLength(text,"utf8")+1;if(bytes>limit)throw new Error("eval output size limit exceeded");logs.push(text);};
|
|
29
|
+
let result;
|
|
30
|
+
try {
|
|
31
|
+
process.chdir(cwd);
|
|
32
|
+
const value=await new AsyncFunction("log","print","display","console",code)(log,log,log,{log,info:log,warn:log,error:log,debug:log});
|
|
33
|
+
if(value!==undefined)log(value);
|
|
34
|
+
result={success:true,output:logs.join("\\n")||"(eval completed with no output)"};
|
|
35
|
+
} catch(error) {result={success:false,output:"",error:"eval failed: "+String(error?.message??error)};}
|
|
36
|
+
process.stdout.write(JSON.stringify({id,type:"result",result})+"\\n");
|
|
37
|
+
}
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
export interface HandsWorkerOptions {
|
|
41
|
+
input: ReadableStream<Uint8Array>;
|
|
42
|
+
output: (line: string) => void | Promise<void>;
|
|
43
|
+
cwd: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runHandsWorker(options: HandsWorkerOptions): Promise<void> {
|
|
47
|
+
const jobs = new JobRegistry();
|
|
48
|
+
const monitors = new MonitorRegistry();
|
|
49
|
+
let kernel: { process: Subprocess<"pipe", "pipe", "pipe">; frames: AsyncGenerator<unknown> } | undefined;
|
|
50
|
+
const evaluate: ToolHandler = async (args, cwd): Promise<ToolResult> => {
|
|
51
|
+
if (typeof args.code !== "string" || !args.code.trim()) return { success: false, output: "", error: "eval requires a code body" };
|
|
52
|
+
try { await assertBashAllowed(cwd); }
|
|
53
|
+
catch (error) { return { success: false, output: "", error: String(error) }; }
|
|
54
|
+
if (!kernel || kernel.process.exitCode !== null) {
|
|
55
|
+
const child = Bun.spawn([process.execPath, "-e", EVAL_BOOTSTRAP], { cwd, env: { ...process.env }, stdin: "pipe", stdout: "pipe", stderr: "pipe" });
|
|
56
|
+
kernel = { process: child, frames: readHandsFrames(child.stdout) };
|
|
57
|
+
void (async () => {
|
|
58
|
+
const reader = child.stderr.getReader();
|
|
59
|
+
try { while (!(await reader.read()).done) { /* User stderr cannot create an unbounded parent buffer. */ } }
|
|
60
|
+
finally { reader.releaseLock(); }
|
|
61
|
+
})().catch(() => {});
|
|
62
|
+
}
|
|
63
|
+
const current = kernel;
|
|
64
|
+
const id = crypto.randomUUID();
|
|
65
|
+
const timeoutMs = typeof args.timeoutMs === "number" && Number.isFinite(args.timeoutMs) && args.timeoutMs > 0
|
|
66
|
+
? Math.min(args.timeoutMs, 1_800_000) : 300_000;
|
|
67
|
+
const timeout = Promise.withResolvers<ToolResult>();
|
|
68
|
+
const timer = setTimeout(() => {
|
|
69
|
+
current.process.kill("SIGKILL");
|
|
70
|
+
if (kernel === current) kernel = undefined;
|
|
71
|
+
timeout.resolve({ success: false, output: "", error: "eval execution timed out" });
|
|
72
|
+
}, timeoutMs);
|
|
73
|
+
try {
|
|
74
|
+
// The outer request is byte-bounded already; this private child envelope adds trusted cwd metadata.
|
|
75
|
+
await current.process.stdin.write(`${JSON.stringify({ id, code: args.code, cwd, limit: Math.floor(MAX_FRAME_BYTES / 2) })}\n`);
|
|
76
|
+
await current.process.stdin.flush();
|
|
77
|
+
const response = (async (): Promise<ToolResult> => {
|
|
78
|
+
const frame = await current.frames.next();
|
|
79
|
+
if (frame.done) throw new Error("eval child closed without a result");
|
|
80
|
+
const parsed = parseHandsResponse(frame.value);
|
|
81
|
+
if (parsed.id !== id || parsed.type !== "result") throw new Error("eval child returned an invalid result identity");
|
|
82
|
+
return parsed.result;
|
|
83
|
+
})();
|
|
84
|
+
return await Promise.race([response, timeout.promise]);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
current.process.kill("SIGKILL");
|
|
87
|
+
if (kernel === current) kernel = undefined;
|
|
88
|
+
return { success: false, output: "", error: `eval child failed: ${String(error)}` };
|
|
89
|
+
} finally { clearTimeout(timer); }
|
|
90
|
+
};
|
|
91
|
+
const tools: Record<string, ToolHandler> = {
|
|
92
|
+
...DEFAULT_TOOLS,
|
|
93
|
+
eval: evaluate,
|
|
94
|
+
job: createJobTool(jobs),
|
|
95
|
+
monitor: createMonitorTool(monitors),
|
|
96
|
+
goal: createGoalTool(),
|
|
97
|
+
// Private (not a model tool): consent comes only from the trusted parent wrapper.
|
|
98
|
+
__computer: async (args): Promise<ToolResult> => {
|
|
99
|
+
if (typeof args.enabled !== "boolean") return { success: false, output: "", error: "__computer requires boolean enabled" };
|
|
100
|
+
if (!args.action || typeof args.action !== "object" || Array.isArray(args.action)) return { success: false, output: "", error: "Expected a computer action object." };
|
|
101
|
+
computerSupervisor.setKillSwitchLive(true);
|
|
102
|
+
computerSupervisor.heartbeat();
|
|
103
|
+
return executeComputerAction(args.action as any, { enabledOverride: args.enabled });
|
|
104
|
+
},
|
|
105
|
+
__plan_approve: createApproveTool(),
|
|
106
|
+
__workflow: createWorkflowStateTool(),
|
|
107
|
+
};
|
|
108
|
+
const output = async (id: string, result: SandboxExecResult): Promise<void> => {
|
|
109
|
+
let frame: string;
|
|
110
|
+
try { frame = encodeHandsFrame({ id, type: "result", result }); }
|
|
111
|
+
catch { frame = encodeHandsFrame({ id, type: "result", result: { success: false, output: "", error: "SANDBOX_PROTOCOL: tool output size limit exceeded", mutationMayHaveOccurred: true } }); }
|
|
112
|
+
await options.output(frame);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const exec = async (argv: string[], cwd: string, env?: Record<string, string>, stdin?: string): Promise<SandboxExecResult> => {
|
|
116
|
+
await assertBashAllowed(cwd);
|
|
117
|
+
const child = Bun.spawn(argv, { cwd, env: { ...process.env, ...env }, stdin: "pipe", stdout: "pipe", stderr: "pipe" });
|
|
118
|
+
const capture = async (stream: ReadableStream<Uint8Array>): Promise<string> => {
|
|
119
|
+
const reader = stream.getReader();
|
|
120
|
+
const chunks: Uint8Array[] = [];
|
|
121
|
+
let size = 0;
|
|
122
|
+
try {
|
|
123
|
+
for (;;) {
|
|
124
|
+
const chunk = await reader.read();
|
|
125
|
+
if (chunk.done) break;
|
|
126
|
+
size += chunk.value.byteLength;
|
|
127
|
+
if (size > Math.floor((MAX_FRAME_BYTES - 4096) / 4)) { child.kill(); throw new Error("sandbox exec output limit exceeded"); }
|
|
128
|
+
chunks.push(chunk.value);
|
|
129
|
+
}
|
|
130
|
+
} finally { reader.releaseLock(); }
|
|
131
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
132
|
+
};
|
|
133
|
+
const reads = Promise.all([capture(child.stdout), capture(child.stderr), child.exited]);
|
|
134
|
+
if (stdin !== undefined) await child.stdin.write(stdin);
|
|
135
|
+
await child.stdin.end();
|
|
136
|
+
const [stdout, stderr, exitCode] = await reads;
|
|
137
|
+
return { success: exitCode === 0, output: `${stdout}${stderr}`, stdout, stderr, exitCode, timedOut: false, aborted: false, ...(exitCode !== 0 ? { error: `Command exited ${exitCode}`, mutationMayHaveOccurred: true } : {}) };
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
for await (const frame of readHandsFrames(options.input)) {
|
|
142
|
+
const request = parseHandsRequest(frame);
|
|
143
|
+
let result: SandboxExecResult;
|
|
144
|
+
try {
|
|
145
|
+
if (request.type === "exec") {
|
|
146
|
+
result = await exec(request.argv, request.cwd ? path.resolve(options.cwd, request.cwd) : options.cwd, request.env, request.stdin);
|
|
147
|
+
} else if (!Object.hasOwn(tools, request.tool)) {
|
|
148
|
+
result = { success: false, output: "", error: `Unknown or unavailable hands tool: ${request.tool}` };
|
|
149
|
+
} else {
|
|
150
|
+
result = await tools[request.tool]!(request.args, request.cwd ? path.resolve(options.cwd, request.cwd) : options.cwd, partial => {
|
|
151
|
+
try { void options.output(encodeHandsFrame({ id: request.id, type: "progress", text: partial })); }
|
|
152
|
+
catch { /* The bounded final result reports oversized output; progress is cosmetic. */ }
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
} catch (error) { result = { success: false, output: "", error: String(error), mutationMayHaveOccurred: true }; }
|
|
156
|
+
await output(request.id, result);
|
|
157
|
+
}
|
|
158
|
+
} finally {
|
|
159
|
+
if (kernel) { kernel.process.kill("SIGKILL"); await kernel.process.exited; }
|
|
160
|
+
jobs.cancelAll();
|
|
161
|
+
monitors.cancelAll();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (import.meta.main) {
|
|
166
|
+
if (process.env.JEO_SANDBOX_HANDS !== "1") throw new Error("Hands runtime requires its isolated backend");
|
|
167
|
+
await runHandsWorker({ input: Bun.stdin.stream(), output: line => { process.stdout.write(line); }, cwd: process.cwd() });
|
|
168
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import type { SandboxConfig, SandboxMount, SandboxPolicy } from "./types";
|
|
5
|
+
|
|
6
|
+
export interface SandboxPolicyOptions {
|
|
7
|
+
sessionKey: string;
|
|
8
|
+
revision: number;
|
|
9
|
+
cwd: string;
|
|
10
|
+
config: SandboxConfig;
|
|
11
|
+
grants?: readonly { host: string; mode: "ro" | "rw" }[];
|
|
12
|
+
privatePaths?: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function validateSandboxConfig(value: SandboxConfig): void {
|
|
16
|
+
if (!value || typeof value.image !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.:/@-]*$/.test(value.image)) {
|
|
17
|
+
throw new Error("SANDBOX_CONFIG: an existing local image reference is required");
|
|
18
|
+
}
|
|
19
|
+
const limits = value.limits;
|
|
20
|
+
if (!limits || !Number.isSafeInteger(limits.memoryBytes) || limits.memoryBytes <= 0 ||
|
|
21
|
+
!Number.isSafeInteger(limits.pids) || limits.pids <= 0 ||
|
|
22
|
+
!Number.isFinite(limits.cpus) || limits.cpus <= 0) {
|
|
23
|
+
throw new Error("SANDBOX_CONFIG: positive finite memory, PID and CPU limits are required");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function sandboxPathContains(parent: string, child: string): boolean {
|
|
28
|
+
const relative = path.relative(parent, child);
|
|
29
|
+
return relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function windowsSystemRoot(): string {
|
|
33
|
+
const configured = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? process.env.windir;
|
|
34
|
+
if (configured && path.isAbsolute(configured)) return configured;
|
|
35
|
+
for (const candidate of ["powershell.exe", "cmd.exe"]) {
|
|
36
|
+
const resolved = Bun.which(candidate);
|
|
37
|
+
const match = resolved ? /^(.*)[\\/]System32(?:[\\/]|$)/i.exec(resolved) : null;
|
|
38
|
+
if (match?.[1] && path.isAbsolute(match[1])) return match[1];
|
|
39
|
+
}
|
|
40
|
+
throw new Error("SANDBOX_STATE: Windows system directory is unavailable");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let nativeHome: Promise<string> | undefined;
|
|
44
|
+
|
|
45
|
+
/** Bun userInfo() can inherit startup HOME; query the native account database instead. */
|
|
46
|
+
export function nativeSandboxHome(): Promise<string> {
|
|
47
|
+
nativeHome ??= (async () => {
|
|
48
|
+
let home: string | undefined;
|
|
49
|
+
let argv: string[] | undefined;
|
|
50
|
+
let systemRoot = "";
|
|
51
|
+
if (process.platform === "win32") {
|
|
52
|
+
systemRoot = windowsSystemRoot();
|
|
53
|
+
const script = "$ErrorActionPreference='Stop';[Console]::Out.Write([Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile))";
|
|
54
|
+
argv = [path.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")];
|
|
55
|
+
} else {
|
|
56
|
+
const uid = process.getuid?.();
|
|
57
|
+
if (uid === undefined) throw new Error("SANDBOX_STATE: native user identity unavailable");
|
|
58
|
+
if (process.platform === "darwin") argv = ["/usr/bin/dscacheutil", "-q", "user", "-a", "uid", String(uid)];
|
|
59
|
+
else {
|
|
60
|
+
const entry = (await fs.readFile("/etc/passwd", "utf8")).split("\n").find(line => line.split(":")[2] === String(uid));
|
|
61
|
+
home = entry?.split(":")[5];
|
|
62
|
+
if (!home) argv = ["/usr/bin/getent", "passwd", String(uid)];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (argv) {
|
|
66
|
+
const child = Bun.spawn(argv, { env: { SystemRoot: systemRoot }, stdin: "ignore", stdout: "pipe", stderr: "ignore" });
|
|
67
|
+
const timer = setTimeout(() => child.kill(), 30_000);
|
|
68
|
+
try {
|
|
69
|
+
const [code, output] = await Promise.all([child.exited, new Response(child.stdout).text()]);
|
|
70
|
+
if (code !== 0) throw new Error("SANDBOX_STATE: native account home lookup failed");
|
|
71
|
+
home = process.platform === "darwin" ? /^dir: (.+)$/m.exec(output)?.[1] : process.platform === "win32" ? output.trim() : output.trim().split(":")[5];
|
|
72
|
+
} finally { clearTimeout(timer); }
|
|
73
|
+
}
|
|
74
|
+
if (!home || !path.isAbsolute(home)) throw new Error("SANDBOX_STATE: native account home unavailable");
|
|
75
|
+
return canonicalSandboxPath(home);
|
|
76
|
+
})();
|
|
77
|
+
return nativeHome;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Canonicalize even a not-yet-created private state path without trusting symlinked ancestors. */
|
|
81
|
+
export async function canonicalSandboxPath(value: string, mustExist = true, cwd?: string): Promise<string> {
|
|
82
|
+
if (typeof value !== "string" || !value.trim() || /[\0\r\n]/.test(value)) throw new Error("SANDBOX_PATH: invalid host path");
|
|
83
|
+
// Reject namespaces before normalization or metadata can contact SMB servers or devices.
|
|
84
|
+
if (process.platform === "win32" && /^(?:[\\/]{2}|[\\/]\?\?[\\/])/.test(value)) throw new Error("SANDBOX_PATH: Windows network and device paths are forbidden");
|
|
85
|
+
const absolute = cwd === undefined ? path.resolve(value) : path.resolve(cwd, value);
|
|
86
|
+
if (process.platform === "win32" && /^[\\/]{2}/.test(absolute)) throw new Error("SANDBOX_PATH: Windows network and device paths are forbidden");
|
|
87
|
+
try {
|
|
88
|
+
const resolved = await fs.realpath(absolute);
|
|
89
|
+
return process.platform === "win32" && /^[A-Za-z]:$/.test(resolved) ? `${resolved}\\` : resolved;
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
93
|
+
// macOS realpath can reject launchd sockets even though their parent is resolvable.
|
|
94
|
+
const socket = code === "EOPNOTSUPP" && (await fs.lstat(absolute)).isSocket();
|
|
95
|
+
if (!socket && (mustExist || code !== "ENOENT")) throw error;
|
|
96
|
+
const parent = path.dirname(absolute);
|
|
97
|
+
if (parent === absolute) throw error;
|
|
98
|
+
const resolvedParent = await canonicalSandboxPath(parent, false);
|
|
99
|
+
const result = path.join(resolvedParent, path.basename(absolute));
|
|
100
|
+
return process.platform === "win32" && /^[A-Za-z]:$/.test(result) ? `${result}\\` : result;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Deny endpoint ancestors even when their socket has not been created yet. */
|
|
105
|
+
export async function validateSandboxMountSource(host: string, privatePaths: readonly string[], controlPaths: readonly string[] = []): Promise<void> {
|
|
106
|
+
const home = await canonicalSandboxPath(os.homedir());
|
|
107
|
+
const isRoot = host === path.parse(host).root || (process.platform === "win32" && /^[A-Za-z]:[\\/]?$/.test(host));
|
|
108
|
+
if (isRoot || host === home) throw new Error("SANDBOX_POLICY: host root and whole-home mounts are forbidden");
|
|
109
|
+
if (privatePaths.some(privatePath => sandboxPathContains(host, privatePath) || sandboxPathContains(privatePath, host))) {
|
|
110
|
+
throw new Error("SANDBOX_POLICY: mount overlaps private control state");
|
|
111
|
+
}
|
|
112
|
+
const endpoints = [process.env.SSH_AUTH_SOCK, process.env.DOCKER_HOST?.startsWith("unix://") ? process.env.DOCKER_HOST.slice(7) : undefined, ...controlPaths].filter((value): value is string => Boolean(value));
|
|
113
|
+
for (const endpoint of endpoints) {
|
|
114
|
+
if (sandboxPathContains(host, await canonicalSandboxPath(endpoint, false))) throw new Error("SANDBOX_POLICY: mount exposes a control socket endpoint");
|
|
115
|
+
}
|
|
116
|
+
const pending = [host];
|
|
117
|
+
while (pending.length) {
|
|
118
|
+
const current = pending.pop()!;
|
|
119
|
+
const stat = await fs.lstat(current);
|
|
120
|
+
// Symlinks inside a bind resolve in the guest namespace, not the host namespace.
|
|
121
|
+
if (stat.isSymbolicLink()) continue;
|
|
122
|
+
if (stat.isSocket() || path.basename(current).toLowerCase() === "docker.sock") throw new Error("SANDBOX_POLICY: control sockets cannot be mounted");
|
|
123
|
+
if (stat.isDirectory()) {
|
|
124
|
+
for (const entry of await fs.readdir(current)) pending.push(path.join(current, entry));
|
|
125
|
+
} else if (!stat.isFile()) throw new Error("SANDBOX_POLICY: non-file resources cannot be mounted");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function createSandboxPolicy(options: SandboxPolicyOptions): Promise<SandboxPolicy> {
|
|
130
|
+
validateSandboxConfig(options.config);
|
|
131
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(options.sessionKey) || !Number.isSafeInteger(options.revision) || options.revision < 0) {
|
|
132
|
+
throw new Error("SANDBOX_POLICY: invalid session or revision");
|
|
133
|
+
}
|
|
134
|
+
const home = await canonicalSandboxPath(os.homedir());
|
|
135
|
+
const nativeHome = await nativeSandboxHome();
|
|
136
|
+
const privatePaths = await Promise.all([
|
|
137
|
+
...[...new Set([home, nativeHome])].flatMap(root => [".jeo", ".jeopi", ".docker", ".gemini", ".ssh", ".claude", ".codex", ".agents", ".aws", ".azure", ".gnupg", ".config"].map(name => path.join(root, name))),
|
|
138
|
+
...["JEO_CONFIG_DIR", "DOCKER_CONFIG", "XDG_CONFIG_HOME", "JEO_GEMINI_CREDS_PATH", "CODEX_HOME", "CLAUDE_CONFIG_DIR", "AWS_CONFIG_FILE", "AWS_SHARED_CREDENTIALS_FILE", "AZURE_CONFIG_DIR", "GNUPGHOME"].map(key => process.env[key]).filter((value): value is string => Boolean(value)),
|
|
139
|
+
...(options.privatePaths ?? []),
|
|
140
|
+
].map(value => canonicalSandboxPath(value, false)));
|
|
141
|
+
const mounts = new Map<string, SandboxMount>();
|
|
142
|
+
const workspace = await canonicalSandboxPath(options.cwd);
|
|
143
|
+
const resources = [{ host: workspace, mode: "rw" as const }, ...(options.grants ?? [])];
|
|
144
|
+
for (const resource of resources) {
|
|
145
|
+
if (resource.mode !== "ro" && resource.mode !== "rw") throw new Error("SANDBOX_POLICY: invalid access mode");
|
|
146
|
+
const host = await canonicalSandboxPath(resource.host);
|
|
147
|
+
await validateSandboxMountSource(host, privatePaths);
|
|
148
|
+
for (const existing of mounts.values()) {
|
|
149
|
+
if (existing.host !== host && (sandboxPathContains(existing.host, host) || sandboxPathContains(host, existing.host))) {
|
|
150
|
+
throw new Error("SANDBOX_MOUNT_OVERLAP: non-identical ancestor and descendant mount roots are forbidden");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const existing = mounts.get(host);
|
|
154
|
+
const mode = existing?.mode === "rw" ? "rw" : resource.mode;
|
|
155
|
+
const guest = host === workspace ? "/workspace" : `/grants/${new Bun.CryptoHasher("sha256").update(host).digest("hex")}`;
|
|
156
|
+
mounts.set(host, Object.freeze({ host, guest, mode }));
|
|
157
|
+
}
|
|
158
|
+
const ordered = [...mounts.values()].sort((a, b) => a.guest.localeCompare(b.guest));
|
|
159
|
+
const config = Object.freeze({ image: options.config.image, limits: Object.freeze({ ...options.config.limits }) });
|
|
160
|
+
const content = { sessionKey: options.sessionKey, revision: options.revision, mounts: ordered, config, privatePaths: Object.freeze(privatePaths) };
|
|
161
|
+
const digest = new Bun.CryptoHasher("sha256").update(JSON.stringify(content)).digest("hex");
|
|
162
|
+
return Object.freeze({ ...content, mounts: Object.freeze(ordered), digest });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function sandboxGuestPath(policy: SandboxPolicy, hostPath: string): string {
|
|
166
|
+
const absolute = path.resolve(hostPath);
|
|
167
|
+
const mount = [...policy.mounts].sort((a, b) => b.host.length - a.host.length)
|
|
168
|
+
.find(candidate => sandboxPathContains(candidate.host, absolute));
|
|
169
|
+
if (!mount) throw new Error("SANDBOX_PATH: path has no approved mount");
|
|
170
|
+
const relative = path.relative(mount.host, absolute).split(path.sep).join("/");
|
|
171
|
+
return relative ? `${mount.guest}/${relative}` : mount.guest;
|
|
172
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { SandboxExecResult } from "./types";
|
|
2
|
+
|
|
3
|
+
// Match the existing messenger worker transport bound, measured before UTF-8 decoding.
|
|
4
|
+
export const MAX_FRAME_BYTES = 1_000_000;
|
|
5
|
+
|
|
6
|
+
export type HandsRequest =
|
|
7
|
+
| { id: string; type: "tool"; tool: string; args: Record<string, unknown>; cwd?: string }
|
|
8
|
+
| { id: string; type: "exec"; argv: string[]; cwd?: string; env?: Record<string, string>; stdin?: string };
|
|
9
|
+
export type HandsResponse =
|
|
10
|
+
| { id: string; type: "progress"; text: string }
|
|
11
|
+
| { id: string; type: "result"; result: SandboxExecResult };
|
|
12
|
+
|
|
13
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
14
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function encodeHandsFrame(value: unknown): string {
|
|
18
|
+
const text = JSON.stringify(value);
|
|
19
|
+
if (typeof text !== "string" || Buffer.byteLength(text, "utf8") > MAX_FRAME_BYTES) throw new Error("SANDBOX_PROTOCOL: frame size limit exceeded");
|
|
20
|
+
return `${text}\n`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function* readHandsFrames(input: ReadableStream<Uint8Array>): AsyncGenerator<unknown> {
|
|
24
|
+
const reader = input.getReader();
|
|
25
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
26
|
+
let pieces: Uint8Array[] = [];
|
|
27
|
+
let size = 0;
|
|
28
|
+
try {
|
|
29
|
+
for (;;) {
|
|
30
|
+
const chunk = await reader.read();
|
|
31
|
+
if (chunk.done) break;
|
|
32
|
+
let start = 0;
|
|
33
|
+
for (let index = 0; index <= chunk.value.byteLength; index++) {
|
|
34
|
+
const complete = index < chunk.value.byteLength && chunk.value[index] === 10;
|
|
35
|
+
if (!complete && index !== chunk.value.byteLength) continue;
|
|
36
|
+
const part = chunk.value.subarray(start, index);
|
|
37
|
+
size += part.byteLength;
|
|
38
|
+
if (size > MAX_FRAME_BYTES) throw new Error("SANDBOX_PROTOCOL: frame size limit exceeded");
|
|
39
|
+
if (part.byteLength) pieces.push(part);
|
|
40
|
+
if (complete) {
|
|
41
|
+
try { yield JSON.parse(decoder.decode(Buffer.concat(pieces, size))); }
|
|
42
|
+
catch (error) { throw new Error("SANDBOX_PROTOCOL: malformed JSON frame", { cause: error }); }
|
|
43
|
+
pieces = [];
|
|
44
|
+
size = 0;
|
|
45
|
+
start = index + 1;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (size) throw new Error("SANDBOX_PROTOCOL: incomplete frame at EOF");
|
|
50
|
+
} finally { reader.releaseLock(); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function parseHandsRequest(value: unknown): HandsRequest {
|
|
54
|
+
if (!record(value) || typeof value.id !== "string" || !/^[A-Za-z0-9_-]{1,256}$/.test(value.id)) {
|
|
55
|
+
throw new Error("SANDBOX_PROTOCOL: invalid request identity");
|
|
56
|
+
}
|
|
57
|
+
if (value.type === "tool" && typeof value.tool === "string" && record(value.args) &&
|
|
58
|
+
(value.cwd === undefined || (typeof value.cwd === "string" && !value.cwd.includes("\0"))) &&
|
|
59
|
+
Object.keys(value).every(key => ["id", "type", "tool", "args", "cwd"].includes(key))) {
|
|
60
|
+
return { id: value.id, type: "tool", tool: value.tool, args: value.args, ...(value.cwd !== undefined ? { cwd: value.cwd } : {}) };
|
|
61
|
+
}
|
|
62
|
+
if (value.type === "exec" && Array.isArray(value.argv) && value.argv.length > 0 &&
|
|
63
|
+
value.argv.every(arg => typeof arg === "string" && !arg.includes("\0")) &&
|
|
64
|
+
(value.cwd === undefined || typeof value.cwd === "string") &&
|
|
65
|
+
(value.stdin === undefined || typeof value.stdin === "string") &&
|
|
66
|
+
(value.env === undefined || (record(value.env) && Object.values(value.env).every(item => typeof item === "string" && !item.includes("\0")))) &&
|
|
67
|
+
Object.keys(value).every(key => ["id", "type", "argv", "cwd", "env", "stdin"].includes(key))) {
|
|
68
|
+
return { id: value.id, type: "exec", argv: value.argv, ...(value.cwd !== undefined ? { cwd: value.cwd } : {}), ...(value.stdin !== undefined ? { stdin: value.stdin } : {}), ...(value.env !== undefined ? { env: value.env as Record<string, string> } : {}) };
|
|
69
|
+
}
|
|
70
|
+
throw new Error("SANDBOX_PROTOCOL: invalid request frame");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parseHandsResponse(value: unknown): HandsResponse {
|
|
74
|
+
if (!record(value) || typeof value.id !== "string" || !/^[A-Za-z0-9_-]{1,256}$/.test(value.id)) throw new Error("SANDBOX_PROTOCOL: invalid response identity");
|
|
75
|
+
if (value.type === "progress" && typeof value.text === "string" && Object.keys(value).every(key => ["id", "type", "text"].includes(key))) {
|
|
76
|
+
return { id: value.id, type: "progress", text: value.text };
|
|
77
|
+
}
|
|
78
|
+
const result = value.result;
|
|
79
|
+
if (value.type === "result" && record(result) && typeof result.success === "boolean" && typeof result.output === "string" &&
|
|
80
|
+
(result.error === undefined || typeof result.error === "string") &&
|
|
81
|
+
(result.mutationMayHaveOccurred === undefined || typeof result.mutationMayHaveOccurred === "boolean") &&
|
|
82
|
+
(result.images === undefined || (Array.isArray(result.images) && result.images.every(image => record(image) && typeof image.mediaType === "string" && /^image\/[a-zA-Z0-9.+-]+$/.test(image.mediaType) && typeof image.data === "string" && /^[A-Za-z0-9+/]*={0,2}$/.test(image.data)))) &&
|
|
83
|
+
["stdout", "stderr"].every(key => result[key] === undefined || typeof result[key] === "string") &&
|
|
84
|
+
(result.exitCode === undefined || result.exitCode === null || Number.isInteger(result.exitCode)) &&
|
|
85
|
+
["timedOut", "aborted"].every(key => result[key] === undefined || typeof result[key] === "boolean") &&
|
|
86
|
+
Object.keys(value).every(key => ["id", "type", "result"].includes(key))) {
|
|
87
|
+
return { id: value.id, type: "result", result: result as unknown as SandboxExecResult };
|
|
88
|
+
}
|
|
89
|
+
throw new Error("SANDBOX_PROTOCOL: invalid response frame");
|
|
90
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { createDockerBackend, SandboxLaunchError } from "./docker";
|
|
3
|
+
import { prepareHandsBundle } from "./bundle";
|
|
4
|
+
import { sandboxPathContains } from "./policy";
|
|
5
|
+
import { encodeHandsFrame, parseHandsResponse, readHandsFrames } from "./rpc";
|
|
6
|
+
import type { HandsRequest } from "./rpc";
|
|
7
|
+
import type { ToolHandler } from "../engine";
|
|
8
|
+
import type { ToolResult } from "../tools";
|
|
9
|
+
import type { SandboxBackend, SandboxCallOptions, SandboxExecOptions, SandboxExecResult, SandboxPolicy, SandboxProcess, SandboxSupervisor, SandboxTermination } from "./types";
|
|
10
|
+
|
|
11
|
+
interface PendingEffect {
|
|
12
|
+
request: HandsRequest;
|
|
13
|
+
frame: string;
|
|
14
|
+
settled: boolean;
|
|
15
|
+
result: PromiseWithResolvers<SandboxExecResult>;
|
|
16
|
+
progress?: (text: string) => void;
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
abort?: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createSandboxSupervisor(options: { policy: SandboxPolicy; backend?: SandboxBackend; bundlePath?: string }): SandboxSupervisor {
|
|
22
|
+
const backend = options.backend ?? createDockerBackend();
|
|
23
|
+
let policy = options.policy;
|
|
24
|
+
let state: "ready" | "changing" | "revoked" | "closed" | "failed" = "ready";
|
|
25
|
+
let reason = "";
|
|
26
|
+
let generation = 0;
|
|
27
|
+
let worker: SandboxProcess | undefined;
|
|
28
|
+
let launching: Promise<SandboxProcess> | undefined;
|
|
29
|
+
let launchFailure: Error | undefined;
|
|
30
|
+
let stopping: Promise<SandboxTermination> | undefined;
|
|
31
|
+
let active: PendingEffect | undefined;
|
|
32
|
+
let pumping = false;
|
|
33
|
+
const queue: PendingEffect[] = [];
|
|
34
|
+
|
|
35
|
+
function settle(effect: PendingEffect, result: SandboxExecResult): void {
|
|
36
|
+
if (effect.settled) return;
|
|
37
|
+
effect.settled = true;
|
|
38
|
+
if (effect.abort) effect.signal?.removeEventListener("abort", effect.abort);
|
|
39
|
+
effect.result.resolve(result);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function invalidate(message: string, next: typeof state): number {
|
|
43
|
+
state = state === "closed" ? "closed" : next;
|
|
44
|
+
reason = message;
|
|
45
|
+
generation++;
|
|
46
|
+
const failure = { success: false, output: "", error: message };
|
|
47
|
+
if (active) settle(active, failure);
|
|
48
|
+
for (const effect of queue.splice(0)) settle(effect, failure);
|
|
49
|
+
return generation;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function terminate(): Promise<SandboxTermination> {
|
|
53
|
+
if (stopping) return stopping;
|
|
54
|
+
const removal = (async (): Promise<SandboxTermination> => {
|
|
55
|
+
const owned = worker ?? (launching ? await launching.catch(() => undefined) : undefined);
|
|
56
|
+
const residual = owned ? [owned.name] : launchFailure instanceof SandboxLaunchError ? [...launchFailure.residual] : launchFailure ? [launchFailure.message] : [];
|
|
57
|
+
try {
|
|
58
|
+
const result = owned ? await owned.stop() : launchFailure instanceof SandboxLaunchError ? await launchFailure.cleanup() : launchFailure ? { gone: false, residual } : { gone: true, residual: [] };
|
|
59
|
+
if (!result.gone || result.residual.length) {
|
|
60
|
+
const remaining = result.residual.length ? [...result.residual] : residual;
|
|
61
|
+
invalidate(`REVOKE_INCOMPLETE: ${remaining.join(", ") || "launch cleanup remains unverified"}`, "failed");
|
|
62
|
+
return { gone: false, residual: remaining };
|
|
63
|
+
}
|
|
64
|
+
if (worker === owned) worker = undefined;
|
|
65
|
+
launchFailure = undefined;
|
|
66
|
+
return { gone: true, residual: [] };
|
|
67
|
+
} catch (error) {
|
|
68
|
+
invalidate(`REVOKE_INCOMPLETE: ${String(error)}`, "failed");
|
|
69
|
+
return { gone: false, residual: residual.length ? residual : [String(error)] };
|
|
70
|
+
}
|
|
71
|
+
})();
|
|
72
|
+
stopping = removal;
|
|
73
|
+
void removal.then(result => { if (!result.gone && stopping === removal) stopping = undefined; });
|
|
74
|
+
return removal;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fail(error: unknown): void {
|
|
78
|
+
if (state !== "ready") return;
|
|
79
|
+
invalidate(String(error), "failed");
|
|
80
|
+
void terminate();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function start(): Promise<SandboxProcess> {
|
|
84
|
+
if (worker) return worker;
|
|
85
|
+
if (launching) return launching;
|
|
86
|
+
const epoch = generation;
|
|
87
|
+
launching = (async () => {
|
|
88
|
+
const bundle = options.bundlePath ?? await prepareHandsBundle();
|
|
89
|
+
const process = await backend.launch(policy, bundle).catch(error => {
|
|
90
|
+
if (error instanceof SandboxLaunchError || (error instanceof Error && /REVOKE_INCOMPLETE/.test(error.message))) launchFailure = error;
|
|
91
|
+
throw error;
|
|
92
|
+
});
|
|
93
|
+
worker = process;
|
|
94
|
+
void (async () => {
|
|
95
|
+
try {
|
|
96
|
+
for await (const value of readHandsFrames(process.stdout)) {
|
|
97
|
+
if (generation !== epoch || state !== "ready") continue;
|
|
98
|
+
const response = parseHandsResponse(value);
|
|
99
|
+
const effect = active;
|
|
100
|
+
if (!effect || effect.settled || response.id !== effect.request.id) throw new Error("SANDBOX_PROTOCOL: unknown response correlation identity");
|
|
101
|
+
if (response.type === "result") settle(effect, response.result);
|
|
102
|
+
else { try { effect.progress?.(response.text); } catch { /* A UI progress callback is not authority. */ } }
|
|
103
|
+
}
|
|
104
|
+
if (generation === epoch) fail(new Error("SANDBOX_PROTOCOL: hands output closed"));
|
|
105
|
+
} catch (error) { if (generation === epoch) fail(error); }
|
|
106
|
+
})();
|
|
107
|
+
void (async () => {
|
|
108
|
+
const reader = process.stderr.getReader();
|
|
109
|
+
try { while (!(await reader.read()).done) { /* Drain diagnostics without an unbounded parent buffer. */ } }
|
|
110
|
+
catch (error) { if (generation === epoch) fail(error); }
|
|
111
|
+
finally { reader.releaseLock(); }
|
|
112
|
+
})();
|
|
113
|
+
void process.exited.then(() => {
|
|
114
|
+
if (generation === epoch) fail(new Error("SANDBOX_EXIT: hands runtime exited"));
|
|
115
|
+
}, error => { if (generation === epoch) fail(error); });
|
|
116
|
+
return process;
|
|
117
|
+
})();
|
|
118
|
+
try { return await launching; }
|
|
119
|
+
finally { launching = undefined; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function pump(): Promise<void> {
|
|
123
|
+
if (pumping) return;
|
|
124
|
+
pumping = true;
|
|
125
|
+
try {
|
|
126
|
+
while (state === "ready" && queue.length) {
|
|
127
|
+
const effect = queue.shift()!;
|
|
128
|
+
if (effect.settled) continue;
|
|
129
|
+
active = effect;
|
|
130
|
+
const epoch = generation;
|
|
131
|
+
try {
|
|
132
|
+
const process = await start();
|
|
133
|
+
if (state !== "ready" || generation !== epoch || effect.settled) continue;
|
|
134
|
+
await process.stdin.write(effect.frame);
|
|
135
|
+
await process.stdin.flush();
|
|
136
|
+
await effect.result.promise;
|
|
137
|
+
} catch (error) { fail(error); }
|
|
138
|
+
finally { if (active === effect) active = undefined; }
|
|
139
|
+
}
|
|
140
|
+
} finally { pumping = false; }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function enqueue(request: HandsRequest, callOptions: SandboxCallOptions = {}): Promise<SandboxExecResult> {
|
|
144
|
+
if (state !== "ready") return Promise.resolve({ success: false, output: "", error: reason || "SANDBOX_BLOCKED" });
|
|
145
|
+
if (callOptions.signal?.aborted) return Promise.resolve({ success: false, output: "", error: "SANDBOX_ABORTED: request cancelled" });
|
|
146
|
+
if (queue.length >= 100) return Promise.resolve({ success: false, output: "", error: "SANDBOX_QUEUE: capacity exceeded" });
|
|
147
|
+
let frame: string;
|
|
148
|
+
try { frame = encodeHandsFrame(request); }
|
|
149
|
+
catch (error) { return Promise.resolve({ success: false, output: "", error: String(error) }); }
|
|
150
|
+
const effect: PendingEffect = { request, frame, settled: false, result: Promise.withResolvers<SandboxExecResult>(), progress: callOptions.onProgress, signal: callOptions.signal };
|
|
151
|
+
effect.abort = () => {
|
|
152
|
+
if (effect.settled) return;
|
|
153
|
+
if (active !== effect) { settle(effect, { success: false, output: "", error: "SANDBOX_ABORTED: queued request cancelled" }); return; }
|
|
154
|
+
const epoch = invalidate("SANDBOX_ABORTED: active generation cancelled", "changing");
|
|
155
|
+
void terminate().then(result => {
|
|
156
|
+
if (result.gone && generation === epoch && state === "changing") { stopping = undefined; state = "ready"; reason = ""; }
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
effect.signal?.addEventListener("abort", effect.abort, { once: true });
|
|
160
|
+
queue.push(effect);
|
|
161
|
+
void pump();
|
|
162
|
+
return effect.result.promise;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function guestPath(value: string): string {
|
|
166
|
+
if (!path.isAbsolute(value)) return value;
|
|
167
|
+
const mount = [...policy.mounts].sort((a, b) => b.host.length - a.host.length).find(item => sandboxPathContains(item.host, value));
|
|
168
|
+
if (!mount) return value;
|
|
169
|
+
const relative = path.relative(mount.host, value).split(path.sep).join("/");
|
|
170
|
+
return relative ? `${mount.guest}/${relative}` : mount.guest;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function call(tool: string, args: Record<string, unknown>, callOptions?: SandboxCallOptions): Promise<ToolResult> {
|
|
174
|
+
const mapped = { ...args };
|
|
175
|
+
for (const key of ["filePath", "path", "dirPath", "dir", "targetPath", "cwd", "subdir"]) {
|
|
176
|
+
if (typeof mapped[key] === "string") mapped[key] = guestPath(mapped[key]);
|
|
177
|
+
}
|
|
178
|
+
return enqueue({ id: crypto.randomUUID(), type: "tool", tool: tool === "approve" ? "__plan_approve" : tool, args: mapped, ...(callOptions?.cwd ? { cwd: guestPath(callOptions.cwd) } : {}) }, callOptions);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
call,
|
|
183
|
+
toolMap(names): Record<string, ToolHandler> {
|
|
184
|
+
return Object.fromEntries(names.map(name => [name, (args: Record<string, unknown>, cwd: string, onProgress?: (text: string) => void) => call(name, args, { cwd, onProgress })]));
|
|
185
|
+
},
|
|
186
|
+
exec(argv: readonly string[], execOptions: SandboxExecOptions = {}): Promise<SandboxExecResult> {
|
|
187
|
+
return enqueue({ id: crypto.randomUUID(), type: "exec", argv: [...argv], ...(execOptions.cwd ? { cwd: guestPath(execOptions.cwd) } : {}), ...(execOptions.env ? { env: execOptions.env } : {}), ...(execOptions.stdin !== undefined ? { stdin: execOptions.stdin } : {}) }, { signal: execOptions.signal });
|
|
188
|
+
},
|
|
189
|
+
async replacePolicy(next): Promise<void> {
|
|
190
|
+
if (state === "closed" || state === "changing" || next.sessionKey !== policy.sessionKey || next.revision <= policy.revision) throw new Error("SANDBOX_POLICY: closed, changing or invalid policy revision");
|
|
191
|
+
const epoch = invalidate("SANDBOX_POLICY: replacing active generation", "changing");
|
|
192
|
+
const result = await terminate();
|
|
193
|
+
if (!result.gone) throw new Error(reason || "REVOKE_INCOMPLETE");
|
|
194
|
+
if (generation !== epoch) throw new Error(reason || "SANDBOX_POLICY: generation changed");
|
|
195
|
+
policy = next;
|
|
196
|
+
stopping = undefined;
|
|
197
|
+
state = "ready";
|
|
198
|
+
reason = "";
|
|
199
|
+
},
|
|
200
|
+
async revoke(): Promise<SandboxTermination> {
|
|
201
|
+
if (state !== "closed") invalidate("SANDBOX_REVOKED: session authority removed", "revoked");
|
|
202
|
+
return terminate();
|
|
203
|
+
},
|
|
204
|
+
async close(): Promise<void> {
|
|
205
|
+
invalidate("SANDBOX_CLOSED: supervisor closed", "closed");
|
|
206
|
+
const result = await terminate();
|
|
207
|
+
if (!result.gone) throw new Error("REVOKE_INCOMPLETE: sandbox close did not remove its environment");
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|