mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
* fix(bootstrap): codex scope-note guard + scope-aware wire hint
runHooks: on --harness codex, print a stderr note when an explicit,
non-skipped MCP_SCOPE=project answer is persisted (raw readInterviewState
read, not the project-defaulting consentAnswer resolver) — reachable via
attach from a Claude Code machine or a pre-fix install. consentAnswer is
now shape-tolerant: a hand-edited non-string answer value falls through to
the bank default instead of throwing at a toLowerCase call site.
status.ts: the wire-phase resume_hint states the scope rule (Claude Code
consent, phase 3; Codex always user-global — no scope flag).
Tests: 7-case branch matrix on the note guard + wire-hint pin.
* fix(bootstrap): harness-scope the MCP consent prose (never offer project scope on Codex)
Runbook: the scope consent moves to phase 3 (Claude Code only, recorded with
interview --set BEFORE the read-back so the confirmation covers it — a
wire-time set clears the A8 confirm and regresses status); phase 6's Codex
bullet gains the counter-signal: Do NOT offer an MCP scope choice — codex
mcp add has no scope flag, registrations are always user-global.
Bank: MCP_SCOPE.question gets a read-aloud-safe "(Claude Code only. ...)"
prefix and phase moves wire -> interview to match.
Templates: ACCESS_POLICY's scope section becomes two static harness
paragraphs; CLAUDE.md stops conflating directory-based identity loading
with MCP scope. Vendored template-repo regenerated.
* chore(ci): pin the harness-scoping counter-signals (check-bootstrap-templates §e)
Three SKIP-GRACEFUL tripwires: the runbook must carry "Do NOT offer an MCP
scope choice" and "Claude Code only"; questions.json's MCP_SCOPE.question
must start with "(Claude Code only". Guard-test fixtures gain a compliant
MCP_SCOPE entry + pass/fail cases for each pin.
* docs: Codex user-global scope caveats + A8 consent-semantics follow-up TODO
Install table and KEY_FILES hooks entry now state the split: Claude Code
takes --scope (project default); Codex has no scope flag. TODOS gains the
deferred structural question (consent keys vs the A8 confirm gate).
* fix: pre-landing + red-team review hardening
Guard §(e): a valid-JSON bank missing its questions object now FAILS (it
silently passed both §a and §e); the pin also asserts MCP_SCOPE.phase is
'interview' so the schema half of the fix can't silently revert. Fixtures
for both + entry-vanished. consentAnswer says so on stderr when it discards
a malformed answer value (a silent fall-through could flip a damaged opt-out
to the permissive default) — pinned by a claude-code matrix case asserting
the receipt. Off-ramp texts gain the narrow `codex mcp remove gbrain`
alongside full uninstall (note, runbook, ACCESS_POLICY + vendored copy).
Cross-refs de-ordinaled (phase names, not numbers — master renumbers steps).
TODOS A8 entry gains the healing half (status can't distinguish consent-key
invalidation from tampering).
* fix: adversarial-review hardening — fail-closed consents + honest scope prose
consentAnswer: a present-but-unusable answer (non-string, empty, bare {})
now fails CLOSED to 'no' with a stderr note — a bank-default fall-through
could flip a damaged opt-out into consent (cross-model finding); 'no' is
every consent key's safe reading (no hooks, no cron, project scope). Pinned
by a malformed-HOOKS_CONSENT matrix case asserting hooks are declined.
Codex scope note: names the safe remediation (--skip MCP_SCOPE + reconfirm;
never --set user — the answer git-syncs to paired Claude Code machines and
would widen their scope) [codex review P2]. Prose states the user-global
registration's read+write reach honestly (runbook, ACCESS_POLICY + vendored).
Interview resume_hint carries the consent-recording step (the CLI channel a
resumed install actually reads). Guard §(e): unparseable questions.json now
FAILS instead of passing silently. TODOS: P1 filed for stdio dispatch scope
parity (pre-existing, surfaced by the adversarial pass).
* chore: bump version and changelog (v0.45.3.0)
Runbook stamp + vendored template-repo regenerated to match (the two
version gates check:bootstrap-tag and check:bootstrap-templates enforce).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: update project documentation for v0.45.3.0
KEY_FILES.md: bootstrap.ts entry carries the fail-closed consent
resolution + Codex stale-scope-answer note; CI-guards entry adds the
check-bootstrap-templates §(e) harness-scoping counter-signal pins.
bootstrap.md: degradation matrix Codex row states the no-scope-flag
reality (registrations are user-global). llms bundles regenerated
(byte-identical — these docs are linked, not inlined).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs: cross-model doc-review fixes for v0.45.3.0
CHANGELOG: Codex off-ramps are removal commands, not narrowing; the
refresh recipe now covers both harnesses and names the re-confirm step
for pre-fix installs whose confirmation was invalidated. Runbook: the
user-scope tradeoff says read and write, matching ACCESS_POLICY.
bootstrap.md: degradation-matrix Codex row rephrased (lose the ability
to confine reach, not the reach itself). KEY_FILES: the section-(e)
description no longer implies placement pinning.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
366 lines
14 KiB
TypeScript
366 lines
14 KiB
TypeScript
/**
|
|
* bootstrap status — phase list [D5], install log [B1], runbook skew [C1],
|
|
* support blob [B5], and the CLI reachability membership asserts (#2035
|
|
* precedent, extended per ENG-2 for the bootstrap family).
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import { runBootstrap } from '../src/commands/bootstrap.ts';
|
|
|
|
import { CLI_ONLY, THIN_CLIENT_REFUSED_COMMANDS } from '../src/cli.ts';
|
|
import { VERSION } from '../src/version.ts';
|
|
import {
|
|
BOOTSTRAP_PHASE_IDS,
|
|
PHASES,
|
|
appendInstallLog,
|
|
readInstallLog,
|
|
installLogPath,
|
|
readRunbookStamp,
|
|
listVerifyRuns,
|
|
statusReport,
|
|
} from '../src/core/bootstrap/status.ts';
|
|
import { initState, setAnswer, confirm, readBackHash } from '../src/core/bootstrap/interview.ts';
|
|
import { writeManifest } from '../src/core/bootstrap/format.ts';
|
|
import { loadQuestionBank } from '../src/core/bootstrap/assets.ts';
|
|
|
|
let tmpParent: string; // GBRAIN_HOME parent (configDir appends .gbrain)
|
|
let home: string;
|
|
let ws: string;
|
|
let prevHome: string | undefined;
|
|
|
|
beforeAll(() => {
|
|
tmpParent = mkdtempSync(join(tmpdir(), 'gb-status-'));
|
|
home = join(tmpParent, '.gbrain');
|
|
mkdirSync(home, { recursive: true });
|
|
ws = mkdtempSync(join(tmpdir(), 'gb-status-ws-'));
|
|
prevHome = process.env.GBRAIN_HOME;
|
|
process.env.GBRAIN_HOME = tmpParent;
|
|
});
|
|
|
|
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('phase list is the single TS source of truth [D5]', () => {
|
|
test('PHASES matches BOOTSTRAP_PHASE_IDS exactly, in order', () => {
|
|
expect(PHASES.map((p) => p.id)).toEqual([...BOOTSTRAP_PHASE_IDS]);
|
|
expect(BOOTSTRAP_PHASE_IDS).toEqual([
|
|
'preflight', 'engine', 'interview', 'render', 'skills', 'wire', 'repo', 'verify',
|
|
]);
|
|
});
|
|
|
|
test('every phase carries a title, a resume hint, and a detector', () => {
|
|
for (const p of PHASES) {
|
|
expect(p.title.length).toBeGreaterThan(0);
|
|
expect(p.resume_hint.length).toBeGreaterThan(0);
|
|
expect(typeof p.detect).toBe('function');
|
|
}
|
|
});
|
|
|
|
test('wire hint carries the harness scope rule (Claude Code consent vs Codex user-global)', () => {
|
|
const wire = PHASES.find((p) => p.id === 'wire');
|
|
expect(wire?.resume_hint).toContain('MCP scope consent is Claude Code only');
|
|
expect(wire?.resume_hint).toContain('Codex registrations are always user-global (no scope flag)');
|
|
});
|
|
});
|
|
|
|
describe('CLI reachability membership (#2035 shape, ENG-2)', () => {
|
|
test('bootstrap, hook, and sweep are in CLI_ONLY so dispatch reaches them', () => {
|
|
expect(CLI_ONLY.has('bootstrap')).toBe(true);
|
|
expect(CLI_ONLY.has('hook')).toBe(true);
|
|
expect(CLI_ONLY.has('sweep')).toBe(true);
|
|
});
|
|
|
|
test('bootstrap and hook are NOT thin-client refused; sweep IS (local engine only)', () => {
|
|
expect(THIN_CLIENT_REFUSED_COMMANDS.has('bootstrap')).toBe(false);
|
|
expect(THIN_CLIENT_REFUSED_COMMANDS.has('hook')).toBe(false);
|
|
expect(THIN_CLIENT_REFUSED_COMMANDS.has('sweep')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('install log [B1]', () => {
|
|
test('append + read round-trip, torn lines skipped, never throws', () => {
|
|
appendInstallLog(home, {
|
|
ts: new Date().toISOString(),
|
|
phase: 'render',
|
|
outcome: 'ok',
|
|
duration_ms: 12,
|
|
binary_version: VERSION,
|
|
workspace: ws,
|
|
});
|
|
// Torn line in the middle must not break the reader.
|
|
writeFileSync(installLogPath(home), '{"half\n', { flag: 'a' });
|
|
appendInstallLog(home, {
|
|
ts: new Date().toISOString(),
|
|
phase: 'wire',
|
|
outcome: 'error',
|
|
duration_ms: 5,
|
|
binary_version: VERSION,
|
|
harness: 'claude-code',
|
|
workspace: ws,
|
|
});
|
|
const entries = readInstallLog(home);
|
|
expect(entries.length).toBe(2);
|
|
expect(entries[0].phase).toBe('render');
|
|
expect(entries[1].phase).toBe('wire');
|
|
expect(entries[1].harness).toBe('claude-code');
|
|
});
|
|
|
|
test('reader returns [] for a missing log', () => {
|
|
expect(readInstallLog(join(tmpdir(), 'no-such-gbrain-home'))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('runbook stamp [C1]', () => {
|
|
test('reads the stamp comment; null when absent', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'gb-runbook-'));
|
|
expect(readRunbookStamp(dir)).toBeNull();
|
|
writeFileSync(join(dir, 'BOOTSTRAP_FOR_AGENTS.md'), '<!-- gbrain-runbook-stamp: 0.0.1 -->\n# runbook\n');
|
|
expect(readRunbookStamp(dir)).toBe('0.0.1');
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe('statusReport detection + support blob [B5]', () => {
|
|
test('fresh workspace: engine/interview/render/verify pending, next action present', async () => {
|
|
const report = await statusReport(ws, { gbrainHomeDir: home });
|
|
const byId = new Map(report.phases.map((p) => [p.id, p]));
|
|
expect(byId.get('engine')!.state).toBe('pending');
|
|
expect(byId.get('interview')!.state).toBe('pending');
|
|
expect(byId.get('render')!.state).toBe('pending');
|
|
expect(byId.get('verify')!.state).toBe('pending');
|
|
expect(report.next).toBeTruthy();
|
|
expect(report.support.binary_version).toBe(VERSION);
|
|
expect(report.support.engine).toBeNull();
|
|
expect(report.support.harness_registrations).toEqual([]);
|
|
});
|
|
|
|
test('artifacts flip phases: config → engine done; confirmed interview → done; manifest → render done', async () => {
|
|
// engine: config file in the sandboxed home.
|
|
writeFileSync(join(home, 'config.json'), JSON.stringify({ engine: 'pglite', database_path: join(home, 'brain.pglite') }));
|
|
|
|
// interview: answer every required key, then confirm the read-back hash.
|
|
const bank = loadQuestionBank();
|
|
const required = bank.interviewKeys.filter((k) => bank.questions[k]?.required === true);
|
|
expect(initState(ws).ok).toBe(true);
|
|
for (const key of required) {
|
|
const r = setAnswer(ws, key, `verbatim answer for ${key} with enough substance to be real`);
|
|
expect(r.ok).toBe(true);
|
|
}
|
|
const h = readBackHash(ws);
|
|
if (!h.ok) throw new Error('readBackHash failed');
|
|
expect(confirm(ws, h.hash).ok).toBe(true);
|
|
|
|
// render: initialized manifest.
|
|
writeManifest(ws, {
|
|
format_version: 1,
|
|
initialized: true,
|
|
agent_name: 'Testy',
|
|
created_by: 'test',
|
|
created_at: new Date().toISOString(),
|
|
source_id: 'workspace',
|
|
});
|
|
|
|
// verify: one persisted failing snapshot → verify phase partial.
|
|
mkdirSync(join(home, 'bootstrap'), { recursive: true });
|
|
writeFileSync(
|
|
join(home, 'bootstrap', 'verify-2026-01-01T00-00-00-000Z.json'),
|
|
JSON.stringify({ ts: '2026-01-01T00:00:00.000Z', ok: false, checks: [{ id: 'roundtrip', ok: false, detail: 'x' }] }),
|
|
);
|
|
|
|
const report = await statusReport(ws, { gbrainHomeDir: home });
|
|
const byId = new Map(report.phases.map((p) => [p.id, p]));
|
|
expect(byId.get('engine')!.state).toBe('done');
|
|
expect(byId.get('interview')!.state).toBe('done');
|
|
expect(byId.get('render')!.state).toBe('done');
|
|
expect(byId.get('verify')!.state).toBe('partial');
|
|
expect(report.support.engine).toBe('pglite');
|
|
expect(report.support.last_verify).toEqual({
|
|
ts: '2026-01-01T00:00:00.000Z',
|
|
ok: false,
|
|
checks_failed: ['roundtrip'],
|
|
});
|
|
// listVerifyRuns agrees with the support blob.
|
|
const runs = listVerifyRuns(home);
|
|
expect(runs.length).toBe(1);
|
|
expect(runs[0].ok).toBe(false);
|
|
});
|
|
|
|
test('runbook skew surfaces when the stamp disagrees with the binary', async () => {
|
|
writeFileSync(join(ws, 'BOOTSTRAP_FOR_AGENTS.md'), '<!-- gbrain-runbook-stamp: 0.0.1 -->\n');
|
|
const report = await statusReport(ws, { gbrainHomeDir: home });
|
|
expect(report.runbookSkew).toEqual({ runbookStamp: '0.0.1', binaryVersion: VERSION });
|
|
|
|
// Matching stamp → no skew reported.
|
|
writeFileSync(join(ws, 'BOOTSTRAP_FOR_AGENTS.md'), `<!-- gbrain-runbook-stamp: ${VERSION} -->\n`);
|
|
const clean = await statusReport(ws, { gbrainHomeDir: home });
|
|
expect(clean.runbookSkew).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('template-door privacy gate (recording gh shim)', () => {
|
|
let tws: string; // template-clone workspace with an origin remote
|
|
let shimDir: string;
|
|
let recordFile: string;
|
|
let savedPath: string | undefined;
|
|
let savedRecord: string | undefined;
|
|
|
|
beforeAll(() => {
|
|
tws = mkdtempSync(join(tmpdir(), 'gb-status-tmpl-'));
|
|
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: tws });
|
|
execFileSync('git', ['remote', 'add', 'origin', 'https://github.com/tester/agent-template.git'], { cwd: tws });
|
|
// Uninitialized template clone — the gate's trigger state [CX2-1].
|
|
writeManifest(tws, {
|
|
format_version: 1,
|
|
initialized: false,
|
|
agent_name: '{{AGENT_NAME}}',
|
|
created_by: 'template',
|
|
created_at: '1970-01-01T00:00:00.000Z',
|
|
source_id: 'workspace',
|
|
});
|
|
|
|
shimDir = mkdtempSync(join(tmpdir(), 'gb-status-shim-'));
|
|
recordFile = join(shimDir, 'record.log');
|
|
savedPath = process.env.PATH;
|
|
savedRecord = process.env.GB_FAKE_RECORD;
|
|
process.env.GB_FAKE_RECORD = recordFile;
|
|
// Recording gh shim: answer comes from the GB_FAKE_GH_ANSWER env var.
|
|
writeFileSync(
|
|
join(shimDir, 'gh'),
|
|
`#!/bin/sh
|
|
echo "gh $*" >> "$GB_FAKE_RECORD"
|
|
case "$GB_FAKE_GH_ANSWER" in
|
|
private) echo "true"; exit 0 ;;
|
|
public) echo "false"; exit 0 ;;
|
|
*) echo "auth error" >&2; exit 4 ;;
|
|
esac
|
|
`,
|
|
);
|
|
chmodSync(join(shimDir, 'gh'), 0o755);
|
|
process.env.PATH = `${shimDir}:${process.env.PATH ?? ''}`;
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (savedPath === undefined) delete process.env.PATH;
|
|
else process.env.PATH = savedPath;
|
|
if (savedRecord === undefined) delete process.env.GB_FAKE_RECORD;
|
|
else process.env.GB_FAKE_RECORD = savedRecord;
|
|
delete process.env.GB_FAKE_GH_ANSWER;
|
|
rmSync(tws, { recursive: true, force: true });
|
|
rmSync(shimDir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function captureStatus(): Promise<{ code: number; out: string; err: string }> {
|
|
const origLog = console.log;
|
|
const origErr = console.error;
|
|
let out = '';
|
|
let err = '';
|
|
console.log = (...a: unknown[]) => { out += a.map(String).join(' ') + '\n'; };
|
|
console.error = (...a: unknown[]) => { err += a.map(String).join(' ') + '\n'; };
|
|
try {
|
|
const code = await runBootstrap(['status', '--workspace', tws]);
|
|
return { code, out, err };
|
|
} finally {
|
|
console.log = origLog;
|
|
console.error = origErr;
|
|
}
|
|
}
|
|
|
|
test('PUBLIC origin on a template clone → hard fail (exit 1, agent-readable fix)', async () => {
|
|
process.env.GB_FAKE_GH_ANSWER = 'public';
|
|
const report = await statusReport(tws, { gbrainHomeDir: home });
|
|
expect(report.privacy_gate).toEqual({
|
|
verdict: 'public',
|
|
origin: 'https://github.com/tester/agent-template.git',
|
|
detail: expect.stringContaining('PUBLIC'),
|
|
});
|
|
// The probe went through gh repo view --json isPrivate (recorded argv).
|
|
expect(readFileSync(recordFile, 'utf8')).toContain('gh repo view https://github.com/tester/agent-template.git --json isPrivate');
|
|
|
|
const r = await captureStatus();
|
|
expect(r.code).toBe(1);
|
|
expect(r.err).toContain('PUBLIC');
|
|
expect(r.err).toContain('private');
|
|
});
|
|
|
|
test('[FIX6] `bootstrap render` refuses a PUBLIC origin BEFORE writing identity files', async () => {
|
|
process.env.GB_FAKE_GH_ANSWER = 'public';
|
|
// Precondition: no identity file has been rendered yet.
|
|
expect(existsSync(join(tws, 'SOUL.md'))).toBe(false);
|
|
|
|
const origLog = console.log;
|
|
const origErr = console.error;
|
|
let out = '';
|
|
let err = '';
|
|
console.log = (...a: unknown[]) => { out += a.map(String).join(' ') + '\n'; };
|
|
console.error = (...a: unknown[]) => { err += a.map(String).join(' ') + '\n'; };
|
|
let code: number;
|
|
try {
|
|
code = await runBootstrap(['render', '--workspace', tws]);
|
|
} finally {
|
|
console.log = origLog;
|
|
console.error = origErr;
|
|
}
|
|
void out;
|
|
expect(code).toBe(1);
|
|
expect(err).toContain('render refused');
|
|
expect(err).toContain('PUBLIC');
|
|
expect(err).toContain('private');
|
|
// Refused BEFORE any identity file was written.
|
|
expect(existsSync(join(tws, 'SOUL.md'))).toBe(false);
|
|
expect(existsSync(join(tws, 'USER.md'))).toBe(false);
|
|
});
|
|
|
|
test('PRIVATE origin → no gate, exit 0', async () => {
|
|
process.env.GB_FAKE_GH_ANSWER = 'private';
|
|
const report = await statusReport(tws, { gbrainHomeDir: home });
|
|
expect(report.privacy_gate).toBeUndefined();
|
|
const r = await captureStatus();
|
|
expect(r.code).toBe(0);
|
|
});
|
|
|
|
test('gh unavailable/offline → loud warning, NOT a fail', async () => {
|
|
delete process.env.GB_FAKE_GH_ANSWER; // shim exits 4 (auth error / offline shape)
|
|
const report = await statusReport(tws, { gbrainHomeDir: home });
|
|
expect(report.privacy_gate?.verdict).toBe('unknown');
|
|
const r = await captureStatus();
|
|
expect(r.code).toBe(0);
|
|
expect(r.err).toContain('WARNING');
|
|
expect(r.err).toContain('visibility');
|
|
});
|
|
|
|
test('initialized workspace never trips the template-door gate', async () => {
|
|
process.env.GB_FAKE_GH_ANSWER = 'public';
|
|
// ws (outer fixture) is initialized by the earlier detection test and has
|
|
// no origin anyway; flip tws to initialized to isolate the discriminator.
|
|
writeManifest(tws, {
|
|
format_version: 1,
|
|
initialized: true,
|
|
agent_name: 'Testy',
|
|
created_by: 'test',
|
|
created_at: new Date().toISOString(),
|
|
source_id: 'workspace',
|
|
});
|
|
try {
|
|
const report = await statusReport(tws, { gbrainHomeDir: home });
|
|
expect(report.privacy_gate).toBeUndefined();
|
|
} finally {
|
|
writeManifest(tws, {
|
|
format_version: 1,
|
|
initialized: false,
|
|
agent_name: '{{AGENT_NAME}}',
|
|
created_by: 'template',
|
|
created_at: '1970-01-01T00:00:00.000Z',
|
|
source_id: 'workspace',
|
|
});
|
|
}
|
|
});
|
|
});
|