mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 14:34:42 +01:00
fix: the CEO-only report-path guard compares real paths and refuses at the finish when the report file is the plan
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
ba50ed9b1d
commit
bce4b77fa1
2 files changed
+35
-2
No files matched your search
@@ -42,7 +42,8 @@ export function checkCeoOnlyFlags(args: readonly string[], plan: string | undefi
|
||||
if (!args.includes(CEO_ONLY_FLAG)) return false;
|
||||
const conflicts = CEO_ONLY_CONFLICTS.filter(f => args.includes(f) || tokens.includes(f));
|
||||
if (conflicts.length) throw new RunError('CEO_ONLY_CONFLICT', `${CEO_ONLY_FLAG} with ${conflicts.join(', ')}`, 3);
|
||||
if (plan && out && path.resolve(plan) === path.resolve(out, CEO_ONLY_REPORT)) throw new RunError('CEO_ONLY_CONFLICT', `the plan ${plan} is the report path`, 3);
|
||||
const real = (p: string) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
|
||||
if (plan && out && real(plan) === path.join(real(out), CEO_ONLY_REPORT)) throw new RunError('CEO_ONLY_CONFLICT', `the plan ${plan} is the report path`, 3);
|
||||
if (existing && !existing.ceo_only) throw new RunError('CEO_ONLY_AFTER_INIT', `${existing.out} holds full run ${existing.run} (status ${existing.status})`, 3);
|
||||
return true;
|
||||
}
|
||||
@@ -55,6 +56,10 @@ export function refuseCeoOnly(state: RunState, verb: string): void {
|
||||
/** Every phase closed: a full run writes its gate (carry lines follow); a CEO-only run writes its report and ends reviewed. */
|
||||
export function finishRun(state: RunState): string[] {
|
||||
if (!state.ceo_only) { finishGate(state); return carryLines(state); }
|
||||
const report = path.join(state.out, CEO_ONLY_REPORT);
|
||||
const at = fs.existsSync(report) && !fs.lstatSync(report).isSymbolicLink() ? fs.statSync(report) : undefined;
|
||||
const plan = [state.plan_source, state.plan_input, state.active_plan].find(p => p && at && fs.existsSync(p) && fs.statSync(p).ino === at.ino && fs.statSync(p).dev === at.dev);
|
||||
if (plan) throw new RunError('CEO_ONLY_CONFLICT', `the plan ${plan} is the report path ${report}`, 3);
|
||||
const list = buildGate(state, 1);
|
||||
const items = list.items.filter(i => i.id !== 'p1');
|
||||
const kept = (item: GateItem) => (item.cap && item.kind === 'approval' ? null : keepDirection(item));
|
||||
@@ -63,7 +68,6 @@ export function finishRun(state: RunState): string[] {
|
||||
state.status = 'reviewed';
|
||||
exportRun(state);
|
||||
const findings = aggregateFindings(state);
|
||||
const report = path.join(state.out, CEO_ONLY_REPORT);
|
||||
const tmp = `${report}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
||||
fs.writeFileSync(tmp, renderReport(state, items, rows, findings), { flag: 'wx' });
|
||||
fs.renameSync(tmp, report);
|
||||
|
||||
@@ -227,6 +227,35 @@ describe('the CEO-only finish records no answer nobody gave and writes only its
|
||||
expect(fs.readFileSync(plan, 'utf8')).toBe(SMALL_PLAN);
|
||||
});
|
||||
|
||||
test('a plan that reaches the report path through a symlink, or through a symlinked --out, refuses too', () => {
|
||||
const real = path.join(dir('alias'), 'real-run');
|
||||
fs.mkdirSync(real, { recursive: true });
|
||||
const report = path.join(real, 'ceo-review.md');
|
||||
fs.writeFileSync(report, SMALL_PLAN);
|
||||
const link = path.join(dir('alias'), 'input.md');
|
||||
fs.symlinkSync(report, link);
|
||||
const linkedOut = path.join(dir('alias'), 'linked-run');
|
||||
fs.symlinkSync(real, linkedOut);
|
||||
for (const [plan, out] of [[link, real], [report, linkedOut]]) {
|
||||
const r = autoplan(['next', '--out', out!, '--plan', plan!, '--no-ui', '--ceo-only']);
|
||||
expect(r.status, plan).toBe(3);
|
||||
expect(r.stderr).toContain('(CEO_ONLY_CONFLICT)');
|
||||
expect(fs.existsSync(path.join(real, 'run.json'))).toBe(false);
|
||||
}
|
||||
expect(fs.readFileSync(report, 'utf8')).toBe(SMALL_PLAN);
|
||||
});
|
||||
|
||||
test('a plan that became the report file after init (a hard link) refuses at the finish and stays intact', () => {
|
||||
const out = startSmallRun(dir('hardlink'), ['--ceo-only']);
|
||||
const plan = path.join(dir('hardlink'), 'plan.md');
|
||||
fs.linkSync(plan, path.join(out, 'ceo-review.md'));
|
||||
const submit = runPhase(out, 'ceo').outside;
|
||||
expect(submit.status).toBe(3);
|
||||
expect(submit.stderr).toContain('(CEO_ONLY_CONFLICT)');
|
||||
expect(fs.readFileSync(plan, 'utf8')).toBe(SMALL_PLAN);
|
||||
expect(readRun(out).status).not.toBe('reviewed');
|
||||
});
|
||||
|
||||
test('a symlink at the report path is replaced, never written through', () => {
|
||||
const out = startSmallRun(dir('symlink'), ['--ceo-only']);
|
||||
const victim = path.join(dir('symlink'), 'victim.txt');
|
||||
|
||||
Reference in new issue
Block a user