Files
gbrain/test/durability-cron.test.ts
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

157 lines
6.8 KiB
TypeScript

/**
* Durability cron generators (v0.42.44, D2 + D12): pure-string renderers.
* Asserts the cron is DB-free (gbrain sources pull --path, NOT `pull <id>`),
* secret-free, self-disabling, and that the launchd plist is periodic.
*/
import { describe, test, expect } from 'bun:test';
import { mkdtempSync, mkdirSync, writeFileSync, utimesSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { withEnv } from './helpers/with-env.ts';
import {
renderCronWrapper,
generateBrainPullPlist,
installDurabilityCron,
durabilityJobStatus,
} from '../src/core/brain-repo-durability.ts';
const TOKEN = 'ghp_SHOULD_NEVER_APPEAR';
describe('renderCronWrapper (D2 DB-free)', () => {
const w = renderCronWrapper('wiki', '/data/clones/wiki', 'main', '/usr/local/bin/gbrain', '/home/u/.gbrain/brain-push.log');
test('calls the DB-free path command, not the engine-opening one', () => {
expect(w).toContain("sources pull --path '/data/clones/wiki'");
expect(w).toContain("--branch 'main'");
expect(w).not.toMatch(/sources pull '?wiki'?(\s|$)/); // never `sources pull wiki`
});
test('self-disables via git rev-parse (recognizes worktrees where the git marker is a FILE), not a bare dir test', () => {
expect(w).toContain("if ! git -C '/data/clones/wiki' rev-parse --is-inside-work-tree");
expect(w).not.toContain("-d '/data/clones/wiki/.git'");
expect(w).not.toContain("-d '/data/clones/wiki'");
expect(w).toContain('not a git work tree, skipping');
});
test('sources the shell profile (secret-free) and never bakes a token', () => {
expect(w).toContain('source ~/.zshenv');
expect(w.includes(TOKEN)).toBe(false);
});
});
describe('generateBrainPullPlist (D12 launchd)', () => {
const plist = generateBrainPullPlist('com.gbrain.brain-pull.wiki', '/home/u/.gbrain/brain-pull-wiki.sh', '/home/u', 1800);
test('is periodic (StartInterval), not a KeepAlive daemon', () => {
expect(plist).toContain('<key>StartInterval</key><integer>1800</integer>');
expect(plist).not.toContain('<key>KeepAlive</key>');
});
test('carries the per-source label and the wrapper path only (no secret)', () => {
expect(plist).toContain('<string>com.gbrain.brain-pull.wiki</string>');
expect(plist).toContain('/home/u/.gbrain/brain-pull-wiki.sh');
expect(plist.includes(TOKEN)).toBe(false);
});
});
describe('installDurabilityCron — crontab probe [B2/D-cloud]', () => {
test('crontab absent on a non-darwin host → skipped (expected in containers), never needs_attention', async () => {
const empty = mkdtempSync(join(tmpdir(), 'no-bin-'));
const home = mkdtempSync(join(tmpdir(), 'gb-cron-'));
await withEnv({ PATH: empty, GBRAIN_HOME: home }, async () => {
const r = installDurabilityCron('wiki', '/data/clones/wiki', 'main', 1800, false, 'linux');
expect(r.status).toBe('skipped');
expect(r.detail).toContain('no crontab on this host');
expect(r.detail).toContain('post-commit auto-push');
});
});
test('crontab present but failing → needs_attention (a real breakage stays loud)', async () => {
const shim = mkdtempSync(join(tmpdir(), 'shim-cron-'));
// -l lists empty; writing the new tab (crontab -) fails.
writeFileSync(join(shim, 'crontab'), '#!/bin/sh\ncase "$1" in -l) exit 0;; esac\nexit 1\n', { mode: 0o755 });
const home = mkdtempSync(join(tmpdir(), 'gb-cron2-'));
await withEnv({ PATH: `${shim}:${process.env.PATH ?? ''}`, GBRAIN_HOME: home }, async () => {
const r = installDurabilityCron('wiki', '/data/clones/wiki', 'main', 1800, false, 'linux');
expect(r.status).toBe('needs_attention');
expect(r.detail).toContain('crontab install failed');
});
});
test('dry-run on a crontab-less host still reports the honest skip', async () => {
const empty = mkdtempSync(join(tmpdir(), 'no-bin2-'));
await withEnv({ PATH: empty }, async () => {
const r = installDurabilityCron('wiki', '/data/clones/wiki', 'main', 1800, true, 'linux');
expect(r.status).toBe('skipped');
});
});
});
describe('durabilityJobStatus — presence + liveness [D7]', () => {
test('no scheduler binaries at all → kind none (never throws)', async () => {
const empty = mkdtempSync(join(tmpdir(), 'no-bin3-'));
const home = mkdtempSync(join(tmpdir(), 'jb-home-'));
await withEnv({ PATH: empty, HOME: home }, async () => {
const s = durabilityJobStatus('wiki', 1800, 'linux');
expect(s.kind).toBe('none');
expect(s.wrapperPresent).toBe(false);
});
});
test('crontab line present (shim) → kind crontab, live', async () => {
const shim = mkdtempSync(join(tmpdir(), 'shim-jb-'));
writeFileSync(
join(shim, 'crontab'),
'#!/bin/sh\ncase "$1" in -l) echo "*/30 * * * * /x.sh # com.gbrain.brain-pull.wiki"; exit 0;; esac\nexit 1\n',
{ mode: 0o755 },
);
const home = mkdtempSync(join(tmpdir(), 'jb-home2-'));
await withEnv({ PATH: shim, HOME: home }, async () => {
const s = durabilityJobStatus('wiki', 1800, 'linux');
expect(s.kind).toBe('crontab');
expect(s.live).toBe(true);
});
});
test('stale pull log is reported (logFresh false)', async () => {
const empty = mkdtempSync(join(tmpdir(), 'no-bin4-'));
const home = mkdtempSync(join(tmpdir(), 'jb-home3-'));
const logDir = join(home, '.gbrain');
// A log last touched 3 hours ago against a 30-min interval.
mkdirSync(logDir, { recursive: true });
const log = join(logDir, 'brain-pull.log');
writeFileSync(log, 'old\n');
const old = new Date(Date.now() - 3 * 60 * 60 * 1000);
utimesSync(log, old, old);
await withEnv({ PATH: empty, HOME: home }, async () => {
const s = durabilityJobStatus('wiki', 1800, 'linux');
expect(s.logFresh).toBe(false);
});
});
});
describe('durabilityJobStatus — darwin launchd liveness [D7]', () => {
async function darwinFixture(launchctlExit: number): Promise<ReturnType<typeof durabilityJobStatus>> {
const home = mkdtempSync(join(tmpdir(), 'jb-mac-'));
const plistDir = join(home, 'Library', 'LaunchAgents');
mkdirSync(plistDir, { recursive: true });
writeFileSync(join(plistDir, 'com.gbrain.brain-pull.wiki.plist'), '<plist/>');
const shim = mkdtempSync(join(tmpdir(), 'shim-lc-'));
writeFileSync(join(shim, 'launchctl'), `#!/bin/sh\nexit ${launchctlExit}\n`, { mode: 0o755 });
return withEnv({ PATH: shim, HOME: home }, async () => durabilityJobStatus('wiki', 1800, 'darwin'));
}
test('plist present + launchctl reports loaded → live', async () => {
const s = await darwinFixture(0);
expect(s.kind).toBe('launchd');
expect(s.live).toBe(true);
});
test('plist present but NOT loaded (the dead-job shape [D7]) → live=false', async () => {
const s = await darwinFixture(1);
expect(s.kind).toBe('launchd');
expect(s.live).toBe(false);
});
});