mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +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>
333 lines
14 KiB
TypeScript
333 lines
14 KiB
TypeScript
/**
|
|
* repo-visibility.ts — parser union, gh-403 classification, the full ladder
|
|
* verdict matrix [D4]/[D14], and the private-only verdict cache [D11].
|
|
* Everything injected (runner + fetchImpl + env) — no network, no gh, no git.
|
|
*/
|
|
import { describe, expect, test } from 'bun:test';
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import {
|
|
anonProbeUrl,
|
|
classifyGh403,
|
|
githubOwnerRepoString,
|
|
parseGithubOwnerRepo,
|
|
readCachedPrivateVerdict,
|
|
verifyRepoVisibility,
|
|
writeVisibilityCache,
|
|
type ExecRunner,
|
|
type RepoVisibilityVerdict,
|
|
} from '../src/core/repo-visibility.ts';
|
|
|
|
const URL_GH = 'https://github.com/acme-example/widget-co.git';
|
|
|
|
// ── Fake IO ─────────────────────────────────────────────────────────────────
|
|
|
|
/** Runner keyed on the binary: gh → opts.gh, git ls-remote → opts.git. */
|
|
function fakeRunner(opts: {
|
|
gh?: { code: number; stdout?: string; stderr?: string };
|
|
git?: { code: number; stderr?: string };
|
|
}): ExecRunner {
|
|
return async (argv: string[]) => {
|
|
if (argv[0] === 'gh') {
|
|
const r = opts.gh ?? { code: 127, stderr: 'ENOENT' };
|
|
return { code: r.code, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
|
}
|
|
const r = opts.git ?? { code: 0 };
|
|
return { code: r.code, stdout: r.code === 0 ? 'abc123\tHEAD\n' : '', stderr: r.stderr ?? '' };
|
|
};
|
|
}
|
|
|
|
function fakeFetch(res: {
|
|
status: number;
|
|
contentType?: string;
|
|
wwwAuthenticate?: string;
|
|
githubRequestId?: boolean;
|
|
body?: string;
|
|
throws?: boolean;
|
|
}): typeof fetch {
|
|
return (async () => {
|
|
if (res.throws) throw new Error('network unreachable');
|
|
const headers = new Headers();
|
|
if (res.contentType) headers.set('content-type', res.contentType);
|
|
if (res.wwwAuthenticate) headers.set('www-authenticate', res.wwwAuthenticate);
|
|
if (res.githubRequestId) headers.set('x-github-request-id', 'ABCD:1234');
|
|
return new Response(res.body ?? '', { status: res.status, headers });
|
|
}) as unknown as typeof fetch;
|
|
}
|
|
|
|
const GH_ADVERTISEMENT = '001e# service=git-upload-pack\n0000';
|
|
const PROXY_403 = 'gh: HTTP 403 {"message":"GitHub access to this repository is not enabled for this session. Use add_repo to request access."}';
|
|
const GITHUB_403 = 'gh: HTTP 403 {"message":"Resource not accessible by integration","documentation_url":"https://docs.github.com/rest"}';
|
|
|
|
// ── Parser union ────────────────────────────────────────────────────────────
|
|
|
|
describe('parseGithubOwnerRepo (canonical union grammar)', () => {
|
|
test.each([
|
|
['https://github.com/a/b', 'a', 'b'],
|
|
['https://github.com/a/b.git', 'a', 'b'],
|
|
['https://github.com/a/b/', 'a', 'b'],
|
|
['git@github.com:a/b.git', 'a', 'b'],
|
|
['git@github.com:a/b', 'a', 'b'],
|
|
['ssh://git@github.com/a/b', 'a', 'b'],
|
|
['ssh://git@github.com/a/b.git/', 'a', 'b'],
|
|
[' https://github.com/a/b.git ', 'a', 'b'],
|
|
])('%s → %s/%s', (url, owner, repo) => {
|
|
expect(parseGithubOwnerRepo(url)).toEqual({ owner, repo });
|
|
expect(githubOwnerRepoString(url)).toBe(`${owner}/${repo}`);
|
|
});
|
|
|
|
test.each([['https://gitlab.com/a/b'], ['/tmp/origin.git'], ['git@example.com:a/b.git'], ['']])(
|
|
'%s → null',
|
|
(url) => {
|
|
expect(parseGithubOwnerRepo(url)).toBeNull();
|
|
},
|
|
);
|
|
});
|
|
|
|
describe('anonProbeUrl', () => {
|
|
test('github forms normalize to the canonical .git/info/refs URL', () => {
|
|
const want = 'https://github.com/a/b.git/info/refs?service=git-upload-pack';
|
|
expect(anonProbeUrl('https://github.com/a/b')).toBe(want);
|
|
expect(anonProbeUrl('git@github.com:a/b.git')).toBe(want);
|
|
expect(anonProbeUrl('ssh://git@github.com/a/b')).toBe(want);
|
|
});
|
|
test('non-github https appends info/refs; ssh/file get null', () => {
|
|
expect(anonProbeUrl('https://git.example.com/a/b.git/')).toBe(
|
|
'https://git.example.com/a/b.git/info/refs?service=git-upload-pack',
|
|
);
|
|
expect(anonProbeUrl('git@example.com:a/b.git')).toBeNull();
|
|
expect(anonProbeUrl('/tmp/origin.git')).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── 403 classification ──────────────────────────────────────────────────────
|
|
|
|
describe('classifyGh403', () => {
|
|
test('GitHub-shaped JSON (message + documentation_url) → github', () => {
|
|
expect(classifyGh403(GITHUB_403)).toBe('github');
|
|
});
|
|
test('session-scoping / proxy phrases → proxy', () => {
|
|
expect(classifyGh403(PROXY_403)).toBe('proxy');
|
|
expect(classifyGh403('x-deny-reason: host_not_allowed')).toBe('proxy');
|
|
expect(classifyGh403('remote: access denied by the git proxy: repo is not in this session')).toBe('proxy');
|
|
});
|
|
test('bare text → unknown', () => {
|
|
expect(classifyGh403('HTTP 403 Forbidden')).toBe('unknown');
|
|
});
|
|
});
|
|
|
|
// ── Ladder verdict matrix ───────────────────────────────────────────────────
|
|
|
|
async function ladder(opts: {
|
|
url?: string;
|
|
gh?: { code: number; stdout?: string; stderr?: string };
|
|
git?: { code: number; stderr?: string };
|
|
probe?: Parameters<typeof fakeFetch>[0];
|
|
env?: Record<string, string | undefined>;
|
|
}): Promise<RepoVisibilityVerdict> {
|
|
return verifyRepoVisibility({
|
|
originUrl: opts.url ?? URL_GH,
|
|
runner: fakeRunner({ ...(opts.gh ? { gh: opts.gh } : {}), ...(opts.git ? { git: opts.git } : {}) }),
|
|
fetchImpl: fakeFetch(opts.probe ?? { status: 500 }),
|
|
env: opts.env ?? {},
|
|
timeoutMs: 2_000,
|
|
});
|
|
}
|
|
|
|
describe('verifyRepoVisibility — verdict matrix', () => {
|
|
test('rung 1 REST true → private/rest (CRITICAL regression parity with the old probe)', async () => {
|
|
const v = await ladder({ gh: { code: 0, stdout: 'true\n' } });
|
|
expect(v.verdict).toBe('private');
|
|
expect(v.verdict === 'private' && v.via).toBe('rest');
|
|
});
|
|
|
|
test('rung 1 REST false → public/rest, refuse (CRITICAL regression parity)', async () => {
|
|
const v = await ladder({ gh: { code: 0, stdout: 'false\n' } });
|
|
expect(v.verdict).toBe('public');
|
|
});
|
|
|
|
test('gh missing (127) → git rungs: ls-remote ok + github-attributed 401 → private/git-protocol', async () => {
|
|
const v = await ladder({
|
|
gh: { code: 127, stderr: 'ENOENT' },
|
|
git: { code: 0 },
|
|
probe: { status: 401, wwwAuthenticate: 'Basic realm="GitHub"', githubRequestId: true },
|
|
});
|
|
expect(v.verdict).toBe('private');
|
|
expect(v.verdict === 'private' && v.via).toBe('git-protocol');
|
|
});
|
|
|
|
test('[D14] github origin: a 401 with ONLY a www-authenticate challenge (RFC-mandated on every 401, middleboxes included) is NOT attribution → unverifiable', async () => {
|
|
const v = await ladder({
|
|
gh: { code: 127 },
|
|
git: { code: 0 },
|
|
probe: { status: 401, wwwAuthenticate: 'Basic realm="corp-proxy"' },
|
|
});
|
|
expect(v.verdict).toBe('unverifiable');
|
|
});
|
|
|
|
test('[D14] non-github origin: a 401 challenge is NOT trustable attribution (a middlebox 401s identically) → unverifiable, fail-closed', async () => {
|
|
const v = await ladder({
|
|
url: 'https://git.example.com/team/brain.git',
|
|
git: { code: 0 },
|
|
probe: { status: 401, wwwAuthenticate: 'Basic realm="git"' },
|
|
});
|
|
expect(v.verdict).toBe('unverifiable');
|
|
});
|
|
|
|
test('proxy-403 REST → git rungs, and the rung log names the proxy', async () => {
|
|
const v = await ladder({
|
|
gh: { code: 1, stderr: PROXY_403 },
|
|
git: { code: 0 },
|
|
probe: { status: 401, wwwAuthenticate: 'Basic realm="GitHub"', githubRequestId: true },
|
|
});
|
|
expect(v.verdict).toBe('private');
|
|
expect(v.rungs.map((r) => r.outcome).join(' ')).toContain('egress proxy');
|
|
});
|
|
|
|
test('[D14] un-attributed 401 (middlebox) → unverifiable, NEVER private', async () => {
|
|
const v = await ladder({
|
|
gh: { code: 127 },
|
|
git: { code: 0 },
|
|
probe: { status: 401 }, // no www-authenticate, no github headers
|
|
});
|
|
expect(v.verdict).toBe('unverifiable');
|
|
expect(v.detail).toContain('un-attributed');
|
|
});
|
|
|
|
test('404 with x-github-request-id counts as attributed for github origins', async () => {
|
|
const v = await ladder({
|
|
gh: { code: 127 },
|
|
git: { code: 0 },
|
|
probe: { status: 404, githubRequestId: true },
|
|
});
|
|
expect(v.verdict).toBe('private');
|
|
});
|
|
|
|
test('[D4] 200 with advertisement proof (content-type) → public/git-protocol', async () => {
|
|
const v = await ladder({
|
|
gh: { code: 127 },
|
|
git: { code: 0 },
|
|
probe: { status: 200, contentType: 'application/x-git-upload-pack-advertisement' },
|
|
});
|
|
expect(v.verdict).toBe('public');
|
|
});
|
|
|
|
test('[D4] 200 with pkt-line body proof (no content-type) → public', async () => {
|
|
const v = await ladder({
|
|
gh: { code: 127 },
|
|
git: { code: 0 },
|
|
probe: { status: 200, body: GH_ADVERTISEMENT },
|
|
});
|
|
expect(v.verdict).toBe('public');
|
|
});
|
|
|
|
test('[D4] 200 WITHOUT proof (SSO login HTML) → unverifiable, never a false PUBLIC', async () => {
|
|
const v = await ladder({
|
|
url: 'https://git.example.com/team/brain.git',
|
|
git: { code: 0 },
|
|
probe: { status: 200, contentType: 'text/html', body: '<html>Sign in</html>' },
|
|
});
|
|
expect(v.verdict).toBe('unverifiable');
|
|
expect(v.detail).toContain('advertisement');
|
|
});
|
|
|
|
test('200 with proof behind a credential-injecting proxy → unverifiable + actionable message', async () => {
|
|
const v = await ladder({
|
|
gh: { code: 1, stderr: PROXY_403 },
|
|
git: { code: 0 },
|
|
probe: { status: 200, contentType: 'application/x-git-upload-pack-advertisement' },
|
|
env: { GH_TOKEN: 'proxy-injected' },
|
|
});
|
|
expect(v.verdict).toBe('unverifiable');
|
|
expect(v.detail).toContain('GBRAIN_ALLOW_UNVERIFIED_REMOTE');
|
|
});
|
|
|
|
test('ls-remote failure → unverifiable ("a push would fail anyway")', async () => {
|
|
const v = await ladder({
|
|
gh: { code: 127 },
|
|
git: { code: 128, stderr: 'fatal: could not read Username' },
|
|
});
|
|
expect(v.verdict).toBe('unverifiable');
|
|
expect(v.detail).toContain('push would fail');
|
|
});
|
|
|
|
test('anon probe network error → unverifiable with rung log', async () => {
|
|
const v = await ladder({ gh: { code: 127 }, git: { code: 0 }, probe: { status: 0, throws: true } });
|
|
expect(v.verdict).toBe('unverifiable');
|
|
expect(v.rungs.some((r) => r.rung === 'anon-refs' && r.outcome.includes('network error'))).toBe(true);
|
|
});
|
|
|
|
test('ssh non-github origin: readable but no probe surface → unverifiable', async () => {
|
|
const v = await ladder({ url: 'git@example.com:team/brain.git', git: { code: 0 }, probe: { status: 200 } });
|
|
expect(v.verdict).toBe('unverifiable');
|
|
expect(v.rungs.some((r) => r.outcome.includes('no https probe surface'))).toBe(true);
|
|
});
|
|
|
|
test('anon probe answering an unexpected status (500) → unverifiable, names the status', async () => {
|
|
const v = await ladder({
|
|
gh: { code: 127 },
|
|
git: { code: 0 },
|
|
probe: { status: 500 },
|
|
});
|
|
expect(v.verdict).toBe('unverifiable');
|
|
expect(v.detail).toContain('HTTP 500');
|
|
expect(v.detail).toContain('GBRAIN_ALLOW_UNVERIFIED_REMOTE');
|
|
});
|
|
|
|
test('rung timeout is recorded and degrades to unverifiable, never hangs', async () => {
|
|
const hangingRunner: ExecRunner = () => new Promise(() => {}); // never resolves
|
|
const v = await verifyRepoVisibility({
|
|
originUrl: URL_GH,
|
|
runner: hangingRunner,
|
|
fetchImpl: fakeFetch({ status: 500 }),
|
|
env: {},
|
|
timeoutMs: 50,
|
|
});
|
|
expect(v.verdict).toBe('unverifiable');
|
|
expect(v.rungs.some((r) => r.outcome.includes('timeout'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ── Verdict cache [D11] ─────────────────────────────────────────────────────
|
|
|
|
describe('visibility cache (private-only, TTL)', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'vis-cache-'));
|
|
const path = join(dir, 'visibility-cache.json');
|
|
const privateVerdict: RepoVisibilityVerdict = { verdict: 'private', via: 'rest', detail: 'x', rungs: [] };
|
|
const publicVerdict: RepoVisibilityVerdict = { verdict: 'public', via: 'rest', detail: 'x', rungs: [] };
|
|
|
|
test('private verdict round-trips within TTL; expired entry misses', () => {
|
|
const t0 = Date.now();
|
|
writeVisibilityCache(URL_GH, privateVerdict, { now: t0, path });
|
|
expect(readCachedPrivateVerdict(URL_GH, { now: t0 + 60_000, path })?.verdict).toBe('private');
|
|
expect(readCachedPrivateVerdict(URL_GH, { now: t0 + 61 * 60_000, path })).toBeNull();
|
|
});
|
|
|
|
test('public and unverifiable verdicts are NEVER written', () => {
|
|
rmSync(path, { force: true });
|
|
writeVisibilityCache(URL_GH, publicVerdict, { path });
|
|
writeVisibilityCache(URL_GH, { verdict: 'unverifiable', detail: 'x', rungs: [] }, { path });
|
|
expect(readCachedPrivateVerdict(URL_GH, { path })).toBeNull();
|
|
});
|
|
|
|
test('corrupt cache file is a miss, and the next write recovers it', () => {
|
|
writeFileSync(path, 'not json', { mode: 0o600 });
|
|
expect(readCachedPrivateVerdict(URL_GH, { path })).toBeNull();
|
|
const t0 = Date.now();
|
|
writeVisibilityCache(URL_GH, privateVerdict, { now: t0, path });
|
|
expect(readCachedPrivateVerdict(URL_GH, { now: t0, path })?.verdict).toBe('private');
|
|
expect(() => JSON.parse(readFileSync(path, 'utf8'))).not.toThrow();
|
|
});
|
|
|
|
test('expired entries are pruned on write', () => {
|
|
const t0 = Date.now();
|
|
rmSync(path, { force: true });
|
|
writeVisibilityCache('https://github.com/a/old.git', privateVerdict, { now: t0 - 2 * 60 * 60_000, path });
|
|
writeVisibilityCache(URL_GH, privateVerdict, { now: t0, path });
|
|
const map = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
|
|
expect(Object.keys(map)).toEqual([URL_GH]);
|
|
});
|
|
});
|