diff --git a/src/cli.ts b/src/cli.ts index 804d7dbf3..ee5efe251 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -24,6 +24,7 @@ import type { GBrainConfig } from './core/config.ts'; import type { AIGatewayConfig } from './core/ai/types.ts'; import type { BrainEngine } from './core/engine.ts'; import { operations, OperationError } from './core/operations.ts'; +import { resolveSourceIdEngineFree } from './core/source-resolver.ts'; import { formatVolunteeredPage } from './core/context/volunteer.ts'; import type { Operation, OperationContext } from './core/operations.ts'; import { shouldForceExitAfterMain, finishCliTeardown, flushThenExit, currentExitCode, setCliExitVerdict } from './core/cli-force-exit.ts'; @@ -382,6 +383,15 @@ async function main() { if (op.localOnly) { refuseThinClient(command, cfgPre!.remote_mcp!.mcp_url); } + // #2098: the local path resolves --source / GBRAIN_SOURCE / .gbrain-source + // inside makeContext (ctx.sourceId), which this route never reaches — so + // scope must be mapped onto the op's source_id wire param before the call. + try { + applyThinClientSourceScope(op, params); + } catch (e: unknown) { + console.error(e instanceof Error ? e.message : String(e)); + process.exit(1); + } await runThinClientRouted(op, params, cfgPre!, cliOpts); return; } @@ -802,6 +812,52 @@ export function parseOpArgs(op: Operation, args: string[]): Record, + cwd?: string, +): void { + if ('source' in op.params) return; // the op owns --source; not a scope flag + const explicit = typeof params.source === 'string' && params.source.length > 0 + ? (params.source as string) + : null; + delete params.source; // never a wire param on these ops — don't leak it + // Explicit per-call scope already on the wire wins over ambient tiers. + if (params.source_id !== undefined || params.all_sources === true) { + if (explicit) { + throw new Error('Pass either --source or --source-id/--all-sources, not both.'); + } + return; + } + const resolved = resolveSourceIdEngineFree(explicit, cwd); + if (!resolved) return; + if (!('source_id' in op.params)) { + if (explicit) { + throw new Error( + `gbrain ${op.cliHints?.name || op.name} does not accept --source on a thin-client install ` + + `(the remote op has no source_id parameter; the server scopes it to your grant).`, + ); + } + return; // ambient env/dotfile scope with nowhere to send it + } + params.source_id = resolved; +} + async function makeContext(engine: BrainEngine, params: Record): Promise { // v0.31.8 (D11): resolve sourceId via the canonical 6-tier chain. Honors // --source / GBRAIN_SOURCE / .gbrain-source / path-match / brain default / diff --git a/src/core/source-resolver.ts b/src/core/source-resolver.ts index 8b9f3bada..4c21af558 100644 --- a/src/core/source-resolver.ts +++ b/src/core/source-resolver.ts @@ -160,6 +160,33 @@ export async function resolveSourceId( return 'default'; } +/** + * Engine-free tiers (1-3) of the resolution chain: explicit flag → + * GBRAIN_SOURCE env → .gbrain-source dotfile walk. Used by the thin-client + * CLI path (#2098), which has no local engine to run tiers 4-6 or + * assertSourceExists against — the remote server enforces existence + grant. + * Returns null when no engine-free tier fires. + */ +export function resolveSourceIdEngineFree( + explicit: string | null | undefined, + cwd: string = process.cwd(), +): string | null { + if (explicit) { + if (!SOURCE_ID_RE.test(explicit)) { + throw new Error(`Invalid --source value "${explicit}". Must match [a-z0-9-]{1,32}.`); + } + return explicit; + } + const env = process.env.GBRAIN_SOURCE; + if (env && env.length > 0) { + if (!SOURCE_ID_RE.test(env)) { + throw new Error(`Invalid GBRAIN_SOURCE value "${env}". Must match [a-z0-9-]{1,32}.`); + } + return env; + } + return readDotfileWalk(cwd); +} + /** * Returns the id of the SINGLE registered non-default source with a * local_path, when exactly one such row exists. Returns null when: diff --git a/test/thin-client-source-scope.test.ts b/test/thin-client-source-scope.test.ts new file mode 100644 index 000000000..0d9784316 --- /dev/null +++ b/test/thin-client-source-scope.test.ts @@ -0,0 +1,107 @@ +/** + * #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, beforeEach, afterEach } 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'; + +const queryOp = operationsByName.query; + +let savedEnv: string | undefined; +beforeEach(() => { + savedEnv = process.env.GBRAIN_SOURCE; + delete process.env.GBRAIN_SOURCE; +}); +afterEach(() => { + if (savedEnv === undefined) delete process.env.GBRAIN_SOURCE; + else process.env.GBRAIN_SOURCE = savedEnv; +}); + +describe('applyThinClientSourceScope (#2098)', () => { + test('--source maps onto the query op wire param source_id', () => { + 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', () => { + process.env.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', () => { + 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', () => { + process.env.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', () => { + const params = parseOpArgs(queryOp, ['q', '--source', 'a', '--source-id', 'b']); + expect(() => applyThinClientSourceScope(queryOp, params, '/')).toThrow(/not both/); + }); + + test('invalid --source value is rejected loudly', () => { + 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', () => { + 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)', () => { + process.env.GBRAIN_SOURCE = 'wiki'; + const op = operationsByName.add_tag; + const params: Record = { slug: 'x', tag: 'y' }; + applyThinClientSourceScope(op, params, '/'); + expect(params.source_id).toBeUndefined(); + }); + + test('ops that declare their OWN source param are left untouched', () => { + const op = operationsByName.put_raw_data; + expect('source' in op.params).toBe(true); + const params: Record = { slug: 'x', source: 'crustdata', data: {} }; + applyThinClientSourceScope(op, params, '/'); + expect(params.source).toBe('crustdata'); + expect(params.source_id).toBeUndefined(); + }); + + test('no scope from any tier leaves params unchanged', () => { + const params = parseOpArgs(queryOp, ['find things']); + applyThinClientSourceScope(queryOp, params, '/'); + expect(params.source_id).toBeUndefined(); + }); +});