mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-16 09:52:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c80b8b6757 |
+11
-66
@@ -24,7 +24,6 @@ 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';
|
||||
@@ -383,15 +382,6 @@ 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;
|
||||
}
|
||||
@@ -812,72 +802,26 @@ export function parseOpArgs(op: Operation, args: string[]): Record<string, unkno
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* #2098: thin-client source scoping. Locally, --source / GBRAIN_SOURCE /
|
||||
* .gbrain-source resolve to ctx.sourceId in makeContext; the thin-client
|
||||
* route short-circuits before that, so `gbrain query --source X` against a
|
||||
* remote brain silently searched unscoped. This runs the engine-free tiers
|
||||
* (flag → env → dotfile; the DB-backed tiers can't run without an engine —
|
||||
* the server's grant scoping covers the rest) and maps the result onto the
|
||||
* op's `source_id` wire param.
|
||||
*
|
||||
* Ops that declare their OWN `source` param (facts add, etc.) are left
|
||||
* untouched — their --source is an op param, not scope. An explicit --source
|
||||
* on an op with no source_id wire param throws (loud beats silent drop);
|
||||
* ambient env/dotfile scope with nowhere to send it is ignored, matching the
|
||||
* pre-fix behavior for non-scopeable ops. Exported for tests.
|
||||
*/
|
||||
// Ops whose `source_id` wire param is NOT read-scope semantics: get_skill's
|
||||
// source_id flips the lookup from host catalog to brain-resident-pack
|
||||
// (getResidentSkillDetail). Ambient env/dotfile scope must never leak into
|
||||
// these; an explicit --source-id still passes through untouched above.
|
||||
const NON_SCOPE_SOURCE_ID_OPS = new Set(['get_skill']);
|
||||
|
||||
export function applyThinClientSourceScope(
|
||||
op: Operation,
|
||||
params: Record<string, unknown>,
|
||||
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) || NON_SCOPE_SOURCE_ID_OPS.has(op.name)) {
|
||||
if (explicit) {
|
||||
const hint = NON_SCOPE_SOURCE_ID_OPS.has(op.name)
|
||||
? `(its source_id parameter is not a scope filter; pass --source-id explicitly if you mean it)`
|
||||
: `(the remote op has no source_id parameter; the server scopes it to your grant)`;
|
||||
throw new Error(
|
||||
`gbrain ${op.cliHints?.name || op.name} does not accept --source on a thin-client install ${hint}.`,
|
||||
);
|
||||
}
|
||||
return; // ambient env/dotfile scope with nowhere to send it
|
||||
}
|
||||
params.source_id = resolved;
|
||||
}
|
||||
|
||||
async function makeContext(engine: BrainEngine, params: Record<string, unknown>): Promise<OperationContext> {
|
||||
// v0.31.8 (D11): resolve sourceId via the canonical 6-tier chain. Honors
|
||||
// --source / GBRAIN_SOURCE / .gbrain-source / path-match / brain default /
|
||||
// 'default'. Wrapped in try/catch so a doctor / single-source brain that
|
||||
// never set up sources still returns 'default' silently.
|
||||
let sourceId: string | undefined;
|
||||
// #2561: when the source resolved via a NON-explicit tier (path-match /
|
||||
// brain default / sole-non-default / seed default), unqualified search-shaped
|
||||
// reads span every `config.federated = true` source. Computed here (the
|
||||
// trusted local boundary) and consumed by federatedSearchScope in
|
||||
// operations.ts, which additionally gates on ctx.remote === false.
|
||||
let localFederated: string[] | undefined;
|
||||
try {
|
||||
const { resolveSourceId } = await import('./core/source-resolver.ts');
|
||||
const { resolveSourceWithTier, localFederatedSourceIds } = await import('./core/source-resolver.ts');
|
||||
// params.source is set when a CLI flag was parsed for the op (rare; most
|
||||
// CLI ops don't take --source). Falls through to env/dotfile/path-match.
|
||||
const explicit = (params.source as string | undefined) ?? null;
|
||||
sourceId = await resolveSourceId(engine, explicit);
|
||||
const resolved = await resolveSourceWithTier(engine, explicit);
|
||||
sourceId = resolved.source_id;
|
||||
localFederated = await localFederatedSourceIds(engine, resolved.source_id, resolved.tier);
|
||||
} catch {
|
||||
// Source resolution failed (e.g. sources table doesn't exist on a fresh
|
||||
// pre-init brain). Leave sourceId unset; engine read methods fall through
|
||||
@@ -898,6 +842,7 @@ async function makeContext(engine: BrainEngine, params: Record<string, unknown>)
|
||||
// table). Matches dispatch.ts's auto-fill so the contract holds across
|
||||
// every transport.
|
||||
sourceId: sourceId ?? 'default',
|
||||
...(localFederated ? { localFederatedSourceIds: localFederated } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+61
-2
@@ -424,6 +424,23 @@ export interface OperationContext {
|
||||
* satisfied even on single-source brains.
|
||||
*/
|
||||
sourceId: string;
|
||||
/**
|
||||
* #2561 — federated read scope for UNQUALIFIED local CLI reads.
|
||||
*
|
||||
* Set ONLY by the local CLI's context builder (src/cli.ts makeContext), and
|
||||
* only when the source resolved via a non-explicit tier (local_path /
|
||||
* brain_default / sole_non_default / seed_default — NOT --source, NOT
|
||||
* GBRAIN_SOURCE, NOT a .gbrain-source dotfile). Contains the resolved
|
||||
* source first, then every other `config.federated = true` source, so an
|
||||
* unqualified `gbrain search "X"` spans federated sources as
|
||||
* docs/guides/multi-source-brains.md promises.
|
||||
*
|
||||
* Consumed exclusively by `federatedSearchScope` and ONLY when
|
||||
* `ctx.remote === false` — a remote caller's scope stays governed by
|
||||
* `ctx.auth.allowedSources` / scalar `ctx.sourceId` (source-isolation
|
||||
* invariant, fail-closed).
|
||||
*/
|
||||
localFederatedSourceIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -539,6 +556,45 @@ export function resolveRequestedScope(
|
||||
return sourceScopeOpts(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* #2561 — source scope for the search-shaped read ops (`search`, `query`).
|
||||
*
|
||||
* Delegates to `resolveRequestedScope` (the single trust+grant resolver), then
|
||||
* widens an UNQUALIFIED trusted-local scalar scope to the CLI-computed
|
||||
* federated set (`ctx.localFederatedSourceIds`, resolved source first). This is
|
||||
* what makes `sources add --federated` mean something for local search: a
|
||||
* federated source participates in unqualified `gbrain search "X"` results.
|
||||
*
|
||||
* The expansion NEVER applies when:
|
||||
* - the caller is not strictly trusted-local (`ctx.remote !== false`) —
|
||||
* remote scope stays grant-governed (fail-closed source isolation);
|
||||
* - a per-call `source_id` was passed (explicit wins, including `__all__`);
|
||||
* - the resolver already produced a federated array (OAuth grant);
|
||||
* - the CLI resolved the source from an explicit signal (--source / env /
|
||||
* dotfile) — makeContext leaves `localFederatedSourceIds` unset then.
|
||||
*
|
||||
* Deliberately NOT inside `sourceScopeOpts`: code-intel ops collapse a
|
||||
* multi-element scope to an error (`resolveCodeIntelScope`), and non-search
|
||||
* reads (get_page, get_links, …) keep their long-standing scalar behavior.
|
||||
*/
|
||||
export function federatedSearchScope(
|
||||
ctx: OperationContext,
|
||||
sourceIdParam?: string,
|
||||
): { sourceId?: string; sourceIds?: string[] } {
|
||||
const scope = resolveRequestedScope(ctx, sourceIdParam);
|
||||
if (
|
||||
ctx.remote === false &&
|
||||
sourceIdParam === undefined &&
|
||||
scope.sourceId !== undefined &&
|
||||
scope.sourceIds === undefined &&
|
||||
ctx.localFederatedSourceIds !== undefined &&
|
||||
ctx.localFederatedSourceIds.length > 1
|
||||
) {
|
||||
return { sourceIds: ctx.localFederatedSourceIds };
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Code-intel adapter for `resolveRequestedScope`. Graph traversal
|
||||
* (code_callers/code_callees/code_blast/code_flow) is single-source by design —
|
||||
@@ -1448,7 +1504,8 @@ const search: Operation = {
|
||||
const queryText = p.query as string;
|
||||
const limit = (p.limit as number) || 20;
|
||||
const offset = (p.offset as number) || 0;
|
||||
const scope = sourceScopeOpts(ctx);
|
||||
// #2561: unqualified trusted-local search spans federated sources.
|
||||
const scope = federatedSearchScope(ctx);
|
||||
|
||||
// T4/D5 — per-call mode honored ONLY for trusted/local callers so a remote
|
||||
// OAuth client can't escalate to the costly tokenmax bundle. Local + unknown
|
||||
@@ -1610,7 +1667,9 @@ const query: Operation = {
|
||||
// is spread into BOTH the image-similarity searchVector path and the text
|
||||
// hybridSearch path below, so both honor the same grant.
|
||||
const sourceIdParam = typeof p.source_id === 'string' ? p.source_id : undefined;
|
||||
const querySourceScope = resolveRequestedScope(ctx, sourceIdParam);
|
||||
// #2561: unqualified trusted-local query spans federated sources (per-call
|
||||
// source_id / remote grants still resolve through resolveRequestedScope).
|
||||
const querySourceScope = federatedSearchScope(ctx, sourceIdParam);
|
||||
|
||||
// v0.27.1: image-similarity branch. Bypasses hybridSearch (which is
|
||||
// text-only); embeds the image via embedMultimodal and runs a direct
|
||||
|
||||
+39
-27
@@ -160,33 +160,6 @@ 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:
|
||||
@@ -380,6 +353,45 @@ export async function resolveSourceWithTier(
|
||||
return { source_id: 'default', tier: 'seed_default' };
|
||||
}
|
||||
|
||||
/**
|
||||
* #2561 — compute the federated read scope for an UNQUALIFIED local CLI call.
|
||||
*
|
||||
* `sources add --federated` promises that a `config.federated = true` source
|
||||
* "participates in unqualified `gbrain search` results"
|
||||
* (docs/guides/multi-source-brains.md). This helper turns that promise into a
|
||||
* scope: given the resolved source and WHICH tier resolved it, return
|
||||
* `[resolvedSource, ...other federated source ids]` — or `undefined` when the
|
||||
* expansion must not apply:
|
||||
*
|
||||
* - explicit tiers (`flag` / `env` / `dotfile`): the user named a source;
|
||||
* scalar scope stands (that IS the qualified case);
|
||||
* - no other federated source exists: keep the scalar fast path unchanged.
|
||||
*
|
||||
* Archived sources are excluded (same rationale as pickSoleNonDefaultSource);
|
||||
* the archived column is v34+, so fall back to the un-archived query on older
|
||||
* brains. Callers put the result on `OperationContext.localFederatedSourceIds`
|
||||
* — consumed only by `federatedSearchScope` and only when `remote === false`.
|
||||
*/
|
||||
export async function localFederatedSourceIds(
|
||||
engine: BrainEngine,
|
||||
sourceId: string,
|
||||
tier: SourceTier,
|
||||
): Promise<string[] | undefined> {
|
||||
if (tier === 'flag' || tier === 'env' || tier === 'dotfile') return undefined;
|
||||
let rows: Array<{ id: string }>;
|
||||
try {
|
||||
rows = await engine.executeRaw<{ id: string }>(
|
||||
`SELECT id FROM sources WHERE config->>'federated' = 'true' AND archived = false ORDER BY id`,
|
||||
);
|
||||
} catch {
|
||||
rows = await engine.executeRaw<{ id: string }>(
|
||||
`SELECT id FROM sources WHERE config->>'federated' = 'true' ORDER BY id`,
|
||||
);
|
||||
}
|
||||
const ids = [sourceId, ...rows.map((r) => r.id).filter((id) => id !== sourceId)];
|
||||
return ids.length > 1 ? ids : undefined;
|
||||
}
|
||||
|
||||
/** Exposed for tests. */
|
||||
export const __testing = {
|
||||
readDotfileWalk,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* #2561 — sources.config.federated participates in UNQUALIFIED local CLI
|
||||
* search/query.
|
||||
*
|
||||
* Pre-fix: the local CLI always emitted a scalar `{sourceId}` scope (required
|
||||
* field, auto-filled 'default'), so a source registered with
|
||||
* `gbrain sources add --federated` was invisible to an unqualified
|
||||
* `gbrain search "X"` — contradicting docs/guides/multi-source-brains.md
|
||||
* ("Source participates in unqualified `gbrain search` results").
|
||||
*
|
||||
* Fix: the CLI context builder computes `ctx.localFederatedSourceIds`
|
||||
* (resolved source + every other federated source) whenever the source
|
||||
* resolved via a NON-explicit tier; `federatedSearchScope` widens the scalar
|
||||
* scope to that set for the `search` / `query` ops — trusted-local only
|
||||
* (`ctx.remote === false`), never for remote callers, never when a per-call
|
||||
* `source_id` or an explicit --source/env/dotfile was given.
|
||||
*/
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { localFederatedSourceIds } from '../src/core/source-resolver.ts';
|
||||
import {
|
||||
federatedSearchScope,
|
||||
operations,
|
||||
type OperationContext,
|
||||
} from '../src/core/operations.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
const search = operations.find((o) => o.name === 'search')!;
|
||||
|
||||
function ctxOf(overrides: Partial<OperationContext> = {}): OperationContext {
|
||||
return {
|
||||
engine: engine as any,
|
||||
config: {} as any,
|
||||
logger: console as any,
|
||||
dryRun: false,
|
||||
remote: false,
|
||||
sourceId: 'default',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
// Seeded 'default' source is federated=true. Add:
|
||||
// wiki — federated (must join unqualified search)
|
||||
// private — NOT federated (must stay invisible unless explicitly named)
|
||||
// oldnews — federated but archived (must stay excluded)
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config) VALUES ('wiki', 'wiki', '/tmp/wiki', '{"federated": true}'::jsonb)`,
|
||||
);
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config) VALUES ('private', 'private', '/tmp/private', '{}'::jsonb)`,
|
||||
);
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO sources (id, name, local_path, config, archived) VALUES ('oldnews', 'oldnews', '/tmp/oldnews', '{"federated": true}'::jsonb, true)`,
|
||||
);
|
||||
const pages: Array<[slug: string, sourceId: string, where: string]> = [
|
||||
['notes/home', 'default', 'default'],
|
||||
['wiki/topic', 'wiki', 'wiki'],
|
||||
['private/topic', 'private', 'private'],
|
||||
['old/topic', 'oldnews', 'oldnews'],
|
||||
];
|
||||
for (const [slug, sourceId, where] of pages) {
|
||||
await engine.putPage(slug, {
|
||||
type: 'note', title: `Topic in ${where}`, compiled_truth: `the zebra telescope in ${where}`, frontmatter: {},
|
||||
}, { sourceId });
|
||||
await engine.upsertChunks(slug, [
|
||||
{ chunk_index: 0, chunk_text: `the zebra telescope in ${where}`, chunk_source: 'compiled_truth' },
|
||||
], { sourceId });
|
||||
}
|
||||
// Keyword-only search path: no embedding provider needed in tests.
|
||||
await engine.setConfig('search.mcp_keyword_only', 'true');
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (engine) await engine.disconnect();
|
||||
}, 60_000);
|
||||
|
||||
describe('localFederatedSourceIds — CLI-side scope computation', () => {
|
||||
test('non-explicit tier: resolved source first, then other federated, archived excluded', async () => {
|
||||
expect(await localFederatedSourceIds(engine, 'default', 'seed_default')).toEqual(['default', 'wiki']);
|
||||
});
|
||||
|
||||
test('non-federated resolved source still joins its own scope', async () => {
|
||||
expect(await localFederatedSourceIds(engine, 'private', 'brain_default')).toEqual(['private', 'default', 'wiki']);
|
||||
});
|
||||
|
||||
test('explicit tiers (--source / env / dotfile) never expand', async () => {
|
||||
expect(await localFederatedSourceIds(engine, 'default', 'flag')).toBeUndefined();
|
||||
expect(await localFederatedSourceIds(engine, 'default', 'env')).toBeUndefined();
|
||||
expect(await localFederatedSourceIds(engine, 'default', 'dotfile')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('single federated source (the resolved one) keeps the scalar fast path', async () => {
|
||||
const solo = { executeRaw: async () => [{ id: 'default' }] } as any;
|
||||
expect(await localFederatedSourceIds(solo, 'default', 'seed_default')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('federatedSearchScope — trust + explicitness matrix', () => {
|
||||
test('trusted local + unqualified widens to the federated set', () => {
|
||||
const ctx = ctxOf({ localFederatedSourceIds: ['default', 'wiki'] });
|
||||
expect(federatedSearchScope(ctx)).toEqual({ sourceIds: ['default', 'wiki'] });
|
||||
});
|
||||
|
||||
test('remote caller NEVER widens (fail-closed), even if the field is set', () => {
|
||||
const ctx = ctxOf({ remote: true, localFederatedSourceIds: ['default', 'wiki'] });
|
||||
expect(federatedSearchScope(ctx)).toEqual({ sourceId: 'default' });
|
||||
});
|
||||
|
||||
test('per-call source_id wins over the federated set', () => {
|
||||
const ctx = ctxOf({ localFederatedSourceIds: ['default', 'wiki'] });
|
||||
expect(federatedSearchScope(ctx, 'wiki')).toEqual({ sourceId: 'wiki' });
|
||||
});
|
||||
|
||||
test('per-call __all__ keeps the whole-brain semantics for trusted local', () => {
|
||||
const ctx = ctxOf({ localFederatedSourceIds: ['default', 'wiki'] });
|
||||
expect(federatedSearchScope(ctx, '__all__')).toEqual({});
|
||||
});
|
||||
|
||||
test('a federated OAuth grant wins over the local set', () => {
|
||||
const ctx = ctxOf({
|
||||
localFederatedSourceIds: ['default', 'wiki'],
|
||||
auth: { allowedSources: ['a', 'b'] } as OperationContext['auth'],
|
||||
});
|
||||
expect(federatedSearchScope(ctx)).toEqual({ sourceIds: ['a', 'b'] });
|
||||
});
|
||||
|
||||
test('no local federated set → unchanged scalar scope', () => {
|
||||
expect(federatedSearchScope(ctxOf())).toEqual({ sourceId: 'default' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('search op — unqualified local search spans federated sources', () => {
|
||||
test('federated source results appear; non-federated + archived stay invisible', async () => {
|
||||
const ctx = ctxOf({
|
||||
localFederatedSourceIds: await localFederatedSourceIds(engine, 'default', 'seed_default'),
|
||||
});
|
||||
const results = (await search.handler(ctx, { query: 'zebra telescope' })) as Array<{ slug: string }>;
|
||||
const slugs = results.map((r) => r.slug);
|
||||
expect(slugs).toContain('notes/home');
|
||||
expect(slugs).toContain('wiki/topic'); // pre-#2561 this was missing
|
||||
expect(slugs).not.toContain('private/topic');
|
||||
expect(slugs).not.toContain('old/topic');
|
||||
});
|
||||
|
||||
test('explicit source resolution (no federated set on ctx) stays single-source', async () => {
|
||||
const results = (await search.handler(ctxOf(), { query: 'zebra telescope' })) as Array<{ slug: string }>;
|
||||
const slugs = results.map((r) => r.slug);
|
||||
expect(slugs).toEqual(['notes/home']);
|
||||
});
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* #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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user