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.
@@ -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,47 +12,42 @@ 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,
37
31
  type PersistedSandboxState,
38
32
  type ResolvedConfig,
39
33
  type RuntimeExecution,
34
+ type RuntimePreparation,
40
35
  type RuntimeState,
41
- type StoragePlan,
42
36
  type ToolOperations,
43
37
  type ToolOpsProvider,
44
- type VolumeRecord,
38
+ type Workspace,
45
39
  type DeepPartial,
46
40
  } from "./types.ts";
47
41
 
48
- const execFile = promisify(execFileCallback);
49
42
  const STATE_ENTRY = "pi-msb.state";
50
43
  const OVERRIDE_ENTRY = "pi-msb.override";
51
- 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;
52
45
 
53
46
  type AnyRecord = Record<string, any>;
54
47
 
55
48
  /** The deliberately small SDK surface used by the adapter. Tests can inject this. */
56
49
  export interface MicrosandboxModule {
57
50
  Sandbox: AnyRecord;
58
- Volume: AnyRecord;
59
51
  NetworkPolicy?: AnyRecord;
60
52
  Rule?: AnyRecord;
61
53
  }
@@ -63,7 +55,6 @@ export interface MicrosandboxModule {
63
55
  export interface SessionSetup {
64
56
  sessionId: string;
65
57
  cwd: string;
66
- repoRoot?: string | null;
67
58
  projectTrusted: boolean;
68
59
  config?: ResolvedConfig;
69
60
  restored?: PersistedSandboxState | null;
@@ -108,7 +99,7 @@ function expandHome(path: string): string {
108
99
  function sdkNotFound(error: unknown): boolean {
109
100
  const value = error as AnyRecord;
110
101
  const name = typeof value?.constructor?.name === "string" ? value.constructor.name : "";
111
- return name.includes("NotFound") || ["NOT_FOUND", "ENOENT", "volumeNotFound", "sandboxNotFound"].includes(value?.code);
102
+ return name.includes("NotFound") || ["NOT_FOUND", "ENOENT", "sandboxNotFound"].includes(value?.code);
112
103
  }
113
104
 
114
105
  function objectConfig(value: unknown): AnyRecord {
@@ -158,116 +149,12 @@ function inspectedFromHandle(handle: AnyRecord, config: AnyRecord = {}): Inspect
158
149
  };
159
150
  }
160
151
 
161
- function volumeHostPath(value: AnyRecord): string | undefined {
162
- // VolumeHandle has no path getter. Only use a path
163
- // when the SDK object explicitly exposes one (the Volume returned by create)
164
- // or a future metadata object documents one; never manufacture a host path.
165
- const direct = typeof value.path === "string" ? value.path : typeof value.hostPath === "string" ? value.hostPath : undefined;
166
- if (direct && isAbsolute(direct)) return direct;
167
- const metadata = objectConfig(value.metadata ?? value.info);
168
- const documented = typeof metadata.hostPath === "string" ? metadata.hostPath : typeof metadata.path === "string" ? metadata.path : undefined;
169
- return documented && isAbsolute(documented) ? documented : undefined;
170
- }
171
-
172
- function volumeIdentityFromHandle(handle: AnyRecord): { name: string; labels: Record<string, string> } | null {
173
- const name = typeof handle.name === "string" ? handle.name : undefined;
174
- const labels = sdkLabels(handle);
175
- return name && Object.keys(labels).length ? { name, labels } : null;
176
- }
177
-
178
- function volumeFromHandle(handle: AnyRecord): VolumeRecord | null {
179
- const identity = volumeIdentityFromHandle(handle);
180
- const hostPath = volumeHostPath(handle);
181
- if (!identity || !hostPath) return null;
182
- return normalizeVolumeRecord({
183
- name: identity.name,
184
- hostPath,
185
- labels: identity.labels,
186
- kind: handle.kind,
187
- usedBytes: handle.usedBytes,
188
- createdAt: handle.createdAt instanceof Date ? handle.createdAt.getTime() : handle.createdAt,
189
- });
190
- }
191
-
192
- function reusableVolumeIdentity(plan: Extract<StoragePlan, { kind: "git-volume" }>, identity: { name: string; labels: Record<string, string> }): boolean {
193
- const labels = identity.labels;
194
- return identity.name === plan.volumeName &&
195
- labels[LABEL_KEYS.managed] === "true" &&
196
- labels[LABEL_KEYS.schema] === "1" &&
197
- labels[LABEL_KEYS.session] === plan.sessionId &&
198
- labels[LABEL_KEYS.cwd] === plan.workdir &&
199
- labels[LABEL_KEYS.mode] === "git" &&
200
- labels[LABEL_KEYS.keep] === "true";
201
- }
202
-
203
- function labelsForVolume(input: { sessionId: string; cwd: string }): Record<string, string> {
204
- return { ...buildVolumeLabels(input), [LABEL_KEYS.mode]: "git" };
205
- }
206
-
207
- interface ManagedVolumeTarget {
208
- sessionId: string;
209
- cwd: string;
210
- name: string;
211
- }
212
-
213
- function managedVolumeTarget(requestedName: string, identity: { name: string; labels: Record<string, string> }): ManagedVolumeTarget | null {
214
- const labels = identity.labels;
215
- const sessionId = labels[LABEL_KEYS.session];
216
- const cwd = labels[LABEL_KEYS.cwd];
217
- if (!sessionId || !cwd || !isAbsolute(cwd)) return null;
218
- const expectedLabels = labelsForVolume({ sessionId, cwd });
219
- if (identity.name !== requestedName || requestedName !== volumeNameFor(sessionId)) return null;
220
- if (Object.entries(expectedLabels).some(([key, value]) => labels[key] !== value)) return null;
221
- return { sessionId, cwd, name: requestedName };
222
- }
223
-
224
- function namedMountVolume(mount: unknown): string | undefined {
225
- const value = objectConfig(mount);
226
- const kind = value.kind ?? value.type;
227
- if (typeof kind === "string" && kind.toLowerCase() === "named" && typeof value.name === "string") return value.name;
228
- const nested = objectConfig(value.named ?? value.Named);
229
- return typeof nested.name === "string" ? nested.name : undefined;
230
- }
231
-
232
- async function volumeIsMounted(msb: MicrosandboxModule, name: string): Promise<boolean> {
233
- let cursor: string | undefined;
234
- const seenCursors = new Set<string>();
235
- while (true) {
236
- const page = await msb.Sandbox.listWith((list: AnyRecord) => {
237
- if (cursor) list.cursor(cursor);
238
- return list;
239
- });
240
- if (!page || !Array.isArray(page.sandboxes)) throw new Error("unable to verify volume mount state: malformed sandbox list");
241
- for (const listed of page.sandboxes) {
242
- if (typeof listed?.name !== "string" || !listed.name) throw new Error("unable to verify volume mount state: malformed sandbox handle");
243
- let handle: AnyRecord;
244
- try {
245
- handle = await msb.Sandbox.get(listed.name);
246
- } catch (error) {
247
- if (sdkNotFound(error)) continue;
248
- throw error;
249
- }
250
- const status = sdkStatus(handle)?.trim().toLowerCase();
251
- if (status === "stopped" || status === "crashed") continue;
252
- const config = await handleConfig(handle);
253
- if (!Array.isArray(config.mounts)) throw new Error(`unable to verify volume mount state for sandbox ${listed.name}`);
254
- if (config.mounts.some((mount: unknown) => namedMountVolume(mount) === name)) return true;
255
- }
256
- if (page.nextCursor === undefined) return false;
257
- if (typeof page.nextCursor !== "string" || !page.nextCursor || seenCursors.has(page.nextCursor)) {
258
- throw new Error("unable to verify volume mount state: invalid sandbox page cursor");
259
- }
260
- seenCursors.add(page.nextCursor);
261
- cursor = page.nextCursor;
262
- }
263
- }
264
-
265
152
  function parsePort(value: string): { bind: string; host: number; guest: number } {
266
153
  const parts = value.split(":");
267
154
  const numbers = parts.slice(-2).map((item) => Number(item));
268
155
  if (parts.length === 1) return { bind: "127.0.0.1", host: numbers[0], guest: numbers[0] };
269
156
  if (parts.length === 2) return { bind: "127.0.0.1", host: numbers[0], guest: numbers[1] };
270
- return { bind: parts[0], host: numbers[1], guest: numbers[2] };
157
+ return { bind: parts[0], host: numbers[0], guest: numbers[1] };
271
158
  }
272
159
 
273
160
  function applyMount(builder: AnyRecord, mount: Config["mounts"][number]): void {
@@ -306,7 +193,7 @@ function applyNetwork(builder: AnyRecord, config: Config, sdk: MicrosandboxModul
306
193
  return destination.domain(host);
307
194
  }));
308
195
  }
