Repository Public
1import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, symlinkSync, writeFileSync } from 'node:fs';2import path from 'node:path';3import { afterEach, beforeEach, describe, expect, it } from 'vitest';4import { unifiedDiff } from './diff.js';5import { searchWorkspace } from './files.js';6import { finalizeToolOutput, sha256Hex, TOOLS, applyFileStateDelta } from './index.js';7import { makeFixture, type Fixture } from './testutil.js';89let fx: Fixture;10beforeEach(() => {11 fx = makeFixture();12});13afterEach(() => fx.cleanup());14const T = (name: string) => TOOLS[name]!;15const w = (rel: string, content: string | Buffer) => {16 mkdirSync(path.dirname(path.join(fx.ws, rel)), { recursive: true });17 writeFileSync(path.join(fx.ws, rel), content);18};19const r = (rel: string) => readFileSync(path.join(fx.ws, rel), 'utf8');2021describe('read_file', () => {22 it('returns numbered lines and records the sha in fileStateOut', async () => {23 w('a.txt', 'one\ntwo\nthree\n');24 const res = await fx.call(T('read_file'), { path: 'a.txt', offset: null, limit: null });25 expect(res.ok).toBe(true);26 expect(res.output).toBe(' 1\tone\n 2\ttwo\n 3\tthree');27 expect(res.fileStateOut).toEqual({ 'a.txt': sha256Hex('one\ntwo\nthree\n') });28 expect(fx.state()).toEqual({ 'a.txt': sha256Hex('one\ntwo\nthree\n') });29 });30 it('supports line ranges and tells how to continue', async () => {31 w('b.txt', Array.from({ length: 10 }, (_, i) => `L${i + 1}`).join('\n'));32 const res = await fx.call(T('read_file'), { path: 'b.txt', offset: 4, limit: 2 });33 expect(res.output).toBe(' 4\tL4\n 5\tL5\n[lines 4-5 of 10; continue with offset 6]');34 const past = await fx.call(T('read_file'), { path: 'b.txt', offset: 50 });35 expect(past.ok).toBe(false);36 expect(past.output).toMatch(/only 10 lines/);37 });38 it('accepts omitted optional fields as well as nulls', async () => {39 w('c.txt', 'x\n');40 expect((await fx.call(T('read_file'), { path: 'c.txt' })).ok).toBe(true);41 });42 it('refuses missing files, directories, binaries, files over 2 MiB and paths outside', async () => {43 mkdirSync(path.join(fx.ws, 'dir'));44 w('bin.dat', Buffer.from([0x89, 0x50, 0x00, 0x01]));45 w('latin1.txt', Buffer.from([0x63, 0x61, 0x66, 0xe9]));46 w('big.txt', 'x'.repeat(2 * 1024 * 1024 + 1));47 for (const [p, re] of [48 ['nope.txt', /not found/i], ['dir', /directory/], ['bin.dat', /binary/], ['latin1.txt', /binary|UTF-8/], ['big.txt', /2 MiB/],49 ['/etc/passwd', /outside the workspace/], ['../x', /"\.\."/],50 ] as const) {51 const res = await fx.call(T('read_file'), { path: p });52 expect(res.ok, p).toBe(false);53 expect(res.output, p).toMatch(re);54 expect(res.fileStateOut, p).toBeUndefined();55 }56 });57 it('handles an empty file', async () => {58 w('empty', '');59 const res = await fx.call(T('read_file'), { path: 'empty' });60 expect(res.ok).toBe(true);61 expect(res.output).toMatch(/empty/);62 });63});6465describe('list_dir', () => {66 it('lists entries with type markers, sorted', async () => {67 w('b.txt', 'hello');68 mkdirSync(path.join(fx.ws, 'adir'));69 symlinkSync('b.txt', path.join(fx.ws, 'link'));70 w('.hidden', '');71 const res = await fx.call(T('list_dir'), { path: null });72 expect(res.ok).toBe(true);73 expect(res.output).toBe('.:\n.hidden (0 bytes)\nadir/\nb.txt (5 bytes)\nlink@');74 });75 it('errors on files, missing dirs and escapes', async () => {76 w('f', 'x');77 expect((await fx.call(T('list_dir'), { path: 'f' })).output).toMatch(/is a file/);78 expect((await fx.call(T('list_dir'), { path: 'nope' })).ok).toBe(false);79 expect((await fx.call(T('list_dir'), { path: '/' })).output).toMatch(/outside/);80 mkdirSync(path.join(fx.ws, 'e'));81 expect((await fx.call(T('list_dir'), { path: 'e' })).output).toMatch(/empty/);82 });83});8485describe('search', () => {86 beforeEach(() => {87 w('src/alpha.ts', 'export const alpha = 1;\n// TODO: fix\n');88 w('src/beta.py', 'def beta():\n return "Alpha"\n');89 w('docs/notes.md', 'nothing here\n');90 w('.git/config', 'alpha = hidden\n');91 w('bin.dat', Buffer.from([0, 1, 2, 97, 108, 112, 104, 97]));92 });93 for (const forceFallback of [false, true]) {94 const label = forceFallback ? 'fallback walker' : 'ripgrep';95 it(`content search (${label})`, async () => {96 const res = await searchWorkspace({ pattern: 'alpha', mode: 'content' }, fx.ctx, { forceFallback });97 expect(res.ok).toBe(true);98 expect(res.output).toContain('src/alpha.ts:1:export const alpha = 1;');99 expect(res.output).not.toContain('.git/config');100 expect(res.output).not.toContain('bin.dat');101 expect(res.output).not.toContain('beta.py');102 const ci = await searchWorkspace({ pattern: 'alpha', mode: 'content', caseInsensitive: true, glob: '*.py' }, fx.ctx, { forceFallback });103 expect(ci.output).toContain('src/beta.py:2:');104 expect(ci.output).not.toContain('alpha.ts');105 const none = await searchWorkspace({ pattern: 'zzz-not-there', mode: 'content' }, fx.ctx, { forceFallback });106 expect(none.ok).toBe(true);107 expect(none.output).toMatch(/No matches/);108 });109 it(`name search (${label})`, async () => {110 const frag = await searchWorkspace({ pattern: 'ALPHA', mode: 'name' }, fx.ctx, { forceFallback });111 expect(frag.output.trim()).toBe('src/alpha.ts');112 const glob = await searchWorkspace({ pattern: '*.py', mode: 'name', path: 'src' }, fx.ctx, { forceFallback });113 expect(glob.output.trim()).toBe('src/beta.py');114 });115 it(`does not follow symlinks out of the workspace (${label})`, async () => {116 mkdirSync(path.join(fx.root, 'outside'));117 writeFileSync(path.join(fx.root, 'outside', 'secret.txt'), 'alpha secret\n');118 symlinkSync(path.join(fx.root, 'outside'), path.join(fx.ws, 'out'));119 const res = await searchWorkspace({ pattern: 'alpha secret', mode: 'content' }, fx.ctx, { forceFallback });120 expect(res.output).toMatch(/No matches/);121 const direct = await searchWorkspace({ pattern: 'alpha', mode: 'content', path: 'out' }, fx.ctx, { forceFallback });122 expect(direct.ok).toBe(false);123 expect(direct.output).toMatch(/symbolic link/);124 });125 }126 it('reports an invalid regex as an error (ripgrep)', async () => {127 const res = await fx.call(T('search'), { pattern: '(unclosed', mode: 'content' });128 expect(res.ok).toBe(false);129 expect(res.output).toMatch(/search failed/);130 });131 it('treats a pattern starting with a dash as a pattern, not an option', async () => {132 w('opt.txt', 'value --pre=evil here\n');133 const res = await fx.call(T('search'), { pattern: '--pre=evil', mode: 'content' });134 expect(res.ok).toBe(true);135 expect(res.output).toContain('opt.txt:1:');136 });137 it('truncates at 200 results', async () => {138 for (let i = 0; i < 10; i++) w(`many/f${i}.txt`, Array.from({ length: 40 }, () => 'needle').join('\n'));139 const res = await fx.call(T('search'), { pattern: 'needle', mode: 'content' });140 expect(res.output).toMatch(/results truncated at 200/);141 expect(res.output.split('\n').filter((l) => l.includes(':needle')).length).toBe(200);142 });143});144145describe('write_file', () => {146 it('creates files (with parent directories) and returns a diff', async () => {147 const res = await fx.call(T('write_file'), { path: 'new/dir/a.txt', content: 'hello\nworld\n' });148 expect(res.ok).toBe(true);149 expect(res.output).toBe('Created new/dir/a.txt (2 lines, 12 bytes).');150 expect(r('new/dir/a.txt')).toBe('hello\nworld\n');151 expect(res.diff).toBe('--- /dev/null\n+++ b/new/dir/a.txt\n@@ -0,0 +1,2 @@\n+hello\n+world\n');152 expect(fx.state()['new/dir/a.txt']).toBe(sha256Hex('hello\nworld\n'));153 expect(readdirSync(path.join(fx.ws, 'new/dir'))).toEqual(['a.txt']); // no temp file left154 });155 it('lets a run overwrite what it wrote or read, and refuses unread or changed files', async () => {156 await fx.call(T('write_file'), { path: 'a.txt', content: 'v1\n' });157 const again = await fx.call(T('write_file'), { path: 'a.txt', content: 'v2\n' });158 expect(again.ok).toBe(true);159 expect(again.output).toMatch(/^Overwrote/);160 expect(again.diff).toContain('-v1\n+v2');161162 w('other.txt', 'pre-existing\n');163 const unread = await fx.call(T('write_file'), { path: 'other.txt', content: 'x' });164 expect(unread.ok).toBe(false);165 expect(unread.output).toMatch(/already exists.*read_file/s);166 expect(r('other.txt')).toBe('pre-existing\n');167168 w('a.txt', 'changed behind our back\n');169 const stale = await fx.call(T('write_file'), { path: 'a.txt', content: 'v3\n' });170 expect(stale.ok).toBe(false);171 expect(stale.output).toMatch(/changed on disk/);172 await fx.call(T('read_file'), { path: 'a.txt' });173 expect((await fx.call(T('write_file'), { path: 'a.txt', content: 'v3\n' })).ok).toBe(true);174 });175 it('is idempotent: same content again is a no-op even with an empty file state (replayed step)', async () => {176 w('a.txt', 'same\n');177 const res = await T('write_file').run({ path: 'a.txt', content: 'same\n' }, { ...fx.ctx, fileState: {} });178 expect(res.ok).toBe(true);179 expect(res.output).toMatch(/no change/);180 expect(res.fileStateOut).toEqual({ 'a.txt': sha256Hex('same\n') });181 });182 it('preserves the file mode and replaces symlinks/hard links instead of writing through them', async () => {183 w('run.sh', '#!/bin/sh\n');184 chmodSync(path.join(fx.ws, 'run.sh'), 0o755);185 await fx.call(T('read_file'), { path: 'run.sh' });186 await fx.call(T('write_file'), { path: 'run.sh', content: '#!/bin/sh\necho hi\n' });187 expect(statSync(path.join(fx.ws, 'run.sh')).mode & 0o777).toBe(0o755);188 });189 it('refuses protected paths, escapes, directories and oversized content', async () => {190 mkdirSync(path.join(fx.ws, 'd'));191 w('file', 'x');192 for (const [p, re] of [193 ['.git/config', /protected/], ['.git/hooks/pre-commit', /protected/], ['AGENTS.md', /protected/], ['CLAUDE.md', /protected/], ['.aos/x', /protected/],194 ['.zshrc', /protected/], ['/tmp/aos-outside-file', /outside/], ['../escape', /"\.\."/], ['d', /directory/], ['file/child', /parent of this path is a file/],195 ] as const) {196 const res = await fx.call(T('write_file'), { path: p, content: 'evil' });197 expect(res.ok, p).toBe(false);198 expect(res.output, p).toMatch(re);199 }200 expect(existsSync(path.join(fx.ws, '.git'))).toBe(false);201 expect(existsSync(path.join(fx.ws, '.aos'))).toBe(false);202 expect(existsSync(path.join(fx.ws, 'AGENTS.md'))).toBe(false);203 expect(existsSync('/tmp/aos-outside-file')).toBe(false);204 const big = await fx.call(T('write_file'), { path: 'big', content: 'x'.repeat(2 * 1024 * 1024 + 1) });205 expect(big.output).toMatch(/2 MiB/);206 });207});208209describe('edit_file', () => {210 it('replaces a unique string after a read and returns a diff', async () => {211 w('a.ts', 'const a = 1;\nconst b = 2;\nconst c = 3;\n');212 await fx.call(T('read_file'), { path: 'a.ts' });213 const res = await fx.call(T('edit_file'), { path: 'a.ts', oldString: 'const b = 2;', newString: 'const b = 20;', replaceAll: null });214 expect(res.ok).toBe(true);215 expect(res.output).toBe('Edited a.ts: 1 replacement.');216 expect(r('a.ts')).toBe('const a = 1;\nconst b = 20;\nconst c = 3;\n');217 expect(res.diff).toBe('--- a/a.ts\n+++ b/a.ts\n@@ -1,3 +1,3 @@\n const a = 1;\n-const b = 2;\n+const b = 20;\n const c = 3;\n');218 // the edit refreshed the state: a second edit works without re-reading219 expect((await fx.call(T('edit_file'), { path: 'a.ts', oldString: 'const c = 3;', newString: '' })).ok).toBe(true);220 expect(r('a.ts')).toBe('const a = 1;\nconst b = 20;\n\n');221 });222 it('staleness: never read ⇒ refused with instructions', async () => {223 w('a.ts', 'x = 1\n');224 const res = await fx.call(T('edit_file'), { path: 'a.ts', oldString: 'x = 1', newString: 'x = 2' });225 expect(res.ok).toBe(false);226 expect(res.output).toMatch(/have not read a\.ts.*read_file/s);227 expect(r('a.ts')).toBe('x = 1\n');228 });229 it('staleness: changed on disk since the read ⇒ refused; re-reading unblocks', async () => {230 w('a.ts', 'x = 1\n');231 await fx.call(T('read_file'), { path: 'a.ts' });232 w('a.ts', 'x = 1\ny = 5\n'); // e.g. a formatter run by the shell233 const res = await fx.call(T('edit_file'), { path: 'a.ts', oldString: 'x = 1', newString: 'x = 2' });234 expect(res.ok).toBe(false);235 expect(res.output).toMatch(/changed on disk.*Read it again/s);236 expect(r('a.ts')).toBe('x = 1\ny = 5\n');237 await fx.call(T('read_file'), { path: 'a.ts', offset: 1, limit: 1 }); // a partial read counts238 expect((await fx.call(T('edit_file'), { path: 'a.ts', oldString: 'x = 1', newString: 'x = 2' })).ok).toBe(true);239 expect(r('a.ts')).toBe('x = 2\ny = 5\n');240 });241 it('the file state is a plain serialisable value that survives a replay', async () => {242 w('a.ts', 'x = 1\n');243 const read = await fx.call(T('read_file'), { path: 'a.ts' });244 // what the engine journals and folds on replay:245 const journaled = JSON.parse(JSON.stringify(read.fileStateOut)) as Record<string, string>;246 const rebuilt = applyFileStateDelta(applyFileStateDelta(undefined, journaled), { 'gone.txt': null });247 expect(rebuilt).toEqual({ 'a.ts': sha256Hex('x = 1\n') });248 const res = await T('edit_file').run({ path: 'a.ts', oldString: 'x = 1', newString: 'x = 3' }, { ...fx.ctx, fileState: rebuilt, idemKey: 'k1' });249 expect(res.ok).toBe(true);250 expect(applyFileStateDelta(rebuilt, res.fileStateOut)).toEqual({ 'a.ts': sha256Hex('x = 3\n') });251 });252 it('a re-executed edit step (crash after the write, before the journal) is recognised, a different step is not', async () => {253 w('a.ts', 'x = 1\n');254 const read = await fx.call(T('read_file'), { path: 'a.ts' });255 const stateBefore = applyFileStateDelta(undefined, read.fileStateOut);256 const input = { path: 'a.ts', oldString: 'x = 1', newString: 'x = 2' };257 const first = await T('edit_file').run(input, { ...fx.ctx, fileState: stateBefore, idemKey: 'step-7' });258 expect(first.ok).toBe(true);259 // the worker died before recording `first`: same step, same idemKey, same (old) file state260 const second = await T('edit_file').run(input, { ...fx.ctx, fileState: stateBefore, idemKey: 'step-7' });261 expect(second.ok).toBe(true);262 expect(second.output).toMatch(/already applied/);263 expect(second.fileStateOut).toEqual(first.fileStateOut);264 expect(r('a.ts')).toBe('x = 2\n');265 const other = await T('edit_file').run(input, { ...fx.ctx, fileState: stateBefore, idemKey: 'step-8' });266 expect(other.ok).toBe(false);267 expect(other.output).toMatch(/changed on disk/);268 });269 it('fails when the string is absent, ambiguous, identical or the file is missing', async () => {270 w('a.ts', 'foo\nfoo\nbar\n');271 await fx.call(T('read_file'), { path: 'a.ts' });272 const absent = await fx.call(T('edit_file'), { path: 'a.ts', oldString: 'nope', newString: 'x' });273 expect(absent.output).toMatch(/not found in a\.ts/);274 const ambiguous = await fx.call(T('edit_file'), { path: 'a.ts', oldString: 'foo', newString: 'x' });275 expect(ambiguous.output).toMatch(/occurs 2 times/);276 const same = await fx.call(T('edit_file'), { path: 'a.ts', oldString: 'foo', newString: 'foo' });277 expect(same.output).toMatch(/identical/);278 const missing = await fx.call(T('edit_file'), { path: 'zzz.ts', oldString: 'a', newString: 'b' });279 expect(missing.output).toMatch(/not found/i);280 for (const x of [absent, ambiguous, same, missing]) expect(x.ok).toBe(false);281 expect(r('a.ts')).toBe('foo\nfoo\nbar\n');282 const all = await fx.call(T('edit_file'), { path: 'a.ts', oldString: 'foo', newString: '$& $1 baz', replaceAll: true });283 expect(all.output).toBe('Edited a.ts: 2 replacements.');284 expect(r('a.ts')).toBe('$& $1 baz\n$& $1 baz\nbar\n'); // replacement is literal, no $-patterns285 });286 it('treats "$" sequences literally in single replacements too', async () => {287 w('a.ts', 'price\n');288 await fx.call(T('read_file'), { path: 'a.ts' });289 await fx.call(T('edit_file'), { path: 'a.ts', oldString: 'price', newString: "$' $` $$ $&" });290 expect(r('a.ts')).toBe("$' $` $$ $&\n");291 });292 it('refuses protected paths and symlink escapes even when "read"', async () => {293 w('.git/config', '[core]\n');294 w('AGENTS.md', 'rules\n');295 writeFileSync(path.join(fx.root, 'outside.txt'), 'outside\n');296 symlinkSync(path.join(fx.root, 'outside.txt'), path.join(fx.ws, 'link'));297 await fx.call(T('read_file'), { path: '.git/config' });298 await fx.call(T('read_file'), { path: 'AGENTS.md' });299 expect((await fx.call(T('edit_file'), { path: '.git/config', oldString: '[core]', newString: '[evil]' })).output).toMatch(/protected/);300 expect((await fx.call(T('edit_file'), { path: 'AGENTS.md', oldString: 'rules', newString: 'no rules' })).output).toMatch(/protected/);301 expect((await fx.call(T('edit_file'), { path: 'link', oldString: 'outside', newString: 'pwned' })).output).toMatch(/symbolic link/);302 expect((await fx.call(T('write_file'), { path: 'link', content: 'pwned' })).output).toMatch(/symbolic link/);303 expect((await fx.call(T('read_file'), { path: 'link' })).output).toMatch(/symbolic link/);304 expect(readFileSync(path.join(fx.root, 'outside.txt'), 'utf8')).toBe('outside\n');305 expect(r('.git/config')).toBe('[core]\n');306 expect(r('AGENTS.md')).toBe('rules\n');307 });308});309310describe('unifiedDiff', () => {311 it('is empty for identical texts and bounded for large ones', () => {312 expect(unifiedDiff('a', 'x\n', 'x\n')).toBe('');313 const a = Array.from({ length: 5000 }, (_, i) => `line ${i}`).join('\n');314 const b = Array.from({ length: 5000 }, (_, i) => `LINE ${i}`).join('\n');315 const d = unifiedDiff('big', a, b, 2000);316 expect(d.length).toBeLessThan(2100);317 expect(d).toMatch(/diff truncated/);318 });319 it('emits separate hunks with correct headers', () => {320 const a = Array.from({ length: 30 }, (_, i) => `l${i + 1}`);321 const b = [...a];322 b[1] = 'changed2';323 b[25] = 'changed26';324 b.splice(10, 0, 'inserted');325 const d = unifiedDiff('f', a.join('\n') + '\n', b.join('\n') + '\n');326 expect(d).toContain('@@ -1,5 +1,5 @@\n l1\n-l2\n+changed2\n l3');327 expect(d).toContain('@@ -8,6 +8,7 @@\n l8\n l9\n l10\n+inserted\n l11');328 expect(d).toContain('@@ -23,7 +24,7 @@');329 expect(d).toContain('-l26\n+changed26');330 });331 it('reports a trailing-newline-only change', () => {332 expect(unifiedDiff('f', 'a', 'a\n')).toMatch(/trailing newline/);333 });334});335336describe('output offload and read_artifact', () => {337 it('passes short output through', async () => {338 const out = await finalizeToolOutput('shell', 'x'.repeat(12_000), fx.ctx);339 expect(out).toEqual({ content: 'x'.repeat(12_000), artifact: null });340 expect(fx.artifacts.size).toBe(0);341 });342 it('offloads long output: head 6000 + tail 3000 + note, artifact readable by pages', async () => {343 const lines = Array.from({ length: 3000 }, (_, i) => `row ${String(i + 1).padStart(5, '0')} ${'#'.repeat(20)}`);344 const full = lines.join('\n');345 const out = await finalizeToolOutput('shell', full, fx.ctx);346 expect(out.artifact).not.toBeNull();347 const name = out.artifact!.name;348 expect(name).toMatch(/^shell-[0-9a-f]{16}\.txt$/);349 expect(out.artifact).toEqual({ name, sha256: sha256Hex(full), size: Buffer.byteLength(full) });350 expect(out.content.startsWith(full.slice(0, 6000))).toBe(true);351 expect(out.content).toContain(full.slice(-3000));352 expect(out.content).toContain(`read_artifact with {"name":"${name}"`);353 expect(out.content.length).toBeLessThan(10_000);354 expect(fx.artifacts.get(name)?.toString()).toBe(full);355 // deterministic (replay-safe)356 expect((await finalizeToolOutput('shell', full, fx.ctx)).content).toBe(out.content);357358 const page = await fx.call(T('read_artifact'), { name, offset: 1500, limit: 3 });359 expect(page.ok).toBe(true);360 expect(page.output).toBe(` 1500\t${lines[1499]}\n 1501\t${lines[1500]}\n 1502\t${lines[1501]}\n[artifact ${name}: lines 1500-1502 of 3000; continue with offset 1503]`);361 const wide = await fx.call(T('read_artifact'), { name, offset: null, limit: 2000 });362 expect(wide.output.length).toBeLessThanOrEqual(12_000); // never re-offloaded363 expect(wide.output).toMatch(/continue with offset \d+\]$/);364 const viaEngine = await fx.call(T('read_artifact'), { name: 'custom-name', offset: null, limit: null }, { getArtifact: async (n) => (n === 'custom-name' ? Buffer.from('a\nb\n') : null) });365 expect(viaEngine.output).toContain(' 1\ta\n 2\tb');366 });367 it('read_artifact errors: unknown name, traversal, past the end', async () => {368 for (const name of ['nope-0000000000000000.txt', '../../etc/passwd', 'shell-zzzz.txt']) {369 const res = await fx.call(T('read_artifact'), { name });370 expect(res.ok).toBe(false);371 expect(res.output).toMatch(/not found/);372 }373 const out = await finalizeToolOutput('git_diff', 'y\n'.repeat(7000), fx.ctx);374 const past = await fx.call(T('read_artifact'), { name: out.artifact!.name, offset: 999_999 });375 expect(past.ok).toBe(false);376 expect(past.output).toMatch(/past the end/);377 });378});379