@nexrall/code-core 1.3.0 → 1.4.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.
@@ -43,13 +43,25 @@ const dns = __importStar(require("dns"));
43
43
  const child_process_1 = require("child_process");
44
44
  const sandbox_1 = require("./sandbox");
45
45
  const auth_1 = require("../auth");
46
+ const editCompleteness_1 = require("../agent/editCompleteness");
47
+ const crossFile_1 = require("../agent/crossFile");
46
48
  const client_1 = require("../api/client");
47
49
  const symbols_1 = require("./symbols");
50
+ const tsLangService_1 = require("./tsLangService");
48
51
  // ─── Constants ────────────────────────────────────────────────────────────────
49
52
  const DEFAULT_TIMEOUT_MS = 60000;
50
53
  const MAX_FETCH_BYTES = 200 * 1024; // 200 KB
51
54
  const MAX_READ_BYTES = 500 * 1024; // 500 KB
52
55
  const MAX_OUTPUT_CHARS = 100000; // bash / grep output cap (~100 KB)
56
+ // GAP B — output spillover. The inline bash result keeps only HEAD+TAIL (~100 KB),
57
+ // which loses the MIDDLE of a large log — often exactly where a stack trace's root
58
+ // cause or a failing assertion lives. To make the full log recoverable WITHOUT
59
+ // bloating the model context, we stream the complete stdout+stderr to a temp file
60
+ // (bounded by MAX_SPILL_BYTES) and tell the model it can `read_file` that path with
61
+ // offset/limit to inspect any section. This is the key long-debug capability: the
62
+ // agent decides which slice of a 5 MB log it needs, instead of us guessing.
63
+ const MAX_SPILL_BYTES = 20 * 1024 * 1024; // 20 MB cap on a single spill file
64
+ const SPILL_DIR = path.join(os.tmpdir(), 'nexrall-code', 'bash-output');
53
65
  // ─── Blocked commands (safety) ────────────────────────────────────────────────
54
66
  // Regexes are tested against the LOWERCASED command so case variants and minor
55
67
  // spacing differences (double spaces, no-space pipes) are all caught.
@@ -59,10 +71,15 @@ const MAX_OUTPUT_CHARS = 100000; // bash / grep output cap (~100 KB)
59
71
  // hidden in a fake heredoc/quote (e.g. `echo "<<EOF"\nrm -rf /\nEOF`) is still
60
72
  // caught.
61
73
  const BLOCKED_REGEXES = [
62
- // Filesystem destruction — match rm/chmod with any whitespace between flags
63
- /rm\s+-rf\s+\//, // rm -rf /… (any path under /)
64
- /rm\s+-rf\s+~/, // rm -rf ~
65
- /rm\s+-rf\s+\*/, // rm -rf *
74
+ // Filesystem destruction — match rm/chmod with any whitespace between flags.
75
+ // Only root itself and BARE top-level system dirs are blocked; ordinary
76
+ // absolute subpaths (/tmp/x, /var/folders/x, /Users/me/proj/node_modules)
77
+ // must stay allowed — the old /rm\s+-rf\s+\// matched every absolute path.
78
+ // `-[a-z]*(?:rf|fr)[a-z]*` accepts -rf, -fr, -rfv, -vrf … (combined flag).
79
+ /rm\s+-[a-z]*(?:rf|fr)[a-z]*\s+\/(?:\*|\s|;|&|\||$)/, // rm -rf / · / * · root, bare
80
+ /rm\s+-[a-z]*(?:rf|fr)[a-z]*\s+\/(?:bin|sbin|etc|usr|var|lib|lib64|boot|sys|proc|dev|root|home|system|library|applications|opt|users)(?:\/)?(?:\*|\s|;|&|\||$)/, // rm -rf /etc … bare system dir
81
+ /rm\s+-[a-z]*(?:rf|fr)[a-z]*\s+~(?:\/)?(?:\*|\s|;|&|\||$)/, // rm -rf ~ · ~/ · ~/*
82
+ /rm\s+-[a-z]*(?:rf|fr)[a-z]*\s+\*/, // rm -rf *
66
83
  /rm\s+--no-preserve-root/,
67
84
  /chmod\s+-[rr]\s+777\s+\//,
68
85
  /chown\s+-[rr]/,
@@ -199,6 +216,59 @@ function isBinaryFile(filePath) {
199
216
  return false;
200
217
  }
201
218
  }