309
- if (network.allowDns) policy.egress((rule: AnyRecord) => rule.allowDns());
196
+ if (network.allowDns) policy.egress((rule: AnyRecord) => rule.udp().tcp().port(53).allowHost());
310
197
  builder.network((n: AnyRecord) => n.policy(policy));
311
198
  }
312
199
  }
@@ -318,14 +205,12 @@ function applyNetwork(builder: AnyRecord, config: Config, sdk: MicrosandboxModul
318
205
  }
319
206
 
320
207
  function lockInfoFor(request: BootRequest): LockInfo {
321
- const mode = request.config.mode === "direct" || request.config.mode === "none" ? request.config.mode : "git";
322
208
  return {
323
- version: 1,
209
+ version: LOCKFILE_VERSION,
324
210
  sessionId: request.sessionId,
325
211
  sandboxName: request.config.sandboxName ?? sandboxNameFor(request.sessionId),
326
- volumeName: mode === "git" ? volumeNameFor(request.sessionId) : undefined,
327
- mode,
328
212
  cwd: request.cwd,
213
+ root: request.workspace.hostRoot,
329
214
  pid: process.pid,
330
215
  createdAt: Date.now(),
331
216
  };
@@ -347,11 +232,10 @@ function sanitizeOverride(key: string, value: unknown): unknown {
347
232
  }
348
233
 
349
234
  export function createMsbIntegration(options: MsbControlOptions): MsbIntegration {
350
- const configRef = { value: { ...DEFAULT_CONFIG, network: { ...DEFAULT_CONFIG.network } } as Config };
235
+ const configRef = { value: { ...DEFAULT_CONFIG, network: { ...DEFAULT_CONFIG.network }, docker: { ...DEFAULT_CONFIG.docker } } as Config };
351
236
  let sessionId = options.sessionId;
352
237
  let cwd = options.cwd;
353
- let repoRoot: string | null = null;
354
- let currentGit: GitRepoInfo = { isGitRepo: false, repoRoot: null, branch: null, headSha: null, unborn: false, isLinkedWorktree: false };
238
+ let workspace: Workspace | null = null;
355
239
  let resolved: ResolvedConfig = { config: configRef.value, provenance: {}, warnings: [] };
356
240
  let overrides: DeepPartial<Config> = {};
357
241
  let projectTrusted = true;
@@ -406,55 +290,6 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
406
290
  ? options.acquireOwnerLock(request)
407
291
  : acquireOwnerLock({ lockDir: expandHome(request.config.lockDir) }, lockInfoFor(request)),
408
292
  pruneOthers: async (currentSessionId) => pruneStale({ port: prunePort, locks: lockPort(), currentSessionId, stopTimeoutMs: configRef.value.stopTimeoutMs }),
409
- detectGit: async (path) => {
410
- currentGit = await detectGitRepo(path);
411
- currentGit = currentGit;
412
- return currentGit;
413
- },
414
- buildStoragePlan: (input) => {
415
- const plan = buildStoragePlan(input);
416
- activeProjectRoot = plan.kind === "git-volume" ? plan.mountGuestPath : input.cwd;
417
- return plan;
418
- },
419
- prepareStorage: async (plan, restored) => {
420
- if (plan.kind !== "git-volume") return { plan, createdVolume: false };
421
- const msb = await sdk();
422
- let volume: VolumeRecord | undefined;
423
- let createdVolume = false;
424
- try {
425
- const handle = await msb.Volume.get(plan.volumeName);
426
- const identity = volumeIdentityFromHandle(handle);
427
- if (!identity || !reusableVolumeIdentity(plan, identity)) throw new Error(`managed volume identity mismatch: ${plan.volumeName}`);
428
- const record = volumeFromHandle(handle);
429
- if (record && !validateReusableVolume(plan, record)) throw new Error(`managed volume identity mismatch: ${plan.volumeName}`);
430
- // VolumeHandle has no supported host path. Reuse is safe
431
- // because mounting uses the name; status/export report no fabricated path.
432
- volume = record ?? undefined;
433
- } catch (error) {
434
- if (!sdkNotFound(error)) throw error;
435
- const labels = labelsForVolume({ sessionId: plan.sessionId, cwd: plan.workdir });
436
- let builder = msb.Volume.builder(plan.volumeName).directory().quota(plan.volumeQuotaMiB);
437
- for (const [key, value] of Object.entries(labels)) builder = builder.label(key, value);
438
- const created = await builder.create();
439
- const createdVolumeRecord = volumeFromHandle({
440
- name: created.name,
441
- path: volumeHostPath(created),
442
- labels,
443
- kind: created.kind,
444
- usedBytes: created.usedBytes,
445
- createdAt: created.createdAt,
446
- });
447
- if (!createdVolumeRecord) throw new Error("microsandbox returned invalid volume metadata");
448
- volume = createdVolumeRecord;
449
- createdVolume = true;
450
- }
451
- let bundle = null;
452
- if (createdVolume && plan.seedRequired && !plan.unborn) {
453
- bundle = await createSeedBundle(currentGit, { branch: configRef.value.cloneBranch, depth: configRef.value.cloneDepth });
454
- }
455
- // The manager owns cleanup after seed/boot failure; storage owns only the bundle creation.
456
- return { plan, volume, bundle, createdVolume };
457
- },
458
293
  inspectSandbox: async (name) => {
459
294
  const msb = await sdk();
460
295
  try {
@@ -467,16 +302,12 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
467
302
  },
468
303
  connectSandbox: async (value) => objectConfig(value)._handle.connect(),
469
304
  startSandbox: async (value) => objectConfig(value)._handle.startDetached(),
470
- createSandbox: async (request, prepared) => {
305
+ createSandbox: async (request) => {
471
306
  const msb = await sdk();
472
307
  const name = request.config.sandboxName ?? sandboxNameFor(request.sessionId);
473
- const plan = prepared.plan;
474
- const mode = plan.kind === "git-volume" ? "git" : plan.kind === "direct-mount" ? "direct" : "none";
475
- 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 });
476
- 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);
477
- if (plan.kind === "git-volume") builder.volume(plan.mountGuestPath, (m: AnyRecord) => m.named(plan.volumeName));
478
- else if (plan.kind === "direct-mount") builder.volume(plan.guestPath, (m: AnyRecord) => m.bind(plan.hostPath));
479
- 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));
480
311
  for (const mount of request.config.mounts) applyMount(builder, mount);
