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.
@@ -1,8 +1,5 @@
1
- import { promises as fs } from "node:fs";
2
- import { execFile as execFileCallback } from "node:child_process";
3
- import { promisify } from "node:util";
4
1
  import { homedir } from "node:os";
5
- import { isAbsolute, join, resolve, basename, relative as relativePath } from "node:path";
2
+ import { join } from "node:path";
6
3
 
7
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
5
  import {
@@ -15,22 +12,19 @@ import {
15
12
  removeOverride,
16
13
  toEffectiveToml,
17
14
  } from "./config.ts";
18
- import { createSeedBundle, detectGitRepo } from "./git.ts";
19
- import { buildSandboxLabels, buildVolumeLabels, decodeSessionState, normalizeSandboxRecord, normalizeVolumeRecord, encodeSessionState } from "./labels.ts";
15
+ import { buildSandboxLabels, decodeSessionState, normalizeSandboxRecord, encodeSessionState } from "./labels.ts";
20
16
  import { acquireOwnerLock, createLocksPort } from "./locks.ts";
21
17
  import { createBashOps, createFindOps, createGrepOps } from "./operations-exec.ts";
22
18
  import { createEditOps, createLsOps, createReadOps, createWriteOps } from "./operations.ts";
23
19
  import { pruneStale, type PrunePort } from "./prune.ts";
24
- import { buildStoragePlan, seedGitVolume, validateReusableVolume } from "./storage.ts";
25
20
  import { createSdkTransport } from "./transport.ts";
21
+ import { discoverWorkspace } from "./workspace.ts";
26
22
  import { createSandboxManager, type InspectedSandbox, type SandboxManagerDeps } from "./sandbox-manager.ts";
27
23
  import {
28
- LABEL_KEYS,
24
+ LOCKFILE_VERSION,
29
25
  sandboxNameFor,
30
- volumeNameFor,
31
26
  type BootRequest,
32
27
  type Config,
33
- type GitRepoInfo,
34
28
  type LockInfo,
35
29
  type LocksPort,
36
30
  type MsbControl,
@@ -39,24 +33,21 @@ import {
39
33
  type RuntimeExecution,
40
34
  type RuntimePreparation,
41
35
  type RuntimeState,
42
- type StoragePlan,
43
36
  type ToolOperations,
44
37
  type ToolOpsProvider,
45
- type VolumeRecord,
38
+ type Workspace,
46
39
  type DeepPartial,
47
40
  } from "./types.ts";
48
41
 
49
- const execFile = promisify(execFileCallback);
50
42
  const STATE_ENTRY = "pi-msb.state";
51
43
  const OVERRIDE_ENTRY = "pi-msb.override";
52
- const REQUIRED_GUEST_COMMANDS = ["bash", "git", "rg", "file", "cat", "mkdir", "rm"] as const;
44
+ const REQUIRED_GUEST_COMMANDS = ["bash", "rg", "file", "cat", "mkdir", "rm"] as const;
53
45
 
54
46
  type AnyRecord = Record<string, any>;
55
47
 
56
48
  /** The deliberately small SDK surface used by the adapter. Tests can inject this. */
57
49
  export interface MicrosandboxModule {
58
50
  Sandbox: AnyRecord;
59
- Volume: AnyRecord;
60
51
  NetworkPolicy?: AnyRecord;
61
52
  Rule?: AnyRecord;
62
53
  }
@@ -64,7 +55,6 @@ export interface MicrosandboxModule {
64
55
  export interface SessionSetup {
65
56
  sessionId: string;
66
57
  cwd: string;
67
- repoRoot?: string | null;
68
58
  projectTrusted: boolean;
69
59
  config?: ResolvedConfig;
70
60
  restored?: PersistedSandboxState | null;
@@ -109,7 +99,7 @@ function expandHome(path: string): string {
109
99
  function sdkNotFound(error: unknown): boolean {
110
100
  const value = error as AnyRecord;
111
101
  const name = typeof value?.constructor?.name === "string" ? value.constructor.name : "";
112
- return name.includes("NotFound") || ["NOT_FOUND", "ENOENT", "volumeNotFound", "sandboxNotFound"].includes(value?.code);
102
+ return name.includes("NotFound") || ["NOT_FOUND", "ENOENT", "sandboxNotFound"].includes(value?.code);
113
103
  }
114
104
 
115
105
  function objectConfig(value: unknown): AnyRecord {
@@ -159,110 +149,6 @@ function inspectedFromHandle(handle: AnyRecord, config: AnyRecord = {}): Inspect
159
149
  };
160
150
  }
161
151
 
162
- function volumeHostPath(value: AnyRecord): string | undefined {
163
- // VolumeHandle has no path getter. Only use a path
164
- // when the SDK object explicitly exposes one (the Volume returned by create)
165
- // or a future metadata object documents one; never manufacture a host path.
166
- const direct = typeof value.path === "string" ? value.path : typeof value.hostPath === "string" ? value.hostPath : undefined;
167
- if (direct && isAbsolute(direct)) return direct;
168
- const metadata = objectConfig(value.metadata ?? value.info);
169
- const documented = typeof metadata.hostPath === "string" ? metadata.hostPath : typeof metadata.path === "string" ? metadata.path : undefined;
170
- return documented && isAbsolute(documented) ? documented : undefined;
171
- }
172
-
173
- function volumeIdentityFromHandle(handle: AnyRecord): { name: string; labels: Record<string, string> } | null {
174
- const name = typeof handle.name === "string" ? handle.name : undefined;
175
- const labels = sdkLabels(handle);
176
- return name && Object.keys(labels).length ? { name, labels } : null;
177
- }
178
-
179
- function volumeFromHandle(handle: AnyRecord): VolumeRecord | null {
180
- const identity = volumeIdentityFromHandle(handle);
181
- const hostPath = volumeHostPath(handle);
182
- if (!identity || !hostPath) return null;
183
- return normalizeVolumeRecord({
184
- name: identity.name,
185
- hostPath,
186
- labels: identity.labels,
187
- kind: handle.kind,
188
- usedBytes: handle.usedBytes,
189
- createdAt: handle.createdAt instanceof Date ? handle.createdAt.getTime() : handle.createdAt,
190
- });
191
- }
192
-
193
- function reusableVolumeIdentity(plan: Extract<StoragePlan, { kind: "git-volume" }>, identity: { name: string; labels: Record<string, string> }): boolean {
194
- const labels = identity.labels;
195
- return identity.name === plan.volumeName &&
196
- labels[LABEL_KEYS.managed] === "true" &&
197
- labels[LABEL_KEYS.schema] === "1" &&
198
- labels[LABEL_KEYS.session] === plan.sessionId &&
199
- labels[LABEL_KEYS.cwd] === plan.workdir &&
200
- labels[LABEL_KEYS.mode] === "git" &&
201
- labels[LABEL_KEYS.keep] === "true";
202
- }
203
-
204
- function labelsForVolume(input: { sessionId: string; cwd: string }): Record<string, string> {
205
- return { ...buildVolumeLabels(input), [LABEL_KEYS.mode]: "git" };
206
- }
207
-
208
- interface ManagedVolumeTarget {
209
- sessionId: string;
210
- cwd: string;
211
- name: string;
212
- }
213
-
214
- function managedVolumeTarget(requestedName: string, identity: { name: string; labels: Record<string, string> }): ManagedVolumeTarget | null {
215
- const labels = identity.labels;
216
- const sessionId = labels[LABEL_KEYS.session];
217
- const cwd = labels[LABEL_KEYS.cwd];
218
- if (!sessionId || !cwd || !isAbsolute(cwd)) return null;
219
- const expectedLabels = labelsForVolume({ sessionId, cwd });
220
- if (identity.name !== requestedName || requestedName !== volumeNameFor(sessionId)) return null;
221
- if (Object.entries(expectedLabels).some(([key, value]) => labels[key] !== value)) return null;
222
- return { sessionId, cwd, name: requestedName };
223
- }
224
-
225
- function namedMountVolume(mount: unknown): string | undefined {
226
- const value = objectConfig(mount);
227
- const kind = value.kind ?? value.type;
228
- if (typeof kind === "string" && kind.toLowerCase() === "named" && typeof value.name === "string") return value.name;
229
- const nested = objectConfig(value.named ?? value.Named);
230
- return typeof nested.name === "string" ? nested.name : undefined;
231
- }
232
-
233
- async function volumeIsMounted(msb: MicrosandboxModule, name: string): Promise<boolean> {
234
- let cursor: string | undefined;
235
- const seenCursors = new Set<string>();
236
- while (true) {
237
- const page = await msb.Sandbox.listWith((list: AnyRecord) => {
238
- if (cursor) list.cursor(cursor);
239
- return list;
240
- });
241
- if (!page || !Array.isArray(page.sandboxes)) throw new Error("unable to verify volume mount state: malformed sandbox list");
242
- for (const listed of page.sandboxes) {
243
- if (typeof listed?.name !== "string" || !listed.name) throw new Error("unable to verify volume mount state: malformed sandbox handle");
244
- let handle: AnyRecord;
245
- try {
246
- handle = await msb.Sandbox.get(listed.name);
247
- } catch (error) {
248
- if (sdkNotFound(error)) continue;
249
- throw error;
250
- }
251
- const status = sdkStatus(handle)?.trim().toLowerCase();
252
- if (status === "stopped" || status === "crashed") continue;
253
- const config = await handleConfig(handle);
254
- if (!Array.isArray(config.mounts)) throw new Error(`unable to verify volume mount state for sandbox ${listed.name}`);
255
- if (config.mounts.some((mount: unknown) => namedMountVolume(mount) === name)) return true;
256
- }
257
- if (page.nextCursor === undefined) return false;
258
- if (typeof page.nextCursor !== "string" || !page.nextCursor || seenCursors.has(page.nextCursor)) {
259
- throw new Error("unable to verify volume mount state: invalid sandbox page cursor");
260
- }
261
- seenCursors.add(page.nextCursor);
262
- cursor = page.nextCursor;
263
- }
264
- }
265
-
266
152
  function parsePort(value: string): { bind: string; host: number; guest: number } {
267
153
  const parts = value.split(":");
268
154
  const numbers = parts.slice(-2).map((item) => Number(item));
@@ -319,14 +205,12 @@ function applyNetwork(builder: AnyRecord, config: Config, sdk: MicrosandboxModul
319
205
  }
320
206
 
321
207
  function lockInfoFor(request: BootRequest): LockInfo {
322
- const mode = request.config.mode === "direct" || request.config.mode === "none" ? request.config.mode : "git";
323
208
  return {
324
- version: 1,
209
+ version: LOCKFILE_VERSION,
325
210
  sessionId: request.sessionId,
326
211
  sandboxName: request.config.sandboxName ?? sandboxNameFor(request.sessionId),
327
- volumeName: mode === "git" ? volumeNameFor(request.sessionId) : undefined,
328
- mode,
329
212
  cwd: request.cwd,
213
+ root: request.workspace.hostRoot,
330
214
  pid: process.pid,
331
215
  createdAt: Date.now(),
332
216
  };
@@ -351,8 +235,7 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
351
235
  const configRef = { value: { ...DEFAULT_CONFIG, network: { ...DEFAULT_CONFIG.network }, docker: { ...DEFAULT_CONFIG.docker } } as Config };
352
236
  let sessionId = options.sessionId;
353
237
  let cwd = options.cwd;
354
- let repoRoot: string | null = null;
355
- let currentGit: GitRepoInfo = { isGitRepo: false, repoRoot: null, branch: null, headSha: null, unborn: false, isLinkedWorktree: false };
238
+ let workspace: Workspace | null = null;
356
239
  let resolved: ResolvedConfig = { config: configRef.value, provenance: {}, warnings: [] };
357
240
  let overrides: DeepPartial<Config> = {};
358
241
  let projectTrusted = true;
@@ -407,55 +290,6 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
407
290
  ? options.acquireOwnerLock(request)
408
291
  : acquireOwnerLock({ lockDir: expandHome(request.config.lockDir) }, lockInfoFor(request)),
409
292
  pruneOthers: async (currentSessionId) => pruneStale({ port: prunePort, locks: lockPort(), currentSessionId, stopTimeoutMs: configRef.value.stopTimeoutMs }),
410
- detectGit: async (path) => {
411
- currentGit = await detectGitRepo(path);
412
- currentGit = currentGit;
413
- return currentGit;
414
- },
415
- buildStoragePlan: (input) => {
416
- const plan = buildStoragePlan(input);
417
- activeProjectRoot = plan.kind === "git-volume" ? plan.mountGuestPath : input.cwd;
418
- return plan;
419
- },
420
- prepareStorage: async (plan, restored) => {
421
- if (plan.kind !== "git-volume") return { plan, createdVolume: false };
422
- const msb = await sdk();
423
- let volume: VolumeRecord | undefined;
424
- let createdVolume = false;
425
- try {
426
- const handle = await msb.Volume.get(plan.volumeName);
427
- const identity = volumeIdentityFromHandle(handle);
428
- if (!identity || !reusableVolumeIdentity(plan, identity)) throw new Error(`managed volume identity mismatch: ${plan.volumeName}`);
429
- const record = volumeFromHandle(handle);
430
- if (record && !validateReusableVolume(plan, record)) throw new Error(`managed volume identity mismatch: ${plan.volumeName}`);
431
- // VolumeHandle has no supported host path. Reuse is safe
432
- // because mounting uses the name; status/export report no fabricated path.
433
- volume = record ?? undefined;
434
- } catch (error) {
435
- if (!sdkNotFound(error)) throw error;
436
- const labels = labelsForVolume({ sessionId: plan.sessionId, cwd: plan.workdir });
437
- let builder = msb.Volume.builder(plan.volumeName).directory().quota(plan.volumeQuotaMiB);
438
- for (const [key, value] of Object.entries(labels)) builder = builder.label(key, value);
439
- const created = await builder.create();
440
- const createdVolumeRecord = volumeFromHandle({
441
- name: created.name,
442
- path: volumeHostPath(created),
443
- labels,
444
- kind: created.kind,
445
- usedBytes: created.usedBytes,
446
- createdAt: created.createdAt,
447
- });
448
- if (!createdVolumeRecord) throw new Error("microsandbox returned invalid volume metadata");
449
- volume = createdVolumeRecord;
450
- createdVolume = true;
451
- }
452
- let bundle = null;
453
- if (createdVolume && plan.seedRequired && !plan.unborn) {
454
- bundle = await createSeedBundle(currentGit, { branch: configRef.value.cloneBranch, depth: configRef.value.cloneDepth });
455
- }
456
- // The manager owns cleanup after seed/boot failure; storage owns only the bundle creation.
457
- return { plan, volume, bundle, createdVolume };
458
- },
459
293
  inspectSandbox: async (name) => {
460
294
  const msb = await sdk();
461
295
  try {
@@ -468,16 +302,12 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
468
302
  },
469
303
  connectSandbox: async (value) => objectConfig(value)._handle.connect(),
470
304
  startSandbox: async (value) => objectConfig(value)._handle.startDetached(),
471
- createSandbox: async (request, prepared) => {
305
+ createSandbox: async (request) => {
472
306
  const msb = await sdk();
473
307
  const name = request.config.sandboxName ?? sandboxNameFor(request.sessionId);
474
- const plan = prepared.plan;
475
- const mode = plan.kind === "git-volume" ? "git" : plan.kind === "direct-mount" ? "direct" : "none";
476
- const labels = buildSandboxLabels({ sessionId: request.sessionId, mode, cwd: request.cwd, pid: process.pid, image: request.config.image, volumeName: plan.kind === "git-volume" ? plan.volumeName : undefined, seedBranch: plan.kind === "git-volume" ? plan.branch : null, seedSha: plan.kind === "git-volume" ? plan.headSha : null });
477
- let builder = msb.Sandbox.builder(name).image(request.config.image).pullPolicy(request.config.pullPolicy).cpus(request.config.cpus).memory(request.config.memoryMiB).idleTimeout(request.config.idleTimeoutSec).detached(request.config.detached).workdir(plan.kind === "git-volume" ? plan.workdir : request.cwd).labels(labels);
478
- if (plan.kind === "git-volume") builder.volume(plan.mountGuestPath, (m: AnyRecord) => m.named(plan.volumeName));
479
- else if (plan.kind === "direct-mount") builder.volume(plan.guestPath, (m: AnyRecord) => m.bind(plan.hostPath));
480
- else builder.volume(plan.guestPath, (m: AnyRecord) => m.tmpfs());
308
+ const labels = buildSandboxLabels({ sessionId: request.sessionId, cwd: request.cwd, root: request.workspace.hostRoot, guestRoot: request.workspace.guestRoot, pid: process.pid, image: request.config.image });
309
+ let builder = msb.Sandbox.builder(name).image(request.config.image).pullPolicy(request.config.pullPolicy).cpus(request.config.cpus).memory(request.config.memoryMiB).idleTimeout(request.config.idleTimeoutSec).detached(request.config.detached).workdir(request.workspace.cwd).labels(labels);
310
+ builder.volume(request.workspace.guestRoot, (m: AnyRecord) => m.bind(request.workspace.hostRoot));
481
311
  for (const mount of request.config.mounts) applyMount(builder, mount);
482
312
  applyNetwork(builder, request.config, msb);
483
313
  const envValues: Record<string, string> = {};
@@ -545,7 +375,7 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
545
375
  const apt = await runtime.transport.exec("sh", ["-c", 'command -v "$1" >/dev/null 2>&1', "pi-msb-probe", "apt-get"]);
546
376
  if (apt.exitCode === 0) {
547
377
  await runtime.transport.exec("apt-get", ["update", "-y"]);
548
- await runtime.transport.exec("apt-get", ["install", "-y", "--no-install-recommends", "bash", "git", "ripgrep", "file", "coreutils", "ca-certificates"]);
378
+ await runtime.transport.exec("apt-get", ["install", "-y", "--no-install-recommends", "bash", "ripgrep", "file", "coreutils", "ca-certificates"]);
549
379
  commands = await commandsMissing(REQUIRED_GUEST_COMMANDS);
550
380
  }
551
381
  }
@@ -627,7 +457,6 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
627
457
  }
628
458
  return { docker: { mode, readiness: "ready", version, storageDriver } };
629
459
  },
630
- seed: async (runtime, prepared) => seedGitVolume(runtime.transport, prepared.plan as any, prepared.bundle ?? null),
631
460
  persist: (state) => options.appendEntry?.(STATE_ENTRY, encodeSessionState(state)),
632
461
  };
633
462
 
@@ -657,16 +486,15 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
657
486
  }
658
487
  return result;
659
488
  };
660
- const resolveForSession = async (setup: SessionSetup): Promise<ResolvedConfig> => {
489
+ const resolveForSession = async (setup: SessionSetup, selected: Workspace): Promise<ResolvedConfig> => {
661
490
  if (setup.config) return setup.config;
662
- return resolveConfig({ cwd: setup.cwd, repoRoot: setup.repoRoot, projectTrusted: setup.projectTrusted, configDirName: options.configDirName, env: options.env, cliOverridesToml: overridesToToml(overrides) });
491
+ return resolveConfig({ cwd: selected.cwd, repoRoot: selected.guestRoot, projectTrusted: setup.projectTrusted, configDirName: options.configDirName, env: options.env, cliOverridesToml: overridesToToml(overrides) });
663
492
  };
664
493
 
665
494
  const configureSession = async (setup: SessionSetup): Promise<RuntimeState> => {
666
495
  sessionId = setup.sessionId;
667
496
  cwd = setup.cwd;
668
497
  configReady = false;
669
- repoRoot = setup.repoRoot ?? null;
670
498
  projectTrusted = setup.projectTrusted;
671
499
  explicitOff = isDisabledByEnv(options.env ?? process.env);
672
500
  if (explicitOff) {
@@ -674,14 +502,13 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
674
502
  notifyState(failureState);
675
503
  return visibleState();
676
504
  }
677
- currentGit = await detectGitRepo(cwd);
678
- repoRoot = currentGit.guestRepoRoot === null
679
- ? cwd
680
- : currentGit.guestRepoRoot ?? currentGit.repoRoot;
681
- overrides = overrideEntries(options.entries?.() ?? []);
682
505
  let next: ResolvedConfig;
683
506
  try {
684
- next = await resolveForSession({ ...setup, repoRoot });
507
+ overrides = overrideEntries(options.entries?.() ?? []);
508
+ workspace = await discoverWorkspace(cwd);
509
+ cwd = workspace.cwd;
510
+ activeProjectRoot = workspace.guestRoot;
511
+ next = await resolveForSession(setup, workspace);
685
512
  } catch (error) {
686
513
  failureState = { status: "unavailable", info: null, reason: redactedError(error, configRef.value).message };
687
514
  notifyState(failureState);
@@ -697,7 +524,7 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
697
524
  if (!explicitOff) { await manager.setEnabled(false); failureState = { status: "off", info: null }; }
698
525
  return visibleState();
699
526
  }
700
- const result = await manager.boot({ sessionId, cwd, config: configSnapshot(), restored: state });
527
+ const result = await manager.boot({ sessionId, cwd, workspace, config: configSnapshot(), restored: state });
701
528
  if (result.status === "unavailable") failureState = result;
702
529
  notifyState(visibleState());
703
530
  return visibleState();
@@ -725,8 +552,19 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
725
552
  await manager.setEnabled(false);
726
553
  failureState = { status: "off", info: null };
727
554
  } else {
728
- const result = await manager.boot({ sessionId, cwd, config: configSnapshot(), restored: persistenceState(options.entries?.() ?? [], sessionId) });
729
- if (result.status === "unavailable") failureState = result;
555
+ try {
556
+ workspace = await discoverWorkspace(cwd);
557
+ cwd = workspace.cwd;
558
+ activeProjectRoot = workspace.guestRoot;
559
+ const next = await resolveConfig({ cwd, repoRoot: workspace.guestRoot, projectTrusted, configDirName: options.configDirName, env: options.env, cliOverridesToml: overridesToToml(overrides) });
560
+ resolved = next;
561
+ configReady = true;
562
+ Object.assign(configRef.value, next.config, { network: { ...next.config.network }, docker: { ...next.config.docker }, secrets: [...next.config.secrets], mounts: [...next.config.mounts] });
563
+ const result = await manager.boot({ sessionId, cwd, workspace, config: configSnapshot(), restored: persistenceState(options.entries?.() ?? [], sessionId) });
564
+ if (result.status === "unavailable") failureState = result;
565
+ } catch (error) {
566
+ failureState = { status: "unavailable", info: null, reason: redactedError(error, configRef.value).message };
567
+ }
730
568
  }
731
569
  notifyState(visibleState());
732
570
  },
@@ -739,7 +577,10 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
739
577
  }
740
578
  let next: ResolvedConfig;
741
579
  try {
742
- next = await resolveConfig({ cwd, repoRoot, projectTrusted, configDirName: options.configDirName, env: options.env, cliOverridesToml: overridesToToml(overrides) });
580
+ workspace = await discoverWorkspace(cwd);
581
+ cwd = workspace.cwd;
582
+ activeProjectRoot = workspace.guestRoot;
583
+ next = await resolveConfig({ cwd, repoRoot: workspace.guestRoot, projectTrusted, configDirName: options.configDirName, env: options.env, cliOverridesToml: overridesToToml(overrides) });
743
584
  } catch (error) {
744
585
  configReady = false;
745
586
  if (!manager.isActive()) failureState = { status: "unavailable", info: null, reason: redactedError(error, configRef.value).message };
@@ -750,8 +591,8 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
750
591
  configReady = true;
751
592
  Object.assign(configRef.value, next.config, { network: { ...next.config.network }, docker: { ...next.config.docker }, secrets: [...next.config.secrets], mounts: [...next.config.mounts] });
752
593
  failureState = null;
753
- if (!explicitOff && configRef.value.autoStart) {
754
- const result = await manager.boot({ sessionId, cwd, config: configSnapshot(), restored: persistenceState(options.entries?.() ?? [], sessionId) });
594
+ if (!explicitOff && configRef.value.autoStart && workspace) {
595
+ const result = await manager.boot({ sessionId, cwd, workspace, config: configSnapshot(), restored: persistenceState(options.entries?.() ?? [], sessionId) });
755
596
  if (result.status === "unavailable") failureState = result;
756
597
  }
757
598
  notifyState(visibleState());
@@ -761,94 +602,6 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
761
602
  notifyState(visibleState());
762
603
  return report;
763
604
  },
764
- async listVolumes() {
765
- const msb = await sdk();
766
- const handles = await msb.Volume.list();
767
- const volumes: VolumeRecord[] = [];
768
- for (const handle of handles) {
769
- const identity = volumeIdentityFromHandle(handle);
770
- if (!identity || identity.labels[LABEL_KEYS.managed] !== "true") continue;
771
- const record = volumeFromHandle(handle);
772
- if (!record) throw new Error(`host path metadata is unavailable for managed volume ${identity.name}; refusing to fabricate a path`);
773
- volumes.push(record);
774
- }
775
- return volumes;
776
- },
777
- async describeVolume(name) {
778
- const msb = await sdk();
779
- const handle = await msb.Volume.get(name);
780
- const volume = volumeFromHandle(handle);
781
- if (!volume) throw new Error("microsandbox returned invalid volume metadata");
782
- if (volume.labels[LABEL_KEYS.managed] !== "true") throw new Error("refusing to inspect an unmanaged volume");
783
- let branch: string | undefined;
784
- let lastCommit: string | undefined;
785
- let dirtyCount: number | undefined;
786
- try {
787
- const head = await execFile("git", ["-C", volume.hostPath, "symbolic-ref", "--quiet", "--short", "HEAD"]);
788
- branch = head.stdout.trim() || undefined;
789
- } catch { /* detached/uninitialized volumes are valid */ }
790
- try {
791
- const head = await execFile("git", ["-C", volume.hostPath, "rev-parse", "HEAD"]);
792
- lastCommit = head.stdout.trim() || undefined;
793
- } catch { /* no commit */ }
794
- try {
795
- const status = await execFile("git", ["-C", volume.hostPath, "status", "--porcelain"]);
796
- dirtyCount = status.stdout.trim() ? status.stdout.trim().split("\n").length : 0;
797
- } catch { /* non-git volume */ }
798
- return { volume, branch, lastCommit, dirtyCount, mounted: visibleState().info?.volumeName === name };
799
- },
800
- async removeVolume(name) {
801
- const msb = await sdk();
802
- const initialIdentity = volumeIdentityFromHandle(await msb.Volume.get(name));
803
- const target = initialIdentity ? managedVolumeTarget(name, initialIdentity) : null;
804
- if (!target) throw new Error(`managed volume identity mismatch: ${name}`);
805
- const targetRequest: BootRequest = {
806
- sessionId: target.sessionId,
807
- cwd: target.cwd,
808
- config: { ...configRef.value, mode: "git", sandboxName: sandboxNameFor(target.sessionId) },
809
- restored: null,
810
- };
811
- const owner = options.acquireOwnerLock
812
- ? await options.acquireOwnerLock(targetRequest)
813
- : await acquireOwnerLock({ lockDir: expandHome(configRef.value.lockDir) }, lockInfoFor(targetRequest));
814
- if (!owner) throw new Error("another process owns the target session; volume removal is blocked");
815
- try {
816
- const lockedIdentity = volumeIdentityFromHandle(await msb.Volume.get(name));
817
- const lockedTarget = lockedIdentity ? managedVolumeTarget(name, lockedIdentity) : null;
818
- if (!lockedTarget || lockedTarget.sessionId !== target.sessionId || lockedTarget.cwd !== target.cwd || lockedTarget.name !== target.name) {
819
- throw new Error(`managed volume identity mismatch: ${name}`);
820
- }
821
- if (visibleState().info?.volumeName === name || await volumeIsMounted(msb, name)) {
822
- throw new Error("refusing to remove a mounted volume");
823
- }
824
- // Volume.remove is the final atomic mount check: microsandbox rejects
825
- // deletion if a sandbox mounts the volume after the list recheck.
826
- await msb.Volume.remove(name);
827
- } finally {
828
- await owner.release();
829
- }
830
- },
831
- async exportPaths(paths, destination) {
832
- if (!manager.isActive()) throw new Error("sandbox is not active");
833
- const target = destination ? resolve(destination) : await fs.mkdtemp(join((process.env.TMPDIR ?? "/tmp"), "pi-msb-export-"));
834
- await fs.mkdir(target, { recursive: true, mode: 0o700 });
835
- const results: Array<{ source: string; destination: string }> = [];
836
- await manager.withRuntime(async (runtime) => {
837
- for (const requested of paths) {
838
- const source = requested.startsWith("/") ? resolve(requested) : resolve(cwd, requested);
839
- const sourceRoot = resolve(activeProjectRoot);
840
- const sourceRelative = relativePath(sourceRoot, source);
841
- if (sourceRelative === ".." || sourceRelative.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(sourceRelative)) {
842
- throw new Error(`refusing to export a path outside the sandbox project: ${requested}`);
843
- }
844
- const output = join(target, basename(source));
845
- if (resolve(output) !== output || await fs.lstat(output).then(() => true, () => false)) throw new Error(`export destination exists: ${output}`);
846
- await runtime.transport.copyToHost(source, output);
847
- results.push({ source, destination: output });
848
- }
849
- });
850
- return results;
851
- },
852
605
  async getLogs(tailLines) {
853
606
  const name = visibleState().info?.name;
854
607
  if (!name) return "";
@@ -876,8 +629,6 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
876
629
  },
877
630
  };
878
631
 
879
- // Keep the returned config facade useful to callers without exposing mutable internals.
880
- void repoRoot;
881
632
  return { control, manager, provider, configRef, configureSession };
882
633
  }
883
634
 
@@ -117,8 +117,8 @@ export default function registerPiMsb(pi: ExtensionAPI): void {
117
117
  currentContext = ctx;
118
118
  updateStatus(ctx, { status: "booting", info: null });
119
119
  try {
120
- // configureSession performs Git discovery before project config resolution,
121
- // so trust and the nearest project file are evaluated against the real root.
120
+ // configureSession discovers the workspace once before project config
121
+ // resolution, and passes that same identity through sandbox boot.
122
122
  const state = await integration.configureSession({
123
123
  sessionId: ctx.sessionManager.getSessionId(),
124
124
  cwd: ctx.cwd,