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>
487 lines
17 KiB
TypeScript
487 lines
17 KiB
TypeScript
/**
|
|
* Guard tests for scripts/check-bootstrap-tag.sh [C1 = D6-A] and
|
|
* scripts/check-bootstrap-templates.sh [D4/D5/A1 + privacy], plus the
|
|
* release-mechanics workflow pins (latest-stable advance, publish-template
|
|
* gating) and the verify-dispatcher wiring.
|
|
*
|
|
* Both guards accept GBRAIN_BOOTSTRAP_GUARD_ROOT so every failure mode is
|
|
* exercised against throwaway fixture trees (the no-tracked-symlinks-guard
|
|
* precedent) — the real repo is only asserted green.
|
|
*/
|
|
|
|
import { describe, expect, test } from 'bun:test';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
|
|
const ROOT = resolve(import.meta.dir, '..');
|
|
const TAG_GUARD = join(ROOT, 'scripts/check-bootstrap-tag.sh');
|
|
const TPL_GUARD = join(ROOT, 'scripts/check-bootstrap-templates.sh');
|
|
|
|
// The banned fork name must never appear literally in a test file
|
|
// (scripts/check-privacy.sh + scripts/check-test-real-names.sh scan test/**),
|
|
// so fixtures construct it at runtime.
|
|
const BANNED_FORK_NAME = ['winter', 'mute'].join('');
|
|
|
|
function runGuard(script: string, fixtureRoot: string): { status: number | null; out: string } {
|
|
const r = spawnSync('bash', [script], {
|
|
cwd: ROOT,
|
|
encoding: 'utf-8',
|
|
env: { ...process.env, GBRAIN_BOOTSTRAP_GUARD_ROOT: fixtureRoot },
|
|
});
|
|
return { status: r.status, out: `${r.stdout}\n${r.stderr}` };
|
|
}
|
|
|
|
function makeFixture(files: Record<string, string>): string {
|
|
const dir = mkdtempSync(join(tmpdir(), 'gbrain-bootstrap-guard-'));
|
|
for (const [rel, content] of Object.entries(files)) {
|
|
const p = join(dir, rel);
|
|
mkdirSync(dirname(p), { recursive: true });
|
|
writeFileSync(p, content);
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
function withFixture(files: Record<string, string>, fn: (dir: string) => void): void {
|
|
const dir = makeFixture(files);
|
|
try {
|
|
fn(dir);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
const BANK_JSON = JSON.stringify({
|
|
version: 1,
|
|
maxQuestions: 12,
|
|
interviewKeys: ['AGENT_NAME'],
|
|
consentKeys: ['HOOKS_CONSENT', 'MCP_SCOPE'],
|
|
questions: {
|
|
AGENT_NAME: { maxLength: 64 },
|
|
HOOKS_CONSENT: { consent: true, maxLength: 8 },
|
|
// Section (e) pins the harness-scoping prefix + interview phase on this
|
|
// question — every "clean" fixture must carry a compliant MCP_SCOPE entry.
|
|
MCP_SCOPE: {
|
|
consent: true,
|
|
phase: 'interview',
|
|
question: '(Claude Code only. Codex has no scope flag.) Register for this folder or the whole machine?',
|
|
maxLength: 8,
|
|
},
|
|
UNUSED_OPTIONAL: { maxLength: 8 },
|
|
},
|
|
});
|
|
|
|
// Minimal runbook that satisfies the section (e) counter-signal pins; fixtures
|
|
// exercising OTHER failure modes include it so they fail only for their own
|
|
// reason.
|
|
const RUNBOOK_PINS = 'Claude Code only\nDo NOT offer an MCP scope choice\n';
|
|
|
|
describe('check-bootstrap-tag.sh', () => {
|
|
test('exists and is executable', () => {
|
|
expect(existsSync(TAG_GUARD)).toBe(true);
|
|
expect((statSync(TAG_GUARD).mode & 0o100) !== 0).toBe(true);
|
|
});
|
|
|
|
test('passes on this repo', () => {
|
|
const r = runGuard(TAG_GUARD, ROOT);
|
|
expect(r.status).toBe(0);
|
|
expect(r.out).toContain('check-bootstrap-tag: ok');
|
|
});
|
|
|
|
test('skips with exit 0 when both entry docs are absent', () => {
|
|
withFixture({ VERSION: '1.2.3.4\n' }, (dir) => {
|
|
const r = runGuard(TAG_GUARD, dir);
|
|
expect(r.status).toBe(0);
|
|
expect(r.out).toContain('SKIP');
|
|
expect(r.out).toContain('check-bootstrap-tag: ok');
|
|
});
|
|
});
|
|
|
|
test('accepts sanctioned refs + matching runbook stamp', () => {
|
|
withFixture(
|
|
{
|
|
VERSION: '1.2.3.4\n',
|
|
'README.md':
|
|
'Fetch https://raw.githubusercontent.com/garrytan/gbrain/latest-stable/BOOTSTRAP_FOR_AGENTS.md\n' +
|
|
'Install: `bun install -g github:garrytan/gbrain#latest-stable`\n',
|
|
'BOOTSTRAP_FOR_AGENTS.md':
|
|
'<!-- gbrain-runbook-stamp: 1.2.3.4 -->\n# Runbook\n' +
|
|
'Fetched from https://raw.githubusercontent.com/garrytan/gbrain/latest-stable/BOOTSTRAP_FOR_AGENTS.md\n',
|
|
},
|
|
(dir) => {
|
|
const r = runGuard(TAG_GUARD, dir);
|
|
expect(r.status).toBe(0);
|
|
expect(r.out).toContain('check-bootstrap-tag: ok');
|
|
},
|
|
);
|
|
});
|
|
|
|
test('fails on a v-prefixed raw pin', () => {
|
|
withFixture(
|
|
{
|
|
VERSION: '1.2.3.4\n',
|
|
'README.md':
|
|
'Fetch https://raw.githubusercontent.com/garrytan/gbrain/v0.42.0.0/BOOTSTRAP_FOR_AGENTS.md\n',
|
|
},
|
|
(dir) => {
|
|
const r = runGuard(TAG_GUARD, dir);
|
|
expect(r.status).toBe(1);
|
|
expect(r.out).toContain("unsanctioned ref 'v0.42.0.0'");
|
|
},
|
|
);
|
|
});
|
|
|
|
test('fails on a branch-ref install pin (#master)', () => {
|
|
withFixture(
|
|
{
|
|
VERSION: '1.2.3.4\n',
|
|
'README.md': 'Install: `bun install -g github:garrytan/gbrain#master`\n',
|
|
},
|
|
(dir) => {
|
|
const r = runGuard(TAG_GUARD, dir);
|
|
expect(r.status).toBe(1);
|
|
expect(r.out).toContain("'#master'");
|
|
},
|
|
);
|
|
});
|
|
|
|
test('tolerates the pre-bootstrap INSTALL_FOR_AGENTS.md master path (carve-out)', () => {
|
|
withFixture(
|
|
{
|
|
VERSION: '1.2.3.4\n',
|
|
'README.md':
|
|
'OpenClaw path: https://raw.githubusercontent.com/garrytan/gbrain/master/INSTALL_FOR_AGENTS.md\n',
|
|
},
|
|
(dir) => {
|
|
expect(runGuard(TAG_GUARD, dir).status).toBe(0);
|
|
},
|
|
);
|
|
});
|
|
|
|
test('fails when the runbook stamp mismatches VERSION', () => {
|
|
withFixture(
|
|
{
|
|
VERSION: '1.2.3.4\n',
|
|
'BOOTSTRAP_FOR_AGENTS.md': '<!-- gbrain-runbook-stamp: 9.9.9.9 -->\n# Runbook\n',
|
|
},
|
|
(dir) => {
|
|
const r = runGuard(TAG_GUARD, dir);
|
|
expect(r.status).toBe(1);
|
|
expect(r.out).toContain("stamp '9.9.9.9' != VERSION '1.2.3.4'");
|
|
},
|
|
);
|
|
});
|
|
|
|
test('fails when the runbook is missing its stamp line entirely', () => {
|
|
withFixture(
|
|
{
|
|
VERSION: '1.2.3.4\n',
|
|
'BOOTSTRAP_FOR_AGENTS.md': '# Runbook without a stamp\n',
|
|
},
|
|
(dir) => {
|
|
const r = runGuard(TAG_GUARD, dir);
|
|
expect(r.status).toBe(1);
|
|
expect(r.out).toContain('missing its');
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('check-bootstrap-templates.sh', () => {
|
|
test('exists and is executable', () => {
|
|
expect(existsSync(TPL_GUARD)).toBe(true);
|
|
expect((statSync(TPL_GUARD).mode & 0o100) !== 0).toBe(true);
|
|
});
|
|
|
|
test('passes on this repo', () => {
|
|
const r = runGuard(TPL_GUARD, ROOT);
|
|
expect(r.status).toBe(0);
|
|
expect(r.out).toContain('check-bootstrap-templates: ok');
|
|
});
|
|
|
|
test('skips with exit 0 when templates/bootstrap is absent', () => {
|
|
withFixture({ VERSION: '1.2.3.4\n' }, (dir) => {
|
|
const r = runGuard(TPL_GUARD, dir);
|
|
expect(r.status).toBe(0);
|
|
expect(r.out).toContain('SKIP');
|
|
});
|
|
});
|
|
|
|
test('accepts a clean bijection and warns (only) on unused non-consent keys', () => {
|
|
withFixture(
|
|
{
|
|
'templates/bootstrap/questions.json': BANK_JSON,
|
|
'templates/bootstrap/SOUL.md.template':
|
|
'# {{AGENT_NAME}}\nRepo: {{GITHUB_REPO_URL}}\n',
|
|
},
|
|
(dir) => {
|
|
const r = runGuard(TPL_GUARD, dir);
|
|
expect(r.status).toBe(0);
|
|
expect(r.out).toContain('WARN');
|
|
expect(r.out).toContain('UNUSED_OPTIONAL');
|
|
// Consent keys are exempt from the usage warning.
|
|
expect(r.out).not.toContain('HOOKS_CONSENT');
|
|
},
|
|
);
|
|
});
|
|
|
|
test('fails on a token with no bank or DERIVED_TOKENS entry', () => {
|
|
withFixture(
|
|
{
|
|
'templates/bootstrap/questions.json': BANK_JSON,
|
|
'templates/bootstrap/SOUL.md.template': '# {{AGENT_NAME}} {{NOT_IN_BANK}}\n',
|
|
},
|
|
(dir) => {
|
|
const r = runGuard(TPL_GUARD, dir);
|
|
expect(r.status).toBe(1);
|
|
expect(r.out).toContain('NOT_IN_BANK');
|
|
},
|
|
);
|
|
});
|
|
|
|
test('fails on a malformed question bank', () => {
|
|
withFixture(
|
|
{
|
|
'templates/bootstrap/questions.json': '{ not json',
|
|
'templates/bootstrap/SOUL.md.template': '# {{AGENT_NAME}}\n',
|
|
},
|
|
(dir) => {
|
|
const r = runGuard(TPL_GUARD, dir);
|
|
expect(r.status).toBe(1);
|
|
expect(r.out).toContain('could not parse');
|
|
},
|
|
);
|
|
});
|
|
|
|
test('fails when a template carries the banned fork name (placeholder-only assertion)', () => {
|
|
withFixture(
|
|
{
|
|
'templates/bootstrap/questions.json': BANK_JSON,
|
|
'templates/bootstrap/SOUL.md.template': `# {{AGENT_NAME}}, successor to ${BANNED_FORK_NAME}\n`,
|
|
},
|
|
(dir) => {
|
|
const r = runGuard(TPL_GUARD, dir);
|
|
expect(r.status).toBe(1);
|
|
expect(r.out).toContain('banned term');
|
|
},
|
|
);
|
|
});
|
|
|
|
test('checks runbook Phase: names against the TS phase list when both exist [D5]', () => {
|
|
withFixture(
|
|
{
|
|
'templates/bootstrap/questions.json': BANK_JSON,
|
|
'templates/bootstrap/SOUL.md.template': '# {{AGENT_NAME}}\n',
|
|
'BOOTSTRAP_FOR_AGENTS.md':
|
|
`<!-- gbrain-runbook-stamp: 1.2.3.4 -->\n${RUNBOOK_PINS}Phase: preflight\nPhase: not_a_phase\n`,
|
|
'src/core/bootstrap/status.ts': "export const PHASES = ['preflight', 'interview'] as const;\n",
|
|
},
|
|
(dir) => {
|
|
const r = runGuard(TPL_GUARD, dir);
|
|
expect(r.status).toBe(1);
|
|
expect(r.out).toContain("phase 'not_a_phase'");
|
|
expect(r.out).not.toContain("phase 'preflight'");
|
|
},
|
|
);
|
|
});
|
|
|
|
test('(e) clean: runbook counter-signals + compliant MCP_SCOPE prefix pass', () => {
|
|
withFixture(
|
|
{
|
|
'templates/bootstrap/questions.json': BANK_JSON,
|
|
'templates/bootstrap/SOUL.md.template': '# {{AGENT_NAME}}\n',
|
|
'BOOTSTRAP_FOR_AGENTS.md': `<!-- gbrain-runbook-stamp: 1.2.3.4 -->\n${RUNBOOK_PINS}`,
|
|
},
|
|
(dir) => {
|
|
const r = runGuard(TPL_GUARD, dir);
|
|
expect(r.status).toBe(0);
|
|
expect(r.out).toContain('check-bootstrap-templates: ok');
|
|
},
|
|
);
|
|
});
|
|
|
|
test('(e) fails when the runbook loses the Codex do-not-offer counter-signal', () => {
|
|
withFixture(
|
|
{
|
|
'templates/bootstrap/questions.json': BANK_JSON,
|
|
'templates/bootstrap/SOUL.md.template': '# {{AGENT_NAME}}\n',
|
|
'BOOTSTRAP_FOR_AGENTS.md':
|
|
'<!-- gbrain-runbook-stamp: 1.2.3.4 -->\nClaude Code only\n(counter-signal deleted)\n',
|
|
},
|
|
(dir) => {
|
|
const r = runGuard(TPL_GUARD, dir);
|
|
expect(r.status).toBe(1);
|
|
expect(r.out).toContain('Codex counter-signal');
|
|
},
|
|
);
|
|
});
|
|
|
|
test("(e) fails when the runbook loses the 'Claude Code only' consent scoping", () => {
|
|
withFixture(
|
|
{
|
|
'templates/bootstrap/questions.json': BANK_JSON,
|
|
'templates/bootstrap/SOUL.md.template': '# {{AGENT_NAME}}\n',
|
|
'BOOTSTRAP_FOR_AGENTS.md':
|
|
'<!-- gbrain-runbook-stamp: 1.2.3.4 -->\nDo NOT offer an MCP scope choice\n',
|
|
},
|
|
(dir) => {
|
|
const r = runGuard(TPL_GUARD, dir);
|
|
expect(r.status).toBe(1);
|
|
expect(r.out).toContain("'Claude Code only'");
|
|
},
|
|
);
|
|
});
|
|
|
|
test('(e) fails when the questions object vanishes (valid JSON that silently passes §a)', () => {
|
|
withFixture(
|
|
{
|
|
'templates/bootstrap/questions.json': '{"version":1,"maxQuestions":12,"interviewKeys":[],"consentKeys":[]}',
|
|
'templates/bootstrap/SOUL.md.template': '# plain\n',
|
|
'BOOTSTRAP_FOR_AGENTS.md': `<!-- gbrain-runbook-stamp: 1.2.3.4 -->\n${RUNBOOK_PINS}`,
|
|
},
|
|
(dir) => {
|
|
const r = runGuard(TPL_GUARD, dir);
|
|
expect(r.status).toBe(1);
|
|
expect(r.out).toContain("must start with '(Claude Code only'");
|
|
},
|
|
);
|
|
});
|
|
|
|
test('(e) fails when the MCP_SCOPE entry vanishes from the bank', () => {
|
|
const bankNoEntry = JSON.stringify({
|
|
version: 1,
|
|
maxQuestions: 12,
|
|
interviewKeys: ['AGENT_NAME'],
|
|
consentKeys: ['HOOKS_CONSENT'],
|
|
questions: {
|
|
AGENT_NAME: { maxLength: 64 },
|
|
HOOKS_CONSENT: { consent: true, maxLength: 8 },
|
|
},
|
|
});
|
|
withFixture(
|
|
{
|
|
'templates/bootstrap/questions.json': bankNoEntry,
|
|
'templates/bootstrap/SOUL.md.template': '# {{AGENT_NAME}}\n',
|
|
'BOOTSTRAP_FOR_AGENTS.md': `<!-- gbrain-runbook-stamp: 1.2.3.4 -->\n${RUNBOOK_PINS}`,
|
|
},
|
|
(dir) => {
|
|
const r = runGuard(TPL_GUARD, dir);
|
|
expect(r.status).toBe(1);
|
|
expect(r.out).toContain("must start with '(Claude Code only'");
|
|
},
|
|
);
|
|
});
|
|
|
|
test('(e) fails when MCP_SCOPE.phase reverts to wire (schema-vs-runbook contradiction)', () => {
|
|
const bankWirePhase = JSON.stringify({
|
|
version: 1,
|
|
maxQuestions: 12,
|
|
interviewKeys: ['AGENT_NAME'],
|
|
consentKeys: ['HOOKS_CONSENT', 'MCP_SCOPE'],
|
|
questions: {
|
|
AGENT_NAME: { maxLength: 64 },
|
|
HOOKS_CONSENT: { consent: true, maxLength: 8 },
|
|
MCP_SCOPE: {
|
|
consent: true,
|
|
phase: 'wire',
|
|
question: '(Claude Code only. Codex has no scope flag.) Register for this folder or the whole machine?',
|
|
maxLength: 8,
|
|
},
|
|
},
|
|
});
|
|
withFixture(
|
|
{
|
|
'templates/bootstrap/questions.json': bankWirePhase,
|
|
'templates/bootstrap/SOUL.md.template': '# {{AGENT_NAME}}\n',
|
|
'BOOTSTRAP_FOR_AGENTS.md': `<!-- gbrain-runbook-stamp: 1.2.3.4 -->\n${RUNBOOK_PINS}`,
|
|
},
|
|
(dir) => {
|
|
const r = runGuard(TPL_GUARD, dir);
|
|
expect(r.status).toBe(1);
|
|
expect(r.out).toContain("MCP_SCOPE.phase must be 'interview'");
|
|
},
|
|
);
|
|
});
|
|
|
|
test('(e) fails when MCP_SCOPE.question loses its harness prefix (or the entry vanishes)', () => {
|
|
const bankNoPrefix = JSON.stringify({
|
|
version: 1,
|
|
maxQuestions: 12,
|
|
interviewKeys: ['AGENT_NAME'],
|
|
consentKeys: ['HOOKS_CONSENT', 'MCP_SCOPE'],
|
|
questions: {
|
|
AGENT_NAME: { maxLength: 64 },
|
|
HOOKS_CONSENT: { consent: true, maxLength: 8 },
|
|
MCP_SCOPE: { consent: true, phase: 'interview', question: 'Register for this folder or the whole machine?', maxLength: 8 },
|
|
},
|
|
});
|
|
withFixture(
|
|
{
|
|
'templates/bootstrap/questions.json': bankNoPrefix,
|
|
'templates/bootstrap/SOUL.md.template': '# {{AGENT_NAME}}\n',
|
|
'BOOTSTRAP_FOR_AGENTS.md': `<!-- gbrain-runbook-stamp: 1.2.3.4 -->\n${RUNBOOK_PINS}`,
|
|
},
|
|
(dir) => {
|
|
const r = runGuard(TPL_GUARD, dir);
|
|
expect(r.status).toBe(1);
|
|
expect(r.out).toContain("must start with '(Claude Code only'");
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('verify + workflow wiring', () => {
|
|
test('both guards are registered in the verify dispatcher', () => {
|
|
const r = spawnSync('bash', [join(ROOT, 'scripts/run-verify-parallel.sh'), '--dry-list'], {
|
|
cwd: ROOT,
|
|
encoding: 'utf-8',
|
|
});
|
|
expect(r.status).toBe(0);
|
|
const checks = new Set(r.stdout.trim().split('\n'));
|
|
expect(checks).toContain('check:bootstrap-tag');
|
|
expect(checks).toContain('check:bootstrap-templates');
|
|
});
|
|
|
|
test('package.json carries both check scripts', () => {
|
|
const pkg = require(join(ROOT, 'package.json'));
|
|
expect(pkg.scripts['check:bootstrap-tag']).toContain('check-bootstrap-tag.sh');
|
|
expect(pkg.scripts['check:bootstrap-templates']).toContain('check-bootstrap-templates.sh');
|
|
});
|
|
|
|
test('ci-cache-hash re-admits the bootstrap entry docs [C2]', () => {
|
|
const script = require('node:fs').readFileSync(join(ROOT, 'scripts/ci-cache-hash.sh'), 'utf8');
|
|
expect(script).toContain("'README\\.md$'");
|
|
expect(script).toContain("'BOOTSTRAP_FOR_AGENTS\\.md$'");
|
|
});
|
|
|
|
test('release.yml advances latest-stable only as the final release step [C1]', () => {
|
|
const wf = require('node:fs').readFileSync(join(ROOT, '.github/workflows/release.yml'), 'utf8');
|
|
expect(wf).toContain('git push origin "+${GITHUB_SHA}:refs/tags/latest-stable"');
|
|
// The advance comes AFTER the release publish step in the same job.
|
|
expect(wf.indexOf('refs/tags/latest-stable')).toBeGreaterThan(wf.indexOf('Create release'));
|
|
});
|
|
|
|
test('release.yml publish-template job gates on TEMPLATE_REPO_PAT via env indirection', () => {
|
|
const wf = require('node:fs').readFileSync(join(ROOT, '.github/workflows/release.yml'), 'utf8');
|
|
expect(wf).toContain('publish-template:');
|
|
expect(wf).toContain('TEMPLATE_REPO_PAT: ${{ secrets.TEMPLATE_REPO_PAT }}');
|
|
// Secrets must never appear in an `if:` expression (unreadable there).
|
|
for (const line of wf.split('\n')) {
|
|
if (line.trimStart().startsWith('if:')) {
|
|
expect(line).not.toContain('secrets.');
|
|
}
|
|
}
|
|
// The byte-diff gate against the vendored tree [A1].
|
|
expect(wf).toContain('diff -r /tmp/template-tree templates/bootstrap/template-repo');
|
|
});
|
|
|
|
test('heavy-tests.yml carries the bootstrap Docker e2e placeholder [A7]', () => {
|
|
const wf = require('node:fs').readFileSync(
|
|
join(ROOT, '.github/workflows/heavy-tests.yml'),
|
|
'utf8',
|
|
);
|
|
expect(wf).toContain('tests/docker/bootstrap-e2e.sh');
|
|
});
|
|
});
|