481
312
  applyNetwork(builder, request.config, msb);
482
313
  const envValues: Record<string, string> = {};
@@ -531,23 +362,101 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
531
362
  bash: createBashOps({ withRuntime: async (callback) => callback({ transport, operations: undefined as never }) }),
532
363
  } as ToolOperations;
533
364
  },
534
- probeAndBootstrap: async (runtime, config) => {
535
- const missing = async () => {
536
- const result = await Promise.all(REQUIRED_GUEST_COMMANDS.map(async (command) => ({ command, result: await runtime.transport.exec("sh", ["-lc", `command -v ${command}`]) })));
365
+ prepareRuntime: async (runtime, config): Promise<RuntimePreparation> => {
366
+ const commandsMissing = async (commandNames: readonly string[]) => {
367
+ const result = await Promise.all(commandNames.map(async (command) => ({
368
+ command,
369
+ result: await runtime.transport.exec("sh", ["-c", 'command -v "$1" >/dev/null 2>&1', "pi-msb-probe", command]),
370
+ })));
537
371
  return result.filter((item) => item.result.exitCode !== 0).map((item) => item.command);
538
372
  };
539
- let commands = await missing();
373
+ let commands = await commandsMissing(REQUIRED_GUEST_COMMANDS);
540
374
  if (commands.length && config.bootstrapTools !== false) {
541
- const apt = await runtime.transport.exec("sh", ["-lc", "command -v apt-get"]);
375
+ const apt = await runtime.transport.exec("sh", ["-c", 'command -v "$1" >/dev/null 2>&1', "pi-msb-probe", "apt-get"]);
542
376
  if (apt.exitCode === 0) {
543
377
  await runtime.transport.exec("apt-get", ["update", "-y"]);
544
- await runtime.transport.exec("apt-get", ["install", "-y", "--no-install-recommends", "bash", "git", "ripgrep", "file", "coreutils", "ca-certificates"]);
545
- commands = await missing();
378
+ await runtime.transport.exec("apt-get", ["install", "-y", "--no-install-recommends", "bash", "ripgrep", "file", "coreutils", "ca-certificates"]);
379
+ commands = await commandsMissing(REQUIRED_GUEST_COMMANDS);
546
380
  }
547
381
  }
548
382
  if (commands.length) throw new Error(`sandbox is missing required commands: ${commands.join(", ")}; install them or use bootstrapTools=true`);
383
+
384
+ const mode = config.docker.mode;
385
+ if (mode === "disabled") return { docker: { mode, readiness: "disabled" } };
386
+
387
+ const dockerComponents = [
388
+ { name: "docker", path: "/usr/local/bin/docker" },
389
+ { name: "dockerd", path: "/usr/local/bin/dockerd" },
390
+ { name: "pi-msb-docker-start", path: "/usr/local/sbin/pi-msb-docker-start" },
391
+ ] as const;
392
+ const dockerChecks = await Promise.all(dockerComponents.map(async (component) => ({
393
+ ...component,
394
+ result: await runtime.transport.exec("/usr/bin/test", ["-x", component.path]),
395
+ })));
396
+ const missingDocker = dockerChecks.filter((item) => item.result.exitCode !== 0).map((item) => item.name);
397
+ if (missingDocker.length) {
398
+ const reason = `image is missing Docker components: ${missingDocker.join(", ")}`;
399
+ if (mode === "require") throw new Error(reason);
400
+ return { docker: { mode, readiness: "missing", reason } };
401
+ }
402
+
403
+ let started = false;
404
+ try {
405
+ const result = await runtime.transport.exec(
406
+ "/usr/bin/env",
407
+ [
408
+ "-i",
409
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
410
+ "HOME=/root",
411
+ "/usr/local/sbin/pi-msb-docker-start",
412
+ String(config.docker.startupTimeoutMs),
413
+ ],
414
+ { timeoutMs: config.docker.startupTimeoutMs + 2_000 },
415
+ );
416
+ started = result.exitCode === 0;
417
+ } catch {
418
+ // Keep host-visible status bounded so transport errors cannot expose
419
+ // environment or secret values.
420
+ }
421
+ if (!started) {
422
+ const reason = "Docker daemon did not become ready; inspect /var/log/pi-msb-dockerd.log inside the sandbox";
423
+ if (mode === "require") throw new Error(reason);
424
+ return { docker: { mode, readiness: "unavailable", reason } };
425
+ }
426
+
427
+ const inspectLocalDocker = (args: string[]) => runtime.transport.exec(
428
+ "/usr/bin/env",
429
+ [
430
+ "-i",
431
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
432
+ "HOME=/root",
433
+ "/usr/local/bin/docker",
434
+ "--host=unix:///var/run/docker.sock",
435
+ ...args,
436
+ ],
437
+ { timeoutMs: 5_000 },
438
+ );
439
+ let version: string | undefined;
440
+ let storageDriver: string | undefined;
441
+ try {
442
+ const [versionResult, driverResult] = await Promise.all([
443
+ inspectLocalDocker(["version", "--format", "{{.Server.Version}}"]),
444
+ inspectLocalDocker(["info", "--format", "{{.Driver}}"]),
445
+ ]);
446
+ if (versionResult.exitCode === 0 && driverResult.exitCode === 0) {
447
+ version = versionResult.stdout.toString("utf8").trim().split(/\r?\n/, 1)[0]?.slice(0, 128);
448
+ storageDriver = driverResult.stdout.toString("utf8").trim().split(/\r?\n/, 1)[0]?.slice(0, 128);
449
+ }
450
+ } catch {
451
+ // Report a bounded capability error below.
452
+ }
453
+ if (!version || !storageDriver) {
454
+ const reason = "Docker daemon became reachable but capability inspection failed";
455
+ if (mode === "require") throw new Error(reason);
456
+ return { docker: { mode, readiness: "unavailable", reason } };
457
+ }
458
+ return { docker: { mode, readiness: "ready", version, storageDriver } };
549
459
  },
550
- seed: async (runtime, prepared) => seedGitVolume(runtime.transport, prepared.plan as any, prepared.bundle ?? null),
551
460
  persist: (state) => options.appendEntry?.(STATE_ENTRY, encodeSessionState(state)),
552
461
  };
553
462
 
@@ -564,6 +473,7 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
564
473
  withRuntime: (callback) => manager.withRuntime(callback),
565
474
  };