219
+ /**
220
+ * Read a bounded line window [offset, offset+limit) from a file too large to load
221
+ * whole, by streaming it in chunks and keeping only the requested lines. Memory is
222
+ * bounded by (limit lines actually kept) + one chunk, never the whole file — so a
223
+ * 20MB bash-output spill file can be inspected section-by-section. (GAP B.)
224
+ */
225
+ async function readLargeFileWindow(resolved, offset, limit, kb) {
226
+ return new Promise((resolve) => {
227
+ const endLine = offset + limit; // exclusive, 0-based
228
+ const kept = [];
229
+ let lineNo = 0; // 0-based index of the NEXT line to be completed
230
+ let carry = ''; // partial line spanning chunk boundaries
231
+ let stopped = false;
232
+ const stream = fs.createReadStream(resolved, { encoding: 'utf-8', highWaterMark: 256 * 1024 });
233
+ const finish = () => {
234
+ if (stopped)
235
+ return;
236
+ stopped = true;
237
+ stream.destroy();
238
+ const first = offset + 1;
239
+ const last = offset + kept.length;
240
+ const numbered = kept.map((l, i) => `${String(offset + i + 1).padStart(4, ' ')}\t${l}`).join('\n');
241
+ const note = kept.length < limit
242
+ ? ` (reached end of file at line ${last})`
243
+ : ` (more lines follow — increase offset to continue)`;
244
+ resolve({ output: `[File: ${resolved} — ${kb} KB, showing lines ${first}-${last}${note}]\n${numbered}` });
245
+ };
246
+ stream.on('data', (chunk) => {
247
+ const text = carry + (typeof chunk === 'string' ? chunk : chunk.toString('utf-8'));
248
+ const lines = text.split('\n');
249
+ carry = lines.pop() ?? ''; // last element is an incomplete line (or '')
250
+ for (const line of lines) {
251
+ if (lineNo >= offset && lineNo < endLine)
252
+ kept.push(line);
253
+ lineNo++;
254
+ if (lineNo >= endLine) {
255
+ finish();
256
+ return;
257
+ }
258
+ }
259
+ });
260
+ stream.on('end', () => {
261
+ // Flush the final carry (file not ending in newline) if it's in range.
262
+ if (!stopped && carry !== '' && lineNo >= offset && lineNo < endLine)
263
+ kept.push(carry);
264
+ finish();
265
+ });
266
+ stream.on('error', (err) => { if (!stopped) {
267
+ stopped = true;
268
+ resolve({ error: err.message });
269
+ } });
270
+ });
271
+ }
202
272
  async function readFile(input, workDir) {
203
273
  const filePath = typeof input.path === 'string' ? input.path : '';
204
274
  const offset = typeof input.offset === 'number' ? Math.max(0, Math.floor(input.offset)) : 0;
@@ -208,17 +278,22 @@ async function readFile(input, workDir) {
208
278
  try {
209
279
  const resolved = resolvePath(filePath, workDir);
210
280
  const stat = fs.statSync(resolved);
211
- // Always enforce size cap — even with offset/limit we still readFileSync the whole
212
- // file before slicing, so a 500 MB file would OOM regardless of the slice range.
213
- if (stat.size > MAX_READ_BYTES) {
214
- const kb = (stat.size / 1024).toFixed(0);
215
- return { error: `File too large (${kb} KB, max ${MAX_READ_BYTES / 1024} KB). Use offset+limit to read sections, or search_files to locate the relevant part first.` };
216
- }
217
281
  // Reject binary files early — reading them as UTF-8 produces garbage.
218
282
  if (isBinaryFile(resolved)) {
219
283
  const ext = path.extname(resolved).toLowerCase();
220
284
  return { error: `Cannot read binary file: ${resolved} (${ext || 'no extension'}). Use a text-based tool or convert it first.` };
221
285
  }
286
+ // Large-file path: when the file exceeds the in-memory cap, a full readFileSync
287
+ // would OOM — but we must still be able to inspect SECTIONS (this is what makes a
288
+ // 20MB bash-output spill file usable, GAP B). If offset+limit are given we stream
289
+ // line-by-line and materialise only the requested window; otherwise we require it.
290
+ if (stat.size > MAX_READ_BYTES) {
291
+ const kb = (stat.size / 1024).toFixed(0);
292
+ if (limit <= 0) {
293
+ return { error: `File too large (${kb} KB, max ${MAX_READ_BYTES / 1024} KB). Pass offset + limit to read a section (e.g. {offset:0, limit:500}), or search_files to locate the relevant part first.` };
294
+ }
295
+ return await readLargeFileWindow(resolved, offset, limit, kb);
296
+ }
222
297
  const content = fs.readFileSync(resolved, 'utf-8');
223
298
  const allLines = content.split('\n');
224
299
  const totalLines = allLines.length;
@@ -255,14 +330,32 @@ async function writeFile(input, workDir) {
255
330
  if (!isNew && content === '') {
256
331
  return { error: `Refusing to overwrite ${resolved} with empty content. Provide the full file content or use edit_file for targeted changes.` };
257
332
  }
333
+ // Edit-completeness guard: refuse writes that elide code with a placeholder
334
+ // ("// ... rest of the code unchanged") — a silent, irreversible data loss —
335
+ // or that still contain unresolved merge-conflict markers. Opt-out with
336
+ // NEXRALL_ALLOW_ELIDED_WRITE=1 for the rare intentional case.
337
+ if (process.env.NEXRALL_ALLOW_ELIDED_WRITE !== '1') {
338
+ const problem = (0, editCompleteness_1.checkEditCompleteness)(content, !isNew);
339
+ if (problem) {
340
+ if (problem.kind === 'elision-placeholder') {
341
+ return { error: `Refusing to write ${resolved}: ${problem.reason}.\nOffending line: ${problem.sample}\nwrite_file must contain the COMPLETE file — never a "rest unchanged" placeholder. Send the full content, or use edit_file/multi_edit for a targeted change so surrounding code is preserved.` };
342
+ }
343
+ return { error: `Refusing to write ${resolved}: ${problem.reason}.\nOffending line: ${problem.sample}\nResolve the conflict (remove <<<<<<< / ======= / >>>>>>> markers and keep the intended code) before writing.` };
344
+ }
345
+ }
258
346
  // Preserve file permissions (mode bits) when overwriting an existing file.
259
347
  // writeFileSync creates new files with umask-default mode, stripping +x etc.
260
348
  let existingMode;
349
+ let priorContent = '';
261
350
  if (!isNew) {
262
351
  try {
263
352
  existingMode = fs.statSync(resolved).mode;
264
353
  }
265
354
  catch { /* ignore */ }
355
+ try {
356
+ priorContent = fs.readFileSync(resolved, 'utf-8');
357
+ }
358
+ catch { /* ignore */ }
266
359
  }
267
360
  fs.writeFileSync(resolved, content, 'utf-8');
268
361
  if (existingMode !== undefined) {
@@ -276,7 +369,8 @@ async function writeFile(input, workDir) {
276
369
  if (isNew) {
277
370
  return { output: `Created ${resolved} (${lines} lines, ${bytes} bytes)` };
278
371
  }
279
- return { output: `Overwrote ${resolved} (${lines} lines, ${bytes} bytes)` };
372
+ const xfile = crossFileBreakageWarning(resolved, normalizeLF(priorContent), normalizeLF(content), workDir ?? process.cwd());
373
+ return { output: `Overwrote ${resolved} (${lines} lines, ${bytes} bytes)${xfile}` };
280
374
  }
281
375
  catch (err) {
282
376
  return { error: err.message };
@@ -335,11 +429,44 @@ function startBackgroundShell(command, displayCommand, workDir, note) {
335
429
  const shell = {
336
430
  id, command: displayCommand, child, output: '', readCursor: 0, truncated: false,
337
431
  status: 'running', exitCode: null,
432
+ spillPath: null, spillFd: null, spillBytes: 0, spillFull: false,
338
433
  };
339
434
  // Rolling buffer: keep the LAST MAX_OUTPUT_CHARS instead of the first, so a
340
435
  // long-lived watcher/dev-server's recent output (the useful part) is never
341
- // permanently lost behind an early cap.
436
+ // permanently lost behind an early cap. In parallel, once output exceeds the
437
+ // inline cap we mirror the FULL stream to a bounded spill file (GAP B) so the
438
+ // discarded earlier output is still recoverable via read_file.
342
439
  const append = (chunk) => {
440
+ if (shell.spillPath || shell.output.length + chunk.length > MAX_OUTPUT_CHARS) {
441
+ if (!shell.spillPath && !shell.spillFull) {
442
+ try {
443
+ fs.mkdirSync(SPILL_DIR, { recursive: true });
444
+ shell.spillPath = path.join(SPILL_DIR, `${id}-${Date.now()}.log`);
445
+ shell.spillFd = fs.openSync(shell.spillPath, 'w');
446
+ fs.writeSync(shell.spillFd, shell.output); // backfill what we already have
447
+ }
448
+ catch {
449
+ shell.spillPath = null;
450
+ shell.spillFd = null;
451
+ }
452
+ }
453
+ if (shell.spillFd !== null && !shell.spillFull) {
454
+ const buf = Buffer.from(chunk, 'utf-8');
455
+ const room = MAX_SPILL_BYTES - shell.spillBytes;
456
+ if (room <= 0) {
457
+ shell.spillFull = true;
458
+ }
459
+ else {
460
+ try {
461
+ fs.writeSync(shell.spillFd, room >= buf.length ? buf : buf.subarray(0, room));
462
+ shell.spillBytes += Math.min(room, buf.length);
463
+ if (buf.length > room)
464
+ shell.spillFull = true;
465
+ }
466
+ catch { /* degrade to inline-only */ }
467
+ }
468
+ }
469
+ }
343
470
  shell.output += chunk;
344
471
  if (shell.output.length > MAX_OUTPUT_CHARS) {
345
472
  // Drop from the front but never behind the read cursor's logical position;
@@ -355,6 +482,13 @@ function startBackgroundShell(command, displayCommand, workDir, note) {
355
482
  child.on('close', (code) => {
356
483
  shell.status = shell.status === 'killed' ? 'killed' : 'exited';
357
484
  shell.exitCode = code;
485
+ if (shell.spillFd !== null) {
486
+ try {
487
+ fs.closeSync(shell.spillFd);
488
+ }
489
+ catch { /* ignore */ }
490
+ shell.spillFd = null;
491
+ }
358
492
  // Evict oldest exited shell once map grows past 50 to prevent session-long leak
359
493
  if (_bgShells.size > 50) {
360
494
  for (const [k, v] of _bgShells) {
@@ -382,7 +516,9 @@ async function bashOutput(input) {
382
516
  const statusLine = shell.status === 'running'
383
517
  ? '[still running]'
384
518
  : `[${shell.status}${shell.exitCode != null ? `, exit ${shell.exitCode}` : ''}]`;
385
- const trunc = shell.truncated ? '\n[output truncated at 100KB]' : '';
519
+ const trunc = shell.truncated
520
+ ? `\n[output truncated at 100KB${shell.spillPath ? `; full log at ${shell.spillPath} — read_file with offset/limit` : ''}]`
521
+ : '';
386
522
  return { output: `Shell ${id} ${statusLine}\n${fresh || '(no new output)'}${trunc}` };
387
523
  }
388
524
  async function killShell(input) {
@@ -491,8 +627,66 @@ async function bash(input, abortSignal, sandbox, workDir) {
491
627
  let totalLen = 0;
492
628
  let truncated = false;
493
629
  let settled = false;
630
+ // GAP B — full-output spill. We lazily open a temp file the FIRST time output
631
+ // exceeds the inline budget and stream everything to it (bounded), so the
632
+ // complete log survives even though the inline result is HEAD+TAIL only. The
633
+ // model is told the path and can read_file it with offset/limit.
634
+ let spillPath = null;
635
+ let spillFd = null;
636
+ let spillBytes = 0;
637
+ let spillFull = false;
638
+ const openSpill = () => {
639
+ if (spillPath || spillFull)
640
+ return;
641
+ try {
642
+ fs.mkdirSync(SPILL_DIR, { recursive: true });
643
+ spillPath = path.join(SPILL_DIR, `bash-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.log`);
644
+ spillFd = fs.openSync(spillPath, 'w');
645
+ }
646
+ catch {
647
+ spillPath = null;
648
+ spillFd = null;
649
+ } // spill is best-effort; never break the command
650
+ };
651
+ const writeSpill = (chunk) => {
652
+ if (spillFull || spillFd === null)
653
+ return;
654
+ const buf = Buffer.from(chunk, 'utf-8');
655
+ const room = MAX_SPILL_BYTES - spillBytes;
656
+ if (room <= 0) {
657
+ spillFull = true;
658
+ return;
659
+ }
660
+ try {
661
+ fs.writeSync(spillFd, room >= buf.length ? buf : buf.subarray(0, room));
662
+ spillBytes += Math.min(room, buf.length);
663
+ if (buf.length > room)
664
+ spillFull = true;
665
+ }
666
+ catch { /* disk full / closed — degrade to inline-only */ }
667
+ };
668
+ const closeSpill = () => {
669
+ if (spillFd !== null) {
670
+ try {
671
+ fs.closeSync(spillFd);
672
+ }
673
+ catch { /* ignore */ }
674
+ spillFd = null;
675
+ }
676
+ };
494
677
  const appendOutput = (chunk) => {
495
678
  totalLen += chunk.length;
679
+ // Mirror the full stream to the spill file once we know output is large.
680
+ if (spillPath || totalLen > MAX_OUTPUT_CHARS) {
681
+ if (!spillPath) {
682
+ openSpill();
683
+ if (spillPath) {
684
+ writeSpill(head);
685
+ writeSpill(tail);
686
+ }
687
+ }
688
+ writeSpill(chunk);
689
+ }
496
690
  if (head.length < HEAD_CHARS) {
497
691
  const room = HEAD_CHARS - head.length;
498
692
  head += chunk.slice(0, room);
@@ -508,10 +702,14 @@ async function bash(input, abortSignal, sandbox, workDir) {
508
702
  }
509
703
  };
510
704
  const collect = () => {
705
+ closeSpill();
511
706
  if (!truncated)
512
707
  return head + tail;
513
708
  const omitted = totalLen - head.length - tail.length;
514
- return `${head}\n\n[… ${omitted} chars omitted (kept first ${HEAD_CHARS / 1024}KB + last ${Math.round(TAIL_CHARS / 1024)}KB) …]\n\n${tail}`;
709
+ const spillNote = spillPath
710
+ ? ` Full output (${(totalLen / 1024).toFixed(0)}KB${spillFull ? '+, truncated at 20MB' : ''}) saved to ${spillPath} — read_file that path with offset/limit to inspect the omitted middle.`
711
+ : '';
712
+ return `${head}\n\n[… ${omitted} chars omitted (kept first ${HEAD_CHARS / 1024}KB + last ${Math.round(TAIL_CHARS / 1024)}KB).${spillNote} …]\n\n${tail}`;
515
713
  };
516
714
  const done = (code, signal, timedOut = false) => {
517
715
  if (settled)
@@ -574,6 +772,90 @@ function ripgrepAvailable() {
574
772
  }
575
773
  return _rgAvailable;
576
774
  }
775
+ // ── Cross-file breakage warning ──────────────────────────────────────────────
776
+ // After an edit removes/renames an exported symbol, scan the rest of the repo for
777
+ // surviving references. Returns a short warning string (or '' when clean). Best-
778
+ // effort, time-boxed, and never throws — a scan failure must not fail the edit.
779
+ function crossFileBreakageWarning(editedAbsPath, oldContent, newContent, workDir) {
780
+ if (process.env.NEXRALL_CROSSFILE_CHECK === '0')
781
+ return '';
782
+ let removed;
783
+ try {
784
+ removed = (0, crossFile_1.findRemovedExports)(editedAbsPath, oldContent, newContent)
785
+ .filter((s) => (0, crossFile_1.isCheckableSymbol)(s.name));
786
+ }
787
+ catch {
788
+ return '';
789
+ }
790
+ if (!removed.length)
791
+ return '';
792
+ const editedReal = (() => { try {
793
+ return fs.realpathSync(editedAbsPath);
794
+ }
795
+ catch {
796
+ return editedAbsPath;
797
+ } })();
798
+ const hits = [];
799
+ const MAX_SYMBOLS = 8;
800
+ for (const sym of removed.slice(0, MAX_SYMBOLS)) {
801
+ const refs = scanReferences(sym.name, workDir, editedReal);
802
+ if (refs.length) {
803
+ const shown = refs.slice(0, 3).map((r) => ` ${r}`).join('\n');
804
+ const more = refs.length > 3 ? `\n … and ${refs.length - 3} more` : '';
805
+ hits.push(` • ${sym.kind} "${sym.name}" (removed/renamed) still referenced in:\n${shown}${more}`);
806
+ }
807
+ }
808
+ if (!hits.length)
809
+ return '';
810
+ return (`\n\n⚠ CROSS-FILE BREAKAGE: you removed/renamed exported symbol(s) still used elsewhere.\n` +
811
+ hits.join('\n') +
812
+ `\nUpdate these call-sites (or restore the symbol), then run a build/typecheck to confirm nothing is broken.`);
813
+ }
814
+ // Find files (other than the edited one) that reference `name` as a whole word.
815
+ // Uses ripgrep when available (fast, .gitignore-aware), else a bounded grep -r.
816
+ function scanReferences(name, workDir, excludeRealPath) {
817
+ const pattern = `\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`;
818
+ const files = new Set();
819
+ try {
820
+ if (ripgrepAvailable()) {
821
+ const r = (0, child_process_1.spawnSync)('rg', ['-l', '--no-messages', '-e', pattern, '.'], {
822
+ cwd: workDir, encoding: 'utf-8', timeout: 8000, maxBuffer: 8 * 1024 * 1024,
823
+ });
824
+ if (r.stdout)
825
+ for (const line of r.stdout.split('\n')) {
826
+ if (line.trim())
827
+ files.add(line.trim());
828
+ }
829
+ }
830
+ else {
831
+ const r = (0, child_process_1.spawnSync)('grep', ['-rlI', '--exclude-dir=node_modules', '--exclude-dir=.git',
832
+ '--exclude-dir=dist', '--exclude-dir=.next', '-E', pattern, '.'], {
833
+ cwd: workDir, encoding: 'utf-8', timeout: 8000, maxBuffer: 8 * 1024 * 1024,
834
+ });
835
+ if (r.stdout)
836
+ for (const line of r.stdout.split('\n')) {
837
+ if (line.trim())
838
+ files.add(line.trim());
839
+ }
840
+ }
841
+ }
842
+ catch {
843
+ return [];
844
+ }
845
+ const rel = [];
846
+ for (const f of files) {
847
+ const abs = path.isAbsolute(f) ? f : path.join(workDir, f);
848
+ let real = abs;
849
+ try {
850
+ real = fs.realpathSync(abs);
851
+ }
852
+ catch { /* keep abs */ }
853
+ if (real === excludeRealPath)
854
+ continue; // the file we just edited
855
+ rel.push(path.relative(workDir, abs) || f);
856
+ }
857
+ return rel;
858
+ }
577
859
  async function searchFiles(input, workDir) {
578
860
  const pattern = typeof input.pattern === 'string' ? input.pattern : '';
579
861
  const searchPath = typeof input.path === 'string' ? input.path : '.';
@@ -891,7 +1173,8 @@ async function editFile(input, workDir) {
891
1173
  const diff = buildDiff(filePath, oldNorm, newNorm, origNorm);
892
1174
  const linesBefore = origNorm.split('\n').length;
893
1175
  const linesAfter = updated.split('\n').length;
894
- return { output: `Edited ${resolved} (${linesBefore} ${linesAfter} lines)\n\n${diff}` };
1176
+ const xfile = crossFileBreakageWarning(resolved, origNorm, normalizeLF(updated), workDir ?? process.cwd());
1177
+ return { output: `Edited ${resolved} (${linesBefore} → ${linesAfter} lines)\n\n${diff}${xfile}` };
895
1178
  }
896
1179
  catch (err) {
897
1180
  return { error: err.message };
@@ -1140,7 +1423,11 @@ async function fetchUrl(input, _workDir, _redirectCount = 0) {
1140
1423
  if (isHtml)
1141
1424
  body = stripHtml(body);
1142
1425
  const truncNote = truncated ? `\n\n[Truncated at ${MAX_FETCH_BYTES / 1024}KB]` : '';
1143
- resolve({ output: `HTTP ${status}\n\n${body}${truncNote}` });
1426
+ // Prompt-injection mitigation: fetched web content is fully attacker-controlled
1427
+ // (anyone can put "ignore previous instructions..." on a page). Wrap it in an
1428
+ // explicit untrusted-data delimiter so the model treats it as DATA to read, not
1429
+ // as instructions to follow — defense-in-depth alongside the system-prompt rule.
1430
+ resolve({ output: `HTTP ${status}\n\n<untrusted_web_content url="${url}">\n${body}${truncNote}\n</untrusted_web_content>` });
1144
1431
  });
1145
1432
  res.on('error', (err) => resolve({ error: err.message }));
1146
1433
  });
@@ -1197,6 +1484,7 @@ async function multiEdit(input, workDir) {
1197
1484
  // Work in LF-normalised space so CRLF files match LF old_strings
1198
1485
  const wasCRLF = rawFile.includes('\r\n');
1199
1486
  let content = normalizeLF(rawFile);
1487
+ const originalNorm = content; // captured before edits mutate `content`
1200
1488
  const diffs = [];
1201
1489
  for (let i = 0; i < edits.length; i++) {
1202
1490
  const edit = edits[i];
@@ -1223,9 +1511,10 @@ async function multiEdit(input, workDir) {
1223
1511
  // preserving the original mode bits (+x on scripts etc.).
1224
1512
  const finalContent = wasCRLF ? content.replace(/\n/g, '\r\n') : content;
1225
1513
  atomicWritePreservingMode(resolved, finalContent, pre.mode);
1514
+ const xfile = crossFileBreakageWarning(resolved, originalNorm, content, workDir ?? process.cwd());
1226
1515
  return {
1227
1516
  output: `Applied ${edits.length} edit(s) to ${resolved}:\n` +
1228
- diffs.map((d, i) => `\n--- edit #${i + 1} ---\n${d}`).join('\n'),
1517
+ diffs.map((d, i) => `\n--- edit #${i + 1} ---\n${d}`).join('\n') + xfile,
1229
1518
  };
1230
1519
  }
1231
1520
  catch (err) {
@@ -1665,7 +1954,46 @@ async function memoryRead(_input) {
1665
1954
  return { error: err.message };
1666
1955
  }
1667
1956
  }
1668
- // ─── Tool Dispatch ────────────────────────────────────────────────────────────
1957
+ function semanticPreamble(input, workDir) {
1958
+ const p = typeof input.path === 'string' ? input.path : '';
1959
+ const line = typeof input.line === 'number' ? Math.floor(input.line) : 0;
1960
+ const character = typeof input.character === 'number' ? Math.floor(input.character) : 0;
1961
+ if (!p)
1962
+ return { ok: false, err: { error: 'Missing required parameter: path' } };
1963
+ if (line < 1 || character < 1)
1964
+ return { ok: false, err: { error: 'line and character are 1-based and required' } };
1965
+ let resolved;
1966
+ try {
1967
+ resolved = resolvePath(p, workDir);
1968
+ }
1969
+ catch (e) {
1970
+ return { ok: false, err: { error: e.message } };
1971
+ }
1972
+ if (!(0, tsLangService_1.isTsLike)(resolved)) {
1973
+ return { ok: false, err: { error: `Semantic navigation currently supports TypeScript/JavaScript only. For ${path.extname(resolved) || 'this file type'} use get_symbols / get_workspace_symbols (regex-based).` } };
1974
+ }
1975
+ return { ok: true, args: { workDir: workDir ?? process.cwd(), filePath: resolved, line, character } };
1976
+ }
1977
+ const NO_TS_SERVICE = 'No TypeScript language service available (install `typescript` in the project). Use get_workspace_symbols / get_symbols instead.';
1978
+ async function goToDefinition(input, workDir) {
1979
+ const pre = semanticPreamble(input, workDir);
1980
+ if (!pre.ok)
1981
+ return pre.err;
1982
+ return (0, tsLangService_1.tsGoToDefinition)(pre.args) ?? { error: NO_TS_SERVICE };
1983
+ }
1984
+ async function findReferences(input, workDir) {
1985
+ const pre = semanticPreamble(input, workDir);
1986
+ if (!pre.ok)
1987
+ return pre.err;
1988
+ const includeDeclaration = input.include_declaration !== false;
1989
+ return (0, tsLangService_1.tsFindReferences)({ ...pre.args, includeDeclaration }) ?? { error: NO_TS_SERVICE };
1990
+ }
1991
+ async function getHover(input, workDir) {
1992
+ const pre = semanticPreamble(input, workDir);
1993
+ if (!pre.ok)
1994
+ return pre.err;
1995
+ return (0, tsLangService_1.tsGetHover)(pre.args) ?? { error: NO_TS_SERVICE };
1996
+ }
1669
1997
  const TOOL_MAP = {
1670
1998
  read_file: readFile,
1671
1999
  write_file: writeFile,
@@ -1695,6 +2023,10 @@ const TOOL_MAP = {
1695
2023
  // so this fallback only serves the CLI / headless environments.
1696
2024
  get_symbols: symbols_1.getSymbols,
1697
2025
  get_workspace_symbols: symbols_1.getWorkspaceSymbols,
2026
+ // Real semantic navigation (TS/JS) for the CLI — see GAP A above.
2027
+ go_to_definition: goToDefinition,
2028
+ find_references: findReferences,
2029
+ get_hover: getHover,
1698
2030
  };
1699
2031
  // Tools that accept a workDir for correct relative-path resolution.
1700
2032
  // TOOL_MAP stores the base signatures; executeTool injects workDir at call time.
@@ -1704,6 +2036,7 @@ const WORKDIR_TOOLS = new Set([
1704
2036
  'delete_file', 'notebook_read', 'notebook_edit',
1705
2037
  'generate_image', 'stock_photo',
1706
2038
  'get_symbols', 'get_workspace_symbols',
2039
+ 'go_to_definition', 'find_references', 'get_hover',
1707
2040
  ]);
1708
2041
  async function executeTool(name, input, abortSignal, sandbox, workDir, agentScope) {
1709
2042
  if (!TOOL_MAP[name])
@@ -0,0 +1,21 @@
1
+ import type { ToolResult } from '../types';
2
+ export declare function isTsLike(filePath: string): boolean;
3
+ export interface SemanticDeps {
4
+ workDir: string;
5
+ filePath: string;
6
+ line: number;
7
+ character: number;
8
+ }
9
+ /** go_to_definition — resolve the symbol at (line, character) to its declaration(s). */
10
+ export declare function tsGoToDefinition(deps: SemanticDeps): ToolResult | null;
11
+ /** find_references — every usage of the symbol at (line, character) across the project. */
12
+ export declare function tsFindReferences(deps: SemanticDeps & {
13
+ includeDeclaration?: boolean;
14
+ }): ToolResult | null;
15
+ /** get_hover — the inferred type + doc for the symbol at (line, character). */
16
+ export declare function tsGetHover(deps: SemanticDeps): ToolResult | null;
17
+ /** Test/diagnostic helper: is a real TS language service available for this workspace? */
18
+ export declare function tsServiceAvailable(workDir: string): boolean;
19
+ /** Reset cached services + module resolution (tests only). */
20
+ export declare function _resetTsServiceCache(): void;
21
+ //# sourceMappingURL=tsLangService.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tsLangService.d.ts","sourceRoot":"","sources":["../../src/tools/tsLangService.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAsB3C,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAElD;AAsID,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,wFAAwF;AACxF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,YAAY,GAAG,UAAU,GAAG,IAAI,CAgBtE;AAED,2FAA2F;AAC3F,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,YAAY,GAAG;IAAE,kBAAkB,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,UAAU,GAAG,IAAI,CAmBzG;AAED,+EAA+E;AAC/E,wBAAgB,UAAU,CAAC,IAAI,EAAE,YAAY,GAAG,UAAU,GAAG,IAAI,CAchE;AAED,0FAA0F;AAC1F,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAE3D;AAED,8DAA8D;AAC9D,wBAAgB,oBAAoB,IAAI,IAAI,CAI3C"}