mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
* 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>
504 lines
22 KiB
TypeScript
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);
|
|
});
|