Files
gbrain/src/core/bootstrap/verify.ts
T
Garry TanandClaude Fable 5 a34935a312 Merge remote-tracking branch 'origin/master' into garrytan/nouakchott + v0.45.11.0
Master's 12-commit fix batch (#4064 hooks-phase source_id + eager brain/,
#4065 --help mutation guards, #4063 Gate-2 active-account check, plus search/
doctor/dream/autopilot/skills fixes) merges cleanly except one import-union
conflict in the dispatcher test. Release re-bumped 0.45.10.0 → 0.45.11.0 per
the release queue (a sibling wave holds an in-flight claim): VERSION,
package.json, openclaw.plugin.json, runbook stamp, template tree, CHANGELOG
header + self-references all move together. Derived files (template tree,
llms bundles, skills lock, flag registry) regenerated from merged source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:53:38 -07:00

1030 lines
45 KiB
TypeScript

/**
* `gbrain bootstrap verify` — the whole-install contract check
* (agent-bootstrap plan: D8 + G1, G6, G13, CX2-5, CX-P0.5, ENG-tests, B2,
* D3.6/A4 first-run tour).
*
* Every check is FAIL-SOFT and COLLECTED: a thrown probe becomes a failed
* check with the error in `detail`, never an unhandled exception — the
* runbook pastes the whole report to the human.
*
* Determinism [CX2-5]: verify never talks to a host's live serve. The caller
* (the bootstrap dispatcher, or a test) holds the engine, so the graph-floor
* seam is: write via the REAL put_page op handler (ctx.remote:false) →
* `runMaintenanceSweep` directly (the `gbrain sweep --once` equivalent) →
* edge query via the engine link tables. No timing nondeterminism, works
* pre-registration AND as the weekly re-run (verify's caller holds the
* engine either way).
*
* Keyless first-class [CX-P0.5]: the magic-moment fact is written through a
* `## Facts` fence on the probe page and reconciled by the sweep's ZERO-LLM
* fence pass, then read back at visibility='world' (the visibility the
* harness can read, S3#1) — the check passes with zero API keys.
*
* [G13] Fixed probe slugs; prior leftovers are swept before writing; probe
* deletion failure is a WARNING, not a verify failure.
*
* [B2] Every run persists to `<gbrain home>/bootstrap/verify-<ts>.json`,
* keeping the last 5 snapshots. `bootstrap status` + doctor read them.
*/
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import type { BrainEngine } from '../engine.ts';
import { operations, type Operation, type OperationContext } from '../operations.ts';
import { loadConfigFileOnly, type GBrainConfig } from '../config.ts';
import { detectExecutionEnvironment } from '../execution-env.ts';
import { resolveGbrainHome } from '../gbrain-home.ts';
import { realpathOrResolve } from '../path-confine.ts';
import { runMaintenanceSweep } from '../sweep.ts';
import { detectCapabilities, renderCapabilityReport, type CapabilityReport } from '../capability.ts';
import { loadWorkspaceAllowlist, matchesGlob, scanFiles, type SecretFinding } from '../secret-scan.ts';
import { PUSH_DENY_GLOBS, verifyRemotePrivacy, readPushStatuses, summarizePushStatuses } from '../workspace-push.ts';
import { FACTS_DEFAULT_VISIBILITY_KEY } from '../facts/visibility.ts';
import { byteFloors } from './render.ts';
import { BOOTSTRAP_TEMPLATES, loadQuestionBank } from './assets.ts';
import { readManifest, writeManifest } from './format.ts';
import { status as interviewStatus } from './interview.ts';
import { gitOriginUrl, hooksInstalled } from './status.ts';
import {
ensureIpcSecret,
resolveSocketPath,
startResolveIpcServer,
} from '../context/resolve-ipc.ts';
import { assembleTurnContext } from '../context/turn-context.ts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface VerifyCheck {
id: string;
ok: boolean;
/** Set when a non-ok state is advisory (does not fail the run). */
warn?: boolean;
detail: string;
}
export interface VerifyReport {
ok: boolean;
checks: VerifyCheck[];
/** Human-readable report, capability block + first-run tour included. */
report: string;
capability: CapabilityReport;
/** The three scripted first-run prompts [D3.6/A4]. */
tour: string[];
/** The OOBE hand-off lines (ownership + the cold-start next action) —
* unconditional in the shape like `tour`; printed in the report on PASS. */
handoff: string[];
}
export interface VerifyOpts {
/** Source the workspace brain/ dir registered as. Default 'workspace'. */
sourceId?: string;
/** Gbrain home for snapshot persistence. Default resolveGbrainHome(). */
gbrainHomeDir?: string;
/** Capability report override (test seam — mirrors SweepOpts). */
capabilities?: CapabilityReport;
/** Sweep wall-clock budget. Default 20s (fence + links passes are cheap). */
sweepBudgetMs?: number;
/** Skip the hooks IPC smoke (tests that don't stage a settings file). */
skipHooksSmoke?: boolean;
log?: (line: string) => void;
}
/** Fixed probe slugs [G13]. */
export const VERIFY_PROBE_SLUG = 'wiki/bootstrap-verify-probe';
export const VERIFY_PROBE_ENTITY_SLUG = 'wiki/bootstrap-verify-probe-entity';
/** Deterministic magic-moment token the fence fact carries [CX-P0.5]. */
export const VERIFY_MAGIC_TOKEN = 'verify-lighthouse-passphrase';
/** The three scripted first-run prompts [D3.6] — pinned by the A4 snapshot test.
* Exactly three (the count is copy-pinned here, in BOOTSTRAP_FOR_AGENTS.md,
* and the A4 plan). Prompt 3 must be TRUE on day one — the brain is empty at
* install, so "everything ingested so far" would be an anticlimax; the
* round-trip fact from prompt 2 is the honest day-one payoff. */
export const FIRST_RUN_TOUR: readonly string[] = [
'"Who am I to you?" — identity from SOUL.md/USER.md, no lookup needed.',
'"Remember that <one small true fact>." — it lands in the brain, not this chat.',
'"What do you remember about me?" — asked in the NEW session: on day one that is the fact from prompt 2, recalled from the brain. Every session after this adds more. That round-trip is the whole product.',
];
// ---------------------------------------------------------------------------
// Op plumbing (trusted local ctx)
// ---------------------------------------------------------------------------
function findOp(name: string): Operation {
const op = operations.find((o) => o.name === name);
if (!op) throw new Error(`operation not found: ${name}`);
return op;
}
function localCtx(engine: BrainEngine, sourceId: string, log?: (l: string) => void): OperationContext {
const sink = log ?? (() => {});
return {
engine,
config: (loadConfigFileOnly() ?? { engine: 'pglite' }) as GBrainConfig,
logger: { info: sink, warn: sink, error: sink },
dryRun: false,
remote: false,
sourceId,
};
}
// ---------------------------------------------------------------------------
// Probe content
// ---------------------------------------------------------------------------
const PROBE_ENTITY_CONTENT = `---
title: Bootstrap Verify Probe Entity
type: concept
---
# Bootstrap Verify Probe Entity
Anchor page for the \`gbrain bootstrap verify\` graph-floor probe. Written and
deleted by verify; not user content.
`;
const PROBE_CONTENT = `---
title: Bootstrap Verify Probe
type: concept
---
# Bootstrap Verify Probe
Written and deleted by \`gbrain bootstrap verify\` (fixed slug, swept on every
run). It links to [[${VERIFY_PROBE_ENTITY_SLUG}]] so the graph floor has a
deterministic edge to extract.
## Facts
<!--- gbrain:facts:begin -->
| # | claim | kind | confidence | visibility | notability | valid_from | valid_until | source | context |
|---|-------|------|------------|------------|------------|------------|-------------|--------|---------|
| 1 | The bootstrap verify magic-moment token is ${VERIFY_MAGIC_TOKEN} | fact | 1.0 | world | low | | | bootstrap-verify | |
<!--- gbrain:facts:end -->
`;
/** Remove any prior probe state [G13]: page rows (hard — verify is a trusted
* local caller and the probe is not user content), reconciled probe facts,
* and write-through files under brain/. Best-effort, never throws. */
async function sweepProbeLeftovers(engine: BrainEngine, ws: string, sourceId: string): Promise<void> {
for (const slug of [VERIFY_PROBE_SLUG, VERIFY_PROBE_ENTITY_SLUG]) {
try {
await engine.deletePage(slug, { sourceId });
} catch {
/* absent / engine without hard delete — soft path below still applies */
}
try {
rmSync(join(ws, 'brain', `${slug}.md`), { force: true });
} catch {
/* best effort */
}
}
try {
// Scope the delete to the EXACT probe page(s) this run wrote (via the
// v51 fence column source_markdown_slug), NEVER a `fact LIKE %token%`
// substring match — a user fact that merely mentions the token string
// must survive verify's cleanup [G13].
await engine.executeRaw(
`DELETE FROM facts WHERE source_id = $1 AND source_markdown_slug IN ($2, $3)`,
[sourceId, VERIFY_PROBE_SLUG, VERIFY_PROBE_ENTITY_SLUG],
);
} catch {
/* facts table may not exist on a pre-migration brain — doctor_green names that */
}
}
// ---------------------------------------------------------------------------
// Individual checks (each returns a VerifyCheck; never throws)
// ---------------------------------------------------------------------------
async function checkDoctorGreen(engine: BrainEngine): Promise<VerifyCheck> {
const id = 'doctor_green';
try {
// The cheapest existing health probe: config round-trip + schema probe.
// Deliberately NOT a shell-out to `gbrain doctor` (a second engine open
// would contend for the PGLite lock verify's caller already holds).
const version = await engine.getConfig('version');
await engine.executeRaw(`SELECT 1 FROM pages LIMIT 1`, []);
return { id, ok: true, detail: `engine healthy (schema version ${version ?? 'unrecorded'})` };
} catch (e) {
return { id, ok: false, detail: `engine probe failed: ${(e as Error).message}` };
}
}
const TOKEN_RE = /\{\{([A-Z0-9_]+)\}\}/g;
function checkTokenSweep(ws: string): VerifyCheck {
const id = 'token_sweep';
try {
const manifest = readManifest(ws);
if (manifest.state === 'template') {
// Minimal-mode template trees legitimately keep the six required-key
// markers — the sweep would be a false alarm on a template clone.
return { id, ok: true, warn: true, detail: 'template clone (initialized:false) — placeholder tokens are expected; render before verifying for real' };
}
const offenders: string[] = [];
for (const t of BOOTSTRAP_TEMPLATES) {
const p = join(ws, t.dest);
if (!existsSync(p)) continue;
const raw = readFileSync(p, 'utf8');
const tokens = [...raw.matchAll(TOKEN_RE)].map((m) => m[1]);
if (tokens.length > 0) offenders.push(`${t.dest} (${[...new Set(tokens)].join(', ')})`);
}
if (offenders.length > 0) {
return { id, ok: false, detail: `unresolved {{TOKEN}}s in rendered files: ${offenders.join('; ')} — finish the interview and re-run render` };
}
return { id, ok: true, detail: 'no unresolved template tokens in rendered identity files' };
} catch (e) {
return { id, ok: false, detail: `token sweep failed: ${(e as Error).message}` };
}
}
function checkByteFloors(ws: string): VerifyCheck {
const id = 'byte_floors';
try {
const bank = loadQuestionBank();
const st = interviewStatus(ws);
const answeredCount = st.ok ? st.answered.filter((k) => bank.interviewKeys.includes(k)).length : 0;
const floors = byteFloors(answeredCount);
const problems: string[] = [];
for (const [file, floor] of Object.entries(floors)) {
const p = join(ws, file);
if (!existsSync(p)) {
problems.push(`${file} missing`);
continue;
}
const size = statSync(p).size;
if (size < floor) problems.push(`${file} is ${size}B < floor ${floor}B (@${answeredCount} answered)`);
}
if (problems.length > 0) {
return { id, ok: false, detail: `byte floors: ${problems.join('; ')} — floors catch skipped interviews, never pad; re-run the interview + render` };
}
return { id, ok: true, detail: `SOUL.md/USER.md above floors for ${answeredCount} answered question(s)` };
} catch (e) {
return { id, ok: false, detail: `byte-floor check failed: ${(e as Error).message}` };
}
}
/** Tracked files via git; falls back to the rendered file set + state/ when
* the workspace is not (yet) a git repo. */
function trackedWorkspaceFiles(ws: string): { files: string[]; via: 'git' | 'fallback' } {
try {
const out = execFileSync('git', ['-C', ws, 'ls-files', '--cached', '--others', '--exclude-standard', '-z'], {
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 15_000,
maxBuffer: 16 * 1024 * 1024,
}).toString();
const rel = out.split('\0').filter((s) => s.length > 0);
if (rel.length > 0) return { files: rel, via: 'git' };
} catch {
/* not a repo — fallback below */
}
const files: string[] = [];
for (const t of BOOTSTRAP_TEMPLATES) {
if (existsSync(join(ws, t.dest))) files.push(t.dest);
}
if (existsSync(join(ws, 'state', 'interview.json'))) files.push(join('state', 'interview.json'));
return { files, via: 'fallback' };
}
function checkSecretScan(ws: string): VerifyCheck {
const id = 'secret_scan';
try {
const { files, via } = trackedWorkspaceFiles(ws);
const allowlist = loadWorkspaceAllowlist(ws);
const findings: SecretFinding[] = scanFiles(files.map((f) => join(ws, f)), {
allowlist,
workspaceRoot: ws,
});
if (findings.length > 0) {
const sample = findings.slice(0, 5).map((f) => `${f.file}:${f.line} [${f.pattern}] ${f.fingerprint}`).join('; ');
return { id, ok: false, detail: `${findings.length} secret-scan finding(s): ${sample}${findings.length > 5 ? '; …' : ''} — fix, or allowlist a false positive in .gbrain-scan-allow` };
}
return { id, ok: true, detail: `no secrets in ${files.length} ${via === 'git' ? 'tracked' : 'workspace'} file(s)` };
} catch (e) {
return { id, ok: false, detail: `secret scan failed: ${(e as Error).message}` };
}
}
function checkDenyGlobs(ws: string): VerifyCheck {
const id = 'deny_globs';
try {
const { files, via } = trackedWorkspaceFiles(ws);
const matches = files.filter((f) => PUSH_DENY_GLOBS.some((g) => matchesGlob(g, f)));
if (via === 'git' && matches.length > 0) {
return { id, ok: false, detail: `deny-glob matches among candidate files: ${matches.slice(0, 10).join(', ')} — remove from the index (git rm --cached) before any push` };
}
return { id, ok: true, detail: via === 'git' ? 'no tracked files match the push deny list' : 'not a git repo yet — deny-glob backstop applies at push time' };
} catch (e) {
return { id, ok: false, detail: `deny-glob check failed: ${(e as Error).message}` };
}
}
/** Self-repair channel for existing installs [B8]: a git-TRACKED .mcp.json
* carries an absolute machine-specific binary path into the private repo.
* Fresh renders gitignore it; this warn heals pre-fix installs. Never gates. */
function checkMcpJsonHygiene(ws: string): VerifyCheck {
const id = 'mcp_json_hygiene';
try {
if (!existsSync(join(ws, '.mcp.json'))) return { id, ok: true, detail: 'no .mcp.json in the workspace' };
try {
execFileSync('git', ['-C', ws, 'ls-files', '--error-unmatch', '.mcp.json'], {
stdio: 'ignore', timeout: 5_000,
});
} catch {
return { id, ok: true, detail: '.mcp.json present but untracked (gitignored) — correct' };
}
return {
id,
ok: true, // warn-only: informational, never gating
detail:
'WARN: machine-specific .mcp.json is COMMITTED to the repo — run `git rm --cached .mcp.json` ' +
'(bootstrap now gitignores it; it regenerates via `claude mcp add` / `bootstrap hooks --repair`)',
};
} catch (e) {
return { id, ok: true, detail: `mcp.json hygiene probe failed (${(e as Error).message})` };
}
}
/** [D12 upgrade-path guard]: an event carried by BOTH hook files double-fires
* every turn (possible when the committed carrier arrives via git pull onto a
* machine whose local file predates the dedupe-aware writers). Warn-only. */
function checkHookCarrierOverlap(ws: string): VerifyCheck {
const id = 'hook_carrier_overlap';
try {
const events = (['SessionStart', 'UserPromptSubmit', 'Stop', 'SessionEnd'] as const).filter((event) => {
const has = (rel: string): boolean => {
try {
const parsed = JSON.parse(readFileSync(join(ws, rel), 'utf8')) as { hooks?: Record<string, unknown> };
const groups = parsed?.hooks?.[event];
return Array.isArray(groups) && JSON.stringify(groups).includes('"_gbrain"');
} catch {
return false;
}
};
return has(join('.claude', 'settings.json')) && has(join('.claude', 'settings.local.json'));
});
if (events.length === 0) return { id, ok: true, detail: 'no event fires from both hook carriers' };
return {
id,
ok: true, // warn-only self-repair channel
detail: `WARN: ${events.join(', ')} fire from BOTH .claude/settings.json and settings.local.json (double-fire) — run \`gbrain bootstrap hooks --repair\` to dedupe`,
};
} catch (e) {
return { id, ok: true, detail: `carrier overlap probe failed (${(e as Error).message})` };
}
}
async function checkRepoPrivacy(ws: string): Promise<VerifyCheck> {
const id = 'repo_privacy';
try {
const origin = gitOriginUrl(ws);
if (!origin) {
return { id, ok: true, detail: 'local-only (no origin remote) — run `gbrain bootstrap repo` any time to add the private body' };
}
const verdict = await verifyRemotePrivacy(ws);
if (verdict.verdict === 'private') return { id, ok: true, detail: `origin verified private (${origin})` };
if (verdict.verdict === 'not_private') return { id, ok: false, detail: `origin is NOT private: ${verdict.detail} — make it private before pushing workspace contents` };
return { id, ok: false, detail: `origin visibility unverifiable (${verdict.detail}) — refusing to bless an unverified remote [G8]; re-run once gh works` };
} catch (e) {
return { id, ok: false, detail: `repo privacy check failed: ${(e as Error).message}` };
}
}
/** Informational, NEVER gating [D-cloud]: name the detected execution
* environment and its expected degradations so an installing agent (and the
* pasted verify report) states them as facts instead of rediscovering them
* as mystery failures. */
function checkExecutionEnvironment(): VerifyCheck {
const id = 'execution_env';
try {
const env = detectExecutionEnvironment();
if (env === 'cloud-sandbox') {
return {
id,
ok: true,
detail:
'cloud sandbox detected — expected degradations: no crontab (scheduled pull skipped; per-turn/session-end pushes cover it); ' +
'GitHub GraphQL always blocked and REST scoped to session-attached repos (privacy verification falls back to git protocol); ' +
'pushes restricted to the session\'s working branch; only repo-committed files carry into the next session',
};
}
if (env === 'ephemeral-container') {
return {
id,
ok: true,
detail: 'container detected — no reliable scheduler (scheduled pull skipped); event-driven pushes cover persistence',
};
}
return { id, ok: true, detail: 'local machine — full persistence surface available' };
} catch (e) {
return { id, ok: true, detail: `environment detection failed (${(e as Error).message}) — treated as local` };
}
}
/** [CX-P1.1] Single-principal posture: facts written without an explicit
* visibility must be recallable by the owner's own sessions (the harness
* reads at visibility='world'). Set-IF-UNSET only, through the engine config
* plane — an operator's explicit value (e.g. 'private' for a brain exposed to
* other surfaces) is NEVER overridden. The report line names the posture and
* where to flip it. */
async function ensureDefaultVisibilityPosture(engine: BrainEngine): Promise<VerifyCheck> {
const id = 'facts_visibility';
try {
const existing = await engine.getConfig(FACTS_DEFAULT_VISIBILITY_KEY);
if (existing == null || existing.trim() === '') {
await engine.setConfig(FACTS_DEFAULT_VISIBILITY_KEY, 'world');
return {
id,
ok: true,
detail:
`facts default visibility: world (set by bootstrap verify — was unset). ` +
`Flip with \`gbrain config set ${FACTS_DEFAULT_VISIBILITY_KEY} private\` if less-trusted surfaces will read this brain.`,
};
}
return {
id,
ok: true,
detail:
`facts default visibility: ${existing.trim()} (explicit operator value — untouched). ` +
`Flip with \`gbrain config set ${FACTS_DEFAULT_VISIBILITY_KEY} <world|private>\`.`,
};
} catch (e) {
return { id, ok: true, warn: true, detail: `could not read/set ${FACTS_DEFAULT_VISIBILITY_KEY}: ${(e as Error).message}` };
}
}
/** Effective MCP-surface posture: bootstrap registrations pin `--surface
* full`, but a REGISTRATION THAT PREDATES that pin resolves the config key —
* `mcp_surface: 'verbs'` would silently narrow the bootstrap contract
* (put_page/get_page/timeline are not memory verbs). Loud WARN naming the fix. */
function checkMcpSurface(): VerifyCheck {
const id = 'mcp_surface';
try {
const surface = loadConfigFileOnly()?.mcp_surface;
if (surface === 'verbs') {
return {
id,
ok: true,
warn: true,
detail:
`config mcp_surface='verbs' narrows a bare \`gbrain serve\` to the five memory verbs — the bootstrap ` +
`contract needs the full surface. Fix: re-run \`gbrain bootstrap hooks --repair\` (registrations now pin ` +
`--surface full) or \`gbrain config set mcp_surface full\`.`,
};
}
return { id, ok: true, detail: `mcp_surface: ${surface ?? 'full (default)'} — full op surface for registrations` };
} catch (e) {
return { id, ok: true, warn: true, detail: `mcp_surface config unreadable: ${(e as Error).message}` };
}
}
/**
* Pure, engine-free derivation of the collision-fallback source_id for a
* workspace — a deterministic hash of the workspace's real path, no DB
* lookup involved. `resolveSourceIdCollision` (below) is the only thing that
* decides WHETHER this id is actually needed (that half requires the engine,
* since the sources registry lives only in the DB) — but the id itself is
* safe to preview from an engine-free phase. `bootstrap hooks` does exactly
* that, so a human has the fallback id in hand before they ever hand-register
* a source, instead of discovering it only after an FK error + a corrective
* `verify` run.
*/
export function deriveWorkspaceSourceId(ws: string): string {
const hash = createHash('sha256').update(realpathOrResolve(ws)).digest('hex').slice(0, 8);
return `workspace-${hash}`;
}
/**
* source_id collision resolution [engine seam]. Render is ENGINE-FREE and the
* sources registry lives ONLY in the DB (no registry file exists), so verify —
* the one bootstrap subcommand holding an engine — is where a manifest
* source_id already registered to a DIFFERENT checkout is detected. On
* collision it derives a stable `workspace-<8char-path-hash>` id (via
* `deriveWorkspaceSourceId`), persists it to agent.json (render preserves it
* on re-render), and names the re-register steps; every consumer (hooks
* GBRAIN_SOURCE env, verify, status hints, attach, repo persistence) reads
* manifest.source_id, so the derived id propagates. Returns a sourceId ONLY
* when it derived one.
*/
async function resolveSourceIdCollision(
engine: BrainEngine,
ws: string,
): Promise<{ sourceId: string | null; check: VerifyCheck | null }> {
const state = readManifest(ws);
if (state.state !== 'initialized') return { sourceId: null, check: null };
const current = state.manifest.source_id;
const brainDir = join(ws, 'brain');
try {
const rows = await engine.executeRaw<{ local_path: string | null }>(
`SELECT local_path FROM sources WHERE id = $1`,
[current],
);
const registered = rows[0]?.local_path ?? null;
if (rows.length === 0 || registered === null) return { sourceId: null, check: null };
if (realpathOrResolve(registered) === realpathOrResolve(brainDir)) {
return { sourceId: null, check: null }; // same checkout — no collision
}
const derived = deriveWorkspaceSourceId(ws);
writeManifest(ws, { ...state.manifest, source_id: derived });
return {
sourceId: derived,
check: {
id: 'source_id',
ok: true,
warn: true,
detail:
`source '${current}' is already registered to a different checkout (${registered}) — derived ` +
`'${derived}' and persisted it to agent.json. Register it (gbrain sources add ${derived} --path ${brainDir}) ` +
`and re-run \`gbrain bootstrap hooks --repair\` so GBRAIN_SOURCE follows.`,
},
};
} catch (e) {
return {
sourceId: null,
check: { id: 'source_id', ok: true, warn: true, detail: `source-id collision probe failed: ${(e as Error).message}` },
};
}
}
interface RoundtripOutcome {
checks: VerifyCheck[];
}
/** The G1/CX2-5 core: put_page (real op, remote:false) → committed file under
* brain/ → sweep → graph floor via link tables → get_page → search → magic
* moment (keyless fence fact) → delete probes. */
async function runRoundtrip(
engine: BrainEngine,
ws: string,
sourceId: string,
opts: VerifyOpts,
): Promise<RoundtripOutcome> {
const checks: VerifyCheck[] = [];
const ctx = localCtx(engine, sourceId, opts.log);
const putPage = findOp('put_page');
const getPage = findOp('get_page');
const queryOp = findOp('query');
const deletePage = findOp('delete_page');
await sweepProbeLeftovers(engine, ws, sourceId);
// 1. Write both probe pages through the REAL op handler [G1].
let writeThroughDetail = '';
try {
await putPage.handler(ctx, { slug: VERIFY_PROBE_ENTITY_SLUG, content: PROBE_ENTITY_CONTENT });
const res = (await putPage.handler(ctx, { slug: VERIFY_PROBE_SLUG, content: PROBE_CONTENT })) as {
write_through?: { written: boolean; path?: string; skipped?: string; error?: string };
};
const wt = res.write_through;
const expectedFile = join(ws, 'brain', `${VERIFY_PROBE_SLUG}.md`);
if (wt?.written && wt.path && existsSync(wt.path)) {
writeThroughDetail = `file materialized at ${wt.path}`;
checks.push({ id: 'roundtrip', ok: true, detail: `put_page landed in DB and ${writeThroughDetail}` });
} else if (existsSync(expectedFile)) {
checks.push({ id: 'roundtrip', ok: true, detail: `put_page landed in DB and file materialized at ${expectedFile}` });
} else {
// [G1] A green verify with an empty repo is impossible: name the
// write-through problem explicitly.
const why = wt?.skipped ?? wt?.error ?? 'write-through reported nothing';
checks.push({
id: 'roundtrip',
ok: false,
detail:
`put_page wrote the DB but NO file appeared under brain/ (write-through: ${why}). ` +
`The workspace source must be registered with its brain/ dir as local_path ` +
`(gbrain sources add ${sourceId} --path ${join(ws, 'brain')}) or agent writes never reach the repo.`,
});
return { checks };
}
} catch (e) {
checks.push({ id: 'roundtrip', ok: false, detail: `put_page failed: ${(e as Error).message}` });
return { checks };
}
// 2. Sweep — the `gbrain sweep --once` equivalent, run in-process because
// verify's caller holds the engine [CX2-5].
const caps = opts.capabilities ?? detectCapabilities();
try {
const report = await runMaintenanceSweep(engine, {
sourceId,
budgetMs: opts.sweepBudgetMs ?? 20_000,
capabilities: caps,
log: opts.log,
});
opts.log?.(`[verify] sweep: facts=${report.factsReconciled} links=${report.linksExtracted} timeline=${report.timelineExtracted}`);
} catch (e) {
checks.push({ id: 'graph_floor', ok: false, detail: `maintenance sweep failed: ${(e as Error).message}` });
return { checks };
}
// 3. Graph floor: ≥1 extracted edge, answered through the link tables.
try {
const links = await engine.getLinks(VERIFY_PROBE_SLUG, { sourceId });
const edge = links.find((l) => l.to_slug === VERIFY_PROBE_ENTITY_SLUG);
const backlinks = await engine.getBacklinks(VERIFY_PROBE_ENTITY_SLUG, { sourceId });
const back = backlinks.find((l) => l.from_slug === VERIFY_PROBE_SLUG);
if (edge && back) {
checks.push({ id: 'graph_floor', ok: true, detail: `entity link extracted and the edge-only query answers (probe → entity, ${links.length} outbound / ${backlinks.length} inbound)` });
} else {
checks.push({ id: 'graph_floor', ok: false, detail: `expected probe→entity edge missing after sweep (outbound: ${links.length}, inbound: ${backlinks.length}) — link extraction is not compounding the graph` });
}
} catch (e) {
checks.push({ id: 'graph_floor', ok: false, detail: `edge query failed: ${(e as Error).message}` });
}
// 4. get_page + search recall.
try {
await getPage.handler(ctx, { slug: VERIFY_PROBE_SLUG });
} catch (e) {
checks.push({ id: 'roundtrip', ok: false, detail: `get_page read-back failed: ${(e as Error).message}` });
}
try {
const result = await queryOp.handler(ctx, {
query: 'bootstrap verify probe lighthouse',
limit: 10,
expand: false,
});
const found = JSON.stringify(result).includes(VERIFY_PROBE_SLUG);
if (!found) {
checks.push({ id: 'roundtrip', ok: false, detail: `search did not return the probe page (${caps.search} search) — retrieval round-trip broken` });
}
} catch (e) {
checks.push({ id: 'roundtrip', ok: false, detail: `search failed: ${(e as Error).message}` });
}
// 5. Magic moment [CX-P0.5]: the fence fact reconciled by the ZERO-LLM
// sweep pass, read back at visibility='world' (what the harness can read).
try {
const rows = await engine.executeRaw<{ fact: string }>(
`SELECT fact FROM facts WHERE source_id = $1 AND visibility = 'world' AND fact LIKE $2 LIMIT 1`,
[sourceId, `%${VERIFY_MAGIC_TOKEN}%`],
);
if (rows.length > 0) {
checks.push({ id: 'magic_moment', ok: true, detail: `fence-written fact recalled at visibility=world (keyless path: ${caps.mode === 'keyless' ? 'yes' : 'keyed install, same zero-LLM pass'})` });
} else {
checks.push({ id: 'magic_moment', ok: false, detail: 'the ## Facts fence fact never reached the facts index — keyless memory (agent-authored fences) is broken; check the sweep fence pass' });
}
} catch (e) {
checks.push({ id: 'magic_moment', ok: false, detail: `facts read-back failed: ${(e as Error).message}` });
}
// 6. Delete the probes [G13] — failure is a WARNING, never a verify fail.
const deleteWarnings: string[] = [];
for (const slug of [VERIFY_PROBE_SLUG, VERIFY_PROBE_ENTITY_SLUG]) {
try {
await deletePage.handler(ctx, { slug });
} catch (e) {
deleteWarnings.push(`${slug}: ${(e as Error).message}`);
}
try {
rmSync(join(ws, 'brain', `${slug}.md`), { force: true });
} catch {
/* best effort */
}
}
try {
// Exact-identity delete (see sweepProbeLeftovers): only facts whose fence
// lives on a probe page, never a token substring match over user facts.
await engine.executeRaw(
`DELETE FROM facts WHERE source_id = $1 AND source_markdown_slug IN ($2, $3)`,
[sourceId, VERIFY_PROBE_SLUG, VERIFY_PROBE_ENTITY_SLUG],
);
} catch {
/* best effort */
}
if (deleteWarnings.length > 0) {
checks.push({ id: 'probe_cleanup', ok: false, warn: true, detail: `probe deletion incomplete: ${deleteWarnings.join('; ')}` });
}
return { checks };
}
/** Hooks smoke [D8#8 as amended by CX2-5]: hooks installed → pipe a fixture
* UserPromptSubmit stdin into runHook('user-prompt') IN-PROCESS against a
* live in-process IPC server backed by the verify engine (the
* test/resolve-ipc-v2 pattern). Asserts exit 0 + within the hook's own
* USER_PROMPT_DEADLINE_MS budget + non-empty block or a documented
* degradation. */
async function checkHooksSmoke(engine: BrainEngine, ws: string, sourceId: string): Promise<VerifyCheck> {
const id = 'hooks_smoke';
if (!hooksInstalled(ws)) {
return { id, ok: true, detail: 'hooks not installed — smoke not applicable (Codex/pull-mode or declined consent)' };
}
if (process.env.GBRAIN_HOOKS === '0') {
return { id, ok: true, warn: true, detail: 'GBRAIN_HOOKS=0 kill switch is set — hooks are installed but disabled; unset it to re-enable per-turn context' };
}
let cfg: GBrainConfig | null = null;
try {
cfg = loadConfigFileOnly();
} catch {
cfg = null;
}
const dataDir = cfg?.database_path;
if (!dataDir) {
return { id, ok: true, warn: true, detail: 'no PGLite data dir in config (Postgres brain?) — IPC smoke not applicable' };
}
let server: { close: () => void } | null = null;
const prevSource = process.env.GBRAIN_SOURCE;
try {
const secret = ensureIpcSecret(dataDir);
server = await startResolveIpcServer(
resolveSocketPath(dataDir),
{
resolve: async () => null,
turn_context: (req) =>
assembleTurnContext(engine, {
sourceId,
window: req.window,
...(req.priorContextText !== undefined ? { priorContextText: req.priorContextText } : {}),
...(req.sessionId !== undefined ? { sessionId: req.sessionId } : {}),
...(req.maxBytes !== undefined ? { maxBytes: req.maxBytes } : {}),
}),
},
{ secret, boundSourceId: sourceId },
);
if (!server) {
return { id, ok: true, warn: true, detail: 'could not bind the IPC socket (a live serve owns it?) — smoke skipped; the live serve itself is the IPC provider' };
}
process.env.GBRAIN_SOURCE = sourceId;
// USER_PROMPT_DEADLINE_MS is the hook's own budget — the smoke compares
// against the same constant it enforces (no drifting hardcoded copy).
const { runHook, readHeartbeatTail, USER_PROMPT_DEADLINE_MS } = await import('../../commands/hook.ts');
let out = '';
const t0 = Date.now();
const code = await runHook(['user-prompt'], {
stdin: JSON.stringify({ prompt: 'bootstrap verify smoke: who am I to you?', session_id: 'bootstrap-verify' }),
write: (s: string) => {
out += s;
},
cwd: ws,
});
const elapsed = Date.now() - t0;
if (code !== 0) return { id, ok: false, detail: `hook exited ${code} (must fail open with exit 0)` };
if (out.trim().length > 0) {
if (elapsed >= USER_PROMPT_DEADLINE_MS) {
// [A7] Real latency is a non-gating benchmark — the hard deadline
// assertion lives in the hook tests against an injected slow-IPC stub.
return { id, ok: true, warn: true, detail: `context block delivered but in ${elapsed}ms (over the ${USER_PROMPT_DEADLINE_MS}ms budget on this box) — watch hook latency` };
}
return { id, ok: true, detail: `context block delivered in ${elapsed}ms (${out.trim().length} chars)` };
}
// Empty stdout must be a DOCUMENTED degradation — read the heartbeat it wrote.
const tail = await readHeartbeatTail(1);
const reason = tail[tail.length - 1]?.reason ?? 'empty_block';
if (reason === 'empty_block' || reason === 'empty_window') {
return { id, ok: true, warn: true, detail: `empty context block in ${elapsed}ms (documented degradation: ${reason}) — plumbing works, brain has nothing to volunteer yet` };
}
if (reason === 'deadline' || reason === 'ipc_unavailable' || reason === 'server_budget') {
// [A7] Latency-shaped degradations don't gate verify (slow CI box ≠
// broken install); the reason is named so a human can judge.
return { id, ok: true, warn: true, detail: `hook degraded on latency (${reason}) in ${elapsed}ms — non-gating; re-run on an idle machine if it persists` };
}
return { id, ok: false, detail: `hook degraded (${reason}) in ${elapsed}ms — stdin→IPC→stdout plumbing is not healthy` };
} catch (e) {
return { id, ok: false, detail: `hooks smoke failed: ${(e as Error).message}` };
} finally {
if (prevSource === undefined) delete process.env.GBRAIN_SOURCE;
else process.env.GBRAIN_SOURCE = prevSource;
try {
server?.close();
} catch {
/* noop */
}
}
}
function checkPushProbe(ws: string): VerifyCheck {
const id = 'push_probe';
try {
// Read through the shared per-root reader [D8/D13] — a v0.45.8+ push
// writes push-status-<roothash>.json, not the legacy single file, so the
// old direct read reported "no push recorded" on every fresh install.
const entries = readPushStatuses();
if (entries.length > 0) {
const { failing } = summarizePushStatuses(entries);
if (failing.length > 0) {
const s = failing[0]!;
const rest = failing.length > 1 ? ` [+${failing.length - 1} more]` : '';
return { id, ok: true, warn: true, detail: `last workspace push FAILED (${s.ts ?? 'unknown'}): ${s.reason ?? 'unknown'}${rest} — run \`gbrain sources push --path ${s.repoRoot ?? ws}\`` };
}
const ok = entries.find((e) => e.ok === true);
return { id, ok: true, detail: `last workspace push succeeded (${ok?.ts ?? 'unknown time'})` };
}
const origin = gitOriginUrl(ws);
if (origin) return { id, ok: true, warn: true, detail: 'origin exists but no push recorded yet — run `gbrain sources push` once to prove the persistence path' };
return { id, ok: true, detail: 'local-only mode — no push expected' };
} catch (e) {
return { id, ok: true, warn: true, detail: `push probe unreadable: ${(e as Error).message}` };
}
}
function checkInertSkills(ws: string, caps: CapabilityReport): VerifyCheck {
const id = 'inert_skills';
const dir = join(ws, 'skills');
if (!existsSync(dir)) return { id, ok: true, detail: 'no skills/ dir — scaffold not run (optional)' };
try {
// Best-effort: a SKILL.md that names a provider key is dormant in keyless
// mode. Deep provider matrices live in `gbrain skillpack check`.
const skillFiles: string[] = [];
const walk = (d: string, depth: number): void => {
if (depth > 3) return;
for (const name of readdirSync(d)) {
const p = join(d, name);
let st;
try {
st = statSync(p);
} catch {
continue;
}
if (st.isDirectory()) walk(p, depth + 1);
else if (name === 'SKILL.md') skillFiles.push(p);
}
};
walk(dir, 0);
if (skillFiles.length === 0) return { id, ok: true, detail: 'skills/ present, no SKILL.md files yet' };
const providerHint = /OPENAI_API_KEY|ANTHROPIC_API_KEY|VOYAGE_API_KEY|api[_ -]?key/i;
const inert = caps.mode === 'keyless'
? skillFiles.filter((p) => {
try {
return providerHint.test(readFileSync(p, 'utf8'));
} catch {
return false;
}
}).length
: 0;
if (inert > 0) {
return { id, ok: true, warn: true, detail: `${inert}/${skillFiles.length} skill(s) reference provider keys and are dormant in keyless mode — one key wakes them (see the capability report)` };
}
return { id, ok: true, detail: `${skillFiles.length} skill(s) installed${caps.mode === 'keyless' ? ', none blocked on API keys' : ''}` };
} catch (e) {
return { id, ok: true, warn: true, detail: `inert-skill scan failed: ${(e as Error).message}` };
}
}
// ---------------------------------------------------------------------------
// Persistence [B2]
// ---------------------------------------------------------------------------
export const VERIFY_SNAPSHOTS_KEPT = 5;
function persistVerifyRun(gbrainHomeDir: string, payload: { ts: string; ok: boolean; checks: VerifyCheck[] }): void {
try {
const dir = join(gbrainHomeDir, 'bootstrap');
mkdirSync(dir, { recursive: true, mode: 0o700 });
const name = `verify-${payload.ts.replace(/[:.]/g, '-')}.json`;
// tmp+rename (the writeReceipt pattern): a killed verify never leaves a
// torn snapshot for status/doctor to trip on. The .tmp-* name does not
// match the retention regex, so a leaked tmp is never counted as a run.
const dest = join(dir, name);
const tmp = `${dest}.tmp-${process.pid}`;
writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', { mode: 0o600 });
renameSync(tmp, dest);
// Keep the newest VERIFY_SNAPSHOTS_KEPT.
const runs = readdirSync(dir).filter((n) => /^verify-.*\.json$/.test(n)).sort().reverse();
for (const stale of runs.slice(VERIFY_SNAPSHOTS_KEPT)) {
try {
rmSync(join(dir, stale), { force: true });
} catch {
/* best effort */
}
}
} catch {
/* snapshot telemetry never fails a verify */
}
}
// ---------------------------------------------------------------------------
// verifyWorkspace
// ---------------------------------------------------------------------------
export async function verifyWorkspace(
engine: BrainEngine,
ws: string,
opts: VerifyOpts = {},
): Promise<VerifyReport> {
let sourceId = opts.sourceId ?? 'workspace';
const gbrainHomeDir = opts.gbrainHomeDir ?? resolveGbrainHome();
const caps = opts.capabilities ?? detectCapabilities();
const checks: VerifyCheck[] = [];
checks.push(await checkDoctorGreen(engine));
const engineHealthy = checks.find((c) => c.id === 'doctor_green')?.ok === true;
if (engineHealthy) {
// Transient-engine work while we hold the engine (before/independent of
// host registration): the CX-P1.1 visibility posture and the source-id
// collision resolution both need the config/sources plane.
checks.push(await ensureDefaultVisibilityPosture(engine));
const collision = await resolveSourceIdCollision(engine, ws);
if (collision.sourceId !== null) sourceId = collision.sourceId;
if (collision.check !== null) checks.push(collision.check);
}
checks.push(checkTokenSweep(ws));
checks.push(checkByteFloors(ws));
checks.push(checkSecretScan(ws));
checks.push(checkDenyGlobs(ws));
checks.push(await checkRepoPrivacy(ws));
checks.push(checkExecutionEnvironment());
checks.push(checkMcpJsonHygiene(ws));
checks.push(checkHookCarrierOverlap(ws));
// Round-trip family only makes sense on a reachable engine.
if (engineHealthy) {
const rt = await runRoundtrip(engine, ws, sourceId, { ...opts, capabilities: caps });
checks.push(...rt.checks);
} else {
checks.push({ id: 'roundtrip', ok: false, detail: 'skipped — engine probe failed (see doctor_green)' });
}
checks.push({ id: 'capability_report', ok: true, detail: `${caps.mode} mode / ${caps.search} search` });
checks.push(checkMcpSurface());
if (opts.skipHooksSmoke) {
checks.push({ id: 'hooks_smoke', ok: true, detail: 'skipped by caller' });
} else {
checks.push(await checkHooksSmoke(engine, ws, sourceId));
}
checks.push(checkPushProbe(ws));
checks.push(checkInertSkills(ws, caps));
const tourCheck = { id: 'first_run_tour', ok: true, detail: 'three scripted prompts appended to the report' };
checks.push(tourCheck);
const ok = checks.every((c) => c.ok || c.warn === true);
if (!ok) tourCheck.detail = 'tour withheld — prints on PASS';
const ts = new Date().toISOString();
persistVerifyRun(gbrainHomeDir, { ts, ok, checks });
const lines: string[] = [];
lines.push(`gbrain bootstrap verify — ${ok ? 'PASS' : 'FAIL'} (${ts})`);
for (const c of checks) {
const mark = c.ok && !c.warn ? 'ok ' : c.warn ? 'warn' : 'FAIL';
lines.push(` [${mark}] ${c.id}: ${c.detail}`);
}
lines.push('');
lines.push(renderCapabilityReport(caps));
lines.push('');
// The tour celebrates a WORKING install — under a FAIL banner it reads as
// a mixed signal ("broken, but go enjoy it"). Gate the report lines on ok;
// the returned `tour` array (and --json field) stays unconditional so
// machine consumers keep a stable shape.
const handoff = buildHandoff(ws);
if (ok) {
lines.push('First-run tour — have your human RESTART the session first');
lines.push('(a fresh session proves the files and the brain, not this chat),');
lines.push('then try these three prompts in order:');
FIRST_RUN_TOUR.forEach((p, i) => lines.push(` ${i + 1}. ${p}`));
lines.push('');
for (const h of handoff) lines.push(h);
} else {
lines.push('Fix the FAIL checks above and re-run — the first-run tour prints on PASS.');
}
return { ok, checks, report: lines.join('\n'), capability: caps, tour: [...FIRST_RUN_TOUR], handoff };
}
/**
* The post-tour hand-off block [OOBE]: the two things a fresh user must walk
* away UNDERSTANDING, in priority order —
* 1. OWNERSHIP: the brain is markdown in a repo THEY own (or local-only,
* with the one command that gives it a durable home). Ownership is the
* trust story; say the URL, say what owning it means.
* 2. THE ONE NEXT ACTION: run the cold-start skill. An empty brain is a
* database; every flagship skill (book-mirror, briefings, meeting prep)
* only becomes magical once the brain holds the user's real life —
* cold-start is the designed filler (Gmail/calendar/contacts via
* ClawVisor, or offline archives), one consented phase at a time.
* Returned unconditionally in the machine shape (like `tour`); printed in
* the report only on PASS. Relay it to the human verbatim.
*/
export function buildHandoff(ws: string): string[] {
const origin = gitOriginUrl(ws);
const ownership = origin
? [
`What you own: every memory your agent keeps is a markdown file in YOUR private repo — ${origin}.`,
'Read it any time, take it to a second machine (`gbrain bootstrap attach`), or delete it and the brain is gone. It is yours.',
]
: [
'What you own: your agent\'s memory is markdown on this machine only (no remote yet).',
'Run `gbrain bootstrap repo` any time to give it a private GitHub home you own — readable, portable, deletable.',
];
return [
...ownership,
'',
'Fill it next: an empty brain is a database; a filled one is a memory.',
'Ask your agent to run the cold-start skill — it imports your real life',
'(Gmail, calendar, contacts via ClawVisor, an OAuth vault so the agent never',
'holds raw tokens; or offline archives like Google Takeout), one consented',
'phase at a time. Each phase is independently valuable — stop whenever.',
];
}