import test from 'node:test'; import assert from 'node:assert/strict'; import fs, { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync, symlinkSync } from 'node:fs'; import { syncBuiltinESMExports } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { execFileSync } from 'node:child_process'; import * as api from '../src/index.mjs'; const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-C', repo, ...args], { encoding: 'utf8' }).trim(); async function fixture(t) { const root = mkdtempSync(join(tmpdir(), 'project-spec-read-')); t.after(() => rmSync(root, { recursive: true, force: true })); const context = { business: { id: 'demo', human: 'jason', projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') }, } }, project: 'stack', dataRoot: root }; const { repo, commit } = await api.initProject(context); const save = (path, bytes, author = 'jason') => { writeFileSync(join(repo, path), bytes); git(repo, 'add', '--', path); git(repo, '-c', `user.name=${author}`, '-c', 'user.email=fixture@mosaic.invalid', 'commit', '-m', 'Save ' + path); return git(repo, 'rev-parse', 'HEAD'); }; return { context, repo, commit, save, root }; } test('spec reads exact committed content and its path commit/log, not dirty bytes or a later docs commit', async t => { const { context, repo, save, commit } = await fixture(t); const spec = save('spec/north-star.md', '# Spec\n\nKeep this.\n\n'); const docs = save('docs/notes.md', '# Docs\n'); writeFileSync(join(repo, 'spec/north-star.md'), '# Unsaved human edit\n'); const result = await api.readSpec(context); assert.equal(result.content, '# Spec\n\nKeep this.\n\n'); assert.equal(result.repo, repo); assert.equal(result.path, 'spec/north-star.md'); assert.equal(result.head, docs); assert.equal(result.revision, docs); assert.equal(result.commit, spec); assert.equal(result.confirmed, true); assert.deepEqual(result.author, { name: 'jason', email: 'fixture@mosaic.invalid' }); assert.deepEqual(result.log.map(r => r.commit), [spec, commit]); assert.equal(result.truncated, false); assert.equal(readFileSync(join(repo, 'spec/north-star.md'), 'utf8'), '# Unsaved human edit\n'); assert.equal(git(repo, 'rev-parse', 'HEAD'), docs); for (const row of result.log) assert.equal(new Date(row.at).toISOString(), row.at); const historical = await api.readSpec({ ...context, commit }); assert.equal(historical.commit, commit); assert.deepEqual(historical.log.map(r => r.commit), [commit]); assert.equal(historical.content, readFileSync(new URL('../templates/north-star.md', import.meta.url), 'utf8')); }); test('latest agent spec edit is visible but never confirmed or replaced by an earlier human version', async t => { const { context, save } = await fixture(t); const commit = save('spec/north-star.md', '# Agent\n', 'coder.builder'); const result = await api.readSpec(context); assert.equal(result.commit, commit); assert.equal(result.confirmed, false); assert.equal(result.author.name, 'coder.builder'); }); test('shared file reader exposes committed docs and refuses path/revision injection or another history', async t => { const { context, repo, save } = await fixture(t); const commit = save('docs/design.md', '# Design\n'); assert.equal((await api.readProjectFile({ ...context, path: 'docs/design.md' })).commit, commit); for (const path of ['../outside.md', 'spec/other.md', '/spec/north-star.md', 'docs/a/b.md', 'requirements/R.json']) await assert.rejects(api.readProjectFile({ ...context, path }), { code: 'project-read-path' }); for (const commit of ['HEAD', '--help', 'a'.repeat(40), 'abc', git(repo, 'write-tree')]) await assert.rejects(api.readSpec({ ...context, commit }), { code: 'project-read-commit' }); const orphan = git(repo, '-c', 'user.name=jason', '-c', 'user.email=jason@mosaic.invalid', 'commit-tree', git(repo, 'write-tree'), '-m', 'Unreachable'); await assert.rejects(api.readSpec({ ...context, commit: orphan }), { code: 'project-read-commit' }); }); test('reader rejects missing paths, links, invalid UTF-8, NUL and oversized committed blobs', async t => { const { context, repo, save } = await fixture(t); await assert.rejects(api.readProjectFile({ ...context, path: 'docs/missing.md' }), { code: 'project-read-missing' }); for (const bytes of [Buffer.from([0xff]), 'x\0y', 'x'.repeat(1024 * 1024 + 1)]) { save('spec/north-star.md', bytes); await assert.rejects(api.readSpec(context), { code: 'project-read-content' }); } rmSync(join(repo, 'spec/north-star.md')); symlinkSync('../docs/README.md', join(repo, 'spec/north-star.md')); git(repo, 'add', '--', 'spec/north-star.md'); git(repo, '-c', 'user.name=jason', '-c', 'user.email=jason@mosaic.invalid', 'commit', '-m', 'Linked spec'); await assert.rejects(api.readSpec(context), { code: 'project-read-content' }); }); test('reader refuses mismatched committed project identity, unsafe config and unknown argument keys', async t => { const { context, repo, save } = await fixture(t); await assert.rejects(api.readSpec({ ...context, path: 'docs/README.md' }), { code: 'project-read-path' }); await assert.rejects(api.readSpec({ ...context, project: 'other' }), { code: 'project-not-declared' }); const metadata = JSON.parse(readFileSync(join(repo, 'project.json'))); metadata.business = 'other'; save('project.json', JSON.stringify(metadata)); await assert.rejects(api.readSpec(context), { code: 'project-invalid' }); git(repo, 'remote', 'add', 'origin', 'https://fixture.invalid/no-network'); await assert.rejects(api.readSpec(context), { code: 'project-remote' }); }); test('path history is bounded at 100 and explicitly marks truncation', async t => { const { context, save } = await fixture(t); for (let i = 0; i < 101; i++) save('spec/north-star.md', `# Spec ${i}\n`); const result = await api.readSpec(context); assert.equal(result.log.length, 100); assert.equal(result.truncated, true); assert.equal(result.log[0].commit, result.commit); }); // Row 780: git can write and rename pack temp files while the metadata walk is running. The walk // calls readdirSync and lstatSync through the builtin's live bindings, so the test replaces // fs.readdirSync for one path and removes the named entry itself. The product then sees exactly // what a concurrent git process produces, with no hook in production code. test('a git metadata entry that vanishes during inspection refuses as project-repository, never raw ENOENT', async t => { const { context, repo, commit } = await fixture(t); const pack = join(repo, '.git/objects/pack'), temp = join(pack, 'tmp_idx_vanishes'); const directory = join(repo, '.git/objects/tmp_dir_vanishes'); writeFileSync(temp, 'x'); mkdirSync(directory); assert.equal((await api.readSpec(context)).commit, commit, 'control: the extra entries alone do not refuse'); const real = fs.readdirSync, unlink = fs.unlinkSync, rmdir = fs.rmdirSync; let armed = null; fs.readdirSync = function (path, ...rest) { if (armed === 'file' && String(path) === pack) { const names = real.call(this, path, ...rest); unlink(temp); return names; } if (armed === 'directory' && String(path) === directory) rmdir(directory); return real.call(this, path, ...rest); }; syncBuiltinESMExports(); t.after(() => { fs.readdirSync = real; syncBuiltinESMExports(); }); for (const kind of ['file', 'directory']) { armed = kind; await assert.rejects(api.readSpec(context), error => { assert.equal(error.code, 'project-repository', `${kind}: ${error.message}`); assert.match(error.message, /changed during inspection/); return true; }); assert.equal(armed, kind); armed = null; writeFileSync(temp, 'x'); if (!fs.existsSync(directory)) mkdirSync(directory); } assert.equal(git(repo, 'rev-parse', 'HEAD'), commit); }); // Row 780 R2: the walk stats a listed directory, then must stat it again when it enters it. The // test replaces fs.lstatSync for one path and swaps the directory for something else inside the // call that stats it as a listed entry, after the real stat is taken. A walk that trusts that // stale stat would read through a link or leak ENOTDIR; the fresh stat refuses both. async function replaceListedDirectory(t, replace) { const { context, repo, commit, root } = await fixture(t); const directory = join(repo, '.git/objects/tmp_dir_replaced'); const outside = join(root, 'outside'); mkdirSync(directory); mkdirSync(outside); writeFileSync(join(outside, 'private'), 'x'); assert.equal((await api.readSpec(context)).commit, commit, 'control: the extra directory alone does not refuse'); const real = fs.lstatSync; let armed = true; fs.lstatSync = function (path, ...rest) { if (!armed || String(path) !== directory) return real.call(this, path, ...rest); armed = false; const stat = real.call(this, path, ...rest); fs.rmdirSync(directory); replace(directory, outside); return stat; }; syncBuiltinESMExports(); try { await assert.rejects(api.readSpec(context), error => { assert.equal(error.code, 'project-repository', error.message); assert.match(error.message, /expected local \.git directory/); return true; }); } finally { fs.lstatSync = real; syncBuiltinESMExports(); } assert.equal(armed, false, 'the listed directory was replaced during inspection'); assert.equal(git(repo, 'rev-parse', 'HEAD'), commit); } test('a git metadata directory replaced by a symlink after its listed stat refuses, never reads through it', async t => { await replaceListedDirectory(t, (directory, outside) => symlinkSync(outside, directory)); }); test('a git metadata directory replaced by a file after its listed stat refuses, never raw ENOTDIR', async t => { await replaceListedDirectory(t, directory => writeFileSync(directory, 'x')); }); test('committed UTF-8 BOM, Unicode and trailing newlines are returned without normalization', async t => { const { context, save } = await fixture(t); const value = '\uFEFF# North star 🧭\r\n\r\n'; save('spec/north-star.md', Buffer.from(value)); assert.equal((await api.readSpec(context)).content, value); }); test('D4: spec log refuses a commit subject larger than its 256 KiB read bound', async t => { const root = mkdtempSync(join(tmpdir(), 'spec-log-bound-')); t.after(() => rmSync(root, { recursive: true, force: true })); const business = { id: 'demo', human: 'jason', projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') } } }; const { repo } = await api.initProject({ business, project: 'stack', dataRoot: root }); const message = join(root, 'commit-message.txt'); writeFileSync(message, 'X'.repeat(256 * 1024 + 1) + '\n'); writeFileSync(join(repo, 'spec/north-star.md'), '# Small spec\n'); execFileSync('git', ['-C', repo, 'add', '--', 'spec/north-star.md']); execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-c', 'commit.gpgsign=false', '-c', 'user.name=jason', '-c', 'user.email=jason@mosaic.invalid', '-C', repo, 'commit', '-F', message], { stdio: ['ignore', 'ignore', 'pipe'] }); await assert.rejects(api.readSpec({ business, project: 'stack', dataRoot: root }), { code: 'project-git' }); }); test('missing record repository has a named refusal while a damaged repository still refuses', async t => { const root = mkdtempSync(join(tmpdir(), 'project-missing-records-')); t.after(() => rmSync(root, { recursive: true, force: true })); const context = { business: { id: 'demo', human: 'jason', projects: { stack: { root: join(root, 'code') } } }, project: 'stack', dataRoot: root }; await assert.rejects(api.listProjectFiles(context), { code: 'project-records-missing' }); mkdirSync(join(root, 'projects', 'demo', 'stack'), { recursive: true }); await assert.rejects(api.listProjectFiles(context), { code: 'project-repository' }); });