pi-microsandbox 0.2.0 → 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.
@@ -4,34 +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,
13
11
  type RuntimePreparation,
14
12
  type RuntimeState,
15
13
  type SandboxManager,
16
14
  type SandboxTransport,
17
- type SeedResult,
18
- type StorageMode,
19
- type StoragePlan,
20
15
  type ToolOperations,
21
16
  } from "./types.ts";
22
17
 
23
- /**
24
- * Inputs to the storage planner. Kept local because the shared contract only
25
- * describes the values crossing the manager boundary.
26
- */
27
- export interface StoragePlanInput {
28
- cwd: string;
29
- sessionId: string;
30
- config: Config;
31
- git: GitRepoInfo;
32
- restored?: PersistedSandboxState | null;
33
- }
34
-
35
18
  /** The SDK adapter deliberately owns the concrete shape of an inspected handle. */
36
19
  export interface InspectedSandbox {
37
20
  name: string;
@@ -43,21 +26,14 @@ export interface InspectedSandbox {
43
26
  export interface SandboxManagerDeps {
44
27
  acquireOwnerLock(request: BootRequest): Promise<LockHandle | null>;
45
28
  pruneOthers(currentSessionId: string): Promise<PruneReport>;
46
- detectGit(cwd: string): Promise<GitRepoInfo>;
47
- buildStoragePlan(input: StoragePlanInput): StoragePlan;
48
- prepareStorage(
49
- plan: StoragePlan,
50
- restored: PersistedSandboxState | null,
51
- ): Promise<PreparedStorage>;
52
29
  inspectSandbox(name: string): Promise<InspectedSandbox | null>;
53
30
  connectSandbox(value: InspectedSandbox): Promise<unknown>;
54
31
  startSandbox(value: InspectedSandbox): Promise<unknown>;
55
- createSandbox(request: BootRequest, prepared: PreparedStorage): Promise<unknown>;
32
+ createSandbox(request: BootRequest): Promise<unknown>;
56
33
  stopAndRemove(name: string, timeoutMs: number): Promise<void>;
57
34
  createTransport(raw: unknown): SandboxTransport;
58
35
  createOperations(transport: SandboxTransport): ToolOperations;
59
36
  prepareRuntime(runtime: RuntimeExecution, config: Config): Promise<RuntimePreparation>;
60
- seed(runtime: RuntimeExecution, prepared: PreparedStorage): Promise<SeedResult>;
61
37
  persist(state: PersistedSandboxState): void;
62
38
  now?: () => number;
63
39
  }
@@ -72,18 +48,6 @@ const STOPPED_STATUSES = new Set([
72
48
  "created",
73
49
  ]);
74
50
 
75
- function modeForPlan(plan: StoragePlan): StorageMode {
76
- return plan.kind === "git-volume"
77
- ? "git"
78
- : plan.kind === "direct-mount"
79
- ? "direct"
80
- : "none";
81
- }
82
-
83
- function volumeNameForPlan(plan: StoragePlan): string | undefined {
84
- return plan.kind === "git-volume" ? plan.volumeName : undefined;
85
- }
86
-
87
51
  function statusOf(value: InspectedSandbox): string | undefined {
88
52
  if (typeof value.status === "string") return value.status.toLowerCase();
89
53
  if (value.running === true) return "running";
@@ -112,29 +76,25 @@ function isManagedOwner(value: InspectedSandbox, request: BootRequest): boolean
112
76
  );
113
77
  }
114
78
 
115
- function matchesPlan(value: InspectedSandbox, request: BootRequest, plan: StoragePlan): boolean {
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 {
116
88
  const labels = labelsOf(value);
117
- const expectedMode = modeForPlan(plan);
118
- const expectedVolume = volumeNameForPlan(plan);
119
-
120
- if (request.config.mode !== "auto" && request.config.mode !== expectedMode) return false;
121
- if (!isManagedOwner(value, request)) return false;
122
- if (labels["pi-msb.mode"] !== expectedMode) return false;
123
- if (labels["pi-msb.cwd"] !== request.cwd) return false;
124
- if (labels["pi-msb.image"] !== request.config.image) return false;
125
-
126
- const actualVolume = labels["pi-msb.volume"];
127
- return expectedVolume === undefined
128
- ? actualVolume === undefined
129
- : 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;
130
94
  }
131
95
 
132
96
  function infoFor(
133
97
  request: BootRequest,
134
- plan: StoragePlan,
135
- prepared: PreparedStorage,
136
- seedBranch: string | null | undefined,
137
- seedSha: string | null | undefined,
138
98
  createdAt: number,
139
99
  name: string,
140
100
  preparation: RuntimePreparation,
@@ -142,14 +102,10 @@ function infoFor(
142
102
  return {
143
103
  name,
144
104
  displayId: displayId(request.sessionId),
145
- mode: modeForPlan(plan),
146
105
  image: request.config.image,
147
106
  pid: process.pid,
148
107
  cwd: request.cwd,
149
- volumeName: volumeNameForPlan(plan),
150
- volumeHostPath: plan.kind === "git-volume" ? prepared.volume?.hostPath : undefined,
151
- seedBranch: seedBranch ?? null,
152
- seedSha: seedSha ?? null,
108
+ root: request.workspace.guestRoot,
153
109
  createdAt,
154
110
  docker: { ...preparation.docker },
155
111
  };
@@ -172,6 +128,8 @@ function sameBootRequest(a: BootRequest, b: BootRequest): boolean {
172
128
  return (
173
129
  a.sessionId === b.sessionId &&
174
130
  a.cwd === b.cwd &&
131
+ a.workspace.hostRoot === b.workspace.hostRoot &&
132
+ a.workspace.guestRoot === b.workspace.guestRoot &&
175
133
  JSON.stringify(a.config) === JSON.stringify(b.config)
176
134
  );
177
135
  }
@@ -183,7 +141,9 @@ function sameSessionRestore(
183
141
  if (!restored) return null;
184
142
  if (restored.version !== STATE_SCHEMA_VERSION) return null;
185
143
  if (restored.sessionId !== request.sessionId) return null;
186
- if (restored.cwd !== request.cwd) return null;
144
+ if (restored.cwd !== request.cwd ||
145
+ restored.root !== request.workspace.hostRoot ||
146
+ restored.guestRoot !== request.workspace.guestRoot) return null;
187
147
  return restored;
188
148
  }
189
149
 
@@ -199,7 +159,6 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
199
159
  let sandboxName: string | null = null;
200
160
  let lastRequest: BootRequest | null = null;
201
161
  let retainedState: PersistedSandboxState | null = null;
202
- let activePlan: StoragePlan | null = null;
203
162
  // activeUses includes reservations made before preflight. pendingUses marks
204
163
  // those reservations; only activeUses - pendingUses are callbacks currently
205
164
  // using a transport and therefore must drain before replacement/disposal.
@@ -353,7 +312,6 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
353
312
  }
354
313
  lock = owner;
355
314
 
356
- let prepared: PreparedStorage | null = null;
357
315
  let partialSandbox = false;
358
316
  let currentRequestInfo: RuntimeState["info"] = null;
359
317
  let bootedRuntime: RuntimeExecution | null = null;
@@ -362,17 +320,7 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
362
320
  if (request.config.pruneOnStart) {
363
321
  await deps.pruneOthers(request.sessionId);
364
322
  }
365
- const git = await deps.detectGit(request.cwd);
366
323
  const restored = sameSessionRestore(request);
367
- const plan = deps.buildStoragePlan({
368
- cwd: request.cwd,
369
- sessionId: request.sessionId,
370
- config: request.config,
371
- git,
372
- restored,
373
- });
374
- prepared = await deps.prepareStorage(plan, restored);
375
-
376
324
  const name = requestSandboxName(request);
377
325
  sandboxName = name;
378
326
  const inspected = await deps.inspectSandbox(name);
@@ -380,17 +328,23 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
380
328
 
381
329
  if (!inspected) {
382
330
  partialSandbox = true;
383
- raw = await deps.createSandbox(request, prepared);
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);
384
338
  } else if (!isManagedOwner(inspected, request)) {
385
339
  throw new Error(`sandbox name conflict: ${name}`);
386
- } else if (!matchesPlan(inspected, request, plan)) {
340
+ } else if (!matchesWorkspace(inspected, request)) {
387
341
  // The labels prove this is ours, but its configuration is stale. Only
388
342
  // that proven ownership permits a replacement.
389
343
  await deps.stopAndRemove(name, request.config.stopTimeoutMs);
390
344
  // The old instance was removed; a failed replacement still needs its
391
345
  // own best-effort cleanup.
392
346
  partialSandbox = true;
393
- raw = await deps.createSandbox(request, prepared);
347
+ raw = await deps.createSandbox(request);
394
348
  } else if (isRunning(inspected)) {
395
349
  raw = await deps.connectSandbox(inspected);
396
350
  partialSandbox = true;
@@ -410,72 +364,25 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
410
364
  runtimeInvalid = false;
411
365
  const preparation = await deps.prepareRuntime(bootedRuntime, request.config);
412
366
 
413
- let seed: SeedResult | null = null;
414
- if (
415
- prepared.plan.kind === "git-volume" &&
416
- prepared.createdVolume &&
417
- prepared.plan.seedRequired
418
- ) {
419
- seed = await deps.seed(bootedRuntime, prepared);
420
- }
421
-
422
367
  const createdAt = deps.now ? deps.now() : Date.now();
423
- const previous = restored;
424
- const seedBranch = prepared.createdVolume
425
- ? prepared.plan.kind === "git-volume"
426
- ? prepared.plan.branch
427
- : null
428
- : previous?.seedBranch ??
429
- (prepared.plan.kind === "git-volume" ? prepared.plan.branch : null);
430
- const seedSha = prepared.createdVolume
431
- ? seed?.headSha ??
432
- (prepared.plan.kind === "git-volume" ? prepared.plan.headSha : null)
433
- : previous?.seedSha ??
434
- (prepared.plan.kind === "git-volume" ? prepared.plan.headSha : null);
435
- currentRequestInfo = infoFor(
436
- request,
437
- prepared.plan,
438
- prepared,
439
- seedBranch,
440
- seedSha,
441
- createdAt,
442
- name,
443
- preparation,
444
- );
445
-
446
- // Bundle cleanup is deliberately before state publication: a successful
447
- // boot never publishes a state whose temporary host artifact leaked.
448
- if (prepared.bundle) await prepared.bundle.cleanup();
449
- prepared.bundle = null;
368
+ currentRequestInfo = infoFor(request, createdAt, name, preparation);
450
369
 
451
370
  const persisted: PersistedSandboxState = {
452
371
  version: STATE_SCHEMA_VERSION,
453
372
  sessionId: request.sessionId,
454
373
  sandboxName: name,
455
- mode: currentRequestInfo.mode,
456
374
  cwd: request.cwd,
375
+ root: request.workspace.hostRoot,
376
+ guestRoot: request.workspace.guestRoot,
457
377
  image: request.config.image,
458
- volumeName: currentRequestInfo.volumeName,
459
- volumeHostPath: currentRequestInfo.volumeHostPath,
460
- seedBranch: currentRequestInfo.seedBranch,
461
- seedSha: currentRequestInfo.seedSha,
462
378
  enabled: true,
463
379
  createdAt,
464
380
  };
465
381
  deps.persist(persisted);
466
382
  retainedState = persisted;
467
- activePlan = prepared.plan;
468
383
  state = { status: "active", info: currentRequestInfo };
469
384
  return state;
470
385
  } catch (error) {
471
- if (prepared?.bundle) {
472
- try {
473
- await prepared.bundle.cleanup();
474
- } catch {
475
- // The primary boot error is more useful, and no secret is exposed.
476
- }
477
- prepared.bundle = null;
478
- }
479
386
  await disposeRuntime();
480
387
  if (partialSandbox && sandboxName) {
481
388
  try {
@@ -486,7 +393,6 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
486
393
  }
487
394
  runtime = null;
488
395
  bootedRuntime = null;
489
- activePlan = null;
490
396
  sandboxName = null;
491
397
  if (lock === owner) {
492
398
  lock = null;
@@ -502,7 +408,7 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
502
408
  }
503
409
 
504
410
  async function wakeIfNeeded(): Promise<RuntimeExecution> {
505
- if (state.status !== "active" || !runtime || !lastRequest || !sandboxName || !activePlan) {
411
+ if (state.status !== "active" || !runtime || !lastRequest || !sandboxName) {
506
412
  throw unavailableError(state);
507
413
  }
508
414
 
@@ -517,9 +423,9 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
517
423
  throw new Error(`sandbox ${sandboxName} has conflicting labels`);
518
424
  }
519
425
  // Revalidate the complete managed runtime identity before waking or
520
- // reconnecting. A same-name resource with changed mode/cwd/image/volume is
521
- // not safe to attach to.
522
- if (!matchesPlan(inspected, lastRequest, activePlan)) {
426
+ // reconnecting. A same-name resource with changed cwd/root/image is not
427
+ // safe to attach to.
428
+ if (!matchesWorkspace(inspected, lastRequest)) {
523
429
  throw new Error(`sandbox ${sandboxName} configuration changed`);
524
430
  }
525
431
 
@@ -564,7 +470,6 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
564
470
  }
565
471
  const failedName = sandboxName;
566
472
  sandboxName = null;
567
- activePlan = null;
568
473
  if (failedName) {
569
474
  try {
570
475
  await deps.stopAndRemove(failedName, lastRequest.config.stopTimeoutMs);
@@ -611,7 +516,6 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
611
516
  }
612
517
 
613
518
  runtime = null;
614
- activePlan = null;
615
519
  sandboxName = null;
616
520
 
617
521
  if (persistDisabled && request && oldInfo) {
@@ -620,13 +524,10 @@ export function createSandboxManager(deps: SandboxManagerDeps): SandboxManager {
620
524
  version: STATE_SCHEMA_VERSION,
621
525
  sessionId: request.sessionId,
622
526
  sandboxName: oldInfo.name,
623
- mode: oldInfo.mode,
624
527
  cwd: oldInfo.cwd,
528
+ root: request.workspace.hostRoot,
529
+ guestRoot: request.workspace.guestRoot,
625
530
  image: oldInfo.image,
626
- volumeName: oldInfo.volumeName,
627
- volumeHostPath: oldInfo.volumeHostPath,
628
- seedBranch: oldInfo.seedBranch,
629
- seedSha: oldInfo.seedSha,
630
531
  enabled: false,
631
532
  createdAt,
632
533
  };
@@ -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
- const lines = [
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
- "This operation will run outside the selected pi-microsandbox environment with the host process's permissions and environment.",
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, config: Config): boolean {
126
- return state.status === "off" || state.status === "disabled" || state.status === "host-fallback" ||
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
- // mode change must never extend a one-call host escape.
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, mode);
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, deps.config);
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, undefined);
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, state.info?.mode);
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, deps.config);
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, state.info?.mode),
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, deps.config)) return undefined;
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,