Compare commits

..
Author SHA1 Message Date
Garry Tan 19af788774 Merge remote-tracking branch 'origin/master' into garrytan/code-smell-fix-wave
# Conflicts:
#	BOOTSTRAP_FOR_AGENTS.md
#	CHANGELOG.md
#	TODOS.md
#	VERSION
#	docs/architecture/KEY_FILES.md
#	openclaw.plugin.json
#	package.json
#	templates/bootstrap/template-repo/README.md
2026-08-14 20:50:55 -07:00
Garry TanandClaude Fable 5 0db03e9c01 test: withEnv() for snapshot opt-out in embedding-dim fresh-brain case (test-isolation guard)
The W0 ship-pass fix used a manual save/delete/restore of
GBRAIN_PGLITE_SNAPSHOT, which check-test-isolation rule R1 flags on CI
(the local ship verify ran before this file gained the mutation).
withEnv() scopes the opt-out to the connect() call with identical
behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 20:46:36 -07:00
Garry Tan 0c84367e5e Merge remote-tracking branch 'origin/master' into garrytan/code-smell-fix-wave 2026-08-14 20:37:59 -07:00
Garry TanandClaude Fable 5 8a2c233d94 chore: re-bump to v0.45.16.0 (version queue collision with #4125)
The sibling jobs fix wave (PR #4125, open) claims v0.45.15.0; per the
user's call this PR advances past it. All six version locations re-synced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 20:14:53 -07:00
Garry TanandClaude Fable 5 d644f18b22 docs: cross-model doc-review fixes for v0.45.15.0 (Codex pass)
- CHANGELOG 0.45.15.0: the image-chunk recovery command is `gbrain backfill
  modality` (flipped chunks are not stale, so an embed --stale re-run cannot
  restore them — doctor names the same fix); upgrade note now also covers
  jobs supervisor/worker restarts; the prompt-hang fix names its two commands
  instead of implying all destructive prompts; guard self-test claim scoped
  to self-tested scanners.
- KEY_FILES: cycle entry counts all 23 ALL_PHASES (was 9); raced-wait nuance
  for the 5 long phases (in-flight work runs to its bounded timeout);
  snapshot-lock last-resort unlocked path + version-file-not-tar gate scope;
  guards manifest registers/classifies but does not schedule (CHECKS array
  stays the execution list).
- TESTING.md: same snapshot-lock last-resort honesty.
- CONTRIBUTING.md: self-test scope (selftest=yes rows), stale ~85s inner-loop
  figure and 19+ check count refreshed.
- FIX_WAVE_BASELINES.md: two W0 line counts refreshed per the doc's own
  method (post-ship-pass HEAD).

llms bundle rebuilt (no byte changes); guards + build-llms test green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 20:08:42 -07:00
Garry TanandClaude Fable 5 5df46f8215 docs: update project documentation for v0.45.15.0
- docs/TESTING.md: tiers table now documents the default-on PGLite schema
  snapshot for `bun run test` (GBRAIN_NO_SNAPSHOT opt-out); new "PGLite
  schema snapshot" + "Guard registry and self-test" sections (build/loader
  contract, GBRAIN_SNAPSHOT_LOCK_TIMEOUT_MS, guards-manifest.tsv,
  GBRAIN_GUARD_ROOT); removed the deleted `check:all` tier; added the nine
  new W0 test suites to the unit-test inventory.
- docs/architecture/KEY_FILES.md: current-state refresh for db-lock.ts
  (fenced handles, boolean refresh, LockStolenError), cycle.ts (dedicated
  serialized lock refresher, GBRAIN_CYCLE_LOCK_REFRESH_MS, steal-abort with
  reason lock_stolen, composed DB+file lock semantics; dropped the closed
  TODO-OPS-2 residual), minions/queue.ts (shared killJobs tail,
  lockParentsOrdered, stranded-parent sweep, started_at resets),
  supervisor.ts (fenced miss exits LOCK_LOST immediately), embed.ts
  (carryChunkMetadata shared field list), import.ts (typed
  ImportAbortError), lint.ts (single-pass --fix), pglite-repair.ts (EOF-safe
  stderr confirm prompts), check-no-double-retry.sh (arrow-paren-crossing
  pattern, perl fallback); new entries for guards-manifest.tsv +
  guard-self-test.sh and build-pglite-snapshot.ts; swept stale check:all
  references.
- CONTRIBUTING.md: verify check count refreshed; check:all replaced with the
  guard-registry + self-test workflow.

llms bundles verified fresh (bun run build:llms — no byte changes;
test/build-llms.test.ts green). CHANGELOG/TODOS/VERSION already current from
the ship pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 19:59:25 -07:00
Garry TanandClaude Fable 5 56f80310be chore: bump version and changelog (v0.45.15.0)
W0 verified-bug hotfix wave of the code-smell fix-wave series. All six
version locations synced (VERSION, package.json, openclaw.plugin.json,
runbook stamp, template stamp, lockfile).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 19:47:01 -07:00
Garry TanandClaude Fable 5 b425c411d5 fix: red-team findings — unfenced file-lock half, 5th started_at path, sync-phase steal coverage (W0 ship pass 2)
The post-specialist red team found what five specialists and the coverage
audit all missed — two of them critical:

- cycle (CRITICAL): the PGLite composite lock's FILE half was rewritten
  unconditionally even when the fenced DB refresh reported a steal — the
  losing holder clobbered the successor's file lock with its own pid on the
  very tick it detected the loss, after which its pid-checked file release
  DELETED the successor's only host-local protection mid-run (single-writer
  violation). The file half now rewrites only while the DB fence says owned.
- queue (CRITICAL): fifth path of the started_at class — every
  waiting-children→waiting parent unblock (killJobs, completeJob resolve,
  failJob remove_dep/ignore, cancelJob, resolveParent, the new retroactive
  sweep: 7 sites) preserved the parent's attempt-1 anchor, so an aggregator
  whose children ran >5 minutes was wall-clock dead-lettered on re-claim —
  orphaning the exact child_done results the W0 parent-unblock fix just
  delivered. All 7 unblock sites now clear started_at; pinned by a
  parked-parent-survives-the-sweep test.
- cycle: the sync phase — production's LONGEST await (resumable imports can
  run hours) — was the one long phase outside steal coverage. Now raced like
  the other five (sync checkpoints, holds its own per-source lock, and its
  stall watchdog bounds the dangling import).
- build-pglite-snapshot: takeover verifies lock-dir mtime staleness before
  rmdir (two exhausted waiters could steal each other's LIVE lock);
  hermetic temp home created only past the short-circuit (was leaking one
  dir per `bun run test`).
- Stale docs: jobs.ts import-handler comment claimed a process.exit that no
  longer exists; CLAUDE.md's engine-dynamic-import exception list now names
  the snapshot loader's require() cluster (build:llms regenerated in this
  commit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 19:35:41 -07:00
Garry TanandClaude Fable 5 d7175253b3 fix: pre-landing review fixes — 5 specialists + coverage audit findings (W0 ship pass)
Specialist review (testing/maintainability/security/performance/data-migration,
5 parallel fresh-context reviewers + ship coverage audit at 92%) on the W0
diff. Every accepted finding fixed in-line:

- db-lock: fence rendered as extract(epoch from acquired_at)::text — THREE
  specialists independently flagged timestamptz::text as GUC-fragile (the
  fence is captured on the acquire pool but compared on the direct pool; a
  TimeZone/DateStyle divergence would turn every refresh into a false steal
  and loop the supervisor through LOCK_LOST). Epoch text is session-invariant.
- cycle: anyAbortSignal returns {signal, dispose}; runCycle disposes in its
  finally — the forward listener lives on the CALLER's signal and the
  autopilot daemon reuses one shutdown signal across every tick, so
  undisposed combines accumulated listeners + captured controllers for the
  daemon's lifetime (MaxListenersExceededWarning within ~10 ticks). Stub
  poll timers clear on dispose too. Helper moved out of the import block and
  behaviorally tested (5 cases incl. the daemon-leak class).
- queue: retroactive stranded-parent sweep on every handleStalled tick — the
  per-kill unblock was forward-only, so parents stranded by PRE-upgrade
  stall-deaths (children already 'dead') never healed. Idempotent NOT-EXISTS
  UPDATE; pinned with stranded-heals + live-child-stays tests.
- build-pglite-snapshot: the stale-lock takeover could NEVER acquire
  (mkdirSync on an existing dir always throws), so one crashed builder left
  every future rebuild waiting the full deadline then proceeding UNLOCKED
  forever. Takeover now removes the stale dir first; lock timeout is
  env-tunable; hermetic setup moved into main() (ESM hoisting made the
  module-scope placement illusory) and the temp home is cleaned up.
- check-no-double-retry.sh: the perl multi-line pass exited 1 from clean
  batches — under pipefail, xargs's 123 would override grep's verdict the
  moment src/ outgrows one batch (a future silent miss of the exact class
  this guard just got cured of; repro'd by the reviewer). Output-presence now
  decides; multi-line bad fixture added so the pass self-tests.
- check-jsonb-pattern.sh: the widened greedy pattern false-positived a SAFE
  ::text::jsonb line followed by a paren-bearing ${expr()}::jsonb on the same
  line (proven by repro); bracket-bounded [^}]* pattern can't span
  interpolations — good fixture now pins the multi-interpolation shape.
- check-engine-dynamic-import.ts: also matches require() calls (the new
  snapshot-loader require was invisible to the guard, its marker decorative);
  4 pre-existing lazy requires in tryLoadSnapshot marked with their existing
  justification.
- Coverage gaps closed: supervisor fenced-false → immediate LOCK_LOST test;
  snapshot shape/hash guard tests (pre-W0 version files refused, dims/model
  mismatch refused, handler-edit changes the hash); anyAbortSignal behavior
  suite; steal-test window widened 200ms → 1.5s (shard-load starvation).
- lint: tree walked once (onPagesCollected sizes the progress bar; the CLI's
  extra collectPages walk removed); stale docstrings corrected (hook fires
  AFTER the fix attempt; carry list includes modality).
- Suite hygiene: 3 fresh-brain-premise tests opt out of the default-on
  snapshot; the check:all contract test now pins the single CHECKS registry.
- TODOS.md: 6 fix-wave deferrals filed (each individually decided in review);
  TODO-OPS-2 marked CLOSED by this wave.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 19:05:50 -07:00
Garry TanandClaude Fable 5 fe3b39d944 fix(cycle,ci): duck-type-tolerant signal combining + contract-test updates (W0 follow-through)
- anyAbortSignal no longer uses AbortSignal.any: CycleOpts.signal has always
  been duck-typed in practice (test stubs pass { aborted: false } and flip
  the flag; pre-W0 the raw object flowed straight into checkAborted).
  AbortSignal.any threw ERR_INVALID_ARG_TYPE and broke the autopilot-cycle
  handler suite. Manual fan-in: real signals propagate via listener,
  listener-less stubs are polled at 50ms, and the RETURNED signal is a
  genuine AbortSignal so phases can hand it to fetch/timers.
- cycle-abort.test.ts source-contract tests updated to the cycleSignal truth
  (boundaries now check the combined external+steal signal) and additionally
  pin that the combine folds BOTH sources.
- Restored the `typecheck` entry an errant edit dropped from
  run-verify-parallel's CHECKS array (caught by its own contract test —
  the registry pinning working as designed).
