test(bootstrap): real-agent e2e — drive the actual claude + codex binaries end to end

Closes the audit's biggest gap ("no real harness ever drives a turn"). Adapts
gstack's PTY/headless agent harness to prove the bootstrap install + smoke work
against the REAL binaries, not PATH shims. Test/CI/docs only — zero src changes.

- test/helpers/agent-harness.ts: hermetic clean-room child env (ported from
  gstack; drops CONDUCTOR_/CLAUDE_/GSTACK_/MCP_/GBRAIN_, promotes
  GSTACK_ANTHROPIC_API_KEY→ANTHROPIC_API_KEY), real-binary resolvers + auth
  probes, headless `claude -p --output-format stream-json` and `codex exec
  --json` turn runners, a gbrain stdio MCP-config writer, and a keyless brain
  seeder. + a fixture-parse unit test (no binary needed).
- test/e2e/bootstrap-real-claude.serial.test.ts: real `gbrain bootstrap`
  install → REAL `claude mcp add` (verified via `claude mcp get`) → verify
  exit 0 → a real `claude -p --mcp-config --strict-mcp-config` turn that
  invokes mcp__gbrain__search and answers from the brain (proven: toolCalls
  include mcp__gbrain__search, final text carries the seeded fact).
- test/e2e/bootstrap-real-codex.serial.test.ts: same install with REAL `codex
  mcp add` into a real ~/.codex/config.toml + Gate-3 pull-protocol assertion,
  then a real `codex exec --json` turn surfacing the fact (MCP or the pull-
  protocol shell path). Bounded retry absorbs codex's occasional MCP-call
  cancellation without softening the fact-requiring assertion.
- Everything hermetic (temp HOME/CLAUDE_CONFIG_DIR/CODEX_HOME/GBRAIN_HOME;
  real ~/.codex auth copied read-only) and skipIf-gated so it self-skips
  cleanly where the binaries/auth are absent.
- heavy-tests.yml: gated `real-agent-e2e` job (nightly/label, never the PR
  shard; no-op on a runner without authed binaries).
- TODOS: compiled `gbrain` binary can't serve a PGLite brain (bun compile
  omits the WASM/extension payloads); harness falls back to `bun run` serve.

