/**
* Field list 2, W6: `gstack-autoplan export --public
` writes a copy of a
* run with its recorded machine roots rewritten, never touching the private
* run, and `gstack-artifact validate --public [--private ]`
* fails on a recorded root (file:line) and on provenance drift, and WARNs on
* generic home paths. The regression shape is gbrain-evals #164: a committed
* plan that cited the run directory and the author's home in prose. Runs on
* the simulated Capy host; every spawn has a timeout.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { LOCAL_NOTES, rewriteRoots } from '../lib/autoplan-public';
import { ROOT, SMALL_PLAN } from './helpers/autoplan-run-fixture';
import { capyRunner, phaseVia, readRunJson, writePlan } from './helpers/autoplan-capy';
const r = capyRunner();
afterAll(() => r.cleanup());
const ARTIFACT = path.join(ROOT, 'bin', 'gstack-artifact');
const artifact = (args: string[]) => spawnSync('bun', [ARTIFACT, ...args], { cwd: ROOT, env: r.env, encoding: 'utf8', timeout: 60_000 });
let n = 0;
/** A gated run whose plan cites its own run directory and the author's home in prose (gbrain-evals #164). */
function gatedRun(extra = ''): { out: string; dir: string } {
const dir = path.join(r.host.base, `pub${++n}`);
const out = path.join(dir, 'run');
const text = SMALL_PLAN.replace('## Review record', `Run notes live in ${out}/plan-active.md and ${r.host.home}/notes/retry.md.${extra}\n\n## Review record`);
const plan = writePlan(dir, text);
const first = r.ap(['next', '--out', out, '--plan', plan, '--no-ui']);
if (first.status !== 0) throw new Error(first.stderr);
phaseVia(r, out, 'ceo', { native: [{ severity: 'High', title: 'Notes path leaks', file: path.join(out, 'plan-active.md'), line: 3 }], outside: [] });
phaseVia(r, out, 'eng', { native: [], outside: [] });
expect(r.ap(['export', '--out', out]).status).toBe(0);
return { out, dir };
}
const digest = (dir: string) => Object.fromEntries(fs.readdirSync(dir, { recursive: true, withFileTypes: true }).filter(e => e.isFile() && e.name !== 'run.lock')
.map(e => { const f = path.join(e.parentPath, e.name); return [path.relative(dir, f), crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex')]; }));
const allText = (dir: string) => fs.readdirSync(dir).map(f => fs.readFileSync(path.join(dir, f), 'utf8')).join('\n');
describe('export --public', () => {
test('rewrites recorded roots, leaves the private run byte-identical and resumable, and validates', () => {
const { out, dir } = gatedRun();
const before = digest(out);
const pub = path.join(dir, 'public');
const e = r.ap(['export', '--out', out, '--public', pub]);
expect(e.status, e.stderr).toBe(0);
expect(e.stdout).toContain(`EXPORTED_PUBLIC: ${path.join(pub, 'plan.md')}`);
expect(digest(out)).toEqual(before);
const text = allText(pub);
expect(text).not.toContain(out);
expect(text).not.toContain(r.host.home);
expect(text).not.toContain(dir);
expect(fs.readFileSync(path.join(pub, 'plan.md'), 'utf8')).toContain('Run notes live in /plan-active.md and /notes/retry.md.');
const manifest = JSON.parse(fs.readFileSync(path.join(pub, 'run.json'), 'utf8'));
expect(manifest.provenance.private_run).toBe(readRunJson(out).run);
expect(manifest.provenance.files['plan.md'].private_sha256).toBe(readRunJson(out).artifacts['plan.md'].sha256);
expect(manifest.artifacts['plan.md'].sha256).toBe(manifest.provenance.files['plan.md'].public_sha256);
const full = artifact(['validate', '--public', pub, '--private', path.join(out, 'run.json')]);
expect(full.status, full.stderr).toBe(0);
expect(full.stdout).toMatch(/^ARTIFACT_VALID: public run=\S+ warnings=0$/m);
const standalone = artifact(['validate', '--public', pub]);
expect(standalone.status, standalone.stderr).toBe(0);
expect(standalone.stdout).toContain('recorded roots unchecked: pass --private on the exporting machine');
const resumed = r.ap(['resume', '--out', out]);
expect(resumed.stdout).toContain('status=gate_pending');
expect(r.validate(out).status).toBe(0);
});
test('validate --public fails on a recorded root with file:line, and on provenance drift without --private', () => {
const { out, dir } = gatedRun();
const pub = path.join(dir, 'public');
expect(r.ap(['export', '--out', out, '--public', pub]).status).toBe(0);
const record = path.join(pub, 'review-record.md');
const lines = fs.readFileSync(record, 'utf8').split('\n').length;
fs.appendFileSync(record, `see ${out}/attempts.jsonl\n`);
const full = artifact(['validate', '--public', pub, '--private', path.join(out, 'run.json')]);
expect(full.status).toBe(1);
expect(full.stderr).toContain(`at review-record.md:${lines}`);
expect(full.stderr).toMatch(/the recorded run root \S+ appears in the public copy at review-record\.md:\d+\).*\(ARTIFACT_MACHINE_PATH\)$/m);
const standalone = artifact(['validate', '--public', pub]);
expect(standalone.status).toBe(1);
expect(standalone.stderr).toMatch(/\(ARTIFACT_STALE\)$/m);
});
test('a recorded root inside plan.md code is never rewritten: export refuses and writes nothing; generic home paths WARN', () => {
const { out, dir } = gatedRun(`\n\n\`\`\`bash\ncat ${r.host.home}/notes/retry.md\n\`\`\`\n\nThe old box kept them in /home/someone/notes.`);
const pub = path.join(dir, 'public');
const e = r.ap(['export', '--out', out, '--public', pub]);
expect(e.status).toBe(3);
expect(e.stderr).toMatch(/plan\.md:\d+: the recorded home root .* \(inside code, which export never rewrites: edit the plan\).*\(ARTIFACT_MACHINE_PATH\)$/m);
expect(fs.existsSync(pub)).toBe(false);
expect(fs.readdirSync(dir).filter(f => f.includes('.public-'))).toEqual([]);
const ok = gatedRun('\n\nThe old box kept them in /home/someone/notes.');
const pub2 = path.join(ok.dir, 'public');
const warned = r.ap(['export', '--out', ok.out, '--public', pub2]);
expect(warned.status, warned.stderr).toBe(0);
expect(warned.stderr).toMatch(/^WARN: plan\.md:\d+ has a home-directory path \(\/home\/someone\/\)/m);
const taken = path.join(ok.dir, 'taken');
fs.mkdirSync(taken);
fs.writeFileSync(path.join(taken, 'keep.txt'), 'x');
const refused = r.ap(['export', '--out', ok.out, '--public', taken]);
expect(refused.status).toBe(3);
expect(refused.stderr).toMatch(/\(ARTIFACT_PUBLIC_TARGET_EXISTS\)$/m);
expect(r.ap(['export', '--out', ok.out, '--public', pub2]).status).toBe(0);
});
});
describe('export --public path containment', () => {
test('an artifact path that leaves the run directory refuses before anything is written, outside or inside --public', () => {
const { out, dir } = gatedRun();
const text = 'synthetic artifact text\n';
fs.writeFileSync(path.join(dir, 'sentinel.txt'), text);
const victim = path.join(r.host.base, `victim${n}.txt`);
fs.writeFileSync(victim, 'unrelated owner file\n');
const state = readRunJson(out);
state.artifacts['escape.txt'] = { path: `../../${path.basename(victim)}`, sha256: crypto.createHash('sha256').update(text).digest('hex') };
fs.writeFileSync(path.join(out, 'run.json'), JSON.stringify(state) + '\n');
const pub = path.join(dir, 'public');
const x = r.ap(['export', '--out', out, '--public', pub]);
expect(x.status, x.stdout + x.stderr).toBe(3);
expect(x.stderr).toContain('ARTIFACT_PATH_ESCAPE');
expect(fs.readFileSync(victim, 'utf8')).toBe('unrelated owner file\n');
expect(fs.existsSync(pub)).toBe(false);
});
});
describe('rewriteRoots', () => {
test('a worktree path becomes repo-relative; a local root nested in the worktree still reads as local notes', () => {
const roots = [{ label: 'run', root: '/w/repo/.context/run' }, { label: 'home', root: '/home/u' }, { label: 'worktree', root: '/w/repo' }];
expect(rewriteRoots('see /w/repo/lib/a.ts and /w/repo', roots)).toBe('see lib/a.ts and .');
expect(rewriteRoots('notes at /home/u/notes.md', roots)).toBe(`notes at ${LOCAL_NOTES}/notes.md`);
expect(rewriteRoots('/w/repo/.context/run/run.json', roots)).toBe('.context/run/run.json');
});
});