@vellumai/assistant 0.10.2-dev.202606242135.63f618e → 0.10.2-dev.202606242234.c9e9e1d

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/bun.lock CHANGED
@@ -38,6 +38,7 @@
38
38
  "playwright": "1.58.2",
39
39
  "postgres": "3.4.8",
40
40
  "quickjs-emscripten": "0.32.0",
41
+ "re2js": "2.8.3",
41
42
  "rrule": "2.8.1",
42
43
  "semver": "7.8.0",
43
44
  "stemmer": "2.0.1",
@@ -1040,6 +1041,8 @@
1040
1041
 
1041
1042
  "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
1042
1043
 
1044
+ "re2js": ["re2js@2.8.3", "", {}, "sha512-lUKeXVwMqU302DM/WaOm33XF2hhzjGUZ8CbLg4zSAMpW2PPkJ2+is25AROevNiw+DytFmKU5gsQ92cB1Kxy+fA=="],
1045
+
1043
1046
  "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
1044
1047
 
1045
1048
  "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
package/openapi.yaml CHANGED
@@ -14739,6 +14739,37 @@ paths:
14739
14739
  responses:
14740
14740
  "200":
14741
14741
  description: Successful response
14742
+ /v1/internal/mcp/auth/revoke:
14743
+ post:
14744
+ operationId: internal_mcp_auth_revoke_post
14745
+ summary: Revoke MCP OAuth credentials
14746
+ description: Deletes stored OAuth tokens for an MCP server and triggers a reload.
14747
+ tags:
14748
+ - internal
14749
+ requestBody:
14750
+ required: true
14751
+ content:
14752
+ application/json:
14753
+ schema:
14754
+ type: object
14755
+ properties:
14756
+ serverId:
14757
+ type: string
14758
+ required:
14759
+ - serverId
14760
+ responses:
14761
+ "200":
14762
+ description: Successful response
14763
+ content:
14764
+ application/json:
14765
+ schema:
14766
+ type: object
14767
+ properties:
14768
+ revoked:
14769
+ type: boolean
14770
+ required:
14771
+ - revoked
14772
+ additionalProperties: false
14742
14773
  /v1/internal/mcp/auth/start:
14743
14774
  post:
14744
14775
  operationId: internal_mcp_auth_start_post
@@ -14818,6 +14849,8 @@ paths:
14818
14849
  type: boolean
14819
14850
  defaultRiskLevel:
14820
14851
  type: string
14852
+ hasOAuth:
14853
+ type: boolean
14821
14854
  allowedTools:
14822
14855
  type: array
14823
14856
  items:
@@ -14832,6 +14865,7 @@ paths:
14832
14865
  - transport
14833
14866
  - enabled
14834
14867
  - defaultRiskLevel
14868
+ - hasOAuth
14835
14869
  additionalProperties: false
14836
14870
  required:
14837
14871
  - servers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.2-dev.202606242135.63f618e",
3
+ "version": "0.10.2-dev.202606242234.c9e9e1d",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -66,6 +66,7 @@
66
66
  "playwright": "1.58.2",
67
67
  "postgres": "3.4.8",
68
68
  "quickjs-emscripten": "0.32.0",
69
+ "re2js": "2.8.3",
69
70
  "rrule": "2.8.1",
70
71
  "semver": "7.8.0",
71
72
  "stemmer": "2.0.1",
