mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
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>
153 lines
6.7 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
});
|