Files
gstack/test/gbrain-local-status.test.ts
T
94993f7401 v1.61.0.0 fix wave: guards failing open / silent failures (9 fixes, 4 community PRs absorbed) (#2472)
* fix(careful): warn on chained rm even when the last target is safe

The safe-exception block whitelisted rm -rf of build artifacts by
extracting targets with a single greedy match (.*rm ...), which only ever
inspects the LAST rm in the command. A chain like 'rm -rf /; rm -rf
node_modules' was therefore judged solely by its trailing safe target and
allowed without warning, waving through the destructive 'rm -rf /'.

Gate the shortcut to single rm invocations: when any shell separator
(; | & newline, incl. JSON-escaped \n/\r from the grep extraction path)
is present, fall through to the destructive-pattern check, which warns on
any recursive rm. Single-command artifact cleanups still allow.

Adds 3 regression tests covering semicolon and && chains in both orders.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* harden(careful): substitution separators + capital -R recursive flag (#2039)

Two residual fail-opens in the same guard PR #2040 hardened, both verified
by executing the script pre-fix:

- rm -rf $(./wipe-all)/node_modules silently allowed: the substitution token
  ends in a whitelisted suffix and the safe-exception early exit skipped ALL
  downstream checks. $( and backtick now count as chain separators; plain
  $VAR expansion stays allowed.
- rm -R / silently allowed: both greps required a lowercase r in the flag
  cluster; capital -R is the documented BSD/macOS recursive flag. Both greps
  now match -[a-zA-Z]*[rR].

Six new tests: substitution x2 -> ask, capital-R x2 -> ask, rm -Rf
node_modules single-command -> still allowed, escaped-newline branch
(existing code, previously untested), and a pinned deliberate FP
(cd app && rm -rf node_modules -> ask) documenting the fail-closed
direction on chains.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(context-restore): prefer the current branch's own checkpoint (#2052)

All worktrees of a repo share one origin-derived slug, so they share one
`~/.gstack/projects/<slug>/checkpoints/` dir. `/context-restore` loaded the
newest checkpoint across the whole dir, so in one worktree it could silently
restore a *sibling worktree's* newer checkpoint.

Step 1 now orders candidates current-branch-first (read from each file's
`branch:` frontmatter), keeping other branches as a fallback. A branch is
checked out in at most one worktree, so this stops cross-worktree contamination
while preserving Conductor cross-branch handoff: when the current branch has no
checkpoint of its own, the full newest-first set is still used.

- scan the 200 newest before partitioning so a current-branch checkpoint sitting
  below a burst of sibling saves is still found; output still capped at 20
- non-git / detached HEAD / branchless legacy saves fall back to the old
  newest-first behavior (back-compat)
- +5 regression tests in context-save-hardening.test.ts (the #2052 bug case
  fails on the old pipeline); regenerated SKILL.md + proactive-suggestions.json

Fixes #2052

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gbrain): pass --confirm-destructive on drift re-register (#1985)

ensureSourceRegistered() handles match-but-different-path by removing the
old source then re-adding it at the new path. The remove was issued as
`gbrain sources remove <id> --yes`, but gbrain >= 0.42 gates `sources
remove` behind `--confirm-destructive` (`--yes` alone no longer suppresses
the data-loss prompt). The remove therefore fails with "To proceed, pass
--confirm-destructive", which ensureSourceRegistered surfaces as "source
registration failed" — aborting the entire /sync-gbrain code stage for any
already-registered source whose path has drifted. The memory and brain-sync
stages still pass, so the code index silently stops refreshing.

The orchestrator's own safeSourcesRemove() already passes
--confirm-destructive; this brings the lib helper in line with that
convention. Keeps --yes for older gbrain.

Tests: extend the fake gbrain shim in gbrain-sources.test.ts to simulate
the gbrain >= 0.42 guard (remove without --confirm-destructive exits 1),
update the drift re-register assertion, and add a regression test that
proves the drift path no longer throws. Both fail on main with the exact
"To proceed, pass --confirm-destructive" error and pass with the fix.

Fixes #1985

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* harden(gbrain-sources): route drift remove through #1734 guards + realpath drift check

Absorbing #2031 un-blocked a destructive remove that bypassed the #1734
data-loss guards: ensureSourceRegistered's drift path issued
`gbrain sources remove` directly, without the detectAutopilot +
decideSourceRemove checks every other remove routes through via
safeSourcesRemove. gbrain >= 0.42's own prompt was accidentally blocking
that path; with --confirm-destructive passed it is live again.

- Drift remove now refuses LOUDLY (throws, actionable message) while an
  autopilot is active or when decideSourceRemove disallows; a silent
  changed=false would hide the drifted registration.
- decideSourceRemove's extraArgs (--keep-storage when supported) propagate
  to the remove call, matching safeSourcesRemove.
- Drift is realpath-normalized before being declared: a symlink alias of the
  same directory (macOS /tmp -> /private/tmp) is a match, not drift — the
  probable cause of #1985's reporter hitting the remove on an unmoved repo.
- Drift fires a loud stderr line (old -> new path); perpetual drift in logs
  is the trigger for promoting #1985's reindex-in-place design.

Tests: autopilot-active refusal (no remove in call log), fail-closed refusal
on unreadable sources list, --keep-storage propagation, symlink-alias
no-drift; existing drift tests pin the guard probes so a live autopilot on
the dev machine can't flip them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(developer-profile): exclude mode:resources rows from SESSION_COUNT, TIER, NUDGE_ELIGIBLE (#2067)

Every /office-hours run appends a mode:"resources" bookkeeping row alongside
the real session row, so --read double-counted sessions (~2x): tiers promoted
early and the builder-to-founder nudge armed prematurely. The file already
filtered resources rows for LAST_*/CROSS_PROJECT; the same realSessions
filter now feeds SESSION_COUNT/TIER, and the nudge predicate is the faithful
allowlist (mode === 'builder') so a future mode #4 fails closed instead of
re-opening this bug.

8 regression tests: count vs resources noise, tier boundaries both sides,
nudge false-with-noise / true-at-3-builders, cross-project trailing row.

Absorbed from PR #1991 by @mvann (fix + tests commits; the PR's version-bump
commit is superseded by this wave's consolidated release commit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(hooks): passThrough() two-branch contract — never emit permissionDecision:'defer' (#2035, #2006)

Every AskUserQuestion died with "Tool result missing due to internal error"
on current Claude Code builds (Desktop 1.14271.0, CC 2.1.177). Root cause:
the question-preference-hook emitted permissionDecision:'defer' on every
pass-through path. 'defer' is a real PreToolUse value, but since CC v2.1.89
its semantics are "pause this tool call for external resumption" (headless
resume) — never "abstain". Interactive sessions have nothing to resume the
paused call, so the tool orphaned. Pre-2.1.89 builds ignored the unknown
value, which is why the hook worked when it shipped and broke later.

The fix is the two-branch pass-through contract:
- no context -> exit 0 with EXACTLY empty stdout
- memory nuggets present -> hookSpecificOutput with hookEventName +
  additionalContext ONLY (the documented shape; plan-tune Layer 8 memory
  injection ships through this branch and keeps working)

defer() is renamed passThrough() so the function says what it does, and
docs/spikes/claude-code-hook-mutation.md's protocol contract (cited by the
hook header) is corrected in the same commit — it taught '"defer" — let
permission flow continue' and was the reintroduction vector.

Test contract rewritten in the same commit (13 assertions across 3 files,
verified fail-first against the unfixed hook): pass-through paths assert
exact-empty stdout (a garbage/partial write cannot slip past an
optional-chained parse), the nugget path asserts permissionDecision is
ABSENT while additionalContext survives, and a new tripwire asserts no
non-deny path ever puts the string "permissionDecision" on stdout. The
deny (auto-decide) and Conductor prose-redirect paths are unchanged.

Deployment: no migration needed — settings.json points at the absolute
bash shim which execs the .ts live; /gstack-upgrade delivers the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(one-way-doors): unify credential noun net + wire it into the runtime (#2024)

Library fix: revoke/reset/rotate now share ONE noun alternation (api key,
token, secret, credential, access key, password) with optional plural s?.
Pre-fix leaks: "reset my secret", "reset my access key", "revoke my secret"
(mismatched per-verb lists) and every plural form ("rotate the credentials",
"revoke all tokens" — \b(...)\b cannot match a trailing s).

Runtime wiring — the regexes could never fire in production before:
- gstack-question-preference --check gains --summary-stdin: the question
  text pipes via stdin (never argv — summaries carry quotes/newlines/shell
  metacharacters) and feeds isOneWayDoor alongside the id, so an ad-hoc
  destructive question with a stored never-ask preference now forces
  ASK_NORMALLY. Empty/absent stdin keeps exact id-only semantics.
- question-preference-hook falls back to classifyQuestion(question text)
  when the registry lookup misses, so unregistered destructive questions
  pass through to a human instead of auto-deciding.
- question-tuning resolver prose shows the piped form (SKILL.md regen lands
  in the wave's release commit).

Tripwires (verified fail-first): full verbs x nouns x singular/plural matrix
with the #2024 repro rows, benign-summary no-over-match rows, stdin
transport survival (quotes/newlines), empty-stdin fail-safe, and hook
fallback both directions (destructive -> pass-through, benign -> deny).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(design): loud integer-flag contract for --count/--retry/--timeout (#2032)

design variants --count abc silently generated ZERO variants and exited 0:
parseInt(NaN) flowed through Math.min into the generation loop bound. The
same NaN class was live on the two sibling flags in the same file:
--retry abc made generate() a silent no-op (attempt <= NaN never true, null
output, exit 0) and --timeout abc killed the serve board ~immediately
(setTimeout(NaN)).

New design/src/flag-utils.ts: parseIntFlag (pure, unit-testable) +
normalizeIntFlag (CLI wrapper). Contract matches the --viewports precedent
(error loudly on nonsense — these commands spend real image-API money, a
silent fixup hides typos from calling agents): undefined -> default; bare
flag/empty/non-integer ("3.7" rejected, not truncated)/below-min -> exit 1
with usage hint; above-max -> clamp with stderr warning. --count normalizes
at the variants() consumption site so programmatic callers are covered, with
the ceiling derived from STYLE_VARIATIONS.length instead of a magic 7; the
CLI passes the raw flag through (a pre-parseInt would truncate "3.7").

Tripwires live in test/design-flag-utils.test.ts — deliberately under test/,
not design/test/, which is invisible to the bun test glob, TEST_ROOTS, and
every workflow (wiring design/test/ into CI is a captured TODO).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gbrain): thin-client state — remote-MCP brains no longer classify as broken-config (#2051)

A thin client (remote-HTTP MCP brain, no local engine by design) probed
`gbrain sources list`, which gbrain's dispatch guard REFUSES on thin clients
(exit 1, no recognized error string), so the classifier fell to its
defensive broken-config default and every suppression gate silently hid
brain-aware blocks from exactly the users on a shared team brain.

New 'thin-client' state, detected PRE-probe from gbrain's own remote_mcp
config marker via the existing gbrainConfigPath() helper (mirrors gbrain's
isThinClient(); honors GBRAIN_HOME; zero network, immune to error-string
drift), with a /thin[- ]client/ stderr backstop in the probe catch. Remote
reachability is deliberately NOT probed by the classifier — that is the
#1964 pathology; gbrain calls degrade gracefully at use time, and the detect
JSON says so honestly (gbrain_thin_client: {probed: false}).

The state is admitted at every suppression gate — gstack-gbrain-detect
--is-ok (drives setup + gbrain-refresh), gen-skill-docs' detection override,
gstack-config gbrain-refresh — while the sync stages (code/memory/dream)
SKIP with an accurate reason: code indexing runs on the brain server, memory
syncs via the remote brain's artifacts pull. The two consumer classes need
opposite answers, which is why this is a distinct state and not a
skip-the-probe special case. sync-gbrain Step 1.5 and setup-gbrain prose
route thin-client to proceed, never into broken-config remediation.

detectMcpMode secondary generalization: url-match against the config's
remote_mcp.mcp_url (deterministic — gbrain mounts at the generic /mcp path)
-> name pattern gbrain[-_]* -> stdio command token; gbrain_mcp_mode stays a
3-value enum.

Tripwires: end-to-end --is-ok exits 0 on a thin-client fixture AND still
exits 1 on broken-config (the gate didn't widen); pre-probe + stderr-fallback
classifier paths; 4 detectMcpMode identification cases incl. a non-matching
url that must NOT false-positive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* release: v1.60.0.0 — regen SKILL.md, VERSION, CHANGELOG, TODOS follow-ups

- Regenerate all SKILL.md from templates (question-tuning --summary-stdin
  prose from #2024, context-restore branch preference from PR #2054,
  sync-gbrain/setup-gbrain thin-client prose from #2051) + llms.txt.
- VERSION + package.json -> 1.60.0.0 (bin/gstack-next-version, queue-aware:
  #1815 claims 1.59.0.0, #2213 claims 1.59.1.0).
- CHANGELOG release summary + itemized entry crediting @jbetala7 (x3) and
  @mvann.
- TODOS.md: three eng-review follow-ups (design/test CI wiring + documented
  pre-existing retry-after flake, /context-save worktree identity, gbrain
  reindex-in-place conditional on the new drift log).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(resolvers): compress --summary-stdin preamble prose to fit parity budget; re-bless ship goldens

The v1.57.7.0 parity suite caps investigate's generated size at 1.09x
baseline; the #2024 question-tuning prose (duplicated into every tier->=2
skill) tipped it to 1.092. Compressed to a single inline command + short
pointer (the full rationale lives in bin/gstack-question-preference's
header and the one-way-doors module docs). Ship goldens re-blessed against
the final resolver text (conscious template-change acknowledgment, per the
golden-file regression contract).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(e2e): office-hours-spec-review turn budget fits the carved skill layout (#2473)

The test failed deterministically with error_max_turns at 9 turns on main
and this branch alike (CI attempt logs + local main repro). Root cause from
the failing transcript: the Spec Review Loop content is carved out of
office-hours/SKILL.md into office-hours/sections/, so the agent needs
discovery hops (grep SKILL.md -> ls sections/ -> read the section) before it
can write — 8 tool turns + the closing text turn = 9 > the 8-turn budget,
which predates the carve. Observed failures wrote a CORRECT summary on tool
turn 8 and died on the closing turn.

maxTurns 8 -> 12. Verified: PASS locally post-fix (7 turns this run — the
extra headroom absorbs discovery-path nondeterminism).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(e2e): review-dashboard-via session budget survives runner contention (#2473)

The test failed on CI (and its baseline run) with the timeout signature:
0 turns, $0.00, exactly 183s, 3/3 attempts — the spawned claude -p session
never emitted a single stream event before the 180s inner timeout. The
file's tests run concurrently on one runner; session startup queues behind
sibling sessions, and this test had the tightest budget in the file (the
240s-budget tests in the same job passed). A clean local run takes 270s
wall for 4 turns, confirming 180s was too tight even without contention.

Inner timeout 180s -> 300s; outer bun timeout 240s -> 360s to keep headroom
over the inner budget. Verified: PASS locally post-fix (4 turns, 270s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(e2e): retro-base-branch session budget survives runner contention (#2473)

Same class as review-dashboard-via, one test over in the same file: /retro
is a long multi-step flow whose clean pass measures 225-239s — a coin flip
against the 240s inner budget. First CI run passed at 225s; the rerun timed
out at the 240s line on all 3 attempts (exitReason "timeout"); the local
verification run passed at 239s, ONE second under the old cap.

Inner timeout 240s -> 360s; outer bun timeout 300s -> 480s for headroom.
Verified: PASS locally post-fix (17 turns, 239s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jayesh Betala <jayesh.betala7@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Michael Vann <9221873+mvann@users.noreply.github.com>
2026-08-08 09:28:45 -07:00

529 lines
20 KiB
TypeScript

/**
* Unit tests for lib/gbrain-local-status.ts.
*
* Per the eng-review D6 (gate-tier = mocked, codex #9): no real gbrain CLI, no
* real PGLite, no real Postgres. Each case builds a fake `gbrain` shell script
* on PATH that emits canned exit codes + stderr matching the patterns the
* classifier looks for.
*
* Seven status cases:
* 1. no-cli — gbrain absent from PATH
* 2. missing-config — gbrain present, config.json absent (honors GBRAIN_HOME)
* 3. broken-config — gbrain present, config exists, stderr contains "config.json"
* 4. broken-db — gbrain present, config exists, stderr contains "Cannot connect to database"
* 5. timeout — probe exceeds GSTACK_GBRAIN_PROBE_TIMEOUT_MS with no recognized error (#1964)
* 6. engine-locked — PGLite CLI exits 124 because another process owns the DB (#2194)
* 7. ok — gbrain present, config exists, sources list returns valid JSON
*
* Plus cache behavior: hit, TTL expiry, invariant invalidation (HOME change,
* probe-timeout change), --no-cache bypass. Timeout tests keep runtime sane by
* setting GSTACK_GBRAIN_PROBE_TIMEOUT_MS=300 against a fake gbrain that sleeps 2s.
*/
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import {
mkdtempSync,
writeFileSync,
readFileSync,
mkdirSync,
rmSync,
chmodSync,
existsSync,
utimesSync,
} from "fs";
import { tmpdir } from "os";
import { join, dirname } from "path";
import { spawnSync } from "child_process";
import {
localEngineStatus,
cacheFilePath,
probeTimeoutMs,
CACHE_TTL_MS,
DEFAULT_PROBE_TIMEOUT_MS,
type LocalEngineStatus,
} from "../lib/gbrain-local-status";
interface FakeEnv {
tmp: string;
bindir: string;
home: string;
gstackHome: string;
configPath: string;
cleanup: () => void;
}
/**
* Build a tmp HOME + GSTACK_HOME + optional fake `gbrain` on PATH.
*
* The classifier reads HOME via os.homedir() which reads process.env.HOME, so
* we mutate process.env ambiently in each test (restored in afterEach).
*/
function makeEnv(opts: {
withGbrain?: boolean;
gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "engine-locked" | "throws" | "slow" | "thin-refusal";
withConfig?: boolean;
/** #2051: config carries gbrain's remote_mcp thin-client marker. */
thinClientConfig?: boolean;
}): FakeEnv {
const tmp = mkdtempSync(join(tmpdir(), "gbrain-local-status-test-"));
const bindir = join(tmp, "bin");
const home = join(tmp, "home");
const gstackHome = join(home, ".gstack");
const configDir = join(home, ".gbrain");
const configPath = join(configDir, "config.json");
mkdirSync(bindir, { recursive: true });
mkdirSync(home, { recursive: true });
mkdirSync(gstackHome, { recursive: true });
mkdirSync(configDir, { recursive: true });
if (opts.thinClientConfig) {
writeFileSync(
configPath,
JSON.stringify({ remote_mcp: { mcp_url: "https://brain.example.com/mcp" } }),
);
} else if (opts.withConfig) {
writeFileSync(
configPath,
JSON.stringify({ engine: "pglite", database_url: "pglite:///fake" }),
);
}
if (opts.withGbrain) {
const behavior = opts.gbrainBehavior || "ok";
const fake = makeFakeGbrainScript(behavior);
const gbrainPath = join(bindir, "gbrain");
writeFileSync(gbrainPath, fake);
chmodSync(gbrainPath, 0o755);
}
return {
tmp,
bindir,
home,
gstackHome,
configPath,
cleanup: () => rmSync(tmp, { recursive: true, force: true }),
};
}
function makeFakeGbrainScript(
behavior: "ok" | "broken-db" | "broken-config" | "engine-locked" | "throws" | "slow" | "thin-refusal",
): string {
// "slow": healthy engine on a cold pooler connection (#1964) — sleeps past
// the (test-lowered) probe timeout, then would answer fine.
if (behavior === "slow") {
return `#!/bin/sh
if [ "$1" = "--version" ]; then
echo "gbrain 0.33.1.0"
exit 0
fi
if [ "$1 $2" = "sources list" ]; then
sleep 2
echo '{"sources":[]}'
exit 0
fi
exit 0
`;
}
const stderrLine =
behavior === "broken-db"
? 'echo "Cannot connect to database: . Fix: Check your connection URL in ~/.gbrain/config.json" >&2'
: behavior === "broken-config"
? 'echo "Error: malformed config.json at ~/.gbrain/config.json" >&2'
: behavior === "engine-locked"
? 'echo "gbrain sources: connect timed out (default 10000ms; pass --timeout=Ns to override)." >&2'
: behavior === "throws"
? 'echo "unexpected gbrain failure" >&2'
: behavior === "thin-refusal"
? 'echo "Error: gbrain sources is not routable to the remote brain (thin-client of https://brain.example.com/mcp)" >&2'
: "";
const exitCode = behavior === "ok" ? 0 : behavior === "engine-locked" ? 124 : 1;
return `#!/bin/sh
if [ "$1" = "--version" ]; then
echo "gbrain 0.33.1.0"
exit 0
fi
if [ "$1 $2" = "sources list" ]; then
if [ ${exitCode} -eq 0 ]; then
echo '{"sources":[]}'
exit 0
fi
${stderrLine}
exit ${exitCode}
fi
exit 0
`;
}
/**
* Apply a FakeEnv to process.env. Returns a function that restores previous values.
*
* PATH is REPLACED (not prepended) so a real `gbrain` on the inherited PATH
* can't shadow the test's fake-or-absent binary. /usr/bin:/bin is kept so `sh`
* and `command` work.
*/
function applyEnv(env: FakeEnv): () => void {
const prev = {
HOME: process.env.HOME,
PATH: process.env.PATH,
GSTACK_HOME: process.env.GSTACK_HOME,
GBRAIN_HOME: process.env.GBRAIN_HOME,
GSTACK_GBRAIN_PROBE_TIMEOUT_MS: process.env.GSTACK_GBRAIN_PROBE_TIMEOUT_MS,
};
process.env.HOME = env.home;
process.env.PATH = `${env.bindir}:/usr/bin:/bin`;
process.env.GSTACK_HOME = env.gstackHome;
delete process.env.GBRAIN_HOME;
delete process.env.GSTACK_GBRAIN_PROBE_TIMEOUT_MS;
return () => {
for (const [k, v] of Object.entries(prev)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
};
}
describe("lib/gbrain-local-status — status classification", () => {
let env: FakeEnv | null = null;
let restoreEnv: (() => void) | null = null;
afterEach(() => {
if (restoreEnv) restoreEnv();
if (env) env.cleanup();
env = null;
restoreEnv = null;
});
it("probes the gbrain executable directly instead of shelling through command -v", () => {
const source = readFileSync(
join(import.meta.dir, "..", "lib", "gbrain-local-status.ts"),
"utf-8",
);
expect(source).not.toContain('command -v gbrain');
expect(source).toContain('execFileSync("gbrain", ["--version"]');
});
it("returns 'no-cli' when gbrain is not on PATH", () => {
env = makeEnv({ withGbrain: false });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("no-cli");
});
it("returns 'missing-config' when CLI is present but ~/.gbrain/config.json absent", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: false });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
});
it("returns 'broken-db' when sources list emits 'Cannot connect to database'", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "broken-db", withConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("broken-db");
});
it("returns 'broken-config' when sources list emits config.json error", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "broken-config", withConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("broken-config");
});
it("returns 'broken-config' defensively when stderr matches neither pattern", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "throws", withConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("broken-config");
});
it("returns 'engine-locked' when PGLite exits 124 with its own connect timeout (#2194)", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "engine-locked", withConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("engine-locked");
});
it("classifies a non-PGLite connect timeout as unreachable DB, not malformed config", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "engine-locked", withConfig: true });
restoreEnv = applyEnv(env);
writeFileSync(env.configPath, JSON.stringify({ engine: "postgres", database_url: "postgres://fake" }));
expect(localEngineStatus({ noCache: true })).toBe("broken-db");
});
it("returns 'ok' when sources list succeeds", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("ok");
});
it("returns 'timeout' (not broken-config) when the probe exceeds the deadline (#1964)", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "slow", withConfig: true });
restoreEnv = applyEnv(env);
process.env.GSTACK_GBRAIN_PROBE_TIMEOUT_MS = "300";
expect(localEngineStatus({ noCache: true })).toBe("timeout");
});
it("honors GBRAIN_HOME for config detection (codex D11)", () => {
// Config lives ONLY at the alternate GBRAIN_HOME; ~/.gbrain has none.
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: false });
restoreEnv = applyEnv(env);
const altHome = join(env.tmp, "alt-gbrain");
mkdirSync(altHome, { recursive: true });
writeFileSync(
join(altHome, "config.json"),
JSON.stringify({ engine: "pglite", database_url: "pglite:///fake" }),
);
// Without GBRAIN_HOME: misclassified as missing-config.
expect(localEngineStatus({ noCache: true })).toBe("missing-config");
// With GBRAIN_HOME: the relocated config is found.
process.env.GBRAIN_HOME = altHome;
expect(localEngineStatus({ noCache: true })).toBe("ok");
});
});
describe("gstack-gbrain-detect --is-ok — timeout is usable (eng review D1)", () => {
it("exits 0 when the engine probe times out (slow-but-healthy must not suppress brain features)", () => {
const env = makeEnv({ withGbrain: true, gbrainBehavior: "slow", withConfig: true });
try {
const detect = join(import.meta.dir, "..", "bin", "gstack-gbrain-detect");
const r = spawnSync(process.execPath, [detect, "--is-ok"], {
encoding: "utf-8",
timeout: 20_000,
env: {
...process.env,
HOME: env.home,
GSTACK_HOME: env.gstackHome,
PATH: `${env.bindir}:/usr/bin:/bin`,
GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "300",
GSTACK_DETECT_NO_CACHE: "1",
GBRAIN_HOME: "",
},
});
expect(r.status).toBe(0);
} finally {
env.cleanup();
}
});
});
describe("probeTimeoutMs — env override parsing", () => {
it("defaults to 15s when unset", () => {
expect(probeTimeoutMs({})).toBe(DEFAULT_PROBE_TIMEOUT_MS);
expect(DEFAULT_PROBE_TIMEOUT_MS).toBe(15_000);
});
it("parses a numeric override", () => {
expect(probeTimeoutMs({ GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "300" })).toBe(300);
});
it("falls back to the default on non-numeric, empty, and non-positive values", () => {
expect(probeTimeoutMs({ GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "fast" })).toBe(DEFAULT_PROBE_TIMEOUT_MS);
expect(probeTimeoutMs({ GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "" })).toBe(DEFAULT_PROBE_TIMEOUT_MS);
expect(probeTimeoutMs({ GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "0" })).toBe(DEFAULT_PROBE_TIMEOUT_MS);
expect(probeTimeoutMs({ GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "-5" })).toBe(DEFAULT_PROBE_TIMEOUT_MS);
});
it("never returns 0 for fractional sub-millisecond values (0 = NO timeout in execFileSync)", () => {
expect(probeTimeoutMs({ GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "0.5" })).toBe(1);
expect(probeTimeoutMs({ GSTACK_GBRAIN_PROBE_TIMEOUT_MS: "0.0001" })).toBe(1);
});
});
describe("lib/gbrain-local-status — cache behavior", () => {
let env: FakeEnv | null = null;
let restoreEnv: (() => void) | null = null;
afterEach(() => {
if (restoreEnv) restoreEnv();
if (env) env.cleanup();
env = null;
restoreEnv = null;
});
it("writes a cache entry on first call", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
restoreEnv = applyEnv(env);
localEngineStatus({ noCache: false });
expect(existsSync(cacheFilePath())).toBe(true);
});
it("returns cached value within TTL even if underlying state would change", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
restoreEnv = applyEnv(env);
const first = localEngineStatus({ noCache: false });
expect(first).toBe("ok");
// Make the fake gbrain emit broken-db now. Cache should still say ok.
writeFileSync(
join(env.bindir, "gbrain"),
makeFakeGbrainScript("broken-db"),
);
chmodSync(join(env.bindir, "gbrain"), 0o755);
const second = localEngineStatus({ noCache: false });
expect(second).toBe("ok"); // cache hit
});
it("re-probes when --no-cache is passed", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: false })).toBe("ok");
writeFileSync(
join(env.bindir, "gbrain"),
makeFakeGbrainScript("broken-db"),
);
chmodSync(join(env.bindir, "gbrain"), 0o755);
expect(localEngineStatus({ noCache: true })).toBe("broken-db");
});
it("invalidates cache when config_mtime changes (key invariant)", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: false })).toBe("ok");
// Bump config mtime artificially (touch +10s) AND rewrite gbrain to broken-db.
const future = Math.floor(Date.now() / 1000) + 10;
utimesSync(env.configPath, future, future);
writeFileSync(
join(env.bindir, "gbrain"),
makeFakeGbrainScript("broken-db"),
);
chmodSync(join(env.bindir, "gbrain"), 0o755);
// Even with cache enabled, mtime mismatch forces re-probe.
expect(localEngineStatus({ noCache: false })).toBe("broken-db");
});
it("caches a 'timeout' result (sync probes 3x/run — uncached would cost 3 deadlines)", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "slow", withConfig: true });
restoreEnv = applyEnv(env);
process.env.GSTACK_GBRAIN_PROBE_TIMEOUT_MS = "300";
expect(localEngineStatus({ noCache: false })).toBe("timeout");
// Swap the fake to a fast-ok binary; the cached timeout should still win
// within TTL (same key — proving the result was cached, not re-probed).
writeFileSync(join(env.bindir, "gbrain"), makeFakeGbrainScript("ok"));
chmodSync(join(env.bindir, "gbrain"), 0o755);
expect(localEngineStatus({ noCache: false })).toBe("timeout");
});
it("invalidates a cached 'timeout' when GSTACK_GBRAIN_PROBE_TIMEOUT_MS changes (key invariant, codex D13)", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "slow", withConfig: true });
restoreEnv = applyEnv(env);
process.env.GSTACK_GBRAIN_PROBE_TIMEOUT_MS = "300";
expect(localEngineStatus({ noCache: false })).toBe("timeout");
// User raises the timeout past the fake's 2s sleep: cache key changes,
// re-probe succeeds.
process.env.GSTACK_GBRAIN_PROBE_TIMEOUT_MS = "5000";
expect(localEngineStatus({ noCache: false })).toBe("ok");
});
it("invalidates cache when GBRAIN_HOME changes (key invariant, codex D11)", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: false })).toBe("ok");
// Point GBRAIN_HOME at an empty dir: a stale cached "ok" must not win —
// gbrain_home is part of the cache key, so this re-probes and finds no
// config at the new location.
const altHome = join(env.tmp, "alt-gbrain-empty");
mkdirSync(altHome, { recursive: true });
process.env.GBRAIN_HOME = altHome;
expect(localEngineStatus({ noCache: false })).toBe("missing-config");
});
it("invalidates cache when HOME changes (key invariant)", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: false })).toBe("ok");
// Switch to a new HOME (different user). Same gstack home (shared cache file).
const env2 = makeEnv({
withGbrain: true,
gbrainBehavior: "broken-db",
withConfig: true,
});
process.env.HOME = env2.home;
process.env.PATH = `${env2.bindir}:/usr/bin:/bin`;
// GSTACK_HOME stays pointing at env.gstackHome (the original cache file).
try {
expect(localEngineStatus({ noCache: false })).toBe("broken-db");
} finally {
env2.cleanup();
}
});
});
// ---------------------------------------------------------------------------
// #2051: thin-client classification + the end-to-end --is-ok gate
// ---------------------------------------------------------------------------
describe("lib/gbrain-local-status — thin-client (#2051)", () => {
let env: FakeEnv | null = null;
let restoreEnv: (() => void) | null = null;
afterEach(() => {
if (restoreEnv) restoreEnv();
if (env) env.cleanup();
env = null;
restoreEnv = null;
});
it("returns 'thin-client' when config carries gbrain's remote_mcp marker (pre-probe, no engine call)", () => {
// The fake gbrain would answer "ok" if probed — proving the marker is
// read from config BEFORE any probe (zero network, no error-string
// dependence).
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", thinClientConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
});
it("returns 'thin-client' via the stderr refusal fallback when the config marker is unreadable", () => {
// Regular (non-thin) config on disk, but gbrain itself refuses with the
// dispatch-guard message — the catch-path backstop.
env = makeEnv({ withGbrain: true, gbrainBehavior: "thin-refusal", withConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("thin-client");
});
// The eng-review 3A tripwire: the END-TO-END gate, not just the classifier
// return. --is-ok drives setup:1299 and gstack-config gbrain-refresh — this
// exit code is what decides whether brain-aware blocks render for a
// thin-client user (the #2051 report).
it("--is-ok exits 0 on a thin-client fixture (end-to-end gate)", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", thinClientConfig: true });
const detectBin = join(import.meta.dir, "..", "bin", "gstack-gbrain-detect");
const bunDir = dirname(process.execPath);
const r = spawnSync(detectBin, ["--is-ok"], {
encoding: "utf-8",
env: {
HOME: env.home,
PATH: `${env.bindir}:${bunDir}:/usr/bin:/bin`,
GSTACK_HOME: env.gstackHome,
GSTACK_DETECT_NO_CACHE: "1",
},
});
expect(r.status).toBe(0);
});
it("--is-ok still exits 1 on broken-config (thin-client did not widen the gate)", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "broken-config", withConfig: true });
const detectBin = join(import.meta.dir, "..", "bin", "gstack-gbrain-detect");
const bunDir = dirname(process.execPath);
const r = spawnSync(detectBin, ["--is-ok"], {
encoding: "utf-8",
env: {
HOME: env.home,
PATH: `${env.bindir}:${bunDir}:/usr/bin:/bin`,
GSTACK_HOME: env.gstackHome,
GSTACK_DETECT_NO_CACHE: "1",
},
});
expect(r.status).toBe(1);
});
});