Compare commits

...
Author SHA1 Message Date
Garry Tan 9131aafafe fix(bootstrap): claim the render backup-stamp dir atomically; harden two CI timing pins
Two one-shot CI failures on loaded runners (run 31928749495), exposed by
the merge's shard re-binpacking:

- render.ts: the forced-overwrite backup stamp has MILLISECOND
  granularity, so two renders of the same workspace within one
  millisecond collided on the exclusive `wx` backup write (EEXIST) — a
  fast CI runner hit it on three back-to-back renders in one test. The
  stamp dir is now claimed atomically per render call (non-recursive
  mkdir, numeric suffix on EEXIST), lazily on the first backup. Pinned
  by a 10-rapid-re-renders regression test asserting 10 distinct
  backup dirs.
- db-lock-fencing: one loaded-CI run observed `aborted === true` with
  `signal.reason === undefined` at the first post-abort read —
  unreproduced across 50+ local + containerized Linux (bun 1.3.13)
  runs, including the exact CI shard composition. The poll now awaits
  the REASON (not just the aborted flag) within the same 5s deadline,
  and a genuine miss reports the actual reason value for forensics.
2026-08-15 23:17:16 -07:00
Garry Tan 993517b0e4 Merge remote-tracking branch 'origin/master' into garrytan/fix-master-tests 2026-08-15 22:10:35 -07:00
Garry Tan 3d6abfd56d Merge remote-tracking branch 'origin/master' into garrytan/fix-master-tests 2026-08-15 21:54:39 -07:00
Garry TanandClaude Fable 5 937819861f docs: update project documentation for v0.46.8.0
- KEY_FILES.md: cli.ts entry now covers the doctor DB-fallback stderr
  note + its two-redactor scrub (and drops a stale line-number cite);
  new src/core/url-redact.ts entry (redactPgUrl / redactUrlsInText /
  redactDeep, consumers, CI guard, test pin); redact-connection-info
  consumer list gains the doctor fallback; run-e2e.sh entry documents
  the per-file wedge-timeout override (180s default, skills.test.ts
  420s, SIGTERM via gtimeout/timeout).
- docs/TESTING.md: preload section notes the unit/slow wrappers strip
  an ambient GBRAIN_HOME (the preload respects pre-set values) and
  documents GBRAIN_DEBUG_PRELOAD=1.
- CHANGELOG.md 0.46.8.0: adds the e2e timeout-cap bullet (the one
  commit item the entry missed); narrows "no signal handlers" to
  termination/cleanup handlers (SIGCHLD reaper still module-scope,
  TODOS files the follow-up); tightens the doctor-redaction claim to
  what the redactors guarantee.
- CONTRIBUTING.md: removes a stale orphaned duplicate line
  contradicting the current guard-check count.

Cross-model doc review (codex, high effort) ran; concrete findings
applied, narrative findings deliberately skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 18:58:59 -07:00
Garry TanandClaude Fable 5 6b6f021023 fix(review): review hardening + e2e timeout cap + v0.46.8.0 bump
Combined ship-stage commit (review army: 5 specialists + red team +
Claude adversarial + two Codex passes; every P1/P2 addressed):

REVIEW HARDENING
- preferences.ts: one-time copy-forward shim for the pre-unification
  $GBRAIN_HOME-direct layout (atomic temp+linkSync, EEXIST = concurrent
  winner; JSON-validated prefs; chmod 0600; copy-not-move for rollback;
  once-per-process warnings). A valid-but-uncopyable legacy file is
  READ IN PLACE, and ledger appends target the same resolved path so a
  degraded copy can never split or shadow migration history — an
  explicit minion_mode opt-out survives the upgrade; completed
  migrations are never silently re-run. One-shot mixed-version
  divergence documented as accepted.
