Files
gbrain/test/submit-agent.test.ts
Sina Matian aff34a428a v0.42.72.0 feat(auth): server-enforced slug-prefix write fence for OAuth clients + qm-harness integration guide (#3712)
Registering an OAuth client with --bound-slug-prefixes now makes the write boundary real: writes outside the bound prefixes are refused on every op that can name a page, and ops that write by something other than a slug are refused outright rather than left unfenced. Deny-by-default at dispatch, so a write op added later is refused to bound clients until it is explicitly fenced.

Adds docs/integrations/qm-harness.md (gbrain as the company brain for a qm deployment) with a roster-driven provisioning script and deployment templates, plus a Known limitations section stating plainly that this is a write boundary and not a privacy boundary.

Five review rounds, including three clean-room passes by codex gpt-5.6-sol and Claude Fable 5 against an instruction-stripped tree.
2026-08-02 07:05:13 +08:00

421 lines
16 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { withEnv } from './helpers/with-env.ts';
import { operationsByName } from '../src/core/operations.ts';
/**
* v0.38 Slice 3 — `submit_agent` MCP op tests.
*
* Covers the load-bearing trust-boundary surface:
* - Per-dispatch binding enforcement against oauth_clients.bound_*
* - allowed_tools ⊆ bound_tools subset check
* - allowed_slug_prefixes prefix-match against bound_slug_prefixes
* - bound_max_concurrent concurrency cap
* - Local CLI bypass (ctx.remote === false → invalid_request)
* - Refusal when client has scope but missing bindings
* - Refusal for unknown client_id
* - dry_run path
* - Happy-path submission writes audit row + queue row
*
* Audit-trail writes go to a tmpdir via GBRAIN_AUDIT_DIR (withEnv-wrapped).
*/
const submit_agent = operationsByName['submit_agent'];
if (!submit_agent) {
throw new Error('submit_agent op missing from operations registry — test fixture invalid');
}
let engine: PGLiteEngine;
let tmpAuditDir: string;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
// resetPgliteState truncates `config` table; restore the version row so
// MinionQueue.ensureSchema() sees the migrated state. The schema itself
// is preserved (initSchema applied in beforeAll); only the config-table
// marker row needs re-seeding.
await engine.setConfig('version', '85');
tmpAuditDir = fs.mkdtempSync(path.join(os.tmpdir(), 'submit-agent-audit-'));
});
interface SeedOpts {
bound_tools?: string[] | null;
bound_source_id?: string | null;
bound_brain_id?: string | null;
bound_slug_prefixes?: string[] | null;
bound_max_concurrent?: number;
budget_usd_per_day?: number | null;
scope?: string;
}
async function seedClient(clientId: string, opts: SeedOpts = {}): Promise<void> {
await engine.executeRaw(
`INSERT INTO oauth_clients
(client_id, client_name, client_secret_hash, scope, grant_types,
redirect_uris, token_endpoint_auth_method,
bound_tools, bound_source_id, bound_brain_id, bound_slug_prefixes,
bound_max_concurrent, budget_usd_per_day, created_at, deleted_at)
VALUES ($1, $1, '', $2, ARRAY['client_credentials'],
ARRAY[]::text[], 'client_secret_post',
$3, $4, $5, $6, $7, $8, now(), NULL)
ON CONFLICT (client_id) DO UPDATE SET
bound_tools = EXCLUDED.bound_tools,
bound_source_id = EXCLUDED.bound_source_id,
bound_slug_prefixes = EXCLUDED.bound_slug_prefixes,
bound_max_concurrent = EXCLUDED.bound_max_concurrent,
budget_usd_per_day = EXCLUDED.budget_usd_per_day,
scope = EXCLUDED.scope`,
[
clientId,
opts.scope ?? 'read agent',
opts.bound_tools ?? null,
opts.bound_source_id ?? null,
opts.bound_brain_id ?? null,
opts.bound_slug_prefixes ?? null,
opts.bound_max_concurrent ?? 1,
opts.budget_usd_per_day ?? null,
],
);
}
function makeCtx(opts: { clientId?: string; remote?: boolean; dryRun?: boolean } = {}): any {
return {
engine,
config: {},
logger: console,
dryRun: opts.dryRun ?? false,
remote: opts.remote ?? true,
auth: opts.clientId ? { clientId: opts.clientId } : undefined,
};
}
async function callSubmitAgent(ctx: any, params: Record<string, unknown>): Promise<any> {
return await withEnv({ GBRAIN_AUDIT_DIR: tmpAuditDir }, async () => {
return await submit_agent.handler(ctx, params);
});
}
describe('submit_agent op (v0.38 Slice 3 — remote-callable agent dispatch with binding enforcement)', () => {
describe('op surface', () => {
it('declares scope=agent + mutating=true', () => {
expect(submit_agent.scope).toBe('agent' as any);
expect(submit_agent.mutating).toBe(true);
});
it('declares required prompt param', () => {
expect(submit_agent.params.prompt).toBeDefined();
expect((submit_agent.params.prompt as any).required).toBe(true);
});
});
describe('local CLI bypass (ctx.remote === false)', () => {
it('throws invalid_request — local CLI must use gbrain agent run', async () => {
const ctx = makeCtx({ remote: false });
await expect(callSubmitAgent(ctx, { prompt: 'hi' })).rejects.toThrow(
/local CLI.*gbrain agent run/i,
);
});
});
describe('OAuth client requirement', () => {
it('refuses when no clientId in ctx.auth', async () => {
const ctx = makeCtx(); // no clientId
await expect(callSubmitAgent(ctx, { prompt: 'hi' })).rejects.toThrow(
/requires an OAuth client with the `agent` scope/i,
);
});
it('refuses when client_id is unknown', async () => {
const ctx = makeCtx({ clientId: 'nobody-here' });
await expect(callSubmitAgent(ctx, { prompt: 'hi' })).rejects.toThrow(
/client_id nobody-here not found/,
);
});
});
describe('binding requirement (D13 — opt-in only)', () => {
it('refuses when client has agent scope but bound_tools is NULL', async () => {
// Legacy admin client gets agent scope appended via re-registration but
// forgot to set --bound-tools. Refuse with the paste-ready hint.
await seedClient('legacy-admin', { bound_tools: null });
const ctx = makeCtx({ clientId: 'legacy-admin' });
await expect(callSubmitAgent(ctx, { prompt: 'hi' })).rejects.toThrow(
/has the agent scope but no bindings.*re-register/i,
);
});
});
describe('allowed_tools subset enforcement', () => {
it('passes when allowed_tools ⊆ bound_tools', async () => {
await seedClient('cursor', {
bound_tools: ['search', 'get_page', 'put_page'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
bound_max_concurrent: 3,
});
const ctx = makeCtx({ clientId: 'cursor', dryRun: true });
const result = await callSubmitAgent(ctx, {
prompt: 'go',
allowed_tools: ['search', 'get_page'],
});
expect(result.dry_run).toBe(true);
expect(result.action).toBe('submit_agent');
});
it('refuses when allowed_tools requests a tool outside bound_tools', async () => {
await seedClient('cursor', {
bound_tools: ['search', 'get_page'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
});
const ctx = makeCtx({ clientId: 'cursor' });
await expect(
callSubmitAgent(ctx, { prompt: 'go', allowed_tools: ['put_page'] }),
).rejects.toThrow(/tool "put_page" is not in client cursor's bound_tools/);
});
it('defaults to bound_tools when allowed_tools omitted', async () => {
await seedClient('cursor', {
bound_tools: ['search'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
});
const ctx = makeCtx({ clientId: 'cursor', dryRun: true });
const result = await callSubmitAgent(ctx, { prompt: 'go' });
expect(result.dry_run).toBe(true);
});
// An EXPLICIT [] used to pass both subset loops vacuously and reach the
// worker, which reads empty allowed_tools as "the whole registry" — so a
// client bound to ['search'] got put_page. `??` doesn't substitute for an
// empty array, only for null/undefined.
it('collapses an explicit empty allowed_tools to the binding, not the full registry', async () => {
await seedClient('cursor', {
bound_tools: ['search'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
});
const ctx = makeCtx({ clientId: 'cursor', dryRun: true });
const result = await callSubmitAgent(ctx, { prompt: 'go', allowed_tools: [] });
expect(result.dry_run).toBe(true);
expect(result.resolved_tools).toEqual(['search']);
});
// Empty prefixes reached the subagent as "use the legacy
// wiki/agents/<job-id>/ namespace" — outside every bound prefix.
it('collapses an explicit empty allowed_slug_prefixes to the binding', async () => {
await seedClient('cursor', {
bound_tools: ['put_page'],
bound_source_id: 'default',
bound_slug_prefixes: ['emp-alice/'],
});
const ctx = makeCtx({ clientId: 'cursor', dryRun: true });
const result = await callSubmitAgent(ctx, { prompt: 'go', allowed_slug_prefixes: [] });
// Normalized into the glob the delegated matcher understands, so the
// subagent can write descendants rather than one exact slug.
expect(result.resolved_slug_prefixes).toEqual(['emp-alice/*']);
});
});
describe('allowed_slug_prefixes enforcement', () => {
it('passes when each requested prefix is under a bound prefix', async () => {
await seedClient('cursor', {
bound_tools: ['put_page'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/', 'people/'],
});
const ctx = makeCtx({ clientId: 'cursor', dryRun: true });
// 'wiki/' starts with 'wiki/' (exact prefix match)
const r1 = await callSubmitAgent(ctx, {
prompt: 'go',
allowed_slug_prefixes: ['wiki/'],
});
expect(r1.dry_run).toBe(true);
});
it('refuses when a requested prefix has no bound parent', async () => {
await seedClient('cursor', {
bound_tools: ['put_page'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
});
const ctx = makeCtx({ clientId: 'cursor' });
await expect(
callSubmitAgent(ctx, {
prompt: 'go',
allowed_slug_prefixes: ['private/'],
}),
).rejects.toThrow(/slug_prefix "private\/" is not under any.*bound_slug_prefixes/);
});
});
describe('concurrency cap enforcement', () => {
it('refuses when inflight count >= bound_max_concurrent', async () => {
await seedClient('cursor', {
bound_tools: ['search'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
bound_max_concurrent: 2,
});
// Seed 2 already-running subagent jobs for this client.
for (let i = 0; i < 2; i++) {
await engine.executeRaw(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'active', $1::jsonb, 'default', 0, now())`,
[JSON.stringify({ prompt: `existing-${i}`, __owner_client_id: 'cursor' })],
);
}
const ctx = makeCtx({ clientId: 'cursor' });
await expect(callSubmitAgent(ctx, { prompt: 'one too many' })).rejects.toThrow(
/at concurrency cap \(2\/2\)/,
);
});
it('allows submit when inflight count < cap', async () => {
await seedClient('cursor', {
bound_tools: ['search'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
bound_max_concurrent: 3,
});
await engine.executeRaw(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'active', $1::jsonb, 'default', 0, now())`,
[JSON.stringify({ prompt: 'one', __owner_client_id: 'cursor' })],
);
const ctx = makeCtx({ clientId: 'cursor', dryRun: true });
const result = await callSubmitAgent(ctx, { prompt: 'two' });
expect(result.dry_run).toBe(true);
expect(result.bound_max_concurrent).toBe(3);
});
it('does NOT count terminal-state jobs toward the cap', async () => {
await seedClient('cursor', {
bound_tools: ['search'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
bound_max_concurrent: 1,
});
// 5 completed jobs — none counted (status filter is waiting/active/waiting-children).
for (let i = 0; i < 5; i++) {
await engine.executeRaw(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'completed', $1::jsonb, 'default', 0, now())`,
[JSON.stringify({ prompt: `done-${i}`, __owner_client_id: 'cursor' })],
);
}
const ctx = makeCtx({ clientId: 'cursor', dryRun: true });
const result = await callSubmitAgent(ctx, { prompt: 'fresh' });
expect(result.dry_run).toBe(true);
});
it('isolates inflight count by client_id (no cross-client leakage)', async () => {
await seedClient('alice', {
bound_tools: ['search'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
bound_max_concurrent: 1,
});
await seedClient('bob', {
bound_tools: ['search'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
bound_max_concurrent: 1,
});
// Alice has 1 active — at her cap.
await engine.executeRaw(
`INSERT INTO minion_jobs (name, status, data, queue, priority, created_at)
VALUES ('subagent', 'active', $1::jsonb, 'default', 0, now())`,
[JSON.stringify({ prompt: 'alice-busy', __owner_client_id: 'alice' })],
);
// Bob's submit should succeed — his cap (1) is independent.
const ctxBob = makeCtx({ clientId: 'bob', dryRun: true });
const result = await callSubmitAgent(ctxBob, { prompt: 'bob-fresh' });
expect(result.dry_run).toBe(true);
});
});
describe('happy-path submission', () => {
it('inserts a subagent job + writes audit row', async () => {
await seedClient('cursor', {
bound_tools: ['search', 'get_page'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
bound_max_concurrent: 3,
budget_usd_per_day: 5.00,
});
const ctx = makeCtx({ clientId: 'cursor' });
const result = await callSubmitAgent(ctx, {
prompt: 'research the YC W26 batch',
allowed_tools: ['search'],
});
expect(result.id).toBeGreaterThan(0);
expect(result.name).toBe('subagent');
expect(result.client_id).toBe('cursor');
// Job persisted with correct shape.
const rows = await engine.executeRaw<Record<string, unknown>>(
`SELECT name, status, data FROM minion_jobs WHERE id = $1`,
[result.id],
);
expect(rows.length).toBe(1);
expect(rows[0].name).toBe('subagent');
const data = typeof rows[0].data === 'string'
? JSON.parse(rows[0].data as string)
: (rows[0].data as Record<string, unknown>);
expect(data.prompt).toBe('research the YC W26 batch');
expect(data.allowed_tools).toEqual(['search']);
expect(data.__owner_client_id).toBe('cursor');
expect(data.source_id).toBe('default'); // auto-set from bound_source_id
// Audit file written.
const auditFiles = fs.readdirSync(tmpAuditDir).filter(f => f.startsWith('agent-jobs-'));
expect(auditFiles.length).toBe(1);
const auditContent = fs.readFileSync(path.join(tmpAuditDir, auditFiles[0]), 'utf8');
const auditLine = JSON.parse(auditContent.trim().split('\n')[0]);
expect(auditLine.client_id).toBe('cursor');
expect(auditLine.job_id).toBe(result.id);
expect(auditLine.bound_tools).toEqual(['search']);
expect(auditLine.bound_source).toBe('default');
expect(auditLine.budget_remaining_cents).toBe(500); // 5.00 USD → 500 cents
expect(auditLine.outcome).toBe('submitted');
// CRITICAL: prompt text MUST NOT be in audit (only byte count).
expect(auditContent).not.toContain('YC W26 batch');
});
it('caps max_turns at 100', async () => {
await seedClient('cursor', {
bound_tools: ['search'],
bound_source_id: 'default',
bound_slug_prefixes: ['wiki/'],
});
const ctx = makeCtx({ clientId: 'cursor' });
const result = await callSubmitAgent(ctx, {
prompt: 'long',
max_turns: 9999, // way over cap
});
const rows = await engine.executeRaw<Record<string, unknown>>(
`SELECT data FROM minion_jobs WHERE id = $1`,
[result.id],
);
const data = typeof rows[0].data === 'string'
? JSON.parse(rows[0].data as string)
: (rows[0].data as Record<string, unknown>);
expect(data.max_turns).toBe(100);
});
});
});