mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
* feat(core): execution-environment detection — local | cloud-sandbox | ephemeral-container detectExecutionEnvironment() + isCredentialInjectingProxy() with injected signals (CLAUDE_CODE_REMOTE, cse_ session-id prefix, proxy-injected token placeholder, anthropic-egress proxy JWT, container markers). binaryOnPath moves here as the canonical PATH probe. autopilot's detectInstallTarget ephemeral branch now routes through the shared detector. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bootstrap): repo-visibility ladder + per-turn Stop push + same-session failure banner The durability lane. One repo-visibility verdict for every consumer (REST first — never GraphQL, which sandbox proxies pin; authed ls-remote + an attributed anonymous probe as the git-protocol fallback), replacing three drifted probes. A 200 counts as public only with advertisement proof; a 401/404 counts as private-signal only with an auth challenge — fail-closed in BOTH directions. Private verdicts cache 1h (private-only, per origin). gbrain hook stop now spawns a debounced detached push per turn (per-root state; cloud-sandbox defaults to every turn, elsewhere 5 min; a failing status bypasses the debounce), closing the /exit and VM-reclaim gaps. Push status is per workspace root, read through one shared reader by the user-prompt banner (additionalContext + systemMessage — visible to the human, not just the model), the SessionStart note, and doctor. Escape hatches for self-hosted git: flag > env > file-plane config key. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bootstrap): environment-aware install — honest cron skip, cloud repo-create guard, execution_env surfaces installDurabilityCron probes for crontab before writing anything (containers and cloud sandboxes ship without one — expected, reported as an honest skip naming the event-driven pushes that still cover persistence). The repo phase installs the container-friendly harden half (post-commit hook, no scheduler) outside local machines. createPrivateRepo fails fast in cloud sandboxes with the flow that works (create outside, open the session ON the repo, attach). bootstrap verify gains a never-gating execution_env check; bootstrap status --json carries execution_environment for installing agents to branch on. Also fixes a live-PATH resolution class: binaryOnPath and the crontab execs now pass the current env explicitly (Bun resolves against the startup snapshot otherwise). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bootstrap): lifecycle hygiene — uninstall teardown, doctor job liveness, .mcp.json out of the repo, honest persistence copy uninstall now tears down the durability wiring it installed (launchd/cron job, untracked post-commit hook, credential wiring — the committed helper and AGENTS rules stay). doctor gains bootstrap_durability_job: presence + LIVENESS (launchctl load-state, crontab line, pull-log freshness) — a plist on disk with a dead job no longer reads as healthy. Rendered .gitignore now covers .mcp.json (absolute machine paths must not land in the private repo); verify warns on pre-fix installs that committed it; the never-built state/mcp.json promise is gone from GITHUB.md. Persistence copy tells the truth everywhere: event-driven pushes do the durability work, the 30-min job is a multi-machine pull freshener. Post-commit hook install/removal is worktree-safe (git-path resolution; the git marker is a FILE there) and the cron wrapper's self-disable tests the repo dir, not its git marker. Two follow-up TODOs filed (plugin hook distribution; Channels push lane). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bootstrap): committed hook carrier for cloud + cloud-setup-script emitter + cloud runbook Cloud sessions clone fresh and snapshot hook config at session start — the gitignored settings.local.json never exists there, so hooks never fired in cloud at all. Cloud installs now write the repo-COMMITTED .claude/settings.json with PATH-resolved, fail-open commands (no machine paths; a host without the binary no-ops); local installs keep settings.local.json; the writers enforce that one event never fires from both carriers, and removal cleans both. New: gbrain bootstrap cloud-setup-script prints the paste-ready environment setup script (npm transport — bun fetching is proxy-incompatible in cloud; never the unrelated npm-registry package). Runbook gains a NEVER FABRICATE TOOLING hard rule, a cloud-sandbox section (expected degradations as facts to relay, the attach-first flow), and failure-table rows for the proxy-403 and missing-crontab signatures. Codex lane stated honestly: AGENTS.md Gate 2 now has the pull-side push-health check. Guide documents the new knobs and the cloud contract; llms bundles regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(bootstrap): per-turn push e2e chain, cloud-sandbox sim, per-root status reader migration + registry regen bootstrap-persistence e2e gains the Stop-hook chain (one stop banks the turn to the real bare remote; the debounce holds across stops; debounce-0 lands consecutive turns) and fixes a pre-existing post-#4024 break: repoPhaseComplete required a github-parseable origin, so session-end pushes deferred FOREVER for self-hosted/file-transport origins — non-github repo_urls now bind by exact URL equality (redirect protection preserved). Degraded-modes e2e gains the cloud-sandbox simulation (status reports the environment; repo creation refuses with the attach flow). workspace-push tests read per-root status through the shared reader. Flag registry regenerated (new git argv literals from the git-path/ls-files calls — the accepted argv-bleed class). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(doctor): categorize bootstrap_durability_job (categories drift guard) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bootstrap): adversarial-review hardening — fail-closed privacy, no push-storm, no exfil paths Both Claude and Codex adversarial passes ran; 11 findings fixed (union): - Non-github 401+WWW-Authenticate no longer reads as private (RFC 7235 makes the header mandatory on every 401, so a middlebox 401s identically) — the top exfil path both models flagged; now unverifiable/fail-closed, operator confirms via the escape hatch. github.com still needs x-github-request-id. - Escape hatches downgrade ONLY 'unverifiable' — a PROVEN-public origin still refuses (hatches never authorize a public push). - treeNeedsPush measures against origin/<branch> (the push's own ref), not @{u}: a no-upstream branch no longer reports a committed-but-unpushed tree as push_clean and silently strands it. - [D20] failing-retry uses a fixed 60s floor, not min(debounce,60s) — cloud debounce=0 no longer re-runs the network ladder every turn. - Committed hook carrier: dedupe/suppress only on the EXACT portable-command shape, not a 'gbrain hook' substring (blocks the suppress-local-run-evil supply-chain vector); GBRAIN_HOME refused in the committed carrier. - push-status reason sanitized (charset+length) at every surface (banner, doctor, status blob) so remote git stderr can't inject via the remediation. - Per-root state: ghost-root records (deleted workspaces) filtered so a dead failing record can't re-fire the banner forever; uninstall removes them. - statusReport support blob reads push status through the shared per-root reader; visibility cache strips URL userinfo (no PAT persisted). - anonProbe strips userinfo + redirect:manual + SSRF flags + --end-of-options on ls-remote; cron self-disable uses git rev-parse (worktree-safe both ways); cloud-setup-script fails loud on a broken update; durability liveness won't certify a never-run crontab as live; config get/unset resolve the dotted file-plane keys; .bak/.broken gitignored; typed config fields. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * v0.45.8.0 feat(bootstrap): first-class cloud-sandbox install + per-turn persistence + fail-closed privacy ladder VERSION + package.json + CHANGELOG + TODOS + llms bundles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: sync KEY_FILES + README for the cloud-DX wave (v0.45.8.0) New KEY_FILES entries for execution-env.ts and repo-visibility.ts; updated the bootstrap repo/hooks, workspace-push, brain-repo-durability, and hook.ts entries to current behavior (ladder verification, committed cloud hook carrier, per-root push status, crontab probe + liveness, per-turn stop push + banner). README's Claude Code line now states per-turn + cloud persistence honestly. llms bundles regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bootstrap): doc-review pass — attach records repo_url (enables cloud persistence), verify reads per-root push status, honest docs Cross-model doc review (Codex) caught that the headline cloud-persistence claim was hollow and two docs over-claimed: - attach now records repo_url from the adopted origin, so the no-daemon push gate (repoPhaseComplete) recognizes the repo phase as done — WITHOUT this, the per-turn/session-end pushes deferred forever after an attach, which is the ONLY install path in a cloud sandbox (repo is refused there). Privacy is still enforced at push time by the ladder. - bootstrap verify's push_probe reads the shared per-root reader [D8], not the legacy single file — a fresh v0.45.8 install no longer reports 'no push recorded' when per-root status exists. - Docs corrected to match code: runbook stamp → 0.45.8.0; README states the per-turn cadence honestly (debounced local, next-turn failure notice); GITHUB.md qualifies auto-push as Claude Code (Codex is pull); KEY_FILES says repo/status use REST (not the full ladder) and drops the stale --push; bootstrap.md hooks-location covers both carriers; README verbs surface says seven. llms bundles regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bootstrap): regenerate vendored template-repo tree + isolate env-mutating tests (verify gate) The verify CI gate caught two things the piecemeal local runs missed: - templates/bootstrap/template-repo/ is a GENERATED tree; the source-template edits (AGENTS.md Gate 2, CLAUDE.md cloud note) plus the version stamp had drifted. Regenerated from source (check:bootstrap-templates green). - test/bootstrap-repo.test.ts + test/durability-cron.test.ts newly mutated process.env in non-serial files; converted to withEnv() (check:test-isolation green) — no .serial rename needed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(ambient-recall): pin a keyless gateway so delta/context_pack writes never fire a real embed Root cause of the shard-2 CI red after the 0.45.9.0 re-bump: adding this wave's test files reshuffled the weight-packed shards, moving ambient-recall.test.ts next to a neighbor that leaks CI's dummy OPENAI_API_KEY (sk-test-*) into the gateway singleton (the bunfig preload configures with env:{...process.env}, and a present-but-invalid key turns remember's keyless-degrade embed into a hard 401). The delta/context_pack tests exercise cursor + budget logic, not embedding quality, so this pins a keyless gateway (env:{}) in beforeAll — isAvailable('embedding') is false, writeSingleFact degrades (degraded_dedup) with no HTTP call, and the file is deterministic regardless of shard bin-packing. Verified: passes with OPENAI_API_KEY=sk-test-* set (the CI condition). Master's own comment in legacy-embedding-preload.ts already warns 'adding any test file reshuffles the mines'; this makes the file immune. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
371 lines
14 KiB
TypeScript
371 lines
14 KiB
TypeScript
/**
|
|
* bootstrap status — phase list [D5], install log [B1], runbook skew [C1],
|
|
* support blob [B5], and the CLI reachability membership asserts (#2035
|
|
* precedent, extended per ENG-2 for the bootstrap family).
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import { runBootstrap } from '../src/commands/bootstrap.ts';
|
|
|
|
import { CLI_ONLY, THIN_CLIENT_REFUSED_COMMANDS } from '../src/cli.ts';
|
|
import { VERSION } from '../src/version.ts';
|
|
import {
|
|
BOOTSTRAP_PHASE_IDS,
|
|
PHASES,
|
|
appendInstallLog,
|
|
readInstallLog,
|
|
installLogPath,
|
|
readRunbookStamp,
|
|
listVerifyRuns,
|
|
statusReport,
|
|
} from '../src/core/bootstrap/status.ts';
|
|
import { initState, setAnswer, confirm, readBackHash } from '../src/core/bootstrap/interview.ts';
|
|
import { writeManifest } from '../src/core/bootstrap/format.ts';
|
|
import { loadQuestionBank } from '../src/core/bootstrap/assets.ts';
|
|
|
|
let tmpParent: string; // GBRAIN_HOME parent (configDir appends .gbrain)
|
|
let home: string;
|
|
let ws: string;
|
|
let prevHome: string | undefined;
|
|
|
|
beforeAll(() => {
|
|
tmpParent = mkdtempSync(join(tmpdir(), 'gb-status-'));
|
|
home = join(tmpParent, '.gbrain');
|
|
mkdirSync(home, { recursive: true });
|
|
ws = mkdtempSync(join(tmpdir(), 'gb-status-ws-'));
|
|
prevHome = process.env.GBRAIN_HOME;
|
|
process.env.GBRAIN_HOME = tmpParent;
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (prevHome === undefined) delete process.env.GBRAIN_HOME;
|
|
else process.env.GBRAIN_HOME = prevHome;
|
|
rmSync(tmpParent, { recursive: true, force: true });
|
|
rmSync(ws, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('phase list is the single TS source of truth [D5]', () => {
|
|
test('PHASES matches BOOTSTRAP_PHASE_IDS exactly, in order', () => {
|
|
expect(PHASES.map((p) => p.id)).toEqual([...BOOTSTRAP_PHASE_IDS]);
|
|
expect(BOOTSTRAP_PHASE_IDS).toEqual([
|
|
'preflight', 'engine', 'interview', 'render', 'skills', 'wire', 'repo', 'verify',
|
|
]);
|
|
});
|
|
|
|
test('every phase carries a title, a resume hint, and a detector', () => {
|
|
for (const p of PHASES) {
|
|
expect(p.title.length).toBeGreaterThan(0);
|
|
expect(p.resume_hint.length).toBeGreaterThan(0);
|
|
expect(typeof p.detect).toBe('function');
|
|
}
|
|
});
|
|
|
|
test('wire hint carries the harness scope rule (Claude Code consent vs Codex user-global)', () => {
|
|
const wire = PHASES.find((p) => p.id === 'wire');
|
|
expect(wire?.resume_hint).toContain('MCP scope consent is Claude Code only');
|
|
expect(wire?.resume_hint).toContain('Codex registrations are always user-global (no scope flag)');
|
|
});
|
|
});
|
|
|
|
describe('CLI reachability membership (#2035 shape, ENG-2)', () => {
|
|
test('bootstrap, hook, and sweep are in CLI_ONLY so dispatch reaches them', () => {
|
|
expect(CLI_ONLY.has('bootstrap')).toBe(true);
|
|
expect(CLI_ONLY.has('hook')).toBe(true);
|
|
expect(CLI_ONLY.has('sweep')).toBe(true);
|
|
});
|
|
|
|
test('bootstrap and hook are NOT thin-client refused; sweep IS (local engine only)', () => {
|
|
expect(THIN_CLIENT_REFUSED_COMMANDS.has('bootstrap')).toBe(false);
|
|
expect(THIN_CLIENT_REFUSED_COMMANDS.has('hook')).toBe(false);
|
|
expect(THIN_CLIENT_REFUSED_COMMANDS.has('sweep')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('install log [B1]', () => {
|
|
test('append + read round-trip, torn lines skipped, never throws', () => {
|
|
appendInstallLog(home, {
|
|
ts: new Date().toISOString(),
|
|
phase: 'render',
|
|
outcome: 'ok',
|
|
duration_ms: 12,
|
|
binary_version: VERSION,
|
|
workspace: ws,
|
|
});
|
|
// Torn line in the middle must not break the reader.
|
|
writeFileSync(installLogPath(home), '{"half\n', { flag: 'a' });
|
|
appendInstallLog(home, {
|
|
ts: new Date().toISOString(),
|
|
phase: 'wire',
|
|
outcome: 'error',
|
|
duration_ms: 5,
|
|
binary_version: VERSION,
|
|
harness: 'claude-code',
|
|
workspace: ws,
|
|
});
|
|
const entries = readInstallLog(home);
|
|
expect(entries.length).toBe(2);
|
|
expect(entries[0].phase).toBe('render');
|
|
expect(entries[1].phase).toBe('wire');
|
|
expect(entries[1].harness).toBe('claude-code');
|
|
});
|
|
|
|
test('reader returns [] for a missing log', () => {
|
|
expect(readInstallLog(join(tmpdir(), 'no-such-gbrain-home'))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('runbook stamp [C1]', () => {
|
|
test('reads the stamp comment; null when absent', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'gb-runbook-'));
|
|
expect(readRunbookStamp(dir)).toBeNull();
|
|
writeFileSync(join(dir, 'BOOTSTRAP_FOR_AGENTS.md'), '<!-- gbrain-runbook-stamp: 0.0.1 -->\n# runbook\n');
|
|
expect(readRunbookStamp(dir)).toBe('0.0.1');
|
|
rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe('statusReport detection + support blob [B5]', () => {
|
|
test('fresh workspace: engine/interview/render/verify pending, next action present', async () => {
|
|
const report = await statusReport(ws, { gbrainHomeDir: home });
|
|
const byId = new Map(report.phases.map((p) => [p.id, p]));
|
|
expect(byId.get('engine')!.state).toBe('pending');
|
|
expect(byId.get('interview')!.state).toBe('pending');
|
|
expect(byId.get('render')!.state).toBe('pending');
|
|
expect(byId.get('verify')!.state).toBe('pending');
|
|
expect(report.next).toBeTruthy();
|
|
// The third axis [D-cloud]: installing agents branch on this field.
|
|
expect(['local', 'cloud-sandbox', 'ephemeral-container']).toContain(report.execution_environment);
|
|
expect(report.support.binary_version).toBe(VERSION);
|
|
expect(report.support.engine).toBeNull();
|
|
expect(report.support.harness_registrations).toEqual([]);
|
|
});
|
|
|
|
test('artifacts flip phases: config → engine done; confirmed interview → done; manifest → render done', async () => {
|
|
// engine: config file in the sandboxed home.
|
|
writeFileSync(join(home, 'config.json'), JSON.stringify({ engine: 'pglite', database_path: join(home, 'brain.pglite') }));
|
|
|
|
// interview: answer every required key, then confirm the read-back hash.
|
|
const bank = loadQuestionBank();
|
|
const required = bank.interviewKeys.filter((k) => bank.questions[k]?.required === true);
|
|
expect(initState(ws).ok).toBe(true);
|
|
for (const key of required) {
|
|
const r = setAnswer(ws, key, `verbatim answer for ${key} with enough substance to be real`);
|
|
expect(r.ok).toBe(true);
|
|
}
|
|
const h = readBackHash(ws);
|
|
if (!h.ok) throw new Error('readBackHash failed');
|
|
expect(confirm(ws, h.hash).ok).toBe(true);
|
|
|
|
// render: initialized manifest.
|
|
writeManifest(ws, {
|
|
format_version: 1,
|
|
initialized: true,
|
|
agent_name: 'Testy',
|
|
created_by: 'test',
|
|
created_at: new Date().toISOString(),
|
|
source_id: 'workspace',
|
|
});
|
|
|
|
// verify: one persisted failing snapshot → verify phase partial.
|
|
mkdirSync(join(home, 'bootstrap'), { recursive: true });
|
|
writeFileSync(
|
|
join(home, 'bootstrap', 'verify-2026-01-01T00-00-00-000Z.json'),
|
|
JSON.stringify({ ts: '2026-01-01T00:00:00.000Z', ok: false, checks: [{ id: 'roundtrip', ok: false, detail: 'x' }] }),
|
|
);
|
|
|
|
const report = await statusReport(ws, { gbrainHomeDir: home });
|
|
const byId = new Map(report.phases.map((p) => [p.id, p]));
|
|
expect(byId.get('engine')!.state).toBe('done');
|
|
expect(byId.get('interview')!.state).toBe('done');
|
|
expect(byId.get('render')!.state).toBe('done');
|
|
expect(byId.get('verify')!.state).toBe('partial');
|
|
expect(report.support.engine).toBe('pglite');
|
|
expect(report.support.last_verify).toEqual({
|
|
ts: '2026-01-01T00:00:00.000Z',
|
|
ok: false,
|
|
checks_failed: ['roundtrip'],
|
|
});
|
|
// listVerifyRuns agrees with the support blob.
|
|
const runs = listVerifyRuns(home);
|
|
expect(runs.length).toBe(1);
|
|
expect(runs[0].ok).toBe(false);
|
|
});
|
|
|
|
test('runbook skew surfaces when the stamp disagrees with the binary', async () => {
|
|
writeFileSync(join(ws, 'BOOTSTRAP_FOR_AGENTS.md'), '<!-- gbrain-runbook-stamp: 0.0.1 -->\n');
|
|
const report = await statusReport(ws, { gbrainHomeDir: home });
|
|
expect(report.runbookSkew).toEqual({ runbookStamp: '0.0.1', binaryVersion: VERSION });
|
|
|
|
// Matching stamp → no skew reported.
|
|
writeFileSync(join(ws, 'BOOTSTRAP_FOR_AGENTS.md'), `<!-- gbrain-runbook-stamp: ${VERSION} -->\n`);
|
|
const clean = await statusReport(ws, { gbrainHomeDir: home });
|
|
expect(clean.runbookSkew).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('template-door privacy gate (recording gh shim)', () => {
|
|
let tws: string; // template-clone workspace with an origin remote
|
|
let shimDir: string;
|
|
let recordFile: string;
|
|
let savedPath: string | undefined;
|
|
let savedRecord: string | undefined;
|
|
|
|
beforeAll(() => {
|
|
tws = mkdtempSync(join(tmpdir(), 'gb-status-tmpl-'));
|
|
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: tws });
|
|
execFileSync('git', ['remote', 'add', 'origin', 'https://github.com/tester/agent-template.git'], { cwd: tws });
|
|
// Uninitialized template clone — the gate's trigger state [CX2-1].
|
|
writeManifest(tws, {
|
|
format_version: 1,
|
|
initialized: false,
|
|
agent_name: '{{AGENT_NAME}}',
|
|
created_by: 'template',
|
|
created_at: '1970-01-01T00:00:00.000Z',
|
|
source_id: 'workspace',
|
|
});
|
|
|
|
shimDir = mkdtempSync(join(tmpdir(), 'gb-status-shim-'));
|
|
recordFile = join(shimDir, 'record.log');
|
|
savedPath = process.env.PATH;
|
|
savedRecord = process.env.GB_FAKE_RECORD;
|
|
process.env.GB_FAKE_RECORD = recordFile;
|
|
// Recording gh shim: answer comes from the GB_FAKE_GH_ANSWER env var.
|
|
writeFileSync(
|
|
join(shimDir, 'gh'),
|
|
`#!/bin/sh
|
|
echo "gh $*" >> "$GB_FAKE_RECORD"
|
|
case "$GB_FAKE_GH_ANSWER" in
|
|
private) echo "true"; exit 0 ;;
|
|
public) echo "false"; exit 0 ;;
|
|
*) echo "auth error" >&2; exit 4 ;;
|
|
esac
|
|
`,
|
|
);
|
|
chmodSync(join(shimDir, 'gh'), 0o755);
|
|
process.env.PATH = `${shimDir}:${process.env.PATH ?? ''}`;
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (savedPath === undefined) delete process.env.PATH;
|
|
else process.env.PATH = savedPath;
|
|
if (savedRecord === undefined) delete process.env.GB_FAKE_RECORD;
|
|
else process.env.GB_FAKE_RECORD = savedRecord;
|
|
delete process.env.GB_FAKE_GH_ANSWER;
|
|
rmSync(tws, { recursive: true, force: true });
|
|
rmSync(shimDir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function captureStatus(): Promise<{ code: number; out: string; err: string }> {
|
|
const origLog = console.log;
|
|
const origErr = console.error;
|
|
let out = '';
|
|
let err = '';
|
|
console.log = (...a: unknown[]) => { out += a.map(String).join(' ') + '\n'; };
|
|
console.error = (...a: unknown[]) => { err += a.map(String).join(' ') + '\n'; };
|
|
try {
|
|
const code = await runBootstrap(['status', '--workspace', tws]);
|
|
return { code, out, err };
|
|
} finally {
|
|
console.log = origLog;
|
|
console.error = origErr;
|
|
}
|
|
}
|
|
|
|
test('PUBLIC origin on a template clone → hard fail (exit 1, agent-readable fix)', async () => {
|
|
process.env.GB_FAKE_GH_ANSWER = 'public';
|
|
const report = await statusReport(tws, { gbrainHomeDir: home });
|
|
expect(report.privacy_gate).toEqual({
|
|
verdict: 'public',
|
|
origin: 'https://github.com/tester/agent-template.git',
|
|
detail: expect.stringContaining('PUBLIC'),
|
|
});
|
|
// The probe went through REST (`gh api repos/...`), NEVER `gh repo view`
|
|
// — that command rides GraphQL, which cloud sandbox proxies always 403.
|
|
const recorded = readFileSync(recordFile, 'utf8');
|
|
expect(recorded).toContain('gh api repos/tester/agent-template --jq .private');
|
|
expect(recorded).not.toContain('repo view');
|
|
|
|
const r = await captureStatus();
|
|
expect(r.code).toBe(1);
|
|
expect(r.err).toContain('PUBLIC');
|
|
expect(r.err).toContain('private');
|
|
});
|
|
|
|
test('[FIX6] `bootstrap render` refuses a PUBLIC origin BEFORE writing identity files', async () => {
|
|
process.env.GB_FAKE_GH_ANSWER = 'public';
|
|
// Precondition: no identity file has been rendered yet.
|
|
expect(existsSync(join(tws, 'SOUL.md'))).toBe(false);
|
|
|
|
const origLog = console.log;
|
|
const origErr = console.error;
|
|
let out = '';
|
|
let err = '';
|
|
console.log = (...a: unknown[]) => { out += a.map(String).join(' ') + '\n'; };
|
|
console.error = (...a: unknown[]) => { err += a.map(String).join(' ') + '\n'; };
|
|
let code: number;
|
|
try {
|
|
code = await runBootstrap(['render', '--workspace', tws]);
|
|
} finally {
|
|
console.log = origLog;
|
|
console.error = origErr;
|
|
}
|
|
void out;
|
|
expect(code).toBe(1);
|
|
expect(err).toContain('render refused');
|
|
expect(err).toContain('PUBLIC');
|
|
expect(err).toContain('private');
|
|
// Refused BEFORE any identity file was written.
|
|
expect(existsSync(join(tws, 'SOUL.md'))).toBe(false);
|
|
expect(existsSync(join(tws, 'USER.md'))).toBe(false);
|
|
});
|
|
|
|
test('PRIVATE origin → no gate, exit 0', async () => {
|
|
process.env.GB_FAKE_GH_ANSWER = 'private';
|
|
const report = await statusReport(tws, { gbrainHomeDir: home });
|
|
expect(report.privacy_gate).toBeUndefined();
|
|
const r = await captureStatus();
|
|
expect(r.code).toBe(0);
|
|
});
|
|
|
|
test('gh unavailable/offline → loud warning, NOT a fail', async () => {
|
|
delete process.env.GB_FAKE_GH_ANSWER; // shim exits 4 (auth error / offline shape)
|
|
const report = await statusReport(tws, { gbrainHomeDir: home });
|
|
expect(report.privacy_gate?.verdict).toBe('unknown');
|
|
const r = await captureStatus();
|
|
expect(r.code).toBe(0);
|
|
expect(r.err).toContain('WARNING');
|
|
expect(r.err).toContain('visibility');
|
|
});
|
|
|
|
test('initialized workspace never trips the template-door gate', async () => {
|
|
process.env.GB_FAKE_GH_ANSWER = 'public';
|
|
// ws (outer fixture) is initialized by the earlier detection test and has
|
|
// no origin anyway; flip tws to initialized to isolate the discriminator.
|
|
writeManifest(tws, {
|
|
format_version: 1,
|
|
initialized: true,
|
|
agent_name: 'Testy',
|
|
created_by: 'test',
|
|
created_at: new Date().toISOString(),
|
|
source_id: 'workspace',
|
|
});
|
|
try {
|
|
const report = await statusReport(tws, { gbrainHomeDir: home });
|
|
expect(report.privacy_gate).toBeUndefined();
|
|
} finally {
|
|
writeManifest(tws, {
|
|
format_version: 1,
|
|
initialized: false,
|
|
agent_name: '{{AGENT_NAME}}',
|
|
created_by: 'template',
|
|
created_at: '1970-01-01T00:00:00.000Z',
|
|
source_id: 'workspace',
|
|
});
|
|
}
|
|
});
|
|
});
|