mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 06:23:01 +00:00
* fix(thin-client): map --source/GBRAIN_SOURCE/.gbrain-source onto source_id for routed ops (#2098) The thin-client route short-circuits before makeContext, so the 6-tier source resolution never ran and `gbrain query --source X` against a remote brain sent the unknown `source` key verbatim — the server op ignored it and searched unscoped. applyThinClientSourceScope now runs the engine-free tiers (flag → env → dotfile; DB-backed tiers need an engine, and the server's grant scoping covers the rest) and sets the op's source_id wire param. Ops declaring their own `source` param are untouched; an explicit --source on an op with no source_id wire param errors loudly instead of silently dropping; explicit --source-id/--all-sources on the wire win over ambient tiers. Fixes #2098 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(thin-client): use withEnv() instead of direct process.env mutation (test-isolation R1) CI verify failed on check:test-isolation — thin-client-source-scope.test.ts mutated process.env.GBRAIN_SOURCE via beforeEach/afterEach. Wrapped each test body in withEnv() from test/helpers/with-env.ts instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(thin-client): keep ambient scope out of get_skill's non-scope source_id param get_skill's source_id is a mode switch (host catalog vs brain-resident-pack lookup), not a read-scope filter. Ambient GBRAIN_SOURCE / .gbrain-source injection would silently reroute 'gbrain skill <name>' on thin clients to getResidentSkillDetail. Exclude it via NON_SCOPE_SOURCE_ID_OPS; explicit --source-id still passes through, explicit --source errors with a hint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Garry Tan <garrytan@gmail.com>
143 lines
6.1 KiB
TypeScript
143 lines
6.1 KiB
TypeScript
/**
|
|
* #2098: thin-client routing dropped --source / GBRAIN_SOURCE / .gbrain-source.
|
|
*
|
|
* The local CLI path resolves source scope in makeContext (ctx.sourceId); the
|
|
* thin-client route short-circuits before that and sent params verbatim, so
|
|
* `gbrain query --source X` against a remote brain silently searched unscoped
|
|
* (the server op ignores the unknown `source` key).
|
|
*
|
|
* applyThinClientSourceScope runs the engine-free tiers (flag → env → dotfile)
|
|
* and maps the result onto the op's `source_id` wire param. These tests fail
|
|
* without the fix (params.source_id stays undefined / params.source leaks).
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { applyThinClientSourceScope, parseOpArgs } from '../src/cli.ts';
|
|
import { operationsByName } from '../src/core/operations.ts';
|
|
import { withEnv } from './helpers/with-env.ts';
|
|
|
|
const queryOp = operationsByName.query;
|
|
|
|
describe('applyThinClientSourceScope (#2098)', () => {
|
|
test('--source maps onto the query op wire param source_id', async () => {
|
|
await withEnv({ GBRAIN_SOURCE: undefined }, () => {
|
|
const params = parseOpArgs(queryOp, ['find things', '--source', 'wiki']);
|
|
expect(params.source).toBe('wiki'); // pre-fix state: wrong key
|
|
applyThinClientSourceScope(queryOp, params, '/');
|
|
expect(params.source_id).toBe('wiki');
|
|
expect('source' in params).toBe(false); // never leaks the unknown key
|
|
});
|
|
});
|
|
|
|
test('GBRAIN_SOURCE env tier fires when no flag is passed', async () => {
|
|
await withEnv({ GBRAIN_SOURCE: 'gstack' }, () => {
|
|
const params = parseOpArgs(queryOp, ['find things']);
|
|
applyThinClientSourceScope(queryOp, params, '/');
|
|
expect(params.source_id).toBe('gstack');
|
|
});
|
|
});
|
|
|
|
test('.gbrain-source dotfile tier fires when flag and env are absent', async () => {
|
|
await withEnv({ GBRAIN_SOURCE: undefined }, () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'gbrain-thin-scope-'));
|
|
try {
|
|
writeFileSync(join(tmp, '.gbrain-source'), 'essays\n');
|
|
const params = parseOpArgs(queryOp, ['find things']);
|
|
applyThinClientSourceScope(queryOp, params, tmp);
|
|
expect(params.source_id).toBe('essays');
|
|
} finally {
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
test('explicit --source-id on the wire wins over ambient env scope', async () => {
|
|
await withEnv({ GBRAIN_SOURCE: 'gstack' }, () => {
|
|
const params = parseOpArgs(queryOp, ['find things', '--source-id', 'wiki']);
|
|
applyThinClientSourceScope(queryOp, params, '/');
|
|
expect(params.source_id).toBe('wiki');
|
|
});
|
|
});
|
|
|
|
test('--source together with --source-id is rejected loudly', async () => {
|
|
await withEnv({ GBRAIN_SOURCE: undefined }, () => {
|
|
const params = parseOpArgs(queryOp, ['q', '--source', 'a', '--source-id', 'b']);
|
|
expect(() => applyThinClientSourceScope(queryOp, params, '/')).toThrow(/not both/);
|
|
});
|
|
});
|
|
|
|
test('invalid --source value is rejected loudly', async () => {
|
|
await withEnv({ GBRAIN_SOURCE: undefined }, () => {
|
|
const params = parseOpArgs(queryOp, ['q', '--source', 'Bad_Value!']);
|
|
expect(() => applyThinClientSourceScope(queryOp, params, '/')).toThrow(/Invalid --source/);
|
|
});
|
|
});
|
|
|
|
test('--source on an op with no source_id wire param errors instead of silently dropping', async () => {
|
|
await withEnv({ GBRAIN_SOURCE: undefined }, () => {
|
|
const op = operationsByName.add_tag;
|
|
expect('source_id' in op.params).toBe(false);
|
|
const params = { slug: 'x', tag: 'y', source: 'wiki' };
|
|
expect(() => applyThinClientSourceScope(op, params, '/')).toThrow(/--source/);
|
|
});
|
|
});
|
|
|
|
test('ambient env scope on an op with no source_id wire param is ignored (no throw)', async () => {
|
|
await withEnv({ GBRAIN_SOURCE: 'wiki' }, () => {
|
|
const op = operationsByName.add_tag;
|
|
const params: Record<string, unknown> = { slug: 'x', tag: 'y' };
|
|
applyThinClientSourceScope(op, params, '/');
|
|
expect(params.source_id).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
test('ops that declare their OWN source param are left untouched', async () => {
|
|
await withEnv({ GBRAIN_SOURCE: undefined }, () => {
|
|
const op = operationsByName.put_raw_data;
|
|
expect('source' in op.params).toBe(true);
|
|
const params: Record<string, unknown> = { slug: 'x', source: 'crustdata', data: {} };
|
|
applyThinClientSourceScope(op, params, '/');
|
|
expect(params.source).toBe('crustdata');
|
|
expect(params.source_id).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
test('get_skill: ambient scope never leaks into its non-scope source_id param', async () => {
|
|
await withEnv({ GBRAIN_SOURCE: 'wiki' }, () => {
|
|
const op = operationsByName.get_skill;
|
|
expect('source_id' in op.params).toBe(true); // has the param, but it is a mode switch
|
|
const params: Record<string, unknown> = { name: 'ingest' };
|
|
applyThinClientSourceScope(op, params, '/');
|
|
expect(params.source_id).toBeUndefined(); // would flip host catalog → brain-pack lookup
|
|
});
|
|
});
|
|
|
|
test('get_skill: explicit --source errors instead of masquerading as --source-id', async () => {
|
|
await withEnv({ GBRAIN_SOURCE: undefined }, () => {
|
|
const op = operationsByName.get_skill;
|
|
const params: Record<string, unknown> = { name: 'ingest', source: 'wiki' };
|
|
expect(() => applyThinClientSourceScope(op, params, '/')).toThrow(/--source-id/);
|
|
});
|
|
});
|
|
|
|
test('get_skill: explicit --source-id passes through untouched', async () => {
|
|
await withEnv({ GBRAIN_SOURCE: 'gstack' }, () => {
|
|
const op = operationsByName.get_skill;
|
|
const params: Record<string, unknown> = { name: 'ingest', source_id: 'wiki' };
|
|
applyThinClientSourceScope(op, params, '/');
|
|
expect(params.source_id).toBe('wiki');
|
|
});
|
|
});
|
|
|
|
test('no scope from any tier leaves params unchanged', async () => {
|
|
await withEnv({ GBRAIN_SOURCE: undefined }, () => {
|
|
const params = parseOpArgs(queryOp, ['find things']);
|
|
applyThinClientSourceScope(queryOp, params, '/');
|
|
expect(params.source_id).toBeUndefined();
|
|
});
|
|
});
|
|
});
|