jaitus 0.3.3 → 0.3.5

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.
@@ -17,7 +17,7 @@
17
17
  import fs from 'node:fs/promises';
18
18
  import fsSync from 'node:fs';
19
19
  import path from 'node:path';
20
- import { spawn } from 'node:child_process';
20
+ import { spawn, spawnSync } from 'node:child_process';
21
21
  import { loadIgnore, IGNORE_FILE } from '../project.js';
22
22
  import { searchWeb, formatResults } from './websearch.js';
23
23
 
@@ -32,6 +32,91 @@ function truncate(text, limit = MAX_OUTPUT) {
32
32
  return `${s.slice(0, limit)}\n\n[truncated — ${s.length - limit} more characters]`;
33
33
  }
34
34
 
35
+ // ── Processes ──
36
+ const CAPTURE_MAX = 2 * 1024 * 1024;
37
+
38
+ /** Collect a stream's bytes up to CAPTURE_MAX; .text() decodes them once. */
39
+ export function captureStream(stream) {
40
+ const chunks = [];
41
+ let size = 0;
42
+ let dropped = 0;
43
+ stream?.on('data', (d) => {
44
+ if (size >= CAPTURE_MAX) { dropped += d.length; return; }
45
+ const take = d.subarray(0, CAPTURE_MAX - size);
46
+ chunks.push(take);
47
+ size += take.length;
48
+ dropped += d.length - take.length;
49
+ });
50
+ return {
51
+ text() {
52
+ const s = new TextDecoder('utf-8').decode(Buffer.concat(chunks));
53
+ return dropped ? `${s}\n[… ${Math.round(dropped / 1024)} KB more output not kept]` : s;
54
+ },
55
+ };
56
+ }
57
+
58
+ /** Kill a child and everything it started. */
59
+ export function killTree(child) {
60
+ if (!child?.pid) return;
61
+ try {
62
+ if (process.platform === 'win32') {
63
+ spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
64
+ } else {
65
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); }
66
+ }
67
+ } catch { /* already gone */ }
68
+ }
69
+
70
+ // ── Text files as they really are on disk ──
71
+ const READ_MAX_BYTES = 16 * 1024 * 1024;
72
+
73
+ async function readHead(file, bytes) {
74
+ const handle = await fs.open(file, 'r');
75
+ try {
76
+ const buf = Buffer.alloc(bytes);
77
+ const { bytesRead } = await handle.read(buf, 0, bytes, 0);
78
+ return buf.subarray(0, bytesRead);
79
+ } finally { await handle.close(); }
80
+ }
81
+
82
+ /** A NUL in the first 8 KB means binary — except UTF-16, which is full of them. */
83
+ export function looksBinary(buf) {
84
+ if (!buf?.length) return false;
85
+ if ((buf[0] === 0xff && buf[1] === 0xfe) || (buf[0] === 0xfe && buf[1] === 0xff)) return false;
86
+ return buf.subarray(0, 8192).includes(0);
87
+ }
88
+
89
+ /**
90
+ * Bytes → { text, eol, bom, encoding }. UTF-16 (what PowerShell 5.1 `>` writes) is
91
+ * decoded rather than returned as garbage, and a UTF-8 BOM is remembered so a rewrite
92
+ * keeps it. `eol` is the file's dominant line ending, so edits can write it back.
93
+ */
94
+ export function decodeText(buf) {
95
+ let encoding = 'utf8';
96
+ let bom = false;
97
+ let text;
98
+ if (buf[0] === 0xff && buf[1] === 0xfe) { encoding = 'utf16le'; bom = true; text = buf.subarray(2).toString('utf16le'); }
99
+ else if (buf[0] === 0xfe && buf[1] === 0xff) {
100
+ encoding = 'utf16be'; bom = true;
101
+ const swapped = Buffer.from(buf.subarray(2)); swapped.swap16();
102
+ text = swapped.toString('utf16le');
103
+ } else if (buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) { bom = true; text = buf.subarray(3).toString('utf8'); }
104
+ else text = buf.toString('utf8');
105
+ const crlf = (text.match(/\r\n/g) || []).length;
106
+ const lf = (text.match(/\n/g) || []).length - crlf;
107
+ return { text, eol: crlf > lf ? '\r\n' : '\n', bom, encoding };
108
+ }
109
+
110
+ /** Text (with \n line endings) → bytes in the style decodeText found. */
111
+ export function encodeText(text, { eol = '\n', bom = false, encoding = 'utf8' } = {}) {
112
+ let body = String(text).replace(/\r\n/g, '\n');
113
+ if (eol === '\r\n') body = body.replace(/\n/g, '\r\n');
114
+ if (encoding === 'utf16le') return Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(body, 'utf16le')]);
115
+ if (encoding === 'utf16be') { const b = Buffer.from(body, 'utf16le'); b.swap16(); return Buffer.concat([Buffer.from([0xfe, 0xff]), b]); }
116
+ const out = Buffer.from(body, 'utf8');
117
+ return bom ? Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), out]) : out;
118
+ }
119
+
35
120
  /**
36
121
  * Resolve a user/model-supplied path inside the working directory.
37
122
  * Rejects anything that escapes it, including via a symlink whose real path leads out.
@@ -52,8 +137,18 @@ function safePath(cwd, p) {
52
137
  }
53
138
  } catch (e) {
54
139
  if (e instanceof ToolError) throw e;
55
- // Does not exist yet — fine for a write, and the containment check above already
56
- // passed on the literal path.
140
+ // Does not exist yet (a new file): its nearest EXISTING parent must be inside too —
141
+ // otherwise a write through a symlinked or junctioned directory (src -> C:\Windows)
142
+ // escaped the project, because only the missing file itself was ever resolved.
143
+ let parent = path.dirname(resolved);
144
+ while (parent && !fsSync.existsSync(parent) && path.dirname(parent) !== parent) parent = path.dirname(parent);
145
+ try {
146
+ const realParent = fsSync.realpathSync(parent);
147
+ const parentRel = path.relative(fsSync.realpathSync(cwd), realParent);
148
+ if (parentRel.startsWith('..') || path.isAbsolute(parentRel)) {
149
+ throw new ToolError(`Refused: ${p} is inside a folder that links outside the working directory.`);
150
+ }
151
+ } catch (inner) { if (inner instanceof ToolError) throw inner; }
57
152
  }
58
153
  return resolved;
59
154
  }
@@ -94,22 +189,29 @@ function refuseIfIgnored(cwd, p) {
94
189
  }
95
190
  }
96
191
 
97
- async function walk(dir, cwd, out, depth, maxEntries) {
98
- if (out.length >= maxEntries || depth < 0) return;
192
+ /**
193
+ * Every path under `dir`, relative to `cwd`. `out.truncated` is set when the walk
194
+ * stopped early (entry or depth limit) — callers say so, because "no match" from a walk
195
+ * that gave up is not "no match". Dot-entries are skipped unless `dot` (a pattern that
196
+ * asks for `.github/**`); `.git` never.
197
+ */
198
+ async function walk(dir, cwd, out, depth, maxEntries, { dot = false } = {}) {
199
+ if (out.length >= maxEntries) { out.truncated = true; return; }
200
+ if (depth < 0) { out.truncated = true; return; }
99
201
  const ignore = ignoreFor(cwd);
100
202
  let entries;
101
203
  try {
102
204
  entries = await fs.readdir(dir, { withFileTypes: true });
103
205
  } catch { return; }
104
206
  for (const e of entries) {
105
- if (out.length >= maxEntries) return;
106
- if (e.name.startsWith('.') && e.name !== '.env.example') continue;
207
+ if (out.length >= maxEntries) { out.truncated = true; return; }
208
+ if (e.name.startsWith('.') && e.name !== '.env.example' && (!dot || e.name === '.git')) continue;
107
209
  const rel = posix(path.relative(cwd, path.join(dir, e.name)));
108
210
  if (ignore.matches(rel)) continue;
109
211
  if (e.isDirectory()) {
110
212
  if (IGNORED_DIRS.has(e.name)) continue;
111
213
  out.push(`${rel}/`);
112
- await walk(path.join(dir, e.name), cwd, out, depth - 1, maxEntries);
214
+ await walk(path.join(dir, e.name), cwd, out, depth - 1, maxEntries, { dot });
113
215
  } else {
114
216
  out.push(rel);
115
217
  }
@@ -138,8 +240,14 @@ export const TOOLS = [
138
240
  const stat = await fs.stat(file).catch(() => null);
139
241
  if (!stat) throw new ToolError(`No such file: ${input.path}`);
140
242
  if (stat.isDirectory()) throw new ToolError(`${input.path} is a directory — use list_files.`);
141
- const raw = await fs.readFile(file, 'utf8');
243
+ // A 200 MB log was read whole and split just to return 2000 lines — about a GB of
244
+ // memory and a frozen interface. Past READ_MAX_BYTES only the head is read.
245
+ const partial = stat.size > READ_MAX_BYTES;
246
+ const buf = partial ? await readHead(file, READ_MAX_BYTES) : await fs.readFile(file);
247
+ if (looksBinary(buf)) throw new ToolError(`${input.path} is a binary file (${Math.round(stat.size / 1024)} KB), not text. Inspect it with a command if you need to.`);
248
+ const raw = decodeText(buf).text.replace(/\r\n/g, '\n');
142
249
  const lines = raw.split('\n');
250
+ if (partial) lines.push(`[… file is ${Math.round(stat.size / 1048576)} MB; only the first ${Math.round(READ_MAX_BYTES / 1048576)} MB were read. Use search, or a command like findstr / grep, for the rest.]`);
143
251
  const start = Math.max(0, (input.offset || 1) - 1);
144
252
  const end = Math.min(lines.length, start + (input.limit || MAX_READ_LINES));
145
253
  const body = lines.slice(start, end)
@@ -168,9 +276,12 @@ export const TOOLS = [
168
276
  const file = safePath(cwd, input.path);
169
277
  refuseIfIgnored(cwd, input.path);
170
278
  await fs.mkdir(path.dirname(file), { recursive: true });
171
- const existed = await fs.stat(file).then(() => true).catch(() => false);
172
- await fs.writeFile(file, String(input.content ?? ''), 'utf8');
173
- return `${existed ? 'Replaced' : 'Created'} ${input.path}`;
279
+ const before = await fs.readFile(file).catch(() => null);
280
+ // A replaced file keeps its line endings and encoding: rewriting a CRLF file as LF
281
+ // (or a UTF-16 one as UTF-8) changes every line of it in the diff.
282
+ const style = before ? decodeText(before) : { eol: '\n', bom: false, encoding: 'utf8' };
283
+ await fs.writeFile(file, encodeText(String(input.content ?? ''), style));
284
+ return `${before ? 'Replaced' : 'Created'} ${input.path}`;
174
285
  },
175
286
  },
176
287
 
@@ -208,15 +319,26 @@ export const TOOLS = [
208
319
  async run(input, { cwd }) {
209
320
  const file = safePath(cwd, input.path);
210
321
  refuseIfIgnored(cwd, input.path);
211
- const raw = await fs.readFile(file, 'utf8').catch(() => null);
212
- if (raw === null) throw new ToolError(`No such file: ${input.path}`);
322
+ const buf = await fs.readFile(file).catch(() => null);
323
+ if (buf === null) throw new ToolError(`No such file: ${input.path}`);
324
+ if (looksBinary(buf)) throw new ToolError(`${input.path} is a binary file — it cannot be edited as text.`);
325
+ // Worked on with plain \n line endings, written back in the file's own. On Windows
326
+ // most checkouts are CRLF, the model writes \n, and every multi-line `old` failed
327
+ // to match — "That exact text is not in …" until the stuck detector stopped it.
328
+ const style = decodeText(buf);
329
+ const raw = style.text.replace(/\r\n/g, '\n');
330
+ const lf = (s) => String(s).replace(/\r\n/g, '\n');
213
331
 
214
- const edits = Array.isArray(input.edits) && input.edits.length
332
+ const edits = (Array.isArray(input.edits) && input.edits.length
215
333
  ? input.edits
216
- : [{ old: input.old, new: input.new, replace_all: input.replace_all }];
334
+ : [{ old: input.old, new: input.new, replace_all: input.replace_all }])
335
+ .map((e) => (e && typeof e.old === 'string' && typeof e.new === 'string' ? { ...e, old: lf(e.old), new: lf(e.new) } : e));
217
336
  if (!edits.length || edits.some((e) => typeof e?.old !== 'string' || typeof e?.new !== 'string')) {
218
337
  throw new ToolError('Give old and new, or a list of edits each with old and new.');
219
338
  }
339
+ if (edits.some((e) => e.old === '')) {
340
+ throw new ToolError('old is empty. To add text, include a line next to where it goes in old; to create or replace a whole file, use write_file. Nothing was changed.');
341
+ }
220
342
 
221
343
  // Applied to a working copy and written once. A partial multi-edit — three of five
222
344
  // replacements landing before the fourth fails to match — leaves a file in a state
@@ -238,11 +360,18 @@ export const TOOLS = [
238
360
  `That text appears ${occurrences} times in ${input.path}${where}. Include more surrounding context, or set replace_all. Nothing was changed.`,
239
361
  );
240
362
  }
241
- next = edit.replace_all ? next.split(edit.old).join(edit.new) : next.replace(edit.old, edit.new);
363
+ // Spliced, never String.replace with a string: that expands `$$`, `$&` and `$'`
364
+ // in the NEW text, so `echo "pid $$"` was written as `echo "pid $"` — reported
365
+ // as a successful edit.
366
+ if (edit.replace_all) next = next.split(edit.old).join(edit.new);
367
+ else {
368
+ const at = next.indexOf(edit.old);
369
+ next = next.slice(0, at) + edit.new + next.slice(at + edit.old.length);
370
+ }
242
371
  changed += occurrences;
243
372
  }
244
373
 
245
- await fs.writeFile(file, next, 'utf8');
374
+ await fs.writeFile(file, encodeText(next, style));
246
375
  const before = raw.split('\n').length;
247
376
  const after = next.split('\n').length;
248
377
  const delta = after - before;
@@ -269,13 +398,20 @@ export const TOOLS = [
269
398
  summarise: (i) => `glob ${i.pattern}`,
270
399
  async run(input, { cwd }) {
271
400
  const root = safePath(cwd, input.path || '.');
272
- const re = globToRegExp(String(input.pattern || ''));
401
+ // `src\**\*.ts` is how a Windows path is written; the pattern language is `/`.
402
+ const pattern = String(input.pattern || '').replace(/\\/g, '/').replace(/^\.\//, '');
403
+ const re = globToRegExp(pattern);
273
404
  const limit = Math.min(Math.max(Number(input.limit) || 200, 1), 2000);
274
405
  const found = [];
275
- await walk(root, cwd, found, 12, 20_000);
276
- const matched = found.filter((rel) => !rel.endsWith('/') && re.test(rel));
406
+ await walk(root, cwd, found, 12, 20_000, { dot: /(^|\/)\./.test(pattern) });
407
+ // Matched against the path from `path`, returned from the project root: with
408
+ // path "src", "*.ts" means src/*.ts. It was matched against the root-relative
409
+ // path, so glob {pattern:"*.ts", path:"src"} never matched anything.
410
+ const fromRoot = (rel) => posix(path.relative(root, path.resolve(cwd, rel)));
411
+ const matched = found.filter((rel) => !rel.endsWith('/') && re.test(fromRoot(rel)));
412
+ const cut = found.truncated ? ' The search stopped early (a very large tree) — give a narrower path.' : '';
277
413
  if (!matched.length) {
278
- return `No file matches ${input.pattern}. Note that * does not cross a / — use ** for that, as in **/*.ts.`;
414
+ return `No file matches ${input.pattern}.${cut} Note that * does not cross a / — use ** for that, as in **/*.ts.`;
279
415
  }
280
416
  // Newest first: when a pattern matches many files, the ones touched most recently
281
417
  // are almost always the ones the question is about.
@@ -305,9 +441,11 @@ export const TOOLS = [
305
441
  async run(input, { cwd }) {
306
442
  const dir = safePath(cwd, input.path || '.');
307
443
  const found = [];
308
- await walk(dir, cwd, found, input.depth ?? 2, 600);
444
+ // depth N means N levels (the walk counts from 0, so it went one deeper than asked).
445
+ await walk(dir, cwd, found, Math.max(0, (input.depth ?? 2) - 1), 600);
309
446
  if (!found.length) return 'No files found.';
310
- return truncate(found.sort().join('\n'));
447
+ const cut = found.truncated ? '\n\n[stopped at 600 entries or the depth limit — list a subdirectory, or use glob]' : '';
448
+ return truncate(found.sort().join('\n') + cut);
311
449
  },
312
450
  },
313
451
 
@@ -335,12 +473,18 @@ export const TOOLS = [
335
473
  }
336
474
  const dir = safePath(cwd, input.path || '.');
337
475
  const files = [];
338
- await walk(dir, cwd, files, 8, 4000);
476
+ await walk(dir, cwd, files, 12, 20_000);
477
+ // `glob` may be an ending (".ts") or a real pattern ("*.ts", "**/*.ts"). Only the
478
+ // ending worked, so a model passing a glob always got "No matches".
479
+ const g = String(input.glob || '').replace(/\\/g, '/');
480
+ const fileRe = g && /[*?]/.test(g) ? globToRegExp(g.includes('/') ? g : `**/${g}`) : null;
481
+ const wanted = (rel) => !g || (fileRe ? fileRe.test(rel) : rel.endsWith(g));
339
482
  const hits = [];
483
+ let capped = false;
340
484
  for (const rel of files) {
341
485
  if (rel.endsWith('/')) continue;
342
- if (input.glob && !rel.endsWith(input.glob)) continue;
343
- if (hits.length >= 200) break;
486
+ if (!wanted(rel)) continue;
487
+ if (hits.length >= 200) { capped = true; break; }
344
488
  let text;
345
489
  try {
346
490
  const stat = await fs.stat(path.join(cwd, rel));
@@ -352,7 +496,10 @@ export const TOOLS = [
352
496
  if (re.test(line)) hits.push(`${rel}:${i + 1}: ${line.trim().slice(0, 240)}`);
353
497
  });
354
498
  }
355
- return hits.length ? truncate(hits.join('\n')) : 'No matches.';
499
+ const note = capped || hits.length >= 200 ? '\n\n[first 200 matches — narrow the pattern or the path for the rest]'
500
+ : files.truncated ? '\n\n[the search stopped early (a very large tree) — some files were not searched; give a narrower path]' : '';
501
+ if (!hits.length) return files.truncated ? 'No matches in the files searched — but the search stopped early (a very large tree). Give a narrower path.' : 'No matches.';
502
+ return truncate(hits.join('\n') + note);
356
503
  },
357
504
  },
358
505
 
@@ -370,10 +517,11 @@ export const TOOLS = [
370
517
  required: ['command'],
371
518
  },
372
519
  summarise: (i) => i.command,
373
- run(input, { cwd }) {
520
+ run(input, { cwd, signal = null }) {
374
521
  const command = String(input.command || '').trim();
375
522
  if (!command) throw new ToolError('No command given.');
376
523
  const timeout = Math.min(Math.max(Number(input.timeout_ms) || 120_000, 1000), 600_000);
524
+ if (signal?.aborted) return Promise.resolve('Not run: stopped by the user.');
377
525
 
378
526
  return new Promise((resolve) => {
379
527
  // `shell: true` rather than spawning cmd.exe with an argument array.
@@ -390,30 +538,56 @@ export const TOOLS = [
390
538
  // No TTY: anything that would prompt gets EOF and exits instead of hanging
391
539
  // forever with nobody able to type into it.
392
540
  stdio: ['ignore', 'pipe', 'pipe'],
541
+ windowsHide: true,
542
+ // Its own process group on POSIX, so the whole tree can be killed at once.
543
+ detached: process.platform !== 'win32',
393
544
  });
394
545
 
395
- let stdout = '';
396
- let stderr = '';
397
- child.stdout.on('data', (d) => { stdout += d; });
398
- child.stderr.on('data', (d) => { stderr += d; });
546
+ // Bytes, decoded once at the end: decoding chunk by chunk split multi-byte
547
+ // characters across chunks into garbage. Capped: `type huge.log` grew memory
548
+ // until a RangeError crashed the process.
549
+ const out = captureStream(child.stdout);
550
+ const err = captureStream(child.stderr);
551
+ let note = '';
552
+ let settled = false;
553
+
554
+ const finish = (code) => {
555
+ if (settled) return;
556
+ settled = true;
557
+ clearTimeout(timer);
558
+ signal?.removeEventListener?.('abort', onAbort);
559
+ // Each stream is cut on its own and the exit code always survives: truncating
560
+ // the joined text cut the exit code off the end of any long output, so the
561
+ // model could not tell whether the command had worked.
562
+ const parts = [];
563
+ const stdout = truncate(out.text().trim(), 40_000);
564
+ const stderr = truncate(err.text().trim(), 16_000);
565
+ if (stdout) parts.push(stdout);
566
+ if (stderr) parts.push(`stderr:\n${stderr}`);
567
+ if (note) parts.push(note);
568
+ parts.push(`exit code: ${code ?? 'none (killed)'}`);
569
+ resolve(parts.join('\n\n'));
570
+ };
399
571
 
400
- const timer = setTimeout(() => {
401
- child.kill('SIGKILL');
402
- stderr += `\n[killed after ${timeout}ms]`;
403
- }, timeout);
572
+ // Killing the shell alone left what it started running — on Windows the node or
573
+ // npm grandchild kept the pipes open, `close` never came, and the tool never
574
+ // returned: timeouts did nothing and Esc could not stop a command.
575
+ const stop = (why) => { note = why; killTree(child); setTimeout(() => finish(null), 1500).unref?.(); };
576
+ const timer = setTimeout(() => stop(`[stopped after ${Math.round(timeout / 1000)}s — the time limit for this command]`), timeout);
577
+ const onAbort = () => stop('[stopped by the user]');
578
+ signal?.addEventListener?.('abort', onAbort, { once: true });
404
579
 
405
580
  child.on('error', (e) => {
581
+ if (settled) return;
582
+ settled = true;
406
583
  clearTimeout(timer);
407
584
  resolve(`Could not run the command: ${e.message}`);
408
585
  });
409
- child.on('close', (code) => {
410
- clearTimeout(timer);
411
- const parts = [];
412
- if (stdout.trim()) parts.push(stdout.trim());
413
- if (stderr.trim()) parts.push(`stderr:\n${stderr.trim()}`);
414
- parts.push(`exit code: ${code}`);
415
- resolve(truncate(parts.join('\n\n')));
416
- });
586
+ // `exit` is the command finishing; `close` also waits for every process holding
587
+ // its output. A background child can hold that forever — give it a moment, then
588
+ // report what the command itself did.
589
+ child.on('exit', (code) => { setTimeout(() => finish(code), 400).unref?.(); });
590
+ child.on('close', (code) => finish(code));
417
591
  });
418
592
  },
419
593
  },
@@ -451,7 +625,10 @@ export const TOOLS = [
451
625
  name: 'web_fetch',
452
626
  description:
453
627
  'Fetch a web page and return its readable text with scripts, styles and navigation stripped. For documentation, GitHub files (use raw.githubusercontent.com), API references, and any URL that came from web_search. Prefer this over run_command + curl — it is faster and returns clean text.',
454
- needsPermission: false,
628
+ // Asks, per site ("always" covers that host). Reading never asks and fetching did
629
+ // not either: read .env, then fetch attacker.example/?d=... went through silently.
630
+ needsPermission: true,
631
+ network: true,
455
632
  input_schema: {
456
633
  type: 'object',
457
634
  properties: {
@@ -461,9 +638,10 @@ export const TOOLS = [
461
638
  required: ['url'],
462
639
  },
463
640
  summarise: (i) => `fetch ${String(i.url).slice(0, 80)}`,
464
- async run(input) {
641
+ async run(input, { signal = null } = {}) {
465
642
  const raw = String(input.url || '').trim();
466
643
  if (!/^https?:\/\//i.test(raw)) throw new ToolError('Give a full http(s) URL.');
644
+ await refusePrivateTarget(raw);
467
645
  const limit = Math.min(Math.max(Number(input.max_chars) || 20_000, 1000), 50_000);
468
646
 
469
647
  const cached = fetchCache.get(raw);
@@ -480,7 +658,7 @@ export const TOOLS = [
480
658
  'accept-language': 'en-US,en;q=0.9',
481
659
  },
482
660
  redirect: 'follow',
483
- signal: AbortSignal.timeout(20_000),
661
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(20_000)]) : AbortSignal.timeout(20_000),
484
662
  });
485
663
  } catch (e) {
486
664
  throw new ToolError(explainFetchFailure(e, raw));
@@ -502,11 +680,14 @@ export const TOOLS = [
502
680
  throw new ToolError(`That URL is ${ctype.split(';')[0]}, not a document. Use read_image for a local image file.`);
503
681
  }
504
682
 
683
+ // Read up to FETCH_MAX_BYTES: res.text() had no limit, so a 20-second download could
684
+ // be hundreds of MB in memory.
685
+ const body = await readCapped(res, FETCH_MAX_BYTES);
505
686
  let text;
506
687
  if (/json|xml|csv|javascript|text\/plain|markdown/i.test(ctype)) {
507
- text = await res.text();
688
+ text = body;
508
689
  } else {
509
- text = readableText(await res.text());
690
+ text = readableText(body);
510
691
  if (!text.trim()) {
511
692
  throw new ToolError(
512
693
  'The page returned no readable text — it is almost certainly rendered by JavaScript. '
@@ -691,6 +872,41 @@ export async function readImageForModel(cwd, p) {
691
872
  return block;
692
873
  }
693
874
 
875
+ const FETCH_MAX_BYTES = 5 * 1024 * 1024;
876
+
877
+ async function readCapped(res, max) {
878
+ if (!res.body?.getReader) return (await res.text()).slice(0, max);
879
+ const reader = res.body.getReader();
880
+ const chunks = [];
881
+ let size = 0;
882
+ for (;;) {
883
+ const { done, value } = await reader.read();
884
+ if (done) break;
885
+ chunks.push(Buffer.from(value));
886
+ size += value.length;
887
+ if (size >= max) { try { await reader.cancel(); } catch { /* closed */ } break; }
888
+ }
889
+ return new TextDecoder('utf-8').decode(Buffer.concat(chunks).subarray(0, max));
890
+ }
891
+
892
+ /**
893
+ * The cloud metadata endpoint and the link-local range: never. A fetched page or file
894
+ * with injected instructions could otherwise have the agent read an instance's
895
+ * credentials (169.254.169.254) and send them on. Localhost and the LAN stay reachable —
896
+ * a dev server is a normal thing to check — and every fetch asks first.
897
+ */
898
+ async function refusePrivateTarget(raw) {
899
+ let host = '';
900
+ try { host = new URL(raw).hostname.replace(/^\[|\]$/g, ''); } catch { throw new ToolError('That is not a valid URL.'); }
901
+ const bad = (ip) => /^169\.254\./.test(ip) || /^fe80:/i.test(ip) || ip === '100.100.100.200' || /^fd00:ec2::254$/i.test(ip);
902
+ if (bad(host) || /^metadata(\.google\.internal)?$/i.test(host)) throw new ToolError('Cloud metadata and link-local addresses are blocked for web_fetch.');
903
+ try {
904
+ const { lookup } = await import('node:dns/promises');
905
+ const addrs = await lookup(host, { all: true });
906
+ if (addrs.some((a) => bad(a.address))) throw new ToolError('That host resolves to a link-local / metadata address, which is blocked for web_fetch.');
907
+ } catch (e) { if (e instanceof ToolError) throw e; /* a resolution error is reported by fetch itself */ }
908
+ }
909
+
694
910
  const BROWSER_HEADERS = {
695
911
  'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
696
912
  'accept': 'text/html',
package/src/background.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // with background_read; /tasks lists jobs with attach/stop. `! cmd &` and
6
6
  // Ctrl+B start one from the prompt; background_run does it from the model.
7
7
 
8
- import { spawn } from 'node:child_process';
8
+ import { spawn, spawnSync } from 'node:child_process';
9
9
  import fs from 'node:fs';
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
@@ -36,6 +36,7 @@ export function startBackground(command, cwd) {
36
36
  child.on('close', (code) => {
37
37
  job.exit = code ?? 'signal';
38
38
  try { fs.appendFileSync(log, `\n[exit ${job.exit}]\n`); } catch { /* log gone */ }
39
+ try { fs.closeSync(out); } catch { /* already closed */ }
39
40
  });
40
41
  try { child.unref(); } catch { /* stay attached */ }
41
42
  job.child = child;
@@ -61,9 +62,19 @@ function findJob(id) {
61
62
  export function readBackground(id, tail = 40) {
62
63
  const job = findJob(id);
63
64
  if (!job) return null;
65
+ // Only the end of the log: a dev server's log grows for hours, and reading all of it on
66
+ // every poll put the whole file in memory each time.
64
67
  let lines = [];
65
68
  try {
66
- lines = fs.readFileSync(job.log, 'utf8').split('\n');
69
+ const size = fs.statSync(job.log).size;
70
+ const from = Math.max(0, size - 64 * 1024);
71
+ const fd = fs.openSync(job.log, 'r');
72
+ try {
73
+ const buf = Buffer.alloc(size - from);
74
+ fs.readSync(fd, buf, 0, buf.length, from);
75
+ lines = buf.toString('utf8').split('\n');
76
+ if (from > 0) lines.shift();
77
+ } finally { fs.closeSync(fd); }
67
78
  } catch { /* empty so far */ }
68
79
  return {
69
80
  id: job.id, command: job.command, running: job.exit === null, exit: job.exit,
@@ -75,11 +86,27 @@ export function stopBackground(id) {
75
86
  const job = findJob(id);
76
87
  if (!job) return false;
77
88
  if (job.exit !== null) return true;
89
+ killJob(job);
90
+ return true;
91
+ }
92
+
93
+ // The whole tree. On Windows child.kill() ended only cmd.exe: the dev server it started
94
+ // kept running and holding its port while the tool said "Stopped".
95
+ function killJob(job) {
96
+ const pid = job.child?.pid;
97
+ if (!pid) return;
78
98
  try {
79
- job.child?.kill();
80
- if (job.child?.pid && process.platform !== 'win32') {
81
- try { process.kill(-job.child.pid); } catch { /* already gone */ }
82
- }
99
+ if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
100
+ else { try { process.kill(-pid, 'SIGTERM'); } catch { job.child.kill('SIGTERM'); } }
83
101
  } catch { /* already gone */ }
84
- return true;
85
102
  }
103
+
104
+ /**
105
+ * Stop every job still running. Called when Jaitus exits: jobs used to outlive it, and
106
+ * after a restart nothing could see or stop them — a dev server holding a port with no
107
+ * way back to it.
108
+ */
109
+ export function stopAllBackground() {
110
+ for (const job of jobs.values()) if (job.exit === null) killJob(job);
111
+ }
112
+ process.once('exit', stopAllBackground);