Verified against live claude 4.6 + codex 0.147.0: 15 pass / 0 fail; verify
36/36; typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-11 14:56:52 -07:00
co-authored by Claude Fable 5
parent 99e15f0a10
commit 2f6b817a28
7 changed files with 1684 additions and 0 deletions
+41
View File
@@ -101,3 +101,44 @@ jobs:
path: heavy-artifacts/
retention-days: 14
if-no-files-found: ignore
# Real-agent door e2e: drives the ACTUAL `claude` + `codex` binaries (no PATH
# shims) against a real gbrain over MCP. These pay real API cost and need the
# binaries installed + authed, which a stock GitHub runner does NOT have — so
# both tests self-SKIP (describe.skipIf on binary/auth) and the job is a clean
# no-op here. It exists so a self-hosted / manually-provisioned runner WITH
# authed claude/codex (and ANTHROPIC/OPENAI creds) actually exercises the real
# binaries. Heavy cadence only (nightly + `real-agent-e2e` label + dispatch);
# NEVER the PR shard matrix.
real-agent-e2e:
name: Real-agent door e2e (skips without authed binaries)
if: |
github.event_name != 'pull_request' ||
contains(github.event.pull_request.labels.*.name, 'real-agent-e2e') ||
contains(github.event.pull_request.labels.*.name, 'heavy-tests')
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13
- run: bun install
# Reference both door tests; run only the ones present (the claude door
# may land in a sibling PR). Missing binary/auth → the file self-skips, so
# a stock runner reports a green no-op rather than failing.
- name: Run real-agent door tests
run: |
FILES=""
for f in \
test/e2e/bootstrap-real-claude.serial.test.ts \
test/e2e/bootstrap-real-codex.serial.test.ts; do
[ -f "$f" ] && FILES="$FILES $f"
done
if [ -z "$FILES" ]; then
echo "SKIP: no real-agent door test files present yet."
exit 0
fi
echo "Running:$FILES"
bun test $FILES
+23
View File
@@ -5018,6 +5018,19 @@ respective shapes. Small, mechanical; pinned by `test/init-embed-check.test.ts`
## Agent-bootstrap wave follow-ups (filed at build time)
- [ ] **P2 — compiled `gbrain` binary cannot `serve` a PGLite brain.** `bun
build --compile` does not embed PGLite's WASM/extension payloads
(`pglite.data`, `vector.tar.gz`, `pg_trgm.tar.gz`) — they're absent from the
read-only `/$bunfs` (Bun vfs #1340), so `bin/gbrain serve` fails to open a
PGLite data dir. Real installs use `bun install -g` (runs `serve` via bun on
source), so normal users are unaffected — but the release also publishes
compiled binaries for self-update, and `gbrain serve` from one on PGLite
would fail. Fix: embed the WASM/extension assets in the compile step (so the
compiled binary is serve-capable and the real-agent e2e MCP server can use
it for fast startup), OR document that PGLite `serve` requires the bun-run
path. Found building the real-agent e2e (the harness falls back to `bun run
src/cli.ts serve`, measured ~300ms tools/list, so speed isn't the blocker —
this is purely the compiled-binary capability gap).
- [ ] **P1 — `--background --follow` spawns a nonexistent subcommand.**
`src/core/cli-options.ts` (~:391) spawns `gbrain jobs follow <id>` after a
background submit, but jobs.ts has no `follow` subcommand (`jobs watch
@@ -5114,6 +5127,16 @@ respective shapes. Small, mechanical; pinned by `test/init-embed-check.test.ts`
install, gh auth, repo create, MCP registration) stays manual. Unblocks after 10
consecutive green offline runs in heavy-tests (CEO review D3.3b). Start:
tests/docker/ harness + the fake-gh recording shim.
- [ ] **P2 — Real-agent door e2e needs a provisioned runner.** The real-binary door
tests (`test/e2e/bootstrap-real-{claude,codex}.serial.test.ts`) drive the ACTUAL
`claude`/`codex` binaries against a real gbrain and pay API cost. They self-SKIP
(`describe.skipIf` on binary/auth) everywhere else, so the `real-agent-e2e` job in
`.github/workflows/heavy-tests.yml` is a green no-op on stock GitHub runners. To
actually EXERCISE them we need a self-hosted / manually-provisioned runner with
authed `claude` + `codex` and provider creds exported
(`GSTACK_ANTHROPIC_API_KEY`/`ANTHROPIC_API_KEY`, `VOYAGE_API_KEY`). Until then they
run locally on an operator machine only. Start: stand up a labeled runner with the
binaries pre-authed, or a scheduled self-hosted lane.
- [ ] **P1 — unit-shard exit hang: bun test process leaks a ref'd handle and never
exits after all tests pass.** Probabilistic (scales with file count/duration),
+34
View File
@@ -123,3 +123,37 @@ One command: `gbrain doctor`. It covers hook health, push staleness, serve/lock
collisions, schema state, and prints fixes. `gbrain bootstrap status --json` emits
a support blob (versions, harness, last verify/push, hook failure rate) your agent
can relay verbatim when you report a problem.
## Real-agent e2e
Most bootstrap tests drive the dispatcher with PATH-shimmed `claude`/`codex`
recorders — fast, hermetic, no API cost. Two additional "door" tests drive the
ACTUAL binaries end to end so we catch real-world drift (a `codex mcp add` flag
that changed shape, a harness that stopped calling our MCP server):
- `test/e2e/bootstrap-real-claude.serial.test.ts` — real `claude -p` over MCP.
- `test/e2e/bootstrap-real-codex.serial.test.ts` — real `codex exec`. It runs the
keyless-`init` → interview → render → `gbrain bootstrap hooks --harness codex`
path (executing the real `codex mcp add` into a hermetic `~/.codex/config.toml`),
asserts the rendered `AGENTS.md` carries the Gate-3 brain-first pull protocol
(Codex has no hook system, so the pull protocol is its per-turn seam), then
spends one live `codex exec` turn to prove real codex → gbrain MCP → brain →
a seeded, brain-only fact (falling back to a shell `gbrain query` if headless
stdio-MCP is unavailable).
These pay real API cost and take 30s2min per turn, so they are NOT in the PR
shard. Everything is hermetic (temp `HOME` / `CODEX_HOME` / `CLAUDE_CONFIG_DIR` /
`GBRAIN_HOME` per test — the operator's real `~/.claude`, `~/.gbrain`, `~/.codex`
are never touched; auth is copied read-only). Each file self-SKIPS via
`describe.skipIf` when its binary or auth is absent, so on a machine without the
tool it is a clean no-op that never fails. CI wires them into the `real-agent-e2e`
job in `.github/workflows/heavy-tests.yml` (nightly + the `real-agent-e2e` /
`heavy-tests` label); on a stock runner they self-skip. To actually exercise the
binaries you need a runner with authed `claude`/`codex` and the provider creds
(`GSTACK_ANTHROPIC_API_KEY`/`ANTHROPIC_API_KEY`, `VOYAGE_API_KEY`) exported.
Run locally (where both are installed + authed):
```bash
bun test test/e2e/bootstrap-real-codex.serial.test.ts
```
@@ -0,0 +1,310 @@
/**
* REAL Claude Code "door" E2E — drives the ACTUAL `claude` binary (no PATH
* shims), end to end, against a real keyless PGLite brain. Two proofs:
*
* (1) SETUP+INSTALL — in a hermetic temp HOME + CLAUDE_CONFIG_DIR +
* GBRAIN_HOME + workspace, drive the real `gbrain` bootstrap CLI to an
* installed state: `gbrain init --pglite --no-embedding` (keyless),
* interview (--set required answers + --confirm), render, then
* `gbrain bootstrap hooks --harness claude-code` — which runs the REAL
* `claude mcp add` into the temp config (project-scope `.mcp.json` in the
* workspace). We then run the REAL `claude mcp get gbrain` and assert it
* shows OUR serve command + GBRAIN_SOURCE binding (real-registration
* proof), and `gbrain bootstrap verify` exits 0 in this hermetic HOME.
*
* (2) SMOKE — the real-harness-in-the-loop proof: seed a distinctive,
* 100%-synthetic fact into a keyless brain, write a `--mcp-config` that
* runs THIS repo's `gbrain serve` over stdio, and drive one headless
* `claude -p` turn asking about the seeded entity. Assert a gbrain MCP
* tool actually fired (real claude → real gbrain MCP → real brain) AND
* the seeded fact surfaced. This is the whole product in one turn.
*
* Every real-agent turn pays API cost and takes 30s2min, so prompts are
* minimal (one seeded fact, one question) and timeouts are capped. The whole
* describe fail-SKIPs (never fail-HARD) on a machine without the `claude`
* binary or its auth. Serial: PGLite cold starts + real subprocess spawns
* would starve parallel siblings.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, chmodSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import {
resolveClaudeBinary,
hasClaudeAuth,
hermeticChildEnv,
seedBrainForAgent,
writeGbrainMcpConfig,
claudeHeadlessTurn,
resolveGbrainServerCommand,
} from '../helpers/agent-harness.ts';
import { runBootstrap } from '../../src/commands/bootstrap.ts';
import type { ExecRunner, ExecResult } from '../../src/core/bootstrap/repo.ts';
import { readBackHash } from '../../src/core/bootstrap/interview.ts';
import { createEngine } from '../../src/core/engine-factory.ts';
import { addSource } from '../../src/core/sources-ops.ts';
const REPO_ROOT = resolve(import.meta.dir, '..', '..');
const CLAUDE_BIN = resolveClaudeBinary();
const CAN_RUN = !!CLAUDE_BIN && hasClaudeAuth();
/** Same required-answer set the shimmed lifecycle e2e uses (synthetic). */
const REQUIRED_ANSWERS: Record<string, string> = {
AGENT_NAME: 'Lighthouse',
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; values signal over noise.',
VOICE_REGISTER: 'Direct: three options, the second one wins.',
};
// ── Shared hermetic state (built once, torn down after) ─────────────────────
let tmpParent: string; // GBRAIN_HOME (parent — config appends `.gbrain`)
let home: string; // temp $HOME for the spawned claude
let claudeCfg: string; // temp CLAUDE_CONFIG_DIR
let ws: string; // the "installed" workspace
let binDir: string; // holds the dummy absolute gbrain binary path
let gbrainBin: string;
const SAVED_ENV: Record<string, string | undefined> = {};
const ENV_KEYS = ['GBRAIN_HOME', 'GBRAIN_SOURCE', 'GBRAIN_HOOKS', 'DATABASE_URL', 'GBRAIN_DATABASE_URL'];
/** Capture console.log around an async call (runBootstrap prints via console.log). */
async function captureStdout<T>(fn: () => Promise<T>): Promise<{ result: T; out: string }> {
const orig = console.log;
let out = '';
console.log = (...args: unknown[]) => {
out += args.map(String).join(' ') + '\n';
};
try {
const result = await fn();
return { result, out };
} finally {
console.log = orig;
}
}
/**
* ExecRunner that spawns the REAL `claude` binary with a hermetic env pinned
* at the temp HOME + CLAUDE_CONFIG_DIR, cwd=workspace (project-scope MCP config
* lives in the workspace `.mcp.json`, and `mcp get`/`list` read it from cwd).
* argv[0]==='claude' is rewritten to the resolved absolute binary so PATH quirks
* never intercept it. Nothing else on the operator's box is touched.
*/
const claudeRunner: ExecRunner = async (argv: string[]): Promise<ExecResult> => {
const real = argv[0] === 'claude' && CLAUDE_BIN ? [CLAUDE_BIN, ...argv.slice(1)] : argv;
try {
const proc = Bun.spawn(real, {
cwd: ws,
env: hermeticChildEnv({ HOME: home, CLAUDE_CONFIG_DIR: claudeCfg }),
stdout: 'pipe',
stderr: 'pipe',
stdin: 'ignore',
});
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { code, stdout, stderr };
} catch (e) {
return { code: 127, stdout: '', stderr: (e as Error).message };
}
};
beforeAll(() => {
for (const k of ENV_KEYS) SAVED_ENV[k] = process.env[k];
// Ambient DATABASE_URL must not flip the sandboxed brain to Postgres.
delete process.env.DATABASE_URL;
delete process.env.GBRAIN_DATABASE_URL;
delete process.env.GBRAIN_SOURCE;
delete process.env.GBRAIN_HOOKS;
// Short prefix — the IPC socket for the hooks smoke lives under database_path
// and unix socket paths cap ~104 bytes on macOS.
tmpParent = mkdtempSync(join(tmpdir(), 'gb-rc-'));
home = mkdtempSync(join(tmpdir(), 'gb-rc-home-'));
claudeCfg = mkdtempSync(join(tmpdir(), 'gb-rc-cfg-'));
ws = mkdtempSync(join(tmpdir(), 'gb-rc-ws-'));
mkdirSync(join(ws, 'brain'), { recursive: true });
// A dummy absolute gbrain binary for the MCP registration + hook command
// strings (never executed here — the registration only records the path, and
// `claude mcp get` reads config without launching the server).
binDir = mkdtempSync(join(tmpdir(), 'gb-rc-bin-'));
gbrainBin = join(binDir, 'gbrain');
writeFileSync(gbrainBin, '#!/bin/sh\nexit 0\n');
chmodSync(gbrainBin, 0o755);
// In-process bootstrap subcommands resolve the home from this env.
process.env.GBRAIN_HOME = tmpParent;
}, 60_000);
afterAll(() => {
for (const k of ENV_KEYS) {
if (SAVED_ENV[k] === undefined) delete process.env[k];
else process.env[k] = SAVED_ENV[k];
}
for (const dir of [tmpParent, home, claudeCfg, ws, binDir]) {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* best effort */
}
}
});
describe.skipIf(!CAN_RUN)('bootstrap real Claude Code door (serial e2e)', () => {
test('SETUP+INSTALL: real `gbrain` bootstrap → real `claude mcp add` → verify exit 0', async () => {
// 1. Engine phase: real keyless PGLite init as a subprocess (no API keys).
const init = spawnSync(
'bun',
['run', join(REPO_ROOT, 'src', 'cli.ts'), 'init', '--pglite', '--no-embedding', '--non-interactive'],
{
cwd: ws,
env: { ...process.env, GBRAIN_HOME: tmpParent, HOME: home } as Record<string, string>,
encoding: 'utf8',
timeout: 120_000,
stdio: ['ignore', 'pipe', 'pipe'],
},
);
expect(init.status).toBe(0);
const cfgPath = join(tmpParent, '.gbrain', 'config.json');
const cfg = JSON.parse(readFileSync(cfgPath, 'utf8')) as { engine: string; database_path: string; embedding_disabled?: boolean };
expect(cfg.engine).toBe('pglite');
expect(cfg.embedding_disabled).toBe(true);
// Register the workspace source with its brain/ dir so put_page write-through
// (the roundtrip check) lands files in the repo — the installed-state that
// `gbrain sources add workspace --path <ws>/brain` produces.
const engineConfig = { engine: 'pglite' as const, database_path: cfg.database_path };
const engine = await createEngine(engineConfig);
await engine.connect(engineConfig);
await addSource(engine, { id: 'workspace', localPath: join(ws, 'brain'), force: true });
await engine.disconnect();
// 2. Interview: real CLI, scripted answers, read-back-hash confirm.
expect(await runBootstrap(['interview', '--init', '--workspace', ws])).toBe(0);
for (const [key, value] of Object.entries(REQUIRED_ANSWERS)) {
expect(await runBootstrap(['interview', '--set', key, value, '--workspace', ws])).toBe(0);
}
expect(await runBootstrap(['interview', '--set', 'MCP_SCOPE', 'project', '--workspace', ws])).toBe(0);
expect(await runBootstrap(['interview', '--set', 'HOOKS_CONSENT', 'yes', '--workspace', ws])).toBe(0);
const h = readBackHash(ws);
if (!h.ok) throw new Error(h.message);
expect(await runBootstrap(['interview', '--confirm', h.hash, '--workspace', ws])).toBe(0);
// 3. Render identity files from the confirmed answers.
expect(await runBootstrap(['render', '--workspace', ws])).toBe(0);
// 4. Hooks: REAL `claude mcp add` (project scope) via the hermetic runner,
// plus per-turn hooks written into the workspace settings.
const { result: hooksCode, out: hooksOut } = await captureStdout(() =>
runBootstrap(['hooks', '--workspace', ws, '--harness', 'claude-code', '--gbrain-bin', gbrainBin], {
runner: claudeRunner,
}),
);
expect(hooksCode).toBe(0);
// The registration smoke VERIFIED (via real `claude mcp get`) that the
// recorded server carries our binary + this workspace's source.
expect(hooksOut).toContain('verified targeting this workspace');
// 5. Real-registration PROOF: query the actual `claude` config back.
const got = await claudeRunner(['claude', 'mcp', 'get', 'gbrain']);
expect(got.code).toBe(0);
const registered = `${got.stdout}\n${got.stderr}`;
expect(registered).toContain(gbrainBin); // OUR serve binary, absolute path
expect(registered).toContain('serve');
expect(registered).toContain('GBRAIN_SOURCE=workspace'); // the source binding
// 6. `gbrain bootstrap verify` — the whole install contract — exits 0 in
// this real hermetic HOME (keyless: magic moment via the zero-LLM fence).
const { result: verifyCode, out: verifyOut } = await captureStdout(() =>
runBootstrap(['verify', '--workspace', ws, '--json']),
);
if (verifyCode !== 0) {
// Surface the report so a failure is diagnosable, not a bare exit code.
throw new Error(`verify failed (exit ${verifyCode}):\n${verifyOut}`);
}
expect(verifyCode).toBe(0);
const payload = JSON.parse(verifyOut) as { ok: boolean; checks: Array<{ id: string; ok: boolean; warn?: boolean }> };
expect(payload.ok).toBe(true);
const byId = (id: string) => payload.checks.find((c) => c.id === id);
expect(byId('roundtrip')?.ok).toBe(true);
expect(byId('magic_moment')?.ok).toBe(true);
}, 300_000);
test('SMOKE: real `claude -p` → real gbrain MCP → real brain → surfaced fact', async () => {
// Independent hermetic brain for the recall proof (its own home + workspace).
const smokeHome = mkdtempSync(join(tmpdir(), 'gb-rc-smoke-'));
const smokeWs = mkdtempSync(join(tmpdir(), 'gb-rc-smoke-ws-'));
const mcpCfgPath = join(smokeHome, 'gbrain-mcp.json');
try {
const seeded = await seedBrainForAgent(smokeHome, 'workspace');
// Prefer a compiled binary (fast MCP startup) so the child's first tool
// call isn't cancelled; fall back to `bun run src/cli.ts serve` with a
// wider readiness window if the compile is unavailable in this sandbox.
const server = resolveGbrainServerCommand(REPO_ROOT);
writeGbrainMcpConfig({
path: mcpCfgPath,
server,
gbrainHome: smokeHome,
sourceId: 'workspace',
});
// Bounded retry (max 2) rides out a transient MCP-startup cancellation.
// PASS only when an attempt lands BOTH a gbrain tool AND the seeded fact;
// never pass on zero tool calls, never soften to "answered something".
const perAttemptTimeout = server.kind === 'compiled' ? 150_000 : 190_000;
const maxAttempts = 2;
let passed = false;
let lastEvidence = '';
for (let attempt = 1; attempt <= maxAttempts && !passed; attempt++) {
if (attempt > 1) await new Promise((r) => setTimeout(r, 3_000));
const turn = await claudeHeadlessTurn({
prompt:
`Use your gbrain memory tools to answer: ${seeded.query} ` +
`Answer only from the brain, in one short sentence.`,
cwd: smokeWs,
home,
claudeConfigDir: claudeCfg,
mcpConfigPath: mcpCfgPath,
timeoutMs: perAttemptTimeout,
});
// Real claude actually invoked OUR MCP server over stdio: a gbrain tool
// name (e.g. `mcp__gbrain__search`) appears in the tool-call trace.
// `toolCalls` holds tool NAMES only, so this can't false-positive on a
// stray "gbrain" substring in a file path or the raw event stream.
const firedGbrainTool = turn.toolCalls.some((t) => /gbrain/i.test(t));
// And the seeded, brain-ONLY fact reached the final answer. Only
// "rivermouth" proves recall — the entity "Summit Robotics" is echoed
// from the question, so matching it would be a false positive.
const gotFact = /rivermouth/i.test(turn.finalText);
lastEvidence =
`[smoke claude attempt ${attempt}/${maxAttempts}] server=${server.kind} ` +
`exit=${turn.exitCode} timedOut=${turn.timedOut} firedGbrainTool=${firedGbrainTool} gotFact=${gotFact}\n` +
`toolCalls=${JSON.stringify(turn.toolCalls)}\n` +
`finalText=${turn.finalText.slice(0, 800)}\n` +
`raw tail=${turn.rawLines.slice(-6).join('\n')}`;
console.log(lastEvidence);
if (firedGbrainTool && gotFact) passed = true;
}
expect(passed, `SMOKE failed on all ${maxAttempts} attempts.\n${lastEvidence}`).toBe(true);
} finally {
for (const dir of [smokeHome, smokeWs]) {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* best effort */
}
}
}
}, 420_000);
});
@@ -0,0 +1,357 @@
/**
* REAL-agent door test — Codex edition. Drives the ACTUAL `codex` binary (no
* PATH shim) against a real gbrain, over the two seams that matter for Codex:
*
* 1. INSTALL — the real `gbrain bootstrap` CLI (keyless `gbrain init`,
* interview --set/--confirm, render, then `gbrain bootstrap hooks
* --harness codex` executing the REAL `codex mcp add` into a hermetic
* ~/.codex/config.toml). Asserts the registration landed (real `codex mcp
* get gbrain` + the temp config.toml carry our server + GBRAIN_SOURCE),
* `gbrain bootstrap verify` exits 0, and the rendered AGENTS.md carries the
* Gate-3 brain-first pull protocol — Codex's ONLY per-turn mechanism (it
* has no hook system).
*
* 2. SMOKE — a live `codex exec` turn. gbrain is registered as a Codex stdio
* MCP server (`bun run <repo>/src/cli.ts serve --surface full`) pinned to a
* seeded keyless brain. Codex is asked a single question whose answer only
* the brain holds; we assert a gbrain tool/command shows up in the turn AND
* the seeded fact surfaces in the final text. Proves: real codex → gbrain
* MCP → brain → fact. Falls back to the AGENTS.md pull protocol + a direct
* shell instruction (`gbrain query`) if headless stdio-MCP is unreliable;
* the assertion documents which path proved out.
*
* EVERYTHING is hermetic (temp HOME / CODEX_HOME / GBRAIN_HOME per test) and the
* whole file self-SKIPS via describe.skipIf when the codex binary or its auth is
* absent, so it is a clean no-op on a runner without them. Serial: PGLite cold
* starts + a real codex spawn would starve parallel siblings; every test carries
* an explicit timeout. Real turns cost API + take 30s2min — prompts are minimal
* (one seeded fact, one question) and capped at 240s.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { spawnSync, execFileSync } from 'node:child_process';
import {
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import {
resolveCodexBinary,
hasCodexAuth,
codexExecTurn,
seedBrainForAgent,
hermeticChildEnv,
resolveGbrainServerCommand,
} from '../helpers/agent-harness.ts';
import { runBootstrap } from '../../src/commands/bootstrap.ts';
import type { ExecRunner } from '../../src/core/bootstrap/repo.ts';
import { initState, setAnswer, confirm, readBackHash } from '../../src/core/bootstrap/interview.ts';
import { readManifest } from '../../src/core/bootstrap/format.ts';
import { createEngine } from '../../src/core/engine-factory.ts';
import { addSource } from '../../src/core/sources-ops.ts';
const REPO_ROOT = resolve(import.meta.dir, '..', '..');
const CLI = join(REPO_ROOT, 'src', 'cli.ts');
const CODEX_BIN = resolveCodexBinary();
const CAN_RUN = !!CODEX_BIN && hasCodexAuth();
const REQUIRED_ANSWERS: Record<string, string> = {
AGENT_NAME: 'Lifeboat',
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; values signal over noise.',
VOICE_REGISTER: 'Direct: three options, the second one wins.',
};
const ENV_KEYS = [
'GBRAIN_HOME', 'GBRAIN_DATABASE_URL', 'DATABASE_URL', 'GBRAIN_BRAIN_ID',
'GBRAIN_SOURCE', 'GBRAIN_HOOKS', 'GBRAIN_BOOTSTRAP_ABORT_AFTER',
'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CODEX_HOME', 'CODEX_SANDBOX', 'CODEX_CI',
];
const SAVED_ENV: Record<string, string | undefined> = {};
/** Seed a hermetic ~/.codex under `home` with ONLY the operator's real
* auth.json (read-only copy) so a spawned codex authenticates without ever
* touching the real config dir. Deliberately does NOT copy the operator's
* real config.toml: it defines the operator's private remote MCP servers
* (which require secrets we don't have and would fail the whole session), and
* copying private server names is a privacy smell. `codex mcp add` writes a
* fresh, gbrain-only config.toml on top of this. */
function seedCodexHome(home: string): string {
const codexHome = join(home, '.codex');
mkdirSync(codexHome, { recursive: true });
const src = join(homedir(), '.codex', 'auth.json');
const dst = join(codexHome, 'auth.json');
if (existsSync(src) && !existsSync(dst)) {
try { cpSync(src, dst); } catch { /* best-effort */ }
}
// Placeholder global identity files so codexExecTurn's copy-if-missing loop
// never pulls the operator's PRIVATE ~/.codex/{AGENTS,SOUL}.md into the turn
// (behavior + privacy). The per-turn instruction rides the prompt instead.
for (const f of ['AGENTS.md', 'SOUL.md']) {
const p = join(codexHome, f);
if (!existsSync(p)) {
try { writeFileSync(p, '<!-- hermetic test placeholder -->\n'); } catch { /* best-effort */ }
}
}
return codexHome;
}
/** Run the REAL `codex` binary under a hermetic HOME/CODEX_HOME. Used both as
* the bootstrap `hooks` exec runner (so `codex mcp add` writes the temp
* config.toml) and for direct `codex mcp get`/`list` probes. */
function makeCodexRunner(home: string): ExecRunner {
const codexHome = join(home, '.codex');
return async (argv: string[]) => {
try {
const proc = Bun.spawn(argv, {
env: hermeticChildEnv(
{ HOME: home, CODEX_HOME: codexHome },
{ extraAllow: ['OPENAI_API_KEY', 'CODEX_*'] },
),
stdout: 'pipe',
stderr: 'pipe',
stdin: 'ignore',
});
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { code, stdout, stderr };
} catch (e) {
return { code: 127, stdout: '', stderr: (e as Error).message };
}
};
}
async function captureStdout<T>(fn: () => Promise<T>): Promise<{ result: T; out: string }> {
const orig = console.log;
let out = '';
console.log = (...args: unknown[]) => {
out += args.map(String).join(' ') + '\n';
};
try {
const result = await fn();
return { result, out };
} finally {
console.log = orig;
}
}
beforeAll(() => {
for (const k of ENV_KEYS) SAVED_ENV[k] = process.env[k];
// Ambient-state strip: a dev/CI DATABASE_URL must not flip the sandboxed
// brain to Postgres; stray GBRAIN_SOURCE/CODEX_* must not leak into a child.
for (const k of ENV_KEYS) delete process.env[k];
});
afterAll(() => {
for (const k of ENV_KEYS) {
if (SAVED_ENV[k] === undefined) delete process.env[k];
else process.env[k] = SAVED_ENV[k];
}
});
describe.skipIf(!CAN_RUN)('bootstrap real-codex door (serial e2e)', () => {
// ── 1. INSTALL ────────────────────────────────────────────────────────────
test('INSTALL: keyless init → interview → render → real `codex mcp add` → verify', async () => {
const gbHome = mkdtempSync(join(tmpdir(), 'gb-rc-home-'));
const ws = mkdtempSync(join(tmpdir(), 'gb-rc-ws-'));
const codexHost = mkdtempSync(join(tmpdir(), 'gb-rc-codex-'));
const shimDir = mkdtempSync(join(tmpdir(), 'gb-rc-bin-'));
const savedHome = process.env.GBRAIN_HOME;
try {
// A fresh git workspace (the cwd the human pasted into) with a brain/ dir.
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: ws });
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: ws });
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: ws });
mkdirSync(join(ws, 'brain'), { recursive: true });
// Hermetic ~/.codex for the real `codex mcp add`.
seedCodexHome(codexHost);
const codexRunner = makeCodexRunner(codexHost);
// A dummy absolute gbrain binary the registration records (never executed
// by INSTALL — the mcp-add just writes its path into config.toml).
const gbrainBin = join(shimDir, 'gbrain');
Bun.write(gbrainBin, '#!/bin/sh\nexit 0\n');
execFileSync('chmod', ['+x', gbrainBin]);
process.env.GBRAIN_HOME = gbHome;
// (a) Keyless init — the REAL CLI, non-interactive, no embedding provider.
const init = spawnSync('bun', ['run', CLI, 'init', '--pglite', '--no-embedding', '--non-interactive'], {
cwd: ws,
env: { ...process.env, GBRAIN_HOME: gbHome },
encoding: 'utf8',
timeout: 120_000,
});
expect(init.status).toBe(0);
const dbPath = join(gbHome, '.gbrain', 'brain.pglite');
expect(existsSync(dbPath)).toBe(true);
// Register the workspace source the interview's default GBRAIN_SOURCE
// routes to (verify's write-through needs a source with a localPath).
const engineConfig = { engine: 'pglite' as const, database_path: dbPath };
const engine = await createEngine(engineConfig);
await engine.connect(engineConfig);
await engine.initSchema();
await addSource(engine, { id: 'workspace', localPath: join(ws, 'brain'), force: true });
await engine.disconnect();
// (b) Interview — scripted answers, read-back hash confirm.
expect(initState(ws).ok).toBe(true);
for (const [key, value] of Object.entries(REQUIRED_ANSWERS)) {
const r = setAnswer(ws, key, value);
if (!r.ok) throw new Error(r.message);
}
const h = readBackHash(ws);
if (!h.ok) throw new Error(h.message);
expect(confirm(ws, h.hash).ok).toBe(true);
// (c) Render — identity files + manifest.
expect(await runBootstrap(['render', '--workspace', ws])).toBe(0);
expect(readManifest(ws).state).toBe('initialized');
// AGENTS.md carries the Gate-3 brain-first pull protocol (Codex's only
// per-turn mechanism — it has no hook system).
const agents = readFileSync(join(ws, 'AGENTS.md'), 'utf8');
expect(agents).toContain('Gate 3');
expect(agents.toLowerCase()).toContain('brain first');
expect(agents).toContain('recall');
// (d) hooks --harness codex → REAL `codex mcp add` via the runner seam.
const { result: hooksCode, out: hooksOut } = await captureStdout(() =>
runBootstrap(['hooks', '--workspace', ws, '--harness', 'codex', '--gbrain-bin', gbrainBin], {
runner: codexRunner,
}),
);
expect(hooksCode).toBe(0);
// Codex has no hooks — the pull protocol is the per-turn seam, stated plainly.
expect(hooksOut).toContain('Codex has no hook system');
// Real `codex mcp get gbrain` shows our server (env values are masked in
// the human view, so the source binding is asserted on config.toml below).
const get = await codexRunner(['codex', 'mcp', 'get', 'gbrain']);
expect(get.code).toBe(0);
expect(get.stdout).toContain('gbrain');
expect(get.stdout).toContain('serve');
expect(get.stdout).toContain('GBRAIN_SOURCE');
// The hermetic config.toml the real codex wrote carries our server +
// the exact GBRAIN_SOURCE binding (the [G1] workspace-source routing).
const toml = readFileSync(join(codexHost, '.codex', 'config.toml'), 'utf8');
expect(toml).toContain('[mcp_servers.gbrain]');
expect(toml).toContain(gbrainBin);
expect(toml).toContain('GBRAIN_SOURCE = "workspace"');
// (e) verify → exit 0 (keyless PGLite; no repo/hooks for the codex path).
const { result: verifyCode, out: verifyOut } = await captureStdout(() =>
runBootstrap(['verify', '--workspace', ws, '--json']),
);
expect(verifyCode).toBe(0);
const payload = JSON.parse(verifyOut) as { ok: boolean; checks: Array<{ id: string; ok: boolean }> };
expect(payload.ok).toBe(true);
const roundtrip = payload.checks.find((c) => c.id === 'roundtrip');
expect(roundtrip?.ok).toBe(true);
} finally {
if (savedHome === undefined) delete process.env.GBRAIN_HOME;
else process.env.GBRAIN_HOME = savedHome;
for (const d of [gbHome, ws, codexHost, shimDir]) {
try { rmSync(d, { recursive: true, force: true }); } catch { /* best-effort */ }
}
}
}, 300_000);
// ── 2. SMOKE ────────────────────────────────────────────────────────────────
test('SMOKE: real `codex exec` → gbrain MCP → brain → seeded fact', async () => {
const home = mkdtempSync(join(tmpdir(), 'gb-rc-smoke-'));
try {
// `codex exec` refuses an untrusted cwd ("Not inside a trusted
// directory"); a git repo satisfies the check. The turn runs with
// cwd=home, so make home a repo (never committed to).
execFileSync('git', ['init', '-q', home]);
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: home });
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: home });
// Seed a keyless brain the spawned `gbrain serve` will read.
const sourceId = 'workspace';
const seeded = await seedBrainForAgent(home, sourceId);
// Hermetic ~/.codex (auth + model settings), then register gbrain as a
// stdio MCP server pinned to the seeded brain via the REAL `codex mcp add`
// (exact shape from docs/mcp/CODEX.md + registerCodexMcp). Prefer a
// compiled binary so MCP startup is fast enough that codex's tool-call
// window doesn't close before the server is ready (the "the available
// gbrain mcp calls were cancelled" flake); fall back to `bun run
// src/cli.ts serve` otherwise.
seedCodexHome(home);
const runner = makeCodexRunner(home);
const server = resolveGbrainServerCommand(REPO_ROOT, ['--surface', 'full']);
const add = await runner([
'codex', 'mcp', 'add', 'gbrain',
'--env', `GBRAIN_HOME=${home}`,
'--env', `GBRAIN_SOURCE=${sourceId}`,
'--', server.command, ...server.args,
]);
expect(add.code).toBe(0);
// The prompt names the shell fallback explicitly: if the MCP tool is
// unavailable, run `gbrain query` — which resolves to $HOME/.gbrain
// (== the seeded brain, since HOME is the temp dir).
const prompt =
`You have a gbrain MCP tool connected to a knowledge brain. ` +
`Using ONLY that brain (do not guess, do not use general knowledge), answer: ` +
`${seeded.query} Report exactly what the brain says. ` +
`If no gbrain MCP tool is available, run the shell command ` +
`\`bun run ${CLI} query "${seeded.query}"\` and answer from its output.`;
// Bounded retry (max 2) rides out a transient MCP-startup cancellation.
// PASS only when an attempt lands BOTH a gbrain tool/command AND the
// seeded fact; never pass on zero tool calls, never soften.
const fact = 'rivermouth';
const perAttemptTimeout = server.kind === 'compiled' ? 190_000 : 230_000;
const maxAttempts = 2;
let passed = false;
let lastEvidence = '';
for (let attempt = 1; attempt <= maxAttempts && !passed; attempt++) {
if (attempt > 1) await new Promise((r) => setTimeout(r, 3_000));
const turn = await codexExecTurn({ prompt, cwd: home, home, timeoutMs: perAttemptTimeout });
const raw = turn.rawLines.join('\n');
// The harness JSONL parser only captures command_execution/agent_message/
// reasoning — a Codex MCP tool call is a distinct `mcp_tool_call` item, so
// detect it on the raw stream. server:"gbrain" proves the call hit OUR
// registered brain server (not general knowledge).
const usedMcp = /"type"\s*:\s*"mcp_tool_call"[^\n]*"server"\s*:\s*"gbrain"/.test(raw);
// Shell fallback: codex ran our `gbrain query` command (surfaced in the
// parsed command_execution toolCalls). Either path is a valid proof of
// real codex → real gbrain → brain.
const usedShell = turn.toolCalls.some((c) => /gbrain|cli\.ts\s+query|\bquery\b/i.test(c));
const usedGbrain = usedMcp || usedShell;
const gotFact = turn.finalText.toLowerCase().includes(fact);
lastEvidence =
`[smoke codex attempt ${attempt}/${maxAttempts}] server=${server.kind} ` +
`exit=${turn.exitCode} timedOut=${turn.timedOut} usedMcp=${usedMcp} usedShell=${usedShell} gotFact=${gotFact}\n` +
`toolCalls=${JSON.stringify(turn.toolCalls)}\n` +
`finalText=${turn.finalText.slice(0, 800)}`;
console.log(lastEvidence);
if (usedGbrain && gotFact) passed = true;
}
expect(passed, `SMOKE failed on all ${maxAttempts} attempts.\n${lastEvidence}`).toBe(true);
} finally {
try { rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ }
}
}, 480_000);
});
+743
View File
@@ -0,0 +1,743 @@
/**
* Shared harness for the REAL-agent "door" E2E tests — the ones that drive the
* ACTUAL `claude` and `codex` binaries against a live gbrain brain (over MCP
* for Claude Code, over CLI for Codex). No PATH shims, no SDK: the door tests
* spawn the operator's installed binaries and pay real API cost, so every entry
* point here is written to fail-SKIP (never fail-HARD) on a machine that lacks
* a binary or its auth.
*
* Adapted from gstack's test/helpers/{hermetic-env,session-runner,
* codex-session-runner,claude-pty-runner}.ts. What changed for gbrain:
*
* - Hermeticity is UNCONDITIONAL here (no EVALS_HERMETIC escape hatch). A
* door test must NEVER see the operator's real ~/.claude, ~/.gbrain, or
* ~/.codex. Every spawn gets a scrubbed env pointed at temp HOME /
* CLAUDE_CONFIG_DIR / CODEX_HOME / GBRAIN_HOME the test owns.
* - promotedEnv is inlined (no dependency on gstack's conductor-env-shim):
* GSTACK_ANTHROPIC_API_KEY is promoted to ANTHROPIC_API_KEY when the
* canonical key is unset, so a Conductor workspace (which only exports the
* GSTACK_ form) still authenticates the child.
* - The stream parsers (parseClaudeStream / parseCodexJsonl) are pure and
* exported so the companion unit test exercises extraction with ZERO real
* binaries.
* - seedBrainForAgent + writeGbrainMcpConfig wire a real keyless PGLite brain
* to a spawned `gbrain serve` so a Claude Code door test can assert recall.
*
* The drop-list is the security contract: CONDUCTOR_* / CLAUDE_* / GSTACK_* /
* MCP_* / GBRAIN_* never reach a child except via the explicit overrides the
* caller passes (which spread LAST and always win).
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
import { operations, type OperationContext, type Operation } from '../../src/core/operations.ts';
import { saveConfig, gbrainPath, type GBrainConfig } from '../../src/core/config.ts';
import { addSource } from '../../src/core/sources-ops.ts';
// ────────────────────────────────────────────────────────────────────────────
// 1. Hermetic child environment
// ────────────────────────────────────────────────────────────────────────────
/** Exact env names a hermetic child keeps. Everything else (unless matched by
* a prefix rule or the caller's extraAllow) is dropped. */
const ALLOW_EXACT = new Set<string>([
// Process basics
'PATH', 'HOME', 'TMPDIR', 'TERM', 'COLORTERM', 'LANG', 'LC_ALL', 'SHELL',
'USER', 'LOGNAME', 'TZ', 'NODE_ENV', 'CI',
// Network reachability — proxied networks can't reach the Anthropic API
// without these.
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY',
'http_proxy', 'https_proxy', 'no_proxy',
'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS',
// Auth — named, NOT the broad ANTHROPIC_* prefix (a prefix rule would smuggle
// model/beta/debug knobs that change agent behavior).
'ANTHROPIC_API_KEY',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_AUTH_TOKEN',
]);
/** Prefix rules: eval-harness knobs + CI metadata. Deliberately NOT here:
* CONDUCTOR_* / CLAUDE_* / GSTACK_* / MCP_* / GBRAIN_* (session-context
* contamination) and operator credentials (GH_TOKEN, OPENAI_API_KEY, …). A
* provider runner re-admits its own auth via opts.extraAllow. */
const ALLOW_PREFIXES = ['EVALS_', 'GITHUB_'];
export interface HermeticEnvOpts {
/** Additional allowed names (exact) or prefixes (entries ending in '*').
* Example: the codex runner passes ['OPENAI_API_KEY', 'CODEX_*']. */
extraAllow?: string[];
}
/**
* Pure form of the GSTACK_ → canonical promotion. Returns a copy of `base`
* with ANTHROPIC_API_KEY / OPENAI_API_KEY filled from their GSTACK_-prefixed
* form when the canonical is empty. Never mutates `base`.
*/
export function promotedEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const out: NodeJS.ProcessEnv = { ...base };
for (const key of ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY'] as const) {
if (!out[key] && out[`GSTACK_${key}`]) out[key] = out[`GSTACK_${key}`];
}
return out;
}
/**
* Build a scrubbed child env: promote GSTACK_ keys, keep only the allowlisted
* names (+ caller's extraAllow), then spread the caller's overrides LAST so a
* per-test HOME / CLAUDE_CONFIG_DIR / CODEX_HOME / GBRAIN_HOME always wins.
* Reads process.env at CALL time.
*/
export function hermeticChildEnv(
overrides: Record<string, string | undefined> = {},
opts?: HermeticEnvOpts,
): NodeJS.ProcessEnv {
const promoted = promotedEnv(process.env);
const extraExact = new Set<string>();
const extraPrefixes: string[] = [];
for (const entry of opts?.extraAllow ?? []) {
if (entry.endsWith('*')) extraPrefixes.push(entry.slice(0, -1));
else extraExact.add(entry);
}
const out: NodeJS.ProcessEnv = {};
for (const [k, v] of Object.entries(promoted)) {
if (v === undefined) continue;
const allowed =
ALLOW_EXACT.has(k) ||
extraExact.has(k) ||
ALLOW_PREFIXES.some((p) => k.startsWith(p)) ||
extraPrefixes.some((p) => k.startsWith(p));
if (allowed) out[k] = v;
}
if (!out.TERM) out.TERM = 'xterm-256color';
for (const [k, v] of Object.entries(overrides)) {
if (v !== undefined) out[k] = v;
}
return out;
}
// ────────────────────────────────────────────────────────────────────────────
// 2. Binary resolution
// ────────────────────────────────────────────────────────────────────────────
function whichBin(name: string): string | null {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const found = (Bun as any).which?.(name);
return found || null;
} catch {
return null;
}
}
function firstExecutable(candidates: string[]): string | null {
for (const c of candidates) {
if (!c) continue;
try {
fs.accessSync(c, fs.constants.X_OK);
return c;
} catch {
/* keep searching */
}
}
return null;
}
/** Locate the real `claude` binary. Bun.which first, then known install dirs. */
export function resolveClaudeBinary(): string | null {
const which = whichBin('claude');
if (which) return which;
const home = process.env.HOME ?? os.homedir();
return firstExecutable([
'/opt/homebrew/bin/claude',
'/usr/local/bin/claude',
`${home}/.local/bin/claude`,
`${home}/.bun/bin/claude`,
`${home}/.npm-global/bin/claude`,
]);
}
/** Locate the real `codex` binary. Bun.which first, then known install dirs
* (adds ~/.nvm + common node bin dirs where the npm global lands). */
export function resolveCodexBinary(): string | null {
const which = whichBin('codex');
if (which) return which;
const home = process.env.HOME ?? os.homedir();
const candidates = [
'/opt/homebrew/bin/codex',
'/usr/local/bin/codex',
`${home}/.local/bin/codex`,
`${home}/.bun/bin/codex`,
`${home}/.npm-global/bin/codex`,
`${home}/.cargo/bin/codex`,
];
// ~/.nvm/versions/node/*/bin/codex and any dir already on PATH.
try {
const nvmBase = path.join(home, '.nvm', 'versions', 'node');
for (const v of fs.readdirSync(nvmBase)) {
candidates.push(path.join(nvmBase, v, 'bin', 'codex'));
}
} catch {
/* no nvm */
}
for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
if (dir) candidates.push(path.join(dir, 'codex'));
}
return firstExecutable(candidates);
}
// ────────────────────────────────────────────────────────────────────────────
// 3. Auth probes (drive skipIf in the door tests)
// ────────────────────────────────────────────────────────────────────────────
/** Claude Code is usable if an Anthropic key is exported (either form) OR the
* operator has a real ~/.claude.json (subscription auth). */
export function hasClaudeAuth(): boolean {
if (process.env.ANTHROPIC_API_KEY || process.env.GSTACK_ANTHROPIC_API_KEY) return true;
try {
return fs.existsSync(path.join(os.homedir(), '.claude.json'));
} catch {
return false;
}
}
/** Codex is usable if the operator has a real ~/.codex/auth.json. */
export function hasCodexAuth(): boolean {
try {
return fs.existsSync(path.join(os.homedir(), '.codex', 'auth.json'));
} catch {
return false;
}
}
// ────────────────────────────────────────────────────────────────────────────
// 4. Stream parsers (pure — exercised by the unit test with fixtures)
// ────────────────────────────────────────────────────────────────────────────
export interface ParsedClaudeStream {
/** The final assistant/result text of the turn. */
finalText: string;
/** Names of every tool_use block the assistant emitted, in order. */
toolCalls: string[];
}
/**
* Parse `claude -p --output-format stream-json` NDJSON. Collects tool_use
* names from assistant events and the final answer text. Prefers the terminal
* `result` event's `result` field; falls back to the concatenation of the last
* assistant message's text blocks. Skips malformed lines.
*/
export function parseClaudeStream(lines: string[]): ParsedClaudeStream {
const toolCalls: string[] = [];
let resultText: string | null = null;
let lastAssistantText = '';
for (const line of lines) {
if (!line.trim()) continue;
let event: any;
try {
event = JSON.parse(line);
} catch {
continue;
}
if (event.type === 'assistant') {
const content = event.message?.content ?? [];
const textParts: string[] = [];
for (const item of content) {
if (item?.type === 'tool_use') toolCalls.push(item.name || 'unknown');
else if (item?.type === 'text' && typeof item.text === 'string') textParts.push(item.text);
}
if (textParts.length > 0) lastAssistantText = textParts.join('');
} else if (event.type === 'result') {
if (typeof event.result === 'string') resultText = event.result;
}
}
return { finalText: (resultText ?? lastAssistantText) || '', toolCalls };
}
export interface ParsedCodexJsonl {
/** Concatenated agent_message text. */
finalText: string;
/** command_execution commands, in order. */
toolCalls: string[];
/** reasoning item text blocks, in order. */
reasoning: string[];
}
/**
* Parse `codex exec --json` JSONL. Extracts agent_message → finalText,
* command_execution → toolCalls, reasoning → reasoning. Skips malformed lines.
*/
export function parseCodexJsonl(lines: string[]): ParsedCodexJsonl {
const outputParts: string[] = [];
const toolCalls: string[] = [];
const reasoning: string[] = [];
for (const line of lines) {
if (!line.trim()) continue;
let obj: any;
try {
obj = JSON.parse(line);
} catch {
continue;
}
if (obj.type === 'item.completed' && obj.item) {
const item = obj.item;
const text = item.text || '';
if (item.type === 'reasoning' && text) reasoning.push(text);
else if (item.type === 'agent_message' && text) outputParts.push(text);
else if (item.type === 'command_execution' && item.command) toolCalls.push(item.command);
}
}
return { finalText: outputParts.join('\n'), toolCalls, reasoning };
}
// ────────────────────────────────────────────────────────────────────────────
// 5. Real-binary turns
// ────────────────────────────────────────────────────────────────────────────
/** Read a piped stream to a string of NDJSON/JSONL lines, calling onLine for
* each complete line. Returns all collected lines. */
async function streamLines(
stream: ReadableStream<Uint8Array>,
collected: string[],
): Promise<void> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buf = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split('\n');
buf = parts.pop() || '';
for (const line of parts) {
if (line.trim()) collected.push(line);
}
}
} catch {
/* stream cancelled (timeout) or read error — fall through */
}
if (buf.trim()) collected.push(buf);
}
export interface ClaudeTurnOpts {
prompt: string;
cwd: string;
home: string;
claudeConfigDir: string;
mcpConfigPath?: string;
model?: string;
timeoutMs?: number;
}
export interface ClaudeTurnResult {
finalText: string;
toolCalls: string[];
rawLines: string[];
exitCode: number | null;
timedOut: boolean;
}
/**
* Drive one headless `claude -p` turn against a hermetic HOME/config dir.
* Optionally wires an MCP config (with --strict-mcp-config so ONLY that server
* is loaded — no operator MCP contamination). Prompt is piped via stdin.
*/
export async function claudeHeadlessTurn(opts: ClaudeTurnOpts): Promise<ClaudeTurnResult> {
const model = opts.model ?? 'claude-sonnet-4-6';
const timeoutMs = opts.timeoutMs ?? 180_000;
const args = [
'-p',
'--model', model,
'--output-format', 'stream-json',
'--verbose',
'--dangerously-skip-permissions',
...(opts.mcpConfigPath ? ['--mcp-config', opts.mcpConfigPath, '--strict-mcp-config'] : []),
];
const proc = Bun.spawn(['claude', ...args], {
cwd: opts.cwd,
env: hermeticChildEnv({ HOME: opts.home, CLAUDE_CONFIG_DIR: opts.claudeConfigDir }),
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
});
// Write the prompt then close stdin.
proc.stdin.write(opts.prompt);
await proc.stdin.end();
let timedOut = false;
const rawLines: string[] = [];
const stdoutDone = streamLines(proc.stdout, rawLines);
const stderrDone = new Response(proc.stderr).text();
const timer = setTimeout(() => {
timedOut = true;
try { proc.kill(); } catch { /* already dead */ }
}, timeoutMs);
await stdoutDone;
await stderrDone.catch(() => '');
const exitCode = await proc.exited;
clearTimeout(timer);
const parsed = parseClaudeStream(rawLines);
return { finalText: parsed.finalText, toolCalls: parsed.toolCalls, rawLines, exitCode, timedOut };
}
export interface CodexTurnOpts {
prompt: string;
cwd: string;
home: string;
timeoutMs?: number;
sandbox?: string;
}
export interface CodexTurnResult {
finalText: string;
toolCalls: string[];
reasoning: string[];
rawLines: string[];
exitCode: number | null;
timedOut: boolean;
}
/**
* Drive one `codex exec` turn against a hermetic HOME. Copies the operator's
* real ~/.codex/* (except skills/) into <home>/.codex so codex authenticates
* without touching the real config dir. Parses JSONL output.
*/
export async function codexExecTurn(opts: CodexTurnOpts): Promise<CodexTurnResult> {
const timeoutMs = opts.timeoutMs ?? 240_000;
const sandbox = opts.sandbox ?? 'workspace-write';
// Seed the temp HOME's .codex from the operator's real auth (read-only copy,
// skipping skills/ so a downstream test can install its own).
const realCodex = path.join(os.homedir(), '.codex');
const tempCodex = path.join(opts.home, '.codex');
fs.mkdirSync(tempCodex, { recursive: true });
if (fs.existsSync(realCodex)) {
for (const entry of fs.readdirSync(realCodex)) {
if (entry === 'skills') continue;
const src = path.join(realCodex, entry);
const dst = path.join(tempCodex, entry);
if (!fs.existsSync(dst)) {
try { fs.cpSync(src, dst, { recursive: true }); } catch { /* best-effort */ }
}
}
}
const proc = Bun.spawn(['codex', 'exec', opts.prompt, '--json', '-s', sandbox], {
cwd: opts.cwd,
env: hermeticChildEnv({ HOME: opts.home }, { extraAllow: ['OPENAI_API_KEY', 'CODEX_*'] }),
stdout: 'pipe',
stderr: 'pipe',
});
let timedOut = false;
const rawLines: string[] = [];
const stdoutDone = streamLines(proc.stdout, rawLines);
const stderrDone = new Response(proc.stderr).text();
const timer = setTimeout(() => {
timedOut = true;
try { proc.kill(); } catch { /* already dead */ }
}, timeoutMs);
await stdoutDone;
await stderrDone.catch(() => '');
const exitCode = await proc.exited;
clearTimeout(timer);
const parsed = parseCodexJsonl(rawLines);
return {
finalText: parsed.finalText,
toolCalls: parsed.toolCalls,
reasoning: parsed.reasoning,
rawLines,
exitCode: timedOut ? 124 : exitCode,
timedOut,
};
}
// ────────────────────────────────────────────────────────────────────────────
// 5b. Fast gbrain MCP server command (compiled binary, cached; bun-run fallback)
// ────────────────────────────────────────────────────────────────────────────
/**
* A resolved launch spec for the gbrain MCP stdio server. The door tests
* register THIS as their MCP server so the child agent's first tool call finds
* a server that is already up. A compiled binary starts fast; `bun run
* src/cli.ts serve` cold-transpiles the whole CLI on every spawn and can miss
* an agent's tool-call window (the source of the codex "the available gbrain
* mcp calls were cancelled" flake). `kind` lets the caller widen the turn
* timeout when we fell back to the slow path.
*/
export interface GbrainServerCommand {
command: string;
/** Full arg vector INCLUDING the `serve` subcommand + any extra flags. */
args: string[];
kind: 'compiled' | 'bun-run';
}
// Module-level cache: compile the binary at most once per test process.
let _compiledBin: string | null = null;
let _compileTried = false;
let _compileReason = '';
let _compileBuildDir: string | null = null;
/**
* Verify a freshly-built `gbrain` binary can actually stand up a PGLite brain.
* `bun build --compile` bundles the code into a read-only vfs (`/$bunfs/root`)
* but does NOT embed PGLite's WASM runtime + extension tarballs (`pglite.data`,
* `vector.tar.gz`, `pg_trgm.tar.gz`), so a compiled `gbrain init --pglite`
* fails at `initSchema()` with an ENOENT on those assets (Bun vfs #1340). A
* compiled binary that can't open PGLite is useless as our door-test MCP server
* (the brain is keyless PGLite), so we probe before trusting it. `init` is the
* cheapest exercise of the WASM path that terminates on its own (unlike
* `serve`, which either waits on stdin or bails early on a brain-less home).
*/
function probeCompiledPglite(binPath: string): { ok: boolean; reason: string } {
const probeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gb-agent-probe-'));
try {
const res = spawnSync(binPath, ['init', '--pglite', '--no-embedding', '--non-interactive'], {
env: { ...process.env, HOME: probeHome, GBRAIN_HOME: probeHome, GBRAIN_SKIP_STARTUP_HOOKS: '1' },
encoding: 'utf8',
timeout: 60_000,
stdio: ['ignore', 'pipe', 'pipe'],
});
const stderr = `${res.stdout ?? ''}\n${res.stderr ?? ''}`;
const wasmBroken = /PGLite failed to initialize|Extension bundle not found|\$bunfs|pglite\.data/.test(stderr);
if (res.error) return { ok: false, reason: `compiled \`gbrain init --pglite\` probe error: ${res.error.message}` };
if (wasmBroken || res.status !== 0) {
const lines = stderr.split('\n').map((l) => l.trim()).filter(Boolean);
const markerLine =
lines.find((l) => /PGLite failed to initialize|Extension bundle not found|\$bunfs|pglite\.data/.test(l)) ??
lines[0] ??
`exit ${res.status}`;
return {
ok: false,
reason:
`compiled \`gbrain\` cannot open a PGLite brain — \`bun build --compile\` does not embed ` +
`PGLite's WASM/extension payload (Bun vfs #1340): ${markerLine.slice(0, 160)}`,
};
}
return { ok: true, reason: '' };
} catch (e) {
return { ok: false, reason: `compiled PGLite probe threw: ${(e as Error).message}` };
} finally {
try { fs.rmSync(probeHome, { recursive: true, force: true }); } catch { /* best-effort */ }
}
}
/**
* Build a standalone `gbrain` binary once via `bun build --compile` into a
* cached temp path AND verify it can serve a PGLite brain. Returns the path (or
* null + a reason) — null covers both "compile unavailable in this sandbox" and
* "compile succeeds but the binary can't open PGLite" (#1340). Mirrors
* test/e2e/bootstrap-compiled-binary.serial.test.ts. Fail-soft: every error is
* captured as a reason string, never thrown, so any failure degrades to the
* bun-run fallback instead of hard-failing the door suite.
*/
export function ensureCompiledGbrain(repoRoot: string): { binPath: string | null; reason: string } {
if (_compileTried) return { binPath: _compiledBin, reason: _compileReason };
_compileTried = true;
try {
const buildDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gb-agent-bin-'));
_compileBuildDir = buildDir;
const binPath = path.join(buildDir, 'gbrain');
const res = spawnSync('bun', ['build', '--compile', '--outfile', binPath, 'src/cli.ts'], {
cwd: repoRoot,
encoding: 'utf8',
timeout: 240_000,
maxBuffer: 64 * 1024 * 1024,
});
if (res.error) {
_compileReason = `bun build --compile unavailable: ${res.error.message}`;
} else if (res.status !== 0) {
_compileReason = `bun build --compile exited ${res.status}: ${(res.stderr ?? '').slice(-1000)}`;
} else if (!fs.existsSync(binPath)) {
_compileReason = 'bun build --compile exited 0 but produced no binary';
} else {
const probe = probeCompiledPglite(binPath);
if (probe.ok) _compiledBin = binPath;
else _compileReason = probe.reason;
}
} catch (e) {
_compileReason = `bun build --compile threw: ${(e as Error).message}`;
}
return { binPath: _compiledBin, reason: _compileReason };
}
// Best-effort cleanup of the compiled-binary temp dir at process exit.
process.on('exit', () => {
if (_compileBuildDir) {
try { fs.rmSync(_compileBuildDir, { recursive: true, force: true }); } catch { /* best-effort */ }
}
});
/**
* Resolve the gbrain MCP server launch spec: a compiled binary
* (`{command:<bin>, args:['serve', ...extra]}`) when `bun build --compile`
* both succeeds AND can open a PGLite brain, else the `bun run src/cli.ts
* serve` fallback. `extraArgs` are appended after `serve` (e.g.
* `['--surface','full']` for the codex door). The env
* (GBRAIN_HOME/GBRAIN_SOURCE) is layered on by the caller and stays identical
* across both kinds.
*
* NOTE: with the current `bun build --compile` command the compiled binary
* canNOT serve a PGLite brain (its WASM/extension payload isn't embedded —
* Bun vfs #1340), so this resolves to `bun-run` in practice today. The
* bun-run server is measured ready to answer `tools/list` in ~300ms, so the
* door tests lean on the bounded SMOKE retry (not server speed) for
* robustness. The compiled path stays wired so this self-heals the day the
* build embeds those assets.
*/
export function resolveGbrainServerCommand(repoRoot: string, extraArgs: string[] = []): GbrainServerCommand {
const { binPath, reason } = ensureCompiledGbrain(repoRoot);
if (binPath) {
return { command: binPath, args: ['serve', ...extraArgs], kind: 'compiled' };
}
console.error(
`[agent-harness] compiled gbrain unavailable, falling back to \`bun run src/cli.ts\` ` +
`(slower MCP startup, wider readiness window): ${reason}`,
);
return {
command: 'bun',
args: ['run', path.join(repoRoot, 'src', 'cli.ts'), 'serve', ...extraArgs],
kind: 'bun-run',
};
}
// ────────────────────────────────────────────────────────────────────────────
// 6. MCP config for a Claude Code door test
// ────────────────────────────────────────────────────────────────────────────
export interface GbrainMcpConfigOpts {
path: string;
/** Resolved server launch spec (compiled bin or bun-run fallback). */
server: { command: string; args: string[] };
gbrainHome: string;
sourceId: string;
}
/**
* Write a Claude Code `--mcp-config` JSON that runs THIS repo's gbrain over
* stdio, pinned to a hermetic GBRAIN_HOME + source. The server command comes
* from `resolveGbrainServerCommand` (compiled binary preferred, `bun run
* src/cli.ts serve` fallback) so startup is fast enough that the child agent's
* first tool call doesn't get cancelled. Mirrors `claude mcp add gbrain --
* gbrain serve` from docs/mcp/CLAUDE_CODE.md.
*/
export function writeGbrainMcpConfig(opts: GbrainMcpConfigOpts): void {
const cfg = {
mcpServers: {
gbrain: {
command: opts.server.command,
args: opts.server.args,
env: {
GBRAIN_HOME: opts.gbrainHome,
GBRAIN_SOURCE: opts.sourceId,
},
},
},
};
fs.mkdirSync(path.dirname(opts.path), { recursive: true });
fs.writeFileSync(opts.path, JSON.stringify(cfg, null, 2));
}
// ────────────────────────────────────────────────────────────────────────────
// 7. Seed a real keyless PGLite brain the spawned `gbrain serve` will read
// ────────────────────────────────────────────────────────────────────────────
const put_page = operations.find((o) => o.name === 'put_page') as Operation | undefined;
export interface SeededBrain {
fact: string;
entity: string;
query: string;
}
/**
* Initialize a keyless PGLite brain at GBRAIN_HOME=<home> and seed ONE page
* with a distinctive, 100%-synthetic fact so a door test can assert the agent
* recalls it over MCP. Persistent on disk (so the spawned `gbrain serve`
* subprocess reads the same brain), keyless (embedding_disabled) so it runs
* with no API key, skills published so the verbs surface is available.
*
* Temporarily pins process.env.GBRAIN_HOME while creating the brain, then
* restores it — the door test sets GBRAIN_HOME on the spawned child via the
* MCP config's env block, not on this process.
*/
export async function seedBrainForAgent(home: string, sourceId: string): Promise<SeededBrain> {
if (!put_page) throw new Error('seedBrainForAgent: put_page op not registered');
const entity = 'Summit Robotics';
const fact = 'Summit Robotics runs the Rivermouth fulfillment center.';
const query = 'Where does Summit Robotics run its fulfillment center?';
const savedHome = process.env.GBRAIN_HOME;
process.env.GBRAIN_HOME = home;
try {
const dbPath = gbrainPath('brain.pglite'); // <home>/.gbrain/brain.pglite
const engine = new PGLiteEngine();
await engine.connect({ database_path: dbPath, engine: 'pglite' });
await engine.initSchema();
try {
// Register a non-default source so GBRAIN_SOURCE routing has a real row.
if (sourceId !== 'default') {
const srcDir = path.join(home, `source-${sourceId}`);
fs.mkdirSync(srcDir, { recursive: true });
try {
await addSource(engine, { id: sourceId, localPath: srcDir, force: true });
} catch {
/* already registered — fine */
}
}
const ctx: OperationContext = {
engine,
config: { engine: 'pglite' } as never,
logger: { info: () => {}, warn: () => {}, error: () => {} },
dryRun: false,
remote: false,
sourceId,
};
await put_page.handler(ctx, {
slug: 'companies/summit-robotics',
content: `# Summit Robotics\n\n${fact}\n`,
});
} finally {
// Release the PGLite lock so the spawned `gbrain serve` can open the
// same data dir.
try { await engine.disconnect(); } catch { /* best-effort */ }
}
// Persist a keyless config so the spawned `gbrain serve` reads the same
// engine + path and doesn't try to reach an embedding provider.
const config: GBrainConfig = {
engine: 'pglite',
database_path: dbPath,
embedding_disabled: true,
mcp: { publish_skills: true },
} as unknown as GBrainConfig;
saveConfig(config);
} finally {
if (savedHome === undefined) delete process.env.GBRAIN_HOME;
else process.env.GBRAIN_HOME = savedHome;
}
return { fact, entity, query };
}
+176
View File
@@ -0,0 +1,176 @@
/**
* Unit coverage for the real-agent door harness that needs NO real binary and
* pays ZERO API cost. Exercises:
* - parseClaudeStream against a captured `claude -p --output-format
* stream-json` NDJSON fixture (tool_use names + final result text).
* - parseCodexJsonl against a captured `codex exec --json` JSONL fixture
* (command_execution → toolCalls, agent_message → finalText, reasoning).
* - hermeticChildEnv: drops CONDUCTOR_* / CLAUDE_* / GSTACK_* / MCP_* /
* GBRAIN_*, promotes GSTACK_ANTHROPIC_API_KEY, honors extraAllow, and lets
* overrides win.
* - resolveClaudeBinary / resolveCodexBinary SMOKE (whatever this machine
* has — assertion is only that the result is a string-or-null, plus a note
* printed when found).
*
* The env test mutates process.env and restores it in finally so it never
* leaks into sibling tests.
*/
import { describe, test, expect } from 'bun:test';
import {
parseClaudeStream,
parseCodexJsonl,
hermeticChildEnv,
promotedEnv,
resolveClaudeBinary,
resolveCodexBinary,
} from './agent-harness.ts';
import { withEnv } from './with-env.ts';
// A captured claude stream-json turn: a system init line, an assistant text +
// tool_use turn, a tool_result user line, a second assistant text turn, and the
// terminal result line. Trailing blank + one malformed line prove the parser
// tolerates both.
const CLAUDE_NDJSON = [
'{"type":"system","subtype":"init","session_id":"abc","tools":["mcp__gbrain__recall"]}',
'{"type":"assistant","message":{"content":[{"type":"text","text":"Let me search the brain."},{"type":"tool_use","id":"tu_1","name":"mcp__gbrain__recall","input":{"query":"fulfillment center"}}]}}',
'{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tu_1","content":"Summit Robotics runs the Rivermouth fulfillment center."}]}}',
'{"type":"assistant","message":{"content":[{"type":"text","text":"Summit Robotics runs the Rivermouth fulfillment center."}]}}',
'',
'this is not json and must be skipped',
'{"type":"result","subtype":"success","is_error":false,"result":"Summit Robotics runs the Rivermouth fulfillment center.","num_turns":2,"total_cost_usd":0.01}',
];
// A captured codex exec --json turn: thread.started, a reasoning item, a
// command_execution item, an agent_message item, turn.completed, plus a blank
// and a malformed line.
const CODEX_JSONL = [
'{"type":"thread.started","thread_id":"th_123"}',
'{"type":"item.completed","item":{"type":"reasoning","text":"I should read the file to answer."}}',
'{"type":"item.completed","item":{"type":"command_execution","command":"cat facts.md","exit_code":0}}',
'{"type":"item.completed","item":{"type":"agent_message","text":"The fulfillment center is Rivermouth."}}',
'',
'{oops not json',
'{"type":"turn.completed","usage":{"input_tokens":100,"output_tokens":20}}',
];
describe('parseClaudeStream', () => {
test('extracts tool_use names and the final result text', () => {
const parsed = parseClaudeStream(CLAUDE_NDJSON);
expect(parsed.toolCalls).toEqual(['mcp__gbrain__recall']);
expect(parsed.finalText).toBe('Summit Robotics runs the Rivermouth fulfillment center.');
});
test('falls back to last assistant text when no result line', () => {
const noResult = CLAUDE_NDJSON.filter((l) => !l.includes('"type":"result"'));
const parsed = parseClaudeStream(noResult);
expect(parsed.finalText).toBe('Summit Robotics runs the Rivermouth fulfillment center.');
expect(parsed.toolCalls).toEqual(['mcp__gbrain__recall']);
});
test('empty input yields empty result, never throws', () => {
expect(parseClaudeStream([])).toEqual({ finalText: '', toolCalls: [] });
});
});
describe('parseCodexJsonl', () => {
test('extracts command executions, agent message, and reasoning', () => {
const parsed = parseCodexJsonl(CODEX_JSONL);
expect(parsed.toolCalls).toEqual(['cat facts.md']);
expect(parsed.finalText).toBe('The fulfillment center is Rivermouth.');
expect(parsed.reasoning).toEqual(['I should read the file to answer.']);
});
test('empty input yields empty result, never throws', () => {
expect(parseCodexJsonl([])).toEqual({ finalText: '', toolCalls: [], reasoning: [] });
});
});
describe('promotedEnv', () => {
test('promotes GSTACK_ANTHROPIC_API_KEY when canonical is unset', () => {
const out = promotedEnv({ GSTACK_ANTHROPIC_API_KEY: 'sk-gstack' } as NodeJS.ProcessEnv);
expect(out.ANTHROPIC_API_KEY).toBe('sk-gstack');
});
test('does NOT clobber an existing canonical key', () => {
const out = promotedEnv({
ANTHROPIC_API_KEY: 'sk-real',
GSTACK_ANTHROPIC_API_KEY: 'sk-gstack',
} as NodeJS.ProcessEnv);
expect(out.ANTHROPIC_API_KEY).toBe('sk-real');
});
});
describe('hermeticChildEnv', () => {
test('drops CONDUCTOR_*/CLAUDE_*/GSTACK_*/MCP_*/GBRAIN_*, keeps PATH/HOME, promotes GSTACK key', async () => {
// Contaminate the process env with everything a real Conductor + Claude
// Code session would carry (ANTHROPIC_API_KEY unset so promotion shows).
await withEnv(
{
CONDUCTOR_WORKSPACE_PATH: '/should/drop',
CLAUDE_CODE_ENTRYPOINT: 'cli',
CLAUDECODE: '1',
MCP_SERVER: 'gbrain',
GBRAIN_HOME: '/operator/.gbrain',
GSTACK_HOME: '/operator/.gstack',
ANTHROPIC_API_KEY: undefined,
GSTACK_ANTHROPIC_API_KEY: 'sk-promote-me',
},
() => {
const env = hermeticChildEnv({ HOME: '/tmp/hermetic-home', GBRAIN_HOME: '/tmp/hermetic-gbrain' });
// Dropped.
expect(env.CONDUCTOR_WORKSPACE_PATH).toBeUndefined();
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
expect(env.CLAUDECODE).toBeUndefined();
expect(env.MCP_SERVER).toBeUndefined();
expect(env.GSTACK_HOME).toBeUndefined();
// GSTACK_ANTHROPIC_API_KEY itself is dropped (prefix), but its value was
// promoted onto the allowlisted canonical name.
expect(env.GSTACK_ANTHROPIC_API_KEY).toBeUndefined();
expect(env.ANTHROPIC_API_KEY).toBe('sk-promote-me');
// Kept.
expect(env.PATH).toBe(process.env.PATH);
// Overrides win — HOME is the temp one, and a GBRAIN_HOME override is
// honored even though the bare GBRAIN_ prefix is dropped.
expect(env.HOME).toBe('/tmp/hermetic-home');
expect(env.GBRAIN_HOME).toBe('/tmp/hermetic-gbrain');
},
);
});
test('extraAllow admits exact names and PREFIX_* forms (codex auth surface)', async () => {
await withEnv(
{
OPENAI_API_KEY: 'sk-openai',
CODEX_HOME: '/operator/.codex',
CODEX_SANDBOX: 'workspace-write',
},
() => {
const env = hermeticChildEnv({}, { extraAllow: ['OPENAI_API_KEY', 'CODEX_*'] });
expect(env.OPENAI_API_KEY).toBe('sk-openai');
expect(env.CODEX_HOME).toBe('/operator/.codex');
expect(env.CODEX_SANDBOX).toBe('workspace-write');
// Without extraAllow, the same vars are dropped.
const scrubbed = hermeticChildEnv({});
expect(scrubbed.OPENAI_API_KEY).toBeUndefined();
expect(scrubbed.CODEX_HOME).toBeUndefined();
},
);
});
});
describe('binary resolution SMOKE', () => {
test('resolveClaudeBinary returns a string or null', () => {
const bin = resolveClaudeBinary();
expect(bin === null || typeof bin === 'string').toBe(true);
if (bin) console.log(`[smoke] claude resolved at: ${bin}`);
});
test('resolveCodexBinary returns a string or null', () => {
const bin = resolveCodexBinary();
expect(bin === null || typeof bin === 'string').toBe(true);
if (bin) console.log(`[smoke] codex resolved at: ${bin}`);
});
});