Files
gbrain/test/gbrain-home-isolation.test.ts
T
42c4ea929f fix(test): isolate audit writes to a per-run scratch dir in the shared bootstrap (#2823) (#2966)
The content-sanity gate's audit logger (logContentSanityAssessment)
defaults, via audit-writer.ts::resolveAuditDir(), to writing
~/.gbrain/audit/content-sanity-YYYY-Www.jsonl on disk. A GBRAIN_AUDIT_DIR
env override exists, but nothing in the shared test bootstrap ever set
it, so any test that exercised an audit-emitting code path without
wrapping the call in its own withEnv() fell through to the operator's
real audit trail. test/import-file.test.ts's oversize-boundary fixture
('borderline-slug', content just under MAX_FILE_SIZE but over
DEFAULT_BYTES_BLOCK) fired a real soft_block event into the developer's
live ~/.gbrain/audit on every run — which doctor's
content_sanity_audit_recent check then reported as production signal.

Fix: add a bootstrap preload (test/helpers/audit-dir-preload.ts, wired
via bunfig.toml) that points GBRAIN_AUDIT_DIR at a fresh per-process
mkdtemp dir before any test file loads. Each run-unit-shard.sh shard is
its own bun process, so each shard gets its own scratch dir with no
cross-shard collision. This closes the leak for every audit-emitting
test, not just this fixture. It respects a developer-exported override
(only sets the var when unset), and files that manage their own
per-test GBRAIN_AUDIT_DIR via withEnv() are unaffected.

Also fix a latent isolation bug this surfaced: gbrain-home-isolation.test.ts
unconditionally deleted GBRAIN_AUDIT_DIR in a finally block instead of
restoring the prior value, which clobbered the bootstrap's scratch dir
for every test file that ran after it in the same shard process.

Adds test/audit/audit-dir-preload.test.ts to pin the behavior: it
reproduces the exact soft_block event shape and asserts it lands in the
scratch dir, never in ~/.gbrain/audit.

Reported by @paul-0320.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 23:07:58 -07:00

153 lines
6.7 KiB
TypeScript

/**
* Hermeticity test: every site that writes under `~/.gbrain` must honor
* `GBRAIN_HOME=<tmp>` and write under `<tmp>/.gbrain` instead of the developer's
* real home.
*
* Why this exists: `src/core/config.ts::configDir()` already supports
* `GBRAIN_HOME` as a parent-dir override (returns `<override>/.gbrain`), but
* historically many call sites built paths from `os.homedir()` directly,
* bypassing the override. The hermeticity migration migrated every write-side
* caller to `gbrainPath(...)`. This test is the regression gate.
*
* Scope: write-isolation only. Read-side host detection in
* `src/commands/init.ts` (reading `~/.claude`, `~/.openclaw`, etc. for module
* fingerprinting) is the documented v1 caveat and is NOT asserted here.
*/
import { describe, test, expect } from 'bun:test';
import { mkdtempSync, existsSync, readdirSync, statSync, rmSync } from 'fs';
import { homedir, tmpdir } from 'os';
import { join } from 'path';
// Save original env so we don't leak between tests. #2823: GBRAIN_AUDIT_DIR
// must be captured too — the shared test bootstrap (test/helpers/audit-dir-preload.ts)
// sets a process-global scratch dir before any test file runs, so "restore"
// here means "put back the preload's value," not "delete the var and let
// it fall through to the real ~/.gbrain/audit for every test file that
// runs after this one in the same shard process."
const ORIG_GBRAIN_HOME = process.env.GBRAIN_HOME;
const ORIG_GBRAIN_AUDIT_DIR = process.env.GBRAIN_AUDIT_DIR;
function fresh(): string {
return mkdtempSync(join(tmpdir(), 'gbrain-home-isolation-'));
}
describe('GBRAIN_HOME write-side isolation', () => {
test('configDir() returns <GBRAIN_HOME>/.gbrain when override is set', async () => {
const tmp = fresh();
process.env.GBRAIN_HOME = tmp;
try {
const { configDir, gbrainPath } = await import('../src/core/config.ts');
expect(configDir()).toBe(join(tmp, '.gbrain'));
expect(gbrainPath('foo', 'bar.json')).toBe(join(tmp, '.gbrain', 'foo', 'bar.json'));
} finally {
process.env.GBRAIN_HOME = ORIG_GBRAIN_HOME;
rmSync(tmp, { recursive: true, force: true });
}
});
test('configDir() falls back to homedir when GBRAIN_HOME unset', async () => {
delete process.env.GBRAIN_HOME;
try {
const { configDir } = await import('../src/core/config.ts');
// Contract: when GBRAIN_HOME is unset, configDir() === os.homedir()/.gbrain.
// Asserting against os.homedir() (rather than a "not /tmp/" sentinel) keeps
// this test correct under safety wrappers that redirect HOME=/tmp/... — the
// behavior we care about is that the fallback path equals homedir().
expect(configDir()).toBe(join(homedir(), '.gbrain'));
} finally {
if (ORIG_GBRAIN_HOME !== undefined) process.env.GBRAIN_HOME = ORIG_GBRAIN_HOME;
}
});
test('rejects relative GBRAIN_HOME', async () => {
process.env.GBRAIN_HOME = 'relative/path';
try {
const { configDir } = await import('../src/core/config.ts');
expect(() => configDir()).toThrow(/absolute path/);
} finally {
process.env.GBRAIN_HOME = ORIG_GBRAIN_HOME;
}
});
test("rejects GBRAIN_HOME containing '..' segments", async () => {
process.env.GBRAIN_HOME = '/tmp/foo/../bar';
try {
const { configDir } = await import('../src/core/config.ts');
expect(() => configDir()).toThrow(/'\.\.' segments/);
} finally {
process.env.GBRAIN_HOME = ORIG_GBRAIN_HOME;
}
});
test('saveConfig/loadConfig honor GBRAIN_HOME', async () => {
const tmp = fresh();
process.env.GBRAIN_HOME = tmp;
try {
const { saveConfig, loadConfig } = await import('../src/core/config.ts');
const cfg = { engine: 'pglite' as const, database_path: join(tmp, '.gbrain', 'brain.pglite') };
saveConfig(cfg);
// Config file should exist under the override, NOT under real ~/.gbrain.
expect(existsSync(join(tmp, '.gbrain', 'config.json'))).toBe(true);
// Round-trip: loadConfig() finds it back via the override.
const loaded = loadConfig();
expect(loaded?.engine).toBe('pglite');
expect(loaded?.database_path).toBe(cfg.database_path);
} finally {
process.env.GBRAIN_HOME = ORIG_GBRAIN_HOME;
rmSync(tmp, { recursive: true, force: true });
}
});
test('integrity, sync-failures, integrations heartbeat resolve under GBRAIN_HOME', async () => {
const tmp = fresh();
process.env.GBRAIN_HOME = tmp;
try {
const { gbrainPath } = await import('../src/core/config.ts');
// Spot-check a representative set of paths used across the migrated sites.
const paths = [
gbrainPath('integrity-review.md'), // src/commands/integrity.ts
gbrainPath('sync-failures.jsonl'), // src/core/sync.ts
gbrainPath('integrations', 'recipe-x'), // src/commands/integrations.ts
gbrainPath('migrate-manifest.json'), // src/commands/migrate-engine.ts
gbrainPath('import-checkpoint.json'), // src/commands/import.ts
gbrainPath('migrations', 'v0_13_1-rollback.jsonl'), // src/commands/migrations/v0_13_1.ts
gbrainPath('migrations', 'pending-host-work.jsonl'), // src/commands/migrations/v0_14_0.ts
gbrainPath('audit'), // shell-audit / backpressure-audit
gbrainPath('cycle.lock'), // src/core/cycle.ts
gbrainPath('fail-improve'), // src/core/fail-improve.ts
gbrainPath('validator-lint.jsonl'), // src/core/output/post-write.ts
gbrainPath('brain.pglite'), // init pglite default
];
for (const p of paths) {
expect(p.startsWith(join(tmp, '.gbrain'))).toBe(true);
}
} finally {
process.env.GBRAIN_HOME = ORIG_GBRAIN_HOME;
rmSync(tmp, { recursive: true, force: true });
}
});
test('GBRAIN_AUDIT_DIR override still wins over GBRAIN_HOME', async () => {
const tmp = fresh();
const auditTmp = fresh();
process.env.GBRAIN_HOME = tmp;
process.env.GBRAIN_AUDIT_DIR = auditTmp;
try {
const { resolveAuditDir } = await import('../src/core/minions/handlers/shell-audit.ts');
// Per the docstring: GBRAIN_AUDIT_DIR is the explicit override and wins.
expect(resolveAuditDir()).toBe(auditTmp);
} finally {
process.env.GBRAIN_HOME = ORIG_GBRAIN_HOME;
if (ORIG_GBRAIN_AUDIT_DIR === undefined) {
delete process.env.GBRAIN_AUDIT_DIR;
} else {
process.env.GBRAIN_AUDIT_DIR = ORIG_GBRAIN_AUDIT_DIR;
}
rmSync(tmp, { recursive: true, force: true });
rmSync(auditTmp, { recursive: true, force: true });
}
});
});