@stixxert/pi-docker-sandbox 1.0.1 → 1.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,496 @@
1
+ /**
2
+ * pi's built-in tool operations, executed inside the sandbox.
3
+ *
4
+ * Each factory returns an implementation of pi's pluggable `*Operations`
5
+ * interface (see pi's docs/extensions.md -> "Remote Execution"). Passing one
6
+ * to `createXToolDefinition(cwd, { operations })` replaces the *execution* of
7
+ * that tool while keeping its schema, description, prompt snippet/guidelines
8
+ * and renderer inherited from the built-in — which is what keeps this
9
+ * extension free of prompt cost.
10
+ *
11
+ * Two properties of the sbx backend shape everything here:
12
+ *
13
+ * 1. **Paths are identical inside and outside.** The workspace is mounted in
14
+ * the sandbox at its host absolute path, so there is no /workspace
15
+ * translation: an absolute host path is a valid sandbox path. Ops just
16
+ * pass paths through.
17
+ *
18
+ * 2. **argv is the only reliable channel.** stdin forwarding through
19
+ * `sbx exec` is not guaranteed, and stdout is captured as text, so binary
20
+ * file contents move as base64 in argv (chunked) rather than raw bytes.
21
+ *
22
+ * Paths and content are always passed as POSITIONAL arguments to `sh -c`
23
+ * (never spliced into the script text), so a path or file body can never be
24
+ * reinterpreted as shell syntax or as an option.
25
+ */
26
+
27
+ import path from "node:path";
28
+ import {
29
+ DEFAULT_MAX_BYTES,
30
+ type GrepToolDetails,
31
+ type GrepToolInput,
32
+ truncateHead,
33
+ truncateLine,
34
+ } from "@earendil-works/pi-coding-agent";
35
+ import type {
36
+ BashOperations,
37
+ EditOperations,
38
+ FindOperations,
39
+ LsOperations,
40
+ ReadOperations,
41
+ WriteOperations,
42
+ } from "@earendil-works/pi-coding-agent";
43
+ import { type ExecOptions, type ExecOutcome, type ExecTransport, shArgs, shQuote } from "./transport.ts";
44
+
45
+ /** Files larger than this would exceed a comfortable argv budget when base64'd. */
46
+ const MAX_WRITE_BYTES = 64 * 1024 * 1024;
47
+ /** base64 characters per argv chunk (~288KB of file per call). */
48
+ const WRITE_CHUNK = 384 * 1024;
49
+ /** Default cap for a single sandbox round-trip, so a wedged CLI cannot hang a turn. */
50
+ const DEFAULT_OP_TIMEOUT = 120;
51
+ const DEFAULT_GREP_LIMIT = 100;
52
+
53
+ const IMAGE_MIME: Record<string, string> = {
54
+ ".png": "image/png",
55
+ ".jpg": "image/jpeg",
56
+ ".jpeg": "image/jpeg",
57
+ ".gif": "image/gif",
58
+ ".webp": "image/webp",
59
+ ".bmp": "image/bmp",
60
+ ".svg": "image/svg+xml",
61
+ };
62
+
63
+ function errText(r: ExecOutcome, fallback: string): string {
64
+ const text = `${r.stdout.toString("utf8")}\n${r.stderr.toString("utf8")}`.trim();
65
+ return text || fallback;
66
+ }
67
+
68
+ /**
69
+ * Run a sandbox command with a bounded timeout.
70
+ *
71
+ * NOTE: the `*Operations` interfaces other than BashOperations do not receive
72
+ * a caller `AbortSignal` (see pi's tool.d.ts), so the best available
73
+ * protection against a wedged `sbx exec` is a timeout rather than true
74
+ * cancellation. Bash — which *does* get a signal — is wired for real aborts.
75
+ */
76
+ function opOpts(extra?: ExecOptions): ExecOptions {
77
+ return { timeout: DEFAULT_OP_TIMEOUT, ...extra };
78
+ }
79
+
80
+ async function must(t: ExecTransport, argv: string[], fallback: string, opts?: ExecOptions): Promise<ExecOutcome> {
81
+ const r = await t.exec(argv, opOpts(opts));
82
+ if (r.exitCode !== 0) throw new Error(errText(r, fallback));
83
+ return r;
84
+ }
85
+
86
+ async function ok(t: ExecTransport, argv: string[]): Promise<boolean> {
87
+ try {
88
+ const r = await t.exec(argv, opOpts());
89
+ return r.exitCode === 0;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ /* ------------------------------------------------------------------ */
96
+ /* file primitives (shared by the read/write/edit/ls/grep ops) */
97
+ /* ------------------------------------------------------------------ */
98
+
99
+ async function readBytes(t: ExecTransport, filePath: string): Promise<Buffer> {
100
+ // `< file` avoids option parsing of the path entirely; `tr -d '\n'` makes the
101
+ // decode independent of the base64 line-wrapping default (GNU -w0 is not
102
+ // universal).
103
+ const r = await must(t, shArgs("base64 < \"$1\" | tr -d '\\n'", filePath), `read failed: ${filePath}`);
104
+ return Buffer.from(r.stdout.toString("utf8").replace(/\s+/g, ""), "base64");
105
+ }
106
+
107
+ async function writeBytes(t: ExecTransport, filePath: string, data: Buffer): Promise<void> {
108
+ if (data.byteLength > MAX_WRITE_BYTES) {
109
+ throw new Error(`write: ${filePath} is ${data.byteLength} bytes (max ${MAX_WRITE_BYTES})`);
110
+ }
111
+ const b64 = data.toString("base64");
112
+ const parts: string[] = [];
113
+ for (let i = 0; i < b64.length; i += WRITE_CHUNK) parts.push(b64.slice(i, i + WRITE_CHUNK));
114
+ if (parts.length === 0) parts.push("");
115
+
116
+ for (let index = 0; index < parts.length; index++) {
117
+ // First chunk truncates, the rest append — one file, many argv-sized calls.
118
+ const script = index === 0 ? 'printf %s "$1" | base64 -d > "$2"' : 'printf %s "$1" | base64 -d >> "$2"';
119
+ await must(t, shArgs(script, parts[index], filePath), `write failed: ${filePath}`);
120
+ }
121
+ }
122
+
123
+ const exists = (t: ExecTransport, p: string) => ok(t, shArgs('test -e "$1"', p));
124
+ const isDirectory = (t: ExecTransport, p: string) => ok(t, shArgs('test -d "$1"', p));
125
+ const isReadable = (t: ExecTransport, p: string) => ok(t, shArgs('test -r "$1"', p));
126
+ const isWritable = (t: ExecTransport, p: string) => ok(t, shArgs('test -w "$1"', p));
127
+
128
+ async function listDirEntries(t: ExecTransport, dir: string): Promise<Map<string, boolean>> {
129
+ // One POSIX-sh pass returns name + isDirectory for every entry, so a full
130
+ // listing costs a single round-trip instead of one per entry. (POSIX sh
131
+ // rather than `find -printf`, which busybox lacks.) The `-d` guard makes a
132
+ // non-directory an error rather than an empty listing, so callers can trust
133
+ // a successful result to mean "this really is a directory".
134
+ const script = [
135
+ '[ -d "$1" ] || exit 3',
136
+ 'for f in "$1"/* "$1"/.[!.]* "$1"/..?*; do',
137
+ ' [ -e "$f" ] || [ -L "$f" ] || continue',
138
+ " if [ -d \"$f\" ]; then printf 'd %s\\n' \"${f##*/}\"; else printf 'f %s\\n' \"${f##*/}\"; fi",
139
+ "done",
140
+ ].join("\n");
141
+ const r = await must(t, shArgs(script, dir), `readdir failed: ${dir}`);
142
+ const entries = new Map<string, boolean>();
143
+ for (const line of r.stdout.toString("utf8").split("\n")) {
144
+ if (line.length < 3 || line[1] !== " ") continue;
145
+ entries.set(line.slice(2), line[0] === "d");
146
+ }
147
+ return entries;
148
+ }
149
+
150
+ /* ------------------------------------------------------------------ */
151
+ /* operations factories */
152
+ /* ------------------------------------------------------------------ */
153
+
154
+ export function createReadOps(t: ExecTransport): ReadOperations {
155
+ return {
156
+ readFile: (filePath) => readBytes(t, filePath),
157
+ access: async (filePath) => {
158
+ if (!(await isReadable(t, filePath))) throw new Error(`not readable: ${filePath}`);
159
+ },
160
+ detectImageMimeType: async (filePath) => IMAGE_MIME[path.extname(filePath).toLowerCase()] ?? null,
161
+ };
162
+ }
163
+
164
+ export function createWriteOps(t: ExecTransport): WriteOperations {
165
+ return {
166
+ writeFile: async (filePath, content) => {
167
+ await writeBytes(t, filePath, Buffer.from(content, "utf8"));
168
+ },
169
+ mkdir: async (dirPath) => {
170
+ await must(t, shArgs('mkdir -p -- "$1"', dirPath), `mkdir failed: ${dirPath}`);
171
+ },
172
+ };
173
+ }
174
+
175
+ export function createEditOps(t: ExecTransport): EditOperations {
176
+ const read = createReadOps(t);
177
+ const write = createWriteOps(t);
178
+ return {
179
+ readFile: read.readFile,
180
+ writeFile: write.writeFile,
181
+ access: async (filePath) => {
182
+ if (!(await isReadable(t, filePath)) || !(await isWritable(t, filePath))) {
183
+ throw new Error(`not readable/writable: ${filePath}`);
184
+ }
185
+ },
186
+ };
187
+ }
188
+
189
+ export function createLsOps(t: ExecTransport): LsOperations {
190
+ // The ls tool asks for exists(), stat(dir), readdir(dir) and then stat() for
191
+ // EVERY entry. Served naively over `sbx exec` that is N+3 sandbox
192
+ // round-trips per listing; one directory listing + memoisation collapses it
193
+ // to ~3. The cache lives on this ops object, which the extension builds per
194
+ // tool execution, so it can never serve a stale listing across calls.
195
+ const listings = new Map<string, Promise<Map<string, boolean>>>();
196
+ const known = new Map<string, boolean>();
197
+
198
+ function listing(dir: string): Promise<Map<string, boolean>> {
199
+ let hit = listings.get(dir);
200
+ if (!hit) {
201
+ hit = listDirEntries(t, dir);
202
+ listings.set(dir, hit);
203
+ }
204
+ return hit;
205
+ }
206
+
207
+ return {
208
+ exists: (p) => exists(t, p),
209
+ stat: async (p) => {
210
+ // 1) already resolved, 2) a cached listing of the parent, 3) one probe.
211
+ // The parent listing is only consulted if it is already in hand —
212
+ // enumerating the parent just to answer a stat() can be far more
213
+ // expensive than a single `test -d`.
214
+ const knownHit = known.get(p);
215
+ if (knownHit !== undefined) return { isDirectory: () => knownHit };
216
+ const cached = listings.get(path.dirname(p));
217
+ if (cached) {
218
+ const entries = await cached.catch(() => undefined);
219
+ const hit = entries?.get(path.basename(p));
220
+ if (hit !== undefined) {
221
+ known.set(p, hit);
222
+ return { isDirectory: () => hit };
223
+ }
224
+ }
225
+ const dir = await isDirectory(t, p);
226
+ known.set(p, dir);
227
+ return { isDirectory: () => dir };
228
+ },
229
+ readdir: async (p) => {
230
+ const entries = await listing(p);
231
+ known.set(p, true);
232
+ return [...entries.keys()];
233
+ },
234
+ };
235
+ }
236
+
237
+ /**
238
+ * Files under `root` that a search should consider, using git's own index when
239
+ * it is available: `--cached --others --exclude-standard` is exactly "tracked
240
+ * plus untracked-but-not-ignored", so `.gitignore` is honoured precisely (as
241
+ * the built-in grep/find descriptions promise) instead of by a hand-rolled
242
+ * approximation. Returns null when git is missing or the path is not a repo,
243
+ * so the caller can fall back to a pruned walk.
244
+ *
245
+ * `safe.directory=*` is required in practice: the workspace is a mount whose
246
+ * owner need not match the sandbox user, and git otherwise refuses with
247
+ * "detected dubious ownership". Only a read-only index query runs here - no
248
+ * hook, filter or any other repo-provided code is executed.
249
+ */
250
+ async function gitSearchableFiles(t: ExecTransport, root: string): Promise<string[] | null> {
251
+ try {
252
+ const r = await t.exec(
253
+ shArgs('git -c safe.directory=\'*\' -C "$1" ls-files -z --cached --others --exclude-standard', root),
254
+ opOpts(),
255
+ );
256
+ if (r.exitCode !== 0) return null;
257
+ const files: string[] = [];
258
+ for (const relative of r.stdout.toString("utf8").split("\0")) {
259
+ if (relative) files.push(path.join(root, relative));
260
+ }
261
+ return files;
262
+ } catch {
263
+ return null;
264
+ }
265
+ }
266
+
267
+ /** Pruned-walk fallback for search enumeration when git is unavailable. */
268
+ async function walkSearchableFiles(t: ExecTransport, root: string): Promise<string[]> {
269
+ const files: string[] = [];
270
+ await walkFiles(t, root, "", async (absolute) => {
271
+ files.push(absolute);
272
+ return true;
273
+ });
274
+ return files;
275
+ }
276
+
277
+ /** Glob matching identical in spirit to the tool's: basename unless the pattern has a slash. */
278
+ function matchesToolGlob(relativePath: string, pattern: string): boolean {
279
+ const posix = relativePath.split(path.sep).join("/");
280
+ const norm = pattern.split(path.sep).join("/");
281
+ if (norm.includes("/")) {
282
+ return path.posix.matchesGlob(posix, norm) || path.posix.matchesGlob(posix, `**/${norm}`);
283
+ }
284
+ return path.posix.matchesGlob(path.posix.basename(posix), norm);
285
+ }
286
+
287
+ export function createFindOps(t: ExecTransport): FindOperations {
288
+ return {
289
+ exists: (p) => exists(t, p),
290
+ glob: async (pattern, cwd, options) => {
291
+ // Enumerate in the sandbox, match host-side: pattern semantics (basename
292
+ // vs full path, ignore list, limit) stay under our control instead of
293
+ // depending on the sandbox's fd/glob dialect. Enumeration via git means
294
+ // `.gitignore` is honoured exactly and build output (`dist/`, `.next/`,
295
+ // coverage) correctly stays out of results.
296
+ const candidates = (await gitSearchableFiles(t, cwd)) ?? (await walkSearchableFiles(t, cwd));
297
+ const results: string[] = [];
298
+ for (const absolute of candidates) {
299
+ if (results.length >= options.limit) break;
300
+ const relative = path.relative(cwd, absolute);
301
+ if (!relative || relative.startsWith("..")) continue;
302
+ if (options.ignore.some((ignored) => matchesToolGlob(relative, ignored))) continue;
303
+ if (matchesToolGlob(relative, pattern)) results.push(absolute);
304
+ }
305
+ return results;
306
+ },
307
+ };
308
+ }
309
+
310
+ function makeMatcher(pattern: string, literal: boolean | undefined, ignoreCase: boolean | undefined) {
311
+ if (literal) {
312
+ const needle = ignoreCase ? pattern.toLowerCase() : pattern;
313
+ return (line: string) => (ignoreCase ? line.toLowerCase() : line).includes(needle);
314
+ }
315
+ const regex = new RegExp(pattern, ignoreCase ? "i" : undefined);
316
+ return (line: string) => regex.test(line);
317
+ }
318
+
319
+ /** Recursively visit files under `dir`, skipping VCS/build directories. */
320
+ async function walkFiles(
321
+ t: ExecTransport,
322
+ dir: string,
323
+ relDir: string,
324
+ visit: (absolute: string, relative: string) => Promise<boolean>,
325
+ ): Promise<boolean> {
326
+ let entries: Map<string, boolean>;
327
+ try {
328
+ entries = await listDirEntries(t, dir);
329
+ } catch {
330
+ return true; // unreadable subtree: skip, like the built-in does
331
+ }
332
+ for (const [name, isDir] of entries) {
333
+ if (name === ".git" || name === "node_modules") continue;
334
+ const absolute = path.join(dir, name);
335
+ const relative = relDir ? `${relDir}/${name}` : name;
336
+ if (isDir) {
337
+ if (!(await walkFiles(t, absolute, relative, visit))) return false;
338
+ } else if (!(await visit(absolute, relative))) {
339
+ return false;
340
+ }
341
+ }
342
+ return true;
343
+ }
344
+
345
+ /**
346
+ * grep implemented entirely over the transport, so matching happens against
347
+ * sandbox content (pi's own grep tool would run host ripgrep).
348
+ */
349
+ export async function executeSandboxGrep(
350
+ t: ExecTransport,
351
+ cwd: string,
352
+ params: GrepToolInput,
353
+ ): Promise<{ content: Array<{ type: "text"; text: string }>; details: GrepToolDetails | undefined }> {
354
+ const root = params.path ? path.resolve(cwd, params.path) : cwd;
355
+ if (!(await exists(t, root))) throw new Error(`Path not found: ${params.path ?? root}`);
356
+ const rootIsDir = await isDirectory(t, root);
357
+ const matcher = makeMatcher(params.pattern, params.literal, params.ignoreCase);
358
+ const contextLines = params.context && params.context > 0 ? params.context : 0;
359
+ const limit = Math.max(1, params.limit ?? DEFAULT_GREP_LIMIT);
360
+ const output: string[] = [];
361
+ let matchCount = 0;
362
+ let limitReached = false;
363
+ let linesTruncated = false;
364
+
365
+ const visit = async (absolute: string, display: string): Promise<boolean> => {
366
+ if (params.glob && !matchesToolGlob(display, params.glob)) return true;
367
+ let content: string;
368
+ try {
369
+ content = (await readBytes(t, absolute)).toString("utf8");
370
+ } catch {
371
+ return true; // binary/unreadable file
372
+ }
373
+ const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
374
+
375
+ // Collect this file's matches first, so a line that is itself a match is
376
+ // always rendered as a match even when it also falls inside a
377
+ // neighbouring match's context window.
378
+ const budget = Math.max(1, limit - matchCount);
379
+ const matches: number[] = [];
380
+ for (let index = 0; index < lines.length; index++) {
381
+ if (!matcher(lines[index] ?? "")) continue;
382
+ matches.push(index);
383
+ if (matches.length >= budget) {
384
+ limitReached = true;
385
+ break;
386
+ }
387
+ }
388
+ if (matches.length === 0) return true;
389
+ matchCount += matches.length;
390
+
391
+ // Coalesce overlapping context windows (like ripgrep): every line is
392
+ // emitted exactly once, as a match line or as a context line.
393
+ const matchSet = new Set(matches);
394
+ let cursor = 0;
395
+ for (const index of matches) {
396
+ const start = contextLines > 0 ? Math.max(0, index - contextLines) : index;
397
+ const end = contextLines > 0 ? Math.min(lines.length - 1, index + contextLines) : index;
398
+ for (let line = Math.max(start, cursor); line <= end; line++) {
399
+ const trimmed = truncateLine((lines[line] ?? "").replace(/\r/g, ""));
400
+ if (trimmed.wasTruncated) linesTruncated = true;
401
+ const separator = matchSet.has(line) ? ":" : "-";
402
+ output.push(`${display}${separator}${line + 1}${separator} ${trimmed.text}`);
403
+ }
404
+ cursor = end + 1;
405
+ }
406
+ return !limitReached;
407
+ };
408
+
409
+ if (!rootIsDir) {
410
+ await visit(root, path.basename(root));
411
+ } else {
412
+ // git enumeration honours .gitignore exactly (as this tool's description
413
+ // promises); a pruned walk is the fallback when git is unavailable.
414
+ const gitFiles = await gitSearchableFiles(t, root);
415
+ if (gitFiles) {
416
+ for (const absolute of gitFiles) {
417
+ const display = path.relative(root, absolute).split(path.sep).join("/");
418
+ if (!(await visit(absolute, display))) break;
419
+ }
420
+ } else {
421
+ await walkFiles(t, root, "", visit);
422
+ }
423
+ }
424
+
425
+ if (matchCount === 0) return { content: [{ type: "text", text: "No matches found" }], details: undefined };
426
+
427
+ const truncation = truncateHead(output.join("\n"), { maxLines: Number.MAX_SAFE_INTEGER });
428
+ const details: GrepToolDetails = {};
429
+ const notices: string[] = [];
430
+ let text = truncation.content;
431
+
432
+ if (limitReached) {
433
+ details.matchLimitReached = limit;
434
+ notices.push(`${limit} matches limit reached`);
435
+ }
436
+ if (linesTruncated) {
437
+ details.linesTruncated = true;
438
+ notices.push("long lines truncated");
439
+ }
440
+ if (truncation.truncated) {
441
+ details.truncation = truncation;
442
+ notices.push(`${DEFAULT_MAX_BYTES} limit reached`);
443
+ }
444
+ if (notices.length > 0) text += `\n\n[${notices.join(". ")}]`;
445
+
446
+ return { content: [{ type: "text", text }], details: Object.keys(details).length > 0 ? details : undefined };
447
+ }
448
+
449
+ /* ------------------------------------------------------------------ */
450
+ /* bash */
451
+ /* ------------------------------------------------------------------ */
452
+
453
+ export interface BashOpsOptions {
454
+ /**
455
+ * Which environment variable names may be exported into the sandbox shell.
456
+ *
457
+ * SECURITY: pi's built-in `bash` tool builds the child env from the FULL
458
+ * host environment (`getShellEnv()`), so exporting `env` verbatim would
459
+ * push host API keys and tokens into the sandbox — readable by anything
460
+ * running there. Default is therefore "session metadata only" (`PI_*`),
461
+ * matching the extension's secure-by-default env policy.
462
+ */
463
+ allowEnv?: (name: string) => boolean;
464
+ }
465
+
466
+ const PI_SESSION_ENV = (name: string) => name.startsWith("PI_");
467
+
468
+ /** Export lines for the (filtered) session env pi injects. */
469
+ function exportLines(env: NodeJS.ProcessEnv | undefined, allow: (name: string) => boolean): string {
470
+ if (!env) return "";
471
+ const lines: string[] = [];
472
+ for (const [key, value] of Object.entries(env)) {
473
+ if (typeof value !== "string") continue;
474
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
475
+ // Docker-affecting vars are never exported, in any mode.
476
+ if (key === "DOCKER_HOST" || key === "DOCKER_CONTEXT" || key.startsWith("DOCKER_") || key.startsWith("COMPOSE_")) continue;
477
+ if (!allow(key)) continue;
478
+ lines.push(`export ${key}=${shQuote(value)}`);
479
+ }
480
+ return lines.join("\n");
481
+ }
482
+
483
+ export function createBashOps(t: ExecTransport, options: BashOpsOptions = {}): BashOperations {
484
+ const allow = options.allowEnv ?? PI_SESSION_ENV;
485
+ return {
486
+ exec: async (command, cwd, { onData, signal, timeout, env }) => {
487
+ // cd + env + command are joined into ONE script string; the whole
488
+ // string is a single argv entry, so nothing here is re-split.
489
+ const script = [`cd ${shQuote(cwd)} || exit 1`, exportLines(env, allow), command]
490
+ .filter(Boolean)
491
+ .join("\n");
492
+ const r = await t.exec(["sh", "-lc", script], { onData, signal, timeout });
493
+ return { exitCode: r.exitCode };
494
+ },
495
+ };
496
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "pi-extension-sbx-sandbox",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "pi": {
7
+ "extensions": [
8
+ "./index.ts"
9
+ ]
10
+ }
11
+ }