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,321 @@
1
+ import type {
2
+ EditOperations,
3
+ LsOperations,
4
+ ReadOperations,
5
+ WriteOperations,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import type { SandboxTransport } from "./types.ts";
8
+
9
+ export interface FileOpsOptions {
10
+ /** The absolute project root mounted in the sandbox. */
11
+ projectRoot: string;
12
+ }
13
+
14
+ const SUPPORTED_IMAGE_MIME_TYPES = new Set([
15
+ "image/jpeg",
16
+ "image/png",
17
+ "image/gif",
18
+ "image/webp",
19
+ "image/bmp",
20
+ // `file` uses this spelling on some distributions for BMP files.
21
+ "image/x-ms-bmp",
22
+ ]);
23
+
24
+ interface CodedError extends Error {
25
+ code?: string | number;
26
+ }
27
+
28
+ function errorCode(error: unknown): string | number | undefined {
29
+ if (typeof error !== "object" || error === null) return undefined;
30
+ const value = (error as { code?: unknown }).code;
31
+ return typeof value === "string" || typeof value === "number" ? value : undefined;
32
+ }
33
+
34
+ function isNotFound(error: unknown): boolean {
35
+ const code = errorCode(error);
36
+ return code === "NOT_FOUND" || code === "ENOENT";
37
+ }
38
+
39
+ function isOutsideProjectRoot(filePath: string, projectRoot: string): boolean {
40
+ // Do not use this to rewrite paths. It is only an error-classification check.
41
+ const root = resolveLexically(projectRoot);
42
+ const candidate = resolveLexically(filePath);
43
+ if (root === "/" || candidate === root) return false;
44
+ return !candidate.startsWith(`${root}/`);
45
+ }
46
+
47
+ function resolveLexically(filePath: string): string {
48
+ if (filePath === "/") return "/";
49
+ const normalized = filePath.replace(/\\/g, "/").replace(/\/+/g, "/");
50
+ const absolute = normalized.startsWith("/") ? normalized : `/${normalized}`;
51
+ const parts: string[] = [];
52
+ for (const part of absolute.split("/")) {
53
+ if (!part || part === ".") continue;
54
+ if (part === "..") parts.pop();
55
+ else parts.push(part);
56
+ }
57
+ return `/${parts.join("/")}` || "/";
58
+ }
59
+
60
+ function sandboxPathError(filePath: string, projectRoot: string): Error {
61
+ return new Error(
62
+ `Path is not available in the sandbox: ${filePath}. ` +
63
+ `The sandbox project root is ${projectRoot}; use a path mounted inside it.`,
64
+ );
65
+ }
66
+
67
+ function withPathError<T>(
68
+ filePath: string,
69
+ options: FileOpsOptions,
70
+ operation: () => Promise<T>,
71
+ ): Promise<T> {
72
+ return operation().catch((error: unknown) => {
73
+ if (
74
+ isNotFound(error) &&
75
+ isOutsideProjectRoot(filePath, options.projectRoot)
76
+ ) {
77
+ throw sandboxPathError(filePath, options.projectRoot);
78
+ }
79
+ throw error;
80
+ });
81
+ }
82
+
83
+ function commandError(
84
+ command: string,
85
+ args: readonly string[],
86
+ exitCode: number,
87
+ stderr: Buffer,
88
+ ): CodedError {
89
+ const detail = stderr.toString("utf8").trim();
90
+ const error = new Error(
91
+ detail || `${command} exited with code ${exitCode}`,
92
+ ) as CodedError;
93
+ // 127 means the executable/shell command is missing, not that the path was
94
+ // absent. Keep that distinct so outside-root classification cannot rewrite it.
95
+ error.code = exitCode === 127 ? "COMMAND_NOT_FOUND" : "EIO";
96
+ // Keep the command in the fallback message useful without joining user input
97
+ // into a shell command. Paths are deliberately not interpreted or executed.
98
+ if (!detail && args.length === 0) error.message = `${command}: ${error.message}`;
99
+ return error;
100
+ }
101
+
102
+ async function execChecked(
103
+ transport: SandboxTransport,
104
+ command: string,
105
+ args: string[],
106
+ ): Promise<void> {
107
+ const result = await transport.exec(command, args);
108
+ if (result.exitCode !== 0) {
109
+ throw commandError(command, args, result.exitCode, result.stderr);
110
+ }
111
+ }
112
+
113
+ async function testAccess(
114
+ transport: SandboxTransport,
115
+ script: string,
116
+ filePath: string,
117
+ ): Promise<void> {
118
+ // The script is fixed; the path is a quoted positional shell argument. This
119
+ // handles spaces and leading dashes without interpolating user input.
120
+ const args = ["-c", script, "pi-msb-test", filePath];
121
+ const result = await transport.exec("sh", args);
122
+ if (result.exitCode === 0) return;
123
+
124
+ const error = commandError("sh", args, result.exitCode, result.stderr);
125
+ // `test` deliberately has a boolean exit status, so recover the useful
126
+ // missing-vs-permission distinction from the transport's filesystem probe.
127
+ // This probe is only made after a failed access check and never reads host fs.
128
+ try {
129
+ const exists = await transport.exists(filePath);
130
+ if (!exists && result.exitCode !== 127) {
131
+ error.code = "ENOENT";
132
+ if (!result.stderr.length) {
133
+ error.message = `ENOENT: no such file or directory, access '${filePath}'`;
134
+ }
135
+ } else if (exists && result.exitCode !== 127 && !result.stderr.length) {
136
+ error.code = "EACCES";
137
+ error.message = `EACCES: permission denied, access '${filePath}'`;
138
+ }
139
+ } catch {
140
+ // Keep the original command error when the diagnostic probe is unavailable.
141
+ }
142
+ throw error;
143
+ }
144
+
145
+ async function testReadable(
146
+ transport: SandboxTransport,
147
+ filePath: string,
148
+ ): Promise<void> {
149
+ await testAccess(transport, 'test -r "$1"', filePath);
150
+ }
151
+
152
+ async function testWritable(
153
+ transport: SandboxTransport,
154
+ filePath: string,
155
+ ): Promise<void> {
156
+ await testAccess(transport, 'test -w "$1"', filePath);
157
+ }
158
+
159
+ async function testReadableAndWritable(
160
+ transport: SandboxTransport,
161
+ filePath: string,
162
+ ): Promise<void> {
163
+ await testAccess(transport, 'test -r "$1" && test -w "$1"', filePath);
164
+ }
165
+
166
+ async function diagnoseFileCapability(transport: SandboxTransport): Promise<void> {
167
+ // `command` is a shell builtin on the supported images. The fixed script and
168
+ // separate argv[0] make this a capability probe, not a path-bearing shell call.
169
+ try {
170
+ await transport.exec("sh", [
171
+ "-c",
172
+ "command -v file >/dev/null 2>&1",
173
+ "pi-msb-file-capability",
174
+ ]);
175
+ } catch {
176
+ // A missing shell is itself a capability failure; image detection remains
177
+ // optional and the caller will treat the file as text.
178
+ }
179
+ }
180
+
181
+ function isMissingFileCommand(error: unknown): boolean {
182
+ const code = errorCode(error);
183
+ if (
184
+ code === "NOT_FOUND" ||
185
+ code === "ENOENT" ||
186
+ code === "COMMAND_NOT_FOUND" ||
187
+ code === 127
188
+ ) return true;
189
+ return error instanceof Error && /(?:file: command not found|file not found)/i.test(error.message);
190
+ }
191
+
192
+ async function detectImageMimeType(
193
+ transport: SandboxTransport,
194
+ filePath: string,
195
+ ): Promise<string | null> {
196
+ let result;
197
+ try {
198
+ result = await transport.exec("file", ["--mime-type", "-b", "--", filePath]);
199
+ } catch (error) {
200
+ if (!isMissingFileCommand(error)) throw error;
201
+ await diagnoseFileCapability(transport);
202
+ return null;
203
+ }
204
+
205
+ if (result.exitCode !== 0) {
206
+ if (result.exitCode === 127) await diagnoseFileCapability(transport);
207
+ return null;
208
+ }
209
+
210
+ const mimeType = result.stdout.toString("utf8").trim().toLowerCase();
211
+ if (!SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)) return null;
212
+ return mimeType === "image/x-ms-bmp" ? "image/bmp" : mimeType;
213
+ }
214
+
215
+ export function createReadOps(
216
+ transport: SandboxTransport,
217
+ options: FileOpsOptions,
218
+ ): ReadOperations {
219
+ return {
220
+ access: (absolutePath) =>
221
+ withPathError(absolutePath, options, () =>
222
+ testReadable(transport, absolutePath),
223
+ ),
224
+ readFile: (absolutePath) =>
225
+ withPathError(absolutePath, options, () => transport.readFile(absolutePath)),
226
+ detectImageMimeType: (absolutePath) =>
227
+ withPathError(absolutePath, options, () =>
228
+ detectImageMimeType(transport, absolutePath),
229
+ ),
230
+ };
231
+ }
232
+
233
+ export function createWriteOps(
234
+ transport: SandboxTransport,
235
+ options: FileOpsOptions,
236
+ ): WriteOperations {
237
+ return {
238
+ mkdir: (directory) =>
239
+ withPathError(directory, options, () =>
240
+ execChecked(transport, "mkdir", ["-p", "--", directory]),
241
+ ),
242
+ writeFile: (absolutePath, content) =>
243
+ withPathError(absolutePath, options, () =>
244
+ transport.writeFile(absolutePath, content),
245
+ ),
246
+ };
247
+ }
248
+
249
+ export function createEditOps(
250
+ transport: SandboxTransport,
251
+ options: FileOpsOptions,
252
+ ): EditOperations {
253
+ const read = createReadOps(transport, options);
254
+ const write = createWriteOps(transport, options);
255
+ return {
256
+ readFile: read.readFile,
257
+ writeFile: write.writeFile,
258
+ access: (absolutePath) =>
259
+ withPathError(absolutePath, options, async () => {
260
+ // Equivalent to `test -r path && test -w path`, with each path kept in
261
+ // its own argument and no shell interpolation.
262
+ await testReadableAndWritable(transport, absolutePath);
263
+ }),
264
+ };
265
+ }
266
+
267
+ async function isDirectory(
268
+ transport: SandboxTransport,
269
+ absolutePath: string,
270
+ ): Promise<boolean> {
271
+ const stat = await transport.stat(absolutePath);
272
+ if (stat.kind === "directory") return true;
273
+
274
+ // Some SDK stat implementations report a symlink as `other` instead of
275
+ // following it. `test -d` supplies the same follow-symlink behavior as Node's
276
+ // fs.stat used by Pi's built-in ls operation.
277
+ const args = ["-c", 'test -d "$1"', "pi-msb-test", absolutePath];
278
+ try {
279
+ const result = await transport.exec("sh", args);
280
+ // Exit 1 is the intended negative probe: missing/non-directory. Exit 127
281
+ // means the shell/test command is unavailable and must remain an error.
282
+ if (result.exitCode === 127) {
283
+ throw commandError("sh", args, result.exitCode, result.stderr);
284
+ }
285
+ return result.exitCode === 0;
286
+ } catch (error) {
287
+ // Only a typed missing-path result is an intended negative probe. Transport
288
+ // failures such as ACCESS, IO, TIMEOUT, or SANDBOX_DOWN must remain visible.
289
+ if (isNotFound(error)) return false;
290
+ throw error;
291
+ }
292
+ }
293
+
294
+ export function createLsOps(
295
+ transport: SandboxTransport,
296
+ options: FileOpsOptions,
297
+ ): LsOperations {
298
+ return {
299
+ exists: (absolutePath) =>
300
+ withPathError(absolutePath, options, async () => {
301
+ const exists = await transport.exists(absolutePath);
302
+ if (
303
+ !exists &&
304
+ isOutsideProjectRoot(absolutePath, options.projectRoot)
305
+ ) {
306
+ throw sandboxPathError(absolutePath, options.projectRoot);
307
+ }
308
+ return exists;
309
+ }),
310
+ stat: (absolutePath) =>
311
+ withPathError(absolutePath, options, async () => {
312
+ const directory = await isDirectory(transport, absolutePath);
313
+ return { isDirectory: () => directory };
314
+ }),
315
+ readdir: (absolutePath) =>
316
+ withPathError(absolutePath, options, async () => {
317
+ const entries = await transport.list(absolutePath);
318
+ return entries.map((entry) => entry.name);
319
+ }),
320
+ };
321
+ }
@@ -0,0 +1,232 @@
1
+ import {
2
+ LABEL_KEYS,
3
+ STATE_SCHEMA_VERSION,
4
+ } from "./types.ts";
5
+ import type {
6
+ LocksPort,
7
+ ManagedSandboxRecord,
8
+ PruneReport,
9
+ } from "./types.ts";
10
+
11
+ /**
12
+ * The prune adapter deliberately exposes only sandbox operations. Keeping the
13
+ * port this narrow makes retention structural: prune has no volume mutation
14
+ * capability to accidentally call.
15
+ */
16
+ export interface PrunePort {
17
+ listPage(input: { labels: Record<string, string>; cursor?: string }): Promise<{
18
+ sandboxes: ManagedSandboxRecord[];
19
+ nextCursor?: string;
20
+ }>;
21
+ stop(name: string, timeoutMs: number): Promise<void>;
22
+ remove(name: string): Promise<void>;
23
+ }
24
+
25
+ const MANAGED_LABELS: Readonly<Record<string, string>> = Object.freeze({
26
+ [LABEL_KEYS.managed]: "true",
27
+ });
28
+
29
+ const REQUIRED_LABELS = [
30
+ LABEL_KEYS.managed,
31
+ LABEL_KEYS.schema,
32
+ LABEL_KEYS.session,
33
+ LABEL_KEYS.mode,
34
+ LABEL_KEYS.cwd,
35
+ LABEL_KEYS.pid,
36
+ LABEL_KEYS.image,
37
+ LABEL_KEYS.keep,
38
+ ] as const;
39
+
40
+ function isObject(value: unknown): value is Record<string, unknown> {
41
+ return typeof value === "object" && value !== null;
42
+ }
43
+
44
+ function recordName(value: unknown): string | undefined {
45
+ if (!isObject(value) || typeof value.name !== "string" || value.name.length === 0) {
46
+ return undefined;
47
+ }
48
+ return value.name;
49
+ }
50
+
51
+ function validateRecord(value: unknown): ManagedSandboxRecord | null {
52
+ if (!isObject(value) || typeof value.name !== "string" || value.name.length === 0) {
53
+ return null;
54
+ }
55
+ if (!isObject(value.labels)) return null;
56
+ if (value.status !== undefined && typeof value.status !== "string") return null;
57
+ if (value.createdAt !== undefined && typeof value.createdAt !== "number") return null;
58
+
59
+ const labels: Record<string, string> = {};
60
+ for (const [key, label] of Object.entries(value.labels)) {
61
+ if (typeof label !== "string") return null;
62
+ labels[key] = label;
63
+ }
64
+
65
+ for (const key of REQUIRED_LABELS) {
66
+ if (typeof labels[key] !== "string" || labels[key].length === 0) return null;
67
+ }
68
+ if (labels[LABEL_KEYS.managed] !== "true") return null;
69
+ if (labels[LABEL_KEYS.schema] !== String(STATE_SCHEMA_VERSION)) return null;
70
+ if (!/^\d+$/.test(labels[LABEL_KEYS.pid])) return null;
71
+ if (!Number.isSafeInteger(Number(labels[LABEL_KEYS.pid]))) return null;
72
+ if (labels[LABEL_KEYS.keep] !== "true") return null;
73
+ if (!["git", "direct", "none"].includes(labels[LABEL_KEYS.mode])) return null;
74
+ if (labels[LABEL_KEYS.mode] === "git" && !labels[LABEL_KEYS.volume]) return null;
75
+ if (labels[LABEL_KEYS.mode] !== "git" && labels[LABEL_KEYS.volume] !== undefined) return null;
76
+
77
+ return {
78
+ name: value.name,
79
+ ...(value.status === undefined ? {} : { status: value.status }),
80
+ labels,
81
+ ...(value.createdAt === undefined ? {} : { createdAt: value.createdAt }),
82
+ };
83
+ }
84
+
85
+ function errorText(error: unknown): string {
86
+ if (error instanceof Error && error.message) return error.message;
87
+ return String(error);
88
+ }
89
+
90
+ function isRunning(record: ManagedSandboxRecord): boolean {
91
+ const status = record.status?.trim().toLowerCase();
92
+ return status === "running" || status === "started" || status === "up" || status === "active";
93
+ }
94
+
95
+ function pageFingerprint(records: ManagedSandboxRecord[]): string | undefined {
96
+ if (records.length === 0) return undefined;
97
+ return records.map((record) => record.name).sort().join("\u0000");
98
+ }
99
+
100
+ /**
101
+ * Remove only managed sandboxes whose owner lock can be acquired. This is
102
+ * intentionally best-effort: an individual SDK failure must not prevent the
103
+ * next session from pruning other orphaned sandboxes.
104
+ */
105
+ export async function pruneStale(input: {
106
+ port: PrunePort;
107
+ locks: LocksPort;
108
+ currentSessionId?: string;
109
+ stopTimeoutMs: number;
110
+ }): Promise<PruneReport> {
111
+ const report: PruneReport = {
112
+ inspected: 0,
113
+ removed: [],
114
+ kept: [],
115
+ errors: [],
116
+ };
117
+
118
+ let cursor: string | undefined;
119
+ let pageNumber = 0;
120
+ const requestedCursors = new Set<string>();
121
+ const pageFingerprints = new Set<string>();
122
+ const seenNames = new Set<string>();
123
+
124
+ while (true) {
125
+ const requestKey = cursor ?? "<first>";
126
+ if (requestedCursors.has(requestKey)) {
127
+ report.errors.push(`duplicate prune page cursor: ${requestKey}`);
128
+ break;
129
+ }
130
+ requestedCursors.add(requestKey);
131
+
132
+ let page: { sandboxes: ManagedSandboxRecord[]; nextCursor?: string };
133
+ try {
134
+ page = await input.port.listPage(
135
+ cursor === undefined
136
+ ? { labels: MANAGED_LABELS }
137
+ : { labels: MANAGED_LABELS, cursor },
138
+ );
139
+ } catch (error) {
140
+ report.errors.push(`list page ${pageNumber + 1} failed: ${errorText(error)}`);
141
+ break;
142
+ }
143
+ pageNumber += 1;
144
+
145
+ if (!isObject(page) || !Array.isArray(page.sandboxes)) {
146
+ report.errors.push(`list page ${pageNumber} returned malformed data`);
147
+ break;
148
+ }
149
+ if (page.nextCursor !== undefined && typeof page.nextCursor !== "string") {
150
+ report.errors.push(`list page ${pageNumber} returned an invalid cursor`);
151
+ break;
152
+ }
153
+
154
+ const validPage = page.sandboxes
155
+ .map((value) => validateRecord(value))
156
+ .filter((value): value is ManagedSandboxRecord => value !== null);
157
+ const fingerprint = pageFingerprint(validPage);
158
+ if (fingerprint !== undefined) {
159
+ if (pageFingerprints.has(fingerprint)) {
160
+ report.errors.push(`duplicate prune page ${pageNumber}`);
161
+ break;
162
+ }
163
+ pageFingerprints.add(fingerprint);
164
+ }
165
+
166
+ for (let index = 0; index < page.sandboxes.length; index += 1) {
167
+ const rawRecord = page.sandboxes[index];
168
+ report.inspected += 1;
169
+ const record = validateRecord(rawRecord);
170
+ if (record === null) {
171
+ const name = recordName(rawRecord);
172
+ report.errors.push(
173
+ `skipped malformed managed sandbox record${name === undefined ? "" : ` ${name}`}`,
174
+ );
175
+ continue;
176
+ }
177
+ if (seenNames.has(record.name)) continue;
178
+ seenNames.add(record.name);
179
+
180
+ const sessionId = record.labels[LABEL_KEYS.session];
181
+ if (sessionId === input.currentSessionId) {
182
+ report.kept.push(record.name);
183
+ continue;
184
+ }
185
+
186
+ let orphanLock;
187
+ try {
188
+ orphanLock = await input.locks.tryAcquire(sessionId);
189
+ } catch (error) {
190
+ report.errors.push(`${record.name}: lock check failed: ${errorText(error)}`);
191
+ continue;
192
+ }
193
+ if (orphanLock === null) {
194
+ report.kept.push(record.name);
195
+ continue;
196
+ }
197
+
198
+ try {
199
+ if (isRunning(record)) {
200
+ try {
201
+ await input.port.stop(record.name, input.stopTimeoutMs);
202
+ } catch (error) {
203
+ report.errors.push(`${record.name}: stop failed: ${errorText(error)}`);
204
+ continue;
205
+ }
206
+ }
207
+
208
+ try {
209
+ await input.port.remove(record.name);
210
+ report.removed.push(record.name);
211
+ } catch (error) {
212
+ report.errors.push(`${record.name}: remove failed: ${errorText(error)}`);
213
+ }
214
+ } finally {
215
+ try {
216
+ await orphanLock.release();
217
+ } catch (error) {
218
+ report.errors.push(`${record.name}: lock release failed: ${errorText(error)}`);
219
+ }
220
+ }
221
+ }
222
+
223
+ if (page.nextCursor === undefined) break;
224
+ if (page.nextCursor.length === 0) {
225
+ report.errors.push(`list page ${pageNumber} returned an empty cursor`);
226
+ break;
227
+ }
228
+ cursor = page.nextCursor;
229
+ }
230
+
231
+ return report;
232
+ }