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.
@@ -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. The named volume is retained.
10
- 4. If a same-name resource has different managed labels, pi-microsandbox refuses to
11
- attach or replace it. Remove only a verified managed, unmounted volume with
12
- `/msb volumes rm NAME --yes`.
13
- 5. On unsupported virtualization hosts, use `PI_MSB_DISABLE=1` for explicit host
14
- mode or configure `fallback_mode = "host"` knowingly. The latter remains
15
- visibly distinct from `/msb off`.
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
- do not bypass the outer policy or mount the host Docker socket as a workaround.
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 [installation and requirements](getting-started.md). To test a live sandbox from source, see the [live test matrix](development.md#live-test-matrix).
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
- LABEL_KEYS,
7
- type MsbControl,
8
- type PruneReport,
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 (never volumes)
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.mode} · ${info.name}`;
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 is active in ${info.mode} mode (${info.name}).${volume}${targetWarning}`;
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(`Mode: ${info.mode}`);
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}\nVolumes are never pruned.`;
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
- const after = control.getState();
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 and retained volumes",
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 key = part.slice(0, at).trim();
241
- assertSafeConfigKey(key, [key]);
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().split(".").map((x) => x.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().split(".").map((x) => x.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 key = line.slice(0, at).trim();
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
- assertSafeConfigKey(key, [...arraySection, key]);
308
- parent[key] = value;
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", "mode", "cloneBranch", "cloneDepth", "shallowArchive", "volumeQuotaMiB", "blockThirdParty", "routeTools", "passThroughTools", "allowHostExecution", "allowSkillReads", "fallbackMode", "exposeSessionEnvironment", "hostEnv", "autoStart", "pruneOnStart", "showFooter", "lockDir", "hostRoAllowlist"].includes(parts[0]);
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 value = withMountDefaults(normalizeValue(raw) as LayerValue);
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 { throw new ConfigError([`invalid TOML in ${source}`]); }
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", "shallowArchive", "blockThirdParty", "allowHostExecution", "allowSkillReads", "exposeSessionEnvironment", "autoStart", "pruneOnStart", "showFooter"] as const;
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 { throw new ConfigError([`invalid TOML in ${source}`]); }
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
- if (project && !input.projectTrusted) warnings.push("project config ignored because this project is not trusted");
647
- if (project && input.projectTrusted) {
648
- const text = await readFile(project);
649
- if (text !== null) {
650
- const parsed = layerFromText("project", text, project);
651
- const projectValue = withMountDefaults(clone(parsed.value) as LayerValue);
652
- if (Array.isArray(projectValue.mounts)) {
653
- projectValue.mounts = await Promise.all(projectValue.mounts.map(async (mount: any) => {
654
- if (mount && (mount.type === "dir" || mount.type === "file") && typeof mount.hostPath === "string" && isAbsolute(mount.hostPath)) {
655
- return { ...mount, hostPath: await canonicalPath(mount.hostPath, true) };
656
- }
657
- return mount;
658
- })) as any;
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
- return setAt(base, rawParts.map(camel), value) as DeepPartial<Config>;
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]];