pi-microsandbox 0.1.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.
@@ -0,0 +1,532 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ LABEL_KEYS,
7
+ type MsbControl,
8
+ type PruneReport,
9
+ type RuntimeState,
10
+ type VolumeRecord,
11
+ } from "./types.ts";
12
+
13
+ /** The small part of Pi's command context used by this module. */
14
+ export type CommandContext = Pick<
15
+ ExtensionCommandContext,
16
+ "ui" | "hasUI" | "waitForIdle"
17
+ >;
18
+ export type CommandHandler = (
19
+ args: string,
20
+ ctx: CommandContext,
21
+ ) => Promise<void>;
22
+
23
+ const HELP = `Usage: /msb <command>
24
+
25
+ /status Show the current runtime
26
+ /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
31
+ /logs [tail-lines] Show recent sandbox logs
32
+ /config Show redacted effective configuration
33
+ /set <key> <value> Set a session override
34
+ /unset <key> Remove a session override
35
+ /reset Remove all session overrides
36
+ /network allow <host...> Allow network hosts
37
+ /network deny Seal network access
38
+ /seal Alias for network deny
39
+ /mount add <json|host guest> Add a mount
40
+ /mount rm <guest-path> Remove a mount override
41
+ /help Show this help`;
42
+
43
+ function displayTime(createdAt: number | undefined): string {
44
+ if (createdAt === undefined || !Number.isFinite(createdAt)) return "unknown";
45
+ const timestamp = createdAt < 1_000_000_000_000 ? createdAt * 1000 : createdAt;
46
+ const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
47
+ if (seconds < 60) return `${seconds}s`;
48
+ const minutes = Math.floor(seconds / 60);
49
+ if (minutes < 60) return `${minutes}m`;
50
+ const hours = Math.floor(minutes / 60);
51
+ if (hours < 48) return `${hours}h`;
52
+ return `${Math.floor(hours / 24)}d`;
53
+ }
54
+
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
+ /**
69
+ * Format the short footer/status representation. A display ID is deliberately
70
+ * not used as an identity here; the full sandbox name is the authoritative name.
71
+ */
72
+ export function formatStatus(state: RuntimeState): string | undefined {
73
+ switch (state.status) {
74
+ case "active": {
75
+ const info = state.info;
76
+ if (!info) return "MSB active (sandbox details unavailable)";
77
+ return `MSB active · ${info.mode} · ${info.name}`;
78
+ }
79
+ case "booting":
80
+ return "MSB booting…";
81
+ case "stopping":
82
+ return "MSB stopping…";
83
+ case "off":
84
+ return "MSB host (off)";
85
+ case "host-fallback":
86
+ return "MSB host fallback (sandbox unavailable)";
87
+ case "unavailable":
88
+ return `MSB unavailable (blocked)${state.reason ? `: ${state.reason}` : ""}`;
89
+ case "disabled":
90
+ return "MSB disabled";
91
+ default:
92
+ return undefined;
93
+ }
94
+ }
95
+
96
+ /** Text injected into the agent system prompt for the current runtime. */
97
+ export function systemPromptNote(state: RuntimeState): string {
98
+ switch (state.status) {
99
+ case "active": {
100
+ const info = state.info;
101
+ 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
+ const targetWarning =
106
+ " 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}`;
108
+ }
109
+ case "off":
110
+ return "MSB is explicitly off: tools run on the host. No sandbox is active.";
111
+ case "host-fallback":
112
+ return "MSB could not start and is using the configured host fallback. This is different from explicitly turning MSB off.";
113
+ case "unavailable":
114
+ return "MSB is unavailable and routed tools are blocked. Use /msb on after fixing the reported problem, or explicitly use /msb off if host execution is intended.";
115
+ case "booting":
116
+ return "MSB is booting; wait for it to become active before using routed tools.";
117
+ case "stopping":
118
+ return "MSB is stopping; routed tools are temporarily unavailable.";
119
+ case "disabled":
120
+ return "MSB is disabled.";
121
+ default:
122
+ return "MSB runtime state is unknown; routed tools remain fail-closed.";
123
+ }
124
+ }
125
+
126
+ function tokenize(input: string): string[] {
127
+ const result: string[] = [];
128
+ let token = "";
129
+ let quote: "'" | '"' | null = null;
130
+ let escaped = false;
131
+ let started = false;
132
+
133
+ for (const char of input.trim()) {
134
+ if (escaped) {
135
+ token += char;
136
+ escaped = false;
137
+ started = true;
138
+ continue;
139
+ }
140
+ if (char === "\\" && quote !== "'") {
141
+ escaped = true;
142
+ started = true;
143
+ continue;
144
+ }
145
+ if (quote) {
146
+ if (char === quote) quote = null;
147
+ else token += char;
148
+ started = true;
149
+ continue;
150
+ }
151
+ if (char === "'" || char === '"') {
152
+ quote = char;
153
+ started = true;
154
+ continue;
155
+ }
156
+ if (/\s/.test(char)) {
157
+ if (started) {
158
+ result.push(token);
159
+ token = "";
160
+ started = false;
161
+ }
162
+ continue;
163
+ }
164
+ token += char;
165
+ started = true;
166
+ }
167
+ if (escaped) token += "\\";
168
+ if (quote) throw new Error("unterminated quote in command arguments");
169
+ if (started) result.push(token);
170
+ return result;
171
+ }
172
+
173
+ function parseValue(raw: string): unknown {
174
+ if (raw === "true") return true;
175
+ if (raw === "false") return false;
176
+ if (raw === "null") return null;
177
+ if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(raw)) return Number(raw);
178
+ if (raw.startsWith("{") || raw.startsWith("[") || raw.startsWith('"')) {
179
+ try {
180
+ return JSON.parse(raw);
181
+ } catch {
182
+ throw new Error("value is not valid JSON");
183
+ }
184
+ }
185
+ return raw;
186
+ }
187
+
188
+ function fullState(state: RuntimeState): string {
189
+ const lines = [formatStatus(state) ?? "MSB state unavailable"];
190
+ const info = state.info;
191
+ if (!info) {
192
+ if (state.reason) lines.push(`Reason: ${state.reason}`);
193
+ return lines.join("\n");
194
+ }
195
+
196
+ lines.push(`Name: ${info.name}`);
197
+ lines.push(`Mode: ${info.mode}`);
198
+ lines.push(`Image: ${info.image}`);
199
+ lines.push(`PID: ${info.pid}`);
200
+ 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}`);
205
+ return lines.join("\n");
206
+ }
207
+
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
+ function redactedError(error: unknown, control: MsbControl): string {
226
+ let message = error instanceof Error ? error.message : String(error);
227
+ try {
228
+ for (const secret of control.getEffectiveConfig().config.secrets) {
229
+ if (secret.value) message = message.split(secret.value).join("[redacted]");
230
+ }
231
+ } catch {
232
+ // Error reporting must not make a failed command fail a second time.
233
+ }
234
+ return message || "command failed";
235
+ }
236
+
237
+ function notify(ctx: CommandContext, message: string, type: "info" | "warning" | "error" = "info"): void {
238
+ ctx.ui.notify(message, type);
239
+ }
240
+
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
+ function parseTail(args: string[]): number | undefined {
251
+ if (!args.length) return undefined;
252
+ if (args.length !== 1 || !/^\d+$/.test(args[0])) {
253
+ throw new Error("logs accepts at most one non-negative tail line count");
254
+ }
255
+ const tail = Number(args[0]);
256
+ if (!Number.isSafeInteger(tail)) throw new Error("tail line count is too large");
257
+ return tail;
258
+ }
259
+
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
+ function pruneSummary(report: PruneReport): string {
277
+ const removed = report.removed.length ? report.removed.join(", ") : "none";
278
+ const kept = report.kept.length ? report.kept.join(", ") : "none";
279
+ 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"));
372
+ }
373
+
374
+ async function handleNetwork(args: string[], control: MsbControl): Promise<string> {
375
+ if (!args.length || args[0] === "help") throw new Error("usage: /msb network allow <host...> | deny");
376
+ const mode = args[0];
377
+ if (mode === "deny") {
378
+ if (args.length !== 1) throw new Error("usage: /msb network deny");
379
+ await control.setOverride("network.mode", "deny");
380
+ return "Network sealed (deny mode).";
381
+ }
382
+ if (mode === "allow") {
383
+ const hosts = args.slice(1);
384
+ if (!hosts.length) throw new Error("network allow requires at least one host");
385
+ await control.setOverride("network.mode", "allowlist");
386
+ await control.setOverride("network.allow_hosts", hosts);
387
+ return `Network allowlist set: ${hosts.join(", ")}`;
388
+ }
389
+ throw new Error("usage: /msb network allow <host...> | deny");
390
+ }
391
+
392
+ async function handleMount(args: string[], control: MsbControl): Promise<string> {
393
+ const action = args[0];
394
+ if (action === "add") {
395
+ const value = args.slice(1);
396
+ if (value.length === 1) {
397
+ const mount = parseValue(value[0]);
398
+ if (!mount || typeof mount !== "object" || Array.isArray(mount)) {
399
+ throw new Error("mount add expects a JSON object or <hostPath> <guestPath>");
400
+ }
401
+ await control.setOverride("mounts", [mount]);
402
+ return "Mount override added.";
403
+ }
404
+ if (value.length < 2 || value.length > 3 || (value[2] !== "--readonly" && value.length === 3)) {
405
+ throw new Error("usage: /msb mount add <json|hostPath guestPath [--readonly]>");
406
+ }
407
+ const mount = {
408
+ type: "dir",
409
+ hostPath: value[0],
410
+ guestPath: value[1],
411
+ readonly: value[2] === "--readonly",
412
+ options: [],
413
+ };
414
+ await control.setOverride("mounts", [mount]);
415
+ return "Mount override added.";
416
+ }
417
+ if (action === "rm" && args.length === 2) {
418
+ await control.setOverride("remove_mounts", [{ guestPath: args[1] }]);
419
+ return `Mount override removed for ${args[1]}.`;
420
+ }
421
+ throw new Error("usage: /msb mount add <json|hostPath guestPath [--readonly]> | rm <guestPath>");
422
+ }
423
+
424
+ async function executeCommand(
425
+ args: string,
426
+ ctx: CommandContext,
427
+ control: MsbControl,
428
+ ): Promise<void> {
429
+ const tokens = tokenize(args);
430
+ const command = tokens.shift() ?? "status";
431
+
432
+ switch (command) {
433
+ case "help":
434
+ if (tokens.length) throw new Error("help does not accept arguments");
435
+ notify(ctx, HELP);
436
+ return;
437
+ case "status":
438
+ if (tokens.length) throw new Error("usage: /msb status");
439
+ notify(ctx, fullState(control.getState()));
440
+ return;
441
+ case "on":
442
+ case "off":
443
+ case "reload": {
444
+ if (tokens.length) throw new Error(`usage: /msb ${command}`);
445
+ await ctx.waitForIdle();
446
+ const before = control.getState();
447
+ if (command === "reload") await control.reload();
448
+ 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}`);
456
+ return;
457
+ }
458
+ case "prune": {
459
+ if (tokens.length) throw new Error("usage: /msb prune");
460
+ await ctx.waitForIdle();
461
+ notify(ctx, pruneSummary(await control.pruneNow()));
462
+ return;
463
+ }
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
+ case "logs":
478
+ notify(ctx, await control.getLogs(parseTail(tokens)));
479
+ return;
480
+ case "config":
481
+ if (tokens.length) throw new Error("usage: /msb config");
482
+ // The facade's TOML representation is the redacted representation. Do not
483
+ // stringify getEffectiveConfig(): resolved secret values must not reach UI.
484
+ notify(ctx, control.getEffectiveConfigToml());
485
+ return;
486
+ case "set":
487
+ if (tokens.length !== 2) throw new Error("usage: /msb set <key> <value>");
488
+ await control.setOverride(tokens[0], parseValue(tokens[1]));
489
+ notify(ctx, `Set override ${tokens[0]}.`);
490
+ return;
491
+ case "unset":
492
+ if (tokens.length !== 1) throw new Error("usage: /msb unset <key>");
493
+ await control.unsetOverride(tokens[0]);
494
+ notify(ctx, `Unset override ${tokens[0]}.`);
495
+ return;
496
+ case "reset":
497
+ if (tokens.length) throw new Error("usage: /msb reset");
498
+ await control.resetOverrides();
499
+ notify(ctx, "Session overrides reset.");
500
+ return;
501
+ case "network":
502
+ notify(ctx, await handleNetwork(tokens, control));
503
+ return;
504
+ case "seal":
505
+ if (tokens.length) throw new Error("usage: /msb seal");
506
+ await control.setOverride("network.mode", "deny");
507
+ notify(ctx, "Network sealed (deny mode).");
508
+ return;
509
+ case "mount":
510
+ notify(ctx, await handleMount(tokens, control));
511
+ return;
512
+ default:
513
+ throw new Error(`unknown /msb command ${command}; use /msb help`);
514
+ }
515
+ }
516
+
517
+ export function createCommandHandler(control: MsbControl): CommandHandler {
518
+ return async (args, ctx) => {
519
+ try {
520
+ await executeCommand(args, ctx, control);
521
+ } catch (error) {
522
+ notify(ctx, `msb: ${redactedError(error, control)}`, "error");
523
+ }
524
+ };
525
+ }
526
+
527
+ export function registerMsbCommand(pi: ExtensionAPI, control: MsbControl): void {
528
+ pi.registerCommand("msb", {
529
+ description: "Manage pi-microsandbox and retained volumes",
530
+ handler: createCommandHandler(control),
531
+ });
532
+ }