@@ -0,0 +1,585 @@
1
+ import {
2
+ chmodSync,
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ realpathSync,
8
+ rmSync,
9
+ statSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { afterEach, describe, expect, test } from "bun:test";
15
+
16
+ import { SUBAGENT_ONLY_TOOL_NAMES } from "../daemon/conversation-tool-setup.js";
17
+ import { codeSearchTool } from "../tools/filesystem/search.js";
18
+ import type { ToolContext } from "../tools/types.js";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const testDirs: string[] = [];
25
+
26
+ function makeTempDir(): string {
27
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "code-search-test-")));
28
+ testDirs.push(dir);
29
+ return dir;
30
+ }
31
+
32
+ afterEach(() => {
33
+ for (const dir of testDirs.splice(0)) {
34
+ rmSync(dir, { recursive: true, force: true });
35
+ }
36
+ });
37
+
38
+ function makeToolContext(workingDir: string): ToolContext {
39
+ return {
40
+ workingDir,
41
+ conversationId: "test-conv",
42
+ trustClass: "guardian",
43
+ };
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // code_search
48
+ // ---------------------------------------------------------------------------
49
+
50
+ describe("codeSearchTool", () => {
51
+ test("finds a known pattern with correct file:line", async () => {
52
+ const dir = makeTempDir();
53
+ writeFileSync(join(dir, "a.ts"), "const foo = 1;\nconst bar = 2;\n");
54
+
55
+ const result = await codeSearchTool.execute(
56
+ { pattern: "bar", activity: "search" },
57
+ makeToolContext(dir),
58
+ );
59
+
60
+ expect(result.isError).toBe(false);
61
+ expect(result.content).toBe("a.ts:2: const bar = 2;");
62
+ });
63
+
64
+ test("respects glob filter", async () => {
65
+ const dir = makeTempDir();
66
+ writeFileSync(join(dir, "match.ts"), "needle here\n");
67
+ writeFileSync(join(dir, "skip.md"), "needle here too\n");
68
+
69
+ const result = await codeSearchTool.execute(
70
+ { pattern: "needle", glob: "*.ts", activity: "search" },
71
+ makeToolContext(dir),
72
+ );
73
+
74
+ expect(result.isError).toBe(false);
75
+ expect(result.content).toContain("match.ts:1:");
76
+ expect(result.content).not.toContain("skip.md");
77
+ });
78
+
79
+ test("rejects a path escaping the workspace root", async () => {
80
+ const dir = makeTempDir();
81
+
82
+ const result = await codeSearchTool.execute(
83
+ { pattern: "anything", path: "../../../etc", activity: "search" },
84
+ makeToolContext(dir),
85
+ );
86
+
87
+ expect(result.isError).toBe(true);
88
+ expect(result.content).toContain("outside the working directory");
89
+ });
90
+
91
+ test("case_insensitive matching", async () => {
92
+ const dir = makeTempDir();
93
+ writeFileSync(join(dir, "a.txt"), "Hello WORLD\n");
94
+
95
+ const sensitive = await codeSearchTool.execute(
96
+ { pattern: "world", activity: "search" },
97
+ makeToolContext(dir),
98
+ );
99
+ expect(sensitive.content).toContain("No matches found");
100
+
101
+ const insensitive = await codeSearchTool.execute(
102
+ { pattern: "world", case_insensitive: true, activity: "search" },
103
+ makeToolContext(dir),
104
+ );
105
+ expect(insensitive.isError).toBe(false);
106
+ expect(insensitive.content).toBe("a.txt:1: Hello WORLD");
107
+ });
108
+
109
+ test("no match returns a clear empty result", async () => {
110
+ const dir = makeTempDir();
111
+ writeFileSync(join(dir, "a.txt"), "nothing relevant\n");
112
+
113
+ const result = await codeSearchTool.execute(
114
+ { pattern: "absent-token", activity: "search" },
115
+ makeToolContext(dir),
116
+ );
117
+
118
+ expect(result.isError).toBe(false);
119
+ expect(result.content).toContain("No matches found");
120
+ });
121
+
122
+ test("non-existent root path returns a path error, not a false 'No matches'", async () => {
123
+ const dir = makeTempDir();
124
+ writeFileSync(join(dir, "a.ts"), "needle\n");
125
+
126
+ // A typo'd subdirectory (e.g. "srcc") must surface an actionable path error
127
+ // rather than being swallowed and reported as a successful empty search.
128
+ const result = await codeSearchTool.execute(
129
+ { pattern: "needle", path: "srcc", activity: "search" },
130
+ makeToolContext(dir),
131
+ );
132
+
133
+ expect(result.isError).toBe(true);
134
+ expect(result.content).toContain("path not found");
135
+ expect(result.content).not.toContain("No matches found");
136
+ });
137
+
138
+ test("searches a single file when the root resolves to a regular file", async () => {
139
+ const dir = makeTempDir();
140
+ writeFileSync(join(dir, "a.ts"), "const foo = 1;\nconst needle = 2;\n");
141
+
142
+ const result = await codeSearchTool.execute(
143
+ { pattern: "needle", path: "a.ts", activity: "search" },
144
+ makeToolContext(dir),
145
+ );
146
+
147
+ expect(result.isError).toBe(false);
148
+ expect(result.content).toBe("a.ts:2: const needle = 2;");
149
+ });
150
+
151
+ test("an oversized explicit-file root returns an error, not a false 'No matches'", async () => {
152
+ const dir = makeTempDir();
153
+ // A single-file root larger than MAX_FILE_BYTES (8 MiB) was never searched,
154
+ // so reporting "No matches found" would be a silent false negative. Surface
155
+ // a hard error instead.
156
+ const oversize = 8 * 1024 * 1024 + 1;
157
+ writeFileSync(join(dir, "huge.txt"), "x".repeat(oversize));
158
+
159
+ const result = await codeSearchTool.execute(
160
+ { pattern: "x", path: "huge.txt", activity: "search" },
161
+ makeToolContext(dir),
162
+ );
163
+
164
+ expect(result.isError).toBe(true);
165
+ expect(result.content).toContain("file too large to search");
166
+ expect(result.content).not.toContain("No matches found");
167
+ });
168
+
169
+ test("an unreadable explicit file root surfaces a read error, not a false 'No matches'", async () => {
170
+ const dir = makeTempDir();
171
+ const f = join(dir, "secret.txt");
172
+ writeFileSync(f, "needle here\n");
173
+ chmodSync(f, 0o000);
174
+ // When the suite runs as root (e.g. CI in Docker), chmod 000 does not block
175
+ // reads, so EACCES can't be simulated — skip the assertion in that case.
176
+ let readable = true;
177
+ try {
178
+ readFileSync(f);
179
+ } catch {
180
+ readable = false;
181
+ }
182
+ if (!readable) {
183
+ const result = await codeSearchTool.execute(
184
+ { pattern: "needle", path: "secret.txt", activity: "search" },
185
+ makeToolContext(dir),
186
+ );
187
+ expect(result.isError).toBe(true);
188
+ expect(result.content).toContain("failed to read file");
189
+ expect(result.content).not.toContain("No matches found");
190
+ }
191
+ chmodSync(f, 0o644);
192
+ });
193
+
194
+ test("an unreadable explicit directory root surfaces a read error, not a false 'No matches'", async () => {
195
+ const dir = makeTempDir();
196
+ const subdir = join(dir, "locked");
197
+ mkdirSync(subdir);
198
+ writeFileSync(join(subdir, "a.ts"), "needle here\n");
199
+ chmodSync(subdir, 0o000);
200
+ // When the suite runs as root (e.g. CI in Docker), chmod 000 does not block
201
+ // reads, so EACCES can't be simulated — probe readdir and skip the assertion
202
+ // in that case.
203
+ let readable = true;
204
+ try {
205
+ readdirSync(subdir);
206
+ } catch {
207
+ readable = false;
208
+ }
209
+ if (!readable) {
210
+ const result = await codeSearchTool.execute(
211
+ { pattern: "needle", path: "locked", activity: "search" },
212
+ makeToolContext(dir),
213
+ );
214
+ expect(result.isError).toBe(true);
215
+ expect(result.content).toContain("failed to read directory");
216
+ expect(result.content).not.toContain("No matches found");
217
+ }
218
+ chmodSync(subdir, 0o755);
219
+ });
220
+
221
+ test("an oversized file skipped mid-walk keeps scanning others but flags the result incomplete", async () => {
222
+ const dir = makeTempDir();
223
+ // A directory search must keep scanning sibling files when it hits an
224
+ // oversized one (so a smaller file still matches), yet flag the overall
225
+ // result incomplete since the skipped file could have contained the pattern.
226
+ const oversize = 8 * 1024 * 1024 + 1;
227
+ writeFileSync(join(dir, "huge.txt"), "needle " + "x".repeat(oversize));
228
+ writeFileSync(join(dir, "small.txt"), "needle here\n");
229
+
230
+ const result = await codeSearchTool.execute(
231
+ { pattern: "needle", activity: "search" },
232
+ makeToolContext(dir),
233
+ );
234
+
235
+ expect(result.isError).toBe(false);
236
+ // The walk wasn't aborted by the skip — the small file still matched...
237
+ expect(result.content).toContain("small.txt");
238
+ // ...and the result is flagged incomplete with a size-limit note.
239
+ expect(result.status).toBe("truncated");
240
+ expect(result.content).toContain("size limit");
241
+ });
242
+
243
+ test("a directory whose only candidate is oversized reports incompleteness, not a clean miss", async () => {
244
+ const dir = makeTempDir();
245
+ const oversize = 8 * 1024 * 1024 + 1;
246
+ writeFileSync(join(dir, "huge.txt"), "needle " + "x".repeat(oversize));
247
+
248
+ const result = await codeSearchTool.execute(
249
+ { pattern: "needle", activity: "search" },
250
+ makeToolContext(dir),
251
+ );
252
+
253
+ expect(result.isError).toBe(false);
254
+ // Zero matches because the only file was skipped — but the result must say
255
+ // it's incomplete (status truncated + size-limit note), not a definitive miss.
256
+ expect(result.status).toBe("truncated");
257
+ expect(result.content).toContain("size limit");
258
+ });
259
+
260
+ test("a single multi-MiB matching line is found but its printed output is display-bounded", async () => {
261
+ const dir = makeTempDir();
262
+ // One matching line many megabytes wide whose file stays under MAX_FILE_BYTES
263
+ // (8 MiB) so it isn't skipped. The match is on the full line (so it's found),
264
+ // but the EMITTED line is truncated to the display cap — so a single wide
265
+ // line cannot, by itself, blow the output-byte budget anymore. The result is
266
+ // a clean single match, not truncated, with the printed line bounded.
267
+ const lineLen = 5 * 1024 * 1024;
268
+ writeFileSync(join(dir, "wide.txt"), "needle" + "z".repeat(lineLen) + "\n");
269
+
270
+ const result = await codeSearchTool.execute(
271
+ { pattern: "needle", path: "wide.txt", activity: "search" },
272
+ makeToolContext(dir),
273
+ );
274
+
275
+ expect(result.isError).toBe(false);
276
+ // Found on the full line — never a false "No matches" or scan-cap message.
277
+ expect(result.content).not.toContain("No matches found");
278
+ expect(result.content).not.toContain("scan cap");
279
+ expect(result.content).toContain("wide.txt:1:");
280
+ // The printed line is display-truncated, so the output stays tiny and the
281
+ // output budget is never hit — single match => not truncated.
282
+ expect(result.content).toContain("…[line truncated]");
283
+ expect(result.status).toBeUndefined();
284
+ expect(result.content).not.toContain("Output capped");
285
+ // The multi-MiB body never reaches the emitted output.
286
+ expect(Buffer.byteLength(result.content, "utf8")).toBeLessThan(64 * 1024);
287
+ });
288
+
289
+ test("single-file search still honors the denied-basename guard", async () => {
290
+ const dir = makeTempDir();
291
+ writeFileSync(join(dir, "backup.key"), "supersecret token\n");
292
+
293
+ const result = await codeSearchTool.execute(
294
+ { pattern: "supersecret", path: "backup.key", activity: "search" },
295
+ makeToolContext(dir),
296
+ );
297
+
298
+ // The denied basename is rejected by the sandbox policy before any read,
299
+ // so the secret never surfaces in the result.
300
+ expect(result.isError).toBe(true);
301
+ expect(result.content).not.toContain("supersecret");
302
+ });
303
+
304
+ test("max_results truncates and reports it", async () => {
305
+ const dir = makeTempDir();
306
+ const body = Array.from({ length: 10 }, () => "match").join("\n");
307
+ writeFileSync(join(dir, "a.txt"), body + "\n");
308
+
309
+ const result = await codeSearchTool.execute(
310
+ { pattern: "match", max_results: 3, activity: "search" },
311
+ makeToolContext(dir),
312
+ );
313
+
314
+ expect(result.isError).toBe(false);
315
+ expect(result.status).toBe("truncated");
316
+ expect(result.content).toContain("truncated at 3 matches");
317
+ const matchLines = result.content
318
+ .split("\n")
319
+ .filter((l) => l.startsWith("a.txt:"));
320
+ expect(matchLines.length).toBe(3);
321
+ });
322
+
323
+ test("includes context lines when requested", async () => {
324
+ const dir = makeTempDir();
325
+ writeFileSync(join(dir, "a.txt"), "before\nTARGET\nafter\n");
326
+
327
+ const result = await codeSearchTool.execute(
328
+ { pattern: "TARGET", context_lines: 1, activity: "search" },
329
+ makeToolContext(dir),
330
+ );
331
+
332
+ expect(result.isError).toBe(false);
333
+ expect(result.content).toContain("a.txt:1- before");
334
+ expect(result.content).toContain("a.txt:2: TARGET");
335
+ expect(result.content).toContain("a.txt:3- after");
336
+ });
337
+
338
+ test("matches a token past the display cap on a long line, truncating only the printed output", async () => {
339
+ const dir = makeTempDir();
340
+ // A line far longer than MAX_DISPLAY_LINE_LENGTH (2000) whose matchable token
341
+ // sits well beyond column 2000. With RE2's linear-time matching the pattern
342
+ // is run against the FULL line, so the token MUST be found — but the emitted
343
+ // line is truncated for display with a clear marker. This also exercises that
344
+ // a huge line is handled without throwing/hanging.
345
+ const prefix = "x".repeat(5000);
346
+ writeFileSync(
347
+ join(dir, "huge.txt"),
348
+ `${prefix}BEYOND_CAP_TOKEN\ninside INSIDE_CAP_TOKEN here\n`,
349
+ );
350
+
351
+ const beyond = await codeSearchTool.execute(
352
+ { pattern: "BEYOND_CAP_TOKEN", activity: "search" },
353
+ makeToolContext(dir),
354
+ );
355
+ expect(beyond.isError).toBe(false);
356
+ // The token appears past column 2000, but matching runs against the full
357
+ // line, so it IS found (correct grep behavior — no false "No matches").
358
+ expect(beyond.content).not.toContain("No matches found");
359
+ expect(beyond.content).toContain("huge.txt:1:");
360
+ // The displayed line is truncated with a marker, so the multi-kilobyte line
361
+ // never balloons the output...
362
+ expect(beyond.content).toContain("…[line truncated]");
363
+ // ...and the matched token (which sits beyond the display cap) is NOT echoed
364
+ // back in the bounded output even though the match was found.
365
+ expect(beyond.content).not.toContain("BEYOND_CAP_TOKEN\n");
366
+ expect(beyond.content.length).toBeLessThan(prefix.length);
367
+
368
+ const inside = await codeSearchTool.execute(
369
+ { pattern: "INSIDE_CAP_TOKEN", activity: "search" },
370
+ makeToolContext(dir),
371
+ );
372
+ expect(inside.isError).toBe(false);
373
+ expect(inside.content).toContain("huge.txt:2:");
374
+ // A short line is emitted verbatim — no truncation marker.
375
+ expect(inside.content).toContain("inside INSIDE_CAP_TOKEN here");
376
+ expect(inside.content).not.toContain("…[line truncated]");
377
+ });
378
+
379
+ test("caps output via the byte budget when many matches have context", async () => {
380
+ const dir = makeTempDir();
381
+ // Many matches with context lines. Even though context_lines is requested
382
+ // far above the clamp (MAX_CONTEXT_LINES = 20), the clamp plus the
383
+ // output-byte budget must produce a truncated result rather than an
384
+ // unbounded one. The source file stays under MAX_FILE_BYTES (8 MiB) so it
385
+ // isn't skipped, but because every line both matches and is re-emitted as
386
+ // context for ~41 nearby matches, the accumulated output crosses the 4 MiB
387
+ // budget.
388
+ const filler = "y".repeat(200);
389
+ const lineCount = 20_000;
390
+ const body = Array.from(
391
+ { length: lineCount },
392
+ () => `match ${filler}`,
393
+ ).join("\n");
394
+ writeFileSync(join(dir, "big.txt"), body + "\n");
395
+
396
+ const result = await codeSearchTool.execute(
397
+ {
398
+ pattern: "match",
399
+ context_lines: 1000,
400
+ max_results: 1_000_000,
401
+ activity: "search",
402
+ },
403
+ makeToolContext(dir),
404
+ );
405
+
406
+ expect(result.isError).toBe(false);
407
+ expect(result.status).toBe("truncated");
408
+ expect(result.content).toContain("Output capped");
409
+ // The accumulated output must stay near the budget, not balloon to the full
410
+ // file size (~20 MiB of body * surrounding context).
411
+ expect(Buffer.byteLength(result.content, "utf8")).toBeLessThan(
412
+ 8 * 1024 * 1024,
413
+ );
414
+ });
415
+
416
+ test("an already-aborted signal stops the scan and reports a timed-out result", async () => {
417
+ const dir = makeTempDir();
418
+ writeFileSync(join(dir, "a.txt"), "needle here\nneedle there\n");
419
+
420
+ // The wall-clock deadline (MAX_SEARCH_MS) and the abort signal are checked
421
+ // at the same per-line checkpoint. MAX_SEARCH_MS is a 10s const that isn't
422
+ // injectable, so we exercise the shared stop path deterministically via an
423
+ // already-aborted signal: the scan must stop at the first line check and
424
+ // return a truncated/timed-out result instead of running the regex.
425
+ const controller = new AbortController();
426
+ controller.abort();
427
+ const context = { ...makeToolContext(dir), signal: controller.signal };
428
+
429
+ const result = await codeSearchTool.execute(
430
+ { pattern: "needle", activity: "search" },
431
+ context,
432
+ );
433
+
434
+ // Aborted before any match was emitted, so this is the zero-match
435
+ // timed-out branch (incomplete), not a definitive "No matches found".
436
+ expect(result.isError).toBe(false);
437
+ expect(result.status).toBe("truncated");
438
+ expect(result.content).toContain("timed out");
439
+ expect(result.content).not.toContain("No matches found");
440
+ });
441
+
442
+ test("an already-aborted signal stops the directory traversal, not just per-line scanning", async () => {
443
+ const dir = makeTempDir();
444
+ // Build a small tree of subdirectories and files. The traversal deadline /
445
+ // abort check now lives at the top of walk() and inside its per-entry loop
446
+ // (shared with the per-line checkpoint), so an already-aborted signal must
447
+ // stop the walk before it descends/scans the whole tree, returning a
448
+ // truncated/timed-out result rather than scanning everything.
449
+ for (let d = 0; d < 5; d++) {
450
+ const sub = join(dir, `sub${d}`);
451
+ mkdirSync(sub);
452
+ for (let f = 0; f < 5; f++) {
453
+ writeFileSync(join(sub, `f${f}.txt`), "needle here\n");
454
+ }
455
+ }
456
+
457
+ const controller = new AbortController();
458
+ controller.abort();
459
+ const context = { ...makeToolContext(dir), signal: controller.signal };
460
+
461
+ const result = await codeSearchTool.execute(
462
+ { pattern: "needle", activity: "search" },
463
+ context,
464
+ );
465
+
466
+ // The traversal honored the abort: stopped promptly with a timed-out /
467
+ // incomplete result instead of a definitive miss or full scan.
468
+ expect(result.isError).toBe(false);
469
+ expect(result.status).toBe("truncated");
470
+ expect(result.content).toContain("timed out");
471
+ expect(result.content).not.toContain("No matches found");
472
+ });
473
+
474
+ test("a catastrophic-backtracking pattern returns near-instantly (linear-time RE2)", async () => {
475
+ const dir = makeTempDir();
476
+ // The classic ReDoS proof: `(a+)+$` against many non-matching lines of the
477
+ // form `'a'.repeat(50) + '!'`. Under V8's backtracking RegExp a single
478
+ // `regex.test()` on one such line blocks the event loop for seconds, and
479
+ // the synchronous scan never yields so neither the wall-clock deadline nor
480
+ // the promise timeout can interrupt it — the call would hang. With the
481
+ // linear-time RE2 engine the whole file scans in milliseconds and returns
482
+ // a clean, non-truncated "No matches found". The small per-call timeout
483
+ // below would trip if matching ever fell back to backtracking.
484
+ const evilLine = "a".repeat(50) + "!"; // forces backtracking, no match
485
+ const body = Array.from({ length: 200 }, () => evilLine).join("\n");
486
+ writeFileSync(join(dir, "evil.txt"), body + "\n");
487
+
488
+ const result = await codeSearchTool.execute(
489
+ { pattern: "(a+)+$", activity: "search" },
490
+ makeToolContext(dir),
491
+ );
492
+
493
+ expect(result.isError).toBe(false);
494
+ // Linear-time matching completes well within the deadline, so this is a
495
+ // definitive miss, not a timed-out/truncated result.
496
+ expect(result.status).toBeUndefined();
497
+ expect(result.content).toContain("No matches found");
498
+ }, 5_000);
499
+
500
+ test("an unsupported pattern (backreference) returns a clean error, not a throw", async () => {
501
+ const dir = makeTempDir();
502
+ writeFileSync(join(dir, "a.txt"), "aa\n");
503
+
504
+ // RE2 does not support backreferences (or lookarounds). The pattern must be
505
+ // rejected at compile time and surfaced as an isError result rather than
506
+ // throwing out of execute().
507
+ const result = await codeSearchTool.execute(
508
+ { pattern: "(a)\\1", activity: "search" },
509
+ makeToolContext(dir),
510
+ );
511
+
512
+ expect(result.isError).toBe(true);
513
+ expect(result.content).toContain("invalid or unsupported pattern");
514
+ });
515
+
516
+ test("does not return contents of denied-basename files", async () => {
517
+ const dir = makeTempDir();
518
+ // A denied file (forbidden to file_read/file_write) that contains the
519
+ // search pattern must never surface in code_search results.
520
+ writeFileSync(join(dir, ".backup.key"), "supersecret token\n");
521
+ writeFileSync(join(dir, "backup.key"), "another supersecret\n");
522
+ writeFileSync(join(dir, "ok.txt"), "supersecret in a normal file\n");
523
+
524
+ const result = await codeSearchTool.execute(
525
+ { pattern: "supersecret", activity: "search" },
526
+ makeToolContext(dir),
527
+ );
528
+
529
+ expect(result.isError).toBe(false);
530
+ expect(result.content).toContain("ok.txt:1:");
531
+ expect(result.content).not.toContain(".backup.key");
532
+ expect(result.content).not.toContain("backup.key");
533
+ });
534
+
535
+ test("ignores node_modules and .git", async () => {
536
+ const dir = makeTempDir();
537
+ mkdirSync(join(dir, "node_modules"));
538
+ writeFileSync(join(dir, "node_modules", "dep.js"), "secret\n");
539
+ writeFileSync(join(dir, "src.js"), "secret\n");
540
+
541
+ const result = await codeSearchTool.execute(
542
+ { pattern: "secret", activity: "search" },
543
+ makeToolContext(dir),
544
+ );
545
+
546
+ expect(result.content).toContain("src.js:1:");
547
+ expect(result.content).not.toContain("node_modules");
548
+ });
549
+
550
+ test("requires a non-empty pattern", async () => {
551
+ const dir = makeTempDir();
552
+ const result = await codeSearchTool.execute(
553
+ { pattern: "", activity: "search" },
554
+ makeToolContext(dir),
555
+ );
556
+ expect(result.isError).toBe(true);
557
+ expect(result.content).toContain("pattern is required");
558
+ });
559
+
560
+ test("is read-only: directory is unchanged after a search", async () => {
561
+ const dir = makeTempDir();
562
+ const filePath = join(dir, "a.txt");
563
+ writeFileSync(filePath, "hello search\n");
564
+ const before = readFileSync(filePath, "utf8");
565
+ const mtimeBefore = statSync(filePath).mtimeMs;
566
+
567
+ await codeSearchTool.execute(
568
+ { pattern: "search", activity: "search" },
569
+ makeToolContext(dir),
570
+ );
571
+
572
+ expect(readFileSync(filePath, "utf8")).toBe(before);
573
+ expect(statSync(filePath).mtimeMs).toBe(mtimeBefore);
574
+ });
575
+ });
576
+
577
+ // ---------------------------------------------------------------------------
578
+ // Subagent-only visibility
579
+ // ---------------------------------------------------------------------------
580
+
581
+ describe("code_search subagent visibility", () => {
582
+ test("code_search is in SUBAGENT_ONLY_TOOL_NAMES", () => {
583
+ expect(SUBAGENT_ONLY_TOOL_NAMES.has("code_search")).toBe(true);
584
+ });
585
+ });
@@ -225,10 +225,11 @@ describe("exploration-drift post-tool-use hook — long-dig trigger", () => {
225
225
  expect(ctx.toolResponse.content).toBe(BASE_CONTENT);
226
226
  });
227
227
 
228
- test("counts file_read and file_list as exploration tools", async () => {
228
+ test("counts code_search, file_read, and file_list as exploration tools", async () => {
229
+ const explorationNames = ["code_search", "file_read", "file_list"];
229
230
  const messages: Message[] = [];
230
231
  for (let i = 0; i < EXPLORATION_NUDGE_THRESHOLD - 1; i++) {
231
- const name = i % 2 === 0 ? "file_read" : "file_list";
232
+ const name = explorationNames[i % explorationNames.length];
232
233
  const id = `${name}-${i}`;
233
234
  messages.push(toolUseTurn(id, name, { path: `/tmp/file-${i}` }));
234
235
  messages.push(toolResultTurn(id));