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
package/docs/troubleshooting.md
CHANGED
|
@@ -4,19 +4,38 @@
|
|
|
4
4
|
|
|
5
5
|
1. Run `/msb status` and `/msb logs`.
|
|
6
6
|
2. If the state is `unavailable (blocked)`, fix the displayed configuration,
|
|
7
|
-
image, virtualization, or missing-tool error and run `/msb reload`.
|
|
7
|
+
image, virtualization, path, or missing-tool error and run `/msb reload`.
|
|
8
|
+
Startup failures fail closed; routed tools do not silently run on the host.
|
|
8
9
|
3. If a process died, a later startup or `/msb prune` can remove its labelled
|
|
9
|
-
sandbox after the owner lock is free.
|
|
10
|
-
4. If a same-name resource has different managed labels, pi-microsandbox
|
|
11
|
-
attach or replace it.
|
|
12
|
-
|
|
13
|
-
5. On unsupported virtualization hosts, use `PI_MSB_DISABLE=1`
|
|
14
|
-
mode or configure `fallback_mode = "host"` knowingly.
|
|
15
|
-
visibly distinct from
|
|
10
|
+
sandbox after the owner lock is free.
|
|
11
|
+
4. If a same-name resource has different managed labels, pi-microsandbox
|
|
12
|
+
refuses to attach or replace it. Verify the resource with Microsandbox's
|
|
13
|
+
tooling before removing it.
|
|
14
|
+
5. On unsupported virtualization hosts, use `PI_MSB_DISABLE=1` or `/msb off`
|
|
15
|
+
for explicit host mode, or configure `fallback_mode = "host"` knowingly.
|
|
16
|
+
Automatic fallback remains visibly distinct from explicit off mode; both are
|
|
17
|
+
unsandboxed.
|
|
16
18
|
6. For bootstrap failures, use an image that already contains the required
|
|
17
19
|
command list or allow the configured network policy to reach the package
|
|
18
20
|
repositories. Deny mode cannot bootstrap an image missing those commands.
|
|
19
21
|
|
|
22
|
+
There are no workspace modes. A cwd inside Git mounts the entire worktree root
|
|
23
|
+
read/write at the same guest path; any other cwd mounts itself. If startup fails
|
|
24
|
+
because the selected root or symlink layout cannot be represented safely, fix
|
|
25
|
+
the host path rather than expecting a narrower mount. Remember that writes are
|
|
26
|
+
immediate, `.git`, secrets, untracked files, and sibling directories are
|
|
27
|
+
visible, concurrent sessions share a checkout, and nested containers can reach
|
|
28
|
+
the mount.
|
|
29
|
+
|
|
30
|
+
## Legacy named volumes
|
|
31
|
+
|
|
32
|
+
Named Git volumes created by older releases are never migrated or deleted
|
|
33
|
+
automatically. Before upgrading, use the old `/msb volumes` and `/msb export`
|
|
34
|
+
commands to inventory and copy work while those commands are available. After
|
|
35
|
+
upgrading, use Microsandbox's volume tooling to inspect and recover each
|
|
36
|
+
`pi-msb-vol-*` volume, then remove it manually only after confirming that
|
|
37
|
+
nothing is needed. Current pi-microsandbox commands do not manage these volumes.
|
|
38
|
+
|
|
20
39
|
## Docker daemon problems
|
|
21
40
|
|
|
22
41
|
`/msb status` shows the configured Docker mode, readiness, server version, and
|
|
@@ -38,7 +57,9 @@ larger Compose stacks should have 2 GiB or more; the default is 8 GiB.
|
|
|
38
57
|
A published container port needs two mappings. Configure the outer
|
|
39
58
|
`network.publish_ports` mapping before sandbox creation, then use Docker `-p`
|
|
40
59
|
inside the guest. A Docker mapping by itself is not reachable from the host.
|
|
41
|
-
Pull and container-egress failures under `deny` or `allowlist` are expected;
|
|
42
|
-
|
|
60
|
+
Pull and container-egress failures under `deny` or `allowlist` are expected; do
|
|
61
|
+
not bypass the outer policy or mount the host Docker socket as a workaround.
|
|
43
62
|
|
|
44
|
-
For installation prerequisites, see
|
|
63
|
+
For installation prerequisites, see
|
|
64
|
+
[installation and requirements](getting-started.md). To test a live sandbox
|
|
65
|
+
from source, see the [live test matrix](development.md#live-test-matrix).
|
|
@@ -2,12 +2,10 @@ import type {
|
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
ExtensionCommandContext,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
type RuntimeState,
|
|
10
|
-
type VolumeRecord,
|
|
5
|
+
import type {
|
|
6
|
+
MsbControl,
|
|
7
|
+
PruneReport,
|
|
8
|
+
RuntimeState,
|
|
11
9
|
} from "./types.ts";
|
|
12
10
|
|
|
13
11
|
/** The small part of Pi's command context used by this module. */
|
|
@@ -24,10 +22,7 @@ const HELP = `Usage: /msb <command>
|
|
|
24
22
|
|
|
25
23
|
/status Show the current runtime
|
|
26
24
|
/on | /off | /reload Change runtime state
|
|
27
|
-
/prune Remove stale sandboxes
|
|
28
|
-
/volumes ls List retained volumes
|
|
29
|
-
/volumes rm <name> [--yes] Remove one unmounted managed volume
|
|
30
|
-
/export <paths...> [--to dir] Safely export files from the sandbox
|
|
25
|
+
/prune Remove stale sandboxes
|
|
31
26
|
/logs [tail-lines] Show recent sandbox logs
|
|
32
27
|
/config Show redacted effective configuration
|
|
33
28
|
/set <key> <value> Set a session override
|
|
@@ -52,19 +47,6 @@ function displayTime(createdAt: number | undefined): string {
|
|
|
52
47
|
return `${Math.floor(hours / 24)}d`;
|
|
53
48
|
}
|
|
54
49
|
|
|
55
|
-
function formatBytes(bytes: number | undefined): string {
|
|
56
|
-
if (bytes === undefined || !Number.isFinite(bytes)) return "unknown";
|
|
57
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
58
|
-
const units = ["KiB", "MiB", "GiB", "TiB"];
|
|
59
|
-
let value = bytes;
|
|
60
|
-
let unit = -1;
|
|
61
|
-
while (value >= 1024 && unit < units.length - 1) {
|
|
62
|
-
value /= 1024;
|
|
63
|
-
unit++;
|
|
64
|
-
}
|
|
65
|
-
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
50
|
/**
|
|
69
51
|
* Format the short footer/status representation. A display ID is deliberately
|
|
70
52
|
* not used as an identity here; the full sandbox name is the authoritative name.
|
|
@@ -74,7 +56,7 @@ export function formatStatus(state: RuntimeState): string | undefined {
|
|
|
74
56
|
case "active": {
|
|
75
57
|
const info = state.info;
|
|
76
58
|
if (!info) return "MSB active (sandbox details unavailable)";
|
|
77
|
-
return `MSB active · ${info.
|
|
59
|
+
return `MSB active · ${info.root} · ${info.name}`;
|
|
78
60
|
}
|
|
79
61
|
case "booting":
|
|
80
62
|
return "MSB booting…";
|
|
@@ -99,12 +81,9 @@ export function systemPromptNote(state: RuntimeState): string {
|
|
|
99
81
|
case "active": {
|
|
100
82
|
const info = state.info;
|
|
101
83
|
if (!info) return "MSB is active, but runtime details are unavailable.";
|
|
102
|
-
const volume = info.volumeName
|
|
103
|
-
? ` Retained volume: ${info.volumeName}${info.volumeHostPath ? ` at ${info.volumeHostPath}` : ""}.`
|
|
104
|
-
: "";
|
|
105
84
|
const targetWarning =
|
|
106
85
|
" Host-target execution, when enabled, is an explicit escape from the sandbox and should be used deliberately.";
|
|
107
|
-
return `MSB sandbox
|
|
86
|
+
return `MSB sandbox ${info.name} has the host workspace mounted read/write at ${info.root}.${targetWarning}`;
|
|
108
87
|
}
|
|
109
88
|
case "off":
|
|
110
89
|
return "MSB is explicitly off: tools run on the host. No sandbox is active.";
|
|
@@ -194,7 +173,8 @@ function fullState(state: RuntimeState): string {
|
|
|
194
173
|
}
|
|
195
174
|
|
|
196
175
|
lines.push(`Name: ${info.name}`);
|
|
197
|
-
lines.push(`
|
|
176
|
+
lines.push(`Workspace root: ${info.root}`);
|
|
177
|
+
lines.push(`Workdir: ${info.cwd}`);
|
|
198
178
|
lines.push(`Image: ${info.image}`);
|
|
199
179
|
lines.push(`PID: ${info.pid}`);
|
|
200
180
|
lines.push(`Age: ${displayTime(info.createdAt)}`);
|
|
@@ -203,30 +183,9 @@ function fullState(state: RuntimeState): string {
|
|
|
203
183
|
if (info.docker.version) lines.push(`Docker version: ${info.docker.version}`);
|
|
204
184
|
if (info.docker.storageDriver) lines.push(`Docker storage driver: ${info.docker.storageDriver}`);
|
|
205
185
|
if (info.docker.reason) lines.push(`Docker reason: ${info.docker.reason}`);
|
|
206
|
-
if (info.seedBranch) lines.push(`Branch: ${info.seedBranch}`);
|
|
207
|
-
if (info.seedSha) lines.push(`Seed SHA: ${info.seedSha}`);
|
|
208
|
-
if (info.volumeName) lines.push(`Retained volume: ${info.volumeName}`);
|
|
209
|
-
if (info.volumeHostPath) lines.push(`Volume path: ${info.volumeHostPath}`);
|
|
210
186
|
return lines.join("\n");
|
|
211
187
|
}
|
|
212
188
|
|
|
213
|
-
function volumeLine(
|
|
214
|
-
volume: VolumeRecord,
|
|
215
|
-
details?: {
|
|
216
|
-
branch?: string;
|
|
217
|
-
lastCommit?: string;
|
|
218
|
-
dirtyCount?: number;
|
|
219
|
-
mounted?: boolean;
|
|
220
|
-
},
|
|
221
|
-
): string {
|
|
222
|
-
const labels = volume.labels;
|
|
223
|
-
const branch = details?.branch ?? labels[LABEL_KEYS.seedBranch] ?? "-";
|
|
224
|
-
const lastCommit = details?.lastCommit ?? labels[LABEL_KEYS.seedSha] ?? "-";
|
|
225
|
-
const dirty = details?.dirtyCount === undefined ? "unknown" : String(details.dirtyCount);
|
|
226
|
-
const mounted = details?.mounted ? " mounted" : "";
|
|
227
|
-
return `${volume.name} labels=${JSON.stringify(labels)} path=${volume.hostPath} size=${formatBytes(volume.usedBytes)} age=${displayTime(volume.createdAt)} branch=${branch} last=${lastCommit} dirty=${dirty}${mounted}`;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
189
|
function redactedError(error: unknown, control: MsbControl): string {
|
|
231
190
|
let message = error instanceof Error ? error.message : String(error);
|
|
232
191
|
try {
|
|
@@ -243,15 +202,6 @@ function notify(ctx: CommandContext, message: string, type: "info" | "warning" |
|
|
|
243
202
|
ctx.ui.notify(message, type);
|
|
244
203
|
}
|
|
245
204
|
|
|
246
|
-
async function confirm(
|
|
247
|
-
ctx: CommandContext,
|
|
248
|
-
title: string,
|
|
249
|
-
message: string,
|
|
250
|
-
): Promise<boolean> {
|
|
251
|
-
if (!ctx.hasUI || typeof ctx.ui.confirm !== "function") return false;
|
|
252
|
-
return ctx.ui.confirm(title, message);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
205
|
function parseTail(args: string[]): number | undefined {
|
|
256
206
|
if (!args.length) return undefined;
|
|
257
207
|
if (args.length !== 1 || !/^\d+$/.test(args[0])) {
|
|
@@ -262,118 +212,11 @@ function parseTail(args: string[]): number | undefined {
|
|
|
262
212
|
return tail;
|
|
263
213
|
}
|
|
264
214
|
|
|
265
|
-
async function listVolumes(control: MsbControl): Promise<string> {
|
|
266
|
-
const volumes = await control.listVolumes();
|
|
267
|
-
if (!volumes.length) return "No retained volumes.\nVolumes are never pruned automatically.";
|
|
268
|
-
const rows = await Promise.all(
|
|
269
|
-
volumes.map(async (volume) => {
|
|
270
|
-
try {
|
|
271
|
-
const described = await control.describeVolume(volume.name);
|
|
272
|
-
return volumeLine(volume, described);
|
|
273
|
-
} catch {
|
|
274
|
-
return volumeLine(volume);
|
|
275
|
-
}
|
|
276
|
-
}),
|
|
277
|
-
);
|
|
278
|
-
return ["Retained volumes (volumes are never pruned automatically):", ...rows].join("\n");
|
|
279
|
-
}
|
|
280
|
-
|
|
281
215
|
function pruneSummary(report: PruneReport): string {
|
|
282
216
|
const removed = report.removed.length ? report.removed.join(", ") : "none";
|
|
283
217
|
const kept = report.kept.length ? report.kept.join(", ") : "none";
|
|
284
218
|
const errors = report.errors.length ? report.errors.join("; ") : "none";
|
|
285
|
-
return `Prune complete\nInspected: ${report.inspected}\nRemoved: ${removed}\nKept: ${kept}\nErrors: ${errors}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
async function handleVolumeRemove(
|
|
289
|
-
args: string[],
|
|
290
|
-
ctx: CommandContext,
|
|
291
|
-
control: MsbControl,
|
|
292
|
-
): Promise<void> {
|
|
293
|
-
const yes = args.includes("--yes");
|
|
294
|
-
const names = args.filter((arg) => arg !== "--yes");
|
|
295
|
-
if (names.length !== 1 || args.filter((arg) => arg === "--yes").length > 1) {
|
|
296
|
-
throw new Error("usage: /msb volumes rm <name> [--yes]");
|
|
297
|
-
}
|
|
298
|
-
await ctx.waitForIdle();
|
|
299
|
-
const name = names[0];
|
|
300
|
-
const described = await control.describeVolume(name);
|
|
301
|
-
if (described.volume.labels[LABEL_KEYS.managed] !== "true") {
|
|
302
|
-
throw new Error("refusing to remove an unmanaged volume");
|
|
303
|
-
}
|
|
304
|
-
if (described.mounted) throw new Error("refusing to remove a mounted volume");
|
|
305
|
-
|
|
306
|
-
const metadata = volumeLine(described.volume, described);
|
|
307
|
-
if (!yes) {
|
|
308
|
-
if (!ctx.hasUI || typeof ctx.ui.confirm !== "function") {
|
|
309
|
-
throw new Error("volume removal requires --yes when no UI is available");
|
|
310
|
-
}
|
|
311
|
-
const approved = await confirm(
|
|
312
|
-
ctx,
|
|
313
|
-
"Remove retained volume?",
|
|
314
|
-
`This permanently removes the managed volume.\n${metadata}`,
|
|
315
|
-
);
|
|
316
|
-
if (!approved) {
|
|
317
|
-
notify(ctx, "Volume removal cancelled", "warning");
|
|
318
|
-
return;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
await control.removeVolume(name);
|
|
322
|
-
notify(ctx, `Removed volume ${name}.`);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function parseExportArgs(args: string[]): { paths: string[]; destination?: string; yes: boolean } {
|
|
326
|
-
const paths: string[] = [];
|
|
327
|
-
let destination: string | undefined;
|
|
328
|
-
let yes = false;
|
|
329
|
-
for (let index = 0; index < args.length; index++) {
|
|
330
|
-
const arg = args[index];
|
|
331
|
-
if (arg === "--yes") {
|
|
332
|
-
if (yes) throw new Error("duplicate --yes");
|
|
333
|
-
yes = true;
|
|
334
|
-
} else if (arg === "--to") {
|
|
335
|
-
if (destination !== undefined || index + 1 >= args.length) {
|
|
336
|
-
throw new Error("usage: /msb export <paths...> [--to dir]");
|
|
337
|
-
}
|
|
338
|
-
destination = args[++index];
|
|
339
|
-
} else if (arg.startsWith("--")) {
|
|
340
|
-
throw new Error(`unknown export option ${arg}`);
|
|
341
|
-
} else {
|
|
342
|
-
paths.push(arg);
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
if (!paths.length) throw new Error("usage: /msb export <paths...> [--to dir]");
|
|
346
|
-
return { paths, destination, yes };
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
async function handleExport(
|
|
350
|
-
args: string[],
|
|
351
|
-
ctx: CommandContext,
|
|
352
|
-
control: MsbControl,
|
|
353
|
-
): Promise<void> {
|
|
354
|
-
const { paths, destination, yes } = parseExportArgs(args);
|
|
355
|
-
await ctx.waitForIdle();
|
|
356
|
-
if (!yes && (!ctx.hasUI || typeof ctx.ui.confirm !== "function")) {
|
|
357
|
-
throw new Error("export requires --yes when no UI is available");
|
|
358
|
-
}
|
|
359
|
-
if (!yes && ctx.hasUI && typeof ctx.ui.confirm === "function") {
|
|
360
|
-
const where = destination ? ` to ${destination}` : " to the dedicated export directory";
|
|
361
|
-
const approved = await confirm(
|
|
362
|
-
ctx,
|
|
363
|
-
"Export sandbox files?",
|
|
364
|
-
`Export ${paths.length} path${paths.length === 1 ? "" : "s"}${where}. Existing destinations are never overwritten without confirmation.`,
|
|
365
|
-
);
|
|
366
|
-
if (!approved) {
|
|
367
|
-
notify(ctx, "Export cancelled", "warning");
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
const results = await control.exportPaths(paths, destination);
|
|
372
|
-
if (!results.length) {
|
|
373
|
-
notify(ctx, "No paths were exported.", "warning");
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
376
|
-
notify(ctx, results.map((result) => `${result.source} -> ${result.destination}`).join("\n"));
|
|
219
|
+
return `Prune complete\nInspected: ${report.inspected}\nRemoved: ${removed}\nKept: ${kept}\nErrors: ${errors}`;
|
|
377
220
|
}
|
|
378
221
|
|
|
379
222
|
async function handleNetwork(args: string[], control: MsbControl): Promise<string> {
|
|
@@ -448,16 +291,9 @@ async function executeCommand(
|
|
|
448
291
|
case "reload": {
|
|
449
292
|
if (tokens.length) throw new Error(`usage: /msb ${command}`);
|
|
450
293
|
await ctx.waitForIdle();
|
|
451
|
-
const before = control.getState();
|
|
452
294
|
if (command === "reload") await control.reload();
|
|
453
295
|
else await control.setEnabled(command === "on");
|
|
454
|
-
|
|
455
|
-
const reused =
|
|
456
|
-
before.info?.volumeName &&
|
|
457
|
-
after.info?.volumeName === before.info.volumeName
|
|
458
|
-
? `\nVolume reused: ${after.info.volumeName}`
|
|
459
|
-
: "";
|
|
460
|
-
notify(ctx, `${fullState(after)}${reused}`);
|
|
296
|
+
notify(ctx, fullState(control.getState()));
|
|
461
297
|
return;
|
|
462
298
|
}
|
|
463
299
|
case "prune": {
|
|
@@ -466,19 +302,6 @@ async function executeCommand(
|
|
|
466
302
|
notify(ctx, pruneSummary(await control.pruneNow()));
|
|
467
303
|
return;
|
|
468
304
|
}
|
|
469
|
-
case "volumes":
|
|
470
|
-
if (tokens[0] === "ls" && tokens.length === 1) {
|
|
471
|
-
notify(ctx, await listVolumes(control));
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
|
-
if (tokens[0] === "rm") {
|
|
475
|
-
await handleVolumeRemove(tokens.slice(1), ctx, control);
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
478
|
-
throw new Error("usage: /msb volumes ls | /msb volumes rm <name> [--yes]");
|
|
479
|
-
case "export":
|
|
480
|
-
await handleExport(tokens, ctx, control);
|
|
481
|
-
return;
|
|
482
305
|
case "logs":
|
|
483
306
|
notify(ctx, await control.getLogs(parseTail(tokens)));
|
|
484
307
|
return;
|
|
@@ -531,7 +354,7 @@ export function createCommandHandler(control: MsbControl): CommandHandler {
|
|
|
531
354
|
|
|
532
355
|
export function registerMsbCommand(pi: ExtensionAPI, control: MsbControl): void {
|
|
533
356
|
pi.registerCommand("msb", {
|
|
534
|
-
description: "Manage pi-microsandbox
|
|
357
|
+
description: "Manage pi-microsandbox",
|
|
535
358
|
handler: createCommandHandler(control),
|
|
536
359
|
});
|
|
537
360
|
}
|
|
@@ -19,6 +19,8 @@ const CONTROL_KEYS = new Set(["removeSecrets", "removeMounts", "removeRouteTools
|
|
|
19
19
|
const SECRET_FIELDS = new Set(["env", "value", "allowHosts"]);
|
|
20
20
|
const MOUNT_FIELDS = new Set(["type", "hostPath", "guestPath", "readonly", "options"]);
|
|
21
21
|
const FORBIDDEN_CONFIG_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
|
22
|
+
const REMOVED_WORKSPACE_KEYS = new Set(["mode", "cloneBranch", "cloneDepth", "shallowArchive", "volumeQuotaMiB"]);
|
|
23
|
+
const REMOVED_WORKSPACE_MESSAGE = "was removed; pi-microsandbox now bind-mounts the discovered workspace root read/write";
|
|
22
24
|
const PROTECTED_GUEST_ENV = new Set([
|
|
23
25
|
"BASH_ENV",
|
|
24
26
|
"CDPATH",
|
|
@@ -72,11 +74,6 @@ export const DEFAULT_CONFIG = deepFreeze<Config>({
|
|
|
72
74
|
replace: false,
|
|
73
75
|
replaceTimeoutMs: 10_000,
|
|
74
76
|
sandboxName: null,
|
|
75
|
-
mode: "direct",
|
|
76
|
-
cloneBranch: "current",
|
|
77
|
-
cloneDepth: "unlimited",
|
|
78
|
-
shallowArchive: false,
|
|
79
|
-
volumeQuotaMiB: 2_048,
|
|
80
77
|
network: { mode: "default", allowHosts: [], allowDns: true, publishPorts: [] },
|
|
81
78
|
docker: { mode: "auto", startupTimeoutMs: 15_000 },
|
|
82
79
|
secrets: [],
|
|
@@ -178,6 +175,16 @@ function camel(key: string): string {
|
|
|
178
175
|
function snake(key: string): string {
|
|
179
176
|
return key.replace(/MiB/g, "mib").replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
180
177
|
}
|
|
178
|
+
function removedWorkspaceIssues(value: Record<string, unknown>): string[] {
|
|
179
|
+
return Object.keys(value)
|
|
180
|
+
.map(camel)
|
|
181
|
+
.filter((key) => REMOVED_WORKSPACE_KEYS.has(key))
|
|
182
|
+
.map((key) => `${snake(key)} ${REMOVED_WORKSPACE_MESSAGE}`);
|
|
183
|
+
}
|
|
184
|
+
function assertNoRemovedWorkspaceKeys(value: Record<string, unknown>): void {
|
|
185
|
+
const issues = removedWorkspaceIssues(value);
|
|
186
|
+
if (issues.length) throw new ConfigError(issues);
|
|
187
|
+
}
|
|
181
188
|
function pathKey(parts: string[]): string { return parts.join("."); }
|
|
182
189
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
183
190
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
@@ -229,6 +236,64 @@ function findEquals(text: string): number {
|
|
|
229
236
|
}
|
|
230
237
|
return -1;
|
|
231
238
|
}
|
|
239
|
+
function decodeBasicTomlKey(text: string): string {
|
|
240
|
+
let result = "";
|
|
241
|
+
for (let index = 1; index < text.length - 1; index++) {
|
|
242
|
+
const character = text[index]!;
|
|
243
|
+
if (character !== "\\") {
|
|
244
|
+
result += character;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const escape = text[++index];
|
|
248
|
+
if (escape === undefined) throw new Error("invalid quoted TOML key");
|
|
249
|
+
const simple: Record<string, string> = { b: "\b", t: "\t", n: "\n", f: "\f", r: "\r", "\"": "\"", "\\": "\\" };
|
|
250
|
+
if (Object.hasOwn(simple, escape)) {
|
|
251
|
+
result += simple[escape];
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (escape !== "u" && escape !== "U") throw new Error("invalid quoted TOML key");
|
|
255
|
+
const length = escape === "u" ? 4 : 8;
|
|
256
|
+
const hex = text.slice(index + 1, index + 1 + length);
|
|
257
|
+
if (!new RegExp(`^[0-9a-fA-F]{${length}}$`).test(hex)) throw new Error("invalid quoted TOML key");
|
|
258
|
+
const codePoint = Number.parseInt(hex, 16);
|
|
259
|
+
if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) throw new Error("invalid quoted TOML key");
|
|
260
|
+
result += String.fromCodePoint(codePoint);
|
|
261
|
+
index += length;
|
|
262
|
+
}
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
function parseTomlKeySegment(text: string): string {
|
|
266
|
+
const key = text.trim();
|
|
267
|
+
if (key.startsWith("\"") && key.endsWith("\"")) return decodeBasicTomlKey(key);
|
|
268
|
+
if (key.startsWith("'") && key.endsWith("'") && !key.slice(1, -1).includes("'")) return key.slice(1, -1);
|
|
269
|
+
if (/^[A-Za-z0-9_-]+$/.test(key)) return key;
|
|
270
|
+
throw new Error("invalid TOML key");
|
|
271
|
+
}
|
|
272
|
+
function parseTomlKey(text: string): string[] {
|
|
273
|
+
const parts: string[] = [];
|
|
274
|
+
let start = 0;
|
|
275
|
+
let quote = "";
|
|
276
|
+
for (let index = 0; index < text.length; index++) {
|
|
277
|
+
const character = text[index]!;
|
|
278
|
+
if (quote === "\"") {
|
|
279
|
+
if (character === "\\") index++;
|
|
280
|
+
else if (character === quote) quote = "";
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (quote === "'") {
|
|
284
|
+
if (character === quote) quote = "";
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (character === "\"" || character === "'") quote = character;
|
|
288
|
+
else if (character === ".") {
|
|
289
|
+
parts.push(parseTomlKeySegment(text.slice(start, index)));
|
|
290
|
+
start = index + 1;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (quote) throw new Error("invalid TOML key");
|
|
294
|
+
parts.push(parseTomlKeySegment(text.slice(start)));
|
|
295
|
+
return parts;
|
|
296
|
+
}
|
|
232
297
|
function parseTomlValue(text: string): unknown {
|
|
233
298
|
const value = text.trim();
|
|
234
299
|
if (value.startsWith("[") && value.endsWith("]")) return splitTopLevel(value.slice(1, -1)).map(parseTomlValue);
|
|
@@ -237,9 +302,8 @@ function parseTomlValue(text: string): unknown {
|
|
|
237
302
|
for (const part of splitTopLevel(value.slice(1, -1))) {
|
|
238
303
|
const at = findEquals(part);
|
|
239
304
|
if (at < 0) throw new Error("invalid inline table");
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
object[key] = parseTomlValue(part.slice(at + 1));
|
|
305
|
+
const keys = parseTomlKey(part.slice(0, at));
|
|
306
|
+
assign(object, keys, parseTomlValue(part.slice(at + 1)));
|
|
243
307
|
}
|
|
244
308
|
return object;
|
|
245
309
|
}
|
|
@@ -270,7 +334,7 @@ function readToml(text: string): Record<string, unknown> {
|
|
|
270
334
|
const line = stripTomlComment(raw);
|
|
271
335
|
if (!line) continue;
|
|
272
336
|
if (line.startsWith("[[") && line.endsWith("]]")) {
|
|
273
|
-
const keys = line.slice(2, -2).trim()
|
|
337
|
+
const keys = parseTomlKey(line.slice(2, -2).trim());
|
|
274
338
|
assertSafeConfigPath(keys);
|
|
275
339
|
let parent: Record<string, unknown> = root;
|
|
276
340
|
for (const key of keys.slice(0, -1)) {
|
|
@@ -288,14 +352,14 @@ function readToml(text: string): Record<string, unknown> {
|
|
|
288
352
|
continue;
|
|
289
353
|
}
|
|
290
354
|
if (line.startsWith("[") && line.endsWith("]")) {
|
|
291
|
-
section = line.slice(1, -1).trim()
|
|
355
|
+
section = parseTomlKey(line.slice(1, -1).trim());
|
|
292
356
|
assertSafeConfigPath(section);
|
|
293
357
|
arraySection = null;
|
|
294
358
|
continue;
|
|
295
359
|
}
|
|
296
360
|
const at = findEquals(line);
|
|
297
361
|
if (at < 0) throw new Error("invalid assignment");
|
|
298
|
-
const
|
|
362
|
+
const keys = parseTomlKey(line.slice(0, at));
|
|
299
363
|
const value = parseTomlValue(line.slice(at + 1));
|
|
300
364
|
if (arraySection) {
|
|
301
365
|
let parent: Record<string, unknown> = root;
|
|
@@ -304,9 +368,8 @@ function readToml(text: string): Record<string, unknown> {
|
|
|
304
368
|
if (!Array.isArray(list) || !isPlainObject(list[list.length - 1])) throw new Error("invalid array table");
|
|
305
369
|
parent = list[list.length - 1] as Record<string, unknown>;
|
|
306
370
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
} else assign(root, [...section, ...key.split(".").map((x) => x.trim())], value);
|
|
371
|
+
assign(parent, keys, value);
|
|
372
|
+
} else assign(root, [...section, ...keys], value);
|
|
310
373
|
}
|
|
311
374
|
return root;
|
|
312
375
|
}
|
|
@@ -335,7 +398,7 @@ function knownPath(path: string): boolean {
|
|
|
335
398
|
if (parts[0] === "docker") return parts.length === 1 || (parts.length === 2 && ["mode", "startupTimeoutMs"].includes(parts[1]));
|
|
336
399
|
if (parts[0] === "secrets") return parts.length === 1 || (parts.length === 2 && SECRET_FIELDS.has(parts[1]));
|
|
337
400
|
if (parts[0] === "mounts") return parts.length === 1 || (parts.length === 2 && MOUNT_FIELDS.has(parts[1]));
|
|
338
|
-
return ["image", "pullPolicy", "bootstrapTools", "cpus", "memoryMiB", "idleTimeoutSec", "stopTimeoutMs", "detached", "replace", "replaceTimeoutMs", "sandboxName", "
|
|
401
|
+
return ["image", "pullPolicy", "bootstrapTools", "cpus", "memoryMiB", "idleTimeoutSec", "stopTimeoutMs", "detached", "replace", "replaceTimeoutMs", "sandboxName", "blockThirdParty", "routeTools", "passThroughTools", "allowHostExecution", "allowSkillReads", "fallbackMode", "exposeSessionEnvironment", "hostEnv", "autoStart", "pruneOnStart", "showFooter", "lockDir", "hostRoAllowlist"].includes(parts[0]);
|
|
339
402
|
}
|
|
340
403
|
function collectUnknown(value: unknown, base: string[], warnings: string[]): void {
|
|
341
404
|
if (!isPlainObject(value)) return;
|
|
@@ -368,7 +431,9 @@ function withMountDefaults(value: LayerValue): LayerValue {
|
|
|
368
431
|
}
|
|
369
432
|
function normalizeLayer(name: ConfigLayerName, raw: Record<string, unknown>, source?: string): ParsedConfigLayer {
|
|
370
433
|
assertSafeConfigValue(raw);
|
|
371
|
-
const
|
|
434
|
+
const normalized = normalizeValue(raw) as Record<string, unknown>;
|
|
435
|
+
assertNoRemovedWorkspaceKeys(normalized);
|
|
436
|
+
const value = withMountDefaults(normalized as LayerValue);
|
|
372
437
|
const warnings: string[] = [];
|
|
373
438
|
collectUnknown(value, [], warnings);
|
|
374
439
|
if (name === "project" && Array.isArray(value.secrets)) {
|
|
@@ -383,7 +448,10 @@ function normalizeLayer(name: ConfigLayerName, raw: Record<string, unknown>, sou
|
|
|
383
448
|
|
|
384
449
|
export function parseTomlConfig(text: string, source: string): ParsedConfigLayer {
|
|
385
450
|
try { return normalizeLayer(source === "cli" ? "cli" : source === "env" ? "env" : source === "project" ? "project" : "global", readToml(text), source); }
|
|
386
|
-
catch
|
|
451
|
+
catch (error) {
|
|
452
|
+
if (error instanceof ConfigError) throw error;
|
|
453
|
+
throw new ConfigError([`invalid TOML in ${source}`]);
|
|
454
|
+
}
|
|
387
455
|
}
|
|
388
456
|
|
|
389
457
|
function envScalar(text: string, key: string): unknown {
|
|
@@ -488,6 +556,7 @@ export function mergeConfigLayers(layers: readonly ConfigLayerInput[]): MergeRes
|
|
|
488
556
|
const warnings: string[] = [];
|
|
489
557
|
for (const layer of layers) {
|
|
490
558
|
assertSafeConfigValue(layer.value);
|
|
559
|
+
assertNoRemovedWorkspaceKeys(layer.value as Record<string, unknown>);
|
|
491
560
|
warnings.push(...layer.warnings.map(warning));
|
|
492
561
|
const source = withMountDefaults(clone(layer.value) as LayerValue);
|
|
493
562
|
applyRemovals(result, source, layer.name, provenance);
|
|
@@ -517,6 +586,7 @@ function validatePort(port: unknown): boolean {
|
|
|
517
586
|
}
|
|
518
587
|
|
|
519
588
|
export function validateConfig(raw: DeepPartial<Config>): Config {
|
|
589
|
+
assertNoRemovedWorkspaceKeys(raw as Record<string, unknown>);
|
|
520
590
|
const config = mergeWithDefaults(raw);
|
|
521
591
|
const issues: string[] = [];
|
|
522
592
|
const n = (value: unknown) => typeof value === "number" && Number.isFinite(value);
|
|
@@ -527,18 +597,14 @@ export function validateConfig(raw: DeepPartial<Config>): Config {
|
|
|
527
597
|
if (!n(config.idleTimeoutSec) || config.idleTimeoutSec < 0) issues.push(issueForPath("idleTimeoutSec", "must be non-negative"));
|
|
528
598
|
if (!n(config.stopTimeoutMs) || config.stopTimeoutMs < 1) issues.push(issueForPath("stopTimeoutMs", "must be positive"));
|
|
529
599
|
if (!n(config.replaceTimeoutMs) || config.replaceTimeoutMs < 1) issues.push(issueForPath("replaceTimeoutMs", "must be positive"));
|
|
530
|
-
if (!n(config.volumeQuotaMiB) || config.volumeQuotaMiB < 1) issues.push(issueForPath("volumeQuotaMiB", "must be positive"));
|
|
531
|
-
if (!["auto", "git", "direct", "none"].includes(config.mode)) issues.push(issueForPath("mode", "unknown storage mode"));
|
|
532
600
|
const docker = isPlainObject(config.docker) ? config.docker : null;
|
|
533
601
|
if (!docker || !["auto", "require", "disabled"].includes(docker.mode as string)) issues.push(issueForPath("docker.mode", "must be auto, require, or disabled"));
|
|
534
602
|
if (!docker || !n(docker.startupTimeoutMs) || !Number.isInteger(docker.startupTimeoutMs) || (docker.startupTimeoutMs as number) < 1 || (docker.startupTimeoutMs as number) > 300_000) issues.push(issueForPath("docker.startupTimeoutMs", "must be an integer between 1 and 300000"));
|
|
535
603
|
if (!["auto", true, false].includes(config.bootstrapTools)) issues.push(issueForPath("bootstrapTools", "must be auto, true, or false"));
|
|
536
604
|
if (!["block", "host"].includes(config.fallbackMode)) issues.push(issueForPath("fallbackMode", "must be block or host"));
|
|
537
|
-
const booleanFields = ["detached", "replace", "
|
|
605
|
+
const booleanFields = ["detached", "replace", "blockThirdParty", "allowHostExecution", "allowSkillReads", "exposeSessionEnvironment", "autoStart", "pruneOnStart", "showFooter"] as const;
|
|
538
606
|
for (const field of booleanFields) if (typeof config[field] !== "boolean") issues.push(issueForPath(field, "must be boolean"));
|
|
539
607
|
if (config.sandboxName !== null && typeof config.sandboxName !== "string") issues.push(issueForPath("sandboxName", "must be a string or null"));
|
|
540
|
-
if (typeof config.cloneBranch !== "string" || !config.cloneBranch) issues.push(issueForPath("cloneBranch", "must be a non-empty string"));
|
|
541
|
-
if (config.cloneDepth !== "unlimited" && (!n(config.cloneDepth) || !Number.isInteger(config.cloneDepth) || config.cloneDepth < 1)) issues.push(issueForPath("cloneDepth", "must be a positive integer or unlimited"));
|
|
542
608
|
const stringArray = (field: string, value: unknown) => {
|
|
543
609
|
if (!Array.isArray(value)) { issues.push(issueForPath(field, "must be an array")); return false; }
|
|
544
610
|
for (const item of value) if (typeof item !== "string" || !item) issues.push(issueForPath(field, "must contain non-empty strings"));
|
|
@@ -619,7 +685,10 @@ function configDir(env: NodeJS.ProcessEnv, input: ResolveConfigInput): string {
|
|
|
619
685
|
}
|
|
620
686
|
function layerFromText(name: ConfigLayerName, text: string, source: string): ParsedConfigLayer {
|
|
621
687
|
try { return normalizeLayer(name, readToml(text), source); }
|
|
622
|
-
catch
|
|
688
|
+
catch (error) {
|
|
689
|
+
if (error instanceof ConfigError) throw error;
|
|
690
|
+
throw new ConfigError([`invalid TOML in ${source}`]);
|
|
691
|
+
}
|
|
623
692
|
}
|
|
624
693
|
|
|
625
694
|
export async function resolveConfig(input: ResolveConfigInput): Promise<ResolvedConfig> {
|
|
@@ -643,23 +712,35 @@ export async function resolveConfig(input: ResolveConfigInput): Promise<Resolved
|
|
|
643
712
|
}
|
|
644
713
|
};
|
|
645
714
|
const project = await projectFile(input.cwd, input.repoRoot, input.configDirName, exists);
|
|
646
|
-
|
|
647
|
-
if (project && input.projectTrusted) {
|
|
648
|
-
|
|
649
|
-
if (
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
715
|
+
const projectText = project ? await readFile(project) : null;
|
|
716
|
+
if (project && !input.projectTrusted) {
|
|
717
|
+
warnings.push("project config ignored because this project is not trusted");
|
|
718
|
+
if (projectText !== null) {
|
|
719
|
+
// Ignored project configuration cannot influence policy, but a removed
|
|
720
|
+
// workspace selector must still stop startup before direct host writes.
|
|
721
|
+
try {
|
|
722
|
+
const normalized = normalizeValue(readToml(projectText)) as Record<string, unknown>;
|
|
723
|
+
assertNoRemovedWorkspaceKeys(normalized);
|
|
724
|
+
} catch (error) {
|
|
725
|
+
if (error instanceof ConfigError && error.issues.some((issue) => issue.includes(REMOVED_WORKSPACE_MESSAGE))) throw error;
|
|
726
|
+
// Preserve the existing trust rule for every other malformed/unsafe key:
|
|
727
|
+
// untrusted project configuration is not interpreted.
|
|
659
728
|
}
|
|
660
|
-
layers.push({ ...parsed, value: projectValue });
|
|
661
729
|
}
|
|
662
730
|
}
|
|
731
|
+
if (project && input.projectTrusted && projectText !== null) {
|
|
732
|
+
const parsed = layerFromText("project", projectText, project);
|
|
733
|
+
const projectValue = withMountDefaults(clone(parsed.value) as LayerValue);
|
|
734
|
+
if (Array.isArray(projectValue.mounts)) {
|
|
735
|
+
projectValue.mounts = await Promise.all(projectValue.mounts.map(async (mount: any) => {
|
|
736
|
+
if (mount && (mount.type === "dir" || mount.type === "file") && typeof mount.hostPath === "string" && isAbsolute(mount.hostPath)) {
|
|
737
|
+
return { ...mount, hostPath: await canonicalPath(mount.hostPath, true) };
|
|
738
|
+
}
|
|
739
|
+
return mount;
|
|
740
|
+
})) as any;
|
|
741
|
+
}
|
|
742
|
+
layers.push({ ...parsed, value: projectValue });
|
|
743
|
+
}
|
|
663
744
|
layers.push(parseEnvConfig(env));
|
|
664
745
|
if (input.cliOverridesToml !== undefined) layers.push(parseTomlConfig(input.cliOverridesToml, "cli"));
|
|
665
746
|
const merged = mergeConfigLayers(layers);
|
|
@@ -767,7 +848,11 @@ function setAt(base: any, parts: string[], value: unknown): any {
|
|
|
767
848
|
export function applyOverride(base: DeepPartial<Config>, dottedSnakeKey: string, value: unknown): DeepPartial<Config> {
|
|
768
849
|
const rawParts = dottedSnakeKey.split(".").filter(Boolean);
|
|
769
850
|
assertSafeConfigPath(rawParts);
|
|
770
|
-
|
|
851
|
+
const parts = rawParts.map(camel);
|
|
852
|
+
if (REMOVED_WORKSPACE_KEYS.has(parts[0] ?? "")) {
|
|
853
|
+
throw new ConfigError([`${dottedSnakeKey} ${REMOVED_WORKSPACE_MESSAGE}`]);
|
|
854
|
+
}
|
|
855
|
+
return setAt(base, parts, value) as DeepPartial<Config>;
|
|
771
856
|
}
|
|
772
857
|
export function removeOverride(base: DeepPartial<Config>, dottedSnakeKey: string): DeepPartial<Config> {
|
|
773
858
|
assertSafeConfigValue(base);
|
|
@@ -776,6 +861,9 @@ export function removeOverride(base: DeepPartial<Config>, dottedSnakeKey: string
|
|
|
776
861
|
assertSafeConfigPath(rawParts);
|
|
777
862
|
const parts = rawParts.map(camel);
|
|
778
863
|
assertSafeConfigPath(parts);
|
|
864
|
+
if (REMOVED_WORKSPACE_KEYS.has(parts[0] ?? "")) {
|
|
865
|
+
throw new ConfigError([`${dottedSnakeKey} ${REMOVED_WORKSPACE_MESSAGE}`]);
|
|
866
|
+
}
|
|
779
867
|
let current = out;
|
|
780
868
|
for (const part of parts.slice(0, -1)) { if (!isPlainObject(current[part])) return out; current = current[part]; }
|
|
781
869
|
delete current[parts[parts.length - 1]];
|