mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-16 09:52:22 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9131aafafe | ||
|
|
993517b0e4 | ||
|
|
3d6abfd56d | ||
|
|
937819861f | ||
|
|
6b6f021023 | ||
|
|
62feb3a230 | ||
|
|
eb33cd2dee | ||
|
|
786049a9fa | ||
|
|
96c86ba3d9 |
@@ -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. -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.42–v0.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.
|
||||
|
||||
+7
-1
@@ -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"]
|
||||
|
||||
@@ -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,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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")/.."
|
||||
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 SIGTERM→exit(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
|
||||
* SIGTERM→exit(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;
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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/SIGHUP→exit handlers. The install lives inside the
|
||||
* `import.meta.main` seam; at module load it leaked a process-wide
|
||||
* SIGTERM→exit(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');
|
||||
});
|
||||
});
|
||||
@@ -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 { /* */ } }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 ?? ''}`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user