566
475
  const effective = (): ResolvedConfig => resolved;
476
+ const configSnapshot = (): Config => structuredClone(configRef.value);
567
477
  const overrideEntries = (entries: readonly unknown[]): DeepPartial<Config> => {
568
478
  let result: DeepPartial<Config> = {};
569
479
  for (const entry of entries) {
@@ -576,16 +486,15 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
576
486
  }
577
487
  return result;
578
488
  };
579
- const resolveForSession = async (setup: SessionSetup): Promise<ResolvedConfig> => {
489
+ const resolveForSession = async (setup: SessionSetup, selected: Workspace): Promise<ResolvedConfig> => {
580
490
  if (setup.config) return setup.config;
581
- 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) });
582
492
  };
583
493
 
584
494
  const configureSession = async (setup: SessionSetup): Promise<RuntimeState> => {
585
495
  sessionId = setup.sessionId;
586
496
  cwd = setup.cwd;
587
497
  configReady = false;
588
- repoRoot = setup.repoRoot ?? null;
589
498
  projectTrusted = setup.projectTrusted;
590
499
  explicitOff = isDisabledByEnv(options.env ?? process.env);
591
500
  if (explicitOff) {
@@ -593,14 +502,13 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
593
502
  notifyState(failureState);
594
503
  return visibleState();
595
504
  }
596
- currentGit = await detectGitRepo(cwd);
597
- repoRoot = currentGit.guestRepoRoot === null
598
- ? cwd
599
- : currentGit.guestRepoRoot ?? currentGit.repoRoot;
600
- overrides = overrideEntries(options.entries?.() ?? []);
601
505
  let next: ResolvedConfig;
602
506
  try {
603
- 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);
604
512
  } catch (error) {
605
513
  failureState = { status: "unavailable", info: null, reason: redactedError(error, configRef.value).message };
606
514
  notifyState(failureState);
@@ -608,7 +516,7 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
608
516
  }
