pi-microsandbox 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -57
- package/SECURITY.md +12 -8
- package/docs/commands.md +18 -14
- package/docs/configuration.md +49 -11
- package/docs/development.md +52 -10
- package/docs/getting-started.md +25 -8
- package/docs/images.md +18 -9
- package/docs/safety.md +43 -14
- package/docs/storage.md +63 -50
- package/docs/troubleshooting.md +54 -9
- package/extensions/pi-msb/command.ts +17 -189
- package/extensions/pi-msb/config.ts +191 -44
- package/extensions/pi-msb/control.ts +134 -302
- package/extensions/pi-msb/index.ts +2 -2
- package/extensions/pi-msb/labels.ts +46 -231
- package/extensions/pi-msb/locks.ts +4 -6
- package/extensions/pi-msb/prune.ts +16 -12
- package/extensions/pi-msb/sandbox-manager.ts +93 -143
- package/extensions/pi-msb/tools.ts +13 -26
- package/extensions/pi-msb/transport.ts +0 -18
- package/extensions/pi-msb/types.ts +42 -108
- package/extensions/pi-msb/workspace.ts +126 -0
- package/native/flock/prebuilds/darwin-arm64/flock.node +0 -0
- package/package.json +1 -1
- package/extensions/pi-msb/git.ts +0 -256
- package/extensions/pi-msb/storage.ts +0 -332
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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", "
|
|
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", "
|
|
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[
|
|
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.
|
|
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:
|
|
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
|
|
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
|
|
305
|
+
createSandbox: async (request) => {
|
|
471
306
|
const msb = await sdk();
|
|
472
307
|
const name = request.config.sandboxName ?? sandboxNameFor(request.sessionId);
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
535
|
-
const
|
|
536
|
-
const result = await Promise.all(
|
|
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
|
|
373
|
+
let commands = await commandsMissing(REQUIRED_GUEST_COMMANDS);
|
|
540
374
|
if (commands.length && config.bootstrapTools !== false) {
|
|
541
|
-
const apt = await runtime.transport.exec("sh", ["-
|
|
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", "
|
|
545
|
-
commands = await
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
648
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
121
|
-
//
|
|
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,
|