Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
import { describe, it, expect, vi } from 'vitest';
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { resolveInRepo, SandboxError, isKicadFile } from '../src/util/paths.js';
import { redactSecrets } from '../src/util/redact.js';
import { withRetry } from '../src/util/retry.js';
import { toolWriteFile, toolEditFile, toolSearch } from '../src/agent/filetools.js';
import { Transcript } from '../src/agent/transcript.js';
import { isDirty, hasCommits, snapshot, restore } from '../src/util/git.js';
import { PreflightError } from '../src/util/preflight.js';
import { tempFixtureRepo } from './helpers.js';
import { execa } from 'execa';
describe('path sandbox (AC-4.2)', () => {
it('rejects traversal outside the repo root', () => {
expect(() => resolveInRepo('/repo', '../../etc/hosts')).toThrow(SandboxError);
expect(() => resolveInRepo('/repo', '/etc/hosts')).toThrow(SandboxError);
});
it('accepts repo-relative paths including the root itself', () => {
expect(resolveInRepo('/repo', 'docs/BOM.md')).toBe(path.resolve('/repo', 'docs/BOM.md'));
expect(resolveInRepo('/repo', '.')).toBe(path.resolve('/repo'));
});
it('does not treat sibling dirs with a shared prefix as inside', () => {
expect(() => resolveInRepo('/repo', '../repo-evil/x')).toThrow(SandboxError);
});
});
describe('secret redaction (AC-4.1)', () => {
it('redacts sk- keys and bearer tokens', () => {
const input = 'key=sk-abc123DEF456ghi789jkl012 Authorization: Bearer abcdefghijklmnop123456';
const out = redactSecrets(input);
expect(out).not.toMatch(/sk-[A-Za-z0-9_-]{20,}/);
expect(out).toContain('[REDACTED]');
});
it('redacts registry and forge tokens, not just model keys', () => {
// Synthetic tokens: correct shape, never valid.
const input = [
'npm_0000000000000000000000000000000000AA',
'ghp_0000000000000000000000000000000000BB',
'github_pat_0000000000000000000000_CCCC',
].join(' ');
const out = redactSecrets(input);
expect(out).not.toMatch(/npm_[A-Za-z0-9]{36,}/);
expect(out).not.toMatch(/gh[pousr]_[A-Za-z0-9]{36,}/);
expect(out).not.toMatch(/github_pat_/);
expect(out).toBe('[REDACTED] [REDACTED] [REDACTED]');
});
it('redacts a base64 bearer token whole, leaving no tail', () => {
// Synthetic token: correct shape, never valid. Standard base64 uses +, /
// and =; a charset that stops at the first one writes the rest to disk.
const input = 'Bearer ABCDEFGHIJKLMNOP+SECRETTAILabcdef/MORESECRET=';
const out = redactSecrets(input);
expect(out).toBe('[REDACTED]');
expect(out).not.toContain('SECRETTAIL');
expect(out).not.toContain('MORESECRET');
});
it('redacts bearer tokens regardless of scheme casing', () => {
// HTTP auth schemes are case-insensitive (RFC 7235 §2.1).
for (const scheme of ['Bearer', 'bearer', 'BEARER', 'BeArEr']) {
const out = redactSecrets(`authorization: ${scheme} abcdefghijklmnopqrst`);
expect(out, scheme).toBe('authorization: [REDACTED]');
}
});
it('redacts npm tokens that contain dashes', () => {
// npm also issues UUID-shaped tokens. Synthetic, never valid.
const out = redactSecrets('npm_0123456789abcdef-0123-4567-89ab-cdef01234567');
expect(out).toBe('[REDACTED]');
});
it('redacts Gemini and Groq keys', () => {
// Synthetic, correctly-shaped, never valid.
const input = [
'AIzaSyD-9tSrke72PouQMnMX-a7eZSW0jkFMBc',
'gsk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFGH',
].join(' ');
const out = redactSecrets(input);
expect(out).not.toMatch(/AIzaSy[A-Za-z0-9_-]{10,}/);
expect(out).not.toMatch(/gsk_[A-Za-z0-9]{10,}/);
expect(out).toBe('[REDACTED] [REDACTED]');
});
it('redacts a Google key with a prefix other than AIzaSy — regression', () => {
// AIzaSy is the common fifth/sixth pair but not the only one Google
// issues; the pattern must match on AIza alone.
const out = redactSecrets('AIzaBcD1234567890abcdefghijklmnopqrstu');
expect(out).toBe('[REDACTED]');
});
it('leaves ordinary prose alone', () => {
// The patterns are deliberately broad, but not so broad that a run summary
// loses readable text.
for (const text of [
'the bearer of this note',
'Bearer token missing',
'a npm_ package name',
'see the github_pat docs',
'a gsk_ prefixed identifier',
'an AIza-prefixed placeholder',
]) {
expect(redactSecrets(text), text).toBe(text);
}
});
it('transcript and summary are redacted at write time', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'ch-'));
const t = new Transcript(dir);
await t.init();
await t.event('test', { secret: 'sk-abc123DEF456ghi789jkl012' });
const summaryPath = await t.writeSummary({
request: 'uses sk-abc123DEF456ghi789jkl012',
changeId: null,
plan: null,
filesTouched: [],
ercResult: null,
drcResult: null,
decisions: [],
tokensIn: 0,
tokensOut: 0,
outcome: 'success',
openObligations: null,
});
const jsonl = await readFile(t.jsonlPath, 'utf8');
const summary = await readFile(summaryPath, 'utf8');
expect(jsonl).not.toMatch(/sk-[A-Za-z0-9_-]{20,}/);
expect(summary).not.toMatch(/sk-[A-Za-z0-9_-]{20,}/);
});
it('recreates its audit directory when rollback removed it', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'ch-'));
const t = new Transcript(dir);
await t.init();
await rm(t.dir, { recursive: true, force: true });
await t.event('run-failed', { reason: 'repair budget exhausted' });
const summaryPath = await t.writeSummary({
request: 'test rollback recovery',
changeId: null,
plan: null,
filesTouched: [],
ercResult: null,
drcResult: null,
decisions: [],
tokensIn: 0,
tokensOut: 0,
outcome: 'failure',
openObligations: null,
});
expect(await readFile(t.jsonlPath, 'utf8')).toContain('run-failed');
expect(await readFile(summaryPath, 'utf8')).toContain('# Run summary');
});
});
describe('file tools', () => {
it('write_file refuses KiCad files and overwrites', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'ch-'));
await expect(toolWriteFile(dir, 'x.kicad_sch', 'nope')).rejects.toThrow(/refuses KiCad/);
await toolWriteFile(dir, 'a.md', 'hello');
await expect(toolWriteFile(dir, 'a.md', 'again')).rejects.toThrow(/overwrite/);
});
it('edit_file requires a unique anchor with actionable errors', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'ch-'));
await writeFile(path.join(dir, 'f.txt'), 'aaa\nbbb\naaa\n');
await expect(toolEditFile(dir, 'f.txt', 'zzz', 'x')).rejects.toThrow(/not found/);
await expect(toolEditFile(dir, 'f.txt', 'aaa', 'x')).rejects.toThrow(/matched 2 times/);
await toolEditFile(dir, 'f.txt', 'bbb', 'ccc');
expect(await readFile(path.join(dir, 'f.txt'), 'utf8')).toBe('aaa\nccc\naaa\n');
});
it('search finds regex matches with glob filtering', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'ch-'));
await writeFile(path.join(dir, 'a.md'), 'KEY_DAH here');
await writeFile(path.join(dir, 'b.txt'), 'KEY_DAH there');
const all = await toolSearch(dir, 'KEY_DAH');
expect(all).toHaveLength(2);
const mdOnly = await toolSearch(dir, 'KEY_DAH', '**/*.md');
expect(mdOnly).toHaveLength(1);
expect(mdOnly[0]!.file).toBe('a.md');
});
it('isKicadFile covers the design formats', () => {
expect(isKicadFile('a/b.kicad_sch')).toBe(true);
expect(isKicadFile('a/b.kicad_pcb')).toBe(true);
expect(isKicadFile('a/b.md')).toBe(false);
});
});
describe('retry', () => {
it('backs off on 429 then succeeds', async () => {
let calls = 0;
const res = await withRetry(
async () => {
calls++;
if (calls < 3) throw Object.assign(new Error('rate'), { status: 429 });
return 'ok';
},
{ sleep: async () => {} },
);
expect(res).toBe('ok');
expect(calls).toBe(3);
});
it('does not retry non-429 errors', async () => {
let calls = 0;
await expect(
withRetry(
async () => {
calls++;
throw new Error('boom');
},
{ sleep: async () => {} },
),
).rejects.toThrow('boom');
expect(calls).toBe(1);
});
});
describe('git guard (AC-3.8, AC-3.6)', () => {
it('hasCommits distinguishes an unborn HEAD from a committed repo', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'ch-'));
expect(await hasCommits(dir)).toBe(false); // not a repo at all
await execa('git', ['init', '-q'], { cwd: dir });
expect(await hasCommits(dir)).toBe(false); // repo, but no commits yet
const { repo, cleanup } = await tempFixtureRepo();
try {
expect(await hasCommits(repo)).toBe(true);
} finally {
await cleanup();
}
});
it('a rollback gives back untracked work instead of deleting it', async () => {
// Regression: `git stash create` only ever captures tracked changes, so
// restore()'s `git clean -fd` used to wipe every file the user had not
// added yet, with nothing to recover them from. The dirty-tree preflight
// promises the opposite ("copperhead preserve them via git stash create").
const { repo, cleanup } = await tempFixtureRepo();
try {
const sch = path.join(repo, 'hardware', 'open-key.kicad_sch');
const tracked = await readFile(sch, 'utf8');
await writeFile(sch, tracked.replace('KEY_DAH', 'KEY_EDITED'), 'utf8');
await writeFile(path.join(repo, 'hand-written-notes.md'), 'do not lose me\n', 'utf8');
await mkdir(path.join(repo, 'docs'), { recursive: true });
await writeFile(path.join(repo, 'docs', 'new-doc.md'), 'nested and untracked\n', 'utf8');
// The snapshot is taken with the tree already dirty, exactly as a run
// started with --allow-dirty does.
const snap = await snapshot(repo);
await writeFile(sch, tracked.replace('KEY_DAH', 'KEY_RUINED_BY_THE_AGENT'), 'utf8');
await writeFile(path.join(repo, 'agent-scratch.txt'), 'created by the failed run\n', 'utf8');
await restore(repo, snap);
// The user's uncommitted edit and both untracked files come back...
expect(await readFile(sch, 'utf8')).toBe(tracked.replace('KEY_DAH', 'KEY_EDITED'));
expect(await readFile(path.join(repo, 'hand-written-notes.md'), 'utf8')).toBe('do not lose me\n');
expect(await readFile(path.join(repo, 'docs', 'new-doc.md'), 'utf8')).toBe('nested and untracked\n');
// ...and the failed run's own scratch file does not.
expect(existsSync(path.join(repo, 'agent-scratch.txt'))).toBe(false);
} finally {
await cleanup();
}
});
it('a failed untracked restore still rolls back the tracked state', async () => {
// The untracked replay is best-effort: it runs after `stash apply`, so if
// it throws, the tracked rollback is already done and must be kept. A
// corrupt snapshot (tree sha that no longer resolves) is the real-world
// shape of that failure — an unreachable object pruned before rollback.
const { repo, cleanup } = await tempFixtureRepo();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const sch = path.join(repo, 'hardware', 'open-key.kicad_sch');
const tracked = await readFile(sch, 'utf8');
await writeFile(sch, tracked.replace('KEY_DAH', 'KEY_EDITED'), 'utf8');
await writeFile(path.join(repo, 'hand-written-notes.md'), 'do not lose me\n', 'utf8');
const snap = await snapshot(repo);
expect(snap.untracked).not.toBeNull();
await writeFile(sch, tracked.replace('KEY_DAH', 'KEY_RUINED_BY_THE_AGENT'), 'utf8');
// A tree sha that does not resolve: read-tree fails, restoreUntracked throws.
await expect(restore(repo, { ...snap, untracked: '0'.repeat(40) })).resolves.toBeUndefined();
expect(warn.mock.calls.map((c) => String(c[0]))).toContainEqual(
expect.stringContaining('could not restore untracked files after rollback'),
);
// The tracked rollback survived the failure, up to and including the
// user's uncommitted edit replayed from the stash object.
expect(await readFile(sch, 'utf8')).toBe(tracked.replace('KEY_DAH', 'KEY_EDITED'));
// The untracked file is the part that is genuinely lost: pinned so a
// future partial replay cannot pass this test by half-restoring.
expect(existsSync(path.join(repo, 'hand-written-notes.md'))).toBe(false);
} finally {
warn.mockRestore();
await cleanup();
}
});
it('refuses the run, naming the file, when an untracked path cannot be read', async () => {
// Regression: every untracked path went straight into `git update-index`,
// which aborts the whole batch on the first it cannot open. snapshot() runs
// before the first turn, so one stray root-owned or mode-000 file refused
// the run with a bare "fatal: Unable to process path ..." and no hint that
// copperhead was taking a snapshot.
const { repo, cleanup } = await tempFixtureRepo();
try {
await writeFile(path.join(repo, 'tracked-change.txt'), 'x', 'utf8');
const locked = path.join(repo, 'unreadable.bin');
await writeFile(locked, 'secret\n', 'utf8');
await chmod(locked, 0o000);
await expect(snapshot(repo)).rejects.toThrow(PreflightError);
await expect(snapshot(repo)).rejects.toThrow(/unreadable\.bin/);
// The refusal has to be actionable, not just a git error surfaced raw.
await expect(snapshot(repo)).rejects.toThrow(/--allow-dirty/);
// Readable again: the snapshot goes through and captures it.
await chmod(locked, 0o644);
const snap = await snapshot(repo);
const tree = await execa('git', ['ls-tree', '-r', '--name-only', snap.untracked!], { cwd: repo });
expect(tree.stdout.split('\n')).toContain('unreadable.bin');
} finally {
await cleanup();
}
});
it('ignores an untracked file that vanishes between listing and snapshot', async () => {
// The benign half of the same race: a watcher or build step deleting its
// own temp file must not refuse the run, because a file that no longer
// exists cannot be lost to the rollback.
const { repo, cleanup } = await tempFixtureRepo();
try {
await writeFile(path.join(repo, 'keep-me.txt'), 'keep\n', 'utf8');
const doomed = path.join(repo, 'vanishes.tmp');
await writeFile(doomed, 'transient\n', 'utf8');
// Deleted after git listed it, which is what the race amounts to.
const listed = await execa('git', ['ls-files', '--others', '--exclude-standard'], { cwd: repo });
expect(listed.stdout.split('\n')).toContain('vanishes.tmp');
await rm(doomed, { force: true });
const snap = await snapshot(repo);
const tree = await execa('git', ['ls-tree', '-r', '--name-only', snap.untracked!], { cwd: repo });
expect(tree.stdout.split('\n')).toContain('keep-me.txt');
expect(tree.stdout).not.toContain('vanishes.tmp');
} finally {
await cleanup();
}
});
it('does not resurrect gitignored files, which the rollback never deletes', async () => {
// Symmetry check: `ls-files --exclude-standard` skips ignored paths and
// plain `clean -fd` (no -x) leaves them alone, so neither side touches
// them and .env cannot be swept into a snapshot object.
const { repo, cleanup } = await tempFixtureRepo();
try {
await writeFile(path.join(repo, '.env'), 'OPENAI_API_KEY=sk-should-never-be-captured\n', 'utf8');
await writeFile(path.join(repo, 'tracked-change.txt'), 'x', 'utf8');
const snap = await snapshot(repo);
expect(snap.untracked).not.toBeNull();
await restore(repo, snap);
// Untouched on disk, and absent from the captured tree.
expect(await readFile(path.join(repo, '.env'), 'utf8')).toContain('sk-should-never-be-captured');
const tree = await execa('git', ['ls-tree', '-r', '--name-only', snap.untracked!], { cwd: repo });
expect(tree.stdout.split('\n')).toContain('tracked-change.txt');
expect(tree.stdout).not.toContain('.env');
} finally {
await cleanup();
}
});
it('snapshot and restore leave the tree byte-identical', async () => {
const { repo, cleanup } = await tempFixtureRepo();
try {
expect(await isDirty(repo)).toBe(false);
const snap = await snapshot(repo);
const sch = path.join(repo, 'hardware', 'open-key.kicad_sch');
const before = await readFile(sch, 'utf8');
await writeFile(sch, before.replace('KEY_DAH', 'KEY_RUINED'), 'utf8');
await writeFile(path.join(repo, 'junk.txt'), 'junk', 'utf8');
expect(await isDirty(repo)).toBe(true);
await restore(repo, snap);
expect(await isDirty(repo)).toBe(false);
expect(await readFile(sch, 'utf8')).toBe(before);
} finally {
await cleanup();
}
});
it('preserves a staged in-flight audit trail during rollback', async () => {
const { repo, cleanup } = await tempFixtureRepo();
try {
const snap = await snapshot(repo);
const runFile = path.join(repo, '.copperhead', 'runs', 'in-flight', 'transcript.jsonl');
await mkdir(path.dirname(runFile), { recursive: true });
await writeFile(runFile, '{"type":"run-start"}\n', 'utf8');
await execa('git', ['add', '-f', '.copperhead/runs/in-flight/transcript.jsonl'], { cwd: repo });
await restore(repo, snap);
expect(await readFile(runFile, 'utf8')).toBe('{"type":"run-start"}\n');
} finally {
await cleanup();
}
});
it('preserves the audit trail even when rollback fails', async () => {
const { repo, cleanup } = await tempFixtureRepo();
try {
const snap = await snapshot(repo);
const runFile = path.join(repo, '.copperhead', 'runs', 'in-flight', 'transcript.jsonl');
await mkdir(path.dirname(runFile), { recursive: true });
await writeFile(runFile, '{"type":"run-start"}\n', 'utf8');
await execa('git', ['add', '-f', '.copperhead/runs/in-flight/transcript.jsonl'], { cwd: repo });
await expect(restore(repo, { ...snap, stash: 'not-a-stash' })).rejects.toThrow();
expect(await readFile(runFile, 'utf8')).toBe('{"type":"run-start"}\n');
} finally {
await cleanup();
}
});
it('still rolls back when temporary audit backup storage is unavailable', async () => {
const { repo, cleanup } = await tempFixtureRepo();
const originalTmpDir = process.env.TMPDIR;
try {
const snap = await snapshot(repo);
const sch = path.join(repo, 'hardware', 'open-key.kicad_sch');
const before = await readFile(sch, 'utf8');
await writeFile(sch, before.replace('KEY_DAH', 'KEY_RUINED'), 'utf8');
process.env.TMPDIR = path.join(repo, 'missing-temp-directory');
await expect(restore(repo, snap)).resolves.toBeUndefined();
expect(await readFile(sch, 'utf8')).toBe(before);
} finally {
process.env.TMPDIR = originalTmpDir;
await cleanup();
}
});
});
54e85dd7fThis file is pinned to the selected release. Download the original to open it in your design tool.