v0.37.7.0 fix wave: federated brains + autopilot safety + OAuth confidential clients (#1253)

* fix(reindex-frontmatter): connect engine before query (#1225)

`createEngine()` from src/core/engine-factory.ts only constructs the
engine; callers MUST call connect() before any executeRaw. The
reindex-frontmatter CLI was constructing the engine and going
straight to countAffected, which crashed on PGLite with "PGLite not
connected. Call connect() first." even on --dry-run.

Fix follows the existing-command pattern (src/commands/auth.ts,
src/commands/backfill.ts, src/commands/integrity.ts all do the
same): pass toEngineConfig(cfg) into both createEngine() AND
engine.connect(), then engine.initSchema() (idempotent on a current
schema, ~1ms cost).

Pre-fix verification: codex outside-voice CF5 flagged the related
"can't import connectEngine from cli.ts" misdirection in the
original fix plan. This implementation uses the canonical sibling
pattern instead.

Regression test pinned at test/reindex-frontmatter-connect.test.ts.

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

* chore: bump VERSION to 0.37.7.0 + stub CHANGELOG

v0.37.5.0 claimed by #1229 (warsaw-v4); v0.37.6.0 by #1246
(OpenRouter recipe). v0.37.7.0 is the next free slot for this
fix wave.

CHANGELOG entry stubbed in user-facing voice per CLAUDE.md
"CHANGELOG voice + release-summary format" — ELI10 lead-first,
real fix details below. The "## To take advantage of v0.37.7.0"
block follows the v0.13+ self-repair pattern from CLAUDE.md.

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

* fix(subagent): short-circuit terminal-on-resume (#1151)

Bug: when the worker resumed a subagent job whose persisted last
message was an assistant turn with text-only content (no tool_use
blocks), the replay reconciler at subagent.ts:241-247 had no
branch for that case. The main loop then called messages.create
against a conversation ending in assistant role, which Sonnet 4.6+
rejects with HTTP 400 "This model does not support assistant
message prefill." 3 retries later → dead-letter, despite all the
job's work having committed in earlier turns.

@zscgeek's bug report pinned this exactly: dream-cycle Otter
corpus runs hit ~7% dead-letter rate, every dead job's last
subagent_messages row was a text-only synthesis summary listing
slugs that already existed in `pages`. Their proposed fix mirrors
this implementation.

Fix: add an else branch to the assistant-tail check that mirrors
the live-loop terminal logic at subagent.ts:440-447 — reconstruct
finalText from the persisted text blocks, return
stop_reason='end_turn' immediately. No LLM call, no schema change.

Two new regression cases:
  - text-only terminal on resume returns immediately with zero
    messages.create calls
  - tool-use replay path unchanged (existing behavior preserved)

Codex outside-voice (CF13) initially flagged this fix as
mis-targeted, claiming subagent.ts already handled the case.
/investigate run revealed the live-loop terminal at :440-447 was
covered but the REPLAY-path terminal at :241-247 was missing —
both branches need symmetric handling.

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

* fix(autopilot): scope lockfile to GBRAIN_HOME (#1226)

The autopilot lockfile was hardcoded at `~/.gbrain/autopilot.lock`
(via `process.env.HOME`), bypassing GBRAIN_HOME. Two brains pointed
at different GBRAIN_HOME directories still wrote to the same global
lockfile; one would silently take over the other on each restart.

Fix: route through `gbrainPath('autopilot.lock')` from
src/core/config.ts (imported aliased as gbrainHomePath since the
local `gbrainPath` var in installAutopilot references the CLI
binary path). The mkdirSync(`~/.gbrain`) call also routes through
the helper so the directory is created in the right place too.

Co-authored with @rafaelreis-r — same fix shape as PR #1227,
re-implemented against current master per the wave's
"re-implement, credit, close" workflow.

Tests cover: one GBRAIN_HOME → one canonical lock; two
GBRAIN_HOME values → two distinct locks; default fall-through
still works.

Co-Authored-By: rafaelreis-r <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(graph-query): foreign-edge footer + --include-foreign (#1153)

The graph-query CLI silently dropped edges to pages in other sources
on federated brains. Users had no signal those edges existed unless
they read the source code.

Fix:
- New --include-foreign flag (off by default, preserves the existing
  scoping contract; on = explicit cross-source traversal).
- After every traversal, count edges from rootSlug whose target page
  lives in a different source. When count > 0 AND user didn't opt in,
  emit a stderr footer:
    `(N edge(s) to foreign-source pages hidden; pass --include-foreign
     to include them)`
- The "no edges found" path also runs the count + footer so users
  discover foreign edges even when scoped traversal returned nothing.
- Thin-client path skips the count (engine query not available);
  future T1 work threads source resolution through MCP for that path.
- Single quotation correctness in count SQL: page_links table is
  `links` (not `page_links`); JOIN both endpoints to pages and compare
  source_id, NULL-safe via `IS NOT NULL` guards on both sides.
- Fail-open on missing source_id column for pre-v0.18 brains: return 0
  (no foreign edges to report) instead of throwing.

4 new test cases: footer fires on scoped query with foreign edge,
--include-foreign suppresses footer, zero-foreign no-footer case,
pluralization regression guard.

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

* feat(sources): `gbrain sources current` + tier attribution (#1222)

Federated-brain users running destructive ops (extract, import,
purge) need a way to verify which source they're targeting BEFORE
the op runs. Pre-fix, the only way was to grep config files or run
the op with --dry-run and inspect output.

New command:
  gbrain sources current             # human output
  gbrain sources current --json      # machine-readable
  gbrain sources current --source X  # show what an explicit --source
                                     # X would resolve to (validates
                                     # X exists in the sources table)

Output names BOTH the resolved source id AND which tier of the 6-tier
resolution chain won (flag / env / dotfile / local_path /
brain_default / seed_default), plus a `detail` line naming the
winning signal (e.g. "GBRAIN_SOURCE=dept-x" or ".gbrain-source" or
"/work/gstack/src").

Implementation:
- New `resolveSourceWithTier()` in source-resolver.ts as an additive
  variant of `resolveSourceId()`. Walks the same 6 steps in the same
  order; just returns `{ source_id, tier, detail? }` instead of bare
  string. Existing `resolveSourceId()` unchanged — all callers
  continue working.
- New `SOURCE_TIER_NAMES` const + `SourceTier` type export so the
  CLI, doctor (Tier 5 follow-up), and future MCP consumers share one
  vocabulary instead of inlining strings.
- Help text updated; `current` subcommand registered in dispatcher.

11 new tests pin the 6-tier ladder + priority semantics. Existing
19 source-resolver tests still pass (regression preserved).

Per codex CF3 (the existing src/core/source-resolver.ts was missed
in the original plan). Re-uses the existing helper instead of
inventing a duplicate.

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

* feat(extract): --source-id scopes extraction to one brain source (#1204)

Federated brain users running `gbrain extract` had no way to scope
extraction to one source. The DB path walks all sources together via
listAllPageRefs(), which is correct for cross-source resolution but
sometimes the user wants to extract per-source explicitly (e.g.
re-running extract on a specific source after a manual import).

The pre-existing `--source` flag is the data-source axis (fs|db) and
can't be repurposed. New flag `--source-id <id>` joins it on the
brain-source-id axis:

  gbrain extract all --source db --source-id alpha
    -> walks only alpha-source pages; extracts links + timeline
       from those, into the alpha source

Important: the resolver maps (allSlugs + slugToSources) stay built
from the FULL listAllPageRefs result, not the scoped subset. This
ensures qualified cross-source wikilinks like `[[other-src:slug]]`
still resolve correctly even when the extract walk is scoped — the
filter is on which pages we extract FROM, not what we can resolve TO.

Threaded through both `extractLinksFromDB` and `extractTimelineFromDB`
with backward-compat: callers passing no opts get the old behavior.

4 new test cases pin: walks-all-without-flag baseline,
alpha-only-when-scoped-to-alpha, beta-only-when-scoped-to-beta,
empty-set-on-unknown-source.

Note: #1204's wider "silent 0 links" report on federated brains has
additional facets beyond this flag (resolver path edge cases on
overlapping slugs). The scoped-walk fix gives users an explicit
workaround AND closes the per-source extraction gap.

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

* chore(todos): file v0.37.7.0 follow-ups (#1173, #1204, T5N)

Three items deferred from v0.37.7.0:

1. #1173 .sql indexing — verify-first gate found
   tree-sitter-sql.wasm missing from src/assets/wasm/grammars/.
   Dedicated wave needed: vendor the wasm, add .sql to walker
   filter, address slug-shape collision with #1172.

2. #1204 deeper investigation — wave added --source-id flag as
   workaround. Underlying silent-zero-links bug on unscoped
   federated extracts needs its own /investigate pass against
   a cross-source-duplicate-slug fixture.

3. Tier 5N doctor sweep for dead-lettered subagent jobs matching
   the #1151 fingerprint. Deferred to v0.37.8+ behind the islamabad
   doctor.ts conflict resolution.

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

* fix(sync): walker skips git submodule directories (#1169)

Sync walker descended into git submodules and indexed their markdown
content as if it belonged to the parent brain. Users with submodules
in their brain repo saw foreign content in their pages table.

Fix: pruneDir gains an optional `parentDir` arg. When set, the helper
stats `<parentDir>/<name>/.git` and skips the directory if `.git`
exists as a FILE (gitfile pointer — the canonical submodule shape).
Directories containing `.git` as a DIRECTORY (a real nested repo,
not a submodule) are descended into; the inner `.git` dir itself is
then dot-prefix-excluded.

Callers updated to pass parentDir:
- src/commands/extract.ts walkMarkdownFiles
- src/core/cycle/transcript-discovery.ts walker

Back-compat preserved: existing pruneDir(name) callers without
parentDir get the pre-v0.37.7.0 behavior unchanged.

Companion `.gitignore`-respect feature from PR #1159 (@jetsetterfl)
NOT in this wave — it would require adding the `ignore` npm package
as a dep, which the plan's "no new deps in this PR" gate excludes.
Filed as follow-up TODO for a dedicated wave.

5 new test cases pin the submodule shape + back-compat + nested-repo
ambiguity. Existing extract-fs / extract-db tests unchanged.

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

* docs(brain-routing): document 6-tier source resolution chain (#1222)

The convention skill didn't have a tier-by-tier reference for how
gbrain resolves the active source. Users running federated brains
had to read the source code to know which signal wins.

Added:
- Canonical 6-tier table (flag → env → dotfile → local_path →
  brain_default → seed_default) matching src/core/source-resolver.ts.
- Pointer to `gbrain sources current` (new in v0.37.7.0) as the
  verification command.
- The CLI-layer trust boundary note: operations.ts handlers don't
  read env/dotfile (preserves v0.34.1.0 source-isolation work for
  MCP callers).
- Per-command flag map: --source, --source-id (extract), and
  --include-foreign (graph-query).

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

* feat(import): --source-id flag routes pages to a brain source (#1167)

`gbrain import --source dept-x ./pages` silently fell back to the
default source because the CLI parser never consumed --source. PR
#707's design intent excluded the flag explicitly; users had no
signal their pages were going to the wrong place. #1167 + #1222
filed the regression.

Fix: parse `--source-id <id>` (matching v0.37.7.0 extract.ts T2's
naming convention — --source-id stays out of conflict with future
axes that may want --source). When set, the flag value wins over
any programmatic opts.sourceId; back-compat preserved for callers
that pass sourceId via opts only.

Also threaded into the positional-dir arg parser's flagValues set
so `--source-id <value> <dir>` doesn't treat <value> as the dir.

Note on related surfaces:
- `gbrain query "X" --source_id dept-x` already routed correctly
  via the operations.ts query op (added in v0.34) — no fix needed.
- `gbrain extract --source-id <id>` shipped in T2.
- `gbrain sync --source <id>` already worked (pre-existing).
- `gbrain sources current` (shipped in T4) is the verification
  tool — run it before destructive ops to confirm routing.

Closes the silent-fallback for the import path. Co-authored with
@tyad67-netizen (#1168), @hnshah (#1124, #1120), whose patches
informed the shape; re-implemented against current master per
the wave's "re-implement, credit, close" workflow.

3 new test cases pin: default-without-flag, --source-id-routes-correctly,
flag-value-not-treated-as-dirArg.

Co-Authored-By: tyad67-netizen <noreply@github.com>
Co-Authored-By: hnshah <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(autopilot): reconnect classifier + launchd ThrottleInterval (#1162)

Pre-fix: when database_url was unset/malformed, the DB-health-check
reconnect loop logged `config.database_url undefined` forever
because the catch swallowed every error type uniformly. launchd's
KeepAlive=true respawned immediately on any exit, so even when
the process did exit, it came right back into the same bad state.
@colin477 reported the daemon-thrash pattern.

Two-part fix:

1. In-process error classifier — `classifyReconnectError(err)`:
   - `unrecoverable` (database_url missing/empty/malformed, auth
     failure, no-brain-configured): exit immediately with a clear
     stderr line. Pattern-matched against postgres / config-loader
     error shapes. Tests pin the matcher against the #1162
     fingerprint exactly.
   - `recoverable` (network blip, pool saturated, connection refused
     on a port coming up, Supabase 503): retry. Up to
     GBRAIN_AUTOPILOT_MAX_RECONNECT_FAILS (default 30 = ~5min) before
     finally giving up with `max_reconnect_fails_exceeded`.
   - Counter resets on every successful health probe or reconnect.

2. launchd plist gains `ThrottleInterval=60`. Combined with the
   in-process exit, launchd waits 60s before relaunching instead
   of immediate respawn. Pure-function `generateLaunchdPlist()`
   exported for tests.

16 new test cases:
- 11 classifier cases (database_url shapes, malformed URL, auth,
  role-does-not-exist with quoted name, network blip, pool
  saturated, 503, non-Error inputs, case-insensitivity)
- 5 plist generator cases (ThrottleInterval=60, KeepAlive
  preserved, wrapper path, XML escaping, StandardErrorPath).

Pre-existing autopilot-lock-path tests unchanged — both fixes
land cleanly side-by-side.

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

* fix(oauth): confidential clients via custom /token middleware (#1166)

v0.34.1.0 (#909) fixed PUBLIC PKCE clients (client_secret=undefined)
by normalizing NULL → undefined in getClient. Confidential clients
regressed: the MCP SDK's clientAuth middleware does plaintext
`client.client_secret !== presented_secret` compare, but gbrain
stores SHA-256 hashes, so the SDK's compare always failed for
authorization_code and refresh_token grants on confidential clients.
Result: /token returned `invalid_client` for every confidential
exchange.

Fix shape per locked-decision-5: custom /token middleware BEFORE the
SDK's authRouter, similar to the pre-existing client_credentials
handler. The middleware:

1. Detects confidential auth via `client_secret` in body
   (client_secret_post) OR `Authorization: Basic` header
   (client_secret_basic per RFC 6749 §2.3.1).
2. Falls through to the SDK when neither is present (public PKCE
   path stays canonical, preserves v0.34.1.0 behavior).
3. Calls new `verifyConfidentialClientSecret(clientId, presented)`
   on the provider which does SHA-256 hash compare ourselves
   (same shape as exchangeClientCredentials' existing hash check).
4. On verification success, calls existing
   `exchangeAuthorizationCode` / `exchangeRefreshToken` directly
   with the validated client.
5. RFC 6749 §5.2 error semantics: 401 invalid_client for auth
   failures, 400 invalid_grant for code/token problems.

Per CLAUDE.md "GBRAIN:RLS_EXEMPT" annotation contract: this surface
sits in front of the SDK's clientAuth and doesn't depend on the
SDK's plaintext compare working — the SDK's middleware never
fires for confidential paths the new middleware claims.

7 new test cases pin: correct-secret-returns-client, wrong-secret
opaque rejection, non-existent client, public-client refuses
the confidential path, case-sensitivity, soft-deleted revocation,
verify-then-exchange-refresh round-trip with second-use rejection.

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

* feat(doctor): 3 new checks — source routing + oauth + autopilot lock (T12/T13/T14)

Three v0.37.7.0 doctor checks landing in one atomic commit (single
file, shared merge-conflict surface with garrytan/islamabad-v3 per
locked decision 1):

1. source_routing_health (T12 / #1167):
   Sample non-default sources for pages; warn when a registered
   source has zero pages (silent-collapse-to-default fingerprint).
   D5 lock: total-sample cap of 200 pages across all sources, with
   per-source cap = min(50, ceil(200/N)) so a 20-source CEO brain
   pays 200 selects, not 1000. Fix hint paste-ready to
   `gbrain sources current --json` for verification.

2. oauth_confidential_client_health (T13 / #1166):
   Probe every oauth_clients row. Confidential clients (auth_method
   != 'none') must have a non-NULL client_secret_hash; if any row
   claims confidential auth but stores NULL hash, that's the
   pre-v0.37.7.0 regression. Public clients (auth_method='none')
   correctly keep NULL hash per v0.34.1.0 #909. Fix hint:
   `gbrain auth revoke-client + register-client` OR `gbrain upgrade`.
   Pre-OAuth schemas (missing oauth_clients table) skip gracefully.

3. autopilot_lock_scope (T14 / #1226):
   Detect stale ~/.gbrain/autopilot.lock outside the current
   GBRAIN_HOME. Codex CF11: dangerous to paste-ready `rm` without
   verifying the owning PID isn't a live process. Hint reads the
   PID file and gives the user a `ps -p <pid>` check before any
   delete — matches sshd-style stale-lock recovery hints.

9 new test cases pin the canonical paths. Pre-existing 80+ doctor
checks unchanged.

Expected to conflict with garrytan/islamabad-v3 at merge time. The
3 new check functions live in their own block far from the
islamabad skill_brain_first check; the conflict surface should be
limited to the `checks.push(...)` call site near the end of
runDoctor's DB-checks phase (~10 lines).

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

* fix(test): withEnv wrapper in source-resolver-with-tier (test-isolation lint)

The new source-resolver-with-tier.test.ts from T4 mutated
process.env.GBRAIN_SOURCE directly in two cases, which violates
scripts/check-test-isolation.sh R1 (env mutations leak across
parallel-loaded test files in the same shard process).

Fix: wrap both mutation sites in withEnv() from test/helpers/with-env.ts,
which saves+restores via try/finally per the canonical pattern in
CLAUDE.md.

Pure refactor — all 11 cases still green.

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

* docs: update project documentation for v0.37.7.0

CHANGELOG.md — populated the "What landed" stub with the 18-commit
brisbane wave (source-id flag threading, sources current subcommand,
graph-query foreign-edge footer, autopilot lockfile scope + reconnect
classifier + launchd ThrottleInterval, OAuth confidential client
middleware, reindex-frontmatter connect fix, subagent terminal-on-resume
fix, sync walker submodule skip, 3 new doctor checks, brain-routing.md
convention skill). Voice: ELI10 lead, capability table, paste-ready
verification, "what's safe to know" + "what we caught" sections.

CLAUDE.md — extended Key Files annotations for the v0.37.7.0 changes:
import/extract --source-id flags, sources current subcommand, graph-query
--include-foreign, resolveSourceWithTier() additive helper, autopilot
classifyReconnectError + generateLaunchdPlist exports, OAuth confidential
client middleware, pruneDir submodule detection, subagent terminal
short-circuit, 3 new doctor checks. Pinned by their test files.

llms-full.txt — regenerated via `bun run build:llms` (CI guard at
test/build-llms.test.ts will fail otherwise).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: rafaelreis-r <noreply@github.com>
This commit is contained in:
Garry Tan
2026-05-21 08:29:55 -07:00
committed by GitHub
co-authored by Claude Opus 4.7 rafaelreis-r
parent 430d784a76
commit f1f9199ff4
31 changed files with 2119 additions and 37 deletions
+67
View File
@@ -0,0 +1,67 @@
/**
* v0.37.7.0 #1226 regression test.
*
* The autopilot lockfile was hardcoded at `~/.gbrain/autopilot.lock`
* (via `process.env.HOME`), bypassing GBRAIN_HOME. Two brains pointed
* at different GBRAIN_HOME directories would still write to the same
* global lockfile; one would silently take over the other on each
* restart.
*
* Fix: route through `gbrainPath('autopilot.lock')` which honors
* GBRAIN_HOME. This file pins the contract via the canonical helper
* directly, since the autopilot daemon's lifecycle is heavy to drive
* in a unit test.
*/
import { describe, test, expect } from 'bun:test';
import { withEnv } from './helpers/with-env.ts';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { gbrainPath } from '../src/core/config.ts';
describe('autopilot lock path scoped to GBRAIN_HOME (#1226)', () => {
test('one GBRAIN_HOME produces one canonical lock path', async () => {
const home = mkdtempSync(join(tmpdir(), 'gbrain-autopilot-lock-'));
await withEnv({ GBRAIN_HOME: home }, async () => {
const lockPath = gbrainPath('autopilot.lock');
// Lockfile MUST live inside the per-brain GBRAIN_HOME, not under
// process.env.HOME — that was the pre-fix bug.
expect(lockPath.startsWith(home)).toBe(true);
expect(lockPath.endsWith('autopilot.lock')).toBe(true);
});
});
test('two GBRAIN_HOME values produce two distinct lockfiles', async () => {
const homeA = mkdtempSync(join(tmpdir(), 'gbrain-autopilot-A-'));
const homeB = mkdtempSync(join(tmpdir(), 'gbrain-autopilot-B-'));
let lockA = '';
let lockB = '';
await withEnv({ GBRAIN_HOME: homeA }, async () => {
lockA = gbrainPath('autopilot.lock');
});
await withEnv({ GBRAIN_HOME: homeB }, async () => {
lockB = gbrainPath('autopilot.lock');
});
// The contract that prevents two brains from silently colliding:
// distinct GBRAIN_HOME values MUST produce distinct lockfile paths.
expect(lockA).not.toBe(lockB);
expect(lockA.startsWith(homeA)).toBe(true);
expect(lockB.startsWith(homeB)).toBe(true);
});
test('default (no GBRAIN_HOME override) still produces a valid path', async () => {
// When GBRAIN_HOME is unset, gbrainPath falls through to its
// default (`~/.gbrain`). The path must still exist as a string
// and end with the expected filename — we don't assert the exact
// home dir since that varies by environment.
await withEnv({ GBRAIN_HOME: undefined }, async () => {
const lockPath = gbrainPath('autopilot.lock');
expect(typeof lockPath).toBe('string');
expect(lockPath.endsWith('autopilot.lock')).toBe(true);
expect(lockPath.length).toBeGreaterThan('autopilot.lock'.length);
});
});
});
+101
View File
@@ -0,0 +1,101 @@
/**
* v0.37.7.0 #1162 — autopilot reconnect-error classifier + launchd plist
* generator regression tests.
*
* Pre-fix: autopilot's DB-health-check reconnect loop caught every error
* and looped forever. When `database_url` was unset/malformed the loop
* spammed `config.database_url undefined` until the user killed launchd.
*
* Fix: classify errors into recoverable (transient, retry) vs
* unrecoverable (config / auth — exit). Combined with launchd plist's
* `ThrottleInterval=60`, unrecoverable exits trigger a real 60s
* backoff instead of immediate respawn.
*/
import { describe, test, expect } from 'bun:test';
import { classifyReconnectError, generateLaunchdPlist } from '../src/commands/autopilot.ts';
describe('classifyReconnectError (#1162)', () => {
test('database_url undefined → unrecoverable (the #1162 fingerprint)', () => {
const err = new Error('config.database_url undefined');
expect(classifyReconnectError(err)).toBe('unrecoverable');
});
test('database_url empty → unrecoverable', () => {
expect(classifyReconnectError(new Error('database_url is empty'))).toBe('unrecoverable');
});
test('database_url missing → unrecoverable', () => {
expect(classifyReconnectError(new Error('database_url missing'))).toBe('unrecoverable');
});
test('malformed URL → unrecoverable', () => {
expect(classifyReconnectError(new Error('Invalid URL: not a postgres connection string'))).toBe('unrecoverable');
expect(classifyReconnectError(new Error('Failed to parse URL'))).toBe('unrecoverable');
});
test('auth failure → unrecoverable (creds don\'t fix themselves)', () => {
expect(classifyReconnectError(new Error('password authentication failed for user "gbrain"'))).toBe('unrecoverable');
expect(classifyReconnectError(new Error('role "ghost" does not exist'))).toBe('unrecoverable');
});
test('no brain configured → unrecoverable', () => {
expect(classifyReconnectError(new Error('No brain configured. Run: gbrain init'))).toBe('unrecoverable');
});
test('network blip → recoverable', () => {
expect(classifyReconnectError(new Error('ECONNREFUSED 127.0.0.1:5432'))).toBe('recoverable');
expect(classifyReconnectError(new Error('connection terminated unexpectedly'))).toBe('recoverable');
});
test('pool saturated → recoverable', () => {
expect(classifyReconnectError(new Error('connection pool timed out'))).toBe('recoverable');
expect(classifyReconnectError(new Error('remaining connection slots are reserved'))).toBe('recoverable');
});
test('Supabase 503 → recoverable', () => {
expect(classifyReconnectError(new Error('HTTP 503 Service Unavailable'))).toBe('recoverable');
});
test('non-Error inputs degrade safely', () => {
expect(classifyReconnectError(null)).toBe('recoverable');
expect(classifyReconnectError(undefined)).toBe('recoverable');
expect(classifyReconnectError('plain string error')).toBe('recoverable');
expect(classifyReconnectError({ weird: 'object' })).toBe('recoverable');
});
test('case-insensitive match', () => {
expect(classifyReconnectError(new Error('DATABASE_URL UNDEFINED'))).toBe('unrecoverable');
expect(classifyReconnectError(new Error('Password Authentication FAILED'))).toBe('unrecoverable');
});
});
describe('generateLaunchdPlist (#1162)', () => {
test('plist contains ThrottleInterval=60', () => {
const plist = generateLaunchdPlist('/Users/me/.gbrain/autopilot-run.sh', '/Users/me');
expect(plist).toMatch(/<key>ThrottleInterval<\/key><integer>60<\/integer>/);
});
test('plist contains KeepAlive (existing behavior preserved)', () => {
const plist = generateLaunchdPlist('/Users/me/.gbrain/autopilot-run.sh', '/Users/me');
expect(plist).toMatch(/<key>KeepAlive<\/key><true\/>/);
});
test('plist references the wrapper path correctly', () => {
const plist = generateLaunchdPlist('/path/to/wrapper.sh', '/home');
expect(plist).toContain('/path/to/wrapper.sh');
});
test('plist escapes XML special chars in paths', () => {
const plist = generateLaunchdPlist('/path/with&amp/test.sh', '/home');
// The path with `&` should be escaped to `&amp;` (idempotent on
// already-escaped strings is acceptable; the key contract is "no
// raw `&` in the XML output").
expect(plist).not.toContain('with&/test'); // raw unescaped `&` between with and `/`
});
test('plist writes StandardErrorPath under the home dir (#1162 — error visibility)', () => {
const plist = generateLaunchdPlist('/wrapper.sh', '/Users/alice');
expect(plist).toContain('/Users/alice/.gbrain/autopilot.err');
});
});
+145
View File
@@ -0,0 +1,145 @@
/**
* v0.37.7.0 doctor checks — T12, T13, T14.
*
* - checkSourceRoutingHealth (T12 / 5K) — multi-source brains with
* empty non-default sources surface the silent-collapse-to-default
* fingerprint from #1167.
* - checkOauthConfidentialHealth (T13 / 5L) — confidential clients
* missing client_secret_hash fail loud.
* - checkAutopilotLockScope (T14 / 5M) — stale lockfile outside
* GBRAIN_HOME surfaces a PID-safe hint.
*
* Hermetic via PGLite + tmpdir overrides.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import {
checkSourceRoutingHealth,
checkOauthConfidentialHealth,
checkAutopilotLockScope,
} from '../src/commands/doctor.ts';
import { withEnv } from './helpers/with-env.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
async function truncate(): Promise<void> {
for (const t of ['pages', 'oauth_tokens', 'oauth_codes', 'oauth_clients']) {
await (engine as any).db.exec(`DELETE FROM ${t}`);
}
await (engine as any).db.exec(`DELETE FROM sources WHERE id <> 'default'`);
}
describe('checkSourceRoutingHealth (#1167)', () => {
beforeEach(truncate);
test('single-source brain (only default) → ok', async () => {
const r = await checkSourceRoutingHealth(engine);
expect(r.status).toBe('ok');
expect(r.message).toMatch(/single-source/i);
});
test('multi-source brain, all populated → ok', async () => {
await engine.executeRaw(`INSERT INTO sources (id, name) VALUES ('a', 'a'), ('b', 'b')`);
await engine.executeRaw(
`INSERT INTO pages (slug, source_id, type, title, compiled_truth, timeline)
VALUES ('p1', 'a', 'note', 'p1', '', ''), ('p2', 'b', 'note', 'p2', '', '')`,
);
const r = await checkSourceRoutingHealth(engine);
expect(r.status).toBe('ok');
expect(r.message).toMatch(/all populated/i);
});
test('non-default source with zero pages → warn (the #1167 fingerprint)', async () => {
await engine.executeRaw(`INSERT INTO sources (id, name) VALUES ('lonely', 'lonely')`);
const r = await checkSourceRoutingHealth(engine);
expect(r.status).toBe('warn');
expect(r.message).toMatch(/lonely/);
expect(r.message).toMatch(/--source-id/);
expect(r.message).toMatch(/gbrain sources current/);
});
});
describe('checkOauthConfidentialHealth (#1166)', () => {
beforeEach(truncate);
test('no OAuth clients → ok', async () => {
const r = await checkOauthConfidentialHealth(engine);
expect(r.status).toBe('ok');
});
test('public client (auth_method=none, hash=NULL) → ok (v0.34.1.0 shape preserved)', async () => {
await engine.executeRaw(
`INSERT INTO oauth_clients (client_id, client_secret_hash, client_name, redirect_uris, grant_types, scope, token_endpoint_auth_method)
VALUES ('pkce-pub', NULL, 'pub', $1, $2, 'read', 'none')`,
[['https://e.test/cb'], ['authorization_code']],
);
const r = await checkOauthConfidentialHealth(engine);
expect(r.status).toBe('ok');
});
test('confidential client with NULL hash → fail (the #1166 regression fingerprint)', async () => {
await engine.executeRaw(
`INSERT INTO oauth_clients (client_id, client_secret_hash, client_name, redirect_uris, grant_types, scope, token_endpoint_auth_method)
VALUES ('conf-broken', NULL, 'broken', $1, $2, 'read', 'client_secret_post')`,
[['https://e.test/cb'], ['authorization_code']],
);
const r = await checkOauthConfidentialHealth(engine);
expect(r.status).toBe('fail');
expect(r.message).toMatch(/conf-broken/);
expect(r.message).toMatch(/revoke-client/);
});
test('confidential client with proper hash → ok', async () => {
await engine.executeRaw(
`INSERT INTO oauth_clients (client_id, client_secret_hash, client_name, redirect_uris, grant_types, scope, token_endpoint_auth_method)
VALUES ('healthy', 'abc123def', 'h', $1, $2, 'read', 'client_secret_post')`,
[['https://e.test/cb'], ['authorization_code']],
);
const r = await checkOauthConfidentialHealth(engine);
expect(r.status).toBe('ok');
});
});
describe('checkAutopilotLockScope (#1226)', () => {
test('no lockfile → ok', async () => {
const sandbox = mkdtempSync(join(tmpdir(), 'doctor-lock-scope-'));
await withEnv({ GBRAIN_HOME: sandbox, HOME: sandbox }, async () => {
const r = checkAutopilotLockScope();
expect(r.status).toBe('ok');
expect(r.message).toMatch(/Lock path:/);
});
rmSync(sandbox, { recursive: true, force: true });
});
test('stale lock outside GBRAIN_HOME → warn with PID-safe hint', async () => {
const home = mkdtempSync(join(tmpdir(), 'doctor-lock-home-'));
const gbrainHome = mkdtempSync(join(tmpdir(), 'doctor-lock-gbrain-'));
mkdirSync(join(home, '.gbrain'), { recursive: true });
writeFileSync(join(home, '.gbrain', 'autopilot.lock'), '99999');
await withEnv({ HOME: home, GBRAIN_HOME: gbrainHome }, async () => {
const r = checkAutopilotLockScope();
expect(r.status).toBe('warn');
expect(r.message).toMatch(/Stale lockfile/);
expect(r.message).toMatch(/99999/);
expect(r.message).toMatch(/ps -p 99999/);
});
rmSync(home, { recursive: true, force: true });
rmSync(gbrainHome, { recursive: true, force: true });
});
});
+125
View File
@@ -0,0 +1,125 @@
/**
* v0.37.7.0 #1204 — `gbrain extract --source-id <id>` scopes extraction.
*
* Federated brain users running `gbrain extract` need to scope by
* source. Pre-fix, every run walked all sources together which
* confused link resolution on cross-source duplicates. This test
* pins the new `--source-id` flag: walk + extract only that source's
* pages, while the resolver still sees ALL sources so qualified
* `[[source:slug]]` wikilinks across sources can resolve.
*
* Hermetic via PGLite in-memory (no DATABASE_URL needed). Dedicated
* file per D4 lock.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { runExtract } from '../src/commands/extract.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
async function truncateAll(): Promise<void> {
for (const t of ['content_chunks', 'links', 'timeline_entries', 'tags', 'raw_data', 'page_versions', 'ingest_log', 'pages']) {
await (engine as any).db.exec(`DELETE FROM ${t}`);
}
await (engine as any).db.exec(`DELETE FROM sources WHERE id <> 'default'`);
}
describe('extract --source-id flag (#1204)', () => {
beforeEach(async () => {
await truncateAll();
// Two sources, each with a page whose body contains a wikilink to
// its sibling in the same source.
await engine.executeRaw(
`INSERT INTO sources (id, name) VALUES ('alpha', 'alpha'), ('beta', 'beta')
ON CONFLICT (id) DO NOTHING`,
);
await engine.executeRaw(
`INSERT INTO pages (slug, source_id, type, title, compiled_truth, timeline)
VALUES
('people/alice', 'alpha', 'person', 'Alice', 'Met [[people/bob]] today.', ''),
('people/bob', 'alpha', 'person', 'Bob', 'Friend of [[people/alice]].', ''),
('people/carol', 'beta', 'person', 'Carol', 'Met [[people/dave]].', ''),
('people/dave', 'beta', 'person', 'Dave', 'Friend of [[people/carol]].', '')`,
);
});
test('without --source-id, walks all sources', async () => {
const captured: unknown[] = [];
const origLog = console.log;
console.log = (m: unknown) => { captured.push(m); };
try {
await runExtract(engine, ['links', '--source', 'db', '--json']);
} finally {
console.log = origLog;
}
// Some non-zero number of links across both sources.
const linkRows = await engine.executeRaw<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM links`,
);
expect(Number(linkRows[0]?.n ?? 0)).toBeGreaterThanOrEqual(2);
});
test('--source-id alpha scopes extraction to alpha only', async () => {
const captured: unknown[] = [];
const origLog = console.log;
console.log = (m: unknown) => { captured.push(m); };
try {
await runExtract(engine, ['links', '--source', 'db', '--source-id', 'alpha', '--json']);
} finally {
console.log = origLog;
}
// Links produced should only originate from alpha-source pages.
const linkRows = await engine.executeRaw<{ slug: string; source_id: string }>(
`SELECT p.slug, p.source_id FROM links l
JOIN pages p ON l.from_page_id = p.id`,
);
// Every link's from-page must be in alpha.
for (const r of linkRows) {
expect(r.source_id).toBe('alpha');
}
// And there should be at least one such link.
expect(linkRows.length).toBeGreaterThanOrEqual(1);
});
test('--source-id beta scopes to beta and produces beta-origin links only', async () => {
const origLog = console.log;
console.log = () => {};
try {
await runExtract(engine, ['links', '--source', 'db', '--source-id', 'beta', '--json']);
} finally {
console.log = origLog;
}
const linkRows = await engine.executeRaw<{ source_id: string }>(
`SELECT p.source_id FROM links l
JOIN pages p ON l.from_page_id = p.id`,
);
for (const r of linkRows) {
expect(r.source_id).toBe('beta');
}
});
test('--source-id with non-matching source produces zero links', async () => {
const origLog = console.log;
console.log = () => {};
try {
await runExtract(engine, ['links', '--source', 'db', '--source-id', 'nonexistent', '--json']);
} finally {
console.log = origLog;
}
const linkRows = await engine.executeRaw<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM links`,
);
expect(Number(linkRows[0]?.n ?? 0)).toBe(0);
});
});
+93
View File
@@ -44,6 +44,24 @@ function captureStdout(fn: () => Promise<void>): Promise<string[]> {
})();
}
function captureBoth(fn: () => Promise<void>): Promise<{ out: string[]; err: string[] }> {
return (async () => {
const out: string[] = [];
const err: string[] = [];
const origLog = console.log;
const origErr = console.error;
console.log = (msg: unknown) => { out.push(String(msg)); };
console.error = (msg: unknown) => { err.push(String(msg)); };
try {
await fn();
} finally {
console.log = origLog;
console.error = origErr;
}
return { out, err };
})();
}
describe('graph-query command', () => {
beforeEach(async () => {
await truncateAll();
@@ -112,3 +130,78 @@ describe('graph-query command', () => {
expect(joined.toLowerCase()).toContain('no edges found');
});
});
// v0.37.7.0 #1153 — foreign-edge footer + --include-foreign flag.
describe('graph-query foreign-edge footer (#1153)', () => {
beforeEach(async () => {
await truncateAll();
// Two sources. Default source has alice + bob; second source has
// carol. Edge from alice (default) to carol (other) is the foreign
// edge the footer should surface.
// sources table requires an 'id' entry per source; pglite-engine
// initSchema seeds 'default'. Add the other one.
await engine.executeRaw(
`INSERT INTO sources (id, name) VALUES ('other-src', 'other-src') ON CONFLICT DO NOTHING`,
);
await engine.putPage('people/alice', { type: 'person', title: 'Alice', compiled_truth: '', timeline: '' });
await engine.putPage('people/bob', { type: 'person', title: 'Bob', compiled_truth: '', timeline: '' });
// Carol lives in other-src. Use raw SQL because putPage doesn't
// expose source_id directly via its options.
await engine.executeRaw(
`INSERT INTO pages (slug, source_id, type, title, compiled_truth, timeline)
VALUES ('people/carol', 'other-src', 'person', 'Carol', '', '')`,
);
// Edge: alice (default) → carol (other-src) = foreign edge
// Edge: alice (default) → bob (default) = same-source edge
await engine.addLink('people/alice', 'people/carol', '', 'mentions', undefined, undefined, undefined, {
fromSourceId: 'default', toSourceId: 'other-src',
});
await engine.addLink('people/alice', 'people/bob', '', 'mentions');
});
test('default scoped traversal emits footer with foreign-edge count', async () => {
const { err } = await captureBoth(async () => {
await runGraphQuery(engine, ['people/alice', '--depth', '1']);
});
const joined = err.join('\n');
// Footer text contract: counts the foreign edge (alice → carol)
// and tells the user how to include them.
expect(joined).toMatch(/1 edge to foreign-source pages hidden/);
expect(joined).toMatch(/--include-foreign/);
});
test('--include-foreign suppresses the footer', async () => {
const { err } = await captureBoth(async () => {
await runGraphQuery(engine, ['people/alice', '--depth', '1', '--include-foreign']);
});
const joined = err.join('\n');
// No footer when the flag is set.
expect(joined).not.toMatch(/foreign-source pages hidden/);
});
test('no footer when there are zero foreign edges', async () => {
// Single-source brain — carol is removed; only same-source edge remains.
await engine.executeRaw(`DELETE FROM pages WHERE slug = 'people/carol'`);
const { err } = await captureBoth(async () => {
await runGraphQuery(engine, ['people/alice', '--depth', '1']);
});
const joined = err.join('\n');
expect(joined).not.toMatch(/foreign-source pages hidden/);
});
test('footer pluralizes correctly for 2+ foreign edges', async () => {
// Add a second foreign target in other-src so the count is plural.
await engine.executeRaw(
`INSERT INTO pages (slug, source_id, type, title, compiled_truth, timeline)
VALUES ('people/dave', 'other-src', 'person', 'Dave', '', '')`,
);
await engine.addLink('people/alice', 'people/dave', '', 'mentions', undefined, undefined, undefined, {
fromSourceId: 'default', toSourceId: 'other-src',
});
const { err } = await captureBoth(async () => {
await runGraphQuery(engine, ['people/alice', '--depth', '1']);
});
const joined = err.join('\n');
expect(joined).toMatch(/2 edges to foreign-source pages hidden/);
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* v0.37.7.0 #1167 — `gbrain import --source-id <id>` routes to a brain source.
*
* Pre-fix, `gbrain import --source dept-x ./pages` silently fell back to
* `default` because the CLI parser didn't consume `--source` at all
* (PR #707's design intent explicitly excluded it). Users had no signal
* their pages were being written to the wrong place.
*
* Fix: add `--source-id <id>` parsing. The flag is named --source-id
* (not --source) to avoid colliding with future axes; matches the
* v0.37.7.0 extract.ts convention from T2.
*
* Hermetic PGLite in-memory.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { runImport } from '../src/commands/import.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
async function truncatePages(): Promise<void> {
for (const t of ['content_chunks', 'links', 'tags', 'raw_data', 'page_versions', 'ingest_log', 'pages']) {
await (engine as any).db.exec(`DELETE FROM ${t}`);
}
await (engine as any).db.exec(`DELETE FROM sources WHERE id <> 'default'`);
}
describe('import --source-id (#1167)', () => {
let scratchDir: string;
beforeEach(async () => {
await truncatePages();
await engine.executeRaw(
`INSERT INTO sources (id, name) VALUES ('dept-x', 'dept-x') ON CONFLICT DO NOTHING`,
);
scratchDir = mkdtempSync(join(tmpdir(), 'gbrain-import-src-'));
mkdirSync(join(scratchDir, 'wiki'), { recursive: true });
writeFileSync(
join(scratchDir, 'wiki', 'alpha.md'),
'---\ntype: note\n---\n# Alpha\n\nContent of alpha.',
);
writeFileSync(
join(scratchDir, 'wiki', 'beta.md'),
'---\ntype: note\n---\n# Beta\n\nContent of beta.',
);
});
test('without --source-id, pages land in default source', async () => {
await runImport(engine, [scratchDir, '--no-embed', '--json']);
const rows = await engine.executeRaw<{ source_id: string; slug: string }>(
`SELECT source_id, slug FROM pages ORDER BY slug`,
);
expect(rows.length).toBeGreaterThanOrEqual(2);
for (const r of rows) {
expect(r.source_id).toBe('default');
}
});
test('--source-id dept-x routes pages to dept-x source', async () => {
await runImport(engine, [scratchDir, '--source-id', 'dept-x', '--no-embed', '--json']);
const rows = await engine.executeRaw<{ source_id: string; slug: string }>(
`SELECT source_id, slug FROM pages ORDER BY slug`,
);
expect(rows.length).toBeGreaterThanOrEqual(2);
for (const r of rows) {
expect(r.source_id).toBe('dept-x');
}
});
test('--source-id value is NOT treated as a positional dir arg', async () => {
// Regression: flag-value-as-dirArg was a real bug class in early
// CLI parsers. Pre-fix the parser at line 82-83 would have
// matched 'dept-x' as dirArg (since dept-x doesn't start with --).
// The flagValues set now excludes the arg at sourceIdIdx+1.
let threw = false;
try {
await runImport(engine, ['--source-id', 'dept-x', scratchDir, '--no-embed', '--json']);
} catch (e) {
threw = true;
}
// Should NOT throw "Usage: gbrain import <dir>..." because scratchDir
// is still recognized as the positional dir.
expect(threw).toBe(false);
const rows = await engine.executeRaw<{ source_id: string }>(
`SELECT source_id FROM pages LIMIT 1`,
);
expect(rows[0]?.source_id).toBe('dept-x');
});
});
+143
View File
@@ -0,0 +1,143 @@
/**
* v0.37.7.0 #1166 — OAuth confidential clients regression test.
*
* The MCP SDK's clientAuth middleware does `client.client_secret !==
* presented_secret` plaintext compare. gbrain stores SHA-256 hashes,
* so the SDK's compare always failed for confidential authorization_code
* and refresh_token grants. v0.34.1.0 fixed PUBLIC PKCE clients
* (client_secret = undefined); confidential clients regressed.
*
* Fix: provider gains `verifyConfidentialClientSecret(clientId, secret)`
* that does hash-then-compare ourselves. The serve-http /token middleware
* uses this BEFORE delegating to exchangeAuthorizationCode /
* exchangeRefreshToken. Public clients fall through to the SDK as today.
*
* Hermetic via PGLite in-memory.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { GBrainOAuthProvider } from '../src/core/oauth-provider.ts';
import { sqlQueryForEngine } from '../src/core/sql-query.ts';
let engine: PGLiteEngine;
let provider: GBrainOAuthProvider;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
provider = new GBrainOAuthProvider({ sql: sqlQueryForEngine(engine) });
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await (engine as any).db.exec('DELETE FROM oauth_tokens');
await (engine as any).db.exec('DELETE FROM oauth_codes');
await (engine as any).db.exec('DELETE FROM oauth_clients');
});
describe('verifyConfidentialClientSecret (#1166)', () => {
test('confidential client_secret_post: returns client on correct secret', async () => {
const reg = await provider.registerClientManual('test-conf', ['authorization_code'], 'read write', ['https://example.test/cb']);
expect(reg.clientId).toBeTruthy();
expect(reg.clientSecret).toBeTruthy();
const client = await provider.verifyConfidentialClientSecret(reg.clientId, reg.clientSecret);
expect(client.client_id).toBe(reg.clientId);
});
test('wrong secret → throws "Invalid client" (RFC 6749 opaque error)', async () => {
const reg = await provider.registerClientManual('test-conf', ['authorization_code'], 'read', ['https://example.test/cb']);
await expect(
provider.verifyConfidentialClientSecret(reg.clientId, 'wrong-secret'),
).rejects.toThrow(/Invalid client/);
});
test('non-existent client → throws "Invalid client"', async () => {
await expect(
provider.verifyConfidentialClientSecret('does-not-exist', 'anything'),
).rejects.toThrow(/Invalid client/);
});
test('public client (token_endpoint_auth_method=none) refuses confidential path', async () => {
// Public PKCE clients are registered via the SDK's DCR path with
// `token_endpoint_auth_method: 'none'` — those store
// client_secret_hash = NULL. registerClientManual sets a secret
// unconditionally, so we test the rejection by directly inserting
// a public-client row.
await engine.executeRaw(
`INSERT INTO oauth_clients
(client_id, client_secret_hash, client_name, redirect_uris, grant_types, scope, token_endpoint_auth_method)
VALUES ('public-pkce', NULL, 'public', $1, $2, 'read', 'none')`,
[
['https://example.test/cb'],
['authorization_code'],
],
);
await expect(
provider.verifyConfidentialClientSecret('public-pkce', 'any-secret'),
).rejects.toThrow(/Invalid client/);
});
test('case-insensitive secret? NO — must be exact match', async () => {
const reg = await provider.registerClientManual('test-case', ['authorization_code'], 'read', ['https://example.test/cb']);
const wrongCase = reg.clientSecret.toUpperCase();
if (wrongCase !== reg.clientSecret) {
await expect(
provider.verifyConfidentialClientSecret(reg.clientId, wrongCase),
).rejects.toThrow(/Invalid client/);
}
});
test('soft-deleted client → throws "Client has been revoked"', async () => {
const reg = await provider.registerClientManual('to-revoke', ['authorization_code'], 'read', ['https://example.test/cb']);
await engine.executeRaw(
`UPDATE oauth_clients SET deleted_at = NOW() WHERE client_id = $1`,
[reg.clientId],
);
await expect(
provider.verifyConfidentialClientSecret(reg.clientId, reg.clientSecret),
).rejects.toThrow(/revoked/);
});
});
describe('confidential-client full flow #1166', () => {
test('verify-then-exchange refresh token end-to-end', async () => {
const reg = await provider.registerClientManual('full-flow-rt', ['authorization_code', 'refresh_token'], 'read', ['https://example.test/cb']);
// Mint an initial token pair via client_credentials (simpler than
// /authorize round-trip in a unit test).
await engine.executeRaw(
`UPDATE oauth_clients SET grant_types = $1 WHERE client_id = $2`,
[['client_credentials', 'refresh_token'], reg.clientId],
);
const initial = await provider.exchangeClientCredentials(reg.clientId, reg.clientSecret, 'read');
// client_credentials grants don't issue refresh tokens (RFC 6749
// 4.4.3), so we manually insert a refresh token to test the
// verify-then-rotate path.
const refreshToken = 'rt_' + Buffer.from(Math.random().toString()).toString('hex');
const { hashToken } = await import('../src/core/utils.ts');
await engine.executeRaw(
`INSERT INTO oauth_tokens (token_hash, token_type, client_id, scopes, expires_at)
VALUES ($1, 'refresh', $2, $3, $4)`,
[hashToken(refreshToken), reg.clientId, ['read'], Math.floor(Date.now() / 1000) + 3600],
);
// verify → exchange round-trip with the correct secret
const client = await provider.verifyConfidentialClientSecret(reg.clientId, reg.clientSecret);
const rotated = await provider.exchangeRefreshToken(client, refreshToken);
expect(rotated.access_token).toBeTruthy();
expect(rotated.refresh_token).toBeTruthy();
expect(rotated.refresh_token).not.toBe(refreshToken); // rotated
// Original refresh token is now consumed; second use rejected.
await expect(
provider.exchangeRefreshToken(client, refreshToken),
).rejects.toThrow(/not found/);
});
});
+64
View File
@@ -0,0 +1,64 @@
/**
* v0.37.7.0 #1225 regression test.
*
* `gbrain reindex-frontmatter` was instantiating the engine via
* `createEngine()` (which only constructs) but never calling `connect()`
* before its first `executeRaw` in `countAffected`. The dry-run path
* crashed with "PGLite not connected. Call connect() first."
*
* This test pins the fix: a connected engine handles the dry-run
* happy path without throwing.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { runReindexFrontmatter } from '../src/commands/reindex-frontmatter.ts';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await (engine as any).db.exec('DELETE FROM pages');
});
describe('reindex-frontmatter connect-before-query (#1225)', () => {
test('dry-run on an empty brain does not throw "PGLite not connected"', async () => {
// Seed nothing — exercises the count-affected pre-flight path that
// was the original crash site.
const result = await runReindexFrontmatter(engine, { dryRun: true, json: true });
expect(result.status).toBe('dry_run');
expect(result.examined).toBe(0);
expect(result.updated).toBe(0);
});
test('dry-run with a seeded backfillable row reports examined>0 and does not crash on engine query', async () => {
// Seed a page with frontmatter that would trigger a backfill.
// The point of this case is NOT to assert what dry-run counts as
// "updated" (the command reports "would update" in dry-run mode);
// the point is to prove the engine is connected enough to scan
// and report at all. Pre-fix this scenario crashed with "PGLite
// not connected".
await engine.executeRaw(
`INSERT INTO pages (slug, type, title, compiled_truth, page_kind, frontmatter, effective_date)
VALUES ($1, 'note', $2, $3, 'markdown', $4::jsonb, NULL)`,
[
'wiki/notes/test',
'test',
'# test\n\nbody',
JSON.stringify({ effective_date: '2025-01-15' }),
],
);
const result = await runReindexFrontmatter(engine, { dryRun: true, json: true });
expect(result.status).toBe('dry_run');
expect(result.examined).toBeGreaterThanOrEqual(1);
});
});
+184
View File
@@ -0,0 +1,184 @@
/**
* v0.37.7.0 — resolveSourceWithTier() tier-attribution variant tests.
*
* Mirrors the 6-tier priority chain from source-resolver.test.ts but
* asserts the returned `tier` label matches the winning tier.
* Powers `gbrain sources current` so users can verify both the
* resolved source AND the reason it resolved.
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import {
resolveSourceWithTier,
SOURCE_TIER_NAMES,
type SourceTier,
} from '../src/core/source-resolver.ts';
import type { BrainEngine } from '../src/core/engine.ts';
import { withEnv } from './helpers/with-env.ts';
// Stub engine same shape as source-resolver.test.ts
function makeStub(
registeredSources: string[],
paths: Array<{ id: string; local_path: string }>,
defaultKey: string | null,
): BrainEngine {
return {
kind: 'pglite',
executeRaw: async <T>(sql: string, params?: unknown[]): Promise<T[]> => {
if (sql.includes('SELECT id FROM sources WHERE id = $1')) {
const target = params?.[0];
return (registeredSources.includes(target as string)
? [{ id: target } as unknown as T]
: []);
}
if (sql.includes('SELECT id, local_path FROM sources')) {
return paths as unknown as T[];
}
return [];
},
getConfig: async (key: string) => (key === 'sources.default' ? defaultKey : null),
} as unknown as BrainEngine;
}
describe('SOURCE_TIER_NAMES ordering matches resolveSourceId priority', () => {
test('canonical order is 1=flag → 6=seed_default', () => {
expect(SOURCE_TIER_NAMES).toEqual([
'flag',
'env',
'dotfile',
'local_path',
'brain_default',
'seed_default',
]);
});
});
describe('resolveSourceWithTier — tier 1 (flag)', () => {
test('explicit flag returns tier=flag with detail naming the value', async () => {
const engine = makeStub(['default', 'dept-x'], [], null);
const result = await resolveSourceWithTier(engine, 'dept-x', '/tmp');
expect(result.source_id).toBe('dept-x');
expect(result.tier).toBe('flag');
expect(result.detail).toContain('--source dept-x');
});
test('rejects unregistered explicit source', async () => {
const engine = makeStub(['default'], [], null);
await expect(resolveSourceWithTier(engine, 'ghost', '/tmp')).rejects.toThrow(/not found/);
});
});
describe('resolveSourceWithTier — tier 2 (env)', () => {
test('GBRAIN_SOURCE env returns tier=env when no flag', async () => {
const engine = makeStub(['default', 'wiki'], [], null);
await withEnv({ GBRAIN_SOURCE: 'wiki' }, async () => {
const result = await resolveSourceWithTier(engine, null, '/tmp');
expect(result.source_id).toBe('wiki');
expect(result.tier).toBe('env');
expect(result.detail).toBe('GBRAIN_SOURCE=wiki');
});
});
});
describe('resolveSourceWithTier — tier 3 (dotfile)', () => {
let scratchDir: string;
beforeEach(() => {
scratchDir = mkdtempSync(join(tmpdir(), 'gbrain-tier-dotfile-'));
});
afterEach(() => {
rmSync(scratchDir, { recursive: true, force: true });
});
test('.gbrain-source dotfile in CWD returns tier=dotfile', async () => {
writeFileSync(join(scratchDir, '.gbrain-source'), 'team-alpha\n');
const engine = makeStub(['default', 'team-alpha'], [], null);
const result = await resolveSourceWithTier(engine, null, scratchDir);
expect(result.source_id).toBe('team-alpha');
expect(result.tier).toBe('dotfile');
expect(result.detail).toBe('.gbrain-source');
});
test('dotfile in ancestor directory walks up to find it', async () => {
writeFileSync(join(scratchDir, '.gbrain-source'), 'team-alpha\n');
const nested = join(scratchDir, 'a', 'b', 'c');
mkdirSync(nested, { recursive: true });
const engine = makeStub(['default', 'team-alpha'], [], null);
const result = await resolveSourceWithTier(engine, null, nested);
expect(result.tier).toBe('dotfile');
});
});
describe('resolveSourceWithTier — tier 4 (local_path)', () => {
test('registered source whose local_path contains CWD returns tier=local_path', async () => {
const engine = makeStub(
['default', 'gstack'],
[{ id: 'gstack', local_path: '/work/gstack' }],
null,
);
const result = await resolveSourceWithTier(engine, null, '/work/gstack/src');
expect(result.source_id).toBe('gstack');
expect(result.tier).toBe('local_path');
expect(result.detail).toContain('/work/gstack');
});
test('longest-prefix wins on nested registered sources', async () => {
const engine = makeStub(
['default', 'parent', 'child'],
[
{ id: 'parent', local_path: '/work' },
{ id: 'child', local_path: '/work/sub' },
],
null,
);
const result = await resolveSourceWithTier(engine, null, '/work/sub/file');
expect(result.source_id).toBe('child');
expect(result.tier).toBe('local_path');
});
});
describe('resolveSourceWithTier — tier 5 (brain_default)', () => {
test('sources.default config returns tier=brain_default', async () => {
const engine = makeStub(['default', 'dept-x'], [], 'dept-x');
const result = await resolveSourceWithTier(engine, null, '/tmp/no-dotfile-here');
expect(result.source_id).toBe('dept-x');
expect(result.tier).toBe('brain_default');
expect(result.detail).toContain('sources.default');
});
});
describe('resolveSourceWithTier — tier 6 (seed_default)', () => {
test('no other signals returns tier=seed_default with no detail', async () => {
const engine = makeStub(['default'], [], null);
const result = await resolveSourceWithTier(engine, null, '/tmp/no-dotfile-here');
expect(result.source_id).toBe('default');
expect(result.tier).toBe('seed_default');
expect(result.detail).toBeUndefined();
});
});
describe('resolveSourceWithTier — priority assertion', () => {
test('flag wins over env wins over dotfile wins over default', async () => {
// Set up a stub where ALL tiers could resolve. Assert the
// higher-priority one wins.
const engine = makeStub(
['default', 'flag-src', 'env-src', 'dot-src', 'default-src'],
[],
'default-src',
);
await withEnv({ GBRAIN_SOURCE: 'env-src' }, async () => {
// Flag highest priority
const r1 = await resolveSourceWithTier(engine, 'flag-src', '/tmp');
expect(r1.tier).toBe('flag');
// Without flag → env
const r2 = await resolveSourceWithTier(engine, null, '/tmp');
expect(r2.tier).toBe('env');
});
});
});
// Typecheck-only assertion: SourceTier is the union of SOURCE_TIER_NAMES.
const _exhaustiveCheck: SourceTier = 'flag';
void _exhaustiveCheck;
+82
View File
@@ -341,6 +341,88 @@ describe('subagent handler replay (crash recovery)', () => {
expect(client.calls.length).toBe(1);
});
// v0.37.7.0 #1151 regression — terminal-on-resume.
// Pre-fix, this scenario dead-lettered the job: replay reconciler saw
// last=assistant with zero tool_uses, did nothing, main loop called
// messages.create against a conversation ending in assistant → Sonnet
// 4.6+ rejects assistant-prefill with HTTP 400 → 3 retries → dead.
// Post-fix, the reconciler short-circuits: reconstructs finalText from
// the persisted text blocks and returns stop_reason='end_turn' without
// any LLM call.
test('text-only assistant tail on resume returns terminal without LLM call (#1151)', async () => {
const ctx = await makeCtx({ prompt: 'start' });
// Seed prior state: user prompt, then a TERMINAL assistant turn
// (text-only, no tool_use blocks). This is the exact shape the
// #1151 reporter found in their dead jobs (job 190's last message
// was a synthesis summary listing 3 written slugs).
await engine.executeRaw(
`INSERT INTO subagent_messages (job_id, message_idx, role, content_blocks)
VALUES ($1, 0, 'user', $2::jsonb)`,
[ctx.id, JSON.stringify([{ type: 'text', text: 'start' }])],
);
await engine.executeRaw(
`INSERT INTO subagent_messages (job_id, message_idx, role, content_blocks, model, tokens_in, tokens_out)
VALUES ($1, 1, 'assistant', $2::jsonb, 'claude-sonnet-4-6', 100, 50)`,
[
ctx.id,
JSON.stringify([
{ type: 'text', text: 'wrote 3 pages: wiki/notes/a, wiki/notes/b, wiki/notes/c' },
]),
],
);
// The FakeMessagesClient has ZERO scripted responses. If the handler
// tries to call messages.create, it throws. The fix guarantees we
// never reach that path.
const client = new FakeMessagesClient([]);
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
const result = await handler(ctx);
expect(result.stop_reason).toBe('end_turn');
expect(result.result).toBe('wrote 3 pages: wiki/notes/a, wiki/notes/b, wiki/notes/c');
// Crucial assertion: no messages.create call was made on resume.
expect(client.calls.length).toBe(0);
// Token totals from the persisted assistant message rolled up.
expect(result.tokens.in).toBe(100);
expect(result.tokens.out).toBe(50);
});
// Companion: the existing tool-use replay path is unchanged.
test('text-only terminal short-circuit does NOT affect tool-use replay path', async () => {
// This is a smoke test that the new else-branch doesn't accidentally
// swallow the pending-tool-use case. If we have a persisted assistant
// with a tool_use block (no synthesized user turn yet), the existing
// tool-synthesis path must still fire.
const echoTool = makeEchoTool('echo_x');
const ctx = await makeCtx({ prompt: 'start' });
await engine.executeRaw(
`INSERT INTO subagent_messages (job_id, message_idx, role, content_blocks)
VALUES ($1, 0, 'user', $2::jsonb)`,
[ctx.id, JSON.stringify([{ type: 'text', text: 'start' }])],
);
await engine.executeRaw(
`INSERT INTO subagent_messages (job_id, message_idx, role, content_blocks, model)
VALUES ($1, 1, 'assistant', $2::jsonb, 'claude-sonnet-4-6')`,
[
ctx.id,
JSON.stringify([
{ type: 'tool_use', id: 'tu_pending', name: 'echo_x', input: { v: 'r' } },
]),
],
);
// No prior tool_exec row — replay reconciler will dispatch.
const client = new FakeMessagesClient([
{ content: [{ type: 'text', text: 'done after tool' }] as any, stop_reason: 'end_turn' },
]);
const handler = makeSubagentHandler({ engine, client, toolRegistry: [echoTool] });
const result = await handler(ctx);
expect(result.stop_reason).toBe('end_turn');
expect(result.result).toBe('done after tool');
// The handler DID call messages.create (one call) after synthesizing
// the tool_result wrapper.
expect(client.calls.length).toBe(1);
});
test('pending non-idempotent tool exec rejects on resume', async () => {
const nonIdempotent = { ...makeEchoTool('do_once'), idempotent: false };
const ctx = await makeCtx({ prompt: 'start' });
+83
View File
@@ -0,0 +1,83 @@
/**
* v0.37.7.0 #1169 — sync walker skips git submodule directories.
*
* A submodule directory contains `.git` as a FILE (a gitfile pointer
* into the parent's `.git/modules/...`), not a directory. Pre-fix, the
* walker descended into submodules and indexed their markdown content
* as if it belonged to the parent brain.
*
* Fix: pruneDir now accepts an optional parentDir; when set, it stats
* `<parentDir>/<name>/.git` and skips when that's a file.
*
* NOTE: The companion `.gitignore`-respect feature from PR #1159 is
* NOT in this wave (would require adding the `ignore` npm package as a
* dep; per the plan's "no new deps" gate, deferred to a follow-up
* wave). This file only pins submodule-skip.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { pruneDir } from '../src/core/sync.ts';
import { walkMarkdownFiles } from '../src/commands/extract.ts';
describe('pruneDir submodule detection (#1169)', () => {
let scratch: string;
beforeAll(() => {
scratch = mkdtempSync(join(tmpdir(), 'gbrain-submodule-'));
// Create a submodule-like directory: `.git` is a FILE inside it.
const subDir = join(scratch, 'vendor-submodule');
mkdirSync(subDir, { recursive: true });
writeFileSync(join(subDir, '.git'), 'gitdir: ../../.git/modules/vendor-submodule\n');
writeFileSync(join(subDir, 'README.md'), '# Vendor README\nshould not be indexed');
writeFileSync(join(subDir, 'doc.md'), '# Doc\nalso should not be indexed');
// Create a normal directory: contains real markdown content.
const normalDir = join(scratch, 'wiki');
mkdirSync(normalDir, { recursive: true });
writeFileSync(join(normalDir, 'page.md'), '# Page\nlegitimate content');
// Create a normal dir whose .git is a DIRECTORY (a real nested
// repo, not a submodule pointer). pruneDir should NOT skip this
// unless one of the OTHER rules fires (`.git` itself is dot-prefix
// and would be excluded if walked into directly).
const nestedRepo = join(scratch, 'nested-repo');
mkdirSync(join(nestedRepo, '.git'), { recursive: true });
writeFileSync(join(nestedRepo, 'README.md'), '# nested repo');
});
afterAll(() => {
rmSync(scratch, { recursive: true, force: true });
});
test('skips a submodule directory (.git as file)', () => {
expect(pruneDir('vendor-submodule', scratch)).toBe(false);
});
test('descends into a regular markdown directory', () => {
expect(pruneDir('wiki', scratch)).toBe(true);
});
test('back-compat: parentDir undefined keeps the pre-v0.37.7 behavior', () => {
// Without parentDir, the submodule check can't fire — only the
// dot-prefix / PRUNE_DIR_NAMES / .raw / node_modules rules apply.
expect(pruneDir('vendor-submodule')).toBe(true); // not skipped sans context
expect(pruneDir('.git')).toBe(false); // dot-prefix still excluded
expect(pruneDir('node_modules')).toBe(false); // explicit list
});
test('descends into a directory containing .git as a DIRECTORY (nested git repo, not submodule)', () => {
// pruneDir returns true (we descend); the walker then encounters
// the inner `.git` DIRECTORY which is itself dot-prefix → excluded.
expect(pruneDir('nested-repo', scratch)).toBe(true);
});
test('walkMarkdownFiles does not return files from a submodule directory', () => {
const files = walkMarkdownFiles(scratch);
const paths = files.map(f => f.relPath);
// Should include the normal page.
expect(paths.some(p => p.endsWith('page.md'))).toBe(true);
// Should NOT include anything from the submodule.
expect(paths.some(p => p.includes('vendor-submodule'))).toBe(false);
});
});