pi-microsandbox 0.1.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.
@@ -0,0 +1,803 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { execFile as execFileCallback } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ import { homedir } from "node:os";
5
+ import { isAbsolute, join, resolve, basename, relative as relativePath } from "node:path";
6
+
7
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ applyOverride,
10
+ DEFAULT_CONFIG,
11
+ isDisabledByEnv,
12
+ overridesToToml,
13
+ resolveConfig,
14
+ resolveSecretValue,
15
+ removeOverride,
16
+ toEffectiveToml,
17
+ } from "./config.ts";
18
+ import { createSeedBundle, detectGitRepo } from "./git.ts";
19
+ import { buildSandboxLabels, buildVolumeLabels, decodeSessionState, normalizeSandboxRecord, normalizeVolumeRecord, encodeSessionState } from "./labels.ts";
20
+ import { acquireOwnerLock, createLocksPort } from "./locks.ts";
21
+ import { createBashOps, createFindOps, createGrepOps } from "./operations-exec.ts";
22
+ import { createEditOps, createLsOps, createReadOps, createWriteOps } from "./operations.ts";
23
+ import { pruneStale, type PrunePort } from "./prune.ts";
24
+ import { buildStoragePlan, seedGitVolume, validateReusableVolume } from "./storage.ts";
25
+ import { createSdkTransport } from "./transport.ts";
26
+ import { createSandboxManager, type InspectedSandbox, type SandboxManagerDeps } from "./sandbox-manager.ts";
27
+ import {
28
+ LABEL_KEYS,
29
+ sandboxNameFor,
30
+ volumeNameFor,
31
+ type BootRequest,
32
+ type Config,
33
+ type GitRepoInfo,
34
+ type LockInfo,
35
+ type LocksPort,
36
+ type MsbControl,
37
+ type PersistedSandboxState,
38
+ type ResolvedConfig,
39
+ type RuntimeExecution,
40
+ type RuntimeState,
41
+ type StoragePlan,
42
+ type ToolOperations,
43
+ type ToolOpsProvider,
44
+ type VolumeRecord,
45
+ type DeepPartial,
46
+ } from "./types.ts";
47
+
48
+ const execFile = promisify(execFileCallback);
49
+ const STATE_ENTRY = "pi-msb.state";
50
+ const OVERRIDE_ENTRY = "pi-msb.override";
51
+ const REQUIRED_GUEST_COMMANDS = ["bash", "git", "rg", "file", "cat", "mkdir", "rm"] as const;
52
+
53
+ type AnyRecord = Record<string, any>;
54
+
55
+ /** The deliberately small SDK surface used by the adapter. Tests can inject this. */
56
+ export interface MicrosandboxModule {
57
+ Sandbox: AnyRecord;
58
+ Volume: AnyRecord;
59
+ NetworkPolicy?: AnyRecord;
60
+ Rule?: AnyRecord;
61
+ }
62
+
63
+ export interface SessionSetup {
64
+ sessionId: string;
65
+ cwd: string;
66
+ repoRoot?: string | null;
67
+ projectTrusted: boolean;
68
+ config?: ResolvedConfig;
69
+ restored?: PersistedSandboxState | null;
70
+ }
71
+
72
+ export interface MsbControlOptions {
73
+ sessionId: string;
74
+ cwd: string;
75
+ configDirName: string;
76
+ env?: NodeJS.ProcessEnv;
77
+ sdkLoader?: () => Promise<MicrosandboxModule>;
78
+ locksPort?: LocksPort;
79
+ acquireOwnerLock?: SandboxManagerDeps["acquireOwnerLock"];
80
+ appendEntry?: (customType: string, data?: unknown) => void;
81
+ entries?: () => readonly unknown[];
82
+ notify?: (message: string, type?: "info" | "warning" | "error") => void;
83
+ onState?: (state: RuntimeState) => void;
84
+ }
85
+
86
+ export interface MsbIntegration {
87
+ control: MsbControl;
88
+ manager: ReturnType<typeof createSandboxManager>;
89
+ provider: ToolOpsProvider;
90
+ configRef: { value: Config };
91
+ configureSession(setup: SessionSetup): Promise<RuntimeState>;
92
+ }
93
+
94
+ function errorText(error: unknown): string {
95
+ return error instanceof Error && error.message ? error.message : String(error);
96
+ }
97
+
98
+ function redactedError(error: unknown, config: Config): Error {
99
+ let message = errorText(error);
100
+ for (const secret of config.secrets) if (secret.value) message = message.split(secret.value).join("[REDACTED]");
101
+ return new Error(message || "microsandbox operation failed");
102
+ }
103
+
104
+ function expandHome(path: string): string {
105
+ return path === "~" ? homedir() : path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
106
+ }
107
+
108
+ function sdkNotFound(error: unknown): boolean {
109
+ const value = error as AnyRecord;
110
+ const name = typeof value?.constructor?.name === "string" ? value.constructor.name : "";
111
+ return name.includes("NotFound") || ["NOT_FOUND", "ENOENT", "volumeNotFound", "sandboxNotFound"].includes(value?.code);
112
+ }
113
+
114
+ function objectConfig(value: unknown): AnyRecord {
115
+ if (value && typeof value === "object") return value as AnyRecord;
116
+ return {};
117
+ }
118
+
119
+ function sdkLabels(value: unknown): Record<string, string> {
120
+ const raw = objectConfig(value);
121
+ const source = raw.labels ?? raw.config?.labels;
122
+ const out: Record<string, string> = {};
123
+ if (Array.isArray(source)) {
124
+ for (const pair of source) {
125
+ if (Array.isArray(pair) && typeof pair[0] === "string" && typeof pair[1] === "string") out[pair[0]] = pair[1];
126
+ }
127
+ return out;
128
+ }
129
+ const labels = objectConfig(source);
130
+ for (const [key, item] of Object.entries(labels)) if (typeof item === "string") out[key] = item;
131
+ return out;
132
+ }
133
+
134
+ function sdkStatus(value: unknown): string | undefined {
135
+ const status = objectConfig(value).status ?? objectConfig(value).state;
136
+ return typeof status === "string" ? status : undefined;
137
+ }
138
+
139
+ async function handleConfig(handle: AnyRecord): Promise<AnyRecord> {
140
+ try {
141
+ if (typeof handle.config === "function") return objectConfig(await handle.config());
142
+ } catch { /* configJson is a compatible fallback */ }
143
+ try {
144
+ if (typeof handle.configJson === "string") return objectConfig(JSON.parse(handle.configJson));
145
+ if (typeof handle.configJson === "function") return objectConfig(JSON.parse(await handle.configJson()));
146
+ } catch { /* malformed SDK metadata is treated as untrusted */ }
147
+ return {};
148
+ }
149
+
150
+ function inspectedFromHandle(handle: AnyRecord, config: AnyRecord = {}): InspectedSandbox {
151
+ return {
152
+ name: typeof handle.name === "string" ? handle.name : String(config.name ?? ""),
153
+ status: sdkStatus(handle),
154
+ labels: sdkLabels(config),
155
+ createdAt: handle.createdAt instanceof Date ? handle.createdAt.getTime() : typeof handle.createdAt === "number" ? handle.createdAt : undefined,
156
+ _handle: handle,
157
+ _config: config,
158
+ };
159
+ }
160
+
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
+ function parsePort(value: string): { bind: string; host: number; guest: number } {
266
+ const parts = value.split(":");
267
+ const numbers = parts.slice(-2).map((item) => Number(item));
268
+ if (parts.length === 1) return { bind: "127.0.0.1", host: numbers[0], guest: numbers[0] };
269
+ 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] };
271
+ }
272
+
273
+ function applyMount(builder: AnyRecord, mount: Config["mounts"][number]): void {
274
+ const guest = mount.guestPath!;
275
+ builder.volume(guest, (m: AnyRecord) => {
276
+ if (mount.type === "dir" || mount.type === "file") m.bind(mount.hostPath!);
277
+ else if (mount.type === "named") m.named(mount.hostPath!);
278
+ else m.tmpfs();
279
+ if (mount.readonly) m.readonly();
280
+ for (const option of mount.options) {
281
+ if (option === "noexec") m.noexec();
282
+ else if (option === "nosuid") m.nosuid();
283
+ else if (option === "nodev") m.nodev();
284
+ else throw new Error(`unsupported microsandbox mount option: ${option}`);
285
+ }
286
+ return m;
287
+ });
288
+ }
289
+
290
+ function applyNetwork(builder: AnyRecord, config: Config, sdk: MicrosandboxModule): void {
291
+ const network = config.network;
292
+ if (network.mode !== "default") {
293
+ if (network.mode === "deny") {
294
+ builder.disableNetwork();
295
+ } else if (network.mode === "open") {
296
+ const policyApi = sdk.NetworkPolicy;
297
+ if (!policyApi?.allowAll) throw new Error("microsandbox does not provide NetworkPolicy.allowAll");
298
+ builder.network((n: AnyRecord) => n.policy(policyApi.allowAll()));
299
+ } else {
300
+ const policyApi = sdk.NetworkPolicy;
301
+ if (!policyApi?.builder) throw new Error("microsandbox does not provide a network policy builder");
302
+ const policy = policyApi.builder().defaultDeny().defaultIngress("deny");
303
+ for (const host of network.allowHosts) {
304
+ policy.egress((rule: AnyRecord) => rule.allow((destination: AnyRecord) => {
305
+ if (host.includes("/") || /^\d+(?:\.\d+){3}$/.test(host)) return destination.cidr(host);
306
+ return destination.domain(host);
307
+ }));
308
+ }
309
+ if (network.allowDns) policy.egress((rule: AnyRecord) => rule.allowDns());
310
+ builder.network((n: AnyRecord) => n.policy(policy));
311
+ }
312
+ }
313
+ for (const portValue of network.publishPorts) {
314
+ const port = parsePort(portValue);
315
+ if (port.bind === "127.0.0.1") builder.port(port.host, port.guest);
316
+ else builder.portBind(port.bind, port.host, port.guest);
317
+ }
318
+ }
319
+
320
+ function lockInfoFor(request: BootRequest): LockInfo {
321
+ const mode = request.config.mode === "direct" || request.config.mode === "none" ? request.config.mode : "git";
322
+ return {
323
+ version: 1,
324
+ sessionId: request.sessionId,
325
+ sandboxName: request.config.sandboxName ?? sandboxNameFor(request.sessionId),
326
+ volumeName: mode === "git" ? volumeNameFor(request.sessionId) : undefined,
327
+ mode,
328
+ cwd: request.cwd,
329
+ pid: process.pid,
330
+ createdAt: Date.now(),
331
+ };
332
+ }
333
+
334
+ function persistenceState(entries: readonly unknown[], sessionId: string): PersistedSandboxState | null {
335
+ for (const entry of [...entries].reverse()) {
336
+ const value = objectConfig(entry);
337
+ if (value.type !== "custom" || value.customType !== STATE_ENTRY) continue;
338
+ const decoded = decodeSessionState(value.data, sessionId);
339
+ if (decoded) return decoded;
340
+ }
341
+ return null;
342
+ }
343
+
344
+ function sanitizeOverride(key: string, value: unknown): unknown {
345
+ if (/secret|password|token|credential|\.value/i.test(key)) return "[REDACTED]";
346
+ return structuredClone(value);
347
+ }
348
+
349
+ export function createMsbIntegration(options: MsbControlOptions): MsbIntegration {
350
+ const configRef = { value: { ...DEFAULT_CONFIG, network: { ...DEFAULT_CONFIG.network } } as Config };
351
+ let sessionId = options.sessionId;
352
+ 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 };
355
+ let resolved: ResolvedConfig = { config: configRef.value, provenance: {}, warnings: [] };
356
+ let overrides: DeepPartial<Config> = {};
357
+ let projectTrusted = true;
358
+ let configReady = false;
359
+ let explicitOff = isDisabledByEnv(options.env ?? process.env);
360
+ let failureState: RuntimeState | null = explicitOff ? { status: "off", info: null } : { status: "unavailable", info: null, reason: "session has not started" };
361
+ let sdkPromise: Promise<MicrosandboxModule> | null = null;
362
+ let activeProjectRoot = cwd;
363
+
364
+ const loadSdk = async (): Promise<MicrosandboxModule> => {
365
+ if (!sdkPromise) sdkPromise = (options.sdkLoader ?? (async () => await import("microsandbox")))();
366
+ return sdkPromise;
367
+ };
368
+ const lockPort = () => options.locksPort ?? createLocksPort({ lockDir: expandHome(configRef.value.lockDir) });
369
+ const sdk = async () => loadSdk();
370
+
371
+ const listSandboxPage = async (input: { labels: Record<string, string>; cursor?: string }) => {
372
+ const msb = await sdk();
373
+ const page = await msb.Sandbox.listWith((list: AnyRecord) => {
374
+ if (input.cursor) list.cursor(input.cursor);
375
+ if (typeof list.labels === "function") list.labels(input.labels);
376
+ else for (const [key, value] of Object.entries(input.labels)) list.label(key, value);
377
+ return list;
378
+ });
379
+ return {
380
+ sandboxes: page.sandboxes.map((item: AnyRecord) => normalizeSandboxRecord({
381
+ name: item.name,
382
+ status: item.status,
383
+ labels: sdkLabels(item.config?.() ?? item),
384
+ createdAt: item.createdAt instanceof Date ? item.createdAt.getTime() : item.createdAt,
385
+ })).filter((item: any): item is any => item !== null),
386
+ nextCursor: page.nextCursor,
387
+ };
388
+ };
389
+
390
+ const prunePort: PrunePort = {
391
+ listPage: listSandboxPage,
392
+ stop: async (name, timeout) => {
393
+ const msb = await sdk();
394
+ const handle = await msb.Sandbox.get(name);
395
+ await handle.stopWithTimeout(timeout);
396
+ },
397
+ remove: async (name) => {
398
+ const msb = await sdk();
399
+ const handle = await msb.Sandbox.get(name);
400
+ await handle.remove();
401
+ },
402
+ };
403
+
404
+ const deps: SandboxManagerDeps = {
405
+ acquireOwnerLock: async (request) => options.acquireOwnerLock
406
+ ? options.acquireOwnerLock(request)
407
+ : acquireOwnerLock({ lockDir: expandHome(request.config.lockDir) }, lockInfoFor(request)),
408
+ 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
+ inspectSandbox: async (name) => {
459
+ const msb = await sdk();
460
+ try {
461
+ const handle = await msb.Sandbox.get(name);
462
+ return inspectedFromHandle(handle, await handleConfig(handle));
463
+ } catch (error) {
464
+ if (sdkNotFound(error)) return null;
465
+ throw error;
466
+ }
467
+ },
468
+ connectSandbox: async (value) => objectConfig(value)._handle.connect(),
469
+ startSandbox: async (value) => objectConfig(value)._handle.startDetached(),
470
+ createSandbox: async (request, prepared) => {
471
+ const msb = await sdk();
472
+ 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());
480
+ for (const mount of request.config.mounts) applyMount(builder, mount);
481
+ applyNetwork(builder, request.config, msb);
482
+ const envValues: Record<string, string> = {};
483
+ if (request.config.exposeSessionEnvironment) {
484
+ for (const name of request.config.hostEnv) if (typeof (options.env ?? process.env)[name] === "string") envValues[name] = (options.env ?? process.env)[name]!;
485
+ }
486
+ if (Object.keys(envValues).length) builder.envs(envValues);
487
+ // Secret values are resolved and passed only to the immediate SDK builder closure.
488
+ // SDK failures are sanitized before they cross the manager boundary.
489
+ const resolvedSecrets: string[] = [];
490
+ try {
491
+ for (const secret of request.config.secrets) {
492
+ const value = await resolveSecretValue(secret.value, options.env ?? process.env);
493
+ resolvedSecrets.push(value);
494
+ builder.secret((entry: AnyRecord) => {
495
+ entry.env(secret.env).value(value).requireTlsIdentity(true);
496
+ for (const host of secret.allowHosts) {
497
+ if (host.includes("*") || host.includes("?")) entry.allowHostPattern(host);
498
+ else entry.allowHost(host);
499
+ }
500
+ return entry;
501
+ });
502
+ }
503
+ return await builder.create();
504
+ } catch (error) {
505
+ let message = errorText(error);
506
+ for (const value of resolvedSecrets) if (value) message = message.split(value).join("[REDACTED]");
507
+ throw new Error(message || "sandbox creation failed");
508
+ }
509
+ },
510
+ stopAndRemove: async (name, timeout) => {
511
+ const msb = await sdk();
512
+ const handle = await msb.Sandbox.get(name);
513
+ await handle.stopWithTimeout(timeout);
514
+ if (typeof handle.waitUntilStopped === "function") {
515
+ const stopped = await handle.waitUntilStopped();
516
+ const status = String(stopped?.status ?? "").toLowerCase();
517
+ if (status && !["stopped", "exited", "dead", "killed", "created"].includes(status)) throw new Error(`sandbox ${name} did not stop`);
518
+ }
519
+ await handle.remove();
520
+ },
521
+ createTransport: (raw) => createSdkTransport(raw),
522
+ createOperations: (transport) => {
523
+ const fileOptions = { projectRoot: activeProjectRoot };
524
+ return {
525
+ read: createReadOps(transport, fileOptions),
526
+ write: createWriteOps(transport, fileOptions),
527
+ edit: createEditOps(transport, fileOptions),
528
+ ls: createLsOps(transport, fileOptions),
529
+ find: createFindOps(transport),
530
+ grep: createGrepOps(transport),
531
+ bash: createBashOps({ withRuntime: async (callback) => callback({ transport, operations: undefined as never }) }),
532
+ } as ToolOperations;
533
+ },
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}`]) })));
537
+ return result.filter((item) => item.result.exitCode !== 0).map((item) => item.command);
538
+ };
539
+ let commands = await missing();
540
+ if (commands.length && config.bootstrapTools !== false) {
541
+ const apt = await runtime.transport.exec("sh", ["-lc", "command -v apt-get"]);
542
+ if (apt.exitCode === 0) {
543
+ 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();
546
+ }
547
+ }
548
+ if (commands.length) throw new Error(`sandbox is missing required commands: ${commands.join(", ")}; install them or use bootstrapTools=true`);
549
+ },
550
+ seed: async (runtime, prepared) => seedGitVolume(runtime.transport, prepared.plan as any, prepared.bundle ?? null),
551
+ persist: (state) => options.appendEntry?.(STATE_ENTRY, encodeSessionState(state)),
552
+ };
553
+
554
+ const manager = createSandboxManager(deps);
555
+ const notifyState = (state: RuntimeState) => options.onState?.(state);
556
+ const visibleState = (): RuntimeState => {
557
+ const state = failureState ?? manager.getState();
558
+ if (explicitOff && (state.status === "disabled" || state.status === "off")) return { status: "off", info: null };
559
+ return state;
560
+ };
561
+ const provider: ToolOpsProvider = {
562
+ isActive: () => manager.isActive(),
563
+ getState: visibleState,
564
+ withRuntime: (callback) => manager.withRuntime(callback),
565
+ };
566
+ const effective = (): ResolvedConfig => resolved;
567
+ const overrideEntries = (entries: readonly unknown[]): DeepPartial<Config> => {
568
+ let result: DeepPartial<Config> = {};
569
+ for (const entry of entries) {
570
+ const value = objectConfig(entry);
571
+ if (value.type !== "custom" || value.customType !== OVERRIDE_ENTRY) continue;
572
+ if (value.data?.reset === true) { result = {}; continue; }
573
+ if (typeof value.data?.key !== "string") continue;
574
+ if (value.data?.unset === true) result = removeOverride(result, value.data.key);
575
+ else if (value.data?.value !== "[REDACTED]") result = applyOverride(result, value.data.key, value.data.value);
576
+ }
577
+ return result;
578
+ };
579
+ const resolveForSession = async (setup: SessionSetup): Promise<ResolvedConfig> => {
580
+ 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) });
582
+ };
583
+
584
+ const configureSession = async (setup: SessionSetup): Promise<RuntimeState> => {
585
+ sessionId = setup.sessionId;
586
+ cwd = setup.cwd;
587
+ configReady = false;
588
+ repoRoot = setup.repoRoot ?? null;
589
+ projectTrusted = setup.projectTrusted;
590
+ explicitOff = isDisabledByEnv(options.env ?? process.env);
591
+ if (explicitOff) {
592
+ failureState = { status: "off", info: null };
593
+ notifyState(failureState);
594
+ return visibleState();
595
+ }
596
+ currentGit = await detectGitRepo(cwd);
597
+ repoRoot = currentGit.guestRepoRoot === null
598
+ ? cwd
599
+ : currentGit.guestRepoRoot ?? currentGit.repoRoot;
600
+ overrides = overrideEntries(options.entries?.() ?? []);
601
+ let next: ResolvedConfig;
602
+ try {
603
+ next = await resolveForSession({ ...setup, repoRoot });
604
+ } catch (error) {
605
+ failureState = { status: "unavailable", info: null, reason: redactedError(error, configRef.value).message };
606
+ notifyState(failureState);
607
+ return visibleState();
608
+ }
609
+ resolved = next;
610
+ configReady = true;
611
+ Object.assign(configRef.value, next.config, { network: { ...next.config.network }, secrets: [...next.config.secrets], mounts: [...next.config.mounts] });
612
+ const state = setup.restored ?? persistenceState(options.entries?.() ?? [], sessionId);
613
+ explicitOff = isDisabledByEnv(options.env ?? process.env);
614
+ failureState = explicitOff ? { status: "off", info: null } : null;
615
+ if (explicitOff || !configRef.value.autoStart) {
616
+ if (!explicitOff) { await manager.setEnabled(false); failureState = { status: "off", info: null }; }
617
+ return visibleState();
618
+ }
619
+ const result = await manager.boot({ sessionId, cwd, config: configRef.value, restored: state });
620
+ if (result.status === "unavailable") failureState = result;
621
+ notifyState(visibleState());
622
+ return visibleState();
623
+ };
624
+
625
+ const control: MsbControl = {
626
+ getState: visibleState,
627
+ async setEnabled(enabled) {
628
+ if (enabled && isDisabledByEnv(options.env ?? process.env)) {
629
+ explicitOff = true;
630
+ failureState = { status: "off", info: null };
631
+ notifyState(failureState);
632
+ return;
633
+ }
634
+ if (enabled && !configReady) {
635
+ if (!manager.isActive()) {
636
+ failureState = failureState ?? { status: "unavailable", info: null, reason: "no valid configuration is available; reload after fixing configuration" };
637
+ notifyState(failureState);
638
+ }
639
+ return;
640
+ }
641
+ explicitOff = !enabled;
642
+ failureState = null;
643
+ if (!enabled) {
644
+ await manager.setEnabled(false);
645
+ failureState = { status: "off", info: null };
646
+ } else {
647
+ const result = await manager.boot({ sessionId, cwd, config: configRef.value, restored: persistenceState(options.entries?.() ?? [], sessionId) });
648
+ if (result.status === "unavailable") failureState = result;
649
+ }
650
+ notifyState(visibleState());
651
+ },
652
+ async reload() {
653
+ if (isDisabledByEnv(options.env ?? process.env)) {
654
+ explicitOff = true;
655
+ failureState = { status: "off", info: null };
656
+ notifyState(failureState);
657
+ return;
658
+ }
659
+ let next: ResolvedConfig;
660
+ try {
661
+ next = await resolveConfig({ cwd, repoRoot, projectTrusted, configDirName: options.configDirName, env: options.env, cliOverridesToml: overridesToToml(overrides) });
662
+ } catch (error) {
663
+ configReady = false;
664
+ if (!manager.isActive()) failureState = { status: "unavailable", info: null, reason: redactedError(error, configRef.value).message };
665
+ notifyState(visibleState());
666
+ throw error;
667
+ }
668
+ resolved = next;
669
+ configReady = true;
670
+ Object.assign(configRef.value, next.config, { network: { ...next.config.network }, secrets: [...next.config.secrets], mounts: [...next.config.mounts] });
671
+ failureState = null;
672
+ if (!explicitOff && configRef.value.autoStart) {
673
+ const result = await manager.boot({ sessionId, cwd, config: configRef.value, restored: persistenceState(options.entries?.() ?? [], sessionId) });
674
+ if (result.status === "unavailable") failureState = result;
675
+ }
676
+ notifyState(visibleState());
677
+ },
678
+ async pruneNow() {
679
+ const report = await pruneStale({ port: prunePort, locks: lockPort(), currentSessionId: sessionId, stopTimeoutMs: configRef.value.stopTimeoutMs });
680
+ notifyState(visibleState());
681
+ return report;
682
+ },
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
+ async getLogs(tailLines) {
772
+ const name = visibleState().info?.name;
773
+ if (!name) return "";
774
+ const msb = await sdk();
775
+ const handle = await msb.Sandbox.get(name);
776
+ const rows = await handle.logs(tailLines === undefined ? undefined : { tail: tailLines });
777
+ return rows.map((row: AnyRecord) => typeof row.text === "function" ? row.text() : Buffer.from(row.data ?? []).toString("utf8")).join("");
778
+ },
779
+ getEffectiveConfig: effective,
780
+ getEffectiveConfigToml: () => toEffectiveToml(effective()),
781
+ async setOverride(key, value) {
782
+ overrides = applyOverride(overrides, key, value);
783
+ options.appendEntry?.(OVERRIDE_ENTRY, { key, value: sanitizeOverride(key, value) });
784
+ await control.reload();
785
+ },
786
+ async unsetOverride(key) {
787
+ overrides = removeOverride(overrides, key);
788
+ options.appendEntry?.(OVERRIDE_ENTRY, { key, unset: true });
789
+ await control.reload();
790
+ },
791
+ async resetOverrides() {
792
+ overrides = {};
793
+ options.appendEntry?.(OVERRIDE_ENTRY, { reset: true });
794
+ await control.reload();
795
+ },
796
+ };
797
+
798
+ // Keep the returned config facade useful to callers without exposing mutable internals.
799
+ void repoRoot;
800
+ return { control, manager, provider, configRef, configureSession };
801
+ }
802
+
803
+ export const createControl = createMsbIntegration;