- De-flaked the refresher steal test: poll to a 5s deadline instead of a
  fixed 120ms sleep (shard-load timer starvation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 18:41:36 -07:00
Garry TanandClaude Fable 5 e37e89ad03 docs(eval): fix-wave baseline metrics — measure the 10x claim (W0, D4.13)
Records the series' starting numbers: god-file line counts (the registry
waves' targets), guard census (47 guards / 3 self-tested / single registry),
and the measured snapshot speedup (1.63s → 0.91s per PGLite test file).
Each wave PR appends its row; the deltas are the receipt. The retrieval-
quality canary (eval gate on a non-production brain) is documented as the
mandatory pre-W1 step — W0 touches no search paths and the production brain
is single-writer-held by the live serve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 17:16:17 -07:00
Garry TanandClaude Fable 5 21c3f4ced8 fix(test-infra): snapshot bakes the pinned test embedding shape; loader refuses shape mismatches (W0 follow-through)
Turning the snapshot default-on exposed a latent poisoning class: the build
script ran with an UNCONFIGURED gateway, so initSchema fell back to the
shipped default (1280-d zembed columns) — while bunfig's preload pins every
`bun test` file to the legacy OpenAI 1536-d shape. The moment tests loaded
the fixture, every embedding write failed with "expected 1280 dimensions,
not 1536" (115 suite failures from one root cause).

- The pinned shape now lives ONCE in test/helpers/legacy-embedding-config.ts;
  both the bunfig preload and the snapshot build script consume it (no
  hand-copied twins — the exact disease this wave cures). The build also
  isolates GBRAIN_HOME so ambient machine config can't leak in.
- The version file records dims= and model= alongside the schema hash; the
  loader resolves its own would-be shape through the same gateway-or-default
  fallback initSchema uses and REFUSES a shape-mismatched snapshot (falls
  back to cold init with a rebuild hint). Pre-W0 hash-only version files
  read as stale. A test that reconfigures the gateway to a different shape
  now correctly bypasses the fixture instead of writing into wrong columns.
- The build's freshness short-circuit checks all three lines.
- Rephrased a guard comment that spelled a batch-call token literally —
  check-system-of-record scans scripts/ comments (the prose-bleed class,
  third occurrence this month).

put-page-provenance: 9 fail → 0 under the fixture, still 3x faster than
cold init.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 17:16:02 -07:00
Garry TanandClaude Fable 5 53f49c5f2c fix(test-infra): PGLite snapshot default-on for bun run test — idempotent, shard-safe, handler-aware (W0 Tier-1 #16)
500+ test files each cold-boot PGLite and replay all 126 migrations, but the
snapshot fixture that skips that was enabled ONLY inside scripts/ci-local.sh
— the everyday `bun run test` loop paid full cold-init on every file
(measured: 1.63s → 0.91s per PGLite-booting file with the fixture).

- run-unit-parallel.sh (the `bun run test` entrypoint) builds + exports the
  snapshot BEFORE its shard fan-out. Opt out: GBRAIN_NO_SNAPSHOT=1.
- build-pglite-snapshot.ts is now idempotent: hash short-circuit exits in
  ~40ms when fresh, and REBUILDS stale snapshots — the old build-if-missing
  guard left a stale-but-present snapshot permanently on the warn+slow path.
  ci-local.sh now calls it unconditionally.
- Shard/workspace concurrency safety (Codex D5.8): atomic mkdir lock with
  takeover-on-stale; tar written first, version file last, so a crash can
  never leave a fresh-looking torn fixture.
- Hash soundness (Codex D5.13 / #4): 19+ migrations carry executable
  `handler` code with empty sql — invisible to the sql-only hash, so editing
  a handler reused a stale snapshot. The handler SOURCE now folds into the
  hash via Function.prototype.toString.

Migration-replay coverage is unchanged: the replay canary tests clear
GBRAIN_PGLITE_SNAPSHOT themselves and migrate.test.ts exercises
runMigrations directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 15:57:53 -07:00
Garry TanandClaude Fable 5 958cb2b3f7 fix(ci): guard self-test harness — a guard that cannot fail is not coverage (W0 Tier-1 #11)
The audit proved scripts/check-no-double-retry.sh had been PERMANENTLY GREEN
since it shipped: its `[^)]*` regex could not cross the `)` in `() =>`, so
the canonical banned shape `withRetry(() => engine.addLinksBatch(...))` was
invisible, and its multi-line fallback was gated on pcregrep — installed
neither locally nor in CI. check-jsonb-pattern.sh carried the same
nested-paren hole. Two more structural findings: package.json's `check:all`
was a second, stale, hand-synced guard registry (the exact disease this
fix-wave exists to cure), and three guards were reachable ONLY from it —
i.e. never run anywhere.

- Both regexes fixed; the no-double-retry multi-line pass now uses perl
  (always present) instead of pcregrep (never present). Real tree verified
  clean under the fixed patterns.
- scripts/guards-manifest.tsv is THE single guard registry: all 45 guards
  classified (scanner / buildfresh / repostate, per Codex D5.14 — build and
  freshness guards are exempt-with-reason, not fixture-tested).
- scripts/guard-self-test.sh runs every selftest=yes scanner against
  known-bad (must fail) and known-good (must pass) fixture trees via the
  GBRAIN_GUARD_ROOT seam, enforces manifest completeness for new guards, and
  carries a runtime budget (D4.5) so guard sprawl surfaces here first.
  Wired into `bun run verify`; adding a self-test = flip a manifest flag +
  two fixture files.
- `check:all` deleted; its three orphaned guards (newlines, exports-count,
  no-legacy-getconnection) verified green and wired into the real registry.

The bad fixtures are the exact shapes the old regexes missed — the harness
fails loudly on the pre-fix scripts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 15:54:43 -07:00
Garry TanandClaude Fable 5 f10af869f4 fix(cli): confirm prompts resolve on EOF and refuse non-TTY in-prompt (W0 Tier-1 #15)
Port-ledger note: since the audit, both destructive-command callers
(pglite-repair, reinit-pglite) gained caller-side non-TTY guards
('Non-TTY environment requires --yes'), so the original always-hangs case is
already blocked upstream. The residual: a TTY session whose stdin hits EOF
mid-prompt still parked forever — pglite-repair's readline had no 'close'
handler and reinit-pglite's raw data-listener had no 'end' path (and its
prompt wrote to stdout, polluting --json output).

Both prompts now: refuse non-TTY in-prompt (defense-in-depth, safe default
false), resolve(false) on EOF/close, prompt on stderr, and clean up their
listeners. Decline paths and --yes/-y escape hatches unchanged. No new test:
exercising EOF-mid-TTY needs a PTY harness — the W5 prompt canonicalization
(core/prompt.ts) picks that up when all seven prompt copies converge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 15:48:59 -07:00
Garry TanandClaude Fable 5 0a073a64d1 fix(lint): single scan for --fix — true fixed count, half the work (W0 Tier-1 #14)
runLint ran its own full read+lint+fix loop for human output, then called
runLintCore a second time for the summary line. Every page was linted twice,
and because the first pass had already written the fixes, the second pass's
total_fixed counted against already-fixed content — `gbrain lint --fix`
printed "0 auto-fixed." after fixing N issues.

runLintCore now exposes per-page hooks (onPageScanned for the progress bar,
onPageIssues with the applied fix count); the CLI streams its human detail
from the same single pass that produces the canonical counts. Pinned: two
pages scan as exactly two ticks, total_fixed matches the page-level fix
count, the fix lands on disk, and a second run reports 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 15:47:18 -07:00
Garry TanandClaude Fable 5 65d6babc0e fix(import): throw typed ImportAbortError instead of process.exit — MCP server survives failed preflights (W0 Tier-1 #5)
runImport called process.exit(1) at five preflight/argv sites (deferred-setup
sentinel, missing embedding credentials, invalid --workers, missing dir,
unreadable dir). Correct for the CLI — but runImport is invoked IN-PROCESS by
the sync_brain MCP op (via performFullSync), the autopilot daemon, and the
minion sync handler, so a first/forced sync against a brain with unusable
embedding credentials terminated the stdio MCP server mid-tool-call with no
error envelope (verified reachable in adversarial review; daemon/worker paths
are partially shielded by noEmbed defaults, the MCP path was not).

The five sites now throw ImportAbortError (exitCode, alreadyReported) AFTER
printing their user-facing messages exactly as before; the CLI dispatch case
maps the error to process.exit(exitCode) — byte-identical CLI behavior. The
in-process callers get a normal error: the MCP op returns an error envelope,
the job handler fails the job, the daemon logs and continues.

Pinned: three abort classes throw typed (not exit), and the calling process
demonstrably survives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 15:44:59 -07:00
Garry TanandClaude Fable 5 141a8ad1b9 fix(embed): carry modality through every re-embed path — one shared field list (W0 Tier-1 #3)
CONFIRMED in the audit + adversarial verification: preserveCodeMetadata
(commands/embed.ts) rebuilt ChunkInputs without `modality`, and upsertChunks
overwrites that column from EXCLUDED — so every CLI re-embed path (embedPage,
embed --all, embed --stale, including the autopilot-reachable stale loop)
flipped image chunks to modality='text'. The image search arm filters
cc.modality='image', so image retrieval silently went to zero while keyword
search started returning raw OCR text. The minion twin in core/embed-stale.ts
carried modality correctly and its comment documented this exact hazard —
the two hand-copied field lists had diverged.

carryChunkMetadata (core/embed-stale.ts) is now the single carry list;
preserveCodeMetadata delegates to it, killing the divergence class at the
root (the full loop merge lands in W6). embedding_image stays deliberately
un-carried (COALESCEd by the upsert; getChunks returns pgvector strings).

Pinned: the carry preserves modality + all 8 code-metadata fields; an image
chunk round-trips the stale-merge intact; and the write-side contract test
documents WHY the carry is load-bearing (omission demonstrably resets to
'text').

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 15:42:24 -07:00
Garry TanandClaude Fable 5 7e646f577a fix(minions): shared killJobs tail — stall-death notifies parents; reapers use parents-first lock order (W0 Tier-1 #4)
handleStalled's dead-letter branch set status='dead' and emitted NOTHING: no
child_done inbox row, no aggregator unblock. A child that died via max-stall
stranded its parent in 'waiting-children' forever — the exact hang the v0.15
comment says was fixed for timeouts (resolveParent has no periodic caller;
the worker only logs counts). Meanwhile handleTimeouts and
handleWallClockTimeouts carried two verbatim copies of the ~45-line
notify-and-unblock block.

- One private killJobs(tx, rows, outcome, errorText) now owns the child_done
  insert + waiting-children unblock; all three reapers route through it.
  handleStalled's dead branch emits outcome 'dead' / 'max stalled count
  exceeded' (distinct from 'timeout' so consumers can tell stall-death from
  overrun).
- Deadlock safety (Codex eng-review D5.12): failJob locks the parent BEFORE
  touching the child, while the reapers previously updated children first —
  opposite lock order. All three reapers now discover candidates with a plain
  read, lock parents in ascending-id order via lockParentsOrdered(), then
  transition children under a re-checked FOR UPDATE SKIP LOCKED subselect in
  the same transaction.

Pinned: stall-exhausted child → child_done(dead) + parent flips to waiting;
budget-remaining stall requeues without touching the parent; all three
reapers' outcome/error strings asserted through the shared tail (D5.5).
Full minions e2e suite green (187 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 15:38:06 -07:00
Garry TanandClaude Fable 5 7a424745d9 fix(minions): reset started_at on every automatic re-run path (W0 Tier-1 #7)
handleWallClockTimeouts anchors on now() - started_at, but only the manual
`jobs retry` path cleared started_at — its own docstring documented the bug.
The four automatic paths (failJob's delayed branch, handleStalled's requeue,
promoteDelayed, and releaseLeaseFullJob — the fourth site surfaced by
adversarial verification) preserved the FIRST claim's timestamp, so an
exponential-backoff job burned its wall-clock budget while parked in
'delayed' and could be dead-lettered before executing a single line of its
retry attempt.

All four paths now clear started_at; claim()'s COALESCE re-stamps per
attempt. Terminal failures (failed/dead) keep started_at for duration
accounting. Pinned end-to-end: a job whose first attempt ran an hour
survives the sweep on its fresh attempt, and the negative control proves the
sweep still kills genuinely overrunning attempts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 15:33:56 -07:00
Garry TanandClaude Fable 5 f8ff610b46 fix(cycle,locks): fenced lock identity + steal-abort — the cycle lock is actually refreshed in production (W0 Tier-1 #1)
The 2026-08-14 audit (CONFIRMED by adversarial verification) found the cycle
DB lock was effectively never refreshed: lock.refresh() was reachable only
through buildYieldDuringPhase, three of five pass sites handed phases the raw
caller hook, and NO production caller (jobs.ts, autopilot.ts) sets
yieldDuringPhase at all — so with the 5-minute TTL against 35-minute subagent
waits, every long cycle lost its lock mid-run and a second cycle could start
against the same source.

Fixes, per the fix-wave plan (D5.10/D5.11/D5.6):

- db-lock: refresh() and release() predicates now require the acquisition
  fence (id, holder_pid, acquired_at::text) captured at acquire time, so a
  PID-reuse impostor or a stolen handle can never refresh or delete a
  successor's row. refresh() returns true only while owned; a fenced miss is
  distinguished from transient DB errors (which still throw and retry).
- cycle: runCycle owns a SERIALIZED background refresher (6x per TTL window,
  GBRAIN_CYCLE_LOCK_REFRESH_MS escape hatch) for the cycle lock only — Minion
  job-lock renewal stays on the phase-boundary hooks per the cycle.ts:618
  decision. A detected steal aborts an internal controller; the combined
  signal reaches every existing checkAborted() boundary, and the five long
  phases (synthesize, extract_atoms, patterns, synthesize_concepts,
  consolidate) race their awaits against it since their opts cannot carry a
  signal yet. The three raw yieldDuringPhase pass sites are now wrapped.
- A steal returns a structured partial report (reason 'lock_stolen') instead
  of throwing; completed phases' writes are durable, the freshness stamp is
  skipped, and the fenced release leaves the successor's row intact.
- supervisor: a fenced refresh returning false is CERTAIN lock loss, not a
  blip — exit LOCK_LOST immediately instead of resetting the failure counter.
- withRefreshingLock: stops its heartbeat and reports loudly when the fenced
  refresh proves the lock gone.

Closes TODO-OPS-2 (refresh had no rows-affected check, so lock loss was
undetectable).

Tests: db-lock-fencing (fence round-trip, steal → refresh false, fenced
release no-op, refresher abort/serialization/transient-vs-steal, yield hook
steal reporting), cycle-lock-steal.serial (end-to-end mid-run steal →
partial/lock_stolen report, no further phases, successor row intact +
steal-free regression guard). All pre-existing lock suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 15:30:59 -07:00
46 changed files with 43 additions and 896 deletions
-5
View File
@@ -6,11 +6,6 @@
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/gbrain_test
# Option B: Real Supabase instance (tests the actual production path)
# DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-0-us-east-1.pooler.supabase.com:6543/postgres
# NOTE (#3485): destructive tests enforce a database-name floor — the name must
# carry "test" as a word segment (gbrain_test passes; Supabase's default
# "postgres" does not). Use a dedicated test project/database, or opt the exact
# name in one-shot: GBRAIN_E2E_ALLOW_DB=postgres bun run test:e2e
# (never a shell-profile export — that would permanently disarm the floor).
# Tier 2 (required for skill tests, optional for mechanical tests)
OPENAI_API_KEY=sk-...
+1 -10
View File
@@ -61,15 +61,10 @@ jobs:
- name: Run JSONB double-encode parity tests on real Postgres
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gbrain_test
# #3485 preload guard: this job intentionally tests against a DB.
GBRAIN_TEST_ALLOW_DATABASE_URL: '1'
# --timeout also raises bun's 5s default hook budget (beforeAll/afterAll
# do NOT inherit a test's third-arg timeout; verified on bun 1.3.x).
# Every runner script in scripts/ passes it; bare invocations must too.
# phantom-redirect rides this job: its Postgres arm is the other
# engine-parity backstop and no other CI lane carries DATABASE_URL to it
# (the unit wrappers strip the URL per #3485).
run: bun test --timeout=60000 test/e2e/op-checkpoint-jsonb-parity.test.ts test/e2e/jsonb-roundtrip.test.ts test/phantom-redirect-engine-parity.test.ts
run: bun test --timeout=60000 test/e2e/op-checkpoint-jsonb-parity.test.ts test/e2e/jsonb-roundtrip.test.ts
tier1:
name: Tier 1 (Mechanical)
@@ -99,8 +94,6 @@ jobs:
run: bun test --timeout=60000 test/e2e/mechanical.test.ts test/e2e/mcp.test.ts
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gbrain_test
# #3485 preload guard: this job intentionally tests against a DB.
GBRAIN_TEST_ALLOW_DATABASE_URL: '1'
tier2:
name: Tier 2 (LLM Skills)
@@ -168,8 +161,6 @@ jobs:
run: bun test --timeout=60000 test/e2e/skills.test.ts test/e2e/zeroentropy-live.test.ts
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gbrain_test
# #3485 preload guard: this job intentionally tests against a DB.
GBRAIN_TEST_ALLOW_DATABASE_URL: '1'
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
# v0.33.3.0: ZE live API tests skip gracefully when this is unset,
-2
View File
@@ -64,8 +64,6 @@ jobs:
- name: Run heavy tests
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/gbrain_test
# #3485 preload guard: this job intentionally tests against a DB.
GBRAIN_TEST_ALLOW_DATABASE_URL: '1'
run: bun run test:heavy
# Agent-bootstrap offline Docker e2e [A7] — placeholder registration.
+1 -1
View File
@@ -1,4 +1,4 @@
<!-- gbrain-runbook-stamp: 0.45.18.0 -->
<!-- gbrain-runbook-stamp: 0.45.16.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. -->
-73
View File
@@ -2,79 +2,6 @@
All notable changes to GBrain will be documented in this file.
## [0.45.18.0] - 2026-08-15
**Today's agent spend now reads correctly at every hour, in every timezone.** The admin spend endpoint computed "today" against a naive timestamp that each database session reinterpreted in its own timezone — on any non-UTC session (a PGLite brain following the host clock, a timezone-configured Postgres role), the day boundary shifted by the offset and every evening's spend silently underreported as 0. The boundary is now a UTC instant, independent of session timezone, pinned by a regression test that exercises sessions 12 hours either side of UTC at any wall-clock hour.
The same class also made the new test-suite snapshot fixture time-of-day flaky: the snapshot bakes the build machine's timezone into the restored cluster, so snapshot-restored engines ran sessions in the builder's zone while cold-init engines followed the running process. Restored engines now re-pin their session to the runtime zone (existing tarballs heal without a rebuild), the snapshot builder pins UTC so tarballs are deterministic across hosts, and a parity test asserts cold and snapshot engines agree on their UTC offset.
### Fixed
- `/admin/api/agents/spend`: `spent_cents_today` no longer underreports on non-UTC sessions (UTC-instant day boundary).
- Snapshot-restored PGLite engines behave identically to cold-init engines regardless of the machine that built the tarball.
## [0.45.17.0] - 2026-08-15
**A test run can no longer silently touch a real brain.** `gbrain init` writes your
database URL into `~/.gbrain/.env`; anyone who had that sourced and ran a bare
`bun test` in the repo was one destructive fixture away from their own data
(#3485 — it has happened). Four independent layers now stand in the way, and
each one fails loudly instead of silently skipping:
- **The run refuses to start.** A test preload (registered first in
`bunfig.toml`) hard-fails any `bun test` invocation while `DATABASE_URL` or
`GBRAIN_DATABASE_URL` is ambient, with instructions — it never silently
unsets, because a silent unset would turn database-gated e2e tests into
green skips. The e2e and heavy lanes opt in at their own boundary;
the unit and slow lanes strip the variables at theirs, so
`bun run test:full` with a database URL exported still reaches its e2e leg.
- **Destructive tests check the database name.** Every test that runs
destructive SQL against the ambient URL now calls a shared name floor
(moved to a leaf module so unit-directory tests can use it too): the
database name must carry "test" as a word segment, or be opted in
explicitly, one-shot. (One suite keeps its own equivalent inline floor,
pinned by the coverage gate.) This adopts the patch contributed in #3485 by
@cheRoma — thank you — extended to two newer files the original audit
predates and one raw-client suite it couldn't see.
- **Shell lanes get the same floor.** The heavy-test scripts (schema drops,
parallel syncs, migration replays) share a floor that checks BOTH database
URL variables and strips query strings before extracting the name, so a
`?host=/tmp/test-sockets` parameter can't smuggle a test-shaped segment
past it.
- **A repo-wide static gate keeps it that way.** A scanner walks every test
file bun would collect (all naming patterns, fixtures included), flags any
file that reads an ambient database URL, opens a connection, and runs
destructive SQL without a guard — and its own classifiers are pinned by
positive controls so the gate can never rot into passing vacuously.
### Added
- Test-run guard preload (`test/helpers/database-url-guard-preload.ts`) with
subprocess tests covering every branch: both variables, both-set, override,
strict override value, empty-string, and clean runs.
- Shared destructive-SQL name floor `test/helpers/db-guard.ts` (re-exported
from `test/e2e/helpers.ts` for existing call sites) and shell twin
`tests/heavy/_db_floor.sh`.
- Repo-wide destructive-SQL coverage gate `test/db-guard-coverage.test.ts`
with classifier self-tests and positive controls.
### Fixed
- Ten destructive test files now verify the database name before connecting
(#3485; patch by @cheRoma, extended).
- The heavy lane's fixture builder, sync-lock, upgrade-matrix, and wallclock
scripts refuse non-test-shaped database names instead of operating on
whatever the environment points at.
- The phantom-redirect engine-parity test's Postgres arm is now carried by
the e2e lane and CI's parity job — previously no lane could reach it.
### To take advantage of v0.45.17.0
Nothing to configure. If a bare `bun test` now refuses to start, the message
tells you exactly why and what to do — usually just unset the database URL
(unit tests need no database) or use `bun run test:e2e`, which opts in at its
own boundary. If your e2e database has a non-test-shaped name, opt in one-shot
with `GBRAIN_E2E_ALLOW_DB=<name>` rather than exporting it in your shell
profile — a permanent export would disarm the guard for exactly the database
it protects.
## [0.45.16.0] - 2026-08-14
**Fix wave W0: the verified-bug hotfix pass of the code-smell series.** A 10-auditor sweep of the codebase produced 122 findings; the top claims were adversarially verified, and this release fixes every verified live bug — the ones that survived the skeptic pass. Long-running brains get the biggest wins: background cycles can no longer silently run twice, dead background jobs no longer strand their parents, and image search no longer silently degrades after re-embedding. Developers get a test suite that runs 10x faster.
-14
View File
@@ -115,20 +115,6 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5434/gbrain_test bun run t
DATABASE_URL=postgresql://... bun run test:e2e
```
Heads-up: a bare `bun test` refuses to start while `DATABASE_URL` or
`GBRAIN_DATABASE_URL` is set in your environment — some tests run destructive
SQL against whatever those URLs point at. Unset the variable for unit runs
(they need no database) or use the wrappers: the unit/slow runners strip the
variables at their boundary, and `bun run test:e2e` opts in at its own. The
refusal message walks you through it; details in
[`docs/TESTING.md`](docs/TESTING.md) ("Database-URL run guard"). If you point
`bun run test:e2e` at your own Postgres or Supabase, a second floor applies:
the database name must carry "test" as a word segment (like `gbrain_test`
above) or destructive tests refuse to run — opt a differently-named database
in one-shot with `GBRAIN_E2E_ALLOW_DB=<name>`.
Use `bun run verify` before pushing. It runs 19+ guard checks in parallel
Use `bun run verify` before pushing. It runs 40+ guard checks in parallel
(`scripts/run-verify-parallel.sh`), including: banned fork-name leaks
(`scripts/check-privacy.sh`), `JSON.stringify(x)::jsonb` interpolation
-12
View File
@@ -1,17 +1,5 @@
# TODOS
## Security-process follow-ups (filed with Wave 1 of the fix-wave campaign, 2026-08-14)
- [ ] **P2 — Vulnerability disclosure policy.** **What:** a written disclosure
process: advisory ownership, severity ladder, reporter acknowledgment SLA,
embargo windows, private patch review, supported-version/backport policy,
release timing, post-release rotation guidance. **Why:** private vulnerability
reporting is now enabled (#579) and a reporter has a channel, but a channel
without a process leaves triage decisions ad-hoc; a public PR diff can still
broadcast attack surface mid-embargo. **Context:** filed from the fix-wave
campaign's Codex review (CX-11); the campaign deliberately shipped only the
toggle + reporter acknowledgment. Start from the responsible-disclosure rules
already in CLAUDE.md and docs/RELEASING.md. **Effort:** M. **Priority:** P2.
## Code-smell fix-wave deferrals (filed at W0; plan: ~/.claude/plans/system-instruction-you-are-working-encapsulated-eclipse.md)
Each was individually decided as a deferral in the CEO/eng reviews of the
+1 -1
View File
@@ -1 +1 @@
0.45.18.0
0.45.16.0
+1 -5
View File
@@ -23,8 +23,4 @@ timeout = 60_000
# were appending rows into the operator's real ~/.gbrain/sync-failures.jsonl,
# which `gbrain doctor` reads and warns on. See
# test/helpers/sync-failures-preload.ts.
# #3485: database-url-guard-preload runs FIRST — it refuses to start the run
# while DATABASE_URL/GBRAIN_DATABASE_URL is ambient without the explicit
# GBRAIN_TEST_ALLOW_DATABASE_URL=1 opt-in that the e2e wrappers set at their
# own subprocess boundary. See test/helpers/database-url-guard-preload.ts.
preload = ["./test/helpers/database-url-guard-preload.ts", "./test/helpers/legacy-embedding-preload.ts", "./test/helpers/audit-dir-preload.ts", "./test/helpers/sync-failures-preload.ts"]
preload = ["./test/helpers/legacy-embedding-preload.ts", "./test/helpers/audit-dir-preload.ts", "./test/helpers/sync-failures-preload.ts"]
+2 -31
View File
@@ -132,7 +132,7 @@ Triage rule: a `warn-pass` EXIT-HANG line in `.context/test-summary.txt` is NOT
- `*.test.ts` → fast loop (parallel up-to-4-shard fan-out, memory-adaptive).
- `*.slow.test.ts` → run via `bun run test:slow` only (intentional cold-path tests; would dominate the fast loop's wallclock).
- `*.serial.test.ts` → run via `bun run test:serial` after the parallel pass completes; one bun process per file (`--max-concurrency=1` within a shared process is not enough — the module registry still leaks `mock.module`). Quarantine for tests that share file-wide state and race when run alongside other files in the same `bun test` process. Several dozen files, discovered by the `*.serial.test.ts` glob — no list to maintain. Typical residents: `mock.module(...)` users (top-level mocks leak across files in a shard process, e.g. `test/embed.serial.test.ts`), env-coupled files (e.g. `test/brain-registry.serial.test.ts`), and process-lifecycle suites that assert on `process.exitCode` (e.g. `test/pglite-engine-disconnect.serial.test.ts`). **Do not put the parallelism back on a serial file unless you've fixed the contention root cause** (it just re-introduces the flake).
- `test/e2e/*.test.ts` → real-Postgres E2E. Skipped when `DATABASE_URL` is unset. One out-of-directory file rides this lane: `test/phantom-redirect-engine-parity.test.ts` (lives in `test/` for its PGLite arm, but its Postgres arm is only reachable through a DATABASE_URL-bearing lane — the unit wrappers strip the URL per #3485, so `run-e2e.sh`'s no-args list and CI's parity job carry it).
- `test/e2e/*.test.ts` → real-Postgres E2E. Skipped when `DATABASE_URL` is unset.
- `tests/heavy/*.sh` → ops-shape shell scripts. Cost minutes per run; NOT in default `bun test`. Run via `bun run test:heavy` or scheduled nightly via `.github/workflows/heavy-tests.yml`. Examples: pg_upgrade matrix (boot legacy brain → walk to head), RSS budget gate (measure peak worker RSS vs committed baseline), read-latency-under-sync (p50/p95/p99 under concurrent writer load), sync lock regression (N concurrent syncs assert 1 winner + N-1 lock-busy + zero leaked `gbrain_cycle_locks` rows). See `tests/heavy/README.md` for when to add a script here vs `*.slow.test.ts`. Files prefixed with `_` (e.g. `tests/heavy/_build_legacy_fixtures.sh`) are helpers/libs invoked by sibling tests — the runner skips them.
- `test/fuzz/*.test.ts` → property-based fuzz harness. Pure-validator targets in `pure-validators.test.ts` are guarded by `scripts/check-fuzz-purity.sh` (in `bun run verify`), which `bun build --target=bun` bundles each target and greps the resulting bundle for banned transitive imports (`node:fs`, `node:child_process`, engine modules). Anything that fails the guard moves to `mixed-validators.test.ts` (still property-tested, but no purity guarantee) or `filesystem-validators.test.ts` (fs-backed, uses temp dirs). Fuzz tests run in the default `bun test` loop because they're fast (~3s for ~12 properties × 1000 runs each).
@@ -220,35 +220,6 @@ The quarantine has grown to dozens of files — treat it as debt: every addition
`bun test` runs all tests without a database. E2E tests skip gracefully when `DATABASE_URL` is not set.
**Database-URL run guard (#3485).** A `bun test` invocation REFUSES to start while
`DATABASE_URL` or `GBRAIN_DATABASE_URL` is ambient in the environment, because some
tests run destructive SQL against whatever those URLs point at (a bare `bun test`
with `~/.gbrain/.env` sourced has wiped a real brain). The guard is a bunfig
`[test]` preload (`test/helpers/database-url-guard-preload.ts`); it hard-fails with
instructions rather than silently unsetting (a silent unset would turn
DATABASE_URL-gated e2e tests into green skips). The e2e wrappers
(`scripts/run-e2e.sh`, the e2e/heavy workflows) opt in at their own boundary via
`GBRAIN_TEST_ALLOW_DATABASE_URL=1`; the unit/slow wrappers instead strip both
URL vars at their boundary (unit tests need no database), which keeps
`bun run test:full` working with DATABASE_URL exported. Caveat: bun loads
`bunfig.toml` from the invocation cwd, so the preload layer only applies to
runs started at the repo root — the per-file name floor below is the layer
that doesn't care about cwd. Two more layers apply after the opt-in: every
test that runs destructive SQL on the ambient URL must call
`assertSafeE2eDatabaseUrl()` (`test/helpers/db-guard.ts` — name floor: the database
name must contain "test" as a segment, or be opted in via `GBRAIN_E2E_ALLOW_DB`)
or carry an inline name floor the coverage gate recognizes
(`test/e2e/schema-drift.test.ts` keeps its own `looksLikeTestDb`, deliberately
different because it also accepts `*_e2e`), and `test/db-guard-coverage.test.ts`
statically scans the suite and fails when a file connects to `DATABASE_URL` and
runs destructive SQL unguarded. The heavy shell lane gets the same floor outside
bun: `tests/heavy/_db_floor.sh` (sourced by `scripts/run-heavy.sh` for the whole
lane, and by each database-touching heavy script itself, since scripts are
documented for direct invocation — the PGLite-based heavy scripts unset the URL
instead) checks BOTH URL variables and strips query strings before extracting
the database name, so a `?host=/tmp/test-sockets` parameter can't smuggle a
test-shaped segment past it.
Unit tests and what they cover:
- `test/markdown.test.ts` — frontmatter parsing; `splitBody` sentinel precedence, horizontal-rule preservation, `inferType` wiki subtypes.
@@ -364,7 +335,7 @@ Unit tests and what they cover:
### E2E test inventory
E2E tests live in `test/e2e/` and run against real Postgres+pgvector (require `DATABASE_URL`), except where noted as PGLite in-memory (no `DATABASE_URL` needed). One file outside the directory also rides the e2e lane: `test/phantom-redirect-engine-parity.test.ts` (Postgres arm; see the file taxonomy above).
E2E tests live in `test/e2e/` and run against real Postgres+pgvector (require `DATABASE_URL`), except where noted as PGLite in-memory (no `DATABASE_URL` needed).
- `bun run test:e2e` runs Tier 1 (mechanical, all operations, no API keys). Includes dedicated cases for the postgres-engine `addLinksBatch` / `addTimelineEntriesBatch` bind path — postgres-js's JSONB bind (`jsonb_to_recordset(($1::jsonb)->'rows')`) differs from PGLite's and gets its own coverage.
- `test/e2e/search-quality.test.ts` — search quality against PGLite (no API keys, in-memory).
+1 -1
View File
@@ -339,7 +339,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this
- `scripts/build-pglite-snapshot.ts``bun run build:pglite-snapshot`: bakes a post-`initSchema()` PGLite data dir into `test/fixtures/pglite-snapshot.tar` + a version file (schema hash line, then `dims=`/`model=` lines recording the embedding shape it was baked with). Idempotent (hash short-circuit ~40ms when fresh; rebuilds stale) and concurrency-safe (atomic `mkdir` lock at `test/fixtures/.pglite-snapshot.lock` with staleness-verified takeover — a live lock is never stolen; tar written first, version file last, so a crash can't leave a fresh-looking torn fixture; waiter bounded by `GBRAIN_SNAPSHOT_LOCK_TIMEOUT_MS`, default 120000; an exhausted waiter facing a still-live lock proceeds unlocked as a last resort — the loader's hash/shape gate validates the version file, not the tar bytes). Called by `scripts/run-unit-parallel.sh` (default-on for `bun run test`; opt out `GBRAIN_NO_SNAPSHOT=1`) and `scripts/ci-local.sh`, both of which export `GBRAIN_PGLITE_SNAPSHOT`. The loader side is `tryLoadSnapshot` + `computeSnapshotSchemaHash` (exported from `src/core/pglite-engine.ts`): the hash folds `PGLITE_SCHEMA_SQL`, every migration's `sql` + `sqlFor.pglite`, AND each migration `handler`'s function source (handler-only migrations are invisible to a sql-only hash); any hash or embedding-shape mismatch warns once and falls through to normal cold init — the snapshot is an optimization, never authoritative. Pinned by `test/snapshot-shape-guard.test.ts`.
- `docker-compose.ci.yml` + `scripts/ci-local.sh` — Local CI gate. `bun run ci:local` spins up four `pgvector/pgvector:pg16` services (postgres-1..4) + `oven/bun:1` with named volumes (`gbrain-ci-pg-data-{1..4}`, `gbrain-ci-node-modules`, `gbrain-ci-bun-cache`), runs gitleaks on host, smoke-tests `scripts/run-e2e.sh` argv handling, runs guards + typecheck, then the Tier 1 default: 4-shard parallel unit + E2E (`xargs -P4`, one Postgres per shard; unit phase keeps `DATABASE_URL` unset). `--no-shard` falls back to the legacy unsharded sequential flow (debug aid); `--diff` runs the diff-aware selector unsharded. Also runs a `pgbouncer` service (`edoburu/pgbouncer`, `POOL_MODE: transaction`, `AUTH_TYPE: plain` — pg16 stores SCRAM verifiers, so the userlist must hold the plaintext password; `IGNORE_STARTUP_PARAMETERS` whitelists gbrain's `statement_timeout`/`idle_in_transaction_session_timeout` startup params the way the Supabase pooler does) fronting postgres-1 on host port `GBRAIN_CI_PGBOUNCER_PORT` (default 6543); every E2E invocation exports `GBRAIN_PGBOUNCER_URL` (pooled; dedicated `gbrain_pgbouncer` database so it never races the `gbrain_test` TRUNCATE fixtures) + `GBRAIN_PGBOUNCER_DIRECT_URL`, consumed by `test/e2e/pgbouncer-teardown.test.ts` — the transaction-mode teardown bug class (#1972/#2015/#2084) reproduced in the local gate instead of only in production. `--no-pull` skips upstream pulls; `--clean` nukes named volumes. Postgres host port defaults to 5434; override with `GBRAIN_CI_PG_PORT=NNNN`. Stronger gate than PR CI's 2-file Tier 1 set.
- `scripts/select-e2e.ts` + `scripts/e2e-test-map.ts` — Diff-aware E2E test selector. Reads three git sources (committed `origin/master...HEAD`, working-tree `HEAD`, and `git ls-files --others --exclude-standard` for untracked NOT-gitignored files), classifies as EMPTY / DOC_ONLY / SRC. Fail-closed: EMPTY → all files; DOC_ONLY (every path matches the README/CLAUDE/AGENTS/CHANGELOG/TODOS allowlist) → empty stdout; SRC → escape-hatch paths (schema, package.json, skills/) trigger all, else the hand-tuned `E2E_TEST_MAP` glob narrows, and an unmapped src/ change still emits ALL files (never silently nothing). Pure-function exports `selectTests`, `classify`, `matchGlob`. `bun run ci:select-e2e` prints the current selection on stdout. `test/select-e2e.test.ts` covers all 4 branches plus 3 regression guards (skills/, untracked files, unmapped src/) — 24 cases.
- `scripts/run-e2e.sh` — Sequential E2E runner. Accepts an optional argv-driven file list (used by `ci:local:diff`) and a `--dry-run-list` flag that prints the resolved file list and exits (used by `ci-local.sh`'s startup smoke-test). Falls back to `test/e2e/*.test.ts` plus `test/phantom-redirect-engine-parity.test.ts` when invoked with no args (the phantom-redirect Postgres arm is only reachable through a DATABASE_URL-bearing lane; the unit wrappers strip the URL per #3485, so this lane must carry it). This wrapper is the database-URL opt-in boundary (#3485): it exports `GBRAIN_TEST_ALLOW_DATABASE_URL=1` so the bunfig preload guard (`test/helpers/database-url-guard-preload.ts`) lets the run start, unsets `GBRAIN_DATABASE_URL` (the e2e suite runs on `DATABASE_URL` only — an ambient `GBRAIN_DATABASE_URL` would pass the opt-in yet reach CLI-subprocess paths with no name floor), and its GBRAIN_* env scrub preserves `GBRAIN_E2E_ALLOW_DB` so the name-floor escape hatch the guard's own error message names stays usable.
- `scripts/run-e2e.sh` — Sequential E2E runner. Accepts an optional argv-driven file list (used by `ci:local:diff`) and a `--dry-run-list` flag that prints the resolved file list and exits (used by `ci-local.sh`'s startup smoke-test). Falls back to `test/e2e/*.test.ts` when invoked with no args.
- `scripts/llms-config.ts` + `scripts/build-llms.ts` — Generator for `llms.txt` (llmstxt.org-spec web index) + `llms-full.txt` (inlined single-fetch bundle). Curated config drives both. Run `bun run build:llms` after adding a new doc. `LLMS_REPO_BASE` env lets forks regenerate with their own URL base. `FULL_SIZE_BUDGET` (600KB) caps the inline bundle; generator WARNs if exceeded. Committed output has no runtime consumer; committed for GitHub browsing and fork-safe fetching.
- `AGENTS.md` — Local-clone entry point for non-Claude agents (Codex, Cursor, OpenClaw, Aider). Mirrors `CLAUDE.md` intent via relative links. Claude Code keeps using `CLAUDE.md`.
- `docs/UPGRADING_DOWNSTREAM_AGENTS.md` — Patches for downstream agent skill forks to apply when upgrading. Each release appends a new section; includes diffs for brain-ops, meeting-ingestion, signal-detector, enrich.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "gbrain-context-engine",
"name": "gbrain",
"version": "0.45.18.0",
"version": "0.45.16.0",
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
"family": "bundle-plugin",
"configSchema": {
+1 -1
View File
@@ -156,7 +156,7 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.45.18.0",
"version": "0.45.16.0",
"overrides": {
"@hono/node-server": "^2.0.5",
"fast-uri": "^3.1.5",
-7
View File
@@ -1,13 +1,6 @@
#!/usr/bin/env bun
// scripts/build-pglite-snapshot.ts
//
// TZ pinned to UTC BEFORE any PGLite work: dumpDataDir bakes this process's
// TimeZone into the tar's cluster defaults. Building under the host zone made
// restored engines run sessions in the build machine's zone (the engine also
// re-pins at restore — this is the belt to that suspender, and it keeps any
// OTHER zone-derived state baked into the tar deterministic across hosts).
process.env.TZ = 'UTC';
//
// Tier 3 fast-restore: boot a fresh PGLite, run the full initSchema (forward
// bootstrap + PGLITE_SCHEMA_SQL + every migration), dump the post-init state
// to a tar fixture. Test files that read GBRAIN_PGLITE_SNAPSHOT can skip the
+1 -18
View File
@@ -36,16 +36,6 @@ set -euo pipefail
cd "$(dirname "$0")/.."
# #3485: this wrapper IS the e2e boundary — opt in to running with a database
# URL present. The bunfig test preload (database-url-guard-preload.ts) refuses
# bare `bun test` runs while DATABASE_URL/GBRAIN_DATABASE_URL is ambient; the
# per-file name floor (test/helpers/db-guard.ts) still applies after this.
export GBRAIN_TEST_ALLOW_DATABASE_URL=1
# The e2e suite runs on DATABASE_URL only; an ambient GBRAIN_DATABASE_URL
# would pass the opt-in yet reach CLI-subprocess paths with no name floor —
# drop it here so only the floored variable crosses the boundary.
unset GBRAIN_DATABASE_URL
# --- HOME isolation: snapshot real user config before switching ---
# Tolerate unset HOME (minimal containers, exotic CI shells) without tripping set -u.
REAL_HOME="${HOME:-/tmp}"
@@ -93,10 +83,6 @@ mkdir -p "$E2E_TMP_HOME/.gbrain"
for _e2e_var in $(env | grep -oE '^(CONDUCTOR_|MCP_|OPENCLAW_|HERMES_|GBRAIN_)[A-Za-z0-9_]*' | sort -u); do
case "$_e2e_var" in
GBRAIN_HOME) ;; # required for HOME isolation (set above) — keep
GBRAIN_TEST_ALLOW_DATABASE_URL) ;; # #3485 preload opt-in (set above) — keep
GBRAIN_E2E_ALLOW_DB) ;; # #3485 name-floor opt-in — the guard's own error
# message tells operators to set it; stripping it
# here would make that escape hatch a dead end
*) unset "$_e2e_var" || true ;;
esac
done
@@ -113,10 +99,7 @@ fi
if [ "$#" -gt 0 ]; then
files=("$@")
else
# phantom-redirect lives in test/ (its PGLite arm runs in the unit suite) but
# its Postgres arm is only reachable through a DATABASE_URL-bearing lane —
# the unit wrappers strip the URL (#3485), so this lane must carry it.
files=(test/e2e/*.test.ts test/phantom-redirect-engine-parity.test.ts)
files=(test/e2e/*.test.ts)
fi
# SHARD env (e.g. SHARD=1/4) keeps every M-th file starting at index N (1-indexed).
-4
View File
@@ -16,10 +16,6 @@
set -euo pipefail
cd "$(dirname "$0")/.."
# #3485: the heavy lane runs destructive shell scripts against DATABASE_URL —
# apply the shared name floor once here for every script it dispatches.
source tests/heavy/_db_floor.sh
PATTERN="${1:-}"
heavy_files=()
-5
View File
@@ -4,11 +4,6 @@
# CI runs both; bun run ci:local skips slow tests via run-unit-shard.sh.
set -euo pipefail
# #3485: unit/slow 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.
unset DATABASE_URL GBRAIN_DATABASE_URL
cd "$(dirname "$0")/.."
slow_files=()
-5
View File
@@ -44,11 +44,6 @@
set -uo pipefail
# #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.
unset DATABASE_URL GBRAIN_DATABASE_URL
cd "$(dirname "$0")/.."
# ──────────────────────────────────────────────────────────────────────────
-5
View File
@@ -14,11 +14,6 @@
set -euo pipefail
# #3485: unit/slow 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.
unset DATABASE_URL GBRAIN_DATABASE_URL
cd "$(dirname "$0")/.."
# --max-concurrency=N is forwarded to `bun test`. v0.26.4: invoked by
+1 -7
View File
@@ -551,13 +551,7 @@ export async function queryAgentClientSpend(engine: BrainEngine): Promise<AgentC
SELECT SUM(spend_cents)::text
FROM mcp_spend_log
WHERE client_id = c.client_id
-- Double AT TIME ZONE: the inner one yields NAIVE UTC-midnight;
-- the outer one converts it back to a timestamptz INSTANT. Without
-- it, the naive value is reinterpreted in the SESSION timezone, so
-- any non-UTC session (host-tz PGLite, a tz-configured Postgres
-- role) shifts the day boundary by the offset and today's spend
-- underreports every evening.
AND created_at >= date_trunc('day', now() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'
AND created_at >= date_trunc('day', now() AT TIME ZONE 'UTC')
), '0') AS spent_cents_today,
COALESCE((
SELECT SUM(estimated_cents)::text
-12
View File
@@ -616,18 +616,6 @@ export class PGLiteEngine implements BrainEngine {
...embedded,
}),
);
// Snapshot-timezone parity: dumpDataDir bakes the BUILD process's
// TimeZone into the restored cluster's defaults, so a snapshot-loaded
// engine would run sessions in the build machine's zone while a
// cold-init engine follows this process (bun test pins TZ=UTC; bun run
// follows the host). That divergence shifted every naive-timestamp
// day-boundary comparison by the offset — date-dependent tests failed
// only in the evening, only under the snapshot. Pin the session to the
// RUNTIME zone so restored engines behave exactly like cold ones.
if (this._snapshotLoaded && this._db) {
const runtimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
await this._db.query(`SELECT set_config('TimeZone', $1, false)`, [runtimeZone]);
}
// Healthy open: close any repair episode left open by a prior failed
// attempt (red-team: episodes otherwise stayed open forever — doctor
// kept reporting corruption-likely and a weeks-stale episode backup
+1 -1
View File
@@ -1,6 +1,6 @@
# gbrain agent workspace — template
<!-- gbrain-template-stamp: 0.45.18.0 -->
<!-- gbrain-template-stamp: 0.45.16.0 -->
This repository is the **"Use this template"** distribution artifact for a
[gbrain](https://github.com/garrytan/gbrain) personal-agent workspace — the same
-35
View File
@@ -150,41 +150,6 @@ describe('queryAgentClientSpend (v0.38 Slice 4 — /admin/api/agents/spend SQL)'
expect(rows[0].spent_cents_today).toBe(50);
});
it('day boundary is a UTC INSTANT, independent of the session timezone', async () => {
// Regression pin for the snapshot-timezone incident: the old predicate
// compared created_at against a NAIVE date_trunc result, which the
// session timezone reinterpreted — a non-UTC session (host-tz PGLite,
// tz-configured Postgres role, snapshot-restored engine pre-parity-fix)
// shifted the day boundary by its offset and underreported evening spend.
//
// Deterministic at ANY wall-clock hour: rows exactly AT UTC midnight and
// 1s BEFORE it must classify identically under sessions ±12h from UTC.
// Under the old predicate, Etc/GMT+12 excluded the midnight row and
// Etc/GMT-12 included the pre-midnight row — one of the two always broke.
await seedClient({ id: 'tz-edge', scope: 'read agent' });
await engine.executeRaw(
`INSERT INTO mcp_spend_log (client_id, operation, spend_cents, created_at)
VALUES
('tz-edge', 'subagent_loop', 7,
date_trunc('day', now() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'),
('tz-edge', 'subagent_loop', 999,
(date_trunc('day', now() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC') - interval '1 second')`,
);
const original = (await engine.executeRaw<{ TimeZone: string }>(`SHOW timezone`))[0].TimeZone;
for (const zone of ['Etc/GMT+12', 'Etc/GMT-12', 'UTC']) {
await engine.executeRaw(`SELECT set_config('TimeZone', '${zone}', false)`);
try {
const rows = await queryAgentClientSpend(engine);
const edge = rows.find(r => r.client_id === 'tz-edge')!;
// Only the exactly-at-midnight row (7¢) counts as today — never the
// 1s-before row (999¢) — regardless of session zone.
expect(`${zone}:${edge.spent_cents_today}`).toBe(`${zone}:7`);
} finally {
await engine.executeRaw(`SELECT set_config('TimeZone', $1, false)`, [original]);
}
}
});
it('isolates spend by client_id (no cross-client leakage)', async () => {
await seedClient({ id: 'alice', scope: 'read agent' });
await seedClient({ id: 'bob', scope: 'read agent' });
-103
View File
@@ -1,103 +0,0 @@
/**
* #3485 invocation-level guard: a bare `bun test` must refuse to start while
* DATABASE_URL or GBRAIN_DATABASE_URL is ambient, unless the invoker opted in
* with GBRAIN_TEST_ALLOW_DATABASE_URL=1 (the e2e wrappers do, at their own
* subprocess boundary).
*
* Each case spawns a real `bun test` child on a trivial probe fixture with a
* controlled environment, so what's asserted is the actual preload behavior,
* not a re-implementation of it. The child inherits this repo's bunfig.toml
* (cwd = repo root), which registers the guard preload first.
*/
import { describe, test, expect } from 'bun:test';
import { resolve } from 'path';
const REPO_ROOT = resolve(import.meta.dir, '..');
const PROBE = 'test/fixtures/preload-guard/guard-probe.test.ts';
const GUARD_MARKER = 'TEST-RUN GUARD: refusing to start';
/** Base child env: inherit, then strip every var the guard reads. */
function baseEnv(): Record<string, string> {
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(process.env)) {
if (v === undefined) continue;
if (k === 'DATABASE_URL' || k === 'GBRAIN_DATABASE_URL' || k === 'GBRAIN_TEST_ALLOW_DATABASE_URL') continue;
env[k] = v;
}
return env;
}
function runProbe(extra: Record<string, string>): { exitCode: number; stderr: string } {
const proc = Bun.spawnSync(['bun', 'test', '--timeout=15000', PROBE], {
cwd: REPO_ROOT,
env: { ...baseEnv(), ...extra },
stdout: 'pipe',
stderr: 'pipe',
// A hung child would otherwise block the sync call past bun's own
// per-test timeout (which cannot preempt a native sync call).
timeout: 20_000,
killSignal: 'SIGKILL',
});
return { exitCode: proc.exitCode ?? -1, stderr: proc.stderr.toString() };
}
describe('database-url-guard-preload (#3485)', () => {
test('refuses when DATABASE_URL is set', () => {
const r = runProbe({ DATABASE_URL: 'postgresql://localhost:5434/gbrain' });
expect(r.exitCode).not.toBe(0);
expect(r.stderr).toContain(GUARD_MARKER);
expect(r.stderr).toContain('DATABASE_URL');
}, 30_000);
test('refuses when GBRAIN_DATABASE_URL is set', () => {
const r = runProbe({ GBRAIN_DATABASE_URL: 'postgresql://localhost:5434/gbrain' });
expect(r.exitCode).not.toBe(0);
expect(r.stderr).toContain(GUARD_MARKER);
expect(r.stderr).toContain('GBRAIN_DATABASE_URL');
}, 30_000);
test('refuses and names both when both are set', () => {
const r = runProbe({
DATABASE_URL: 'postgresql://localhost:5434/gbrain',
GBRAIN_DATABASE_URL: 'postgresql://localhost:5434/gbrain',
});
expect(r.exitCode).not.toBe(0);
expect(r.stderr).toContain(GUARD_MARKER);
expect(r.stderr).toContain('DATABASE_URL and GBRAIN_DATABASE_URL are set');
}, 30_000);
test('allows with GBRAIN_TEST_ALLOW_DATABASE_URL=1', () => {
const r = runProbe({
DATABASE_URL: 'postgresql://localhost:5434/gbrain',
GBRAIN_TEST_ALLOW_DATABASE_URL: '1',
});
expect(r.stderr).not.toContain(GUARD_MARKER);
expect(r.exitCode).toBe(0);
}, 30_000);
test('runs clean with no database URL ambient', () => {
const r = runProbe({});
expect(r.stderr).not.toContain(GUARD_MARKER);
expect(r.exitCode).toBe(0);
}, 30_000);
test('refuses truthy-but-wrong override values (strict === "1")', () => {
// A future loosening to Boolean(process.env...) must fail this test:
// only the exact string '1' opts in.
const r = runProbe({
DATABASE_URL: 'postgresql://localhost:5434/gbrain',
GBRAIN_TEST_ALLOW_DATABASE_URL: 'true',
});
expect(r.exitCode).not.toBe(0);
expect(r.stderr).toContain(GUARD_MARKER);
}, 30_000);
test('treats empty-string values as unset (v !== "" branch)', () => {
// `export DATABASE_URL=` (empty) is a plausible dev state after a manual
// unset attempt; the guard deliberately does not refuse on it — an empty
// string cannot name a database to destroy.
const r = runProbe({ DATABASE_URL: '', GBRAIN_DATABASE_URL: '' });
expect(r.stderr).not.toContain(GUARD_MARKER);
expect(r.exitCode).toBe(0);
}, 30_000);
});
-249
View File
@@ -1,249 +0,0 @@
/**
* Coverage gate for the destructive-SQL guard.
*
* Any test file that connects a PostgresEngine to DATABASE_URL can TRUNCATE or
* DELETE from whatever brain that URL points at. setupDB() calls
* assertSafeE2eDatabaseUrl() before it does so; files that connect directly
* bypass that check unless they call the guard themselves.
*
* This test scans the suite and fails when a file connects to DATABASE_URL
* without either going through setupDB() or calling the guard. Static scan
* only: no database connection, no DATABASE_URL required.
*
* KNOWN BLIND SPOTS (documented, not silent): a test that only passes the
* ambient URL into a SPAWNED subprocess (gbrain CLI) has no in-file connect
* idiom and is invisible here the preload guard and the spawned CLI's own
* config path are the layers that cover it. Destructive helpers living in
* non-test files (other than setupDB) are likewise unscanned.
*/
import { describe, test, expect } from 'bun:test';
import { readdirSync, readFileSync } from 'fs';
import { join, resolve, relative } from 'path';
// Walk from the REPO root, not test/: a bare `bun test` at the repo root
// discovers *.test.ts repo-wide (evals/, examples/, …), so a test/-only scan
// would be a permanent blind spot for destructive tests outside test/.
const REPO_ROOT = resolve(import.meta.dir, '..');
function walk(dir: string, out: string[] = []): string[] {
// fixtures/ IS scanned: `bun test` discovers *.test.ts there too, so an
// exemption would be a blind spot. Only node_modules (never collected by
// bun) and .git are skipped.
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === '.git') continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
// Match every pattern bun test collects (.test/.spec/.bench and _test/_spec,
// all JS/TS extensions) — a destructive foo.spec.ts must not evade the scan.
// Skip this file itself: the classifier positive-control fixtures below
// are string literals that would otherwise scan as real test code.
else if (
/(\.(test|spec|bench)|_(test|spec))\.(ts|tsx|js|jsx|mjs|cjs)$/.test(entry.name) &&
full !== import.meta.path
) out.push(full);
}
return out;
}
/**
* Reads an ambient database URL AND constructs a connection path. Detection is
* at the ASSIGNMENT site (any binding name), not a connect-site identifier
* allowlist `const url = process.env.DATABASE_URL!; connect({ database_url:
* url })` and GBRAIN_DATABASE_URL spellings must not evade the scan
* (fail-closed: prefer flagging a guarded file over missing an unguarded one).
*/
/**
* Yields only non-comment lines: skips `//` lines, tracks multi-line block
* comments statefully (a block comment written WITHOUT leading `*` per line
* must still be excluded the fail-open direction), and JSDoc `*` lines.
*/
function* codeLines(src: string): Generator<string> {
let inBlock = false;
for (const line of src.split('\n')) {
if (inBlock) {
if (/\*\//.test(line)) inBlock = false;
continue;
}
if (/^\s*\/\*/.test(line)) {
if (!/\*\//.test(line)) inBlock = true;
continue;
}
if (/^\s*(\/\/|\*)/.test(line)) continue;
yield line;
}
}
function readsAmbientDatabaseUrl(src: string): boolean {
for (const line of codeLines(src)) {
// A `delete` of the env var is a scrub, not a read — files that explicitly
// REMOVE the ambient URL before building fixture engines must not be
// classified as connecting to it. (The regex is split from its match
// target here so the R1 isolation lint doesn't read this scanner's own
// pattern as a mutation.)
if (/delete\s+process\.env/.test(line)) continue;
if (/process\.env\.(GBRAIN_)?DATABASE_URL\b/.test(line)) return true;
// bracket notation: process.env['DATABASE_URL'] / ["GBRAIN_DATABASE_URL"]
if (/process\.env\[\s*['"`](GBRAIN_)?DATABASE_URL['"`]\s*\]/.test(line)) return true;
}
return false;
}
function connectsToAmbientDatabaseUrl(src: string): boolean {
if (!readsAmbientDatabaseUrl(src)) return false;
return /new\s+PostgresEngine\s*\(/.test(src)
|| /\bdb\.connect\s*\(/.test(src)
// engine-factory idiom (env-inferred postgres) and raw postgres.js clients
|| /\bcreateEngine\s*\(/.test(src)
|| /\bpostgres\s*\(\s*[A-Za-z_$'"`]/.test(src);
}
/** Runs SQL that destroys data or schema. initSchema() counts: it migrates. */
function runsDestructiveSql(src: string): boolean {
return /\b(TRUNCATE|DELETE\s+FROM|DROP\s+(TABLE|SCHEMA|INDEX|TRIGGER|FUNCTION|DATABASE|EXTENSION|OWNED)|ALTER\s+TABLE\s+\w+\s+DROP)\b/i.test(src)
|| /\.initSchema\s*\(/.test(src);
}
/**
* Guarded either by the shared helper, by setupDB() (which calls it), or by an
* inline db-name floor. schema-drift.test.ts uses the last form: its pattern is
* deliberately different from the shared one (it also accepts *_e2e), so it is
* recognized rather than rewritten.
*
* Comment-hardened: a guard token on a comment line (`// unlike setupDB() ...`)
* must NOT count this classifier is the exempting (fail-open) direction, so
* it only accepts tokens on non-comment lines. Residual limitation: a trailing
* comment after real code on the same line still counts that line.
*/
function isGuarded(src: string): boolean {
for (const line of codeLines(src)) {
if (/\b(assertSafeE2eDatabaseUrl|setupDB)\s*\(/.test(line)) return true;
if (/looksLikeTestDb/.test(line)) return true;
}
return false;
}
function scan(): { unguarded: string[]; guarded: string[] } {
const unguarded: string[] = [];
const guarded: string[] = [];
for (const file of walk(REPO_ROOT)) {
const src = readFileSync(file, 'utf-8');
if (!connectsToAmbientDatabaseUrl(src)) continue;
if (!runsDestructiveSql(src)) continue;
(isGuarded(src) ? guarded : unguarded).push(relative(REPO_ROOT, file));
}
return { unguarded, guarded };
}
describe('destructive-SQL guard coverage', () => {
test('every test that runs destructive SQL on DATABASE_URL is guarded', () => {
expect(scan().unguarded).toEqual([]);
});
test('positive control: the scan actually detects known destructive files', () => {
// Guards against regex rot: if connectsToAmbientDatabaseUrl or
// runsDestructiveSql stopped matching anything, the gate above would pass
// vacuously forever. These two files connect to the ambient URL, run
// destructive SQL, and call the guard — the scan must classify them so.
const { guarded } = scan();
expect(guarded).toContain('test/e2e/multimodal-postgres.test.ts');
expect(guarded).toContain('test/phantom-redirect-engine-parity.test.ts');
});
});
describe('scan classifiers (the gate must be able to fire)', () => {
test('connectsToAmbientDatabaseUrl matches every ambient spelling in the suite', () => {
const spellings = [
`engine = new PostgresEngine();\nawait engine.connect({ database_url: process.env.DATABASE_URL });`,
`const DATABASE_URL = process.env.DATABASE_URL;\nconst engine = new PostgresEngine();\nawait engine.connect({ database_url: DATABASE_URL! });`,
`const dbUrl = process.env.DATABASE_URL;\npg = new PostgresEngine();\nawait pg.connect({ database_url: dbUrl } as never);`,
// arbitrary binding name — the exact idiom the old allowlist missed
`const url = process.env.DATABASE_URL!;\nengine = new PostgresEngine();\nawait engine.connect({ database_url: url });`,
// GBRAIN_DATABASE_URL is honored by the runtime and must not evade the scan
`const u = process.env.GBRAIN_DATABASE_URL;\nconst engine = new PostgresEngine();\nawait engine.connect({ database_url: u });`,
// db.connect() path (no engine construction in the test file itself)
`const url = process.env.DATABASE_URL;\nawait db.connect({ database_url: url });`,
];
for (const src of spellings) expect(connectsToAmbientDatabaseUrl(src)).toBe(true);
});
test('connectsToAmbientDatabaseUrl ignores fixture-URL connections and non-connecting files', () => {
expect(
connectsToAmbientDatabaseUrl(
`const engine = new PostgresEngine();\nawait engine.connect({ database_url: fixtureUrl });`,
),
).toBe(false);
expect(
connectsToAmbientDatabaseUrl(`const url = process.env.DATABASE_URL; // read, no engine/connect`),
).toBe(false);
});
test('runsDestructiveSql matches each destructive arm and not plain reads', () => {
for (const src of [
'await engine.executeRaw(`TRUNCATE pages`);',
"await engine.executeRaw('DELETE FROM pages');",
'await engine.executeRaw(`DROP TABLE takes`);',
'await engine.executeRaw(`ALTER TABLE pages DROP COLUMN x`);',
'await engine.initSchema();',
]) {
expect(runsDestructiveSql(src)).toBe(true);
}
expect(runsDestructiveSql('await engine.executeRaw(`SELECT 1`);')).toBe(false);
});
test('isGuarded recognizes each accepted guard form and nothing else', () => {
expect(isGuarded('assertSafeE2eDatabaseUrl(url);')).toBe(true);
expect(isGuarded('await setupDB();')).toBe(true);
expect(isGuarded('if (!looksLikeTestDb(name)) return;')).toBe(true);
expect(isGuarded('// totally unguarded')).toBe(false);
});
test('isGuarded rejects comment-only guard mentions (fail-open hardening)', () => {
expect(isGuarded('// unlike setupDB() we connect directly')).toBe(false);
expect(isGuarded('/* assertSafeE2eDatabaseUrl( would go here */')).toBe(false);
expect(isGuarded(' * setupDB() runs SCHEMA_SQL — JSDoc mention')).toBe(false);
// multi-line block comment WITHOUT leading * per line — must not count
expect(isGuarded('/*\nsetupDB() would be wrong here\n*/\nconnect();')).toBe(false);
// real call on a code line still counts even with comments elsewhere
expect(isGuarded('// intro comment\nawait setupDB();')).toBe(true);
expect(isGuarded('/* block */\nawait setupDB();')).toBe(true);
});
test('readsAmbientDatabaseUrl catches bracket notation and skips scrubs', () => {
expect(
connectsToAmbientDatabaseUrl(
`const u = process.env['DATABASE_URL'];\nconst engine = new PostgresEngine();`,
),
).toBe(true);
// Fixture built by concatenation: the classifier must see the contiguous
// scrub statement, but the R1 isolation lint must not read this test's
// own fixture as a real env mutation.
const scrub = 'delete ' + 'process.env.';
expect(
connectsToAmbientDatabaseUrl(
`${scrub}DATABASE_URL;\n${scrub}GBRAIN_DATABASE_URL;\nconst e = await createEngine(cfg);`,
),
).toBe(false);
});
test('runsDestructiveSql catches the non-TABLE DROP arms', () => {
for (const src of [
'await sql.unsafe(`DROP TRIGGER IF EXISTS t ON oauth_tokens`);',
'await sql.unsafe(`DROP FUNCTION IF EXISTS f()`);',
"psqlish('DROP DATABASE gbrain');",
]) {
expect(runsDestructiveSql(src)).toBe(true);
}
});
test('an unguarded destructive source would be flagged end-to-end', () => {
const bad = [
`const engine = new PostgresEngine();`,
`await engine.connect({ database_url: process.env.DATABASE_URL });`,
`await engine.executeRaw('TRUNCATE pages');`,
].join('\n');
expect(connectsToAmbientDatabaseUrl(bad)).toBe(true);
expect(runsDestructiveSql(bad)).toBe(true);
expect(isGuarded(bad)).toBe(false);
});
});
@@ -25,7 +25,6 @@ import { loadCorpusPages } from '../helpers/bootstrap-corpus.ts';
import { runEmbedCore } from '../../src/commands/embed.ts';
import { hybridSearch } from '../../src/core/search/hybrid.ts';
import { runSchemaTransition } from '../../src/core/retrieval-upgrade-planner.ts';
import { assertSafeE2eDatabaseUrl } from '../helpers/db-guard.ts';
import { extractTakesFromPages } from '../../src/core/extract-takes-from-pages.ts';
import { configureGateway, resetGateway } from '../../src/core/ai/gateway.ts';
import {
@@ -283,7 +282,6 @@ describe.skipIf(!DATABASE_URL)('Postgres bootstrap verify (real Postgres)', () =
process.env.GBRAIN_HOME = root;
engine = new PostgresEngine();
assertSafeE2eDatabaseUrl(DATABASE_URL!);
await engine.connect({ database_url: DATABASE_URL! });
await engine.initSchema();
await addSource(engine, { id: 'workspace', localPath: join(ws, 'brain'), force: true });
@@ -20,7 +20,6 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PostgresEngine } from '../../src/core/postgres-engine.ts';
import { quoteIdentifier } from '../../src/core/search/embedding-column.ts';
import type { ResolvedColumn } from '../../src/core/types.ts';
import { assertSafeE2eDatabaseUrl } from '../helpers/db-guard.ts';
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
@@ -34,7 +33,6 @@ if (!dbUrl) {
beforeAll(async () => {
engine = new PostgresEngine();
assertSafeE2eDatabaseUrl(dbUrl!);
await engine.connect({ database_url: dbUrl } as never);
await engine.initSchema();
-2
View File
@@ -15,7 +15,6 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import { PostgresEngine } from '../../src/core/postgres-engine.ts';
import type { EvalCandidateInput } from '../../src/core/types.ts';
import { assertSafeE2eDatabaseUrl } from '../helpers/db-guard.ts';
const DATABASE_URL = process.env.DATABASE_URL;
@@ -27,7 +26,6 @@ beforeAll(async () => {
return;
}
engine = new PostgresEngine();
assertSafeE2eDatabaseUrl(DATABASE_URL!);
await engine.connect({ database_url: DATABASE_URL });
await engine.initSchema();
});
@@ -24,7 +24,6 @@ import { writeRunRow, loadTrend } from '../../src/core/eval-contradictions/trend
import { JudgeCache, buildCacheKey } from '../../src/core/eval-contradictions/cache.ts';
import type { ProbeReport } from '../../src/core/eval-contradictions/types.ts';
import { operationsByName, type OperationContext } from '../../src/core/operations.ts';
import { assertSafeE2eDatabaseUrl } from '../helpers/db-guard.ts';
const DATABASE_URL = process.env.DATABASE_URL;
@@ -36,7 +35,6 @@ beforeAll(async () => {
return;
}
engine = new PostgresEngine();
assertSafeE2eDatabaseUrl(DATABASE_URL!);
await engine.connect({ database_url: DATABASE_URL });
await engine.initSchema();
});
@@ -2,7 +2,6 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { PostgresEngine } from '../../src/core/postgres-engine.ts';
import { runExtractFacts } from '../../src/core/cycle/extract-facts.ts';
import { parseFactsFence, renderFactsTable, type ParsedFact } from '../../src/core/facts-fence.ts';
import { assertSafeE2eDatabaseUrl } from '../helpers/db-guard.ts';
const databaseUrl = process.env.DATABASE_URL;
const skip = !databaseUrl;
@@ -15,7 +14,6 @@ describe.skipIf(skip)('facts-fence escaped-pipe reconciliation on Postgres', ()
beforeAll(async () => {
engine = new PostgresEngine();
assertSafeE2eDatabaseUrl(databaseUrl!);
await engine.connect({ database_url: databaseUrl! });
await engine.initSchema();
});
+31 -5
View File
@@ -13,7 +13,6 @@ import { PostgresEngine } from '../../src/core/postgres-engine.ts';
import * as db from '../../src/core/db.ts';
import { importFromContent } from '../../src/core/import-file.ts';
import { parseMarkdown } from '../../src/core/markdown.ts';
import { assertSafeE2eDatabaseUrl } from '../helpers/db-guard.ts';
// Load .env.testing if present
const envPath = resolve(import.meta.dir, '../../.env.testing');
@@ -68,11 +67,38 @@ export function hasDatabase(): boolean {
}
/**
* Production guard, moved to test/helpers/db-guard.ts so test files outside
* test/e2e/ can import it without loading this module. Re-exported here for
* existing call sites (setupDB below, test/e2e/db-guard.test.ts).
* Production guard: setupDB() TRUNCATEs every data table on whatever
* DATABASE_URL points at, and run-e2e.sh deliberately preserves an exported
* DATABASE_URL so a developer with a production URL in their environment
* would wipe their real brain by running the suite. Refuse unless the
* database name identifies itself as a test database ("test" as a word
* segment, e.g. gbrain_test the CI/.env.testing.example convention), or
* the operator explicitly opts the exact name in via GBRAIN_E2E_ALLOW_DB.
*
* Exported for unit testing; pure no connection is made.
*/
export { assertSafeE2eDatabaseUrl };
export function assertSafeE2eDatabaseUrl(
url: string,
env: Record<string, string | undefined> = process.env,
): void {
let dbName: string;
try {
dbName = decodeURIComponent(new URL(url).pathname.replace(/^\//, ''));
} catch {
throw new Error(`E2E guard: DATABASE_URL is not a parseable URL; refusing to run destructive setup.`);
}
if (!dbName) {
throw new Error(`E2E guard: DATABASE_URL has no database name; refusing to run destructive setup.`);
}
if (/(^|[_-])test([_-]|$)/i.test(dbName)) return;
if (env.GBRAIN_E2E_ALLOW_DB && env.GBRAIN_E2E_ALLOW_DB === dbName) return;
throw new Error(
`E2E guard: database "${dbName}" does not look like a test database ` +
`(expected "test" as a name segment, e.g. gbrain_test). setupDB() would ` +
`TRUNCATE every data table in it. If this is intentional, set ` +
`GBRAIN_E2E_ALLOW_DB=${dbName} to opt in explicitly.`,
);
}
/**
* Connect to DB, run schema init, truncate all tables.
@@ -14,7 +14,6 @@ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test
import { randomUUIDv7 } from 'bun';
import { PostgresEngine } from 'gbrain';
import { BudgetExceededError, reserve } from '../../src/core/minions/budget-meter.ts';
import { assertSafeE2eDatabaseUrl } from '../helpers/db-guard.ts';
const databaseUrl = process.env.DATABASE_URL;
const describePostgres = databaseUrl ? describe : describe.skip;
@@ -25,7 +24,6 @@ describePostgres('MCP spend reservation — Postgres concurrency', () => {
beforeAll(async () => {
engine = new PostgresEngine();
assertSafeE2eDatabaseUrl(databaseUrl!);
await engine.connect({ database_url: databaseUrl!, poolSize: 16 });
await engine.initSchema();
});
-2
View File
@@ -15,7 +15,6 @@
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import { PostgresEngine } from '../../src/core/postgres-engine.ts';
import { assertSafeE2eDatabaseUrl } from '../helpers/db-guard.ts';
const DATABASE_URL = process.env.DATABASE_URL;
const skip = !DATABASE_URL;
@@ -29,7 +28,6 @@ describe.skipIf(skip)('multimodal v0.27.1 against real Postgres', () => {
beforeAll(async () => {
pg = new PostgresEngine();
assertSafeE2eDatabaseUrl(DATABASE_URL!);
await pg.connect({ database_url: DATABASE_URL! });
await pg.initSchema();
}, 60_000);
-2
View File
@@ -25,7 +25,6 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { PostgresEngine } from '../../src/core/postgres-engine.ts';
import { LATEST_VERSION } from '../../src/core/migrate.ts';
import { assertSafeE2eDatabaseUrl } from '../helpers/db-guard.ts';
const DATABASE_URL = process.env.DATABASE_URL;
const skip = !DATABASE_URL;
@@ -35,7 +34,6 @@ describe.skipIf(skip)('PostgresEngine forward-reference bootstrap (E2E)', () =>
beforeAll(async () => {
engine = new PostgresEngine();
assertSafeE2eDatabaseUrl(DATABASE_URL!);
await engine.connect({ database_url: DATABASE_URL! });
}, 30_000);
-7
View File
@@ -15,15 +15,8 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { createHash } from 'crypto';
import { hasDatabase } from './helpers.ts';
import { assertSafeE2eDatabaseUrl } from '../helpers/db-guard.ts';
const skip = !hasDatabase();
// #3485 name floor: this suite opens raw postgres() clients on the ambient URL
// and runs DROP TRIGGER/FUNCTION + DELETE cleanups — refuse non-test-shaped
// database names before any connection is made.
if (!skip) {
assertSafeE2eDatabaseUrl(process.env.GBRAIN_DATABASE_URL || process.env.DATABASE_URL || '');
}
const describeE2E = skip ? describe.skip : describe;
if (skip) {
-13
View File
@@ -1,13 +0,0 @@
/**
* Probe fixture for test/database-url-guard-preload.test.ts.
*
* Deliberately trivial: the parent test spawns `bun test` on this file with
* controlled env and asserts on the RUN's exit code the preload guard fires
* (or doesn't) before this file executes. If the guard refuses, this test
* never runs; if the guard allows, this passes and the run exits 0.
*/
import { test, expect } from 'bun:test';
test('probe: run started, preload guard allowed it', () => {
expect(true).toBe(true);
});
@@ -1,59 +0,0 @@
/**
* Invocation-level guard: refuse to start a test run while a database URL is
* ambient in the environment, unless the invoker explicitly opted in.
*
* Why this exists (#3485): `gbrain init` writes DATABASE_URL into
* ~/.gbrain/.env pointing at the developer's real brain. Several test files
* connect to whatever DATABASE_URL names and run TRUNCATE/DELETE/DROP a
* bare `bun test` with that env sourced has destroyed a real brain before.
* The per-file name floor (test/helpers/db-guard.ts) is the second layer;
* this preload is the first: no test file loads at all while an unexpected
* database URL is present.
*
* Both DATABASE_URL and GBRAIN_DATABASE_URL are checked the runtime honors
* both (src/core/config.ts), so guarding only one leaves a live path.
*
* HARD-FAIL, never silently unset: silently clearing the var would turn
* DATABASE_URL-gated e2e tests into green skips, hiding the exact class of
* bug (#2339) those tests exist to catch. The e2e wrappers (scripts/
* run-e2e.sh, the e2e/heavy workflows) set GBRAIN_TEST_ALLOW_DATABASE_URL=1
* at their own subprocess boundary; nothing sets it globally.
*
* Registered FIRST in bunfig.toml's [test].preload so it runs before any
* other preload does work.
*/
const ALLOW = process.env.GBRAIN_TEST_ALLOW_DATABASE_URL === '1';
if (!ALLOW) {
const offending = (['DATABASE_URL', 'GBRAIN_DATABASE_URL'] as const).filter(
(name) => {
const v = process.env[name];
return v !== undefined && v !== '';
},
);
if (offending.length > 0) {
console.error(
[
`TEST-RUN GUARD: refusing to start — ${offending.join(' and ')} ${offending.length === 1 ? 'is' : 'are'} set.`,
``,
`Some tests run destructive SQL (TRUNCATE/DELETE/DROP) against whatever`,
`these URLs point at. A bare \`bun test\` with a real brain's URL in the`,
`environment has wiped that brain before (#3485).`,
``,
`Pick one:`,
` - unset the variable${offending.length > 1 ? 's' : ''} and re-run (unit tests need no database).`,
` If it keeps coming back, check for a .env file in this directory or a`,
` sourced ~/.gbrain/.env — bun auto-loads cwd .env into the test run.`,
` - run the e2e suite through its wrapper: \`bun run test:e2e\``,
` (the wrapper opts in at its own boundary), or`,
` - if you really mean to run tests against this database, opt in`,
` ONE-SHOT: \`GBRAIN_TEST_ALLOW_DATABASE_URL=1 bun test ...\` — avoid a`,
` shell-profile export, which would permanently disarm this guard. The`,
` per-file guard (test/helpers/db-guard.ts) still requires a test-shaped`,
` database name after that.`,
].join('\n'),
);
process.exit(1);
}
}
-41
View File
@@ -1,41 +0,0 @@
/**
* Production guard for tests that run destructive SQL against DATABASE_URL.
*
* setupDB() TRUNCATEs every data table on whatever DATABASE_URL points at, and
* run-e2e.sh deliberately preserves an exported DATABASE_URL, so a developer
* with a production URL in their environment would wipe their real brain by
* running the suite. Test files that connect a PostgresEngine directly, without
* going through setupDB(), carry the same risk and must call this themselves.
*
* Refuse unless the database name identifies itself as a test database ("test"
* as a word segment, e.g. gbrain_test, the CI/.env.testing.example convention),
* or the operator explicitly opts the exact name in via GBRAIN_E2E_ALLOW_DB.
*
* Lives in its own leaf module so unit-directory tests can import it without
* pulling in test/e2e/helpers.ts, which loads .env.testing and the engines at
* import time. test/e2e/helpers.ts re-exports it for existing call sites.
*
* Pure: no connection is made.
*/
export function assertSafeE2eDatabaseUrl(
url: string,
env: Record<string, string | undefined> = process.env,
): void {
let dbName: string;
try {
dbName = decodeURIComponent(new URL(url).pathname.replace(/^\//, ''));
} catch {
throw new Error(`E2E guard: DATABASE_URL is not a parseable URL; refusing to run destructive setup.`);
}
if (!dbName) {
throw new Error(`E2E guard: DATABASE_URL has no database name; refusing to run destructive setup.`);
}
if (/(^|[_-])test([_-]|$)/i.test(dbName)) return;
if (env.GBRAIN_E2E_ALLOW_DB && env.GBRAIN_E2E_ALLOW_DB === dbName) return;
throw new Error(
`E2E guard: database "${dbName}" does not look like a test database ` +
`(expected "test" as a name segment, e.g. gbrain_test). This test runs ` +
`destructive SQL against it. If this is intentional, set ` +
`GBRAIN_E2E_ALLOW_DB=${dbName} to opt in explicitly.`,
);
}
@@ -1,65 +0,0 @@
/**
* Snapshot-timezone parity pin.
*
* dumpDataDir bakes the BUILD process's TimeZone into the snapshot tar's
* cluster defaults. Un-pinned, a snapshot-restored engine ran sessions in the
* build machine's zone while cold-init engines follow the runtime process
* (bun test pins TZ=UTC) so every naive-timestamp day-boundary comparison
* shifted by the offset, and date-dependent tests failed only in the evening,
* only under GBRAIN_PGLITE_SNAPSHOT. Two fixes hold the line: the build
* script pins TZ=UTC before dumping, and the engine re-pins the session to
* the runtime zone on snapshot restore.
*
* This test is the deterministic pin: at ANY wall-clock hour, a cold engine
* and a snapshot engine created by the same process must report the same
* session TimeZone. Serial file: it mutates GBRAIN_PGLITE_SNAPSHOT (R1).
*/
import { describe, test, expect, afterAll } from 'bun:test';
import { existsSync } from 'fs';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
const SNAPSHOT = 'test/fixtures/pglite-snapshot.tar';
async function sessionUtcOffsetSeconds(env: Record<string, string | undefined>): Promise<number> {
const prev = process.env.GBRAIN_PGLITE_SNAPSHOT;
if (env.GBRAIN_PGLITE_SNAPSHOT === undefined) delete process.env.GBRAIN_PGLITE_SNAPSHOT;
else process.env.GBRAIN_PGLITE_SNAPSHOT = env.GBRAIN_PGLITE_SNAPSHOT;
const engine = new PGLiteEngine();
try {
await engine.connect({} as never);
await engine.initSchema();
// Compare the effective UTC OFFSET, not the zone label: cold init spells
// the runtime zone one way (Etc/GMT0), the restore re-pin another (UTC) —
// the invariant is identical instant arithmetic, not identical strings.
const rows = await engine.executeRaw<{ off: string }>(
`SELECT extract(timezone FROM now())::text AS off`,
);
return Number(rows[0].off);
} finally {
await engine.disconnect?.();
if (prev === undefined) delete process.env.GBRAIN_PGLITE_SNAPSHOT;
else process.env.GBRAIN_PGLITE_SNAPSHOT = prev;
}
}
describe('PGLite snapshot timezone parity', () => {
// Build the fixture in-test (idempotent hash short-circuit makes re-runs
// cheap) so this pin cannot silently skip in lanes without a prebuilt tar.
test('snapshot fixture builds', async () => {
const proc = Bun.spawnSync(['bun', 'run', 'build:pglite-snapshot'], {
cwd: `${import.meta.dir}/..`,
stdout: 'pipe',
stderr: 'pipe',
timeout: 300_000,
killSignal: 'SIGKILL',
});
expect(proc.exitCode).toBe(0);
expect(existsSync(`${import.meta.dir}/../${SNAPSHOT}`)).toBe(true);
}, 320_000);
test('snapshot-restored session UTC offset equals cold-init session UTC offset', async () => {
const cold = await sessionUtcOffsetSeconds({ GBRAIN_PGLITE_SNAPSHOT: undefined });
const snap = await sessionUtcOffsetSeconds({ GBRAIN_PGLITE_SNAPSHOT: SNAPSHOT });
expect(snap).toBe(cold);
}, 120_000);
});
@@ -14,7 +14,6 @@ import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { PostgresEngine } from '../src/core/postgres-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import type { BrainEngine } from '../src/core/engine.ts';
import { assertSafeE2eDatabaseUrl } from './helpers/db-guard.ts';
let pglite: PGLiteEngine;
let pg: PostgresEngine | null = null;
@@ -26,7 +25,6 @@ beforeAll(async () => {
if (process.env.DATABASE_URL) {
pg = new PostgresEngine();
assertSafeE2eDatabaseUrl(process.env.DATABASE_URL!);
await pg.connect({ database_url: process.env.DATABASE_URL });
await pg.initSchema();
}
-15
View File
@@ -38,21 +38,6 @@ The runner is `scripts/run-heavy.sh`. It discovers every `tests/heavy/*.sh`
file at this directory's top level (NOT recursive), runs them in lexical
order, fails on the first non-zero exit.
## Database name floor (#3485)
Heavy scripts run destructive operations (schema drops, migration replays,
parallel syncs) against whatever database URL the environment names. The
runner sources `tests/heavy/_db_floor.sh` once for the whole lane, and every
database-touching script sources it itself: it refuses (exit 2) unless the
database name in `DATABASE_URL` / `GBRAIN_DATABASE_URL` carries "test" as a
word segment (e.g. `gbrain_test`), or the exact name is opted in one-shot via
`GBRAIN_E2E_ALLOW_DB=<name>`. The PGLite-based scripts (`measure_rss.sh`,
`read_latency_under_sync.sh`, `sync_timeout_rescue.sh`) unset the URL instead.
When adding a script that touches the database, add
`source "$(dirname "$0")/_db_floor.sh"` before it does — scripts are
documented for direct invocation, so the runner-level floor alone is not
enough.
## Naming convention
- `tests/heavy/<name>.sh` — top-level test script, picked up by the runner.
-4
View File
@@ -40,10 +40,6 @@ if [ -z "${DATABASE_URL:-}" ]; then
exit 2
fi
# #3485 name floor (shared): this script DROPs the public schema of whatever
# DATABASE_URL names — refuse non-test-shaped names without an explicit opt-in.
source "$(dirname "$0")/_db_floor.sh"
SQL_FILE="tests/heavy/fixtures/down-mutate-${SHAPE}.sql"
if [ ! -f "$SQL_FILE" ]; then
echo "[build_legacy_fixtures] no fixture for shape '$SHAPE' at $SQL_FILE" >&2
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env bash
# #3485 shared shell name floor, mirrored from test/helpers/db-guard.ts.
#
# Heavy-lane scripts run destructive operations (DROP SCHEMA, source-registry
# UPSERTs, initSchema migration replay, parallel syncs) against whatever
# database URL the resolved config names. The bunfig preload guard is
# bun-test-only — shell lanes need their own floor. Source this file AFTER
# confirming DATABASE_URL is set:
#
# source "$(dirname "$0")/_db_floor.sh" # exits 2 unless every set db-url
# # var is test-shaped or allowed
#
# BOTH variables are floored: heavy scripts shell out to the real CLI
# (gbrain doctor / gbrain sync), and src/core/config.ts resolves
# GBRAIN_DATABASE_URL ABOVE DATABASE_URL — flooring only one would let the
# other reach a real brain. Query strings are stripped BEFORE the name is
# extracted: `?host=/tmp/test-sockets` must not smuggle a test-shaped segment
# past the floor (the last path segment of the pre-query URL is the db name).
#
# Refuses unless the database name carries "test" as a word segment
# (gbrain_test) or the operator opts the exact name in via GBRAIN_E2E_ALLOW_DB
# (prefer one-shot inline usage — GBRAIN_E2E_ALLOW_DB=name ./script — over a
# shell-profile export, which would permanently disarm the floor).
_db_floor_check() {
_db_floor_url="$1"
_db_floor_var="$2"
[ -z "$_db_floor_url" ] && return 0
_db_floor_prequery="${_db_floor_url%%\?*}"
_db_floor_name="${_db_floor_prequery##*/}"
if [ -z "$_db_floor_name" ]; then
echo "[db-floor] REFUSING: $_db_floor_var has no database name" >&2
exit 2
fi
if ! printf '%s' "$_db_floor_name" | grep -qiE '(^|[_-])test([_-]|$)'; then
if [ "${GBRAIN_E2E_ALLOW_DB:-}" != "$_db_floor_name" ]; then
echo "[db-floor] REFUSING: $_db_floor_var database \"$_db_floor_name\" does not look like a test database" >&2
echo " (expected \"test\" as a name segment, e.g. gbrain_test). Heavy-lane scripts run" >&2
echo " destructive operations against it. If intentional, opt in one-shot:" >&2
echo " GBRAIN_E2E_ALLOW_DB=$_db_floor_name <command>" >&2
exit 2
fi
fi
}
_db_floor_check "${DATABASE_URL:-}" DATABASE_URL
_db_floor_check "${GBRAIN_DATABASE_URL:-}" GBRAIN_DATABASE_URL
unset -f _db_floor_check 2>/dev/null || true
unset _db_floor_url _db_floor_var _db_floor_prequery _db_floor_name 2>/dev/null || true
@@ -30,10 +30,6 @@
set -euo pipefail
# #3485: shared name floor — these scripts mutate whatever DATABASE_URL names
# and are documented for direct invocation, so each sources the floor itself.
source "$(dirname "$0")/_db_floor.sh"
cd "$(dirname "$0")/../.."
WALLCLOCK_BUDGET_S="${WALLCLOCK_BUDGET_S:-15}"
-4
View File
@@ -33,10 +33,6 @@
set -euo pipefail
# #3485: shared name floor — these scripts mutate whatever DATABASE_URL names
# and are documented for direct invocation, so each sources the floor itself.
source "$(dirname "$0")/_db_floor.sh"
cd "$(dirname "$0")/../.."
if [ -z "${DATABASE_URL:-}" ]; then
-4
View File
@@ -17,10 +17,6 @@
set -euo pipefail
# #3485: shared name floor — these scripts mutate whatever DATABASE_URL names
# and are documented for direct invocation, so each sources the floor itself.
source "$(dirname "$0")/_db_floor.sh"
cd "$(dirname "$0")/../.."
if [ -z "${DATABASE_URL:-}" ]; then