609
517
  resolved = next;
610
518
  configReady = true;
611
- Object.assign(configRef.value, next.config, { network: { ...next.config.network }, secrets: [...next.config.secrets], mounts: [...next.config.mounts] });
519
+ Object.assign(configRef.value, next.config, { network: { ...next.config.network }, docker: { ...next.config.docker }, secrets: [...next.config.secrets], mounts: [...next.config.mounts] });
612
520
  const state = setup.restored ?? persistenceState(options.entries?.() ?? [], sessionId);
613
521
  explicitOff = isDisabledByEnv(options.env ?? process.env);
614
522
  failureState = explicitOff ? { status: "off", info: null } : null;
@@ -616,7 +524,7 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
616
524
  if (!explicitOff) { await manager.setEnabled(false); failureState = { status: "off", info: null }; }
617
525
  return visibleState();
618
526
  }
619
- const result = await manager.boot({ sessionId, cwd, config: configRef.value, restored: state });
527
+ const result = await manager.boot({ sessionId, cwd, workspace, config: configSnapshot(), restored: state });
620
528
  if (result.status === "unavailable") failureState = result;
621
529
  notifyState(visibleState());
622
530
  return visibleState();
@@ -644,8 +552,19 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
644
552
  await manager.setEnabled(false);
645
553
  failureState = { status: "off", info: null };
