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,293 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { ensurePrivateSandboxDirectory } from "./authority";
|
|
4
|
+
import { canonicalSandboxPath, nativeSandboxHome, sandboxPathContains, validateSandboxConfig, validateSandboxMountSource } from "./policy";
|
|
5
|
+
import type { SandboxBackend, SandboxConfig, SandboxPolicy, SandboxProcess, SandboxTermination } from "./types";
|
|
6
|
+
|
|
7
|
+
export interface DockerCommandResult { exitCode: number; stdout: string; stderr: string }
|
|
8
|
+
interface DockerChild {
|
|
9
|
+
stdin: SandboxProcess["stdin"];
|
|
10
|
+
stdout: ReadableStream<Uint8Array>;
|
|
11
|
+
stderr: ReadableStream<Uint8Array>;
|
|
12
|
+
exited: Promise<number>;
|
|
13
|
+
kill(signal?: number | NodeJS.Signals): void;
|
|
14
|
+
}
|
|
15
|
+
export interface DockerBackendOptions {
|
|
16
|
+
dockerPath?: string;
|
|
17
|
+
env?: Record<string, string | undefined>;
|
|
18
|
+
platform?: NodeJS.Platform;
|
|
19
|
+
run?: (argv: readonly string[]) => Promise<DockerCommandResult>;
|
|
20
|
+
spawn?: (argv: readonly string[]) => DockerChild;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Launch failed, but the exact owned container still requires verified removal. */
|
|
24
|
+
export class SandboxLaunchError extends Error {
|
|
25
|
+
readonly residual: readonly string[];
|
|
26
|
+
readonly #retry: () => Promise<SandboxTermination>;
|
|
27
|
+
|
|
28
|
+
constructor(message: string, residual: readonly string[], cleanup: () => Promise<SandboxTermination>, options?: ErrorOptions) {
|
|
29
|
+
super(message, options);
|
|
30
|
+
this.name = "SandboxLaunchError";
|
|
31
|
+
this.residual = Object.freeze([...residual]);
|
|
32
|
+
this.#retry = cleanup;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
cleanup(): Promise<SandboxTermination> { return this.#retry(); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const OWNER = "dev.jeo.sandbox.owner";
|
|
39
|
+
const SESSION = "dev.jeo.sandbox.session";
|
|
40
|
+
const REVISION = "dev.jeo.sandbox.revision";
|
|
41
|
+
const CLI_OUTPUT_BYTES = 1_000_000; // Same transport ceiling as messenger workers.
|
|
42
|
+
|
|
43
|
+
export function createDockerBackend(options: DockerBackendOptions = {}): SandboxBackend {
|
|
44
|
+
const executable = options.dockerPath ?? Bun.which("docker") ?? "docker";
|
|
45
|
+
const platform = options.platform ?? process.platform;
|
|
46
|
+
const suppliedEnv = options.env ?? process.env;
|
|
47
|
+
const environment: Record<string, string> = {};
|
|
48
|
+
for (const key of ["PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "TEMP", "TMP", "SystemRoot", "SYSTEMROOT", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "PATHEXT", "DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_API_VERSION"]) {
|
|
49
|
+
if (suppliedEnv[key] !== undefined) environment[key] = suppliedEnv[key]!;
|
|
50
|
+
}
|
|
51
|
+
const owner = crypto.randomUUID();
|
|
52
|
+
const run = options.run ?? (async (argv: readonly string[]): Promise<DockerCommandResult> => {
|
|
53
|
+
const child = Bun.spawn([...argv], { env: environment, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
54
|
+
const timer = setTimeout(() => child.kill(), 30_000);
|
|
55
|
+
const collect = async (stream: ReadableStream<Uint8Array>): Promise<string> => {
|
|
56
|
+
const reader = stream.getReader();
|
|
57
|
+
const chunks: Uint8Array[] = [];
|
|
58
|
+
let bytes = 0;
|
|
59
|
+
try {
|
|
60
|
+
for (;;) {
|
|
61
|
+
const next = await reader.read();
|
|
62
|
+
if (next.done) break;
|
|
63
|
+
bytes += next.value.byteLength;
|
|
64
|
+
if (bytes > CLI_OUTPUT_BYTES) { child.kill(); throw new Error("Docker control output exceeded its bound"); }
|
|
65
|
+
chunks.push(next.value);
|
|
66
|
+
}
|
|
67
|
+
} finally { reader.releaseLock(); }
|
|
68
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
69
|
+
};
|
|
70
|
+
try {
|
|
71
|
+
const [exitCode, stdout, stderr] = await Promise.all([child.exited, collect(child.stdout), collect(child.stderr)]);
|
|
72
|
+
return { exitCode, stdout, stderr };
|
|
73
|
+
} finally { clearTimeout(timer); }
|
|
74
|
+
});
|
|
75
|
+
const spawn = options.spawn ?? ((argv: readonly string[]): DockerChild =>
|
|
76
|
+
Bun.spawn([...argv], { env: environment, stdin: "pipe", stdout: "pipe", stderr: "pipe" }) as unknown as DockerChild);
|
|
77
|
+
const command = (args: readonly string[]) => run([executable, ...args]);
|
|
78
|
+
|
|
79
|
+
function normalizeMountSource(value: string): string {
|
|
80
|
+
if (platform !== "win32") return value;
|
|
81
|
+
const match = /^\/(?:run\/desktop\/mnt\/host|host_mnt)\/([a-zA-Z])(?:\/(.*))?$/.exec(value.replace(/\\/g, "/"));
|
|
82
|
+
if (!match) return value;
|
|
83
|
+
const drive = `${match[1]!.toUpperCase()}:\\`;
|
|
84
|
+
return match[2] ? path.win32.join(drive, ...match[2].split("/").filter(Boolean)) : drive;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function checked(args: readonly string[], context: string): Promise<string> {
|
|
88
|
+
let result: DockerCommandResult;
|
|
89
|
+
try { result = await command(args); }
|
|
90
|
+
catch { throw new Error(`SANDBOX_UNAVAILABLE: Docker ${context} could not run`); }
|
|
91
|
+
if (result.exitCode !== 0) throw new Error(`SANDBOX_UNAVAILABLE: Docker ${context} failed; check the local daemon and existing image`);
|
|
92
|
+
return result.stdout.trim();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function preflight(config: SandboxConfig): Promise<{ imageId: string; platform: string }> {
|
|
96
|
+
validateSandboxConfig(config);
|
|
97
|
+
const explicit = environment.DOCKER_CONTEXT ? undefined : environment.DOCKER_HOST;
|
|
98
|
+
if (explicit && !/^(unix:\/\/\/|npipe:\/\/)/i.test(explicit)) throw new Error("SANDBOX_UNAVAILABLE: only local Docker endpoints are authorized");
|
|
99
|
+
const context = await checked(["context", "show"], "context discovery");
|
|
100
|
+
const endpointText = await checked(["context", "inspect", context, "--format", "{{json .Endpoints.docker.Host}}"], "context inspection");
|
|
101
|
+
let endpoint: unknown;
|
|
102
|
+
try { endpoint = JSON.parse(endpointText); } catch { throw new Error("SANDBOX_UNAVAILABLE: malformed Docker endpoint"); }
|
|
103
|
+
const effective = explicit ?? endpoint;
|
|
104
|
+
if (typeof effective !== "string" || !/^(unix:\/\/\/|npipe:\/\/)/i.test(effective)) throw new Error("SANDBOX_UNAVAILABLE: remote Docker context is not authorized");
|
|
105
|
+
environment.DOCKER_HOST = effective;
|
|
106
|
+
delete environment.DOCKER_CONTEXT; // Pin the verified local endpoint for every later command.
|
|
107
|
+
let info: { OSType?: string };
|
|
108
|
+
let image: { Id?: string; Os?: string; Architecture?: string };
|
|
109
|
+
try {
|
|
110
|
+
info = JSON.parse(await checked(["info", "--format", "{{json .}}"], "daemon inspection"));
|
|
111
|
+
image = JSON.parse(await checked(["image", "inspect", config.image, "--format", "{{json .}}"], "local image inspection"));
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error instanceof Error && error.message.startsWith("SANDBOX_")) throw error;
|
|
114
|
+
throw new Error("SANDBOX_UNAVAILABLE: malformed Docker daemon or image response");
|
|
115
|
+
}
|
|
116
|
+
if (info.OSType !== "linux" || image.Os !== "linux") throw new Error("SANDBOX_UNAVAILABLE: a Linux Docker daemon and Linux Bun image are required");
|
|
117
|
+
if (!image.Id || !/^sha256:[a-f0-9]{64}$/.test(image.Id) || !image.Architecture) throw new Error("SANDBOX_UNAVAILABLE: local image has no immutable identity");
|
|
118
|
+
return { imageId: image.Id, platform: `linux/${image.Architecture}` };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function acquireLaunchLock(sources: readonly string[]): Promise<() => Promise<void>> {
|
|
122
|
+
// One lock per native user, independent of session/config/backend instance.
|
|
123
|
+
const directory = await ensurePrivateSandboxDirectory(path.join(await nativeSandboxHome(), ".jeo", "sandbox-launch"));
|
|
124
|
+
if (sources.some(source => sandboxPathContains(source, directory) || sandboxPathContains(directory, source))) {
|
|
125
|
+
throw new Error("SANDBOX_POLICY: mount overlaps the parent launch lock");
|
|
126
|
+
}
|
|
127
|
+
const lock = path.join(directory, "launch.lock");
|
|
128
|
+
const deadline = Date.now() + 30_000;
|
|
129
|
+
for (;;) {
|
|
130
|
+
try {
|
|
131
|
+
await fs.mkdir(lock, { mode: 0o700 });
|
|
132
|
+
const owned = await fs.lstat(lock, { bigint: true });
|
|
133
|
+
return async () => {
|
|
134
|
+
let current;
|
|
135
|
+
try { current = await fs.lstat(lock, { bigint: true }); }
|
|
136
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; }
|
|
137
|
+
if (!current.isDirectory() || current.isSymbolicLink() || current.dev !== owned.dev || current.ino !== owned.ino) throw new Error("SANDBOX_UNAVAILABLE: launch lock ownership changed");
|
|
138
|
+
await fs.rmdir(lock);
|
|
139
|
+
};
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST" || Date.now() >= deadline) throw new Error("SANDBOX_UNAVAILABLE: managed launch lock unavailable", { cause: error });
|
|
142
|
+
try {
|
|
143
|
+
const stat = await fs.lstat(lock);
|
|
144
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("SANDBOX_UNAVAILABLE: invalid managed launch lock");
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
await Bun.sleep(25);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Every managed launch holds the same native-user lock. Identical/disjoint bind
|
|
155
|
+
// roots keep hands from renaming another source or its ancestors. Inspect Source
|
|
156
|
+
// is admission metadata, not physical host/guest inode attestation; native host
|
|
157
|
+
// and Docker operators are outside this model-execution boundary.
|
|
158
|
+
async function validateManagedRoots(sources: readonly string[]): Promise<void> {
|
|
159
|
+
const ids = (await checked(["container", "ls", "--all", "--filter", `label=${OWNER}`, "--format", "{{.ID}}"], "managed container discovery")).split(/\r?\n/).filter(Boolean);
|
|
160
|
+
for (const id of ids) {
|
|
161
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error("SANDBOX_UNAVAILABLE: invalid managed container identity");
|
|
162
|
+
let record: { Config?: { Labels?: Record<string, string> }; Mounts?: { Type?: string; Source?: string; Destination?: string }[] };
|
|
163
|
+
try { record = JSON.parse(await checked(["inspect", "--format", "{{json .}}", id], "managed mount inspection")); }
|
|
164
|
+
catch { throw new Error("SANDBOX_UNAVAILABLE: managed mount metadata unavailable"); }
|
|
165
|
+
const labels = record.Config?.Labels;
|
|
166
|
+
if (!labels?.[OWNER] || !labels[SESSION] || !/^\d+$/.test(labels[REVISION] ?? "") || !Array.isArray(record.Mounts)) throw new Error("SANDBOX_UNAVAILABLE: unknown managed ownership or mounts");
|
|
167
|
+
for (const mount of record.Mounts) {
|
|
168
|
+
if (mount.Type === "tmpfs" && mount.Destination === "/tmp") continue;
|
|
169
|
+
const normalizedSource = typeof mount.Source === "string" ? normalizeMountSource(mount.Source) : "";
|
|
170
|
+
if (mount.Type !== "bind" || !normalizedSource || !path.isAbsolute(normalizedSource)) throw new Error("SANDBOX_UNAVAILABLE: unknown managed mount metadata");
|
|
171
|
+
const source = await canonicalSandboxPath(normalizedSource);
|
|
172
|
+
if (sources.some(candidate => candidate !== source && (sandboxPathContains(candidate, source) || sandboxPathContains(source, candidate)))) {
|
|
173
|
+
throw new Error("SANDBOX_MOUNT_OVERLAP: managed mount roots must be identical or disjoint");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function launch(policy: SandboxPolicy, bundlePath: string): Promise<SandboxProcess> {
|
|
180
|
+
const qualified = await preflight(policy.config);
|
|
181
|
+
const bundle = await canonicalSandboxPath(bundlePath);
|
|
182
|
+
if (!(await fs.stat(bundle)).isFile()) throw new Error("SANDBOX_POLICY: hands bundle is not a regular file");
|
|
183
|
+
const sources = [...policy.mounts.map(mount => mount.host), bundle];
|
|
184
|
+
for (let index = 0; index < sources.length; index++) {
|
|
185
|
+
for (const other of sources.slice(index + 1)) {
|
|
186
|
+
if (other !== sources[index] && (sandboxPathContains(other, sources[index]!) || sandboxPathContains(sources[index]!, other))) throw new Error("SANDBOX_MOUNT_OVERLAP: mount roots must be identical or disjoint");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const sourceIdentity = await Promise.all(sources.map(source => fs.stat(source, { bigint: true })));
|
|
190
|
+
const validateSources = async (): Promise<void> => {
|
|
191
|
+
for (let index = 0; index < sources.length; index++) {
|
|
192
|
+
const source = sources[index]!;
|
|
193
|
+
const stat = await fs.stat(source, { bigint: true });
|
|
194
|
+
if (await canonicalSandboxPath(source) !== source || stat.dev !== sourceIdentity[index]!.dev || stat.ino !== sourceIdentity[index]!.ino) throw new Error("SANDBOX_MOUNT_IDENTITY: host mount source changed during launch");
|
|
195
|
+
}
|
|
196
|
+
for (const mount of policy.mounts) await validateSandboxMountSource(mount.host, policy.privatePaths ?? [], [environment.DOCKER_HOST!.startsWith("unix://") ? environment.DOCKER_HOST!.slice(7) : "", suppliedEnv.SSH_AUTH_SOCK ?? ""].filter(Boolean));
|
|
197
|
+
};
|
|
198
|
+
const mounts: string[] = [];
|
|
199
|
+
for (const mount of policy.mounts) {
|
|
200
|
+
if (await canonicalSandboxPath(mount.host) !== mount.host || sandboxPathContains(mount.host, bundle)) {
|
|
201
|
+
throw new Error("SANDBOX_POLICY: mount changed or exposes the trusted hands bundle");
|
|
202
|
+
}
|
|
203
|
+
// Docker's --mount grammar is CSV, not shell syntax. Refuse ambiguous source names.
|
|
204
|
+
if (/[,\r\n\0]/.test(mount.host) || !/^\/(workspace|grants\/[a-f0-9]{64})$/.test(mount.guest)) throw new Error("SANDBOX_POLICY: unsupported mount path");
|
|
205
|
+
mounts.push("--mount", `type=bind,source=${mount.host},target=${mount.guest}${mount.mode === "ro" ? ",readonly" : ""}`);
|
|
206
|
+
}
|
|
207
|
+
if (/[,\r\n\0]/.test(bundle)) throw new Error("SANDBOX_POLICY: unsupported bundle path");
|
|
208
|
+
const name = `jeo-sbx-${new Bun.CryptoHasher("sha256").update(policy.sessionKey).digest("hex").slice(0, 20)}-r${policy.revision}-${crypto.randomUUID()}`;
|
|
209
|
+
const uid = platform === "win32" ? undefined : process.getuid?.();
|
|
210
|
+
const gid = platform === "win32" ? undefined : process.getgid?.();
|
|
211
|
+
if (uid === 0) throw new Error("SANDBOX_UNAVAILABLE: run the host controller as a non-root user");
|
|
212
|
+
const user = uid === undefined || gid === undefined ? "bun" : `${uid}:${gid}`;
|
|
213
|
+
const createArgs = [
|
|
214
|
+
"create", "--pull=never", "--name", name,
|
|
215
|
+
"--label", `${OWNER}=${owner}`, "--label", `${SESSION}=${policy.sessionKey}`, "--label", `${REVISION}=${policy.revision}`,
|
|
216
|
+
"--init", "--interactive", "--network", "none", "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges",
|
|
217
|
+
"--pids-limit", String(policy.config.limits.pids), "--memory", String(policy.config.limits.memoryBytes), "--cpus", String(policy.config.limits.cpus),
|
|
218
|
+
"--user", user, "--tmpfs", "/tmp:rw,nosuid,nodev,mode=1777", "--workdir", "/workspace",
|
|
219
|
+
"--env", "HOME=/tmp/jeo-home", "--env", "JEO_CONFIG_DIR=/tmp/jeo-home", "--env", "JEO_SANDBOX_HANDS=1",
|
|
220
|
+
...mounts, "--mount", `type=bind,source=${bundle},target=/jeo/hands.js,readonly`,
|
|
221
|
+
"--entrypoint", "/usr/local/bin/bun", qualified.imageId, "/jeo/hands.js",
|
|
222
|
+
];
|
|
223
|
+
let stopping: Promise<SandboxTermination> | undefined;
|
|
224
|
+
const stop = (): Promise<SandboxTermination> => {
|
|
225
|
+
stopping ??= (async () => {
|
|
226
|
+
try {
|
|
227
|
+
const before = await command(["inspect", "--format", "{{json .}}", name]);
|
|
228
|
+
if (before.exitCode === 0) {
|
|
229
|
+
const record = JSON.parse(before.stdout) as { Config?: { Labels?: Record<string, string> } };
|
|
230
|
+
const labels = record.Config?.Labels;
|
|
231
|
+
if (labels?.[OWNER] !== owner || labels[SESSION] !== policy.sessionKey || labels[REVISION] !== String(policy.revision)) {
|
|
232
|
+
return { gone: false, residual: [name] };
|
|
233
|
+
}
|
|
234
|
+
await command(["rm", "--force", name]);
|
|
235
|
+
}
|
|
236
|
+
const remaining = await command(["container", "ls", "--all", "--filter", `name=^/${name}$`, "--format", "{{.ID}}"]);
|
|
237
|
+
if (remaining.exitCode !== 0 || remaining.stdout.trim()) return { gone: false, residual: [name] };
|
|
238
|
+
const revision = await command(["container", "ls", "--all", "--filter", `label=${OWNER}=${owner}`, "--filter", `label=${SESSION}=${policy.sessionKey}`, "--filter", `label=${REVISION}=${policy.revision}`, "--format", "{{.Names}}"]);
|
|
239
|
+
if (revision.exitCode !== 0) return { gone: false, residual: [name] };
|
|
240
|
+
const residual = revision.stdout.trim().split(/\r?\n/).filter(Boolean);
|
|
241
|
+
return { gone: residual.length === 0, residual };
|
|
242
|
+
} catch { return { gone: false, residual: [name] }; }
|
|
243
|
+
})();
|
|
244
|
+
const attempt = stopping;
|
|
245
|
+
void attempt.then(result => { if (!result.gone && stopping === attempt) stopping = undefined; });
|
|
246
|
+
return attempt;
|
|
247
|
+
};
|
|
248
|
+
const unlock = await acquireLaunchLock(sources);
|
|
249
|
+
let released = false;
|
|
250
|
+
const cleanupOwned = async (): Promise<SandboxTermination> => {
|
|
251
|
+
const result = await stop();
|
|
252
|
+
if (!released) {
|
|
253
|
+
try { await unlock(); released = true; }
|
|
254
|
+
catch { return { gone: false, residual: [...result.residual, "managed launch lock"] }; }
|
|
255
|
+
}
|
|
256
|
+
return result;
|
|
257
|
+
};
|
|
258
|
+
try {
|
|
259
|
+
await validateManagedRoots(sources);
|
|
260
|
+
await validateSources();
|
|
261
|
+
await checked(createArgs, "container creation");
|
|
262
|
+
await validateSources();
|
|
263
|
+
const child = spawn([executable, "start", "--attach", "--interactive", name]);
|
|
264
|
+
const deadline = Date.now() + 30_000;
|
|
265
|
+
for (;;) {
|
|
266
|
+
const record = JSON.parse(await checked(["inspect", "--format", "{{json .}}", name], "container start verification")) as { Config?: { Labels?: Record<string, string> }; State?: { Running?: boolean }; Mounts?: { Type?: string; Source?: string; Destination?: string; RW?: boolean }[] };
|
|
267
|
+
const labels = record.Config?.Labels;
|
|
268
|
+
if (labels?.[OWNER] !== owner || labels[SESSION] !== policy.sessionKey || labels[REVISION] !== String(policy.revision) || !Array.isArray(record.Mounts)) throw new Error("SANDBOX_UNAVAILABLE: started container ownership or mount metadata changed");
|
|
269
|
+
const actual = record.Mounts.filter(mount => mount.Type === "bind");
|
|
270
|
+
const expected = [...policy.mounts.map(mount => ({ source: mount.host, guest: mount.guest, writable: mount.mode === "rw" })), { source: bundle, guest: "/jeo/hands.js", writable: false }];
|
|
271
|
+
if (actual.length !== expected.length) throw new Error("SANDBOX_UNAVAILABLE: unexpected container bind mounts");
|
|
272
|
+
for (const mount of expected) {
|
|
273
|
+
const observed = actual.find(candidate => candidate.Destination === mount.guest);
|
|
274
|
+
const normalizedObserved = observed?.Source ? normalizeMountSource(observed.Source) : "";
|
|
275
|
+
if (!normalizedObserved || await canonicalSandboxPath(normalizedObserved) !== mount.source || observed?.RW !== mount.writable) throw new Error("SANDBOX_UNAVAILABLE: actual container bind configuration changed");
|
|
276
|
+
}
|
|
277
|
+
if (record.State?.Running === true) break;
|
|
278
|
+
if (Date.now() >= deadline) throw new Error("SANDBOX_UNAVAILABLE: container did not start");
|
|
279
|
+
await Bun.sleep(25);
|
|
280
|
+
}
|
|
281
|
+
await validateSources();
|
|
282
|
+
await unlock();
|
|
283
|
+
released = true;
|
|
284
|
+
return { name, stdin: child.stdin, stdout: child.stdout, stderr: child.stderr, exited: child.exited, stop };
|
|
285
|
+
} catch (error) {
|
|
286
|
+
const cleanup = await cleanupOwned();
|
|
287
|
+
if (!cleanup.gone) throw new SandboxLaunchError(`REVOKE_INCOMPLETE: failed sandbox launch left owned resources for ${name}`, cleanup.residual, cleanupOwned, { cause: error });
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return { preflight, launch };
|
|
293
|
+
}
|