mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +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>
452 lines
19 KiB
TypeScript
452 lines
19 KiB
TypeScript
/**
|
|
* bootstrap verify — the D8 check union against a hermetic in-memory PGLite
|
|
* brain (the resolve-ipc-v2 pattern): keyless roundtrip [G1/CX-P0.5],
|
|
* graph floor [CX2-5], magic moment via the ## Facts fence, probe cleanup
|
|
* [G13], failure-shape checks (token sweep / byte floors / secret scan), and
|
|
* snapshot retention [B2].
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { addSource } from '../src/core/sources-ops.ts';
|
|
import { readManifest, writeManifest } from '../src/core/bootstrap/format.ts';
|
|
import { initState, setAnswer, confirm, readBackHash } from '../src/core/bootstrap/interview.ts';
|
|
import { loadQuestionBank } from '../src/core/bootstrap/assets.ts';
|
|
import { byteFloors } from '../src/core/bootstrap/render.ts';
|
|
import {
|
|
verifyWorkspace,
|
|
VERIFY_PROBE_SLUG,
|
|
VERIFY_PROBE_ENTITY_SLUG,
|
|
VERIFY_MAGIC_TOKEN,
|
|
VERIFY_SNAPSHOTS_KEPT,
|
|
FIRST_RUN_TOUR,
|
|
} from '../src/core/bootstrap/verify.ts';
|
|
import { listVerifyRuns } from '../src/core/bootstrap/status.ts';
|
|
import type { CapabilityReport } from '../src/core/capability.ts';
|
|
import { operations, type OperationContext } from '../src/core/operations.ts';
|
|
import { loadCorpusPages, loadCorpusQueries } from './helpers/bootstrap-corpus.ts';
|
|
|
|
const KEYLESS: CapabilityReport = {
|
|
embeddings: { available: false },
|
|
extraction: { available: false },
|
|
search: 'keyword-only',
|
|
mode: 'keyless',
|
|
};
|
|
|
|
let engine: PGLiteEngine;
|
|
let tmpParent: string;
|
|
let home: string;
|
|
let ws: string;
|
|
let prevHome: string | undefined;
|
|
|
|
function pad(text: string, bytes: number): string {
|
|
let out = text;
|
|
while (Buffer.byteLength(out, 'utf8') < bytes) {
|
|
out += '\nSubstantive identity prose rendered from real interview answers, not filler headers.';
|
|
}
|
|
return out;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
tmpParent = mkdtempSync(join(tmpdir(), 'gb-verify-'));
|
|
home = join(tmpParent, '.gbrain');
|
|
mkdirSync(join(home, 'bootstrap'), { recursive: true });
|
|
ws = mkdtempSync(join(tmpdir(), 'gb-verify-ws-'));
|
|
prevHome = process.env.GBRAIN_HOME;
|
|
process.env.GBRAIN_HOME = tmpParent;
|
|
|
|
// Workspace shape: initialized manifest + interview answers (6 required,
|
|
// confirmed) + identity files above the 6-answer byte floors + brain/.
|
|
writeManifest(ws, {
|
|
format_version: 1,
|
|
initialized: true,
|
|
agent_name: 'Verify Test Agent',
|
|
created_by: 'test',
|
|
created_at: new Date().toISOString(),
|
|
source_id: 'workspace',
|
|
});
|
|
const bank = loadQuestionBank();
|
|
const required = bank.interviewKeys.filter((k) => bank.questions[k]?.required === true);
|
|
expect(initState(ws).ok).toBe(true);
|
|
const answers: Record<string, string> = {
|
|
AGENT_NAME: 'Testa',
|
|
PRINCIPAL_NAME: 'Pat Example',
|
|
AGENT_PURPOSE: 'Maintain the research corpus and draft the weekly memo without re-briefing.',
|
|
AGENT_TOP_JOBS: '- corpus upkeep\n- weekly memo\n- meeting prep',
|
|
PRINCIPAL_CONTEXT: 'Runs a small research group; builds internal tooling; cares about signal over noise.',
|
|
VOICE_REGISTER: 'Direct: three options, the second one wins.',
|
|
};
|
|
for (const key of required) {
|
|
const r = setAnswer(ws, key, answers[key] ?? `a real answer for ${key}`);
|
|
if (!r.ok) throw new Error(`setAnswer(${key}) failed: ${r.message}`);
|
|
}
|
|
const h = readBackHash(ws);
|
|
if (!h.ok) throw new Error(h.message);
|
|
expect(confirm(ws, h.hash).ok).toBe(true);
|
|
|
|
const floors = byteFloors(required.length);
|
|
writeFileSync(join(ws, 'SOUL.md'), pad('# Soul\n\nIdentity rendered from answers.\n', floors['SOUL.md'] + 200));
|
|
writeFileSync(join(ws, 'USER.md'), pad('# User\n\nTheir literal words are ground truth.\n', floors['USER.md'] + 100));
|
|
mkdirSync(join(ws, 'brain'), { recursive: true });
|
|
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
// The workspace source: brain/ registered with its own local_path so the
|
|
// put_page write-through materializes committed files under brain/ [G1].
|
|
await addSource(engine, { id: 'workspace', localPath: join(ws, 'brain'), force: true });
|
|
}, 240_000);
|
|
|
|
afterAll(async () => {
|
|
try {
|
|
await engine.disconnect();
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
if (prevHome === undefined) delete process.env.GBRAIN_HOME;
|
|
else process.env.GBRAIN_HOME = prevHome;
|
|
rmSync(tmpParent, { recursive: true, force: true });
|
|
rmSync(ws, { recursive: true, force: true });
|
|
});
|
|
|
|
function check(checks: Array<{ id: string; ok: boolean; warn?: boolean; detail: string }>, id: string) {
|
|
const found = checks.filter((c) => c.id === id);
|
|
expect(found.length).toBeGreaterThan(0);
|
|
return found;
|
|
}
|
|
|
|
describe('verifyWorkspace — keyless pass', () => {
|
|
test('roundtrip + graph floor + magic moment pass with ZERO api keys; probes cleaned up', async () => {
|
|
// Snapshot-retention setup [B2]: pre-seed old snapshots so the prune path runs.
|
|
for (let i = 0; i < VERIFY_SNAPSHOTS_KEPT; i++) {
|
|
writeFileSync(
|
|
join(home, 'bootstrap', `verify-2020-01-0${i + 1}T00-00-00-000Z.json`),
|
|
JSON.stringify({ ts: `2020-01-0${i + 1}T00:00:00.000Z`, ok: true, checks: [] }),
|
|
);
|
|
}
|
|
|
|
const res = await verifyWorkspace(engine, ws, {
|
|
sourceId: 'workspace',
|
|
gbrainHomeDir: home,
|
|
capabilities: KEYLESS,
|
|
});
|
|
|
|
// Individual checks.
|
|
expect(check(res.checks, 'doctor_green')[0].ok).toBe(true);
|
|
expect(check(res.checks, 'token_sweep')[0].ok).toBe(true);
|
|
expect(check(res.checks, 'byte_floors')[0].ok).toBe(true);
|
|
expect(check(res.checks, 'secret_scan')[0].ok).toBe(true);
|
|
expect(check(res.checks, 'deny_globs')[0].ok).toBe(true);
|
|
expect(check(res.checks, 'repo_privacy')[0].ok).toBe(true); // local-only
|
|
// execution_env is informational and NEVER gates [D-cloud].
|
|
expect(check(res.checks, 'execution_env')[0].ok).toBe(true);
|
|
for (const c of check(res.checks, 'roundtrip')) expect(c.ok).toBe(true);
|
|
expect(check(res.checks, 'graph_floor')[0].ok).toBe(true);
|
|
expect(check(res.checks, 'magic_moment')[0].ok).toBe(true);
|
|
expect(check(res.checks, 'capability_report')[0].detail).toContain('keyless');
|
|
expect(check(res.checks, 'hooks_smoke')[0].ok).toBe(true); // not installed → not applicable
|
|
expect(check(res.checks, 'first_run_tour')[0].ok).toBe(true);
|
|
expect(res.ok).toBe(true);
|
|
|
|
// The report embeds the capability block + the three scripted prompts [D3.6/A4].
|
|
expect(res.report).toContain('keyless mode');
|
|
for (const prompt of FIRST_RUN_TOUR) {
|
|
expect(res.report).toContain(prompt);
|
|
}
|
|
expect(res.tour).toEqual([...FIRST_RUN_TOUR]);
|
|
|
|
// Probe cleanup [G13]: pages, files, and the reconciled fact are gone.
|
|
expect(existsSync(join(ws, 'brain', `${VERIFY_PROBE_SLUG}.md`))).toBe(false);
|
|
expect(existsSync(join(ws, 'brain', `${VERIFY_PROBE_ENTITY_SLUG}.md`))).toBe(false);
|
|
const facts = await engine.executeRaw<{ fact: string }>(
|
|
`SELECT fact FROM facts WHERE source_id = $1 AND fact LIKE $2`,
|
|
['workspace', `%${VERIFY_MAGIC_TOKEN}%`],
|
|
);
|
|
expect(facts.length).toBe(0);
|
|
|
|
// Snapshot persisted + retention holds at VERIFY_SNAPSHOTS_KEPT [B2].
|
|
const runs = listVerifyRuns(home);
|
|
expect(runs.length).toBeLessThanOrEqual(VERIFY_SNAPSHOTS_KEPT);
|
|
expect(runs[0].ok).toBe(true); // newest = this run
|
|
}, 240_000);
|
|
|
|
test('a user fact that merely CONTAINS the magic-token substring survives verify [8b]', async () => {
|
|
// A real user fact from a different page, plus a legacy NULL-slug fact,
|
|
// both mentioning the token STRING. The probe cleanup must scope to the
|
|
// probe page's source_markdown_slug, never a `fact LIKE %token%` match, so
|
|
// neither of these is swept.
|
|
await engine.executeRaw(
|
|
`INSERT INTO facts (source_id, fact, source, source_markdown_slug, visibility)
|
|
VALUES ($1, $2, 'user', $3, 'world')`,
|
|
['workspace', `a real note that mentions ${VERIFY_MAGIC_TOKEN} in passing`, 'wiki/real-user-note'],
|
|
);
|
|
await engine.executeRaw(
|
|
`INSERT INTO facts (source_id, fact, source, visibility)
|
|
VALUES ($1, $2, 'user', 'world')`,
|
|
['workspace', `a legacy NULL-slug fact mentioning ${VERIFY_MAGIC_TOKEN}`],
|
|
);
|
|
|
|
const res = await verifyWorkspace(engine, ws, {
|
|
sourceId: 'workspace',
|
|
gbrainHomeDir: home,
|
|
capabilities: KEYLESS,
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
|
|
// The probe fact (source_markdown_slug = probe slug) IS cleaned up …
|
|
const probeFacts = await engine.executeRaw<{ id: number }>(
|
|
`SELECT id FROM facts WHERE source_id = $1 AND source_markdown_slug = $2`,
|
|
['workspace', VERIFY_PROBE_SLUG],
|
|
);
|
|
expect(probeFacts.length).toBe(0);
|
|
|
|
// … but BOTH user facts that merely mention the token string survive.
|
|
const survivors = await engine.executeRaw<{ fact: string }>(
|
|
`SELECT fact FROM facts WHERE source_id = $1 AND fact LIKE $2`,
|
|
['workspace', `%${VERIFY_MAGIC_TOKEN}%`],
|
|
);
|
|
expect(survivors.length).toBe(2);
|
|
|
|
// Cleanup so later tests start from a clean facts table.
|
|
await engine.executeRaw(`DELETE FROM facts WHERE source_id = $1 AND fact LIKE $2`, [
|
|
'workspace', `%${VERIFY_MAGIC_TOKEN}%`,
|
|
]);
|
|
}, 240_000);
|
|
|
|
test('failure shapes: unresolved token + under-floor USER.md + planted secret all surface', async () => {
|
|
const soulPath = join(ws, 'SOUL.md');
|
|
const userPath = join(ws, 'USER.md');
|
|
const githubPath = join(ws, 'GITHUB.md');
|
|
const soulOriginal = readFileSync(soulPath, 'utf8');
|
|
const userOriginal = readFileSync(userPath, 'utf8');
|
|
try {
|
|
writeFileSync(githubPath, '# GitHub\n\nRepo: {{GITHUB_REPO_URL}}\n');
|
|
writeFileSync(userPath, '# tiny\n');
|
|
writeFileSync(soulPath, soulOriginal + '\napi dump: sk-AAAAAAAAAAAAAAAAAAAAAAAA\n');
|
|
|
|
const res = await verifyWorkspace(engine, ws, {
|
|
sourceId: 'workspace',
|
|
gbrainHomeDir: home,
|
|
capabilities: KEYLESS,
|
|
});
|
|
|
|
const token = check(res.checks, 'token_sweep')[0];
|
|
expect(token.ok).toBe(false);
|
|
expect(token.detail).toContain('GITHUB.md');
|
|
expect(token.detail).toContain('GITHUB_REPO_URL');
|
|
|
|
const floors = check(res.checks, 'byte_floors')[0];
|
|
expect(floors.ok).toBe(false);
|
|
expect(floors.detail).toContain('USER.md');
|
|
|
|
const scan = check(res.checks, 'secret_scan')[0];
|
|
expect(scan.ok).toBe(false);
|
|
expect(scan.detail).toContain('openai');
|
|
// Redaction discipline: the finding detail NEVER carries the secret value.
|
|
expect(scan.detail).not.toContain('sk-AAAAAAAAAAAAAAAAAAAAAAAA');
|
|
|
|
expect(res.ok).toBe(false);
|
|
} finally {
|
|
rmSync(githubPath, { force: true });
|
|
writeFileSync(userPath, userOriginal);
|
|
writeFileSync(soulPath, soulOriginal);
|
|
}
|
|
}, 240_000);
|
|
});
|
|
|
|
describe('verifyWorkspace — engine-plane side effects', () => {
|
|
test('[CX-P1.1] facts.default_visibility: unset → set to world; explicit private → untouched', async () => {
|
|
const e2 = new PGLiteEngine();
|
|
await e2.connect({});
|
|
await e2.initSchema();
|
|
const ws2 = mkdtempSync(join(tmpdir(), 'gb-verify-vis-'));
|
|
try {
|
|
mkdirSync(join(ws2, 'brain'), { recursive: true });
|
|
expect(await e2.getConfig('facts.default_visibility')).toBeNull();
|
|
|
|
const res = await verifyWorkspace(e2, ws2, {
|
|
sourceId: 'workspace',
|
|
gbrainHomeDir: home,
|
|
capabilities: KEYLESS,
|
|
skipHooksSmoke: true,
|
|
sweepBudgetMs: 5_000,
|
|
});
|
|
// Fresh engine: posture set to world through the engine config plane…
|
|
expect(await e2.getConfig('facts.default_visibility')).toBe('world');
|
|
// …and the report names the posture + where to flip it, in one line.
|
|
const check = res.checks.find((c) => c.id === 'facts_visibility')!;
|
|
expect(check.ok).toBe(true);
|
|
expect(check.detail).toContain('world');
|
|
expect(check.detail).toContain('facts.default_visibility');
|
|
expect(check.detail).toContain('gbrain config set');
|
|
|
|
// Pre-set explicit value survives verify (set-if-unset, never override).
|
|
await e2.setConfig('facts.default_visibility', 'private');
|
|
const res2 = await verifyWorkspace(e2, ws2, {
|
|
sourceId: 'workspace',
|
|
gbrainHomeDir: home,
|
|
capabilities: KEYLESS,
|
|
skipHooksSmoke: true,
|
|
sweepBudgetMs: 5_000,
|
|
});
|
|
expect(await e2.getConfig('facts.default_visibility')).toBe('private');
|
|
const check2 = res2.checks.find((c) => c.id === 'facts_visibility')!;
|
|
expect(check2.detail).toContain('private');
|
|
expect(check2.detail).toContain('untouched');
|
|
} finally {
|
|
await e2.disconnect();
|
|
rmSync(ws2, { recursive: true, force: true });
|
|
}
|
|
}, 240_000);
|
|
|
|
test('mcp_surface=verbs in config → loud WARN naming the --surface full fix', async () => {
|
|
const cfgPath = join(home, 'config.json');
|
|
writeFileSync(cfgPath, JSON.stringify({ engine: 'pglite', mcp_surface: 'verbs' }), 'utf8');
|
|
try {
|
|
const res = await verifyWorkspace(engine, ws, {
|
|
sourceId: 'workspace',
|
|
gbrainHomeDir: home,
|
|
capabilities: KEYLESS,
|
|
skipHooksSmoke: true,
|
|
});
|
|
const check = res.checks.find((c) => c.id === 'mcp_surface')!;
|
|
expect(check.ok).toBe(true);
|
|
expect(check.warn).toBe(true);
|
|
expect(check.detail).toContain('verbs');
|
|
expect(check.detail).toContain('--surface full');
|
|
} finally {
|
|
rmSync(cfgPath, { force: true });
|
|
}
|
|
}, 240_000);
|
|
});
|
|
|
|
describe('verifyWorkspace — source_id collision resolution', () => {
|
|
test('single workspace: id registered to THIS checkout stays unchanged (no collision check emitted)', async () => {
|
|
const res = await verifyWorkspace(engine, ws, {
|
|
sourceId: 'workspace',
|
|
gbrainHomeDir: home,
|
|
capabilities: KEYLESS,
|
|
skipHooksSmoke: true,
|
|
});
|
|
const m = readManifest(ws);
|
|
expect(m.state).toBe('initialized');
|
|
if (m.state === 'initialized') expect(m.manifest.source_id).toBe('workspace');
|
|
expect(res.checks.find((c) => c.id === 'source_id')).toBeUndefined();
|
|
}, 240_000);
|
|
|
|
test("second workspace claiming a taken 'workspace' id derives workspace-<8char-hash> and persists it", async () => {
|
|
const e2 = new PGLiteEngine();
|
|
await e2.connect({});
|
|
await e2.initSchema();
|
|
const firstBrain = mkdtempSync(join(tmpdir(), 'gb-verify-first-brain-'));
|
|
const ws2 = mkdtempSync(join(tmpdir(), 'gb-verify-second-ws-'));
|
|
try {
|
|
// The brain already has 'workspace' registered to ANOTHER checkout.
|
|
await addSource(e2, { id: 'workspace', localPath: firstBrain, force: true });
|
|
mkdirSync(join(ws2, 'brain'), { recursive: true });
|
|
writeManifest(ws2, {
|
|
format_version: 1,
|
|
initialized: true,
|
|
agent_name: 'Second',
|
|
created_by: 'test',
|
|
created_at: new Date().toISOString(),
|
|
source_id: 'workspace',
|
|
});
|
|
|
|
const res = await verifyWorkspace(e2, ws2, {
|
|
sourceId: 'workspace',
|
|
gbrainHomeDir: home,
|
|
capabilities: KEYLESS,
|
|
skipHooksSmoke: true,
|
|
sweepBudgetMs: 5_000,
|
|
});
|
|
const m = readManifest(ws2);
|
|
expect(m.state).toBe('initialized');
|
|
if (m.state !== 'initialized') throw new Error('unreachable');
|
|
const derived = m.manifest.source_id;
|
|
expect(derived).toMatch(/^workspace-[0-9a-f]{8}$/);
|
|
const check = res.checks.find((c) => c.id === 'source_id')!;
|
|
expect(check.warn).toBe(true);
|
|
expect(check.detail).toContain(derived);
|
|
expect(check.detail).toContain(`gbrain sources add ${derived}`);
|
|
expect(check.detail).toContain('hooks --repair');
|
|
|
|
// Re-run is stable: the derived id is not itself in collision.
|
|
const res2 = await verifyWorkspace(e2, ws2, {
|
|
sourceId: derived,
|
|
gbrainHomeDir: home,
|
|
capabilities: KEYLESS,
|
|
skipHooksSmoke: true,
|
|
sweepBudgetMs: 5_000,
|
|
});
|
|
const m2 = readManifest(ws2);
|
|
if (m2.state !== 'initialized') throw new Error('unreachable');
|
|
expect(m2.manifest.source_id).toBe(derived);
|
|
expect(res2.checks.find((c) => c.id === 'source_id')).toBeUndefined();
|
|
} finally {
|
|
await e2.disconnect();
|
|
rmSync(firstBrain, { recursive: true, force: true });
|
|
rmSync(ws2, { recursive: true, force: true });
|
|
}
|
|
}, 240_000);
|
|
});
|
|
|
|
describe('verifyWorkspace — real corpus graph floor + qrels recall', () => {
|
|
test('auto-link builds real multi-entity edges; verify passes on the populated brain; gold queries recall the expected pages', async () => {
|
|
// Seed the synthetic world into the SAME workspace source verify checks,
|
|
// through the real put_page handler so auto-link builds REAL edges — a
|
|
// 12-page graph, not the 2-node self-planted probe pair verify writes.
|
|
const loaded = await loadCorpusPages(engine, { sourceId: 'workspace' });
|
|
expect(loaded).toContain('people/alice-example');
|
|
expect(loaded).toContain('companies/ridge-platform');
|
|
|
|
// Real edges exist from the wikilinks in the page bodies (not the probe):
|
|
// alice-example → ridge-platform, with multiple outbound edges on a real
|
|
// entity, and the reverse edge answers through the backlink table.
|
|
const aliceLinks = await engine.getLinks('people/alice-example', { sourceId: 'workspace' });
|
|
const aliceTargets = aliceLinks.map((l) => l.to_slug);
|
|
expect(aliceTargets).toContain('companies/ridge-platform');
|
|
expect(aliceTargets.length).toBeGreaterThanOrEqual(2);
|
|
const ridgeBacklinks = await engine.getBacklinks('companies/ridge-platform', { sourceId: 'workspace' });
|
|
expect(ridgeBacklinks.map((l) => l.from_slug)).toContain('people/alice-example');
|
|
|
|
// verify still runs green end-to-end on the now-populated brain, and its
|
|
// graph_floor / roundtrip checks pass alongside the real corpus graph.
|
|
const res = await verifyWorkspace(engine, ws, {
|
|
sourceId: 'workspace',
|
|
gbrainHomeDir: home,
|
|
capabilities: KEYLESS,
|
|
skipHooksSmoke: true,
|
|
});
|
|
if (!res.ok) console.error(res.report);
|
|
expect(res.ok).toBe(true);
|
|
expect(check(res.checks, 'graph_floor')[0].ok).toBe(true);
|
|
for (const c of check(res.checks, 'roundtrip')) expect(c.ok).toBe(true);
|
|
|
|
// qrels: each gold page-recall case returns its expected slug through the
|
|
// REAL keyword-search query op (keyless), over the multi-page corpus.
|
|
const queryOp = operations.find((o) => o.name === 'query')!;
|
|
const ctx: OperationContext = {
|
|
engine,
|
|
config: { engine: 'pglite' } as never,
|
|
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
dryRun: false,
|
|
remote: false,
|
|
sourceId: 'workspace',
|
|
};
|
|
const goldPageCases = loadCorpusQueries().filter((q) => q.kind === 'page');
|
|
expect(goldPageCases.length).toBeGreaterThan(0);
|
|
const misses: string[] = [];
|
|
for (const q of goldPageCases) {
|
|
const result = await queryOp.handler(ctx, { query: q.query, limit: 10, expand: false });
|
|
if (!JSON.stringify(result).includes(q.expect_slug!)) {
|
|
misses.push(`${q.id}: "${q.query}" did not recall ${q.expect_slug}`);
|
|
}
|
|
}
|
|
expect(misses).toEqual([]);
|
|
}, 240_000);
|
|
});
|