Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 40 additions & 5 deletions src/core/tool_executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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 {
Expand Down
54 changes: 48 additions & 6 deletions test/bridge.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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/);
});

Expand Down
12 changes: 9 additions & 3 deletions test/tui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down