mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
fix(gateway): provider recipes, stdio takes allow-list override, proposal review CLI
- #2657: stdio MCP takesHoldersAllowList is operator-overridable via GBRAIN_MCP_TAKES_HOLDERS (comma-separated). Fail-closed ['world'] default preserved; empty/garbage values never widen. - #2689 (drift-guard half): pin every anthropic-recipe model id (chat + expansion + alias targets) to CANONICAL_PRICING so the allowlist can never rot ahead of / behind the canonical pricing table again. The Claude 5 allowlist entries and the honest MODEL_NOT_USABLE labeling for configured models already landed on master. - #2790: calendar-to-brain recipe backfill + weekly cron now end a week AHEAD of today so the lookahead window ('knows who you're meeting tomorrow') actually has upcoming events to read; cron guide updated. - #1467: take-proposal review surface — new src/core/takes-proposals.ts (list/accept/reject; accept promotes to the canonical takes fence under the per-page lock + DB mirror + promoted_row_num audit), wired as contract-first ops takes_proposals_list / takes_proposal_resolve (resolve is localOnly) and 'gbrain takes propose [--accept N|--reject N]'. propose_takes phase model now resolves models.propose_takes config (alias-aware) and threads the resolved id to the extractor + model_id column. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
0612b0daa8
commit
2d4f980a42
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* #2657 — stdio MCP takes-holder allow-list operator override.
|
||||
*
|
||||
* The stdio transport hardcoded takesHoldersAllowList to ['world'] with no
|
||||
* escape hatch, making non-world takes unreachable for stdio MCP agents.
|
||||
* GBRAIN_MCP_TAKES_HOLDERS (comma-separated) is the opt-in widening; the
|
||||
* fail-closed ['world'] default is preserved when unset/empty/garbage.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { resolveStdioTakesHolders } from '../src/mcp/server.ts';
|
||||
|
||||
describe('resolveStdioTakesHolders (#2657)', () => {
|
||||
test('unset → fail-closed default ["world"]', () => {
|
||||
expect(resolveStdioTakesHolders(undefined)).toEqual(['world']);
|
||||
});
|
||||
|
||||
test('empty / whitespace-only → default, never an empty allow-list', () => {
|
||||
expect(resolveStdioTakesHolders('')).toEqual(['world']);
|
||||
expect(resolveStdioTakesHolders(' ')).toEqual(['world']);
|
||||
expect(resolveStdioTakesHolders(', ,')).toEqual(['world']);
|
||||
});
|
||||
|
||||
test('comma-split with trimming', () => {
|
||||
expect(resolveStdioTakesHolders('world, brain , people/alice-example')).toEqual([
|
||||
'world',
|
||||
'brain',
|
||||
'people/alice-example',
|
||||
]);
|
||||
});
|
||||
|
||||
test('single non-world holder is honored (the unreachable-takes case)', () => {
|
||||
expect(resolveStdioTakesHolders('brain')).toEqual(['brain']);
|
||||
});
|
||||
});
|
||||
@@ -41,12 +41,16 @@ interface CapturedSql {
|
||||
function buildMockEngine(opts: {
|
||||
pages: Page[];
|
||||
existingProposals?: Set<string>; // composite-key strings already in take_proposals
|
||||
config?: Record<string, string>; // #1467: models.propose_takes resolution
|
||||
}): { engine: BrainEngine; captured: CapturedSql[] } {
|
||||
const captured: CapturedSql[] = [];
|
||||
const existing = opts.existingProposals ?? new Set<string>();
|
||||
|
||||
const engine = {
|
||||
kind: 'pglite',
|
||||
async getConfig(key: string) {
|
||||
return opts.config?.[key] ?? null;
|
||||
},
|
||||
async listPages() {
|
||||
return opts.pages;
|
||||
},
|
||||
@@ -267,6 +271,28 @@ describe('runPhaseProposeTakes — phase integration', () => {
|
||||
expect(inserts[0]!.params[9]).toBe('market'); // domain
|
||||
});
|
||||
|
||||
test('#1467: models.propose_takes config routes the extractor model + model_id column', async () => {
|
||||
const pages = [buildPage({ slug: 'wiki/model-routing', body: 'Configured models should reach the extractor.' })];
|
||||
const { engine, captured } = buildMockEngine({
|
||||
pages,
|
||||
config: { 'models.propose_takes': 'anthropic:claude-sonnet-5' },
|
||||
});
|
||||
let seenModelHint: string | undefined;
|
||||
const extractor: ProposeTakesExtractor = async (input) => {
|
||||
seenModelHint = input.modelHint;
|
||||
return [{ claim_text: 'Config routing works', kind: 'take', holder: 'brain', weight: 0.6 }];
|
||||
};
|
||||
const result = await runPhaseProposeTakes(buildCtx(engine), { extractor });
|
||||
|
||||
expect(result.status).toBe('ok');
|
||||
// The RESOLVED model (config key, no explicit opts.model) reaches the
|
||||
// extractor AND lands in the take_proposals.model_id audit column.
|
||||
expect(seenModelHint).toBe('anthropic:claude-sonnet-5');
|
||||
const inserts = captured.filter(c => c.sql.includes('INSERT INTO take_proposals'));
|
||||
expect(inserts).toHaveLength(1);
|
||||
expect(inserts[0]!.params[11]).toBe('anthropic:claude-sonnet-5'); // model_id
|
||||
});
|
||||
|
||||
test('cache hit: page already in take_proposals is skipped', async () => {
|
||||
const body = 'A page that was already processed.';
|
||||
const pages = [buildPage({ slug: 'wiki/old-page', body })];
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* #2689 — recipe allowlist ↔ canonical pricing drift guard.
|
||||
*
|
||||
* The Anthropic chat allowlist once predated the canonical pricing table
|
||||
* (pricing knew claude-sonnet-5; the recipe rejected it), so a config-set
|
||||
* model the whole system "knew" still failed the chat-client probe. This
|
||||
* guard pins the invariant the other way around: every model id the
|
||||
* anthropic recipe accepts (chat + expansion) MUST resolve in
|
||||
* CANONICAL_PRICING, so an allowlist addition can never outrun pricing and
|
||||
* a pricing-known Anthropic model missing from the allowlist shows up in
|
||||
* review as an explicit divergence, not silent rot.
|
||||
*/
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { anthropic } from '../src/core/ai/recipes/anthropic.ts';
|
||||
import { canonicalLookup } from '../src/core/model-pricing.ts';
|
||||
|
||||
describe('anthropic recipe ↔ canonical pricing (#2689 drift guard)', () => {
|
||||
const recipeIds = new Set<string>([
|
||||
...(anthropic.touchpoints.chat?.models ?? []),
|
||||
...(anthropic.touchpoints.expansion?.models ?? []),
|
||||
// Alias targets must be priced too — they're what actually hits the wire.
|
||||
...Object.values(anthropic.aliases ?? {}),
|
||||
]);
|
||||
|
||||
test('recipe declares at least one chat model', () => {
|
||||
expect(recipeIds.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
for (const id of recipeIds) {
|
||||
test(`recipe model "${id}" is priced in CANONICAL_PRICING`, () => {
|
||||
expect(canonicalLookup(`anthropic:${id}`)).toBeTruthy();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* #1467 — take-proposal review queue (list / accept / reject).
|
||||
*
|
||||
* Real PGLite engine (in-memory) + a temp brain dir. Pins the queue→fence
|
||||
* promotion contract: list shows pending proposals (holder allow-list
|
||||
* respected), accept writes the canonical markdown fence + DB take +
|
||||
* verdict row with promoted_row_num, reject records the verdict, and a
|
||||
* second resolve on the same id fails loudly.
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { listTakeProposals, resolveTakeProposal } from '../src/core/takes-proposals.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
let brainDir: string;
|
||||
|
||||
async function insertProposal(opts: {
|
||||
slug: string;
|
||||
claim: string;
|
||||
holder?: string;
|
||||
status?: string;
|
||||
hash?: string;
|
||||
}): Promise<number> {
|
||||
const rows = await engine.executeRaw<{ id: number }>(
|
||||
`INSERT INTO take_proposals
|
||||
(source_id, page_slug, content_hash, prompt_version, proposal_run_id,
|
||||
status, claim_text, kind, holder, weight, domain, model_id)
|
||||
VALUES ('default', $1, $2, 'test-v1', 'run-test',
|
||||
$3, $4, 'take', $5, 0.6, NULL, 'anthropic:claude-sonnet-4-6')
|
||||
RETURNING id`,
|
||||
[opts.slug, opts.hash ?? `hash-${opts.slug}-${opts.claim.length}`, opts.status ?? 'pending', opts.claim, opts.holder ?? 'brain'],
|
||||
);
|
||||
return rows[0]!.id;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
brainDir = mkdtempSync(join(tmpdir(), 'gbrain-proposals-'));
|
||||
await engine.putPage('people/alice-example', {
|
||||
title: 'Alice Example',
|
||||
type: 'person' as const,
|
||||
compiled_truth: 'Alice is a strong founder.\n',
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
rmSync(brainDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('listTakeProposals (#1467)', () => {
|
||||
test('lists pending by default, respects holder allow-list', async () => {
|
||||
const brainId = await insertProposal({ slug: 'people/alice-example', claim: 'Alice will raise a Series A', holder: 'brain', hash: 'h-list-1' });
|
||||
await insertProposal({ slug: 'people/alice-example', claim: 'Consensus view of Alice', holder: 'world', hash: 'h-list-2' });
|
||||
await insertProposal({ slug: 'people/alice-example', claim: 'Already rejected', status: 'rejected', hash: 'h-list-3' });
|
||||
|
||||
const all = await listTakeProposals(engine, { sourceId: 'default' });
|
||||
expect(all.map((r) => r.id)).toContain(brainId);
|
||||
expect(all.every((r) => r.status === 'pending')).toBe(true);
|
||||
expect(all.some((r) => r.claim_text === 'Already rejected')).toBe(false);
|
||||
|
||||
// MCP-style allow-list: only 'world' holders visible.
|
||||
const worldOnly = await listTakeProposals(engine, { sourceId: 'default', takesHoldersAllowList: ['world'] });
|
||||
expect(worldOnly.length).toBeGreaterThan(0);
|
||||
expect(worldOnly.every((r) => r.holder === 'world')).toBe(true);
|
||||
|
||||
// Fail-closed: empty allow-list → nothing, not everything.
|
||||
expect(await listTakeProposals(engine, { sourceId: 'default', takesHoldersAllowList: [] })).toEqual([]);
|
||||
|
||||
// Source isolation: a different source sees nothing.
|
||||
expect(await listTakeProposals(engine, { sourceId: 'other-source' })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTakeProposal (#1467)', () => {
|
||||
test('accept promotes to markdown fence + DB take + verdict row', async () => {
|
||||
const id = await insertProposal({ slug: 'people/alice-example', claim: 'Alice ships weekly', hash: 'h-accept' });
|
||||
const result = await resolveTakeProposal(engine, { id, verdict: 'accept', brainDir, sourceId: 'default', actedBy: 'test' });
|
||||
|
||||
expect(result.status).toBe('accepted');
|
||||
expect(result.promoted_row_num).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Markdown fence written (markdown is canonical).
|
||||
const mdPath = join(brainDir, 'people/alice-example.md');
|
||||
expect(existsSync(mdPath)).toBe(true);
|
||||
expect(readFileSync(mdPath, 'utf-8')).toContain('Alice ships weekly');
|
||||
|
||||
// DB mirror.
|
||||
const takes = await engine.listTakes({ page_slug: 'people/alice-example' });
|
||||
const promoted = takes.find((t) => t.claim === 'Alice ships weekly');
|
||||
expect(promoted).toBeTruthy();
|
||||
expect(promoted!.row_num).toBe(result.promoted_row_num!);
|
||||
|
||||
// Verdict row.
|
||||
const rows = await engine.executeRaw<{ status: string; promoted_row_num: number; acted_by: string }>(
|
||||
`SELECT status, promoted_row_num, acted_by FROM take_proposals WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
expect(rows[0]!.status).toBe('accepted');
|
||||
expect(rows[0]!.promoted_row_num).toBe(result.promoted_row_num!);
|
||||
expect(rows[0]!.acted_by).toBe('test');
|
||||
|
||||
// Double-resolve fails loudly.
|
||||
await expect(
|
||||
resolveTakeProposal(engine, { id, verdict: 'reject', sourceId: 'default' }),
|
||||
).rejects.toThrow(/already accepted/);
|
||||
});
|
||||
|
||||
test('reject records the verdict without touching the fence', async () => {
|
||||
const id = await insertProposal({ slug: 'people/alice-example', claim: 'Overreaching claim', hash: 'h-reject' });
|
||||
const result = await resolveTakeProposal(engine, { id, verdict: 'reject', sourceId: 'default' });
|
||||
expect(result.status).toBe('rejected');
|
||||
|
||||
const rows = await engine.executeRaw<{ status: string }>(
|
||||
`SELECT status FROM take_proposals WHERE id = $1`, [id],
|
||||
);
|
||||
expect(rows[0]!.status).toBe('rejected');
|
||||
expect(readFileSync(join(brainDir, 'people/alice-example.md'), 'utf-8')).not.toContain('Overreaching claim');
|
||||
});
|
||||
|
||||
test('accept without a brain dir fails loudly (markdown is canonical)', async () => {
|
||||
const id = await insertProposal({ slug: 'people/alice-example', claim: 'No dir claim', hash: 'h-nodir' });
|
||||
await expect(
|
||||
resolveTakeProposal(engine, { id, verdict: 'accept', sourceId: 'default' }),
|
||||
).rejects.toThrow(/NO_BRAIN_DIR/);
|
||||
});
|
||||
|
||||
test('unknown or out-of-scope id fails loudly', async () => {
|
||||
await expect(
|
||||
resolveTakeProposal(engine, { id: 9_999_999, verdict: 'reject', sourceId: 'default' }),
|
||||
).rejects.toThrow(/PROPOSAL_NOT_FOUND/);
|
||||
// Source isolation: an id that exists is invisible from another source.
|
||||
const id = await insertProposal({ slug: 'people/alice-example', claim: 'Scoped claim', hash: 'h-scope' });
|
||||
await expect(
|
||||
resolveTakeProposal(engine, { id, verdict: 'reject', sourceId: 'other-source' }),
|
||||
).rejects.toThrow(/PROPOSAL_NOT_FOUND/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user