Files
gbrain/test/bootstrap-doctor-checks.test.ts
Garry TanandClaude Fable 5 1ec6a6e842 v0.45.9.0 feat(bootstrap): first-class cloud-sandbox install + per-turn persistence + fail-closed privacy ladder (#4045)
* feat(core): execution-environment detection — local | cloud-sandbox | ephemeral-container

detectExecutionEnvironment() + isCredentialInjectingProxy() with injected
signals (CLAUDE_CODE_REMOTE, cse_ session-id prefix, proxy-injected token
placeholder, anthropic-egress proxy JWT, container markers). binaryOnPath
moves here as the canonical PATH probe. autopilot's detectInstallTarget
ephemeral branch now routes through the shared detector.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bootstrap): repo-visibility ladder + per-turn Stop push + same-session failure banner

The durability lane. One repo-visibility verdict for every consumer (REST
first — never GraphQL, which sandbox proxies pin; authed ls-remote + an
attributed anonymous probe as the git-protocol fallback), replacing three
drifted probes. A 200 counts as public only with advertisement proof; a
401/404 counts as private-signal only with an auth challenge — fail-closed
in BOTH directions. Private verdicts cache 1h (private-only, per origin).

gbrain hook stop now spawns a debounced detached push per turn (per-root
state; cloud-sandbox defaults to every turn, elsewhere 5 min; a failing
status bypasses the debounce), closing the /exit and VM-reclaim gaps.
Push status is per workspace root, read through one shared reader by the
user-prompt banner (additionalContext + systemMessage — visible to the
human, not just the model), the SessionStart note, and doctor. Escape
hatches for self-hosted git: flag > env > file-plane config key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bootstrap): environment-aware install — honest cron skip, cloud repo-create guard, execution_env surfaces

installDurabilityCron probes for crontab before writing anything (containers
and cloud sandboxes ship without one — expected, reported as an honest skip
naming the event-driven pushes that still cover persistence). The repo phase
installs the container-friendly harden half (post-commit hook, no scheduler)
outside local machines. createPrivateRepo fails fast in cloud sandboxes with
the flow that works (create outside, open the session ON the repo, attach).
bootstrap verify gains a never-gating execution_env check; bootstrap status
--json carries execution_environment for installing agents to branch on.

Also fixes a live-PATH resolution class: binaryOnPath and the crontab execs
now pass the current env explicitly (Bun resolves against the startup
snapshot otherwise).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bootstrap): lifecycle hygiene — uninstall teardown, doctor job liveness, .mcp.json out of the repo, honest persistence copy

uninstall now tears down the durability wiring it installed (launchd/cron
job, untracked post-commit hook, credential wiring — the committed helper
and AGENTS rules stay). doctor gains bootstrap_durability_job: presence +
LIVENESS (launchctl load-state, crontab line, pull-log freshness) — a plist
on disk with a dead job no longer reads as healthy. Rendered .gitignore now
covers .mcp.json (absolute machine paths must not land in the private
repo); verify warns on pre-fix installs that committed it; the never-built
state/mcp.json promise is gone from GITHUB.md. Persistence copy tells the
truth everywhere: event-driven pushes do the durability work, the 30-min
job is a multi-machine pull freshener. Post-commit hook install/removal is
worktree-safe (git-path resolution; the git marker is a FILE there) and the
cron wrapper's self-disable tests the repo dir, not its git marker. Two
follow-up TODOs filed (plugin hook distribution; Channels push lane).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(bootstrap): committed hook carrier for cloud + cloud-setup-script emitter + cloud runbook

Cloud sessions clone fresh and snapshot hook config at session start — the
gitignored settings.local.json never exists there, so hooks never fired in
cloud at all. Cloud installs now write the repo-COMMITTED .claude/settings.json
with PATH-resolved, fail-open commands (no machine paths; a host without the
binary no-ops); local installs keep settings.local.json; the writers enforce
that one event never fires from both carriers, and removal cleans both.

