pi-microsandbox 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/safety.md CHANGED
@@ -4,30 +4,58 @@
4
4
 
5
5
  The default is fail-closed:
6
6
 
7
- - A valid, trusted project configuration is resolved before a sandbox is
8
- started. A boot or image/tool failure blocks the seven routed tools (`read`,
9
- `write`, `edit`, `ls`, `find`, `grep`, and `bash`).
10
- - `mode = "auto"` is retained as an alias for `"direct"`; both use a
11
- same-absolute-path read/write bind. `mode = "git"`, `"direct"`, and
12
- `"none"` select those behaviors explicitly.
7
+ - A valid, trusted project configuration is resolved before a sandbox starts. A
8
+ boot, image, configuration, or tool failure blocks the seven routed tools
9
+ (`read`, `write`, `edit`, `ls`, `find`, `grep`, and `bash`). Failure never
10
+ silently falls back to host execution.
13
11
  - `execution_target = "host"` is an opt-in escape for routed tool calls. While
14
- a sandbox is active it requires an interactive approval for the exact tool,
15
- working directory, and recursively sorted arguments. It is not available in
12
+ a sandbox is active it requires interactive approval for the exact tool,
13
+ working directory, and recursively sorted arguments. It is unavailable in
16
14
  headless operation and is never silently selected.
17
15
  - `/msb off` is an explicit host-mode handoff and does not prompt. This is
18
16
  different from `fallback_mode = "host"`, which automatically uses host tools
19
- after a sandbox failure and is shown as `MSB host fallback`.
17
+ after a sandbox failure and is shown as `MSB host fallback`. Both are
18
+ unsandboxed controls, not workspace settings.
20
19
  - A project cannot replace another process's sandbox: ownership is a
21
- non-blocking kernel `flock` acquired before any sandbox or volume mutation.
22
- The small bundled POSIX addon is loaded lazily, has no install script, and
23
- never falls back to a racy PID check. Stale sandbox pruning never removes
24
- volumes.
20
+ non-blocking kernel `flock` acquired before sandbox mutation. The small
21
+ bundled POSIX addon is loaded lazily, has no install script, and never falls
22
+ back to a racy PID check.
23
+
24
+ There are no workspace modes. Inside a Git worktree, pi-microsandbox
25
+ bind-mounts the entire worktree root read/write at the same lexical guest path.
26
+ Outside Git, it bind-mounts the current directory. Commands start in the
27
+ original current directory, but their path boundary is the selected root.
28
+
29
+ This mount deliberately exposes host files. A Git worktree mount includes
30
+ `.git`, untracked files, secrets such as `.env`, and sibling directories even
31
+ when Pi starts in a subdirectory. Writes are immediately visible on the host,
32
+ and separate sessions using the same checkout can read or overwrite one
33
+ another's work. Use separate host worktrees or checkouts for isolation between
34
+ sessions. Unsafe root mappings fail startup rather than falling back to a
35
+ narrower mount.
25
36
 
26
37
  The extension entry point does not import the native SDK or load the flock
27
38
  addon. Unsupported hosts can still load Pi and remain blocked or explicitly
28
39
  off. pi-microsandbox supports Apple Silicon macOS and GNU Linux x86_64 or arm64
29
40
  with KVM; Windows, Intel macOS, and musl Linux are not supported.
30
41
 
42
+ ## Docker inside the guest
43
+
44
+ The Docker daemon runs inside the microVM and listens only on the guest Unix
45
+ socket. Access to that socket is root-equivalent inside the guest, not on the
46
+ host. Readiness probes explicitly select that socket and reject unverified
47
+ socket ownership. Docker and process-control environment variables are cleared
48
+ for preparation, and configuration cannot forward them. Mounts that shadow
49
+ protected guest executables or Docker runtime paths are rejected. The extension
50
+ never mounts the host Docker socket, starts a host daemon, or copies host Docker
51
+ configuration and registry credentials into the guest.
52
+
53
+ A nested container can still reach anything mounted into the microVM, including
54
+ the entire selected workspace and explicitly configured mounts. Treat a
55
+ Dockerfile or Compose file as guest-root code and use read-only extra mounts
56
+ where possible. Container egress remains behind the Microsandbox network
57
+ policy.
58
+
31
59
  ## Host-read exceptions
32
60
 
33
61
  Pi-discovered `SKILL.md` reads are a narrow host-read exception. A standalone
