Files
gbrain/test/bootstrap-subcommand-help.serial.test.ts
T
Masa 154814b095 fix(bootstrap): guard mutating subcommands against --help side effects (#4065)
`gbrain bootstrap <subcommand> --help` (a help token AFTER the subcommand
name, e.g. `gbrain bootstrap uninstall --help`) fell through into the
subcommand's own arg parsing instead of printing help, since none of the
mutating handlers (repo/hooks/verify/attach/uninstall/render/interview)
checked for --help/-h/help themselves. `uninstall --help` ran a real
uninstall; `repo --help` created a real private GitHub repo; etc.

Add a SUBCOMMAND_HELP usage map plus a pre-dispatch hasHelpToken() guard in
runBootstrap so a help token anywhere in the subcommand's args short-circuits
before any lock/runner/engine/handler call. Bare `help` (no dashes) is also
recognized, except for `interview` (its --set KEY value free-text answers
could legitimately be the literal word "help").

New test/bootstrap-subcommand-help.serial.test.ts arms fixtures so the real
operation would reach its side effect if the guard were removed (an
already-rendered workspace for render/hooks/attach, an operational verify
config, an isolated uninstall home with a real receipt-tracked file, a fresh
interview workspace) and asserts nothing mutates.
2026-08-13 06:19:20 -07:00

341 lines
15 KiB
TypeScript

/**
* bootstrap subcommand `--help`/`-h`/`help` guard (src/commands/bootstrap.ts).
*
* Regression test for a safety defect: `gbrain bootstrap <subcommand> --help`
* (a help token AFTER the subcommand name, e.g. `gbrain bootstrap repo
* --help`) fell straight through into the subcommand's own arg parsing, and
* none of the mutating handlers (repo/hooks/verify/attach/uninstall/render/
* interview) checked for a help token themselves. So `--help` did not print
* help — it ran the real operation (private repo creation, MCP/hook
* registration, the verify contract, workspace adoption, receipt-keyed file
* removal, or recording an interview answer).
*
* Each fixture below is deliberately built so the real (non-`--help`)
* operation WOULD reach its dangerous side effect if the guard were removed
* — an operational verify config, an already-initialized attach workspace,
* an isolated uninstall home with a real receipt + a bootstrap-created file
* to survive, a fresh interview workspace to prove `--init` never runs. This
* proves the guard intercepts BEFORE any write/exec call, not merely that
* the operation would have failed anyway on an unprepared fixture.
*
* Serial: mutates GBRAIN_HOME.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runBootstrap } from '../src/commands/bootstrap.ts';
import type { ExecRunner } from '../src/core/bootstrap/repo.ts';
import { interviewStatePath } from '../src/core/bootstrap/interview.ts';
import { receiptPath, writeReceipt, type InstallReceipt } from '../src/core/bootstrap/format.ts';
import { readInstallLog } from '../src/core/bootstrap/status.ts';
import { initState, setAnswer, skipAnswer, confirm, readBackHash } from '../src/core/bootstrap/interview.ts';
let tmpParent: string; // GBRAIN_HOME parent (configDir appends .gbrain)
let home: string;
let ws: string;
let prevHome: string | undefined;
const REQUIRED_ANSWERS: Record<string, string> = {
AGENT_NAME: 'HelpGuard',
PRINCIPAL_NAME: 'Pat Example',
AGENT_PURPOSE: 'Maintain the research corpus and draft the weekly memo without re-briefing.',
AGENT_TOP_JOBS: '- corpus upkeep\n- weekly memo\n- meeting prep',
PRINCIPAL_CONTEXT: 'Runs a small research group; values signal over noise.',
VOICE_REGISTER: 'Direct: three options, the second one wins.',
};
/** Recording fake runner — never spawns anything; asserts zero calls when
* the --help guard is doing its job. */
function makeRunner(): { runner: ExecRunner; calls: string[][] } {
const calls: string[][] = [];
const runner: ExecRunner = async (argv: string[]) => {
calls.push(argv);
return { code: 0, stdout: '', stderr: '' };
};
return { runner, calls };
}
/** Capture console.log + console.error around an async call. */
async function capture<T>(fn: () => Promise<T>): Promise<{ result: T; out: string; err: string }> {
const origLog = console.log;
const origErr = console.error;
let out = '';
let err = '';
console.log = (...args: unknown[]) => {
out += args.map(String).join(' ') + '\n';
};
console.error = (...args: unknown[]) => {
err += args.map(String).join(' ') + '\n';
};
try {
const result = await fn();
return { result, out, err };
} finally {
console.log = origLog;
console.error = origErr;
}
}
beforeAll(async () => {
tmpParent = mkdtempSync(join(tmpdir(), 'gb-subhelp-'));
home = join(tmpParent, '.gbrain');
mkdirSync(home, { recursive: true });
ws = mkdtempSync(join(tmpdir(), 'gb-subhelp-ws-'));
prevHome = process.env.GBRAIN_HOME;
process.env.GBRAIN_HOME = tmpParent;
// Interview: all required answers, confirmed — so a real (non---help)
// render/hooks/repo/attach call on this workspace would succeed, giving
// the --help guard something real to intercept.
expect(initState(ws).ok).toBe(true);
for (const [key, value] of Object.entries(REQUIRED_ANSWERS)) {
const r = setAnswer(ws, key, value);
if (!r.ok) throw new Error(r.message);
}
expect(setAnswer(ws, 'MCP_SCOPE', 'project').ok).toBe(true);
expect(skipAnswer(ws, 'HOOKS_CONSENT').ok).toBe(true);
const h = readBackHash(ws);
if (!h.ok) throw new Error(h.message);
expect(confirm(ws, h.hash).ok).toBe(true);
// Materialize a real render + receipt so render/hooks/attach --help have
// real, pre-existing state they could (but must not) mutate. `ws` now also
// satisfies attach's precondition (an `initialized: true` agent.json).
const render = await capture(() => runBootstrap(['render', '--workspace', ws]));
expect(render.result).toBe(0);
// An "operational" verify config: loadConfig() resolves, so a real
// (non---help) verify would proceed past the "no brain configured" early
// exit into createEngine()/connect() — which would create files under
// `verifyDataDir`. It's never touched if the --help guard works.
const verifyDataDir = join(home, 'verify-brain.pglite');
writeFileSync(join(home, 'config.json'), JSON.stringify({ engine: 'pglite', database_path: verifyDataDir }), 'utf8');
});
afterAll(() => {
if (prevHome === undefined) delete process.env.GBRAIN_HOME;
else process.env.GBRAIN_HOME = prevHome;
rmSync(tmpParent, { recursive: true, force: true });
rmSync(ws, { recursive: true, force: true });
});
describe('bootstrap <subcommand> --help/-h/help never runs the real operation', () => {
test('render --help: usage text, exit 0, receipt + install log untouched', async () => {
const receiptBefore = readFileSync(receiptPath(home), 'utf8');
const mtimeBefore = statSync(receiptPath(home)).mtimeMs;
const logCountBefore = readInstallLog(home, 1000).length;
const r = await capture(() => runBootstrap(['render', '--workspace', ws, '--help']));
expect(r.result).toBe(0);
expect(r.out).toContain('Render identity files');
expect(readFileSync(receiptPath(home), 'utf8')).toBe(receiptBefore);
expect(statSync(receiptPath(home)).mtimeMs).toBe(mtimeBefore);
expect(readInstallLog(home, 1000).length).toBe(logCountBefore);
});
test('repo --help: usage text, exit 0, zero exec calls', async () => {
const { runner, calls } = makeRunner();
const r = await capture(() => runBootstrap(['repo', '--workspace', ws, '--help'], { runner }));
expect(r.result).toBe(0);
expect(r.out).toContain('PRIVATE GitHub repo');
expect(calls.length).toBe(0);
});
test('repo help (bare word, no dashes): usage text, exit 0, zero exec calls', async () => {
const { runner, calls } = makeRunner();
const r = await capture(() => runBootstrap(['repo', '--workspace', ws, 'help'], { runner }));
expect(r.result).toBe(0);
expect(r.out).toContain('PRIVATE GitHub repo');
expect(calls.length).toBe(0);
});
test('hooks --help: usage text, exit 0, zero exec calls, receipt untouched', async () => {
const receiptBefore = readFileSync(receiptPath(home), 'utf8');
const mtimeBefore = statSync(receiptPath(home)).mtimeMs;
const { runner, calls } = makeRunner();
const r = await capture(() =>
runBootstrap(
['hooks', '--workspace', ws, '--harness', 'claude-code', '--gbrain-bin', process.execPath, '--help'],
{ runner },
),
);
expect(r.result).toBe(0);
expect(r.out).toContain('Register MCP');
expect(calls.length).toBe(0);
expect(readFileSync(receiptPath(home), 'utf8')).toBe(receiptBefore);
expect(statSync(receiptPath(home)).mtimeMs).toBe(mtimeBefore);
});
test('verify --help: usage text, exit 0, engine never connects (data dir never created)', async () => {
const verifyDataDir = join(home, 'verify-brain.pglite');
expect(existsSync(verifyDataDir)).toBe(false); // sanity: config points at a path that doesn't exist yet
const r = await capture(() => runBootstrap(['verify', '--workspace', ws, '--help']));
expect(r.result).toBe(0);
expect(r.out).toContain('install contract');
// A real (unguarded) verify would have called engine.connect(), which
// acquires a file lock under the data dir — creating it. It must still
// be absent.
expect(existsSync(verifyDataDir)).toBe(false);
});
test('attach --help: usage text, exit 0, receipt untouched — a real attach here WOULD rewrite it', async () => {
// `ws` already has an `initialized: true` agent.json (from the render
// above) and an existing receipt — attach's real precondition, and the
// exact case its own docstring says it does NOT refuse (it merges into
// the existing receipt instead). So an unguarded attach --help here
// would visibly touch the receipt.
const receiptBefore = readFileSync(receiptPath(home), 'utf8');
const mtimeBefore = statSync(receiptPath(home)).mtimeMs;
const r = await capture(() => runBootstrap(['attach', '--workspace', ws, '--help']));
expect(r.result).toBe(0);
expect(r.out).toContain('adopt a cloned agent workspace');
expect(readFileSync(receiptPath(home), 'utf8')).toBe(receiptBefore);
expect(statSync(receiptPath(home)).mtimeMs).toBe(mtimeBefore);
});
describe('uninstall --help (isolated home + a real receipt-tracked file that must survive)', () => {
function seedUninstallFixture(): { uninstallWs: string; isolatedHome: string; dummyCreatedPath: string } {
const uninstallWs = mkdtempSync(join(tmpdir(), 'gb-subhelp-uninstall-ws-'));
const isolatedHome = join(uninstallWs, '.gbrain');
mkdirSync(join(isolatedHome, 'brain.pglite'), { recursive: true });
mkdirSync(join(isolatedHome, 'bootstrap'), { recursive: true });
writeFileSync(join(isolatedHome, 'config.json'), '{"engine":"pglite"}', 'utf8');
const dummyCreatedPath = join(uninstallWs, 'DUMMY_CREATED.md');
writeFileSync(dummyCreatedPath, 'created-by-bootstrap — must survive --help', 'utf8');
const receipt: InstallReceipt = {
receipt_version: 1,
workspace_dir: uninstallWs,
source_id: 'workspace',
agent_name: 'Uninstall Help Test',
created_at: '2026-01-01T00:00:00.000Z',
created_by: '0.0.0-test',
brain_created_by_bootstrap: false,
created_paths: [dummyCreatedPath],
registrations: [],
};
writeReceipt(isolatedHome, receipt);
return { uninstallWs, isolatedHome, dummyCreatedPath };
}
test('--help: usage text, exit 0, zero exec calls, receipt-tracked file + receipt survive', async () => {
const { uninstallWs, isolatedHome, dummyCreatedPath } = seedUninstallFixture();
try {
const receiptBefore = readFileSync(receiptPath(isolatedHome), 'utf8');
const { runner, calls } = makeRunner();
const r = await capture(() =>
runBootstrap(['uninstall', '--workspace', uninstallWs, '--home', isolatedHome, '--help'], { runner }),
);
expect(r.result).toBe(0);
expect(r.out).toContain('Receipt-keyed removal');
expect(calls.length).toBe(0);
expect(existsSync(dummyCreatedPath)).toBe(true);
expect(readFileSync(receiptPath(isolatedHome), 'utf8')).toBe(receiptBefore);
} finally {
rmSync(uninstallWs, { recursive: true, force: true });
}
});
test('-h: same interception (the highest-risk subcommand, short flag spelling)', async () => {
const { uninstallWs, isolatedHome, dummyCreatedPath } = seedUninstallFixture();
try {
const { runner, calls } = makeRunner();
const r = await capture(() =>
runBootstrap(['uninstall', '--workspace', uninstallWs, '--home', isolatedHome, '-h'], { runner }),
);
expect(r.result).toBe(0);
expect(r.out).toContain('Receipt-keyed removal');
expect(calls.length).toBe(0);
expect(existsSync(dummyCreatedPath)).toBe(true);
} finally {
rmSync(uninstallWs, { recursive: true, force: true });
}
});
test('help (bare word): same interception', async () => {
const { uninstallWs, isolatedHome, dummyCreatedPath } = seedUninstallFixture();
try {
const { runner, calls } = makeRunner();
const r = await capture(() =>
runBootstrap(['uninstall', '--workspace', uninstallWs, '--home', isolatedHome, 'help'], { runner }),
);
expect(r.result).toBe(0);
expect(r.out).toContain('Receipt-keyed removal');
expect(calls.length).toBe(0);
expect(existsSync(dummyCreatedPath)).toBe(true);
} finally {
rmSync(uninstallWs, { recursive: true, force: true });
}
});
});
describe('interview + help (Warning: help combined with a real action flag)', () => {
test('interview --init --help: usage text, exit 0, state/interview.json never created', async () => {
const freshWs = mkdtempSync(join(tmpdir(), 'gb-subhelp-interview-'));
try {
expect(existsSync(interviewStatePath(freshWs))).toBe(false);
const r = await capture(() => runBootstrap(['interview', '--workspace', freshWs, '--init', '--help']));
expect(r.result).toBe(0);
expect(r.out).toContain('Create/record/read interview state');
expect(existsSync(interviewStatePath(freshWs))).toBe(false);
} finally {
rmSync(freshWs, { recursive: true, force: true });
}
});
test('interview --set KEY value --help: usage text, exit 0, no answer recorded', async () => {
const freshWs = mkdtempSync(join(tmpdir(), 'gb-subhelp-interview-'));
try {
expect(initState(freshWs).ok).toBe(true);
const r = await capture(() =>
runBootstrap(['interview', '--workspace', freshWs, '--set', 'AGENT_NAME', 'ShouldNotRecord', '--help']),
);
expect(r.result).toBe(0);
expect(r.out).toContain('Create/record/read interview state');
const raw = JSON.parse(readFileSync(interviewStatePath(freshWs), 'utf8')) as { answers: Record<string, unknown> };
expect(raw.answers['AGENT_NAME']).toBeUndefined();
} finally {
rmSync(freshWs, { recursive: true, force: true });
}
});
test('interview --set VOICE_REGISTER "help" (bare word as a legitimate answer VALUE, no --help flag): still records — proves the bare-word exclusion for interview', async () => {
const freshWs = mkdtempSync(join(tmpdir(), 'gb-subhelp-interview-'));
try {
expect(initState(freshWs).ok).toBe(true);
const r = await capture(() => runBootstrap(['interview', '--workspace', freshWs, '--set', 'VOICE_REGISTER', 'help']));
expect(r.result).toBe(0);
expect(r.out).toContain('recorded');
const raw = JSON.parse(readFileSync(interviewStatePath(freshWs), 'utf8')) as { answers: Record<string, { value?: string }> };
expect(raw.answers['VOICE_REGISTER']?.value).toBe('help');
} finally {
rmSync(freshWs, { recursive: true, force: true });
}
});
});
});