@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.
- package/README.md +36 -0
- package/boundary.md +10 -0
- package/index.ts +53 -12
- package/package.json +7 -1
- package/sandbox/README.md +277 -0
- package/sandbox/e2e.mjs +467 -0
- package/sandbox/index.ts +229 -0
- package/sandbox/operations.ts +496 -0
- package/sandbox/package.json +11 -0
- package/sandbox/transport.ts +377 -0
- package/sandbox/try.sh +157 -0
- package/security.md +40 -1
- package/template/Dockerfile +34 -0
- package/template/README.md +92 -0
- package/template/build.sh +168 -0
- package/template/install.sh +77 -0
- package/test-loader.mjs +53 -0
|
@@ -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
|
+
}
|