mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
* feat(bootstrap): TTY DX exploration harness + Krug onboarding fix wave Add a real-PTY exploration harness and land 16 verified "Don't Make Me Think" fixes on the paste-in install experience for Claude Code and Codex. Harness: - test/helpers/tty-harness.ts — spawns any CLI (gbrain/claude/codex) under a real pseudo-terminal (Bun terminal: spawn), timestamps every output burst, and turns silence windows into a measurable stall report. Hermetic; pure helpers unit-tested in test/tty-harness.test.ts. - scripts/dx-explore.ts — drives the fresh-user funnel (help / init / real claude-install / real codex-install / manual drive mode), writing transcripts to .context/dx-runs/ (gitignored). Fixes (all adversarially verified against the code first): - Keyless bare `gbrain init` completes in keyless mode instead of exit 1; multi-key non-TTY auto-picks the canonical default; typo stays fail-loud. - Provider picker probe-gates ollama (daemon-up != model-pulled) and offers an explicit "continue keyless" option that is the bare-Enter default. - Fresh-brain init prints one schema-setup line instead of ~240 migration names (GBRAIN_MIGRATE_VERBOSE=1 restores detail). - Init epilogue: memory-verbs funnel is last-on-screen; skills advisory compacted for init; Mod Status trimmed. - PGLite live-serve lock error names the fix (close the agent session). - Mode-picker banner interpolates the applied mode; expansion-key gate is Anthropic/OpenAI/Google, not OpenAI-only. - Missing `claude` binary skips MCP but still installs hooks; honest copy. - Foreign MCP-registration removal targets the conflicting scope and fails loud if it does not land. - Upgrade marker compares the running binary to latest and self-spawns via execPath, so a current/newer binary no longer nags from a stale cache. - interview --set/--skip after --confirm warns it voided the confirmation. - init --help matches behavior; init --supabase fails loud on non-TTY. - Provider capabilities attributed per provider across README / runbook / questions bank / bootstrap.md. - First-run tour: restart-first, prompt 3 true on day one, withheld on FAIL; README gives Codex the same scripted magic moment. - Empty-brain "0 takes" onboard nudge suppressed. - Broken settings.local.json aborts the hooks write fail-closed instead of silently dropping the user's permissions. Regenerated cli-flag-registry.generated.ts and llms-full.txt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bootstrap): second DX polish wave — clean the success screen + honest copy (F17-F21) Follow-up to the DX fix wave, closing the top-5 remaining gaps the scorecard flagged (all human-facing polish, not survival): F17 — machine markers no longer leak to humans: - verify report drops the `[D3.6]` plan-tag from the first_run_tour detail. - the raw `UPGRADE_AVAILABLE <cur> <latest>` marker line prints ONLY on a non-TTY stderr (parsers still get it); an interactive human sees just the "gbrain X -> Y available" sentence. - per-migration "what changed" notices (v123/v124, incl. the #2704 ref) are suppressed on a FRESH-install replay via a module quiet flag; upgrades still narrate. (GBRAIN_MIGRATE_VERBOSE=1 restores them.) F18 — one obvious next action on the init success screen: the memory-verbs demo is the single "→ Do this next" hero, last on screen; import/migrate/doctor collapse into one terse "More:" footer; the graph block only shows for a non-empty brain. F19 — README "moment it clicks" is now the genuine cross-session brain round-trip (remember → restart → recall), explicitly distinguished from the identity-file recall, on both the Codex and Claude Code paths. F20 — the compact init skills advisory is human-voiced (no `[AGENT]` stage-direction on the human-facing success screen; the mode-picker's agent-directed block stays gated to the non-TTY channel). F21 — time promise reconciled: headline is ~15 min (personal-agent path) / ~30 min (always-on OpenClaw/Hermes); the runbook's search-mode line no longer claims "balanced" when keyless applies "conservative". README hooks copy says "on by default, with an opt-out" to match the runbook. Regenerated llms-full.txt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bootstrap): address two-model adversarial review of the DX wave Fixes the regressions the 5-specialist + red-team + Claude/Codex adversarial pass found in the F1–F21 changes, each with a test: - Keyless upgrade hint pointed at `config set embedding_model`, which config.ts hard-refuses as a schema-sizing no-op — now names the working re-init recipe (`gbrain init --force --pglite --embedding-model <id>`), zero-key AND multi-key paths. - Multi-key TTY picker offered "continue keyless" but the caller aborted on it — now honors keyless like the zero-key path. - Detached update-refresh spawn used a `/gbrain$/` basename check that misfires for a renamed/official-named compiled binary (`gbrain-darwin-arm64`) and prepends the /$bunfs entrypoint — now detects dev-vs-compiled by the runtime basename (bun|node) so the refresh always runs. - `bootstrap status` reported the wire phase "done" on a hooks-only receipt (host CLI missing at wire time) — now "partial" with a re-run hint, so a resuming agent doesn't trust a false complete. - Post-repair MCP mismatch re-verifies and aborts instead of blessing a registration a racing writer may have re-claimed. - probeOpenAICompat's abort timer now spans the body read (was cleared before it), so a stalled `/v1/models` body can't hang init past the 1s cap. - Centralized the 4-copy stale-cache upgrade predicate into `pendingUpgradeVersion`; UPGRADE_AVAILABLE gains a GBRAIN_FORCE_UPGRADE_MARKER override for PTY-based agent harnesses. - Mode picker's expansion-key gate adds GEMINI_API_KEY; picker prompt is article-aware ("an embedding" / "a chat"); dead `!brainEmpty` clause removed; migrate.ts try/finally widened + stamp failures named in quiet mode. - DX harness: credential copies scrubbed even on SIGINT/interrupt (+chmod 600), child process TREE reaped on teardown, advisory made fail-open, KEY_MAP typed as a literal union. New tests: migrate quiet-replay, self-upgrade pending predicate + negative cache cases, bootstrap 127/scoped-remove/broken-settings dispatch, interview invalidation flag, verify tour-withheld-on-FAIL, init keyless/supabase/multi-key, init-nudge branches, ai-probes model parsing. Regenerated flag registry + template-repo. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * v0.45.8.0 fix(bootstrap): onboarding DX polish wave (F17-F21) + review fixes DX fix wave on the paste-in install/first-run experience for Claude Code and Codex, driven by a new real-PTY exploration harness. Keyless init completes instead of erroring, the migration wall collapses to one line, the success screen leads with one action, and the "magic moment" copy points at the genuine cross-session round-trip. Full detail in CHANGELOG. Version trio + openclaw manifest + runbook stamp bumped to 0.45.8.0; CHANGELOG release entry; TODOS onboarding-DX follow-ups filed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bootstrap): OOBE hand-off — you own the brain, cold-start is skill #1 A working install now ends by making the two facts that matter actually land: - `gbrain bootstrap verify` prints (and returns as `handoff` in --json) an ownership block — the actual private-repo URL with what owning it means (read it, `gbrain bootstrap attach` on machine two, delete it and the brain is gone), or the local-only variant pointing at `gbrain bootstrap repo` — followed by the ONE next action: run the cold-start skill (Gmail/calendar/ contacts via ClawVisor, an OAuth vault so the agent never holds raw tokens; or offline archives), one consented phase at a time. Withheld on FAIL like the tour; shape stays unconditional for machine consumers. - cold-start ships in the downstream bundle (61 skills): its plugin exclusion ("host onboarding flow") predated the v0.45 personal-agent bootstrap and is deliberately reversed — the paste-in audience is exactly who day-one onboarding is for. It now LEADS the recommended set (ahead of book-mirror: every flagship skill only becomes magical once the brain holds the user's real life). - New drift guard: every recommended slug must be scaffoldable from the plugin bundle — recommended-but-unscaffoldable is a dead-end CTA and now fails the suite. - Runbook Hand off rewritten around the two must-land facts + the on-the-spot cold-start offer; README's Codex and Claude Code paths carry the same two follow-ups. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * v0.45.10.0 feat(bootstrap): the OOBE hand-off release Version trio + runbook stamp + template tree to 0.45.10.0; CHANGELOG entry; llms bundles regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(ci): stop memory-verbs-conformance leaking a fake-keyed gateway into shard-mates The deterministic-embedder helper configures the MODULE-GLOBAL gateway with a fake OpenAI key; the file's afterAll never reset it. The bunfig preload's per-test restore only fires when the gateway is UNCONFIGURED, so the fake-keyed config persisted for every later file in the shard process — turn-context's corpus writes then embedded against real OpenAI and 401'd (CI shard-8 failure; shard re-binning from this branch's new test files exposed it). Fix both sides: conformance's afterAll now resetGateway()s back to the preload baseline and nulls both test transports; turn-context's beforeAll does the same defensively so it stays hermetic regardless of shard composition. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
479 lines
18 KiB
TypeScript
479 lines
18 KiB
TypeScript
/**
|
|
* Tests for src/core/bootstrap/interview.ts — the bootstrap interview engine.
|
|
*
|
|
* Pins the plan's interview hardening (docs/designs/AGENT_BOOTSTRAP_PLAN.md):
|
|
* - required-key gate data (status → missingRequired/complete)
|
|
* - skip refuses required keys
|
|
* - [G10] maxLength / rejectValues / allowed enforcement, control-char
|
|
* stripping, `{{`/`}}` set-time escaping
|
|
* - [G12] conflict-marker + invalid-JSON detection returns typed errors,
|
|
* never throws
|
|
* - [A8] confirm requires the hash of the exact set that was read back;
|
|
* single-batch set+confirm-without-readback fails; set_at provenance
|
|
* - [CX2-13] PROVIDER_KEY (persist:false, sink:'config') never lands in
|
|
* interview.json and never leaks through results or error messages
|
|
*/
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, statSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import {
|
|
initState,
|
|
interviewStatePath,
|
|
readInterviewState,
|
|
setAnswer,
|
|
skipAnswer,
|
|
status,
|
|
readBackHash,
|
|
confirm,
|
|
show,
|
|
sanitizeAnswerValue,
|
|
routeProviderKeyToConfig,
|
|
} from '../src/core/bootstrap/interview.ts';
|
|
import { configPath } from '../src/core/config.ts';
|
|
|
|
const REQUIRED = [
|
|
'AGENT_NAME',
|
|
'PRINCIPAL_NAME',
|
|
'AGENT_PURPOSE',
|
|
'AGENT_TOP_JOBS',
|
|
'PRINCIPAL_CONTEXT',
|
|
'VOICE_REGISTER',
|
|
] as const;
|
|
|
|
const ANSWERS: Record<(typeof REQUIRED)[number], string> = {
|
|
AGENT_NAME: 'Trenton',
|
|
PRINCIPAL_NAME: 'Alice Example',
|
|
AGENT_PURPOSE: 'Maintain the research corpus and draft the weekly memo.',
|
|
AGENT_TOP_JOBS: '- corpus upkeep\n- weekly memo\n- meeting prep',
|
|
PRINCIPAL_CONTEXT: 'Runs a small research lab; ships a memo every Friday.',
|
|
VOICE_REGISTER: 'Direct. Three options, the second one wins.',
|
|
};
|
|
|
|
function makeWs(): string {
|
|
return mkdtempSync(join(tmpdir(), 'gbrain-interview-test-'));
|
|
}
|
|
|
|
function answerAllRequired(ws: string): void {
|
|
for (const key of REQUIRED) {
|
|
const r = setAnswer(ws, key, ANSWERS[key]);
|
|
expect(r.ok).toBe(true);
|
|
}
|
|
}
|
|
|
|
function expectErr(r: { ok: boolean }, code: string): asserts r is { ok: false; code: string; message: string } {
|
|
expect(r.ok).toBe(false);
|
|
expect((r as unknown as { code: string }).code).toBe(code);
|
|
}
|
|
|
|
describe('initState + readInterviewState', () => {
|
|
test('creates state/interview.json once, idempotently', () => {
|
|
const ws = makeWs();
|
|
const first = initState(ws);
|
|
expect(first.ok).toBe(true);
|
|
if (first.ok) expect(first.created).toBe(true);
|
|
const second = initState(ws);
|
|
expect(second.ok).toBe(true);
|
|
if (second.ok) expect(second.created).toBe(false);
|
|
const parsed = JSON.parse(readFileSync(interviewStatePath(ws), 'utf8'));
|
|
expect(parsed).toEqual({ version: 1, answers: {} });
|
|
});
|
|
|
|
test('missing file reads as a fresh empty state (exists: false)', () => {
|
|
const ws = makeWs();
|
|
const read = readInterviewState(ws);
|
|
expect(read.ok).toBe(true);
|
|
if (read.ok) {
|
|
expect(read.exists).toBe(false);
|
|
expect(read.state.answers).toEqual({});
|
|
}
|
|
});
|
|
|
|
test('[G12] git conflict markers → typed error with agent-readable message, no throw', () => {
|
|
const ws = makeWs();
|
|
mkdirSync(join(ws, 'state'), { recursive: true });
|
|
writeFileSync(
|
|
interviewStatePath(ws),
|
|
'<<<<<<< HEAD\n{"version":1,"answers":{}}\n=======\n{"version":1}\n>>>>>>> other\n'
|
|
);
|
|
const read = readInterviewState(ws);
|
|
expectErr(read, 'conflict_markers');
|
|
expect(read.message).toContain('resolve');
|
|
// status and setAnswer surface the same typed error and never clobber.
|
|
expectErr(status(ws), 'conflict_markers');
|
|
expectErr(setAnswer(ws, 'AGENT_NAME', 'Trenton'), 'conflict_markers');
|
|
expect(readFileSync(interviewStatePath(ws), 'utf8')).toContain('<<<<<<<');
|
|
});
|
|
|
|
test('[G12] invalid JSON → typed error, never a thrown SyntaxError', () => {
|
|
const ws = makeWs();
|
|
mkdirSync(join(ws, 'state'), { recursive: true });
|
|
writeFileSync(interviewStatePath(ws), 'this is not json');
|
|
const read = readInterviewState(ws);
|
|
expectErr(read, 'invalid_json');
|
|
expect(read.message).toContain('not valid JSON');
|
|
});
|
|
|
|
test('newer state version → typed error naming the upgrade path', () => {
|
|
const ws = makeWs();
|
|
mkdirSync(join(ws, 'state'), { recursive: true });
|
|
writeFileSync(interviewStatePath(ws), JSON.stringify({ version: 9, answers: {} }));
|
|
expectErr(readInterviewState(ws), 'newer_version');
|
|
});
|
|
});
|
|
|
|
describe('gate data (status)', () => {
|
|
test('fresh state: all 6 required missing, complete false', () => {
|
|
const ws = makeWs();
|
|
initState(ws);
|
|
const st = status(ws);
|
|
expect(st.ok).toBe(true);
|
|
if (st.ok) {
|
|
expect(st.missingRequired.sort()).toEqual([...REQUIRED].sort());
|
|
expect(st.answered).toEqual([]);
|
|
expect(st.complete).toBe(false);
|
|
expect(st.confirmed).toBe(false);
|
|
}
|
|
});
|
|
|
|
test('answering all required flips complete', () => {
|
|
const ws = makeWs();
|
|
answerAllRequired(ws);
|
|
const st = status(ws);
|
|
if (!st.ok) throw new Error(st.message);
|
|
expect(st.missingRequired).toEqual([]);
|
|
expect(st.complete).toBe(true);
|
|
expect(st.answered.sort()).toEqual([...REQUIRED].sort());
|
|
});
|
|
|
|
test('a skipped optional key counts as skipped, not answered', () => {
|
|
const ws = makeWs();
|
|
const r = skipAnswer(ws, 'SOUL_WINCE');
|
|
expect(r.ok).toBe(true);
|
|
const st = status(ws);
|
|
if (!st.ok) throw new Error(st.message);
|
|
expect(st.skipped).toEqual(['SOUL_WINCE']);
|
|
expect(st.answered).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('skipAnswer', () => {
|
|
test('refuses required keys', () => {
|
|
const ws = makeWs();
|
|
for (const key of REQUIRED) {
|
|
const r = skipAnswer(ws, key);
|
|
expectErr(r, 'required_key');
|
|
expect(r.message).toContain(key);
|
|
}
|
|
});
|
|
|
|
test('optional skip persists skipped:true with provenance', () => {
|
|
const ws = makeWs();
|
|
const r = skipAnswer(ws, 'SOUL_WORLDVIEW');
|
|
expect(r.ok).toBe(true);
|
|
const parsed = JSON.parse(readFileSync(interviewStatePath(ws), 'utf8'));
|
|
expect(parsed.answers.SOUL_WORLDVIEW.skipped).toBe(true);
|
|
expect(Number.isNaN(Date.parse(parsed.answers.SOUL_WORLDVIEW.set_at))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('[G10] setAnswer enforcement', () => {
|
|
test('unknown key → typed error', () => {
|
|
const ws = makeWs();
|
|
expectErr(setAnswer(ws, 'NOT_A_KEY', 'x'), 'unknown_key');
|
|
});
|
|
|
|
test('empty value → typed error suggesting skip', () => {
|
|
const ws = makeWs();
|
|
const r = setAnswer(ws, 'SOUL_WINCE', ' ');
|
|
expectErr(r, 'empty_value');
|
|
expect(r.message).toContain('skip');
|
|
});
|
|
|
|
test('maxLength rejected with a clear truncation message', () => {
|
|
const ws = makeWs();
|
|
const r = setAnswer(ws, 'AGENT_NAME', 'a'.repeat(65));
|
|
expectErr(r, 'too_long');
|
|
expect(r.message).toContain('64');
|
|
expect(r.message.toLowerCase()).toContain('truncate');
|
|
});
|
|
|
|
test('rejectValues blocks placeholder names, case-insensitively', () => {
|
|
const ws = makeWs();
|
|
for (const bad of ['agent', 'Agent', ' AI ', 'tbd']) {
|
|
expectErr(setAnswer(ws, 'AGENT_NAME', bad), 'rejected_value');
|
|
}
|
|
expect(setAnswer(ws, 'AGENT_NAME', 'Trenton').ok).toBe(true);
|
|
});
|
|
|
|
test('allowed list enforced for consent keys; canonical spelling stored', () => {
|
|
const ws = makeWs();
|
|
expectErr(setAnswer(ws, 'SEARCH_MODE', 'warp-speed'), 'not_allowed');
|
|
const r = setAnswer(ws, 'SEARCH_MODE', 'BALANCED');
|
|
expect(r.ok).toBe(true);
|
|
const parsed = JSON.parse(readFileSync(interviewStatePath(ws), 'utf8'));
|
|
expect(parsed.answers.SEARCH_MODE.value).toBe('balanced');
|
|
});
|
|
|
|
test('control characters stripped; CRLF normalized to LF', () => {
|
|
expect(sanitizeAnswerValue('hello\x07world\r\nnext\tkeep')).toBe('helloworld\nnext\tkeep');
|
|
});
|
|
|
|
test('{{ and }} escaped at set time — token regex can never match the stored value', () => {
|
|
const ws = makeWs();
|
|
const r = setAnswer(ws, 'VOICE_REGISTER', 'I use {{handlebars}} and {{{RUNS}}} daily');
|
|
expect(r.ok).toBe(true);
|
|
if (r.ok && r.sink === 'state') {
|
|
expect(r.value).not.toContain('{{');
|
|
expect(r.value).not.toContain('}}');
|
|
expect(r.value).toContain('{ {');
|
|
}
|
|
const raw = readFileSync(interviewStatePath(ws), 'utf8');
|
|
const stored = JSON.parse(raw).answers.VOICE_REGISTER.value as string;
|
|
expect(/\{\{/.test(stored)).toBe(false);
|
|
expect(/\}\}/.test(stored)).toBe(false);
|
|
});
|
|
|
|
test('brace runs converge (no adjacent pair survives repeated escaping)', () => {
|
|
const v = sanitizeAnswerValue('{{{{X}}}}');
|
|
expect(v).not.toContain('{{');
|
|
expect(v).not.toContain('}}');
|
|
});
|
|
});
|
|
|
|
describe('[A8] provenance + read-back confirm', () => {
|
|
test('every answer records a parseable set_at ISO timestamp', () => {
|
|
const ws = makeWs();
|
|
answerAllRequired(ws);
|
|
const parsed = JSON.parse(readFileSync(interviewStatePath(ws), 'utf8'));
|
|
for (const key of REQUIRED) {
|
|
const setAt = parsed.answers[key].set_at as string;
|
|
expect(Number.isNaN(Date.parse(setAt))).toBe(false);
|
|
expect(new Date(setAt).toISOString()).toBe(setAt);
|
|
}
|
|
});
|
|
|
|
test('readBackHash refuses an incomplete required set', () => {
|
|
const ws = makeWs();
|
|
setAnswer(ws, 'AGENT_NAME', 'Trenton');
|
|
const r = readBackHash(ws);
|
|
expectErr(r, 'incomplete');
|
|
expect(r.message).toContain('PRINCIPAL_NAME');
|
|
});
|
|
|
|
test('confirm with a wrong hash fails; with the current hash succeeds', () => {
|
|
const ws = makeWs();
|
|
answerAllRequired(ws);
|
|
expectErr(confirm(ws, 'deadbeef'), 'hash_mismatch');
|
|
const h = readBackHash(ws);
|
|
if (!h.ok) throw new Error(h.message);
|
|
const c = confirm(ws, h.hash);
|
|
expect(c.ok).toBe(true);
|
|
const st = status(ws);
|
|
if (!st.ok) throw new Error(st.message);
|
|
expect(st.confirmed).toBe(true);
|
|
});
|
|
|
|
test('HOSTILE: single-batch set+confirm without a fresh read-back fails', () => {
|
|
const ws = makeWs();
|
|
// The agent sets five answers, grabs a hash mid-stream, then sets the
|
|
// sixth and confirms with the stale hash — the human never saw the final
|
|
// set. Must fail.
|
|
for (const key of REQUIRED.slice(0, 5)) setAnswer(ws, key, ANSWERS[key]);
|
|
setAnswer(ws, 'SOUL_WINCE', 'Filler openers.');
|
|
// Not even computable pre-completion for the required set — but simulate
|
|
// the closest attack: complete the set, hash, then change an answer.
|
|
setAnswer(ws, REQUIRED[5], ANSWERS[REQUIRED[5]]);
|
|
const stale = readBackHash(ws);
|
|
if (!stale.ok) throw new Error(stale.message);
|
|
setAnswer(ws, 'SOUL_WINCE', 'Actually: hedging.'); // set AFTER the read-back
|
|
expectErr(confirm(ws, stale.hash), 'hash_mismatch');
|
|
// Only the hash of the exact current set — computed AFTER all answers —
|
|
// is accepted.
|
|
const fresh = readBackHash(ws);
|
|
if (!fresh.ok) throw new Error(fresh.message);
|
|
expect(confirm(ws, fresh.hash).ok).toBe(true);
|
|
});
|
|
|
|
test('any later setAnswer or skipAnswer invalidates a prior confirm', () => {
|
|
const ws = makeWs();
|
|
answerAllRequired(ws);
|
|
const h = readBackHash(ws);
|
|
if (!h.ok) throw new Error(h.message);
|
|
confirm(ws, h.hash);
|
|
setAnswer(ws, 'SOUL_GOOD_OUTPUT', 'A finished artifact.');
|
|
const st = status(ws);
|
|
if (!st.ok) throw new Error(st.message);
|
|
expect(st.confirmed).toBe(false);
|
|
const parsed = JSON.parse(readFileSync(interviewStatePath(ws), 'utf8'));
|
|
expect(parsed.confirmed).toBeUndefined();
|
|
});
|
|
|
|
test('setAnswer surfaces invalidatedConfirmation ONLY when a confirm existed', () => {
|
|
const ws = makeWs();
|
|
answerAllRequired(ws);
|
|
// No prior confirmation → nothing was invalidated (falsy flag).
|
|
const r0 = setAnswer(ws, 'SOUL_WINCE', 'Filler openers.');
|
|
expect(r0.ok).toBe(true);
|
|
if (!r0.ok || r0.sink !== 'state') throw new Error('expected a state-sink result');
|
|
expect(r0.invalidatedConfirmation).toBeFalsy();
|
|
// Full confirm, then a later set → the result SAYS it voided the confirm
|
|
// (the CLI warns at --set time instead of failing much later at render).
|
|
const h = readBackHash(ws);
|
|
if (!h.ok) throw new Error(h.message);
|
|
expect(confirm(ws, h.hash).ok).toBe(true);
|
|
const r1 = setAnswer(ws, 'SOUL_GOOD_OUTPUT', 'A finished artifact.');
|
|
expect(r1.ok).toBe(true);
|
|
if (!r1.ok || r1.sink !== 'state') throw new Error('expected a state-sink result');
|
|
expect(r1.invalidatedConfirmation).toBe(true);
|
|
const st = status(ws);
|
|
if (!st.ok) throw new Error(st.message);
|
|
expect(st.confirmed).toBe(false);
|
|
});
|
|
|
|
test('skipAnswer surfaces invalidatedConfirmation ONLY when a confirm existed (optional key — required keys refuse skip)', () => {
|
|
const ws = makeWs();
|
|
answerAllRequired(ws);
|
|
// No prior confirmation → falsy flag on an optional-key skip.
|
|
const r0 = skipAnswer(ws, 'SOUL_WINCE');
|
|
expect(r0.ok).toBe(true);
|
|
if (!r0.ok) throw new Error('unreachable');
|
|
expect(r0.invalidatedConfirmation).toBeFalsy();
|
|
// Full confirm, then a later optional-key skip → invalidation surfaced.
|
|
const h = readBackHash(ws);
|
|
if (!h.ok) throw new Error(h.message);
|
|
expect(confirm(ws, h.hash).ok).toBe(true);
|
|
const r1 = skipAnswer(ws, 'SOUL_WORLDVIEW');
|
|
expect(r1.ok).toBe(true);
|
|
if (!r1.ok) throw new Error('unreachable');
|
|
expect(r1.invalidatedConfirmation).toBe(true);
|
|
const st = status(ws);
|
|
if (!st.ok) throw new Error(st.message);
|
|
expect(st.confirmed).toBe(false);
|
|
});
|
|
|
|
test('show returns the read-back payload with the hash once complete', () => {
|
|
const ws = makeWs();
|
|
answerAllRequired(ws);
|
|
setAnswer(ws, 'SEARCH_MODE', 'balanced');
|
|
const s = show(ws);
|
|
if (!s.ok) throw new Error(s.message);
|
|
expect(s.entries.length).toBe(12); // the 12 interview keys
|
|
const name = s.entries.find((e) => e.key === 'AGENT_NAME');
|
|
expect(name?.value).toBe('Trenton');
|
|
expect(name?.required).toBe(true);
|
|
expect(s.extras.map((e) => e.key)).toEqual(['SEARCH_MODE']);
|
|
const h = readBackHash(ws);
|
|
if (!h.ok) throw new Error(h.message);
|
|
expect(s.hash).toBe(h.hash);
|
|
});
|
|
});
|
|
|
|
describe('[CX2-13] PROVIDER_KEY never lands in interview.json', () => {
|
|
test('setAnswer returns the config sink and persists NOTHING', () => {
|
|
const ws = makeWs();
|
|
setAnswer(ws, 'AGENT_NAME', 'Trenton'); // ensure the state file exists
|
|
const before = readFileSync(interviewStatePath(ws), 'utf8');
|
|
const r = setAnswer(ws, 'PROVIDER_KEY', 'sk-ant-supersecret-123456');
|
|
expect(r.ok).toBe(true);
|
|
if (r.ok) {
|
|
expect(r.sink).toBe('config');
|
|
expect(r.key).toBe('PROVIDER_KEY');
|
|
}
|
|
// The value appears NOWHERE in the result object.
|
|
expect(JSON.stringify(r)).not.toContain('supersecret');
|
|
// The state file is byte-identical: no PROVIDER_KEY entry, no secret.
|
|
const after = readFileSync(interviewStatePath(ws), 'utf8');
|
|
expect(after).toBe(before);
|
|
expect(after).not.toContain('PROVIDER_KEY');
|
|
expect(after).not.toContain('supersecret');
|
|
});
|
|
|
|
test('skip on PROVIDER_KEY persists nothing either', () => {
|
|
const ws = makeWs();
|
|
setAnswer(ws, 'AGENT_NAME', 'Trenton');
|
|
const before = readFileSync(interviewStatePath(ws), 'utf8');
|
|
const r = skipAnswer(ws, 'PROVIDER_KEY');
|
|
expect(r.ok).toBe(true);
|
|
expect(readFileSync(interviewStatePath(ws), 'utf8')).toBe(before);
|
|
});
|
|
|
|
test('too-long provider key error never echoes the value', () => {
|
|
const ws = makeWs();
|
|
const secret = `sk-${'a'.repeat(300)}`;
|
|
const r = setAnswer(ws, 'PROVIDER_KEY', secret);
|
|
expectErr(r, 'too_long');
|
|
expect(r.message).not.toContain(secret);
|
|
expect(r.message).not.toContain('aaaa');
|
|
});
|
|
|
|
test('PROVIDER_KEY does not participate in the read-back hash', () => {
|
|
const ws = makeWs();
|
|
answerAllRequired(ws);
|
|
const h1 = readBackHash(ws);
|
|
if (!h1.ok) throw new Error(h1.message);
|
|
confirm(ws, h1.hash);
|
|
setAnswer(ws, 'PROVIDER_KEY', 'sk-ant-supersecret-123456');
|
|
const h2 = readBackHash(ws);
|
|
if (!h2.ok) throw new Error(h2.message);
|
|
expect(h2.hash).toBe(h1.hash); // sink answers are hash-invisible
|
|
const st = status(ws);
|
|
if (!st.ok) throw new Error(st.message);
|
|
expect(st.confirmed).toBe(true); // and don't invalidate the confirm
|
|
});
|
|
});
|
|
|
|
describe('routeProviderKeyToConfig (0600 config sink)', () => {
|
|
let home: string;
|
|
let prevHome: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
home = mkdtempSync(join(tmpdir(), 'gbrain-interview-home-'));
|
|
prevHome = process.env.GBRAIN_HOME;
|
|
process.env.GBRAIN_HOME = home;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (prevHome === undefined) delete process.env.GBRAIN_HOME;
|
|
else process.env.GBRAIN_HOME = prevHome;
|
|
rmSync(home, { recursive: true, force: true });
|
|
});
|
|
|
|
function seedConfig(): void {
|
|
mkdirSync(join(home, '.gbrain'), { recursive: true });
|
|
writeFileSync(join(home, '.gbrain', 'config.json'), JSON.stringify({ engine: 'pglite' }));
|
|
}
|
|
|
|
test('routes each provider prefix to its config key, mode 0600', () => {
|
|
seedConfig();
|
|
const cases: Array<[string, string]> = [
|
|
['sk-ant-abc123', 'anthropic_api_key'],
|
|
['sk-plainopenai456', 'openai_api_key'],
|
|
['pa-voyage789', 'voyage_api_key'],
|
|
];
|
|
for (const [key, configKey] of cases) {
|
|
const r = routeProviderKeyToConfig(key);
|
|
expect(r.ok).toBe(true);
|
|
if (r.ok) expect(r.configKey).toBe(configKey as typeof r.configKey);
|
|
const cfg = JSON.parse(readFileSync(configPath(), 'utf8'));
|
|
expect(cfg[configKey]).toBe(key);
|
|
}
|
|
expect(statSync(configPath()).mode & 0o777).toBe(0o600);
|
|
});
|
|
|
|
test('unrecognized key shape → error that never echoes the value', () => {
|
|
seedConfig();
|
|
const r = routeProviderKeyToConfig('xoxb-not-a-supported-provider');
|
|
expectErr(r, 'unrecognized_key_shape');
|
|
expect(r.message).not.toContain('xoxb');
|
|
});
|
|
|
|
test('missing config → agent-readable "run gbrain init first" error', () => {
|
|
const r = routeProviderKeyToConfig('sk-ant-abc123');
|
|
expectErr(r, 'config_missing');
|
|
expect(r.message).toContain('gbrain init');
|
|
expect(existsSync(configPath())).toBe(false);
|
|
});
|
|
});
|