646
554
  } else {
647
- const result = await manager.boot({ sessionId, cwd, config: configRef.value, restored: persistenceState(options.entries?.() ?? [], sessionId) });
648
- 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
+ }
649
568
  }
650
569
  notifyState(visibleState());
651
570
  },
@@ -658,7 +577,10 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
658
577
  }
659
578
  let next: ResolvedConfig;
660
579
  try {
661
- 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) });
662
584
  } catch (error) {
663
585
  configReady = false;
664
586
  if (!manager.isActive()) failureState = { status: "unavailable", info: null, reason: redactedError(error, configRef.value).message };
@@ -667,10 +589,10 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
667
589
  }
668
590
  resolved = next;
669
591
  configReady = true;
670
- Object.assign(configRef.value, next.config, { network: { ...next.config.network }, secrets: [...next.config.secrets], mounts: [...next.config.mounts] });
592
+ Object.assign(configRef.value, next.config, { network: { ...next.config.network }, docker: { ...next.config.docker }, secrets: [...next.config.secrets], mounts: [...next.config.mounts] });
671
593
  failureState = null;
672
- if (!explicitOff && configRef.value.autoStart) {
673
- const result = await manager.boot({ sessionId, cwd, config: configRef.value, 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) });
674
596
  if (result.status === "unavailable") failureState = result;
