pi-microsandbox 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -57
- package/SECURITY.md +12 -8
- package/docs/commands.md +18 -14
- package/docs/configuration.md +49 -11
- package/docs/development.md +52 -10
- package/docs/getting-started.md +25 -8
- package/docs/images.md +18 -9
- package/docs/safety.md +43 -14
- package/docs/storage.md +63 -50
- package/docs/troubleshooting.md +54 -9
- package/extensions/pi-msb/command.ts +17 -189
- package/extensions/pi-msb/config.ts +191 -44
- package/extensions/pi-msb/control.ts +134 -302
- package/extensions/pi-msb/index.ts +2 -2
- package/extensions/pi-msb/labels.ts +46 -231
- package/extensions/pi-msb/locks.ts +4 -6
- package/extensions/pi-msb/prune.ts +16 -12
- package/extensions/pi-msb/sandbox-manager.ts +93 -143
- package/extensions/pi-msb/tools.ts +13 -26
- package/extensions/pi-msb/transport.ts +0 -18
- package/extensions/pi-msb/types.ts +42 -108
- package/extensions/pi-msb/workspace.ts +126 -0
- package/native/flock/prebuilds/darwin-arm64/flock.node +0 -0
- package/package.json +1 -1
- package/extensions/pi-msb/git.ts +0 -256
- package/extensions/pi-msb/storage.ts +0 -332
|
@@ -4,33 +4,17 @@ import {
|
|
|
4
4
|
STATE_SCHEMA_VERSION,
|
|
5
5
|
type BootRequest,
|
|
6
6
|
type Config,
|
|
7
|
-
type GitRepoInfo,
|
|
8
7
|
type LockHandle,
|
|
9
8
|
type PersistedSandboxState,
|
|
10
|
-
type PreparedStorage,
|
|
11
9
|
type PruneReport,
|
|
12
10
|
type RuntimeExecution,
|
|
11
|
+
type RuntimePreparation,
|
|
13
12
|
type RuntimeState,
|
|
14
13
|
type SandboxManager,
|
|
15
14
|
type SandboxTransport,
|
|
16
|
-
type SeedResult,
|
|
17
|
-
type StorageMode,
|
|
18
|
-
type StoragePlan,
|
|
19
15
|
type ToolOperations,
|
|
20
16
|
} from "./types.ts";
|
|
21
17
|
|
|
22
|
-
/**
|
|
23
|
-
* Inputs to the storage planner. Kept local because the shared contract only
|
|
24
|
-
* describes the values crossing the manager boundary.
|
|
25
|
-
*/
|
|
26
|
-
export interface StoragePlanInput {
|
|
27
|
-
cwd: string;
|
|
28
|
-
sessionId: string;
|
|
29
|
-
config: Config;
|
|
30
|
-
git: GitRepoInfo;
|
|
31
|
-
restored?: PersistedSandboxState | null;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
18
|
/** The SDK adapter deliberately owns the concrete shape of an inspected handle. */
|
|
35
19
|
export interface InspectedSandbox {
|
|
36
20
|
name: string;
|
|
@@ -42,21 +26,14 @@ export interface InspectedSandbox {
|
|
|
42
26
|
export interface SandboxManagerDeps {
|
|
43
27
|
acquireOwnerLock(request: BootRequest): Promise<LockHandle | null>;
|
|
44
28
|
pruneOthers(currentSessionId: string): Promise<PruneReport>;
|
|
45
|
-
detectGit(cwd: string): Promise<GitRepoInfo>;
|
|
46
|
-
buildStoragePlan(input: StoragePlanInput): StoragePlan;
|
|
47
|
-
prepareStorage(
|
|
48
|
-
plan: StoragePlan,
|
|
49
|
-
restored: PersistedSandboxState | null,
|
|
50
|
-
): Promise<PreparedStorage>;
|
|
51
29
|
inspectSandbox(name: string): Promise<InspectedSandbox | null>;
|
|
52
30
|
connectSandbox(value: InspectedSandbox): Promise<unknown>;
|
|
53
31
|
startSandbox(value: InspectedSandbox): Promise<unknown>;
|
|
54
|
-
createSandbox(request: BootRequest
|
|
32
|
+
createSandbox(request: BootRequest): Promise<unknown>;
|
|
55
33
|
stopAndRemove(name: string, timeoutMs: number): Promise<void>;
|
|
56
34
|
createTransport(raw: unknown): SandboxTransport;
|
|
57
35
|
createOperations(transport: SandboxTransport): ToolOperations;
|
|
58
|
-
|
|
59
|
-
seed(runtime: RuntimeExecution, prepared: PreparedStorage): Promise<SeedResult>;
|
|
36
|
+
prepareRuntime(runtime: RuntimeExecution, config: Config): Promise<RuntimePreparation>;
|
|
60
37
|
persist(state: PersistedSandboxState): void;
|
|
61
38
|
now?: () => number;
|
|
62
39
|
}
|
|
@@ -71,18 +48,6 @@ const STOPPED_STATUSES = new Set([
|
|
|
71
48
|
"created",
|
|
72
49
|
]);
|
|
73
50
|
|
|
74
|
-
function modeForPlan(plan: StoragePlan): StorageMode {
|
|
75
|
-
return plan.kind === "git-volume"
|
|
76
|
-
? "git"
|
|
77
|
-
: plan.kind === "direct-mount"
|
|
78
|
-
? "direct"
|
|
79
|
-
: "none";
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function volumeNameForPlan(plan: StoragePlan): string | undefined {
|
|
83
|
-
return plan.kind === "git-volume" ? plan.volumeName : undefined;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
51
|
function statusOf(value: InspectedSandbox): string | undefined {
|
|
87
52
|
if (typeof value.status === "string") return value.status.toLowerCase();
|
|
88
53
|
if (value.running === true) return "running";
|
|
@@ -111,44 +76,38 @@ function isManagedOwner(value: InspectedSandbox, request: BootRequest): boolean
|
|
|
111
76
|
);
|
|
112
77
|
}
|
|
113
78
|
|
|
114
|
-
function
|
|
79
|
+
function isLegacyManagedOwner(value: InspectedSandbox, request: BootRequest): boolean {
|
|
80
|
+
const labels = labelsOf(value);
|
|
81
|
+
return labels["pi-msb.managed"] === "true" &&
|
|
82
|
+
labels["pi-msb.session"] === request.sessionId &&
|
|
83
|
+
/^\d+$/.test(labels["pi-msb.schema"] ?? "") &&
|
|
84
|
+
Number(labels["pi-msb.schema"]) < STATE_SCHEMA_VERSION;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function matchesWorkspace(value: InspectedSandbox, request: BootRequest): boolean {
|
|
115
88
|
const labels = labelsOf(value);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (labels["pi-msb.mode"] !== expectedMode) return false;
|
|
122
|
-
if (labels["pi-msb.cwd"] !== request.cwd) return false;
|
|
123
|
-
if (labels["pi-msb.image"] !== request.config.image) return false;
|
|
124
|
-
|
|
125
|
-
const actualVolume = labels["pi-msb.volume"];
|
|
126
|
-
return expectedVolume === undefined
|
|
127
|
-
? actualVolume === undefined
|
|
128
|
-
: actualVolume === expectedVolume;
|
|
89
|
+
return isManagedOwner(value, request) &&
|
|
90
|
+
labels["pi-msb.cwd"] === request.cwd &&
|
|
91
|
+
labels["pi-msb.root"] === request.workspace.hostRoot &&
|
|
92
|
+
labels["pi-msb.guest-root"] === request.workspace.guestRoot &&
|
|
93
|
+
labels["pi-msb.image"] === request.config.image;
|
|
129
94
|
}
|
|
130
95
|
|
|
131
96
|
function infoFor(
|
|
132
97
|
request: BootRequest,
|
|
133
|
-
plan: StoragePlan,
|
|
134
|
-
prepared: PreparedStorage,
|
|
135
|
-
seedBranch: string | null | undefined,
|
|
136
|
-
seedSha: string | null | undefined,
|
|
137
98
|
createdAt: number,
|
|
138
99
|
name: string,
|
|
100
|
+
preparation: RuntimePreparation,
|
|
139
101
|
): NonNullable<RuntimeState["info"]> {
|
|
140
102
|
return {
|
|
141
103
|
name,
|
|
142
104
|
displayId: displayId(request.sessionId),
|
|
143
|
-
mode: modeForPlan(plan),
|
|
144
105
|
image: request.config.image,
|
|
145
106
|
pid: process.pid,
|
|
146
107
|
cwd: request.cwd,
|
|
147
|
-
|
|
148
|
-
volumeHostPath: plan.kind === "git-volume" ? prepared.volume?.hostPath : undefined,
|
|
149
|
-
seedBranch: seedBranch ?? null,
|
|
150
|
-
seedSha: seedSha ?? null,
|
|
108
|
+
root: request.workspace.guestRoot,
|
|
151
109
|
createdAt,
|
|
110
|
+
docker: { ...preparation.docker },
|
|
152
111
|
};
|
|
153
112
|
}
|
|
154
113
|
|
|
@@ -169,6 +128,8 @@ function sameBootRequest(a: BootRequest, b: BootRequest): boolean {
|
|
|
169
128
|
return (
|
|
170
129
|
a.sessionId === b.sessionId &&
|
|
171
130
|
a.cwd === b.cwd &&
|
|
131
|
+
a.workspace.hostRoot === b.workspace.hostRoot &&
|
|
132
|
+
a.workspace.guestRoot === b.workspace.guestRoot &&
|
|
172
133
|
JSON.stringify(a.config) === JSON.stringify(b.config)
|
|
173
134
|
);
|
|
174
135
|
}
|
|
@@ -180,7 +141,9 @@ function sameSessionRestore(
|
|
|
180
141
|
if (!restored) return null;
|
|
181
142
|
if (restored.version !== STATE_SCHEMA_VERSION) return null;
|
|
182
143
|
if (restored.sessionId !== request.sessionId) return null;
|
|
183
|
-
if (restored.cwd !== request.cwd
|
|
144
|
+
if (restored.cwd !== request.cwd ||
|
|
145
|
+
restored.root !== request.workspace.hostRoot ||
|
|
146
|
+
restored.guestRoot !== request.workspace.guestRoot) return null;
|
|
184
147
|
return restored;
|
|
185
148
|
}
|
|
186
149
|
|
|
@@ -192,10 +155,10 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
|
|
|
192
155
|
let state: RuntimeState = { status: "disabled", info: null };
|
|
193
156
|
let lock: LockHandle | null = null;
|
|
194
157
|
let runtime: RuntimeExecution | null = null;
|
|
158
|
+
let runtimeInvalid = false;
|
|
195
159
|
let sandboxName: string | null = null;
|
|
196
160
|
let lastRequest: BootRequest | null = null;
|
|
197
161
|
let retainedState: PersistedSandboxState | null = null;
|
|
198
|
-
let activePlan: StoragePlan | null = null;
|
|
199
162
|
// activeUses includes reservations made before preflight. pendingUses marks
|
|
200
163
|
// those reservations; only activeUses - pendingUses are callbacks currently
|
|
201
164
|
// using a transport and therefore must drain before replacement/disposal.
|
|
@@ -240,6 +203,7 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
|
|
|
240
203
|
async function disposeRuntime(): Promise<void> {
|
|
241
204
|
const current = runtime;
|
|
242
205
|
runtime = null;
|
|
206
|
+
runtimeInvalid = false;
|
|
243
207
|
if (!current) return;
|
|
244
208
|
try {
|
|
245
209
|
await current.transport.dispose();
|
|
@@ -285,6 +249,12 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
|
|
|
285
249
|
return request.config.sandboxName ?? sandboxNameFor(request.sessionId);
|
|
286
250
|
}
|
|
287
251
|
|
|
252
|
+
function invalidatesRuntime(error: unknown): boolean {
|
|
253
|
+
if (!error || typeof error !== "object" || !("code" in error)) return false;
|
|
254
|
+
const code = (error as { code?: unknown }).code;
|
|
255
|
+
return code === "SANDBOX_DOWN";
|
|
256
|
+
}
|
|
257
|
+
|
|
288
258
|
async function connectAndBuild(raw: unknown): Promise<RuntimeExecution> {
|
|
289
259
|
const transport = deps.createTransport(raw);
|
|
290
260
|
try {
|
|
@@ -342,7 +312,6 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
|
|
|
342
312
|
}
|
|
343
313
|
lock = owner;
|
|
344
314
|
|
|
345
|
-
let prepared: PreparedStorage | null = null;
|
|
346
315
|
let partialSandbox = false;
|
|
347
316
|
let currentRequestInfo: RuntimeState["info"] = null;
|
|
348
317
|
let bootedRuntime: RuntimeExecution | null = null;
|
|
@@ -351,17 +320,7 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
|
|
|
351
320
|
if (request.config.pruneOnStart) {
|
|
352
321
|
await deps.pruneOthers(request.sessionId);
|
|
353
322
|
}
|
|
354
|
-
const git = await deps.detectGit(request.cwd);
|
|
355
323
|
const restored = sameSessionRestore(request);
|
|
356
|
-
const plan = deps.buildStoragePlan({
|
|
357
|
-
cwd: request.cwd,
|
|
358
|
-
sessionId: request.sessionId,
|
|
359
|
-
config: request.config,
|
|
360
|
-
git,
|
|
361
|
-
restored,
|
|
362
|
-
});
|
|
363
|
-
prepared = await deps.prepareStorage(plan, restored);
|
|
364
|
-
|
|
365
324
|
const name = requestSandboxName(request);
|
|
366
325
|
sandboxName = name;
|
|
367
326
|
const inspected = await deps.inspectSandbox(name);
|
|
@@ -369,17 +328,23 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
|
|
|
369
328
|
|
|
370
329
|
if (!inspected) {
|
|
371
330
|
partialSandbox = true;
|
|
372
|
-
raw = await deps.createSandbox(request
|
|
331
|
+
raw = await deps.createSandbox(request);
|
|
332
|
+
} else if (isLegacyManagedOwner(inspected, request)) {
|
|
333
|
+
// The current session owner lock guards removal. Legacy named volumes
|
|
334
|
+
// are intentionally neither inspected nor removed.
|
|
335
|
+
await deps.stopAndRemove(name, request.config.stopTimeoutMs);
|
|
336
|
+
partialSandbox = true;
|
|
337
|
+
raw = await deps.createSandbox(request);
|
|
373
338
|
} else if (!isManagedOwner(inspected, request)) {
|
|
374
339
|
throw new Error(`sandbox name conflict: ${name}`);
|
|
375
|
-
} else if (!
|
|
340
|
+
} else if (!matchesWorkspace(inspected, request)) {
|
|
376
341
|
// The labels prove this is ours, but its configuration is stale. Only
|
|
377
342
|
// that proven ownership permits a replacement.
|
|
378
343
|
await deps.stopAndRemove(name, request.config.stopTimeoutMs);
|
|
379
344
|
// The old instance was removed; a failed replacement still needs its
|
|
380
345
|
// own best-effort cleanup.
|
|
381
346
|
partialSandbox = true;
|
|
382
|
-
raw = await deps.createSandbox(request
|
|
347
|
+
raw = await deps.createSandbox(request);
|
|
383
348
|
} else if (isRunning(inspected)) {
|
|
384
349
|
raw = await deps.connectSandbox(inspected);
|
|
385
350
|
partialSandbox = true;
|
|
@@ -396,73 +361,28 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
|
|
|
396
361
|
|
|
397
362
|
bootedRuntime = await connectAndBuild(raw);
|
|
398
363
|
runtime = bootedRuntime;
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
let seed: SeedResult | null = null;
|
|
402
|
-
if (
|
|
403
|
-
prepared.plan.kind === "git-volume" &&
|
|
404
|
-
prepared.createdVolume &&
|
|
405
|
-
prepared.plan.seedRequired
|
|
406
|
-
) {
|
|
407
|
-
seed = await deps.seed(bootedRuntime, prepared);
|
|
408
|
-
}
|
|
364
|
+
runtimeInvalid = false;
|
|
365
|
+
const preparation = await deps.prepareRuntime(bootedRuntime, request.config);
|
|
409
366
|
|
|
410
367
|
const createdAt = deps.now ? deps.now() : Date.now();
|
|
411
|
-
|
|
412
|
-
const seedBranch = prepared.createdVolume
|
|
413
|
-
? prepared.plan.kind === "git-volume"
|
|
414
|
-
? prepared.plan.branch
|
|
415
|
-
: null
|
|
416
|
-
: previous?.seedBranch ??
|
|
417
|
-
(prepared.plan.kind === "git-volume" ? prepared.plan.branch : null);
|
|
418
|
-
const seedSha = prepared.createdVolume
|
|
419
|
-
? seed?.headSha ??
|
|
420
|
-
(prepared.plan.kind === "git-volume" ? prepared.plan.headSha : null)
|
|
421
|
-
: previous?.seedSha ??
|
|
422
|
-
(prepared.plan.kind === "git-volume" ? prepared.plan.headSha : null);
|
|
423
|
-
currentRequestInfo = infoFor(
|
|
424
|
-
request,
|
|
425
|
-
prepared.plan,
|
|
426
|
-
prepared,
|
|
427
|
-
seedBranch,
|
|
428
|
-
seedSha,
|
|
429
|
-
createdAt,
|
|
430
|
-
name,
|
|
431
|
-
);
|
|
432
|
-
|
|
433
|
-
// Bundle cleanup is deliberately before state publication: a successful
|
|
434
|
-
// boot never publishes a state whose temporary host artifact leaked.
|
|
435
|
-
if (prepared.bundle) await prepared.bundle.cleanup();
|
|
436
|
-
prepared.bundle = null;
|
|
368
|
+
currentRequestInfo = infoFor(request, createdAt, name, preparation);
|
|
437
369
|
|
|
438
370
|
const persisted: PersistedSandboxState = {
|
|
439
371
|
version: STATE_SCHEMA_VERSION,
|
|
440
372
|
sessionId: request.sessionId,
|
|
441
373
|
sandboxName: name,
|
|
442
|
-
mode: currentRequestInfo.mode,
|
|
443
374
|
cwd: request.cwd,
|
|
375
|
+
root: request.workspace.hostRoot,
|
|
376
|
+
guestRoot: request.workspace.guestRoot,
|
|
444
377
|
image: request.config.image,
|
|
445
|
-
volumeName: currentRequestInfo.volumeName,
|
|
446
|
-
volumeHostPath: currentRequestInfo.volumeHostPath,
|
|
447
|
-
seedBranch: currentRequestInfo.seedBranch,
|
|
448
|
-
seedSha: currentRequestInfo.seedSha,
|
|
449
378
|
enabled: true,
|
|
450
379
|
createdAt,
|
|
451
380
|
};
|
|
452
381
|
deps.persist(persisted);
|
|
453
382
|
retainedState = persisted;
|
|
454
|
-
activePlan = prepared.plan;
|
|
455
383
|
state = { status: "active", info: currentRequestInfo };
|
|
456
384
|
return state;
|
|
457
385
|
} catch (error) {
|
|
458
|
-
if (prepared?.bundle) {
|
|
459
|
-
try {
|
|
460
|
-
await prepared.bundle.cleanup();
|
|
461
|
-
} catch {
|
|
462
|
-
// The primary boot error is more useful, and no secret is exposed.
|
|
463
|
-
}
|
|
464
|
-
prepared.bundle = null;
|
|
465
|
-
}
|
|
466
386
|
await disposeRuntime();
|
|
467
387
|
if (partialSandbox && sandboxName) {
|
|
468
388
|
try {
|
|
@@ -473,7 +393,6 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
|
|
|
473
393
|
}
|
|
474
394
|
runtime = null;
|
|
475
395
|
bootedRuntime = null;
|
|
476
|
-
activePlan = null;
|
|
477
396
|
sandboxName = null;
|
|
478
397
|
if (lock === owner) {
|
|
479
398
|
lock = null;
|
|
@@ -489,7 +408,7 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
|
|
|
489
408
|
}
|
|
490
409
|
|
|
491
410
|
async function wakeIfNeeded(): Promise<RuntimeExecution> {
|
|
492
|
-
if (state.status !== "active" || !runtime || !lastRequest || !sandboxName
|
|
411
|
+
if (state.status !== "active" || !runtime || !lastRequest || !sandboxName) {
|
|
493
412
|
throw unavailableError(state);
|
|
494
413
|
}
|
|
495
414
|
|
|
@@ -504,22 +423,22 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
|
|
|
504
423
|
throw new Error(`sandbox ${sandboxName} has conflicting labels`);
|
|
505
424
|
}
|
|
506
425
|
// Revalidate the complete managed runtime identity before waking or
|
|
507
|
-
// reconnecting. A same-name resource with changed
|
|
508
|
-
//
|
|
509
|
-
if (!
|
|
426
|
+
// reconnecting. A same-name resource with changed cwd/root/image is not
|
|
427
|
+
// safe to attach to.
|
|
428
|
+
if (!matchesWorkspace(inspected, lastRequest)) {
|
|
510
429
|
throw new Error(`sandbox ${sandboxName} configuration changed`);
|
|
511
430
|
}
|
|
512
431
|
|
|
513
|
-
if (isRunning(inspected)) {
|
|
432
|
+
if (isRunning(inspected) && !runtimeInvalid) {
|
|
514
433
|
// The boot-created transport remains authoritative while the sandbox is
|
|
515
434
|
// running. Reconnecting here would replace a valid handle and dispose a
|
|
516
435
|
// transport that may still have active callers.
|
|
517
436
|
return runtime;
|
|
518
437
|
}
|
|
519
438
|
|
|
520
|
-
// A stopped/unknown sandbox
|
|
521
|
-
// start
|
|
522
|
-
// its runtime-use reservation.
|
|
439
|
+
// A stopped/unknown sandbox or invalid running handle requires a
|
|
440
|
+
// replacement transport. Do not start or dispose the old one until every
|
|
441
|
+
// earlier callback has released its runtime-use reservation.
|
|
523
442
|
await waitForActiveUses();
|
|
524
443
|
let raw: unknown;
|
|
525
444
|
if (isStopped(inspected)) {
|
|
@@ -530,8 +449,40 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
|
|
|
530
449
|
}
|
|
531
450
|
|
|
532
451
|
const replacement = await connectAndBuild(raw);
|
|
452
|
+
let preparation: RuntimePreparation;
|
|
453
|
+
try {
|
|
454
|
+
preparation = await deps.prepareRuntime(replacement, lastRequest.config);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
try {
|
|
457
|
+
await replacement.transport.dispose();
|
|
458
|
+
} catch {
|
|
459
|
+
// Preserve the preparation error.
|
|
460
|
+
}
|
|
461
|
+
const previous = runtime;
|
|
462
|
+
runtime = null;
|
|
463
|
+
runtimeInvalid = false;
|
|
464
|
+
if (previous) {
|
|
465
|
+
try {
|
|
466
|
+
await previous.transport.dispose();
|
|
467
|
+
} catch {
|
|
468
|
+
// The preparation error remains authoritative.
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const failedName = sandboxName;
|
|
472
|
+
sandboxName = null;
|
|
473
|
+
if (failedName) {
|
|
474
|
+
try {
|
|
475
|
+
await deps.stopAndRemove(failedName, lastRequest.config.stopTimeoutMs);
|
|
476
|
+
} catch {
|
|
477
|
+
throw new Error(`${redactMessage(error, lastRequest.config)}; failed to clean up the restarted sandbox`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
throw error;
|
|
481
|
+
}
|
|
533
482
|
const previous = runtime;
|
|
534
483
|
runtime = replacement;
|
|
484
|
+
runtimeInvalid = false;
|
|
485
|
+
if (state.info) state = { ...state, info: { ...state.info, docker: { ...preparation.docker } } };
|
|
535
486
|
if (previous && previous !== replacement) {
|
|
536
487
|
try {
|
|
537
488
|
await previous.transport.dispose();
|
|
@@ -565,7 +516,6 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
|
|
|
565
516
|
}
|
|
566
517
|
|
|
567
518
|
runtime = null;
|
|
568
|
-
activePlan = null;
|
|
569
519
|
sandboxName = null;
|
|
570
520
|
|
|
571
521
|
if (persistDisabled && request && oldInfo) {
|
|
@@ -574,13 +524,10 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
|
|
|
574
524
|
version: STATE_SCHEMA_VERSION,
|
|
575
525
|
sessionId: request.sessionId,
|
|
576
526
|
sandboxName: oldInfo.name,
|
|
577
|
-
mode: oldInfo.mode,
|
|
578
527
|
cwd: oldInfo.cwd,
|
|
528
|
+
root: request.workspace.hostRoot,
|
|
529
|
+
guestRoot: request.workspace.guestRoot,
|
|
579
530
|
image: oldInfo.image,
|
|
580
|
-
volumeName: oldInfo.volumeName,
|
|
581
|
-
volumeHostPath: oldInfo.volumeHostPath,
|
|
582
|
-
seedBranch: oldInfo.seedBranch,
|
|
583
|
-
seedSha: oldInfo.seedSha,
|
|
584
531
|
enabled: false,
|
|
585
532
|
createdAt,
|
|
586
533
|
};
|
|
@@ -621,7 +568,7 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
|
|
|
621
568
|
getState(): RuntimeState {
|
|
622
569
|
return {
|
|
623
570
|
status: state.status,
|
|
624
|
-
info: state.info ? { ...state.info } : null,
|
|
571
|
+
info: state.info ? { ...state.info, docker: { ...state.info.docker } } : null,
|
|
625
572
|
...(state.reason ? { reason: state.reason } : {}),
|
|
626
573
|
};
|
|
627
574
|
},
|
|
@@ -663,6 +610,9 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
|
|
|
663
610
|
}
|
|
664
611
|
try {
|
|
665
612
|
return await callback(current);
|
|
613
|
+
} catch (error) {
|
|
614
|
+
if (runtime === current && invalidatesRuntime(error)) runtimeInvalid = true;
|
|
615
|
+
throw error;
|
|
666
616
|
} finally {
|
|
667
617
|
releaseUse(true);
|
|
668
618
|
}
|
|
@@ -22,7 +22,6 @@ import type {
|
|
|
22
22
|
HostReadAccess,
|
|
23
23
|
RuntimeState,
|
|
24
24
|
SandboxGrepExecute,
|
|
25
|
-
StorageMode,
|
|
26
25
|
ToolOpsProvider,
|
|
27
26
|
} from "./types.ts";
|
|
28
27
|
|
|
@@ -81,24 +80,16 @@ export function hostApprovalMessage(
|
|
|
81
80
|
tool: string,
|
|
82
81
|
input: Record<string, unknown>,
|
|
83
82
|
cwd: string,
|
|
84
|
-
mode?: StorageMode,
|
|
85
83
|
): string {
|
|
86
|
-
|
|
84
|
+
return [
|
|
87
85
|
`Tool: ${tool}`,
|
|
88
86
|
`Working directory: ${cwd}`,
|
|
89
|
-
...(mode ? [`Storage mode: ${mode}`] : []),
|
|
90
87
|
"",
|
|
91
88
|
"Exact arguments:",
|
|
92
89
|
JSON.stringify(sortForFingerprint(withoutExecutionTarget(input)), null, 2),
|
|
93
90
|
"",
|
|
94
|
-
"
|
|
95
|
-
];
|
|
96
|
-
if (mode === "git") {
|
|
97
|
-
lines.push(
|
|
98
|
-
"Warning: host-targeted execution bypasses the retained git volume and can mutate the host working tree.",
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
return lines.join("\n");
|
|
91
|
+
"Warning: this operation runs on the host and bypasses VM, process, and network isolation.",
|
|
92
|
+
].join("\n");
|
|
102
93
|
}
|
|
103
94
|
|
|
104
95
|
export interface RegisterToolsDeps {
|
|
@@ -122,9 +113,8 @@ function stateOf(deps: RegisterToolsDeps): RuntimeState {
|
|
|
122
113
|
return deps.provider.getState();
|
|
123
114
|
}
|
|
124
115
|
|
|
125
|
-
function isHostMode(state: RuntimeState
|
|
126
|
-
return state.status === "off" || state.status === "
|
|
127
|
-
(state.status === "unavailable" && config.fallbackMode === "host");
|
|
116
|
+
function isHostMode(state: RuntimeState): boolean {
|
|
117
|
+
return state.status === "off" || state.status === "host-fallback";
|
|
128
118
|
}
|
|
129
119
|
|
|
130
120
|
function isSandboxActive(state: RuntimeState, provider: ToolOpsProvider): boolean {
|
|
@@ -214,7 +204,6 @@ export function registerSandboxTools(pi: ExtensionAPI, deps: RegisterToolsDeps):
|
|
|
214
204
|
tool: string,
|
|
215
205
|
id: string,
|
|
216
206
|
input: Record<string, unknown>,
|
|
217
|
-
mode: StorageMode | undefined,
|
|
218
207
|
): void => {
|
|
219
208
|
const approved = approvedHostCalls.get(id);
|
|
220
209
|
approvedHostCalls.delete(id);
|
|
@@ -222,8 +211,7 @@ export function registerSandboxTools(pi: ExtensionAPI, deps: RegisterToolsDeps):
|
|
|
222
211
|
throw new Error("Host execution was not approved for this exact tool call.");
|
|
223
212
|
}
|
|
224
213
|
// Approval is deliberately consumed here rather than cached; a runtime
|
|
225
|
-
//
|
|
226
|
-
void mode;
|
|
214
|
+
// state change must never extend a one-call host escape.
|
|
227
215
|
};
|
|
228
216
|
|
|
229
217
|
const executeHost = async (
|
|
@@ -234,13 +222,12 @@ export function registerSandboxTools(pi: ExtensionAPI, deps: RegisterToolsDeps):
|
|
|
234
222
|
onUpdate: any,
|
|
235
223
|
ctx: ExtensionContext,
|
|
236
224
|
active: boolean,
|
|
237
|
-
mode: StorageMode | undefined,
|
|
238
225
|
): Promise<any> => {
|
|
239
226
|
if (active) {
|
|
240
227
|
if (!deps.config.allowHostExecution) {
|
|
241
228
|
throw new Error("Host execution is disabled by pi-microsandbox configuration.");
|
|
242
229
|
}
|
|
243
|
-
requireApprovedHostExecution(definition.name, id, input
|
|
230
|
+
requireApprovedHostExecution(definition.name, id, input);
|
|
244
231
|
}
|
|
245
232
|
return definition.execute(id, withoutExecutionTarget(input), signal, onUpdate, ctx);
|
|
246
233
|
};
|
|
@@ -253,17 +240,17 @@ export function registerSandboxTools(pi: ExtensionAPI, deps: RegisterToolsDeps):
|
|
|
253
240
|
execute: async (id, rawParams, signal, onUpdate, ctx) => {
|
|
254
241
|
const params = rawParams as ToolParams;
|
|
255
242
|
const state = stateOf(deps);
|
|
256
|
-
const hostMode = isHostMode(state
|
|
243
|
+
const hostMode = isHostMode(state);
|
|
257
244
|
const active = isSandboxActive(state, deps.provider);
|
|
258
245
|
const targetHost = isHostTarget(params);
|
|
259
246
|
|
|
260
247
|
if (!deps.config.routeTools.includes(name)) throw routeError(name);
|
|
261
248
|
if (hostMode) {
|
|
262
|
-
return executeHost(hostDefinition, id, params, signal, onUpdate, ctx, false
|
|
249
|
+
return executeHost(hostDefinition, id, params, signal, onUpdate, ctx, false);
|
|
263
250
|
}
|
|
264
251
|
if (!active) throw unavailableError(state);
|
|
265
252
|
if (targetHost) {
|
|
266
|
-
return executeHost(hostDefinition, id, params, signal, onUpdate, ctx, true
|
|
253
|
+
return executeHost(hostDefinition, id, params, signal, onUpdate, ctx, true);
|
|
267
254
|
}
|
|
268
255
|
|
|
269
256
|
if (name === "read" && deps.config.allowSkillReads) {
|
|
@@ -314,7 +301,7 @@ export function registerSandboxTools(pi: ExtensionAPI, deps: RegisterToolsDeps):
|
|
|
314
301
|
|
|
315
302
|
pi.on("tool_call", async (event, ctx) => {
|
|
316
303
|
const state = stateOf(deps);
|
|
317
|
-
const hostMode = isHostMode(state
|
|
304
|
+
const hostMode = isHostMode(state);
|
|
318
305
|
const active = isSandboxActive(state, deps.provider);
|
|
319
306
|
const tool = event.toolName;
|
|
320
307
|
const input = asRecord(event.input);
|
|
@@ -339,7 +326,7 @@ export function registerSandboxTools(pi: ExtensionAPI, deps: RegisterToolsDeps):
|
|
|
339
326
|
}
|
|
340
327
|
const approved = await ctx.ui.confirm(
|
|
341
328
|
"Allow host execution?",
|
|
342
|
-
hostApprovalMessage(tool, input, deps.cwd
|
|
329
|
+
hostApprovalMessage(tool, input, deps.cwd),
|
|
343
330
|
);
|
|
344
331
|
if (!approved) {
|
|
345
332
|
clearApproval(event.toolCallId);
|
|
@@ -375,7 +362,7 @@ export function registerSandboxTools(pi: ExtensionAPI, deps: RegisterToolsDeps):
|
|
|
375
362
|
|
|
376
363
|
pi.on("user_bash", (): UserBashEventResult | undefined => {
|
|
377
364
|
const state = stateOf(deps);
|
|
378
|
-
if (isHostMode(state
|
|
365
|
+
if (isHostMode(state)) return undefined;
|
|
379
366
|
if (!isSandboxActive(state, deps.provider)) {
|
|
380
367
|
const error = unavailableError(state);
|
|
381
368
|
const operations: BashOperations = {
|
|
@@ -327,22 +327,6 @@ export function createSdkTransport(sandbox: unknown): SandboxTransport {
|
|
|
327
327
|
});
|
|
328
328
|
});
|
|
329
329
|
|
|
330
|
-
const copyFromHost = (hostPath: string, guestPath: string): Promise<void> =>
|
|
331
|
-
call("copyFromHost", async () => {
|
|
332
|
-
const copy = method<
|
|
333
|
-
(hostPath: string, guestPath: string) => Promise<void>
|
|
334
|
-
>(getFs(), "copyFromHost");
|
|
335
|
-
await copy(hostPath, guestPath);
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
const copyToHost = (guestPath: string, hostPath: string): Promise<void> =>
|
|
339
|
-
call("copyToHost", async () => {
|
|
340
|
-
const copy = method<
|
|
341
|
-
(guestPath: string, hostPath: string) => Promise<void>
|
|
342
|
-
>(getFs(), "copyToHost");
|
|
343
|
-
await copy(guestPath, hostPath);
|
|
344
|
-
});
|
|
345
|
-
|
|
346
330
|
const exec = (
|
|
347
331
|
command: string,
|
|
348
332
|
args: string[],
|
|
@@ -509,8 +493,6 @@ export function createSdkTransport(sandbox: unknown): SandboxTransport {
|
|
|
509
493
|
exists,
|
|
510
494
|
stat,
|
|
511
495
|
list,
|
|
512
|
-
copyFromHost,
|
|
513
|
-
copyToHost,
|
|
514
496
|
exec,
|
|
515
497
|
execStream,
|
|
516
498
|
dispose,
|