New: gbrain bootstrap cloud-setup-script prints the paste-ready environment
setup script (npm transport — bun fetching is proxy-incompatible in cloud;
never the unrelated npm-registry package). Runbook gains a NEVER FABRICATE
TOOLING hard rule, a cloud-sandbox section (expected degradations as facts to
relay, the attach-first flow), and failure-table rows for the proxy-403 and
missing-crontab signatures. Codex lane stated honestly: AGENTS.md Gate 2 now
has the pull-side push-health check. Guide documents the new knobs and the
cloud contract; llms bundles regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(bootstrap): per-turn push e2e chain, cloud-sandbox sim, per-root status reader migration + registry regen

bootstrap-persistence e2e gains the Stop-hook chain (one stop banks the turn
to the real bare remote; the debounce holds across stops; debounce-0 lands
consecutive turns) and fixes a pre-existing post-#4024 break: repoPhaseComplete
required a github-parseable origin, so session-end pushes deferred FOREVER for
self-hosted/file-transport origins — non-github repo_urls now bind by exact
URL equality (redirect protection preserved). Degraded-modes e2e gains the
cloud-sandbox simulation (status reports the environment; repo creation
refuses with the attach flow). workspace-push tests read per-root status
through the shared reader. Flag registry regenerated (new git argv literals
from the git-path/ls-files calls — the accepted argv-bleed class).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(doctor): categorize bootstrap_durability_job (categories drift guard)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bootstrap): adversarial-review hardening — fail-closed privacy, no push-storm, no exfil paths

Both Claude and Codex adversarial passes ran; 11 findings fixed (union):
- Non-github 401+WWW-Authenticate no longer reads as private (RFC 7235 makes
  the header mandatory on every 401, so a middlebox 401s identically) — the
  top exfil path both models flagged; now unverifiable/fail-closed, operator
  confirms via the escape hatch. github.com still needs x-github-request-id.
- Escape hatches downgrade ONLY 'unverifiable' — a PROVEN-public origin still
  refuses (hatches never authorize a public push).
- treeNeedsPush measures against origin/<branch> (the push's own ref), not
  @{u}: a no-upstream branch no longer reports a committed-but-unpushed tree
  as push_clean and silently strands it.
- [D20] failing-retry uses a fixed 60s floor, not min(debounce,60s) — cloud
  debounce=0 no longer re-runs the network ladder every turn.
- Committed hook carrier: dedupe/suppress only on the EXACT portable-command
  shape, not a 'gbrain hook' substring (blocks the suppress-local-run-evil
  supply-chain vector); GBRAIN_HOME refused in the committed carrier.
- push-status reason sanitized (charset+length) at every surface (banner,
  doctor, status blob) so remote git stderr can't inject via the remediation.
- Per-root state: ghost-root records (deleted workspaces) filtered so a dead
  failing record can't re-fire the banner forever; uninstall removes them.
- statusReport support blob reads push status through the shared per-root
  reader; visibility cache strips URL userinfo (no PAT persisted).
- anonProbe strips userinfo + redirect:manual + SSRF flags + --end-of-options
  on ls-remote; cron self-disable uses git rev-parse (worktree-safe both
  ways); cloud-setup-script fails loud on a broken update; durability liveness
  won't certify a never-run crontab as live; config get/unset resolve the
  dotted file-plane keys; .bak/.broken gitignored; typed config fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v0.45.8.0 feat(bootstrap): first-class cloud-sandbox install + per-turn persistence + fail-closed privacy ladder

VERSION + package.json + CHANGELOG + TODOS + llms bundles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: sync KEY_FILES + README for the cloud-DX wave (v0.45.8.0)

New KEY_FILES entries for execution-env.ts and repo-visibility.ts; updated the
bootstrap repo/hooks, workspace-push, brain-repo-durability, and hook.ts
entries to current behavior (ladder verification, committed cloud hook carrier,
per-root push status, crontab probe + liveness, per-turn stop push + banner).
README's Claude Code line now states per-turn + cloud persistence honestly.
llms bundles regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bootstrap): doc-review pass — attach records repo_url (enables cloud persistence), verify reads per-root push status, honest docs

Cross-model doc review (Codex) caught that the headline cloud-persistence
claim was hollow and two docs over-claimed:
- attach now records repo_url from the adopted origin, so the no-daemon push
  gate (repoPhaseComplete) recognizes the repo phase as done — WITHOUT this,
  the per-turn/session-end pushes deferred forever after an attach, which is
  the ONLY install path in a cloud sandbox (repo is refused there). Privacy is
  still enforced at push time by the ladder.
