jeo-code 0.12.13 → 0.12.15
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 +42 -0
- package/README.ja.md +32 -8
- package/README.ko.md +32 -8
- package/README.md +32 -8
- package/README.zh.md +32 -8
- 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 +295 -0
- package/src/agent/sandbox/bundle.ts +37 -0
- package/src/agent/sandbox/control.ts +49 -0
- package/src/agent/sandbox/docker.ts +283 -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 +155 -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 +261 -35
- package/src/commands/messenger.ts +105 -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 +51 -9
- package/src/messenger/discord-setup.ts +122 -20
- package/src/messenger/discord.ts +37 -43
- package/src/messenger/gateway.ts +33 -10
- package/src/messenger/model-selection.ts +101 -0
- package/src/messenger/session-pool.ts +189 -35
- package/src/messenger/types.ts +3 -2
- package/src/skills/catalog.ts +207 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { canonicalSandboxPath, createSandboxPolicy, sandboxPathContains } from "./policy";
|
|
5
|
+
import type { SandboxConfig, SandboxMount, SandboxSupervisor, SandboxTermination } from "./types";
|
|
6
|
+
|
|
7
|
+
/** Constructed by trusted parent entry points, never from model/tool arguments. */
|
|
8
|
+
export interface SandboxActor {
|
|
9
|
+
readonly origin: "terminal" | "discord" | "model";
|
|
10
|
+
readonly sessionKey: string;
|
|
11
|
+
readonly userId?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface GrantRequest {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly sessionKey: string;
|
|
17
|
+
readonly revision: number;
|
|
18
|
+
readonly host: string;
|
|
19
|
+
readonly mode: "ro" | "rw";
|
|
20
|
+
readonly status: "pending" | "approved" | "denied" | "stale";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface GrantAuthoritySnapshot {
|
|
24
|
+
readonly sessionKey: string;
|
|
25
|
+
readonly revision: number;
|
|
26
|
+
readonly grants: readonly SandboxMount[];
|
|
27
|
+
readonly requests: readonly GrantRequest[];
|
|
28
|
+
readonly blocked: boolean;
|
|
29
|
+
readonly stateDirectory: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GrantAuthority {
|
|
33
|
+
requestAccess(host: string, mode: "ro" | "rw"): Promise<GrantRequest>;
|
|
34
|
+
approve(requestId: string, actor: SandboxActor): Promise<GrantRequest>;
|
|
35
|
+
reject(requestId: string, actor: SandboxActor): Promise<GrantRequest>;
|
|
36
|
+
revoke(actor: SandboxActor): Promise<SandboxTermination>;
|
|
37
|
+
snapshot(actor?: SandboxActor): GrantAuthoritySnapshot;
|
|
38
|
+
awaitDecision(requestId: string, signal?: AbortSignal): Promise<GrantRequest>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface GrantAuthorityOptions {
|
|
42
|
+
sessionKey: string;
|
|
43
|
+
cwd: string;
|
|
44
|
+
config: SandboxConfig;
|
|
45
|
+
supervisor: SandboxSupervisor;
|
|
46
|
+
allowedUserIds: readonly string[];
|
|
47
|
+
stateDirectory: string;
|
|
48
|
+
privateRoots?: readonly string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type DecisionWaiter = { resolve(value: GrantRequest): void; reject(reason: Error): void };
|
|
52
|
+
|
|
53
|
+
/** Create app-private state, or verify existing state without widening its ACL. */
|
|
54
|
+
export async function ensurePrivateSandboxDirectory(directory: string): Promise<string> {
|
|
55
|
+
const canonical = await canonicalSandboxPath(directory, false);
|
|
56
|
+
if (process.platform === "win32") {
|
|
57
|
+
const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT;
|
|
58
|
+
if (!systemRoot || !path.isAbsolute(systemRoot)) throw new Error("SANDBOX_STATE: Windows system directory is unavailable");
|
|
59
|
+
await fs.mkdir(path.dirname(canonical), { recursive: true });
|
|
60
|
+
// Fixed parent-owned program; paths are data, never interpolated into code.
|
|
61
|
+
// Windows PowerShell 5.1 provides .NET Framework's atomic ACL creation API.
|
|
62
|
+
const script = `
|
|
63
|
+
$ErrorActionPreference = 'Stop'
|
|
64
|
+
try {
|
|
65
|
+
$directory = $env:JEO_SANDBOX_PRIVATE_DIRECTORY
|
|
66
|
+
$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
67
|
+
$inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
|
|
68
|
+
if (-not [System.IO.Directory]::Exists($directory)) {
|
|
69
|
+
$security = New-Object System.Security.AccessControl.DirectorySecurity
|
|
70
|
+
$security.SetAccessRuleProtection($true, $false)
|
|
71
|
+
$security.SetOwner($sid)
|
|
72
|
+
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', $inherit, 'None', 'Allow')
|
|
73
|
+
$security.AddAccessRule($rule)
|
|
74
|
+
[void][System.IO.Directory]::CreateDirectory($directory, $security)
|
|
75
|
+
}
|
|
76
|
+
if (([System.IO.File]::GetAttributes($directory) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'reparse point' }
|
|
77
|
+
$actual = [System.IO.Directory]::GetAccessControl($directory)
|
|
78
|
+
if (-not $actual.AreAccessRulesProtected -or $actual.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value) { throw 'owner or inheritance' }
|
|
79
|
+
$rules = $actual.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
|
|
80
|
+
if ($rules.Count -ne 1) { throw 'unexpected access rules' }
|
|
81
|
+
$rule = $rules[0]
|
|
82
|
+
if ($rule.IdentityReference.Value -ne $sid.Value -or $rule.IsInherited -or $rule.AccessControlType -ne 'Allow' -or $rule.FileSystemRights -ne 'FullControl' -or $rule.InheritanceFlags -ne $inherit -or $rule.PropagationFlags -ne 'None') { throw 'non-private rule' }
|
|
83
|
+
[Console]::Out.Write('JEO_PRIVATE_DIRECTORY_VERIFIED')
|
|
84
|
+
exit 0
|
|
85
|
+
} catch { exit 1 }
|
|
86
|
+
`;
|
|
87
|
+
const executable = path.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
88
|
+
const child = Bun.spawn([executable, "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], {
|
|
89
|
+
env: { SystemRoot: systemRoot, JEO_SANDBOX_PRIVATE_DIRECTORY: canonical },
|
|
90
|
+
stdin: "ignore", stdout: "pipe", stderr: "ignore",
|
|
91
|
+
});
|
|
92
|
+
const timer = setTimeout(() => child.kill(), 30_000);
|
|
93
|
+
try {
|
|
94
|
+
const [exitCode, output] = await Promise.all([child.exited, new Response(child.stdout).text()]);
|
|
95
|
+
if (exitCode !== 0 || output !== "JEO_PRIVATE_DIRECTORY_VERIFIED") {
|
|
96
|
+
throw new Error("SANDBOX_STATE: private Windows owner-only ACL could not be verified");
|
|
97
|
+
}
|
|
98
|
+
} finally { clearTimeout(timer); }
|
|
99
|
+
} else {
|
|
100
|
+
await fs.mkdir(canonical, { recursive: true, mode: 0o700 });
|
|
101
|
+
}
|
|
102
|
+
const stat = await fs.lstat(canonical);
|
|
103
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() ||
|
|
104
|
+
(process.platform !== "win32" && ((stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.()))) {
|
|
105
|
+
throw new Error("SANDBOX_STATE: authority state must be a private owner-only directory");
|
|
106
|
+
}
|
|
107
|
+
return canonical;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** All mutable authority remains in this parent closure, outside worker mounts. */
|
|
111
|
+
export async function createGrantAuthority(options: GrantAuthorityOptions): Promise<GrantAuthority> {
|
|
112
|
+
const sessionKey = options.sessionKey;
|
|
113
|
+
const supervisor = options.supervisor;
|
|
114
|
+
const allowedUserIds = new Set(options.allowedUserIds);
|
|
115
|
+
const cwd = await canonicalSandboxPath(options.cwd);
|
|
116
|
+
const stateDirectory = await canonicalSandboxPath(options.stateDirectory, false);
|
|
117
|
+
const privatePaths = await Promise.all([stateDirectory, ...(options.privateRoots ?? [])]
|
|
118
|
+
.map(value => canonicalSandboxPath(value, false)));
|
|
119
|
+
if (sandboxPathContains(cwd, stateDirectory) || sandboxPathContains(stateDirectory, cwd)) {
|
|
120
|
+
throw new Error("SANDBOX_STATE: authority state must be outside workspace mounts");
|
|
121
|
+
}
|
|
122
|
+
const initialPolicy = await createSandboxPolicy({
|
|
123
|
+
sessionKey, revision: 0, cwd, config: options.config, privatePaths,
|
|
124
|
+
});
|
|
125
|
+
await ensurePrivateSandboxDirectory(stateDirectory);
|
|
126
|
+
const config = initialPolicy.config;
|
|
127
|
+
let revision = initialPolicy.revision;
|
|
128
|
+
let grants: readonly SandboxMount[] = Object.freeze([]);
|
|
129
|
+
const requests = new Map<string, GrantRequest>();
|
|
130
|
+
const waiters = new Map<string, Set<DecisionWaiter>>();
|
|
131
|
+
let failed = false;
|
|
132
|
+
let transitioning = false;
|
|
133
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
134
|
+
|
|
135
|
+
function serialize<T>(operation: () => Promise<T> | T): Promise<T> {
|
|
136
|
+
const next = queue.then(operation);
|
|
137
|
+
queue = next.catch(() => undefined);
|
|
138
|
+
return next;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function authorize(actor: SandboxActor): void {
|
|
142
|
+
if (!actor || actor.origin === "model" ||
|
|
143
|
+
(actor.origin !== "terminal" && actor.origin !== "discord") ||
|
|
144
|
+
(actor.origin === "discord" && (!actor.userId || !allowedUserIds.has(actor.userId)))) {
|
|
145
|
+
throw new Error("UNAUTHORIZED: sandbox decisions require an authenticated human");
|
|
146
|
+
}
|
|
147
|
+
if (actor.sessionKey !== sessionKey) throw new Error("SESSION_MISMATCH: sandbox actor belongs to another session");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function requireAvailable(): void {
|
|
151
|
+
if (failed) throw new Error("REVOKE_INCOMPLETE: sandbox authority is blocked after an unconfirmed transition");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function pendingRequest(id: string): GrantRequest {
|
|
155
|
+
const request = requests.get(id);
|
|
156
|
+
if (!request) throw new Error("UNKNOWN_REQUEST: sandbox request does not exist in this session");
|
|
157
|
+
if (request.revision !== revision || request.status !== "pending") {
|
|
158
|
+
throw new Error("STALE_REQUEST: sandbox request is no longer pending at the current revision");
|
|
159
|
+
}
|
|
160
|
+
return request;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function decide(request: GrantRequest, status: GrantRequest["status"]): GrantRequest {
|
|
164
|
+
const decided = Object.freeze({ ...request, status });
|
|
165
|
+
requests.set(request.id, decided);
|
|
166
|
+
const listeners = waiters.get(request.id);
|
|
167
|
+
waiters.delete(request.id);
|
|
168
|
+
for (const waiter of listeners ?? []) waiter.resolve(decided);
|
|
169
|
+
return decided;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function stalePending(): void {
|
|
173
|
+
for (const request of requests.values()) {
|
|
174
|
+
if (request.status === "pending") decide(request, "stale");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function block(error: unknown): never {
|
|
179
|
+
failed = true;
|
|
180
|
+
stalePending();
|
|
181
|
+
throw new Error("REVOKE_INCOMPLETE: sandbox transition could not confirm removal of old authority", { cause: error });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return Object.freeze({
|
|
185
|
+
async requestAccess(host: string, mode: "ro" | "rw"): Promise<GrantRequest> {
|
|
186
|
+
return serialize(async () => {
|
|
187
|
+
requireAvailable();
|
|
188
|
+
if (mode !== "ro" && mode !== "rw") throw new Error("SANDBOX_POLICY: invalid access mode");
|
|
189
|
+
if (typeof host !== "string" || !host.trim()) throw new Error("SANDBOX_PATH: invalid host path");
|
|
190
|
+
const canonical = await canonicalSandboxPath(host, true, cwd);
|
|
191
|
+
// Reuse the policy validator for every private-path and mount restriction.
|
|
192
|
+
await createSandboxPolicy({ sessionKey, revision, cwd, config, grants: [...grants, { host: canonical, mode }], privatePaths });
|
|
193
|
+
const request: GrantRequest = Object.freeze({
|
|
194
|
+
id: randomUUID(), sessionKey, revision, host: canonical, mode, status: "pending",
|
|
195
|
+
});
|
|
196
|
+
requests.set(request.id, request);
|
|
197
|
+
return request;
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
async approve(requestId: string, actor: SandboxActor): Promise<GrantRequest> {
|
|
202
|
+
authorize(actor);
|
|
203
|
+
return serialize(async () => {
|
|
204
|
+
requireAvailable();
|
|
205
|
+
const request = pendingRequest(requestId);
|
|
206
|
+
try {
|
|
207
|
+
if (await canonicalSandboxPath(request.host) !== request.host) {
|
|
208
|
+
throw new Error("Requested resource changed its canonical path");
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
decide(request, "stale");
|
|
212
|
+
throw new Error("STALE_REQUEST: requested resource no longer has its original canonical path", { cause: error });
|
|
213
|
+
}
|
|
214
|
+
const policy = await createSandboxPolicy({
|
|
215
|
+
sessionKey, revision: revision + 1, cwd, config,
|
|
216
|
+
grants: [...grants, { host: request.host, mode: request.mode }], privatePaths,
|
|
217
|
+
});
|
|
218
|
+
transitioning = true;
|
|
219
|
+
try {
|
|
220
|
+
await supervisor.replacePolicy(policy);
|
|
221
|
+
revision = policy.revision;
|
|
222
|
+
grants = Object.freeze(policy.mounts.filter(mount => mount.host !== cwd));
|
|
223
|
+
const approved = decide(request, "approved");
|
|
224
|
+
stalePending();
|
|
225
|
+
return approved;
|
|
226
|
+
} catch (error) {
|
|
227
|
+
return block(error);
|
|
228
|
+
} finally {
|
|
229
|
+
transitioning = false;
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
async reject(requestId: string, actor: SandboxActor): Promise<GrantRequest> {
|
|
235
|
+
authorize(actor);
|
|
236
|
+
return serialize(() => {
|
|
237
|
+
requireAvailable();
|
|
238
|
+
return decide(pendingRequest(requestId), "denied");
|
|
239
|
+
});
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
async revoke(actor: SandboxActor): Promise<SandboxTermination> {
|
|
243
|
+
authorize(actor);
|
|
244
|
+
return serialize(async () => {
|
|
245
|
+
transitioning = true;
|
|
246
|
+
try {
|
|
247
|
+
const termination = await supervisor.revoke();
|
|
248
|
+
if (termination.gone !== true || termination.residual.length !== 0) {
|
|
249
|
+
throw new Error("REVOKE_INCOMPLETE: owned sandbox processes remain");
|
|
250
|
+
}
|
|
251
|
+
grants = Object.freeze([]);
|
|
252
|
+
revision++;
|
|
253
|
+
stalePending();
|
|
254
|
+
return Object.freeze({ gone: true, residual: Object.freeze([]) });
|
|
255
|
+
} catch (error) {
|
|
256
|
+
return block(error);
|
|
257
|
+
} finally {
|
|
258
|
+
transitioning = false;
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
snapshot(actor?: SandboxActor): GrantAuthoritySnapshot {
|
|
264
|
+
if (actor !== undefined) authorize(actor);
|
|
265
|
+
return Object.freeze({
|
|
266
|
+
sessionKey, revision, grants,
|
|
267
|
+
requests: Object.freeze([...requests.values()]),
|
|
268
|
+
blocked: failed || transitioning, stateDirectory,
|
|
269
|
+
});
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
async awaitDecision(requestId: string, signal?: AbortSignal): Promise<GrantRequest> {
|
|
273
|
+
const request = requests.get(requestId);
|
|
274
|
+
if (!request) throw new Error("UNKNOWN_REQUEST: sandbox request does not exist in this session");
|
|
275
|
+
if (signal?.aborted) throw new Error("ABORTED: waiting for sandbox decision was cancelled");
|
|
276
|
+
if (request.status !== "pending") return request;
|
|
277
|
+
return new Promise<GrantRequest>((resolve, reject) => {
|
|
278
|
+
const listeners = waiters.get(requestId) ?? new Set<DecisionWaiter>();
|
|
279
|
+
const cleanup = () => {
|
|
280
|
+
signal?.removeEventListener("abort", abort);
|
|
281
|
+
listeners.delete(waiter);
|
|
282
|
+
if (listeners.size === 0) waiters.delete(requestId);
|
|
283
|
+
};
|
|
284
|
+
const waiter: DecisionWaiter = {
|
|
285
|
+
resolve(value) { cleanup(); resolve(value); },
|
|
286
|
+
reject(error) { cleanup(); reject(error); },
|
|
287
|
+
};
|
|
288
|
+
const abort = () => waiter.reject(new Error("ABORTED: waiting for sandbox decision was cancelled"));
|
|
289
|
+
listeners.add(waiter);
|
|
290
|
+
waiters.set(requestId, listeners);
|
|
291
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
292
|
+
});
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import type { FileHandle } from "node:fs/promises";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { jeoHomeDir } from "../notify/paths";
|
|
6
|
+
import { ensurePrivateSandboxDirectory } from "./authority";
|
|
7
|
+
import source from "./hands-source.text";
|
|
8
|
+
|
|
9
|
+
/** The text asset is embedded by Bun's standalone compiler, not read from a release source tree. */
|
|
10
|
+
export async function prepareHandsBundle(): Promise<string> {
|
|
11
|
+
const directory = await ensurePrivateSandboxDirectory(path.join(jeoHomeDir(), "sandbox", "runtime"));
|
|
12
|
+
const digest = new Bun.CryptoHasher("sha256").update(source).digest("hex");
|
|
13
|
+
const file = path.join(await fs.realpath(directory), `${digest}.js`);
|
|
14
|
+
let existing: FileHandle;
|
|
15
|
+
try { existing = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW); }
|
|
16
|
+
catch (error) {
|
|
17
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
18
|
+
const staging = await fs.mkdtemp(path.join(directory, ".bundle-"));
|
|
19
|
+
const temporary = path.join(staging, "hands.js");
|
|
20
|
+
try {
|
|
21
|
+
const output = await fs.open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
22
|
+
try { await output.writeFile(source); await output.sync(); await output.chmod(0o400); }
|
|
23
|
+
finally { await output.close(); }
|
|
24
|
+
try { await fs.link(temporary, file); }
|
|
25
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
26
|
+
} finally { await fs.rm(staging, { recursive: true, force: true }); }
|
|
27
|
+
existing = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const stat = await existing.stat();
|
|
31
|
+
if (!stat.isFile() || (process.platform !== "win32" && ((stat.mode & 0o022) !== 0 || stat.uid !== process.getuid?.())) ||
|
|
32
|
+
new Bun.CryptoHasher("sha256").update(await existing.readFile()).digest("hex") !== digest) {
|
|
33
|
+
throw new Error("SANDBOX_BUNDLE: trusted runtime content or ownership changed");
|
|
34
|
+
}
|
|
35
|
+
} finally { await existing.close(); }
|
|
36
|
+
return file;
|
|
37
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { GrantAuthority, SandboxActor } from "./authority";
|
|
2
|
+
|
|
3
|
+
export interface SandboxControlResult {
|
|
4
|
+
handled: boolean;
|
|
5
|
+
output: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const USAGE = "Usage: /sandbox approve <requestId> | /sandbox deny <requestId> | /sandbox revoke | /sandbox status";
|
|
9
|
+
|
|
10
|
+
/** Only standalone, directly submitted parent controls can make human decisions. */
|
|
11
|
+
export async function handleSandboxControl(
|
|
12
|
+
text: string,
|
|
13
|
+
actor: SandboxActor,
|
|
14
|
+
authority: GrantAuthority,
|
|
15
|
+
): Promise<SandboxControlResult> {
|
|
16
|
+
// Multiline prompts, quotations, and embedded examples belong to conversation.
|
|
17
|
+
if (/[\r\n]/.test(text) || !/^\/sandbox(?:[\t ]|$)/.test(text)) {
|
|
18
|
+
return { handled: false, output: "" };
|
|
19
|
+
}
|
|
20
|
+
const parts = text.trim().split(/[\t ]+/);
|
|
21
|
+
const command = parts[1];
|
|
22
|
+
try {
|
|
23
|
+
if ((command === "approve" || command === "deny") && parts.length === 3) {
|
|
24
|
+
const request = command === "approve"
|
|
25
|
+
? await authority.approve(parts[2]!, actor)
|
|
26
|
+
: await authority.reject(parts[2]!, actor);
|
|
27
|
+
return { handled: true, output: `Sandbox request ${request.id}: ${request.status} (${request.mode} ${request.host}).` };
|
|
28
|
+
}
|
|
29
|
+
if (command === "revoke" && parts.length === 2) {
|
|
30
|
+
await authority.revoke(actor);
|
|
31
|
+
return { handled: true, output: "Sandbox access revoked; owned containers confirmed removed." };
|
|
32
|
+
}
|
|
33
|
+
if (command === "status" && parts.length === 2) {
|
|
34
|
+
const state = authority.snapshot(actor);
|
|
35
|
+
const pending = state.requests.filter(request => request.status === "pending");
|
|
36
|
+
const details = pending.map(request => `${request.id}: ${request.mode} ${request.host}`);
|
|
37
|
+
return {
|
|
38
|
+
handled: true,
|
|
39
|
+
output: [
|
|
40
|
+
`Sandbox session ${state.sessionKey}, revision ${state.revision}: ${state.blocked ? "blocked" : "ready"}; ${state.grants.length} grants, ${pending.length} pending requests.`,
|
|
41
|
+
...details,
|
|
42
|
+
].join("\n"),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return { handled: true, output: `Invalid sandbox control. ${USAGE}` };
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return { handled: true, output: `Sandbox control error: ${error instanceof Error ? error.message : "Unknown error"}` };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
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
|
+
async function checked(args: readonly string[], context: string): Promise<string> {
|
|
80
|
+
let result: DockerCommandResult;
|
|
81
|
+
try { result = await command(args); }
|
|
82
|
+
catch { throw new Error(`SANDBOX_UNAVAILABLE: Docker ${context} could not run`); }
|
|
83
|
+
if (result.exitCode !== 0) throw new Error(`SANDBOX_UNAVAILABLE: Docker ${context} failed; check the local daemon and existing image`);
|
|
84
|
+
return result.stdout.trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function preflight(config: SandboxConfig): Promise<{ imageId: string; platform: string }> {
|
|
88
|
+
validateSandboxConfig(config);
|
|
89
|
+
const explicit = environment.DOCKER_CONTEXT ? undefined : environment.DOCKER_HOST;
|
|
90
|
+
if (explicit && !/^(unix:\/\/\/|npipe:\/\/)/i.test(explicit)) throw new Error("SANDBOX_UNAVAILABLE: only local Docker endpoints are authorized");
|
|
91
|
+
const context = await checked(["context", "show"], "context discovery");
|
|
92
|
+
const endpointText = await checked(["context", "inspect", context, "--format", "{{json .Endpoints.docker.Host}}"], "context inspection");
|
|
93
|
+
let endpoint: unknown;
|
|
94
|
+
try { endpoint = JSON.parse(endpointText); } catch { throw new Error("SANDBOX_UNAVAILABLE: malformed Docker endpoint"); }
|
|
95
|
+
const effective = explicit ?? endpoint;
|
|
96
|
+
if (typeof effective !== "string" || !/^(unix:\/\/\/|npipe:\/\/)/i.test(effective)) throw new Error("SANDBOX_UNAVAILABLE: remote Docker context is not authorized");
|
|
97
|
+
environment.DOCKER_HOST = effective;
|
|
98
|
+
delete environment.DOCKER_CONTEXT; // Pin the verified local endpoint for every later command.
|
|
99
|
+
let info: { OSType?: string };
|
|
100
|
+
let image: { Id?: string; Os?: string; Architecture?: string };
|
|
101
|
+
try {
|
|
102
|
+
info = JSON.parse(await checked(["info", "--format", "{{json .}}"], "daemon inspection"));
|
|
103
|
+
image = JSON.parse(await checked(["image", "inspect", config.image, "--format", "{{json .}}"], "local image inspection"));
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error instanceof Error && error.message.startsWith("SANDBOX_")) throw error;
|
|
106
|
+
throw new Error("SANDBOX_UNAVAILABLE: malformed Docker daemon or image response");
|
|
107
|
+
}
|
|
108
|
+
if (info.OSType !== "linux" || image.Os !== "linux") throw new Error("SANDBOX_UNAVAILABLE: a Linux Docker daemon and Linux Bun image are required");
|
|
109
|
+
if (!image.Id || !/^sha256:[a-f0-9]{64}$/.test(image.Id) || !image.Architecture) throw new Error("SANDBOX_UNAVAILABLE: local image has no immutable identity");
|
|
110
|
+
return { imageId: image.Id, platform: `linux/${image.Architecture}` };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function acquireLaunchLock(sources: readonly string[]): Promise<() => Promise<void>> {
|
|
114
|
+
// One lock per native user, independent of session/config/backend instance.
|
|
115
|
+
const directory = await ensurePrivateSandboxDirectory(path.join(await nativeSandboxHome(), ".jeo", "sandbox-launch"));
|
|
116
|
+
if (sources.some(source => sandboxPathContains(source, directory) || sandboxPathContains(directory, source))) {
|
|
117
|
+
throw new Error("SANDBOX_POLICY: mount overlaps the parent launch lock");
|
|
118
|
+
}
|
|
119
|
+
const lock = path.join(directory, "launch.lock");
|
|
120
|
+
const deadline = Date.now() + 30_000;
|
|
121
|
+
for (;;) {
|
|
122
|
+
try {
|
|
123
|
+
await fs.mkdir(lock, { mode: 0o700 });
|
|
124
|
+
const owned = await fs.lstat(lock, { bigint: true });
|
|
125
|
+
return async () => {
|
|
126
|
+
let current;
|
|
127
|
+
try { current = await fs.lstat(lock, { bigint: true }); }
|
|
128
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; }
|
|
129
|
+
if (!current.isDirectory() || current.isSymbolicLink() || current.dev !== owned.dev || current.ino !== owned.ino) throw new Error("SANDBOX_UNAVAILABLE: launch lock ownership changed");
|
|
130
|
+
await fs.rmdir(lock);
|
|
131
|
+
};
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST" || Date.now() >= deadline) throw new Error("SANDBOX_UNAVAILABLE: managed launch lock unavailable", { cause: error });
|
|
134
|
+
try {
|
|
135
|
+
const stat = await fs.lstat(lock);
|
|
136
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("SANDBOX_UNAVAILABLE: invalid managed launch lock");
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
await Bun.sleep(25);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Every managed launch holds the same native-user lock. Identical/disjoint bind
|
|
147
|
+
// roots keep hands from renaming another source or its ancestors. Inspect Source
|
|
148
|
+
// is admission metadata, not physical host/guest inode attestation; native host
|
|
149
|
+
// and Docker operators are outside this model-execution boundary.
|
|
150
|
+
async function validateManagedRoots(sources: readonly string[]): Promise<void> {
|
|
151
|
+
const ids = (await checked(["container", "ls", "--all", "--filter", `label=${OWNER}`, "--format", "{{.ID}}"], "managed container discovery")).split(/\r?\n/).filter(Boolean);
|
|
152
|
+
for (const id of ids) {
|
|
153
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error("SANDBOX_UNAVAILABLE: invalid managed container identity");
|
|
154
|
+
let record: { Config?: { Labels?: Record<string, string> }; Mounts?: { Type?: string; Source?: string; Destination?: string }[] };
|
|
155
|
+
try { record = JSON.parse(await checked(["inspect", "--format", "{{json .}}", id], "managed mount inspection")); }
|
|
156
|
+
catch { throw new Error("SANDBOX_UNAVAILABLE: managed mount metadata unavailable"); }
|
|
157
|
+
const labels = record.Config?.Labels;
|
|
158
|
+
if (!labels?.[OWNER] || !labels[SESSION] || !/^\d+$/.test(labels[REVISION] ?? "") || !Array.isArray(record.Mounts)) throw new Error("SANDBOX_UNAVAILABLE: unknown managed ownership or mounts");
|
|
159
|
+
for (const mount of record.Mounts) {
|
|
160
|
+
if (mount.Type === "tmpfs" && mount.Destination === "/tmp") continue;
|
|
161
|
+
if (mount.Type !== "bind" || typeof mount.Source !== "string" || !path.isAbsolute(mount.Source)) throw new Error("SANDBOX_UNAVAILABLE: unknown managed mount metadata");
|
|
162
|
+
const source = await canonicalSandboxPath(mount.Source);
|
|
163
|
+
if (sources.some(candidate => candidate !== source && (sandboxPathContains(candidate, source) || sandboxPathContains(source, candidate)))) {
|
|
164
|
+
throw new Error("SANDBOX_MOUNT_OVERLAP: managed mount roots must be identical or disjoint");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function launch(policy: SandboxPolicy, bundlePath: string): Promise<SandboxProcess> {
|
|
171
|
+
const qualified = await preflight(policy.config);
|
|
172
|
+
const bundle = await canonicalSandboxPath(bundlePath);
|
|
173
|
+
if (!(await fs.stat(bundle)).isFile()) throw new Error("SANDBOX_POLICY: hands bundle is not a regular file");
|
|
174
|
+
const sources = [...policy.mounts.map(mount => mount.host), bundle];
|
|
175
|
+
for (let index = 0; index < sources.length; index++) {
|
|
176
|
+
for (const other of sources.slice(index + 1)) {
|
|
177
|
+
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");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const sourceIdentity = await Promise.all(sources.map(source => fs.stat(source, { bigint: true })));
|
|
181
|
+
const validateSources = async (): Promise<void> => {
|
|
182
|
+
for (let index = 0; index < sources.length; index++) {
|
|
183
|
+
const source = sources[index]!;
|
|
184
|
+
const stat = await fs.stat(source, { bigint: true });
|
|
185
|
+
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");
|
|
186
|
+
}
|
|
187
|
+
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));
|
|
188
|
+
};
|
|
189
|
+
const mounts: string[] = [];
|
|
190
|
+
for (const mount of policy.mounts) {
|
|
191
|
+
if (await canonicalSandboxPath(mount.host) !== mount.host || sandboxPathContains(mount.host, bundle)) {
|
|
192
|
+
throw new Error("SANDBOX_POLICY: mount changed or exposes the trusted hands bundle");
|
|
193
|
+
}
|
|
194
|
+
// Docker's --mount grammar is CSV, not shell syntax. Refuse ambiguous source names.
|
|
195
|
+
if (/[,\r\n\0]/.test(mount.host) || !/^\/(workspace|grants\/[a-f0-9]{64})$/.test(mount.guest)) throw new Error("SANDBOX_POLICY: unsupported mount path");
|
|
196
|
+
mounts.push("--mount", `type=bind,source=${mount.host},target=${mount.guest}${mount.mode === "ro" ? ",readonly" : ""}`);
|
|
197
|
+
}
|
|
198
|
+
if (/[,\r\n\0]/.test(bundle)) throw new Error("SANDBOX_POLICY: unsupported bundle path");
|
|
199
|
+
const name = `jeo-sbx-${new Bun.CryptoHasher("sha256").update(policy.sessionKey).digest("hex").slice(0, 20)}-r${policy.revision}-${crypto.randomUUID()}`;
|
|
200
|
+
const uid = platform === "win32" ? undefined : process.getuid?.();
|
|
201
|
+
const gid = platform === "win32" ? undefined : process.getgid?.();
|
|
202
|
+
if (uid === 0) throw new Error("SANDBOX_UNAVAILABLE: run the host controller as a non-root user");
|
|
203
|
+
const user = uid === undefined || gid === undefined ? "bun" : `${uid}:${gid}`;
|
|
204
|
+
const createArgs = [
|
|
205
|
+
"create", "--pull=never", "--name", name,
|
|
206
|
+
"--label", `${OWNER}=${owner}`, "--label", `${SESSION}=${policy.sessionKey}`, "--label", `${REVISION}=${policy.revision}`,
|
|
207
|
+
"--init", "--interactive", "--network", "none", "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges",
|
|
208
|
+
"--pids-limit", String(policy.config.limits.pids), "--memory", String(policy.config.limits.memoryBytes), "--cpus", String(policy.config.limits.cpus),
|
|
209
|
+
"--user", user, "--tmpfs", "/tmp:rw,nosuid,nodev,mode=1777", "--workdir", "/workspace",
|
|
210
|
+
"--env", "HOME=/tmp/jeo-home", "--env", "JEO_CONFIG_DIR=/tmp/jeo-home", "--env", "JEO_SANDBOX_HANDS=1",
|
|
211
|
+
...mounts, "--mount", `type=bind,source=${bundle},target=/jeo/hands.js,readonly`,
|
|
212
|
+
"--entrypoint", "/usr/local/bin/bun", qualified.imageId, "/jeo/hands.js",
|
|
213
|
+
];
|
|
214
|
+
let stopping: Promise<SandboxTermination> | undefined;
|
|
215
|
+
const stop = (): Promise<SandboxTermination> => {
|
|
216
|
+
stopping ??= (async () => {
|
|
217
|
+
try {
|
|
218
|
+
const before = await command(["inspect", "--format", "{{json .}}", name]);
|
|
219
|
+
if (before.exitCode === 0) {
|
|
220
|
+
const record = JSON.parse(before.stdout) as { Config?: { Labels?: Record<string, string> } };
|
|
221
|
+
const labels = record.Config?.Labels;
|
|
222
|
+
if (labels?.[OWNER] !== owner || labels[SESSION] !== policy.sessionKey || labels[REVISION] !== String(policy.revision)) {
|
|
223
|
+
return { gone: false, residual: [name] };
|
|
224
|
+
}
|
|
225
|
+
await command(["rm", "--force", name]);
|
|
226
|
+
}
|
|
227
|
+
const remaining = await command(["container", "ls", "--all", "--filter", `name=^/${name}$`, "--format", "{{.ID}}"]);
|
|
228
|
+
if (remaining.exitCode !== 0 || remaining.stdout.trim()) return { gone: false, residual: [name] };
|
|
229
|
+
const revision = await command(["container", "ls", "--all", "--filter", `label=${OWNER}=${owner}`, "--filter", `label=${SESSION}=${policy.sessionKey}`, "--filter", `label=${REVISION}=${policy.revision}`, "--format", "{{.Names}}"]);
|
|
230
|
+
if (revision.exitCode !== 0) return { gone: false, residual: [name] };
|
|
231
|
+
const residual = revision.stdout.trim().split(/\r?\n/).filter(Boolean);
|
|
232
|
+
return { gone: residual.length === 0, residual };
|
|
233
|
+
} catch { return { gone: false, residual: [name] }; }
|
|
234
|
+
})();
|
|
235
|
+
const attempt = stopping;
|
|
236
|
+
void attempt.then(result => { if (!result.gone && stopping === attempt) stopping = undefined; });
|
|
237
|
+
return attempt;
|
|
238
|
+
};
|
|
239
|
+
const unlock = await acquireLaunchLock(sources);
|
|
240
|
+
let released = false;
|
|
241
|
+
const cleanupOwned = async (): Promise<SandboxTermination> => {
|
|
242
|
+
const result = await stop();
|
|
243
|
+
if (!released) {
|
|
244
|
+
try { await unlock(); released = true; }
|
|
245
|
+
catch { return { gone: false, residual: [...result.residual, "managed launch lock"] }; }
|
|
246
|
+
}
|
|
247
|
+
return result;
|
|
248
|
+
};
|
|
249
|
+
try {
|
|
250
|
+
await validateManagedRoots(sources);
|
|
251
|
+
await validateSources();
|
|
252
|
+
await checked(createArgs, "container creation");
|
|
253
|
+
await validateSources();
|
|
254
|
+
const child = spawn([executable, "start", "--attach", "--interactive", name]);
|
|
255
|
+
const deadline = Date.now() + 30_000;
|
|
256
|
+
for (;;) {
|
|
257
|
+
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 }[] };
|
|
258
|
+
const labels = record.Config?.Labels;
|
|
259
|
+
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");
|
|
260
|
+
const actual = record.Mounts.filter(mount => mount.Type === "bind");
|
|
261
|
+
const expected = [...policy.mounts.map(mount => ({ source: mount.host, guest: mount.guest, writable: mount.mode === "rw" })), { source: bundle, guest: "/jeo/hands.js", writable: false }];
|
|
262
|
+
if (actual.length !== expected.length) throw new Error("SANDBOX_UNAVAILABLE: unexpected container bind mounts");
|
|
263
|
+
for (const mount of expected) {
|
|
264
|
+
const observed = actual.find(candidate => candidate.Destination === mount.guest);
|
|
265
|
+
if (!observed?.Source || await canonicalSandboxPath(observed.Source) !== mount.source || observed.RW !== mount.writable) throw new Error("SANDBOX_UNAVAILABLE: actual container bind configuration changed");
|
|
266
|
+
}
|
|
267
|
+
if (record.State?.Running === true) break;
|
|
268
|
+
if (Date.now() >= deadline) throw new Error("SANDBOX_UNAVAILABLE: container did not start");
|
|
269
|
+
await Bun.sleep(25);
|
|
270
|
+
}
|
|
271
|
+
await validateSources();
|
|
272
|
+
await unlock();
|
|
273
|
+
released = true;
|
|
274
|
+
return { name, stdin: child.stdin, stdout: child.stdout, stderr: child.stderr, exited: child.exited, stop };
|
|
275
|
+
} catch (error) {
|
|
276
|
+
const cleanup = await cleanupOwned();
|
|
277
|
+
if (!cleanup.gone) throw new SandboxLaunchError(`REVOKE_INCOMPLETE: failed sandbox launch left owned resources for ${name}`, cleanup.residual, cleanupOwned, { cause: error });
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return { preflight, launch };
|
|
283
|
+
}
|