mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
* fix(bootstrap): harden create-repo-first repo adoption `gbrain bootstrap repo` adopts an empty, private, personally-owned GitHub repo the human created (create-repo-first), instead of only ever creating one. This hardens the existing adoption branch: - Empty-only adoption + pending_repo_url proof: a non-empty origin is refused (ORIGIN_NOT_EMPTY) unless it matches this workspace's pending marker (our own interrupted push). Never adopts a user's existing project from a git-ancestry guess, and never silently no-ops without pushing. - Repo-local git identity is set on the adopt path too (fresh-machine commits). - repo_url is recorded only AFTER a successful push (pending marker before); a failed push no longer looks "done" to `bootstrap status`. - Pre-push secret scan also covers an already-committed tree; ls-files failure fails closed. - assertOriginMatches binds BOTH the fetch URL and a configured push URL to the verified-private repo, so a foreign pushurl can't leak the workspace. - disposition: 'created' | 'adopted' | 'reused' replaces the overloaded flag. - Hook push-gate: the no-daemon session-end / recovery push is deferred until the repo phase records repo_url AND the current origin still matches it, so nothing is published to an unverified or redirected remote. Adds ORIGIN_NOT_EMPTY / REMOTE_CHECK_FAILED error codes. * docs(bootstrap): lead with the repo, document create-repo-first README (Claude Code + Codex) now opens with "the folder you open becomes your agent's private repo" and adds a "prefer to make the repo yourself?" callout for the create-repo-first path (empty, personal-account repo). Updates the bootstrap guide, the Claude Code MCP note, and the KEY_FILES / AGENT_BOOTSTRAP_PLAN invariants to describe adoption instead of "foreign origins refused". * v0.45.1.0 fix(bootstrap): create-repo-first repo adoption + hardening Bumps VERSION/package.json to 0.45.1.0, adds the CHANGELOG entry, refreshes the runbook + template-repo version stamps, and regenerates the llms bundle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(todos): file P2 follow-up — index-blob secret scan for bootstrap pushes * ci(gitleaks): run the free CLI instead of the license-gated v2 action gitleaks-action@v2 now enforces a paid GITLEAKS_LICENSE and fails the job ("missing gitleaks license") for accounts it can't validate over the API — blocking every PR's merge gate. Replace it with the open-source gitleaks CLI (pinned 8.30.1, checksum-verified against the release's own checksums file), scanning the PR/push commit range with the committed .gitleaks.toml allowlist. Same secret-scan coverage, no license wall. * v0.45.2.0 chore(release): re-bump 0.45.1.0 -> 0.45.2.0 Re-target the release version at the user's request. Updates VERSION, package.json, the CHANGELOG header + self-repair block, the runbook + template-repo version stamps, the TODOS follow-up reference, and the llms bundle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(perf): raise entity-card ratio ceiling 50x -> 100x (CI flake) The RATIO GUARD asserted entity p99 <= 50x max(getPage p50, 1ms). On a fast runner getPage p50 floors to 1ms and a normal entity p99 (~50ms) reads as ~52x, tripping the gate even though absolute p99 (52ms) is well under the 100ms budget — a p99 tail divided by a sub-ms median. At the 1ms floor, 50x also made the ratio STRICTER than the test's own 100ms absolute budget. Raise the ceiling to 100x: still far below the >=200x O(N)-regression signal the guard exists to catch, and consistent with (never stricter than) the absolute budget. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
716 lines
34 KiB
TypeScript
716 lines
34 KiB
TypeScript
/**
|
|
* `bootstrap repo` + `bootstrap attach` unit tests [G8, CX2-1, CX-P1.5].
|
|
*
|
|
* All gh/git interaction goes through a recording fake ExecRunner — no real
|
|
* subprocesses, no network, no git commands against this repo. The e2e
|
|
* lifecycle test (another task) exercises PATH-shimmed fakes; these pin the
|
|
* library contracts: gate ordering, name-collision suffixing, the hard
|
|
* privacy gate (false vs unavailable are DISTINCT), origin refusal vs attach
|
|
* acceptance, template-state refusal, and remote-URL-keyed idempotency.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import {
|
|
createPrivateRepo,
|
|
GITHUB_URL_PLACEHOLDER,
|
|
parseGithubRemote,
|
|
slugifyRepoName,
|
|
type ExecResult,
|
|
type ExecRunner,
|
|
type RepoReceipt,
|
|
} from '../src/core/bootstrap/repo.ts';
|
|
import { attachWorkspace } from '../src/core/bootstrap/attach.ts';
|
|
import { BootstrapError } from '../src/core/bootstrap/lock.ts';
|
|
import { readReceipt, writeManifest, writeReceipt, type AgentManifest } from '../src/core/bootstrap/format.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface Rule {
|
|
/** Substring of the joined argv this rule answers. First match wins. */
|
|
key: string;
|
|
/** How many calls this rule answers before falling through (default ∞). */
|
|
times?: number;
|
|
code?: number;
|
|
stdout?: string;
|
|
stderr?: string;
|
|
}
|
|
|
|
function makeRunner(rules: Rule[]): { runner: ExecRunner; calls: string[][] } {
|
|
const calls: string[][] = [];
|
|
const state = rules.map((r) => ({ ...r, left: r.times ?? Infinity }));
|
|
const runner: ExecRunner = async (argv: string[]): Promise<ExecResult> => {
|
|
calls.push(argv);
|
|
const joined = argv.join(' ');
|
|
for (const r of state) {
|
|
if (r.left > 0 && joined.includes(r.key)) {
|
|
r.left--;
|
|
return { code: r.code ?? 0, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
|
}
|
|
}
|
|
return { code: 0, stdout: '', stderr: '' };
|
|
};
|
|
return { runner, calls };
|
|
}
|
|
|
|
const INITIALIZED_MANIFEST: AgentManifest = {
|
|
format_version: 1,
|
|
initialized: true,
|
|
agent_name: 'Test Agent',
|
|
created_by: '0.0.0-test',
|
|
created_at: '2026-01-01T00:00:00.000Z',
|
|
source_id: 'workspace',
|
|
};
|
|
|
|
let ws: string;
|
|
let home: string;
|
|
|
|
beforeEach(() => {
|
|
ws = mkdtempSync(join(tmpdir(), 'gbrain-bootstrap-ws-'));
|
|
home = mkdtempSync(join(tmpdir(), 'gbrain-bootstrap-home-'));
|
|
// writeReceipt requires the bootstrap/ subdir (render creates it in prod).
|
|
mkdirSync(join(home, 'bootstrap'), { recursive: true });
|
|
writeManifest(ws, { ...INITIALIZED_MANIFEST });
|
|
writeFileSync(join(ws, 'GITHUB.md'), `This workspace is durably backed up to **${GITHUB_URL_PLACEHOLDER}**.\n`, 'utf8');
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(ws, { recursive: true, force: true });
|
|
rmSync(home, { recursive: true, force: true });
|
|
});
|
|
|
|
/** Baseline happy-path rules; tests override by prepending / replacing. */
|
|
function happyRules(overrides: Rule[] = []): Rule[] {
|
|
return [
|
|
...overrides,
|
|
{ key: 'gh --version', code: 0 },
|
|
{ key: 'auth status', code: 0 },
|
|
// First origin probe: no remote yet. After create, git reports the URL.
|
|
{ key: 'remote get-url origin', times: 1, code: 2, stderr: 'error: No such remote' },
|
|
{ key: 'rev-parse --git-dir', code: 1, stderr: 'fatal: not a git repository' },
|
|
{ key: 'gh api user', stdout: '{"login":"alice","id":123}\n' },
|
|
// NOTE: the -2 probe rule must precede the base probe rule — substring
|
|
// matching would otherwise answer the -2 probe with the base rule.
|
|
{ key: 'repo view alice/test-agent-workspace-2', code: 1, stderr: 'Could not resolve to a Repository' },
|
|
{ key: 'repo view alice/test-agent-workspace', code: 0, stdout: 'exists' },
|
|
{ key: 'repo create', code: 0 },
|
|
{ key: 'remote get-url origin', code: 0, stdout: 'https://github.com/alice/test-agent-workspace-2\n' },
|
|
{ key: '--jq .private', code: 0, stdout: 'true\n' },
|
|
];
|
|
}
|
|
|
|
async function expectBootstrapError(p: Promise<unknown>): Promise<BootstrapError> {
|
|
try {
|
|
await p;
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(BootstrapError);
|
|
return e as BootstrapError;
|
|
}
|
|
throw new Error('expected a BootstrapError, got success');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// createPrivateRepo
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('createPrivateRepo', () => {
|
|
test('create flow: name collision → -2 suffix, identity set, privacy verified, receipt + GITHUB.md updated', async () => {
|
|
const { runner, calls } = makeRunner(happyRules());
|
|
const result = await createPrivateRepo(ws, { runner, gbrainHomeDir: home });
|
|
|
|
expect(result.name).toBe('test-agent-workspace-2');
|
|
expect(result.url).toBe('https://github.com/alice/test-agent-workspace-2');
|
|
expect(result.reused).toBe(false);
|
|
|
|
// git init -b main ran (rev-parse said not-a-repo).
|
|
expect(calls).toContainEqual(['git', '-C', ws, 'init', '-b', 'main']);
|
|
// Repo-local identity from gh api user (login + noreply address).
|
|
expect(calls).toContainEqual(['git', '-C', ws, 'config', 'user.name', 'alice']);
|
|
expect(calls).toContainEqual(['git', '-C', ws, 'config', 'user.email', '123+alice@users.noreply.github.com']);
|
|
// The create call is exact: private, sourced from the workspace, and
|
|
// WITHOUT --push — nothing leaves this machine before the privacy verify [G8].
|
|
expect(calls).toContainEqual(['gh', 'repo', 'create', 'test-agent-workspace-2', '--private', '--source', ws]);
|
|
// Privacy verify hit the API for the created repo…
|
|
expect(calls).toContainEqual(['gh', 'api', 'repos/alice/test-agent-workspace-2', '--jq', '.private']);
|
|
// …and the first push ran only AFTER the verify passed (create → verify → push).
|
|
const verifyIdx = calls.findIndex((c) => c.join(' ') === `gh api repos/alice/test-agent-workspace-2 --jq .private`);
|
|
const pushIdx = calls.findIndex((c) => c.join(' ') === `git -C ${ws} push -u origin main`);
|
|
expect(pushIdx).toBeGreaterThan(verifyIdx);
|
|
|
|
// GITHUB.md placeholder replaced with the real URL.
|
|
const githubMd = readFileSync(join(ws, 'GITHUB.md'), 'utf8');
|
|
expect(githubMd).toContain(result.url);
|
|
expect(githubMd).not.toContain(GITHUB_URL_PLACEHOLDER);
|
|
|
|
// Receipt records the created repo URL (the idempotency key).
|
|
const receipt = readReceipt(home) as RepoReceipt | null;
|
|
expect(receipt).not.toBeNull();
|
|
expect(receipt?.repo_url).toBe(result.url);
|
|
expect(receipt?.brain_created_by_bootstrap).toBe(false);
|
|
});
|
|
|
|
test('[FIX3] freshly-rendered (uncommitted) workspace → stages + secret-scans + commits, then pushes content', async () => {
|
|
const { runner, calls } = makeRunner(
|
|
happyRules([
|
|
{ key: 'rev-parse --verify HEAD', code: 1, stderr: 'fatal: needed a single revision' },
|
|
{ key: 'status --porcelain', code: 0, stdout: ' M GITHUB.md\n?? agent.json\n' },
|
|
{ key: 'ls-files --cached --others', code: 0, stdout: 'agent.json\0GITHUB.md' },
|
|
{ key: 'diff --cached --name-only', code: 0, stdout: 'agent.json\nGITHUB.md\n' },
|
|
]),
|
|
);
|
|
const result = await createPrivateRepo(ws, { runner, gbrainHomeDir: home });
|
|
expect(result.reused).toBe(false);
|
|
|
|
// The commit was created before the push (no empty-remote push).
|
|
expect(calls).toContainEqual(['git', '-C', ws, 'add', '-A']);
|
|
expect(calls).toContainEqual(['git', '-C', ws, 'commit', '-m', 'gbrain: bootstrap workspace']);
|
|
expect(calls).toContainEqual(['git', '-C', ws, 'push', '-u', 'origin', 'main']);
|
|
|
|
const joined = calls.map((c) => c.join(' '));
|
|
const verifyIdx = joined.indexOf('gh api repos/alice/test-agent-workspace-2 --jq .private');
|
|
const commitIdx = joined.indexOf(`git -C ${ws} commit -m gbrain: bootstrap workspace`);
|
|
const pushIdx = joined.indexOf(`git -C ${ws} push -u origin main`);
|
|
expect(commitIdx).toBeGreaterThan(verifyIdx); // create → verify → commit → push
|
|
expect(pushIdx).toBeGreaterThan(commitIdx);
|
|
});
|
|
|
|
test('[FIX3] a rendered workspace with NO files to commit refuses (REPO_CREATE_FAILED), never pushes empty', async () => {
|
|
const { runner, calls } = makeRunner(
|
|
happyRules([
|
|
{ key: 'rev-parse --verify HEAD', code: 1, stderr: 'fatal: needed a single revision' },
|
|
{ key: 'status --porcelain', code: 0, stdout: '' },
|
|
{ key: 'ls-files --cached --others', code: 0, stdout: '' },
|
|
{ key: 'diff --cached --name-only', code: 0, stdout: '' },
|
|
]),
|
|
);
|
|
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
|
expect(err.code).toBe('REPO_CREATE_FAILED');
|
|
expect(err.message).toContain('no files to commit');
|
|
expect(calls.some((c) => c.join(' ').includes('push -u origin'))).toBe(false);
|
|
});
|
|
|
|
test('[FIX3] a secret in the workspace blocks the first commit + push (SECRET_SCAN_BLOCKED)', async () => {
|
|
writeFileSync(join(ws, 'leak.md'), `token: sk-${'A1b2C3d4E5f6G7h8I9j0K1l2M3n4'}\n`, 'utf8');
|
|
const { runner, calls } = makeRunner(
|
|
happyRules([
|
|
{ key: 'rev-parse --verify HEAD', code: 1, stderr: 'fatal: needed a single revision' },
|
|
{ key: 'status --porcelain', code: 0, stdout: '?? leak.md\n' },
|
|
{ key: 'ls-files --cached --others', code: 0, stdout: 'leak.md' },
|
|
{ key: 'diff --cached --name-only', code: 0, stdout: 'leak.md\n' },
|
|
]),
|
|
);
|
|
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
|
expect(err.code).toBe('SECRET_SCAN_BLOCKED');
|
|
expect(err.message).not.toContain('sk-A1b2C3d4'); // value never surfaces
|
|
expect(calls.some((c) => c.join(' ').includes('commit -m'))).toBe(false);
|
|
expect(calls.some((c) => c.join(' ').includes('push -u origin'))).toBe(false);
|
|
});
|
|
|
|
test('[FIX4] origin swapped between the privacy verify and the push → ORIGIN_EXISTS, no push', async () => {
|
|
const { runner, calls } = makeRunner([
|
|
// origin probes, in call order: gate-4 (no remote) → postOrigin (created)
|
|
// → assertOriginMatches (SWAPPED after verify).
|
|
{ key: 'remote get-url origin', times: 1, code: 2, stderr: 'error: No such remote' },
|
|
{ key: 'remote get-url origin', times: 1, code: 0, stdout: 'https://github.com/alice/test-agent-workspace-2\n' },
|
|
{ key: 'remote get-url origin', code: 0, stdout: 'https://github.com/mallory/swapped\n' },
|
|
{ key: 'gh --version', code: 0 },
|
|
{ key: 'auth status', code: 0 },
|
|
{ key: 'rev-parse --git-dir', code: 1, stderr: 'fatal: not a git repository' },
|
|
{ key: 'gh api user', stdout: '{"login":"alice","id":123}\n' },
|
|
{ key: 'repo view alice/test-agent-workspace-2', code: 1, stderr: 'Could not resolve to a Repository' },
|
|
{ key: 'repo view alice/test-agent-workspace', code: 0, stdout: 'exists' },
|
|
{ key: 'repo create', code: 0 },
|
|
{ key: '--jq .private', code: 0, stdout: 'true\n' },
|
|
]);
|
|
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
|
expect(err.code).toBe('ORIGIN_EXISTS');
|
|
expect(err.message).toContain('verified-private');
|
|
expect(calls.some((c) => c.join(' ').includes('push -u origin'))).toBe(false); // nothing pushed
|
|
});
|
|
|
|
test('privacy verify says false → hard REPO_NOT_PRIVATE error', async () => {
|
|
const { runner } = makeRunner(happyRules([{ key: '--jq .private', code: 0, stdout: 'false\n' }]));
|
|
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
|
expect(err.code).toBe('REPO_NOT_PRIVATE');
|
|
});
|
|
|
|
test('privacy verify 500 → VERIFY_UNAVAILABLE (distinct from not-private), tells the user to re-run', async () => {
|
|
const { runner } = makeRunner(happyRules([{ key: '--jq .private', code: 1, stderr: 'gh: HTTP 500 Internal Server Error' }]));
|
|
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
|
expect(err.code).toBe('VERIFY_UNAVAILABLE');
|
|
expect(err.code).not.toBe('REPO_NOT_PRIVATE');
|
|
expect(err.message).toContain('re-run');
|
|
});
|
|
|
|
test('pre-existing origin bootstrap did not create → ORIGIN_EXISTS pointing at attach [G8]', async () => {
|
|
const { runner, calls } = makeRunner(
|
|
happyRules([{ key: 'remote get-url origin', code: 0, stdout: 'https://github.com/someone/else.git\n' }]),
|
|
);
|
|
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
|
expect(err.code).toBe('ORIGIN_EXISTS');
|
|
expect(err.message).toContain('bootstrap attach');
|
|
// Refused before any create.
|
|
expect(calls.some((c) => c.join(' ').includes('repo create'))).toBe(false);
|
|
});
|
|
|
|
test('template manifest → TEMPLATE_UNINITIALIZED [CX2-1]', async () => {
|
|
writeManifest(ws, { ...INITIALIZED_MANIFEST, initialized: false });
|
|
const { runner } = makeRunner(happyRules());
|
|
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
|
expect(err.code).toBe('TEMPLATE_UNINITIALIZED');
|
|
expect(err.message).toContain('bootstrap render');
|
|
});
|
|
|
|
test('absent manifest → NOT_A_WORKSPACE', async () => {
|
|
rmSync(join(ws, 'agent.json'));
|
|
const { runner } = makeRunner(happyRules());
|
|
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
|
expect(err.code).toBe('NOT_A_WORKSPACE');
|
|
});
|
|
|
|
test('idempotent re-run keyed off the remote URL: adopts our origin, re-verifies, never creates', async () => {
|
|
const url = 'https://github.com/alice/test-agent-workspace-2';
|
|
writeReceipt(home, {
|
|
receipt_version: 1,
|
|
workspace_dir: ws,
|
|
source_id: 'workspace',
|
|
agent_name: 'Test Agent',
|
|
created_at: '2026-01-01T00:00:00.000Z',
|
|
created_by: '0.0.0-test',
|
|
brain_created_by_bootstrap: false,
|
|
created_paths: [],
|
|
registrations: [],
|
|
repo_url: url,
|
|
} as RepoReceipt);
|
|
const { runner, calls } = makeRunner(
|
|
happyRules([
|
|
{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` },
|
|
// [FIX3] the remote already carries our branch → reused success is
|
|
// honest; no re-push.
|
|
{ key: 'ls-remote --heads origin', code: 0, stdout: `deadbeef\trefs/heads/main\n` },
|
|
]),
|
|
);
|
|
const result = await createPrivateRepo(ws, { runner, gbrainHomeDir: home });
|
|
expect(result.reused).toBe(true);
|
|
expect(result.url).toBe(url);
|
|
expect(result.name).toBe('test-agent-workspace-2');
|
|
expect(calls.some((c) => c.join(' ').includes('repo create'))).toBe(false);
|
|
// Privacy still verified on the re-run (couldn't-verify would refuse).
|
|
expect(calls).toContainEqual(['gh', 'api', 'repos/alice/test-agent-workspace-2', '--jq', '.private']);
|
|
// Remote already has content → no re-push.
|
|
expect(calls.some((c) => c.join(' ').includes('push -u origin'))).toBe(false);
|
|
});
|
|
|
|
test('[FIX3] idempotent re-run whose prior push FAILED (empty remote) → completes the deferred push', async () => {
|
|
const url = 'https://github.com/alice/test-agent-workspace-2';
|
|
writeReceipt(home, {
|
|
receipt_version: 1,
|
|
workspace_dir: ws,
|
|
source_id: 'workspace',
|
|
agent_name: 'Test Agent',
|
|
created_at: '2026-01-01T00:00:00.000Z',
|
|
created_by: '0.0.0-test',
|
|
brain_created_by_bootstrap: false,
|
|
created_paths: [],
|
|
registrations: [],
|
|
repo_url: url,
|
|
} as RepoReceipt);
|
|
const { runner, calls } = makeRunner(
|
|
happyRules([
|
|
{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` },
|
|
// Empty remote (prior push never landed): ls-remote returns nothing.
|
|
{ key: 'ls-remote --heads origin', code: 0, stdout: '' },
|
|
]),
|
|
);
|
|
const result = await createPrivateRepo(ws, { runner, gbrainHomeDir: home });
|
|
expect(result.reused).toBe(true);
|
|
expect(calls.some((c) => c.join(' ').includes('repo create'))).toBe(false);
|
|
// The deferred push was completed rather than false-succeeding on empty.
|
|
expect(calls).toContainEqual(['git', '-C', ws, 'push', '-u', 'origin', 'main']);
|
|
});
|
|
|
|
test('crash recovery: receipt for this workspace without repo_url adopts the origin and records it', async () => {
|
|
const url = 'https://github.com/alice/test-agent-workspace';
|
|
writeReceipt(home, {
|
|
receipt_version: 1,
|
|
workspace_dir: ws,
|
|
source_id: 'workspace',
|
|
agent_name: 'Test Agent',
|
|
created_at: '2026-01-01T00:00:00.000Z',
|
|
created_by: '0.0.0-test',
|
|
brain_created_by_bootstrap: true,
|
|
created_paths: [],
|
|
registrations: [],
|
|
});
|
|
const { runner } = makeRunner(happyRules([{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` }]));
|
|
const result = await createPrivateRepo(ws, { runner, gbrainHomeDir: home });
|
|
expect(result.reused).toBe(true);
|
|
expect((readReceipt(home) as RepoReceipt).repo_url).toBe(url);
|
|
// Adoption must not clobber the existing receipt's brain ownership.
|
|
expect((readReceipt(home) as RepoReceipt).brain_created_by_bootstrap).toBe(true);
|
|
});
|
|
|
|
test('receipt url mismatch with origin → ORIGIN_EXISTS (never silently adopts a swapped remote)', async () => {
|
|
writeReceipt(home, {
|
|
receipt_version: 1,
|
|
workspace_dir: ws,
|
|
source_id: 'workspace',
|
|
agent_name: 'Test Agent',
|
|
created_at: '2026-01-01T00:00:00.000Z',
|
|
created_by: '0.0.0-test',
|
|
brain_created_by_bootstrap: false,
|
|
created_paths: [],
|
|
registrations: [],
|
|
repo_url: 'https://github.com/alice/test-agent-workspace',
|
|
} as RepoReceipt);
|
|
const { runner } = makeRunner(
|
|
happyRules([{ key: 'remote get-url origin', code: 0, stdout: 'https://github.com/mallory/swapped\n' }]),
|
|
);
|
|
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
|
expect(err.code).toBe('ORIGIN_EXISTS');
|
|
});
|
|
|
|
test('gh missing → GH_MISSING with exit-code-2 semantics', async () => {
|
|
const { runner } = makeRunner([{ key: 'gh --version', code: 127, stderr: 'command not found' }]);
|
|
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
|
expect(err.code).toBe('GH_MISSING');
|
|
expect(err.exitCode).toBe(2);
|
|
});
|
|
|
|
test('gh unauthenticated → GH_AUTH with exit-code-2 semantics', async () => {
|
|
const { runner } = makeRunner([
|
|
{ key: 'gh --version', code: 0 },
|
|
{ key: 'auth status', code: 1, stderr: 'You are not logged into any GitHub hosts' },
|
|
]);
|
|
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
|
expect(err.code).toBe('GH_AUTH');
|
|
expect(err.exitCode).toBe(2);
|
|
expect(err.message).toContain('gh auth login');
|
|
});
|
|
|
|
// ── create-repo-first adoption (the human made the repo, opened it in Claude
|
|
// Code / Codex, then ran bootstrap) + hardening of the adoption path ──────
|
|
|
|
/** The receipt `render` writes: this workspace, no repo_url yet. */
|
|
function renderReceiptNoUrl(): void {
|
|
writeReceipt(home, {
|
|
receipt_version: 1,
|
|
workspace_dir: ws,
|
|
source_id: 'workspace',
|
|
agent_name: 'Test Agent',
|
|
created_at: '2026-01-01T00:00:00.000Z',
|
|
created_by: '0.0.0-test',
|
|
brain_created_by_bootstrap: false,
|
|
created_paths: [],
|
|
registrations: [],
|
|
});
|
|
}
|
|
|
|
test("create-repo-first: adopts an EMPTY private user-owned repo (disposition 'adopted', sets identity, pushes, records after push)", async () => {
|
|
const url = 'https://github.com/alice/my-brain';
|
|
renderReceiptNoUrl();
|
|
const { runner, calls } = makeRunner(
|
|
happyRules([
|
|
{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` },
|
|
// assertAdoptableOrigin: all-refs ls-remote → empty (freshly created repo).
|
|
{ key: 'ls-remote origin refs/heads/main', code: 0, stdout: '' },
|
|
{ key: 'ls-remote origin', code: 0, stdout: '' },
|
|
// ensureRemoteHasWorkspace: --heads empty → first push.
|
|
{ key: 'ls-remote --heads origin', code: 0, stdout: '' },
|
|
// Freshly rendered, uncommitted → stage + scan + commit, then push.
|
|
{ key: 'rev-parse --verify HEAD', code: 1, stderr: 'fatal: needed a single revision' },
|
|
{ key: 'status --porcelain', code: 0, stdout: ' M GITHUB.md\n' },
|
|
{ key: 'ls-files --cached --others', code: 0, stdout: 'GITHUB.md' },
|
|
{ key: 'diff --cached --name-only', code: 0, stdout: 'GITHUB.md\n' },
|
|
{ key: '--jq .private', code: 0, stdout: 'true\n' },
|
|
]),
|
|
);
|
|
const result = await createPrivateRepo(ws, { runner, gbrainHomeDir: home });
|
|
expect(result.disposition).toBe('adopted');
|
|
expect(result.reused).toBe(true);
|
|
expect(result.url).toBe(url);
|
|
// Adopted the human's repo — never created one.
|
|
expect(calls.some((c) => c.join(' ').includes('repo create'))).toBe(false);
|
|
// Repo-local identity set on the ADOPTION path (fresh-machine commit safety).
|
|
expect(calls).toContainEqual(['git', '-C', ws, 'config', 'user.name', 'alice']);
|
|
expect(calls).toContainEqual(['git', '-C', ws, 'config', 'user.email', '123+alice@users.noreply.github.com']);
|
|
// Workspace pushed, and repo_url recorded AFTER the push.
|
|
expect(calls).toContainEqual(['git', '-C', ws, 'push', '-u', 'origin', 'main']);
|
|
expect((readReceipt(home) as RepoReceipt).repo_url).toBe(url);
|
|
});
|
|
|
|
test('[CRITICAL] create-repo-first pointed at a NON-empty repo → ORIGIN_NOT_EMPTY, never a silent no-op', async () => {
|
|
const url = 'https://github.com/alice/existing-project';
|
|
renderReceiptNoUrl();
|
|
const { runner, calls } = makeRunner(
|
|
happyRules([
|
|
{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` },
|
|
{ key: 'ls-remote origin refs/heads/main', code: 0, stdout: '' },
|
|
// Non-empty remote (foreign content) + no local commit → foreign, refuse.
|
|
{ key: 'ls-remote origin', code: 0, stdout: 'cafe1234\trefs/heads/main\n' },
|
|
{ key: 'rev-parse --verify HEAD', code: 1, stderr: 'fatal: needed a single revision' },
|
|
]),
|
|
);
|
|
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
|
expect(err.code).toBe('ORIGIN_NOT_EMPTY');
|
|
expect(err.message).toContain('EMPTY');
|
|
// No silent no-op: nothing pushed, repo_url never recorded.
|
|
expect(calls.some((c) => c.join(' ').includes('push -u origin'))).toBe(false);
|
|
expect((readReceipt(home) as RepoReceipt).repo_url).toBeUndefined();
|
|
});
|
|
|
|
test('[CRITICAL] non-empty repo with NO pending marker → ORIGIN_NOT_EMPTY (never adopt a user project from a git-ancestry guess)', async () => {
|
|
// Even if the remote HEAD looks like ours, without a pending_repo_url proof
|
|
// we cannot distinguish our push from a user's existing project → refuse.
|
|
const url = 'https://github.com/alice/existing-project';
|
|
renderReceiptNoUrl();
|
|
const { runner, calls } = makeRunner(
|
|
happyRules([
|
|
{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` },
|
|
{ key: 'ls-remote origin', code: 0, stdout: 'abc123\trefs/heads/main\n' },
|
|
]),
|
|
);
|
|
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
|
expect(err.code).toBe('ORIGIN_NOT_EMPTY');
|
|
expect(calls.some((c) => c.join(' ').includes('push -u origin'))).toBe(false);
|
|
expect((readReceipt(home) as RepoReceipt).repo_url).toBeUndefined();
|
|
});
|
|
|
|
test('interrupted push recovery: non-empty remote matching pending_repo_url → adopts (resumes)', async () => {
|
|
const url = 'https://github.com/alice/my-brain';
|
|
// A prior run pushed but crashed before recording repo_url — pending proves ours.
|
|
writeReceipt(home, {
|
|
receipt_version: 1,
|
|
workspace_dir: ws,
|
|
source_id: 'workspace',
|
|
agent_name: 'Test Agent',
|
|
created_at: '2026-01-01T00:00:00.000Z',
|
|
created_by: '0.0.0-test',
|
|
brain_created_by_bootstrap: false,
|
|
created_paths: [],
|
|
registrations: [],
|
|
pending_repo_url: url,
|
|
} as RepoReceipt);
|
|
const { runner, calls } = makeRunner(
|
|
happyRules([
|
|
{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` },
|
|
// Remote already carries our branch (the interrupted push landed) → no re-push.
|
|
{ key: 'ls-remote --heads origin', code: 0, stdout: 'abc123\trefs/heads/main\n' },
|
|
{ key: '--jq .private', code: 0, stdout: 'true\n' },
|
|
]),
|
|
);
|
|
const result = await createPrivateRepo(ws, { runner, gbrainHomeDir: home });
|
|
expect(result.disposition).toBe('adopted');
|
|
// pending bypassed the emptiness check — never probed all-refs ls-remote.
|
|
expect(calls.some((c) => c.join(' ') === `git -C ${ws} ls-remote origin`)).toBe(false);
|
|
const receipt = readReceipt(home) as RepoReceipt;
|
|
expect(receipt.repo_url).toBe(url);
|
|
expect(receipt.pending_repo_url).toBeUndefined(); // cleared on record
|
|
});
|
|
|
|
test('create-repo-first under an ORG (owner != login) → ORIGIN_EXISTS (personal-account only, D2=A)', async () => {
|
|
const url = 'https://github.com/acme-org/brain';
|
|
renderReceiptNoUrl();
|
|
const { runner, calls } = makeRunner(
|
|
happyRules([{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` }]),
|
|
);
|
|
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
|
expect(err.code).toBe('ORIGIN_EXISTS');
|
|
// Ownership fails first — never even probes the remote for emptiness.
|
|
expect(calls.some((c) => c.join(' ') === `git -C ${ws} ls-remote origin`)).toBe(false);
|
|
});
|
|
|
|
test('create-repo-first pointed at a PUBLIC repo → REPO_NOT_PRIVATE', async () => {
|
|
const url = 'https://github.com/alice/public-brain';
|
|
renderReceiptNoUrl();
|
|
const { runner } = makeRunner(
|
|
happyRules([
|
|
{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` },
|
|
{ key: 'ls-remote origin', code: 0, stdout: '' }, // empty → adoptable
|
|
{ key: '--jq .private', code: 0, stdout: 'false\n' },
|
|
]),
|
|
);
|
|
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
|
expect(err.code).toBe('REPO_NOT_PRIVATE');
|
|
});
|
|
|
|
test("create-repo-first when the origin can't be listed → REMOTE_CHECK_FAILED, nothing pushed", async () => {
|
|
const url = 'https://github.com/alice/my-brain';
|
|
renderReceiptNoUrl();
|
|
const { runner, calls } = makeRunner(
|
|
happyRules([
|
|
{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` },
|
|
{ key: 'ls-remote origin', code: 1, stderr: 'fatal: could not read from remote repository' },
|
|
]),
|
|
);
|
|
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
|
expect(err.code).toBe('REMOTE_CHECK_FAILED');
|
|
expect(calls.some((c) => c.join(' ').includes('push -u origin'))).toBe(false);
|
|
});
|
|
|
|
test('adoption push fails → repo_url NOT recorded (status stays resumable) [finding 3]', async () => {
|
|
const url = 'https://github.com/alice/my-brain';
|
|
renderReceiptNoUrl();
|
|
const { runner } = makeRunner(
|
|
happyRules([
|
|
{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` },
|
|
{ key: 'ls-remote origin', code: 0, stdout: '' },
|
|
{ key: 'ls-remote --heads origin', code: 0, stdout: '' },
|
|
{ key: 'rev-parse --verify HEAD', code: 1, stderr: 'fatal: needed a single revision' },
|
|
{ key: 'status --porcelain', code: 0, stdout: ' M GITHUB.md\n' },
|
|
{ key: 'ls-files --cached --others', code: 0, stdout: 'GITHUB.md' },
|
|
{ key: 'diff --cached --name-only', code: 0, stdout: 'GITHUB.md\n' },
|
|
{ key: '--jq .private', code: 0, stdout: 'true\n' },
|
|
{ key: 'push -u origin', code: 1, stderr: 'fatal: unable to access' },
|
|
]),
|
|
);
|
|
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
|
expect(err.code).toBe('REPO_CREATE_FAILED');
|
|
// repo_url must NOT be recorded on push failure (else status false-reports done).
|
|
expect((readReceipt(home) as RepoReceipt).repo_url).toBeUndefined();
|
|
});
|
|
|
|
test('adoption secret-scans the workspace before pushing (SECRET_SCAN_BLOCKED) [finding 4]', async () => {
|
|
const url = 'https://github.com/alice/my-brain';
|
|
renderReceiptNoUrl();
|
|
writeFileSync(join(ws, 'leak.md'), `token: sk-${'A1b2C3d4E5f6G7h8I9j0K1l2M3n4'}\n`, 'utf8');
|
|
const { runner, calls } = makeRunner(
|
|
happyRules([
|
|
{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` },
|
|
{ key: 'ls-remote origin', code: 0, stdout: '' },
|
|
{ key: 'ls-remote --heads origin', code: 0, stdout: '' },
|
|
{ key: 'rev-parse --verify HEAD', code: 1, stderr: 'fatal: needed a single revision' },
|
|
{ key: 'status --porcelain', code: 0, stdout: '?? leak.md\n' },
|
|
{ key: 'ls-files --cached --others', code: 0, stdout: 'leak.md' },
|
|
{ key: '--jq .private', code: 0, stdout: 'true\n' },
|
|
]),
|
|
);
|
|
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
|
expect(err.code).toBe('SECRET_SCAN_BLOCKED');
|
|
expect(calls.some((c) => c.join(' ').includes('push -u origin'))).toBe(false);
|
|
});
|
|
|
|
test('[finding 4] a CLEAN committed tree is still secret-scanned before the deferred push', async () => {
|
|
const url = 'https://github.com/alice/test-agent-workspace-2';
|
|
writeReceipt(home, {
|
|
receipt_version: 1,
|
|
workspace_dir: ws,
|
|
source_id: 'workspace',
|
|
agent_name: 'Test Agent',
|
|
created_at: '2026-01-01T00:00:00.000Z',
|
|
created_by: '0.0.0-test',
|
|
brain_created_by_bootstrap: false,
|
|
created_paths: [],
|
|
registrations: [],
|
|
repo_url: url,
|
|
} as RepoReceipt);
|
|
writeFileSync(join(ws, 'secrets.md'), `token: sk-${'A1b2C3d4E5f6G7h8I9j0K1l2M3n4'}\n`, 'utf8');
|
|
const { runner, calls } = makeRunner(
|
|
happyRules([
|
|
{ key: 'remote get-url origin', code: 0, stdout: `${url}\n` },
|
|
{ key: 'ls-remote --heads origin', code: 0, stdout: '' }, // empty → deferred-push path
|
|
{ key: 'rev-parse --verify HEAD', code: 0, stdout: 'abc\n' }, // has a commit
|
|
{ key: 'status --porcelain', code: 0, stdout: '' }, // clean tree
|
|
{ key: 'ls-files -z', code: 0, stdout: 'secrets.md' }, // committed tree to scan
|
|
]),
|
|
);
|
|
const err = await expectBootstrapError(createPrivateRepo(ws, { runner, gbrainHomeDir: home }));
|
|
expect(err.code).toBe('SECRET_SCAN_BLOCKED');
|
|
expect(calls.some((c) => c.join(' ').includes('push -u origin'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// attachWorkspace
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('attachWorkspace', () => {
|
|
test('accepts an initialized clone: writes the receipt, returns the 4-step todo list', () => {
|
|
const result = attachWorkspace(ws, { gbrainHomeDir: home, harness: 'codex', createdBy: '0.0.0-test' });
|
|
expect(result.manifest.agent_name).toBe('Test Agent');
|
|
expect(result.steps.map((s) => s.kind)).toEqual(['register_source', 'hooks_repair', 'mcp_add', 'verify']);
|
|
const receipt = readReceipt(home);
|
|
expect(receipt).not.toBeNull();
|
|
expect(receipt?.brain_created_by_bootstrap).toBe(false); // attach adopts, never claims [CX2-12]
|
|
expect(receipt?.source_id).toBe('workspace');
|
|
expect(receipt?.created_paths).toEqual([]);
|
|
});
|
|
|
|
test('template state → TEMPLATE_UNINITIALIZED pointing at render [CX2-1]', () => {
|
|
writeManifest(ws, { ...INITIALIZED_MANIFEST, initialized: false });
|
|
try {
|
|
attachWorkspace(ws, { gbrainHomeDir: home });
|
|
throw new Error('expected refusal');
|
|
} catch (e) {
|
|
expect(e).toBeInstanceOf(BootstrapError);
|
|
expect((e as BootstrapError).code).toBe('TEMPLATE_UNINITIALIZED');
|
|
expect((e as BootstrapError).message).toContain('bootstrap render');
|
|
}
|
|
});
|
|
|
|
test('absent manifest → NOT_A_WORKSPACE', () => {
|
|
rmSync(join(ws, 'agent.json'));
|
|
try {
|
|
attachWorkspace(ws, { gbrainHomeDir: home });
|
|
throw new Error('expected refusal');
|
|
} catch (e) {
|
|
expect((e as BootstrapError).code).toBe('NOT_A_WORKSPACE');
|
|
}
|
|
});
|
|
|
|
test('conflict-markered manifest → MANIFEST_INVALID, agent-readable [G12]', () => {
|
|
writeFileSync(join(ws, 'agent.json'), '<<<<<<< HEAD\n{}\n>>>>>>> theirs\n', 'utf8');
|
|
try {
|
|
attachWorkspace(ws, { gbrainHomeDir: home });
|
|
throw new Error('expected refusal');
|
|
} catch (e) {
|
|
expect((e as BootstrapError).code).toBe('MANIFEST_INVALID');
|
|
expect((e as BootstrapError).message).toContain('conflict');
|
|
}
|
|
});
|
|
|
|
test('re-attach on the same machine preserves brain ownership + created_paths', () => {
|
|
const hooked = join(home, 'integrations', 'hooks');
|
|
mkdirSync(hooked, { recursive: true });
|
|
writeReceipt(home, {
|
|
receipt_version: 1,
|
|
workspace_dir: ws,
|
|
source_id: 'workspace',
|
|
agent_name: 'Test Agent',
|
|
created_at: '2026-01-01T00:00:00.000Z',
|
|
created_by: '0.0.0-test',
|
|
brain_created_by_bootstrap: true,
|
|
created_paths: [hooked],
|
|
registrations: [{ host: 'claude-code', scope: 'project' }],
|
|
});
|
|
attachWorkspace(ws, { gbrainHomeDir: home });
|
|
const receipt = readReceipt(home);
|
|
expect(receipt?.brain_created_by_bootstrap).toBe(true);
|
|
expect(receipt?.created_paths).toEqual([hooked]);
|
|
expect(receipt?.registrations).toEqual([{ host: 'claude-code', scope: 'project' }]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('repo helpers', () => {
|
|
test('slugifyRepoName', () => {
|
|
expect(slugifyRepoName('Test Agent')).toBe('test-agent');
|
|
expect(slugifyRepoName(' Ada :: Lovelace!! ')).toBe('ada-lovelace');
|
|
expect(slugifyRepoName('***')).toBe('agent');
|
|
});
|
|
|
|
test('parseGithubRemote handles https and ssh forms', () => {
|
|
expect(parseGithubRemote('https://github.com/alice/repo')).toEqual({ owner: 'alice', name: 'repo' });
|
|
expect(parseGithubRemote('https://github.com/alice/repo.git')).toEqual({ owner: 'alice', name: 'repo' });
|
|
expect(parseGithubRemote('git@github.com:alice/repo.git')).toEqual({ owner: 'alice', name: 'repo' });
|
|
expect(parseGithubRemote('https://gitlab.com/alice/repo')).toBeNull();
|
|
});
|
|
});
|