mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-17 18:32:41 +00:00
Compare commits
4
Commits
v0.46.12.2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afe923693a | ||
|
|
864dec4f19 | ||
|
|
b57bcd8f60 | ||
|
|
d995508731 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gbrain",
|
||||
"version": "0.46.12.2",
|
||||
"version": "0.46.15.0",
|
||||
"description": "Personal knowledge brain for your coding agent — hybrid search, synthesis, graph traversal, and durable cross-session memory over Postgres/PGLite with pgvector, plus a curated brain-first skill set.",
|
||||
"author": {
|
||||
"name": "Garry Tan",
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"GBRAIN_PAGE_WARN_BYTES",
|
||||
"GBRAIN_REMOTE_CLIENT_SECRET",
|
||||
"GBRAIN_RETRIEVAL_REFLEX",
|
||||
"GBRAIN_RETRIEVAL_REFLEX_LEXICAL_ARMS",
|
||||
"GBRAIN_RETRIEVAL_REFLEX_WINDOW_TURNS",
|
||||
"GBRAIN_SOURCE",
|
||||
"GBRAIN_SURFACE",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gbrain",
|
||||
"version": "0.46.12.2",
|
||||
"version": "0.46.15.0",
|
||||
"description": "Personal knowledge brain for your coding agent — hybrid search, synthesis, graph traversal, and durable cross-session memory over Postgres/PGLite with pgvector, plus a curated brain-first skill set.",
|
||||
"author": {
|
||||
"name": "Garry Tan",
|
||||
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
needs: e2e-cache-check
|
||||
if: needs.e2e-cache-check.outputs.hit != 'true' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 30
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
@@ -164,6 +164,16 @@ jobs:
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gbrain_test
|
||||
GBRAIN_TEST_ALLOW_DATABASE_URL: '1'
|
||||
- name: Run multi-agent serve suite
|
||||
# cathedral-6 (T7): multi-agent continuity + isolation over a real
|
||||
# `gbrain serve --http`. Own invocation line (engine-parity
|
||||
# precedent): the file spawns two serves on fixed ports
|
||||
# (19133/19134) and SIGKILLs one mid-hammer — never fold it into the
|
||||
# shared-process tier1 line above. Named files only, no glob.
|
||||
run: bun test --timeout=60000 test/e2e/serve-http-multi-agent.test.ts
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gbrain_test
|
||||
GBRAIN_TEST_ALLOW_DATABASE_URL: '1'
|
||||
|
||||
tier2:
|
||||
name: Tier 2 (LLM Skills)
|
||||
|
||||
@@ -94,9 +94,20 @@ jobs:
|
||||
- uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: bun-cache-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
||||
key: bun-cache-${{ runner.os }}-${{ hashFiles('bun.lock', 'admin/bun.lock') }}
|
||||
restore-keys: bun-cache-${{ runner.os }}-
|
||||
- run: bun install --frozen-lockfile
|
||||
# Supply-chain: build the admin UI FRESH from admin/src so the compiled
|
||||
# binary embeds a bundle a reviewer can trace to source — not the committed
|
||||
# admin/dist bytes. `build:admin` runs `vite build` then regenerates
|
||||
# src/admin-embedded.ts to reference the fresh (content-hashed) output, so
|
||||
# the compile below embeds this build. --frozen-lockfile so the release
|
||||
# bundle isn't built from caret-drifted admin deps (a supply-chain PR must
|
||||
# not itself be non-reproducible).
|
||||
- name: Build admin UI fresh from source
|
||||
run: |
|
||||
cd admin && bun install --frozen-lockfile && cd ..
|
||||
bun run build:admin
|
||||
# No test re-run here: the Test workflow already gated this exact SHA at
|
||||
# merge (10 shards + E2E). Re-running the whole suite serially on the
|
||||
# release runner is a flakier duplicate gate — it blocked the first
|
||||
|
||||
@@ -27,10 +27,27 @@ jobs:
|
||||
image: semgrep/semgrep:1.170.0@sha256:c98f8829eea377274ee4b10656458b078b88232469b2ff913f091c2317347c9d
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
# Non-blocking initially (continue-on-error): the first runs establish a
|
||||
# baseline without failing unrelated PRs. Graduation path: once the
|
||||
# baseline findings are triaged (fixed or `# nosemgrep`'d), remove
|
||||
# continue-on-error so new findings block PRs.
|
||||
- name: Semgrep scan (report-only)
|
||||
run: semgrep scan --config p/default --config p/typescript --error
|
||||
continue-on-error: true
|
||||
with:
|
||||
# Full history so --baseline-commit can diff against the PR base;
|
||||
# a shallow clone would not contain the base commit.
|
||||
fetch-depth: 0
|
||||
# Graduated from advisory: on a PR, fail only on findings NEW since the PR
|
||||
# base (semgrep --baseline-commit), so legacy findings never block an
|
||||
# unrelated PR and no full-tree triage is required. Scheduled/dispatch
|
||||
# runs have no PR base, so they do a full-tree report-only scan.
|
||||
- name: Semgrep scan
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
if [ -n "$BASE_SHA" ]; then
|
||||
# Diff against the MERGE BASE, not the base-branch head captured at
|
||||
# event time: the checkout is the merge ref against current master,
|
||||
# so a finding master landed after the event would otherwise be
|
||||
# attributed to this PR. merge-base is the true common ancestor.
|
||||
BASELINE="$(git merge-base "$BASE_SHA" HEAD || echo "$BASE_SHA")"
|
||||
echo "PR scan — failing only on findings new since $BASELINE"
|
||||
semgrep scan --config p/default --config p/typescript --error --baseline-commit "$BASELINE"
|
||||
else
|
||||
echo "Full-tree scan (schedule/dispatch) — report-only"
|
||||
semgrep scan --config p/default --config p/typescript || true
|
||||
fi
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- gbrain-runbook-stamp: 0.46.12.2 -->
|
||||
<!-- gbrain-runbook-stamp: 0.46.15.0 -->
|
||||
<!-- This stamp must equal the VERSION file at every release; CI enforces it
|
||||
(scripts/check-bootstrap-tag.sh). `gbrain bootstrap status` compares it to
|
||||
the installed binary and warns on skew. -->
|
||||
|
||||
+264
@@ -2,6 +2,270 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.46.15.0] - 2026-08-16
|
||||
|
||||
**The brain now recognizes people the way you actually mention them.**
|
||||
Lowercase first-name mentions ("remind me what alice said") and
|
||||
surname-only references ("Did Galewright follow up?") now resolve to the
|
||||
right page and surface a pointer before your agent answers. On the
|
||||
BrainBench identity suite this took the know-to-ask failure rate from
|
||||
0.15 to 0.00 across all three harnesses, with push precision held at
|
||||
1.0 and zero false fires — and the claude-code benchmark row now
|
||||
exercises the real shipped hook instead of a test contract, so those
|
||||
numbers measure production behavior.
|
||||
|
||||
### Added
|
||||
- **Lowercase + surname recall arms in the retrieval reflex.** A
|
||||
lowercase mention resolves through your documented aliases when the
|
||||
match is unique across every source in play; a surname-only reference
|
||||
resolves when exactly one person page carries that surname. Ambiguity
|
||||
in either arm injects nothing — silence beats a wrong pointer. Kill
|
||||
switch: `retrieval_reflex_lexical_arms: false` in config or
|
||||
`GBRAIN_RETRIEVAL_REFLEX_LEXICAL_ARMS=false` (default on).
|
||||
- **Aliases now count everywhere identity resolves.** Entity-slug
|
||||
resolution (fact writes, recall, trajectory seeds) matches documented
|
||||
aliases exactly before falling back to fuzzy matching, and wikilink
|
||||
inference recognizes alias mentions in page bodies — both verified
|
||||
against live pages so a stale alias can never point at a deleted page.
|
||||
- **Concept-shaped queries get concept-shaped ranking.** Definitional
|
||||
paraphrases ("what is the ownership economy?") are classified as a new
|
||||
`concept` intent and ranked vector-lean, so keyword-decoy pages stop
|
||||
outranking the page that actually explains the idea. Entity lookups
|
||||
keep their existing ranking — a proper noun in the query routes as
|
||||
before.
|
||||
- **`--explain` now prints each result's real cosine similarity** next
|
||||
to its blended score.
|
||||
|
||||
### Fixed
|
||||
- **Evidence labels are grounded in real vector similarity.** A result
|
||||
is labeled `high_vector_match` only when its actual cosine similarity
|
||||
clears the floor (config: `search.evidence_cosine_floor`, default
|
||||
0.80) — previously a keyword-heavy blended score could earn the label
|
||||
with no semantic support. Keyless runs degrade to honest
|
||||
keyword-based labels.
|
||||
- **A single dense page can no longer starve vector search.** When one
|
||||
page's chunks fill the candidate pool, the engines escalate the pool
|
||||
(bounded by the vector index's hard ceiling) until the page count is
|
||||
honest; genuine exhaustion is reported in search metadata instead of
|
||||
silently returning a short page.
|
||||
- **Near-duplicate filtering no longer deletes other pages' results.**
|
||||
The text-similarity dedup now only collapses chunks within the same
|
||||
page, so two legitimately similar pages both survive.
|
||||
- **Weak-confidence result lists no longer collapse to one result.**
|
||||
Autocut skips score-cliff trimming entirely when the top score is
|
||||
below a floor (config: `search.autocut_min_top`, default 0.35) —
|
||||
low-confidence lists return the full cluster for you to judge.
|
||||
|
||||
### Changed
|
||||
- **BrainBench's claude-code row measures the shipped hook.** The
|
||||
adapter drives the production `user-prompt` hook end-to-end (real
|
||||
transcript parsing, real IPC resolve path, real injection budget)
|
||||
instead of a harness-shaped contract, and the suite's pre-registered
|
||||
quality floors are now an executable test — a baseline update can no
|
||||
longer bank a threshold violation.
|
||||
- The query cache key version advanced (new ranking knobs participate),
|
||||
so the first re-run of a cached query after upgrade is a one-time
|
||||
cache miss and repopulates automatically.
|
||||
|
||||
To take advantage of v0.46.15.0:
|
||||
- `gbrain upgrade` (or rebuild the binary). No migration required; the
|
||||
new recall arms and ranking are on by default.
|
||||
- Expect a one-time query-cache miss spike on first queries after
|
||||
upgrade (cache key version bump); the cache rewarms itself.
|
||||
- If you need to compare against pre-wave identity behavior, set
|
||||
`GBRAIN_RETRIEVAL_REFLEX_LEXICAL_ARMS=false` — no redeploy needed.
|
||||
- Add aliases to your people pages (`gbrain alias`) to widen what the
|
||||
lowercase arm can catch; it only fires on documented, globally unique
|
||||
aliases.
|
||||
|
||||
## [0.46.14.0] - 2026-08-16
|
||||
|
||||
Fix wave: 13 verified issues fixed + 14 community PRs adopted with credit, from a
|
||||
triage of everything filed since the 2026-08-14 audit (42 new items, each verified
|
||||
against HEAD with an adversarial second pass before entering scope).
|
||||
|
||||
### Fixed
|
||||
- **dream/cycle:** calibration-trio phases (propose_takes, grade_takes,
|
||||
calibration_profile) now clamp their deadlines to the owning job's claim-time
|
||||
timeout via shared `BasePhaseOpts.deadlineAtMs`, so long cycles bank partial
|
||||
work and exit cleanly instead of dead-lettering at the worker kill switch (#4168).
|
||||
extract_atoms distinguishes malformed model output from a real zero-yield
|
||||
extraction (typed parse outcomes), writes atoms with a completion receipt so a
|
||||
partial persist is retried instead of silently skipped forever, and tombstones a
|
||||
page only after 3 consecutive same-content deterministic failures (#4148). The
|
||||
nightly purge survives a RESTRICT-FK-held source (revoked oauth client) with a
|
||||
structured `{purged, blocked}` report instead of aborting the whole sweep (#4115).
|
||||
Dream `--input` on an already-synthesized transcript says why it skipped
|
||||
(PR #4122 by @Masashi-Ono0611). The cycle lock-steal decision keys on the aborted
|
||||
flag, not the droppable abort reason (#4140, PR #4141 by @Masashi-Ono0611).
|
||||
- **takes:** `eval suspected-contradictions` resolution commands are now
|
||||
addressable and truthful — `--row` carries the per-page row number, commands
|
||||
that need operator judgment say so instead of failing, and the unimplemented
|
||||
mark-debate action is no longer minted (#4169).
|
||||
- **minions/claude-cli:** multi-turn claude-cli subagent jobs no longer
|
||||
dead-letter when the provider reuses a tool_use_id — execution rows key on
|
||||
(message_idx, tool_use_id) with migration v131 (#4155, PR #4156 by
|
||||
@Masashi-Ono0611). claude-cli reports cachedInputTokens (PR #4120) and scrubs
|
||||
ALL cloud-auth routing env vars so children always use the subscription auth the
|
||||
recipe documents — intentional cloud routing belongs on the `anthropic` recipe
|
||||
(PR #4111, both by @Masashi-Ono0611). `models doctor` honors slow-start
|
||||
providers instead of a flat 5s probe abort (PR #4112 by @Masashi-Ono0611).
|
||||
- **recipes:** Google `supports_prompt_cache` is a per-model predicate — Gemini
|
||||
2.5+ caches implicitly and no longer reads as cache-less (#4158, PR #4159 by
|
||||
@dovstern). OpenRouter embedding models carry verified per-model dims and
|
||||
unlisted ids require explicit dims instead of inheriting a plausible-wrong 1536
|
||||
(#4114). Qwen embedding ids match case-insensitively so correctly-cased provider
|
||||
ids get their dimensions pinned (#4123). Recipe-declared thinking-by-default
|
||||
models (DeepSeek v4) get reasoning-token headroom in `think` via the capability
|
||||
layer (reimplements stale-fork PR #4172; thanks @Tonyli1010).
|
||||
- **doctor/health:** `get_health`'s islanded check applies endpoint liveness in
|
||||
both directions so it agrees with `gbrain orphans` (#4153), and entity coverage
|
||||
ratios report "too few to grade" below a small-N floor instead of a misleading
|
||||
hard 0%/100% (#4147, also closing the #3945 class). JSON/MCP consumers:
|
||||
`link_coverage` and `timeline_coverage` are now `number | null` — `null` means
|
||||
"too few entity pages to grade" — and the payload adds `entity_page_count` so
|
||||
you can render the floor yourself.
|
||||
- **transcripts:** sparse multiline sessions parse (PR #4163 by @richtheworld);
|
||||
`--max-bytes` gives oversized stores a validated escape hatch while per-format
|
||||
safety defaults stay in charge, with the cap folded into the `--since last`
|
||||
checkpoint fingerprint (#4149; thanks @justemu).
|
||||
- **search/budget:** query-expansion LLM spend records to the budget tracker and
|
||||
audit (#4121, PR #4124 by @Masashi-Ono0611).
|
||||
- **serve --http:** no-grant legacy bearer tokens get #3242's federated read
|
||||
parity via a shared, fail-closed widening decision (PR #4132 by @kyle944).
|
||||
Behavior change: SDK-transport sessions authenticated with such a token now
|
||||
see the same federated read scope as the HTTP dispatch path — reads that
|
||||
previously came back empty on one transport are consistent on both.
|
||||
- **Windows:** path containment uses the OS separator (PR #4103 by
|
||||
@MohammedAlkindi, with CI-runnable win32 shape tests), and sync accepts Windows
|
||||
path casing + indexes .astro/.svelte files (#4044, PR #4144 by @javieraldape).
|
||||
- **facts:** the conversation-type allowlist derives from one frozen module
|
||||
instead of five hand-copied lists (PR #4135 by @Masashi-Ono0611).
|
||||
- **cli:** `sources --help` shows real usage instead of the circular stub
|
||||
(PR #4133 by @Masashi-Ono0611).
|
||||
|
||||
### To take advantage of v0.46.14.0
|
||||
- `gbrain upgrade` picks everything up; migration v131 runs automatically.
|
||||
Multi-worker Postgres deployments: stop running `gbrain jobs work` daemons
|
||||
BEFORE upgrading and restart them on the new binary — an old binary writing
|
||||
tool executions against a migrated database errors on every persist until
|
||||
restarted. Single-binary PGLite installs need nothing.
|
||||
- If claude-cli subagent jobs previously dead-lettered on
|
||||
`uniq_subagent_tools_use_id`, re-run them — the class is fixed.
|
||||
- Hermes stores over the default cap: `gbrain transcripts ingest --max-bytes 4gb <store>`.
|
||||
- If you intentionally route claude-cli through a cloud backend, switch that
|
||||
workload to the `anthropic` recipe with cloud credentials — claude-cli children
|
||||
now always use subscription auth.
|
||||
## [0.46.13.0] - 2026-08-16
|
||||
|
||||
**One brain can now safely serve many agents.** `gbrain agent register` mints
|
||||
a scoped OAuth client and a working access token in one command and prints the
|
||||
exact wiring for your harness — a daily-driver agent, a coding agent, and a
|
||||
teammate's agent all share the same institutional memory, each seeing only
|
||||
what its token grants. This is the shared-brain wave (cathedral 6): the
|
||||
multi-user core the brain always had, finally packaged for multiple agents.
|
||||
|
||||
### Added
|
||||
- `gbrain agent register <name> --harness claude-code|codex|opencode|openclaw`
|
||||
— mints a scoped OAuth client, writes a real 30-day token TTL (the server
|
||||
default is one hour — a printed "long-lived" config used to die silently),
|
||||
and prints a paste-ready block per harness. `--json` is a stable machine
|
||||
contract (`schema_version: 1`) with credential redaction unless
|
||||
`--show-token`; typed failure envelopes for every refusal.
|
||||
- Presets that stay honest to the scoping model: `daily-driver` (read-broad
|
||||
via a registration-time snapshot of your sources — other agents' workspace
|
||||
scratch sources excluded, grant one explicitly to share it — starter tool
|
||||
surface) and `coding-agent` (write-isolated `<name>-workspace` source,
|
||||
requires the project sources it may read). Explicit flags always win;
|
||||
neither preset can grant operator scopes. Surface tiers land through the
|
||||
audited operator path.
|
||||
- `--reissue <client-id>` rotates a client secret and reprints the wiring —
|
||||
outstanding tokens stay valid until expiry, and the output says so.
|
||||
- Cross-agent memory continuity: `recall` now honors federated read grants,
|
||||
so a fact one agent saved in a shared source is recallable by every agent
|
||||
granted that source (world-visible facts only; private stays local).
|
||||
- The company-brain tutorial gains a "many agents, one brain" recipe, and
|
||||
`docs/guides/agent-to-gbrain.md` carries the single decision table for the
|
||||
four onboarding paths. `gbrain auth clients` now shows each client's write
|
||||
source and federated reads; a new doctor check surfaces dangling read
|
||||
grants and orphaned empty workspace sources (a workspace holding only
|
||||
facts counts as data, never orphaned).
|
||||
- `gbrain auth register-client --token-ttl <seconds>` for per-client token
|
||||
lifetimes from the CLI.
|
||||
|
||||
### Changed
|
||||
- Registering on a thin client or against a live PGLite serve is refused
|
||||
up front with exact guidance (previously: dead credentials in a scratch
|
||||
brain, or a silent 30-second lock hang). Registration also probes the
|
||||
target serve and refuses one too old to enforce the token's scope grant —
|
||||
upgrade the serve, or pass `--allow-old-serve` to accept the risk (an
|
||||
unreachable serve stays a warning).
|
||||
- The admin register API validates source existence, archived state, and
|
||||
token TTL bounds with structured 400s, refuses duplicate client names
|
||||
(409), and composes the same registration core as the CLI — atomically,
|
||||
under the same name lock — so the two paths can never drift.
|
||||
- Deleting or purging a source that an OAuth client still references is
|
||||
refused with the exact revoke commands — including clients that were
|
||||
revoked-but-retained, which still block deletion at the database level.
|
||||
Recurring maintenance now skips such sources instead of aborting entirely,
|
||||
and a source's git scaffolding is torn down only after the deletion
|
||||
commits, so a refused delete leaves it fully intact.
|
||||
- Multi-agent write throughput: same-slug writes in different sources no
|
||||
longer serialize on each other (source-scoped advisory locks, with an
|
||||
eleven-site audit documenting every intentionally-global lock). Restart
|
||||
your serve and upgrade CLIs together when picking this up.
|
||||
- Brains that predate the scoped-client schema are refused at registration
|
||||
with a one-line migration command instead of failing mid-transaction.
|
||||
|
||||
### Fixed
|
||||
- `recall --supersessions` now applies the same world-only visibility filter
|
||||
as every other remote read arm, and federated recall merges each arm on its
|
||||
own semantic timestamp.
|
||||
- `gbrain agent run -- --help` submits the literal prompt instead of printing
|
||||
help; `gbrain agent … --help` answers on a machine with no brain configured.
|
||||
|
||||
### Infrastructure
|
||||
- A 12-case end-to-end suite proves continuity, isolation, write-concurrency,
|
||||
chaos-kill integrity, and secret rotation over a real HTTP serve with real
|
||||
tokens, wired as its own CI step. BrainBench gains the first fixture
|
||||
exercising the leak detector's non-default active-source arm. 100+ new
|
||||
unit tests pin the registration contract byte-for-byte.
|
||||
|
||||
To take advantage of v0.46.13.0: on the brain host, run
|
||||
`gbrain agent register <name> --harness <your-harness> --preset coding-agent
|
||||
--federated-read <project-sources> --url <your-serve-url>` and paste the
|
||||
printed block into your agent. Existing setups keep working unchanged; if
|
||||
`gbrain doctor` flags dangling read grants or orphaned workspace sources, the
|
||||
message names the exact fix.
|
||||
## [0.46.12.3] - 2026-08-16
|
||||
|
||||
**Supply-chain hardening for how gbrain updates and how community code lands.**
|
||||
A security pass over the update path, the release build, and the contribution
|
||||
workflow. Nothing here fixes an active exposure; it raises the floor so a
|
||||
future compromised release channel or a slipped contribution can't turn into a
|
||||
silent problem.
|
||||
|
||||
### Added
|
||||
- `gbrain upgrade` (compiled-binary self-update) now confirms the download's
|
||||
integrity before it installs anything. It checks the downloaded binary against
|
||||
the build-provenance attestation GitHub publishes for each release, and confirms
|
||||
the binary really is the release it was fetched for. If the check can't be
|
||||
satisfied, the update is refused and your existing binary is left untouched.
|
||||
- `wave-security-scan` (`bun run wave-security-scan <base>..<head>`): a repeatable
|
||||
security sweep for reviewing batches of community contributions before they
|
||||
ship. It surfaces newly introduced obfuscation, secrets (scanned without the
|
||||
usual test/skills exclusions), and changes to the bundled admin UI, with
|
||||
everything else as context.
|
||||
|
||||
### Changed
|
||||
- Release binaries now build the admin UI fresh from source at release time, so
|
||||
the shipped bundle always corresponds to reviewable source.
|
||||
- Static analysis (Semgrep) now blocks a pull request on issues that PR
|
||||
introduces, while never blocking on pre-existing findings.
|
||||
- `SECURITY.md` documents which install paths verify update integrity and which
|
||||
remain trust-on-first-use, and `docs/RELEASING.md` adds a security-review step
|
||||
to the community-contribution process.
|
||||
|
||||
## [0.46.12.2] - 2026-08-16
|
||||
|
||||
**Your agent can now do over MCP what it could only do from the CLI.** An
|
||||
|
||||
@@ -710,7 +710,11 @@ Before any ship, read **[docs/RELEASING.md](docs/RELEASING.md)** in full. It car
|
||||
full release + contributor process: pre-ship test requirements (`bun run ci:local` / the
|
||||
E2E lifecycle), the CHANGELOG voice + release-summary template, the "To take advantage of
|
||||
vX" self-repair block, version migrations, the GitHub Actions SHA refresh, PR conventions,
|
||||
and the community-PR-wave process. **Use `/ship` — never hand-roll a release.**
|
||||
and the community-PR-wave process. **Use `/ship` — never hand-roll a release.** Every
|
||||
community wave runs `bun run wave-security-scan <base>..<head>` (RELEASING.md step 5) before
|
||||
ship — the repeatable mechanical sweep (obfuscation/eval, gitleaks with the test/skills
|
||||
allowlist stripped, committed `admin/dist` changes as alarms; new endpoints/spawns/env/deps
|
||||
as context).
|
||||
|
||||
The ship-critical IRON RULES stay inline in this file (do NOT relocate them): the
|
||||
Version-locations table above (the 5-file sync + the 3-line VERSION/package.json/CHANGELOG
|
||||
|
||||
+6
-4
@@ -193,10 +193,12 @@ narrower mappings via `scripts/e2e-test-map.ts`.
|
||||
### PR-side security checks
|
||||
|
||||
Besides the test gate, PRs may trigger three security workflows: Semgrep CE
|
||||
SAST (every PR — **advisory/non-blocking** while the baseline is tuned, so a
|
||||
Semgrep finding won't fail your PR), OSV-Scanner (only when `package.json` or
|
||||
`bun.lock` change), and actionlint (only when `.github/workflows/**` change).
|
||||
See `SECURITY.md` → "Automated security scanning" for details.
|
||||
SAST (every PR — **blocking for findings new since the PR base**, so a net-new
|
||||
issue fails the check while pre-existing findings never block an unrelated PR;
|
||||
scheduled/dispatch runs do a full-tree report-only scan), OSV-Scanner (only when
|
||||
`package.json` or `bun.lock` change), and actionlint (only when
|
||||
`.github/workflows/**` change). See `SECURITY.md` → "Automated security
|
||||
scanning" for details.
|
||||
|
||||
## Building
|
||||
|
||||
|
||||
@@ -149,6 +149,8 @@ gbrain connect https://your-host/mcp --token gbrain_xxx --install
|
||||
gbrain connect https://your-host/mcp --token gbrain_xxx --agent codex --install # Codex
|
||||
```
|
||||
|
||||
Onboarding a whole agent harness onto a shared brain? On the brain host, `gbrain agent register <name> --harness claude-code` mints a scoped OAuth client plus a 30-day token and prints the paste-ready wiring block — presets for daily-driver and write-isolated coding agents. The [onboarding decision table](docs/guides/agent-to-gbrain.md#onboarding-paths--the-decision-table) says which path fits.
|
||||
|
||||
**Brain-only install into another coding agent** (Cursor, Claude Cowork, or anything that can fetch a URL and run shell commands) — paste the OpenClaw/Hermes block above (`INSTALL_FOR_AGENTS.md`); it installs the brain, skills, and dream cycle without the personal-agent identity layer. Tested with Codex, Claude Code, Claude Cowork, Cursor, and AlphaClaw.
|
||||
|
||||
**[→ Full walkthrough: give your coding agent a memory](docs/tutorials/connect-coding-agent.md)** — the memory-only paths end to end, plus the brain-first protocol you paste into `CLAUDE.md` / `AGENTS.md` and the four habits that make it actually change how you work.
|
||||
@@ -248,6 +250,7 @@ re-runs are free — unchanged sessions skip on content hash:
|
||||
gbrain transcripts ingest # discover importable session logs
|
||||
gbrain transcripts ingest --all # import everything discovered
|
||||
gbrain transcripts ingest ~/Downloads/conversations.json # consumer export (unzip first)
|
||||
gbrain transcripts ingest --max-bytes 4gb <store> # oversized store; omit to keep per-format caps
|
||||
gbrain transcripts status # found vs imported, per harness
|
||||
```
|
||||
|
||||
@@ -495,7 +498,7 @@ the page PK, soft-delete-filtered, source-safe) and completes in seconds.
|
||||
- [`AGENTS.md`](AGENTS.md) — entry point for non-Claude agents
|
||||
- [`CLAUDE.md`](CLAUDE.md) — entry point for Claude Code (deep operating context)
|
||||
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — contributor guide, test discipline, eval-capture mode
|
||||
- [`SECURITY.md`](SECURITY.md) — OAuth threat model, hardening defaults
|
||||
- [`SECURITY.md`](SECURITY.md) — install-path trust model, self-update integrity, automated scanning, OAuth threat model, hardening defaults
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
+32
-5
@@ -16,13 +16,16 @@ CI runs three automated security checks alongside secret scanning (Gitleaks):
|
||||
(`.github/workflows/osv-scanner.yml`) runs weekly and on any PR that touches
|
||||
`package.json` or `bun.lock`.
|
||||
- **Static analysis (SAST)** — Semgrep CE (`.github/workflows/semgrep.yml`)
|
||||
runs on every PR and weekly. It is currently **advisory (non-blocking)**
|
||||
while the finding baseline is tuned; the graduation path to a blocking check
|
||||
is documented in the workflow file.
|
||||
runs on every PR and weekly. On a PR it is **blocking for findings new since
|
||||
the PR base** (`--baseline-commit`), so a net-new issue fails the check while
|
||||
pre-existing findings never block an unrelated PR. Scheduled/dispatch runs do
|
||||
a full-tree report-only scan.
|
||||
- **Release binary provenance** — release builds
|
||||
(`.github/workflows/release.yml`) attest each compiled binary with
|
||||
[GitHub artifact attestations](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations).
|
||||
Verify a downloaded release binary with:
|
||||
[GitHub artifact attestations](https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations),
|
||||
and build the admin UI fresh from `admin/src` at release time so the shipped
|
||||
binary embeds a bundle traceable to source (not committed `admin/dist` bytes).
|
||||
Verify a downloaded release binary manually with:
|
||||
|
||||
```bash
|
||||
gh attestation verify ./gbrain-darwin-arm64 -R garrytan/gbrain
|
||||
@@ -32,6 +35,30 @@ CI runs three automated security checks alongside secret scanning (Gitleaks):
|
||||
All security workflows use SHA-pinned actions and least-privilege permissions,
|
||||
enforced structurally by actionlint on every workflow change.
|
||||
|
||||
### Install-path trust model
|
||||
|
||||
- **Compiled-binary self-update (`gbrain upgrade` on `darwin-arm64` /
|
||||
`linux-x64`)** verifies integrity automatically before it installs: it
|
||||
computes the downloaded binary's SHA-256 and checks it against the build
|
||||
provenance attestation fetched from the GitHub REST API — a different origin
|
||||
than the asset CDN — confirming both the attested digest and that the
|
||||
attestation's builder id is this repo's release workflow. Verification is
|
||||
fail-closed: on a mismatch or an unfetchable attestation, the download is
|
||||
discarded and the running binary is left untouched. It also refuses a binary
|
||||
whose reported version doesn't match the release it was fetched for (a
|
||||
downgrade-replay guard). The dependency-free check is GitHub-account trust
|
||||
plus origin separation and a digest/identity match against the attestation
|
||||
fetched over TLS; it does NOT independently verify the attestation's Sigstore
|
||||
signature (the Fulcio certificate chain or Rekor inclusion).
|
||||
- **From-source and pinned-tag installs remain trust-on-first-use.**
|
||||
`bun install -g github:garrytan/gbrain#latest-stable` follows a force-moved
|
||||
tag, and the `codex-plugin` branch / template repo are force-published; these
|
||||
paths trust TLS + GitHub without an independent integrity check. From-source
|
||||
installs also serve the committed `admin/dist` bundle (devDeps for a fresh
|
||||
admin build are not installed by a global install), so that bundle is
|
||||
trust-on-first-use on this path. For the strongest guarantee, install the
|
||||
attested release binary and run `gh attestation verify` as above.
|
||||
|
||||
## Remote MCP Security
|
||||
|
||||
### Keep dynamic client registration disabled unless explicitly needed
|
||||
|
||||
@@ -1,5 +1,311 @@
|
||||
# TODOS
|
||||
|
||||
## v0.46.15.0 identity/retrieval wave follow-ups (filed at ship; decisions recorded at CEO review + outside voice)
|
||||
|
||||
- [ ] **P2 — Codex adapter full production flip.** v0.46.15 integrated the REAL rollout
|
||||
parser (`src/core/transcripts/codex.ts`) for turn selection, but fragment DELIVERY
|
||||
remains a harness-shaped contract (no shipped codex injection path exists yet). When
|
||||
one lands, flip the seam like the claude-code row (run-scoped infra via
|
||||
setupRun/teardownRun; bank the baseline in the same commit). Context: outside-voice F5.
|
||||
- [ ] **P2 — Per-model calibration for `search.evidence_cosine_floor` (0.80) and
|
||||
`search.autocut_min_top` (0.35).** Both are provider-scale-dependent; both are
|
||||
config-overridable today. The September reranker default flip (zerank-2 →
|
||||
voyage:rerank-2.5) MUST re-tune autocut_min_top — add that line to the v0.47
|
||||
removal checklist when executing it. Context: outside-voice F16. Ship-review
|
||||
addendum (F6): the floor is not purely a label — `create_safety` consumes the
|
||||
evidence tier and gates duplicate-page creation, so a floor that never fires on
|
||||
a low-cosine-scale embedder degrades `exists`→`probable` and loosens the
|
||||
don't-create-a-duplicate contract. Calibrate BEFORE the September embedder
|
||||
default flip, and include a per-model floor table, not one global number.
|
||||
- [ ] **P2 — Cat 3 undocumented-alias enrichment.** The gbrain-evals Cat 3 runner's
|
||||
undocumented class (initials, nicknames, typos) needs alias-TABLE growth
|
||||
(enrichment writes page_aliases), not resolver changes — the v0.46.15 alias_exact
|
||||
arm only helps documented aliases. Pair with the evals-repo runner repair
|
||||
(seed page_aliases + route through resolveEntitySlug). Context: outside-voice F1.
|
||||
- [ ] **P3 — Lowercase bigram alias candidates.** v2 of the weak-candidate pass
|
||||
(`entity-salience.ts`): "sable finch" as a two-token weak alias probe. Unigram
|
||||
covers the alias-table convention today; bigram needs its own ambiguity study.
|
||||
- [ ] **P3 — Precomputed name-token index at ingest.** The surname arm's
|
||||
`lower(title) LIKE '% <token>'` scan is bounded by the reflex fail-open budgets;
|
||||
if reflex latency telemetry creeps on 10K+-page brains, build the token table
|
||||
and swap the arm to an indexed lookup.
|
||||
- [ ] **P3 — Re-eval community #717 (graph-hop wikilink rerank, claimed +2.6/+2.8
|
||||
P@5/R@5) against the post-v0.46.15 ranker** — the concept intent + dedup scope fix
|
||||
may have absorbed part of its headroom.
|
||||
- [ ] **P2 — #1663 remainder (issue REOPENED at ship): query-shape routing,
|
||||
structural exact-lookup tier, CRAG confidence escalation.** The issue was closed
|
||||
with these three unbuilt; the wave shipped the adjacent pieces (concept intent,
|
||||
autocut weak-top floor, evidence-on-cosine) but deliberately deferred these.
|
||||
- [ ] **P3 — Positive underfill-event coverage for searchVector escalation.** The
|
||||
two NEGATIVE paths are pinned (no event on genuine short corpus / offset past
|
||||
end); the positive fire-at-cap assertion needs a >1000-chunk fixture that pushes
|
||||
`innerLimit` to `HNSW_EF_SEARCH_MAX` with the pre-DISTINCT pull full. Pair with
|
||||
a >400-chunk second-escalation engine-parity case (both current fixtures stop at
|
||||
one escalation). Also cover the exact-scan lane (ship-review): a >2000-dim
|
||||
vector column (no HNSW) must keep deep offsets working — the cap now keys on
|
||||
`hnswIndexExpected`, pinned only by inspection. From the ship coverage audit
|
||||
(C5/T7 partials).
|
||||
- [ ] **P2 — Reflex IPC version skew: weak candidates against an old `gbrain serve`.**
|
||||
An upgraded hook client emits `weak: true` candidates; a not-yet-restarted older
|
||||
serve ignores the unknown field and runs lowercase words through ALL arms
|
||||
(title/slug-suffix), fabricating pointers during the upgrade window (ship-review
|
||||
F4). Options: protocol version tag on ResolveRequest with client-side weak-strip
|
||||
when the server doesn't ack; or an upgrade-flow serve restart requirement made
|
||||
explicit. Exposure ends at serve restart; kill switch (`GBRAIN_RETRIEVAL_REFLEX_
|
||||
LEXICAL_ARMS=false` on the client) also closes it since the client then sends no
|
||||
weak candidates.
|
||||
- [ ] **P3 — Shared wall-clock budget across searchVector escalation attempts.**
|
||||
Each escalation retry gets a FRESH 8s statement_timeout on Postgres (worst ~32s
|
||||
per vector arm; multiplied under tokenmax multi-query expansion). Share one
|
||||
deadline across the loop's attempts (ship-review F8). The loop only fires on
|
||||
dense-wall shapes, and per-op timeouts bound the blast radius — hence P3.
|
||||
- [ ] **P1 — Cat 13 conceptual recall: the concept tilt is NOT enough; the fusion
|
||||
itself is the suspect.** Pre-merge receipt (v0.46.15, voyage-4/1024 space, 500
|
||||
seeded probes, all adapters on the SAME gateway): bare vector 49.5 nDCG@5,
|
||||
grep-only 46.2, vector+grep RRF fusion 40.5, gbrain hybrid 35.6 — and a master
|
||||
A/B at the merge-base scored gbrain BYTE-IDENTICAL (35.6, every template), so the
|
||||
wave neither regressed nor improved Cat 13. Two honest findings: (a) the
|
||||
pre-registered "hybrid ≥ bare vector" target is NOT met — the ±10-20% RRF-k
|
||||
concept tilt provably works on a discriminating corpus
|
||||
(test/search/concept-weights.test.ts) but is a wash on this probe mix; (b)
|
||||
FUSION ITSELF loses to its own best single arm here (40.5 < 46.2 < 49.5) — the
|
||||
keyword arm's noise on paraphrase probes drags the merge below either component.
|
||||
Next: instrument per-arm rank contributions on the Cat 13 losers
|
||||
(synonym 38.7 vs vector 66.4 is the widest), then evaluate arm-confidence-
|
||||
weighted fusion (down-weight keyword when its top score is weak) rather than a
|
||||
bigger static tilt. Ship with the evals-repo PR (the three uncommitted gateway-
|
||||
config patches in gbrain-evals are part of it). Also note: the recorded 47.0-vs-
|
||||
49.1 OpenAI-space numbers cannot be reproduced keylessly; the voyage-space gap
|
||||
is WIDER — stronger embedders make hybrid's keyword noise relatively costlier.
|
||||
|
||||
## LongMemEval temporal gap — date-proximity signal SPIKE-REJECTED (filed v0.46.15.0, identity/retrieval wave)
|
||||
|
||||
- **P2 — Reframe the temporal-reasoning gap (94.7% vs MemPal 96.2%, the only categorical
|
||||
public-benchmark loss) around what the questions actually are.** The v0.46.15 wave
|
||||
pre-registered a spike gate before building a date-proximity ranking term
|
||||
(`COALESCE(effective_date, updated_at)` proximity to query-text-extracted since/until
|
||||
bounds, per the outside-voice-amended plan). The spike FIRED the stop condition:
|
||||
a 12-question sample of the 133 `temporal-reasoning` questions in `longmemeval_s`
|
||||
contained ZERO extractable absolute bounds — they are duration-arithmetic
|
||||
("How many days passed between X and Y?", "how many weeks ago did I …") and
|
||||
pairwise-ordering ("which happened first …") questions. A scalar date-proximity
|
||||
boost fires on none of them; retrieval for these is EVENT-DESCRIPTION recall
|
||||
(find the sessions naming the events), and the date math belongs to the answer
|
||||
layer — which is what the existing `findTrajectory` routing already does.
|
||||
Next honest hypotheses, in order: (a) measure per-question retrieval recall on the
|
||||
temporal slice to locate WHERE the 1.5pt is lost (retrieval vs trajectory coverage
|
||||
vs answer extraction); (b) if retrieval: event-phrase recall (the event descriptions
|
||||
are long noun phrases — expansion/paraphrase territory, adjacent to the v0.46.15
|
||||
concept lane); (c) if trajectory: widen `extractCandidateEntities` coverage on
|
||||
event-shaped (non-person) anchors. Do NOT rebuild the date-proximity boost without
|
||||
new evidence — this entry is the receipt for why it doesn't exist.
|
||||
|
||||
|
||||
|
||||
## chennai fix-wave follow-ups (filed 2026-08-16)
|
||||
|
||||
- [ ] **P1 — read_latency_under_sync hangs from 6a905a1e (#4143); 6a905a1e's
|
||||
read-path hunks are the revert candidate.** **What:** phase B of
|
||||
`tests/heavy/read_latency_under_sync.sh` never returns; the workload's own
|
||||
600s timeout kills it (exit 124). **Investigation so far (chennai wave,
|
||||
timeboxed):** reproduced 2/2 on darwin at wave HEAD with default params
|
||||
(500/200/4); a stderr-instrumented copy of the SAME workload at the SAME
|
||||
params passes cleanly (writers finish by query ~11), and small params
|
||||
(50/20/4) pass — the per-iteration stderr writes act as load-bearing yield
|
||||
points, consistent with the repo's known Bun timers-phase starvation class
|
||||
(cf. GBRAIN_SYNC_YIELD_EVERY: `setTimeout(0)`, NOT `setImmediate` — "Bun
|
||||
starves the timers phase under a tight loop"). Suspect surface: #4096's
|
||||
hybrid.ts read-path rework (embedQueryBounded's AbortSignal.timeout pairs +
|
||||
query-cache/mode changes) turning phase B into a microtask-dominated spin
|
||||
that starves timers. Reporter's Linux bisect (100% reproducible, first bad
|
||||
6a905a1e) is in #4143. **Next:** either root-cause the starvation (try a
|
||||
setTimeout(0) yield in the phase-B loop to confirm the class, then find
|
||||
which #4096 await lost its macrotask boundary) or revert 6a905a1e's
|
||||
hybrid.ts hunks and re-run the lane. Harness hardening also owed: count
|
||||
swallowed query errors (all-fail should not read as latency data), bound
|
||||
the `Promise.allSettled(writers)` wait, per #4143's own notes. **Also:**
|
||||
the Heavy Tests lane comes back `skipped` on in-repo branches, so this
|
||||
gates nothing upstream — fix the lane gating or this class stays invisible.
|
||||
**Effort:** M. **Priority:** P1.
|
||||
|
||||
- [ ] **P2 — Cache-MODE enum: implicit vs Anthropic-explicit prompt caching.**
|
||||
**What:** replace `supports_prompt_cache`'s boolean/predicate with a mode
|
||||
(`explicit-anthropic` | `implicit` | `none`) so the gateway's cache-marker
|
||||
injection is driven by MODE, not by "caching exists". **Why:** the Google
|
||||
predicate fix (#4158) is functionally safe today only because anthropic-
|
||||
namespaced providerOptions are ignored on native-google — a transport-level
|
||||
pin test (`recipe-google-prompt-cache.test.ts`) guards that; the semantic
|
||||
conflation stays until modes exist. **Context:** cross-model review finding
|
||||
on PR #4159; the pin test names this TODO. **Effort:** M (CC: S). **P2.**
|
||||
- [ ] **P2 — Abort-signal threading through BasePhaseOpts + dream generators.**
|
||||
**What:** thread an AbortSignal from the job deadline into every calibration
|
||||
phase's LLM calls so an in-flight hung request is CANCELLED, not just
|
||||
observed at the next loop boundary. **Why:** #4168's clamp restores the
|
||||
clean partial-exit but a wedged provider call still burns the reserve.
|
||||
**Context:** adjacent to banked PR #4077 (cooperative abort through
|
||||
synthesis) — the same seam should serve both. **Effort:** M. **P2.**
|
||||
- [ ] **P2 — transcripts parser: surface out-of-set speaker headings (#4136).**
|
||||
**What:** optional ParseResult field (`suspect_heading_labels` + count) when
|
||||
a heading-only anchor-shaped continuation line with an out-of-set label is
|
||||
folded under a heading-anchored multi_line pattern; extract-conversation-
|
||||
facts warns. **Why:** silent speaker misattribution is accepted parse today.
|
||||
**Context:** reporter offered the PR (green-lit in the issue thread with the
|
||||
three-label reproducer as tests); keep phase `regex_match`; a decline
|
||||
threshold is a follow-on decision. **Effort:** M. **P2.**
|
||||
- [ ] **P2 — skillopt field-report items (#4119, all verified at HEAD).**
|
||||
**What:** (a) in-loop runtime-deadline check (orchestrator.ts:440 is
|
||||
step-granular); (b) output-size-aware cost estimate (preflight.ts fixed
|
||||
800-token constant); (c) validation-gate n-gram overlap detector vs judge
|
||||
definitions; (d) stronger bootstrap judges; (e) opt-in `--hermetic-config`
|
||||
(CLAUDE_CONFIG_DIR) for claude-cli children — default-on needs its own
|
||||
security decision (the provider deliberately rides the operator's ~/.claude
|
||||
OAuth session); (f) docs: rule judges as a gameable optimizer target, D13
|
||||
limitation, cap sizing, human review of proposed.md is load-bearing.
|
||||
**Context:** issue thread carries the full analysis; CLAUDE_CONFIG_DIR is
|
||||
the documented interim mitigation. **Effort:** M spread. **P2.**
|
||||
- [ ] **P3 — orphans.exclude_domains (feature, #4157).** Third exclusion axis
|
||||
on the shared orphan policy, matched on the derived domain; must thread the
|
||||
orphans denominator query AND both engines' getHealth page-scope rows
|
||||
(engine parity). **Effort:** S. **P3.**
|
||||
- [ ] **P3 — dream.synthesize flat/root output_root (feature, #4117).**
|
||||
Per-family prefix shape (reflections/originals prefixes derived into prompts
|
||||
AND the fail-closed allow-list; default preserves wiki/). No config-registry
|
||||
drift to fix (`dream.` prefix already accepted). **Effort:** M. **P3.**
|
||||
- [ ] **P2 — test debt from the chennai wave's pre-landing review (deferred
|
||||
with rationale, not skipped).** (a) `/mcp` SDK-transport integration test:
|
||||
spin the serve-http surface with a legacy no-grant token end-to-end and
|
||||
assert the federated source list matches `localFederatedSourceIds` — the
|
||||
unit precedence test pins the resolver but not the transport wiring; also
|
||||
pin `AuthInfo.hasSourceGrant` at the oauth-provider construction site.
|
||||
(b) postgres `getHealth` parity e2e for the islanded/coverage changes —
|
||||
unit coverage is PGLite-only; the DATABASE_URL-gated parity lane should
|
||||
assert entity_page_count + null-coverage-below-floor on real Postgres.
|
||||
(c) transcripts replay-reconcile tests for WITHIN-TURN duplicate
|
||||
tool_use_id after migration v131 (same id, same message_idx — provider
|
||||
emits the dup inside one message). **Effort:** M spread. **P2.**
|
||||
- [ ] **P2 — adversarial-review residuals on the chennai wave (verified real,
|
||||
deferred with rationale).** (a) subagent tool-ledger zero-row settlement
|
||||
observability: in the residual zombie race a pending INSERT can be swallowed
|
||||
by ON CONFLICT DO NOTHING, the tool still executes, and the settle UPDATE
|
||||
then matches 0 rows — the outcome is silently unrecorded and a non-idempotent
|
||||
tool can re-execute on replay. Add a rowcount check + job-log warn (needs a
|
||||
logging seam in the persist helpers). (b) extract-atoms tombstones cover
|
||||
pages only: `recordPageFailureCount` returns null for `kind !== 'page'`, so
|
||||
a transcript that deterministically yields malformed output re-spends LLM
|
||||
budget every cycle forever — extend #4148's failure-count machinery to
|
||||
transcript items. (c) getHealth coverage numerators are not liveness-
|
||||
filtered while islanded now is (#4153): a page whose only inbound link is
|
||||
from a soft-deleted page counts as covered AND orphaned simultaneously;
|
||||
align the coverage EXISTS subqueries with the islanded liveness JOINs in
|
||||
both engines (parity + bootstrap-probe update). **Effort:** M spread. **P2.**
|
||||
- [ ] **P3 — conversation-parser: corpus-level false-positive receipt for the
|
||||
multi_line bold-name-date builtin (#4163 follow-on).** Flipping the builtin
|
||||
to `multi_line` + score_continuations_as_body means non-conversation prose
|
||||
with as few as two `**Name** (date):`-shaped lines can clear the 5% density
|
||||
floor (every other line counts as a continuation) and parse as a
|
||||
conversation, feeding facts extraction with garbage segments. Build a
|
||||
small negative corpus (essays/notes with incidental bold-date lines) and
|
||||
either raise the floor for this builtin or require a minimum SPEAKER count.
|
||||
Adjacent to the #4136 suspect-heading work above. **Effort:** S. **P3.**
|
||||
- [ ] **P3 — gateway expand(): record spend for a generateObject call that
|
||||
throws after consuming tokens (#4121 follow-on).** The schema-rejection →
|
||||
viaText fallback is the double-billed shape; the first call's tokens go
|
||||
unrecorded because usage is only read on success. If the SDK error carries
|
||||
usage, record it before the fallback retry. **Effort:** S. **P3.**
|
||||
- [ ] **P3 — eval-contradictions: reject flag-shaped slugs at render time.**
|
||||
A slug beginning with `-` renders into `takes supersede '<slug>'` as a
|
||||
flag-shaped positional; the pasted command errors rather than executes, but
|
||||
a render-time shape check (or `--` separator support in the takes CLI)
|
||||
would make the generated command paste-safe for any slug a remote MCP
|
||||
writer can mint. **Effort:** S. **P3.**
|
||||
- [ ] **P3 — DRY refactors flagged by the review army (correct today,
|
||||
duplicated shape).** (a) hoist the settlement-status subquery duplicated
|
||||
across grade-takes call sites into one helper; (b) extract the three-tier
|
||||
resolution (per-call > config > default) repeated in pace-mode/search-mode/
|
||||
probe-timeout into a shared `resolveTiered` helper; (c) `renderBlock`-style
|
||||
functions taking 6+ positional args → params object; (d) the deadline-skip
|
||||
preamble repeated at the top of each cycle phase → shared guard in
|
||||
base-phase.ts. **Effort:** S each. **P3.**
|
||||
## Multi-agent wave follow-ups (cathedral-6, `gbrain agent register`)
|
||||
|
||||
- [ ] **P2 — archived sources keep previously-granted federated reads until
|
||||
re-registration.** **What:** grants are validated at mint time only — a
|
||||
client whose `federated_read` names a source that is archived AFTER
|
||||
registration keeps reading it; there is no per-request archived-source
|
||||
filtering and no grant invalidation on archive. **How:** this is a
|
||||
platform-wide read-path decision affecting every federated op (recall,
|
||||
search, entity, boundary verbs), not just recall — either fold an
|
||||
`archived = false` join into the shared source-scope resolution or sweep
|
||||
grants on `sources archive`; decide once, apply everywhere. **Where:**
|
||||
`src/core/ops/context.ts` (sourceScopeOpts consumers), engine read paths,
|
||||
`src/core/destructive-guard.ts` (archive lifecycle). **Effort:** M.
|
||||
**Priority:** P2.
|
||||
- [ ] **P2 — federate the remaining read verbs across allowedSources.**
|
||||
**What:** `recall` now honors a federated grant (every fact arm fans out
|
||||
across `ctx.auth.allowedSources` and merges per-arm — see the `factSources`
|
||||
ladder in `src/core/ops/facts.ts` as the pattern), but the rest of the
|
||||
frozen-verb read surface stays scalar: `entity` (card assembly) and the
|
||||
`context_pack`/`delta` ambient boundary verbs resolve `ctx.sourceId ?? 'default'`
|
||||
only. A client granted N sources gets cross-source recall but single-source
|
||||
entity cards and boundary packs — the surface splits silently. **How:** route
|
||||
each through `sourceScopeOpts(ctx)` and fan out + merge like recall; for the
|
||||
perf-clean shape push the source set INTO the engine query instead of
|
||||
N round-trips — `findTrajectory`'s `sourceIds` ANY() branch is the
|
||||
engine-level filter to mirror. **Where:** `src/core/ops/facts.ts`
|
||||
(context_pack/delta), `src/core/verbs.ts` (entity),
|
||||
`src/core/context/turn-context.ts`, engine fact/entity list APIs.
|
||||
**Effort:** M. **Priority:** P2.
|
||||
- [ ] **P3 — E5: content-level BrainBench leak detection.** **What:** the
|
||||
isolation gate asserts STRUCTURAL leak-absence (every result's source_id is
|
||||
inside the caller's grant); a content-level arm would seed known-plaintext
|
||||
canary strings into a foreign source and assert no returned text (snippets,
|
||||
synthesized answers, graph annotations) contains them — catching join/
|
||||
snippet/synthesis leak classes a source_id check can't see. **Where:**
|
||||
`evals/brainbench/`. **Effort:** M. **Priority:** P3.
|
||||
- [ ] **P3 — OpenClaw native remote-MCP register block, when upstream ships
|
||||
remote support.** **What:** `gbrain agent register` renders the honest
|
||||
thin-client CLI block for openclaw today (`openclawThinClientBlock` in
|
||||
`src/core/mcp-registration.ts`) because OpenClaw has no native remote-MCP
|
||||
client; when upstream ships one, add a native client-credentials wiring
|
||||
block and demote the CLI block to the fallback. Blocked upstream. **Where:**
|
||||
`src/core/mcp-registration.ts`, `src/commands/agent-register.ts`.
|
||||
**Effort:** S. **Priority:** P3.
|
||||
- [ ] **P3 — E2: `gbrain agent list` / `gbrain agent revoke` conveniences.**
|
||||
**What:** sugar over `gbrain auth clients` / `gbrain auth revoke-client`
|
||||
filtered to agent-register-minted clients, with revoke-by-name. Motivation
|
||||
is partly retired: `gbrain auth clients` now shows source_id +
|
||||
federated_read columns, so the remaining value is the agent-only filter +
|
||||
name-based revoke. Build only if operators ask. **Where:**
|
||||
`src/commands/agent.ts`. **Effort:** S. **Priority:** P3.
|
||||
|
||||
|
||||
|
||||
## Security-sweep mitigation follow-ups (filed 2026-08-16)
|
||||
|
||||
- [ ] **P1 — `gbrain upgrade` binary lane returns success exit status on failure (autopilot false-success).** **What:** `runUpgrade`'s `binary` case logs every failure reason (`smoke_failed`, `download_failed`, `integrity_failed`, `integrity_unavailable`, `version_mismatch`, `replace_failed`) but never sets a non-zero CLI exit verdict, so callers see exit 0. **Why:** autopilot (`src/commands/autopilot.ts`) can read a false success, record "applied," relaunch, and then mark a transiently-unavailable version permanently bad — an amplification loop, now more reachable because `integrity_unavailable` fires on ordinary GitHub API rate limits. **Context:** PRE-EXISTING for the whole binary lane (not introduced by the v0.46.12.3 integrity work); surfaced by that PR's adversarial review with 2-model consensus. Fix needs care: distinguish hard-fail (`integrity_failed`/`version_mismatch` → exit non-zero, autopilot should NOT mark-bad on a security rejection) from transient (`integrity_unavailable` → retry, not a version fault), with autopilot-loop tests — hence its own PR, not a rushed rider. **Start:** `src/commands/upgrade.ts` binary case + `setCliExitVerdict` + `src/commands/autopilot.ts` upgrade handling.
|
||||
- [ ] **P3 — Self-update GitHub API rate-limit resilience.** **What:** each `gbrain upgrade` makes 2 unauthenticated `api.github.com` calls (releases/latest + attestations), 60/hr/IP; corporate NAT / CI fleets hit 403 → `integrity_unavailable` → fail-closed. **Why:** a hard availability regression for shared-egress fleets vs the pre-integrity path. **Options:** honor an ambient `GH_TOKEN`/`GITHUB_TOKEN` when present (weigh against widening what a leaked env token authorizes), or a small bounded retry with backoff, and align the attestation fetch timeout (10s) with the download budget so a slow-but-working link doesn't spuriously fail. **Start:** `defaultFetchRelease`/`defaultFetchAttestation` in `src/core/binary-self-update.ts`.
|
||||
|
||||
|
||||
- [ ] **P3 — Integrity for the from-source / `latest-stable` install paths.** **What:** the
|
||||
compiled-binary self-update now verifies the GitHub build-provenance attestation before
|
||||
installing (`src/core/binary-self-update.ts`), but the primary documented install
|
||||
(`bun install -g github:garrytan/gbrain#latest-stable`, a force-moved tag) and the
|
||||
force-published `codex-plugin` branch / template repo remain TLS+GitHub trust-on-first-use.
|
||||
**Why:** those paths are how most users actually install; a compromised GitHub account could
|
||||
serve an unverified tree. **Context:** documented as a residual in SECURITY.md
|
||||
("Install-path trust model"). A postinstall attestation check (or a documented
|
||||
`gh attestation verify` step for tag installs) would close it, but a from-source tree has no
|
||||
single binary to attest — needs design. **Start:** `scripts/postinstall.ts` +
|
||||
SECURITY.md residual note. **Depends on:** the WS2 self-update integrity that just landed.
|
||||
- [ ] **P3 — Make `check:admin-embedded` deterministic so it can gate.** **What:**
|
||||
`scripts/build-admin-embedded.ts` stamps today's date into a comment in
|
||||
`src/admin-embedded.ts`, so `check-admin-embedded.sh`'s `git diff --exit-code` fails on any
|
||||
day after commit — which is why it's `EXECUTION_EXEMPT` and unwired. **Why:** if the date
|
||||
stamp were dropped (or the check ignored it), the embedded-manifest freshness guard could
|
||||
actually run in CI. **Context:** correctness guard (catches a forgotten manifest regen), not
|
||||
a security control — a backdoored dist regenerates the manifest and passes. The real dist
|
||||
trust anchor is build-fresh-in-release (WS1, landed). **Start:** the date-comment line in
|
||||
`scripts/build-admin-embedded.ts` + `guards-manifest.tsv:50`.
|
||||
## CLI→MCP gap-closure wave follow-ups (2026-08-16; plan: ~/.claude/plans/system-instruction-you-are-working-concurrent-lantern.md)
|
||||
|
||||
- [ ] **P2 — publish-gate fail-open on a DB-config read failure.**
|
||||
@@ -855,7 +1161,7 @@ Deferred from the BrainBench wave (eng-reviewed; plan + GSTACK REVIEW REPORT at
|
||||
|
||||
- [ ] **`--live` agent-in-the-loop know-to-ask.** Replay fixtures with a real model deciding whether to issue retrieval calls; grade the agent, not just the deterministic reflex. Pre-registered in `docs/eval/BRAINBENCH.md` (the v1 metric grades the injection decision, which IS the shipped mechanism). Needs: seeded N-repeat methodology for model stochasticity + budget rails. Priority: P2.
|
||||
- [ ] **Intrusion-budget gating calibration.** `avg_injected_tokens` is reported, non-gating (decision 18) — a wrong threshold is worse than none. After a few weeks of scoreboard data across PRs, pick calibrated per-seam thresholds and promote it to a gated metric. Priority: P2.
|
||||
- [ ] **Flip contract adapters to production — claude-code half now unblocked.** `adapters/claude-code.ts` exports the UserPromptSubmit hook wire types; the real hook (`gbrain hook user-prompt`, shipped with the bootstrap lane and extended with cross-turn dedupe + the channel feedback loop in the cathedral-3 convergence) swaps the in-process transport for an exec of the hook script and flips `seam: 'contract'` → `'production'` with continuous bench numbers. Note the production hook also exercises transcript-based dedupe, which the memoryless contract row deliberately doesn't. For the codex half: the cathedral-4 transcripts lane shipped a verified codex rollout PARSER (`src/core/transcripts/codex.ts`, structural turn selection pinned against a live sample) — a codex contract adapter can now consume it instead of waiting for a hook integration. Priority: P1 (the claude-code integration has landed; codex parsing has landed; this is now standalone-actionable).
|
||||
- [x] **Flip contract adapters to production — claude-code half DONE (v0.46.15 identity/retrieval wave).** `adapters/claude-code.ts` now drives the real `gbrain hook user-prompt` path (synthesized Claude Code JSONL transcripts, run-scoped resolve-IPC server with `turn_context` handler, `HookIo` seams) and the scoreboard row is `seam: 'production'`, banked with justification in the same commit. The codex half (real DELIVERY path, not just the parser) is re-filed as the P2 "Codex adapter full production flip" entry in the v0.46.15.0 wave section at the top of this file.
|
||||
- [ ] **Cathedral 1 conformance-kit fixture import.** The memory-verbs conformance scenarios convert to BrainBench fixtures via the published `evals/brainbench/schema/fixture.schema.json` once `garrytan/cathedral-1` merges ("conformance tests double as BrainBench seed fixtures", decision log 2026-06-12). Free corpus growth from already-reviewed scenarios. Blocked by: cathedral-1 on master. Priority: P2.
|
||||
- [ ] **Live-embeddings fidelity mode (`--embeddings`).** Hermetic CI grades the keyword/alias arms only (disclosed); an opt-in mode seeding real embeddings would grade write-back/continuity retrieval through the vector path. Same budget rails as `--llm`. Priority: P3.
|
||||
- [ ] **Community fixture intake + competitor adapters.** The TD1 remainder after the generated corpus absorbed in-PR growth: an `external-authors/`-style intake path for contributed fixtures (validator + privacy guard already gate them) and adapters for non-gbrain memory systems against the published schemas, enabling true head-to-head rows in the gbrain-evals scorecard. Priority: P3.
|
||||
@@ -960,12 +1266,10 @@ master before starting, several fixes landed independently).
|
||||
into one UNION ALL query and extract a shared targets constant
|
||||
(src/core/jsonb-integrity-targets.ts) consumed by both. Where:
|
||||
`src/commands/doctor.ts` jsonbIntegrityCheck, `src/commands/repair-jsonb.ts`.
|
||||
- [ ] **P3 — register-client HTTP-level e2e (ship-review follow-up).** The
|
||||
source/federatedRead lane is covered by unit normalizers + a structural
|
||||
route pin; a DATABASE_URL-gated serve-http e2e (register with bindings →
|
||||
assert stored client via /admin/api/agents; invalid source → 400
|
||||
invalid_source) closes the wire-level gap. Where:
|
||||
`test/e2e/serve-http-oauth.test.ts`.
|
||||
- [x] **P3 — register-client HTTP-level e2e (ship-review follow-up).** ABSORBED
|
||||
into the cathedral-6 multi-agent e2e suite on `garrytan/cathedral-6`
|
||||
(test/e2e/serve-http-multi-agent.test.ts — wire-level register + scoped
|
||||
round-trips + invalid source → 400).
|
||||
- [ ] **P3 — get_chunks `__all__` sentinel narrows to 'default' (red-team,
|
||||
Wave 3 territory).** `sourceScopeOpts` returns `{}` for a trusted local
|
||||
`--source __all__` caller (documented "spans the brain"), but both engines'
|
||||
@@ -1494,10 +1798,15 @@ is deterministic + precision-biased. See plan + GSTACK REVIEW REPORT at
|
||||
wave: `extractCandidatesFromWindow` now covers assistant-introduced entities and
|
||||
pronoun follow-ups whose antecedent was NAMED in the rolling window; true pronoun
|
||||
coreference for never-named antecedents remains with the LLM-pass idea.)*
|
||||
- [ ] **P3 — recall knob: optional fuzzy/prefix-expansion resolution.** The resolver
|
||||
(`src/core/context/retrieval-reflex.ts`) is exact-only (alias + title + slug-suffix)
|
||||
for precision. Revisit adding `resolveEntitySlug`'s trgm-fuzzy / prefix-expansion
|
||||
arm, gated on an unambiguous single hit, if recall telemetry comes back weak.
|
||||
- [x] **P3 — recall knob: optional fuzzy/prefix-expansion resolution.** RESOLVED
|
||||
differently by the v0.46.15 identity wave, with a receipt: trigram fuzzy in the
|
||||
reflex is deliberately REJECTED — the BrainBench adversarial near-miss class
|
||||
(`"<Name>er"` for a real `<Name>` page) is gold-silent and any usable trigram
|
||||
threshold would false-fire on it. The recall gap the fuzzy arm targeted was
|
||||
closed by exact NORMALIZED-LEXICAL arms instead: the lowercase weak-alias arm
|
||||
+ the surname arm (know_to_ask 0.15→0, push_recall +9.6pp, false_fire/precision
|
||||
unmoved). Do not re-add trigram here without a fixture that defeats the
|
||||
near-miss class first.
|
||||
|
||||
## gbrain#1972 job-layer follow-up (v0.43+)
|
||||
|
||||
@@ -1842,13 +2151,14 @@ Filed from the self-upgrading-gbrain wave. All deliberately scoped OUT (D7a/D7b
|
||||
+ eng-review notes); none is a v0.42.12.0 regression. Plan + reviews at
|
||||
`~/.claude/plans/system-instruction-you-are-working-nifty-badger.md`.
|
||||
|
||||
- [ ] **P2 — Signature/checksum verification before applying an auto-upgrade
|
||||
(D7a).** Auto-upgrade currently trusts TLS + GitHub, same as `gbrain upgrade`.
|
||||
This is the prerequisite for ever making `auto` a default instead of opt-in:
|
||||
verify a release-asset checksum/signature before `atomicReplace`. Until it
|
||||
lands, `self_upgrade.mode` stays opt-in everywhere. Touches
|
||||
`src/core/binary-self-update.ts` (stage step) + the release workflow (publish
|
||||
the signature/checksum alongside the asset).
|
||||
- [x] **P2 — Signature/checksum verification before applying an auto-upgrade
|
||||
(D7a).** **Completed:** v0.46.12.3 (2026-08-16). `verifyIntegrity` in
|
||||
`src/core/binary-self-update.ts` now checks the downloaded asset's SHA-256 +
|
||||
builder identity against the GitHub build-provenance attestation (already
|
||||
published by release.yml's `attest-build-provenance`) BEFORE chmod/exec/rename
|
||||
— fail-closed with typed `integrity_failed`/`integrity_unavailable`. No new
|
||||
release asset needed. Residual (from-source/`latest-stable` install paths) is
|
||||
re-filed as the P3 entry at the top of this file.
|
||||
- [ ] **P2 — `gbrain serve` host graceful request-drain on auto-upgrade (D7b).**
|
||||
The silent channel currently skips while any request/stream/job/tx is in
|
||||
flight and retries next window. A true drain (stop accepting new, finish
|
||||
@@ -3126,14 +3436,12 @@ The original 3 items as filed (kept for traceability):
|
||||
`eval_capture_failures.reason` enum cleanup from the v0.25.0 P1 surgical
|
||||
hardenings list. Effort: human ~3 days / CC ~3 hours.
|
||||
|
||||
- [ ] **P0 — Wire nightly quality probe into autopilot scheduler.** The
|
||||
phase ships callable (`src/core/cycle/nightly-quality-probe.ts`) with
|
||||
full DI surface; doctor surfaces outcomes; the audit JSONL rotates
|
||||
cleanly. What's NOT wired: `src/commands/autopilot.ts` doesn't invoke
|
||||
`runNightlyQualityProbe(deps)` on its 24h cadence. Add the phase
|
||||
trigger; honor `autopilot.nightly_quality_probe.enabled` config gate.
|
||||
Already filed in v0.40.1.0 Track D follow-ups — re-filing here as P0
|
||||
with explicit D1-wave dependency. Effort: human ~3 hours / CC ~30 min.
|
||||
- [x] **P0 — Wire nightly quality probe into autopilot scheduler.** DONE —
|
||||
and this entry was STALE when the v0.46.15 wave audited it: autopilot's
|
||||
tick body already invokes `runNightlyQualityProbe` behind the
|
||||
`autopilot.nightly_quality_probe.enabled` gate
|
||||
(`src/commands/autopilot.ts:1361-1386`, pinned by
|
||||
`test/autopilot-nightly-probe-wiring.test.ts`). Nothing to build.
|
||||
|
||||
### D2 — Code-indexing promoted to P1 (peer of Cursor/Sourcegraph)
|
||||
|
||||
@@ -3564,19 +3872,12 @@ contributor traps.
|
||||
vary too much). Estimate: ~2 weeks. Filed during v0.40.1.0 Track D
|
||||
/plan-eng-review (see `~/.claude/plans/system-instruction-you-are-working-whimsical-acorn.md`).
|
||||
|
||||
- [ ] **v0.41+: Wire the nightly quality probe into autopilot scheduling.**
|
||||
v0.40.1.0 Track D shipped the phase (`src/core/cycle/nightly-quality-probe.ts`),
|
||||
the audit JSONL (`src/core/audit-quality-probe.ts`), the doctor check
|
||||
(`nightly_quality_probe_health` in doctor.ts), and the 10-question
|
||||
placeholder fixture. What's NOT yet wired: `src/commands/autopilot.ts`
|
||||
doesn't yet invoke `runNightlyQualityProbe(deps)` on its 24h cadence —
|
||||
the phase is callable in isolation (good for testing) but no scheduled
|
||||
loop calls it. To finish: add a phase trigger to the autopilot cycle loop
|
||||
that calls the probe with concrete deps wiring (`isEnabled`,
|
||||
`hasEmbeddingProvider`, `resolveMaxUsd`, `resolveRepoRoot`, real
|
||||
`runLongMemEval` / `runCrossModalBatch` invocations via subprocess or
|
||||
direct function call). Honor `autopilot.nightly_quality_probe.enabled`
|
||||
config gate (already in doctor's read-side; needs autopilot read-side).
|
||||
- [x] **v0.41+: Wire the nightly quality probe into autopilot scheduling.**
|
||||
DONE (stale entry swept by the v0.46.15 wave): autopilot's tick body
|
||||
invokes `runNightlyQualityProbe` behind the
|
||||
`autopilot.nightly_quality_probe.enabled` gate
|
||||
(`src/commands/autopilot.ts:1361-1386`, pinned by
|
||||
`test/autopilot-nightly-probe-wiring.test.ts`).
|
||||
Doctor surface is already in place to show outcomes; just need the
|
||||
scheduling lane. Estimate: ~3 hours.
|
||||
|
||||
@@ -3796,9 +4097,9 @@ contributor traps.
|
||||
|
||||
## MCP fix wave follow-ups (v0.34.1)
|
||||
|
||||
- [ ] **v0.34.x: Source-scope `takes_*` ops (pre-existing leak surfaced during v0.34.1 adversarial review).** `takes_list`, `takes_search`, `takes_scorecard`, `takes_calibration` in `src/core/operations.ts:1248-1335` thread `ctx.takesHoldersAllowList` but never `ctx.sourceId`. An auth'd OAuth client scoped to `source_id='canon-a'` can call `takes_list --page_slug=foo` (slug in `canon-b`) and read takes attached to foreign-source pages. Pre-existing, not introduced by v0.34.1, but the wave was framed as "P0 source-isolation seal on the read path" and `takes_*` surfaces were missed. Fix: extend `TakesListOpts` in `src/core/engine.ts:186` with `sourceId?: string` + `sourceIds?: string[]`; thread `sourceScopeOpts(ctx)` at each op handler; engine `listTakes`/`searchTakes` filter via the `pages` JOIN.
|
||||
- [x] **v0.34.x: Source-scope `takes_*` ops (pre-existing leak surfaced during v0.34.1 adversarial review).** DONE — verified already fixed and pinned on `garrytan/cathedral-6`: all four `takes_*` ops route through `sourceScopeOpts(ctx)` at `src/core/ops/takes.ts:30/57/86/113`.
|
||||
|
||||
- [ ] **v0.34.x: Extend `sourceScopeOpts(ctx)` to the 14 read-side ops PR #861 didn't touch.** `get_page`, `get_tags`, `get_links`, `get_backlinks`, `get_timeline`, `list_files`, `get_file`, and the four `takes_*` ops (above) still use the v0.31.8-era `const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {}` pattern. NOT a leak (scalar `ctx.sourceId` IS threaded), but federated_read (#876, `ctx.auth?.allowedSources`) is silently dropped. A "WeCare L3 dept" client gets correct federated results from `search`/`query`/`list_pages`/`traverse_graph`/`find_experts` but only sees its scalar `source_id` for `get_page`/`get_tags`/etc. Fix: route all 14 sites through `sourceScopeOpts(ctx)`.
|
||||
- [ ] **v0.34.x: Extend `sourceScopeOpts(ctx)` to the remaining read-side ops on the v0.31.8-era scalar pattern.** Most of the original list is fixed: `get_page`/`list_pages` route through `federatedSearchScope` (#3242), the four `takes_*` ops route through `sourceScopeOpts` (ops/takes.ts), and links/timeline-read/tag-set reads were converted (#2200). Still on `const sourceOpts = ctx.sourceId ? { sourceId: ctx.sourceId } : {}`: `ops/tags.ts:26,45`, `ops/timeline.ts:51`, `ops/raw-data.ts:27`, `ops/sync-status.ts:31`, `ops/admin.ts:158`, `ops/extraction.ts:83,187`, and `ops/pages.ts:252,775,808` (the pages.ts/extraction.ts arms are write-adjacent — audit each before switching; write authority is deliberately scalar). NOT a leak (scalar `ctx.sourceId` IS threaded), but federated_read (#876, `ctx.auth?.allowedSources`) is silently dropped on those reads.
|
||||
|
||||
- [ ] **v0.34.x: Migration v60 idempotency guard against `--force-retry` race with v64.** `gbrain apply-migrations --force-retry 58` after v64 has already run will re-install the FK with `ON DELETE SET NULL`, silently downgrading the v64 RESTRICT posture. Probability low (operator has to explicitly force-retry 58) but failure mode is invisible. Fix: v60 should probe `pg_constraint.confdeltype` before re-adding and refuse to clobber `'r'` (RESTRICT) with `'n'` (SET NULL).
|
||||
|
||||
@@ -3806,7 +4107,7 @@ contributor traps.
|
||||
|
||||
- [ ] **v0.34.x: Doctor check `oauth_orphan_source_id`** — surfaces OAuth clients whose source_id was nulled by the v60 D10 silent-widen path (`GBRAIN_ACCEPT_SILENT_WIDEN=1`). Closes the observability gap from v0.34.1's D4 decision. Sibling to the `rls_event_trigger` check pattern in `src/commands/doctor.ts`.
|
||||
|
||||
- [ ] **v0.34.x: `gbrain sources purge` FK error UX.** Post-v0.34, deleting a source is refused if any oauth_client references it (v64 ON DELETE RESTRICT). The CLI currently surfaces the raw Postgres FK violation. Fix: pre-check via `SELECT client_id, client_name FROM oauth_clients WHERE source_id = $1`, print "N OAuth clients reference this source: ... Revoke first via `gbrain auth revoke-client <id>`." Mirrors `assessDestructiveImpact` in destructive-guard.ts (v0.26.5).
|
||||
- [x] **v0.34.x: `gbrain sources purge` FK error UX.** DONE on `garrytan/cathedral-6`: `clientsReferencingSource` + `formatClientReferentsBlock` in `src/core/destructive-guard.ts` pre-check remove/purge/auto-purge in `src/commands/sources.ts` and print the named-client refusal (revoke hint included) instead of the raw FK violation; `assessDestructiveImpact` carries `oauthClientCount`.
|
||||
|
||||
- [ ] **v0.34.x: `hybrid.ts:223` explicit-pick refactor.** The SearchOpts rebuild manually picks fields from HybridSearchOpts. This is the bug shape that caused the original v0.34.1 P0 leak — a new SearchOpts field is silently dropped if not manually added here. The wave added `sourceId` + `sourceIds` to the pick; future fields will keep hitting this footgun. Fix: refactor to spread + TypeScript `Pick<>` helper that narrows HybridSearchOpts → SearchOpts type-safely.
|
||||
|
||||
|
||||
+11
-2
@@ -470,10 +470,19 @@ Never merge external PRs directly into master. Instead, use the "fix wave" workf
|
||||
read the diff, understand the fix, and write it yourself if needed.
|
||||
4. **Test the wave** — verify with `bun test && bun run test:e2e` (full E2E lifecycle).
|
||||
Every fix in the wave must have test coverage.
|
||||
5. **Close with context** — every closed PR gets a comment explaining why and what (if
|
||||
5. **Security review** — run `bun run wave-security-scan <base>..<collector-head>` over the
|
||||
collector branch (the repeatable mechanical sweep). It ALARMS on newly-introduced
|
||||
obfuscation/eval in code, secrets found by gitleaks **with the test/skills allowlist
|
||||
stripped**, and any committed `admin/dist` change (the bundle-backdoor artifact); new
|
||||
outbound endpoints, spawns, env reads, and dependency changes print as context. Exit 1
|
||||
means "eyeball before shipping," not "unsafe" — read the ALARM rows and the context lists,
|
||||
and confirm each is benign. Link the result (or a one-line "clean") in the wave PR body.
|
||||
This is the standard's teeth: a wave PR body that claims "security reviewed" must have run
|
||||
this. It is a net, not a proof — a human still reads the diffs.
|
||||
6. **Close with context** — every closed PR gets a comment explaining why and what (if
|
||||
anything) supersedes it. Contributors did real work; respect that with clear communication
|
||||
and thank them.
|
||||
6. **Ship as one PR** — single PR to master with all attributions preserved via
|
||||
7. **Ship as one PR** — single PR to master with all attributions preserved via
|
||||
`Co-Authored-By:` trailers. Include a summary of what merged and what closed.
|
||||
|
||||
**Community PR guardrails:**
|
||||
|
||||
+4
-3
@@ -7,7 +7,7 @@ only.
|
||||
|
||||
### Test command tiers
|
||||
|
||||
Six test command tiers, each with a clear scope:
|
||||
Seven test command tiers, each with a clear scope:
|
||||
|
||||
| Command | What it runs | Wallclock | When to use |
|
||||
|---|---|---|---|
|
||||
@@ -17,6 +17,7 @@ Six test command tiers, each with a clear scope:
|
||||
| `bun run test:slow` | Just the `*.slow.test.ts` set (intentional cold-path correctness checks). | seconds-to-minutes | When touching slow-path code. |
|
||||
| `bun run test:serial` | Just the `*.serial.test.ts` set (cross-file-contention quarantine; one bun process per file for true module-registry isolation), run through a POOL of concurrent per-file processes — the isolation is per-process, not per-machine. Pool defaults to `min(detect_cpus, 4)` then memory-adapts (same doctrine as the parallel runner); a small growth-guarded set of files (machine-global state or contention-critical timing — see the justified `EXCLUSIVE_FILES` list in `scripts/run-serial-tests.sh`, capped at 3 by `test/scripts/serial-files.test.ts`) runs on a sequential EXCLUSIVE lane after the pool. Per-test timeout 120s (pooled contention headroom); each pooled file is wall-clock-killed at 300s (`timeout -k`, exit-hang containment). Externally-killed files (exit 143/137 or a missing exit sentinel — sibling-workspace cleanup, memory jetsam) get ONE sequential rescue re-run, mirroring the parallel runner's doctrine: phantoms stay green with a rescue note, real failures stay red. Prints per-file PASS lines plus a top-10 slowest-files list. Knobs: `GBRAIN_SERIAL_POOL=N` (explicit pool width — bypasses the memory clamp; `1` restores fully-sequential), `GBRAIN_SERIAL_FILE_TIMEOUT`. | ~2.5min for all ~140 files at pool=4 (was ~8.5min sequential) | Debugging quarantined files; CI's serial-tests job. |
|
||||
| `bun run test:e2e` | Real Postgres E2E. Requires Docker + `DATABASE_URL`. Sequential. | ~5-10min | Pre-ship; nightly. |
|
||||
| `bun run test:compile-smoke` | Self-update integrity verify under a REAL `bun build --compile` binary, offline (sets `GBRAIN_SELFUPDATE_COMPILE_SMOKE=1`). The unit suite mocks the network seams; this proves the dependency-free crypto/base64/JSON verify path survives compilation — the failure mode `sigstore-js` would have hit. | ~5s (one compile) | When touching `src/core/binary-self-update.ts`; pre-ship on self-update changes. |
|
||||
|
||||
There is no `check:all` script anymore — it was a second, hand-synced guard
|
||||
registry that drifted from `verify` (three checks were reachable ONLY from it,
|
||||
@@ -461,9 +462,9 @@ Unit tests and what they cover:
|
||||
- `test/search.test.ts` — RRF normalization, compiled truth boost, cosine similarity, dedup key.
|
||||
- `test/sql-ranking.test.ts` — source-boost helpers: longest-prefix-match in SQL CASE, `detail=high` temporal-bypass, three-meta-char LIKE escape (`%`, `_`, `\`), single-quote SQL-literal doubling, env override parsing for `GBRAIN_SOURCE_BOOST` + `GBRAIN_SEARCH_EXCLUDE`, `resolveBoostMap` / `resolveHardExcludes` merge semantics.
|
||||
- `test/dedup.test.ts` — source-aware dedup, compiled truth guarantee, layer interactions.
|
||||
- `test/intent.test.ts` — query intent classification: entity/temporal/event/general.
|
||||
- `test/query-intent-legacy.test.ts` — query intent classification: entity/temporal/event/general (pre-concept behavior pins). `test/query-intent-concept.test.ts` — the `concept` intent: definitional/landscape cue detection, the proper-noun / quoted-phrase / sub-3-word guards, vector-lean weight routing.
|
||||
- `test/eval.test.ts` — retrieval metrics: `precisionAtK`, `recallAtK`, `mrr`, `ndcgAtK`, `parseQrels`.
|
||||
- `test/brainbench-fixtures.test.ts` / `test/brainbench-generator.test.ts` / `test/brainbench-metrics.test.ts` / `test/brainbench-continuity.test.ts` / `test/brainbench-writeback.test.ts` / `test/brainbench-adapters.test.ts` / `test/brainbench-scoreboard.test.ts` — the BrainBench memory-conformance unit suites (`src/eval/brainbench/`): fixture loader/validator + the sealed-gold seal (a `gold` key inside a fixture must reject) and committed-corpus integrity; generator determinism (the committed corpus is exactly what `gen.ts` produces, holdout discipline, category counts); metric formulas over hand-built turn rows (zero should-retrieve turns, empty injections, acceptable-vs-gold asymmetry, micro-averaging); cross-harness continuity (writer's decision persists through the production write-back pipeline, reader recalls on the SAME brain); write-back grading the PRODUCTION conversation→facts pipeline via the injected gold extractor; adapter seam contracts over hermetic PGLite (budget caps, suppression modes); scoreboard + gate governance (baseline determinism, count-aware gating, corpus-bless modes, justification flow, isolation gates-at-zero).
|
||||
- `test/brainbench-fixtures.test.ts` / `test/brainbench-generator.test.ts` / `test/brainbench-metrics.test.ts` / `test/brainbench-continuity.test.ts` / `test/brainbench-writeback.test.ts` / `test/brainbench-adapters.test.ts` / `test/brainbench-scoreboard.test.ts` — the BrainBench memory-conformance unit suites (`src/eval/brainbench/`): fixture loader/validator + the sealed-gold seal (a `gold` key inside a fixture must reject) and committed-corpus integrity; generator determinism (the committed corpus is exactly what `gen.ts` produces, holdout discipline, category counts); metric formulas over hand-built turn rows (zero should-retrieve turns, empty injections, acceptable-vs-gold asymmetry, micro-averaging); cross-harness continuity (writer's decision persists through the production write-back pipeline, reader recalls on the SAME brain); write-back grading the PRODUCTION conversation→facts pipeline via the injected gold extractor; adapter seam contracts over hermetic PGLite (budget caps, suppression modes); scoreboard + gate governance (baseline determinism, count-aware gating, corpus-bless modes, justification flow, isolation gates-at-zero). `test/brainbench-floors.test.ts` — the pre-registered quality floors as executable assertions against the committed baseline (a baseline bless can't bank a threshold violation).
|
||||
- `test/eval-brainbench-e2e.test.ts` — BrainBench CLI end-to-end via subprocess against a small tmp corpus: the literal exit codes (0 pass / 1 regression / 2 error-or-inconclusive — the CI product), `--out` artifact validity incl. `_meta.metric_glossary`, byte-deterministic `--update-baseline`, anti-vacuous-pass, and the `eval run-all` in-process wiring.
|
||||
- `test/check-resolvable.test.ts` — resolver reachability, MECE overlap, gap detection, proximity-based DRY detection, `extractDelegationTargets` coverage.
|
||||
- `test/dry-fix.test.ts` — auto-fix: three shape-aware expander pure-function tests; five guards (working-tree-dirty, no-git-backup, inside-code-fence, already-delegated within 40 lines, ambiguous-multi-match, block-is-callout).
|
||||
|
||||
@@ -66,16 +66,21 @@ stdin:
|
||||
every call would boot the user's configured MCP servers — including
|
||||
gbrain's own MCP, which would recurse and contend for the PGLite
|
||||
single-writer lock.
|
||||
- The subprocess env is a copy of gbrain's own process env with exactly
|
||||
three keys deleted before spawn: `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`,
|
||||
`ANTHROPIC_BASE_URL`. Everything else in gbrain's environment is inherited
|
||||
as-is. The recipe's source comment states the intent (stop an
|
||||
`ANTHROPIC_API_KEY` present in gbrain's own env from being picked up by the
|
||||
subprocess), scoped to those three variables specifically — the doc does
|
||||
not claim this rules out every other way `claude` could end up billing
|
||||
through a non-subscription path (e.g. other env-based auth switches the CLI
|
||||
itself may support); that is between the installed `claude` binary and its
|
||||
own configuration, not something this recipe's code inspects.
|
||||
- The subprocess env is a copy of gbrain's own process env with the
|
||||
cloud-auth routing variables scrubbed before spawn: the three direct-API
|
||||
keys (`ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_BASE_URL`)
|
||||
plus every `CLAUDE_CODE_USE_*` backend-switch flag (a prefix wipe, not a
|
||||
denylist — Bedrock, Vertex AI, and the other cloud backends are each gated
|
||||
by one of these, take priority over subscription OAuth when set, and route
|
||||
billing through a cloud account; clearing the switch is sufficient because
|
||||
the provider-specific credentials are inert without it). Everything else in
|
||||
gbrain's environment is inherited as-is. Subscription-only is the recipe's
|
||||
contract: children always authenticate with the CLI's own login state. If
|
||||
you intentionally route a workload through a cloud backend, use the
|
||||
`anthropic` recipe with cloud credentials instead — the scrub means
|
||||
claude-cli children will not inherit that routing. Whatever auth/billing
|
||||
configuration the installed `claude` binary carries in its own config files
|
||||
(not env) remains between it and its login state.
|
||||
- Beyond that env-scrub, auth resolution is entirely up to the installed
|
||||
`claude` binary — the recipe does not manage or forward credentials
|
||||
itself. Whatever `claude` is already logged in / authenticated with on
|
||||
@@ -106,37 +111,30 @@ above.
|
||||
| Tool use | JSON emission via a system-prompt-injected protocol, not the CLI's native tool-call mechanism. Parallel tool calls in one turn round-trip correctly. |
|
||||
| Multimodal | Not supported over the subprocess path. File/image message parts are rendered as a `[file <mediaType>]` text stub, not sent as actual content. |
|
||||
| Prompt caching | The recipe declares `supports_prompt_cache: false`. The CLI manages its own caching internally but does not expose it through gbrain's `cache_control` control plane, so from the gateway's point of view this model does not support prompt caching. |
|
||||
| Usage / token counts | Reported `usage.input_tokens` / `usage.output_tokens` are read straight from the CLI's `--output-format json` envelope (`result.usage?.input_tokens` / `output_tokens`); gbrain does not independently count tokens for this path. |
|
||||
| Usage / token counts | Reported `usage.input_tokens` / `usage.output_tokens` are read straight from the CLI's `--output-format json` envelope (`result.usage?.input_tokens` / `output_tokens`); gbrain does not independently count tokens for this path. The envelope's `cache_read_input_tokens` is surfaced as `usage.cachedInputTokens`, so cache reads no longer count as zero in gbrain's usage accounting; `cache_creation_input_tokens` is not surfaced (the AI SDK's usage shape has no corresponding field). |
|
||||
| Cost figures | The recipe declares `cost_per_1m_input_usd: 3.0` / `cost_per_1m_output_usd: 15.0` — the same Sonnet-class figures the `anthropic` recipe declares (`price_last_verified: 2026-06-17`) — purely so gbrain's budget ledger has a number to attribute per call. Neither the recipe nor the adapter code checks what you're actually billed; treat these as the ledger's nominal per-call number, not a verified charge. |
|
||||
| User-level CLAUDE.md | `~/.claude/CLAUDE.md` still loads on every call (see above) — only the working directory changes (see "What actually happens on a call" for exactly what that directory is and isn't). |
|
||||
|
||||
## Known doctor caveat: cold-start subprocess vs the fixed 5s probe timeout
|
||||
## Doctor probe timeout: per-recipe, 30s for claude-cli
|
||||
|
||||
`gbrain models doctor`'s chat reachability probe (`probeModel` in
|
||||
`src/commands/models.ts`) wraps every chat call in a fixed 5-second
|
||||
`AbortController` timeout, independent of any per-recipe timeout the recipe
|
||||
itself declares (`claude-cli` does not declare a `default_timeout_ms`).
|
||||
Spawning the `claude` binary and letting it start up is generally fast, but
|
||||
is not instantaneous — a slow first invocation (cold process cache, slow
|
||||
disk, contended machine) can outrun that 5-second window.
|
||||
`src/commands/models.ts`) resolves its timeout per model: the recipe
|
||||
touchpoint's declared `default_timeout_ms` when present, else a flat 5000ms
|
||||
default (the right number for a plain HTTP round-trip). The `claude-cli`
|
||||
recipe declares `default_timeout_ms: 30_000` because each call spawns a
|
||||
`claude -p` subprocess (CLI cold start + user-level CLAUDE.md load) that
|
||||
routinely takes 5-6 seconds even when the CLI and subscription are perfectly
|
||||
healthy — under the old flat 5s abort the probe false-failed on every run
|
||||
with `status: unknown` (`claude-cli adapter aborted`) while `chat()`
|
||||
succeeded fine at normal call sites.
|
||||
|
||||
When that happens, the probe's `AbortController` fires, the subprocess is
|
||||
killed (`child.kill('SIGTERM')`), and the adapter's abort handler rejects
|
||||
with a fixed message (`claude-cli adapter aborted`). `classifyError` in
|
||||
`src/commands/models.ts` only maps a message to `status: network` if it
|
||||
matches `/timeout|network|econn|fetch failed|enotfound/`; `claude-cli
|
||||
adapter aborted` matches none of those, so it falls through to
|
||||
`status: unknown` — the classifier's catch-all — instead of `status:
|
||||
network`, which is what a plain slow/unreachable HTTP provider would map
|
||||
to on the same probe timeout. So a `status: unknown` result on a
|
||||
`claude-cli:` model is not necessarily a broken configuration on its own;
|
||||
a cold subprocess start outrunning the fixed 5s window is one thing that
|
||||
can produce it (the same class of first-call cold-start the embedding
|
||||
reachability probe's own code comment already calls out for local
|
||||
embedders), and re-running the probe is a reasonable first thing to try.
|
||||
`status: unknown` on its own doesn't distinguish that from any other
|
||||
unclassified failure, so if a re-run keeps producing it, treat it as an
|
||||
unclassified error worth investigating rather than assuming cold-start.
|
||||
30 seconds gives the subprocess room to start without masking a truly
|
||||
dead or unauthenticated CLI for long. A probe that still outruns 30s kills
|
||||
the subprocess (`child.kill('SIGTERM')`) and reports `status: unknown` —
|
||||
the adapter's abort message doesn't match `classifyError`'s network
|
||||
patterns, so it lands in the catch-all. A persistent `unknown` is now worth
|
||||
investigating directly (run the same model via `gbrain models doctor
|
||||
--json` or call `claude` by hand) rather than assuming cold-start.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -146,5 +144,5 @@ unclassified error worth investigating rather than assuming cold-start.
|
||||
| `claude-cli exited <code>: ...` | Non-zero exit from the `claude` subprocess itself; the message is whatever the CLI wrote to stderr/stdout | Run `claude` interactively with the same model to see the underlying CLI error directly (e.g. not logged in, model unavailable) |
|
||||
| `claude-cli output not JSON: ...` | `JSON.parse(stdout)` threw (stdout wasn't valid JSON at all) | Confirm the installed `claude` CLI version still supports `--print --output-format json`; this adapter's JSON handling was verified against CLI 2.1.145 |
|
||||
| `claude-cli JSON event array had no "result" event` | stdout parsed as a JSON array (the `"verbose": true` event-stream shape in `~/.claude/settings.json`) but none of the events had `type: "result"` | Check `~/.claude/settings.json` for `"verbose": true`; the adapter tolerates the array shape but still needs a `result` event in it |
|
||||
| `gbrain models doctor` reports `chat` as `status: unknown` for a `claude-cli:` model | See "Known doctor caveat" above — `classifyError` falls through to `unknown` for the adapter's abort message | Re-run the probe; if it persists, treat it as an unclassified failure and investigate directly (e.g. run the same model via `gbrain models doctor --json` or call `claude` by hand) |
|
||||
| A call bills through the Anthropic API instead of the local session | The adapter deletes `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_BASE_URL` from the subprocess env — this covers gbrain's own env leaking into the call. It does not inspect any other auth/billing switch the installed `claude` CLI itself may support | If billing looks wrong, check the `claude` CLI's own auth/billing configuration on this machine, not just gbrain's env |
|
||||
| `gbrain models doctor` reports `chat` as `status: unknown` for a `claude-cli:` model | The probe now allows 30s for the subprocess (see "Doctor probe timeout" above); a persistent `unknown` means the call genuinely failed or outran even that window — `classifyError` falls through to `unknown` for the adapter's abort message | Investigate directly: run the same model via `gbrain models doctor --json` or call `claude` by hand (e.g. not logged in, model unavailable) |
|
||||
| A call bills through the Anthropic API or a cloud backend instead of the local session | The adapter deletes `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_BASE_URL` and every `CLAUDE_CODE_USE_*` backend-switch flag from the subprocess env — this covers gbrain's own env leaking into the call. It does not inspect billing switches the installed `claude` CLI carries in its own config files | If billing looks wrong, check the `claude` CLI's own auth/billing configuration on this machine, not just gbrain's env. For intentional cloud routing, use the `anthropic` recipe instead |
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -71,7 +71,13 @@ more than embedding proximity. Four layers, added after the incident in
|
||||
candidates to the best chunk per page (`DISTINCT ON (slug)`) over the full
|
||||
candidate set before the user `LIMIT`, via the shared `buildBestPerPagePoolCte`
|
||||
in `sql-ranking.ts`. The vector side returns N distinct pages by best chunk,
|
||||
not N chunks that collapse to fewer pages downstream.
|
||||
not N chunks that collapse to fewer pages downstream. When one dense page's
|
||||
chunks fill the inner candidate pool, the engines escalate the pool in a
|
||||
bounded loop (×4 per step, at most 3 escalations; HNSW-backed columns
|
||||
additionally cap at the `ef_search` ceiling) until the page count is honest;
|
||||
a loop that ends still underfilled surfaces `vector_pool_underfilled` on the
|
||||
hybrid layer's `HybridSearchMeta` (the op-layer capture channel) instead of
|
||||
silently returning a short page.
|
||||
- **Title-phrase boost** — when the normalized query is a contiguous token-run
|
||||
inside `page.title` (or an exact full-title match), a floor-ratio-gated,
|
||||
bounded multiplier fires (`applyTitleBoost`, `search.title_boost` knob). A
|
||||
@@ -86,7 +92,12 @@ more than embedding proximity. Four layers, added after the incident in
|
||||
(`alias_hit | exact_title_match | high_vector_match | keyword_exact |
|
||||
weak_semantic`) and `create_safety` (`exists | probable | unknown`). An agent
|
||||
deciding "is this page already here, safe to NOT write a duplicate?" keys off
|
||||
`create_safety`, not a raw blended score.
|
||||
`create_safety`, not a raw blended score. `high_vector_match` is grounded in
|
||||
the result's real query↔chunk cosine (`SearchResult.cosine` at/above
|
||||
`search.evidence_cosine_floor`, default 0.80) — never the blended score, so a
|
||||
keyword+boost pile-up can't read as semantic support; keyless runs have no
|
||||
cosine and degrade to honest keyword-based labels. `gbrain search --explain`
|
||||
prints each result's raw cosine next to its blended score.
|
||||
|
||||
**Extraction quarantine lane (issue #160):** pages carrying the unverified
|
||||
auto-extracted markers (frontmatter `provenance: auto-extracted` +
|
||||
@@ -108,11 +119,12 @@ specific miss with `gbrain search diagnose "<q>" --target <slug>`.
|
||||
|
||||
## Intent-aware query rewriting
|
||||
|
||||
`src/core/search/query-intent.ts` classifies queries into `entity`, `temporal`, `event`, or `general`. Each routes through different ranking knobs:
|
||||
`src/core/search/query-intent.ts` classifies queries into `entity`, `temporal`, `event`, `concept`, or `general`. Each routes through different ranking knobs:
|
||||
|
||||
- **Entity** queries ("who works at X?") apply a higher graph-traversal weight.
|
||||
- **Temporal** queries ("what happened last week?") bypass source-boost so chat/daily pages surface.
|
||||
- **Event** queries ("Acme AI Series A") engage the timeline index.
|
||||
- **Concept** queries ("what is the ownership economy?", "find all the companies doing offshore wind" — definitional paraphrases and landscape/quantifier phrasings with no proper noun) rank vector-lean, so keyword-decoy pages stop outranking the page that actually explains the idea. Proper nouns, quoted phrases, and sub-3-word queries never classify as concept — they keep their existing routing.
|
||||
- **General** queries hit the standard hybrid stack.
|
||||
|
||||
The classifier is deterministic (no LLM call). Wrong classification degrades gracefully — the hybrid stack still works without it.
|
||||
@@ -147,7 +159,7 @@ hybrid recall + fusion:
|
||||
graph augment (optional two-pass structural expansion — walkDepth > 0)
|
||||
│
|
||||
▼
|
||||
deduplication (4-layer: per-page cap, Jaccard, type diversity)
|
||||
deduplication (4-layer: per-page cap, same-page Jaccard, type diversity)
|
||||
│
|
||||
▼
|
||||
reranker (cross-encoder — balanced/tokenmax; fail-open)
|
||||
@@ -180,8 +192,12 @@ reranker and therefore no trustworthy cliff signal). `applyAutocut`
|
||||
cross-encoder rerank-score cliff, before the limit slice, first page only.
|
||||
Never-empty failsafe (`minKeep`), no-op when fewer than 2 results carry a
|
||||
finite rerank score (covers the fail-open reranker path), and alias-hop exact
|
||||
matches are preserved through the cut. Knobs: per-call `SearchOpts.autocut` →
|
||||
`search.autocut` / `search.autocut_jump` config → mode bundle.
|
||||
matches are preserved through the cut. Weak-top floor: when the top rerank
|
||||
score is below `minTopScore` (default 0.35, config `search.autocut_min_top`),
|
||||
cliff trimming is skipped entirely — a low-confidence list returns the full
|
||||
cluster for the caller to judge instead of collapsing to one result. Knobs:
|
||||
per-call `SearchOpts.autocut` → `search.autocut` / `search.autocut_jump` /
|
||||
`search.autocut_min_top` config → mode bundle.
|
||||
|
||||
Each stage is testable in isolation. Each stage is replaceable. The whole pipeline is < 1ms of orchestration cost; the latency budget goes to the upstream HTTP calls (embedding, rerank) and the index scans.
|
||||
|
||||
|
||||
+14
-6
@@ -106,15 +106,23 @@ Decision criteria for the bigger swing (chunk-level `revises` field):
|
||||
|
||||
## When to act on findings
|
||||
|
||||
Each finding ships with a `resolution_command` field — paste-ready:
|
||||
Each finding ships with a `resolution_command` field — addressable and
|
||||
honest about what needs operator judgment:
|
||||
|
||||
- `gbrain takes supersede <slug> --row N` — newer take should replace
|
||||
the older chunk text on the same page (intra_page kind).
|
||||
- `gbrain takes supersede <slug> --row N --claim '<replacement>'` — newer
|
||||
take should replace the older one (intra_page kind). `--row` is the
|
||||
per-page row number and `--claim` is required; when the winning side has
|
||||
an unambiguous claim (temporal supersession where the newer side is
|
||||
itself a take) the command is fully paste-ready, otherwise it carries an
|
||||
explicit `<replacement claim>` placeholder for you to fill from the
|
||||
report — the classifier picks an action, not a winner, and will not
|
||||
fabricate a take from arbitrary chunk prose.
|
||||
- `gbrain dream --phase synthesize --slug <slug>` — compiled_truth for
|
||||
the curated entity needs an update (cross_slug curated-vs-bulk).
|
||||
- `gbrain takes mark-debate <slug> --row N` — intentional disagreement
|
||||
(e.g., two opinions you want to keep both of).
|
||||
- `# manual review: <a> vs <b>` — judge wasn't sure; operator decides.
|
||||
- `# manual review: ...` — intentional-disagreement (debate) findings and
|
||||
judge-unsure findings render as a manual-review comment; a
|
||||
mark-as-debate subcommand does not exist yet, so nothing is minted that
|
||||
would fail when pasted.
|
||||
|
||||
Run `gbrain eval suspected-contradictions review --severity high` to
|
||||
inspect findings without re-running the probe.
|
||||
|
||||
+4
-3
@@ -588,9 +588,10 @@ gbrain config set autopilot.nightly_quality_probe.enabled true
|
||||
gbrain config set autopilot.nightly_quality_probe.max_usd 5.00 # optional override
|
||||
```
|
||||
|
||||
Note: `--phase nightly_quality_probe` wiring into the autopilot scheduler is
|
||||
deferred to a v0.41+ follow-up (see TODOS.md). For now the phase is callable
|
||||
in isolation; the test harness exercises it via DI stubs.
|
||||
The autopilot scheduler invokes the probe on its tick cadence when the
|
||||
config gate is on (`src/commands/autopilot.ts`, pinned by
|
||||
`test/autopilot-nightly-probe-wiring.test.ts`); the phase also stays
|
||||
callable in isolation, and the test harness exercises it via DI stubs.
|
||||
|
||||
```bash
|
||||
# Manual smoke (exercises the path via DI stubs, no real API spend).
|
||||
|
||||
+21
-14
@@ -21,17 +21,13 @@ Every scoreboard row carries a `seam` column:
|
||||
| Harness | Seam | What the row actually measures |
|
||||
|---|---|---|
|
||||
| `openclaw` | **production** | The shipped OpenClaw context-engine pipeline, byte-for-byte (`extractCandidates` → `resolveEntitiesToPointers`, 3-pointer budget, prior-context suppression, markdown pointer block). |
|
||||
| `claude-code` | **contract** | gbrain's memory primitives driven through the UserPromptSubmit hook wire contract (`{prompt, session_id, cwd}` in → `{hookSpecificOutput.additionalContext}` out, exported from `src/eval/brainbench/adapters/claude-code.ts`). 2-pointer budget; NO conversation memory — this row deliberately models the memoryless wire contract (suppression off), so the re-injection cost is visible as `false_fire_rate`; the shipped `gbrain hook user-prompt` layers transcript-based cross-turn dedupe on top of this same contract. |
|
||||
| `codex` | **contract** | The fragments model: a static entity-index preamble (computed once, slugs not counted as injections) + at most ONE per-turn fragment. Measures how much push quality degrades when injection is mostly static. |
|
||||
| `claude-code` | **production** (v0.46.15) | The shipped Claude Code integration end-to-end: fixture turns become `UserPromptSubmit` stdin JSON; `gbrain hook user-prompt` executes for real (stdin parse → synthesized-transcript window parse → cross-turn dedupe via `hook_additional_context` attachments → IPC `turn_context` over a real unix socket with the real shared secret → `additionalContext`). The row now measures the shipped pointer budget, the volunteer layer, and the transcript dedupe — not a memoryless contract sim. Bench-pinned deviations (disclosed): generous `userPromptDeadlineMs` (10s vs 800ms — CI-load flake control; deadline behavior is hook-suite territory), the push-failure banner suppressed, heartbeat telemetry writes disabled, and the hook's config pointed at the run-scoped bench brain (operator-environment isolation; parallel-test safe). |
|
||||
| `codex` | **contract** | The fragments model: a static entity-index preamble (computed once, slugs not counted as injections) + at most ONE per-turn fragment. v0.46.15: fixture conversations round-trip through the REAL rollout format + the shipped parser (`src/core/transcripts/codex.ts`) for turn selection — parser drift now tanks the row visibly. Fragment DELIVERY remains a harness-shaped assumption (no shipped codex injection path yet); the full production flip is a filed follow-up. |
|
||||
|
||||
**Contract rows do NOT measure third-party harness behavior.** They measure
|
||||
gbrain's primitives under each harness's injection-shape constraints. The rows
|
||||
are comparable because fixtures, brain, and gold are identical — only the seam
|
||||
contract varies. The real Claude Code integration has landed (`gbrain hook
|
||||
user-prompt`, registered by `gbrain bootstrap`); flipping this adapter to exec
|
||||
the real hook and report `production` numbers is a filed follow-up (TODOS.md —
|
||||
"Flip contract adapters to production"). Same for codex fragments when that
|
||||
integration lands. Also not graded, by design: the production orchestrator's
|
||||
varies. Also not graded, by design: the production orchestrator's
|
||||
config gate, integration heartbeat, and 1500 ms timeout wrapper.
|
||||
|
||||
All three adapters drive ONE shared pipeline (`adapters/shared.ts`) with
|
||||
@@ -66,20 +62,31 @@ grading is faked in v1.
|
||||
|
||||
### Difficulty is stratified on purpose
|
||||
|
||||
Several know-to-ask variants exercise documented v1 reflex limits (lowercase
|
||||
mentions, surname-only references — `src/core/context/entity-salience.ts`).
|
||||
Gold records what SHOULD happen; the committed baseline records what the
|
||||
current system does (`know_to_ask_failure_rate` ≈ 0.15 at v1). The gap is the
|
||||
measured roadmap, not a bug in the bench.
|
||||
Several know-to-ask variants exercise what were documented v1 reflex limits
|
||||
(lowercase mentions, surname-only references —
|
||||
`src/core/context/entity-salience.ts`). Gold records what SHOULD happen; the
|
||||
committed baseline records what the current system does. At v1 that gap read
|
||||
`know_to_ask_failure_rate` ≈ 0.15 — "the measured roadmap, not a bug in the
|
||||
bench." The v0.46.15 identity wave closed it (weak-alias + surname lexical
|
||||
arms): the rate is 0.00 on all three harnesses, with `false_fire_rate` and
|
||||
`push_precision` unmoved — the roadmap framing worked exactly as designed.
|
||||
|
||||
## Pre-registered expectations (v1, recorded before the first published run)
|
||||
|
||||
1. The production seam (openclaw) leads `push_recall` strictly: 3-pointer > 2-pointer > 1-fragment budgets. *(Observed at landing: 0.81 / 0.65 / 0.45.)*
|
||||
2. The no-suppression contract (claude-code) is the only seam with `false_fire_rate` > 0. *(Observed: 0.02–0.03.)*
|
||||
1. The production seam (openclaw) leads `push_recall` strictly: 3-pointer > 2-pointer > 1-fragment budgets. *(Observed at landing: 0.81 / 0.65 / 0.45. v0.46.15 identity wave + seam flip: 0.90 / 1.00 / 0.54 — the claude-code row now measures the shipped hook path, whose turn_context assembly (pointers + volunteered pages + real dedupe) outruns the raw pointer budget; the ordering hypothesis applied to the CONTRACT rows and is superseded for flipped rows.)*
|
||||
2. The no-suppression contract (claude-code) is the only seam with `false_fire_rate` > 0. *(Observed: 0.02–0.03. v0.46.15: 0 — the production seam's real transcript dedupe removes the re-injection cost the contract row deliberately exposed.)*
|
||||
3. `write_back_fidelity` = 1.0 and `provenance_accuracy` = 1.0 in deterministic mode — the production pipeline must not lose or mis-attribute gold facts it was handed. Anything below 1.0 is a pipeline bug, not benchmark noise.
|
||||
4. `source_isolation_violations` = 0 everywhere.
|
||||
5. `push_precision` = 1.0 at v1 (exact-match resolution arms cannot inject an irrelevant page on this corpus); expected to dip below 1.0 when fuzzy/semantic resolution lands — that dip is the precision/recall trade made visible.
|
||||
|
||||
The quality floors derived from these expectations are an **executable test**
|
||||
(`test/brainbench-floors.test.ts`), asserted against the committed baseline on
|
||||
every suite run: `know_to_ask_failure_rate` ≤ 0.05, `false_fire_rate` ≤ 0.03,
|
||||
`push_precision` ≥ 0.95, `push_recall` ≥ 0.88 / 0.72 / 0.52
|
||||
(openclaw / claude-code / codex), `source_isolation_violations` = 0 in every
|
||||
cell. A baseline update that violates a floor fails the suite — a threshold
|
||||
violation can no longer be banked by blessing a new baseline.
|
||||
|
||||
## Determinism & statistical posture
|
||||
|
||||
The harness is deterministic end-to-end: regex extraction + SQL resolution
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# How a downstream agent should talk to gbrain
|
||||
|
||||
This guide is for authors of downstream agents (hermes, openclaw, future
|
||||
forks) that need to call gbrain operations from their own runtime. Reading
|
||||
this first will save you a debugging cycle: gbrain has **two distinct
|
||||
This guide is for authors of downstream agents (your OpenClaw, any
|
||||
downstream fork) that need to call gbrain operations from their own runtime.
|
||||
Reading this first will save you a debugging cycle: gbrain has **two distinct
|
||||
surfaces**, and which one you pick depends on the operation.
|
||||
|
||||
## The two surfaces
|
||||
@@ -11,8 +11,8 @@ surfaces**, and which one you pick depends on the operation.
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ gbrain process │
|
||||
│ │
|
||||
Agent (hermes, │ ┌──────────────────┐ ┌────────────────┐ │
|
||||
openclaw, fork) ────┼──▶ MCP ops surface │ │ local-only │ │
|
||||
Agent (OpenClaw, │ ┌──────────────────┐ ┌────────────────┐ │
|
||||
or any fork) ───────┼──▶ MCP ops surface │ │ local-only │ │
|
||||
│ │ (HTTP + OAuth) │ │ commands │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ search, query, │ │ sync, embed, │ │
|
||||
@@ -48,12 +48,27 @@ The host runs gbrain as a long-lived HTTP server:
|
||||
gbrain serve --http --port 3131
|
||||
```
|
||||
|
||||
The agent registers as an OAuth client (one-time):
|
||||
**The packaged path is `gbrain agent register`** (run on the brain host —
|
||||
it is a trusted local operation, never a delegation mechanism). One command
|
||||
mints a scoped OAuth client plus a 30-day access token AND prints the exact
|
||||
wiring block for the target harness:
|
||||
|
||||
```bash
|
||||
gbrain auth register-client hermes \
|
||||
gbrain agent register aurora-coder \
|
||||
--harness claude-code \
|
||||
--preset coding-agent \
|
||||
--federated-read proj-widget \
|
||||
--url https://brain.example.com/mcp
|
||||
```
|
||||
|
||||
The raw primitive underneath is `gbrain auth register-client` (one-time,
|
||||
prints `client_id` + `client_secret` and nothing else — you do the token
|
||||
exchange and the harness wiring yourself):
|
||||
|
||||
```bash
|
||||
gbrain auth register-client aurora-coder \
|
||||
--grant-types client_credentials \
|
||||
--scopes read,write
|
||||
--scopes "read write"
|
||||
# Prints client_id + client_secret one-time. Store securely.
|
||||
```
|
||||
|
||||
@@ -66,6 +81,17 @@ client-credentials wiring, plus the `gbrain` CLI itself routes MCP-eligible
|
||||
commands through the configured remote MCP. The agent can call
|
||||
`gbrain search` / `gbrain query` directly and the CLI does the OAuth dance.
|
||||
|
||||
### Onboarding paths — the decision table
|
||||
|
||||
This is THE onboarding-paths table. Other docs link here; none copy it.
|
||||
|
||||
| Path | When to use | Credential kind | Print vs write | Serve location |
|
||||
|---|---|---|---|---|
|
||||
| `gbrain agent register <name> --harness <h>` | The packaged path: onboarding an agent harness (Claude Code, Codex, opencode, your OpenClaw) onto a shared brain. Presets (`daily-driver`, `coding-agent`), starter tool surface, 30-day token TTL, `--reissue` secret rotation. | Scoped OAuth client + a minted access token (source-scoped, expiring) | PRINTS the harness block (redacted unless `--show-token`); writes nothing to harness configs | Runs ON the brain host against a remote-reachable `gbrain serve --http`; `--url` or `--port` required (a live PGLite serve blocks it by design — stop the serve first; a serve too old to enforce scoped tokens is refused — upgrade it, or pass `--allow-old-serve` to accept the risk) |
|
||||
| `gbrain connect <mcp-url> --token <t>` | You already hold a bearer token and want ONE coding agent pointed at a running serve, from any machine. | Legacy bearer token (full-access unless minted with `--scopes`); `--oauth` variant for OAuth-capable connectors | Prints the add command by default; `--install` runs it | Any machine; targets a remote `gbrain serve --http` |
|
||||
| `gbrain bootstrap harness` | Framework-spawned harnesses (`claude -p` / `codex exec` / `opencode run`) on the SAME box that hosts the brain; wires MCP registration + lifecycle hooks with receipts and mint-first token rotation. | Legacy bearer token, minted per run and rotated by receipt | WRITES managed config blocks (Claude Code user scope, codex TOML, opencode JSONC) + hooks | Local loopback serve on the same box (non-loopback URL requires an explicit supplied token) |
|
||||
| `gbrain auth register-client <name>` | The raw primitive: custom flows — PKCE/authorization-code clients, bound `submit_agent` clients, slug-prefix write fences, provisioning scripts that parse output. | Scoped OAuth client only (no token exchange, no TTL default beyond the server's) | Prints `client_id` + `client_secret` one time; you do all wiring | Credential is server-side state; run on the brain host |
|
||||
|
||||
### Why this is preferred for MCP ops
|
||||
|
||||
- Secrets never leave the server process.
|
||||
|
||||
@@ -133,7 +133,10 @@ gbrain sources list [--json] List all sources with page counts + federation st
|
||||
gbrain sources archive <id> Soft-delete: hide from search, keep data for a TTL
|
||||
grace window. Prefer this over `remove`.
|
||||
gbrain sources restore <id> Un-archive. `gbrain sources archived` lists expiries;
|
||||
`gbrain sources purge` permanently deletes expired archives.
|
||||
`gbrain sources purge` permanently deletes expired archives —
|
||||
except sources still referenced by a registered OAuth client
|
||||
(reported as `Blocked:`, sweep continues); revoke or rescope
|
||||
the client (`gbrain auth revoke-client <id>`) and re-run.
|
||||
gbrain sources remove <id> [--confirm-destructive] [--dry-run]
|
||||
Permanently cascade-delete a source (pages, chunks,
|
||||
timeline). Shows an impact preview first.
|
||||
|
||||
@@ -20,9 +20,15 @@ The push channels share one zero-LLM core (`src/core/context/volunteer.ts`):
|
||||
merged with recency / frequency / user-role salience. Assistant-introduced
|
||||
entities and "what did she invest in?" follow-ups whose antecedent was named
|
||||
in the window now resolve.
|
||||
2. **Resolve** through the alias table, exact titles, and slug suffixes — each
|
||||
arm carries an honest confidence: alias 0.9, exact title 0.8, slug-suffix 0.6,
|
||||
+0.05 when mentioned in ≥2 turns or the newest turn.
|
||||
2. **Resolve** through the alias table, exact titles, surnames, and slug
|
||||
suffixes — each arm carries an honest confidence: alias 0.9, exact title
|
||||
0.8, surname 0.72, slug-suffix 0.6, +0.05 when mentioned in ≥2 turns or the
|
||||
newest turn. Lowercase mentions ("remind me what alice said") probe the
|
||||
alias table only, and only when the alias is unique across every source in
|
||||
play; a surname-only reference ("Did Galewright follow up?") resolves when
|
||||
exactly one person page carries that surname. Ambiguity in either arm
|
||||
injects nothing — silence beats a wrong pointer. Kill switch for both:
|
||||
`retrieval_reflex_lexical_arms` (default on).
|
||||
3. **Gate** at `min_confidence` (default 0.7 — slug-suffix matches need an
|
||||
explicit lower gate), suppress pages already surfaced (slug-presence only),
|
||||
cap at 3 pages (hard cap 5).
|
||||
@@ -97,6 +103,7 @@ Kill switch: `GBRAIN_HOOKS=0`. Install/uninstall: `docs/guides/bootstrap.md`.
|
||||
| `retrieval_reflex_window_turns` | 4 | turns the ambient reflex extracts from; 1 = legacy current-turn-only (file/env plane: `GBRAIN_RETRIEVAL_REFLEX_WINDOW_TURNS`) |
|
||||
| `retrieval_reflex` | true | the ambient channel's master switch |
|
||||
| `retrieval_reflex_max_pointers` | 3 | pointer cap per turn |
|
||||
| `retrieval_reflex_lexical_arms` | true | the lowercase-alias + surname recall arms (env: `GBRAIN_RETRIEVAL_REFLEX_LEXICAL_ARMS`); off = pre-v0.46.15 arm set |
|
||||
|
||||
Per-call knobs: `max_pages` + `min_confidence` on both the op and `gbrain watch`
|
||||
(`--max-pages` / `--min-confidence`, plus `--window-turns` / `--source` on watch);
|
||||
|
||||
@@ -25,7 +25,7 @@ The resolved provider + dimensions get persisted to `~/.gbrain/config.json` atom
|
||||
|---|---|---|---|---|---|
|
||||
| `voyage` (**default** — `voyage-4` @ 1024d; `rerank-2.5` reranker on the same key) | `VOYAGE_API_KEY` | 1024 | 0.06 (`voyage-4`) | no | yes (`voyage-multimodal-3`) |
|
||||
| `openai` | `OPENAI_API_KEY` | 1536 | 0.13 | no | no |
|
||||
| `openrouter` | `OPENROUTER_API_KEY` | 1536 | 0.02 | no | model-dependent |
|
||||
| `openrouter` | `OPENROUTER_API_KEY` | per-model (1536 for the default `openai/text-embedding-3-small`; unlisted ids require explicit dims) | 0.02 | no | model-dependent |
|
||||
| `zeroentropyai` — **DEPRECATED** (hosted API **shuts down 2026-09-04**; replacement `voyage:voyage-4` — see note below) | `ZEROENTROPY_API_KEY` | 2560 (Matryoshka to 1280/640/320/...) | 0.05 | no | no |
|
||||
| `google` | `GOOGLE_GENERATIVE_AI_API_KEY` | 768 | 0.025 | no | no |
|
||||
| `azure-openai` | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_DEPLOYMENT` | 1536 | 0.13 | no | no |
|
||||
@@ -112,7 +112,7 @@ For GCP service-account / Vertex AI auth (production deployments), see the v0.32
|
||||
|
||||
Single OpenAI-compatible API for fan-out to OpenAI, Anthropic, Google, DeepSeek, Meta Llama, Qwen, and dozens of other hosted providers. One key, many models. Set `OPENROUTER_API_KEY` or `openrouter_api_key` in `~/.gbrain/config.json`, then use `openrouter:<provider>/<model>` (e.g. `openrouter:openai/gpt-5.2`, `openrouter:anthropic/claude-sonnet-4.6`).
|
||||
|
||||
**Embedding**: `openai/text-embedding-3-small` (1536d default, Matryoshka shrink to 512/768/1024). OR's embedding catalog also includes `text-embedding-3-large`, `google/gemini-embedding-2-preview`, `qwen/qwen3-embedding-8b`, `bge-m3` — opt in via `--embedding-model openrouter:<id>`. Pricing matches the upstream provider (OR adds a small markup).
|
||||
**Embedding**: `openai/text-embedding-3-small` (1536d default, Matryoshka shrink to 512/768/1024). The recipe carries verified per-model native dims for its catalog — `openai/text-embedding-3-large` (3072), `qwen/qwen3-embedding-8b` (4096), `bge-m3` (1024) — so opting in via `--embedding-model openrouter:<id>` plans the right column width automatically. Any id NOT in that list (including `google/gemini-embedding-2-preview`, whose width is unverified) has no silent default: you must pass explicit dimensions (`--embedding-dimensions <N>` or `embedding_dimensions` config) or the command errors with the fix. Pricing matches the upstream provider (OR adds a small markup).
|
||||
|
||||
**Chat**: every chat model OR proxies works through `/v1/chat/completions`. The recipe lists 8 curated entry points (GPT-5.2 family, Claude 4.5/4.6/4.7, Gemini 3 Flash Preview, DeepSeek); any other OR catalog ID also works. Tool-calling envelope is supported by the OR endpoint, but per-model capability varies — check https://openrouter.ai/models before counting on tools for a specific slug.
|
||||
|
||||
|
||||
@@ -248,6 +248,49 @@ Bob should see the performance-review notes from `internal`, plus anything relat
|
||||
|
||||
If both queries return correctly scoped results, isolation is working. (There is no per-query "act as client X" flag — the thin-client config decides which credential the CLI uses; only the client secret can be overridden at call time via `GBRAIN_REMOTE_CLIENT_SECRET`.)
|
||||
|
||||
### Multi-agent: one brain, many agents (`gbrain agent register`)
|
||||
|
||||
The raw `register-client` flow above is the per-teammate primitive. When the client you're onboarding is an **AI agent harness** (a teammate's Claude Code, a coding agent working a project repo, your OpenClaw), there's a packaged one-command path: `gbrain agent register` mints the scoped OAuth client, mints a 30-day access token, and prints the exact wiring block for the harness — all in one step. It runs on the brain host and is a trusted local operation — not a delegation mechanism. (When to use which path lives in [the onboarding decision table](../guides/agent-to-gbrain.md#onboarding-paths--the-decision-table) — link there, it's the single copy.)
|
||||
|
||||
Two presets cover the common shapes, with semantics worth knowing honestly:
|
||||
|
||||
- **`daily-driver`** — a personal assistant agent: writes to one source, reads broadly. The read grant is a **snapshot** of all non-archived sources at registration time, excluding other agents' `*-workspace` scratch sources (name one explicitly in `--federated-read` to share it) — a source you add next month is NOT automatically readable; re-grant with `gbrain auth rescope-client <client_id> --federated-read <updated list>`.
|
||||
- **`coding-agent`** — a write-isolated project agent: its writes land in an auto-created, DB-only `<name>-workspace` source (so a misbehaving agent can't scribble on your wiki), and it reads only the project sources you name via `--federated-read` (required — a coding agent that can read nothing but its own scratch space is a misconfiguration).
|
||||
|
||||
Both presets start the client on the **starter** tool surface (the ~27-op daily set, not the full brain-admin surface). Override at registration with `--surface`, or widen a specific client later with `gbrain auth rescope-client <client_id> --surface full`.
|
||||
|
||||
A worked example — a coding agent for alice-example's widget project, wired into Claude Code:
|
||||
|
||||
```bash
|
||||
# On the brain host. proj-widget is the project source it may read
|
||||
# (create it first with `gbrain sources add proj-widget` if needed).
|
||||
gbrain agent register aurora-coder \
|
||||
--harness claude-code \
|
||||
--preset coding-agent \
|
||||
--federated-read proj-widget,shared \
|
||||
--url https://brain.acme-co.com/mcp
|
||||
```
|
||||
|
||||
The output prints the client id, the resolved scoping (write source `aurora-coder-workspace`, federated reads, surface tier, token expiry), and a paste-ready block for the harness. Credentials print redacted by default; re-run with `--show-token` when you're ready to paste, or use `--json` for provisioning scripts. A `daily-driver` for yourself looks like `gbrain agent register nova-daily --harness claude-code --preset daily-driver --url https://brain.acme-co.com/mcp`.
|
||||
|
||||
Verify the new agent's scoping the same way you verified teammates above — a thin-client install acting as that client (`--force` overwrites the scratch config from the previous check):
|
||||
|
||||
```bash
|
||||
# On a machine that is NOT the brain host (or the same scratch shell)
|
||||
gbrain init --mcp-only --force \
|
||||
--issuer-url https://brain.acme-co.com \
|
||||
--mcp-url https://brain.acme-co.com/mcp \
|
||||
--oauth-client-id <aurora-coder's client_id> \
|
||||
--oauth-client-secret <aurora-coder's client_secret>
|
||||
|
||||
gbrain whoami
|
||||
gbrain search "widget launch plan"
|
||||
```
|
||||
|
||||
`gbrain whoami` should name the aurora-coder client; the search should return results only from `proj-widget`, `shared`, and its own workspace.
|
||||
|
||||
**Renewal.** The minted access token defaults to a 30-day TTL (registration always writes a per-client TTL — the server default for CLI-minted tokens is one hour, which would be useless in a pasted config). When a token expires, rotate with `gbrain agent register --reissue <client_id> --harness claude-code --url https://brain.acme-co.com/mcp`: it rotates the client secret, mints a fresh token, and reprints the block. Rotation is not revocation — outstanding access tokens stay valid until they expire; revoke the client (`gbrain auth revoke-client <client_id>`) to kill them immediately.
|
||||
|
||||
---
|
||||
|
||||
## Part 6: Set up per-person crons
|
||||
@@ -412,7 +455,7 @@ The thin-client install creates a local config that knows how to talk to your br
|
||||
|
||||
**2. Their AI client, connected directly to `https://brain.acme-co.com/mcp`.** Each client has its own connection shape; the per-client pages in [`docs/mcp/`](../mcp/) are the reference:
|
||||
|
||||
- **Claude Code / Codex** — one command from anywhere `gbrain` is installed: `gbrain connect https://brain.acme-co.com/mcp --token <token> --install` (see [CLAUDE_CODE.md](../mcp/CLAUDE_CODE.md) / [CODEX.md](../mcp/CODEX.md)). Note the credential type: `gbrain connect` for these two agents uses **bearer tokens** (`gbrain auth create <name>`), which are full-access. That's fine for you as the admin; for source-scoped teammates, the scoped credential is their OAuth client — use it via the thin-client CLI above and the OAuth-capable clients below.
|
||||
- **Claude Code / Codex** — the scoped path is `gbrain agent register <name> --harness claude-code|codex --url https://brain.acme-co.com/mcp` run on the brain host (the Part 5 multi-agent subsection): it mints a source-scoped OAuth client plus a 30-day token and prints the exact paste block for the harness. The older `gbrain connect https://brain.acme-co.com/mcp --token <token> --install` lane (see [CLAUDE_CODE.md](../mcp/CLAUDE_CODE.md) / [CODEX.md](../mcp/CODEX.md)) still works but uses **bearer tokens** (`gbrain auth create <name>`), which are full-access unless minted with `--scopes` — fine for you as the admin, wrong for source-scoped teammates.
|
||||
- **Claude Desktop** — remote servers are added through the GUI: **Settings > Integrations**, URL `https://brain.acme-co.com/mcp`. Do **not** put a remote server in `claude_desktop_config.json`; that file only works for local stdio servers and fails silently for remote ones. See [CLAUDE_DESKTOP.md](../mcp/CLAUDE_DESKTOP.md).
|
||||
- **ChatGPT** ([CHATGPT.md](../mcp/CHATGPT.md)) and **Perplexity** ([PERPLEXITY.md](../mcp/PERPLEXITY.md)) — both speak OAuth to the server directly, so per-teammate scoping carries into those tools. Perplexity uses the same `client_credentials` clients you registered in Part 5. ChatGPT needs an `authorization_code` (PKCE) client — register one per teammate with the same `--source` / `--federated-read` flags.
|
||||
- **OpenClaw / Hermes forks** — if the teammate's own agent runs on a machine with a full local gbrain install, it can use local stdio (`gbrain serve`) against its own brain and reach yours over HTTP MCP like any other remote client.
|
||||
|
||||
@@ -65,7 +65,12 @@ documented future extension.
|
||||
|
||||
Hand-authored spike fixtures (`kta-001`, `kta-002`, `ms-001`, `wb-001`,
|
||||
`cont-001-*`) froze the schema before the generator scaled it; they remain part
|
||||
of the corpus.
|
||||
of the corpus. `ms-002-nondefault-active` is hand-authored too: it is the only
|
||||
fixture with a non-`default` `active_source` (the generator pins gen-ms
|
||||
fixtures to `active_source: default`), exercising the cross-source leak
|
||||
detector's other arm — a twin slug seeded into `default` AND `teambrain`, a
|
||||
teambrain-only page, and a default-only leak canary, replayed with
|
||||
`active_source: teambrain`.
|
||||
|
||||
## Fixture authoring (contributions welcome)
|
||||
|
||||
|
||||
@@ -22,5 +22,18 @@
|
||||
"date": "2026-06-12",
|
||||
"agreement": 0.964,
|
||||
"findings": "continuity-writer rationale-clause drift (5 gold files) fixed in this corpus version; wb-001 MRR fact added; conventions documented in README"
|
||||
},
|
||||
"hand_authored": {
|
||||
"fixtures": 7,
|
||||
"ids": [
|
||||
"cont-001-widget-pass-reader",
|
||||
"cont-001-widget-pass-writer",
|
||||
"kta-001-deal-recall",
|
||||
"kta-002-quiet-smalltalk",
|
||||
"ms-001-two-source-alias",
|
||||
"ms-002-nondefault-active",
|
||||
"wb-001-pricing-concern"
|
||||
],
|
||||
"note": "Outside the generator contract (drift guard counts gen-* files only). ms-002-nondefault-active is the sole non-default active_source fixture, added to exercise the harness cross-source leak detector's non-default arm. gen.ts rewrites this file on regen; re-add this block if it drops."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"fixtures_hash": "76f201590dd3ad7a929e2e12efc9bf1406627b10ef4edbcfe7caf379aafd4090",
|
||||
"fixtures_hash": "509fd20d7cda693350030393b6d54154e2685516d30f017ca219bd25e92c0e57",
|
||||
"config": {
|
||||
"include_holdout": false,
|
||||
"llm": false,
|
||||
@@ -16,22 +16,23 @@
|
||||
"write-back"
|
||||
]
|
||||
},
|
||||
"justification": "Identity-resolution wave (lowercase-alias + surname lexical arms) + claude-code production seam, re-banked after merging master's expanded fixture corpus (v0.46.12.3–v0.46.14.0 waves): kta 0.1452→0 on all three harnesses, push_recall 0.8125/0.6667/0.4583→0.9063/1.0/0.5521 on the 96-gold corpus.",
|
||||
"cells": {
|
||||
"claude-code/continuity": {
|
||||
"avg_injected_tokens": 75.3333,
|
||||
"avg_injected_tokens": 59.9167,
|
||||
"continuity_rate": 1,
|
||||
"source_isolation_violations": 0
|
||||
},
|
||||
"claude-code/know-to-ask": {
|
||||
"avg_injected_tokens": 30.9247,
|
||||
"false_fire_rate": 0.0233,
|
||||
"know_to_ask_failure_rate": 0.15,
|
||||
"avg_injected_tokens": 37.8389,
|
||||
"false_fire_rate": 0,
|
||||
"know_to_ask_failure_rate": 0,
|
||||
"source_isolation_violations": 0
|
||||
},
|
||||
"claude-code/push": {
|
||||
"avg_injected_tokens": 38.8077,
|
||||
"avg_injected_tokens": 50.1963,
|
||||
"push_precision": 1,
|
||||
"push_recall": 0.6596,
|
||||
"push_recall": 1,
|
||||
"source_isolation_violations": 0
|
||||
},
|
||||
"claude-code/write-back": {
|
||||
@@ -44,15 +45,15 @@
|
||||
"source_isolation_violations": 0
|
||||
},
|
||||
"codex/know-to-ask": {
|
||||
"avg_injected_tokens": 40.7123,
|
||||
"avg_injected_tokens": 45.2215,
|
||||
"false_fire_rate": 0,
|
||||
"know_to_ask_failure_rate": 0.15,
|
||||
"know_to_ask_failure_rate": 0,
|
||||
"source_isolation_violations": 0
|
||||
},
|
||||
"codex/push": {
|
||||
"avg_injected_tokens": 48.5865,
|
||||
"avg_injected_tokens": 54.6449,
|
||||
"push_precision": 1,
|
||||
"push_recall": 0.4468,
|
||||
"push_recall": 0.5521,
|
||||
"source_isolation_violations": 0
|
||||
},
|
||||
"codex/write-back": {
|
||||
@@ -65,15 +66,15 @@
|
||||
"source_isolation_violations": 0
|
||||
},
|
||||
"openclaw/know-to-ask": {
|
||||
"avg_injected_tokens": 33.7945,
|
||||
"avg_injected_tokens": 38.2752,
|
||||
"false_fire_rate": 0,
|
||||
"know_to_ask_failure_rate": 0.15,
|
||||
"know_to_ask_failure_rate": 0,
|
||||
"source_isolation_violations": 0
|
||||
},
|
||||
"openclaw/push": {
|
||||
"avg_injected_tokens": 44.1346,
|
||||
"avg_injected_tokens": 50.0841,
|
||||
"push_precision": 1,
|
||||
"push_recall": 0.8085,
|
||||
"push_recall": 0.9063,
|
||||
"source_isolation_violations": 0
|
||||
},
|
||||
"openclaw/write-back": {
|
||||
@@ -87,12 +88,12 @@
|
||||
"gold_failed": 0
|
||||
},
|
||||
"claude-code/know-to-ask": {
|
||||
"gold_total": 146,
|
||||
"gold_failed": 11
|
||||
"gold_total": 149,
|
||||
"gold_failed": 0
|
||||
},
|
||||
"claude-code/push": {
|
||||
"gold_total": 94,
|
||||
"gold_failed": 32
|
||||
"gold_total": 96,
|
||||
"gold_failed": 0
|
||||
},
|
||||
"claude-code/write-back": {
|
||||
"gold_total": 58,
|
||||
@@ -103,12 +104,12 @@
|
||||
"gold_failed": 0
|
||||
},
|
||||
"codex/know-to-ask": {
|
||||
"gold_total": 146,
|
||||
"gold_failed": 9
|
||||
"gold_total": 149,
|
||||
"gold_failed": 0
|
||||
},
|
||||
"codex/push": {
|
||||
"gold_total": 94,
|
||||
"gold_failed": 52
|
||||
"gold_total": 96,
|
||||
"gold_failed": 43
|
||||
},
|
||||
"codex/write-back": {
|
||||
"gold_total": 58,
|
||||
@@ -119,12 +120,12 @@
|
||||
"gold_failed": 0
|
||||
},
|
||||
"openclaw/know-to-ask": {
|
||||
"gold_total": 146,
|
||||
"gold_failed": 9
|
||||
"gold_total": 149,
|
||||
"gold_failed": 0
|
||||
},
|
||||
"openclaw/push": {
|
||||
"gold_total": 94,
|
||||
"gold_failed": 18
|
||||
"gold_total": 96,
|
||||
"gold_failed": 9
|
||||
},
|
||||
"openclaw/write-back": {
|
||||
"gold_total": 58,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"fixture_id": "ms-002-nondefault-active",
|
||||
"suites": ["know-to-ask", "push"],
|
||||
"category": "multi-source",
|
||||
"sources": ["teambrain"],
|
||||
"active_source": "teambrain",
|
||||
"seed_pages": [
|
||||
{
|
||||
"slug": "people/bianca-example",
|
||||
"content": "---\ntitle: Bianca Example\ntype: person\naliases: [bianca]\nsummary: Founder of Latticework Co (personal notes).\n---\n\nBianca Example is the founder of Latticework Co. Personal-source twin copy — must not be the one graded while the team brain is active.\n"
|
||||
},
|
||||
{
|
||||
"slug": "people/bianca-example",
|
||||
"source_id": "teambrain",
|
||||
"content": "---\ntitle: Bianca Example\ntype: person\naliases: [bianca]\nsummary: Candidate profile in the team brain.\n---\n\nBianca Example, candidate profile. TEAM BRAIN copy — the active source's copy for this fixture.\n"
|
||||
},
|
||||
{
|
||||
"slug": "projects/orbit-example",
|
||||
"source_id": "teambrain",
|
||||
"content": "---\ntitle: Orbit Example\ntype: project\nsummary: Team-brain-only project page.\n---\n\nOrbit Example exists ONLY in the team brain. Injecting it while teambrain is active is correct, not a leak.\n"
|
||||
},
|
||||
{
|
||||
"slug": "companies/widgetline-example",
|
||||
"content": "---\ntitle: Widgetline Example\ntype: company\nsummary: Personal-brain-only company page.\n---\n\nWidgetline Example exists ONLY in the personal source. It must never surface while the team brain is active.\n"
|
||||
}
|
||||
],
|
||||
"turns": [
|
||||
{ "turn_id": 1, "role": "user", "text": "What do we know about Bianca Example?" },
|
||||
{ "turn_id": 2, "role": "user", "text": "What do I know about Orbit Example?" },
|
||||
{ "turn_id": 3, "role": "user", "text": "Anything on Widgetline Example in here?" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"fixture_id": "ms-002-nondefault-active",
|
||||
"turns": {
|
||||
"1": {
|
||||
"should_retrieve": true,
|
||||
"gold_slugs": ["people/bianca-example"]
|
||||
},
|
||||
"2": {
|
||||
"should_retrieve": true,
|
||||
"gold_slugs": ["projects/orbit-example"]
|
||||
},
|
||||
"3": {
|
||||
"should_retrieve": false
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
-5
@@ -865,7 +865,11 @@ Before any ship, read **[docs/RELEASING.md](docs/RELEASING.md)** in full. It car
|
||||
full release + contributor process: pre-ship test requirements (`bun run ci:local` / the
|
||||
E2E lifecycle), the CHANGELOG voice + release-summary template, the "To take advantage of
|
||||
vX" self-repair block, version migrations, the GitHub Actions SHA refresh, PR conventions,
|
||||
and the community-PR-wave process. **Use `/ship` — never hand-roll a release.**
|
||||
and the community-PR-wave process. **Use `/ship` — never hand-roll a release.** Every
|
||||
community wave runs `bun run wave-security-scan <base>..<head>` (RELEASING.md step 5) before
|
||||
ship — the repeatable mechanical sweep (obfuscation/eval, gitleaks with the test/skills
|
||||
allowlist stripped, committed `admin/dist` changes as alarms; new endpoints/spawns/env/deps
|
||||
as context).
|
||||
|
||||
The ship-critical IRON RULES stay inline in this file (do NOT relocate them): the
|
||||
Version-locations table above (the 5-file sync + the 3-line VERSION/package.json/CHANGELOG
|
||||
@@ -1819,6 +1823,8 @@ gbrain connect https://your-host/mcp --token gbrain_xxx --install
|
||||
gbrain connect https://your-host/mcp --token gbrain_xxx --agent codex --install # Codex
|
||||
```
|
||||
|
||||
Onboarding a whole agent harness onto a shared brain? On the brain host, `gbrain agent register <name> --harness claude-code` mints a scoped OAuth client plus a 30-day token and prints the paste-ready wiring block — presets for daily-driver and write-isolated coding agents. The [onboarding decision table](docs/guides/agent-to-gbrain.md#onboarding-paths--the-decision-table) says which path fits.
|
||||
|
||||
**Brain-only install into another coding agent** (Cursor, Claude Cowork, or anything that can fetch a URL and run shell commands) — paste the OpenClaw/Hermes block above (`INSTALL_FOR_AGENTS.md`); it installs the brain, skills, and dream cycle without the personal-agent identity layer. Tested with Codex, Claude Code, Claude Cowork, Cursor, and AlphaClaw.
|
||||
|
||||
**[→ Full walkthrough: give your coding agent a memory](docs/tutorials/connect-coding-agent.md)** — the memory-only paths end to end, plus the brain-first protocol you paste into `CLAUDE.md` / `AGENTS.md` and the four habits that make it actually change how you work.
|
||||
@@ -1918,6 +1924,7 @@ re-runs are free — unchanged sessions skip on content hash:
|
||||
gbrain transcripts ingest # discover importable session logs
|
||||
gbrain transcripts ingest --all # import everything discovered
|
||||
gbrain transcripts ingest ~/Downloads/conversations.json # consumer export (unzip first)
|
||||
gbrain transcripts ingest --max-bytes 4gb <store> # oversized store; omit to keep per-format caps
|
||||
gbrain transcripts status # found vs imported, per harness
|
||||
```
|
||||
|
||||
@@ -2165,7 +2172,7 @@ the page PK, soft-delete-filtered, source-safe) and completes in seconds.
|
||||
- [`AGENTS.md`](AGENTS.md) — entry point for non-Claude agents
|
||||
- [`CLAUDE.md`](CLAUDE.md) — entry point for Claude Code (deep operating context)
|
||||
- [`CONTRIBUTING.md`](CONTRIBUTING.md) — contributor guide, test discipline, eval-capture mode
|
||||
- [`SECURITY.md`](SECURITY.md) — OAuth threat model, hardening defaults
|
||||
- [`SECURITY.md`](SECURITY.md) — install-path trust model, self-update integrity, automated scanning, OAuth threat model, hardening defaults
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -4012,9 +4019,15 @@ The push channels share one zero-LLM core (`src/core/context/volunteer.ts`):
|
||||
merged with recency / frequency / user-role salience. Assistant-introduced
|
||||
entities and "what did she invest in?" follow-ups whose antecedent was named
|
||||
in the window now resolve.
|
||||
2. **Resolve** through the alias table, exact titles, and slug suffixes — each
|
||||
arm carries an honest confidence: alias 0.9, exact title 0.8, slug-suffix 0.6,
|
||||
+0.05 when mentioned in ≥2 turns or the newest turn.
|
||||
2. **Resolve** through the alias table, exact titles, surnames, and slug
|
||||
suffixes — each arm carries an honest confidence: alias 0.9, exact title
|
||||
0.8, surname 0.72, slug-suffix 0.6, +0.05 when mentioned in ≥2 turns or the
|
||||
newest turn. Lowercase mentions ("remind me what alice said") probe the
|
||||
alias table only, and only when the alias is unique across every source in
|
||||
play; a surname-only reference ("Did Galewright follow up?") resolves when
|
||||
exactly one person page carries that surname. Ambiguity in either arm
|
||||
injects nothing — silence beats a wrong pointer. Kill switch for both:
|
||||
`retrieval_reflex_lexical_arms` (default on).
|
||||
3. **Gate** at `min_confidence` (default 0.7 — slug-suffix matches need an
|
||||
explicit lower gate), suppress pages already surfaced (slug-presence only),
|
||||
cap at 3 pages (hard cap 5).
|
||||
@@ -4089,6 +4102,7 @@ Kill switch: `GBRAIN_HOOKS=0`. Install/uninstall: `docs/guides/bootstrap.md`.
|
||||
| `retrieval_reflex_window_turns` | 4 | turns the ambient reflex extracts from; 1 = legacy current-turn-only (file/env plane: `GBRAIN_RETRIEVAL_REFLEX_WINDOW_TURNS`) |
|
||||
| `retrieval_reflex` | true | the ambient channel's master switch |
|
||||
| `retrieval_reflex_max_pointers` | 3 | pointer cap per turn |
|
||||
| `retrieval_reflex_lexical_arms` | true | the lowercase-alias + surname recall arms (env: `GBRAIN_RETRIEVAL_REFLEX_LEXICAL_ARMS`); off = pre-v0.46.15 arm set |
|
||||
|
||||
Per-call knobs: `max_pages` + `min_confidence` on both the op and `gbrain watch`
|
||||
(`--max-pages` / `--min-confidence`, plus `--window-turns` / `--source` on watch);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "gbrain-context-engine",
|
||||
"name": "gbrain",
|
||||
"version": "0.46.12.2",
|
||||
"version": "0.46.15.0",
|
||||
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
|
||||
"family": "bundle-plugin",
|
||||
"configSchema": {
|
||||
|
||||
+3
-1
@@ -37,6 +37,7 @@
|
||||
"build:admin-embedded": "bun run scripts/build-admin-embedded.ts",
|
||||
"build:schema": "bash scripts/build-schema.sh",
|
||||
"build:llms": "bun run scripts/build-llms.ts",
|
||||
"wave-security-scan": "bash scripts/wave-security-scan.sh",
|
||||
"build:flag-registry": "bun run scripts/generate-flag-registry.ts",
|
||||
"build:pglite-snapshot": "bun run scripts/build-pglite-snapshot.ts",
|
||||
"test": "bash scripts/run-unit-parallel.sh",
|
||||
@@ -62,6 +63,7 @@
|
||||
"check:wasm": "bash scripts/check-wasm-embedded.sh",
|
||||
"check:pglite-embedded": "bash scripts/check-pglite-embedded.sh",
|
||||
"check:newlines": "bash scripts/check-trailing-newline.sh",
|
||||
"test:compile-smoke": "GBRAIN_SELFUPDATE_COMPILE_SMOKE=1 bun test test/binary-self-update-compiled.serial.test.ts",
|
||||
"test:e2e": "bash scripts/run-e2e.sh",
|
||||
"test:slow": "bash scripts/run-slow-tests.sh",
|
||||
"test:heavy": "bash scripts/run-heavy.sh",
|
||||
@@ -168,7 +170,7 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.46.12.2",
|
||||
"version": "0.46.15.0",
|
||||
"overrides": {
|
||||
"@hono/node-server": "^2.0.5",
|
||||
"fast-uri": "^3.1.5",
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
<!-- gbrain-plugin-tree-stamp: 0.46.12.2 -->
|
||||
<!-- gbrain-plugin-tree-stamp: 0.46.15.0 -->
|
||||
# gbrain plugin skill tree (generated — do not hand-edit)
|
||||
|
||||
This tree is the curated skill set for the gbrain Codex and Claude Code
|
||||
|
||||
@@ -71,9 +71,15 @@ procedure whenever the source is one of those six formats:
|
||||
```
|
||||
gbrain transcripts ingest ~/Downloads/conversations.json # unzip first
|
||||
gbrain transcripts ingest # discover harness logs
|
||||
gbrain transcripts ingest --max-bytes 4gb <store> # oversized store (omit = per-format caps)
|
||||
gbrain transcripts status # found vs imported gaps
|
||||
```
|
||||
|
||||
`--max-bytes` note: the cap is part of the `--since last` checkpoint
|
||||
fingerprint — running with a different cap (or dropping it) starts a fresh
|
||||
watermark scope, so a capped run's skipped tail is never mistaken for
|
||||
already-scanned.
|
||||
|
||||
Native-vs-manual delta to know: the native lane redacts SECRETS (key
|
||||
patterns) plus your `~/.gbrain/harvest-private-patterns.txt` regexes and
|
||||
counts agent-directed imperatives into frontmatter, but broad PII detection
|
||||
|
||||
@@ -294,6 +294,9 @@ Populate them periodically or after major imports:
|
||||
- `gbrain stats` — verify `link_count > 0` and `timeline_entry_count > 0` after extraction.
|
||||
- `gbrain health` — review `link_coverage` and `timeline_coverage` percentages
|
||||
on entity pages (person/company). Below 50% means more extraction is needed.
|
||||
On brains with very few entity pages these report "too few to grade"
|
||||
(`null` in JSON, with `entity_page_count` carrying the denominator) instead
|
||||
of a misleading 0%/100% — grow the entity set before acting on coverage.
|
||||
|
||||
Available link types (use with `gbrain graph-query --type`):
|
||||
`attended`, `works_at`, `invested_in`, `founded`, `advises`, `mentions`, `source`.
|
||||
|
||||
@@ -132,6 +132,11 @@ Continue with the existing `gbrain init --supabase` / `--pglite` setup below.
|
||||
`gbrain remote doctor` (Tier B convenience commands) call MCP ops with
|
||||
`admin` scope. `read,write` alone breaks ping/doctor.
|
||||
|
||||
For agent harnesses (Claude Code, Codex, opencode, OpenClaw), the host
|
||||
operator can instead run `gbrain agent register` — it mints the scoped
|
||||
client AND prints the paste-ready harness config in one step (see
|
||||
https://github.com/garrytan/gbrain/blob/master/docs/guides/agent-to-gbrain.md).
|
||||
|
||||
3. **Run thin-client init on this machine:**
|
||||
```bash
|
||||
gbrain init --mcp-only \
|
||||
|
||||
@@ -5,31 +5,31 @@
|
||||
# Columns: path max_lines policy note
|
||||
src/commands/doctor.ts 4270 ratchet peel target: containment sprint C8-C13; grown v0.46.11.0 five-issue wave
|
||||
src/core/operations.ts 303 ratchet peel target: containment sprint C4-C7
|
||||
src/core/postgres-engine.ts 5770 ratchet peel target: containment sprint C15; grown v0.46.11.0 five-issue wave
|
||||
src/core/pglite-engine.ts 5660 ratchet peel target: containment sprint C15; grown v0.46.11.0 five-issue wave
|
||||
src/core/postgres-engine.ts 5807 ratchet peel target: containment sprint C15; grown v0.46.11.0 five-issue wave + retrieval wave
|
||||
src/core/pglite-engine.ts 5691 ratchet peel target: containment sprint C15; grown v0.46.11.0 five-issue wave + retrieval wave
|
||||
src/core/migrate.ts 668 region-exempt append-only MIGRATIONS array grows freely; runner logic is ratcheted
|
||||
src/commands/sync.ts 4300 ratchet peel target: containment sprint C13-C14; grown v0.46.11.0 five-issue wave
|
||||
src/core/ai/gateway.ts 4117 ratchet watchlist
|
||||
src/cli.ts 3385 ratchet watchlist; +21 gap-closure wave: thin-client routing call sites (logic in commands/thin-client-routing.ts)
|
||||
src/core/cycle.ts 2933 ratchet
|
||||
src/commands/serve-http.ts 2836 ratchet
|
||||
src/core/ai/gateway.ts 4168 ratchet watchlist
|
||||
src/cli.ts 3453 ratchet watchlist; +21 gap-closure wave thin-client routing; +cathedral-6 agent-register pre-connect guards + `--`-aware help + shared thin-client message import; +sources self-help (chennai wave)
|
||||
src/core/cycle.ts 3024 ratchet
|
||||
src/commands/serve-http.ts 2978 ratchet grown cathedral-6 multi-agent wave (admin register route composes registerScopedClient in one tx under the name advisory lock; ttl validation + brain_too_old preflight) + chennai no-grant federated scope
|
||||
src/commands/jobs.ts 2950 ratchet grown v0.46.11.0 five-issue wave
|
||||
src/core/search/hybrid.ts 2479 ratchet
|
||||
src/core/engine.ts 2343 ratchet
|
||||
src/core/search/hybrid.ts 2500 ratchet
|
||||
src/core/engine.ts 2345 ratchet
|
||||
src/commands/autopilot.ts 2301 ratchet
|
||||
src/commands/extract.ts 2161 ratchet
|
||||
src/commands/extract-conversation-facts.ts 1968 ratchet
|
||||
src/core/import-file.ts 2000 ratchet grown v0.46.11.0 five-issue wave
|
||||
src/core/cycle/synthesize.ts 2685 ratchet grown v0.46.11.0 five-issue wave
|
||||
src/commands/embed.ts 1963 ratchet
|
||||
src/core/types.ts 1829 ratchet
|
||||
src/core/types.ts 1863 ratchet
|
||||
src/commands/skillpack.ts 1763 ratchet
|
||||
src/core/minions/queue.ts 2130 ratchet grown v0.46.11.0 five-issue wave
|
||||
src/commands/init.ts 1932 ratchet
|
||||
src/commands/integrations.ts 1675 ratchet
|
||||
src/core/minions/handlers/subagent.ts 1643 ratchet
|
||||
src/core/minions/handlers/subagent.ts 1773 ratchet
|
||||
src/commands/bootstrap.ts 1923 ratchet grandfathered at merge (grew past the 1500 cap on master)
|
||||
src/core/minions/worker.ts 1560 ratchet grandfathered at merge (grew past the 1500 cap on master, #4170); grown v0.46.11.0 five-issue wave
|
||||
src/commands/sources.ts 1586 ratchet
|
||||
src/commands/sources.ts 1676 ratchet
|
||||
src/core/bootstrap/harness.ts 1947 ratchet
|
||||
src/commands/hook.ts 1525 ratchet
|
||||
src/commands/hook.ts 1551 ratchet
|
||||
|
||||
|
+5
-1
@@ -227,13 +227,17 @@ for f in "${files[@]}"; do
|
||||
# assertion output, which reads like a mystery failure. CI runs those
|
||||
# files in their own job WITHOUT this wrapper (see .github/workflows/
|
||||
# e2e.yml tier2), so the cap only ever bit local runs: give them 4x.
|
||||
# serve-http-multi-agent rides the same carve-out for a different reason:
|
||||
# it spawns TWO `gbrain serve --http` subprocesses (19133 + a chaos serve
|
||||
# on 19134) plus several CLI register subprocesses, so its wall clock is
|
||||
# process-spawn-bound, not test-bound.
|
||||
file_timeout="${GBRAIN_E2E_FILE_TIMEOUT:-${E2E_FILE_TIMEOUT_SECS:-180}}"
|
||||
# Digits-only validation (same strict positive-int posture as the TS env
|
||||
# knobs): a malformed value falls back to the default instead of
|
||||
# word-splitting into extra gtimeout arguments or breaking the 4x math.
|
||||
case "$file_timeout" in ''|*[!0-9]*) file_timeout=180 ;; esac
|
||||
case "$f" in
|
||||
*/skills.test.ts|*/zeroentropy-live.test.ts) file_timeout=$((file_timeout * 4)) ;;
|
||||
*/skills.test.ts|*/zeroentropy-live.test.ts|*/serve-http-multi-agent.test.ts) file_timeout=$((file_timeout * 4)) ;;
|
||||
esac
|
||||
if command -v gtimeout >/dev/null 2>&1; then
|
||||
TIMEOUT_CMD="gtimeout $file_timeout"
|
||||
|
||||
@@ -204,7 +204,15 @@ async function main(): Promise<number> {
|
||||
// dotfiles and Bun-auto-loaded .env files can't reroute the brain.
|
||||
const child = spawnSync(
|
||||
process.execPath,
|
||||
[join(ROOT, 'src', 'cli.ts'), 'eval', 'gate', '--qrels', QRELS_PATH, '--embedder', 'deterministic', '--json'],
|
||||
[
|
||||
join(ROOT, 'src', 'cli.ts'), 'eval', 'gate', '--qrels', QRELS_PATH, '--embedder', 'deterministic', '--json',
|
||||
// v0.46.8: the corpus grew concept-paraphrase queries (q13/q14) and
|
||||
// the pre-wave observed rate was 10/12 — raise the expected_top1
|
||||
// floor from the loose default (0.50) to 0.85 so a single-query
|
||||
// top-1 regression (incl. a concept-weight regression) actually
|
||||
// FAILS the gate instead of coasting on the old floor.
|
||||
'--threshold-expected-top1', '0.85',
|
||||
],
|
||||
{
|
||||
cwd: tmpHome,
|
||||
env: childEnv as NodeJS.ProcessEnv,
|
||||
|
||||
@@ -30,6 +30,13 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Fixture tests that `git commit` in temp repos must not inherit the developer's
|
||||
# global commit.gpgsign — a signing gpg-agent can OOM under full-suite memory
|
||||
# pressure and fail the commit ("gpg: signing failed: Cannot allocate memory",
|
||||
# #1696). git applies these env keys as highest-precedence config on every
|
||||
# invocation in this process tree, so all child `git commit`s run unsigned.
|
||||
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0="commit.gpgsign" GIT_CONFIG_VALUE_0="false"
|
||||
|
||||
# #3485: serial tests need no database — strip ambient DB URLs at this
|
||||
# wrapper boundary (same four-layer guard as run-slow-tests.sh / the
|
||||
# parallel runner) so the bunfig preload guard passes and nothing can
|
||||
|
||||
@@ -44,6 +44,13 @@
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# Fixture tests that `git commit` in temp repos must not inherit the developer's
|
||||
# global commit.gpgsign — a signing gpg-agent can OOM under full-suite memory
|
||||
# pressure and fail the commit ("gpg: signing failed: Cannot allocate memory",
|
||||
# #1696). git applies these env keys as highest-precedence config on every
|
||||
# invocation in this process tree, so all child `git commit`s run unsigned.
|
||||
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0="commit.gpgsign" GIT_CONFIG_VALUE_0="false"
|
||||
|
||||
# #3485: unit tests need no database — strip ambient DB URLs at this wrapper
|
||||
# boundary so the bunfig preload guard passes and nothing can reach a real
|
||||
# brain. The e2e wrapper (run-e2e.sh) is the only lane that keeps them.
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
# GENERATED by scripts/classify-tests.ts; freshness-checked in verify.
|
||||
# Fix misclassifications in the classifier, never by hand-editing rows.
|
||||
# Columns: file suite cases detector
|
||||
test/agent-register.test.ts PGLite integration (pre-flight → tx → audit → exchange) 3 readFileSync
|
||||
test/agent-register.test.ts resolvePreset 7 readFileSync
|
||||
test/agent-register.test.ts structural guards 2 readFileSync
|
||||
test/apply-migrations.test.ts failed migration prints phase detail (#921) 1 readFileSync
|
||||
test/apply-migrations.test.ts resolveSchemaBehind (#1530) 5 readFileSync
|
||||
test/apply-migrations.test.ts runApplyMigrations exit codes (v0.36.1.x #1062) 1 readFileSync
|
||||
@@ -44,6 +47,7 @@ test/codex-plugin-manifest.test.ts curated tree membership + scanner guard 5 rea
|
||||
test/config.test.ts config source correctness 2 readFileSync
|
||||
test/connection-resilience.test.ts Eng-review D3 — executeRaw has no per-call retry wrapper 3 readFileSync
|
||||
test/contextual-retrieval-service-pure.test.ts inline import contextual synopsis containment 1 readFileSync
|
||||
test/conversation-facts-type-allowlist-drift.test.ts (file-level) 17 readFileSync
|
||||
test/cycle-abort.test.ts #1972 — complete cooperative-abort coverage 3 readFileSync
|
||||
test/cycle-abort.test.ts CycleOpts.signal contract (v0.20.5) 4 readFileSync
|
||||
test/cycle-abort.test.ts autopilot-cycle handler contract (v0.20.5) 3 readFileSync
|
||||
@@ -57,6 +61,9 @@ test/cycle-patterns-deadline-budget.test.ts deadline plumbing wiring (structural
|
||||
test/cycle-patterns.test.ts patterns phase wiring 9 readFileSync
|
||||
test/cycle-patterns.test.ts patterns scope filter 6 readFileSync
|
||||
test/cycle/cycle-lock-ttl.test.ts cycle lock TTL (T2 regression pin) 1 readFileSync
|
||||
test/destructive-guard.test.ts FK-RESTRICT lifecycle (clientsReferencingSource + purge skip) 9 readFileSync
|
||||
test/destructive-guard.test.ts assessDestructiveImpact 5 readFileSync
|
||||
test/destructive-guard.test.ts formatters (display helpers) 4 readFileSync
|
||||
test/doctor-embedding-env-override.test.ts cross-surface parity (source-grep regression guard) 1 doctor-source-helper
|
||||
test/doctor-fix.test.ts gbrain doctor --fix CLI integration 3 readFileSync
|
||||
test/doctor-frontmatter-partial.test.ts doctor frontmatter_integrity — load-bearing render strings 5 doctor-source-helper
|
||||
@@ -160,7 +167,7 @@ test/redos-hardening.test.ts #1569 --no-schema-pack + heartbeat wiring (structur
|
||||
test/register-client-source-normalize.test.ts register-client route wiring (structural) 1 readFileSync
|
||||
test/regression-strict-source-id.test.ts cycle reverse-write call sites use the consolidated path 4 readFileSync
|
||||
test/regression-strict-source-id.test.ts utils.ts no longer carries an inline permissive regex 2 readFileSync
|
||||
test/release-workflow.test.ts release.yml ↔ binary-self-update asset contract 7 readFileSync
|
||||
test/release-workflow.test.ts release.yml ↔ binary-self-update asset contract 10 readFileSync
|
||||
test/resolver.test.ts RESOLVER.md trigger round-trip (D5/C) 2 readFileSync
|
||||
test/resolver.test.ts Skill example-name validator (D13) 4 readFileSync
|
||||
test/schema-cli-contract.test.ts v0.39 T6 — schema CLI contract 7 readFileSync
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 30 and column 63.
|
Executable
+277
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wave security scan — the repeatable mechanical sweep for community-PR waves.
|
||||
#
|
||||
# Runs the high-recall checks a maintainer should apply to a batch of external
|
||||
# contributions BEFORE shipping a collector branch (see docs/RELEASING.md,
|
||||
# "Community PR wave process"). It is NOT a proof of safety — it is a fast net
|
||||
# that surfaces the shapes worth a human look: newly-introduced outbound
|
||||
# endpoints, obfuscation/eval, new process spawns, new env reads, dependency
|
||||
# changes, secrets (gitleaks with the test/skills allowlist STRIPPED), and any
|
||||
# change to the committed admin bundle.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/wave-security-scan.sh <base>..<head> # explicit range
|
||||
# scripts/wave-security-scan.sh <base> <head> # two refs
|
||||
# scripts/wave-security-scan.sh # defaults to origin/master..HEAD
|
||||
# scripts/wave-security-scan.sh --json <range> # machine-readable summary
|
||||
#
|
||||
# Exit code: 0 = nothing high-signal; 1 = high-signal hit(s) worth review;
|
||||
# 2 = usage / environment error. Findings are advisory: exit 1 means
|
||||
# "look", not "unsafe".
|
||||
#
|
||||
# On-demand only (never wired into the hot CI path): gitleaks-over-history and
|
||||
# the per-file diff walk are too slow for every push.
|
||||
|
||||
set -euo pipefail
|
||||
# Deliberately NO cd-to-script-repo: the scan operates on the CALLER's git repo
|
||||
# (the collector branch being reviewed), which is not necessarily the repo this
|
||||
# script lives in. The not-a-git-repository guard below handles stray cwds.
|
||||
|
||||
JSON=0
|
||||
ARGS=()
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--json) JSON=1 ;;
|
||||
*) ARGS+=("$a") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Resolve the commit range (guard empty / non-git / bad refs) ---
|
||||
if ! git rev-parse --git-dir >/dev/null 2>&1; then
|
||||
echo "wave-security-scan: not a git repository" >&2
|
||||
exit 2
|
||||
fi
|
||||
# Operate on the CALLER's repo, but ROOTED at its top level. Without this, a run
|
||||
# from a subdirectory would scope every cwd-relative pathspec (`-- .`, root
|
||||
# manifests, `admin/dist`) to the subtree and silently report a clean gate.
|
||||
_TOPLEVEL=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "wave-security-scan: cannot resolve repo top level" >&2; exit 2; }
|
||||
cd "$_TOPLEVEL"
|
||||
# python3 does the regex/JSON work; without it the checks can't run and set -e
|
||||
# would exit 127 outside the documented 0/1/2 contract. Fail as a usage error.
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "wave-security-scan: python3 is required but not found" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
RANGE=""
|
||||
if [ "${#ARGS[@]}" -eq 0 ]; then
|
||||
if git rev-parse --verify -q origin/master >/dev/null; then
|
||||
RANGE="origin/master..HEAD"
|
||||
else
|
||||
RANGE="HEAD~1..HEAD"
|
||||
fi
|
||||
elif [ "${#ARGS[@]}" -eq 1 ]; then
|
||||
RANGE="${ARGS[0]}"
|
||||
elif [ "${#ARGS[@]}" -eq 2 ]; then
|
||||
RANGE="${ARGS[0]}..${ARGS[1]}"
|
||||
else
|
||||
echo "wave-security-scan: too many arguments" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Normalise `a..b`; verify both endpoints resolve.
|
||||
BASE="${RANGE%%..*}"
|
||||
HEAD="${RANGE##*..}"
|
||||
if [ "$BASE" = "$RANGE" ] || [ -z "$BASE" ] || [ -z "$HEAD" ]; then
|
||||
echo "wave-security-scan: range must be <base>..<head> (got '$RANGE')" >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! git rev-parse --verify -q "$BASE^{commit}" >/dev/null || ! git rev-parse --verify -q "$HEAD^{commit}" >/dev/null; then
|
||||
echo "wave-security-scan: cannot resolve one end of '$RANGE'" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
COMMIT_COUNT=$(git rev-list --count "$RANGE" 2>/dev/null || echo 0)
|
||||
if [ "$COMMIT_COUNT" -eq 0 ]; then
|
||||
echo "wave-security-scan: empty range ($RANGE) — nothing to scan" >&2
|
||||
if [ "$JSON" -eq 1 ]; then
|
||||
# Same schema as the main --json path (zero/empty values), safely encoded.
|
||||
python3 -c 'import json,sys; print(json.dumps({"range": sys.argv[1], "commits": 0, "checks": {}, "alarm": 0, "dependency_changed": False, "admin_dist_changed": False, "gitleaks_hits": "n/a"}))' "$RANGE"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Generated / minified / vendored artifacts: excluded from the CONTENT greps
|
||||
# (they trip every obfuscation heuristic and drown real signal), but admin/dist
|
||||
# changes are still surfaced separately below (that is a real threat artifact).
|
||||
is_scannable() {
|
||||
case "$1" in
|
||||
admin/dist/*|*/admin/dist/*) return 1 ;;
|
||||
llms.txt|llms-full.txt) return 1 ;;
|
||||
*.snapshot|*.snap|*.tar|*.tgz|*.wasm|*.png|*.jpg|*.jpeg|*.gif|*.pdf|*.ico) return 1 ;;
|
||||
bun.lock|*/bun.lock|package-lock.json|yarn.lock) return 1 ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
TMP=$(mktemp -d /tmp/wave-scan.XXXXXX)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# --- Build the added-line corpus (content-scannable files only) ---
|
||||
: > "$TMP/added.txt"
|
||||
# Anchor the file-header match to the git unified-diff form (`+++ b/<path>` or
|
||||
# `+++ /dev/null`). A looser `^+++ ` also matches a CONTENT line like `++ x;`
|
||||
# (a `++`-prefixed statement renders as `+++ x;`), which would reassign the
|
||||
# current filename to garbage and suppress checks for the rest of the file.
|
||||
git diff --no-color --unified=0 "$RANGE" -- . 2>/dev/null | awk '
|
||||
/^\+\+\+ (b\/|\/dev\/null)/{ f=$0; sub(/^\+\+\+ b\//,"",f); next }
|
||||
/^\+/ && !/^\+\+\+/ { line=$0; sub(/^\+/,"",line); print f"\t"line }
|
||||
' > "$TMP/added_all.txt" || true
|
||||
while IFS=$'\t' read -r f rest; do
|
||||
[ -z "$f" ] && continue
|
||||
if is_scannable "$f"; then printf '%s\t%s\n' "$f" "$rest" >> "$TMP/added.txt"; fi
|
||||
done < "$TMP/added_all.txt"
|
||||
|
||||
# Python does the regex work (BSD grep/ugrep differ; python is portable).
|
||||
python3 - "$TMP/added.txt" "$TMP" <<'PY'
|
||||
import re, sys, json
|
||||
added = sys.argv[1]; tmp = sys.argv[2]
|
||||
rows = []
|
||||
for line in open(added, encoding='utf-8', errors='replace').read().splitlines():
|
||||
p = line.split('\t', 1)
|
||||
if len(p) == 2:
|
||||
rows.append(p)
|
||||
|
||||
CODE_EXT = ('.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.sh', '.bash')
|
||||
SHELL_EXT = ('.sh', '.bash')
|
||||
def is_code(f):
|
||||
return f.endswith(CODE_EXT)
|
||||
def is_test(f):
|
||||
return f.startswith('test/') or '/test/' in f or f.startswith('skills/')
|
||||
# Execution-reachable source: src/scripts + admin/src (the release job now builds
|
||||
# and embeds admin/src, so its spawns/env reads matter too).
|
||||
def is_exec_source(f):
|
||||
return f.startswith(('src/', 'scripts/', 'admin/src/'))
|
||||
def is_comment(f, c):
|
||||
# Only suppress lines that genuinely can't execute. Do NOT over-broaden:
|
||||
# a leading `#` is a comment only in shell (in JS/TS it's a private field);
|
||||
# a leading `*` is a comment only as `*/` or a JSDoc continuation `* ...`
|
||||
# (with a following space) — `*gen(){}` / `*eval(` are generator/multiply
|
||||
# constructs that DO execute.
|
||||
t = c.lstrip()
|
||||
if t.startswith('//') or t.startswith('/*') or t.startswith('*/'):
|
||||
return True
|
||||
if t.startswith('* ') or t == '*':
|
||||
return True
|
||||
if f.endswith(SHELL_EXT) and t.startswith('#'):
|
||||
return True
|
||||
return False
|
||||
|
||||
# Code-shaped checks fire on CODE FILES only (obfuscation/eval in a .md is prose,
|
||||
# not a payload). ALARM checks (exit 1) are the low-false-positive ones:
|
||||
# obfuscation/eval in executable code lines. The rest are INFORMATIONAL context.
|
||||
# The obfuscation pattern covers JS call form `eval(`/`atob(`/`new Function(` AND
|
||||
# shell forms `eval "$x"` / `eval $x` / `source <(...)`.
|
||||
checks = {
|
||||
'obfuscation': (True, lambda f, c: is_code(f) and not is_comment(f, c) and bool(re.search(
|
||||
r'\beval\s*[("\'$]|\beval\s+\S|\bnew\s+Function\s*\(|\batob\s*\(|Buffer\.from\([^)]*[\'"]base64|String\.fromCharCode|\bsource\s+<\(|(\\x[0-9a-fA-F]{2}){4,}|[A-Za-z0-9+/]{120,}={0,2}', c))),
|
||||
'outbound_url': (False, lambda f, c: bool(re.search(r'https?://|wss?://', c))
|
||||
and not re.search(r'localhost|127\.0\.0\.1|0\.0\.0\.0|example\.(com|org|net|test|invalid)|\.example\b|schema|xmlns|w3\.org|json-schema|spdx|in-toto\.io|slsa\.dev|sigstore|githubusercontent|github\.com/garrytan/gbrain', c)),
|
||||
'new_spawn_exec': (False, lambda f, c: is_code(f) and bool(re.search(r'child_process|execSync|\bexecFileSync|\bspawnSync|\bspawn\s*\(|Bun\.spawn|shell\s*:\s*true', c)) and is_exec_source(f)),
|
||||
'new_env_read': (False, lambda f, c: is_code(f) and bool(re.search(r'(?:process|Bun)\.env[.\[]', c)) and is_exec_source(f)),
|
||||
}
|
||||
results = {k: [] for k in checks}
|
||||
for f, c in rows:
|
||||
for k, (_alarm, pred) in checks.items():
|
||||
try:
|
||||
if pred(f, c):
|
||||
results[k].append((f, c.strip()[:160]))
|
||||
except re.error:
|
||||
pass
|
||||
|
||||
# alarm_total drives exit 1; informational checks are printed but never fail.
|
||||
summary = {}
|
||||
alarm_total = 0
|
||||
for k, hits in results.items():
|
||||
alarm = checks[k][0]
|
||||
summary[k] = {'total': len(hits), 'alarm': alarm, 'sample': hits[:8]}
|
||||
if alarm:
|
||||
alarm_total += len(hits)
|
||||
|
||||
json.dump({'checks': summary, 'alarm': alarm_total}, open(tmp + '/checks.json', 'w'))
|
||||
PY
|
||||
|
||||
# --- Dependency diff (root AND admin — the release job installs admin deps too) ---
|
||||
DEP_CHANGED=0
|
||||
if ! git diff --quiet "$RANGE" -- package.json bun.lock admin/package.json admin/bun.lock 2>/dev/null; then DEP_CHANGED=1; fi
|
||||
|
||||
# --- Admin bundle change (WS1 threat artifact — always flag for manual review) ---
|
||||
ADMIN_DIST_CHANGED=0
|
||||
if git diff --name-only "$RANGE" -- 'admin/dist' 2>/dev/null | grep -q .; then ADMIN_DIST_CHANGED=1; fi
|
||||
|
||||
# --- gitleaks with the test/skills allowlist STRIPPED (temp config; never edits repo .gitleaks.toml) ---
|
||||
# Fail-closed lane: this script's exit code is the RELEASING.md step-5 gate, so a
|
||||
# secrets sweep that DID NOT RUN (gitleaks missing) or ran-but-unparseable ("?")
|
||||
# must alarm — never silently report clean.
|
||||
GITLEAKS_HITS="n/a"
|
||||
if command -v gitleaks >/dev/null 2>&1; then
|
||||
# extend useDefault = gitleaks' built-in rules WITHOUT the repo .gitleaks.toml
|
||||
# (which allowlists test/ + skills/) — the whole point is to see the blind spot.
|
||||
printf '[extend]\nuseDefault = true\n' > "$TMP/gitleaks.toml"
|
||||
if gitleaks git --no-banner -c "$TMP/gitleaks.toml" --log-opts="$RANGE" --report-format json --report-path "$TMP/leaks.json" >/dev/null 2>&1; then
|
||||
GITLEAKS_HITS=0
|
||||
else
|
||||
GITLEAKS_HITS=$(python3 -c "import json;print(len(json.load(open('$TMP/leaks.json'))))" 2>/dev/null || echo "?")
|
||||
fi
|
||||
fi
|
||||
LEAK_LANE_BROKEN=0
|
||||
if [ "$GITLEAKS_HITS" = "n/a" ]; then
|
||||
echo "wave-security-scan: WARNING — gitleaks is not installed; the secrets lane DID NOT RUN (install gitleaks, then re-run)" >&2
|
||||
LEAK_LANE_BROKEN=1
|
||||
elif [ "$GITLEAKS_HITS" = "?" ]; then
|
||||
echo "wave-security-scan: WARNING — gitleaks exited non-zero and its report is unreadable; the secrets lane result is UNKNOWN" >&2
|
||||
LEAK_LANE_BROKEN=1
|
||||
fi
|
||||
|
||||
# --- Report ---
|
||||
ALARM=$(python3 -c "import json;print(json.load(open('$TMP/checks.json'))['alarm'])")
|
||||
LEAK_SIGNAL=0
|
||||
if [ "$GITLEAKS_HITS" != "n/a" ] && [ "$GITLEAKS_HITS" != "0" ] && [ "$GITLEAKS_HITS" != "?" ]; then LEAK_SIGNAL=$GITLEAKS_HITS; fi
|
||||
|
||||
# Compute the gate result up front so --json carries it (a machine consumer must
|
||||
# not read alarm:0 and conclude "clean" while the process exits 1 on an
|
||||
# admin/dist change, a gitleaks hit, or a broken secrets lane).
|
||||
GATE_EXIT=0
|
||||
if [ "$ALARM" -gt 0 ] || [ "$LEAK_SIGNAL" -gt 0 ] || [ "$ADMIN_DIST_CHANGED" = 1 ] || [ "$LEAK_LANE_BROKEN" = 1 ]; then
|
||||
GATE_EXIT=1
|
||||
fi
|
||||
|
||||
if [ "$JSON" -eq 1 ]; then
|
||||
python3 - "$TMP/checks.json" "$RANGE" "$COMMIT_COUNT" "$DEP_CHANGED" "$ADMIN_DIST_CHANGED" "$GITLEAKS_HITS" "$GATE_EXIT" "$LEAK_LANE_BROKEN" <<'PY'
|
||||
import json, sys
|
||||
checks = json.load(open(sys.argv[1]))
|
||||
out = {
|
||||
'range': sys.argv[2], 'commits': int(sys.argv[3]),
|
||||
'checks': checks['checks'], 'alarm': checks['alarm'],
|
||||
'dependency_changed': sys.argv[4] == '1',
|
||||
'admin_dist_changed': sys.argv[5] == '1',
|
||||
'gitleaks_hits': sys.argv[6],
|
||||
'gitleaks_lane_broken': sys.argv[8] == '1',
|
||||
'exit_code': int(sys.argv[7]),
|
||||
'gate': 'review' if sys.argv[7] == '1' else 'clean',
|
||||
}
|
||||
print(json.dumps(out))
|
||||
PY
|
||||
else
|
||||
echo "wave-security-scan range=$RANGE commits=$COMMIT_COUNT"
|
||||
echo " (ALARM = exit 1, worth review before ship; other rows are context)"
|
||||
echo "-------------------------------------------------------------"
|
||||
python3 - "$TMP/checks.json" <<'PY'
|
||||
import json, sys
|
||||
c = json.load(open(sys.argv[1]))['checks']
|
||||
labels = {'obfuscation':'obfuscation / eval (code)','outbound_url':'new outbound URLs/hosts','new_spawn_exec':'new spawn/exec (src/scripts)','new_env_read':'new env reads (src)'}
|
||||
for k, lab in labels.items():
|
||||
s = c[k]
|
||||
tag = 'ALARM' if s['alarm'] else 'info '
|
||||
flag = ' <-- REVIEW' if (s['alarm'] and s['total']) else ''
|
||||
print(f" [{tag}] {lab:30} count={s['total']}{flag}")
|
||||
for f, snip in s['sample'][:4]:
|
||||
print(f" {f}: {snip[:100]}")
|
||||
PY
|
||||
echo " [info ] dependency change (package.json/bun.lock): $([ "$DEP_CHANGED" = 1 ] && echo YES || echo no)"
|
||||
echo " [ALARM] admin/dist change (bundle-backdoor artifact): $([ "$ADMIN_DIST_CHANGED" = 1 ] && echo 'YES <-- REVIEW' || echo no)"
|
||||
echo " [ALARM] gitleaks (test/skills allowlist stripped): $GITLEAKS_HITS"
|
||||
echo "-------------------------------------------------------------"
|
||||
fi
|
||||
|
||||
exit "$GATE_EXIT"
|
||||
@@ -71,9 +71,15 @@ procedure whenever the source is one of those six formats:
|
||||
```
|
||||
gbrain transcripts ingest ~/Downloads/conversations.json # unzip first
|
||||
gbrain transcripts ingest # discover harness logs
|
||||
gbrain transcripts ingest --max-bytes 4gb <store> # oversized store (omit = per-format caps)
|
||||
gbrain transcripts status # found vs imported gaps
|
||||
```
|
||||
|
||||
`--max-bytes` note: the cap is part of the `--since last` checkpoint
|
||||
fingerprint — running with a different cap (or dropping it) starts a fresh
|
||||
watermark scope, so a capped run's skipped tail is never mistaken for
|
||||
already-scanned.
|
||||
|
||||
Native-vs-manual delta to know: the native lane redacts SECRETS (key
|
||||
patterns) plus your `~/.gbrain/harvest-private-patterns.txt` regexes and
|
||||
counts agent-directed imperatives into frontmatter, but broad PII detection
|
||||
|
||||
@@ -294,6 +294,9 @@ Populate them periodically or after major imports:
|
||||
- `gbrain stats` — verify `link_count > 0` and `timeline_entry_count > 0` after extraction.
|
||||
- `gbrain health` — review `link_coverage` and `timeline_coverage` percentages
|
||||
on entity pages (person/company). Below 50% means more extraction is needed.
|
||||
On brains with very few entity pages these report "too few to grade"
|
||||
(`null` in JSON, with `entity_page_count` carrying the denominator) instead
|
||||
of a misleading 0%/100% — grow the entity set before acting on coverage.
|
||||
|
||||
Available link types (use with `gbrain graph-query --type`):
|
||||
`attended`, `works_at`, `invested_in`, `founded`, `advises`, `mentions`, `source`.
|
||||
|
||||
@@ -132,6 +132,11 @@ Continue with the existing `gbrain init --supabase` / `--pglite` setup below.
|
||||
`gbrain remote doctor` (Tier B convenience commands) call MCP ops with
|
||||
`admin` scope. `read,write` alone breaks ping/doctor.
|
||||
|
||||
For agent harnesses (Claude Code, Codex, opencode, OpenClaw), the host
|
||||
operator can instead run `gbrain agent register` — it mints the scoped
|
||||
client AND prints the paste-ready harness config in one step (see
|
||||
https://github.com/garrytan/gbrain/blob/master/docs/guides/agent-to-gbrain.md).
|
||||
|
||||
3. **Run thin-client init on this machine:**
|
||||
```bash
|
||||
gbrain init --mcp-only \
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"conventions/subagent-routing.md": "8b8830b815a9a8581a12b489f966c0b0a39eb9b5f66e905a691a03653eef348d",
|
||||
"conventions/test-before-bulk.md": "6b2c52cda9e2cd5f04c15152b3d92aeb7187ab193a15082be0f8a3991a6a5725",
|
||||
"conventions/untrusted-content.md": "259384d490892cd0e1e8e054decf752d7354f516c83aee57b332c1a96aac6a6e",
|
||||
"conversation-archive/SKILL.md": "4e1dea00f5e1e16e749a42f295fdccf556199d4400a2ba1b891aa91839e37214",
|
||||
"conversation-archive/SKILL.md": "3c1d342c58444a8b2c7fd961dee1ff8c08bfa3487877f877699abd4d0582b003",
|
||||
"conversation-archive/routing-eval.jsonl": "ae087a84b1fd5b108b7cdab8d035a09b3ccecd8aad53ba5f71e463059108cfca",
|
||||
"correction-pipeline/SKILL.md": "caf1264b7afec46569d30f6d92b07f37ae375e3f4e6aeddd58866aec327053de",
|
||||
"correction-pipeline/routing-eval.jsonl": "7f8d96606a8d7bed3d79fdcee6904764c8abb9fa0b506adb414b5c4805b69d0b",
|
||||
@@ -87,7 +87,7 @@
|
||||
"idea-lineage/SKILL.md": "bbf37781d93b71ddc7909ecc5ab635872c874fb8591995dbf88b45ffeac6b1de",
|
||||
"idea-lineage/routing-eval.jsonl": "ee2e00704b9accb7dd58bb8f126a3bc04a2c40be499180fa505dbf6d5061cd41",
|
||||
"ingest/SKILL.md": "dc40ecc0072806fb8c7bb6ab9cf1f103842e05653eb55d67632d7e3ffc4dd7d2",
|
||||
"maintain/SKILL.md": "33e48e31baf89b6b257ad863cdb9de444777bc1272f5ed8c2b28be3a54cbaa14",
|
||||
"maintain/SKILL.md": "89ace6ae686284fd4423416a3b80d8ad940fbcf5313523f690684afe52779788",
|
||||
"manifest.json": "03471868cce05fa38af6f793da54e2fc11f77ef778271a596d75bc29f9ec4c73",
|
||||
"measure-before-you-fix/SKILL.md": "1fd3b40ab65cbd08f50dea16107701859165469be3c85c57d779c7b4bbf92db8",
|
||||
"measure-before-you-fix/routing-eval.jsonl": "0661df9974a9cfe31216d574b1db0ef341945c2eb844ebf4ab6920fcbbc90d6c",
|
||||
@@ -151,7 +151,7 @@
|
||||
"resolve-before-asking/routing-eval.jsonl": "bac1bcf30337f5255ef4ce1a2a8a2b38d58ebcd576503c483190c79ec6e69489",
|
||||
"schema-author/SKILL.md": "1dd11a44dabcb7d57244be4cf5f4903feb9d146bcbb4363fc150daefc01d04ce",
|
||||
"schema-unify/SKILL.md": "e9ac84018d673d35f749a1f74380d635512308fa50951995a7cb339ab4c85fa6",
|
||||
"setup/SKILL.md": "7f11b70ed89d4bff87096aa7e7bb0d41191eb46682066f3b2cffa7a326b56330",
|
||||
"setup/SKILL.md": "f014513080eb81e90f0cc0203ca944698fdff250059be0267f0498c5b69c5416",
|
||||
"signal-detector/SKILL.md": "c85772f129b3a5b5b0edfa191e11b1048942e52b7472bbaea224e7188f8af75a",
|
||||
"skill-autobench/SKILL.md": "144572ec76f3784a97645dfde587ab13d77e804f50b00dc7fbe678204de6ff21",
|
||||
"skill-autobench/routing-eval.jsonl": "8d961ed6403b7e2f690948e4c18529d40f26f6f21064befc56d465966b1a9ec0",
|
||||
|
||||
+72
-4
@@ -80,6 +80,12 @@ export const CLI_ONLY = new Set(['init', 'reinit-pglite', 'pglite-repair', 'upgr
|
||||
// per-subcommand usage stays reachable.
|
||||
const CLI_ONLY_SELF_HELP = new Set([
|
||||
'upgrade', 'post-upgrade', 'check-update',
|
||||
// cathedral-6: agent ships per-subcommand help (run/logs/register) inside
|
||||
// runAgent, answered before any engine or queue is touched. Paired with the
|
||||
// SELF_HELP_WITHOUT_ENGINE entry below so a brainless machine gets real
|
||||
// help, and with the `--`-aware help scan in main() so
|
||||
// `agent run -- --help` submits the literal prompt instead.
|
||||
'agent',
|
||||
// whoknows honours --help first (runWhoknows HELP block, whoknows.ts).
|
||||
'whoknows',
|
||||
// #3502 sweep: pages + bench print their own usage (pages.ts printHelp,
|
||||
@@ -169,6 +175,15 @@ const CLI_ONLY_SELF_HELP = new Set([
|
||||
// would hide both — `gbrain dream retriage --help` printed the one-line
|
||||
// dream stub instead of the retriage contract (outside-voice CX9).
|
||||
'dream',
|
||||
// sources ships its own printHelp() (sources.ts, wired to `case '--help'`)
|
||||
// covering all ~28 subcommands, but was missing from this set — so
|
||||
// `gbrain sources --help` hit the generic one-line stub, which itself says
|
||||
// "run gbrain --help for the full command list", and the top-level help's
|
||||
// own SOURCES block promises `sources --help` as the place to find the
|
||||
// long tail (rename, default, attach, current, federate, set-cr-mode,
|
||||
// webhook, harden, ...). That made the pointer circular and those
|
||||
// subcommands undiscoverable from the CLI in either direction.
|
||||
'sources',
|
||||
// ZE interim cleanup: the retired ze-switch shim ships truthful help
|
||||
// (sunset refusal + canonical migration command); the generic stub hid it.
|
||||
'ze-switch',
|
||||
@@ -197,6 +212,14 @@ const SELF_HELP_WITHOUT_ENGINE: Record<string, () => Promise<(engine: never, arg
|
||||
// runDream accepts BrainEngine | null; --help (and `retriage --help`) is
|
||||
// answered before any engine-bearing work per the dream.ts IRON RULE.
|
||||
dream: async () => (await import('./commands/dream.ts')).runDream as never,
|
||||
// runSources's `--help`/`-h`/undefined-subcommand branch calls printHelp()
|
||||
// without ever touching `engine` — safe to dispatch with no brain
|
||||
// configured, matching the reader who runs `sources --help` because they
|
||||
// have no brain yet.
|
||||
sources: async () => (await import('./commands/sources.ts')).runSources as never,
|
||||
// runAgent accepts BrainEngine | null; help (incl. `register --help`) is
|
||||
// answered before any engine or job-queue work (cathedral-6).
|
||||
agent: async () => (await import('./commands/agent.ts')).runAgent as never,
|
||||
// The retired ze-switch shim answers --help engine-free (arg-order adapter
|
||||
// lives in ze-switch.ts because runZeSwitch takes (args, engine)).
|
||||
'ze-switch': async () => (await import('./commands/ze-switch.ts')).runZeSwitchSelfHelp as never,
|
||||
@@ -442,8 +465,13 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Per-command --help
|
||||
if (hasHelpFlag(subArgs)) {
|
||||
// Per-command --help. For `agent`, the scan STOPS at the `--` terminator:
|
||||
// everything after it is literal prompt text, so `agent run -- --help`
|
||||
// must submit the prompt, never print help (cathedral-6 eng review).
|
||||
const helpScanArgs = command === 'agent' && subArgs.includes('--')
|
||||
? subArgs.slice(0, subArgs.indexOf('--'))
|
||||
: subArgs;
|
||||
if (hasHelpFlag(helpScanArgs)) {
|
||||
// `eval brainbench` ships a published foreign-runner flag surface — its
|
||||
// own usage() must win over the generic eval stub (codex P3). Fall
|
||||
// through to handleCliOnly's no-DB brainbench route, which prints it.
|
||||
@@ -1508,11 +1536,17 @@ export function formatResult(
|
||||
`Stale pages: ${h.stale_pages}`,
|
||||
`Orphan pages: ${h.orphan_pages}`,
|
||||
];
|
||||
if (h.link_coverage !== undefined) {
|
||||
// gbrain#4147: null = below the small-N floor — say so instead of
|
||||
// rendering a misleading hard 0%/100%.
|
||||
if (h.link_coverage != null) {
|
||||
lines.push(`Link coverage (entities): ${(h.link_coverage * 100).toFixed(1)}%`);
|
||||
} else if (h.entity_page_count !== undefined) {
|
||||
lines.push(`Link coverage (entities): n/a (${h.entity_page_count} entity page(s) — too few to grade)`);
|
||||
}
|
||||
if (h.timeline_coverage !== undefined) {
|
||||
if (h.timeline_coverage != null) {
|
||||
lines.push(`Timeline coverage (entity pages): ${(h.timeline_coverage * 100).toFixed(1)}%`);
|
||||
} else if (h.entity_page_count !== undefined) {
|
||||
lines.push(`Timeline coverage (entity pages): n/a (${h.entity_page_count} entity page(s) — too few to grade)`);
|
||||
}
|
||||
if (h.timeline_coverage_score !== undefined) {
|
||||
lines.push(`Timeline density (all pages): ${h.timeline_coverage_score}/15 (whole-brain brain-score component)`);
|
||||
@@ -1740,6 +1774,40 @@ async function handleCliOnly(command: string, args: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
// cathedral-6: `agent register` guards run PRE-connectEngine. A thin client
|
||||
// would otherwise build a scratch PGLite and mint dead credentials into it;
|
||||
// a live PGLite serve holds the single-writer lock, so connectEngine would
|
||||
// hang ~30s before any handler code could print guidance. (`agent` itself
|
||||
// stays out of THIN_CLIENT_REFUSED_COMMANDS — run/logs work elsewhere.)
|
||||
if (command === 'agent' && args[0] === 'register' && !hasHelpFlag(args)) {
|
||||
const cfg = loadConfig();
|
||||
const wantsJson = args.includes('--json');
|
||||
const refuse = (reason: string, message: string) => {
|
||||
if (wantsJson) {
|
||||
console.log(JSON.stringify({ ok: false, reason, message }));
|
||||
} else {
|
||||
console.error(`Error: ${message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
};
|
||||
if (isThinClient(cfg)) {
|
||||
// Shared verbatim with the in-handler belt-and-braces re-check. Lazy
|
||||
// import: this guard runs pre-connect for `agent register` only, and a
|
||||
// top-level import would eager-load the register module on every CLI
|
||||
// start.
|
||||
const { THIN_CLIENT_REGISTER_MESSAGE } = await import('./commands/agent-register.ts');
|
||||
refuse('thin_client', THIN_CLIENT_REGISTER_MESSAGE);
|
||||
}
|
||||
if (cfg && !cfg.database_url && cfg.database_path) {
|
||||
const { probeLivePgliteHolder } = await import('./core/bootstrap/uninstall.ts');
|
||||
const holder = probeLivePgliteHolder(cfg.database_path);
|
||||
if (holder?.serve) {
|
||||
refuse('pglite_live_serve',
|
||||
`a live \`gbrain serve\` (pid ${holder.pid}) holds this PGLite brain's single-writer lock — stop the serve, run \`gbrain agent register\` again, then restart it. (Postgres brains register fine while the serve runs.)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Commands that don't need a database connection
|
||||
if (command === 'schema') {
|
||||
const { runSchema } = await import('./commands/schema.ts');
|
||||
|
||||
@@ -0,0 +1,961 @@
|
||||
/**
|
||||
* gbrain agent register — mint a scoped OAuth client + access token and print
|
||||
* the exact MCP wiring for a harness (cathedral-6, spec PR-6 "shared brain").
|
||||
*
|
||||
* CLI-ONLY, never an MCP op. Composes existing parts — registerScopedClient
|
||||
* (auth.ts peel), exchangeClientCredentials, the mcp-registration argv
|
||||
* builders, renderCodexHttpServerBlock, openclawThinClientBlock — it builds
|
||||
* no new auth machinery.
|
||||
*
|
||||
* register flow (order is load-bearing):
|
||||
* [cli.ts pre-connect guards: thin client refusal + PGLite live-serve]
|
||||
* → parse (pure, exit 2) → preset resolve (pure)
|
||||
* → validate sources (existence + archived, engine lane)
|
||||
* → column PRE-FLIGHT (outside any tx — 25P02 forbids in-tx degrade)
|
||||
* → ONE engine.transaction:
|
||||
* name advisory lock → duplicate-name pre-check
|
||||
* → ensureWorkspaceSource (create-or-clean-reuse, refuse dirty)
|
||||
* → registerScopedClient (INSERT + ttl UPDATE + surface rescope)
|
||||
* → COMMIT
|
||||
* → audit (fail-open, designed post-commit position)
|
||||
* → exchangeClientCredentials (outer engine — the tx sql is dead)
|
||||
* → optional serve probe (--url/--port; note, never a failure)
|
||||
* → render harness block → print (human) | single JSON doc
|
||||
*
|
||||
* Failure after COMMIT leaves a live client (and possibly a clean, empty
|
||||
* workspace source): we print the client_id + the exact revoke command —
|
||||
* never a false "nothing was created".
|
||||
*/
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { loadConfig, isThinClient } from '../core/config.ts';
|
||||
import { sqlQueryForEngine, type SqlQuery } from '../core/sql-query.ts';
|
||||
import { assertAllowedScopes } from '../core/scope.ts';
|
||||
import { assertValidSourceId, ALL_SOURCES, SOURCE_ID_RE } from '../core/source-id.ts';
|
||||
import { addSource } from '../core/sources-ops.ts';
|
||||
import { loadAllSources } from '../core/sources-load.ts';
|
||||
import { generateToken, hashToken } from '../core/utils.ts';
|
||||
import {
|
||||
normalizeMcpUrl,
|
||||
issuerFromMcpUrl,
|
||||
isValidName,
|
||||
buildClaudeMcpAddArgv,
|
||||
buildCodexMcpAddArgv,
|
||||
buildOpencodeMcpAddArgv,
|
||||
cmdString,
|
||||
shellQuote,
|
||||
openclawThinClientBlock,
|
||||
OAUTH_SECRET_NOTE,
|
||||
REDACTED,
|
||||
} from '../core/mcp-registration.ts';
|
||||
import { GBRAIN_REMOTE_TOKEN_ENV } from '../core/bootstrap/opencode-json.ts';
|
||||
import { renderCodexHttpServerBlock } from '../core/bootstrap/codex-toml.ts';
|
||||
import { probeServeHealth, isServeOlderThanScopes, SCOPES_MIN_SERVE_VERSION } from '../core/bootstrap/serve-health.ts';
|
||||
import { writeSurfaceChangeAudit } from '../core/surface-audit.ts';
|
||||
import {
|
||||
registerScopedClient,
|
||||
preflightOauthClientColumns,
|
||||
parseRegisterClientArgs,
|
||||
parseTokenTtl,
|
||||
type RegisterClientArgs,
|
||||
type RegisteredClient,
|
||||
} from './auth.ts';
|
||||
|
||||
// ── constants ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const REGISTER_HARNESSES = ['claude-code', 'codex', 'opencode', 'openclaw'] as const;
|
||||
export type RegisterHarness = (typeof REGISTER_HARNESSES)[number];
|
||||
export const REGISTER_PRESETS = ['daily-driver', 'coding-agent'] as const;
|
||||
export type RegisterPreset = (typeof REGISTER_PRESETS)[number];
|
||||
|
||||
/** Register ALWAYS writes token_ttl: the server default for CLI-minted access
|
||||
* tokens is 3600s — a printed "30-day" config with the default TTL would die
|
||||
* in an hour. 30 days, inside the auth.ts bounds. */
|
||||
export const REGISTER_DEFAULT_TOKEN_TTL_SECONDS = 2_592_000;
|
||||
|
||||
/** Scopes an agent client may not hold — operators use auth register-client. */
|
||||
const SCOPE_BLOCKLIST = new Set(['admin', 'sources_admin', 'users_admin', 'agent']);
|
||||
|
||||
/** Derived-workspace source suffix. Exported for the doctor
|
||||
* oauth_client_scope_health orphan heuristic (single source of truth). */
|
||||
export const WORKSPACE_SUFFIX = '-workspace';
|
||||
/** SOURCE_ID_RE caps ids at 32 chars; '-workspace' is 10. */
|
||||
export const WORKSPACE_NAME_MAX = 22;
|
||||
|
||||
/** Advisory-lock key for name-scoped registration/rotation serialization.
|
||||
* Cross-process wire contract (pinned in test/lock-keys.test.ts) — every
|
||||
* writer hashing a different string holds a different lock. */
|
||||
export function registerClientNameLockKey(name: string): string {
|
||||
return `register_client_name:${name}`;
|
||||
}
|
||||
|
||||
/** The thin-client refusal, shared verbatim by the cli.ts pre-connect guard
|
||||
* and the in-handler belt-and-braces re-check. */
|
||||
export const THIN_CLIENT_REGISTER_MESSAGE =
|
||||
'`gbrain agent register` mints credentials into the HOST brain — run it on the brain host (the machine that runs `gbrain serve --http`), then wire this machine with the printed `gbrain init --mcp-only …` block.';
|
||||
|
||||
export type RegisterFailReason =
|
||||
| 'invalid_argument'
|
||||
| 'unknown_source'
|
||||
| 'archived_source'
|
||||
| 'dirty_source'
|
||||
| 'duplicate_name'
|
||||
| 'thin_client'
|
||||
| 'pglite_live_serve'
|
||||
| 'reissue_invalid_target'
|
||||
| 'mint_failed'
|
||||
| 'brain_too_old'
|
||||
| 'serve_too_old'
|
||||
| 'internal';
|
||||
|
||||
export class RegisterError extends Error {
|
||||
constructor(
|
||||
public reason: RegisterFailReason,
|
||||
message: string,
|
||||
public clientId?: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── parsing (pure) ────────────────────────────────────────────────────────
|
||||
|
||||
export interface AgentRegisterArgs {
|
||||
name?: string;
|
||||
reissueClientId?: string;
|
||||
harness?: RegisterHarness;
|
||||
preset?: RegisterPreset;
|
||||
source?: string;
|
||||
federatedRead?: string[];
|
||||
scopes?: string;
|
||||
url?: string;
|
||||
port?: number;
|
||||
tokenTtlSeconds?: number;
|
||||
surface?: 'verbs' | 'starter' | 'full';
|
||||
showToken: boolean;
|
||||
json: boolean;
|
||||
/** Accept a PROVEN-too-old serve (< SCOPES_MIN_SERVE_VERSION verifies
|
||||
* scoped tokens as FULL ACCESS) instead of failing registration. */
|
||||
allowOldServe: boolean;
|
||||
}
|
||||
|
||||
const REGISTER_USAGE =
|
||||
'Usage: gbrain agent register <name> --harness claude-code|codex|opencode|openclaw ' +
|
||||
'[--preset daily-driver|coding-agent] [--source ID] [--federated-read S1,S2] ' +
|
||||
'[--scopes "read write"] (--url URL | --port N) [--token-ttl SECONDS] ' +
|
||||
'[--surface verbs|starter|full] [--allow-old-serve] [--show-token] [--json]\n' +
|
||||
' gbrain agent register --reissue <client-id> --harness H (--url URL | --port N) [--show-token] [--json]';
|
||||
|
||||
export function parseAgentRegisterArgs(args: string[]): AgentRegisterArgs {
|
||||
const out: AgentRegisterArgs = { showToken: false, json: false, allowOldServe: false };
|
||||
let i = 0;
|
||||
while (i < args.length) {
|
||||
const flag = args[i];
|
||||
const value = args[i + 1];
|
||||
const requireValue = () => {
|
||||
if (value === undefined || value.startsWith('--')) {
|
||||
throw new Error(`${flag} requires a value`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
if (!flag.startsWith('--')) {
|
||||
if (out.name !== undefined) throw new Error(`Unexpected argument: ${flag}`);
|
||||
out.name = flag;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
switch (flag) {
|
||||
case '--reissue':
|
||||
out.reissueClientId = requireValue();
|
||||
i += 2; break;
|
||||
case '--harness': {
|
||||
const v = requireValue();
|
||||
if (!(REGISTER_HARNESSES as readonly string[]).includes(v)) {
|
||||
throw new Error(`--harness must be one of ${REGISTER_HARNESSES.join(' | ')} (got "${v}")`);
|
||||
}
|
||||
out.harness = v as RegisterHarness;
|
||||
i += 2; break;
|
||||
}
|
||||
case '--preset': {
|
||||
const v = requireValue();
|
||||
if (!(REGISTER_PRESETS as readonly string[]).includes(v)) {
|
||||
throw new Error(`--preset must be ${REGISTER_PRESETS.join(' | ')} (got "${v}")`);
|
||||
}
|
||||
out.preset = v as RegisterPreset;
|
||||
i += 2; break;
|
||||
}
|
||||
case '--source': {
|
||||
const v = requireValue();
|
||||
assertValidSourceId(v);
|
||||
out.source = v;
|
||||
i += 2; break;
|
||||
}
|
||||
case '--federated-read': {
|
||||
const v = requireValue();
|
||||
// Set-dedupe (order-preserving): a repeated id would otherwise fan
|
||||
// out twice in every downstream per-source loop.
|
||||
const ids = [...new Set(v.split(',').map(s => s.trim()).filter(Boolean))];
|
||||
if (ids.length === 0) throw new Error('--federated-read requires at least one source id');
|
||||
for (const id of ids) {
|
||||
if (id === ALL_SOURCES) {
|
||||
throw new Error('no wildcard read grant exists — list sources explicitly (__all__ is a trusted-local sentinel, never a grant)');
|
||||
}
|
||||
assertValidSourceId(id);
|
||||
}
|
||||
out.federatedRead = ids;
|
||||
i += 2; break;
|
||||
}
|
||||
case '--scopes': {
|
||||
// Tokenize exactly like auth.ts's register-client parser, then apply
|
||||
// the agent blocklist per-token, then the shared allowlist.
|
||||
const v = requireValue();
|
||||
const tokens = v.split(/[\s,]+/).filter(Boolean);
|
||||
if (tokens.length === 0) {
|
||||
throw new Error(`--scopes requires at least one scope (got ${JSON.stringify(v)})`);
|
||||
}
|
||||
for (const t of tokens) {
|
||||
if (SCOPE_BLOCKLIST.has(t)) {
|
||||
throw new Error(`scope "${t}" is not grantable to an agent client — use \`gbrain auth register-client\` for operator-grade scopes`);
|
||||
}
|
||||
}
|
||||
assertAllowedScopes(tokens);
|
||||
out.scopes = tokens.join(' ');
|
||||
i += 2; break;
|
||||
}
|
||||
case '--url':
|
||||
out.url = requireValue();
|
||||
i += 2; break;
|
||||
case '--port': {
|
||||
const raw = requireValue();
|
||||
const v = Number(raw);
|
||||
if (!Number.isInteger(v) || v < 1 || v > 65535) {
|
||||
throw new Error(`--port must be an integer between 1 and 65535 (got ${JSON.stringify(raw)})`);
|
||||
}
|
||||
out.port = v;
|
||||
i += 2; break;
|
||||
}
|
||||
case '--token-ttl': {
|
||||
out.tokenTtlSeconds = parseTokenTtl(requireValue(), 'Omit the flag for the 30-day default.');
|
||||
i += 2; break;
|
||||
}
|
||||
case '--surface': {
|
||||
const v = requireValue();
|
||||
if (v !== 'verbs' && v !== 'starter' && v !== 'full') {
|
||||
throw new Error(`--surface must be verbs | starter | full (got "${v}")`);
|
||||
}
|
||||
out.surface = v;
|
||||
i += 2; break;
|
||||
}
|
||||
case '--allow-old-serve': out.allowOldServe = true; i += 1; break;
|
||||
case '--show-token': out.showToken = true; i += 1; break;
|
||||
case '--json': out.json = true; i += 1; break;
|
||||
default:
|
||||
throw new Error(`Unknown flag: ${flag}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (out.reissueClientId !== undefined) {
|
||||
if (out.name !== undefined) throw new Error('--reissue takes a client-id, not a name');
|
||||
if (out.preset || out.source || out.federatedRead || out.scopes || out.surface || out.tokenTtlSeconds !== undefined) {
|
||||
throw new Error('--reissue only rotates the secret and reprints the block — scope flags are not allowed (use `gbrain auth rescope-client` to change scope)');
|
||||
}
|
||||
} else {
|
||||
if (!out.name) throw new Error(`agent register requires a <name>.\n${REGISTER_USAGE}`);
|
||||
if (!isValidName(out.name)) {
|
||||
throw new Error(`invalid name "${out.name}" — lowercase letters, digits, - and _ only (it becomes the MCP server name)`);
|
||||
}
|
||||
}
|
||||
if (!out.harness) throw new Error(`--harness is required.\n${REGISTER_USAGE}`);
|
||||
if (out.url !== undefined && out.port !== undefined) {
|
||||
throw new Error('pass --url OR --port, not both');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── preset resolution (pure descriptor; snapshot resolved by the runner) ──
|
||||
|
||||
export interface ResolvedPreset {
|
||||
scopes: string;
|
||||
writeSource: string;
|
||||
/** 'snapshot' = all non-archived sources at registration time. */
|
||||
federatedRead: string[] | 'snapshot';
|
||||
surface?: 'verbs' | 'starter' | 'full';
|
||||
/** The write source is the derived `<name>-workspace` (auto-creatable). */
|
||||
workspaceDerived: boolean;
|
||||
}
|
||||
|
||||
export function resolvePreset(flags: AgentRegisterArgs): ResolvedPreset {
|
||||
const name = flags.name ?? '';
|
||||
const explicit = <T>(v: T | undefined, fallback: T): T => (v !== undefined ? v : fallback);
|
||||
switch (flags.preset) {
|
||||
case 'daily-driver':
|
||||
return {
|
||||
scopes: explicit(flags.scopes, 'read write'),
|
||||
writeSource: explicit(flags.source, 'default'),
|
||||
federatedRead: flags.federatedRead ?? 'snapshot',
|
||||
// starter is literally "the ~20-op daily-driver set" (mcp/surface.ts).
|
||||
surface: explicit(flags.surface, 'starter'),
|
||||
workspaceDerived: false,
|
||||
};
|
||||
case 'coding-agent': {
|
||||
if (!flags.federatedRead || flags.federatedRead.length === 0) {
|
||||
throw new Error(
|
||||
'coding-agent requires --federated-read: a coding agent that reads nothing but its own scratch workspace is a misconfiguration. Pass the project sources it should read, e.g. --federated-read proj-widget',
|
||||
);
|
||||
}
|
||||
let writeSource = flags.source;
|
||||
let workspaceDerived = false;
|
||||
if (writeSource === undefined) {
|
||||
if (name.length > WORKSPACE_NAME_MAX || !SOURCE_ID_RE.test(`${name}${WORKSPACE_SUFFIX}`)) {
|
||||
throw new Error(
|
||||
`cannot derive a workspace source from "${name}": "${name}${WORKSPACE_SUFFIX}" must match ${String(SOURCE_ID_RE)} (name ≤ ${WORKSPACE_NAME_MAX} chars, lowercase letters/digits/hyphens). Pass --source <id> or shorten the name.`,
|
||||
);
|
||||
}
|
||||
writeSource = `${name}${WORKSPACE_SUFFIX}`;
|
||||
workspaceDerived = true;
|
||||
}
|
||||
return {
|
||||
scopes: explicit(flags.scopes, 'read write'),
|
||||
writeSource,
|
||||
federatedRead: [writeSource, ...flags.federatedRead.filter(s => s !== writeSource)],
|
||||
// starter, not full: `full` exposes brain-wide unscoped code-intel
|
||||
// reads to a scoped client. Widen per client via
|
||||
// `gbrain auth rescope-client --surface full`.
|
||||
surface: explicit(flags.surface, 'starter'),
|
||||
workspaceDerived,
|
||||
};
|
||||
}
|
||||
default:
|
||||
// No preset → passthrough defaults matching `auth register-client`
|
||||
// (no surface write, no snapshot).
|
||||
return {
|
||||
scopes: explicit(flags.scopes, 'read write'),
|
||||
writeSource: explicit(flags.source, 'default'),
|
||||
federatedRead: flags.federatedRead ?? [explicit(flags.source, 'default')],
|
||||
surface: flags.surface,
|
||||
workspaceDerived: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* daily-driver snapshot grant: all non-archived sources EXCEPT derived agent
|
||||
* workspaces (`*-workspace`). A workspace is another agent's scratch memory
|
||||
* by construction — sharing one requires an explicit --federated-read grant,
|
||||
* never a default-on snapshot sweep. The write source is re-added by the
|
||||
* runner's write-source-always-readable invariant, so an operator who
|
||||
* EXPLICITLY targets a workspace still gets it. Exported for the unit suite.
|
||||
*/
|
||||
export function snapshotGrantSources(ids: string[]): { granted: string[]; excludedWorkspaces: string[] } {
|
||||
const granted: string[] = [];
|
||||
const excludedWorkspaces: string[] = [];
|
||||
for (const id of ids) {
|
||||
(id.endsWith(WORKSPACE_SUFFIX) ? excludedWorkspaces : granted).push(id);
|
||||
}
|
||||
return { granted, excludedWorkspaces };
|
||||
}
|
||||
|
||||
// ── runner ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RegisterOutput {
|
||||
registered: RegisteredClient;
|
||||
accessToken?: string;
|
||||
tokenExpiresAt?: string;
|
||||
presetResolved: {
|
||||
preset: string | null;
|
||||
scopes: string;
|
||||
write_source: string;
|
||||
federated_read: string[];
|
||||
surface: string | null;
|
||||
token_ttl: number | null;
|
||||
};
|
||||
serveWarning: string | null;
|
||||
probeNote: string | null;
|
||||
block: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function fail(json: boolean, reason: RegisterFailReason, message: string, exitCode: 1 | 2, clientId?: string): never {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ ok: false, reason, message, ...(clientId ? { client_id: clientId } : {}) }));
|
||||
} else {
|
||||
console.error(`Error: ${message}`);
|
||||
if (clientId) {
|
||||
console.error(`The OAuth client was created before the failure. Revoke with: gbrain auth revoke-client "${clientId}"`);
|
||||
}
|
||||
}
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
export async function runAgentRegister(engine: BrainEngine | null, args: string[]): Promise<void> {
|
||||
// Help is answered by runAgent BEFORE this is called; a null engine here
|
||||
// means the dispatcher's help path leaked a real invocation — refuse.
|
||||
const wantsJson = args.includes('--json');
|
||||
if (engine === null) {
|
||||
fail(wantsJson, 'internal', 'agent register needs a configured brain (no engine available).', 1);
|
||||
}
|
||||
|
||||
// Belt-and-braces: cli.ts refuses pre-connect; re-check here for direct callers.
|
||||
const cfg = loadConfig();
|
||||
if (isThinClient(cfg)) {
|
||||
fail(wantsJson, 'thin_client', THIN_CLIENT_REGISTER_MESSAGE, 1);
|
||||
}
|
||||
|
||||
let flags: AgentRegisterArgs;
|
||||
try {
|
||||
flags = parseAgentRegisterArgs(args);
|
||||
} catch (e: any) {
|
||||
fail(wantsJson, 'invalid_argument', e.message, 2);
|
||||
}
|
||||
|
||||
// --url | --port resolution (required by every harness block). The config
|
||||
// remote_mcp fallback is dead by construction: thin clients are refused.
|
||||
const rawUrl = flags.url ?? (flags.port !== undefined ? `http://localhost:${flags.port}/mcp` : undefined);
|
||||
if (!rawUrl) {
|
||||
fail(flags.json, 'invalid_argument',
|
||||
`pass --url <mcp-url> or --port <serve-port> — every harness block embeds the brain URL. Example: --url https://brain.example.com/mcp\n${REGISTER_USAGE}`, 2);
|
||||
}
|
||||
const urlResult = normalizeMcpUrl(rawUrl);
|
||||
if (!urlResult.ok) {
|
||||
fail(flags.json, 'invalid_argument', urlResult.error, 2);
|
||||
}
|
||||
const url = urlResult.url;
|
||||
const urlWarning = urlResult.warning ?? null;
|
||||
|
||||
const sql = sqlQueryForEngine(engine);
|
||||
|
||||
try {
|
||||
if (flags.reissueClientId !== undefined) {
|
||||
const out = await runReissue(sql, engine, flags, url, urlWarning);
|
||||
printOutput(flags, out);
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = resolvePreset(flags);
|
||||
const name = flags.name!;
|
||||
|
||||
// Existence + not-archived check for a set of source ids. Engine lane
|
||||
// for the ANY() — SqlQuery forbids arrays. Shared by the explicit and
|
||||
// snapshot branches so the write source is validated the same way on both.
|
||||
const validateSourceIds = async (ids: string[]): Promise<void> => {
|
||||
const unique = [...new Set(ids)];
|
||||
if (unique.length === 0) return;
|
||||
const rows = await engine.executeRaw<{ id: string; archived: boolean | null }>(
|
||||
`SELECT id, archived FROM sources WHERE id = ANY($1::text[])`,
|
||||
[unique],
|
||||
);
|
||||
const found = new Map(rows.map(r => [r.id, r]));
|
||||
for (const id of unique) {
|
||||
const row = found.get(id);
|
||||
if (!row) {
|
||||
fail(flags.json, 'unknown_source',
|
||||
`source "${id}" does not exist — create it first (gbrain sources add ${id}) or check the spelling with \`gbrain sources list\`. Only the derived <name>-workspace is auto-created.`, 1);
|
||||
}
|
||||
if (row.archived) {
|
||||
fail(flags.json, 'archived_source',
|
||||
`source "${id}" is archived — unarchive it or drop it from the grant.`, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve the snapshot + validate every explicit source id (existence +
|
||||
// not archived).
|
||||
let federated: string[];
|
||||
if (preset.federatedRead === 'snapshot') {
|
||||
const all = await loadAllSources(engine, { includeArchived: false });
|
||||
const snap = snapshotGrantSources(all.map(s => s.id));
|
||||
federated = snap.granted;
|
||||
if (snap.excludedWorkspaces.length > 0) {
|
||||
console.error(
|
||||
`Note: snapshot grant excludes ${snap.excludedWorkspaces.length} agent workspace source(s) ` +
|
||||
`(${snap.excludedWorkspaces.join(', ')}) — agent scratch is not shared by default; ` +
|
||||
`grant one explicitly with --federated-read.`,
|
||||
);
|
||||
}
|
||||
// The snapshot proves existence + non-archived for its members only.
|
||||
// A write source OUTSIDE it (and any explicitly passed --source, even
|
||||
// when it happens to be inside) gets the same check as the explicit
|
||||
// branch — otherwise a typo'd or archived --source would be granted
|
||||
// and then die at the FK (or worse, silently write nowhere readable).
|
||||
if (!federated.includes(preset.writeSource) || flags.source !== undefined) {
|
||||
await validateSourceIds([preset.writeSource]);
|
||||
}
|
||||
} else {
|
||||
federated = preset.federatedRead;
|
||||
const toCheck = federated.filter(id => !(preset.workspaceDerived && id === preset.writeSource));
|
||||
if (!preset.workspaceDerived) toCheck.push(preset.writeSource);
|
||||
await validateSourceIds(toCheck);
|
||||
}
|
||||
// INVARIANT (every branch): the write source is always in the read grant.
|
||||
// A client that can't read its own write source is a remember→recall
|
||||
// black hole.
|
||||
if (!federated.includes(preset.writeSource)) federated.push(preset.writeSource);
|
||||
|
||||
// Column pre-flight: OUTSIDE the tx (25P02 — nothing inside may degrade).
|
||||
const columns = await preflightOauthClientColumns(sql);
|
||||
// Pre-v61 brains lack the scoped-client columns entirely; refuse BEFORE
|
||||
// the tx — registerClientManual's own 42703 retry ladder would die on
|
||||
// 25P02 inside our transaction.
|
||||
if (!columns.has('source_id') || !columns.has('federated_read')) {
|
||||
fail(flags.json, 'brain_too_old',
|
||||
'this brain predates scoped OAuth clients (source_id/federated_read columns) — run `gbrain apply-migrations --yes` first.', 1);
|
||||
}
|
||||
const ttl = flags.tokenTtlSeconds ?? REGISTER_DEFAULT_TOKEN_TTL_SECONDS;
|
||||
const preflightNotes: string[] = [];
|
||||
if (!columns.has('token_ttl')) {
|
||||
preflightNotes.push('this brain predates the token_ttl column; run `gbrain apply-migrations --yes` — the server default (1 hour) applies until then.');
|
||||
}
|
||||
if (preset.surface !== undefined && !columns.has('surface')) {
|
||||
preflightNotes.push('this brain predates the surface column; run `gbrain apply-migrations --yes` — no per-client surface tier was set.');
|
||||
}
|
||||
for (const note of preflightNotes) console.error(`Note: ${note}`);
|
||||
|
||||
const registerArgs: RegisterClientArgs = {
|
||||
grantTypes: ['client_credentials'],
|
||||
scopes: preset.scopes,
|
||||
sourceId: preset.writeSource,
|
||||
federatedRead: federated,
|
||||
redirectUris: [],
|
||||
tokenEndpointAuthMethod: undefined,
|
||||
boundTools: undefined,
|
||||
boundSourceId: undefined,
|
||||
boundBrainId: undefined,
|
||||
boundSlugPrefixes: undefined,
|
||||
boundMaxConcurrent: undefined,
|
||||
budgetUsdPerDay: undefined,
|
||||
tokenTtlSeconds: undefined,
|
||||
};
|
||||
|
||||
let registered!: RegisteredClient;
|
||||
let createdWorkspace = false;
|
||||
await engine.transaction(async (tx) => {
|
||||
const txSql = sqlQueryForEngine(tx);
|
||||
// Name-scoped advisory lock: no unique index exists on client_name, so
|
||||
// two concurrent registers of the same name would both pass the
|
||||
// pre-check. xact-scoped — released at COMMIT/ROLLBACK.
|
||||
await tx.executeRaw(`SELECT pg_advisory_xact_lock(hashtext($1)::bigint)`, [registerClientNameLockKey(name)]);
|
||||
|
||||
const dupRows = columns.has('deleted_at')
|
||||
? await txSql`SELECT client_id FROM oauth_clients WHERE client_name = ${name} AND deleted_at IS NULL`
|
||||
: await txSql`SELECT client_id FROM oauth_clients WHERE client_name = ${name}`;
|
||||
if (dupRows.length > 0) {
|
||||
throw new RegisterError('duplicate_name',
|
||||
`an OAuth client named "${name}" already exists (${String(dupRows[0].client_id)}). Revoke it first (gbrain auth revoke-client "${String(dupRows[0].client_id)}") or rotate its secret with --reissue.`);
|
||||
}
|
||||
|
||||
if (preset.workspaceDerived) {
|
||||
createdWorkspace = await ensureWorkspaceSource(tx, txSql, preset.writeSource);
|
||||
}
|
||||
|
||||
registered = await registerScopedClient(txSql, name, registerArgs, {
|
||||
tokenTtlSeconds: ttl,
|
||||
surface: preset.surface,
|
||||
columns,
|
||||
});
|
||||
});
|
||||
registered.created.source = createdWorkspace;
|
||||
|
||||
// POST-COMMIT: a client row now exists — from here, EVERY failure must
|
||||
// carry the clientId so fail() prints the revoke guidance (never a false
|
||||
// "nothing was created"). RegisterErrors keep their reason; anything else
|
||||
// maps to mint_failed with the clientId attached.
|
||||
try {
|
||||
// Post-commit, fail-open audit (its designed position — never in the tx).
|
||||
if (registered.surface !== undefined) {
|
||||
await writeSurfaceChangeAudit(engine, {
|
||||
actor: 'operator',
|
||||
client_id: registered.clientId,
|
||||
old: registered.surfaceOld ?? null,
|
||||
new: registered.surface,
|
||||
via: 'register_cli',
|
||||
});
|
||||
}
|
||||
|
||||
const out = await mintAndProbe(engine, flags, registered, name, url, urlWarning, {
|
||||
preset: flags.preset ?? null,
|
||||
scopes: preset.scopes,
|
||||
write_source: preset.writeSource,
|
||||
federated_read: federated,
|
||||
surface: registered.surface ?? null,
|
||||
token_ttl: registered.tokenTtl ?? null,
|
||||
});
|
||||
printOutput(flags, out);
|
||||
} catch (e: any) {
|
||||
if (e instanceof RegisterError) {
|
||||
throw new RegisterError(e.reason, e.message, e.clientId ?? registered.clientId);
|
||||
}
|
||||
throw new RegisterError('mint_failed', e?.message ?? String(e), registered.clientId);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e instanceof RegisterError) {
|
||||
fail(flags.json, e.reason, e.message, 1, e.clientId);
|
||||
}
|
||||
fail(flags.json, 'mint_failed', e?.message ?? String(e), 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Create the derived workspace source if missing; reuse only when clean.
|
||||
* Returns true when this call created it. Never catches source_id_taken as
|
||||
* control flow — existence is decided by SELECT first. "Clean" means: not
|
||||
* archived, no local path, zero pages AND zero facts AND zero files (facts
|
||||
* are the primary agent write lane, and files can exist page-less — a
|
||||
* pages-only check would silently reuse a source that already holds another
|
||||
* agent's memory). raw_data and links are FK-subsumed: both are page-scoped
|
||||
* (NOT NULL page FKs, ON DELETE CASCADE, no source_id column), so they
|
||||
* cannot be non-zero when the page count is zero; there is no `entities`
|
||||
* table (entity pages count as pages, fact entities count as facts).
|
||||
* Exported for the unit suite. */
|
||||
export async function ensureWorkspaceSource(
|
||||
tx: BrainEngine,
|
||||
txSql: SqlQuery,
|
||||
id: string,
|
||||
): Promise<boolean> {
|
||||
const existing = await txSql`SELECT id, local_path, archived FROM sources WHERE id = ${id}`;
|
||||
if (existing.length > 0) {
|
||||
if (existing[0].archived === true) {
|
||||
throw new RegisterError('archived_source',
|
||||
`workspace source "${id}" exists but is archived — unarchive it (gbrain sources restore ${id}) or pick another name.`);
|
||||
}
|
||||
const localPath = existing[0].local_path;
|
||||
const pages = await txSql`SELECT count(*) AS n FROM pages WHERE source_id = ${id}`;
|
||||
const nPages = Number(pages[0]?.n ?? 0);
|
||||
const facts = await txSql`SELECT count(*) AS n FROM facts WHERE source_id = ${id}`;
|
||||
const nFacts = Number(facts[0]?.n ?? 0);
|
||||
// files: tolerant of a brain without the table — probed via
|
||||
// information_schema (NEVER try/catch: this runs inside the register tx,
|
||||
// where an aborted statement is a 25P02, not a degrade).
|
||||
let nFiles = 0;
|
||||
const filesTable = await txSql`
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = current_schema() AND table_name = 'files'`;
|
||||
if (filesTable.length > 0) {
|
||||
const files = await txSql`SELECT count(*) AS n FROM files WHERE source_id = ${id}`;
|
||||
nFiles = Number(files[0]?.n ?? 0);
|
||||
}
|
||||
if (localPath != null || nPages > 0 || nFacts > 0 || nFiles > 0) {
|
||||
const why = localPath != null
|
||||
? 'is backed by a local path'
|
||||
: nPages > 0 ? `holds ${nPages} pages`
|
||||
: nFacts > 0 ? `holds ${nFacts} facts` : `holds ${nFiles} files`;
|
||||
throw new RegisterError('dirty_source',
|
||||
`source "${id}" already exists and ${why} — pass --source ${id} to reuse it deliberately, or pick another agent name.`);
|
||||
}
|
||||
console.error(`Note: reusing existing empty workspace source "${id}".`);
|
||||
return false;
|
||||
}
|
||||
await addSource(tx, { id });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Shared tail: exchange client credentials, probe the serve, build the block.
|
||||
* `blockName` is the MCP server name embedded in the harness block: the agent
|
||||
* name on the register lane, the stored client_name (when valid) on reissue. */
|
||||
async function mintAndProbe(
|
||||
engine: BrainEngine,
|
||||
flags: AgentRegisterArgs,
|
||||
registered: RegisteredClient,
|
||||
blockName: string,
|
||||
url: string,
|
||||
urlWarning: string | null,
|
||||
presetResolved: RegisterOutput['presetResolved'],
|
||||
): Promise<RegisterOutput> {
|
||||
const { GBrainOAuthProvider } = await import('../core/oauth-provider.ts');
|
||||
// The tx-scoped sql is dead after COMMIT — the exchange runs on the OUTER engine.
|
||||
const provider = new GBrainOAuthProvider({ sql: sqlQueryForEngine(engine) });
|
||||
|
||||
let accessToken: string | undefined;
|
||||
let tokenExpiresAt: string | undefined;
|
||||
if (registered.clientSecret) {
|
||||
try {
|
||||
const tokens = await provider.exchangeClientCredentials(registered.clientId, registered.clientSecret);
|
||||
accessToken = tokens.access_token;
|
||||
if (typeof tokens.expires_in === 'number') {
|
||||
tokenExpiresAt = new Date(Date.now() + tokens.expires_in * 1000).toISOString();
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw new RegisterError('mint_failed',
|
||||
`client registered but the token exchange failed: ${e?.message ?? String(e)}`, registered.clientId);
|
||||
}
|
||||
}
|
||||
|
||||
// Serve probe. An UNREACHABLE serve stays a note (it proves nothing). A
|
||||
// reachable serve that PROVES version < SCOPES_MIN_SERVE_VERSION fails the
|
||||
// registration lane — that serve verifies the freshly-minted scoped token
|
||||
// as FULL ACCESS — unless the operator passed --allow-old-serve. The
|
||||
// reissue lane keeps the warning: by this point the secret is already
|
||||
// ROTATED, and failing would discard the only copy of the new credential.
|
||||
let probeNote: string | null = null;
|
||||
const health = await probeServeHealth(url, fetch);
|
||||
if (!health.ok) {
|
||||
probeNote = `could not reach ${url.replace(/\/mcp$/, '')}/health — skipping the scoped-token version check (${health.detail ?? 'unreachable'}).`;
|
||||
} else if (health.version && isServeOlderThanScopes(health.version)) {
|
||||
if (flags.reissueClientId === undefined && !flags.allowOldServe) {
|
||||
throw new RegisterError('serve_too_old',
|
||||
`serve at ${url} reports v${health.version} — older than ${SCOPES_MIN_SERVE_VERSION}, so it verifies this scoped token as FULL ACCESS (the scope grant is not enforced). Upgrade the serve, or re-run with --allow-old-serve to accept the risk.`,
|
||||
registered.clientId);
|
||||
}
|
||||
probeNote = `serve at ${url} reports v${health.version} — OLDER than ${SCOPES_MIN_SERVE_VERSION}: it verifies this scoped token as FULL ACCESS. Upgrade the serve.`;
|
||||
} else {
|
||||
probeNote = `serve health: OK${health.version ? ` (v${health.version})` : ''}.`;
|
||||
}
|
||||
|
||||
const block = buildHarnessBlock(flags.harness!, {
|
||||
name: blockName,
|
||||
url,
|
||||
token: accessToken ?? null,
|
||||
clientId: registered.clientId,
|
||||
clientSecret: registered.clientSecret ?? null,
|
||||
showToken: flags.showToken,
|
||||
expiresAt: tokenExpiresAt ?? null,
|
||||
isReissue: flags.reissueClientId !== undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
registered,
|
||||
accessToken,
|
||||
tokenExpiresAt,
|
||||
presetResolved,
|
||||
serveWarning: urlWarning,
|
||||
probeNote,
|
||||
block,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
/** --reissue: rotate the client secret and reprint the block. Outstanding
|
||||
* access tokens remain valid until they expire — rotation is not revocation. */
|
||||
async function runReissue(
|
||||
sql: SqlQuery,
|
||||
engine: BrainEngine,
|
||||
flags: AgentRegisterArgs,
|
||||
url: string,
|
||||
urlWarning: string | null,
|
||||
): Promise<RegisterOutput> {
|
||||
const clientId = flags.reissueClientId!;
|
||||
const columns = await preflightOauthClientColumns(sql);
|
||||
// Projection derived from the pre-flight column set: pre-migration brains
|
||||
// lack source_id/federated_read/token_ttl/deleted_at — drop the absent ones
|
||||
// (the ?? defaults below tolerate missing keys). Column names come from a
|
||||
// fixed allowlist, never caller input.
|
||||
const projection = [
|
||||
'client_id', 'client_name', 'grant_types', 'client_secret_hash',
|
||||
...['source_id', 'federated_read', 'token_ttl', 'deleted_at'].filter(c => columns.has(c)),
|
||||
];
|
||||
const rows = await engine.executeRaw<Record<string, unknown>>(
|
||||
`SELECT ${projection.join(', ')} FROM oauth_clients WHERE client_id = $1`,
|
||||
[clientId],
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
throw new RegisterError('reissue_invalid_target', `no OAuth client with id "${clientId}" — list clients with \`gbrain auth clients\`.`);
|
||||
}
|
||||
const row = rows[0] as Record<string, unknown>;
|
||||
if (row.deleted_at != null) {
|
||||
throw new RegisterError('reissue_invalid_target', `client "${clientId}" is deleted — register a new one.`);
|
||||
}
|
||||
if (row.client_secret_hash == null) {
|
||||
throw new RegisterError('reissue_invalid_target', `client "${clientId}" is a public (PKCE) client — it has no secret to rotate.`);
|
||||
}
|
||||
const grants = Array.isArray(row.grant_types) ? (row.grant_types as string[]) : String(row.grant_types ?? '').split(',');
|
||||
if (!grants.includes('client_credentials')) {
|
||||
throw new RegisterError('reissue_invalid_target', `client "${clientId}" has no client_credentials grant — nothing to reissue.`);
|
||||
}
|
||||
|
||||
const newSecret = await rotateClientSecret(engine, clientId, String(row.client_name));
|
||||
|
||||
const registered: RegisteredClient = {
|
||||
clientId,
|
||||
clientSecret: newSecret,
|
||||
grantTypes: grants,
|
||||
scopes: '(unchanged)',
|
||||
authMethod: 'client_secret_post',
|
||||
redirectUris: [],
|
||||
sourceId: String(row.source_id ?? 'default'),
|
||||
federatedRead: Array.isArray(row.federated_read) ? (row.federated_read as string[]) : [String(row.source_id ?? 'default')],
|
||||
...(typeof row.token_ttl === 'number' ? { tokenTtl: row.token_ttl } : {}),
|
||||
created: { source: false },
|
||||
};
|
||||
|
||||
// MCP server name: the stored client_name when it is a valid server name
|
||||
// (it was validated at registration, but DCR/legacy rows may carry
|
||||
// arbitrary text) — fall back to the client id otherwise.
|
||||
const clientName = String(row.client_name ?? '');
|
||||
const blockName = isValidName(clientName) ? clientName : clientId;
|
||||
const out = await mintAndProbe(engine, flags, registered, blockName, url, urlWarning, {
|
||||
preset: null,
|
||||
scopes: registered.scopes,
|
||||
write_source: registered.sourceId,
|
||||
federated_read: registered.federatedRead,
|
||||
surface: null,
|
||||
token_ttl: registered.tokenTtl ?? null,
|
||||
});
|
||||
out.probeNote = `${out.probeNote ?? ''}${out.probeNote ? ' ' : ''}Secret ROTATED: the old secret no longer mints tokens; outstanding access tokens stay valid until expiry — revoke the client to kill them now.`;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Rotate a confidential client's secret under the same name-scoped advisory
|
||||
* lock registration uses. Rotation is NOT revocation: outstanding access
|
||||
* tokens stay valid until they expire. Exported for the unit suite. */
|
||||
export async function rotateClientSecret(engine: BrainEngine, clientId: string, clientName: string): Promise<string> {
|
||||
let newSecret!: string;
|
||||
await engine.transaction(async (tx) => {
|
||||
const txSql = sqlQueryForEngine(tx);
|
||||
await tx.executeRaw(`SELECT pg_advisory_xact_lock(hashtext($1)::bigint)`, [registerClientNameLockKey(clientName)]);
|
||||
newSecret = generateToken('gbrain_cs_');
|
||||
const updated = await txSql`
|
||||
UPDATE oauth_clients SET client_secret_hash = ${hashToken(newSecret)}
|
||||
WHERE client_id = ${clientId}
|
||||
RETURNING client_id
|
||||
`;
|
||||
if (updated.length === 0) throw new RegisterError('reissue_invalid_target', `client "${clientId}" vanished mid-rotation.`);
|
||||
});
|
||||
return newSecret;
|
||||
}
|
||||
|
||||
// ── rendering ─────────────────────────────────────────────────────────────
|
||||
|
||||
function buildHarnessBlock(
|
||||
harness: RegisterHarness,
|
||||
p: { name: string; url: string; token: string | null; clientId: string; clientSecret: string | null; showToken: boolean; expiresAt: string | null; isReissue: boolean },
|
||||
): string {
|
||||
const shownToken = p.token ? (p.showToken ? p.token : REDACTED) : '<mint-a-token>';
|
||||
const shownSecret = p.clientSecret ? (p.showToken ? p.clientSecret : REDACTED) : REDACTED;
|
||||
// Lane-honest recovery: re-running `agent register <name>` fails on
|
||||
// duplicate_name, so the register lane names the REAL recovery (--reissue);
|
||||
// the reissue lane's re-run genuinely works, so it keeps the simple hint.
|
||||
const hintText = p.isReissue
|
||||
? '(credentials redacted — re-run with --show-token for a paste-ready block)'
|
||||
: `(credentials redacted — reissue a paste-ready block with: gbrain agent register --reissue ${p.clientId} --harness ${harness} --url ${p.url} --show-token)`;
|
||||
const redactionHint = p.showToken ? [] : [hintText];
|
||||
switch (harness) {
|
||||
case 'claude-code': {
|
||||
const cmd = cmdString('claude', buildClaudeMcpAddArgv({ name: p.name, url: p.url, headerToken: shownToken }));
|
||||
return ['# Paste into Claude Code:', '', ` ${cmd}`, '', ...redactionHint].join('\n');
|
||||
}
|
||||
case 'codex': {
|
||||
const cmd = cmdString('codex', buildCodexMcpAddArgv({ name: p.name, url: p.url, envVar: GBRAIN_REMOTE_TOKEN_ENV }));
|
||||
const toml = renderCodexHttpServerBlock({ name: p.name, url: p.url, bearerToken: shownToken });
|
||||
return [
|
||||
'# Paste into Codex:',
|
||||
'',
|
||||
` export ${GBRAIN_REMOTE_TOKEN_ENV}=${shellQuote(shownToken)}`,
|
||||
` ${cmd}`,
|
||||
'',
|
||||
`# Or add to ~/.codex/config.toml directly (then: chmod 600 ~/.codex/config.toml):`,
|
||||
toml,
|
||||
'',
|
||||
...redactionHint,
|
||||
].join('\n');
|
||||
}
|
||||
case 'opencode': {
|
||||
const cmd = cmdString('opencode', buildOpencodeMcpAddArgv({ name: p.name, url: p.url, envVar: GBRAIN_REMOTE_TOKEN_ENV }));
|
||||
return [
|
||||
'# Paste into opencode:',
|
||||
'',
|
||||
` export ${GBRAIN_REMOTE_TOKEN_ENV}=${shellQuote(shownToken)}`,
|
||||
` ${cmd}`,
|
||||
'',
|
||||
...redactionHint,
|
||||
].join('\n');
|
||||
}
|
||||
case 'openclaw':
|
||||
return openclawThinClientBlock({
|
||||
issuerUrl: issuerFromMcpUrl(p.url),
|
||||
mcpUrl: p.url,
|
||||
clientId: p.clientId,
|
||||
clientSecret: shownSecret,
|
||||
}) + (p.showToken ? '' : `\n\n${hintText}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printOutput(flags: AgentRegisterArgs, out: RegisterOutput): void {
|
||||
const r = out.registered;
|
||||
const expiry = out.tokenExpiresAt ?? null;
|
||||
const floorLine = `token scoping requires serve ≥ ${SCOPES_MIN_SERVE_VERSION}; an older serve verifies this token as full access.`;
|
||||
|
||||
if (flags.json) {
|
||||
// ONE JSON document on stdout; every note goes to stderr.
|
||||
if (out.serveWarning) console.error(out.serveWarning);
|
||||
if (out.probeNote) console.error(out.probeNote);
|
||||
console.error(floorLine);
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
schema_version: 1,
|
||||
client_id: r.clientId,
|
||||
client_secret: r.clientSecret ? (flags.showToken ? r.clientSecret : REDACTED) : null,
|
||||
secret_redacted: !!r.clientSecret && !flags.showToken,
|
||||
access_token: out.accessToken ? (flags.showToken ? out.accessToken : REDACTED) : null,
|
||||
token_redacted: !!out.accessToken && !flags.showToken,
|
||||
token_expires_at: expiry,
|
||||
mcp_url: out.url,
|
||||
harness: flags.harness,
|
||||
preset_resolved: out.presetResolved,
|
||||
created_workspace_source: r.created.source,
|
||||
skipped: r.skipped ?? null,
|
||||
// probe_note carries the serve health-probe result; serve_warning is
|
||||
// the URL http-token warning (null when the URL is clean).
|
||||
probe_note: out.probeNote,
|
||||
serve_warning: out.serveWarning ?? null,
|
||||
block: out.block,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Lane-honest header: rotation is not registration (JSON shape unchanged —
|
||||
// this is the human printer only).
|
||||
const header = flags.reissueClientId !== undefined
|
||||
? `Agent client secret ROTATED: "${flags.name ?? r.clientId}"`
|
||||
: `Agent client registered: "${flags.name ?? r.clientId}"`;
|
||||
console.log(`${header}\n`);
|
||||
console.log(` Client ID: ${r.clientId}`);
|
||||
if (r.clientSecret) {
|
||||
console.log(` Client Secret: ${flags.showToken ? r.clientSecret : REDACTED}`);
|
||||
}
|
||||
console.log(` Scopes: ${out.presetResolved.scopes}`);
|
||||
console.log(` Write source: ${out.presetResolved.write_source}${r.created.source ? ' (created)' : ''}`);
|
||||
console.log(` Federated reads: ${out.presetResolved.federated_read.join(', ')}`);
|
||||
if (out.presetResolved.surface) {
|
||||
console.log(` Surface tier: ${out.presetResolved.surface} (widen: gbrain auth rescope-client "${r.clientId}" --surface full)`);
|
||||
}
|
||||
if (out.presetResolved.token_ttl) {
|
||||
console.log(` Token TTL: ${out.presetResolved.token_ttl}s`);
|
||||
}
|
||||
if (expiry) {
|
||||
console.log(` Token expires: ${expiry} — reissue with: gbrain agent register --reissue ${r.clientId} --harness ${flags.harness} --url ${out.url}`);
|
||||
}
|
||||
console.log('');
|
||||
console.log(out.block);
|
||||
console.log('');
|
||||
if (out.serveWarning) console.log(out.serveWarning);
|
||||
if (out.probeNote) console.log(out.probeNote);
|
||||
console.log(floorLine);
|
||||
console.log('');
|
||||
console.log(OAUTH_SECRET_NOTE);
|
||||
console.log(`Revoke with: gbrain auth revoke-client "${r.clientId}"`);
|
||||
}
|
||||
|
||||
// ── help ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function printRegisterHelp(): void {
|
||||
console.log(`gbrain agent register — mint a scoped OAuth client + token and print the harness wiring
|
||||
|
||||
${REGISTER_USAGE}
|
||||
|
||||
PRESETS
|
||||
daily-driver read-broad, write to one source. Federated reads default to a
|
||||
SNAPSHOT of all current non-archived sources EXCLUDING other
|
||||
agents' *-workspace sources (agent scratch — share one via an
|
||||
explicit --federated-read). New sources need a re-grant via
|
||||
\`gbrain auth rescope-client\`. Surface: starter.
|
||||
coding-agent write-isolated: writes land in <name>${WORKSPACE_SUFFIX} (auto-created,
|
||||
DB-only). Requires --federated-read (the project sources it may
|
||||
read). Surface: starter.
|
||||
|
||||
NOTES
|
||||
Runs on the BRAIN HOST (a thin client is refused). Every block embeds the
|
||||
brain URL: pass --url or --port. The minted token defaults to a 30-day TTL
|
||||
(the server default is 1 hour); the printed expiry comes from the exchange.
|
||||
A reachable serve that reports a version older than ${SCOPES_MIN_SERVE_VERSION}
|
||||
FAILS the registration (it would treat the scoped token as full access);
|
||||
pass --allow-old-serve to accept that risk. An unreachable serve is only a
|
||||
note. --reissue <client-id> rotates the client secret and reprints the
|
||||
block; outstanding tokens stay valid until expiry.`);
|
||||
}
|
||||
+24
-3
@@ -38,20 +38,40 @@ function isKnownFlag(s: string): boolean {
|
||||
|
||||
// ── command dispatcher ────────────────────────────────────
|
||||
|
||||
export async function runAgent(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
export async function runAgent(engine: BrainEngine | null, args: string[]): Promise<void> {
|
||||
const sub = args[0];
|
||||
if (!sub || sub === '--help' || sub === '-h') {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Subcommand-aware help that STOPS at the `--` terminator: `agent run --
|
||||
// --help` submits the LITERAL prompt; only a pre-`--` --help/-h is a help
|
||||
// request. Answered before any engine or queue work, so the
|
||||
// SELF_HELP_WITHOUT_ENGINE lane (engine === null) prints real help on a
|
||||
// brainless machine and can never submit a job (cathedral-6 eng review).
|
||||
const rest = args.slice(1);
|
||||
const dd = rest.indexOf('--');
|
||||
const helpScan = dd === -1 ? rest : rest.slice(0, dd);
|
||||
const wantsHelp = helpScan.includes('--help') || helpScan.includes('-h');
|
||||
|
||||
switch (sub) {
|
||||
case 'run':
|
||||
await runAgentRun(engine, args.slice(1));
|
||||
if (wantsHelp) { printHelp(); return; }
|
||||
if (!engine) { console.error('gbrain agent run needs a configured brain. Run `gbrain init` first.'); process.exit(1); }
|
||||
await runAgentRun(engine, rest);
|
||||
return;
|
||||
case 'logs':
|
||||
await runAgentLogsCmd(engine, args.slice(1));
|
||||
if (wantsHelp) { printHelp(); return; }
|
||||
if (!engine) { console.error('gbrain agent logs needs a configured brain. Run `gbrain init` first.'); process.exit(1); }
|
||||
await runAgentLogsCmd(engine, rest);
|
||||
return;
|
||||
case 'register': {
|
||||
const { printRegisterHelp, runAgentRegister } = await import('./agent-register.ts');
|
||||
if (wantsHelp) { printRegisterHelp(); return; }
|
||||
await runAgentRegister(engine, rest);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
console.error(`gbrain agent: unknown subcommand "${sub}"`);
|
||||
printHelp();
|
||||
@@ -65,6 +85,7 @@ function printHelp(): void {
|
||||
USAGE
|
||||
gbrain agent run <prompt> [flags]
|
||||
gbrain agent logs <job_id> [--follow] [--since <spec>]
|
||||
gbrain agent register <name> --harness <h> [flags] (see: gbrain agent register --help)
|
||||
|
||||
SUBMITTING
|
||||
gbrain agent run <prompt>
|
||||
|
||||
+276
-65
@@ -24,6 +24,7 @@ import { loadConfig, toEngineConfig } from '../core/config.ts';
|
||||
import { createEngine } from '../core/engine-factory.ts';
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
import { assertAllowedScopes } from '../core/scope.ts';
|
||||
import { isUndefinedColumnError, isUndefinedTableError } from '../core/utils.ts';
|
||||
import { TOKEN_ID_RE } from '../core/token-mint.ts';
|
||||
import { normalizeTokenScopes } from '../core/legacy-token-scope.ts';
|
||||
import { sqlQueryForEngine, executeRawJsonb, type SqlQuery } from '../core/sql-query.ts';
|
||||
@@ -419,7 +420,7 @@ async function revokeClient(clientId: string) {
|
||||
* and `--token-endpoint-auth-method` is recognized. Repeatable flags
|
||||
* accumulate into arrays. Unknown flags throw a usage error.
|
||||
*/
|
||||
interface RegisterClientArgs {
|
||||
export interface RegisterClientArgs {
|
||||
grantTypes: string[];
|
||||
scopes: string;
|
||||
sourceId: string;
|
||||
@@ -432,6 +433,29 @@ interface RegisterClientArgs {
|
||||
boundSlugPrefixes: string[] | undefined;
|
||||
boundMaxConcurrent: number | undefined;
|
||||
budgetUsdPerDay: string | undefined;
|
||||
tokenTtlSeconds: number | undefined;
|
||||
}
|
||||
|
||||
/** --token-ttl bounds: 1 minute .. 90 days. The SERVER default for CLI-minted
|
||||
* access tokens is 3600s (oauth-provider.ts tokenTtl) — NOT 30 days; callers
|
||||
* that promise long-lived tokens must write oauth_clients.token_ttl. */
|
||||
export const TOKEN_TTL_MIN_SECONDS = 60;
|
||||
export const TOKEN_TTL_MAX_SECONDS = 7_776_000;
|
||||
|
||||
/**
|
||||
* Shared --token-ttl value parser (auth register-client + agent register).
|
||||
* `hint` is the parser-specific tail naming what omitting the flag means
|
||||
* (the two commands have different defaults). Throws the canonical bounds
|
||||
* message on anything outside [TOKEN_TTL_MIN_SECONDS, TOKEN_TTL_MAX_SECONDS].
|
||||
*/
|
||||
export function parseTokenTtl(raw: string, hint: string): number {
|
||||
const v = Number(raw);
|
||||
if (!Number.isInteger(v) || v < TOKEN_TTL_MIN_SECONDS || v > TOKEN_TTL_MAX_SECONDS) {
|
||||
throw new Error(
|
||||
`--token-ttl must be an integer number of seconds between ${TOKEN_TTL_MIN_SECONDS} and ${TOKEN_TTL_MAX_SECONDS} (90 days); got ${JSON.stringify(raw)}. ${hint}`,
|
||||
);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export function parseRegisterClientArgs(args: string[]): RegisterClientArgs {
|
||||
@@ -448,6 +472,7 @@ export function parseRegisterClientArgs(args: string[]): RegisterClientArgs {
|
||||
boundSlugPrefixes: undefined,
|
||||
boundMaxConcurrent: undefined,
|
||||
budgetUsdPerDay: undefined,
|
||||
tokenTtlSeconds: undefined,
|
||||
};
|
||||
let i = 0;
|
||||
let grantTypesSet = false;
|
||||
@@ -538,6 +563,10 @@ export function parseRegisterClientArgs(args: string[]): RegisterClientArgs {
|
||||
out.budgetUsdPerDay = v;
|
||||
i += 2; break;
|
||||
}
|
||||
case '--token-ttl': {
|
||||
out.tokenTtlSeconds = parseTokenTtl(requireValue(), 'Omit the flag to keep the server default.');
|
||||
i += 2; break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown flag: ${flag}`);
|
||||
}
|
||||
@@ -552,19 +581,75 @@ export function parseRegisterClientArgs(args: string[]): RegisterClientArgs {
|
||||
return out;
|
||||
}
|
||||
|
||||
async function registerClient(name: string, args: string[]) {
|
||||
if (!name) {
|
||||
console.error('Usage: auth register-client <name> [--grant-types G] [--scopes S] [--source SOURCE] [--federated-read SRC1,SRC2,...] [--redirect-uri URI ...] [--token-endpoint-auth-method client_secret_post|client_secret_basic|none] [--bound-tools T1,T2] [--bound-source SOURCE] [--bound-brain BRAIN] [--bound-slug-prefixes P1,P2] [--bound-max-concurrent N] [--budget-usd-per-day USD]');
|
||||
process.exit(1);
|
||||
}
|
||||
let parsed: RegisterClientArgs;
|
||||
try {
|
||||
parsed = parseRegisterClientArgs(args);
|
||||
} catch (e: any) {
|
||||
console.error(`Error: ${e.message}`);
|
||||
console.error('Usage: auth register-client <name> [--grant-types G] [--scopes S] [--source SOURCE] [--federated-read SRC1,SRC2,...] [--redirect-uri URI ...] [--token-endpoint-auth-method client_secret_post|client_secret_basic|none] [--bound-tools T1,T2] [--bound-source SOURCE] [--bound-brain BRAIN] [--bound-slug-prefixes P1,P2] [--bound-max-concurrent N] [--budget-usd-per-day USD]');
|
||||
process.exit(1);
|
||||
}
|
||||
/**
|
||||
* Column pre-flight (cathedral-6): decide statement shapes BEFORE any
|
||||
* transaction. Postgres/PGLite abort the whole tx on any statement error
|
||||
* (25P02) and SqlQuery has no savepoint seam, so "catch 42703 and continue"
|
||||
* is impossible inside a tx — optional-column degrades must be decided here,
|
||||
* outside, once.
|
||||
*/
|
||||
export async function preflightOauthClientColumns(sql: SqlQuery): Promise<Set<string>> {
|
||||
const rows = await sql`
|
||||
SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = 'oauth_clients'
|
||||
AND table_schema = current_schema()
|
||||
AND column_name IN ('token_ttl', 'surface', 'federated_read', 'source_id', 'deleted_at')
|
||||
`;
|
||||
return new Set(rows.map(r => String(r.column_name)));
|
||||
}
|
||||
|
||||
export interface RegisterScopedClientOpts {
|
||||
/** Per-client access-token TTL to persist (oauth_clients.token_ttl). */
|
||||
tokenTtlSeconds?: number;
|
||||
/** Per-client tool-surface tier, written via provider.rescopeClient — the
|
||||
* ONLY surface-column writer (sets surface_set_by='operator', the lock
|
||||
* request_tools cannot override). Never a raw column UPDATE. */
|
||||
surface?: 'verbs' | 'starter' | 'full';
|
||||
/** Result of preflightOauthClientColumns — decides which optional-column
|
||||
* writes are attempted. Absent → attempt everything (caller owns errors). */
|
||||
columns?: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The data a scoped-client registration produces — everything a printer
|
||||
* (auth register-client's byte-pinned block, agent register's summary,
|
||||
* or the admin HTTP route) needs, with ZERO console output produced here.
|
||||
*/
|
||||
export interface RegisteredClient {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
grantTypes: string[];
|
||||
scopes: string;
|
||||
authMethod: string;
|
||||
redirectUris: string[];
|
||||
sourceId: string;
|
||||
federatedRead: string[];
|
||||
surface?: 'verbs' | 'starter' | 'full';
|
||||
tokenTtl?: number;
|
||||
created: { source: boolean };
|
||||
/** Previous surface row value when opts.surface was written (for the
|
||||
* post-commit audit row — audit is fail-open and NEVER runs in the tx). */
|
||||
surfaceOld?: string | null;
|
||||
/** Optional-column writes skipped by the pre-flight (pre-migration brain). */
|
||||
skipped?: { tokenTtl?: boolean; surface?: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit-free, print-free registration core (cathedral-6 seam). Named
|
||||
* registerScopedClient — not run*Core — because unlike the other peels it
|
||||
* returns data instead of printing. Takes an INJECTED SqlQuery handle:
|
||||
* callers on the engine-bound CLI lane pass the dispatcher's engine's sql
|
||||
* (a second withConfiguredSql engine self-deadlocks PGLite's single-writer
|
||||
* lock); `registerClient` below keeps withConfiguredSql for the
|
||||
* early-routed auth lane. Throws on failure — the thin callers own
|
||||
* exit/print mapping.
|
||||
*/
|
||||
export async function registerScopedClient(
|
||||
sql: SqlQuery,
|
||||
name: string,
|
||||
parsed: RegisterClientArgs,
|
||||
opts: RegisterScopedClientOpts = {},
|
||||
): Promise<RegisteredClient> {
|
||||
const { grantTypes, scopes, sourceId, federatedRead, redirectUris, tokenEndpointAuthMethod } = parsed;
|
||||
const agentBindings = parsed.boundTools || parsed.boundSourceId || parsed.boundBrainId ||
|
||||
parsed.boundSlugPrefixes || parsed.boundMaxConcurrent !== undefined || parsed.budgetUsdPerDay !== undefined
|
||||
@@ -577,46 +662,135 @@ async function registerClient(name: string, args: string[]) {
|
||||
budgetUsdPerDay: parsed.budgetUsdPerDay,
|
||||
}
|
||||
: undefined;
|
||||
const { GBrainOAuthProvider } = await import('../core/oauth-provider.ts');
|
||||
const provider = new GBrainOAuthProvider({ sql });
|
||||
const { clientId, clientSecret } = await provider.registerClientManual(
|
||||
name, grantTypes, scopes, redirectUris, sourceId, federatedRead, tokenEndpointAuthMethod, agentBindings,
|
||||
);
|
||||
|
||||
const ttl = parsed.tokenTtlSeconds ?? opts.tokenTtlSeconds;
|
||||
let tokenTtl: number | undefined;
|
||||
let ttlSkipped = false;
|
||||
if (ttl !== undefined) {
|
||||
if (opts.columns && !opts.columns.has('token_ttl')) {
|
||||
// Pre-migration brain: the degrade was decided by the pre-flight,
|
||||
// OUTSIDE any transaction — nothing here throws-and-continues.
|
||||
ttlSkipped = true;
|
||||
} else {
|
||||
const updated = await sql`
|
||||
UPDATE oauth_clients SET token_ttl = ${ttl}
|
||||
WHERE client_id = ${clientId}
|
||||
RETURNING client_id
|
||||
`;
|
||||
if (updated.length === 0) {
|
||||
throw new Error(`token_ttl update matched no row for client ${clientId}`);
|
||||
}
|
||||
tokenTtl = ttl;
|
||||
}
|
||||
}
|
||||
|
||||
let surfaceApplied: 'verbs' | 'starter' | 'full' | undefined;
|
||||
let surfaceOld: string | null | undefined;
|
||||
let surfaceSkipped = false;
|
||||
if (opts.surface !== undefined) {
|
||||
if (opts.columns && !opts.columns.has('surface')) {
|
||||
surfaceSkipped = true;
|
||||
} else {
|
||||
const rescoped = await provider.rescopeClient(clientId, { surface: opts.surface });
|
||||
surfaceApplied = opts.surface;
|
||||
surfaceOld = rescoped.surfaceOld ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
clientId,
|
||||
...(clientSecret ? { clientSecret } : {}),
|
||||
grantTypes,
|
||||
scopes,
|
||||
authMethod: tokenEndpointAuthMethod || 'client_secret_post',
|
||||
redirectUris,
|
||||
sourceId,
|
||||
federatedRead: federatedRead && federatedRead.length > 0 ? federatedRead : [sourceId],
|
||||
...(tokenTtl !== undefined ? { tokenTtl } : {}),
|
||||
...(surfaceApplied !== undefined ? { surface: surfaceApplied } : {}),
|
||||
...(surfaceOld !== undefined ? { surfaceOld } : {}),
|
||||
created: { source: false },
|
||||
...(ttlSkipped || surfaceSkipped
|
||||
? { skipped: { ...(ttlSkipped ? { tokenTtl: true } : {}), ...(surfaceSkipped ? { surface: true } : {}) } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact lines `auth register-client` prints. BYTE-IDENTICAL contract:
|
||||
* connect.ts:defaultRegisterOAuthClient regex-scrapes `Client ID:` /
|
||||
* `Client Secret:` from this output in PRODUCTION, and 7+ e2e assertions pin
|
||||
* it — pinned by test/auth-register-client-output-pin.test.ts. Each array
|
||||
* element is one console.log call (embedded \n are intentional).
|
||||
*/
|
||||
export function formatRegisterClientOutput(name: string, r: RegisteredClient, parsed: RegisterClientArgs): string[] {
|
||||
const hasBindings = parsed.boundTools || parsed.boundSourceId || parsed.boundBrainId ||
|
||||
parsed.boundSlugPrefixes || parsed.boundMaxConcurrent !== undefined || parsed.budgetUsdPerDay !== undefined;
|
||||
const lines: string[] = [];
|
||||
lines.push(`OAuth client registered: "${name}"\n`);
|
||||
lines.push(` Client ID: ${r.clientId}`);
|
||||
if (r.clientSecret) {
|
||||
lines.push(` Client Secret: ${r.clientSecret}\n`);
|
||||
} else {
|
||||
lines.push(` Client Secret: <public client — none issued>\n`);
|
||||
}
|
||||
lines.push(` Grant types: ${r.grantTypes.join(', ')}`);
|
||||
lines.push(` Scopes: ${r.scopes}`);
|
||||
lines.push(` Token auth method: ${r.authMethod}`);
|
||||
if (r.redirectUris.length > 0) {
|
||||
lines.push(` Redirect URIs: ${r.redirectUris.join(', ')}`);
|
||||
}
|
||||
lines.push(` Write source: ${r.sourceId}`);
|
||||
lines.push(` Federated reads: ${r.federatedRead.join(', ')}`);
|
||||
if (hasBindings) {
|
||||
lines.push(` Bound tools: ${(parsed.boundTools ?? []).join(', ') || '<none>'}`);
|
||||
lines.push(` Bound source: ${parsed.boundSourceId ?? '<none>'}`);
|
||||
lines.push(` Bound brain: ${parsed.boundBrainId ?? '<none>'}`);
|
||||
lines.push(` Bound slug prefixes:${parsed.boundSlugPrefixes ? ' ' + parsed.boundSlugPrefixes.join(', ') : ' <none>'}`);
|
||||
lines.push(` Max concurrency: ${parsed.boundMaxConcurrent ?? 1}`);
|
||||
lines.push(` Daily budget USD: ${parsed.budgetUsdPerDay ?? '<none>'}`);
|
||||
}
|
||||
lines.push('');
|
||||
if (r.clientSecret) {
|
||||
lines.push('Save the client secret — it will not be shown again.');
|
||||
} else {
|
||||
lines.push('Public client (PKCE-only) — no secret needed.');
|
||||
}
|
||||
lines.push(`Revoke with: gbrain auth revoke-client "${r.clientId}"`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
async function registerClient(name: string, args: string[]) {
|
||||
if (!name) {
|
||||
console.error('Usage: auth register-client <name> [--grant-types G] [--scopes S] [--source SOURCE] [--federated-read SRC1,SRC2,...] [--redirect-uri URI ...] [--token-endpoint-auth-method client_secret_post|client_secret_basic|none] [--bound-tools T1,T2] [--bound-source SOURCE] [--bound-brain BRAIN] [--bound-slug-prefixes P1,P2] [--bound-max-concurrent N] [--budget-usd-per-day USD] [--token-ttl SECONDS]');
|
||||
process.exit(1);
|
||||
}
|
||||
let parsed: RegisterClientArgs;
|
||||
try {
|
||||
parsed = parseRegisterClientArgs(args);
|
||||
} catch (e: any) {
|
||||
console.error(`Error: ${e.message}`);
|
||||
console.error('Usage: auth register-client <name> [--grant-types G] [--scopes S] [--source SOURCE] [--federated-read SRC1,SRC2,...] [--redirect-uri URI ...] [--token-endpoint-auth-method client_secret_post|client_secret_basic|none] [--bound-tools T1,T2] [--bound-source SOURCE] [--bound-brain BRAIN] [--bound-slug-prefixes P1,P2] [--bound-max-concurrent N] [--budget-usd-per-day USD] [--token-ttl SECONDS]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await withConfiguredSql(async (sql) => {
|
||||
const { GBrainOAuthProvider } = await import('../core/oauth-provider.ts');
|
||||
const provider = new GBrainOAuthProvider({ sql });
|
||||
const { clientId, clientSecret } = await provider.registerClientManual(
|
||||
name, grantTypes, scopes, redirectUris, sourceId, federatedRead, tokenEndpointAuthMethod, agentBindings,
|
||||
);
|
||||
const effectiveFederated = federatedRead && federatedRead.length > 0 ? federatedRead : [sourceId];
|
||||
const effectiveAuthMethod = tokenEndpointAuthMethod || 'client_secret_post';
|
||||
console.log(`OAuth client registered: "${name}"\n`);
|
||||
console.log(` Client ID: ${clientId}`);
|
||||
if (clientSecret) {
|
||||
console.log(` Client Secret: ${clientSecret}\n`);
|
||||
} else {
|
||||
console.log(` Client Secret: <public client — none issued>\n`);
|
||||
const columns = parsed.tokenTtlSeconds !== undefined
|
||||
? await preflightOauthClientColumns(sql)
|
||||
: undefined;
|
||||
const registered = await registerScopedClient(sql, name, parsed, { columns });
|
||||
if (registered.skipped?.tokenTtl) {
|
||||
console.error('Note: this brain predates the token_ttl column; run `gbrain apply-migrations --yes`, then rescope. The server default TTL applies.');
|
||||
}
|
||||
console.log(` Grant types: ${grantTypes.join(', ')}`);
|
||||
console.log(` Scopes: ${scopes}`);
|
||||
console.log(` Token auth method: ${effectiveAuthMethod}`);
|
||||
if (redirectUris.length > 0) {
|
||||
console.log(` Redirect URIs: ${redirectUris.join(', ')}`);
|
||||
for (const line of formatRegisterClientOutput(name, registered, parsed)) {
|
||||
console.log(line);
|
||||
}
|
||||
console.log(` Write source: ${sourceId}`);
|
||||
console.log(` Federated reads: ${effectiveFederated.join(', ')}`);
|
||||
if (agentBindings) {
|
||||
console.log(` Bound tools: ${(parsed.boundTools ?? []).join(', ') || '<none>'}`);
|
||||
console.log(` Bound source: ${parsed.boundSourceId ?? '<none>'}`);
|
||||
console.log(` Bound brain: ${parsed.boundBrainId ?? '<none>'}`);
|
||||
console.log(` Bound slug prefixes:${parsed.boundSlugPrefixes ? ' ' + parsed.boundSlugPrefixes.join(', ') : ' <none>'}`);
|
||||
console.log(` Max concurrency: ${parsed.boundMaxConcurrent ?? 1}`);
|
||||
console.log(` Daily budget USD: ${parsed.budgetUsdPerDay ?? '<none>'}`);
|
||||
}
|
||||
console.log('');
|
||||
if (clientSecret) {
|
||||
console.log('Save the client secret — it will not be shown again.');
|
||||
} else {
|
||||
console.log('Public client (PKCE-only) — no secret needed.');
|
||||
}
|
||||
console.log(`Revoke with: gbrain auth revoke-client "${clientId}"`);
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error('Error:', e.message);
|
||||
@@ -757,12 +931,56 @@ export function parseAuthClientsArgs(args: string[]): { usage: boolean; days: nu
|
||||
return out;
|
||||
}
|
||||
|
||||
interface ClientRow {
|
||||
export interface ClientRow {
|
||||
client_id: string;
|
||||
client_name: string | null;
|
||||
scope: string | null;
|
||||
surface: string | null;
|
||||
surface_set_by: string | null;
|
||||
source_id: string | null;
|
||||
federated_read: string[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Projection-widened client listing with a degrade ladder for pre-migration
|
||||
* brains: full shape (scope + surface + source-scoping columns) → source
|
||||
* columns without surface → the bare original triple. Drops the NEWEST
|
||||
* columns first; missing columns render as null. Only schema-shape errors
|
||||
* (undefined column/table) degrade — anything else (dropped connection,
|
||||
* permission) rethrows instead of silently narrowing the listing. One round
|
||||
* trip on a current brain (the widen adds columns, not queries). Exported
|
||||
* for the unit suite.
|
||||
*/
|
||||
export async function listClientRows(engine: BrainEngine): Promise<ClientRow[]> {
|
||||
// The columns each degrade tier drops. isUndefinedColumnError matches any
|
||||
// 42703 by code; the column list covers message-only (code-less) variants.
|
||||
const isSchemaShapeError = (e: unknown): boolean =>
|
||||
isUndefinedTableError(e) ||
|
||||
['surface', 'surface_set_by', 'source_id', 'federated_read']
|
||||
.some(col => isUndefinedColumnError(e, col));
|
||||
try {
|
||||
return await engine.executeRaw<ClientRow>(
|
||||
`SELECT client_id, client_name, scope, surface, surface_set_by, source_id, federated_read
|
||||
FROM oauth_clients ORDER BY client_name, client_id`,
|
||||
);
|
||||
} catch (e) {
|
||||
// Brain predates the surface columns — fall through. Rethrow non-shape errors.
|
||||
if (!isSchemaShapeError(e)) throw e;
|
||||
}
|
||||
try {
|
||||
const mid = await engine.executeRaw<Omit<ClientRow, 'surface' | 'surface_set_by'>>(
|
||||
`SELECT client_id, client_name, scope, source_id, federated_read
|
||||
FROM oauth_clients ORDER BY client_name, client_id`,
|
||||
);
|
||||
return mid.map(r => ({ ...r, surface: null, surface_set_by: null }));
|
||||
} catch (e) {
|
||||
// Brain predates the source-scoping columns — fall through likewise.
|
||||
if (!isSchemaShapeError(e)) throw e;
|
||||
}
|
||||
const bare = await engine.executeRaw<Pick<ClientRow, 'client_id' | 'client_name' | 'scope'>>(
|
||||
`SELECT client_id, client_name, scope FROM oauth_clients ORDER BY client_name, client_id`,
|
||||
);
|
||||
return bare.map(r => ({ ...r, surface: null, surface_set_by: null, source_id: null, federated_read: null }));
|
||||
}
|
||||
|
||||
async function clientsCmd(args: string[]) {
|
||||
@@ -777,20 +995,9 @@ async function clientsCmd(args: string[]) {
|
||||
}
|
||||
try {
|
||||
await withConfiguredSql(async (_sql, engine) => {
|
||||
// Surface columns land in migration v127; a pre-migration brain still
|
||||
// gets the listing (surface renders as unknown) instead of an error.
|
||||
let clients: ClientRow[];
|
||||
try {
|
||||
clients = await engine.executeRaw<ClientRow>(
|
||||
`SELECT client_id, client_name, scope, surface, surface_set_by
|
||||
FROM oauth_clients ORDER BY client_name, client_id`,
|
||||
);
|
||||
} catch {
|
||||
const bare = await engine.executeRaw<Omit<ClientRow, 'surface' | 'surface_set_by'>>(
|
||||
`SELECT client_id, client_name, scope FROM oauth_clients ORDER BY client_name, client_id`,
|
||||
);
|
||||
clients = bare.map(r => ({ ...r, surface: null, surface_set_by: null }));
|
||||
}
|
||||
// Degrade ladder lives in listClientRows: a pre-migration brain still
|
||||
// gets the listing (missing columns render as null) instead of an error.
|
||||
const clients = await listClientRows(engine);
|
||||
|
||||
const { readClientOpUsage } = await import('../core/mcp-usage.ts');
|
||||
const usage = parsed.usage ? await readClientOpUsage(engine, { days: parsed.days }) : [];
|
||||
@@ -808,6 +1015,8 @@ async function clientsCmd(args: string[]) {
|
||||
scopes: c.scope,
|
||||
surface: c.surface,
|
||||
surface_set_by: c.surface_set_by,
|
||||
source_id: c.source_id,
|
||||
federated_read: c.federated_read,
|
||||
usage: usageByToken.get(c.client_id) ?? null,
|
||||
})),
|
||||
// Legacy bearer tokens seen in the window (no oauth_clients row).
|
||||
@@ -829,6 +1038,7 @@ async function clientsCmd(args: string[]) {
|
||||
? `${c.surface}${c.surface_set_by ? ` (set by ${c.surface_set_by})` : ''}`
|
||||
: '<server/config resolution>';
|
||||
console.log(` scopes: ${c.scope ?? '<none>'} surface: ${surfaceStr}`);
|
||||
console.log(` write source: ${c.source_id ?? '<none>'} federated reads: ${(c.federated_read ?? []).join(', ') || '<none>'}`);
|
||||
if (parsed.usage) {
|
||||
if (u) {
|
||||
const auto = u.likely_automation ? ' [automation-shaped: >90% context_pack/delta]' : '';
|
||||
@@ -983,7 +1193,8 @@ Usage:
|
||||
request_tools cannot override; 'clear' removes the pin
|
||||
so server/config resolution applies again). Always
|
||||
bounded by the server's --surface ceiling.
|
||||
gbrain auth clients [--usage] [--days N] [--json] List OAuth clients with scopes + tool surface. --usage
|
||||
gbrain auth clients [--usage] [--days N] [--json] List OAuth clients with scopes, write source, federated
|
||||
reads + tool surface. --usage
|
||||
joins per-client op-call counts, top ops, and last-seen
|
||||
from mcp_request_log (default 30d window; HTTP clients
|
||||
only — stdio use is not logged). Automation-shaped
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
import { promptLine } from '../core/cli-util.ts';
|
||||
import {
|
||||
NAME_RE,
|
||||
OAUTH_SECRET_NOTE,
|
||||
REDACTED,
|
||||
buildClaudeMcpAddArgv,
|
||||
buildCodexMcpAddArgv,
|
||||
@@ -69,6 +70,7 @@ import {
|
||||
// commands). Re-exported so this module's public surface — and every test
|
||||
// that imports from it — is unchanged.
|
||||
export {
|
||||
OAUTH_SECRET_NOTE,
|
||||
REDACTED,
|
||||
buildClaudeMcpAddArgv,
|
||||
buildCodexMcpAddArgv,
|
||||
@@ -133,9 +135,8 @@ const SECRET_NOTE =
|
||||
'Note: that bearer token is a long-lived, full-access secret — keep it private and ' +
|
||||
'prefer a scoped/short-lived token if your host supports one.';
|
||||
|
||||
const OAUTH_SECRET_NOTE =
|
||||
'Note: the client secret is sensitive — store it like a password. It mints ' +
|
||||
'short-lived, scoped access tokens; revoke with `gbrain auth revoke-client`.';
|
||||
// OAUTH_SECRET_NOTE moved to src/core/mcp-registration.ts (imported +
|
||||
// re-exported above; text unchanged).
|
||||
|
||||
const PERPLEXITY_REMOTE_NOTE = [
|
||||
'Perplexity connects remotely, so the brain must be reachable over HTTPS. On the',
|
||||
|
||||
+10
-1
@@ -1,4 +1,7 @@
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
// Leaf module (no flag surface of its own) — see that file for why this
|
||||
// isn't imported from extract-conversation-facts.ts directly (#4135).
|
||||
import { ALLOWED_TYPES } from '../core/facts/conversation-types.ts';
|
||||
import { setCliExitVerdict } from '../core/cli-force-exit.ts';
|
||||
import * as db from '../core/db.ts';
|
||||
import { LATEST_VERSION, getIdleBlockers } from '../core/migrate.ts';
|
||||
@@ -89,6 +92,7 @@ export {
|
||||
checkSourceRoutingHealth,
|
||||
checkFederationHealth,
|
||||
checkOauthConfidentialHealth,
|
||||
checkOauthClientScopeHealth,
|
||||
checkAutopilotLockScope,
|
||||
checkStaleLocks,
|
||||
checkCyclePhaseScope,
|
||||
@@ -166,6 +170,7 @@ import {
|
||||
import {
|
||||
checkSourceRoutingHealth,
|
||||
checkOauthConfidentialHealth,
|
||||
checkOauthClientScopeHealth,
|
||||
checkAutopilotLockScope,
|
||||
checkStaleLocks,
|
||||
checkCyclePhaseScope,
|
||||
@@ -1266,7 +1271,8 @@ export async function buildChecks(
|
||||
try {
|
||||
const { readConversationBodyForParsing } = await import('../core/conversation-parser/body.ts');
|
||||
const { parseConversation } = await import('../core/conversation-parser/parse.ts');
|
||||
const allowedTypes = ['conversation', 'meeting', 'slack', 'email', 'imessage', 'imessage-daily'] as const;
|
||||
// Single source of truth for the conversation-facts type allowlist (#4135).
|
||||
const allowedTypes = ALLOWED_TYPES;
|
||||
// PageFilters supports singular `type` only; iterate the allowed types
|
||||
// and cap at ~50/each to land at ~200 total max.
|
||||
const sample: import('../core/types.ts').Page[] = [];
|
||||
@@ -3730,6 +3736,9 @@ export async function buildChecks(
|
||||
// 5L — oauth_confidential_client_health (success-path probe per codex CF8)
|
||||
progress.heartbeat('oauth_confidential_client_health');
|
||||
checks.push(await checkOauthConfidentialHealth(engine));
|
||||
// oauth_client_scope_health — dangling federated grants + orphaned empty workspace sources
|
||||
progress.heartbeat('oauth_client_scope_health');
|
||||
checks.push(await checkOauthClientScopeHealth(engine));
|
||||
// 5M — autopilot_lock_scope (PID-safe hint per codex CF11)
|
||||
progress.heartbeat('autopilot_lock_scope');
|
||||
checks.push(checkAutopilotLockScope());
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import type { BrainEngine } from '../../../core/engine.ts';
|
||||
import { gbrainPath } from '../../../core/config.ts';
|
||||
import { isUndefinedTableError, isUndefinedColumnError } from '../../../core/utils.ts';
|
||||
import type { Check } from '../../doctor.ts';
|
||||
|
||||
/**
|
||||
@@ -192,6 +193,127 @@ export async function checkOauthConfidentialHealth(engine: BrainEngine): Promise
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* oauth_client_scope_health — scoped-client grant hygiene (cathedral-6).
|
||||
*
|
||||
* Two warn conditions, each a single query (no per-client N+1):
|
||||
*
|
||||
* (a) DANGLING FEDERATED GRANTS — a federated read grant id with no
|
||||
* sources row. oauth_clients.federated_read is a TEXT[] with no FK
|
||||
* (only source_id carries ON DELETE RESTRICT), so removing a source
|
||||
* leaves grants pointing at nothing and the client's reads silently
|
||||
* return less than the operator believes was granted.
|
||||
*
|
||||
* (b) ORPHANED EMPTY WORKSPACE SOURCES — an auto-created
|
||||
* '<name>-workspace' source (DB-only: no local_path, zero pages, ZERO
|
||||
* FACTS, not archived) that no live client references by write source
|
||||
* or read grant. This is the post-failure / post-revoke residue
|
||||
* heuristic for `gbrain agent register` derived workspaces. A
|
||||
* non-default source WITH pages is normal on every local brain and is
|
||||
* never flagged; a zero-page source WITH facts is a revoked agent's
|
||||
* memory (the primary agent write lane) and is never flagged either —
|
||||
* the `sources remove` hint would cascade the facts away.
|
||||
*
|
||||
* Pre-OAuth / pre-migration schemas (missing table or missing column)
|
||||
* short-circuit to ok — same posture as oauth_confidential_client_health.
|
||||
*/
|
||||
export async function checkOauthClientScopeHealth(engine: BrainEngine): Promise<Check> {
|
||||
try {
|
||||
// Single source of truth for the derived-workspace suffix — lazy import
|
||||
// (same pattern as the other checks) so agent-register.ts stays out of
|
||||
// doctor's static import graph.
|
||||
const { WORKSPACE_SUFFIX } = await import('../../agent-register.ts');
|
||||
const dangling = await engine.executeRaw<{ client_id: string; client_name: string | null; grant_id: string }>(
|
||||
`SELECT c.client_id, c.client_name, g.grant_id
|
||||
FROM oauth_clients c
|
||||
CROSS JOIN LATERAL unnest(c.federated_read) AS g(grant_id)
|
||||
LEFT JOIN sources s ON s.id = g.grant_id
|
||||
WHERE s.id IS NULL AND c.deleted_at IS NULL
|
||||
ORDER BY c.client_id, g.grant_id`,
|
||||
);
|
||||
// A revoked agent's workspace can hold FACTS with zero pages (facts are
|
||||
// the primary agent write lane) — such a source is NOT empty and the
|
||||
// `gbrain sources remove` recommendation would cascade the facts away.
|
||||
const orphanSql = (withFactsExclusion: boolean) =>
|
||||
`SELECT s.id
|
||||
FROM sources s
|
||||
WHERE s.id LIKE '%' || $1
|
||||
AND s.local_path IS NULL
|
||||
AND COALESCE(s.archived, false) = false
|
||||
AND NOT EXISTS (SELECT 1 FROM pages p WHERE p.source_id = s.id)
|
||||
${withFactsExclusion ? `AND NOT EXISTS (SELECT 1 FROM facts f WHERE f.source_id = s.id)` : ''}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM oauth_clients c
|
||||
WHERE c.deleted_at IS NULL
|
||||
AND (c.source_id = s.id OR s.id = ANY(c.federated_read))
|
||||
)
|
||||
ORDER BY s.id`;
|
||||
let orphaned: Array<{ id: string }>;
|
||||
try {
|
||||
orphaned = await engine.executeRaw<{ id: string }>(orphanSql(true), [WORKSPACE_SUFFIX]);
|
||||
} catch (e) {
|
||||
// Pre-v0.31 brain without the facts table: a source can't hold facts it
|
||||
// has no table for — retry without the exclusion. Scoped here (code-first
|
||||
// classification + the message must name `facts`) so the dangling-grant
|
||||
// arm's findings above aren't lost to the outer catch's schema-degrade.
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (!(isUndefinedTableError(e) && /facts/i.test(msg))) throw e;
|
||||
orphaned = await engine.executeRaw<{ id: string }>(orphanSql(false), [WORKSPACE_SUFFIX]);
|
||||
}
|
||||
const problems: string[] = [];
|
||||
if (dangling.length > 0) {
|
||||
const byClient = new Map<string, { name: string | null; grants: string[] }>();
|
||||
for (const d of dangling) {
|
||||
const entry = byClient.get(d.client_id) ?? { name: d.client_name, grants: [] };
|
||||
entry.grants.push(d.grant_id);
|
||||
byClient.set(d.client_id, entry);
|
||||
}
|
||||
const shown = [...byClient.entries()].slice(0, 5)
|
||||
.map(([id, e]) => `"${e.name ?? id}" (${id}) → ${e.grants.join(', ')}`);
|
||||
problems.push(
|
||||
`${dangling.length} federated read grant(s) point at missing sources: ${shown.join('; ')}` +
|
||||
(byClient.size > 5 ? ` (+${byClient.size - 5} more clients)` : '') +
|
||||
`. Fix each with \`gbrain auth rescope-client <client_id>\` (set a federated read list naming only existing sources), or recreate the source.`,
|
||||
);
|
||||
}
|
||||
if (orphaned.length > 0) {
|
||||
const shown = orphaned.slice(0, 5).map(o => o.id);
|
||||
problems.push(
|
||||
`${orphaned.length} empty auto-created workspace source(s) with no live client: ${shown.join(', ')}` +
|
||||
(orphaned.length > 5 ? ` (+${orphaned.length - 5} more)` : '') +
|
||||
`. May be residue from a revoked or failed \`gbrain agent register\`; verify before removing with \`gbrain sources remove <id>\`.`,
|
||||
);
|
||||
}
|
||||
if (problems.length > 0) {
|
||||
return {
|
||||
name: 'oauth_client_scope_health',
|
||||
status: 'warn',
|
||||
message: problems.join('\n'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: 'oauth_client_scope_health',
|
||||
status: 'ok',
|
||||
message: 'Scoped-client grants consistent (no dangling federated reads, no orphaned workspace sources)',
|
||||
};
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
// Pre-OAuth schema (table missing) or pre-migration schema (column
|
||||
// missing) → ok, matching the confidential-client check's posture.
|
||||
// CODE-FIRST classification (42P01/42703 via core/utils): a bare message
|
||||
// regex would classify e.g. `function unnest(jsonb) does not exist` — a
|
||||
// type-drift failure this check exists to catch — as "schema not present"
|
||||
// and lie green. Column candidates are the optional oauth-scoping columns
|
||||
// this check's queries touch.
|
||||
const missingColumn = ['federated_read', 'source_id', 'deleted_at', 'archived', 'local_path']
|
||||
.some((c) => isUndefinedColumnError(e, c));
|
||||
if (isUndefinedTableError(e) || missingColumn) {
|
||||
return { name: 'oauth_client_scope_health', status: 'ok', message: 'OAuth scoping schema not present (skipping)' };
|
||||
}
|
||||
return { name: 'oauth_client_scope_health', status: 'warn', message: `Check failed: ${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.37.7.0 — Tier 5M autopilot_lock_scope (PID-safe hint per codex CF11).
|
||||
*
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
*/
|
||||
import type { BrainEngine } from '../../../core/engine.ts';
|
||||
import type { Check } from '../../doctor.ts';
|
||||
// Leaf module (no flag surface of its own) — see that file for why this
|
||||
// isn't imported from extract-conversation-facts.ts directly (#4135).
|
||||
import { ALLOWED_TYPES } from '../../../core/facts/conversation-types.ts';
|
||||
|
||||
/**
|
||||
* v0.32.3 [CDX-20]: surface mode + per-key override drift.
|
||||
@@ -475,7 +478,9 @@ export async function computeConversationFactsBacklogCheck(
|
||||
const typesRaw = await engine.getConfig(
|
||||
'cycle.conversation_facts_backfill.types',
|
||||
);
|
||||
let types = ['conversation', 'meeting', 'slack', 'email', 'imessage', 'imessage-daily'];
|
||||
// Default mirrors ALLOWED_TYPES — the single source of truth for the
|
||||
// conversation-facts type allowlist (#4135).
|
||||
let types: string[] = [...ALLOWED_TYPES];
|
||||
if (typesRaw) {
|
||||
try {
|
||||
const parsed = JSON.parse(typesRaw);
|
||||
|
||||
@@ -454,9 +454,29 @@ function printHuman(report: CycleReport) {
|
||||
}
|
||||
|
||||
if (report.status === 'clean') {
|
||||
// A 'clean' cycle can still carry a skip reason worth surfacing — e.g.
|
||||
// synthesize's D8 legacy-key / D5 oversize-chunk skips leave
|
||||
// transcripts_processed/synth_pages_written at 0 (so deriveStatus sees
|
||||
// no activity) while `details.skips` names exactly why each transcript
|
||||
// was passed over. Without this, `--input <already-handled-file>`
|
||||
// prints only "Brain is healthy" with no indication anything was
|
||||
// examined and skipped.
|
||||
const skipLines: string[] = [];
|
||||
for (const p of report.phases) {
|
||||
const skips = (p.details as { skips?: Array<{ filePath: string; reason: string }> } | undefined)?.skips;
|
||||
if (Array.isArray(skips)) {
|
||||
for (const s of skips) {
|
||||
skipLines.push(` - ${p.phase}: ${s.filePath} (${s.reason})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`Brain is healthy. ${report.phases.length} phase(s) checked in ${(report.duration_ms / 1000).toFixed(1)}s.`,
|
||||
);
|
||||
if (skipLines.length > 0) {
|
||||
console.log('Skipped:');
|
||||
for (const line of skipLines) console.log(line);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -489,6 +509,14 @@ function printHuman(report: CycleReport) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test-only export ───────────────────────────────────────
|
||||
// `__testing` re-exports otherwise-private helpers so unit tests can pin
|
||||
// CLI output behavior without spawning a subprocess. Not part of the
|
||||
// runtime contract.
|
||||
export const __testing = {
|
||||
printHuman,
|
||||
};
|
||||
|
||||
// ─── CLI entry ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -93,6 +93,16 @@ import { withRefreshingLock, LockUnavailableError } from '../core/db-lock.ts';
|
||||
import { assertFactsEmbeddingDimMatchesConfig } from '../core/embedding-dim-check.ts';
|
||||
import { writeReceipt, shortRunId } from '../core/extract/receipt-writer.ts';
|
||||
import { upsertExtractRollup } from '../core/extract/rollup-writer.ts';
|
||||
import { ALLOWED_TYPES, type AllowedType } from '../core/facts/conversation-types.ts';
|
||||
|
||||
// Re-exported verbatim so existing importers (this file's own helpers below
|
||||
// and this file's tests) keep working unchanged; doctor.ts, jobs.ts,
|
||||
// sources.ts, and the cycle backfill phase import the leaf directly. Moved to
|
||||
// src/core/facts/conversation-types.ts (see that file for why) so a
|
||||
// consumer that only needs the six values doesn't also pull in this file's
|
||||
// own CLI flag surface.
|
||||
export { ALLOWED_TYPES };
|
||||
export type { AllowedType };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tunables (exported for tests).
|
||||
@@ -135,21 +145,11 @@ export const MAX_PAGE_BODY_BYTES = 25 * 1024 * 1024;
|
||||
/** Default cost cap when no tracker is passed explicitly. */
|
||||
export const DEFAULT_MAX_COST_USD = 5.0;
|
||||
|
||||
/**
|
||||
* Allowlist of page types this command operates on. Mirrors
|
||||
* cycle.conversation_facts_backfill.types config default. CLI's
|
||||
* `--types` flag is an explicit per-run override; cycle config is
|
||||
* the single source of truth.
|
||||
*/
|
||||
export const ALLOWED_TYPES = [
|
||||
'conversation',
|
||||
'meeting',
|
||||
'slack',
|
||||
'email',
|
||||
'imessage',
|
||||
'imessage-daily',
|
||||
] as const;
|
||||
export type AllowedType = (typeof ALLOWED_TYPES)[number];
|
||||
// ALLOWED_TYPES / AllowedType now live in
|
||||
// ../core/facts/conversation-types.ts (imported + re-exported above).
|
||||
// Mirrors cycle.conversation_facts_backfill.types config default. CLI's
|
||||
// `--types` flag is an explicit per-run override; cycle config is the
|
||||
// single source of truth.
|
||||
|
||||
/**
|
||||
* Granular collector page-types that alias into each canonical conversation
|
||||
|
||||
+34
-8
@@ -158,6 +158,27 @@ export interface HookIo {
|
||||
spawnPush?: (root: string) => void;
|
||||
/** TEST SEAM: user-prompt deadline override (wall-clock flake control). */
|
||||
userPromptDeadlineMs?: number;
|
||||
/**
|
||||
* TEST SEAM (v0.46.15, BrainBench production seam): config override for
|
||||
* hookUserPrompt — `undefined` = load the real file-plane config;
|
||||
* `null`/object = use as-is. Lets the bench point the hook at a throwaway
|
||||
* brain WITHOUT mutating process-global GBRAIN_HOME (parallel-test safe).
|
||||
*/
|
||||
configOverride?: GBrainConfig | null;
|
||||
/**
|
||||
* TEST SEAM (v0.46.15): suppress the pending-push failure banner. The
|
||||
* banner reads the OPERATOR's real push-status files — on a bench run
|
||||
* that's environmental contamination (a locally-failing push would inject
|
||||
* a banner on stay-silent turns and read as a false fire).
|
||||
*/
|
||||
disablePushBanner?: boolean;
|
||||
/**
|
||||
* TEST SEAM (v0.46.15, codex ship-review): suppress hook telemetry WRITES
|
||||
* (heartbeat JSONL). Telemetry paths resolve from GBRAIN_HOME/homedir —
|
||||
* NOT from configOverride — so a hermetic bench replay would otherwise
|
||||
* append every fixture turn to the operator's real hook-health history.
|
||||
*/
|
||||
disableTelemetry?: boolean;
|
||||
/**
|
||||
* Feedback-loop attribution channel (`--harness <claude-code|codex|opencode>`).
|
||||
* Default 'claude-code' — the only harness bootstrap registers hooks for
|
||||
@@ -429,7 +450,12 @@ const HEARTBEAT_COMPACT_CHECK_BYTES = 2 * HEARTBEAT_MAX_LINES * 40;
|
||||
* check says the file exceeds ~2x the cap. Fields are copied EXPLICITLY — the
|
||||
* schema allowlist is enforced by construction, not by trust. Never throws.
|
||||
*/
|
||||
async function writeHeartbeat(entry: HookHeartbeatEntry): Promise<void> {
|
||||
async function writeHeartbeat(io: HookIo, entry: HookHeartbeatEntry): Promise<void> {
|
||||
// TEST SEAM (codex ship-review): a BrainBench replay drives the REAL hook
|
||||
// in-process without redirecting GBRAIN_HOME — without this gate every
|
||||
// fixture turn would append to the OPERATOR's real hook-health history and
|
||||
// skew doctor/failure-notice reads. Benches are hermetic; telemetry is not.
|
||||
if (io.disableTelemetry) return;
|
||||
try {
|
||||
const p = await heartbeatPath();
|
||||
const line = JSON.stringify({
|
||||
@@ -576,7 +602,7 @@ async function hookSessionStart(io: HookIo): Promise<number> {
|
||||
outcome = 'error';
|
||||
reason = errorCode(e); // fail-open: empty stdout, exit 0
|
||||
}
|
||||
await writeHeartbeat({
|
||||
await writeHeartbeat(io, {
|
||||
ts: new Date().toISOString(),
|
||||
event: 'session-start',
|
||||
outcome,
|
||||
@@ -1051,7 +1077,7 @@ async function hookUserPrompt(io: HookIo): Promise<number> {
|
||||
let wrotePayload = false;
|
||||
|
||||
const work = (async (): Promise<UserPromptOutcome> => {
|
||||
banner = pendingPushFailureBanner();
|
||||
banner = io.disablePushBanner ? null : pendingPushFailureBanner();
|
||||
const j = await readStdinJson(io, 300);
|
||||
if (!j) return { outcome: 'degraded', reason: 'no_stdin' };
|
||||
|
||||
@@ -1103,7 +1129,7 @@ async function hookUserPrompt(io: HookIo): Promise<number> {
|
||||
if (prompt.trim()) turns = [...turns, { role: 'user', text: prompt }];
|
||||
if (turns.length === 0) return { outcome: 'ok', reason: 'empty_window' };
|
||||
|
||||
const cfg = loadConfig();
|
||||
const cfg = io.configOverride !== undefined ? io.configOverride : loadConfig();
|
||||
if (!cfg?.database_path) {
|
||||
// No config, or a Postgres brain (no PGLite data dir → no IPC socket).
|
||||
// ENGINE-FREE means no direct-engine fallback here; pull-mode covers it.
|
||||
@@ -1203,7 +1229,7 @@ async function hookUserPrompt(io: HookIo): Promise<number> {
|
||||
);
|
||||
pendingBanner.record();
|
||||
}
|
||||
await writeHeartbeat({
|
||||
await writeHeartbeat(io, {
|
||||
ts: new Date().toISOString(),
|
||||
event: 'user-prompt',
|
||||
outcome: result.outcome,
|
||||
@@ -1291,7 +1317,7 @@ async function hookCompact(io: HookIo): Promise<number> {
|
||||
outcome = 'error';
|
||||
reason = errorCode(e); // fail-open: exit 0
|
||||
}
|
||||
await writeHeartbeat({
|
||||
await writeHeartbeat(io, {
|
||||
ts: new Date().toISOString(),
|
||||
event: 'compact',
|
||||
outcome,
|
||||
@@ -1334,7 +1360,7 @@ async function hookStop(io: HookIo): Promise<number> {
|
||||
} catch {
|
||||
pushReason = 'push_unavailable';
|
||||
}
|
||||
await writeHeartbeat({
|
||||
await writeHeartbeat(io, {
|
||||
ts: new Date().toISOString(),
|
||||
event: 'stop',
|
||||
outcome,
|
||||
@@ -1511,7 +1537,7 @@ async function hookSessionEnd(io: HookIo): Promise<number> {
|
||||
/* best effort */
|
||||
}
|
||||
|
||||
await writeHeartbeat({
|
||||
await writeHeartbeat(io, {
|
||||
ts: new Date().toISOString(),
|
||||
event: 'session-end',
|
||||
outcome,
|
||||
|
||||
+13
-5
@@ -4,6 +4,9 @@
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../core/engine.ts';
|
||||
// Leaf module (no flag surface of its own) — see that file for why this
|
||||
// isn't imported from extract-conversation-facts.ts directly (#4135).
|
||||
import { ALLOWED_TYPES, type AllowedType } from '../core/facts/conversation-types.ts';
|
||||
import { MinionQueue, deriveWedgeSignal } from '../core/minions/queue.ts';
|
||||
import { MinionWorker } from '../core/minions/worker.ts';
|
||||
import {
|
||||
@@ -2152,14 +2155,16 @@ export async function registerBuiltinHandlers(
|
||||
// SHOULD pin to one source per call (job_id is per-call).
|
||||
throw new Error('extract-conversation-facts Minion job requires data.sourceId');
|
||||
}
|
||||
// ALLOWED_TYPES is the single source of truth for the conversation-facts
|
||||
// type allowlist (see src/core/facts/conversation-types.ts).
|
||||
const types = Array.isArray(job.data.types)
|
||||
? (job.data.types as string[]).filter((t) =>
|
||||
['conversation', 'meeting', 'slack', 'email', 'imessage', 'imessage-daily'].includes(t),
|
||||
? (job.data.types as string[]).filter(
|
||||
(t): t is AllowedType => (ALLOWED_TYPES as readonly string[]).includes(t),
|
||||
)
|
||||
: undefined;
|
||||
const result = await runExtractConversationFactsCore(engine, {
|
||||
sourceId,
|
||||
types: types as ('conversation' | 'meeting' | 'slack' | 'email')[] | undefined,
|
||||
types,
|
||||
slug: typeof job.data.slug === 'string' ? job.data.slug : undefined,
|
||||
dryRun: !!job.data.dryRun,
|
||||
limit: typeof job.data.limit === 'number' ? job.data.limit : undefined,
|
||||
@@ -2653,14 +2658,17 @@ export async function registerBuiltinHandlers(
|
||||
const result = await engine.purgeDeletedPages(olderThanHours);
|
||||
pagesPurged = result.count;
|
||||
}
|
||||
let sourcesBlocked: Array<{ id: string; reason: string }> = [];
|
||||
if (scope === 'sources' || scope === 'all') {
|
||||
const { purgeExpiredSources } = await import('../core/destructive-guard.ts');
|
||||
sourcesPurged = await purgeExpiredSources(engine);
|
||||
const purgeResult = await purgeExpiredSources(engine);
|
||||
sourcesPurged = purgeResult.purged;
|
||||
sourcesBlocked = purgeResult.blocked;
|
||||
}
|
||||
// GC stale op_checkpoints rows (folded scope item +C from review).
|
||||
const { purgeStaleCheckpoints } = await import('../core/op-checkpoint.ts');
|
||||
const checkpointsPurged = await purgeStaleCheckpoints(engine, 7);
|
||||
return { pagesPurged, sourcesPurged, checkpointsPurged, dryRun };
|
||||
return { pagesPurged, sourcesPurged, sourcesBlocked, checkpointsPurged, dryRun };
|
||||
});
|
||||
|
||||
// Phase-wrapper handlers — each delegates to runCycle({ phases: [name] }).
|
||||
|
||||
+32
-2
@@ -578,13 +578,43 @@ export async function probeEmbeddingReachability(deps: ProbeDeps = {}): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the chat/expansion probe timeout: the recipe's declared
|
||||
* `touchpoints.<kind>.default_timeout_ms` when set, else the probe's
|
||||
* historical flat 5000ms.
|
||||
*
|
||||
* Pre-fix `probeModel` hardcoded 5000ms for every provider. That's fine for
|
||||
* a plain network round-trip, but `claude-cli:` dispatches through a
|
||||
* `claude -p (print mode)` subprocess (CLI cold start + user-level CLAUDE.md load),
|
||||
* which routinely takes 5-6s even when healthy — so the probe aborted on
|
||||
* every run and reported 'unknown — claude-cli adapter aborted', not
|
||||
* because the model was actually unreachable. Mirrors the reranker probe's
|
||||
* recipe-default fallback (`resolveLiveRerankerTimeoutMs` / mode.ts), but
|
||||
* simpler: unlike `search.reranker.timeout_ms`, there's no config-key
|
||||
* override for chat/expansion timeouts, so the chain is just per-call
|
||||
* default (5000) unless the recipe overrides it.
|
||||
*/
|
||||
/** Historical flat probe timeout — right for fast HTTP providers; recipes override via default_timeout_ms. */
|
||||
const DEFAULT_PROBE_TIMEOUT_MS = 5000;
|
||||
|
||||
export async function resolveChatProbeTimeoutMs(modelStr: string, touchpoint: 'chat' | 'expansion'): Promise<number> {
|
||||
try {
|
||||
const { resolveRecipe } = await import('../core/ai/model-resolver.ts');
|
||||
const { recipe } = resolveRecipe(modelStr);
|
||||
return recipe.touchpoints[touchpoint]?.default_timeout_ms ?? DEFAULT_PROBE_TIMEOUT_MS;
|
||||
} catch {
|
||||
return DEFAULT_PROBE_TIMEOUT_MS;
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeModel(modelStr: string, touchpoint: 'chat' | 'expansion', deps: ProbeDeps = {}): Promise<ProbeResult> {
|
||||
const start = Date.now();
|
||||
const probeTimeoutMs = await resolveChatProbeTimeoutMs(modelStr, touchpoint);
|
||||
try {
|
||||
const chat = deps.chat ?? (await import('../core/ai/gateway.ts')).chat;
|
||||
// Use AbortController so the 5s timeout doesn't hang on a stuck network.
|
||||
// Use AbortController so the resolved timeout doesn't hang on a stuck network.
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(new Error('probe timed out after 5s')), 5000);
|
||||
const timeoutId = setTimeout(() => controller.abort(new Error(`probe timed out after ${probeTimeoutMs}ms`)), probeTimeoutMs);
|
||||
try {
|
||||
await chat({
|
||||
model: modelStr,
|
||||
|
||||
+150
-8
@@ -57,6 +57,15 @@ import { VERSION } from '../version.ts';
|
||||
import * as db from '../core/db.ts';
|
||||
import { sqlQueryForEngine, executeRawJsonb } from '../core/sql-query.ts';
|
||||
import { MinionQueue } from '../core/minions/queue.ts';
|
||||
import {
|
||||
registerScopedClient,
|
||||
preflightOauthClientColumns,
|
||||
TOKEN_TTL_MIN_SECONDS,
|
||||
TOKEN_TTL_MAX_SECONDS,
|
||||
type RegisteredClient,
|
||||
} from './auth.ts';
|
||||
import { registerClientNameLockKey } from './agent-register.ts';
|
||||
import { isUndefinedColumnError } from '../core/utils.ts';
|
||||
import { isRetryableError } from '../core/retry-matcher.ts';
|
||||
import {
|
||||
computeContentHash,
|
||||
@@ -1733,6 +1742,10 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
|
||||
// Register client from admin dashboard
|
||||
app.post('/admin/api/register-client', requireAdmin, express.json(), async (req: Request, res: Response) => {
|
||||
// Set only once the client row has COMMITTED — the catch below folds it
|
||||
// into the 500 payload so a post-commit failure never reads as
|
||||
// "nothing was created".
|
||||
let createdClientId: string | undefined;
|
||||
try {
|
||||
// v0.39.3.0 WARN-9 + CV12: accept BOTH `scopes` (admin SPA convention)
|
||||
// AND `scope` (OAuth wire-format convention, singular). The pre-fix
|
||||
@@ -1795,16 +1808,129 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = await oauthProvider.registerClientManual(
|
||||
name, grants, scopeString, uris, sourceId, federatedReadIds, validatedAuthMethod,
|
||||
);
|
||||
// Set per-client TTL if specified
|
||||
if (tokenTtl && Number(tokenTtl) > 0) {
|
||||
await sql`UPDATE oauth_clients SET token_ttl = ${Number(tokenTtl)} WHERE client_id = ${result.clientId}`;
|
||||
// cathedral-6: a WELL-FORMED but nonexistent source used to surface as
|
||||
// a 500 (the source_id FK fires inside the INSERT). Check existence +
|
||||
// archived up front for a structured 400 — same contract as the
|
||||
// malformed case, mirroring the CLI lane. ONE batched query on the
|
||||
// engine lane (SqlQuery forbids arrays; engine is in scope).
|
||||
{
|
||||
const idsToCheck = [...new Set([sourceId, ...(federatedReadIds ?? [])])];
|
||||
const found = await engine.executeRaw<{ id: string; archived: boolean | null }>(
|
||||
`SELECT id, archived FROM sources WHERE id = ANY($1::text[])`,
|
||||
[idsToCheck],
|
||||
);
|
||||
const byId = new Map(found.map(r => [r.id, r]));
|
||||
for (const id of idsToCheck) {
|
||||
const row = byId.get(id);
|
||||
if (!row) {
|
||||
res.status(400).json({
|
||||
error: 'unknown_source',
|
||||
message: `source "${id}" does not exist — create it first (gbrain sources add ${id})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (row.archived) {
|
||||
res.status(400).json({
|
||||
error: 'archived_source',
|
||||
message: `source "${id}" is archived — unarchive it or drop it from the grant`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
res.json({ ...result, tokenTtl: tokenTtl ? Number(tokenTtl) : null });
|
||||
// cathedral-6: validate tokenTtl BEFORE the transaction. The old
|
||||
// `Number(tokenTtl) > 0` passed Infinity/floats through to fail the
|
||||
// integer UPDATE inside the tx (rollback → opaque 500). Falsy values
|
||||
// (omitted / null / 0 / '') keep the historical "no TTL requested"
|
||||
// meaning; anything else must be an integer inside the shared bounds.
|
||||
let ttlNum: number | undefined;
|
||||
if (tokenTtl) {
|
||||
const v = Number(tokenTtl);
|
||||
if (!Number.isInteger(v) || v < TOKEN_TTL_MIN_SECONDS || v > TOKEN_TTL_MAX_SECONDS) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_token_ttl',
|
||||
message: `tokenTtl must be an integer number of seconds between ${TOKEN_TTL_MIN_SECONDS} and ${TOKEN_TTL_MAX_SECONDS} (90 days); got ${JSON.stringify(tokenTtl)}. Omit the field (or pass 0/null) to keep the server default.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
ttlNum = v;
|
||||
}
|
||||
// Column pre-flight OUTSIDE the tx (25P02 — nothing inside may degrade):
|
||||
// pre-v61 brains lack the scoped-client columns and registerClientManual's
|
||||
// internal 42703 retry ladder would abort the transaction, so refuse up
|
||||
// front with the CLI lane's brain_too_old contract. Passing {columns}
|
||||
// through also makes the ttl write SKIP (rather than throw) on brains
|
||||
// without token_ttl.
|
||||
const columns = await preflightOauthClientColumns(sql);
|
||||
if (!columns.has('source_id') || !columns.has('federated_read')) {
|
||||
res.status(400).json({
|
||||
error: 'brain_too_old',
|
||||
message: 'this brain predates scoped OAuth clients (source_id/federated_read columns) — run `gbrain apply-migrations --yes` first.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Duplicate-name parity with the CLI lane: a second client under the
|
||||
// same name is a 409, never a silent second row. The dup-check and the
|
||||
// INSERT run in ONE transaction under the SAME name-scoped advisory
|
||||
// lock the CLI takes — two concurrent same-name requests serialize, and
|
||||
// the loser sees the winner's committed row (as two separate autocommit
|
||||
// statements, both used to pass the pre-check). deleted_at tolerance is
|
||||
// preflight-decided (no in-tx 42703 retry).
|
||||
let dupClientId: string | null = null;
|
||||
let registered: RegisteredClient | undefined;
|
||||
await engine.transaction(async (tx) => {
|
||||
await tx.executeRaw(`SELECT pg_advisory_xact_lock(hashtext($1)::bigint)`, [registerClientNameLockKey(name)]);
|
||||
const txSql = sqlQueryForEngine(tx);
|
||||
const dupRows = columns.has('deleted_at')
|
||||
? await txSql`SELECT client_id FROM oauth_clients WHERE client_name = ${name} AND deleted_at IS NULL`
|
||||
: await txSql`SELECT client_id FROM oauth_clients WHERE client_name = ${name}`;
|
||||
if (dupRows.length > 0) {
|
||||
dupClientId = String(dupRows[0].client_id);
|
||||
return;
|
||||
}
|
||||
// Compose the SAME core the CLI uses (registerScopedClient) instead of
|
||||
// open-coding registerClientManual + a raw TTL UPDATE — the two paths
|
||||
// had already drifted once (this route hardcoded 'default' pre-v0.41).
|
||||
registered = await registerScopedClient(txSql, name, {
|
||||
grantTypes: grants,
|
||||
scopes: scopeString,
|
||||
sourceId,
|
||||
federatedRead: federatedReadIds,
|
||||
redirectUris: uris,
|
||||
tokenEndpointAuthMethod: validatedAuthMethod,
|
||||
boundTools: undefined,
|
||||
boundSourceId: undefined,
|
||||
boundBrainId: undefined,
|
||||
boundSlugPrefixes: undefined,
|
||||
boundMaxConcurrent: undefined,
|
||||
budgetUsdPerDay: undefined,
|
||||
tokenTtlSeconds: undefined,
|
||||
}, { tokenTtlSeconds: ttlNum, columns });
|
||||
});
|
||||
if (dupClientId !== null) {
|
||||
res.status(409).json({
|
||||
error: 'duplicate_name',
|
||||
client_id: dupClientId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Post-commit: the row exists from here on — any later failure must
|
||||
// name the created client (no false "nothing was created").
|
||||
const reg = registered!;
|
||||
createdClientId = reg.clientId;
|
||||
res.json({
|
||||
clientId: reg.clientId,
|
||||
...(reg.clientSecret !== undefined ? { clientSecret: reg.clientSecret } : {}),
|
||||
tokenTtl: reg.tokenTtl ?? null,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e instanceof Error ? e.message : 'Registration failed' });
|
||||
// A throw INSIDE the tx rolls the row back (no client persists); the
|
||||
// only window where a client exists at failure time is post-commit,
|
||||
// marked by createdClientId — include it so the operator can revoke.
|
||||
res.status(500).json({
|
||||
error: e instanceof Error ? e.message : 'Registration failed',
|
||||
...(createdClientId !== undefined ? { client_id: createdClientId } : {}),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2232,6 +2358,21 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
// verifyAccessToken. The env-fallback is gone.
|
||||
const tokenSourceId = authInfo.sourceId ?? 'default';
|
||||
|
||||
// #3242 parity: the legacy-transport and stdio dispatch sites widen a
|
||||
// no-grant caller's unqualified reads across the federated source set
|
||||
// (localFederatedSourceIds); this SDK-transport site never did, so the
|
||||
// same token saw federated pages over /mcp on one serve mode and scalar
|
||||
// 'default' on the other. hasSourceGrant === false is set ONLY for
|
||||
// legacy bearer tokens with no operator source grant (oauth-provider);
|
||||
// granted tokens and OAuth clients never widen. Best-effort: a resolver
|
||||
// failure keeps the scalar scope.
|
||||
const { noGrantFederatedScope } = await import('../core/source-resolver.ts');
|
||||
const localFederated = await noGrantFederatedScope(
|
||||
engine,
|
||||
authInfo.hasSourceGrant,
|
||||
tokenSourceId,
|
||||
);
|
||||
|
||||
let toolResult: Awaited<ReturnType<typeof dispatchToolCall>>;
|
||||
try {
|
||||
toolResult = await dispatchToolCall(engine, name, params as Record<string, unknown> | undefined, {
|
||||
@@ -2241,6 +2382,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
|
||||
transport: 'http',
|
||||
takesHoldersAllowList: tokenAllowList,
|
||||
sourceId: tokenSourceId,
|
||||
...(localFederated ? { localFederatedSourceIds: localFederated } : {}),
|
||||
metaHook: getBrainHotMemoryMeta,
|
||||
// MEMORY_VERBS v1: fail-closed surface enforcement + usage attribution.
|
||||
...(surfaceAllowedOps ? { allowedOps: surfaceAllowedOps } : {}),
|
||||
|
||||
+122
-32
@@ -40,6 +40,8 @@ import {
|
||||
purgeExpiredSources,
|
||||
formatImpact,
|
||||
formatSoftDelete,
|
||||
clientsReferencingSource,
|
||||
formatClientReferentsBlock,
|
||||
SOFT_DELETE_TTL_HOURS,
|
||||
} from '../core/destructive-guard.ts';
|
||||
import {
|
||||
@@ -60,6 +62,8 @@ import {
|
||||
sourceFederationState,
|
||||
type SourceRow as LoadedSourceRow,
|
||||
} from '../core/sources-load.ts';
|
||||
import { sqlQueryForEngine } from '../core/sql-query.ts';
|
||||
import { preflightOauthClientColumns } from './auth.ts';
|
||||
|
||||
// ── Validation ──────────────────────────────────────────────
|
||||
|
||||
@@ -535,19 +539,63 @@ async function runRemove(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// v0.42.44 — tear down durability scaffolding BEFORE the row is deleted (we
|
||||
// need the path/label while it still exists). Best-effort; tolerates missing
|
||||
// repo/cron/credential independently.
|
||||
// PR6 D5b: FK-RESTRICT pre-check — a referenced source refuses with revoke
|
||||
// guidance, never a raw FK violation.
|
||||
const referents = await clientsReferencingSource(engine, id);
|
||||
if (referents.length > 0) {
|
||||
console.error(formatClientReferentsBlock(id, referents));
|
||||
process.exit(5);
|
||||
}
|
||||
|
||||
// cathedral-6 (F1): the row DELETE commits FIRST — atomically with an in-tx
|
||||
// referents re-check — and external teardown (unharden: git scaffolding /
|
||||
// cron / credential) runs only AFTER the commit. Pre-fix the teardown ran
|
||||
// before the DELETE, so a registration racing between the pre-check and the
|
||||
// DELETE failed the FK AFTER scaffolding was already destroyed. The in-tx
|
||||
// re-check uses a column-preflighted statement shape (25P02: no
|
||||
// catch-and-retry degrade inside a tx; missing table ⇒ empty column set ⇒
|
||||
// no FK ⇒ skip); the FK constraint itself is the backstop for a
|
||||
// registration committing between the re-check and the DELETE.
|
||||
class SourceReferencedError extends Error {}
|
||||
try {
|
||||
await engine.transaction(async (tx) => {
|
||||
const cols = await preflightOauthClientColumns(sqlQueryForEngine(tx));
|
||||
if (cols.has('source_id')) {
|
||||
// PHYSICAL count (no deleted_at filter): the FK ignores soft-deletion.
|
||||
const rows = await tx.executeRaw<{ n: string }>(
|
||||
`SELECT COUNT(*)::text AS n FROM oauth_clients WHERE source_id = $1`,
|
||||
[id],
|
||||
);
|
||||
if (Number(rows[0]?.n ?? 0) > 0) throw new SourceReferencedError();
|
||||
}
|
||||
await tx.executeRaw(`DELETE FROM sources WHERE id = $1`, [id]);
|
||||
});
|
||||
} catch (e) {
|
||||
const code = typeof e === 'object' && e !== null && 'code' in e ? String((e as { code?: unknown }).code) : '';
|
||||
if (e instanceof SourceReferencedError || code === '23503') {
|
||||
const raced = await clientsReferencingSource(engine, id);
|
||||
console.error(formatClientReferentsBlock(id, raced.length > 0 ? raced : referents));
|
||||
process.exit(5);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const pageCount = impact?.pageCount ?? 0;
|
||||
console.log(`Removed source "${id}" (${pageCount} pages + dependent rows cascaded).`);
|
||||
|
||||
// v0.42.44 — durability-scaffolding teardown, POST-COMMIT as of cathedral-6
|
||||
// (the path/label were captured from `src` before the delete). Best-effort;
|
||||
// on failure the DB row is already gone — print exactly what remains so the
|
||||
// operator can sweep the residue (doctor also surfaces it).
|
||||
try {
|
||||
const { unhardenBrainRepo } = await import('../core/brain-repo-durability.ts');
|
||||
await unhardenBrainRepo({ repoPath: src.local_path ?? '', sourceId: id, logger: (l) => console.error(l) });
|
||||
} catch (e) {
|
||||
console.error(`[gbrain] durability teardown skipped (non-fatal): ${(e as Error).message}`);
|
||||
console.error(
|
||||
`[gbrain] source row "${id}" is deleted, but durability teardown failed (non-fatal): ${(e as Error).message}. ` +
|
||||
`Residue may remain${src.local_path ? ` at ${src.local_path}` : ''} (git hardening / cron entry / stored credential) — \`gbrain doctor\` surfaces it.`,
|
||||
);
|
||||
}
|
||||
|
||||
await engine.executeRaw(`DELETE FROM sources WHERE id = $1`, [id]);
|
||||
const pageCount = impact?.pageCount ?? 0;
|
||||
console.log(`Removed source "${id}" (${pageCount} pages + dependent rows cascaded).`);
|
||||
}
|
||||
|
||||
// ── Subcommand: archive (soft-delete) ───────────────────────
|
||||
@@ -724,17 +772,30 @@ async function runPurge(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
process.exit(5);
|
||||
}
|
||||
|
||||
// PR6 D5b: FK-RESTRICT pre-check — refuse with revoke guidance instead of
|
||||
// letting the raw FK violation surface from the DELETE.
|
||||
const referents = await clientsReferencingSource(engine, id);
|
||||
if (referents.length > 0) {
|
||||
console.error(formatClientReferentsBlock(id, referents));
|
||||
process.exit(5);
|
||||
}
|
||||
|
||||
await engine.executeRaw(`DELETE FROM sources WHERE id = $1`, [id]);
|
||||
console.log(`Permanently deleted source "${id}" (${impact.pageCount} pages cascaded).`);
|
||||
return;
|
||||
}
|
||||
|
||||
// No id: purge all expired archives
|
||||
const purged = await purgeExpiredSources(engine);
|
||||
if (purged.length === 0) {
|
||||
const { purged, blocked } = await purgeExpiredSources(engine);
|
||||
if (purged.length === 0 && blocked.length === 0) {
|
||||
console.log('No expired archives to purge.');
|
||||
} else {
|
||||
console.log(`Purged ${purged.length} expired archive(s): ${purged.join(', ')}`);
|
||||
if (purged.length > 0) {
|
||||
console.log(`Purged ${purged.length} expired archive(s): ${purged.join(', ')}`);
|
||||
}
|
||||
for (const b of blocked) {
|
||||
console.log(`Blocked: ${b.id} — ${b.reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -974,6 +1035,17 @@ function formatLag(seconds: number): string {
|
||||
}
|
||||
|
||||
// ── v0.40 sources webhook (D8) ──────────────────────────────
|
||||
// Hoisted so both runWebhook's `case '--help'` and the top-level nested-help
|
||||
// guard in runSources (`sources webhook --help`, `sources webhook <sub>
|
||||
// --help`) print the identical text without dispatching into runWebhook.
|
||||
const SOURCES_WEBHOOK_HELP = `Usage: gbrain sources webhook <subcommand> <source-id> [options]
|
||||
|
||||
Subcommands:
|
||||
set <id> [--secret VAL] [--github-repo owner/name] One-time reveal
|
||||
show <id> Metadata only
|
||||
rotate <id> New secret, reveal
|
||||
clear <id> Remove webhook config`;
|
||||
|
||||
async function runWebhook(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
const sub = args[0];
|
||||
const rest = args.slice(1);
|
||||
@@ -985,13 +1057,7 @@ async function runWebhook(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
case undefined:
|
||||
case '--help':
|
||||
case '-h':
|
||||
console.log(`Usage: gbrain sources webhook <subcommand> <source-id> [options]
|
||||
|
||||
Subcommands:
|
||||
set <id> [--secret VAL] [--github-repo owner/name] One-time reveal
|
||||
show <id> Metadata only
|
||||
rotate <id> New secret, reveal
|
||||
clear <id> Remove webhook config`);
|
||||
console.log(SOURCES_WEBHOOK_HELP);
|
||||
return;
|
||||
default:
|
||||
console.error(`Unknown webhook subcommand: ${sub}`);
|
||||
@@ -1313,14 +1379,8 @@ async function runAudit(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
// frontmatter.type and estimates per-page segment count from body
|
||||
// bytes. Estimated per-segment Sonnet cost is a rough heuristic
|
||||
// (~2000 in + 500 out tokens at $3/MTok in + $15/MTok out ≈ $0.013).
|
||||
const FACTS_BACKFILL_ALLOWED = [
|
||||
'conversation',
|
||||
'meeting',
|
||||
'slack',
|
||||
'email',
|
||||
'imessage',
|
||||
'imessage-daily',
|
||||
];
|
||||
// Single source of truth for the conversation-facts type allowlist.
|
||||
const { ALLOWED_TYPES: FACTS_BACKFILL_ALLOWED } = await import('../core/facts/conversation-types.ts');
|
||||
const FACTS_BACKFILL_CHARS_PER_SEGMENT = 6500; // matches SEGMENT_TEXT_CHAR_LIMIT
|
||||
const FACTS_BACKFILL_USD_PER_SEGMENT = 0.013;
|
||||
let factsBackfillPages = 0;
|
||||
@@ -1364,7 +1424,7 @@ async function runAudit(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
}
|
||||
// Facts-backfill estimator: counts pages matching allowed types.
|
||||
const fmType = (parsed.frontmatter?.type as string | undefined) ?? null;
|
||||
if (fmType && FACTS_BACKFILL_ALLOWED.includes(fmType)) {
|
||||
if (fmType && (FACTS_BACKFILL_ALLOWED as readonly string[]).includes(fmType)) {
|
||||
factsBackfillPages++;
|
||||
const totalBytes = sanity.bytes;
|
||||
const segmentsEstimate = Math.max(
|
||||
@@ -1463,6 +1523,36 @@ export async function runSources(engine: BrainEngine, args: string[]): Promise<v
|
||||
const sub = args[0];
|
||||
const rest = args.slice(1);
|
||||
|
||||
// Help guards run BEFORE the subcommand switch below (mirrors jobs.ts
|
||||
// src/commands/jobs.ts:462-471 — help checked first-position, then any
|
||||
// position, before any subcommand body runs). cli.ts routes bare `sources
|
||||
// --help` here with a placeholder engine (SELF_HELP_WITHOUT_ENGINE): the
|
||||
// second check is why that's safe — without it, `sources <sub> --help`
|
||||
// would fall through to <sub>'s own handler instead of printing help,
|
||||
// which crashes for engine-touching subcommands (the placeholder engine
|
||||
// is not a real one) and, for engine-free subcommands like `detach`
|
||||
// (unlinks .gbrain-source with no engine involved at all), would silently
|
||||
// perform the destructive action instead of showing usage.
|
||||
if (!sub || sub === '--help' || sub === '-h') {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
if (rest.includes('--help') || rest.includes('-h')) {
|
||||
// webhook is the one sources subcommand that ships its own detailed
|
||||
// --help (set/show/rotate/clear, in SOURCES_WEBHOOK_HELP) — print that
|
||||
// instead of the general list so `sources webhook --help` and `sources
|
||||
// webhook <sub> --help` reach it. Do NOT dispatch into runWebhook: that
|
||||
// would let e.g. `sources webhook set x --help` fall through to
|
||||
// runWebhookSet, the same destructive-dispatch class this guard exists
|
||||
// to prevent for the rest of sources' subcommands.
|
||||
if (sub === 'webhook') {
|
||||
console.log(SOURCES_WEBHOOK_HELP);
|
||||
return;
|
||||
}
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (sub) {
|
||||
case 'add': return runAdd(engine, rest);
|
||||
case 'list': return runList(engine, rest);
|
||||
@@ -1496,11 +1586,8 @@ export async function runSources(engine: BrainEngine, args: string[]): Promise<v
|
||||
// agent-bootstrap: scan-gated workspace push
|
||||
case 'push': return runPush(engine, rest);
|
||||
case 'unharden': { const { runUnharden } = await import('./sources-harden.ts'); return runUnharden(engine, rest); }
|
||||
case undefined:
|
||||
case '--help':
|
||||
case '-h':
|
||||
printHelp();
|
||||
return;
|
||||
// undefined / --help / -h are handled by the guards above, before this
|
||||
// switch is ever reached — no case needed here.
|
||||
default:
|
||||
console.error(`Unknown sources subcommand: ${sub}`);
|
||||
printHelp();
|
||||
@@ -1548,6 +1635,9 @@ Subcommands:
|
||||
override (v0.40.3.0). Pass "unset" or
|
||||
"default" to clear (NULL falls through
|
||||
to the global search.mode bundle).
|
||||
webhook <set|show|rotate|clear> <id> [options]
|
||||
v0.40 — per-source webhook secret management.
|
||||
Run 'sources webhook --help' for subcommand detail.
|
||||
harden <id|--all> [--pat-file <p>] [--branch <b>] [--no-cron] [--no-verify] [--dry-run] [--json]
|
||||
v0.42.44 — make a brain repo durable: local
|
||||
auto-push hook, committed commit-push helper,
|
||||
|
||||
@@ -67,13 +67,41 @@ interface IngestCliOpts {
|
||||
source?: string;
|
||||
facts?: boolean;
|
||||
maxCostUsd?: number;
|
||||
/** gbrain#4149: explicit per-format byte-cap override; undefined = adapter-native defaults. */
|
||||
maxBytes?: number;
|
||||
embed?: boolean;
|
||||
all?: boolean;
|
||||
json?: boolean;
|
||||
quiet?: boolean;
|
||||
}
|
||||
|
||||
function parseIngestArgs(args: string[]): IngestCliOpts | { help: true } | { error: string } {
|
||||
/**
|
||||
* gbrain#4149: the checkpoint fingerprint input, extracted so the cap
|
||||
* dimension is unit-testable — a `--since last` watermark written under one
|
||||
* cap must never be reused under another (or the auto defaults).
|
||||
*/
|
||||
export function ingestCheckpointFingerprintInput(args: {
|
||||
sourceId: string;
|
||||
pathspec: string | string[];
|
||||
format: string;
|
||||
version: string | number;
|
||||
maxBytes?: number;
|
||||
}): Record<string, string | number | string[]> {
|
||||
return {
|
||||
sourceId: args.sourceId,
|
||||
pathspec: args.pathspec,
|
||||
format: args.format,
|
||||
version: args.version,
|
||||
// Key present ONLY for an explicit cap (review finding, multi-specialist
|
||||
// confirmed): an unconditional `maxBytes: 'auto'` would re-hash EVERY
|
||||
// pre-existing watermark at upgrade and silently force a one-time full
|
||||
// rescan. Omitting the key keeps the default path on the legacy
|
||||
// fingerprint; every explicit cap still gets its own scope.
|
||||
...(args.maxBytes != null ? { maxBytes: args.maxBytes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIngestArgs(args: string[]): IngestCliOpts | { help: true } | { error: string } {
|
||||
const opts: IngestCliOpts = { paths: [] };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
@@ -128,6 +156,20 @@ function parseIngestArgs(args: string[]): IngestCliOpts | { help: true } | { err
|
||||
opts.maxCostUsd = n;
|
||||
continue;
|
||||
}
|
||||
if (a === '--max-bytes') {
|
||||
// gbrain#4149: optional VALIDATED override for the per-format byte
|
||||
// caps (e.g. the Hermes store guard). Omission preserves each
|
||||
// adapter's native default — one global cap must not replace
|
||||
// format-specific safety limits. Accepts plain bytes or kb/mb/gb.
|
||||
const raw = (args[++i] ?? '').toLowerCase();
|
||||
const m = raw.match(/^(\d+(?:\.\d+)?)(kb|mb|gb)?$/);
|
||||
if (!m) return { error: `max-bytes must be a positive size like 800000000, 512mb, or 4gb (got '${raw || ''}')` };
|
||||
const mult = m[2] === 'kb' ? 1024 : m[2] === 'mb' ? 1024 ** 2 : m[2] === 'gb' ? 1024 ** 3 : 1;
|
||||
const n = Math.floor(parseFloat(m[1]) * mult);
|
||||
if (!Number.isFinite(n) || n <= 0) return { error: 'max-bytes must resolve to a positive byte count' };
|
||||
opts.maxBytes = n;
|
||||
continue;
|
||||
}
|
||||
if (a.startsWith('-')) return { error: `unknown flag ${a}` };
|
||||
opts.paths.push(a);
|
||||
}
|
||||
@@ -158,6 +200,11 @@ skip). Embedding is OFF by default; run the embed backfill later or opt in.
|
||||
--embed Embed pages at import (default: defer to embed backfill)
|
||||
--facts Extract facts from imported pages (budget-capped)
|
||||
--max-cost-usd F Facts budget cap (default 5)
|
||||
--max-bytes N Override the per-format file/store byte caps (e.g. 4gb
|
||||
for a multi-GB hermes store). Omit to keep each
|
||||
format's native safety default. Changing it starts a
|
||||
fresh --since last scope (caps are part of the
|
||||
checkpoint fingerprint)
|
||||
--json Machine-readable result
|
||||
--quiet Suppress the human summary
|
||||
|
||||
@@ -347,12 +394,18 @@ async function runIngest(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
const { TRANSCRIPT_IMPORT_VERSION } = await import('../core/transcripts/render.ts');
|
||||
const checkpointKey = {
|
||||
op: 'transcripts-ingest',
|
||||
fingerprint: fingerprint({
|
||||
// gbrain#4149: the effective cap is part of scan coverage — a checkpoint
|
||||
// written under a different cap (or the auto defaults) must not be
|
||||
// silently reused, or a capped run's skipped tail reads as
|
||||
// already-imported. The input builder distinguishes 'auto' from every
|
||||
// explicit value.
|
||||
fingerprint: fingerprint(ingestCheckpointFingerprintInput({
|
||||
sourceId,
|
||||
pathspec: checkpointSpec,
|
||||
format: parsed.format ?? 'auto',
|
||||
version: TRANSCRIPT_IMPORT_VERSION,
|
||||
}),
|
||||
maxBytes: parsed.maxBytes,
|
||||
})),
|
||||
};
|
||||
let sinceIso = parsed.since;
|
||||
if (parsed.since === 'last') {
|
||||
@@ -383,6 +436,7 @@ async function runIngest(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
limit: parsed.limit,
|
||||
sinceIso,
|
||||
sourceId,
|
||||
maxBytes: parsed.maxBytes,
|
||||
embed: parsed.embed,
|
||||
activePack,
|
||||
onFileDone: () => reporter.tick(),
|
||||
|
||||
@@ -66,6 +66,33 @@ export async function runUpgrade(args: string[]) {
|
||||
console.log('No published binary for this platform/arch.');
|
||||
console.log('Download the latest binary from GitHub Releases:');
|
||||
console.log(' https://github.com/garrytan/gbrain/releases');
|
||||
} else if (
|
||||
result.reason === 'integrity_failed' ||
|
||||
result.reason === 'integrity_unavailable' ||
|
||||
result.reason === 'version_mismatch'
|
||||
) {
|
||||
// Fail-closed: the downloaded binary was never installed (renamed over
|
||||
// the live path). "signed" is intentionally omitted — we match against
|
||||
// the build-provenance attestation's digest + builder identity fetched
|
||||
// over TLS from the GitHub API; we do NOT independently verify the
|
||||
// Sigstore signature chain (see src/core/binary-self-update.ts header).
|
||||
const detail =
|
||||
result.reason === 'integrity_failed'
|
||||
? 'the downloaded binary did not match its build-provenance attestation (digest/builder mismatch)'
|
||||
: result.reason === 'version_mismatch'
|
||||
? 'the downloaded binary reported a different version than the release it was fetched for (possible downgrade)'
|
||||
: 'the build-provenance attestation could not be fetched (offline, rate-limited, or missing)';
|
||||
console.error(`Binary self-update rejected — integrity not confirmed: ${detail}.`);
|
||||
console.error('Your existing binary is unchanged and the download was discarded.');
|
||||
console.error('Retry later, or download + verify manually:');
|
||||
console.error(' https://github.com/garrytan/gbrain/releases');
|
||||
recordUpgradeError({
|
||||
phase: 'binary-self-update',
|
||||
fromVersion: oldVersion,
|
||||
toVersion: '',
|
||||
error: result.reason,
|
||||
hint: 'Integrity check failed; existing binary retained. Retry or download manually.',
|
||||
});
|
||||
} else {
|
||||
console.error(`Binary self-update failed (${result.reason}${result.error ? `: ${result.error}` : ''}).`);
|
||||
console.error('Your existing binary is unchanged. Download manually if needed:');
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
VOLUNTEER_DEFAULT_MIN_CONFIDENCE,
|
||||
} from '../core/context/volunteer.ts';
|
||||
import type { WindowTurn } from '../core/context/entity-salience.ts';
|
||||
import { DEFAULT_WINDOW_TURNS, windowTurnCount } from '../core/context/reflex.ts';
|
||||
import { DEFAULT_WINDOW_TURNS, windowTurnCount, lexicalArmsEnabled } from '../core/context/reflex.ts';
|
||||
import { loadConfig } from '../core/config.ts';
|
||||
import { logVolunteerEventsFireAndForget, volunteerEventRowsFrom } from '../core/context/volunteer-events.ts';
|
||||
|
||||
@@ -161,6 +161,7 @@ export async function runWatch(engine: BrainEngine, args: string[], deps: WatchI
|
||||
// Session dedupe: skipped inside the core BEFORE the gate + cap
|
||||
// (O(1) per pointer) so a recurring slug can't starve new pages.
|
||||
excludeSlugs: pushedSlugs,
|
||||
lexicalArms: lexicalArmsEnabled(loadConfig()),
|
||||
});
|
||||
} catch {
|
||||
continue; // fail-open per turn: a transient DB error never kills the stream
|
||||
|
||||
@@ -101,10 +101,12 @@ export function getProviderCapabilities(modelString: string): ProviderCapabiliti
|
||||
// Subsequent waves can split this into its own recipe field if a provider
|
||||
// ever supports tools without parallel dispatch.
|
||||
supportsParallelTools: chat.supports_tools === true,
|
||||
// Not exposed by ChatTouchpoint today — defaults to false. Recipes can add
|
||||
// a `supports_thinking` field later without breaking this helper (it'll
|
||||
// just keep returning false until a recipe sets it).
|
||||
supportsThinking: false,
|
||||
// Recipe-declared thinking-by-default (gbrain#4172): true when the model
|
||||
// reasons without being asked and bills that reasoning as output tokens.
|
||||
// Boolean or per-model predicate, mirroring supports_prompt_cache.
|
||||
supportsThinking: typeof chat.thinking_by_default === 'function'
|
||||
? chat.thinking_by_default(parsed.modelId)
|
||||
: chat.thinking_by_default === true,
|
||||
maxContext: chat.max_context_tokens ?? 128_000,
|
||||
};
|
||||
}
|
||||
|
||||
+5
-1
@@ -288,7 +288,11 @@ export function dimsProviderOptions(
|
||||
// configured for a smaller width (e.g. 1536) hard-fail at first embed.
|
||||
// Azure/OpenAI-compat embeddings are symmetric — inputType ignored.
|
||||
// v0.36.0.0 (D13): same range validation as native-openai path.
|
||||
const bareModelId = modelId.includes('/') ? modelId.split('/').pop()! : modelId;
|
||||
// Lowercased for matching only — providers' model ids are case-sensitive
|
||||
// (SiliconFlow serves `Qwen/Qwen3-Embedding-4B` and 500s on the
|
||||
// lowercase form), so the ORIGINAL id goes on the wire while every
|
||||
// literal compared here is already lowercase (gbrain#4123).
|
||||
const bareModelId = (modelId.includes('/') ? modelId.split('/').pop()! : modelId).toLowerCase();
|
||||
if (bareModelId.startsWith('text-embedding-3')) {
|
||||
if (isOpenAITextEmbedding3Model(bareModelId) && !isValidOpenAITextEmbedding3Dim(bareModelId, dims)) {
|
||||
const max = maxOpenAITextEmbedding3Dim(bareModelId)!;
|
||||
|
||||
+64
-13
@@ -46,7 +46,7 @@ import type {
|
||||
Recipe,
|
||||
TouchpointKind,
|
||||
} from './types.ts';
|
||||
import { resolveRecipe, assertTouchpoint, parseModelId } from './model-resolver.ts';
|
||||
import { resolveRecipe, assertTouchpoint, parseModelId, embeddingDimsForModel } from './model-resolver.ts';
|
||||
import {
|
||||
OPENROUTER_CACHE_HEADER,
|
||||
openrouterRequiresExplicitPromptCache,
|
||||
@@ -164,6 +164,11 @@ type EmbedManyFn = typeof embedMany;
|
||||
let _embedTransport: EmbedManyFn = embedMany;
|
||||
type GenerateTextFn = typeof generateText;
|
||||
let _generateTextTransport: GenerateTextFn = generateText;
|
||||
// Test-only seam for expand()'s structured-output SDK call. Mirrors
|
||||
// _generateTextTransport (see __setGenerateObjectTransportForTests). Never
|
||||
// swapped in production — expand() always calls the real generateObject.
|
||||
type GenerateObjectFn = typeof generateObject;
|
||||
let _generateObjectTransport: GenerateObjectFn = generateObject;
|
||||
// v0.41.6.0 D1: tests that install a transport stub also pass the
|
||||
// embedding-creds preflight, matching the chat-transport fast-path
|
||||
// pattern. Set when __setEmbedTransportForTests is called with a
|
||||
@@ -619,6 +624,7 @@ function clearGatewayState(): void {
|
||||
_shrinkState.clear();
|
||||
_embedTransport = embedMany;
|
||||
_generateTextTransport = generateText;
|
||||
_generateObjectTransport = generateObject;
|
||||
_embedTransportInstalled = false;
|
||||
_chatTransport = null;
|
||||
_warnedRecipes.clear();
|
||||
@@ -676,6 +682,17 @@ export function __setGenerateTextTransportForTests(fn: GenerateTextFn | null): v
|
||||
_generateTextTransport = fn ?? generateText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only seam for expand()'s generateObject call (the structured-output
|
||||
* path used for native providers and openai-compatible recipes that declare
|
||||
* supportsStructuredOutputs). Same shape as __setGenerateTextTransportForTests.
|
||||
*
|
||||
* @internal exported for tests; not part of the public gateway API.
|
||||
*/
|
||||
export function __setGenerateObjectTransportForTests(fn: GenerateObjectFn | null): void {
|
||||
_generateObjectTransport = fn ?? generateObject;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only seam mirroring `__setEmbedTransportForTests`. When set,
|
||||
* `chat()` skips provider resolution and SDK invocation and calls the
|
||||
@@ -831,8 +848,12 @@ export function diagnoseEmbedding(modelOverride?: string): EmbeddingDiagnosis {
|
||||
// search. The genuine "picked a user-provided provider but no model" UX is
|
||||
// handled at the config/init layer, where a bare provider string still exists.
|
||||
const isUserProvided = (tp as any).user_provided_models === true;
|
||||
const recipeDefaultDims = tp.default_dims ?? 0;
|
||||
if ((isUserProvided || recipeDefaultDims === 0) && !_config!.embedding_dimensions) {
|
||||
// Consult the per-model map, not just the recipe-wide default: a recipe
|
||||
// with default_dims:0 (openrouter, #4114) still KNOWS the width of its
|
||||
// listed models via model_dims, so those must not fail preflight when
|
||||
// embedding_dimensions is unset — only genuinely unknown ids do.
|
||||
const recipeDeclaredDims = embeddingDimsForModel(recipe, parsed.modelId);
|
||||
if ((isUserProvided || recipeDeclaredDims === 0) && !_config!.embedding_dimensions) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'user_provided_dims_unset',
|
||||
@@ -2202,13 +2223,14 @@ async function embedMultimodalOpenAICompat(
|
||||
);
|
||||
}
|
||||
|
||||
// D12 — dim validation. Prefer recipe's declared default_dims when set;
|
||||
// fall back to the brain's configured embedding_dimensions. If neither
|
||||
// is known (LiteLLM recipe with default_dims=0 and no config override),
|
||||
// we skip the dim check rather than fabricate an expected value — the
|
||||
// engine's vector(N) column will reject mismatched rows at INSERT time
|
||||
// with a clearer error than anything we could throw here.
|
||||
const recipeDims = recipe.touchpoints.embedding?.default_dims ?? 0;
|
||||
// D12 — dim validation. Prefer the recipe's declared dims for THIS model
|
||||
// (per-model model_dims first, then default_dims — #4114); fall back to
|
||||
// the brain's configured embedding_dimensions. If neither is known
|
||||
// (LiteLLM recipe with default_dims=0 and no config override), we skip
|
||||
// the dim check rather than fabricate an expected value — the engine's
|
||||
// vector(N) column will reject mismatched rows at INSERT time with a
|
||||
// clearer error than anything we could throw here.
|
||||
const recipeDims = embeddingDimsForModel(recipe, modelId);
|
||||
const expectedDims = recipeDims > 0
|
||||
? recipeDims
|
||||
: (cfg.embedding_dimensions ?? 0);
|
||||
@@ -2531,8 +2553,34 @@ export async function expand(query: string): Promise<string[]> {
|
||||
`Query: ${query}`,
|
||||
].join('\n');
|
||||
|
||||
// #4121: expand() calls generateObject/generateText directly and never
|
||||
// goes through chat()'s _recordBudget closure, so every expansion LLM
|
||||
// call was invisible to BudgetTracker — spend happened but was never
|
||||
// recorded, even inside a withBudgetTracker() scope. Resolve the ambient
|
||||
// tracker once and record on every SUCCESSFUL call site below. Fail-open
|
||||
// (no tracker → no-op) and swallow BudgetExhausted the same way chat()'s
|
||||
// _recordBudget does — TX1 surfaces on the NEXT reserve(), not here.
|
||||
const tracker = getCurrentBudgetTracker();
|
||||
const recordExpansionUsage = (
|
||||
modelLabel: string,
|
||||
usage: { inputTokens?: number; outputTokens?: number } | undefined,
|
||||
): void => {
|
||||
if (!tracker) return;
|
||||
try {
|
||||
tracker.record({
|
||||
modelId: modelLabel,
|
||||
inputTokens: Number(usage?.inputTokens ?? 0),
|
||||
outputTokens: Number(usage?.outputTokens ?? 0),
|
||||
label: 'gateway.expand',
|
||||
});
|
||||
} catch {
|
||||
// BudgetExhausted (TX1) raised here; surfaced via the next reserve().
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const { model, recipe, modelId } = await resolveExpansionProvider(getExpansionModel());
|
||||
const modelLabel = `${recipe.id}:${modelId}`;
|
||||
|
||||
let expansions: string[];
|
||||
|
||||
@@ -2541,35 +2589,38 @@ export async function expand(query: string): Promise<string[]> {
|
||||
// there, so generateObject would warn and silently degrade. generateText + a
|
||||
// tolerant parse recovers the queries instead. Fresh abortSignal per call.
|
||||
const viaText = async (): Promise<string[]> => {
|
||||
const { text } = await generateText({
|
||||
const { text, usage } = await _generateTextTransport({
|
||||
model,
|
||||
abortSignal: withDefaultTimeout(undefined, AI_CHAT_TIMEOUT_MS),
|
||||
prompt: expansionPrompt,
|
||||
});
|
||||
recordExpansionUsage(modelLabel, usage);
|
||||
return parseExpansionResponse(text) ?? [];
|
||||
};
|
||||
|
||||
if (recipe.implementation !== 'openai-compatible') {
|
||||
// Native providers (Anthropic, OpenAI, Google) support generateObject's
|
||||
// structured output natively — unchanged path.
|
||||
const result = await generateObject({
|
||||
const result = await _generateObjectTransport({
|
||||
model,
|
||||
schema: ExpansionSchema,
|
||||
abortSignal: withDefaultTimeout(undefined, AI_CHAT_TIMEOUT_MS),
|
||||
prompt: expansionPrompt,
|
||||
});
|
||||
recordExpansionUsage(modelLabel, result.usage);
|
||||
expansions = result.object?.queries ?? [];
|
||||
} else if (recipeSupportsStructuredOutputs(recipe)) {
|
||||
// openai-compatible backend that honors strict json_schema: request the
|
||||
// schema (strict validation), and fall back to the text path if it is
|
||||
// rejected at call time so a mis-declared capability never drops expansion.
|
||||
try {
|
||||
const result = await generateObject({
|
||||
const result = await _generateObjectTransport({
|
||||
model,
|
||||
schema: ExpansionSchema,
|
||||
abortSignal: withDefaultTimeout(undefined, AI_CHAT_TIMEOUT_MS),
|
||||
prompt: expansionPrompt,
|
||||
});
|
||||
recordExpansionUsage(modelLabel, result.usage);
|
||||
expansions = result.object?.queries ?? [];
|
||||
} catch {
|
||||
expansions = await viaText();
|
||||
|
||||
@@ -219,10 +219,24 @@ function runClaude(
|
||||
// (subscription), never via an inherited API key. Without this, an
|
||||
// ANTHROPIC_API_KEY in gbrain's env (the exact setup this recipe is meant
|
||||
// to replace) silently flips billing to per-token API usage.
|
||||
//
|
||||
// Also scrub the CLAUDE_CODE_USE_* backend-switch flags: Bedrock, Vertex
|
||||
// AI, Mantle, Microsoft Foundry, and Claude Platform on AWS are each
|
||||
// gated by one of these, take priority over subscription OAuth when set,
|
||||
// and route billing through a cloud account instead. Clearing the switch
|
||||
// is sufficient — provider-specific creds (AWS_*, ANTHROPIC_VERTEX_*,
|
||||
// ANTHROPIC_FOUNDRY_*, ANTHROPIC_AWS_*, ...) are inert without it.
|
||||
const env = { ...process.env };
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
delete env.ANTHROPIC_AUTH_TOKEN;
|
||||
delete env.ANTHROPIC_BASE_URL;
|
||||
// Prefix wipe, not a denylist (review hardening): the backend-switch
|
||||
// family grows one CLAUDE_CODE_USE_* flag per new cloud backend, and any
|
||||
// future switch inherited from gbrain's env would silently re-route the
|
||||
// child's billing. Subscription-only is the recipe's contract.
|
||||
for (const k of Object.keys(env)) {
|
||||
if (k.startsWith('CLAUDE_CODE_USE_')) delete env[k];
|
||||
}
|
||||
const child = spawn(claudeBin(), args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
cwd: ensureCleanCwd(),
|
||||
@@ -392,7 +406,12 @@ export class ClaudeCliLanguageModel implements LanguageModelV2 {
|
||||
async doGenerate(options: LanguageModelV2CallOptions): Promise<{
|
||||
content: LanguageModelV2Content[];
|
||||
finishReason: 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' | 'unknown';
|
||||
usage: { inputTokens: number | undefined; outputTokens: number | undefined; totalTokens: number | undefined };
|
||||
usage: {
|
||||
inputTokens: number | undefined;
|
||||
outputTokens: number | undefined;
|
||||
totalTokens: number | undefined;
|
||||
cachedInputTokens: number | undefined;
|
||||
};
|
||||
warnings: never[];
|
||||
}> {
|
||||
const { systemText, userPrompt } = renderPrompt(options.prompt);
|
||||
@@ -422,6 +441,14 @@ export class ClaudeCliLanguageModel implements LanguageModelV2 {
|
||||
const inputTokens = result.usage?.input_tokens;
|
||||
const outputTokens = result.usage?.output_tokens;
|
||||
const totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
|
||||
// `cache_creation_input_tokens` is deliberately NOT surfaced here — the AI
|
||||
// SDK's LanguageModelV2Usage has no corresponding field, and folding it in
|
||||
// would need a claude-cli-specific branch in the gateway's usage assembly
|
||||
// (src/core/ai/gateway.ts). Out of scope for this fix.
|
||||
const cachedInputTokens =
|
||||
result.usage?.cache_read_input_tokens !== undefined
|
||||
? Number(result.usage.cache_read_input_tokens)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
content,
|
||||
@@ -430,6 +457,7 @@ export class ClaudeCliLanguageModel implements LanguageModelV2 {
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens: inputTokens !== undefined && outputTokens !== undefined ? totalTokens : undefined,
|
||||
cachedInputTokens,
|
||||
},
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
@@ -57,6 +57,15 @@ export const claudeCli: Recipe = {
|
||||
cost_per_1m_input_usd: 3.0,
|
||||
cost_per_1m_output_usd: 15.0,
|
||||
price_last_verified: '2026-06-17',
|
||||
// The gateway dispatches via a `claude -p (print mode)` subprocess (CLI cold
|
||||
// start + user-level CLAUDE.md load), which routinely takes 5-6s even
|
||||
// when the CLI and subscription are perfectly healthy. `gbrain models
|
||||
// doctor`'s chat probe used to hardcode a flat 5000ms abort, so this
|
||||
// recipe always false-failed the doctor check ('unknown — claude-cli
|
||||
// adapter aborted') despite `chat()` succeeding fine at normal call
|
||||
// sites. 30s gives the subprocess room to start without masking a truly
|
||||
// dead/unauthenticated CLI for anywhere near that long.
|
||||
default_timeout_ms: 30_000,
|
||||
},
|
||||
},
|
||||
// Friendly aliases mirror the `anthropic` recipe so config strings stay
|
||||
|
||||
@@ -96,6 +96,10 @@ export const deepseek: Recipe = {
|
||||
supports_tools: true,
|
||||
supports_subagent_loop: true,
|
||||
supports_prompt_cache: false,
|
||||
// Thinking mode is DEFAULT ON for both v4 models (see module docstring):
|
||||
// reasoning bills as output and counts against max_tokens, so callers
|
||||
// that size output caps must grant reasoning headroom (gbrain#4172).
|
||||
thinking_by_default: true,
|
||||
max_context_tokens: 1_000_000,
|
||||
cost_per_1m_input_usd: 0.14, // deepseek-v4-flash cache-miss baseline
|
||||
cost_per_1m_output_usd: 0.28,
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
import type { Recipe } from '../types.ts';
|
||||
|
||||
/**
|
||||
* Version-scoped prompt-cache capability.
|
||||
*
|
||||
* Gemini's *implicit* caching is the only kind that applies here: the gateway
|
||||
* sends no cache directives on the Google path, and the explicit CachedContent
|
||||
* API is never called. Implicit caching is on by default for Gemini 2.5 and
|
||||
* newer, so those ids cache (and bill cached input at a discount) with no
|
||||
* request mutation; 1.5 and 2.0 cache only through the explicit API and stay
|
||||
* false. Ids this recipe can also serve but that never cache (Gemma) are out.
|
||||
*
|
||||
* The version digits sit in different positions across ids (`gemini-2.5-pro`,
|
||||
* `gemini-3-flash-preview`, `gemini-3.6-flash`), so match the first numeric
|
||||
* token rather than a fixed segment. A VERSIONED `-latest` alias
|
||||
* (`gemini-1.5-pro-latest`) is judged by its version — 1.5 aliases cache only
|
||||
* via the explicit API and must stay false. Only an UNVERSIONED `-latest`
|
||||
* alias (no digits to judge by) passes on the alias alone, since it resolves
|
||||
* to a current-generation model. Any other unversioned id reads false: a
|
||||
* wrong `true` silently promises a discount the provider never applies.
|
||||
*/
|
||||
export function googleSupportsPromptCache(modelId: string): boolean {
|
||||
const normalized = modelId.trim().toLowerCase();
|
||||
if (!normalized.startsWith('gemini-')) return false;
|
||||
// Version tokens are 1-2 digits (2, 2.5, 3, 3.6, 10…); a longer numeric run
|
||||
// is a DATE/experiment suffix, not a version — `gemini-exp-1206` must not
|
||||
// parse as version 1206 and read as caching (review hardening on #4159).
|
||||
const version = normalized.match(/(?<!\d)\d{1,2}(?:\.\d+)?(?!\d)/);
|
||||
if (version !== null) return Number.parseFloat(version[0]) >= 2.5;
|
||||
return normalized.endsWith('-latest');
|
||||
}
|
||||
|
||||
export const google: Recipe = {
|
||||
id: 'google',
|
||||
name: 'Google Gemini',
|
||||
@@ -40,7 +70,11 @@ export const google: Recipe = {
|
||||
models: ['gemini-2.0-flash-exp', 'gemini-2.0-flash'],
|
||||
supports_tools: true,
|
||||
supports_subagent_loop: true,
|
||||
supports_prompt_cache: false,
|
||||
// Per-model: implicit caching is a 2.5+ capability, and this recipe
|
||||
// accepts off-list ids (the config plane does not pin model ids to the
|
||||
// list above), so a recipe-wide boolean mislabels whichever side it
|
||||
// picks.
|
||||
supports_prompt_cache: googleSupportsPromptCache,
|
||||
max_context_tokens: 1000000, // Gemini 2.0 Flash
|
||||
cost_per_1m_input_usd: 0.30,
|
||||
cost_per_1m_output_usd: 1.20,
|
||||
|
||||
@@ -168,7 +168,26 @@ export const openrouter: Recipe = {
|
||||
touchpoints: {
|
||||
embedding: {
|
||||
models: ['openai/text-embedding-3-small'],
|
||||
default_dims: 1536,
|
||||
// #4114: per-model native dims for the catalog the docs invite users to
|
||||
// pick. The old recipe-wide `default_dims: 1536` was only right for
|
||||
// text-embedding-3-small — `migrate embeddings --to openrouter:bge-m3`
|
||||
// planned a 1536-wide column for a model that returns 1024. Slash-form
|
||||
// ids are the lookup key (embeddingDimsForModel strips only a leading
|
||||
// `provider:`, never the org slash). gemini-embedding-2-preview is
|
||||
// deliberately NOT listed: its width is unverified, and a plausible
|
||||
// guess is this exact bug class — unlisted ids resolve to 0, which
|
||||
// forces an explicit --dim / embedding_dimensions with a clear error.
|
||||
model_dims: {
|
||||
'openai/text-embedding-3-small': 1536,
|
||||
'openai/text-embedding-3-large': 3072,
|
||||
'qwen/qwen3-embedding-8b': 4096,
|
||||
'bge-m3': 1024,
|
||||
'baai/bge-m3': 1024,
|
||||
},
|
||||
// OpenRouter proxies arbitrary embedding models with widths we cannot
|
||||
// know ahead of time; 0 = no silent default for unlisted ids.
|
||||
default_dims: 0,
|
||||
trust_custom_dims: true,
|
||||
// text-embedding-3-small was trained at MRL breakpoints 512/1024/1536
|
||||
// (Weaviate analysis); 768 is a practical intermediate. Users opt into
|
||||
// a smaller dim via `gbrain config set embedding_dimensions <N>`.
|
||||
|
||||
@@ -202,6 +202,14 @@ export interface ExpansionTouchpoint {
|
||||
models: string[];
|
||||
cost_per_1m_tokens_usd?: number;
|
||||
price_last_verified?: string;
|
||||
/**
|
||||
* Recipe-level timeout fallback for `gbrain models doctor`'s expansion
|
||||
* reachability probe. Mirrors `RerankerTouchpoint.default_timeout_ms`: lets
|
||||
* a slow-start provider (e.g. a subprocess-dispatched CLI with real cold-start
|
||||
* latency) declare the headroom it needs instead of the probe's flat 5000ms
|
||||
* default false-failing on every run.
|
||||
*/
|
||||
default_timeout_ms?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,6 +282,17 @@ export interface ChatTouchpoint {
|
||||
* model family).
|
||||
*/
|
||||
supports_prompt_cache?: boolean | ((modelId: string) => boolean);
|
||||
/**
|
||||
* Model reasons/thinks BY DEFAULT, spending output-token budget on internal
|
||||
* reasoning before any answer text (DeepSeek v4's thinking mode bills
|
||||
* reasoning as output and counts it against `max_tokens`). Consumers that
|
||||
* size output caps (e.g. `think`'s `maxOutputTokensFor`) grant these models
|
||||
* the same headroom as thinking-by-default Claude 5 / OpenAI reasoning
|
||||
* models. Boolean for recipe-wide behavior; predicate when only some routed
|
||||
* model ids think by default. Distinct from "can be asked to think" —
|
||||
* default-off reasoning modes should NOT set this (gbrain#4172).
|
||||
*/
|
||||
thinking_by_default?: boolean | ((modelId: string) => boolean);
|
||||
/**
|
||||
* Backend honors OpenAI structured outputs (a strict `json_schema`
|
||||
* response_format). Threaded into `createOpenAICompatible`'s
|
||||
@@ -289,6 +308,14 @@ export interface ChatTouchpoint {
|
||||
cost_per_1m_input_usd?: number;
|
||||
cost_per_1m_output_usd?: number;
|
||||
price_last_verified?: string;
|
||||
/**
|
||||
* Recipe-level timeout fallback for `gbrain models doctor`'s chat
|
||||
* reachability probe. Mirrors `RerankerTouchpoint.default_timeout_ms`: lets
|
||||
* a slow-start provider (e.g. a subprocess-dispatched CLI with real cold-start
|
||||
* latency) declare the headroom it needs instead of the probe's flat 5000ms
|
||||
* default false-failing on every run.
|
||||
*/
|
||||
default_timeout_ms?: number;
|
||||
}
|
||||
|
||||
export interface Recipe {
|
||||
|
||||
@@ -8,25 +8,63 @@
|
||||
* it's the only place we can (and now do) guarantee atomicity:
|
||||
*
|
||||
* resolve published asset → download to a temp sibling of the live binary →
|
||||
* fsync + chmod +x → `--version` smoke test → renameSync over the live path.
|
||||
* verify attestation integrity → fsync + chmod +x → `--version` smoke test →
|
||||
* verify version matches the release tag (downgrade-replay guard) →
|
||||
* renameSync over the live path.
|
||||
*
|
||||
* rename(2) over a running binary is safe on darwin/linux (the running process
|
||||
* keeps the old inode; the next exec picks up the new file). Every failure
|
||||
* (no asset / fetch / download / smoke / rename) leaves the OLD binary
|
||||
* untouched — there is no half-written-binary brick path. Windows can't rename
|
||||
* over a running .exe, and no Windows/`darwin-x64`/`linux-arm64` asset is
|
||||
* (no asset / fetch / download / integrity / smoke / rename) leaves the OLD
|
||||
* binary untouched — there is no half-written-binary brick path. Windows can't
|
||||
* rename over a running .exe, and no Windows/`darwin-x64`/`linux-arm64` asset is
|
||||
* published, so those degrade to notify-only via `resolvePlatformAsset`
|
||||
* returning null. Trust model: TLS + GitHub, same as `gbrain upgrade` (no
|
||||
* signature verification this wave — D7a TODO).
|
||||
* returning null.
|
||||
*
|
||||
* Integrity (D7a, done): before the downloaded binary is ever executed, its
|
||||
* SHA-256 is verified against the SLSA build-provenance attestation
|
||||
* `attest-build-provenance` publishes for every release
|
||||
* (`.github/workflows/release.yml`). The attestation is fetched from the GitHub
|
||||
* REST API (`/repos/OWNER/REPO/attestations/sha256:<digest>`) — a DIFFERENT
|
||||
* origin than the `objects.githubusercontent.com` CDN that serves the bytes —
|
||||
* and we check that (a) an attested subject's digest equals the locally-computed
|
||||
* digest and (b) the attestation's builder id is THIS repo's release workflow.
|
||||
* The verify is dependency-free (node:crypto + fetch + base64 + JSON only, all
|
||||
* Bun built-ins that survive `bun build --compile`; the `sigstore` npm package
|
||||
* does NOT bundle under `--compile`, so it is deliberately not used). Honest
|
||||
* guarantee: this is GitHub-account trust + origin separation + a signed
|
||||
* digest/identity match — it does NOT independently validate the Fulcio cert
|
||||
* chain or Rekor inclusion (that needs the trusted-root material sigstore-js
|
||||
* loads from disk). An unverified binary is NEVER chmod-exec'd or renamed over
|
||||
* the live path; integrity failure is fail-closed.
|
||||
*
|
||||
* Published asset matrix mirrors `.github/workflows/release.yml`:
|
||||
* darwin-arm64 → gbrain-darwin-arm64
|
||||
* linux-x64 → gbrain-linux-x64
|
||||
*/
|
||||
|
||||
import { chmodSync, closeSync, fsyncSync, openSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { chmodSync, closeSync, fsyncSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* The attestation's builder id must be EXACTLY one of these — it binds the
|
||||
* provenance to THIS repo's release workflow running on a trusted ref, so a
|
||||
* valid attestation for some OTHER artifact, a fork's workflow, or a
|
||||
* workflow_dispatch of release.yml from an arbitrary branch can't be replayed.
|
||||
* If tag-triggered releases ever ship, add their ref form here in the same PR.
|
||||
* Mirrors `expectedAssetName`'s coupling to release.yml; pinned by
|
||||
* test/release-workflow.test.ts.
|
||||
*/
|
||||
export const EXPECTED_BUILDER_ID_PREFIX =
|
||||
'https://github.com/garrytan/gbrain/.github/workflows/release.yml@';
|
||||
export const EXPECTED_BUILDER_IDS: readonly string[] = [
|
||||
`${EXPECTED_BUILDER_ID_PREFIX}refs/heads/master`,
|
||||
];
|
||||
|
||||
/** Base for the GitHub attestation REST endpoint (per-subject-digest lookup). */
|
||||
const ATTESTATION_API_BASE =
|
||||
'https://api.github.com/repos/garrytan/gbrain/attestations/sha256:';
|
||||
|
||||
export interface ReleaseAsset {
|
||||
name: string;
|
||||
@@ -38,9 +76,24 @@ export type BinarySelfUpdateReason =
|
||||
| 'fetch_failed'
|
||||
| 'no_asset'
|
||||
| 'download_failed'
|
||||
| 'integrity_unavailable'
|
||||
| 'integrity_failed'
|
||||
| 'version_mismatch'
|
||||
| 'smoke_failed'
|
||||
| 'replace_failed';
|
||||
|
||||
/** One attested subject: an artifact name + its SHA-256 (hex, no `sha256:` prefix). */
|
||||
export interface AttestedSubject {
|
||||
name: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
/** A parsed build-provenance attestation: the subjects it covers + its builder id. */
|
||||
export interface ParsedAttestation {
|
||||
subjects: AttestedSubject[];
|
||||
builderId: string;
|
||||
}
|
||||
|
||||
export interface BinarySelfUpdateResult {
|
||||
ok: boolean;
|
||||
reason?: BinarySelfUpdateReason;
|
||||
@@ -76,6 +129,26 @@ export interface BinarySelfUpdateDeps {
|
||||
download?: (url: string, destPath: string) => Promise<void>;
|
||||
/** Smoke-test the staged binary; returns true if `<path> --version` looks like gbrain. */
|
||||
smoke?: (stagedPath: string) => boolean;
|
||||
/**
|
||||
* Confirm the staged binary actually IS the release it claims to be — its
|
||||
* `--version` must contain `expectedVersion` (derived from the release tag).
|
||||
* Defaults to a real `--version` exec. Blocks a downgrade-replay: an attacker
|
||||
* who swaps the published asset for an OLDER, still-validly-attested binary
|
||||
* passes the digest+builder check (the old digest has a real attestation) but
|
||||
* reports the wrong version here. Injected in tests that stage non-binary bytes.
|
||||
*/
|
||||
checkVersion?: (stagedPath: string, expectedVersion: string) => boolean;
|
||||
/** SHA-256 (hex) of the file at `path`. Default reads the file with node:crypto. */
|
||||
computeDigest?: (path: string) => string;
|
||||
/**
|
||||
* Fetch + parse the build-provenance attestations for `digest` (hex, no
|
||||
* prefix). Returns the parsed attestations, or null when none are available
|
||||
* (missing / network / rate-limited) — null maps to `integrity_unavailable`,
|
||||
* NOT `integrity_failed`. Default hits the GitHub attestation REST API.
|
||||
* Injected in tests so the real digest/identity verify logic is exercised
|
||||
* against crafted attestation data (the network is the only mocked seam).
|
||||
*/
|
||||
fetchAttestation?: (digest: string) => Promise<ParsedAttestation[] | null>;
|
||||
platform?: NodeJS.Platform;
|
||||
arch?: NodeJS.Architecture;
|
||||
}
|
||||
@@ -125,6 +198,121 @@ function defaultSmoke(stagedPath: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function defaultCheckVersion(stagedPath: string, expectedVersion: string): boolean {
|
||||
try {
|
||||
const out = execFileSync(stagedPath, ['--version'], { encoding: 'utf-8', timeout: 10_000 });
|
||||
// Substring, not equality: `--version` prints `gbrain <version>` (+ maybe a
|
||||
// build suffix). The release workflow enforces binary-version == VERSION at
|
||||
// build time, so the tag's numeric version must appear here.
|
||||
return out.includes(expectedVersion);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultComputeDigest(path: string): string {
|
||||
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one GitHub attestation `bundle` into `{subjects, builderId}`.
|
||||
* The DSSE payload is a base64-encoded in-toto Statement:
|
||||
* { subject: [{name, digest:{sha256}}], predicate:{ runDetails:{ builder:{id} } } }
|
||||
* Returns null when the bundle is malformed (missing/undecodable payload).
|
||||
*/
|
||||
export function parseAttestationBundle(bundle: any): ParsedAttestation | null {
|
||||
try {
|
||||
const payloadB64 = bundle?.dsseEnvelope?.payload;
|
||||
if (typeof payloadB64 !== 'string' || payloadB64.length === 0) return null;
|
||||
const stmt = JSON.parse(Buffer.from(payloadB64, 'base64').toString('utf8'));
|
||||
// Only accept SLSA build-provenance statements — don't let some other
|
||||
// attestation type that happens to carry subject[]+builder.id be read as
|
||||
// provenance.
|
||||
if (typeof stmt?.predicateType === 'string' && !stmt.predicateType.includes('slsa.dev/provenance')) {
|
||||
return null;
|
||||
}
|
||||
const subjects: AttestedSubject[] = Array.isArray(stmt?.subject)
|
||||
? stmt.subject
|
||||
.map((s: any) => ({ name: String(s?.name ?? ''), sha256: String(s?.digest?.sha256 ?? '') }))
|
||||
.filter((s: AttestedSubject) => s.sha256.length > 0)
|
||||
: [];
|
||||
const builderId = String(stmt?.predicate?.runDetails?.builder?.id ?? '');
|
||||
if (subjects.length === 0 || builderId.length === 0) return null;
|
||||
return { subjects, builderId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function defaultFetchAttestation(digest: string): Promise<ParsedAttestation[] | null> {
|
||||
try {
|
||||
const res = await fetch(`${ATTESTATION_API_BASE}${digest}`, {
|
||||
headers: { 'User-Agent': 'gbrain-self-upgrade', Accept: 'application/vnd.github+json' },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
// 404 (no attestation), 403 (unauthenticated rate limit, 60/hr), any non-2xx
|
||||
// → treat as "unavailable" (caller fails closed), never as "verified".
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as any;
|
||||
const raw = Array.isArray(data?.attestations) ? data.attestations : [];
|
||||
const parsed = raw
|
||||
.map((a: any) => parseAttestationBundle(a?.bundle))
|
||||
.filter((p: ParsedAttestation | null): p is ParsedAttestation => p !== null);
|
||||
// Distinguish "endpoint reachable but no usable attestation" (null →
|
||||
// unavailable) from "reachable with data" (return the list, possibly empty
|
||||
// only if all bundles were malformed, which we also treat as unavailable).
|
||||
return parsed.length > 0 ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the staged binary against its build-provenance attestation. Returns a
|
||||
* reason on failure (fail-closed), or null on success.
|
||||
* - digest can't be computed → integrity_unavailable
|
||||
* - no attestation available → integrity_unavailable
|
||||
* - attestation exists but does not
|
||||
* cover this (name, digest) under
|
||||
* our release-workflow builder id → integrity_failed
|
||||
*/
|
||||
export async function verifyIntegrity(
|
||||
stagedPath: string,
|
||||
assetName: string,
|
||||
computeDigest: (path: string) => string,
|
||||
fetchAttestation: (digest: string) => Promise<ParsedAttestation[] | null>,
|
||||
): Promise<BinarySelfUpdateReason | null> {
|
||||
let digest: string;
|
||||
try {
|
||||
digest = computeDigest(stagedPath);
|
||||
} catch {
|
||||
return 'integrity_unavailable';
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/.test(digest)) return 'integrity_unavailable';
|
||||
|
||||
// fetchAttestation is an injected seam; a throwing implementation must not
|
||||
// escape runBinarySelfUpdate's never-throws contract (which would skip the
|
||||
// staged-file cleanup). Any failure to obtain attestations is fail-closed.
|
||||
let attestations: ParsedAttestation[] | null;
|
||||
try {
|
||||
attestations = await fetchAttestation(digest);
|
||||
} catch {
|
||||
return 'integrity_unavailable';
|
||||
}
|
||||
if (!attestations || attestations.length === 0) return 'integrity_unavailable';
|
||||
|
||||
// A match requires: an attestation from OUR release workflow ON A TRUSTED REF
|
||||
// that names this asset with exactly this digest. Digest-match alone is
|
||||
// insufficient (any artifact could carry it), and workflow-match alone is
|
||||
// insufficient (a dispatch from an untrusted branch mints a real attestation).
|
||||
const verified = attestations.some(
|
||||
(att) =>
|
||||
EXPECTED_BUILDER_IDS.includes(att.builderId) &&
|
||||
att.subjects.some((s) => s.name === assetName && s.sha256 === digest),
|
||||
);
|
||||
return verified ? null : 'integrity_failed';
|
||||
}
|
||||
|
||||
let _tmpCounter = 0;
|
||||
|
||||
/**
|
||||
@@ -141,6 +329,9 @@ export async function runBinarySelfUpdate(
|
||||
const fetchRelease = deps.fetchRelease ?? defaultFetchRelease;
|
||||
const download = deps.download ?? defaultDownload;
|
||||
const smoke = deps.smoke ?? defaultSmoke;
|
||||
const checkVersion = deps.checkVersion ?? defaultCheckVersion;
|
||||
const computeDigest = deps.computeDigest ?? defaultComputeDigest;
|
||||
const fetchAttestation = deps.fetchAttestation ?? defaultFetchAttestation;
|
||||
|
||||
const assetName = expectedAssetName(platform, arch);
|
||||
if (!assetName) {
|
||||
@@ -166,6 +357,14 @@ export async function runBinarySelfUpdate(
|
||||
return { ok: false, reason: 'download_failed', error: errMsg(e), asset: assetName };
|
||||
}
|
||||
|
||||
// Integrity BEFORE chmod/exec: never make an unverified binary executable and
|
||||
// never run its `--version` smoke test. Fail-closed on unavailable or mismatch.
|
||||
const integrityFailure = await verifyIntegrity(staged, assetName, computeDigest, fetchAttestation);
|
||||
if (integrityFailure) {
|
||||
safeUnlink(staged);
|
||||
return { ok: false, reason: integrityFailure, asset: assetName };
|
||||
}
|
||||
|
||||
try {
|
||||
chmodSync(staged, 0o755);
|
||||
} catch (e) {
|
||||
@@ -178,6 +377,15 @@ export async function runBinarySelfUpdate(
|
||||
return { ok: false, reason: 'smoke_failed', asset: assetName };
|
||||
}
|
||||
|
||||
// Downgrade-replay guard: the staged binary must actually be the release it
|
||||
// claims. A swapped asset serving an older, still-validly-attested binary
|
||||
// clears digest+builder but reports the wrong version here.
|
||||
const expectedVersion = release.tag.replace(/^v/, '').trim();
|
||||
if (expectedVersion && !checkVersion(staged, expectedVersion)) {
|
||||
safeUnlink(staged);
|
||||
return { ok: false, reason: 'version_mismatch', asset: assetName };
|
||||
}
|
||||
|
||||
try {
|
||||
renameSync(staged, targetPath); // atomic on same fs; old binary intact if this throws
|
||||
} catch (e) {
|
||||
|
||||
@@ -147,6 +147,40 @@ function renderBlock(block: CodexHttpServerBlock): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the `[mcp_servers.<name>]` table as a paste-ready snippet WITHOUT
|
||||
* the CODEX_TOML_BLOCK_BEGIN/END lines. The managed markers are SINGLETON —
|
||||
* findBlock refuses duplicate marker pairs and writeCodexHttpServerBlock
|
||||
* strips any prior managed block on rewrite — so a printed snippet carrying
|
||||
* markers would later be stripped or rejected by the writer. A marker-free
|
||||
* snippet stays ordinary user content: a later managed write sees it as a
|
||||
* FOREIGN table and refuses to double-define rather than silently absorbing
|
||||
* it.
|
||||
*
|
||||
* Validation mirrors the writer: the bare-key name assertion up front, then
|
||||
* the rendered text is parsed back and our table's keys are asserted to be
|
||||
* exactly [bearer_token, url] (the same key-set check the writer's
|
||||
* post-render validation performs).
|
||||
*/
|
||||
export function renderCodexHttpServerBlock(block: CodexHttpServerBlock): string {
|
||||
assertBareKeyName(block.name);
|
||||
const lines = renderBlock(block).filter(
|
||||
(line) => line !== CODEX_TOML_BLOCK_BEGIN && line !== CODEX_TOML_BLOCK_END,
|
||||
);
|
||||
const text = lines.join('\n');
|
||||
const parsed = parseToml(text);
|
||||
const servers = parsed.mcp_servers as Record<string, unknown> | undefined;
|
||||
const ours = servers?.[block.name];
|
||||
const ourKeys = typeof ours === 'object' && ours !== null ? Object.keys(ours as object).sort() : [];
|
||||
if (ourKeys.join(',') !== 'bearer_token,url') {
|
||||
throw new Error(
|
||||
`render validation failed: [mcp_servers.${block.name}] keys are [${ourKeys.join(', ')}], ` +
|
||||
`expected exactly [bearer_token, url].`,
|
||||
);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Atomic 0600 write preserving symlinks and the file's dominant EOL
|
||||
* (forceMode: the file carries a bearer token regardless of prior mode). */
|
||||
function atomicWriteToml(configPath: string, unixText: string, crlf: boolean): void {
|
||||
|
||||
@@ -104,6 +104,17 @@ import {
|
||||
removeOpencodeMcpEntry,
|
||||
writeOpencodeMcpEntry,
|
||||
} from './opencode-json.ts';
|
||||
import { isServeOlderThanScopes, probeServeHealth } from './serve-health.ts';
|
||||
|
||||
// Peeled façade seam (cathedral-6): the serve probe + scopes version floor
|
||||
// moved to serve-health.ts; re-exported so this module's public surface — and
|
||||
// every import site — is unchanged.
|
||||
export {
|
||||
SCOPES_MIN_SERVE_VERSION,
|
||||
isServeOlderThanScopes,
|
||||
probeServeHealth,
|
||||
type ServeHealth,
|
||||
} from './serve-health.ts';
|
||||
|
||||
// ── Flags ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -227,13 +238,6 @@ export function parseHarnessArgs(rest: string[]): HarnessFlags {
|
||||
|
||||
// ── Deps (injectable for the serial suite) ──────────────────────────────────
|
||||
|
||||
export interface ServeHealth {
|
||||
ok: boolean;
|
||||
engine?: string;
|
||||
version?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface HarnessDeps {
|
||||
runner: ExecRunner;
|
||||
gbrainHome: string;
|
||||
@@ -351,25 +355,6 @@ function defaultPgliteLiveServe(): boolean {
|
||||
return probeLivePgliteHolder(cfg.database_path) !== null;
|
||||
}
|
||||
|
||||
// ── Serve probe ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function probeServeHealth(
|
||||
mcpUrl: string,
|
||||
fetchFn: typeof fetch,
|
||||
timeoutMs = 3000,
|
||||
): Promise<ServeHealth> {
|
||||
const base = mcpUrl.replace(/\/mcp$/, '');
|
||||
try {
|
||||
const res = await fetchFn(`${base}/health`, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
if (!res.ok) return { ok: false, detail: `GET ${base}/health → ${res.status}` };
|
||||
const body = (await res.json()) as { status?: string; version?: string; engine?: string };
|
||||
if (body.status !== 'ok') return { ok: false, detail: `health status: ${body.status ?? 'unknown'}` };
|
||||
return { ok: true, version: body.version, engine: body.engine };
|
||||
} catch (e) {
|
||||
return { ok: false, detail: (e as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Consent copy [C5 / #4029 register] ──────────────────────────────────────
|
||||
|
||||
export function buildConsentBlock(p: {
|
||||
@@ -1381,24 +1366,6 @@ export async function applyHarness(flags: HarnessFlags, rawDeps: HarnessDeps): P
|
||||
return allConfirmed && smokeOk ? 0 : 1;
|
||||
}
|
||||
|
||||
/** The scopes-honoring release: any serve older verifies scoped tokens as full access. */
|
||||
/** The first release whose verify path honors the scopes column. PINNED — a
|
||||
* comparison against the moving CLI VERSION would false-flag every scope-aware
|
||||
* serve as soon as the next release ships (ship-review P3). */
|
||||
export const SCOPES_MIN_SERVE_VERSION = '0.45.14.0';
|
||||
|
||||
export function isServeOlderThanScopes(serveVersion: string): boolean {
|
||||
const parse = (v: string): number[] => v.split('.').map((n) => Number.parseInt(n, 10) || 0);
|
||||
const a = parse(serveVersion);
|
||||
const b = parse(SCOPES_MIN_SERVE_VERSION);
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
const x = a[i] ?? 0;
|
||||
const y = b[i] ?? 0;
|
||||
if (x !== y) return x < y;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Remove [C9/F2/C8] ───────────────────────────────────────────────────────
|
||||
|
||||
export async function removeHarness(flags: HarnessFlags, rawDeps: HarnessDeps): Promise<number> {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* serve-health.ts — serve /health probe + scopes version-skew floor, peeled
|
||||
* from harness.ts (cathedral-6: the agent-register lane needs these without
|
||||
* dragging the whole harness in). harness.ts re-exports this entire surface
|
||||
* (peeled-façade rule: import sites never chase the peel). fetchFn stays an
|
||||
* explicit argument — no ambient fetch, no engine, no config.
|
||||
*/
|
||||
|
||||
export interface ServeHealth {
|
||||
ok: boolean;
|
||||
engine?: string;
|
||||
version?: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export async function probeServeHealth(
|
||||
mcpUrl: string,
|
||||
fetchFn: typeof fetch,
|
||||
timeoutMs = 3000,
|
||||
): Promise<ServeHealth> {
|
||||
const base = mcpUrl.replace(/\/mcp$/, '');
|
||||
try {
|
||||
const res = await fetchFn(`${base}/health`, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
if (!res.ok) return { ok: false, detail: `GET ${base}/health → ${res.status}` };
|
||||
const body = (await res.json()) as { status?: string; version?: string; engine?: string };
|
||||
if (body.status !== 'ok') return { ok: false, detail: `health status: ${body.status ?? 'unknown'}` };
|
||||
return { ok: true, version: body.version, engine: body.engine };
|
||||
} catch (e) {
|
||||
return { ok: false, detail: (e as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/** The scopes-honoring release: any serve older verifies scoped tokens as full access. */
|
||||
/** The first release whose verify path honors the scopes column. PINNED — a
|
||||
* comparison against the moving CLI VERSION would false-flag every scope-aware
|
||||
* serve as soon as the next release ships (ship-review P3). */
|
||||
export const SCOPES_MIN_SERVE_VERSION = '0.45.14.0';
|
||||
|
||||
export function isServeOlderThanScopes(serveVersion: string): boolean {
|
||||
const parse = (v: string): number[] => v.split('.').map((n) => Number.parseInt(n, 10) || 0);
|
||||
const a = parse(serveVersion);
|
||||
const b = parse(SCOPES_MIN_SERVE_VERSION);
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
const x = a[i] ?? 0;
|
||||
const y = b[i] ?? 0;
|
||||
if (x !== y) return x < y;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from './engine.ts';
|
||||
import { isUndefinedTableError } from './utils.ts';
|
||||
import { CJK_SLUG_CHARS } from './cjk.ts';
|
||||
import { stripCodeBlocks } from './link-extraction.ts';
|
||||
|
||||
@@ -40,6 +41,8 @@ export const LINKABLE_ENTITY_TYPES = ['person', 'company', 'organization', 'enti
|
||||
* pack-aware follow-up (TODO-1) can let users opt specific 3-char entity
|
||||
* types in.
|
||||
*/
|
||||
let aliasGazetteerWarned = false;
|
||||
|
||||
const MIN_NAME_LENGTH = 4;
|
||||
const MIN_CJK_NAME_LENGTH = 2;
|
||||
|
||||
@@ -390,6 +393,12 @@ export async function buildGazetteer(
|
||||
if (!row.title) continue;
|
||||
if (!hasCJK(row.title) && row.title.length < MIN_NAME_LENGTH) continue;
|
||||
if (hasCJK(row.title) && cjkCharCount(row.title) < MIN_CJK_NAME_LENGTH) continue;
|
||||
// NOTE (v0.46.15, deliberately preserved): for TITLES this condition is
|
||||
// intentionally vacuous — every row here IS a real page, so an
|
||||
// ignore-listed name the user explicitly created a page for is always
|
||||
// allowed (documented CK12 policy). The ignore list bites only via
|
||||
// opts.extraIgnore names that have no page, and — with real teeth — on
|
||||
// the ALIAS entries below, which are not user-created pages.
|
||||
if (ignoreSet.has(row.title) && !existingTitles.has(row.title)) continue;
|
||||
|
||||
const tokens = tokenizeTitle(row.title);
|
||||
@@ -408,6 +417,78 @@ export async function buildGazetteer(
|
||||
else gazetteer.set(key, [entry]);
|
||||
}
|
||||
|
||||
// ── Alias entries (v0.46.15 identity wave, #3801) ────────────────────────
|
||||
// page_aliases rows joined to LIVE entity-typed pages become additional
|
||||
// gazetteer entries, so a body mention of "saoirse" links to
|
||||
// people/saoirse-x. Guards (stricter than titles — aliases are not
|
||||
// user-created pages):
|
||||
// - ignore-list applies CASE-INSENSITIVELY with NO existing-page escape
|
||||
// (aliases store normalized lowercase; DEFAULT_IGNORE_LIST is cased)
|
||||
// - aliases mapping to >1 slug within a source are skipped (ambiguous)
|
||||
// - aliases colliding with any existing page TITLE in the SAME source
|
||||
// are skipped (the title entry wins; per-source scoping per R2-9)
|
||||
// - MIN_NAME_LENGTH applies to the alias string
|
||||
try {
|
||||
const aliasRows = await engine.executeRaw<{
|
||||
alias_norm: string;
|
||||
slug: string;
|
||||
source_id: string | null;
|
||||
title: string | null;
|
||||
}>(
|
||||
`SELECT pa.alias_norm, pa.slug, pa.source_id, p.title
|
||||
FROM page_aliases pa
|
||||
JOIN pages p ON p.slug = pa.slug AND p.source_id = pa.source_id
|
||||
WHERE p.type IN (${typeList})
|
||||
AND p.deleted_at IS NULL`,
|
||||
[],
|
||||
);
|
||||
const ignoreLc = new Set(Array.from(ignoreSet, (s) => s.toLowerCase()));
|
||||
// Per-source title index for alias-vs-title collision checks.
|
||||
const titleBySource = new Set<string>();
|
||||
for (const r of rows) {
|
||||
if (r.title) titleBySource.add(`${r.source_id ?? 'default'} | ||||