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