mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(engine): batch inserts use jsonb_to_recordset, not text[] array literals (#1861) addLinksBatch/addTimelineEntriesBatch/addTakesBatch passed free text through unnest(${arr}::text[]); postgres.js serialized it to a Postgres text[] literal that array_in rejected ("malformed array literal") on calendar/Zoom context, aborting the whole `extract links --stale` sweep. Bind the batch as one JSONB doc via jsonb_to_recordset(($1::jsonb)->'rows') through the audited executeRawJsonb contract instead. Shared row builders (src/core/batch-rows.ts) keep both engines byte-identical; NUL is stripped only from free-text body fields (context/summary/detail/claim), while identity/security fields (slugs/source_ids/holder/kind/dates) still reject NUL. addTakesBatch is now batchRetry-wrapped ('addTakesBatch' audit site) and its BrainEngine signature takes BatchOpts. Scalar addLink context is NUL-stripped too. Regression tests on both engines: PGLite always-on poison/NUL/parity suite + DATABASE_URL-gated Postgres lane (the engine that actually crashed). * test: make "no Anthropic key" tests hermetic via withoutAnthropicKey hasAnthropicKey() reads both ANTHROPIC_API_KEY and ~/.gbrain config; tests that only deleted the env var fired a real LLM call on configured machines (warning flipped NO_ANTHROPIC_API_KEY -> LLM_OUTPUT_NOT_JSON). New test/helpers/no-anthropic-key.ts neutralizes both sources (env + GBRAIN_HOME temp dir) for the duration of the call. Refactors the five no-key tests in think-pipeline + takes-mcp-allowlist to use it, including two that previously passed only by luck of the live LLM output. * chore: docs + version bump (v0.42.28.0) KEY_FILES.md/RETRIEVAL.md describe the jsonb_to_recordset batch path; TODOS.md files the #1861 follow-ups (element-isolation, remaining ::text[] sites, shared SQL-string hoist, batch-insert edge-case tests). CHANGELOG + VERSION + package.json to 0.42.28.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: sync TESTING.md batch-insert references for v0.42.28.0 The #1861 fix migrated links/timeline/takes batch inserts from unnest(::text[]) to jsonb_to_recordset. Update the stale "postgres-js unnest() binding" note and add the two new poison-regression test files (test/links-timeline-jsonb-poison.test.ts PGLite half, test/e2e/jsonb-batch-poison-postgres.test.ts Postgres lane) to the inventory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sql-query): reject top-level array jsonb params in executeRawJsonb (#1861 P2a) The "no top-level array" rule was only a comment. A bare JS array bound to a $N::jsonb position can serialize as a Postgres array literal (not jsonb) through postgres.js, silently re-entering the "malformed array literal" class #1861 just escaped. executeRawJsonb now throws a clear error steering callers to the { rows: [...] } object wrapper. Verified breaks zero call sites (all pass objects or null). Codex adversarial P2a; batch-size enforcement (P2b) filed as a TODO. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
241 lines
11 KiB
TypeScript
241 lines
11 KiB
TypeScript
/**
|
|
* v0.28: integration test that proves the per-token takes-holder allow-list
|
|
* filters server-side through the dispatch layer (Codex P0 #3 fix
|
|
* verification). PGLite-only; no DATABASE_URL required.
|
|
*
|
|
* Threads:
|
|
* 1. Auth wires `permissions.takes_holders` from `access_tokens` → AuthResult
|
|
* 2. HTTP transport passes `auth.takesHoldersAllowList` to dispatchToolCall
|
|
* 3. dispatch.ts threads it into OperationContext.takesHoldersAllowList
|
|
* 4. takes_list / takes_search ops pass it to engine.listTakes / .searchTakes
|
|
* 5. engine SQL applies `AND holder = ANY($allowList)`
|
|
*
|
|
* This test exercises step 3-5 directly through dispatchToolCall.
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { withoutAnthropicKey } from './helpers/no-anthropic-key.ts';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { dispatchToolCall } from '../src/mcp/dispatch.ts';
|
|
import { TAKES_FENCE_BEGIN, TAKES_FENCE_END } from '../src/core/takes-fence.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
let alicePageId: number;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
const alice = await engine.putPage('people/alice-example', {
|
|
title: 'Alice', type: 'person', compiled_truth: '## Takes\n',
|
|
});
|
|
alicePageId = alice.id;
|
|
// Seed three takes by three holders. Public fact, garry's bet, brain's hunch.
|
|
await engine.addTakesBatch([
|
|
{ page_id: alicePageId, row_num: 1, claim: 'CEO of Acme', kind: 'fact', holder: 'world', weight: 1.0 },
|
|
{ page_id: alicePageId, row_num: 2, claim: 'Strong technical founder', kind: 'take', holder: 'garry', weight: 0.85 },
|
|
{ page_id: alicePageId, row_num: 3, claim: 'Seemed burned out in last OH', kind: 'hunch', holder: 'brain', weight: 0.4 },
|
|
]);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
function parseResult(result: { content: Array<{ text: string }>; isError?: boolean }): unknown {
|
|
expect(result.isError).toBeFalsy();
|
|
return JSON.parse(result.content[0].text);
|
|
}
|
|
|
|
describe('per-token takes-holder allow-list — takes_list', () => {
|
|
test('default (no allow-list, local CLI) returns all holders', async () => {
|
|
const result = await dispatchToolCall(engine, 'takes_list', { page_slug: 'people/alice-example' }, {
|
|
remote: false, // Local CLI: no allow-list applied.
|
|
});
|
|
const takes = parseResult(result) as Array<{ holder: string; claim: string }>;
|
|
const holders = takes.map(t => t.holder).sort();
|
|
expect(holders).toEqual(['brain', 'garry', 'world']);
|
|
});
|
|
|
|
test('allow-list ["world"] (default-deny token) returns ONLY world holders', async () => {
|
|
const result = await dispatchToolCall(engine, 'takes_list', { page_slug: 'people/alice-example' }, {
|
|
remote: true,
|
|
takesHoldersAllowList: ['world'],
|
|
});
|
|
const takes = parseResult(result) as Array<{ holder: string; claim: string }>;
|
|
expect(takes).toHaveLength(1);
|
|
expect(takes[0].holder).toBe('world');
|
|
expect(takes[0].claim).toBe('CEO of Acme');
|
|
});
|
|
|
|
test('allow-list ["world", "garry"] returns world + garry, hides brain hunches', async () => {
|
|
const result = await dispatchToolCall(engine, 'takes_list', { page_slug: 'people/alice-example' }, {
|
|
remote: true,
|
|
takesHoldersAllowList: ['world', 'garry'],
|
|
});
|
|
const takes = parseResult(result) as Array<{ holder: string }>;
|
|
const holders = takes.map(t => t.holder).sort();
|
|
expect(holders).toEqual(['garry', 'world']);
|
|
});
|
|
|
|
test('allow-list with no overlap returns empty (no fallback to default)', async () => {
|
|
const result = await dispatchToolCall(engine, 'takes_list', { page_slug: 'people/alice-example' }, {
|
|
remote: true,
|
|
takesHoldersAllowList: ['nonexistent-holder'],
|
|
});
|
|
const takes = parseResult(result) as unknown[];
|
|
expect(takes).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('per-token takes-holder allow-list — takes_search', () => {
|
|
test('allow-list ["world"] filters search hits to public claims only', async () => {
|
|
const result = await dispatchToolCall(engine, 'takes_search', { query: 'founder' }, {
|
|
remote: true,
|
|
takesHoldersAllowList: ['world'],
|
|
});
|
|
const hits = parseResult(result) as Array<{ holder: string; claim: string }>;
|
|
expect(hits.every(h => h.holder === 'world')).toBe(true);
|
|
});
|
|
|
|
test('no allow-list (local) sees all holders in search', async () => {
|
|
const result = await dispatchToolCall(engine, 'takes_search', { query: 'founder' }, {
|
|
remote: false,
|
|
});
|
|
const hits = parseResult(result) as Array<{ holder: string }>;
|
|
// 'Strong technical founder' (garry) should match
|
|
expect(hits.some(h => h.holder === 'garry')).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Page-body channel: get_page / get_versions must respect the same allow-list.
|
|
// Take rows are stored in TWO places per the extract-takes contract: the
|
|
// `takes` table (filtered by the SQL `holder = ANY($allowList)` clause) and
|
|
// inline in `pages.compiled_truth` between TAKES_FENCE markers as a markdown
|
|
// table. Without a strip on the page-CRUD path, a `world`-only token reading
|
|
// `get_page <slug>` recovers every non-`world` claim verbatim from the body.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('per-token takes-holder allow-list — get_page body channel', () => {
|
|
const SLUG = 'people/bob-example';
|
|
const FENCE_BODY =
|
|
'## Takes\n\n' +
|
|
`${TAKES_FENCE_BEGIN}\n` +
|
|
'\n| # | claim | kind | who | weight | since | source |\n' +
|
|
'|---|---|---|---|---|---|---|\n' +
|
|
'| 1 | CEO of Widget | fact | world | 1.0 | 2017-01 | Crustdata |\n' +
|
|
'| 2 | Strong technical founder | take | garry | 0.85 | 2026-04-29 | OH |\n' +
|
|
'| 3 | Seemed burned out in last OH | hunch | brain | 0.4 | 2026-05-01 | private |\n\n' +
|
|
`${TAKES_FENCE_END}\n` +
|
|
'\nFooter content stays.\n';
|
|
|
|
beforeAll(async () => {
|
|
await engine.putPage(SLUG, { title: 'Bob', type: 'person', compiled_truth: FENCE_BODY });
|
|
});
|
|
|
|
test('remote token with allow-list strips fence from compiled_truth', async () => {
|
|
const result = await dispatchToolCall(engine, 'get_page', { slug: SLUG }, {
|
|
remote: true,
|
|
takesHoldersAllowList: ['world'],
|
|
});
|
|
const page = parseResult(result) as { compiled_truth: string };
|
|
expect(page.compiled_truth).not.toContain(TAKES_FENCE_BEGIN);
|
|
expect(page.compiled_truth).not.toContain(TAKES_FENCE_END);
|
|
expect(page.compiled_truth).not.toContain('Strong technical founder');
|
|
expect(page.compiled_truth).not.toContain('Seemed burned out');
|
|
expect(page.compiled_truth).not.toContain('| garry |');
|
|
expect(page.compiled_truth).not.toContain('| brain |');
|
|
// Surrounding body kept intact.
|
|
expect(page.compiled_truth).toContain('Footer content stays.');
|
|
});
|
|
|
|
test('local CLI (no allow-list) preserves the fence — backwards compatibility', async () => {
|
|
const result = await dispatchToolCall(engine, 'get_page', { slug: SLUG }, {
|
|
remote: false,
|
|
});
|
|
const page = parseResult(result) as { compiled_truth: string };
|
|
expect(page.compiled_truth).toContain(TAKES_FENCE_BEGIN);
|
|
expect(page.compiled_truth).toContain('Seemed burned out');
|
|
});
|
|
|
|
test('fuzzy resolution path also strips for remote token', async () => {
|
|
const result = await dispatchToolCall(engine, 'get_page', { slug: 'people/bob-example', fuzzy: true }, {
|
|
remote: true,
|
|
takesHoldersAllowList: ['world', 'garry'],
|
|
});
|
|
const page = parseResult(result) as { compiled_truth: string };
|
|
// Allow-list does not yet re-render filtered rows; whole fence is stripped.
|
|
// Pinned so future re-rendering work is an additive change, not a silent
|
|
// semantic flip.
|
|
expect(page.compiled_truth).not.toContain(TAKES_FENCE_BEGIN);
|
|
expect(page.compiled_truth).not.toContain('Strong technical founder');
|
|
});
|
|
});
|
|
|
|
describe('per-token takes-holder allow-list — get_versions body channel', () => {
|
|
const SLUG = 'people/carol-example';
|
|
const FENCE_BODY =
|
|
`${TAKES_FENCE_BEGIN}\n| # | claim | kind | who |\n|---|---|---|---|\n| 1 | private hunch | hunch | brain |\n${TAKES_FENCE_END}\n`;
|
|
|
|
beforeAll(async () => {
|
|
await engine.putPage(SLUG, { title: 'Carol', type: 'person', compiled_truth: FENCE_BODY });
|
|
await engine.createVersion(SLUG); // snapshot now has the fence
|
|
});
|
|
|
|
test('remote token with allow-list strips fence from every snapshot', async () => {
|
|
const result = await dispatchToolCall(engine, 'get_versions', { slug: SLUG }, {
|
|
remote: true,
|
|
takesHoldersAllowList: ['world'],
|
|
});
|
|
const versions = parseResult(result) as Array<{ compiled_truth: string }>;
|
|
expect(versions.length).toBeGreaterThan(0);
|
|
for (const v of versions) {
|
|
expect(v.compiled_truth).not.toContain(TAKES_FENCE_BEGIN);
|
|
expect(v.compiled_truth).not.toContain('private hunch');
|
|
}
|
|
});
|
|
|
|
test('local CLI sees historical takes in snapshots', async () => {
|
|
const result = await dispatchToolCall(engine, 'get_versions', { slug: SLUG }, {
|
|
remote: false,
|
|
});
|
|
const versions = parseResult(result) as Array<{ compiled_truth: string }>;
|
|
expect(versions.some(v => v.compiled_truth.includes('private hunch'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('think op — read-only on remote callers (Lane D landed)', () => {
|
|
test('remote save/take is forced read-only via remote_persisted_blocked flag', async () => {
|
|
// Hermetic no-key: neutralize BOTH env var AND ~/.gbrain config key, else a
|
|
// configured machine fires a real LLM call and the warning flips to
|
|
// LLM_OUTPUT_NOT_JSON. runThink then returns gather-only + NO_ANTHROPIC_API_KEY.
|
|
const result = await withoutAnthropicKey(() => dispatchToolCall(engine, 'think', { question: 'q', save: true, take: true }, {
|
|
remote: true,
|
|
takesHoldersAllowList: ['world', 'garry', 'brain'],
|
|
}));
|
|
const env = parseResult(result) as {
|
|
remote_persisted_blocked: boolean;
|
|
saved_slug: string | null;
|
|
warnings: string[];
|
|
};
|
|
// Codex P1 #7: remote save/take is silently disabled.
|
|
expect(env.remote_persisted_blocked).toBe(true);
|
|
expect(env.saved_slug).toBeNull();
|
|
// Without API key, gather succeeds but synthesis is skipped.
|
|
expect(env.warnings).toContain('NO_ANTHROPIC_API_KEY');
|
|
});
|
|
|
|
test('local-CLI think runs full pipeline (gather-only without API key)', async () => {
|
|
const result = await withoutAnthropicKey(() => dispatchToolCall(engine, 'think', { question: 'q', save: true }, {
|
|
remote: false,
|
|
}));
|
|
const env = parseResult(result) as {
|
|
warnings: string[];
|
|
remote_persisted_blocked: boolean;
|
|
};
|
|
expect(env.remote_persisted_blocked).toBe(false);
|
|
// Without API key, returns gather-only + warning. With key, would actually synthesize.
|
|
expect(env.warnings).toContain('NO_ANTHROPIC_API_KEY');
|
|
});
|
|
});
|