mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
* feat(core): execution-environment detection — local | cloud-sandbox | ephemeral-container detectExecutionEnvironment() + isCredentialInjectingProxy() with injected signals (CLAUDE_CODE_REMOTE, cse_ session-id prefix, proxy-injected token placeholder, anthropic-egress proxy JWT, container markers). binaryOnPath moves here as the canonical PATH probe. autopilot's detectInstallTarget ephemeral branch now routes through the shared detector. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bootstrap): repo-visibility ladder + per-turn Stop push + same-session failure banner The durability lane. One repo-visibility verdict for every consumer (REST first — never GraphQL, which sandbox proxies pin; authed ls-remote + an attributed anonymous probe as the git-protocol fallback), replacing three drifted probes. A 200 counts as public only with advertisement proof; a 401/404 counts as private-signal only with an auth challenge — fail-closed in BOTH directions. Private verdicts cache 1h (private-only, per origin). gbrain hook stop now spawns a debounced detached push per turn (per-root state; cloud-sandbox defaults to every turn, elsewhere 5 min; a failing status bypasses the debounce), closing the /exit and VM-reclaim gaps. Push status is per workspace root, read through one shared reader by the user-prompt banner (additionalContext + systemMessage — visible to the human, not just the model), the SessionStart note, and doctor. Escape hatches for self-hosted git: flag > env > file-plane config key. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bootstrap): environment-aware install — honest cron skip, cloud repo-create guard, execution_env surfaces installDurabilityCron probes for crontab before writing anything (containers and cloud sandboxes ship without one — expected, reported as an honest skip naming the event-driven pushes that still cover persistence). The repo phase installs the container-friendly harden half (post-commit hook, no scheduler) outside local machines. createPrivateRepo fails fast in cloud sandboxes with the flow that works (create outside, open the session ON the repo, attach). bootstrap verify gains a never-gating execution_env check; bootstrap status --json carries execution_environment for installing agents to branch on. Also fixes a live-PATH resolution class: binaryOnPath and the crontab execs now pass the current env explicitly (Bun resolves against the startup snapshot otherwise). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bootstrap): lifecycle hygiene — uninstall teardown, doctor job liveness, .mcp.json out of the repo, honest persistence copy uninstall now tears down the durability wiring it installed (launchd/cron job, untracked post-commit hook, credential wiring — the committed helper and AGENTS rules stay). doctor gains bootstrap_durability_job: presence + LIVENESS (launchctl load-state, crontab line, pull-log freshness) — a plist on disk with a dead job no longer reads as healthy. Rendered .gitignore now covers .mcp.json (absolute machine paths must not land in the private repo); verify warns on pre-fix installs that committed it; the never-built state/mcp.json promise is gone from GITHUB.md. Persistence copy tells the truth everywhere: event-driven pushes do the durability work, the 30-min job is a multi-machine pull freshener. Post-commit hook install/removal is worktree-safe (git-path resolution; the git marker is a FILE there) and the cron wrapper's self-disable tests the repo dir, not its git marker. Two follow-up TODOs filed (plugin hook distribution; Channels push lane). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bootstrap): committed hook carrier for cloud + cloud-setup-script emitter + cloud runbook Cloud sessions clone fresh and snapshot hook config at session start — the gitignored settings.local.json never exists there, so hooks never fired in cloud at all. Cloud installs now write the repo-COMMITTED .claude/settings.json with PATH-resolved, fail-open commands (no machine paths; a host without the binary no-ops); local installs keep settings.local.json; the writers enforce that one event never fires from both carriers, and removal cleans both. New: gbrain bootstrap cloud-setup-script prints the paste-ready environment setup script (npm transport — bun fetching is proxy-incompatible in cloud; never the unrelated npm-registry package). Runbook gains a NEVER FABRICATE TOOLING hard rule, a cloud-sandbox section (expected degradations as facts to relay, the attach-first flow), and failure-table rows for the proxy-403 and missing-crontab signatures. Codex lane stated honestly: AGENTS.md Gate 2 now has the pull-side push-health check. Guide documents the new knobs and the cloud contract; llms bundles regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(bootstrap): per-turn push e2e chain, cloud-sandbox sim, per-root status reader migration + registry regen bootstrap-persistence e2e gains the Stop-hook chain (one stop banks the turn to the real bare remote; the debounce holds across stops; debounce-0 lands consecutive turns) and fixes a pre-existing post-#4024 break: repoPhaseComplete required a github-parseable origin, so session-end pushes deferred FOREVER for self-hosted/file-transport origins — non-github repo_urls now bind by exact URL equality (redirect protection preserved). Degraded-modes e2e gains the cloud-sandbox simulation (status reports the environment; repo creation refuses with the attach flow). workspace-push tests read per-root status through the shared reader. Flag registry regenerated (new git argv literals from the git-path/ls-files calls — the accepted argv-bleed class). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(doctor): categorize bootstrap_durability_job (categories drift guard) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bootstrap): adversarial-review hardening — fail-closed privacy, no push-storm, no exfil paths Both Claude and Codex adversarial passes ran; 11 findings fixed (union): - Non-github 401+WWW-Authenticate no longer reads as private (RFC 7235 makes the header mandatory on every 401, so a middlebox 401s identically) — the top exfil path both models flagged; now unverifiable/fail-closed, operator confirms via the escape hatch. github.com still needs x-github-request-id. - Escape hatches downgrade ONLY 'unverifiable' — a PROVEN-public origin still refuses (hatches never authorize a public push). - treeNeedsPush measures against origin/<branch> (the push's own ref), not @{u}: a no-upstream branch no longer reports a committed-but-unpushed tree as push_clean and silently strands it. - [D20] failing-retry uses a fixed 60s floor, not min(debounce,60s) — cloud debounce=0 no longer re-runs the network ladder every turn. - Committed hook carrier: dedupe/suppress only on the EXACT portable-command shape, not a 'gbrain hook' substring (blocks the suppress-local-run-evil supply-chain vector); GBRAIN_HOME refused in the committed carrier. - push-status reason sanitized (charset+length) at every surface (banner, doctor, status blob) so remote git stderr can't inject via the remediation. - Per-root state: ghost-root records (deleted workspaces) filtered so a dead failing record can't re-fire the banner forever; uninstall removes them. - statusReport support blob reads push status through the shared per-root reader; visibility cache strips URL userinfo (no PAT persisted). - anonProbe strips userinfo + redirect:manual + SSRF flags + --end-of-options on ls-remote; cron self-disable uses git rev-parse (worktree-safe both ways); cloud-setup-script fails loud on a broken update; durability liveness won't certify a never-run crontab as live; config get/unset resolve the dotted file-plane keys; .bak/.broken gitignored; typed config fields. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * v0.45.8.0 feat(bootstrap): first-class cloud-sandbox install + per-turn persistence + fail-closed privacy ladder VERSION + package.json + CHANGELOG + TODOS + llms bundles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: sync KEY_FILES + README for the cloud-DX wave (v0.45.8.0) New KEY_FILES entries for execution-env.ts and repo-visibility.ts; updated the bootstrap repo/hooks, workspace-push, brain-repo-durability, and hook.ts entries to current behavior (ladder verification, committed cloud hook carrier, per-root push status, crontab probe + liveness, per-turn stop push + banner). README's Claude Code line now states per-turn + cloud persistence honestly. llms bundles regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bootstrap): doc-review pass — attach records repo_url (enables cloud persistence), verify reads per-root push status, honest docs Cross-model doc review (Codex) caught that the headline cloud-persistence claim was hollow and two docs over-claimed: - attach now records repo_url from the adopted origin, so the no-daemon push gate (repoPhaseComplete) recognizes the repo phase as done — WITHOUT this, the per-turn/session-end pushes deferred forever after an attach, which is the ONLY install path in a cloud sandbox (repo is refused there). Privacy is still enforced at push time by the ladder. - bootstrap verify's push_probe reads the shared per-root reader [D8], not the legacy single file — a fresh v0.45.8 install no longer reports 'no push recorded' when per-root status exists. - Docs corrected to match code: runbook stamp → 0.45.8.0; README states the per-turn cadence honestly (debounced local, next-turn failure notice); GITHUB.md qualifies auto-push as Claude Code (Codex is pull); KEY_FILES says repo/status use REST (not the full ladder) and drops the stale --push; bootstrap.md hooks-location covers both carriers; README verbs surface says seven. llms bundles regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bootstrap): regenerate vendored template-repo tree + isolate env-mutating tests (verify gate) The verify CI gate caught two things the piecemeal local runs missed: - templates/bootstrap/template-repo/ is a GENERATED tree; the source-template edits (AGENTS.md Gate 2, CLAUDE.md cloud note) plus the version stamp had drifted. Regenerated from source (check:bootstrap-templates green). - test/bootstrap-repo.test.ts + test/durability-cron.test.ts newly mutated process.env in non-serial files; converted to withEnv() (check:test-isolation green) — no .serial rename needed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(ambient-recall): pin a keyless gateway so delta/context_pack writes never fire a real embed Root cause of the shard-2 CI red after the 0.45.9.0 re-bump: adding this wave's test files reshuffled the weight-packed shards, moving ambient-recall.test.ts next to a neighbor that leaks CI's dummy OPENAI_API_KEY (sk-test-*) into the gateway singleton (the bunfig preload configures with env:{...process.env}, and a present-but-invalid key turns remember's keyless-degrade embed into a hard 401). The delta/context_pack tests exercise cursor + budget logic, not embedding quality, so this pins a keyless gateway (env:{}) in beforeAll — isAvailable('embedding') is false, writeSingleFact degrades (degraded_dedup) with no HTTP call, and the file is deterministic regardless of shard bin-packing. Verified: passes with OPENAI_API_KEY=sk-test-* set (the CI condition). Master's own comment in legacy-embedding-preload.ts already warns 'adding any test file reshuffles the mines'; this makes the file immune. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
386 lines
18 KiB
TypeScript
386 lines
18 KiB
TypeScript
/**
|
|
* attach-mode e2e [ENG-tests-2, CX-P1.5, CX2-1, CX2-12]: machine-1 fixture
|
|
* (scripted interview → confirm → render → git init + commit + local bare
|
|
* 'origin') cloned to a machine-2 dir with a SECOND sandboxed GBRAIN_HOME:
|
|
*
|
|
* - `runBootstrap(['attach'])` on the clone: exit 0, machine-2 receipt
|
|
* written with brain_created_by_bootstrap: false, ordered todo steps
|
|
* returned (register_source → hooks_repair → mcp_add → verify).
|
|
* - hooks repair on machine 2 (env-pinned fake ExecRunner): MCP argv
|
|
* recorded, settings.local.json written with the _gbrain marker entries,
|
|
* receipt registration appended.
|
|
* - template clone (initialized: false via `render --minimal`) REFUSED
|
|
* with the agent-readable message pointing at `bootstrap render`.
|
|
* - absent agent.json REFUSED as not-a-workspace.
|
|
*
|
|
* Serial: sandboxed GBRAIN_HOME env mutation across the whole file.
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { execFileSync } from 'node:child_process';
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import { runBootstrap } from '../../src/commands/bootstrap.ts';
|
|
import type { ExecRunner } from '../../src/core/bootstrap/repo.ts';
|
|
import { attachWorkspace } from '../../src/core/bootstrap/attach.ts';
|
|
import { readManifest, readReceipt } from '../../src/core/bootstrap/format.ts';
|
|
import { initState, setAnswer, confirm, readBackHash } from '../../src/core/bootstrap/interview.ts';
|
|
import { realpathOrResolve } from '../../src/core/path-confine.ts';
|
|
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
|
import { addSource } from '../../src/core/sources-ops.ts';
|
|
import { importFromFile } from '../../src/core/import-file.ts';
|
|
import { verifyWorkspace } from '../../src/core/bootstrap/verify.ts';
|
|
import { operations, type OperationContext } from '../../src/core/operations.ts';
|
|
import type { CapabilityReport } from '../../src/core/capability.ts';
|
|
import { CORPUS_DIR } from '../helpers/bootstrap-corpus.ts';
|
|
|
|
/** Keyless capability report — machine 2 re-ingests + verifies with ZERO keys. */
|
|
const KEYLESS: CapabilityReport = {
|
|
embeddings: { available: false },
|
|
extraction: { available: false },
|
|
search: 'keyword-only',
|
|
mode: 'keyless',
|
|
};
|
|
|
|
/** A page authored ONLY on machine 1, committed to the repo, cloned to machine 2. */
|
|
const MACHINE1_PAGE_SLUG = 'companies/summit-robotics';
|
|
/** A distinctive fact that exists nowhere but machine 1's authored repo page. */
|
|
const MACHINE1_MARKER = 'The flagship warehouse pilot runs at the Rivermouth fulfillment center.';
|
|
|
|
const SAVED_ENV: Record<string, string | undefined> = {};
|
|
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 ANSWERS: Record<string, string> = {
|
|
AGENT_NAME: 'Beacon',
|
|
PRINCIPAL_NAME: 'Pat Example',
|
|
AGENT_PURPOSE: 'Keep the research corpus current 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.',
|
|
};
|
|
|
|
let machine1Home: string; // GBRAIN_HOME parent for machine 1
|
|
let machine2Home: string; // GBRAIN_HOME parent for machine 2
|
|
let ws1: string; // machine-1 workspace (bootstrap-created fixture)
|
|
let ws2: string; // machine-2 clone
|
|
let bareOrigin: string; // local bare 'origin'
|
|
let cloneParent: string;
|
|
|
|
/** Recording in-process ExecRunner — the env-pinned fake for MCP registration. */
|
|
const recorded: string[][] = [];
|
|
const fakeRunner: ExecRunner = async (argv: string[]) => {
|
|
recorded.push([...argv]);
|
|
if (argv[0] === 'claude' && argv[1] === 'mcp' && argv[2] === 'list') {
|
|
return { code: 0, stdout: 'gbrain: stdio serve', stderr: '' };
|
|
}
|
|
if (argv[0] === 'claude' || argv[0] === 'codex') {
|
|
return { code: 0, stdout: '', stderr: '' };
|
|
}
|
|
// git and anything else runs for real via a spawn-free refusal — the attach
|
|
// + hooks paths under test never need a real subprocess beyond claude/codex.
|
|
return { code: 0, stdout: '', stderr: '' };
|
|
};
|
|
|
|
async function captureStd<T>(fn: () => Promise<T>): Promise<{ result: T; out: string; err: string }> {
|
|
const origLog = console.log;
|
|
const origErr = console.error;
|
|
let out = '';
|
|
let err = '';
|
|
console.log = (...args: unknown[]) => { out += args.map(String).join(' ') + '\n'; };
|
|
console.error = (...args: unknown[]) => { err += args.map(String).join(' ') + '\n'; };
|
|
try {
|
|
const result = await fn();
|
|
return { result, out, err };
|
|
} finally {
|
|
console.log = origLog;
|
|
console.error = origErr;
|
|
}
|
|
}
|
|
|
|
function git(cwd: string, args: string[]): string {
|
|
return execFileSync('git', ['-C', cwd, ...args], { stdio: ['ignore', 'pipe', 'pipe'] })
|
|
.toString()
|
|
.trim();
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
for (const k of ENV_KEYS) SAVED_ENV[k] = process.env[k];
|
|
for (const k of ENV_KEYS) delete process.env[k];
|
|
|
|
machine1Home = mkdtempSync(join(tmpdir(), 'gb-att1-'));
|
|
machine2Home = mkdtempSync(join(tmpdir(), 'gb-att2-'));
|
|
ws1 = mkdtempSync(join(tmpdir(), 'gb-att-ws1-'));
|
|
cloneParent = mkdtempSync(join(tmpdir(), 'gb-att-cl-'));
|
|
ws2 = join(cloneParent, 'workspace-clone');
|
|
bareOrigin = join(cloneParent, 'origin.git');
|
|
|
|
// ── Machine 1: the committed lifecycle flow (interview → confirm → render).
|
|
process.env.GBRAIN_HOME = machine1Home;
|
|
expect(initState(ws1).ok).toBe(true);
|
|
for (const [key, value] of Object.entries(ANSWERS)) {
|
|
const r = setAnswer(ws1, key, value);
|
|
if (!r.ok) throw new Error(r.message);
|
|
}
|
|
for (const [key, value] of [
|
|
['MCP_SCOPE', 'project'],
|
|
['HOOKS_CONSENT', 'yes'],
|
|
['PERSIST_CRON', 'no'],
|
|
] as const) {
|
|
const r = setAnswer(ws1, key, value);
|
|
if (!r.ok) throw new Error(r.message);
|
|
}
|
|
const h = readBackHash(ws1);
|
|
if (!h.ok) throw new Error(h.message);
|
|
expect(confirm(ws1, h.hash).ok).toBe(true);
|
|
const renderCode = await (await captureStd(() => runBootstrap(['render', '--workspace', ws1]))).result;
|
|
expect(renderCode).toBe(0);
|
|
expect(readManifest(ws1).state).toBe('initialized');
|
|
|
|
// Author a corpus page into machine 1's brain/ BEFORE the commit — a real
|
|
// synthetic page carrying a machine-1-only fact (the Rivermouth marker). It
|
|
// lives ONLY in the repo, so machine 2 can only learn it by re-ingesting the
|
|
// clone [multi-device promise: hot facts arrive via the repo].
|
|
const corpusPage = readFileSync(join(CORPUS_DIR, 'pages', 'companies__summit-robotics.md'), 'utf8');
|
|
mkdirSync(join(ws1, 'brain', 'companies'), { recursive: true });
|
|
writeFileSync(
|
|
join(ws1, 'brain', 'companies', 'summit-robotics.md'),
|
|
`${corpusPage.trimEnd()}\n\n## Operations\n\n${MACHINE1_MARKER}\n`,
|
|
);
|
|
|
|
// git init + commit + local bare 'origin' + clone → machine 2.
|
|
git(ws1, ['init', '-q', '-b', 'main']);
|
|
git(ws1, ['config', 'user.email', 'test@example.com']);
|
|
git(ws1, ['config', 'user.name', 'Test']);
|
|
git(ws1, ['add', '-A']);
|
|
git(ws1, ['commit', '-q', '-m', 'bootstrap fixture']);
|
|
execFileSync('git', ['init', '-q', '--bare', bareOrigin]);
|
|
// The bare origin's default HEAD (master) must match the pushed branch or
|
|
// `git clone` checks out an EMPTY tree ("remote HEAD refers to nonexistent
|
|
// ref") and machine 2 sees no agent.json.
|
|
execFileSync('git', ['-C', bareOrigin, 'symbolic-ref', 'HEAD', 'refs/heads/main']);
|
|
git(ws1, ['remote', 'add', 'origin', bareOrigin]);
|
|
git(ws1, ['push', '-q', 'origin', 'main']);
|
|
execFileSync('git', ['clone', '-q', bareOrigin, ws2]);
|
|
|
|
// ── Machine 2 from here on: a SECOND sandboxed GBRAIN_HOME.
|
|
process.env.GBRAIN_HOME = machine2Home;
|
|
}, 120_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 [machine1Home, machine2Home, ws1, cloneParent]) {
|
|
try {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
}
|
|
});
|
|
|
|
describe('bootstrap attach (machine-2 adoption, serial e2e)', () => {
|
|
test('attach on a machine-2 clone: exit 0, receipt written with brain_created_by_bootstrap false, steps printed', async () => {
|
|
const { result: code, out } = await captureStd(() =>
|
|
runBootstrap(['attach', '--workspace', ws2, '--harness', 'claude-code']),
|
|
);
|
|
expect(code).toBe(0);
|
|
expect(out).toContain('attached workspace for agent "Beacon"');
|
|
expect(out).toContain("source 'workspace'");
|
|
// The ordered todo-list the dispatcher relays.
|
|
expect(out).toContain('gbrain sources add');
|
|
expect(out).toContain('bootstrap hooks --harness claude-code --repair');
|
|
expect(out).toContain('gbrain bootstrap verify');
|
|
|
|
const receipt = readReceipt(join(machine2Home, '.gbrain'));
|
|
expect(receipt).not.toBeNull();
|
|
expect(receipt!.workspace_dir).toBe(realpathOrResolve(ws2));
|
|
expect(receipt!.agent_name).toBe('Beacon');
|
|
expect(receipt!.source_id).toBe('workspace');
|
|
// Attach ADOPTS an existing brain — never claims brain ownership [CX2-12].
|
|
expect(receipt!.brain_created_by_bootstrap).toBe(false);
|
|
expect(receipt!.created_paths).toEqual([]);
|
|
// Attach records repo_url from the adopted origin so the no-daemon push
|
|
// gate (repoPhaseComplete) recognizes the repo phase as done on this
|
|
// machine — without it the per-turn/session-end pushes defer forever, and
|
|
// attach is the ONLY install path in a cloud sandbox.
|
|
expect((receipt as { repo_url?: string }).repo_url).toContain('origin.git');
|
|
}, 60_000);
|
|
|
|
test('attach steps are the ordered machine-2 todo list; re-attach preserves the same-workspace receipt', () => {
|
|
const before = readReceipt(join(machine2Home, '.gbrain'));
|
|
expect(before).not.toBeNull();
|
|
const res = attachWorkspace(ws2, {
|
|
harness: 'claude-code',
|
|
gbrainHomeDir: join(machine2Home, '.gbrain'),
|
|
createdBy: 'test-reattach',
|
|
});
|
|
expect(res.steps.map((s) => s.kind)).toEqual(['register_source', 'hooks_repair', 'mcp_add', 'verify']);
|
|
// Same-workspace re-attach preserves ownership fields (no laundering).
|
|
const after = readReceipt(join(machine2Home, '.gbrain'));
|
|
expect(after!.created_at).toBe(before!.created_at);
|
|
expect(after!.brain_created_by_bootstrap).toBe(false);
|
|
}, 30_000);
|
|
|
|
test('hooks repair on machine 2: MCP argv recorded via the fake runner, settings.local.json carries the marker entries, receipt updated', async () => {
|
|
// Absolute fake gbrain binary for registration/hook command strings —
|
|
// never executed by this test.
|
|
const fakeBin = join(machine2Home, 'gbrain');
|
|
writeFileSync(fakeBin, '#!/bin/sh\nexit 0\n');
|
|
chmodSync(fakeBin, 0o755);
|
|
|
|
recorded.length = 0;
|
|
const { result: code } = await captureStd(() =>
|
|
runBootstrap(
|
|
['hooks', '--workspace', ws2, '--harness', 'claude-code', '--repair', '--gbrain-bin', fakeBin],
|
|
{ runner: fakeRunner },
|
|
),
|
|
);
|
|
expect(code).toBe(0);
|
|
|
|
// MCP registration went through the env-pinned fake runner with the
|
|
// source binding [G1].
|
|
const flat = recorded.map((argv) => argv.join(' '));
|
|
expect(flat.some((c) => c.startsWith('claude mcp add gbrain') && c.includes('GBRAIN_SOURCE=workspace'))).toBe(true);
|
|
expect(flat).toContain('claude mcp list');
|
|
|
|
// Machine-local hook wiring re-rendered with the marker entries.
|
|
const settingsPath = join(ws2, '.claude', 'settings.local.json');
|
|
expect(existsSync(settingsPath)).toBe(true);
|
|
const raw = readFileSync(settingsPath, 'utf8');
|
|
expect(raw).toContain('"_gbrain"');
|
|
expect(raw).toContain('"bootstrap-v1"');
|
|
const settings = JSON.parse(raw) as { hooks: Record<string, unknown> };
|
|
expect(Object.keys(settings.hooks)).toEqual(
|
|
expect.arrayContaining(['SessionStart', 'UserPromptSubmit', 'Stop', 'SessionEnd']),
|
|
);
|
|
|
|
const receipt = readReceipt(join(machine2Home, '.gbrain'));
|
|
expect(receipt!.registrations).toEqual([
|
|
{ host: 'claude-code', scope: 'project', detail: 'mcp+hooks' },
|
|
]);
|
|
}, 60_000);
|
|
|
|
test('machine-2 full round-trip: repo page re-ingests into a FRESH brain, verify passes, and a machine-1-only fact is recalled', async () => {
|
|
// A fresh machine-2 PGLite brain — nothing pre-seeded. The only path a fact
|
|
// can reach it is the cloned repo tree. Hermetic in-memory engine (the
|
|
// proven in-process verify pattern) so teardown is clean.
|
|
const engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
try {
|
|
// Register the cloned brain/ as the workspace source (what `bootstrap
|
|
// attach` → `gbrain sources add` does on machine 2).
|
|
await addSource(engine, { id: 'workspace', localPath: join(ws2, 'brain'), force: true });
|
|
|
|
// Precondition: the machine-1-authored page is NOT in the fresh DB — it
|
|
// exists ONLY as a committed repo file cloned from machine 1.
|
|
expect(await engine.getPage(MACHINE1_PAGE_SLUG, { sourceId: 'workspace' })).toBeNull();
|
|
const clonedFile = join(ws2, 'brain', 'companies', 'summit-robotics.md');
|
|
expect(existsSync(clonedFile)).toBe(true);
|
|
expect(readFileSync(clonedFile, 'utf8')).toContain(MACHINE1_MARKER);
|
|
|
|
// Re-ingest through the REAL per-file import path (the primitive `gbrain
|
|
// sync` runs on each changed file), scoped to the workspace source.
|
|
const rel = join('companies', 'summit-robotics.md');
|
|
const imp = await importFromFile(engine, clonedFile, rel, { sourceId: 'workspace', noEmbed: true });
|
|
expect(imp.status).toBe('imported');
|
|
expect(imp.slug).toBe(MACHINE1_PAGE_SLUG);
|
|
|
|
// The page + its machine-1-authored content now live in machine 2's DB.
|
|
const page = await engine.getPage(MACHINE1_PAGE_SLUG, { sourceId: 'workspace' });
|
|
expect(page).not.toBeNull();
|
|
expect(page!.title).toBe('Summit Robotics');
|
|
expect(page!.compiled_truth ?? '').toContain(MACHINE1_MARKER);
|
|
|
|
// The clone's origin is a local bare path gh cannot prove private; drop it
|
|
// so repo_privacy resolves to the honest local-only pass. The multi-device
|
|
// fact transport under test is orthogonal to remote-privacy verification.
|
|
execFileSync('git', ['-C', ws2, 'remote', 'remove', 'origin']);
|
|
|
|
// `bootstrap verify` core runs GREEN on machine 2, keyless.
|
|
const res = await verifyWorkspace(engine, ws2, {
|
|
sourceId: 'workspace',
|
|
gbrainHomeDir: join(machine2Home, '.gbrain'),
|
|
capabilities: KEYLESS,
|
|
skipHooksSmoke: true,
|
|
});
|
|
if (!res.ok) console.error(res.report);
|
|
expect(res.ok).toBe(true);
|
|
expect(res.checks.find((c) => c.id === 'roundtrip')!.ok).toBe(true);
|
|
|
|
// Recall the machine-1-only fact through the REAL keyword-search query op.
|
|
// The Rivermouth marker was authored on machine 1 and reached machine 2
|
|
// ONLY via the repo, so a hit proves the multi-device promise end to end.
|
|
const queryOp = operations.find((o) => o.name === 'query')!;
|
|
const ctx: OperationContext = {
|
|
engine,
|
|
config: { engine: 'pglite' } as never,
|
|
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
dryRun: false,
|
|
remote: false,
|
|
sourceId: 'workspace',
|
|
};
|
|
const markerHit = await queryOp.handler(ctx, { query: 'Rivermouth fulfillment center warehouse', limit: 10, expand: false });
|
|
expect(JSON.stringify(markerHit)).toContain(MACHINE1_PAGE_SLUG);
|
|
const goldHit = await queryOp.handler(ctx, { query: 'warehouse navigation robots startup', limit: 10, expand: false });
|
|
expect(JSON.stringify(goldHit)).toContain(MACHINE1_PAGE_SLUG);
|
|
} finally {
|
|
// The query op fires retrieval telemetry (last_retrieved_at / search
|
|
// stats) as a background write. PGLite serializes one connection, so a
|
|
// yield-then-flush drains that in-flight write BEFORE close() — issuing
|
|
// db.close() with a query still queued otherwise deadlocks disconnect.
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
await engine.executeRaw('SELECT 1').catch(() => {});
|
|
await engine.disconnect();
|
|
}
|
|
}, 240_000);
|
|
|
|
test('template clone (initialized: false) is REFUSED with the agent-readable render pointer [CX2-1]', async () => {
|
|
const ws3 = mkdtempSync(join(tmpdir(), 'gb-att-ws3-'));
|
|
try {
|
|
// `render --minimal` writes the canonical placeholder manifest —
|
|
// exactly what a template-repo clone carries.
|
|
const { result: renderCode } = await captureStd(() => runBootstrap(['render', '--minimal', '--workspace', ws3]));
|
|
expect(renderCode).toBe(0);
|
|
expect(readManifest(ws3).state).toBe('template');
|
|
|
|
const { result: code, err } = await captureStd(() => runBootstrap(['attach', '--workspace', ws3]));
|
|
expect(code).toBe(1);
|
|
expect(err).toContain('uninitialized template');
|
|
expect(err).toContain('gbrain bootstrap render');
|
|
// No receipt overwrite from the refused attach: the machine-2 receipt
|
|
// still points at ws2.
|
|
expect(readReceipt(join(machine2Home, '.gbrain'))!.workspace_dir).toBe(realpathOrResolve(ws2));
|
|
} finally {
|
|
rmSync(ws3, { recursive: true, force: true });
|
|
}
|
|
}, 60_000);
|
|
|
|
test('absent agent.json is REFUSED as not-a-workspace', async () => {
|
|
const ws4 = mkdtempSync(join(tmpdir(), 'gb-att-ws4-'));
|
|
try {
|
|
const { result: code, err } = await captureStd(() => runBootstrap(['attach', '--workspace', ws4]));
|
|
expect(code).toBe(1);
|
|
expect(err).toContain('not an agent workspace');
|
|
expect(err).toContain('agent.json');
|
|
} finally {
|
|
rmSync(ws4, { recursive: true, force: true });
|
|
}
|
|
}, 30_000);
|
|
});
|