675
597
  }
676
598
  notifyState(visibleState());
@@ -680,94 +602,6 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
680
602
  notifyState(visibleState());
681
603
  return report;
682
604
  },
683
- async listVolumes() {
684
- const msb = await sdk();
685
- const handles = await msb.Volume.list();
686
- const volumes: VolumeRecord[] = [];
687
- for (const handle of handles) {
688
- const identity = volumeIdentityFromHandle(handle);
689
- if (!identity || identity.labels[LABEL_KEYS.managed] !== "true") continue;
690
- const record = volumeFromHandle(handle);
691
- if (!record) throw new Error(`host path metadata is unavailable for managed volume ${identity.name}; refusing to fabricate a path`);
692
- volumes.push(record);
693
- }
694
- return volumes;
695
- },
696
- async describeVolume(name) {
697
- const msb = await sdk();
698
- const handle = await msb.Volume.get(name);
699
- const volume = volumeFromHandle(handle);
700
- if (!volume) throw new Error("microsandbox returned invalid volume metadata");
701
- if (volume.labels[LABEL_KEYS.managed] !== "true") throw new Error("refusing to inspect an unmanaged volume");
702
- let branch: string | undefined;
703
- let lastCommit: string | undefined;
704
- let dirtyCount: number | undefined;
705
- try {
706
- const head = await execFile("git", ["-C", volume.hostPath, "symbolic-ref", "--quiet", "--short", "HEAD"]);
707
- branch = head.stdout.trim() || undefined;
708
- } catch { /* detached/uninitialized volumes are valid */ }
709
- try {
710
- const head = await execFile("git", ["-C", volume.hostPath, "rev-parse", "HEAD"]);
711
- lastCommit = head.stdout.trim() || undefined;
712
- } catch { /* no commit */ }
713
- try {
714
- const status = await execFile("git", ["-C", volume.hostPath, "status", "--porcelain"]);
715
- dirtyCount = status.stdout.trim() ? status.stdout.trim().split("\n").length : 0;
716
- } catch { /* non-git volume */ }
717
- return { volume, branch, lastCommit, dirtyCount, mounted: visibleState().info?.volumeName === name };
718
- },
719
- async removeVolume(name) {
720
- const msb = await sdk();
721
- const initialIdentity = volumeIdentityFromHandle(await msb.Volume.get(name));
722
- const target = initialIdentity ? managedVolumeTarget(name, initialIdentity) : null;
723
- if (!target) throw new Error(`managed volume identity mismatch: ${name}`);
724
- const targetRequest: BootRequest = {
725
- sessionId: target.sessionId,
726
- cwd: target.cwd,
727
- config: { ...configRef.value, mode: "git", sandboxName: sandboxNameFor(target.sessionId) },
728
- restored: null,
729
- };
730
- const owner = options.acquireOwnerLock
731
- ? await options.acquireOwnerLock(targetRequest)
732
- : await acquireOwnerLock({ lockDir: expandHome(configRef.value.lockDir) }, lockInfoFor(targetRequest));
733
- if (!owner) throw new Error("another process owns the target session; volume removal is blocked");
734
- try {
735
- const lockedIdentity = volumeIdentityFromHandle(await msb.Volume.get(name));
736
- const lockedTarget = lockedIdentity ? managedVolumeTarget(name, lockedIdentity) : null;
737
- if (!lockedTarget || lockedTarget.sessionId !== target.sessionId || lockedTarget.cwd !== target.cwd || lockedTarget.name !== target.name) {
738
- throw new Error(`managed volume identity mismatch: ${name}`);
739
- }
740
- if (visibleState().info?.volumeName === name || await volumeIsMounted(msb, name)) {
741
- throw new Error("refusing to remove a mounted volume");
742
- }
743
- // Volume.remove is the final atomic mount check: microsandbox rejects
744
- // deletion if a sandbox mounts the volume after the list recheck.
745
- await msb.Volume.remove(name);
746
- } finally {
747
- await owner.release();
748
- }
749
- },
750
- async exportPaths(paths, destination) {
751
- if (!manager.isActive()) throw new Error("sandbox is not active");
752
- const target = destination ? resolve(destination) : await fs.mkdtemp(join((process.env.TMPDIR ?? "/tmp"), "pi-msb-export-"));
753
- await fs.mkdir(target, { recursive: true, mode: 0o700 });
754
- const results: Array<{ source: string; destination: string }> = [];
755
- await manager.withRuntime(async (runtime) => {
756
- for (const requested of paths) {
757
- const source = requested.startsWith("/") ? resolve(requested) : resolve(cwd, requested);
758
- const sourceRoot = resolve(activeProjectRoot);
759
- const sourceRelative = relativePath(sourceRoot, source);
760
- if (sourceRelative === ".." || sourceRelative.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(sourceRelative)) {
761
- throw new Error(`refusing to export a path outside the sandbox project: ${requested}`);
762
- }
763
- const output = join(target, basename(source));
764
- if (resolve(output) !== output || await fs.lstat(output).then(() => true, () => false)) throw new Error(`export destination exists: ${output}`);
765
- await runtime.transport.copyToHost(source, output);
766
- results.push({ source, destination: output });
767
- }
768
- });
769
- return results;
770
- },
771
605
  async getLogs(tailLines) {
772
606
  const name = visibleState().info?.name;
773
607
  if (!name) return "";
@@ -795,8 +629,6 @@ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration
795
629
  },
796
630
  };
797
631
 
798
- // Keep the returned config facade useful to callers without exposing mutable internals.
799
- void repoRoot;
800
632
  return { control, manager, provider, configRef, configureSession };
801
633
  }
802
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,