mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:34:35 +00:00
* fix(test): make resetGateway restore the test baseline instead of unconfiguring (#3554) bunfig.toml's legacy-embedding-preload pins the gateway once at process start to openai:text-embedding-3-large @ 1536, but resetGateway() wiped that pin to _config = null. The next test file's beforeAll engine-connect then reconfigured from the SHIPPED default (zembed-1 @ 1280) before the preload's per-test beforeEach could restore anything, and every 1536-d fixture in that file failed with `expected 1280 dimensions, not 1536`. Which file pairs collided depended on shard bin-packing, so adding ANY test file reshuffled the mines (this is what blocks #3545). Fix: the preload registers its config as a reset baseline via a new test-only seam (__setGatewayResetBaselineForTests); resetGateway() clears all module state as before, then re-applies the baseline. All 93 existing resetGateway() call sites get the correct behavior with zero edits. Production is untouched: nothing in src/ calls resetGateway() or the setter, so the baseline is never registered outside tests and resetGateway() still fully unconfigures there. Five tests genuinely need an unconfigured gateway (no_gateway_config diagnosis, isAvailable=false, the #2590 cold-gateway path, the registry builtin-default tier); they switch to the new __unconfigureGatewayForTests. Two files' hand-rolled "restore the legacy pin in afterAll/finally" workarounds for this exact bug are now redundant and simplified away. Guard test (test/ai/gateway-reset-baseline.test.ts) pins the contract: 1536/openai immediately after resetGateway(), transports still cleared, hard-unconfigure still available. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(test): restore preload's GBRAIN_AUDIT_DIR instead of deleting it (#3554 sibling) Same bug class as the gateway fix in this PR: state set once by a bunfig preload (audit-dir-preload's scratch GBRAIN_AUDIT_DIR), wiped by one file's cleanup, blast radius decided by shard bin-packing. In shard 6, test/minions-shell.test.ts (position 12) unconditionally deleted the var in afterAll; test/audit/audit-dir-preload.test.ts (position 93) then found it undefined and failed 3 tests — and every file in between wrote audit fixtures toward the operator's real ~/.gbrain/audit/. Fix: capture the prior value at file load and conditionally restore it, the same inline save/restore pattern 11 sibling files already use. test/e2e/skill-brain-first.test.ts had the identical unconditional delete in afterEach; fixed the same way. Sweep of every `delete process.env.GBRAIN_AUDIT_DIR` in test/ confirms all remaining sites are conditional restores. Ordered-pair proof (minions-shell.test.ts then audit/audit-dir-preload.test.ts, one process): 3 fail on master, 43/43 pass with this fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Garry Tan <garrytan@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
363 lines
15 KiB
TypeScript
363 lines
15 KiB
TypeScript
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { MinionQueue } from '../src/core/minions/queue.ts';
|
|
import { UnrecoverableError } from '../src/core/minions/types.ts';
|
|
import type { MinionJobContext } from '../src/core/minions/types.ts';
|
|
import { shellHandler } from '../src/core/minions/handlers/shell.ts';
|
|
import { computeAuditFilename, resolveAuditDir, logShellSubmission } from '../src/core/minions/handlers/shell-audit.ts';
|
|
import { isProtectedJobName, PROTECTED_JOB_NAMES } from '../src/core/minions/protected-names.ts';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import * as os from 'node:os';
|
|
|
|
let engine: PGLiteEngine;
|
|
let queue: MinionQueue;
|
|
// The shell handler at src/core/minions/handlers/shell.ts:210 throws
|
|
// UnrecoverableError when GBRAIN_ALLOW_SHELL_JOBS !== '1'. That's the
|
|
// production-worker RCE guard. Unit tests here exercise the handler
|
|
// mechanics, not the guard, so we enable it for the whole file and
|
|
// restore on teardown. The separate "rejects when env not set" case
|
|
// (in the minion-shell submission E2E / the queue-resilience wave)
|
|
// toggles the var itself.
|
|
let prevAllowShellJobs: string | undefined;
|
|
|
|
beforeAll(async () => {
|
|
prevAllowShellJobs = process.env.GBRAIN_ALLOW_SHELL_JOBS;
|
|
process.env.GBRAIN_ALLOW_SHELL_JOBS = '1';
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({ database_url: '' });
|
|
await engine.initSchema();
|
|
queue = new MinionQueue(engine);
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
if (prevAllowShellJobs === undefined) delete process.env.GBRAIN_ALLOW_SHELL_JOBS;
|
|
else process.env.GBRAIN_ALLOW_SHELL_JOBS = prevAllowShellJobs;
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await engine.executeRaw('DELETE FROM minion_jobs');
|
|
});
|
|
|
|
// Build a minimal MinionJobContext for unit tests. Real worker provides this;
|
|
// here we mock it so the handler can be exercised without spinning up Postgres.
|
|
function makeCtx(
|
|
data: Record<string, unknown>,
|
|
opts: { signal?: AbortSignal; shutdownSignal?: AbortSignal } = {},
|
|
): MinionJobContext {
|
|
return {
|
|
id: 1,
|
|
name: 'shell',
|
|
data,
|
|
attempts_made: 0,
|
|
signal: opts.signal ?? new AbortController().signal,
|
|
deadlineAtMs: null,
|
|
shutdownSignal: opts.shutdownSignal ?? new AbortController().signal,
|
|
updateProgress: async () => {},
|
|
updateTokens: async () => {},
|
|
log: async () => {},
|
|
isActive: async () => true,
|
|
readInbox: async () => [],
|
|
};
|
|
}
|
|
|
|
// ---- protected-names ---------------------------------------------------------
|
|
|
|
describe('protected-names', () => {
|
|
test('shell is protected', () => {
|
|
expect(isProtectedJobName('shell')).toBe(true);
|
|
expect(PROTECTED_JOB_NAMES.has('shell')).toBe(true);
|
|
});
|
|
test('normalization: whitespace is trimmed before check', () => {
|
|
expect(isProtectedJobName(' shell ')).toBe(true);
|
|
expect(isProtectedJobName('\tshell\n')).toBe(true);
|
|
});
|
|
test('case-sensitive: Shell is NOT protected', () => {
|
|
expect(isProtectedJobName('Shell')).toBe(false);
|
|
expect(isProtectedJobName('SHELL')).toBe(false);
|
|
});
|
|
test('non-protected names pass through', () => {
|
|
expect(isProtectedJobName('sync')).toBe(false);
|
|
expect(isProtectedJobName('embed')).toBe(false);
|
|
expect(isProtectedJobName('')).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---- MinionQueue.add trusted guard ------------------------------------------
|
|
|
|
describe('MinionQueue.add protected-name guard', () => {
|
|
test('add("shell", ...) without trusted arg throws', async () => {
|
|
expect(queue.add('shell', { cmd: 'echo', cwd: '/tmp' })).rejects.toThrow(/protected job name/);
|
|
});
|
|
test('add("shell", ..., opts, {allowProtectedSubmit:true}) succeeds', async () => {
|
|
const job = await queue.add('shell', { cmd: 'echo', cwd: '/tmp' }, undefined, { allowProtectedSubmit: true });
|
|
expect(job.name).toBe('shell');
|
|
expect(job.status).toBe('waiting');
|
|
});
|
|
// Whitespace bypass defense (Codex #1)
|
|
test('add(" shell ", ...) without trusted arg throws (whitespace bypass defense)', async () => {
|
|
expect(queue.add(' shell ', { cmd: 'echo', cwd: '/tmp' })).rejects.toThrow(/protected job name/);
|
|
});
|
|
test('add(" shell ", ...) with trusted arg inserts normalized name "shell"', async () => {
|
|
const job = await queue.add(' shell ', { cmd: 'echo', cwd: '/tmp' }, undefined, { allowProtectedSubmit: true });
|
|
expect(job.name).toBe('shell');
|
|
});
|
|
test('add("Shell", ...) is treated as non-protected (case-sensitive)', async () => {
|
|
const job = await queue.add('Shell', {});
|
|
expect(job.name).toBe('Shell');
|
|
expect(job.status).toBe('waiting');
|
|
});
|
|
// Regression: non-protected names unaffected (Codex iron-rule)
|
|
test('REGRESSION: add("sync", ...) without trusted arg still succeeds', async () => {
|
|
const job = await queue.add('sync', { full: true });
|
|
expect(job.name).toBe('sync');
|
|
expect(job.status).toBe('waiting');
|
|
});
|
|
test('REGRESSION: trusted flag does NOT bypass empty-name check', async () => {
|
|
expect(queue.add('', {}, undefined, { allowProtectedSubmit: true })).rejects.toThrow(/cannot be empty/);
|
|
});
|
|
});
|
|
|
|
// ---- Shell handler: validation ----------------------------------------------
|
|
|
|
describe('shell handler: validation', () => {
|
|
test('both cmd and argv → UnrecoverableError', async () => {
|
|
const p = shellHandler(makeCtx({ cmd: 'echo', argv: ['echo'], cwd: '/tmp' }));
|
|
expect(p).rejects.toThrow(UnrecoverableError);
|
|
});
|
|
test('neither cmd nor argv → UnrecoverableError', async () => {
|
|
const p = shellHandler(makeCtx({ cwd: '/tmp' }));
|
|
expect(p).rejects.toThrow(UnrecoverableError);
|
|
});
|
|
test('cwd missing → UnrecoverableError', async () => {
|
|
const p = shellHandler(makeCtx({ cmd: 'echo ok' }));
|
|
expect(p).rejects.toThrow(UnrecoverableError);
|
|
});
|
|
test('cwd not absolute → UnrecoverableError', async () => {
|
|
const p = shellHandler(makeCtx({ cmd: 'echo ok', cwd: 'relative/path' }));
|
|
expect(p).rejects.toThrow(UnrecoverableError);
|
|
});
|
|
test('argv non-array (string) → UnrecoverableError', async () => {
|
|
const p = shellHandler(makeCtx({ argv: 'echo ok', cwd: '/tmp' }));
|
|
expect(p).rejects.toThrow(UnrecoverableError);
|
|
});
|
|
test('argv with non-string entries → UnrecoverableError', async () => {
|
|
const p = shellHandler(makeCtx({ argv: ['echo', 42], cwd: '/tmp' }));
|
|
expect(p).rejects.toThrow(UnrecoverableError);
|
|
});
|
|
test('env with non-string values → UnrecoverableError', async () => {
|
|
const p = shellHandler(makeCtx({ cmd: 'echo', cwd: '/tmp', env: { FOO: 42 } }));
|
|
expect(p).rejects.toThrow(UnrecoverableError);
|
|
});
|
|
});
|
|
|
|
// ---- Shell handler: spawn + output ------------------------------------------
|
|
|
|
describe('shell handler: spawn', () => {
|
|
test('cmd happy path: echo ok → exit 0, stdout captured', async () => {
|
|
const res = await shellHandler(makeCtx({ cmd: 'echo ok', cwd: '/tmp' })) as any;
|
|
expect(res.exit_code).toBe(0);
|
|
expect(res.stdout_tail).toBe('ok\n');
|
|
expect(res.stderr_tail).toBe('');
|
|
expect(typeof res.duration_ms).toBe('number');
|
|
expect(res.duration_ms).toBeGreaterThanOrEqual(0);
|
|
expect(typeof res.pid).toBe('number');
|
|
});
|
|
test('argv happy path: ["echo","hi"] → exit 0, stdout "hi\\n"', async () => {
|
|
const res = await shellHandler(makeCtx({ argv: ['echo', 'hi'], cwd: '/tmp' })) as any;
|
|
expect(res.exit_code).toBe(0);
|
|
expect(res.stdout_tail).toBe('hi\n');
|
|
});
|
|
test('non-zero exit → Error with stderr in message', async () => {
|
|
const p = shellHandler(makeCtx({ cmd: 'echo fail 1>&2; exit 7', cwd: '/tmp' }));
|
|
await expect(p).rejects.toThrow(/exit 7/);
|
|
});
|
|
test('argv with bogus binary → Error (retryable)', async () => {
|
|
const p = shellHandler(makeCtx({ argv: ['gbrain-nonexistent-binary-xyz'], cwd: '/tmp' }));
|
|
// spawn emits 'error' on ENOENT
|
|
await expect(p).rejects.toThrow();
|
|
});
|
|
test('result shape includes all declared keys', async () => {
|
|
const res = await shellHandler(makeCtx({ cmd: 'echo ok', cwd: '/tmp' })) as any;
|
|
expect(Object.keys(res).sort()).toEqual(['duration_ms', 'exit_code', 'pid', 'stderr_tail', 'stdout_tail']);
|
|
});
|
|
});
|
|
|
|
// ---- Shell handler: env allowlist -------------------------------------------
|
|
|
|
describe('shell handler: env allowlist', () => {
|
|
test('process env leak prevention: a faux secret is NOT in child env', async () => {
|
|
const saved = process.env.SHELL_TEST_SECRET;
|
|
process.env.SHELL_TEST_SECRET = 'should-not-leak';
|
|
try {
|
|
const res = await shellHandler(makeCtx({
|
|
cmd: 'echo "secret=${SHELL_TEST_SECRET:-EMPTY}"',
|
|
cwd: '/tmp',
|
|
})) as any;
|
|
expect(res.stdout_tail).toBe('secret=EMPTY\n');
|
|
} finally {
|
|
if (saved === undefined) delete process.env.SHELL_TEST_SECRET;
|
|
else process.env.SHELL_TEST_SECRET = saved;
|
|
}
|
|
});
|
|
test('PATH is inherited from worker', async () => {
|
|
const res = await shellHandler(makeCtx({
|
|
cmd: 'echo "path=$PATH"',
|
|
cwd: '/tmp',
|
|
})) as any;
|
|
expect(res.stdout_tail.startsWith('path=')).toBe(true);
|
|
expect(res.stdout_tail.length).toBeGreaterThan('path=\n'.length);
|
|
});
|
|
test('caller-supplied env key is added', async () => {
|
|
const res = await shellHandler(makeCtx({
|
|
cmd: 'echo "val=$MY_CUSTOM"',
|
|
cwd: '/tmp',
|
|
env: { MY_CUSTOM: 'hello' },
|
|
})) as any;
|
|
expect(res.stdout_tail).toBe('val=hello\n');
|
|
});
|
|
test('caller-supplied env can override allowlisted key (PATH)', async () => {
|
|
const res = await shellHandler(makeCtx({
|
|
cmd: 'echo "path=$PATH"',
|
|
cwd: '/tmp',
|
|
env: { PATH: '/custom/bin' },
|
|
})) as any;
|
|
expect(res.stdout_tail).toBe('path=/custom/bin\n');
|
|
});
|
|
});
|
|
|
|
// ---- Shell handler: abort --------------------------------------------------
|
|
|
|
describe('shell handler: abort', () => {
|
|
test('ctx.signal.abort triggers SIGTERM and handler throws aborted', async () => {
|
|
const ac = new AbortController();
|
|
const promise = shellHandler(makeCtx(
|
|
{ cmd: 'sleep 30', cwd: '/tmp' },
|
|
{ signal: ac.signal },
|
|
));
|
|
// Give spawn a beat to start
|
|
setTimeout(() => ac.abort(new Error('cancel')), 50);
|
|
await expect(promise).rejects.toThrow(/aborted/);
|
|
});
|
|
test('ctx.shutdownSignal.abort also triggers kill', async () => {
|
|
const shutdownCtl = new AbortController();
|
|
const promise = shellHandler(makeCtx(
|
|
{ cmd: 'sleep 30', cwd: '/tmp' },
|
|
{ shutdownSignal: shutdownCtl.signal },
|
|
));
|
|
setTimeout(() => shutdownCtl.abort(new Error('shutdown')), 50);
|
|
await expect(promise).rejects.toThrow(/aborted/);
|
|
});
|
|
test('pre-aborted signal → immediate kill', async () => {
|
|
const ac = new AbortController();
|
|
ac.abort(new Error('cancel'));
|
|
const promise = shellHandler(makeCtx(
|
|
{ cmd: 'sleep 30', cwd: '/tmp' },
|
|
{ signal: ac.signal },
|
|
));
|
|
await expect(promise).rejects.toThrow(/aborted/);
|
|
});
|
|
});
|
|
|
|
// ---- shell-audit: ISO-week filename ----------------------------------------
|
|
|
|
describe('shell-audit: computeAuditFilename', () => {
|
|
test('2027-01-01 is ISO week 53 of 2026', () => {
|
|
expect(computeAuditFilename(new Date('2027-01-01T12:00:00Z'))).toBe('shell-jobs-2026-W53.jsonl');
|
|
});
|
|
test('2026-12-28 (Monday) is ISO week 53 of 2026', () => {
|
|
expect(computeAuditFilename(new Date('2026-12-28T12:00:00Z'))).toBe('shell-jobs-2026-W53.jsonl');
|
|
});
|
|
test('2027-01-04 (Monday) is ISO week 1 of 2027', () => {
|
|
expect(computeAuditFilename(new Date('2027-01-04T12:00:00Z'))).toBe('shell-jobs-2027-W01.jsonl');
|
|
});
|
|
test('2026-04-19 (mid-year reference)', () => {
|
|
const f = computeAuditFilename(new Date('2026-04-19T00:00:00Z'));
|
|
expect(f).toMatch(/^shell-jobs-2026-W\d{2}\.jsonl$/);
|
|
});
|
|
});
|
|
|
|
// ---- shell-audit: write path -----------------------------------------------
|
|
|
|
describe('shell-audit: write', () => {
|
|
let tmpDir: string;
|
|
// #3554-sibling: the audit-dir preload sets GBRAIN_AUDIT_DIR once at
|
|
// process start; deleting it here (instead of restoring) let every file
|
|
// AFTER this one in the shard write audit fixtures to the operator's
|
|
// real ~/.gbrain/audit/ — and failed audit-dir-preload.test.ts whenever
|
|
// bin-packing placed it later in the shard. Restore the prior value.
|
|
const priorAuditDir = process.env.GBRAIN_AUDIT_DIR;
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-audit-test-'));
|
|
process.env.GBRAIN_AUDIT_DIR = tmpDir;
|
|
});
|
|
afterAll(() => {
|
|
if (priorAuditDir === undefined) delete process.env.GBRAIN_AUDIT_DIR;
|
|
else process.env.GBRAIN_AUDIT_DIR = priorAuditDir;
|
|
});
|
|
|
|
test('GBRAIN_AUDIT_DIR env override resolves to the custom dir', () => {
|
|
expect(resolveAuditDir()).toBe(tmpDir);
|
|
});
|
|
test('writes a JSONL line; creates dir if missing', () => {
|
|
const inner = path.join(tmpDir, 'nested-not-yet-created');
|
|
process.env.GBRAIN_AUDIT_DIR = inner;
|
|
logShellSubmission({
|
|
caller: 'cli', remote: false, job_id: 42, cwd: '/tmp', cmd_display: 'echo ok',
|
|
});
|
|
const files = fs.readdirSync(inner);
|
|
expect(files.length).toBe(1);
|
|
const content = fs.readFileSync(path.join(inner, files[0]), 'utf8').trim();
|
|
const parsed = JSON.parse(content);
|
|
expect(parsed.caller).toBe('cli');
|
|
expect(parsed.job_id).toBe(42);
|
|
expect(parsed.cmd_display).toBe('echo ok');
|
|
expect(parsed.ts).toBeDefined();
|
|
});
|
|
test('argv_display stored as JSON array (Codex #11)', () => {
|
|
logShellSubmission({
|
|
caller: 'cli', remote: false, job_id: 1, cwd: '/tmp',
|
|
argv_display: ['node', 'script.mjs', '--date', '2026-04-18'],
|
|
});
|
|
const files = fs.readdirSync(tmpDir);
|
|
const content = fs.readFileSync(path.join(tmpDir, files[0]), 'utf8').trim();
|
|
const parsed = JSON.parse(content);
|
|
expect(Array.isArray(parsed.argv_display)).toBe(true);
|
|
expect(parsed.argv_display).toEqual(['node', 'script.mjs', '--date', '2026-04-18']);
|
|
});
|
|
test('does NOT log env values', () => {
|
|
logShellSubmission({
|
|
caller: 'cli', remote: false, job_id: 1, cwd: '/tmp', cmd_display: 'echo ok',
|
|
});
|
|
const files = fs.readdirSync(tmpDir);
|
|
const content = fs.readFileSync(path.join(tmpDir, files[0]), 'utf8');
|
|
expect(content).not.toContain('env');
|
|
});
|
|
test('write failure (EACCES) is non-blocking', () => {
|
|
// Point at a read-only target. /dev/null is not a directory.
|
|
process.env.GBRAIN_AUDIT_DIR = '/dev/null/not-a-dir';
|
|
// Should not throw — failures go to stderr.
|
|
expect(() => logShellSubmission({
|
|
caller: 'cli', remote: false, job_id: 1, cwd: '/tmp',
|
|
})).not.toThrow();
|
|
});
|
|
});
|
|
|
|
// ---- shell handler: UTF-8-safe output truncation ---------------------------
|
|
|
|
describe('shell handler: output truncation', () => {
|
|
test('stdout > 64KB is truncated and marker is prepended', async () => {
|
|
// Emit ~100KB of stdout to force truncation
|
|
const res = await shellHandler(makeCtx({
|
|
cmd: `yes ok | head -c 100000`,
|
|
cwd: '/tmp',
|
|
})) as any;
|
|
expect(res.exit_code).toBe(0);
|
|
expect(res.stdout_tail).toMatch(/^\[truncated \d+ bytes\]/);
|
|
expect(res.stdout_tail.length).toBeGreaterThan(0);
|
|
// Tail must contain characters we emitted
|
|
expect(res.stdout_tail).toContain('ok');
|
|
});
|
|
});
|