@@ -39,4 +67,5 @@ become readable.
39
67
 
40
68
  ## Reporting vulnerabilities
41
69
 
42
- Report suspected vulnerabilities privately. See the [security policy](../SECURITY.md); do not open a public issue.
70
+ Report suspected vulnerabilities privately. See the
71
+ [security policy](../SECURITY.md); do not open a public issue.
package/docs/storage.md CHANGED
@@ -1,57 +1,70 @@
1
- # Storage modes and retained work
1
+ # Workspace storage
2
2
 
3
3
  [Back to README](../README.md)
4
4
 
5
- ## Storage modes
6
-
7
- | Mode | Guest view | Host effect of routed writes |
8
- | --- | --- | --- |
9
- | `git` | A named volume at the repository root, with the same absolute path | Volume only |
10
- | `direct` | The current directory bind-mounted at the same absolute path | Host directory |
11
- | `none` | An empty tmpfs at the same absolute path | No host files; changes disappear with the sandbox |
12
- | `auto` | Same as `direct` | Host directory |
13
-
14
- `direct` is intentionally a warning-level escape from the Git isolation model:
15
- routed edits modify the live host directory. `none` is useful for testing path
16
- behavior and starts empty; it is not a retained workspace.
17
-
18
- ## Git and retained volumes
19
-
20
- Git mode never bind-mounts the host checkout. On boot, pi-microsandbox captures the
21
- committed `HEAD` (and selected branch) into a temporary verified Git bundle,
22
- copies it into the guest, and seeds a named volume mounted at the repository's
23
- absolute root. Untracked files, including `.env`, and working-tree edits are
24
- not in that bundle. The temporary host and guest bundle files are removed after
25
- seeding, including failure paths.
26
-
27
- Edits and commits made in Git mode land in the retained volume. The host
28
- checkout is not changed by ordinary routed tools. A normal Pi session shutdown
29
- removes the sandbox but keeps the managed volume; the next boot reuses it only
30
- when the complete session/schema/mode/cwd identity matches. A copied or forked
31
- session state is rejected by the full session ID and gets a different resource
32
- identity.
33
-
34
- The SDK's `VolumeHandle` returned by `Volume.get()` does not expose a host
35
- path. pi-microsandbox therefore refuses to fabricate one: a newly created volume
36
- may show its path, while a later retained-volume lookup may not support
37
- `/msb volumes ls` or volume enrichment. The volume remains mountable by name
38
- and is never automatically deleted. Use the path recorded at creation time or
39
- the microsandbox volume tooling when host-side inspection is required.
40
-
41
- To manually synchronize a retained checkout, use a host-side fetch deliberately
42
- (the command is not performed automatically by pi-microsandbox):
5
+ ## One read/write workspace
6
+
7
+ There are no workspace modes. pi-microsandbox selects one workspace root when
8
+ it starts:
9
+
10
+ - If the current working directory is inside a Git worktree, it mounts the
11
+ entire worktree root.
12
+ - Otherwise, it mounts the current working directory itself.
13
+
14
+ The selected root is bind-mounted read/write at the same lexical path inside
15
+ the guest. Commands still start in the original current working directory.
16
+ Writes are immediately visible in the host directory; there is no export or
17
+ synchronization step.
18
+
19
+ Starting Pi below a Git worktree root does not narrow the boundary. The sandbox
20
+ can see the whole worktree, including `.git`, untracked files, sibling
21
+ directories, and files such as `.env`. Separate Pi sessions that use the same
22
+ checkout also use the same files, so their edits can conflict. Use separate
23
+ host worktrees or checkouts when tasks need independent workspaces.
24
+
25
+ The extension creates one project bind mount. Linked-worktree or submodule Git
26
+ metadata stored outside the selected worktree root is not mounted separately.
27
+ An unsupported path or symlink layout fails startup instead of silently
28
+ mounting a narrower directory.
29
+
30
+ ## Inner Docker state
31
+
32
+ Docker stores images, layers, containers, and build cache under
33
+ `/var/lib/docker` on the sandbox root filesystem. It uses the `vfs` storage
34
+ driver because nested overlay filesystems and project-backed mounts cannot be
35
+ assumed to support `overlay2`. This state disappears when the sandbox is
36
+ removed and is not written to the project mount. Separate sessions do not share
37
+ an inner Docker cache.
38
+
39
+ Containers started inside the microVM can reach the mounted workspace. Treat
40
+ Dockerfiles and Compose files as code with access to the entire selected root.
41
+
42
+ ## Upgrading from named Git volumes
43
+
44
+ Older releases could keep Git workspaces in named `pi-msb-vol-*` volumes. The
45
+ new workspace behavior does not reconnect, migrate, or delete those volumes.
46
+ They may contain the only copy of unexported work.
47
+
48
+ Before upgrading, while the old `/msb volumes` and `/msb export` commands are
49
+ still available, inventory every retained volume and export or copy any work
50
+ you need. After upgrading, use Microsandbox itself:
51
+
52
+ ```sh
53
+ msb volume ls
54
+ mkdir -p ./legacy-workspace-recovery
55
+ msb run --name pi-msb-recovery \
56
+ --mount-named pi-msb-vol-REPLACE_ME:/legacy:ro \
57
+ --mount-dir "$PWD/legacy-workspace-recovery:/recovery:rw" \
58
+ alpine -- sh -c 'cp -a /legacy/. /recovery/'
59
+ msb rm pi-msb-recovery
60
+ ```
61
+
62
+ Verify the copied files, then remove the old volume with:
43
63
 
