pi-microsandbox 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -57
- package/SECURITY.md +12 -8
- package/docs/commands.md +18 -14
- package/docs/configuration.md +49 -11
- package/docs/development.md +52 -10
- package/docs/getting-started.md +25 -8
- package/docs/images.md +18 -9
- package/docs/safety.md +43 -14
- package/docs/storage.md +63 -50
- package/docs/troubleshooting.md +54 -9
- package/extensions/pi-msb/command.ts +17 -189
- package/extensions/pi-msb/config.ts +191 -44
- package/extensions/pi-msb/control.ts +134 -302
- package/extensions/pi-msb/index.ts +2 -2
- package/extensions/pi-msb/labels.ts +46 -231
- package/extensions/pi-msb/locks.ts +4 -6
- package/extensions/pi-msb/prune.ts +16 -12
- package/extensions/pi-msb/sandbox-manager.ts +93 -143
- package/extensions/pi-msb/tools.ts +13 -26
- package/extensions/pi-msb/transport.ts +0 -18
- package/extensions/pi-msb/types.ts +42 -108
- package/extensions/pi-msb/workspace.ts +126 -0
- package/native/flock/prebuilds/darwin-arm64/flock.node +0 -0
- package/package.json +1 -1
- package/extensions/pi-msb/git.ts +0 -256
- package/extensions/pi-msb/storage.ts +0 -332
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
|
|
8
|
-
|
|
9
|
-
`write`, `edit`, `ls`, `find`, `grep`, and `bash`).
|
|
10
|
-
|
|
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
|
|
15
|
-
working directory, and recursively sorted arguments. It is
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
-
#
|
|
1
|
+
# Workspace storage
|
|
2
2
|
|
|
3
3
|
[Back to README](../README.md)
|
|
4
4
|
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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.
|
|
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
|
|
|
20
|
-
|
|
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
|
-
|
|
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,34 +173,19 @@ 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)}`);
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (info.
|
|
204
|
-
if (info.
|
|
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}
|
|
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
|
-
|
|
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
|
|
357
|
+
description: "Manage pi-microsandbox",
|
|
530
358
|
handler: createCommandHandler(control),
|
|
531
359
|
});
|
|
532
360
|
}
|