mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
v0.42.28.0 fix(engine): batch inserts use jsonb_to_recordset, not text[] array literals (#1861) (#1927)
* 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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
805814451e
commit
f7f8512b14
@@ -339,7 +339,7 @@ describe('BATCH_AUDIT_SITES typed enum + isBatchAuditSite guard (D10c codex)', (
|
||||
// Pin the set so a future "cleanup" PR can't silently drop a site and
|
||||
// break audit-attribution for the corresponding caller.
|
||||
const expected = new Set([
|
||||
'addLinksBatch', 'addTimelineEntriesBatch', 'upsertChunks',
|
||||
'addLinksBatch', 'addTimelineEntriesBatch', 'addTakesBatch', 'upsertChunks',
|
||||
'extract.links_inc', 'extract.timeline_inc',
|
||||
'extract.links_fs', 'extract.timeline_fs',
|
||||
'extract.links_db', 'extract.timeline_db',
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// gbrain#1861 regression — Postgres lane (DATABASE_URL-gated).
|
||||
//
|
||||
// This is the engine that actually crashed: postgres.js serialized free-text
|
||||
// `context` into a Postgres text[] literal that `array_in` rejected with
|
||||
// "malformed array literal", aborting the whole `extract links --stale` sweep.
|
||||
// The PGLite sibling (test/links-timeline-jsonb-poison.test.ts) may not
|
||||
// reproduce the original crash because PGLite uses a different array serializer,
|
||||
// so this gated test is the true regression lock. It runs in CI lanes that set
|
||||
// DATABASE_URL and skips gracefully elsewhere.
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { hasDatabase, setupDB, teardownDB, getEngine, getConn } from './helpers.ts';
|
||||
import type { PostgresEngine } from '../../src/core/postgres-engine.ts';
|
||||
|
||||
const SKIP = !hasDatabase();
|
||||
const d = SKIP ? describe.skip : describe;
|
||||
|
||||
const POISON =
|
||||
'Zoom: https://zoom.us/j/95178948505?pwd=YmdFRWxXbWZadlNkaG9iNC9CYW12QT09, ' +
|
||||
'"Q2 sync" — notes {a,b}, path C:\\Users\\x, em–dash } and { trailing';
|
||||
const NUL = String.fromCharCode(0);
|
||||
|
||||
let engine: PostgresEngine;
|
||||
|
||||
async function seed(slug: string) {
|
||||
await engine.putPage(slug, {
|
||||
title: slug, type: 'concept' as never,
|
||||
compiled_truth: 'body long enough to pass any minimum length backstop',
|
||||
timeline: '', frontmatter: {}, source_path: `${slug}.md`,
|
||||
});
|
||||
}
|
||||
|
||||
async function pageId(slug: string): Promise<number> {
|
||||
const rows = await engine.executeRaw<{ id: number }>(
|
||||
`SELECT id FROM pages WHERE slug = $1 AND source_id = 'default'`, [slug],
|
||||
);
|
||||
return rows[0].id;
|
||||
}
|
||||
|
||||
d('JSONB batch poison — Postgres (#1861)', () => {
|
||||
beforeAll(async () => { engine = await setupDB(); });
|
||||
afterAll(async () => { await teardownDB(); });
|
||||
beforeEach(async () => {
|
||||
// Clean the three target tables between cases.
|
||||
const conn = getConn();
|
||||
await conn.unsafe('TRUNCATE links, timeline_entries, takes, pages CASCADE');
|
||||
});
|
||||
|
||||
it('addLinksBatch round-trips poison context (the original crash)', async () => {
|
||||
await seed('from-page'); await seed('to-page');
|
||||
const n = await engine.addLinksBatch([
|
||||
{ from_slug: 'from-page', to_slug: 'to-page', link_type: 'mentions',
|
||||
context: POISON, link_source: 'manual' },
|
||||
]);
|
||||
expect(n).toBe(1);
|
||||
const links = await engine.getLinks('from-page', { sourceId: 'default' });
|
||||
expect(links[0].context).toBe(POISON);
|
||||
});
|
||||
|
||||
it('strips embedded NUL in link context', async () => {
|
||||
await seed('a'); await seed('b');
|
||||
await engine.addLinksBatch([
|
||||
{ from_slug: 'a', to_slug: 'b', link_type: 'mentions',
|
||||
context: `before${NUL}after`, link_source: 'manual' },
|
||||
]);
|
||||
const links = await engine.getLinks('a', { sourceId: 'default' });
|
||||
expect(links[0].context).toBe('beforeafter');
|
||||
});
|
||||
|
||||
it('addTimelineEntriesBatch round-trips poison summary/detail/source', async () => {
|
||||
await seed('meeting-page');
|
||||
const n = await engine.addTimelineEntriesBatch([
|
||||
{ slug: 'meeting-page', date: '2026-06-04', source: POISON,
|
||||
summary: POISON, detail: POISON },
|
||||
]);
|
||||
expect(n).toBe(1);
|
||||
const rows = await engine.executeRaw<{ summary: string; detail: string; source: string }>(
|
||||
`SELECT te.summary, te.detail, te.source FROM timeline_entries te
|
||||
JOIN pages p ON p.id = te.page_id WHERE p.slug = 'meeting-page'`,
|
||||
);
|
||||
expect(rows[0].summary).toBe(POISON);
|
||||
expect(rows[0].detail).toBe(POISON);
|
||||
expect(rows[0].source).toBe(POISON);
|
||||
});
|
||||
|
||||
it('addTakesBatch round-trips poison claim + native number/bool/null', async () => {
|
||||
await seed('take-page');
|
||||
const pid = await pageId('take-page');
|
||||
const n = await engine.addTakesBatch([
|
||||
{ page_id: pid, row_num: 1, claim: POISON, kind: 'fact', holder: 'garry',
|
||||
weight: 0.74, active: false },
|
||||
]);
|
||||
expect(n).toBe(1);
|
||||
const rows = await engine.executeRaw<{
|
||||
claim: string; weight: number | string; active: boolean; since_date: string | null;
|
||||
}>(`SELECT claim, weight, active, since_date FROM takes t
|
||||
JOIN pages p ON p.id = t.page_id WHERE p.slug = 'take-page' AND t.row_num = 1`);
|
||||
expect(rows[0].claim).toBe(POISON);
|
||||
expect(Number(rows[0].weight)).toBeCloseTo(0.75, 5);
|
||||
expect(rows[0].active).toBe(false);
|
||||
expect(rows[0].since_date).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Run `fn` with NO Anthropic key reachable from EITHER source the gateway
|
||||
* checks. `hasAnthropicKey()` (src/core/ai/anthropic-key.ts) returns true if
|
||||
* `process.env.ANTHROPIC_API_KEY` is set OR `~/.gbrain/config.json` carries
|
||||
* `anthropic_api_key`. A test that only `delete`s the env var is NOT hermetic:
|
||||
* on a developer machine whose `~/.gbrain` holds a real key (or whose
|
||||
* `.env.testing` sets ANTHROPIC_API_KEY), the "no key" path actually fires a
|
||||
* live LLM call and the assertion flips from `NO_ANTHROPIC_API_KEY` to
|
||||
* `LLM_OUTPUT_NOT_JSON`.
|
||||
*
|
||||
* This helper neutralizes BOTH sources for the duration of `fn`:
|
||||
* - deletes ANTHROPIC_API_KEY from the env, and
|
||||
* - points GBRAIN_HOME at a fresh empty temp dir so `configDir()` (which
|
||||
* honors GBRAIN_HOME) resolves to a directory with no config.json, making
|
||||
* `loadConfig()` return null.
|
||||
*
|
||||
* Both are restored (and the temp dir removed) in a finally, even on throw.
|
||||
* `loadConfig()` reads the file fresh on every call and `probeChatModel` runs
|
||||
* before the gateway's model cache, so per-call isolation is sufficient — no
|
||||
* module-level key state survives.
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
export async function withoutAnthropicKey<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const origKey = process.env.ANTHROPIC_API_KEY;
|
||||
const origHome = process.env.GBRAIN_HOME;
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'gbrain-nokey-'));
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
process.env.GBRAIN_HOME = tmp; // configDir() -> $GBRAIN_HOME/.gbrain (absent -> no config key)
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
if (origKey !== undefined) process.env.ANTHROPIC_API_KEY = origKey;
|
||||
else delete process.env.ANTHROPIC_API_KEY;
|
||||
if (origHome !== undefined) process.env.GBRAIN_HOME = origHome;
|
||||
else delete process.env.GBRAIN_HOME;
|
||||
try { rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// gbrain#1861 regression — batch inserts must survive free-text "poison"
|
||||
// payloads (calendar/Zoom context: commas, quotes, backslashes, braces,
|
||||
// em-dashes) that the old unnest(${arr}::text[]) array-literal path rejected
|
||||
// with Postgres "malformed array literal".
|
||||
//
|
||||
// PGLite half (always-on). PGLite uses a different array serializer than
|
||||
// postgres.js, so it may not reproduce the ORIGINAL crash — but it locks the
|
||||
// new jsonb_to_recordset path's behavior everywhere CI runs. The Postgres lane
|
||||
// (test/e2e/jsonb-batch-poison-postgres.test.ts) is the one that actually
|
||||
// crashed pre-fix.
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
||||
|
||||
// The exact shape from the #1861 crash report: Zoom URL with ?pwd=, commas,
|
||||
// double-quotes, a Windows backslash path, braces, and an em-dash.
|
||||
const POISON =
|
||||
'Zoom: https://zoom.us/j/95178948505?pwd=YmdFRWxXbWZadlNkaG9iNC9CYW12QT09, ' +
|
||||
'"Q2 sync" — notes {a,b}, path C:\\Users\\x, em–dash } and { trailing';
|
||||
|
||||
// U+0000 — Postgres jsonb rejects it; the row builders strip it. Built via
|
||||
// fromCharCode so no literal NUL byte ever lands in this source file.
|
||||
const NUL = String.fromCharCode(0);
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
});
|
||||
|
||||
async function seed(slug: string) {
|
||||
await engine.putPage(slug, {
|
||||
title: slug, type: 'concept' as never,
|
||||
compiled_truth: 'body long enough to pass any minimum length backstop',
|
||||
timeline: '', frontmatter: {}, source_path: `${slug}.md`,
|
||||
});
|
||||
}
|
||||
|
||||
async function pageId(slug: string): Promise<number> {
|
||||
const rows = await engine.executeRaw<{ id: number }>(
|
||||
`SELECT id FROM pages WHERE slug = $1 AND source_id = 'default'`, [slug],
|
||||
);
|
||||
return rows[0].id;
|
||||
}
|
||||
|
||||
describe('addLinksBatch — JSONB poison (#1861)', () => {
|
||||
it('round-trips calendar free-text context without throwing', async () => {
|
||||
await seed('from-page');
|
||||
await seed('to-page');
|
||||
const n = await engine.addLinksBatch([
|
||||
{ from_slug: 'from-page', to_slug: 'to-page', link_type: 'mentions',
|
||||
context: POISON, link_source: 'manual' },
|
||||
]);
|
||||
expect(n).toBe(1);
|
||||
const links = await engine.getLinks('from-page', { sourceId: 'default' });
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0].context).toBe(POISON); // byte-identical
|
||||
});
|
||||
|
||||
it('strips embedded NUL but preserves the rest', async () => {
|
||||
await seed('a'); await seed('b');
|
||||
await engine.addLinksBatch([
|
||||
{ from_slug: 'a', to_slug: 'b', link_type: 'mentions',
|
||||
context: `before${NUL}after`, link_source: 'manual' },
|
||||
]);
|
||||
const links = await engine.getLinks('a', { sourceId: 'default' });
|
||||
expect(links[0].context).toBe('beforeafter');
|
||||
});
|
||||
|
||||
it('null origin_slug leaves origin_page_id NULL', async () => {
|
||||
await seed('a'); await seed('b');
|
||||
await engine.addLinksBatch([
|
||||
{ from_slug: 'a', to_slug: 'b', link_type: 'mentions', link_source: 'manual' },
|
||||
]);
|
||||
const rows = await engine.executeRaw<{ origin_page_id: number | null }>(
|
||||
`SELECT l.origin_page_id FROM links l JOIN pages p ON p.id = l.from_page_id
|
||||
WHERE p.slug = 'a'`,
|
||||
);
|
||||
expect(rows[0].origin_page_id).toBeNull();
|
||||
});
|
||||
|
||||
it('collapses an intra-batch duplicate (ON CONFLICT DO NOTHING)', async () => {
|
||||
await seed('a'); await seed('b');
|
||||
const dup = { from_slug: 'a', to_slug: 'b', link_type: 'mentions',
|
||||
context: POISON, link_source: 'manual' as const };
|
||||
const n = await engine.addLinksBatch([dup, dup]);
|
||||
expect(n).toBe(1);
|
||||
const links = await engine.getLinks('a', { sourceId: 'default' });
|
||||
expect(links).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('round-trips a non-null link_kind through the jsonb recordset', async () => {
|
||||
await seed('a'); await seed('b');
|
||||
await engine.addLinksBatch([
|
||||
{ from_slug: 'a', to_slug: 'b', link_type: 'mentions', context: POISON,
|
||||
link_source: 'mentions', link_kind: 'typed_ner' },
|
||||
]);
|
||||
const rows = await engine.executeRaw<{ link_kind: string | null }>(
|
||||
`SELECT l.link_kind FROM links l JOIN pages p ON p.id = l.from_page_id
|
||||
WHERE p.slug = 'a'`,
|
||||
);
|
||||
expect(rows[0].link_kind).toBe('typed_ner'); // new recordset column, exercised non-NULL
|
||||
});
|
||||
|
||||
it('leaves link_kind NULL when omitted (legacy/plain)', async () => {
|
||||
await seed('a'); await seed('b');
|
||||
await engine.addLinksBatch([
|
||||
{ from_slug: 'a', to_slug: 'b', link_type: 'mentions', link_source: 'manual' },
|
||||
]);
|
||||
const rows = await engine.executeRaw<{ link_kind: string | null }>(
|
||||
`SELECT l.link_kind FROM links l JOIN pages p ON p.id = l.from_page_id
|
||||
WHERE p.slug = 'a'`,
|
||||
);
|
||||
expect(rows[0].link_kind).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addTimelineEntriesBatch — JSONB poison (#1861)', () => {
|
||||
it('round-trips free-text summary/detail/source without throwing', async () => {
|
||||
await seed('meeting-page');
|
||||
const n = await engine.addTimelineEntriesBatch([
|
||||
{ slug: 'meeting-page', date: '2026-06-04', source: POISON,
|
||||
summary: POISON, detail: POISON },
|
||||
]);
|
||||
expect(n).toBe(1);
|
||||
const rows = await engine.executeRaw<{ summary: string; detail: string; source: string }>(
|
||||
`SELECT te.summary, te.detail, te.source FROM timeline_entries te
|
||||
JOIN pages p ON p.id = te.page_id WHERE p.slug = 'meeting-page'`,
|
||||
);
|
||||
expect(rows[0].summary).toBe(POISON);
|
||||
expect(rows[0].detail).toBe(POISON);
|
||||
expect(rows[0].source).toBe(POISON);
|
||||
});
|
||||
|
||||
it('strips embedded NUL in summary', async () => {
|
||||
await seed('m');
|
||||
await engine.addTimelineEntriesBatch([
|
||||
{ slug: 'm', date: '2026-06-04', summary: `a${NUL}b`, detail: '' },
|
||||
]);
|
||||
const rows = await engine.executeRaw<{ summary: string }>(
|
||||
`SELECT te.summary FROM timeline_entries te JOIN pages p ON p.id = te.page_id
|
||||
WHERE p.slug = 'm'`,
|
||||
);
|
||||
expect(rows[0].summary).toBe('ab');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addTakesBatch — JSONB poison + native-type parity (#1861)', () => {
|
||||
it('round-trips free-text claim and native number/bool/null fields', async () => {
|
||||
await seed('take-page');
|
||||
const pid = await pageId('take-page');
|
||||
const n = await engine.addTakesBatch([
|
||||
{ page_id: pid, row_num: 1, claim: POISON, kind: 'fact', holder: 'garry',
|
||||
weight: 0.74, active: false }, // since_date/until_date/source omitted -> null
|
||||
]);
|
||||
expect(n).toBe(1);
|
||||
const rows = await engine.executeRaw<{
|
||||
claim: string; weight: number | string; active: boolean; since_date: string | null;
|
||||
}>(`SELECT claim, weight, active, since_date FROM takes t
|
||||
JOIN pages p ON p.id = t.page_id WHERE p.slug = 'take-page' AND t.row_num = 1`);
|
||||
expect(rows[0].claim).toBe(POISON); // free-text claim survives
|
||||
expect(Number(rows[0].weight)).toBeCloseTo(0.75, 5); // 0.74 -> 0.05 grid
|
||||
expect(rows[0].active).toBe(false); // JSON boolean round-trips
|
||||
expect(rows[0].since_date).toBeNull(); // omitted -> SQL NULL
|
||||
});
|
||||
|
||||
it('strips embedded NUL from the free-text claim', async () => {
|
||||
await seed('tp-nul');
|
||||
const pid = await pageId('tp-nul');
|
||||
await engine.addTakesBatch([
|
||||
{ page_id: pid, row_num: 1, claim: `a${NUL}b`, kind: 'fact', holder: 'h' },
|
||||
]);
|
||||
const rows = await engine.executeRaw<{ claim: string }>(
|
||||
`SELECT claim FROM takes t JOIN pages p ON p.id = t.page_id
|
||||
WHERE p.slug = 'tp-nul' AND t.row_num = 1`,
|
||||
);
|
||||
expect(rows[0].claim).toBe('ab');
|
||||
});
|
||||
|
||||
it('clamps out-of-range weight (>1) to 1.0', async () => {
|
||||
await seed('tp2');
|
||||
const pid = await pageId('tp2');
|
||||
await engine.addTakesBatch([
|
||||
{ page_id: pid, row_num: 1, claim: 'c', kind: 'fact', holder: 'h', weight: 1.5 },
|
||||
]);
|
||||
const rows = await engine.executeRaw<{ weight: number | string }>(
|
||||
`SELECT weight FROM takes t JOIN pages p ON p.id = t.page_id
|
||||
WHERE p.slug = 'tp2' AND t.row_num = 1`,
|
||||
);
|
||||
expect(Number(rows[0].weight)).toBeCloseTo(1.0, 5);
|
||||
});
|
||||
|
||||
it('DO UPDATE upserts an existing (page_id, row_num)', async () => {
|
||||
await seed('tp3');
|
||||
const pid = await pageId('tp3');
|
||||
await engine.addTakesBatch([
|
||||
{ page_id: pid, row_num: 1, claim: 'first', kind: 'fact', holder: 'h' },
|
||||
]);
|
||||
await engine.addTakesBatch([
|
||||
{ page_id: pid, row_num: 1, claim: 'second', kind: 'fact', holder: 'h' },
|
||||
]);
|
||||
const rows = await engine.executeRaw<{ claim: string; n: number | string }>(
|
||||
`SELECT claim, (SELECT count(*) FROM takes t2 JOIN pages p2 ON p2.id = t2.page_id
|
||||
WHERE p2.slug = 'tp3') AS n
|
||||
FROM takes t JOIN pages p ON p.id = t.page_id WHERE p.slug = 'tp3' AND t.row_num = 1`,
|
||||
);
|
||||
expect(rows[0].claim).toBe('second');
|
||||
expect(Number(rows[0].n)).toBe(1);
|
||||
});
|
||||
|
||||
it('round-trips nullable/native take fields (superseded_by, until_date, non-null source)', async () => {
|
||||
await seed('tp4');
|
||||
const pid = await pageId('tp4');
|
||||
await engine.addTakesBatch([
|
||||
{ page_id: pid, row_num: 1, claim: 'c', kind: 'fact', holder: 'h',
|
||||
superseded_by: 7, until_date: '2027-01-01', source: POISON },
|
||||
]);
|
||||
const rows = await engine.executeRaw<{
|
||||
superseded_by: number | string | null; until_date: string | null; source: string;
|
||||
}>(`SELECT superseded_by, until_date::text AS until_date, source FROM takes t
|
||||
JOIN pages p ON p.id = t.page_id WHERE p.slug = 'tp4' AND t.row_num = 1`);
|
||||
expect(Number(rows[0].superseded_by)).toBe(7); // native int column
|
||||
expect(rows[0].until_date).toBe('2027-01-01'); // text/date round-trip
|
||||
expect(rows[0].source).toBe(POISON); // non-null free-text source
|
||||
});
|
||||
});
|
||||
|
||||
describe('scalar addLink — NUL strip on free-text context (#1861 codex #3)', () => {
|
||||
it('addLink strips NUL from context', async () => {
|
||||
await seed('a'); await seed('b');
|
||||
await engine.addLink('a', 'b', `before${NUL}after`, 'mentions', 'manual');
|
||||
const links = await engine.getLinks('a', { sourceId: 'default' });
|
||||
expect(links[0].context).toBe('beforeafter');
|
||||
});
|
||||
});
|
||||
@@ -172,4 +172,20 @@ describe('executeRawJsonb (D1 wave / v0.31)', () => {
|
||||
),
|
||||
).rejects.toThrow(/only supports scalar bind values/);
|
||||
});
|
||||
|
||||
test('rejects a top-level array jsonb param (gbrain#1861 P2a guard)', async () => {
|
||||
// A bare JS array bound to a $N::jsonb position can serialize as a Postgres
|
||||
// array literal (not jsonb) through postgres.js, re-entering the
|
||||
// "malformed array literal" class #1861 escaped. The helper must reject it
|
||||
// and steer callers to the { rows: [...] } wrapper. Objects + null are fine
|
||||
// (covered by the tests above).
|
||||
await expect(
|
||||
executeRawJsonb(
|
||||
engine,
|
||||
`INSERT INTO nope (j) VALUES ($1::jsonb)`,
|
||||
[],
|
||||
[[{ a: 1 }, { a: 2 }] as any],
|
||||
),
|
||||
).rejects.toThrow(/top-level array jsonb param/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* 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';
|
||||
@@ -205,45 +206,35 @@ describe('per-token takes-holder allow-list — get_versions body channel', () =
|
||||
|
||||
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 () => {
|
||||
// Without ANTHROPIC_API_KEY, runThink returns gather-only result with NO_ANTHROPIC_API_KEY warning.
|
||||
const origKey = process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
try {
|
||||
const result = await 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');
|
||||
} finally {
|
||||
if (origKey) process.env.ANTHROPIC_API_KEY = origKey;
|
||||
}
|
||||
// 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 origKey = process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
try {
|
||||
const result = await 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');
|
||||
} finally {
|
||||
if (origKey) process.env.ANTHROPIC_API_KEY = origKey;
|
||||
}
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { runThink, persistSynthesis, type ThinkLLMClient } from '../src/core/thi
|
||||
import { sanitizeTakeForPrompt, renderTakesBlock } from '../src/core/think/sanitize.ts';
|
||||
import { resolveCitations, parseInlineCitations, normalizeStructuredCitations } from '../src/core/think/cite-render.ts';
|
||||
import { runGather } from '../src/core/think/gather.ts';
|
||||
import { withoutAnthropicKey } from './helpers/no-anthropic-key.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
let alicePageId: number;
|
||||
@@ -207,16 +208,13 @@ describe('runThink (with stub client)', () => {
|
||||
});
|
||||
|
||||
test('degrades gracefully without ANTHROPIC_API_KEY', async () => {
|
||||
const origKey = process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
try {
|
||||
const result = await runThink(engine, { question: 'no key test' });
|
||||
expect(result.warnings).toContain('NO_ANTHROPIC_API_KEY');
|
||||
expect(result.answer).toContain('no LLM available');
|
||||
expect(result.rounds).toBe(0);
|
||||
} finally {
|
||||
if (origKey) process.env.ANTHROPIC_API_KEY = origKey;
|
||||
}
|
||||
// Hermetic: neutralize BOTH the env var AND ~/.gbrain config key, else a
|
||||
// developer/CI machine with a configured key fires a real LLM call and this
|
||||
// assertion flips to LLM_OUTPUT_NOT_JSON.
|
||||
const result = await withoutAnthropicKey(() => runThink(engine, { question: 'no key test' }));
|
||||
expect(result.warnings).toContain('NO_ANTHROPIC_API_KEY');
|
||||
expect(result.answer).toContain('no LLM available');
|
||||
expect(result.rounds).toBe(0);
|
||||
});
|
||||
|
||||
test('persistSynthesis writes synthesis page + evidence rows', async () => {
|
||||
@@ -285,16 +283,11 @@ describe('runThink — #1698 explicit-model hard error', () => {
|
||||
});
|
||||
|
||||
test('NON-explicit bad model does NOT throw — graceful degrade (no modelExplicit)', async () => {
|
||||
const origKey = process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
try {
|
||||
// model present but modelExplicit unset → early gate skipped; builder returns null.
|
||||
const result = await runThink(engine, { question: 'nonexplicit bad', model: 'bogusprovider:foo' });
|
||||
expect(result.warnings).toContain('NO_ANTHROPIC_API_KEY');
|
||||
expect(result.synthesisOk).toBe(false);
|
||||
} finally {
|
||||
if (origKey) process.env.ANTHROPIC_API_KEY = origKey;
|
||||
}
|
||||
// model present but modelExplicit unset → early gate skipped; builder returns null.
|
||||
// Hermetic no-key so the assertion can't be perturbed by a configured key.
|
||||
const result = await withoutAnthropicKey(() => runThink(engine, { question: 'nonexplicit bad', model: 'bogusprovider:foo' }));
|
||||
expect(result.warnings).toContain('NO_ANTHROPIC_API_KEY');
|
||||
expect(result.synthesisOk).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -373,15 +366,11 @@ describe('think MCP op — #1698 C3 + #10', () => {
|
||||
|
||||
test('#10: local save with no synthesis → saved_slug is null, not "" + warning surfaced', async () => {
|
||||
const op = operationsByName['think'];
|
||||
const origKey = process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
try {
|
||||
// Local (remote:false), save:true, no key → graceful stub → persist-skip.
|
||||
const res: any = await op.handler(baseCtx(false) as any, { question: 'op empty save test', save: true });
|
||||
expect(res.saved_slug).toBeNull();
|
||||
expect(res.warnings).toContain('SYNTHESIS_EMPTY_NOT_PERSISTED');
|
||||
} finally {
|
||||
if (origKey) process.env.ANTHROPIC_API_KEY = origKey;
|
||||
}
|
||||
// Hermetic no-key: synthesisOk=false → persistSynthesis returns
|
||||
// SYNTHESIS_EMPTY_NOT_PERSISTED deterministically (was previously at the
|
||||
// mercy of whatever a live LLM returned for this prompt).
|
||||
const res: any = await withoutAnthropicKey(() => op.handler(baseCtx(false) as any, { question: 'op empty save test', save: true }));
|
||||
expect(res.saved_slug).toBeNull();
|
||||
expect(res.warnings).toContain('SYNTHESIS_EMPTY_NOT_PERSISTED');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user