44
64
  ```sh
45
- REPO=/absolute/path/to/checkout
46
- VOLUME_PATH=/path/returned-for-the-managed-volume
47
- BRANCH=$(git -C "$REPO" branch --show-current)
48
-
49
- git -C "$VOLUME_PATH" remote remove host 2>/dev/null || true
50
- git -C "$VOLUME_PATH" remote add host "$REPO"
51
- git -C "$VOLUME_PATH" fetch --no-tags host "$BRANCH"
52
- # Review before changing the retained checkout:
53
- git -C "$VOLUME_PATH" log --oneline --decorate --all -10
65
+ msb volume rm pi-msb-vol-REPLACE_ME
54
66
  ```
55
67
 
56
- The host repository is an input to this explicit sync operation. Do not add a
57
- host checkout bind mount to Git mode.
68
+ Copy the exact name from `msb volume ls`: a mistyped named mount can create a
69
+ new empty volume. Choose an already-cached recovery image if `alpine` is
70
+ unavailable. pi-microsandbox no longer lists, removes, or exports volumes.
@@ -4,17 +4,62 @@
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
 
20
- 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).
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
+
39
+ ## Docker daemon problems
40
+
41
+ `/msb status` shows the configured Docker mode, readiness, server version, and
42
+ storage driver. Inside an active sandbox, start or recheck the daemon with:
43
+
44
+ ```sh
45
+ pi-msb-docker-start 15000
46
+ docker info
47
+ ```
48
+
49
+ Startup logs are guest-local at `/var/log/pi-msb-dockerd.log`. Check the network
50
+ prerequisites with `iptables --version` (it should report `nf_tables`), `nft
51
+ --version`, and `sysctl net.ipv4.ip_forward` (it should be `1`). The daemon uses
52
+ `vfs`; slower builds and higher disk use are expected compared with `overlay2`.
53
+ Increase `memory_mib` or `docker.startup_timeout_ms` if startup is killed or
54
+ large builds run out of memory. Small builds should have at least 1 GiB and
55
+ larger Compose stacks should have 2 GiB or more; the default is 8 GiB.
56
+
57
+ A published container port needs two mappings. Configure the outer
58
+ `network.publish_ports` mapping before sandbox creation, then use Docker `-p`
59
+ inside the guest. A Docker mapping by itself is not reachable from the host.
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.
62
+
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,34 +173,19 @@ 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)}`);
201
- if (info.seedBranch) lines.push(`Branch: ${info.seedBranch}`);
202
- if (info.seedSha) lines.push(`Seed SHA: ${info.seedSha}`);
203
- if (info.volumeName) lines.push(`Retained volume: ${info.volumeName}`);
204
- if (info.volumeHostPath) lines.push(`Volume path: ${info.volumeHostPath}`);
181
+ lines.push(`Docker mode: ${info.docker.mode}`);
182
+ lines.push(`Docker readiness: ${info.docker.readiness}`);
183
+ if (info.docker.version) lines.push(`Docker version: ${info.docker.version}`);
184
+ if (info.docker.storageDriver) lines.push(`Docker storage driver: ${info.docker.storageDriver}`);
185
+ if (info.docker.reason) lines.push(`Docker reason: ${info.docker.reason}`);
205
186
  return lines.join("\n");
206
187
  }
207
188
 
208
- function volumeLine(
209
- volume: VolumeRecord,
210
- details?: {
211
- branch?: string;
212
- lastCommit?: string;
213
- dirtyCount?: number;
214
- mounted?: boolean;
215
- },
216
- ): string {
217
- const labels = volume.labels;
218
- const branch = details?.branch ?? labels[LABEL_KEYS.seedBranch] ?? "-";
219
- const lastCommit = details?.lastCommit ?? labels[LABEL_KEYS.seedSha] ?? "-";
220
- const dirty = details?.dirtyCount === undefined ? "unknown" : String(details.dirtyCount);
221
- const mounted = details?.mounted ? " mounted" : "";
222
- 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}`;
223
- }
224
-
225
189
  function redactedError(error: unknown, control: MsbControl): string {
226
190
  let message = error instanceof Error ? error.message : String(error);
227
191
  try {
@@ -238,15 +202,6 @@ function notify(ctx: CommandContext, message: string, type: "info" | "warning" |
238
202
  ctx.ui.notify(message, type);
239
203
  }
240
204
 
241
- async function confirm(
242
- ctx: CommandContext,
243
- title: string,
244
- message: string,
245
- ): Promise<boolean> {
246
- if (!ctx.hasUI || typeof ctx.ui.confirm !== "function") return false;
247
- return ctx.ui.confirm(title, message);
248
- }
249
-
250
205
  function parseTail(args: string[]): number | undefined {
251
206
  if (!args.length) return undefined;
252
207
  if (args.length !== 1 || !/^\d+$/.test(args[0])) {
@@ -257,118 +212,11 @@ function parseTail(args: string[]): number | undefined {
257
212
  return tail;
258
213
  }
259
214
 
260
- async function listVolumes(control: MsbControl): Promise<string> {
261
- const volumes = await control.listVolumes();
262
- if (!volumes.length) return "No retained volumes.\nVolumes are never pruned automatically.";
263
- const rows = await Promise.all(
264
- volumes.map(async (volume) => {
265
- try {
266
- const described = await control.describeVolume(volume.name);
267
- return volumeLine(volume, described);
268
- } catch {
269
- return volumeLine(volume);
270
- }
271
- }),
272
- );
273
- return ["Retained volumes (volumes are never pruned automatically):", ...rows].join("\n");
274
- }
275
-
276
215
  function pruneSummary(report: PruneReport): string {
277
216
  const removed = report.removed.length ? report.removed.join(", ") : "none";
278
217
  const kept = report.kept.length ? report.kept.join(", ") : "none";
279
218
  const errors = report.errors.length ? report.errors.join("; ") : "none";
280
- return `Prune complete\nInspected: ${report.inspected}\nRemoved: ${removed}\nKept: ${kept}\nErrors: ${errors}\nVolumes are never pruned.`;
281
- }
282
-
283
- async function handleVolumeRemove(
284
- args: string[],
285
- ctx: CommandContext,
286
- control: MsbControl,
287
- ): Promise<void> {
288
- const yes = args.includes("--yes");
289
- const names = args.filter((arg) => arg !== "--yes");
290
- if (names.length !== 1 || args.filter((arg) => arg === "--yes").length > 1) {
291
- throw new Error("usage: /msb volumes rm <name> [--yes]");
292
- }
293
- await ctx.waitForIdle();
294
- const name = names[0];
295
- const described = await control.describeVolume(name);
296
- if (described.volume.labels[LABEL_KEYS.managed] !== "true") {
297
- throw new Error("refusing to remove an unmanaged volume");
298
- }
299
- if (described.mounted) throw new Error("refusing to remove a mounted volume");
300
-
301
- const metadata = volumeLine(described.volume, described);
302
- if (!yes) {
303
- if (!ctx.hasUI || typeof ctx.ui.confirm !== "function") {
304
- throw new Error("volume removal requires --yes when no UI is available");
305
- }
306
- const approved = await confirm(
307
- ctx,
308
- "Remove retained volume?",
309
- `This permanently removes the managed volume.\n${metadata}`,
310
- );
311
- if (!approved) {
312
- notify(ctx, "Volume removal cancelled", "warning");
313
- return;
314
- }
315
- }
316
- await control.removeVolume(name);
317
- notify(ctx, `Removed volume ${name}.`);
318
- }
319
-
320
- function parseExportArgs(args: string[]): { paths: string[]; destination?: string; yes: boolean } {
321
- const paths: string[] = [];
322
- let destination: string | undefined;
323
- let yes = false;
324
- for (let index = 0; index < args.length; index++) {
325
- const arg = args[index];
326
- if (arg === "--yes") {
327
- if (yes) throw new Error("duplicate --yes");
328
- yes = true;
329
- } else if (arg === "--to") {
330
- if (destination !== undefined || index + 1 >= args.length) {
331
- throw new Error("usage: /msb export <paths...> [--to dir]");
332
- }
333
- destination = args[++index];
334
- } else if (arg.startsWith("--")) {
335
- throw new Error(`unknown export option ${arg}`);
336
- } else {
337
- paths.push(arg);
338
- }
339
- }
340
- if (!paths.length) throw new Error("usage: /msb export <paths...> [--to dir]");
341
- return { paths, destination, yes };
342
- }
343
-
344
- async function handleExport(
345
- args: string[],
346
- ctx: CommandContext,
347
- control: MsbControl,
348
- ): Promise<void> {
349
- const { paths, destination, yes } = parseExportArgs(args);
350
- await ctx.waitForIdle();
351
- if (!yes && (!ctx.hasUI || typeof ctx.ui.confirm !== "function")) {
352
- throw new Error("export requires --yes when no UI is available");
353
- }
354
- if (!yes && ctx.hasUI && typeof ctx.ui.confirm === "function") {
355
- const where = destination ? ` to ${destination}` : " to the dedicated export directory";
356
- const approved = await confirm(
357
- ctx,
358
- "Export sandbox files?",
359
- `Export ${paths.length} path${paths.length === 1 ? "" : "s"}${where}. Existing destinations are never overwritten without confirmation.`,
360
- );
361
- if (!approved) {
362
- notify(ctx, "Export cancelled", "warning");
363
- return;
364
- }
365
- }
366
- const results = await control.exportPaths(paths, destination);
367
- if (!results.length) {
368
- notify(ctx, "No paths were exported.", "warning");
369
- return;
370
- }
371
- notify(ctx, results.map((result) => `${result.source} -> ${result.destination}`).join("\n"));
219
+ return `Prune complete\nInspected: ${report.inspected}\nRemoved: ${removed}\nKept: ${kept}\nErrors: ${errors}`;
372
220
  }
373
221
 
374
222
  async function handleNetwork(args: string[], control: MsbControl): Promise<string> {
@@ -443,16 +291,9 @@ async function executeCommand(
443
291
  case "reload": {
444
292
  if (tokens.length) throw new Error(`usage: /msb ${command}`);
445
293
  await ctx.waitForIdle();
446
- const before = control.getState();
447
294
  if (command === "reload") await control.reload();
448
295
  else await control.setEnabled(command === "on");
449
- const after = control.getState();
450
- const reused =
451
- before.info?.volumeName &&
452
- after.info?.volumeName === before.info.volumeName
453
- ? `\nVolume reused: ${after.info.volumeName}`
454
- : "";
455
- notify(ctx, `${fullState(after)}${reused}`);
296
+ notify(ctx, fullState(control.getState()));
456
297
  return;
457
298
  }
458
299
  case "prune": {
@@ -461,19 +302,6 @@ async function executeCommand(
461
302
  notify(ctx, pruneSummary(await control.pruneNow()));
462
303
  return;
463
304
  }
464
- case "volumes":
465
- if (tokens[0] === "ls" && tokens.length === 1) {
466
- notify(ctx, await listVolumes(control));
467
- return;
468
- }
469
- if (tokens[0] === "rm") {
470
- await handleVolumeRemove(tokens.slice(1), ctx, control);
471
- return;
472
- }
473
- throw new Error("usage: /msb volumes ls | /msb volumes rm <name> [--yes]");
474
- case "export":
475
- await handleExport(tokens, ctx, control);
476
- return;
477
305
  case "logs":
478
306
  notify(ctx, await control.getLogs(parseTail(tokens)));
479
307
  return;
@@ -526,7 +354,7 @@ export function createCommandHandler(control: MsbControl): CommandHandler {
526
354
 
527
355
  export function registerMsbCommand(pi: ExtensionAPI, control: MsbControl): void {
528
356
  pi.registerCommand("msb", {
529
- description: "Manage pi-microsandbox and retained volumes",
357
+ description: "Manage pi-microsandbox",
530
358
  handler: createCommandHandler(control),
531
359
  });
532
360
  }