mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
+8








1bc579916b
* fix(sync): accept .tf / .tfvars / .hcl in CODE_EXTENSIONS Terraform repos were invisible to `gbrain sync --strategy code` because the three HCL-family extensions never reached the file walker. Silent data loss — the user thinks the sync covered the repo but the IaC layer was dropped on the floor. detectCodeLanguage() returns null for these extensions, so the chunker falls back to recursive (no tree-sitter grammar for HCL) — the same path toml/yaml take. Closes #878. Co-Authored-By: johnybradshaw <johnybradshaw@users.noreply.github.com> * fix(upgrade): run `bun update gbrain` from Bun's global install root `gbrain upgrade --strategy bun` was failing on canonical `bun install -g github:garrytan/gbrain` installs because `execSync('bun update gbrain')` ran in the user's shell cwd. Bun's update operates on whatever package.json it finds via cwd-walk, so a user not standing in the global root got "No package.json, so nothing to update". resolveBunGlobalRoot() returns the right directory: 1. `$BUN_INSTALL/install/global` when set (operator override). 2. `~/.bun/install/global` (Bun's documented default). 3. Walk up from realpath(argv[1]) looking for `node_modules/gbrain` — handles non-standard installs without trusting argv naming. execFileSync replaces execSync (no shell), with cwd pinned. Error path prints the exact `cd && bun update` recovery command instead of a vague hint. Closes #1029. Cherry-picked from PR #1032. Co-Authored-By: mvanhorn <mvanhorn@users.noreply.github.com> * fix(config): redact sensitive values in `config set` output (closes #892) `gbrain config set openai_api_key sk-...` was echoing the full key to stderr via `console.log('Set %s = %s', key, value)`. Shell scrollback and tmux scroll buffers commonly retain stderr for hours; a screen-share or shoulder-glance during set leaked the secret. The `show` path already redacted but used a naive `.includes('key')` substring check that would mask 'monkey' or 'parsekey' (no false-negative but ugly). Single source of truth: `isSensitiveConfigKey()` uses a word-boundary regex (`(^|[._-])(key|secret|token|password|pwd|passwd|auth)([._-]|$)/i`) so 'openai_api_key' matches but 'monkey' doesn't. `redactConfigValue()` composes the postgresql:// URL redactor + sensitive-key check, used by both `show` and `set`. Helpers exported for unit tests. Closes #892. Cherry-pick of @sharziki's PR #918 (config.ts hunk only — the extract.ts walker change in that PR is unrelated and tracked in #202). Co-Authored-By: sharziki <sharziki@users.noreply.github.com> * fix(oauth): throw InvalidTokenError so bearerAuth returns 401, not 500 `verifyAccessToken` was throwing bare `Error` on expired or invalid tokens. The MCP SDK's `requireBearerAuth` middleware catches `InvalidTokenError` and returns 401 with WWW-Authenticate; bare Error falls through to 500. Result: legitimate clients with stale tokens hit 500-not-401, so token-refresh logic (which keys off 401) never fires. Two call sites in verifyAccessToken: token-expired path and invalid-token path. Both now throw InvalidTokenError. Existing tests continue to pass because they assert on the throw, not the message class. Closes #935. Cherry-picked from PR #1012. Co-Authored-By: Aashiqe10 <Aashiqe10@users.noreply.github.com> * fix(serve): return 405 on GET /mcp instead of 404 MCP Streamable HTTP spec says GET /mcp opens an optional SSE backchannel for server-initiated messages. gbrain's transport is stateless and doesn't push server-initiated messages, so per spec we MUST return 405 with Allow: POST, DELETE — not 404. Probing clients (claude.ai, etc.) distinguish "endpoint exists, no SSE channel" from "endpoint missing" on this status code; 404 makes them give up. Cherry-picked from PR #1076. Co-Authored-By: lukejduncan <lukejduncan@users.noreply.github.com> * fix(doctor): resolve whoknows fixture from module location, not cwd `gbrain doctor` warned about a missing whoknows fixture for every install that wasn't standing in the gbrain source repo at run time — which is everyone. The check used `process.cwd()` to locate the fixture, so any real user (running doctor against `~/.gbrain`) saw a spurious warning. `resolveWhoknowsFixturePath()` walks up from `import.meta.url` looking for the source-repo signature (`src/cli.ts` + `skills/RESOLVER.md`), respects `GBRAIN_WHOKNOWS_FIXTURE_PATH` env override (absolute or cwd-relative), and returns null with an actionable warning when the fixture can't be located. Closes #969. Cherry-picked from PR #1034. Co-Authored-By: mvanhorn <mvanhorn@users.noreply.github.com> * fix(frontmatter): centralize --fix backups under ~/.gbrain/backups/ `gbrain frontmatter validate --fix` and `gbrain frontmatter generate --fix` wrote `<file>.bak` siblings into the source tree. Users running gbrain over a brain repo found .bak files scattered through people/, companies/, etc. that broke gitignore expectations and showed up in `git status` after every fix pass. Backups now land under `~/.gbrain/backups/frontmatter/<run-id>/<rel>.bak` with an iso-week-sorted run-id so a multi-fix session keeps the same parent directory. Backup directory + per-file structure mirrored from the original file's relative path. The .bak safety contract is intact for both git and non-git brain repos. Also adds `--include-catch-all` opt-in to `frontmatter generate` so the default catch-all rule (`type: note`) is no longer applied to arbitrary workspace documents that happen to live under a brain root. Closes #902. Cherry-picked from PR #903. Co-Authored-By: 100yenadmin <100yenadmin@users.noreply.github.com> * fix(config): use path.isAbsolute() for GBRAIN_HOME on Windows The GBRAIN_HOME validator rejected every valid Windows path (`C:\\Users\\...`, `D:\\gbrain`, etc.) because it used `trimmed.startsWith('/')` to check for absoluteness — only POSIX absolute paths pass that. `path.isAbsolute()` is the cross-platform check. Same fix for the `..` traversal check: split on both `/` and `\` so Windows path separators don't sneak `..` through. Closes #1019. Cherry-picked from PR #1083. Co-Authored-By: sharziki <sharziki@users.noreply.github.com> * fix(ai): warn only for the configured embedding provider, not all recipes Gateway construction was warning on stderr for every recipe with an embedding touchpoint missing max_batch_tokens — including providers the brain isn't using. Users on Voyage saw noise about OpenAI / Google / DashScope / etc. recipes that never get loaded. Filter the warning to recipes whose provider id is referenced by `embedding_model` or `embedding_multimodal_model` in the active config. The structural protection against forgetting max_batch_tokens stays in place for the recipes that actually run; the noise for unrelated recipes goes away. Cherry-picked from PR #1117. Co-Authored-By: hnshah <hnshah@users.noreply.github.com> * fix(sync): skip git pull when repo has no origin remote `gbrain sync` ran `git pull` unconditionally and printed scary stderr on every cycle for brains that have no `origin` remote (local-only workflows, single-machine setups, brains initialized via `gbrain init --pglite` against an arbitrary directory). The pull failed harmlessly but the noise was confusing and made operators think sync was broken. `hasOriginRemote()` probes `git remote get-url origin` with stdio ignored; on failure (`no such remote`), skip the pull, print a single informational line, and proceed with the local working tree. Cherry-picked from PR #1119. Co-Authored-By: hnshah <hnshah@users.noreply.github.com> * fix(query): drain cache writes before CLI exit The query cache write was fired with `void promise.catch(...)` — true fire-and-forget. On a fast CLI invocation (`gbrain query <q>` exits in ~50ms), the process terminates before the cache write commits. Result: the cache effectively never warms from CLI use; every query is a miss. `awaitPendingSearchCacheWrites()` tracks each in-flight cache write in a module-level Set. The CLI dispatcher awaits the set after `query` finishes formatting output but before the process exits. MCP server path unchanged (long-lived process, fire-and-forget remains correct). Cherry-picked from PR #1125. Co-Authored-By: hnshah <hnshah@users.noreply.github.com> * fix(backlinks): dedupe (source, target) pairs within a single source page A source page that mentions the same entity N times produced N duplicate "Referenced in" lines on the target. `extractEntityRefs` returns one EntityRef per occurrence, and the per-ref `hasBacklink` check reads a snapshot of `target.content` that's frozen at outer scope — so every iteration sees "no backlink yet" and appends another gap. The cumulative effect on a long meeting note with multiple mentions of the same person was visible in PRs landing 3-5 identical Timeline entries. Track seen target slugs per source page; cap gaps at one pair. Cherry-picked from PR #967 with a current-master regression test covering both markdown-link and Obsidian-wikilink formats in the same source page. Co-Authored-By: p3ob7o <p3ob7o@users.noreply.github.com> * fix(dream): audit backlinks without mutating pages during cycle The dream/autopilot maintenance cycle ran the backlinks phase in 'fix' mode, which writes "Referenced in" timeline bullets into entity pages every sync. The graph extractor + auto-link path is the canonical link store during sync/dream/autopilot — the legacy filesystem fixer wrote markdown that fought with both the user's manual edits and the graph layer's own timeline. Cycle now runs backlinks in 'check' mode (audit-only); the materializer remains available via `gbrain check-backlinks fix` for users who really want markdown backlinks committed to disk. Cherry-picked from PR #1027. Co-Authored-By: sliday <sliday@users.noreply.github.com> * fix(autopilot --install): source ~/.zshenv before zshrc/bashrc zshenv is the canonical place for env vars in zsh on macOS — zshrc is sourced only for interactive shells, so vars exported in zshrc don't reach a non-interactive subprocess like the autopilot wrapper. Users who exported GBRAIN_DATABASE_URL, OPENAI_API_KEY, or ANTHROPIC_API_KEY in zshrc and assumed autopilot would inherit them hit silent missing- secret failures on the LaunchAgent. Source ~/.zshenv first (always reaches non-interactive shells per zsh docs), then fall back to ~/.zshrc / ~/.bashrc for users on other profile conventions. Cherry-picked from PR #966. Co-Authored-By: p3ob7o <p3ob7o@users.noreply.github.com> * fix(apply-migrations): return exit 0 on list/dry-run/up-to-date `gbrain apply-migrations list`, `gbrain apply-migrations --dry-run`, and the "All migrations up to date" path were returning from the async function but never calling `process.exit(0)`. The CLI dispatcher in cli.ts treated the implicit fall-through as exit 1 when the parent process inspected status via shell scripts, breaking automation that gates on `apply-migrations list && do-something`. Three call sites: list, dry-run, and the no-op path. All three now exit(0) explicitly. Cherry-picked from PR #1062. Co-Authored-By: nezovskii <nezovskii@users.noreply.github.com> * fix(sync): scope auto-embed to source on incremental syncs `gbrain sync --source-id X` triggered auto-embed for the affected slugs but `runEmbed` ran with no `--source` flag, so it fell back to the default source. For non-default-source syncs the page row lives at (sourceId, slug) — the embed code saw "Page not found" for the right slug under the wrong source, swallowed the error as best-effort, and the sync result reported `embedded: 0` for the wrong reason. `buildAutoEmbedArgs(slugs, sourceId)` is the new helper: when sourceId is set, prepends `--source X`. Exported for the regression test. Pairs with the upcoming source-id write-path audit (P1 #8). Cherry-picked from PR #1120. Co-Authored-By: hnshah <hnshah@users.noreply.github.com> * fix(query): honor source_id with no-expand for cross-source search Two related corrections: 1. `gbrain query --no-expand` parsed `--no-expand` as the literal key `no_expand` instead of negating the boolean `expand` param. Result: the flag was silently ignored and expansion always ran. Now any `--no-<key>` where `<key>` is a boolean param flips it false. 2. The `query` op's source-id resolution treated `ctx.sourceId` as authoritative, so an explicit per-call `source_id` was overridden by the federated read scope. Now per-call `source_id` wins; `source_id=__all__` is an explicit opt-out for local cross-source search. Cherry-picked from PR #1124. Co-Authored-By: hnshah <hnshah@users.noreply.github.com> * fix(doctor): child-table orphan detection (closes #1063) The autopilot orphans phase detects orphan PAGES (no inbound links via page-graph) but never scans FK-child tables. After a bulk delete or a pre-FK-migration code path, orphan rows can persist indefinitely in content_chunks, page_versions, tags, takes, raw_data, timeline_entries, or links — all declared ON DELETE CASCADE, so any orphan row is unexpected. `childTableOrphansCheck` enumerates 10 FK columns across 8 tables: - 8 NOT NULL columns (cascade): any value not in pages.id is an orphan. - 2 nullable SET NULL columns (links.origin_page_id, files.page_id): NULL is valid; only NOT-NULL-but-missing-in-pages counts. Surfaces paste-ready cleanup SQL when orphans are found. Cherry-picked from PR #1064. Co-Authored-By: vincedk-alt <vincedk-alt@users.noreply.github.com> * fix(autopilot,cycle): stop respawn-storm from steady-state 'partial' cycles Two compounding bugs under KeepAlive=true: 1. Autopilot tripped its circuit breaker on cycle.status === 'partial', not just 'failed'. 'partial' means at least one phase warned/failed while others ran — a soft signal, not fatal. On every cycle that warned, autopilot logged a failure and the supervisor respawned the worker. 2. The orphans phase emitted 'warn' when `count > 20` orphan pages. That threshold was tuned for small dev brains; on any corpus past a few hundred pages it fires every cycle in steady state. Together with bug 1, this produced visible respawn storms. Fix: - Autopilot trips only on cycle.status === 'failed'. - Orphans phase warns by ratio: orphans / total_pages > 0.5 (the real "your graph fell apart" signal), not by absolute count. Cherry-picked from PR #1113. Co-Authored-By: sergeclaesen <sergeclaesen@users.noreply.github.com> * fix(ai): reject partial embedding responses before indexing `embedSubBatch` only validated the FIRST embedding's dimension and never asserted the response length matched the input length. If a provider returned fewer embeddings than requested (rate-limit truncation, malformed response, etc.), the gateway silently indexed an offset-shifted result — every page after the missing index got the embedding of a different page's chunk. Two new guards: 1. `result.embeddings.length === texts.length` — fail loud if any count mismatch, with a paste-ready retry hint. 2. Validate dim on EVERY embedding, not just the first. Cherry-picked from PR #926. Co-Authored-By: 100yenadmin <100yenadmin@users.noreply.github.com> * fix(serve): admin register-client supports auth_code + PKCE public clients The admin dashboard's /admin/api/register-client endpoint hardcoded client_credentials and ignored grantTypes, redirectUris, and tokenEndpointAuthMethod. Result: you couldn't register a browser-based PKCE client (claude.ai Custom Connector, Cursor, etc.) through the dashboard — only confidential machine-to-machine clients worked. Pass grantTypes / redirectUris through to registerClientManual. When tokenEndpointAuthMethod === 'none', NULL out client_secret_hash so the SDK's clientAuth middleware skips the hash-vs-plaintext compare that would otherwise reject the no-secret PKCE flow. Cherry-picked from PR #1077. Co-Authored-By: lukejduncan <lukejduncan@users.noreply.github.com> * fix(extract-facts): treat slugs:[] as no-op, not unscoped full-walk `runExtractFacts` checked `opts.slugs && opts.slugs.length > 0` to decide between scoped and full-brain walk. Both `undefined` (caller omits → full walk intended) AND `[]` (sync no-op → zero work intended) fall through to the same `else` branch and triggered `engine.getAllSlugs()`. On a multi-thousand-page brain, the unintended full walk exceeded the autopilot-cycle ~600s timeout and dead-lettered the job — visible in production as `[cycle.extract_facts] start` followed by silence until `Autopilot stopping (cycle-failure-cap)`. Use presence (`opts.slugs !== undefined`), not truthiness, to distinguish the two modes. Empty array is a real incremental no-op. Closes #1096. Three regression cases in test/extract-facts-phase.test.ts: slugs=[] no-op, slugs=undefined still walks, slugs=['a'] walks just one. Co-Authored-By: navin-moorthy <navin-moorthy@users.noreply.github.com> * fix(serve): embed admin/dist into binary; serve from manifest (closes #1090) Pre-fix, /admin returned 404 on every globally-installed binary because serve-http.ts:780 resolved admin/dist via process.cwd(). The admin SPA files are checked into git but `bun build --compile` does NOT embed arbitrary directories — only assets imported via `with { type: 'file' }` ESM imports land in the compiled binary. Wire: - scripts/build-admin-embedded.ts walks admin/dist/, emits src/admin-embedded.ts with one `with { type: 'file' }` import per file + a manifest map (request path → resolved path + mime). Auto-invoked by `bun run build:admin`. - src/admin-embedded.ts is the auto-generated module. Bun resolves every file: import to a path that works at runtime inside the compiled binary (same pattern as src/core/chunkers/code.ts WASM imports). - serve-http.ts switches to two-tier resolution: cwd-relative admin/dist for dev (Vite hot-rebuild), embedded manifest otherwise. Embedded path reads bytes lazily and caches per-asset for the lifetime of the process. - scripts/check-admin-embedded.sh CI gate re-runs the generator and fails on drift (mirrors check-wasm-embedded.sh). PRs that rebuild admin/dist but forget to regenerate the embedded module fail loud. - package.json wires build:admin-embedded + check:admin-embedded. Closes #1090. * test(source-id): lock in routing regression coverage (closes #891 #978 #1078) Audit of every page write path (sync, embed, extract, dream, autopilot, wikilinks, tags, chunks) confirmed that sourceId already threads correctly through importFromContent → engine.putPage → SQL INSERT since v0.18.0. The original bug reports from #891, #978, #1078 were real at the time and got swept by the multi-source refactor; today's master is correct. This commit locks in that correctness with six PGLite regression cases (no Postgres fixture needed; runs in CI everywhere): 1. importFromContent({sourceId:"work"}) lands at source_id=work, not the silent 'default' fallback. 2. Two sources hold the same slug independently. 3. Omitting sourceId falls through to 'default' (legacy contract). 4. Chunks land under the requested source. 5. Tags land under the requested source. 6. FK integrity smoke (originally #1078). The earlier issue reports stay closed by the existing threading; this suite ensures any future refactor of the write path can't silently re-introduce the wrong-source-default bug. The 90-minute write-path audit budget from the plan resolves here. * fix(apply-migrations): unblock PGLite chain (closes #1100) `gbrain apply-migrations --yes` was wedging on the v0.11.0 (Minions) schema phase for PGLite installs. Two compounding bugs: 1. `apply-migrations` pre-flight schema-version warning connects to PGLite to read config.version, then disconnects. The brief lock hold races with downstream subprocess spawns that try to re-acquire it; the 30s lock timeout fires before the parent fully releases. Pre-flight is a *warning*; on PGLite it adds no information the orchestrators don't already handle. Skip the probe for PGLite. 2. v0.11.0 phase A spawned `gbrain init --migrate-only` as an execSync subprocess to apply schema migrations. PGLite is single-writer; the subprocess inherits HOME and tries to lock the same DB. On Postgres this works (concurrent connections OK); on PGLite it deadlocks. Route in-process for PGLite — create + connect + initSchema + disconnect directly, skipping the subprocess hop. Postgres keeps the legacy execSync path. Verified: fresh PGLite install now walks the full migration chain through v0.32.2 (Facts SoR) and lands "All migrations up to date" on re-run. Closes #1100. * fix(serve): bootstrap token env override + suppress flag (closes #1024) `gbrain serve --http` regenerated the admin bootstrap token on every restart and printed it to stderr. In supervisor-managed production deployments (LaunchAgent, systemd, k8s) every restart leaks the value into log aggregators and rotates the access for any agent that paste- copied it. Two new knobs: - **GBRAIN_ADMIN_BOOTSTRAP_TOKEN** env var: when set, used as the bootstrap secret instead of a fresh per-process token. Validated: must match `^[A-Za-z0-9_-]{32,}$` (32-char minimum), else refuse to start with a paste-ready generator hint. Failing closed beats silently accepting a weak token. - **--suppress-bootstrap-token** CLI flag: suppresses the printed token line entirely. Operator takes responsibility for tracking the value out-of-band. Startup banner now reflects the chosen source: - `Admin Token: suppressed` when the flag is set. - `Admin Token: from $GBRAIN_ADMIN_BOOTSTRAP_TOKEN` when env-sourced. - Full token print only when both are absent (default behavior, dev installs). Closes #1024. Co-Authored-By: billy-armstrong <billy-armstrong@users.noreply.github.com> * fix(config): migrate legacy 'provider' + 'model' to 'embedding_model' Pre-v0.32 docs and some community templates used a config shape: { "provider": "voyage", "model": "voyage-4-large" } The canonical shape (since the v0.31.12 gateway seam) is: { "embedding_model": "voyage:voyage-4-large" } Users on the legacy shape hit silent fallthrough to the hardcoded OpenAI default; sync + embed errored out with "OpenAI embedding requires OPENAI_API_KEY" regardless of their actual provider config. loadConfig() now translates the legacy keys at parse time: - emits a one-line stderr nudge with the paste-ready canonical key - preserves the rest of the config unchanged - skipped when `embedding_model` is already set (forward-compat) Closes #1086. Co-Authored-By: jeunessima <jeunessima@users.noreply.github.com> * chore(test): quarantine upgrade tests (process.env mutation) PR #1032's cherry-picked tests use the static-snapshot + try/finally pattern for env vars instead of the project's withEnv() helper. The test-isolation lint catches process.env mutations outside withEnv to prevent cross-test leakage in parallel runs. Renaming to *.serial.test.ts (the quarantine convention) is the documented out: runs sequentially, no cross-file race. A future cleanup PR can migrate the tests to withEnv() and drop the quarantine. * fix(test): update brain-writer .bak assertion for centralized backup path The v0.36.x frontmatter backup change (bd60cdf6— closes #902) moved .bak files from sibling-of-source to ~/.gbrain/backups/frontmatter/... The old test still asserted on the sibling path, so CI failed even though the production behavior was correct. Updated assertion contract: backup lands under the injected backupRoot (test-isolated), the returned backupPath ends in .bak and exists, and no sibling .bak is created next to the source file. The pre-fix sibling-path is now a negative assertion. * chore: bump version and changelog (v0.36.1.0) v0.36.1.0 — community fix wave (28 atomic fixes + 22 PRs closed as already-shipped + 14 issues triaged). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(fix-wave): close test gaps surfaced by post-ship audit After the fix-wave shipped, an audit found 11 commits with no new test file. Some were inherently structural (build pipelines, shell content) or had existing test coverage that worked either way; others had real regression risk with no guard. This commit closes the gaps that matter. New regression tests for: - OAuth `verifyAccessToken` throws `InvalidTokenError` (not bare Error) on both expired and unknown token paths. Pre-fix, the SDK's `requireBearerAuth` middleware fell through to 500 instead of 401 → client token-refresh logic never fired (#935). - `loadConfig` translates legacy `{provider, model}` config shape to the canonical `embedding_model: <provider>:<model>`. 3 cases: pure legacy → migrated; canonical wins over legacy when both present; canonical-only is untouched. Pre-fix, Voyage/Cohere/Mistral users silently fell through to OpenAI (#1086). - `configDir` rejects relative paths; rejects `..` segments via both separators (regression guard for the Windows path acceptance fix #1019 / cherry-pick #1083). - `resolveBootstrapToken` (new exported helper extracted from `runServeHttp`). 9 cases: unset env generates fresh, valid env accepted, hyphens/underscores accepted, < 32 chars rejected, special chars rejected, whitespace trimmed, empty string rejected, 32-char boundary accepted, 31-char one-short rejected. Security-critical validation surface (#1024). - GET /mcp returns 405 with `Allow: POST, DELETE` (E2E case in `serve-http-oauth.test.ts`). Pre-fix, claude.ai and other probing MCP clients saw 404 and gave up (#1076). - apply-migrations `process.exit(0)` on list / dry-run / up-to-date paths. Source-shape assertion locks the rule in; shell scripts gating on `$?` work (#1062). - Autopilot wrapper sources `~/.zshenv` BEFORE `~/.zshrc`. zshenv is the canonical place for env vars in non-interactive zsh; without this ordering, LaunchAgent subprocesses never inherit secrets exported in zshrc (#966). - `test/fix-wave-structural.test.ts` consolidates source-shape regression guards for fixes whose behavior is hard to runtime-test without heavy mocking: query cache drain (#1125), admin embed manifest + handler (#1090), admin register-client PKCE branch (#1077), PGLite v0.11.0 phase A in-process routing (#1100), query `--no-expand` negation (#1124). 9 source-grep assertions. Refactored `runServeHttp` to extract `resolveBootstrapToken` as a pure helper. The boot path now consumes the helper's tagged-union result ({kind:'ok'|'error'}); side effects (`process.exit`, `console.error`) moved to the caller. Unit-testable without spinning up Express. Test counts: oauth 71 (was 69), config 20 (was 14), apply-migrations 19 (was 18), autopilot-install 5 (was 4), serve-http-bootstrap-token 9 (new file), fix-wave-structural 9 (new file). Net: +28 cases across 6 files; +1 new exported function with full coverage. Remaining audit gaps (deferred): -e82dda0aadmin embed E2E (post-deploy curl smoke covers this) -d93fa81dapply-migrations PGLite chain E2E (already smoke-tested manually in the original commit; subprocess test would be flaky in CI without DATABASE_URL gating) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test: close the two deferred E2E gaps from the post-ship audit Both gaps now have real behavior coverage. No DATABASE_URL needed (PGLite engine), so they run in standard unit CI alongside the rest of the suite. Serial quarantine because both spawn subprocesses + bind ports / write tmpdirs. test/admin-embed-spawn.serial.test.ts (4 cases, ~6s wall-clock): - Spawns `gbrain serve --http` from a fresh tmpdir so `process.cwd()/ admin/dist` does not exist — this forces the embedded-manifest branch (the one under test). Pre-fix, this exact setup hit 404. - GET /admin/ → 200 + SPA shell HTML (title + #root div), content-type text/html. - GET /admin/index.html → same body via explicit path. - GET /admin/agents → SPA fallback returns index.html for deep links. - GET /admin/api/stats → NOT 200 (regression guard: SPA fallback must not swallow /admin/api/* routes and silently return HTML to a JSON client). Closes #1090. test/apply-migrations-pglite-spawn.serial.test.ts (3 cases, ~25s): - Seeds a fresh PGLite config in a tmpdir, runs `gbrain init --migrate-only` + `gbrain apply-migrations --yes --non-interactive`. Pre-fix this hit "GBrain: Timed out waiting for PGLite lock" because apply-migrations' pre-flight probe + v0.11.0's phase A subprocess both wanted the single-writer lock. - Asserts exit 0, no "Timed out" string, no "Phase A failed" string, brain.pglite file written. - Re-run case: idempotent — "All migrations up to date" exits 0 (also locks in the #1062 exit-code fix end-to-end). - --list path exits 0 (third leg of the #1062 contract). Closes #1100. Pinned bootstrap token via GBRAIN_ADMIN_BOOTSTRAP_TOKEN env so the admin test doesn't have to scrape stderr; the startup banner format is allowed to drift, the /health probe is the readiness contract. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(test): consolidate PGLite spawn test to one end-to-end pass CI failed on test/apply-migrations-pglite-spawn.serial.test.ts (Ubuntu, bun 1.3.14). The previous shape ran 3 tests × ~3 spawns each. Each `bun run /abs/src/cli.ts` from a tmpdir cwd pays a full parse/transpile cost (no near-cwd .bun cache); on Ubuntu CI that compounds past the runner's per-test budget. Consolidated to ONE test that exercises the full lifecycle in one brain: init --migrate-only → apply-migrations --yes → re-run → --list. Four spawns instead of eight. Local wall-clock: 32s → 11.5s. All four assertion buckets preserved: no PGLite lock timeout, no Phase A failure, brain.pglite written, idempotent re-run "All migrations up to date" exits 0 (#1062 end-to-end), --list exits 0. Per-test timeout 480_000ms as insurance against the runner's --timeout=60000 default (bun's API spec: per-test wins). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(diag): dump apply-migrations output when CI exit != 0 The PGLite spawn test passes locally on macOS/bun 1.3.13 in ~11s end-to-end but fails on Ubuntu/bun 1.3.14 in 4.92s with apply.exitCode = 1 — fast enough that something is failing early, not timing out. The runCli helper captured stdout+stderr but never printed them, so the CI log only showed the bare assertion failure. This commit prints the captured streams from BOTH init and apply when the exit code mismatches expectation. After the next CI run we can read the actual error message and diagnose the Ubuntu-specific failure mode (likely BUN_INSTALL / HOME / PGLite WASM env quirk). No behavior change; pure diagnostic output gate on failure. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(test): shim `gbrain` on PATH for PGLite spawn test Root cause of the Ubuntu CI failure: the v0.11.0 orchestrator's phase B runs `execSync('gbrain jobs smoke')`. PGLite phase A now routes in-process (the #1100 fix), but phase B and several follow-up phases still shell out to the `gbrain` binary on PATH. Locally the binary resolves via `bun link`; on CI Ubuntu it does not exist on PATH, so execSync exits 127 → orchestrator returns 'failed' → apply-migrations exits 1. Test failed at 4.92s with exitCode=1, well before any timeout. Verified locally by removing ~/.bun/bin/gbrain to simulate CI: pre-shim: apply.exitCode=1 (same as CI) post-shim: apply.exitCode=0 in 8.4s The shim writes a tiny `gbrain` executable to a tmpdir that just `exec`s `bun run <repo>/src/cli.ts "$@"`. Prepended to PATH for the spawned subprocesses. Mirrors the production contract (gbrain on PATH) without depending on `bun link` having run in the CI image. Diagnostic dump from the previous commit stays — useful insurance for the next time something silently fails inside a spawned binary. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: johnybradshaw <johnybradshaw@users.noreply.github.com> Co-authored-by: mvanhorn <mvanhorn@users.noreply.github.com> Co-authored-by: sharziki <sharziki@users.noreply.github.com> Co-authored-by: Aashiqe10 <Aashiqe10@users.noreply.github.com> Co-authored-by: lukejduncan <lukejduncan@users.noreply.github.com> Co-authored-by: 100yenadmin <100yenadmin@users.noreply.github.com> Co-authored-by: hnshah <hnshah@users.noreply.github.com> Co-authored-by: p3ob7o <p3ob7o@users.noreply.github.com> Co-authored-by: sliday <sliday@users.noreply.github.com> Co-authored-by: nezovskii <nezovskii@users.noreply.github.com> Co-authored-by: vincedk-alt <vincedk-alt@users.noreply.github.com> Co-authored-by: sergeclaesen <sergeclaesen@users.noreply.github.com> Co-authored-by: navin-moorthy <navin-moorthy@users.noreply.github.com> Co-authored-by: billy-armstrong <billy-armstrong@users.noreply.github.com> Co-authored-by: jeunessima <jeunessima@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
892 lines
40 KiB
TypeScript
892 lines
40 KiB
TypeScript
/**
|
|
* E2E tests for serve-http.ts OAuth 2.1 fixes (v0.26.1).
|
|
*
|
|
* Spins up a real `gbrain serve --http` against real Postgres, registers an
|
|
* OAuth client, mints tokens, and exercises the full MCP JSON-RPC pipeline
|
|
* end-to-end. Catches the three bugs fixed in v0.26.1:
|
|
*
|
|
* 1. client_credentials tokens rejected at /mcp (expiresAt string vs number)
|
|
* 2. OAuth metadata missing client_credentials grant type
|
|
* 3. Express 5 trust proxy + admin SPA wildcard
|
|
*
|
|
* Run: GBRAIN_DATABASE_URL=... bun test test/e2e/serve-http-oauth.test.ts
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { hasDatabase } from './helpers.ts';
|
|
|
|
const skip = !hasDatabase();
|
|
const describeE2E = skip ? describe.skip : describe;
|
|
|
|
if (skip) {
|
|
console.log('Skipping E2E serve-http-oauth tests (DATABASE_URL not set)');
|
|
}
|
|
|
|
const PORT = 19131; // Avoid collision with production 3131
|
|
const BASE = `http://localhost:${PORT}`;
|
|
|
|
describeE2E('serve-http OAuth 2.1 E2E (v0.26.1 + v0.26.2 + v0.26.3)', () => {
|
|
let serverProcess: ReturnType<typeof import('child_process').spawn> | null = null;
|
|
let clientId: string | undefined;
|
|
let clientSecret: string | undefined;
|
|
// DCR-registered clients accumulate here so afterAll can revoke them too
|
|
// (one per test that posts to /register).
|
|
const dcrClientIds: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
const { execSync, spawn } = await import('child_process');
|
|
|
|
// Register a test OAuth client via CLI.
|
|
// env: { ...process.env } is required: bun's execSync does NOT inherit
|
|
// env mutations done via `process.env.X = ...` (only OS-level env from
|
|
// before bun started). helpers.ts loads .env.testing and sets DATABASE_URL
|
|
// via process.env mutation, which is invisible to subprocesses unless we
|
|
// explicitly re-pass process.env. Same pattern applies to every execSync
|
|
// in this file.
|
|
// v0.28.10: register with admin scope so the F7 protected-name guard
|
|
// tests can mint admin-scoped tokens that actually exercise the guard
|
|
// at operations.ts:1527. Without admin in the client's allowed scopes,
|
|
// submit_job for a protected name (`shell`, `subagent`) gets rejected
|
|
// by hasScope() in serve-http.ts BEFORE reaching the F7 guard, so the
|
|
// test was validating scope enforcement instead of the RCE protection.
|
|
// Other tests that mint specific subsets ('read', 'read write') still
|
|
// get the subset they ask for — adding admin to the client's allowed
|
|
// ceiling does not auto-grant it to every minted token.
|
|
const regOutput = execSync(
|
|
'bun run src/cli.ts auth register-client e2e-oauth-test --grant-types client_credentials --scopes "read write admin"',
|
|
{ cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }
|
|
);
|
|
const idMatch = regOutput.match(/Client ID:\s+(gbrain_cl_\S+)/);
|
|
const secretMatch = regOutput.match(/Client Secret:\s+(gbrain_cs_\S+)/);
|
|
if (!idMatch || !secretMatch) throw new Error('Failed to register test client:\n' + regOutput);
|
|
clientId = idMatch[1];
|
|
clientSecret = secretMatch[1];
|
|
|
|
// Start the HTTP server. v0.26.2 adds --enable-dcr so the /register
|
|
// endpoint is reachable for the DCR response-shape test.
|
|
serverProcess = spawn('bun', [
|
|
'run', 'src/cli.ts', 'serve', '--http',
|
|
'--port', String(PORT),
|
|
'--public-url', `http://localhost:${PORT}`,
|
|
'--enable-dcr',
|
|
], {
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
// Collect stderr for debugging failures
|
|
let stderr = '';
|
|
serverProcess.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); });
|
|
|
|
// Wait for server to be ready (up to 15s)
|
|
let ready = false;
|
|
for (let i = 0; i < 30; i++) {
|
|
try {
|
|
const res = await fetch(`${BASE}/health`);
|
|
if (res.ok) { ready = true; break; }
|
|
} catch {}
|
|
await new Promise(r => setTimeout(r, 500));
|
|
}
|
|
if (!ready) throw new Error('Server failed to start within 15s.\nstderr: ' + stderr.slice(-500));
|
|
}, 30_000);
|
|
|
|
afterAll(async () => {
|
|
// Kill server first so it can't issue more tokens during cleanup.
|
|
if (serverProcess) {
|
|
serverProcess.kill('SIGTERM');
|
|
await new Promise(r => setTimeout(r, 1000));
|
|
if (!serverProcess.killed) serverProcess.kill('SIGKILL');
|
|
}
|
|
// v0.26.2 cleanup contract: only revoke if registration succeeded
|
|
// (clientId guard) and surface any cleanup failure to stderr without
|
|
// throwing — a real test failure is more interesting than the cleanup
|
|
// error that follows it. Same shape applies to DCR-registered clients
|
|
// tracked in dcrClientIds.
|
|
const { execSync } = await import('child_process');
|
|
const toRevoke = [...(clientId ? [clientId] : []), ...dcrClientIds];
|
|
for (const id of toRevoke) {
|
|
try {
|
|
execSync(`bun run src/cli.ts auth revoke-client "${id}"`,
|
|
{ cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } });
|
|
} catch (e: any) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`[afterAll] revoke-client cleanup failed for ${id}: ${e.message}`);
|
|
}
|
|
}
|
|
}, 30_000);
|
|
|
|
// Helper: mint a token with given scopes
|
|
async function mintToken(scope = 'read write'): Promise<{ access_token: string; expires_in: number; scope: string }> {
|
|
const res = await fetch(`${BASE}/token`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: `grant_type=client_credentials&client_id=${clientId}&client_secret=${clientSecret}&scope=${encodeURIComponent(scope)}`,
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
return res.json() as any;
|
|
}
|
|
|
|
// Helper: call MCP JSON-RPC with a bearer token
|
|
async function mcpCall(token: string, method: string, params?: any): Promise<Response> {
|
|
return fetch(`${BASE}/mcp`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json, text/event-stream',
|
|
},
|
|
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, ...(params ? { params } : {}) }),
|
|
});
|
|
}
|
|
|
|
// =========================================================================
|
|
// Fix 1: client_credentials tokens validate at /mcp
|
|
// =========================================================================
|
|
|
|
test('mint token via client_credentials grant', async () => {
|
|
const data = await mintToken('read write');
|
|
expect(data.access_token).toMatch(/^gbrain_at_/);
|
|
expect(data.expires_in).toBe(3600);
|
|
expect(data.scope).toContain('read');
|
|
});
|
|
|
|
test('minted token is accepted at /mcp — tools/list returns tools', async () => {
|
|
const { access_token } = await mintToken('read');
|
|
const res = await mcpCall(access_token, 'tools/list');
|
|
|
|
// Before v0.26.1 fix: 401 {"error":"invalid_token","error_description":"Token has no expiration time"}
|
|
expect(res.status).not.toBe(401);
|
|
|
|
const body = await res.text();
|
|
expect(body).toContain('tools');
|
|
expect(body).toContain('search'); // search tool should be in the list
|
|
expect(body).toContain('query'); // query tool too
|
|
}, 15_000);
|
|
|
|
test('minted token works for tools/call — search executes', async () => {
|
|
const { access_token } = await mintToken('read');
|
|
const res = await mcpCall(access_token, 'tools/call', {
|
|
name: 'search',
|
|
arguments: { query: 'gbrain', limit: 1 },
|
|
});
|
|
|
|
expect(res.status).not.toBe(401);
|
|
const body = await res.text();
|
|
// Should contain search results, not an auth error
|
|
expect(body).not.toContain('invalid_token');
|
|
expect(body).toContain('result');
|
|
}, 15_000);
|
|
|
|
test('expired/invalid token is rejected at /mcp', async () => {
|
|
const res = await mcpCall('gbrain_at_totally_fake_token', 'tools/list');
|
|
// Invalid tokens should not return 200 with tool results
|
|
const body = await res.text();
|
|
expect(body).not.toContain('"tools"');
|
|
// Should be an error status (401, 403, or 500 depending on SDK error mapping)
|
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
});
|
|
|
|
test('missing Authorization header returns 401', async () => {
|
|
const res = await fetch(`${BASE}/mcp`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json, text/event-stream',
|
|
},
|
|
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
// =========================================================================
|
|
// Fix 2: OAuth metadata includes client_credentials
|
|
// =========================================================================
|
|
|
|
test('OAuth AS metadata includes all three grant types', async () => {
|
|
const res = await fetch(`${BASE}/.well-known/oauth-authorization-server`);
|
|
expect(res.ok).toBe(true);
|
|
const meta = await res.json() as any;
|
|
expect(meta.grant_types_supported).toContain('authorization_code');
|
|
expect(meta.grant_types_supported).toContain('refresh_token');
|
|
expect(meta.grant_types_supported).toContain('client_credentials');
|
|
});
|
|
|
|
test('OAuth metadata issuer matches public URL', async () => {
|
|
const res = await fetch(`${BASE}/.well-known/oauth-authorization-server`);
|
|
const meta = await res.json() as any;
|
|
expect(meta.issuer).toBe(`http://localhost:${PORT}/`);
|
|
expect(meta.token_endpoint).toContain('/token');
|
|
expect(meta.scopes_supported).toContain('read');
|
|
expect(meta.scopes_supported).toContain('write');
|
|
expect(meta.scopes_supported).toContain('admin');
|
|
});
|
|
|
|
// T2 (eng-review): scopes_supported advertises the full ALLOWED_SCOPES_LIST
|
|
// so MCP clients (Claude Desktop, ChatGPT, Perplexity) can discover the
|
|
// v0.28 sources_admin and users_admin scopes via standard discovery.
|
|
// Pre-v0.28 the list was hardcoded to ['read','write','admin'] in
|
|
// serve-http.ts:195 and this assertion would have failed.
|
|
test('OAuth metadata advertises all 5 v0.28 scopes (sources_admin + users_admin)', async () => {
|
|
const res = await fetch(`${BASE}/.well-known/oauth-authorization-server`);
|
|
const meta = await res.json() as any;
|
|
expect(meta.scopes_supported).toContain('sources_admin');
|
|
expect(meta.scopes_supported).toContain('users_admin');
|
|
expect(meta.scopes_supported).toEqual(
|
|
expect.arrayContaining(['admin', 'read', 'sources_admin', 'users_admin', 'write']),
|
|
);
|
|
});
|
|
|
|
// =========================================================================
|
|
// Fix 3: Express 5 compatibility
|
|
// =========================================================================
|
|
|
|
test('admin dashboard serves SPA index.html (not Express error)', async () => {
|
|
const res = await fetch(`${BASE}/admin/`);
|
|
const html = await res.text();
|
|
expect(html).toContain('GBrain Admin');
|
|
expect(html).not.toContain('<pre>Cannot GET');
|
|
});
|
|
|
|
test('admin sub-routes serve SPA fallback', async () => {
|
|
const res = await fetch(`${BASE}/admin/agents`);
|
|
const html = await res.text();
|
|
expect(html).toContain('GBrain Admin');
|
|
});
|
|
|
|
// v0.36.1.x #1076: GET /mcp must return 405 (Method Not Allowed) per the
|
|
// MCP Streamable HTTP spec, not 404. claude.ai + other probing clients
|
|
// distinguish "endpoint exists, no SSE channel" from "endpoint missing"
|
|
// on this status code; 404 makes them give up.
|
|
test('GET /mcp returns 405 with Allow: POST, DELETE (v0.36.1.x #1076)', async () => {
|
|
const res = await fetch(`${BASE}/mcp`, { method: 'GET' });
|
|
expect(res.status).toBe(405);
|
|
expect(res.headers.get('Allow')).toBe('POST, DELETE');
|
|
const body = await res.json() as { jsonrpc?: string; error?: { code?: number } };
|
|
expect(body.jsonrpc).toBe('2.0');
|
|
expect(body.error?.code).toBe(-32000);
|
|
});
|
|
|
|
test('X-Forwarded-For header does not crash server', async () => {
|
|
const res = await fetch(`${BASE}/health`, {
|
|
headers: { 'X-Forwarded-For': '10.0.0.1, 172.16.0.1' },
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
const data = await res.json() as any;
|
|
expect(data.status).toBe('ok');
|
|
});
|
|
|
|
// =========================================================================
|
|
// Scope enforcement
|
|
// =========================================================================
|
|
|
|
test('read-only token is rejected for write operations', async () => {
|
|
const { access_token } = await mintToken('read');
|
|
const res = await mcpCall(access_token, 'tools/call', {
|
|
name: 'put_page',
|
|
arguments: { slug: 'e2e-scope-test', content: '---\ntitle: test\n---\ntest' },
|
|
});
|
|
|
|
const body = await res.text();
|
|
// Should be rejected via scope check (403 or JSON-RPC error with scope message)
|
|
expect(res.status === 403 || body.includes('scope') || body.includes('Insufficient')).toBe(true);
|
|
}, 15_000);
|
|
|
|
test('write-scoped token can call read operations', async () => {
|
|
const { access_token } = await mintToken('read write');
|
|
const res = await mcpCall(access_token, 'tools/call', {
|
|
name: 'search',
|
|
arguments: { query: 'test', limit: 1 },
|
|
});
|
|
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
const body = await res.text();
|
|
// Should get a result, not an auth error
|
|
expect(body).not.toContain('invalid_token');
|
|
expect(body).not.toContain('insufficient_scope');
|
|
}, 15_000);
|
|
|
|
// =========================================================================
|
|
// Health endpoint (no auth required) — v0.28.10 made /health liveness-only;
|
|
// engine stats moved to /admin/api/full-stats behind requireAdmin so a
|
|
// saturated pool can't pin /health and trigger orchestrator restart cascades.
|
|
// =========================================================================
|
|
|
|
test('v0.28.10: /health returns liveness-only body (no engine stats)', async () => {
|
|
const res = await fetch(`${BASE}/health`);
|
|
expect(res.ok).toBe(true);
|
|
const data = await res.json() as any;
|
|
expect(data.status).toBe('ok');
|
|
expect(data.version).toBeDefined();
|
|
expect(data.engine).toBeDefined();
|
|
// Regression: pre-v0.28.10 /health spread getStats() (page_count,
|
|
// chunk_count, etc.) into the body. The whole point of the v0.28.10
|
|
// split is that /health stops touching those tables. If page_count
|
|
// ever reappears here, the heavy probe leaked back into the public
|
|
// route and the original DoS surface is back.
|
|
expect(data.page_count).toBeUndefined();
|
|
expect(data.chunk_count).toBeUndefined();
|
|
expect(data.embedded_count).toBeUndefined();
|
|
// Body shape is exactly {status, version, engine}.
|
|
expect(Object.keys(data).sort()).toEqual(['engine', 'status', 'version']);
|
|
});
|
|
|
|
test('v0.28.10: /admin/api/full-stats without admin cookie returns 401', async () => {
|
|
const res = await fetch(`${BASE}/admin/api/full-stats`);
|
|
expect(res.status).toBe(401);
|
|
const data = await res.json() as any;
|
|
expect(data.error).toBe('Admin authentication required');
|
|
});
|
|
|
|
test('v0.28.10: /admin/api/full-stats with valid admin cookie returns getStats() body', async () => {
|
|
// Same magic-link cookie dance the existing single-use test uses.
|
|
// Skip gracefully if the bootstrap token isn't extractable — the 401
|
|
// case above pins the auth gate; this test pins the happy path.
|
|
const stderrBuf = (serverProcess as any)?._stderrBuffer || '';
|
|
const tokenMatch = String(stderrBuf).match(/Admin Token[\s\S]*?([a-f0-9]{32,64})/);
|
|
if (!tokenMatch) {
|
|
console.warn('[e2e] skipped /admin/api/full-stats happy path: could not extract bootstrap token');
|
|
return;
|
|
}
|
|
const bootstrapToken = tokenMatch[1];
|
|
|
|
const issueRes = await fetch(`${BASE}/admin/api/issue-magic-link`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${bootstrapToken}` },
|
|
body: '{}',
|
|
});
|
|
expect(issueRes.ok).toBe(true);
|
|
const { url } = await issueRes.json() as any;
|
|
|
|
const click = await fetch(url, { redirect: 'manual' });
|
|
expect(click.status).toBe(302);
|
|
const setCookie = click.headers.get('set-cookie') || '';
|
|
const cookieMatch = setCookie.match(/gbrain_admin=([^;]+)/);
|
|
expect(cookieMatch).toBeTruthy();
|
|
const cookieValue = cookieMatch![1];
|
|
|
|
const statsRes = await fetch(`${BASE}/admin/api/full-stats`, {
|
|
headers: { Cookie: `gbrain_admin=${cookieValue}` },
|
|
});
|
|
expect(statsRes.ok).toBe(true);
|
|
const stats = await statsRes.json() as any;
|
|
expect(stats.status).toBe('ok');
|
|
expect(stats.version).toBeDefined();
|
|
expect(stats.engine).toBeDefined();
|
|
// The full-stats body is probeHealth's spread of getStats() — page_count
|
|
// is the canonical signal that we're hitting the heavy path here.
|
|
expect(typeof stats.page_count).toBe('number');
|
|
expect(stats.page_count).toBeGreaterThanOrEqual(0);
|
|
}, 15_000);
|
|
|
|
// =========================================================================
|
|
// Token lifecycle
|
|
// =========================================================================
|
|
|
|
test('multiple tokens can be minted and used independently', async () => {
|
|
const t1 = await mintToken('read');
|
|
const t2 = await mintToken('read write');
|
|
|
|
// Both should work
|
|
const r1 = await mcpCall(t1.access_token, 'tools/list');
|
|
const r2 = await mcpCall(t2.access_token, 'tools/list');
|
|
|
|
expect(r1.status).not.toBe(401);
|
|
expect(r2.status).not.toBe(401);
|
|
}, 15_000);
|
|
|
|
test('wrong client_secret is rejected at token endpoint', async () => {
|
|
const res = await fetch(`${BASE}/token`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: `grant_type=client_credentials&client_id=${clientId}&client_secret=gbrain_cs_wrong_secret&scope=read`,
|
|
});
|
|
expect(res.ok).toBe(false);
|
|
const data = await res.json() as any;
|
|
expect(data.error).toBe('invalid_grant');
|
|
});
|
|
|
|
// =========================================================================
|
|
// v0.26.2: DCR /register response shape (RFC 7591 §3.2.1 number contract)
|
|
// =========================================================================
|
|
//
|
|
// The user-visible bug v0.26.2 protects against: postgres.js with
|
|
// `prepare: false` returns BIGINT columns as strings, and an RFC-strict
|
|
// DCR client (Claude Code, Cursor) parses the /register response as JSON
|
|
// and rejects timestamps that aren't numbers. This is the HTTP-level test;
|
|
// the internal-store shape test in test/oauth.test.ts is not enough on its
|
|
// own (Codex flagged it as the wrong seam).
|
|
|
|
test('DCR /register returns numeric client_id_issued_at (RFC 7591 §3.2.1)', async () => {
|
|
const res = await fetch(`${BASE}/register`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
client_name: 'e2e-dcr-shape',
|
|
redirect_uris: ['https://example.com/cb'],
|
|
grant_types: ['authorization_code'],
|
|
token_endpoint_auth_method: 'client_secret_basic',
|
|
scope: 'read',
|
|
}),
|
|
});
|
|
expect(res.ok).toBe(true);
|
|
const body = await res.json() as any;
|
|
|
|
// Track for cleanup before any assertion that could throw.
|
|
if (body.client_id) dcrClientIds.push(body.client_id);
|
|
|
|
// The contract: client_id_issued_at is REQUIRED to be a JSON number per
|
|
// RFC 7591. Pre-v0.26.2 with prepare:false returned this as a string
|
|
// (e.g., "1735689600") and strict clients rejected the registration.
|
|
expect(typeof body.client_id_issued_at).toBe('number');
|
|
expect(Number.isFinite(body.client_id_issued_at)).toBe(true);
|
|
expect(body.client_id_issued_at).toBeGreaterThan(0);
|
|
|
|
// client_secret_expires_at is OPTIONAL. If present, it must also be a
|
|
// number. Undefined/missing means "does not expire" per the spec.
|
|
if (body.client_secret_expires_at !== undefined) {
|
|
expect(typeof body.client_secret_expires_at).toBe('number');
|
|
expect(Number.isFinite(body.client_secret_expires_at)).toBe(true);
|
|
}
|
|
}, 15_000);
|
|
|
|
// =========================================================================
|
|
// v0.26.2: revoke-client CLI subprocess test
|
|
// =========================================================================
|
|
//
|
|
// Validates the actual CLI router in src/commands/auth.ts, not just the
|
|
// database deletion semantics. Codex flagged that a unit test in
|
|
// test/oauth.test.ts proves DB DELETE works but does NOT prove the
|
|
// subcommand exists or routes correctly.
|
|
|
|
test('auth revoke-client (CLI) deletes client + cascades to tokens', async () => {
|
|
const { execSync } = await import('child_process');
|
|
|
|
// Step 1: register a throwaway client via CLI.
|
|
// env: { ...process.env } per the bun execSync inheritance fix above.
|
|
const regOutput = execSync(
|
|
'bun run src/cli.ts auth register-client e2e-revoke-cli --grant-types client_credentials --scopes read',
|
|
{ cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }
|
|
);
|
|
const idMatch = regOutput.match(/Client ID:\s+(gbrain_cl_\S+)/);
|
|
const secretMatch = regOutput.match(/Client Secret:\s+(gbrain_cs_\S+)/);
|
|
expect(idMatch).not.toBeNull();
|
|
expect(secretMatch).not.toBeNull();
|
|
const id = idMatch![1];
|
|
const secret = secretMatch![1];
|
|
|
|
// Step 2: mint a token through the live server.
|
|
const tokenRes = await fetch(`${BASE}/token`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: `grant_type=client_credentials&client_id=${id}&client_secret=${secret}&scope=read`,
|
|
});
|
|
expect(tokenRes.ok).toBe(true);
|
|
const { access_token } = await tokenRes.json() as any;
|
|
|
|
// Sanity: the freshly-minted token works at /mcp.
|
|
const before = await mcpCall(access_token, 'tools/list');
|
|
expect(before.status).not.toBe(401);
|
|
|
|
// Step 3: revoke via the CLI subprocess.
|
|
const revokeOutput = execSync(
|
|
`bun run src/cli.ts auth revoke-client "${id}"`,
|
|
{ cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }
|
|
);
|
|
// The handler prints the human confirmation lines. No exit code != 0
|
|
// here since execSync would throw.
|
|
expect(revokeOutput).toMatch(/OAuth client revoked/);
|
|
expect(revokeOutput).toMatch(/cascade/i);
|
|
|
|
// Step 4: previously-minted token must now be rejected at /mcp. Cascade
|
|
// wiped the oauth_tokens row; verifyAccessToken throws "Invalid token".
|
|
// Match the existing pattern at line 156: SDK error mapping varies
|
|
// (401/403/500), so we assert non-success status + non-success body
|
|
// rather than a single status code.
|
|
const after = await mcpCall(access_token, 'tools/list');
|
|
expect(after.status).toBeGreaterThanOrEqual(400);
|
|
const afterBody = await after.text();
|
|
expect(afterBody).not.toContain('"tools":[');
|
|
|
|
// Step 5: re-running revoke-client on the now-deleted id must exit 1.
|
|
let secondRunFailed = false;
|
|
let secondRunStderr = '';
|
|
try {
|
|
execSync(`bun run src/cli.ts auth revoke-client "${id}"`,
|
|
{ cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } });
|
|
} catch (e: any) {
|
|
secondRunFailed = true;
|
|
secondRunStderr = (e.stderr || '').toString() + (e.stdout || '').toString();
|
|
}
|
|
expect(secondRunFailed).toBe(true);
|
|
expect(secondRunStderr).toMatch(/No client found/);
|
|
}, 30_000);
|
|
|
|
// =========================================================================
|
|
// v0.26.3: Migration v33 round-trip — pins the 5 new columns
|
|
// =========================================================================
|
|
//
|
|
// PR #586 referenced oauth_clients.{token_ttl, deleted_at} +
|
|
// mcp_request_log.{agent_name, params, error_message} without an
|
|
// accompanying migration. v33 adds them. This test pins the round-trip:
|
|
// make a /mcp call -> assert all three new mcp_request_log columns
|
|
// persisted correctly. Without v33, the INSERT silently swallows
|
|
// column-doesn't-exist errors via the existing best-effort try/catch
|
|
// and the row never appears.
|
|
|
|
test('v0.26.3: /mcp request persists agent_name + params + error_message', async () => {
|
|
const postgres = (await import('postgres')).default;
|
|
const sql = postgres(process.env.GBRAIN_DATABASE_URL || process.env.DATABASE_URL || '', { prepare: false });
|
|
try {
|
|
// Wipe any prior log rows for our test client so we can assert exact counts.
|
|
await sql`DELETE FROM mcp_request_log WHERE token_name = ${clientId!}`;
|
|
|
|
// Mint a fresh write-scoped token and make a successful tools/list call.
|
|
const tokenRes = await fetch(`${BASE}/token`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: `grant_type=client_credentials&client_id=${clientId!}&client_secret=${clientSecret!}&scope=read`,
|
|
});
|
|
expect(tokenRes.ok).toBe(true);
|
|
const { access_token } = await tokenRes.json() as any;
|
|
const okRes = await mcpCall(access_token, 'tools/list');
|
|
expect(okRes.status).not.toBe(401);
|
|
|
|
// Trigger an error path so the error_message column gets a value too.
|
|
// Request a tool that doesn't exist — v0.28.10 logs unknown-op attempts
|
|
// with operation = the attempted name and error_message starting with
|
|
// 'unknown_operation:'.
|
|
await mcpCall(access_token, 'tools/call', { name: 'this_tool_does_not_exist', arguments: {} });
|
|
|
|
// Allow async best-effort INSERT to flush.
|
|
await new Promise(r => setTimeout(r, 250));
|
|
|
|
const rows = await sql`
|
|
SELECT operation, status, agent_name, params, error_message
|
|
FROM mcp_request_log
|
|
WHERE token_name = ${clientId!}
|
|
ORDER BY created_at ASC
|
|
` as unknown as Array<Record<string, unknown>>;
|
|
|
|
expect(rows.length).toBeGreaterThanOrEqual(2);
|
|
|
|
// Agent name resolved from oauth_clients.client_name (the JOIN in
|
|
// verifyAccessToken or the agent_name backfill path).
|
|
for (const row of rows) {
|
|
expect(row.agent_name).toBe('e2e-oauth-test');
|
|
}
|
|
|
|
// v0.28.10: tools/list logs as operation='tools/list' (the JSON-RPC
|
|
// method name). tools/call success/error logs as operation=<inner
|
|
// tool name> (the convention preserved from pre-v0.28.10 dispatch
|
|
// logging — agents querying mcp_request_log filter by tool name, not
|
|
// by JSON-RPC method).
|
|
const listRow = rows.find(r => r.operation === 'tools/list');
|
|
expect(listRow).toBeDefined();
|
|
expect(listRow!.status).toBe('success');
|
|
|
|
// The unknown-op call shows up with operation = the attempted name.
|
|
const callRow = rows.find(r => r.operation === 'this_tool_does_not_exist');
|
|
expect(callRow).toBeDefined();
|
|
expect(callRow!.status).toBe('error');
|
|
|
|
// error_message populated on the failed call.
|
|
const errorRow = rows.find(r => r.status === 'error');
|
|
expect(errorRow).toBeDefined();
|
|
expect(errorRow!.error_message).toBeTruthy();
|
|
expect(typeof errorRow!.error_message).toBe('string');
|
|
expect(errorRow!.error_message as string).toContain('unknown_operation');
|
|
} finally {
|
|
await sql.end();
|
|
}
|
|
}, 30_000);
|
|
|
|
// =========================================================================
|
|
// v0.26.3: request-log filter injection probe
|
|
// =========================================================================
|
|
//
|
|
// Pre-fix: /admin/api/requests built WHERE clauses via sql.unsafe() with
|
|
// single-quote escape (`token_name = '${agent.replace(/'/g, "''")}'`).
|
|
// Post-fix: postgres.js tagged-template fragments. This probe sends a
|
|
// payload that, under broken escaping, would short-circuit to TRUE and
|
|
// return all rows. Under correct parameterization, it matches no rows.
|
|
|
|
test("v0.26.3: request-log filter rejects injection attempt (' OR 1=1)", async () => {
|
|
// Use a plain admin session via /admin/login + bootstrap token. This
|
|
// test covers the unauthenticated SQL-injection vector via the agent
|
|
// query parameter — even though the endpoint is admin-gated, defense-
|
|
// in-depth on parameterization matters.
|
|
//
|
|
// Extract the admin bootstrap token from the spawned server's stderr.
|
|
const probe = "alice'%20OR%201%3D1";
|
|
|
|
// We don't have a clean way to pull the admin token from the spawned
|
|
// process here (commit 16 deleted the regex extraction). The injection
|
|
// probe still works WITHOUT auth — the endpoint requires it via 401.
|
|
// We assert that the 401 lands BEFORE any SQL gets built, so we don't
|
|
// crash the server with malformed SQL on the way to the auth check.
|
|
const res = await fetch(`${BASE}/admin/api/requests?agent=${probe}`, {
|
|
method: 'GET',
|
|
});
|
|
// No admin cookie — must hit 401, not 500 (no SQL crash).
|
|
expect(res.status).toBe(401);
|
|
|
|
// Server is still alive (didn't crash on the malformed input).
|
|
const health = await fetch(`${BASE}/health`);
|
|
expect(health.ok).toBe(true);
|
|
});
|
|
|
|
// =========================================================================
|
|
// v0.26.3: per-client TTL flow
|
|
// =========================================================================
|
|
//
|
|
// PR #586 added `tokenTtl` per OAuth client. exchangeClientCredentials
|
|
// reads oauth_clients.token_ttl (per-client override) and falls back to
|
|
// the server default. This test registers a client with a custom TTL,
|
|
// mints a token, and asserts the response's expires_in matches.
|
|
|
|
test('v0.26.3: per-client token_ttl is honored on token mint', async () => {
|
|
const postgres = (await import('postgres')).default;
|
|
const sql = postgres(process.env.GBRAIN_DATABASE_URL || process.env.DATABASE_URL || '', { prepare: false });
|
|
try {
|
|
// Register a client + set a custom token_ttl (24 hours = 86400 seconds).
|
|
const { execSync } = await import('child_process');
|
|
const regOutput = execSync(
|
|
'bun run src/cli.ts auth register-client e2e-test-ttl --grant-types client_credentials --scopes read',
|
|
{ cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }
|
|
);
|
|
const idMatch = regOutput.match(/Client ID:\s+(gbrain_cl_\S+)/);
|
|
const secretMatch = regOutput.match(/Client Secret:\s+(gbrain_cs_\S+)/);
|
|
expect(idMatch).not.toBeNull();
|
|
expect(secretMatch).not.toBeNull();
|
|
const id = idMatch![1];
|
|
const secret = secretMatch![1];
|
|
dcrClientIds.push(id); // afterAll cleanup
|
|
|
|
// Set a 24-hour TTL.
|
|
await sql`UPDATE oauth_clients SET token_ttl = 86400 WHERE client_id = ${id}`;
|
|
|
|
// Mint a token. Response must include expires_in close to 86400.
|
|
const tokenRes = await fetch(`${BASE}/token`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: `grant_type=client_credentials&client_id=${id}&client_secret=${secret}&scope=read`,
|
|
});
|
|
expect(tokenRes.ok).toBe(true);
|
|
const body = await tokenRes.json() as any;
|
|
expect(body.expires_in).toBe(86400);
|
|
|
|
// Update TTL to a different value mid-test, mint again, assert new value.
|
|
await sql`UPDATE oauth_clients SET token_ttl = 7200 WHERE client_id = ${id}`;
|
|
const tokenRes2 = await fetch(`${BASE}/token`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: `grant_type=client_credentials&client_id=${id}&client_secret=${secret}&scope=read`,
|
|
});
|
|
expect(tokenRes2.ok).toBe(true);
|
|
const body2 = await tokenRes2.json() as any;
|
|
expect(body2.expires_in).toBe(7200);
|
|
|
|
// NULL token_ttl falls back to server default (3600 = 1 hour).
|
|
await sql`UPDATE oauth_clients SET token_ttl = NULL WHERE client_id = ${id}`;
|
|
const tokenRes3 = await fetch(`${BASE}/token`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: `grant_type=client_credentials&client_id=${id}&client_secret=${secret}&scope=read`,
|
|
});
|
|
expect(tokenRes3.ok).toBe(true);
|
|
const body3 = await tokenRes3.json() as any;
|
|
expect(body3.expires_in).toBe(3600);
|
|
} finally {
|
|
await sql.end();
|
|
}
|
|
}, 30_000);
|
|
|
|
// =========================================================================
|
|
// v0.26.3: magic-link single-use + 401 styled error page
|
|
// =========================================================================
|
|
//
|
|
// D11=C: /admin/auth/:nonce is single-use. First click consumes the nonce,
|
|
// second click fails with the styled 401 page. No bootstrap token in URL.
|
|
//
|
|
// Also covers F6.5: server returns Content-Type: text/html on the 401
|
|
// path (Express auto-sets this for HTML body) so browsers render the
|
|
// styled page instead of treating it as plain text.
|
|
|
|
test('v0.26.3: invalid magic-link nonce returns styled 401 HTML page', async () => {
|
|
const res = await fetch(`${BASE}/admin/auth/garbage_nonce_that_does_not_exist`, { redirect: 'manual' });
|
|
expect(res.status).toBe(401);
|
|
const ct = res.headers.get('content-type') || '';
|
|
expect(ct).toContain('text/html');
|
|
const body = await res.text();
|
|
expect(body).toContain('expired');
|
|
expect(body).toContain('GBrain');
|
|
});
|
|
|
|
test('v0.26.3: magic-link nonce is single-use (second click fails)', async () => {
|
|
// Get a real bootstrap token from the spawned server's environment.
|
|
// The server prints it to stderr at startup but commit 16 removed our
|
|
// regex extractor. Use the issue-magic-link endpoint directly with the
|
|
// bootstrap token from process env — except that env var doesn't exist
|
|
// in the test fixture. The portable approach: extract from the server
|
|
// process's stderr.
|
|
|
|
// Pull the bootstrap token from server stderr by re-reading the
|
|
// spawn handle. The spawn already started so stderr has flushed.
|
|
// Skip if we can't extract — the test is best-effort coverage of the
|
|
// single-use semantic; the styled-401 test above covers the negative path.
|
|
const stderrBuf = (serverProcess as any)?._stderrBuffer || '';
|
|
const tokenMatch = String(stderrBuf).match(/Admin Token[\s\S]*?([a-f0-9]{32,64})/);
|
|
if (!tokenMatch) {
|
|
// No way to get the bootstrap token in this test fixture — skip gracefully.
|
|
// The unit-level coverage for nonce single-use is in oauth.test.ts and
|
|
// the styled-401 test above pins the consumed-nonce path.
|
|
console.warn('[e2e] skipped magic-link single-use: could not extract bootstrap token');
|
|
return;
|
|
}
|
|
const bootstrapToken = tokenMatch[1];
|
|
|
|
// Mint a one-time nonce.
|
|
const issueRes = await fetch(`${BASE}/admin/api/issue-magic-link`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${bootstrapToken}` },
|
|
body: '{}',
|
|
});
|
|
expect(issueRes.ok).toBe(true);
|
|
const { url } = await issueRes.json() as any;
|
|
expect(url).toContain('/admin/auth/');
|
|
|
|
// First click — should set cookie + redirect (302 to /admin/).
|
|
const first = await fetch(url, { redirect: 'manual' });
|
|
expect(first.status).toBe(302);
|
|
const cookie = first.headers.get('set-cookie') || '';
|
|
expect(cookie).toContain('gbrain_admin=');
|
|
|
|
// Second click on the same URL — must fail (single-use consumed).
|
|
const second = await fetch(url, { redirect: 'manual' });
|
|
expect(second.status).toBe(401);
|
|
const secondBody = await second.text();
|
|
expect(secondBody).toContain('GBrain');
|
|
}, 15_000);
|
|
|
|
// =========================================================================
|
|
// v0.26.3: agent_name backfill across oauth_clients + access_tokens
|
|
// =========================================================================
|
|
//
|
|
// Migration v33 backfills mcp_request_log.agent_name using
|
|
// COALESCE(oauth_clients.client_name, access_tokens.name, token_name)
|
|
// This test confirms the agent_name is correctly resolved across both
|
|
// auth lanes (oauth client + legacy api key).
|
|
|
|
test('v0.26.3: agent_name resolves correctly for OAuth + legacy paths', async () => {
|
|
const postgres = (await import('postgres')).default;
|
|
const sql = postgres(process.env.GBRAIN_DATABASE_URL || process.env.DATABASE_URL || '', { prepare: false });
|
|
try {
|
|
// Make an OAuth-authenticated request — agent_name should be the OAuth client_name.
|
|
const tokenRes = await fetch(`${BASE}/token`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: `grant_type=client_credentials&client_id=${clientId!}&client_secret=${clientSecret!}&scope=read`,
|
|
});
|
|
const { access_token } = await tokenRes.json() as any;
|
|
await mcpCall(access_token, 'tools/list');
|
|
await new Promise(r => setTimeout(r, 250));
|
|
|
|
const oauthRows = await sql`
|
|
SELECT agent_name FROM mcp_request_log
|
|
WHERE token_name = ${clientId!}
|
|
ORDER BY created_at DESC LIMIT 1
|
|
` as unknown as Array<{ agent_name: string }>;
|
|
expect(oauthRows.length).toBeGreaterThan(0);
|
|
expect(oauthRows[0].agent_name).toBe('e2e-oauth-test');
|
|
} finally {
|
|
await sql.end();
|
|
}
|
|
}, 15_000);
|
|
|
|
// =========================================================================
|
|
// v0.26.3: register-client missing-name returns 400
|
|
// =========================================================================
|
|
//
|
|
// Defense-in-depth: the admin register-client endpoint must validate
|
|
// input. Pre-fix would have crashed or returned 500.
|
|
|
|
test('v0.26.3: /admin/api/register-client without name returns 400', async () => {
|
|
// Endpoint is admin-cookie-gated. Without auth we should get 401, not 500.
|
|
// Without a name in the body (with auth) we should get 400. We test the
|
|
// 401 path here as a basic input-validation smoke; the 400 path requires
|
|
// an admin session which the test fixture doesn't easily produce.
|
|
const res = await fetch(`${BASE}/admin/api/register-client`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: '{}',
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
// =========================================================================
|
|
// F7 + F7b: HTTP MCP shell-job RCE regression
|
|
// =========================================================================
|
|
//
|
|
// The headline trust-boundary fix. Pre-fix, the inlined OperationContext
|
|
// literal in serve-http.ts forgot to set `remote: true`, which meant
|
|
// operations.ts:1391's protected-job-name guard (`if (ctx.remote && ...)`)
|
|
// saw a falsy undefined and skipped. An HTTP MCP caller with a write-scoped
|
|
// token could then submit `{name: "shell", params: {cmd: "id"}}` over /mcp
|
|
// and execute arbitrary commands on the gbrain host.
|
|
//
|
|
// The fix is two-layered:
|
|
// 1) F7 — serve-http.ts sets `remote: true` explicitly.
|
|
// 2) F7b — operations.ts:1391 + :1400 use `ctx.remote !== false` /
|
|
// `ctx.remote === false` so undefined fails closed even if a
|
|
// future transport bypasses the type via cast.
|
|
//
|
|
// Together they close the path even if either layer regresses alone.
|
|
|
|
test('F7: HTTP MCP cannot submit shell jobs (RCE regression)', async () => {
|
|
// v0.28.10: must mint admin scope. submit_job's required scope is
|
|
// 'admin'; without it, hasScope() rejects with insufficient_scope BEFORE
|
|
// the F7 protected-name guard at operations.ts:1527 fires. To validate
|
|
// the actual RCE protection (the protected-name guard), the token has
|
|
// to clear the scope check first.
|
|
const { access_token } = await mintToken('admin');
|
|
const res = await mcpCall(access_token, 'tools/call', {
|
|
name: 'submit_job',
|
|
arguments: { name: 'shell', data: { cmd: 'id' } },
|
|
});
|
|
|
|
const body = await res.text();
|
|
// Must reject. Either HTTP 4xx, or a JSON-RPC envelope carrying an
|
|
// OperationError with code permission_denied. The exact wire shape
|
|
// depends on SDK error mapping — assert the negative invariant
|
|
// (no command executed) and the positive invariant (rejection signal).
|
|
const rejected =
|
|
res.status >= 400 ||
|
|
body.includes('permission_denied') ||
|
|
body.includes('cannot be submitted over MCP');
|
|
expect(rejected).toBe(true);
|
|
|
|
// Negative: response must NOT contain a successful submit_job result
|
|
// (which would surface a job_id field). If a job ID came back the
|
|
// privesc landed.
|
|
expect(body).not.toMatch(/"job_id"\s*:\s*"?\d+/);
|
|
}, 15_000);
|
|
|
|
test('F7: HTTP MCP cannot submit subagent jobs (protected name)', async () => {
|
|
// Same admin-scope requirement as the shell-job sibling test above.
|
|
const { access_token } = await mintToken('admin');
|
|
const res = await mcpCall(access_token, 'tools/call', {
|
|
name: 'submit_job',
|
|
arguments: { name: 'subagent', data: { prompt: 'noop' } },
|
|
});
|
|
const body = await res.text();
|
|
const rejected =
|
|
res.status >= 400 ||
|
|
body.includes('permission_denied') ||
|
|
body.includes('cannot be submitted over MCP');
|
|
expect(rejected).toBe(true);
|
|
expect(body).not.toMatch(/"job_id"\s*:\s*"?\d+/);
|
|
}, 15_000);
|
|
});
|