Files
gbrain/test/workspace-push.serial.test.ts
T
Garry TanandClaude Fable 5 1ec6a6e842 v0.45.9.0 feat(bootstrap): first-class cloud-sandbox install + per-turn persistence + fail-closed privacy ladder (#4045)
* 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>
2026-08-12 16:12:59 -07:00

605 lines
28 KiB
TypeScript

/**
* workspace-push (agent-bootstrap D6/G6/G8/G14/CX2-3/CX2-6/CX2-7/B4):
* scan-gated add→commit→pull→push against a real local bare remote.
*
* HOME + GBRAIN_HOME are redirected to a tmp dir per test;
* GBRAIN_GIT_ALLOW_FILE_TRANSPORT=1 lets the SSRF-flagged push/pull use the
* file transport (same knob as the durability serial tests). All "secrets"
* are synthetic fixtures. No serve is spawned (plain git subprocesses only),
* but every test carries an explicit timeout — bun ignores bunfig's timeout.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import {
mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync, statSync, utimesSync,
} from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { execFileSync, spawnSync } from 'child_process';
import {
workspacePush, acquirePushLock, pushLockDir, pushStatusPath, pushStatusPathForRoot,
readPushStatuses, summarizePushStatuses, verifyRemotePrivacy,
parseGithubOwnerRepo, resolveWorkspaceRoot, PUSH_LOCK_STALE_MS, PUSH_DENY_GLOBS,
} from '../src/core/workspace-push.ts';
import { SCAN_ALLOW_FILENAME } from '../src/core/secret-scan.ts';
import { visibilityCachePath } from '../src/core/repo-visibility.ts';
const T = 60_000; // explicit per-test timeout — bun ignores bunfig.toml's key
const OPENAI = 'sk-' + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4';
// #2943: env: process.env is REQUIRED — Bun snapshots env at startup, so
// spawned git would otherwise be blind to beforeEach's HOME/GBRAIN_HOME swap.
function git(cwd: string, ...args: string[]): string {
return execFileSync('git', ['-C', cwd, '-c', 'protocol.file.allow=always', ...args], {
stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8', env: process.env,
}).trim();
}
function originHead(bare: string): string {
return git(bare, 'rev-parse', 'refs/heads/main');
}
function commitCount(work: string): number {
return parseInt(git(work, 'rev-list', '--count', 'HEAD'), 10);
}
let root: string;
let work: string;
let bare: string;
let saved: Record<string, string | undefined> = {};
function makePair(opts: { identity?: boolean } = {}): void {
bare = mkdtempSync(join(root, 'origin-')) + '.git';
execFileSync('git', ['init', '-q', '--bare', '-b', 'main', bare], { stdio: 'ignore', env: process.env });
work = mkdtempSync(join(root, 'work-'));
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', bare, work], {
stdio: 'ignore', env: process.env,
});
if (opts.identity !== false) {
git(work, 'config', 'user.email', 't@t.t');
git(work, 'config', 'user.name', 'tester');
}
writeFileSync(join(work, 'README.md'), 'init\n');
git(work, 'add', 'README.md');
git(work, '-c', 'user.email=seed@t.t', '-c', 'user.name=seed', 'commit', '-qm', 'init');
git(work, 'push', '-q', 'origin', 'main');
try { git(work, 'remote', 'set-head', 'origin', 'main'); } catch { /* */ }
}
function push(extra: Record<string, unknown> = {}) {
return workspacePush({ dir: work, branch: 'main', allowUnverifiedRemote: true, ...extra });
}
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'wsp-'));
saved = {
HOME: process.env.HOME,
GBRAIN_HOME: process.env.GBRAIN_HOME,
GBRAIN_GIT_ALLOW_FILE_TRANSPORT: process.env.GBRAIN_GIT_ALLOW_FILE_TRANSPORT,
PATH: process.env.PATH,
};
process.env.HOME = mkdtempSync(join(root, 'home-'));
// CX2-8: GBRAIN_HOME is a PARENT dir → effective home is $HOME/.gbrain.
process.env.GBRAIN_HOME = process.env.HOME;
process.env.GBRAIN_GIT_ALLOW_FILE_TRANSPORT = '1';
makePair();
});
afterEach(() => {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
rmSync(root, { recursive: true, force: true });
});
describe('happy path', () => {
test('commits untracked work and pushes it to origin; status file + lock cleanup', async () => {
writeFileSync(join(work, 'note.md'), 'remember this\n');
const r = await push();
expect(r.status).toBe('pushed');
expect(r.ok).toBe(true);
expect(r.committed).toBe(true);
expect(r.ahead).toBe(0);
expect(originHead(bare)).toBe(git(work, 'rev-parse', 'HEAD'));
// B4: push-status.json written on success
const status = readPushStatuses()[0]!; // [D13] per-root file, via the shared reader
expect(status.ok).toBe(true);
expect(status.ahead).toBe(0);
expect(typeof status.ts).toBe('string');
// no leftover lock
expect(existsSync(pushLockDir(work))).toBe(false);
// status file is not world-readable
expect(statSync(readPushStatuses()[0]!.file).mode & 0o077).toBe(0);
}, T);
test('CX2-3 — a subdirectory target resolves and pushes the repo ROOT', async () => {
const sub = join(work, 'brain');
mkdirSync(sub, { recursive: true });
writeFileSync(join(sub, 'fact.md'), 'a fact\n');
const r = await workspacePush({ dir: sub, branch: 'main', allowUnverifiedRemote: true });
expect(r.status).toBe('pushed');
expect(r.repoRoot).toBe(git(work, 'rev-parse', '--show-toplevel'));
expect(git(bare, 'ls-tree', '-r', '--name-only', 'main')).toContain('brain/fact.md');
}, T);
test('pushes even on a clean tree when local is ahead (prior failed run)', async () => {
writeFileSync(join(work, 'ahead.md'), 'x\n');
git(work, 'add', 'ahead.md');
git(work, 'commit', '-qm', 'local-only');
// clean tree, local ahead of origin by 1
const r = await push();
expect(r.status).toBe('pushed');
expect(r.committed).toBe(false); // nothing new to commit
expect(r.ahead).toBe(0);
expect(originHead(bare)).toBe(git(work, 'rev-parse', 'HEAD'));
}, T);
test('sets a repo-local bootstrap identity ONLY when none exists', async () => {
makePair({ identity: false }); // fresh pair without user config (HOME is empty too)
writeFileSync(join(work, 'note.md'), 'n\n');
const r = await push();
expect(r.status).toBe('pushed');
expect(git(work, 'config', '--local', '--get', 'user.email')).toBe('bootstrap@localhost');
expect(git(work, 'config', '--local', '--get', 'user.name')).toBe('gbrain-bootstrap');
// and never overrides an existing identity
const r2 = await push({});
expect(r2.ok).toBe(true);
expect(git(work, 'config', '--local', '--get', 'user.email')).toBe('bootstrap@localhost');
}, T);
});
describe('deny-glob backstop [G6]', () => {
test('a TRACKED .env hard-fails even when .gitignore is truncated', async () => {
writeFileSync(join(work, '.env'), 'SECRETISH=value\n');
git(work, 'add', '.env');
git(work, 'commit', '-qm', 'oops tracked env');
writeFileSync(join(work, '.gitignore'), ''); // truncated .gitignore — irrelevant to tracked state
const before = originHead(bare);
const r = await push();
expect(r.status).toBe('blocked_tracked_deny');
expect(r.ok).toBe(false);
expect(r.denyMatches).toEqual(['.env']);
expect(r.reason).toContain('.env');
expect(originHead(bare)).toBe(before); // nothing pushed
// B4: status written on failure too
const status = readPushStatuses()[0]!;
expect(status.ok).toBe(false);
}, T);
test('an UNTRACKED deny match is excluded from the commit, loudly, and stays on disk', async () => {
// binary-ish pglite file + a legit note
writeFileSync(join(work, 'brain.pglite'), Buffer.from([0, 1, 2, 3, 0, 5]));
writeFileSync(join(work, 'server.key'), 'not-actually-a-key\n');
writeFileSync(join(work, 'note.md'), 'keep me\n');
const r = await push();
expect(r.status).toBe('pushed');
expect(r.excludedUntracked?.sort()).toEqual(['brain.pglite', 'server.key']);
const shipped = git(bare, 'ls-tree', '-r', '--name-only', 'main');
expect(shipped).toContain('note.md');
expect(shipped).not.toContain('brain.pglite');
expect(shipped).not.toContain('server.key');
// still on disk
expect(existsSync(join(work, 'brain.pglite'))).toBe(true);
expect(existsSync(join(work, 'server.key'))).toBe(true);
}, T);
test('the deny list covers the documented classes', () => {
expect(PUSH_DENY_GLOBS).toEqual(['*.pglite', '.env*', '*.pem', '*.key', '.gbrain/**']);
});
test('a GITIGNORED deny match is named in excludedUntracked and never reaches the index', async () => {
writeFileSync(join(work, '.gitignore'), '*.pglite\n');
writeFileSync(join(work, 'brain.pglite'), Buffer.from([0, 1, 2, 0]));
writeFileSync(join(work, 'note.md'), 'keep me\n');
const r = await push();
expect(r.status).toBe('pushed');
expect(r.excludedUntracked).toContain('brain.pglite');
const shipped = git(bare, 'ls-tree', '-r', '--name-only', 'main');
expect(shipped).toContain('note.md');
expect(shipped).toContain('.gitignore');
expect(shipped).not.toContain('brain.pglite');
// never staged, still on disk
expect(git(work, 'ls-files', '--cached')).not.toContain('brain.pglite');
expect(existsSync(join(work, 'brain.pglite'))).toBe(true);
}, T);
});
describe('secret-scan gate', () => {
test('a finding blocks the commit loudly, names file+pattern; allowlist unblocks', async () => {
writeFileSync(join(work, 'notes.md'), `my key: ${OPENAI}\n`);
const before = commitCount(work);
const lines: string[] = [];
const r = await push({ logger: (l: string) => lines.push(l) });
expect(r.status).toBe('blocked_secrets');
expect(r.ok).toBe(false);
expect(commitCount(work)).toBe(before); // NOTHING committed
expect(r.findings?.length).toBe(1);
expect(r.findings?.[0]?.file).toBe('notes.md');
expect(r.findings?.[0]?.pattern).toBe('openai');
expect(JSON.stringify(r).includes(OPENAI)).toBe(false); // value never surfaces
expect(lines.join('\n')).toContain('notes.md');
// B4: failure status written
expect(readPushStatuses()[0]!.ok).toBe(false);
// per-finding allowlist override [CX2-15]
writeFileSync(join(work, SCAN_ALLOW_FILENAME), `${r.findings![0]!.fingerprint}\n`);
const r2 = await push();
expect(r2.status).toBe('pushed');
expect(git(bare, 'ls-tree', '-r', '--name-only', 'main')).toContain('notes.md');
}, T);
test('TOCTOU: the scan reads STAGED bytes, not disk bytes (clean filter injects at stage time)', async () => {
// A `clean` filter rewrites content on `git add`, so the INDEX blob (what
// a commit would record) differs from the on-disk file — the same shape
// as a file mutated between a disk snapshot and staging. The disk file is
// benign; only the staged bytes carry the secret. A disk-based scan
// would pass this; the index-based scan must block.
writeFileSync(join(work, '.gitattributes'), 'payload.md filter=inject\n');
git(work, 'config', 'filter.inject.clean', `sed s/SECRET_PLACEHOLDER/${OPENAI}/`);
writeFileSync(join(work, 'payload.md'), 'my key: SECRET_PLACEHOLDER\n');
const before = commitCount(work);
const r = await push();
expect(r.status).toBe('blocked_secrets');
expect(r.ok).toBe(false);
expect(r.findings?.length).toBe(1);
expect(r.findings?.[0]?.file).toBe('payload.md');
expect(r.findings?.[0]?.pattern).toBe('openai');
expect(JSON.stringify(r).includes(OPENAI)).toBe(false); // value never surfaces
expect(commitCount(work)).toBe(before); // NOTHING committed
// block restored the index — nothing left staged, disk untouched
expect(git(work, 'diff', '--cached', '--name-only')).toBe('');
expect(readFileSync(join(work, 'payload.md'), 'utf-8')).toContain('SECRET_PLACEHOLDER');
}, T);
});
describe('secret-scan gate — fails CLOSED on unscannable staged blobs', () => {
test('a staged blob that fails cat-file (non-deletion) BLOCKS the push', async () => {
// A gitlink to a nested repo whose commit is not in the parent object db:
// it stages as an ADD (not a deletion) and `git cat-file -p :sub` fails —
// exactly the "cannot prove this file is clean" case that must fail closed.
const sub = join(work, 'sub');
execFileSync('git', ['init', '-q', '-b', 'main', sub], { stdio: 'ignore', env: process.env });
git(sub, 'config', 'user.email', 's@s.s');
git(sub, 'config', 'user.name', 'subtester');
writeFileSync(join(sub, 'f.txt'), 'x\n');
git(sub, 'add', 'f.txt');
git(sub, 'commit', '-qm', 'subinit');
const subSha = git(sub, 'rev-parse', 'HEAD');
git(work, 'update-index', '--add', '--cacheinfo', `160000,${subSha},sub`);
// sanity: it's an addition, and cat-file can't read it
expect(git(work, 'diff', '--cached', '--name-only', '--diff-filter=D')).toBe('');
const before = originHead(bare);
const beforeCount = commitCount(work);
const r = await push();
expect(r.status).toBe('blocked_unscannable');
expect(r.ok).toBe(false);
expect(r.unscannable?.some((u) => u.startsWith('sub'))).toBe(true);
expect(r.reason).toContain('sub');
// fail-closed = nothing committed, nothing pushed, index restored to HEAD
expect(commitCount(work)).toBe(beforeCount);
expect(originHead(bare)).toBe(before);
expect(git(work, 'diff', '--cached', '--name-only')).toBe('');
// B4: failure status recorded
expect(readPushStatuses()[0]!.ok).toBe(false);
}, T);
test('an oversized staged blob (> scan cap) BLOCKS the push', async () => {
// 26MiB > SCAN_MAX_FILE_BYTES (25MiB): too big to scan in-memory, so it
// must block rather than sail through the sole pre-push secret gate.
writeFileSync(join(work, 'big.bin'), Buffer.alloc(26 * 1024 * 1024, 0x61));
const before = originHead(bare);
const beforeCount = commitCount(work);
const r = await push();
expect(r.status).toBe('blocked_unscannable');
expect(r.ok).toBe(false);
expect(r.unscannable?.some((u) => u.startsWith('big.bin'))).toBe(true);
expect(r.reason).toContain('scan cap');
expect(commitCount(work)).toBe(beforeCount); // nothing committed
expect(originHead(bare)).toBe(before); // nothing pushed
expect(git(work, 'diff', '--cached', '--name-only')).toBe('');
}, T);
test('a staged DELETION is skipped (benign) — the push proceeds', async () => {
// README.md exists from init; delete it and add a benign note. The staged
// deletion has no content to leak, so it is skipped, not blocked.
rmSync(join(work, 'README.md'));
writeFileSync(join(work, 'note.md'), 'keep me\n');
const r = await push();
expect(r.status).toBe('pushed');
expect(r.ok).toBe(true);
const shipped = git(bare, 'ls-tree', '-r', '--name-only', 'main');
expect(shipped).toContain('note.md');
expect(shipped).not.toContain('README.md'); // deletion pushed through
}, T);
test('a binary (NUL) staged blob is scanned anyway — an embedded ASCII key still blocks', async () => {
// A NUL byte no longer means "safe": an ASCII key sitting inside a binary
// blob must still fire the gate.
writeFileSync(
join(work, 'blob.bin'),
Buffer.concat([Buffer.from([0, 1, 2, 0]), Buffer.from(`key ${OPENAI}\n`)]),
);
const r = await push();
expect(r.status).toBe('blocked_secrets');
expect(r.ok).toBe(false);
expect(r.findings?.[0]?.file).toBe('blob.bin');
expect(r.findings?.[0]?.pattern).toBe('openai');
expect(JSON.stringify(r).includes(OPENAI)).toBe(false);
}, T);
});
describe('commit-first-then-pull [CX2-7]', () => {
test('dirty local + advanced remote → both commits survive', async () => {
// Advance the remote from a second clone.
const other = mkdtempSync(join(root, 'other-'));
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', bare, other], {
stdio: 'ignore', env: process.env,
});
git(other, 'config', 'user.email', 'o@o.o');
git(other, 'config', 'user.name', 'other');
writeFileSync(join(other, 'remote.md'), 'from other\n');
git(other, 'add', 'remote.md');
git(other, 'commit', '-qm', 'remote change');
git(other, 'push', '-q', 'origin', 'main');
// Dirty MODIFICATION of a tracked file locally (the write-through shape).
writeFileSync(join(work, 'README.md'), 'modified locally\n');
const r = await push({ commitMessage: 'local change' });
expect(r.status).toBe('pushed');
const subjects = git(bare, 'log', '--format=%s', 'main');
expect(subjects).toContain('local change');
expect(subjects).toContain('remote change');
expect(git(work, 'status', '--porcelain')).toBe(''); // nothing stranded
}, T);
test('CONFLICTING divergence → pull_conflict; local commit survives, rebase aborted', async () => {
// Both clones add the same file with different content → rebase conflict.
const other = mkdtempSync(join(root, 'other-'));
execFileSync('git', ['-c', 'protocol.file.allow=always', 'clone', '-q', bare, other], {
stdio: 'ignore', env: process.env,
});
git(other, 'config', 'user.email', 'o@o.o');
git(other, 'config', 'user.name', 'other');
writeFileSync(join(other, 'shared.md'), 'remote version\n');
git(other, 'add', 'shared.md');
git(other, 'commit', '-qm', 'remote change');
git(other, 'push', '-q', 'origin', 'main');
writeFileSync(join(work, 'shared.md'), 'local version\n');
const r = await push({ commitMessage: 'local conflicting change' });
expect(r.status).toBe('pull_conflict');
expect(r.ok).toBe(false);
expect(r.committed).toBe(true);
expect(r.reason).toContain('conflict');
// local commit survives; the aborted rebase left a clean tree
expect(git(work, 'log', '--format=%s', '-1', 'HEAD')).toBe('local conflicting change');
expect(git(work, 'status', '--porcelain')).toBe('');
// origin still holds the other clone's commit — nothing force-pushed
expect(git(bare, 'log', '--format=%s', '-1', 'main')).toBe('remote change');
// B4: failure status written
expect(readPushStatuses()[0]!.ok).toBe(false);
}, T);
});
describe('remote-privacy gate [G8]', () => {
test('unverifiable visibility (non-GitHub origin) refuses with a named reason — never fail-open', async () => {
writeFileSync(join(work, 'note.md'), 'n\n');
const before = originHead(bare);
const r = await push({ allowUnverifiedRemote: false });
expect(r.status).toBe('refused_visibility');
expect(r.ok).toBe(false);
expect(r.reason).toContain('cannot verify');
expect(r.reason).toContain('--allow-unverified-remote');
// the commit was made (local durability) but NOTHING left the machine
expect(r.committed).toBe(true);
expect(originHead(bare)).toBe(before);
expect(readPushStatuses()[0]!.ok).toBe(false);
}, T);
test('verifyRemotePrivacy: gh false → not_private; gh true → private (PATH-shimmed gh, REST rung)', async () => {
const shim = mkdtempSync(join(root, 'shim-'));
git(work, 'remote', 'set-url', 'origin', 'https://github.com/acme-example/widget-co.git');
writeFileSync(join(shim, 'gh'), '#!/bin/sh\necho false\n', { mode: 0o755 });
process.env.PATH = `${shim}:${saved.PATH}`;
expect((await verifyRemotePrivacy(work)).verdict).toBe('not_private');
// CRITICAL regression guard [D10]: rest-true must reproduce the pre-ladder
// verdict exactly (private → push allowed).
writeFileSync(join(shim, 'gh'), '#!/bin/sh\necho true\n', { mode: 0o755 });
expect((await verifyRemotePrivacy(work)).verdict).toBe('private');
// The private verdict is cached [D11]; clear it so the next case exercises
// the ladder, not the cache.
rmSync(visibilityCachePath(), { force: true });
}, T);
test('verifyRemotePrivacy: cached private verdict short-circuits; cache cleared → re-verifies', async () => {
const shim = mkdtempSync(join(root, 'shim-'));
git(work, 'remote', 'set-url', 'origin', 'https://github.com/acme-example/widget-co.git');
writeFileSync(join(shim, 'gh'), '#!/bin/sh\necho true\n', { mode: 0o755 });
process.env.PATH = `${shim}:${saved.PATH}`;
expect((await verifyRemotePrivacy(work)).verdict).toBe('private');
// Break gh entirely: the fresh cache must still answer private...
writeFileSync(join(shim, 'gh'), '#!/bin/sh\nexit 1\n', { mode: 0o755 });
expect((await verifyRemotePrivacy(work)).verdict).toBe('private');
// ...and clearing it must force live re-verification. Point origin at a
// local path so the git-protocol rungs stay offline-fast: readable via
// ls-remote, no https probe surface → unverifiable, never fail-open.
rmSync(visibilityCachePath(), { force: true });
git(work, 'remote', 'set-url', 'origin', bare);
const v = await verifyRemotePrivacy(work);
expect(v.verdict).toBe('unverifiable');
git(work, 'remote', 'set-url', 'origin', 'https://github.com/acme-example/widget-co.git');
}, T);
test('parseGithubOwnerRepo handles https/.git/scp forms; non-github → null', () => {
expect(parseGithubOwnerRepo('https://github.com/a/b')).toBe('a/b');
expect(parseGithubOwnerRepo('https://github.com/a/b.git')).toBe('a/b');
expect(parseGithubOwnerRepo('git@github.com:a/b.git')).toBe('a/b');
expect(parseGithubOwnerRepo('ssh://git@github.com/a/b')).toBe('a/b');
expect(parseGithubOwnerRepo('https://gitlab.com/a/b')).toBeNull();
expect(parseGithubOwnerRepo('/tmp/origin.git')).toBeNull();
});
});
describe('single-flight lock [G14/A5, CX2-6]', () => {
test('5 concurrent invocations → exactly 1 winner, 4 clean skips, no leftover lock', async () => {
writeFileSync(join(work, 'note.md'), 'race\n');
// holdLockMs keeps the winner inside the locked section long enough that
// the other four (which start within the same tick) deterministically
// observe the held lock.
const results = await Promise.all(
Array.from({ length: 5 }, () => push({ holdLockMs: 500 })),
);
const winners = results.filter((r) => r.status === 'pushed');
const skips = results.filter((r) => r.status === 'skipped_in_flight');
expect(winners.length).toBe(1);
expect(skips.length).toBe(4);
for (const s of skips) {
expect(s.ok).toBe(true); // clean skip — exit 0 at the CLI
expect(s.lockHolderPid).toBe(process.pid);
expect(s.reason).toContain('push in flight');
}
expect(existsSync(pushLockDir(work))).toBe(false); // no leftover lock
expect(originHead(bare)).toBe(git(work, 'rev-parse', 'HEAD'));
}, T);
test('stale-lock steal requires BOTH dead pid AND old age', () => {
const lockDir = pushLockDir(work);
mkdirSync(lockDir, { recursive: true });
const oldIso = new Date(Date.now() - PUSH_LOCK_STALE_MS - 60_000).toISOString();
const freshIso = new Date().toISOString();
const deadPid = spawnSync('true', { stdio: 'ignore' }).pid!; // exited + reaped → ESRCH
// live pid + old age → REFUSED (a live holder is never stolen)
writeFileSync(join(lockDir, 'owner.json'), JSON.stringify({ pid: process.pid, acquired_at: oldIso, token: 't1' }));
let r = acquirePushLock(work);
expect(r.acquired).toBe(false);
if (!r.acquired) expect(r.holderPid).toBe(process.pid);
// dead pid + young age → REFUSED (may be a mid-acquisition peer)
writeFileSync(join(lockDir, 'owner.json'), JSON.stringify({ pid: deadPid, acquired_at: freshIso, token: 't2' }));
r = acquirePushLock(work);
expect(r.acquired).toBe(false);
// dead pid + old age → STOLEN
writeFileSync(join(lockDir, 'owner.json'), JSON.stringify({ pid: deadPid, acquired_at: oldIso, token: 't3' }));
r = acquirePushLock(work);
expect(r.acquired).toBe(true);
if (r.acquired) {
const owner = JSON.parse(readFileSync(join(lockDir, 'owner.json'), 'utf-8'));
expect(owner.pid).toBe(process.pid);
r.handle.release();
}
expect(existsSync(lockDir)).toBe(false);
}, T);
test('a corrupt owner file is never stolen while young (mtime fallback)', () => {
const lockDir = pushLockDir(work);
mkdirSync(lockDir, { recursive: true });
writeFileSync(join(lockDir, 'owner.json'), 'not json{{{');
expect(acquirePushLock(work).acquired).toBe(false);
// …but IS stolen once the dir itself is stale (dead-by-absence + old age)
const old = (Date.now() - PUSH_LOCK_STALE_MS - 60_000) / 1000;
utimesSync(lockDir, old, old);
const r = acquirePushLock(work);
expect(r.acquired).toBe(true);
if (r.acquired) r.handle.release();
}, T);
});
describe('error paths', () => {
test('not a git repository → typed error result', async () => {
const plain = mkdtempSync(join(root, 'plain-'));
const r = await workspacePush({ dir: plain, allowUnverifiedRemote: true });
expect(r.ok).toBe(false);
expect(r.status).toBe('error');
expect(r.reason).toContain('not a git repository');
expect(resolveWorkspaceRoot(plain)).toBeNull();
}, T);
test('unreachable origin → push_failed with ahead count + failure status file', async () => {
git(work, 'remote', 'set-url', 'origin', join(root, 'gone.git'));
writeFileSync(join(work, 'note.md'), 'n\n');
const r = await push();
expect(r.status).toBe('push_failed');
expect(r.ok).toBe(false);
expect(r.committed).toBe(true); // commit survives locally
const status = readPushStatuses()[0]!;
expect(status.ok).toBe(false);
expect(existsSync(pushLockDir(work))).toBe(false);
}, T);
});
describe('unverified-remote escape hatches [D18/S3]', () => {
test('env hatch: unverifiable origin + GBRAIN_ALLOW_UNVERIFIED_REMOTE=1 → pushed (WARN path)', async () => {
process.env.GBRAIN_ALLOW_UNVERIFIED_REMOTE = '1';
try {
writeFileSync(join(work, 'note.md'), 'env hatch\n');
// file origin → ladder unverifiable; env hatch downgrades it to allowed.
const r = await workspacePush({ dir: work, branch: 'main' });
expect(r.ok).toBe(true);
expect(r.status).toBe('pushed');
} finally {
delete process.env.GBRAIN_ALLOW_UNVERIFIED_REMOTE;
}
}, T);
test('config hatch: push.allow_unverified_remote=true in the file plane → pushed', async () => {
mkdirSync(join(process.env.HOME!, '.gbrain'), { recursive: true });
writeFileSync(
join(process.env.HOME!, '.gbrain', 'config.json'),
JSON.stringify({ engine: 'pglite', push: { allow_unverified_remote: true } }),
);
writeFileSync(join(work, 'note2.md'), 'config hatch\n');
const r = await workspacePush({ dir: work, branch: 'main' });
expect(r.ok).toBe(true);
expect(r.status).toBe('pushed');
}, T);
test('CRITICAL [S3]: a hatch never covers a PROVEN-PUBLIC origin — still refused', async () => {
const shim = mkdtempSync(join(root, 'shim-pub-'));
writeFileSync(join(shim, 'gh'), '#!/bin/sh\necho false\n', { mode: 0o755 });
process.env.PATH = `${shim}:${saved.PATH}`;
process.env.GBRAIN_ALLOW_UNVERIFIED_REMOTE = '1';
try {
git(work, 'remote', 'set-url', 'origin', 'https://github.com/acme-example/widget-co.git');
writeFileSync(join(work, 'note3.md'), 'must not leave\n');
const r = await workspacePush({ dir: work, branch: 'main' });
expect(r.ok).toBe(false);
expect(r.status).toBe('refused_visibility');
expect(r.reason).toContain('does NOT cover proven-public');
} finally {
delete process.env.GBRAIN_ALLOW_UNVERIFIED_REMOTE;
git(work, 'remote', 'set-url', 'origin', bare);
}
}, T);
});
describe('per-root masking [D13]', () => {
test('one root failing + another succeeding: the failure is never masked (reader + summarize)', async () => {
const otherRoot = mkdtempSync(join(root, 'other-ws-'));
mkdirSync(join(process.env.HOME!, '.gbrain', 'bootstrap'), { recursive: true });
writeFileSync(
pushStatusPathForRoot(otherRoot),
JSON.stringify({ ts: new Date().toISOString(), ok: false, reason: 'refused_visibility', repoRoot: otherRoot }) + '\n',
);
writeFileSync(join(work, 'ok.md'), 'fine\n');
const r = await push();
expect(r.ok).toBe(true); // this workspace pushed fine…
const entries = readPushStatuses();
const { failing } = summarizePushStatuses(entries);
expect(failing).toHaveLength(1); // …and the OTHER root's failure survives
expect(failing[0]!.repoRoot).toBe(otherRoot);
}, T);
test('ghost roots are filtered: a failing record for a DELETED workspace stops feeding the surfaces', async () => {
const ghost = mkdtempSync(join(root, 'ghost-ws-'));
mkdirSync(join(process.env.HOME!, '.gbrain', 'bootstrap'), { recursive: true });
writeFileSync(
pushStatusPathForRoot(ghost),
JSON.stringify({ ts: new Date().toISOString(), ok: false, reason: 'refused_visibility', repoRoot: ghost }) + '\n',
);
rmSync(ghost, { recursive: true, force: true });
const { failing } = summarizePushStatuses(readPushStatuses());
expect(failing).toHaveLength(0);
}, T);
});