- bootstrap verify's push_probe reads the shared per-root reader [D8], not the
  legacy single file — a fresh v0.45.8 install no longer reports 'no push
  recorded' when per-root status exists.
- Docs corrected to match code: runbook stamp → 0.45.8.0; README states the
  per-turn cadence honestly (debounced local, next-turn failure notice);
  GITHUB.md qualifies auto-push as Claude Code (Codex is pull); KEY_FILES says
  repo/status use REST (not the full ladder) and drops the stale --push;
  bootstrap.md hooks-location covers both carriers; README verbs surface says
  seven. llms bundles regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bootstrap): regenerate vendored template-repo tree + isolate env-mutating tests (verify gate)

The verify CI gate caught two things the piecemeal local runs missed:
- templates/bootstrap/template-repo/ is a GENERATED tree; the source-template
  edits (AGENTS.md Gate 2, CLAUDE.md cloud note) plus the version stamp had
  drifted. Regenerated from source (check:bootstrap-templates green).
- test/bootstrap-repo.test.ts + test/durability-cron.test.ts newly mutated
  process.env in non-serial files; converted to withEnv() (check:test-isolation
  green) — no .serial rename needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(ambient-recall): pin a keyless gateway so delta/context_pack writes never fire a real embed

Root cause of the shard-2 CI red after the 0.45.9.0 re-bump: adding this
wave's test files reshuffled the weight-packed shards, moving
ambient-recall.test.ts next to a neighbor that leaks CI's dummy
OPENAI_API_KEY (sk-test-*) into the gateway singleton (the bunfig preload
configures with env:{...process.env}, and a present-but-invalid key turns
remember's keyless-degrade embed into a hard 401). The delta/context_pack
tests exercise cursor + budget logic, not embedding quality, so this pins a
keyless gateway (env:{}) in beforeAll — isAvailable('embedding') is false,
writeSingleFact degrades (degraded_dedup) with no HTTP call, and the file is
deterministic regardless of shard bin-packing. Verified: passes with
OPENAI_API_KEY=sk-test-* set (the CI condition). Master's own comment in
legacy-embedding-preload.ts already warns 'adding any test file reshuffles
the mines'; this makes the file immune.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:12:59 -07:00

504 lines
22 KiB
TypeScript

/**
* bootstrapDoctorChecks (doctor.ts) — the agent-bootstrap check group
* [B2/B3/B4/ENG-4/C1 + one-live-serve].
*
* Covers the six checks (bootstrap_hooks_heartbeat, bootstrap_push_health,
* bootstrap_serve_lock, bootstrap_hook_schema_pairing, bootstrap_runbook_skew,
* bootstrap_last_verify), the no-bootstrap-state → zero-checks gate, and the
* fail-soft catches (torn/corrupt state files degrade to warn or silence,
* never a throw).
*
* Isolation: every test builds a fresh tmp GBRAIN_HOME parent and runs the
* probe inside `withEnv({ GBRAIN_HOME: parent })` — no direct process.env
* mutation, no PGLite engine (checks accept `engine: null`; the one
* engine-shaped check gets a stub with just `getConfig`).
*/
import { describe, test, expect, afterAll } from 'bun:test';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync, spawnSync } from 'node:child_process';
import { bootstrapDoctorChecks, type Check } from '../src/commands/doctor.ts';
import { LATEST_VERSION } from '../src/core/migrate.ts';
import { VERSION } from '../src/version.ts';
import type { BrainEngine } from '../src/core/engine.ts';
import { withEnv } from './helpers/with-env.ts';
const T = 30_000; // explicit per-test timeout — bun ignores bunfig.toml's key
// ── fixtures ────────────────────────────────────────────────────────────────
const tmpDirs: string[] = [];
afterAll(() => {
for (const d of tmpDirs) rmSync(d, { recursive: true, force: true });
});
/** Fresh GBRAIN_HOME parent + the effective home dir (`<parent>/.gbrain`). */
function makeHome(): { parent: string; home: string } {
const parent = mkdtempSync(join(tmpdir(), 'gb-bdc-'));
tmpDirs.push(parent);
const home = join(parent, '.gbrain');
mkdirSync(home, { recursive: true });
return { parent, home };
}
interface HeartbeatLine {
outcome: 'ok' | 'degraded' | 'error';
}
/** Write `<home>/integrations/hooks/heartbeat.jsonl` (rawLines appended verbatim). */
function writeHeartbeat(home: string, entries: HeartbeatLine[], rawLines: string[] = []): void {
const dir = join(home, 'integrations', 'hooks');
mkdirSync(dir, { recursive: true });
const lines = entries.map((e) =>
JSON.stringify({ ts: new Date().toISOString(), event: 'session-start', outcome: e.outcome, duration_ms: 1 }),
);
writeFileSync(join(dir, 'heartbeat.jsonl'), [...lines, ...rawLines].join('\n') + '\n');
}
function writePushStatus(home: string, body: string): void {
mkdirSync(join(home, 'bootstrap'), { recursive: true });
writeFileSync(join(home, 'bootstrap', 'push-status.json'), body);
}
/** A valid v1 install receipt pointing at `ws` (or a receipt-shaped blob). */
function writeReceipt(home: string, ws: string): void {
mkdirSync(join(home, 'bootstrap'), { recursive: true });
writeFileSync(
join(home, 'bootstrap', 'receipt.json'),
JSON.stringify({
receipt_version: 1,
workspace_dir: ws,
source_id: 'workspace',
agent_name: 'Testy',
created_at: new Date().toISOString(),
created_by: 'test',
brain_created_by_bootstrap: false,
created_paths: [],
registrations: [],
}),
);
}
function writeVerifyRun(home: string, name: string, body: string): void {
mkdirSync(join(home, 'bootstrap'), { recursive: true });
writeFileSync(join(home, 'bootstrap', name), body);
}
/** A workspace dir; `dirty: true` makes it a git repo with an untracked file. */
function makeWorkspace(opts: { dirty?: boolean } = {}): string {
const ws = mkdtempSync(join(tmpdir(), 'gb-bdc-ws-'));
tmpDirs.push(ws);
if (opts.dirty) {
execFileSync('git', ['init', '-q', ws], { stdio: 'ignore' });
writeFileSync(join(ws, 'unpushed-note.md'), 'recent agent memory\n');
}
return ws;
}
function run(parent: string, engine: BrainEngine | null = null): Promise<Check[]> {
return withEnv({ GBRAIN_HOME: parent }, () => bootstrapDoctorChecks(engine));
}
function byName(checks: Check[], name: string): Check | undefined {
return checks.find((c) => c.name === name);
}
const STALE_TS = new Date(Date.now() - 72 * 60 * 60 * 1000).toISOString(); // 3 days ago
// ── the gate ────────────────────────────────────────────────────────────────
describe('no-bootstrap-state gate', () => {
test('a home with no receipt, no push-status, no heartbeat → zero checks', async () => {
const { parent } = makeHome();
expect(await run(parent)).toEqual([]);
}, T);
test('unresolvable home (relative GBRAIN_HOME) → zero checks, no throw', async () => {
const checks = await withEnv({ GBRAIN_HOME: 'relative/not-absolute' }, () =>
bootstrapDoctorChecks(null),
);
expect(checks).toEqual([]);
}, T);
test('a corrupt receipt ALONE does not open the gate (readReceipt fail-soft → null)', async () => {
const { parent, home } = makeHome();
mkdirSync(join(home, 'bootstrap'), { recursive: true });
writeFileSync(join(home, 'bootstrap', 'receipt.json'), 'not json{{{');
expect(await run(parent)).toEqual([]);
}, T);
test('a heartbeat file alone opens the gate', async () => {
const { parent, home } = makeHome();
writeHeartbeat(home, [{ outcome: 'ok' }]);
const checks = await run(parent);
expect(byName(checks, 'bootstrap_hooks_heartbeat')?.status).toBe('ok');
}, T);
});
// ── 1. hook heartbeat failure rate [B3] ─────────────────────────────────────
describe('bootstrap_hooks_heartbeat thresholds', () => {
const mix = (errors: number, ok: number): HeartbeatLine[] => [
...Array.from({ length: errors }, () => ({ outcome: 'error' as const })),
...Array.from({ length: ok }, () => ({ outcome: 'ok' as const })),
];
test('>0.5 hard-failure rate → fail, message carries the ratio', async () => {
const { parent, home } = makeHome();
writeHeartbeat(home, mix(6, 4));
const c = byName(await run(parent), 'bootstrap_hooks_heartbeat');
expect(c?.status).toBe('fail');
expect(c?.message).toContain('6/10');
}, T);
test('>0.2 and ≤0.5 → warn (hooks fail open)', async () => {
const { parent, home } = makeHome();
writeHeartbeat(home, mix(3, 7));
const c = byName(await run(parent), 'bootstrap_hooks_heartbeat');
expect(c?.status).toBe('warn');
expect(c?.message).toContain('3/10');
}, T);
test('exactly 0.2 → ok (threshold is strictly greater-than)', async () => {
const { parent, home } = makeHome();
writeHeartbeat(home, mix(2, 8));
const c = byName(await run(parent), 'bootstrap_hooks_heartbeat');
expect(c?.status).toBe('ok');
}, T);
test('degraded entries are DESIGNED fallbacks — never counted as failures', async () => {
const { parent, home } = makeHome();
writeHeartbeat(home, Array.from({ length: 10 }, () => ({ outcome: 'degraded' as const })));
const c = byName(await run(parent), 'bootstrap_hooks_heartbeat');
expect(c?.status).toBe('ok');
expect(c?.message).toContain('0/10');
}, T);
test('torn heartbeat lines are skipped, valid ones still counted — no throw', async () => {
const { parent, home } = makeHome();
writeHeartbeat(home, [{ outcome: 'ok' }, { outcome: 'error' }], ['{"half', 'garbage!!!', '{']);
const c = byName(await run(parent), 'bootstrap_hooks_heartbeat');
// 1 error / 2 valid = 0.5 → warn (not fail; torn lines don't inflate the denominator)
expect(c?.status).toBe('warn');
expect(c?.message).toContain('1/2');
}, T);
test('an ALL-torn heartbeat file opens the gate but emits no heartbeat check — no throw', async () => {
const { parent, home } = makeHome();
writeHeartbeat(home, [], ['not json', '{{{{']);
const checks = await run(parent);
expect(byName(checks, 'bootstrap_hooks_heartbeat')).toBeUndefined();
}, T);
});
// ── 2. push staleness [B4] ──────────────────────────────────────────────────
describe('bootstrap_push_health', () => {
test('fresh successful push → ok', async () => {
const { parent, home } = makeHome();
writePushStatus(home, JSON.stringify({ ts: new Date().toISOString(), ok: true }));
const c = byName(await run(parent), 'bootstrap_push_health');
expect(c?.status).toBe('ok');
expect(c?.message).toContain('last push ok');
}, T);
test('last push FAILED → warn naming the reason (regardless of age)', async () => {
const { parent, home } = makeHome();
writePushStatus(home, JSON.stringify({ ts: new Date().toISOString(), ok: false, reason: 'push_failed' }));
const c = byName(await run(parent), 'bootstrap_push_health');
expect(c?.status).toBe('warn');
expect(c?.message).toContain('FAILED');
expect(c?.message).toContain('push_failed');
}, T);
test('>48h stale + CLEAN tree → warn only (likely just idle)', async () => {
const { parent, home } = makeHome();
// No receipt → ws is null → dirty stays false: the stale-only branch.
writePushStatus(home, JSON.stringify({ ts: STALE_TS, ok: true }));
const c = byName(await run(parent), 'bootstrap_push_health');
expect(c?.status).toBe('warn');
expect(c?.message).toContain('>48h');
expect(c?.message).toContain('idle');
}, T);
test('>48h stale + DIRTY workspace tree → fail [B4]', async () => {
const { parent, home } = makeHome();
const ws = makeWorkspace({ dirty: true });
writeReceipt(home, ws);
writePushStatus(home, JSON.stringify({ ts: STALE_TS, ok: true }));
const c = byName(await run(parent), 'bootstrap_push_health');
expect(c?.status).toBe('fail');
expect(c?.message).toContain('DIRTY');
expect(c?.message).toContain(ws);
}, T);
test('unparseable ts → not stale (NaN guard) → ok', async () => {
const { parent, home } = makeHome();
writePushStatus(home, JSON.stringify({ ts: 'not-a-date', ok: true }));
const c = byName(await run(parent), 'bootstrap_push_health');
expect(c?.status).toBe('ok');
}, T);
test('a corrupt push-status.json degrades to warn — never a throw', async () => {
const { parent, home } = makeHome();
writePushStatus(home, 'torn{{{not-json');
const c = byName(await run(parent), 'bootstrap_push_health');
expect(c?.status).toBe('warn');
expect(c?.message).toContain('unreadable');
}, T);
});
// ── 3. one-live-serve / lock collision note ────────────────────────────────
describe('bootstrap_serve_lock', () => {
function writeLock(home: string, lock: Record<string, unknown>): void {
const lockDir = join(home, 'brain.pglite', '.gbrain-lock');
mkdirSync(lockDir, { recursive: true });
writeFileSync(join(lockDir, 'lock'), JSON.stringify(lock));
}
test('live serve holder → ok (hook IPC available), names the pid', async () => {
const { parent, home } = makeHome();
writeHeartbeat(home, [{ outcome: 'ok' }]); // open the gate
writeLock(home, { pid: process.pid, subcommand: 'serve' });
const c = byName(await run(parent), 'bootstrap_serve_lock');
expect(c?.status).toBe('ok');
expect(c?.message).toContain(String(process.pid));
expect(c?.message).toContain('live serve');
}, T);
test('live NON-serve holder → warn (hook IPC blocked)', async () => {
const { parent, home } = makeHome();
writeHeartbeat(home, [{ outcome: 'ok' }]);
writeLock(home, { pid: process.pid, subcommand: 'sync' });
const c = byName(await run(parent), 'bootstrap_serve_lock');
expect(c?.status).toBe('warn');
expect(c?.message).toContain('non-serve');
}, T);
test('dead holder / no lock → no check (stale lock dir is inert)', async () => {
const { parent, home } = makeHome();
writeHeartbeat(home, [{ outcome: 'ok' }]);
const deadPid = spawnSync('true', { stdio: 'ignore' }).pid!; // exited + reaped → ESRCH
writeLock(home, { pid: deadPid, subcommand: 'serve' });
expect(byName(await run(parent), 'bootstrap_serve_lock')).toBeUndefined();
}, T);
test('a corrupt lock file probes to null — no check, no throw', async () => {
const { parent, home } = makeHome();
writeHeartbeat(home, [{ outcome: 'ok' }]);
const lockDir = join(home, 'brain.pglite', '.gbrain-lock');
mkdirSync(lockDir, { recursive: true });
writeFileSync(join(lockDir, 'lock'), 'not json{{{');
expect(byName(await run(parent), 'bootstrap_serve_lock')).toBeUndefined();
}, T);
});
// ── 4. hooks-in-use + unmigrated brain pairing [ENG-4] ─────────────────────
describe('bootstrap_hook_schema_pairing', () => {
const stubEngine = (getConfig: (key: string) => Promise<string | null>): BrainEngine =>
({ getConfig } as unknown as BrainEngine);
test('hooks seen + stale schema → warn naming apply-migrations', async () => {
const { parent, home } = makeHome();
writeHeartbeat(home, [{ outcome: 'ok' }]); // hooksSeen = true
const engine = stubEngine(async () => '1'); // v1 < LATEST_VERSION
const c = byName(await run(parent, engine), 'bootstrap_hook_schema_pairing');
expect(c?.status).toBe('warn');
expect(c?.message).toContain(`v${LATEST_VERSION}`);
expect(c?.message).toContain('apply-migrations');
}, T);
test('hooks seen + current schema → no pairing check', async () => {
const { parent, home } = makeHome();
writeHeartbeat(home, [{ outcome: 'ok' }]);
const engine = stubEngine(async () => String(LATEST_VERSION));
expect(byName(await run(parent, engine), 'bootstrap_hook_schema_pairing')).toBeUndefined();
}, T);
test('null engine → no pairing check (heartbeat check still emitted)', async () => {
const { parent, home } = makeHome();
writeHeartbeat(home, [{ outcome: 'ok' }]);
const checks = await run(parent, null);
expect(byName(checks, 'bootstrap_hook_schema_pairing')).toBeUndefined();
expect(byName(checks, 'bootstrap_hooks_heartbeat')).toBeDefined();
}, T);
test('getConfig throwing is swallowed — no pairing check, no throw', async () => {
const { parent, home } = makeHome();
writeHeartbeat(home, [{ outcome: 'ok' }]);
const engine = stubEngine(async () => {
throw new Error('schema_version unreadable');
});
expect(byName(await run(parent, engine), 'bootstrap_hook_schema_pairing')).toBeUndefined();
}, T);
});
// ── 5. runbook skew [C1] ────────────────────────────────────────────────────
describe('bootstrap_runbook_skew', () => {
test('stamp != binary version → warn carrying both', async () => {
const { parent, home } = makeHome();
const ws = makeWorkspace();
writeReceipt(home, ws);
writeFileSync(join(ws, 'BOOTSTRAP_FOR_AGENTS.md'), '<!-- gbrain-runbook-stamp: 0.0.1 -->\n# runbook\n');
const c = byName(await run(parent), 'bootstrap_runbook_skew');
expect(c?.status).toBe('warn');
expect(c?.message).toContain('0.0.1');
expect(c?.message).toContain(VERSION);
}, T);
test('matching stamp → no check; absent runbook → no check', async () => {
const { parent, home } = makeHome();
const ws = makeWorkspace();
writeReceipt(home, ws);
expect(byName(await run(parent), 'bootstrap_runbook_skew')).toBeUndefined();
writeFileSync(join(ws, 'BOOTSTRAP_FOR_AGENTS.md'), `<!-- gbrain-runbook-stamp: ${VERSION} -->\n`);
expect(byName(await run(parent), 'bootstrap_runbook_skew')).toBeUndefined();
}, T);
});
// ── 6. last verify freshness [B2 read side] ─────────────────────────────────
describe('bootstrap_last_verify', () => {
test('last verify FAILED → warn naming the failed check ids', async () => {
const { parent, home } = makeHome();
writeHeartbeat(home, [{ outcome: 'ok' }]); // verify snapshots alone don't open the gate
writeVerifyRun(
home,
'verify-2026-01-01T00-00-00-000Z.json',
JSON.stringify({ ts: '2026-01-01T00:00:00.000Z', ok: false, checks: [{ id: 'roundtrip', ok: false }] }),
);
const c = byName(await run(parent), 'bootstrap_last_verify');
expect(c?.status).toBe('warn');
expect(c?.message).toContain('FAILED');
expect(c?.message).toContain('roundtrip');
}, T);
test('passed >14 days ago → warn (workspace rot self-check nag)', async () => {
const { parent, home } = makeHome();
writeHeartbeat(home, [{ outcome: 'ok' }]);
const oldTs = new Date(Date.now() - 20 * 86_400_000).toISOString();
writeVerifyRun(
home,
'verify-2026-01-02T00-00-00-000Z.json',
JSON.stringify({ ts: oldTs, ok: true, checks: [] }),
);
const c = byName(await run(parent), 'bootstrap_last_verify');
expect(c?.status).toBe('warn');
expect(c?.message).toContain('d ago');
}, T);
test('recent pass → ok; the NEWEST run (by filename) wins over an older failure', async () => {
const { parent, home } = makeHome();
writeHeartbeat(home, [{ outcome: 'ok' }]);
writeVerifyRun(
home,
'verify-2026-01-01T00-00-00-000Z.json',
JSON.stringify({ ts: '2026-01-01T00:00:00.000Z', ok: false, checks: [{ id: 'roundtrip', ok: false }] }),
);
const freshTs = new Date().toISOString();
writeVerifyRun(
home,
'verify-2026-06-01T00-00-00-000Z.json',
JSON.stringify({ ts: freshTs, ok: true, checks: [] }),
);
const c = byName(await run(parent), 'bootstrap_last_verify');
expect(c?.status).toBe('ok');
expect(c?.message).toContain(freshTs);
}, T);
test('an unreadable snapshot is skipped; corrupt-only → no check, no throw', async () => {
const { parent, home } = makeHome();
writeHeartbeat(home, [{ outcome: 'ok' }]); // keep the gate open
writeVerifyRun(home, 'verify-2026-01-01T00-00-00-000Z.json', 'torn{{{');
expect(byName(await run(parent), 'bootstrap_last_verify')).toBeUndefined();
}, T);
});
// ── everything torn at once — the fail-soft umbrella ────────────────────────
// ── bootstrap_durability_job [B7/D7] ────────────────────────────────────────
//
// Presence-only checks certify dead jobs as healthy, so the doctor probes
// consent + liveness. These pin the two branch families a unit test can hold
// deterministically: the non-local environment (no scheduler EXPECTED — ok)
// and the consented-but-missing job (warn naming `gbrain sources harden`).
describe('bootstrap_durability_job [B7/D7]', () => {
/** Env keys that would flip detectExecutionEnvironment away from local. */
const NEUTRAL_ENV = {
CLAUDE_CODE_REMOTE: undefined,
CLAUDE_CODE_REMOTE_SESSION_ID: undefined,
GH_TOKEN: undefined,
GITHUB_TOKEN: undefined,
https_proxy: undefined,
HTTPS_PROXY: undefined,
RENDER: undefined,
RAILWAY_ENVIRONMENT: undefined,
FLY_APP_NAME: undefined,
} as const;
function writeConsent(ws: string, value: 'yes' | 'no'): void {
mkdirSync(join(ws, 'state'), { recursive: true });
writeFileSync(
join(ws, 'state', 'interview.json'),
JSON.stringify({ version: 1, answers: { PERSIST_CRON: { value, set_at: new Date().toISOString() } } }),
);
}
test('cloud sandbox: no scheduler is EXPECTED → ok naming the environment, never a warn', async () => {
const { parent, home } = makeHome();
const ws = makeWorkspace();
writeReceipt(home, ws);
writeConsent(ws, 'yes'); // even with consent, a cloud sandbox has no scheduler to check
const checks = await withEnv(
{ GBRAIN_HOME: parent, CLAUDE_CODE_REMOTE: 'true' },
() => bootstrapDoctorChecks(null),
);
const c = byName(checks, 'bootstrap_durability_job');
expect(c?.status).toBe('ok');
expect(c?.message).toContain('no scheduler in this environment');
expect(c?.message).toContain('cloud-sandbox');
}, T);
test('local + PERSIST_CRON=yes but NO scheduled job on disk → warn naming `gbrain sources harden`', async () => {
const { parent, home } = makeHome();
const ws = makeWorkspace();
writeReceipt(home, ws);
writeConsent(ws, 'yes');
// HOME redirected: the launchd-plist / pull-log probes must never read the
// real machine's LaunchAgents (a developer's own gbrain install would flip
// the verdict). Fresh empty HOME → durabilityJobStatus kind 'none'.
const fakeHome = mkdtempSync(join(tmpdir(), 'gb-bdc-fakehome-'));
tmpDirs.push(fakeHome);
const checks = await withEnv(
{ ...NEUTRAL_ENV, GBRAIN_HOME: parent, HOME: fakeHome },
() => bootstrapDoctorChecks(null),
);
const c = byName(checks, 'bootstrap_durability_job');
expect(c?.status).toBe('warn');
expect(c?.message).toContain('PERSIST_CRON=yes');
expect(c?.message).toContain('gbrain sources harden workspace');
}, T);
});
describe('fail-soft umbrella', () => {
test('corrupt receipt + torn heartbeat + corrupt push-status + torn verify → warns, never throws', async () => {
const { parent, home } = makeHome();
mkdirSync(join(home, 'bootstrap'), { recursive: true });
writeFileSync(join(home, 'bootstrap', 'receipt.json'), '{"receipt_version":'); // torn
writeHeartbeat(home, [], ['{"half', 'garbage']);
writePushStatus(home, 'also not json');
writeVerifyRun(home, 'verify-2026-01-01T00-00-00-000Z.json', '{{{{');
const checks = await run(parent);
// The only surviving signal is the push-status warn; nothing throws.
expect(byName(checks, 'bootstrap_push_health')?.status).toBe('warn');
expect(checks.every((c) => c.name.startsWith('bootstrap_'))).toBe(true);
expect(checks.some((c) => c.status === 'fail')).toBe(false);
}, T);
});