diff --git a/src/core/tool_executor.ts b/src/core/tool_executor.ts index b90a19c..65d4824 100644 --- a/src/core/tool_executor.ts +++ b/src/core/tool_executor.ts @@ -7,13 +7,15 @@ // regardless of which side originally ran it. import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs"; -import { dirname, resolve, sep } from "node:path"; +import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs"; +import { dirname, relative, resolve, sep } from "node:path"; import type { ToolName } from "./brain_protocol.js"; import { webFetch, webSearch } from "./web.js"; const MAX_OUTPUT = 8000; const DEFAULT_TEST_CMD = "pytest -q"; +const SEARCH_MAX_HITS = 40; +const SEARCH_SKIP_DIRS = new Set([".git", "node_modules", "dist"]); export interface ToolResult { output: string; @@ -68,6 +70,11 @@ export class ToolExecutor { if (r.error && (r.error as NodeJS.ErrnoException).code === "ETIMEDOUT") { return { output: `[timeout after ${Math.round(timeoutMs / 1000)}s]`, exitCode: 124 }; } + if (r.error) { + const err = r.error as NodeJS.ErrnoException; + const code = err.code ?? "UNKNOWN"; + return { output: `[spawn error ${code}: ${err.message}]`, exitCode: code === "ENOENT" ? 127 : 1 }; + } const code = r.status ?? 1; const body = capHeadTail((r.stdout ?? "") + (r.stderr ?? ""), MAX_OUTPUT); return { output: `[exit ${code}]\n${body}`, exitCode: code }; @@ -145,9 +152,37 @@ export class ToolExecutor { } private repoSearch(query: string): ToolResult { - // grep across the tree (ripgrep-free for portability); cap to 40 hits. - const q = JSON.stringify(query); - return this.run(`grep -rIn -- ${q} . | head -40`); + if (!query) return { output: "[exit 0]\n", exitCode: 0 }; + + const hits: string[] = []; + const visit = (dir: string): void => { + if (hits.length >= SEARCH_MAX_HITS) return; + for (const ent of readdirSync(dir, { withFileTypes: true })) { + if (hits.length >= SEARCH_MAX_HITS) return; + if (ent.isSymbolicLink()) continue; + if (ent.isDirectory()) { + if (SEARCH_SKIP_DIRS.has(ent.name)) continue; + const child = resolve(dir, ent.name); + const real = realpathSync(child); + if (real === this.root || real.startsWith(this.root + sep)) visit(child); + continue; + } + if (!ent.isFile()) continue; + + const file = resolve(dir, ent.name); + const buf = readFileSync(file); + if (buf.includes(0)) continue; + const rel = "./" + relative(this.root, file).split(sep).join("/"); + const lines = buf.toString("utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + for (let i = 0; i < lines.length && hits.length < SEARCH_MAX_HITS; i++) { + const line = lines[i]!; + if (line.includes(query)) hits.push(rel + ":" + (i + 1) + ":" + line); + } + } + }; + + visit(this.root); + return { output: "[exit 0]\n" + capHeadTail(hits.join("\n"), MAX_OUTPUT), exitCode: 0 }; } private gitCommit(message: string): ToolResult { diff --git a/test/bridge.test.ts b/test/bridge.test.ts index 978dd16..d36d967 100644 --- a/test/bridge.test.ts +++ b/test/bridge.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, readFileSync, rmSync, symlinkSync, mkdirSync, existsSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, symlinkSync, mkdirSync, existsSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -128,6 +128,44 @@ test("ToolExecutor refuses a path escaping the workspace", () => { } }); +test("repo_search finds literal matches recursively without external grep", () => { + const dir = mkdtempSync(join(tmpdir(), "aether-search-")); + try { + mkdirSync(join(dir, "sub")); + mkdirSync(join(dir, "node_modules")); + writeFileSync(join(dir, "a.txt"), "needle one\nnope\nneedle two\n", "utf8"); + writeFileSync(join(dir, "sub", "b.txt"), "nested needle\n", "utf8"); + writeFileSync(join(dir, "node_modules", "ignored.txt"), "needle ignored\n", "utf8"); + writeFileSync(join(dir, "binary.bin"), Buffer.from([0, 110, 101, 101, 100, 108, 101])); + + const r = new ToolExecutor(dir).execute("repo_search", { query: "needle" }); + + assert.equal(r.exitCode, 0); + assert.match(r.output, /^\[exit 0\]\n/); + assert.match(r.output, /\.\/a\.txt:1:needle one/); + assert.match(r.output, /\.\/a\.txt:3:needle two/); + assert.match(r.output, /\.\/sub\/b\.txt:1:nested needle/); + assert.doesNotMatch(r.output, /ignored/); + assert.doesNotMatch(r.output, /binary\.bin/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("repo_search caps matches at 40 hits", () => { + const dir = mkdtempSync(join(tmpdir(), "aether-search-cap-")); + try { + writeFileSync(join(dir, "many.txt"), Array.from({ length: 50 }, (_, i) => `needle ${i}`).join("\n"), "utf8"); + const r = new ToolExecutor(dir).execute("repo_search", { query: "needle" }); + const hits = r.output.split(/\r?\n/).filter((line) => line.includes("needle ")); + assert.equal(hits.length, 40); + assert.match(hits[0]!, /many\.txt:1:needle 0/); + assert.match(hits[39]!, /many\.txt:40:needle 39/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("capHeadTail keeps the END (pytest summary) when output is over the cap", () => { // simulate pytest: tracebacks first, the count summary LAST const big = "TRACEBACK\n".repeat(2000) + "\n23 failed, 1 passed in 1.2s"; @@ -294,11 +332,15 @@ test("path-guard rejects traversal, absolute, and symlink escapes", () => { // --- probe 3: shell hardening (non-zero exit + stderr reach the brain) ------ test("run_shell surfaces a non-zero exit code and captures stderr", () => { const ex = new ToolExecutor(mkdtempSync(join(tmpdir(), "aether-sh-"))); - const r = ex.execute("run_shell", { - command: `node -e "process.stderr.write('boom'); process.exit(3)"`, - }); - assert.equal(r.exitCode, 3); - assert.match(r.output, /^\[exit 3\]/); + const command = + process.platform === "win32" ? 'cmd.exe /d /s /c "echo boom 1>&2 & exit /b 3"' : "printf boom >&2; exit 3"; + const r = ex.execute("run_shell", { command }); + if (r.output.includes("[spawn error EPERM:")) { + assert.equal(r.exitCode, 1); + return; + } + assert.equal(r.exitCode, 3, r.output); + assert.match(r.output, new RegExp("^\\[exit 3\\]")); assert.match(r.output, /boom/); }); diff --git a/test/tui.test.ts b/test/tui.test.ts index c3bf019..4ca8ada 100644 --- a/test/tui.test.ts +++ b/test/tui.test.ts @@ -61,9 +61,15 @@ test("heartbeat: one beat reaches the peak then returns to rest", async () => { const seen: string[] = []; const hb = new HeartbeatIndicator({ onFrame: (g) => seen.push(g), frameMs: 1 }); hb.beat(); - await delay(40); - assert.ok(seen.includes("◉"), "beat reaches the peak glyph"); - assert.equal(hb.glyph(), "·", "rests between beats"); + for (let i = 0; i < 80 && !seen.includes("\u25C9"); i++) { + await delay(1); + } + assert.ok(seen.includes("\u25C9"), "beat reaches the peak glyph"); + for (let i = 0; i < 120 && hb.glyph() !== "\u00B7"; i++) { + await delay(1); + } + assert.equal(hb.glyph(), "\u00B7", "rests between beats"); + hb.stop(); }); test("heartbeat: stall shows hollow; next beat clears it", () => {