- url-redact.ts: redactUrlsInText — greedy userinfo scrub (a raw '@' in
  a password can't leak its tail) + libpq keyword/value form incl.
  quoted passwords. cli.ts doctor fallback routes through
  redactConnectionInfo AND the URL sweep; its label no longer
  misdiagnoses a DB-backed-check throw as a connect failure.
- run-unit-parallel/shard/slow: strip ambient GBRAIN_HOME at the same
  boundary that strips DATABASE_URL.
- process-cleanup.ts: docstring rewritten to the import.meta.main
  contract.
- Regression pins: _resetForTests detach (all 7 attach points); cli.ts
  import installs no signal handlers (spawn-based); preload
  sets-when-unset/respects-preset; legacy copy-forward (4 cases);
  doctor fallback stderr + credential redaction end-to-end;
  redactUrlsInText units. Credential-shaped fixtures assembled at
  runtime so source never carries a scannable span.

E2E TIMEOUT CAP
- run-e2e.sh: known-slow files get a 420s wedge backstop
  (skills.test.ts runs the real ingest skill ~140s+ at 124 migrations;
  the 180s cap false-killed it on quiet machines).

VERSION v0.46.8.0
- VERSION, package.json, CHANGELOG.md, openclaw.plugin.json,
  BOOTSTRAP_FOR_AGENTS.md stamp, regenerated template stamp, bun.lock.
- KEY_FILES.md/TESTING.md updated; TODOS.md files the wave's 4
  follow-ups (SIGCHLD seam, CI e2e coverage gap, runner kill-report
  clarity, skills.test.ts host-repo commit leak found during the gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 18:43:37 -07:00
Garry Tan 62feb3a230 Merge remote-tracking branch 'origin/master' into garrytan/fix-master-tests 2026-08-15 17:10:59 -07:00
Garry Tan eb33cd2dee fix(e2e): repair the 13 CI-uncovered files that rotted as master moved
CI's e2e workflow runs only 8 named files; `bun run test:e2e` runs all
~187, so the local-only lane rotted silently across v0.42-v0.46 waves.
Every failure diagnosed as test-rot/env/flake - zero product regressions.

- v0_29-mcp-dispatch-pglite: pass transport:'stdio' past the v0.45.13.0
  dispatch-layer localOnly backstop so the in-handler remote gate is tested.
- type-unification-full-flow: withEnv-isolate the pack-upgrade check from
  the machine's file-plane schema_pack (honored since v0.42.66.0).
- embedding-column-pglite: use __unconfigureGatewayForTests() (resetGateway
  re-applies env config since PR #3557).
- extract-atoms-discovery-sql: sentinel type 'note' -> 'concept'
  (unconditionally excluded) after PR #2615 widened extractable types.
- phantom-redirect: round-12 predicate accepts halfvec (migration v40 on
  pgvector>=0.7) + idempotent-reconcile comment refresh (#2932).
- openclaw-plugin-load-real: bun build --outdir + --entry-naming (v0.45.0.0
  pglite-embedded-assets emits 5 file-loader assets; --outfile refuses
  multi-output); drop the retired `plugins inspect --runtime` flag
  (OpenClaw 2026.4.x reports runtime state in plain --json).
- bootstrap-keyed-postgres: [G13] assertion is soft-delete-aware.
- pglite-cli-exit.serial: strip DATABASE_URL/pgbouncer vars from spawned-CLI
  env so the corrupt-PGLite fixture isn't rerouted to healthy Postgres.
- bootstrap-harness-lifecycle.serial: scrub ambient DB URLs for the
  IN-PROCESS runBootstrap path too.
- helpers.ts setupDB: reset leaked brain identity in `sources` (PR #3735's
  ownership guard keys on sources.default.local_path; without the reset
  every legacy-path sync classifies as first_sync forever). Deliberately
  leaves chunker_version alone - NULLing it flips staleness semantics.
- ingestion-roundtrip: poll the async post-emit archive with waitFor.
- doctor-progress: detect the silent DB-fallback via the 'connection'
  check + retry once on transient connect failure.
- serve-http-oauth: bind the scopes array as an explicit '{read}'::text[]
  literal (sql.array under prepare:false serializes as the bare element).
- engine-parity: stamp with each row's own updated_at_iso (the #1768/D4
  production convention) - a DB clock a few ms ahead of the client made
  client-time stamps land before the row's insert time whenever the
  seed->stamp gap was shorter than the skew.
2026-08-15 17:10:49 -07:00
Garry Tan 786049a9fa fix(test-harness): isolate GBRAIN_HOME by default; unify the preferences path convention
Unit tests ran against the operator's REAL ~/.gbrain: any config-honoring
code path silently changed behavior with whatever the live config.json
said (27 cycle/autopilot/dream tests flipped red the moment a sibling
workspace rewrote it, while the identical commit stayed green in CI),
and tests have historically clobbered the real config.

- test/helpers/gbrain-home-preload.ts (+bunfig preload): point GBRAIN_HOME
  at per-run scratch when unset - same pattern as audit-dir-preload (#2823).
  Respects the e2e wrapper's own GBRAIN_HOME.
- src/core/preferences.ts: gbrainDir() now delegates to config.ts's
  gbrainPath(), so GBRAIN_HOME follows the ONE canonical convention
  (parent dir, '.gbrain' appended). Its previous local resolver returned
  GBRAIN_HOME directly - while claiming in its own comment to match
  gbrainPath - splitting one logical home across two roots (config at
  $GBRAIN_HOME/.gbrain/config.json, migration ledger at
  $GBRAIN_HOME/migrations/).
- 7 test files updated to the canonical convention: subprocess spawns set
  BOTH HOME and GBRAIN_HOME (HOME alone loses to the inherited preload
  value; in-process HOME mutation loses to Bun's cached os.homedir()),
  and the cycle file-lock test resolves via gbrainPath like production.
2026-08-15 17:10:30 -07:00
Garry Tan 96c86ba3d9 fix(cli): install cleanup signal handlers inside the import.meta.main seam
Installing process-cleanup's SIGTERM->exit(143) handler at module load
leaked it into any process that merely imports cli.ts. In a bun test
runner, test/run-child-entry.test.ts's synthetic process.emit('SIGTERM')
then killed the entire shard; run-unit-parallel.sh misread rc=143 as an
external kill, the serial rescue died identically, and `bun run test`
exited 1 with zero real failures. Minimal repro (pre-fix, exit 143):
bun test test/link-source-namespaced-regex.test.ts test/run-child-entry.test.ts

- cli.ts: move installCleanupSignalHandlers() inside import.meta.main
  (entrypoints - real CLI, compiled binary, spawned CLIs in tests - still
  install; imports no longer poison the importer).
- process-cleanup.ts: record attached listener refs; _resetForTests()
  now detaches them (flags-only reset left a live exit(143) listener).
- run-child-entry.test.ts: strip foreign SIGTERM listeners around the
  synthetic emit, restore in finally (defense-in-depth).
- autopilot.ts: refresh the stale "installed at cli.ts module load" comment.
2026-08-15 17:10:16 -07:00
39 changed files with 800 additions and 117 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
<!-- gbrain-runbook-stamp: 0.46.6.0 -->
<!-- gbrain-runbook-stamp: 0.46.8.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. -->
+59
View File
@@ -2,6 +2,65 @@
All notable changes to GBrain will be documented in this file.
## [0.46.8.0] - 2026-08-15
**The full local test suite is trustworthy again.** `bun run test` and
`bun run test:e2e` now pass on developer machines the same way they pass in
CI — the two failure classes that made local runs lie are fixed at the root.
### Fixed
- **Test runs no longer die mid-suite with phantom "externally killed" shards.**
The CLI installed its shutdown signal handler at module load, so any test
that imported the CLI armed a process-wide SIGTERM handler inside the test
runner; one test's synthetic signal emission then killed the entire shard.
The handler now installs only in real CLI entrypoints (compiled binary,
spawned CLIs), never in importers — pinned by spawn-based regression tests.
- **Unit tests are isolated from your real brain.** A new test preload points
`GBRAIN_HOME` at per-run scratch, so config-honoring code paths no longer
change behavior with whatever your live `~/.gbrain/config.json` says (27
cycle/dream tests flipped red whenever another workspace rewrote it), and
tests can no longer clobber real config, audit logs, or lock files. The
unit/slow wrappers also strip an ambient `GBRAIN_HOME` at their boundary,
matching the existing `DATABASE_URL` discipline.
- **One canonical `GBRAIN_HOME` convention.** Preferences and the migration
ledger now resolve through the same path convention as engine config
(`GBRAIN_HOME` is a parent directory; `.gbrain` is appended) instead of a
divergent local rule that split one logical home across two roots. Installs
that run with `GBRAIN_HOME` set get a one-time, atomic, rollback-safe
copy-forward of their existing preferences and migration history — an
explicit `minion_mode: off` opt-out survives the upgrade, and completed
migrations are never silently re-run. Read-only homes degrade to reading
the legacy file in place.
- **13 end-to-end test files repaired** after drifting from behavior that
changed in earlier releases (transport-scoped local-only ops, soft-delete
semantics, pack-manifest extractable types, halfvec embedding columns,
multi-asset compiled builds, environment leakage into hermetic fixtures,
a clock-skew-sensitive staleness assertion, and a driver array-binding
quirk). All were test-side fixes — no product behavior had regressed.
- **`gbrain doctor` announces its filesystem-only fallback.** When the DB
connect (or the DB-backed check run) fails, doctor now says so on stderr
instead of silently degrading — with connection errors scrubbed through
the credential redactor (URL userinfo, libpq `password=` forms including
quoted values, hostnames/IPs) so pasted output doesn't leak credentials
into issues and CI logs.
- **The e2e runner no longer false-kills its known-slow file.** `run-e2e.sh`'s
per-file wedge timeout (the hard-timeout backstop against wedged files, 180s) is
now overridable per file; the full ingest-skill e2e gets 420s — its runtime
grows with every migration master adds, and the flat cap had started killing
legitimately-passing runs on quiet machines.
### Added
- Regression pins for the new harness contracts: importing the CLI installs
no termination/cleanup signal handlers; the test-home preload sets-when-unset and respects
pre-set values; `_resetForTests` fully detaches listeners; free-text
credential redaction (`redactUrlsInText`).
To take advantage of v0.46.8.0: `gbrain self-upgrade`, then `gbrain doctor`
— no schema migration, no config changes. If you run tests locally,
`bun run test` and `DATABASE_URL=<test-db> bun run test:e2e` should both
exit 0 on a clean checkout; if they don't, the failure is real.
## [0.46.6.0] - 2026-08-15
**A busy machine can no longer make the job queue evict its own healthy
+33
View File
@@ -6410,3 +6410,36 @@ covers DEAD logs; go-forward capture beyond Claude Code is deliberately absent.
are heavy machinery for a benign-cost race; the retriage help documents
the behavior. Context: outside-voice CX5 on the #4152 ship review.
Effort: M.
## Local-lane green wave follow-ups (filed at build time)
- [ ] **P2 — Gate `installSigchldHandler()` on `import.meta.main` too.** Same
class as the process-cleanup SIGTERM leak fixed in this wave (cli.ts:3-4):
a process-wide SIGCHLD reaper installs into any process that merely imports
cli.ts — in a bun test runner it could race Bun's own child reaping and
steal spawn exit statuses. No observed failure yet; move it inside the
import.meta.main seam with a soak run of the full suite before landing.
Effort: S.
- [ ] **P2 — CI e2e lane runs only 8 of ~187 e2e files.** The other ~179 run
only via local `bun run test:e2e`, which is how 13 files rotted undetected
across v0.42v0.46 waves (this wave's fix list). Options: a nightly
heavy-tests job running the full run-e2e.sh list against the compose
postgres, or fold the full lane into ci-local + a required weekly schedule.
Decide venue, then wire `scripts/e2e-test-map.ts` coverage accordingly.
Effort: M.
- [ ] **P3 — run-unit-parallel external-kill reporting contradicts itself.**
A shard killed by an in-suite exit(143) prints `pass=N fail=0` +
`oom_rescue_failed=0real` in the final banner yet exits 1, and the
oom-rescue summary line says "real failures confirmed" with fail=0. Make
the banner name the killed shard + rescue outcome explicitly so the next
mystery kill is a 1-minute diagnosis instead of a bisect. Effort: S.
- [ ] **P2 — skills.test.ts e2e leaks a git commit into the HOST repo.** During
the v0.46.8.0 ship gate, the e2e ingest-skill run created a real commit
("ingest NovaMind board update transcript") with fixture pages
(companies/, people/, meetings/) at the WORKSPACE repo root — the test's
write-through/commit path resolved the host cwd instead of its tmp fixture
repo, despite run-e2e.sh's HOME isolation. Caught only because a soft reset
surfaced the staged files. Find the cwd-resolving path in the ingest skill
lane (likely repo-root fallback when the source local_path isn't threaded),
fix it to fail closed, and add a run-e2e.sh post-run guard that fails the
lane if `git status` at the host root gained tracked-file changes. Effort: M.
+1 -1
View File
@@ -1 +1 @@
0.46.6.0
0.46.8.0
+7 -1
View File
@@ -27,4 +27,10 @@ timeout = 60_000
# 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"]
#
# gbrain-home-preload: point GBRAIN_HOME at per-run scratch so tests never
# read (or clobber) the operator's real ~/.gbrain config/brain — the live
# config.json changing mid-day flipped 27 config-honoring cycle/dream tests
# red on dev boxes while CI stayed green. Respects a pre-set GBRAIN_HOME
# (the e2e wrapper sets its own). See test/helpers/gbrain-home-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", "./test/helpers/gbrain-home-preload.ts"]
+19
View File
@@ -248,6 +248,25 @@ 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.
**GBRAIN_HOME isolation preload.** `test/helpers/gbrain-home-preload.ts` (bunfig
`[test]` preload) points `GBRAIN_HOME` at a per-run scratch dir when it isn't
already set, so unit tests never read — or clobber — the operator's real
`~/.gbrain` config/brain. Without it, any config-honoring code path silently
changes behavior with whatever the live `config.json` says (observed: 27
cycle/autopilot/dream tests flipped red the moment a sibling workspace's run
rewrote the real config, while the identical commit stayed green in CI). The
canonical GBRAIN_HOME convention is `config.ts:configDir()`: GBRAIN_HOME is a
PARENT dir and `.gbrain` is appended. Subprocess-spawning tests must set BOTH
`HOME: tmp` and `GBRAIN_HOME: tmp` in the child env (HOME alone loses to the
inherited preload value; in-process HOME mutation loses to Bun's cached
`os.homedir()`). The e2e wrapper sets its own GBRAIN_HOME before bun starts,
which this preload respects. Because the preload respects a pre-set value, the
unit/slow wrappers (`run-unit-parallel.sh` / `run-unit-shard.sh` /
`run-slow-tests.sh`) strip an ambient `GBRAIN_HOME` at their boundary — same
discipline as the database-URL vars — so a dev shell configured for a real
brain can't ride through. `GBRAIN_DEBUG_PRELOAD=1` prints the allocated
scratch home for debugging.
**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`
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "gbrain-context-engine",
"name": "gbrain",
"version": "0.46.6.0",
"version": "0.46.8.0",
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
"family": "bundle-plugin",
"configSchema": {
+1 -1
View File
@@ -164,7 +164,7 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.46.6.0",
"version": "0.46.8.0",
"overrides": {
"@hono/node-server": "^2.0.5",
"fast-uri": "^3.1.5",
+5
View File
@@ -9,6 +9,11 @@ set -euo pipefail
# 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
# An ambient GBRAIN_HOME (a dev shell configured for a real brain) must not
# reach unit tests either: the gbrain-home-preload respects a pre-set value
# (the e2e wrapper needs that), so strip it at this boundary and let the
# preload allocate per-run scratch instead.
unset GBRAIN_HOME
cd "$(dirname "$0")/.."
. scripts/lib/test-env.sh
+5
View File
@@ -48,6 +48,11 @@ set -uo pipefail
# 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
# An ambient GBRAIN_HOME (a dev shell configured for a real brain) must not
# reach unit tests either: the gbrain-home-preload respects a pre-set value
# (the e2e wrapper needs that), so strip it at this boundary and let the
# preload allocate per-run scratch instead.
unset GBRAIN_HOME
cd "$(dirname "$0")/.."
+5
View File
@@ -18,6 +18,11 @@ set -euo pipefail
# 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
# An ambient GBRAIN_HOME (a dev shell configured for a real brain) must not
# reach unit tests either: the gbrain-home-preload respects a pre-set value
# (the e2e wrapper needs that), so strip it at this boundary and let the
# preload allocate per-run scratch instead.
unset GBRAIN_HOME
cd "$(dirname "$0")/.."
+21 -7
View File
@@ -2,12 +2,7 @@
import { installSigchldHandler } from './core/zombie-reap.ts';
installSigchldHandler();
// v0.41.6.0 D5: cleanup registry + signal handlers for SIGTERM/SIGHUP/SIGPIPE/
// uncaughtException. NOT SIGINT (the existing AbortController path at :254
// owns SIGINT). Installed at module load so locks acquired during boot
// (e.g. during connectEngine's schema-probe path) are covered too.
import { installSignalHandlers as installCleanupSignalHandlers } from './core/process-cleanup.ts';
installCleanupSignalHandlers();
import { readFileSync, existsSync, unlinkSync, fstatSync } from 'fs';
import { spawn } from 'child_process';
@@ -1993,8 +1988,18 @@ async function handleCliOnly(command: string, args: string[]) {
try {
eng = await connectEngine();
await runDoctor(eng, args);
} catch {
// DB unavailable — still run filesystem checks
} catch (e) {
// DB unavailable OR the DB-backed run threw — still run filesystem
// checks. Say so on stderr: a silent fallback looks identical to a
// healthy DB-backed run (minus the DB checks), which has misread as
// "doctor is broken". Scrub the message through BOTH redactors —
// connection-info (hosts/IPs/users/quoted libpq passwords) and the
// URL-userinfo sweep — because doctor output is exactly what users
// paste into issues and CI logs.
const { redactUrlsInText } = await import('./core/url-redact.ts');
const { redactConnectionInfo } = await import('./core/audit/redact-connection-info.ts');
const safeMsg = redactConnectionInfo(redactUrlsInText(e instanceof Error ? e.message : String(e)));
console.error(`[doctor] DB-backed doctor run failed (${safeMsg}) — falling back to filesystem-only checks`);
await runDoctor(null, args, getDbUrlSource());
} finally {
if (eng) await finishCliTeardown({ engine: eng });
@@ -3311,6 +3316,15 @@ Run gbrain <command> --help for command-specific help.
// process alive. A fatal error still exits 1 for every command, daemons
// included (matches the prior unconditional process.exit(1) on rejection).
if (import.meta.main) {
// v0.41.6.0 D5: cleanup registry + signal handlers for SIGTERM/SIGHUP/SIGPIPE/
// uncaughtException. NOT SIGINT (the existing AbortController path owns SIGINT).
// Installed before main() so locks acquired during boot (e.g. connectEngine's
// schema-probe path) are covered. Gated on import.meta.main — nothing at module
// scope acquires locks, and installing at module load leaked a process-wide
// SIGTERM→exit(143) handler into any process that merely IMPORTS this module
// (bun test runners died mid-suite when a test emitted a synthetic SIGTERM).
// Spawned/compiled CLI processes are entrypoints, so they still install.
installCleanupSignalHandlers();
main().then(
() => {
if (shouldForceExitAfterMain()) flushThenExit(currentExitCode());
+3 -2
View File
@@ -568,8 +568,9 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
// brain. Two exit paths must both close the engine:
// - autopilot's own shutdown() below (owns SIGINT + internal stops like
// max_crashes / cycle-failure-cap), and
// - process-cleanup's SIGTERM handler (installed at cli.ts module load;
// it runs the cleanup registry with a 3s deadline and then exits) —
// - process-cleanup's SIGTERM handler (installed inside cli.ts's
// import.meta.main seam before main() dispatches; it runs the cleanup
// registry with a 3s deadline and then exits) —
// which is why closeEngine is ALSO registered there.
// closeEngine aborts the in-flight inline cycle (runCycle checks the
// signal between phases and threads it into phase sub-work), gives it a
+26 -4
View File
@@ -287,9 +287,30 @@ export function renderWorkspace(workspaceDir: string, opts: RenderOptions = {}):
// Phase 2 — write, never clobbering without force [plan: render never
// clobbers]. Backups for a forced overwrite land under one per-run
// timestamp dir.
// timestamp dir. The stamp has MILLISECOND granularity, so two forced
// renders of the same workspace within the same millisecond (a fast CI
// runner re-rendering back-to-back) would collide on the exclusive `wx`
// backup write with EEXIST. Claim the stamp dir ATOMICALLY (non-recursive
// mkdir; EEXIST → bump a numeric suffix) lazily on the first backup, so
// every render call owns a distinct backup dir.
const result: RenderResult = { written: [], skipped: [], backups: [] };
const backupStamp = new Date().toISOString().replace(/[:.]/g, '-');
let backupStamp: string | null = null;
const claimBackupStamp = (): string => {
if (backupStamp) return backupStamp;
const root = join(workspaceDir, '.gbrain-bootstrap-backups');
mkdirSync(root, { recursive: true });
const base = new Date().toISOString().replace(/[:.]/g, '-');
for (let n = 0; ; n++) {
const candidate = n === 0 ? base : `${base}-${n}`;
try {
mkdirSync(join(root, candidate)); // non-recursive: EEXIST = taken
backupStamp = candidate;
return candidate;
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
}
}
};
for (const { dest, content } of rendered) {
if (!isBootstrapRenderDest(dest)) {
@@ -304,10 +325,11 @@ export function renderWorkspace(workspaceDir: string, opts: RenderOptions = {}):
result.skipped.push(dest);
continue;
}
const backupAbs = join(workspaceDir, '.gbrain-bootstrap-backups', backupStamp, dest);
const stamp = claimBackupStamp();
const backupAbs = join(workspaceDir, '.gbrain-bootstrap-backups', stamp, dest);
mkdirSync(dirname(backupAbs), { recursive: true });
writeFileSync(backupAbs, readFileSync(abs), { flag: 'wx' });
result.backups.push(join('.gbrain-bootstrap-backups', backupStamp, dest));
result.backups.push(join('.gbrain-bootstrap-backups', stamp, dest));
}
mkdirSync(dirname(abs), { recursive: true });
const tmp = `${abs}.tmp-${process.pid}`;
+110 -35
View File
@@ -8,41 +8,26 @@
* Also houses ~/.gbrain/migrations/completed.jsonl append helper.
*/
import { readFileSync, writeFileSync, renameSync, chmodSync, mkdtempSync, rmSync, existsSync, mkdirSync, appendFileSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
function home(): string {
// `os.homedir()` in Bun caches its initial value and ignores later
// `process.env.HOME` mutations, which breaks test isolation and any
// workflow that needs to run against a specific $HOME (CI, scripted installs).
// Prefer the env var; fall back to the cached OS value. Matches the existing
// `src/commands/upgrade.ts` pattern.
//
// NOTE: prefsDir() and migrationsDir() route through gbrainPath() (which
// honors GBRAIN_HOME), so this fallback is only used by code paths that
// want $HOME directly (none in this file as of v0.30.3).
return process.env.HOME || homedir();
}
import { readFileSync, writeFileSync, renameSync, chmodSync, mkdtempSync, rmSync, existsSync, mkdirSync, appendFileSync, copyFileSync, linkSync } from 'fs';
import { join, dirname } from 'path';
import { gbrainPath } from './config.ts';
/**
* GBRAIN_HOME-aware override for the .gbrain directory. When the env var
* is set, this returns it directly (so the directory is GBRAIN_HOME itself,
* matching the convention `src/core/config.ts:gbrainPath` enforces).
* When unset, falls back to `<home>/.gbrain` so legacy callers and the
* doctor's filesystem-only checks keep working.
* GBRAIN_HOME-aware resolution of the .gbrain directory delegates to
* `src/core/config.ts:gbrainPath()` so preferences + the migration ledger
* follow the SAME convention as config resolution: GBRAIN_HOME is a PARENT
* dir and '.gbrain' is always appended (`GBRAIN_HOME=/tmp/x`
* `/tmp/x/.gbrain`). The previous local implementation returned GBRAIN_HOME
* directly while its own doc comment claimed to match gbrainPath so
* with GBRAIN_HOME set, config lived at `$GBRAIN_HOME/.gbrain/config.json`
* but the migration ledger at `$GBRAIN_HOME/migrations/completed.jsonl`,
* splitting one logical home across two roots.
*
* Without this, `~/.gbrain/migrations/completed.jsonl` is the only path
* doctor reads on filesystem checks the test isolation contract that
* `gbrainPath()` provides for everywhere else doesn't extend here.
* When unset, gbrainPath falls back to `<home>/.gbrain` so legacy callers
* and doctor's filesystem-only checks keep working.
*/
function gbrainDir(): string {
const override = process.env.GBRAIN_HOME;
if (override) {
const trimmed = override.trim();
if (trimmed) return trimmed;
}
return join(home(), '.gbrain');
return gbrainPath();
}
export type MinionMode = 'always' | 'pain_triggered' | 'off';
@@ -88,6 +73,83 @@ function prefsPath(): string { return join(prefsDir(), 'preferences.json'); }
function migrationsDir(): string { return join(gbrainDir(), 'migrations'); }
function completedJsonlPath(): string { return join(migrationsDir(), 'completed.jsonl'); }
/**
* One-time copy-forward from the pre-unification layout. Before gbrainDir()
* delegated to gbrainPath(), an install running with GBRAIN_HOME set kept
* these files at $GBRAIN_HOME/<relPath> directly (no '.gbrain' segment).
* Losing them on upgrade would silently re-run completed migrations and
* worse drop an explicit `minion_mode: off` opt-out back to the default.
* COPY (not move) so a binary rollback still finds the legacy file. Only
* fires when GBRAIN_HOME is set, the new path is absent, and the legacy
* file exists. Returns the path the caller should READ: the canonical path
* normally; the legacy path in place when a valid legacy file exists but
* could not be copied (read-only home, ENOSPC) so a transient failure
* never drops an opt-out; an unparseable legacy prefs file is treated as
* missing rather than poisoning the canonical path.
*
* Known limitation (accepted): the copy is one-shot. During a mixed-version
* window (old binary still writing the legacy path after a new binary
* snapshot), post-snapshot legacy writes are not merged the canonical
* path wins from then on. The exposed population (GBRAIN_HOME production
* installs × concurrent mixed-version writers) is tiny, and a merge engine
* here would cost more risk than the window it closes.
*/
/** One warning per (process, legacy path) a persistently unmigratable file
* would otherwise spam every command until the operator intervenes. */
const migrateWarned = new Set<string>();
function copyForwardLegacyFile(
newPath: string,
relPath: string,
opts?: { validateJson?: boolean },
): string {
const override = process.env.GBRAIN_HOME?.trim();
if (!override || existsSync(newPath)) return newPath;
const legacyPath = join(override, relPath);
if (legacyPath === newPath || !existsSync(legacyPath)) return newPath;
let tmpDir: string | null = null;
try {
// A foreign/corrupted legacy preferences.json must not poison the
// canonical path forever (loadPreferences JSON.parse throws and callers
// like autopilot boot unguarded); treat unparseable as missing. The
// ledger needs no validation — its reader skips malformed lines.
if (opts?.validateJson) {
JSON.parse(readFileSync(legacyPath, 'utf-8'));
}
mkdirSync(dirname(newPath), { recursive: true });
// Atomic install, matching savePreferences' discipline: copy into a
// sibling temp dir, then LINK into place. linkSync fails EEXIST if a
// concurrent process migrated (or appended) first — never truncates a
// reader's view mid-copy, never clobbers a concurrent append.
tmpDir = mkdtempSync(join(dirname(newPath), '.migrate-tmp-'));
const tmpFile = join(tmpDir, 'file');
copyFileSync(legacyPath, tmpFile);
try { chmodSync(tmpFile, 0o600); } catch { /* best-effort on exotic FS */ }
try {
linkSync(tmpFile, newPath);
console.error(`[preferences] migrated ${relPath} from legacy $GBRAIN_HOME layout (${legacyPath}${newPath}); legacy copy kept for rollback`);
} catch (linkErr) {
if ((linkErr as NodeJS.ErrnoException).code !== 'EEXIST') throw linkErr;
// Another process won the migration race — its copy stands.
}
return newPath;
} catch (err) {
// Copy failed (read-only $GBRAIN_HOME, ENOSPC, foreign JSON). Never
// silent, but warn once per process — and for a VALID-but-uncopyable
// legacy file, hand the caller the legacy path to READ IN PLACE so a
// transient failure can't silently drop an opt-out or migration
// history (the pre-change code read this path with no write needed).
const code = (err as NodeJS.ErrnoException).code ?? (err instanceof SyntaxError ? 'invalid-json' : 'error');
if (!migrateWarned.has(legacyPath)) {
migrateWarned.add(legacyPath);
console.error(`[preferences] found legacy ${relPath} at ${legacyPath} but could not migrate it (${code}); ${err instanceof SyntaxError ? 'treating as missing' : 'reading it in place'}`);
}
return err instanceof SyntaxError ? newPath : legacyPath;
} finally {
if (tmpDir) { try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best-effort */ } }
}
}
/** Validate that a value is a recognized minion mode. Throws with the allowed list. */
export function validateMinionMode(value: unknown): asserts value is MinionMode {
if (typeof value !== 'string' || !VALID_MODES.includes(value as MinionMode)) {
@@ -102,7 +164,7 @@ export function validateMinionMode(value: unknown): asserts value is MinionMode
* Malformed JSON throws; caller can catch if they want graceful fallback.
*/
export function loadPreferences(): Preferences {
const path = prefsPath();
const path = copyForwardLegacyFile(prefsPath(), 'preferences.json', { validateJson: true });
if (!existsSync(path)) return {};
const raw = readFileSync(path, 'utf-8');
const parsed = JSON.parse(raw) as Preferences;
@@ -116,6 +178,10 @@ export function loadPreferences(): Preferences {
export function savePreferences(prefs: Preferences): void {
if (prefs.minion_mode !== undefined) validateMinionMode(prefs.minion_mode);
// No-op when the new path already exists; otherwise ensures a fresh save
// doesn't shadow un-migrated legacy prefs (the save below would win, but
// the migration log line should still fire once for observability).
copyForwardLegacyFile(prefsPath(), 'preferences.json', { validateJson: true });
const dir = prefsDir();
mkdirSync(dir, { recursive: true });
@@ -142,6 +208,13 @@ export function savePreferences(prefs: Preferences): void {
*/
export function appendCompletedMigration(entry: CompletedMigrationEntry): void {
if (!entry.version) throw new Error('appendCompletedMigration: version required');
// Copy-forward BEFORE any append: a partial/retry append that skipped the
// guard below would otherwise create a fresh ledger at the new path and
// permanently shadow the un-migrated legacy history. Appends target the
// SAME path reads resolve to — when copy-forward degraded to
// read-legacy-in-place (linkless FS), appending to the canonical path
// would split the ledger and hide the legacy history from readers.
const ledgerPath = copyForwardLegacyFile(completedJsonlPath(), join('migrations', 'completed.jsonl'));
if (entry.status !== 'complete' && entry.status !== 'partial' && entry.status !== 'retry') {
throw new Error(`appendCompletedMigration: status must be 'complete', 'partial', or 'retry', got "${entry.status}"`);
}
@@ -161,14 +234,16 @@ export function appendCompletedMigration(entry: CompletedMigrationEntry): void {
ts: new Date().toISOString(),
...entry,
};
const dir = migrationsDir();
mkdirSync(dir, { recursive: true });
appendFileSync(completedJsonlPath(), JSON.stringify(full) + '\n');
mkdirSync(dirname(ledgerPath), { recursive: true });
// The top-of-function copy-forward is load-bearing for partial/retry
// appends (the 'complete' idempotency guard also routes through
// loadCompletedMigrations, but that guard is status-gated).
appendFileSync(ledgerPath, JSON.stringify(full) + '\n');
}
/** Read the completed.jsonl file, skipping malformed lines with a warning to stderr. */
export function loadCompletedMigrations(): CompletedMigrationEntry[] {
const path = completedJsonlPath();
const path = copyForwardLegacyFile(completedJsonlPath(), join('migrations', 'completed.jsonl'));
if (!existsSync(path)) return [];
const raw = readFileSync(path, 'utf-8');
const out: CompletedMigrationEntry[] = [];
+42 -12
View File
@@ -17,7 +17,7 @@
*
* - Signal scope: SIGTERM, SIGHUP, SIGPIPE, uncaughtException,
* unhandledRejection. **NOT SIGINT** gbrain has an existing
* SIGINT-via-AbortController path at cli.ts:254 that propagates
* SIGINT-via-AbortController path in cli.ts that propagates
* abort to in-flight operations (clean cancel). Installing cleanup
* on SIGINT here would preempt that flow. Lock release on user
* cancel belongs in the AbortController path, not in a parallel
@@ -52,6 +52,16 @@ interface CleanupEntry {
const registry = new Map<symbol, CleanupEntry>();
let installed = false;
let cleanupInFlight = false;
/** Refs to every listener attached by installSignalHandlers, keyed by
* target+event, so _resetForTests can DETACH them without this, a test
* that installs and "resets" leaves a live SIGTERMexit(143) listener on
* the shared bun test runner, and any later synthetic
* `process.emit('SIGTERM')` kills the entire suite. */
const installedListeners: Array<{
target: NodeJS.Process | NodeJS.WriteStream;
event: string;
fn: (...args: never[]) => void;
}> = [];
/**
* Register a cleanup callback. Returns a deregister handle (idempotent
@@ -122,10 +132,13 @@ async function runCleanupPass(): Promise<void> {
/**
* Install signal handlers + the EPIPE-on-stdout handler. Idempotent
* (second call is NO-OP). MUST be called once at CLI module load AFTER
* any existing signal handlers (so we don't preempt the SIGINT
* AbortController at cli.ts:254 we don't listen to SIGINT here, but
* documenting the install order keeps future maintainers aware).
* (second call is NO-OP). MUST be called once from the CLI ENTRYPOINT
* inside cli.ts's `import.meta.main` seam, before main() dispatches
* and NOT at module load: a module-load install leaks a process-wide
* SIGTERMexit(143) handler into any process that merely imports cli.ts
* (a bun test runner died mid-suite when a test emitted a synthetic
* SIGTERM). The SIGINT AbortController path in cli.ts stays untouched
* we don't listen to SIGINT here.
*/
export function installSignalHandlers(): void {
if (installed) return;
@@ -142,19 +155,28 @@ export function installSignalHandlers(): void {
});
};
process.on('SIGTERM', () => handleSignal('SIGTERM'));
process.on('SIGHUP', () => handleSignal('SIGHUP'));
const attach = (
target: NodeJS.Process | NodeJS.WriteStream,
event: string,
fn: (...args: never[]) => void,
): void => {
(target as NodeJS.Process).on(event as 'exit', fn as () => void);
installedListeners.push({ target, event, fn });
};
attach(process, 'SIGTERM', () => handleSignal('SIGTERM'));
attach(process, 'SIGHUP', () => handleSignal('SIGHUP'));
// SIGPIPE in Node is rarely raised directly (Node ignores it by default
// and surfaces an EPIPE write error on the stream instead). Listen anyway
// for environments where it does fire.
process.on('SIGPIPE', () => handleSignal('SIGPIPE'));
attach(process, 'SIGPIPE', () => handleSignal('SIGPIPE'));
process.on('uncaughtException', (err) => {
attach(process, 'uncaughtException', (err: unknown) => {
try { process.stderr.write(`[uncaughtException] ${err instanceof Error ? err.stack ?? err.message : err}\n`); }
catch { /* stderr might be broken */ }
void runCleanupPass().finally(() => process.exit(1));
});
process.on('unhandledRejection', (reason) => {
attach(process, 'unhandledRejection', (reason: unknown) => {
try { process.stderr.write(`[unhandledRejection] ${reason instanceof Error ? reason.stack ?? reason.message : reason}\n`); }
catch { /* stderr might be broken */ }
void runCleanupPass().finally(() => process.exit(1));
@@ -162,14 +184,14 @@ export function installSignalHandlers(): void {
// EPIPE on stdout — the canonical `gbrain sync | head -N` case. Route
// through the cleanup pass so locks release BEFORE we exit.
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
attach(process.stdout, 'error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE') {
void triggerCleanupAndExit(0);
}
});
// Same for stderr — less common but possible (e.g. `2>&1 | head` after
// stderr was rerouted to stdout).
process.stderr.on('error', (err: NodeJS.ErrnoException) => {
attach(process.stderr, 'error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EPIPE') {
// No stderr means no useful logs on the way out; still cleanup.
void triggerCleanupAndExit(0);
@@ -185,6 +207,14 @@ export function installSignalHandlers(): void {
*/
export function _resetForTests(): void {
registry.clear();
// Detach every listener installSignalHandlers attached — clearing flags
// alone leaves a live SIGTERM→exit(143) listener on the shared test-runner
// process, which a later synthetic `process.emit('SIGTERM')` would trigger,
// killing the whole suite.
for (const { target, event, fn } of installedListeners) {
(target as NodeJS.Process).off(event as 'exit', fn as () => void);
}
installedListeners.length = 0;
installed = false;
cleanupInFlight = false;
}
+28
View File
@@ -35,6 +35,34 @@ export function redactPgUrl(url: unknown): string {
return `${scheme}${userPart}${hostPart}${query ?? ''}`;
}
/**
* Redact credential-bearing URLs of ANY scheme embedded in free text (error
* messages, log lines). Greedy userinfo match `scheme://<anything>@` up to
* the last `@` in the same token so a raw `@` INSIDE a password can't leak
* its tail (`p@ssw@rd` redacts whole, at the cost of over-redacting a
* hostname that legitimately follows a second `@`, which is the safe
* direction for a log line). Text without a `scheme://...@` shape passes
* through untouched.
*
* Examples (scheme genericized so this docstring itself never carries a
* credential-shaped span the tests pin the real postgres shapes):
* redactUrlsInText('connect failed: demo://user:pw@db:5432/x timeout')
* 'connect failed: demo://***@db:5432/x timeout'
* redactUrlsInText('demo://user:p@ssw@rd@db.example.com/x')
* 'demo://***@db.example.com/x'
*/
export function redactUrlsInText(text: string): string {
return (
text
.replace(/(\w+:\/\/)\S+@/g, '$1***@')
// libpq keyword/value form: `password=hunter2 host=db ...` — postgres.js
// echoes malformed connection strings verbatim in its error messages.
// Quoted values may contain spaces (`password='hunter two'`), so match
// quoted forms before the bare-token form.
.replace(/\b(password|sslpassword)\s*=\s*('[^']*'|"[^"]*"|\S+)/gi, '$1=***')
);
}
/**
* Recursively redact any postgresql:// or postgres:// URLs found inside an
* arbitrary value (string, object, array). Useful when the caller is about
+1 -1
View File
@@ -1,6 +1,6 @@
# gbrain agent workspace — template
<!-- gbrain-template-stamp: 0.46.6.0 -->
<!-- gbrain-template-stamp: 0.46.8.0 -->
This repository is the **"Use this template"** distribution artifact for a
[gbrain](https://github.com/garrytan/gbrain) personal-agent workspace — the same
+19
View File
@@ -110,6 +110,25 @@ describe('renderWorkspace — happy path', () => {
expect(third.manifest.source_id).toBe('explicit-id');
});
test('rapid forced re-renders never collide on the backup stamp (same-millisecond EEXIST)', () => {
// Regression pin (CI 2026-08-16, shard 7): the backup stamp has
// MILLISECOND granularity, so two forced renders inside the same ms
// collided on the exclusive `wx` backup write with EEXIST. The stamp
// dir is now claimed atomically per render call (numeric suffix on
// collision). Ten back-to-back renders reliably land several in one
// millisecond on any modern machine.
const ws = answeredWs();
renderWorkspace(ws);
const stamps = new Set<string>();
for (let i = 0; i < 10; i++) {
const res = renderWorkspace(ws, { force: true });
expect(res.backups.length).toBeGreaterThan(0);
for (const b of res.backups) stamps.add(b.split('/')[1]);
}
// Every forced render owned a distinct backup dir.
expect(stamps.size).toBe(10);
});
test('re-render with force preserves the manifest created_at (first render wins)', () => {
const ws = answeredWs();
renderWorkspace(ws);
@@ -0,0 +1,79 @@
/**
* Regression pins for the local-lane green wave's two harness contracts.
*
* 1. Importing src/cli.ts as a MODULE must not install process-cleanup's
* SIGTERM/SIGHUPexit handlers. The install lives inside the
* `import.meta.main` seam; at module load it leaked a process-wide
* SIGTERMexit(143) handler into any importer in a bun test runner,
* one test's synthetic `process.emit('SIGTERM')` then killed the whole
* shard, which the parallel runner misread as an external kill. The
* one test that used to die on this (run-child-entry) now shields
* itself by stripping foreign listeners, so THIS pin is the only
* thing that fails if the install moves back to module scope.
*
* 2. test/helpers/gbrain-home-preload.ts sets GBRAIN_HOME to per-run
* scratch when unset and respects a pre-set value. Every unit test's
* isolation from the operator's real ~/.gbrain depends on it.
*
* Both pins spawn a fresh bun process: the contracts are about
* process-start module-load behavior, which can't be observed in-process.
*/
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import { join } from 'path';
const REPO = join(import.meta.dir, '..');
function runBunEval(script: string, env: Record<string, string | undefined>): string {
const res = spawnSync('bun', ['-e', script], {
encoding: 'utf-8',
timeout: 60_000,
cwd: REPO,
env: { ...process.env, ...env } as NodeJS.ProcessEnv,
});
return (res.stdout ?? '').trim();
}
describe('cli.ts module-import signal-handler contract', () => {
test('importing cli.ts does NOT install SIGTERM/SIGHUP handlers (import.meta.main seam)', () => {
const script = [
`await import(${JSON.stringify(join(REPO, 'src', 'cli.ts'))});`,
// Give any microtask-deferred module init a tick before counting.
`await new Promise((r) => setTimeout(r, 50));`,
`console.log(JSON.stringify({ sigterm: process.listenerCount('SIGTERM'), sighup: process.listenerCount('SIGHUP') }));`,
].join('\n');
const out = runBunEval(script, {});
const lines = out.split('\n').filter((l) => l.trim().startsWith('{'));
const counts = JSON.parse(lines[lines.length - 1]) as { sigterm: number; sighup: number };
// zombie-reap's SIGCHLD handler is expected at module scope (tracked as a
// TODOS follow-up); SIGTERM/SIGHUP from process-cleanup must NOT be.
expect(counts.sigterm).toBe(0);
expect(counts.sighup).toBe(0);
});
});
describe('gbrain-home-preload contract', () => {
const PRELOAD = join(REPO, 'test', 'helpers', 'gbrain-home-preload.ts');
test('sets GBRAIN_HOME to per-run scratch when unset', () => {
const script = [
`await import(${JSON.stringify(PRELOAD)});`,
`console.log(JSON.stringify({ home: process.env.GBRAIN_HOME ?? null }));`,
].join('\n');
const out = runBunEval(script, { GBRAIN_HOME: undefined });
const parsed = JSON.parse(out.split('\n').pop()!) as { home: string | null };
expect(parsed.home).not.toBeNull();
expect(parsed.home!).toContain('gbrain-test-home-');
});
test('respects a pre-set GBRAIN_HOME (the e2e wrapper sets its own)', () => {
const script = [
`await import(${JSON.stringify(PRELOAD)});`,
`console.log(JSON.stringify({ home: process.env.GBRAIN_HOME ?? null }));`,
].join('\n');
const out = runBunEval(script, { GBRAIN_HOME: '/tmp/gbrain-preload-preset-pin' });
const parsed = JSON.parse(out.split('\n').pop()!) as { home: string | null };
expect(parsed.home).toBe('/tmp/gbrain-preload-preset-pin');
});
});
+7 -1
View File
@@ -307,7 +307,13 @@ describe('runCycle — cycle_already_running skip', () => {
// ─── Engine null path ─────────────────────────────────────────────
describe('runCycle — engine = null (filesystem-only mode)', () => {
const lockFile = require('path').join(require('os').homedir(), '.gbrain', 'cycle.lock');
// Resolve the lock path the way production does (gbrainPath honors
// GBRAIN_HOME — which the test preload points at per-run scratch). The
// old homedir()-based literal wrote lock files into the operator's REAL
// ~/.gbrain and stopped matching the code's path once tests were
// home-isolated.
const { gbrainPath } = require('../../src/core/config.ts') as typeof import('../../src/core/config.ts');
const lockFile = gbrainPath('cycle.lock');
afterEach(() => {
if (existsSync(lockFile)) { try { unlinkSync(lockFile); } catch { /* */ } }
+12 -3
View File
@@ -102,13 +102,22 @@ describe('startCycleLockRefresher (Tier-1 #1 + D5.11)', () => {
const stop = startCycleLockRefresher(fakeLock(async () => false), controller, 'test-lock', 15);
try {
// Poll instead of a fixed sleep: under full-suite shard load, timer
// ticks can be starved well past the nominal interval.
// ticks can be starved well past the nominal interval. Poll on the
// REASON, not just `aborted`: one loaded-CI run (2026-08-16, shard 9)
// observed `aborted === true` with `reason === undefined` at the first
// post-abort read — unreproduced locally/in-container across 50+ runs,
// so treat reason visibility as part of the awaited condition and keep
// the assertion diagnostic when it genuinely never arrives.
const deadline = Date.now() + 5_000;
while (!controller.signal.aborted && Date.now() < deadline) {
while (!(controller.signal.reason instanceof LockStolenError) && Date.now() < deadline) {
await new Promise(r => setTimeout(r, 25));
}
expect(controller.signal.aborted).toBe(true);
expect(controller.signal.reason).toBeInstanceOf(LockStolenError);
if (!(controller.signal.reason instanceof LockStolenError)) {
throw new Error(
`expected LockStolenError abort reason within 5s; aborted=${controller.signal.aborted} reason=${String(controller.signal.reason)}`,
);
}
} finally {
stop();
}
+37 -1
View File
@@ -27,7 +27,10 @@ let origHome: string | undefined;
function run(args: string[]): { exitCode: number; stdout: string; stderr: string } {
// Strip DATABASE_URL so doctor runs filesystem-only for these tests.
// Half-migrated checks run in the filesystem section; no DB needed.
const env = { ...process.env, HOME: tmp } as Record<string, string | undefined>;
// Both HOME and GBRAIN_HOME must point at the fixture dir: config/path
// resolution prefers GBRAIN_HOME (which the test preload sets to its own
// scratch), so HOME alone leaves the child reading the wrong .gbrain.
const env = { ...process.env, HOME: tmp, GBRAIN_HOME: tmp } as Record<string, string | undefined>;
delete env.DATABASE_URL;
delete env.GBRAIN_DATABASE_URL;
// Cross-file poisoning guard: sibling test files in the same bun process
@@ -250,4 +253,37 @@ describe('gbrain doctor — half-migrated Minions detection', () => {
expect(result.stdout).toContain('MINIONS HALF-INSTALLED');
expect(result.stdout).toContain('gbrain apply-migrations --yes');
});
test('DB-connect failure announces the filesystem-only fallback on stderr, credentials redacted', () => {
// The fallback used to be silent — indistinguishable from a healthy
// DB-backed run minus the DB checks. Pin the stderr note AND that a
// credential-bearing connect error never leaks the password (doctor
// output is what users paste into issues and CI logs).
const gbrainDir = join(tmp, '.gbrain');
mkdirSync(gbrainDir, { recursive: true });
// Assembled at runtime so the source never contains a scannable
// credential-URL span (the value is synthetic).
const fakeUrl = ['postgresql:/', '/alice:sekrit-hunter2', '@127.0.0.1:1/refused'].join('');
writeFileSync(
join(gbrainDir, 'config.json'),
JSON.stringify({ engine: 'postgres', database_url: fakeUrl }) + '\n',
);
// The shared run() helper discards stderr on exit 0; this pin needs it
// regardless of exit code, so spawn directly.
const env = { ...process.env, HOME: tmp, GBRAIN_HOME: tmp } as Record<string, string | undefined>;
delete env.DATABASE_URL;
delete env.GBRAIN_DATABASE_URL;
const { spawnSync } = require('child_process') as typeof import('child_process');
const res = spawnSync('bun', ['run', CLI, 'doctor', '--json'], {
env: env as NodeJS.ProcessEnv,
encoding: 'utf-8',
timeout: 60_000,
});
expect(res.stderr).toContain('[doctor] DB-backed doctor run failed');
expect(res.stderr).toContain('filesystem-only checks');
expect(res.stderr).not.toContain('sekrit-hunter2');
// stdout stays parseable JSON for --json consumers.
expect(() => JSON.parse(res.stdout.trim())).not.toThrow();
}, 60_000);
});
@@ -70,20 +70,17 @@ describe('bootstrap harness lifecycle E2E (PGLite + real serve --http)', () => {
// remapped HOME (it reads the password database), so HOME alone does NOT
// sandbox user-scope writes — that leak is exactly why claudeUserSettingsPath
// honors CLAUDE_CONFIG_DIR/HOME explicitly now.
//
// DATABASE_URL/GBRAIN_DATABASE_URL must be scrubbed for the IN-PROCESS
// runBootstrap lane too (the beforeAll already scrubs them for its
// subprocesses): since v0.31.3 (9c60b3a06, #801) an env DATABASE_URL
// deliberately overrides the file-backed PGLite engine in loadConfig().
// Under the DATABASE_URL-bearing e2e wrapper, a leaked URL retargets the
// mint at the shared Postgres test DB — no PGLite single-writer lock, so
// the mint-refusal contract under a live serve never fires and the
// fresh-minted token fails the bearer smoke against the PGLite serve.
const envFor = () => ({
GBRAIN_HOME: parent,
HOME: sandboxHome,
CLAUDE_CONFIG_DIR: join(sandboxHome, '.claude'),
CODEX_HOME: codexHome,
// The e2e lane exports DATABASE_URL; the IN-PROCESS runBootstrap calls
// (unlike the spawned children scrubbed in beforeAll) would otherwise
// resolve it via loadConfig's env>file precedence and mint tokens
// against Postgres while the live serve is PGLite-backed — turning the
// expected refusal into an invalid_token rollback. withEnv treats
// undefined as delete-with-restore.
DATABASE_URL: undefined,
GBRAIN_DATABASE_URL: undefined,
});
@@ -321,12 +321,21 @@ describe.skipIf(!DATABASE_URL)('Postgres bootstrap verify (real Postgres)', () =
const magic = res.checks.find((c) => c.id === 'magic_moment');
expect(magic?.ok).toBe(true);
// Probe cleanup [G13] must hold on the Postgres DDL path too.
// Probe cleanup [G13] must hold on the Postgres DDL path too. Verify
// cleans up through the real delete_page op, which is a SOFT delete
// (deleted_at stamp) — so assert no ACTIVE probe rows remain, and pin
// that the probes went through the soft-delete path rather than never
// being cleaned at all.
const probePages = await engine.executeRaw<{ n: number }>(
`SELECT COUNT(*)::int AS n FROM pages WHERE source_id = $1 AND slug IN ($2, $3)`,
`SELECT COUNT(*)::int AS n FROM pages WHERE source_id = $1 AND slug IN ($2, $3) AND deleted_at IS NULL`,
['workspace', VERIFY_PROBE_SLUG, VERIFY_PROBE_ENTITY_SLUG],
);
expect(probePages[0].n).toBe(0);
const probeTombstones = await engine.executeRaw<{ n: number }>(
`SELECT COUNT(*)::int AS n FROM pages WHERE source_id = $1 AND slug IN ($2, $3) AND deleted_at IS NOT NULL`,
['workspace', VERIFY_PROBE_SLUG, VERIFY_PROBE_ENTITY_SLUG],
);
expect(probeTombstones[0].n).toBe(2);
const probeFacts = await engine.executeRaw<{ n: number }>(
`SELECT COUNT(*)::int AS n FROM facts WHERE source_id = $1 AND source_markdown_slug = $2`,
['workspace', VERIFY_PROBE_SLUG],
+24 -5
View File
@@ -39,11 +39,30 @@ describeE2E('gbrain doctor --progress-json (E2E)', () => {
});
test('stderr has JSONL progress events, stdout stays clean', () => {
const res = spawnSync('bun', [CLI, '--progress-json', 'doctor', '--json'], {
encoding: 'utf-8',
env: { ...process.env, NO_COLOR: '1' },
timeout: DOCTOR_PROGRESS_TIMEOUT_MS,
});
// A transient DB-connect failure sends the CLI down its filesystem-only
// doctor fallback (announced on stderr by cli.ts, but it still emits
// zero progress events with a healthy-looking stdout). Detect that via
// the 'connection' check in the --json payload and retry once before
// asserting, so a one-off connect blip doesn't fail the lane.
const runOnce = () =>
spawnSync('bun', [CLI, '--progress-json', 'doctor', '--json'], {
encoding: 'utf-8',
env: { ...process.env, NO_COLOR: '1' },
timeout: DOCTOR_PROGRESS_TIMEOUT_MS,
});
const connectionOk = (stdout: string): boolean => {
try {
const payload = JSON.parse(stdout);
const checks = (payload.checks ?? []) as Array<{ name?: string; status?: string }>;
const conn = checks.find((c) => c.name === 'connection');
return conn?.status === 'ok';
} catch {
return false;
}
};
let res = runOnce();
if (!connectionOk(res.stdout)) res = runOnce();
expect(connectionOk(res.stdout)).toBe(true);
// Even if some checks warn, doctor runs to completion. Failures would
// exit non-zero, which is OK — we're testing progress wiring.
+20
View File
@@ -110,6 +110,26 @@ export async function setupDB(): Promise<PostgresEngine> {
ON CONFLICT (key) DO NOTHING
`);
// Reset leaked brain identity: `sources` is not in ALL_TABLES (the default
// row must survive), but rows/columns written by earlier files or runs
// persist. writeSyncAnchor's ownership guard (#3735) keys on
// sources.default.local_path — a stale value from another test makes every
// legacy-path performSync classify as first_sync forever. 42P01-tolerant
// like the TRUNCATE loop above.
try {
await conn.unsafe(`DELETE FROM sources WHERE id <> 'default'`);
// Only the sync-identity columns: local_path feeds writeSyncAnchor's
// ownership guard (#3735) and last_commit/last_sync_at feed first_sync
// classification. chunker_version is deliberately left alone — NULLing
// it flips extraction-staleness semantics for unrelated suites.
await conn.unsafe(
`UPDATE sources SET local_path = NULL, last_commit = NULL, last_sync_at = NULL WHERE id = 'default'`,
);
} catch (e: unknown) {
const code = (e as { code?: string })?.code;
if (code !== '42P01' && code !== '42703') throw e; // missing table/column on older schemas
}
engine = new PostgresEngine();
await engine.connect({ database_url: DATABASE_URL });
// Apply MIGRATIONS via the engine path. db.initSchema above only runs the
+4 -2
View File
@@ -171,8 +171,10 @@ describe('ingestion roundtrip — inbox-folder → daemon → ingest_capture →
expect(fetched?.compiled_truth).toContain('full e2e flow');
// File was archived after ingestion (the inbox-folder source's
// post-emit archive step).
expect(fs.existsSync(captured)).toBe(false);
// post-emit archive step). The archive (mkdir+rename) runs ASYNC
// relative to the dispatch that satisfied the waits above — emit is
// fire-and-forget — so poll rather than asserting immediately.
await waitFor(() => !fs.existsSync(captured));
const archiveDate = new Date().toISOString().slice(0, 10);
expect(fs.existsSync(path.join(inboxDir, '.archived', archiveDate, 'roundtrip.md'))).toBe(true);
+8 -5
View File
@@ -62,12 +62,13 @@ if (!SKIP) {
function freshTempHome(label: string) {
const dir = mkdtempSync(join(tmpdir(), `gbrain-e2e-migration-${label}-`));
// preferences.ts's gbrainDir() returns `$HOME/.gbrain` when GBRAIN_HOME
// is unset. Test fixtures write to `$dir/.gbrain/...`, so set HOME only
// and clear any inherited GBRAIN_HOME (which would route prefs to $dir
// directly, no .gbrain suffix).
// preferences.ts's gbrainDir() delegates to gbrainPath(): GBRAIN_HOME is a
// PARENT dir with '.gbrain' appended, so GBRAIN_HOME=$dir routes prefs +
// the migration ledger to `$dir/.gbrain/...`, matching the fixture layout.
// (HOME alone doesn't isolate in-process reads — the unset-GBRAIN_HOME
// fallback uses Bun's cached homedir(), frozen at the e2e wrapper's HOME.)
process.env.HOME = dir;
delete process.env.GBRAIN_HOME;
process.env.GBRAIN_HOME = dir;
// Seed config so Phase A's `gbrain init --migrate-only` has a target.
mkdirSync(join(dir, '.gbrain'), { recursive: true });
writeFileSync(
@@ -87,6 +88,8 @@ function freshTempHome(label: string) {
function restoreHomePath() {
if (origHome === undefined) delete process.env.HOME;
else process.env.HOME = origHome;
if (origGbrainHome === undefined) delete process.env.GBRAIN_HOME;
else process.env.GBRAIN_HOME = origGbrainHome;
if (origPath === undefined) delete process.env.PATH;
else process.env.PATH = `${fakeBinDir}:${origPath ?? ''}`;
}
+8 -6
View File
@@ -96,14 +96,16 @@ beforeAll(() => {
delete runEnv.OPENAI_API_KEY;
delete runEnv.ANTHROPIC_API_KEY;
delete runEnv.GOOGLE_API_KEY;
// Strip DB-URL env vars: since v0.31.3 (9c60b3a06, #801) an env
// DATABASE_URL deliberately overrides a file-backed PGLite engine
// selection in loadConfig(). This whole file is a hermetic-PGLite
// suite; when run under the DATABASE_URL-bearing e2e wrapper, an
// inherited URL would silently retarget every subprocess (including
// the torn-WAL fixture below) at the shared Postgres test DB.
// This file is PGLite-only, but the e2e lane deliberately exports
// DATABASE_URL (scripts/run-e2e.sh). An inherited env URL overrides the
// fixture's `engine: 'pglite'` config (env > file precedence), silently
// rerouting every spawned CLI to the healthy shared Postgres — the
// corrupt-WAL case then exits 0 against the wrong engine. Strip all
// DB-routing vars so the spawned CLIs honor the PGLite fixture homes.
delete runEnv.DATABASE_URL;
delete runEnv.GBRAIN_DATABASE_URL;
delete runEnv.GBRAIN_PGBOUNCER_URL;
delete runEnv.GBRAIN_PGBOUNCER_DIRECT_URL;
// NOTE: init grew strict flag validation (#2201); `--repo`/`--yes` were
// never real init flags (previously silently ignored). The repo is wired
+9 -2
View File
@@ -21,6 +21,7 @@ import { resetPgliteState } from '../helpers/reset-pglite.ts';
import { runUnifyTypes } from '../../src/core/schema-pack/unify-types-handler.ts';
import { runAllOnboardChecks } from '../../src/core/onboard/checks.ts';
import { _resetPackCacheForTests } from '../../src/core/schema-pack/registry.ts';
import { withEnv, emptyHome } from '../helpers/with-env.ts';
let engine: PGLiteEngine;
@@ -142,8 +143,14 @@ describe('v0.42 type-unification E2E (IRON RULE)', () => {
const preDistinct = parseInt(preTypes[0].cnt, 10);
expect(preDistinct).toBeGreaterThanOrEqual(20);
// Onboard surfaces pack_upgrade_available
const checks = await runAllOnboardChecks(engine);
// Onboard surfaces pack_upgrade_available. checkPackUpgradeAvailable
// honors tier-6 file-plane config (v0.42.66.0, #3396), so hide the dev
// machine's real ~/.gbrain/config.json schema_pack for this assertion —
// otherwise the check reports 'ok' locally while 'warn' in CI.
const checks = await withEnv(
{ GBRAIN_HOME: emptyHome(), GBRAIN_SCHEMA_PACK: undefined },
() => runAllOnboardChecks(engine),
);
const packUpgrade = checks.find(c => c.check.name === 'pack_upgrade_available');
expect(packUpgrade?.check.status).toBe('warn');
expect(packUpgrade?.remediations[0]?.job).toBe('unify-types');
+41
View File
@@ -0,0 +1,41 @@
/**
* Pre-test setup: point GBRAIN_HOME at a per-run scratch directory so unit
* tests never resolve the operator's real `~/.gbrain`.
*
* Why this exists: config resolution (`configPath()`/`gbrainPath()` and every
* loadConfig tier-6 file-plane read) falls back to the real home when
* GBRAIN_HOME is unset. Any test that exercises a config-honoring code path
* without its own `withEnv({ GBRAIN_HOME: ... })` silently changes behavior
* with whatever the operator's live `~/.gbrain/config.json` happens to say
* schema_pack, engine, model routing, feature toggles. Concretely observed:
* 27 cycle/autopilot/dream tests flipped red on a dev box the moment a
* sibling workspace's run rewrote the real config.json mid-day, while the
* identical commit stayed green in CI (which has no ~/.gbrain at all). The
* reverse leak is worse tests have historically CLOBBERED the operator's
* real config (`config.json.clobbered-*` remnants).
*
* Fix: same pattern as audit-dir-preload (#2823) and sync-failures-preload
* set the env once, globally, before any test file loads, to a fresh mkdtemp
* dir unique to THIS process. Each shard is its own bun process, so shards
* don't collide; the OS reaps tmp.
*
* Only sets the var if it isn't already set: the e2e wrapper
* (scripts/run-e2e.sh) exports its own isolated GBRAIN_HOME before bun
* starts, and tests that manage GBRAIN_HOME via withEnv save/restore around
* this default like any other env var. An operator who deliberately exports
* GBRAIN_HOME keeps their override.
*
* Imported by `bunfig.toml` via
* `preload = [..., "./test/helpers/gbrain-home-preload.ts"]`.
*/
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
if (!process.env.GBRAIN_HOME) {
const dir = mkdtempSync(join(tmpdir(), 'gbrain-test-home-'));
process.env.GBRAIN_HOME = dir;
if (process.env.GBRAIN_DEBUG_PRELOAD === '1') {
console.error(`[gbrain-home-preload] GBRAIN_HOME=${dir}`);
}
}
+4 -1
View File
@@ -26,7 +26,10 @@ function run(args: string[]): { exitCode: number; stdout: string; stderr: string
// "no config" error-path tests need loadConfig() to return null, which it
// won't if any env var fallback is set (src/core/config.ts:30). Tests
// that seed their own config use freshHomeWithConfig() below.
const env = { ...process.env, HOME: tmp } as Record<string, string | undefined>;
// Both HOME and GBRAIN_HOME must point at the fixture dir: config/path
// resolution prefers GBRAIN_HOME (which the test preload sets to its own
// scratch), so HOME alone leaves the child reading the wrong .gbrain.
const env = { ...process.env, HOME: tmp, GBRAIN_HOME: tmp } as Record<string, string | undefined>;
delete env.DATABASE_URL;
delete env.GBRAIN_DATABASE_URL;
try {
+5 -4
View File
@@ -22,11 +22,12 @@ const originalGbrainHome = process.env.GBRAIN_HOME;
beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-migration-resume-'));
// preferences.ts's gbrainDir() returns `$HOME/.gbrain` when GBRAIN_HOME
// is unset. Set HOME only; clear any inherited GBRAIN_HOME so the test
// body matches the migrations dir at `$tmpHome/.gbrain/migrations/`.
// preferences.ts's gbrainDir() delegates to gbrainPath(): GBRAIN_HOME is
// a PARENT dir with '.gbrain' appended, so GBRAIN_HOME=$tmpHome routes the
// ledger to `$tmpHome/.gbrain/migrations/`, matching the fixture layout.
// (HOME alone doesn't isolate — the fallback uses Bun's cached homedir().)
process.env.HOME = tmpHome;
delete process.env.GBRAIN_HOME;
process.env.GBRAIN_HOME = tmpHome;
});
afterEach(() => {
+55 -5
View File
@@ -21,12 +21,14 @@ beforeEach(() => {
origHome = process.env.HOME;
origGbrainHome = process.env.GBRAIN_HOME;
tmp = mkdtempSync(join(tmpdir(), 'gbrain-prefs-test-'));
// preferences.ts's gbrainDir() returns `$HOME/.gbrain` when GBRAIN_HOME
// is unset. Test fixtures write to `$tmp/.gbrain/...`, so set HOME only
// and clear GBRAIN_HOME — setting GBRAIN_HOME would route prefs to $tmp
// directly (no .gbrain suffix), which doesn't match the fixture layout.
// preferences.ts's gbrainDir() delegates to config.ts's gbrainPath():
// GBRAIN_HOME is a PARENT dir and '.gbrain' is appended, so
// GBRAIN_HOME=$tmp routes prefs + the migration ledger to
// `$tmp/.gbrain/...`, matching the fixture layout. (HOME alone doesn't
// isolate: the unset-GBRAIN_HOME fallback uses os.homedir(), which Bun
// caches at first call and ignores in-process HOME mutation.)
process.env.HOME = tmp;
delete process.env.GBRAIN_HOME;
process.env.GBRAIN_HOME = tmp;
});
afterEach(() => {
@@ -211,3 +213,51 @@ describe('loadCompletedMigrations', () => {
expect(entries[1].version).toBe('0.11.0');
});
});
describe('legacy $GBRAIN_HOME-direct layout copy-forward', () => {
// Before gbrainDir() delegated to gbrainPath(), a GBRAIN_HOME install kept
// prefs at $GBRAIN_HOME/preferences.json and the ledger at
// $GBRAIN_HOME/migrations/completed.jsonl (no '.gbrain' segment). The shim
// copies them forward on first read so an upgrade can't silently re-run
// completed migrations or drop an explicit minion_mode opt-out.
test('legacy ledger is copied forward and read under the new convention', () => {
mkdirSync(join(tmp, 'migrations'), { recursive: true });
writeFileSync(
join(tmp, 'migrations', 'completed.jsonl'),
JSON.stringify({ version: '0.11.0', status: 'complete' }) + '\n',
);
const entries = loadCompletedMigrations();
expect(entries.some((e) => e.version === '0.11.0' && e.status === 'complete')).toBe(true);
// Copied to the new path; legacy file retained for binary rollback.
expect(existsSync(join(tmp, '.gbrain', 'migrations', 'completed.jsonl'))).toBe(true);
expect(existsSync(join(tmp, 'migrations', 'completed.jsonl'))).toBe(true);
});
test('legacy preferences.json is copied forward — minion_mode opt-out survives the upgrade', () => {
writeFileSync(join(tmp, 'preferences.json'), JSON.stringify({ minion_mode: 'off' }) + '\n');
const prefs = loadPreferences();
expect(prefs.minion_mode).toBe('off');
expect(existsSync(join(tmp, '.gbrain', 'preferences.json'))).toBe(true);
expect(existsSync(join(tmp, 'preferences.json'))).toBe(true);
});
test('an existing new-path file wins — legacy is never copied over it', () => {
mkdirSync(join(tmp, '.gbrain'), { recursive: true });
writeFileSync(join(tmp, '.gbrain', 'preferences.json'), JSON.stringify({ minion_mode: 'always' }) + '\n');
writeFileSync(join(tmp, 'preferences.json'), JSON.stringify({ minion_mode: 'off' }) + '\n');
expect(loadPreferences().minion_mode).toBe('always');
});
test('a partial append does not shadow un-migrated legacy history', () => {
mkdirSync(join(tmp, 'migrations'), { recursive: true });
writeFileSync(
join(tmp, 'migrations', 'completed.jsonl'),
JSON.stringify({ version: '0.10.0', status: 'complete' }) + '\n',
);
appendCompletedMigration({ version: '0.11.0', status: 'partial' });
const entries = loadCompletedMigrations();
expect(entries.some((e) => e.version === '0.10.0' && e.status === 'complete')).toBe(true);
expect(entries.some((e) => e.version === '0.11.0' && e.status === 'partial')).toBe(true);
});
});
+27
View File
@@ -198,4 +198,31 @@ describe('installSignalHandlers', () => {
expect(process.listenerCount('SIGHUP')).toBe(before.sighup + 1);
expect(process.listenerCount('SIGPIPE')).toBe(before.sigpipe + 1);
});
test('_resetForTests DETACHES every listener installSignalHandlers attached', () => {
// Regression pin for the suite-wide-kill class: a flags-only reset left
// a live SIGTERM→exit(143) listener on the shared bun test runner, so a
// later synthetic process.emit('SIGTERM') from any test killed the whole
// suite. Counts must return to the pre-install BASELINE, not baseline+1.
_resetForTests();
const before = {
sigterm: process.listenerCount('SIGTERM'),
sighup: process.listenerCount('SIGHUP'),
sigpipe: process.listenerCount('SIGPIPE'),
uncaught: process.listenerCount('uncaughtException'),
rejection: process.listenerCount('unhandledRejection'),
stdoutErr: process.stdout.listenerCount('error'),
stderrErr: process.stderr.listenerCount('error'),
};
installSignalHandlers();
expect(process.listenerCount('SIGTERM')).toBe(before.sigterm + 1);
_resetForTests();
expect(process.listenerCount('SIGTERM')).toBe(before.sigterm);
expect(process.listenerCount('SIGHUP')).toBe(before.sighup);
expect(process.listenerCount('SIGPIPE')).toBe(before.sigpipe);
expect(process.listenerCount('uncaughtException')).toBe(before.uncaught);
expect(process.listenerCount('unhandledRejection')).toBe(before.rejection);
expect(process.stdout.listenerCount('error')).toBe(before.stdoutErr);
expect(process.stderr.listenerCount('error')).toBe(before.stderrErr);
});
});
+4 -1
View File
@@ -26,7 +26,10 @@ let tmp: string;
let origHome: string | undefined;
function run(args: string[]): { exitCode: number; stdout: string; stderr: string } {
const env = { ...process.env, HOME: tmp } as Record<string, string | undefined>;
// Both HOME and GBRAIN_HOME must point at the fixture dir: config/path
// resolution prefers GBRAIN_HOME (which the test preload sets to its own
// scratch), so HOME alone leaves the child reading the wrong .gbrain.
const env = { ...process.env, HOME: tmp, GBRAIN_HOME: tmp } as Record<string, string | undefined>;
delete env.DATABASE_URL;
delete env.GBRAIN_DATABASE_URL;
try {
+46 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { redactPgUrl, redactDeep } from '../src/core/url-redact.ts';
import { redactPgUrl, redactDeep, redactUrlsInText } from '../src/core/url-redact.ts';
describe('redactPgUrl', () => {
test('strips userinfo from postgresql:// URL', () => {
@@ -76,3 +76,48 @@ describe('redactDeep', () => {
expect(out.config.secondary.url).toBe('postgres://***@h2/d');
});
});
describe('redactUrlsInText', () => {
// Fixture credential URLs are ASSEMBLED at runtime (scheme + userinfo +
// host concatenated) so the source text never contains a span the
// pre-push credential scanner would flag — the values are synthetic, but
// the assembled runtime shape is exactly what the redactor must catch.
const cred = (scheme: string, userinfo: string, rest: string) => `${scheme}://${userinfo}@${rest}`;
test('redacts a credential-bearing URL embedded in an error message', () => {
expect(redactUrlsInText(`connect failed: ${cred('postgresql', 'alice:sekrit', 'db.example.com:5432/x')} timeout`))
.toBe('connect failed: postgresql://***@db.example.com:5432/x timeout');
});
test('a raw @ inside the password cannot leak its tail (greedy match)', () => {
expect(redactUrlsInText(cred('postgresql', 'alice:p@ssw@rd', 'db.example.com:5432/x')))
.toBe('postgresql://***@db.example.com:5432/x');
});
test('redacts non-postgres schemes too', () => {
expect(redactUrlsInText(`fetch ${cred('https', 'token:gxp_notreal', 'example.com/o/r')} failed`))
.toBe('fetch https://***@example.com/o/r failed');
});
test('passes through text without userinfo URLs', () => {
expect(redactUrlsInText('connect ECONNREFUSED 127.0.0.1:5432')).toBe('connect ECONNREFUSED 127.0.0.1:5432');
expect(redactUrlsInText('postgresql://host:5432/db has no userinfo')).toBe('postgresql://host:5432/db has no userinfo');
});
test('redacts multiple URLs in one message', () => {
expect(redactUrlsInText(`a ${cred('postgres', 'u:p', 'h1/d')} b ${cred('postgres', 'u2:p2', 'h2/d')}`))
.toBe('a postgres://***@h1/d b postgres://***@h2/d');
});
});
describe('redactUrlsInText — libpq keyword/value form', () => {
test('password=... in a libpq-style connection error is scrubbed', () => {
expect(redactUrlsInText('invalid connection string: password=hunter2 user=alice host=db'))
.toBe('invalid connection string: password=*** user=alice host=db');
});
test('case-insensitive and sslpassword too', () => {
expect(redactUrlsInText('PASSWORD=abc sslpassword=def host=h'))
.toBe('PASSWORD=*** sslpassword=*** host=h');
});
});