diff --git a/src/commands/config.ts b/src/commands/config.ts index 59be20056..9bd8a50df 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -204,6 +204,42 @@ export async function runConfig(engine: BrainEngine, args: string[]) { const coverageOverride = args.includes('--coverage-override') || args.includes('--yes'); + // Validate sources.default at set time. This key is read by + // source-resolver.ts tier 5 on EVERY unqualified call, and tier 5 calls + // assertSourceExists — so a syntactically valid but non-existent id set + // here would make every later unqualified command throw, far from the + // typo that caused it. `gbrain sources default ` already validates; + // config set is the lower-level door to the same key and must not be a + // way around that check. + if (key === 'sources.default') { + const { isValidSourceId } = await import('../core/source-id.ts'); + if (!isValidSourceId(value)) { + console.error( + `[config] sources.default must be 1-32 lowercase alphanumerics with ` + + `optional interior hyphens (got '${value}').\n` + + `[config] gbrain sources default # preferred — validates and reports`, + ); + process.exit(1); + } + // No .catch() here: a connection failure or SQL regression must NOT be + // reported as "source is not registered". fetchSource already absorbs + // the one expected legacy-column case; anything else is a real error and + // should surface as itself. + const { fetchSource } = await import('../core/sources-load.ts'); + const src = await fetchSource(engine, value); + if (!src) { + // NOTE: keep flag literals out of this message. The generated flag + // registry (#2185) scans command sources for flag tokens, so naming a + // flag in prose would silently grant it to `gbrain config`. + console.error( + `[config] source "${value}" is not registered; refusing to set sources.default.\n` + + `[config] gbrain sources list # see registered sources\n` + + `[config] gbrain sources add # register one first`, + ); + process.exit(1); + } + } + // v0.42.42.0 (#2139): validate spend.posture at set time so a typo // ('tokenMax', 'max') doesn't silently fall back to gated. if (key === 'spend.posture') { diff --git a/src/core/cli-flag-registry.generated.ts b/src/core/cli-flag-registry.generated.ts index ec4432d2f..321ab92e2 100644 --- a/src/core/cli-flag-registry.generated.ts +++ b/src/core/cli-flag-registry.generated.ts @@ -30,7 +30,7 @@ export const CLI_FLAG_REGISTRY: Record = { 'code-callers': ['--aliases', '--all', '--all-sources', '--brain', '--chunker-debug', '--clone-dir', '--confirm-destructive', '--federated', '--force', '--help', '--include-null-signature', '--json', '--limit', '--no-extract', '--no-federated', '--no-json', '--path', '--pattern', '--pending', '--repo', '--reset', '--resolve', '--restore-only', '--source', '--stale', '--supersessions', '--thin', '--url', '--url-managed', '--yes'], 'code-def': ['--aliases', '--all', '--brain', '--chunker-debug', '--help', '--include-null-signature', '--json', '--lang', '--limit', '--no-extract', '--no-json', '--pattern', '--pending', '--pretty', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--yes'], 'code-refs': ['--aliases', '--all', '--brain', '--chunker-debug', '--help', '--include-null-signature', '--json', '--lang', '--limit', '--no-extract', '--no-json', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin', '--yes'], - 'config': ['--aliases', '--all', '--brain', '--column', '--coverage-override', '--embedding-dimensions', '--embedding-model', '--fast', '--follow', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--markdown', '--model', '--multimodal', '--no-embedding', '--no-extract', '--pattern', '--pending', '--pglite', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--surface', '--thin', '--yes'], + 'config': ['--aliases', '--all', '--brain', '--column', '--coverage-override', '--embedding-dimensions', '--embedding-model', '--fast', '--federated-read', '--follow', '--force', '--from-pages', '--help', '--http', '--include-null-signature', '--json', '--markdown', '--model', '--multimodal', '--no-embedding', '--no-extract', '--pattern', '--pending', '--pglite', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--surface', '--thin', '--yes'], 'connect': ['--agent', '--bearer-token-env-var', '--bind', '--brain', '--client-id', '--client-secret', '--force', '--grant-types', '--help', '--http', '--install', '--json', '--name', '--oauth', '--public-url', '--register', '--scopes', '--show-token', '--source', '--timeout-ms', '--token', '--token-endpoint-auth-method', '--url', '--version', '--yes'], 'conversation-parser': ['--aliases', '--all', '--brain', '--help', '--include-null-signature', '--json', '--no-extract', '--pattern', '--pending', '--reset', '--resolve', '--source', '--stale', '--supersessions', '--thin'], 'doctor': ['--ab', '--abi', '--aliases', '--all', '--allow-shell-jobs', '--auto', '--auto-fix', '--auto-update', '--background', '--batch', '--brain', '--brain-wide-max-cost-usd', '--break-lock', '--build-index', '--by-mention', '--by-type', '--check', '--column', '--compile', '--concurrency', '--confidence', '--content-audit', '--count', '--days', '--detach', '--dim', '--dir', '--drain', '--dry-run', '--embedding-dimensions', '--embedding-model', '--exclusive', '--explain', '--fast', '--fix', '--follow', '--force', '--force-break-lock', '--force-retry', '--force-schema', '--format', '--fresh', '--from-meetings', '--from-pages', '--full', '--grant-types', '--health-interval', '--help', '--history', '--http', '--include-flagged', '--include-frontmatter', '--include-null-signature', '--include-pseudo', '--index-audit', '--input', '--json', '--lang', '--limit', '--locks', '--markdown', '--max-age', '--max-cost', '--max-cost-usd', '--max-crashes', '--max-jobs', '--max-rss', '--max-usd', '--mcp-only', '--migrate-only', '--model', '--multimodal', '--name-only', '--name-status', '--near-symbol', '--nice', '--no', '--no-embed', '--no-embedding', '--no-extract', '--no-mutate', '--oauth-client-secret', '--older-than', '--once', '--overwrite', '--parallel', '--params', '--path', '--pattern', '--pending', '--pglite', '--phase', '--pid-file', '--porcelain', '--priority', '--progress-interval', '--progress-json', '--query', '--queue', '--quiet', '--rebuild-rollup', '--refresh', '--refresh-unqualified', '--regenerate', '--remediate', '--remediation-plan', '--repo', '--reset', '--resolve', '--restore-only', '--resume', '--review-lower', '--rollback', '--scope', '--scopes', '--since', '--skills-dir', '--skip-bare-tweet', '--skip-failed', '--skip-urls', '--skip-verify', '--slugs', '--source', '--source-id', '--stale', '--status', '--strategy', '--strict', '--supabase', '--supersessions', '--surface', '--symbol-kind', '--target', '--target-score', '--thin', '--timeout', '--to', '--top-k', '--type', '--undo-wave', '--unsafe-bypass-dream-guard', '--untracked-files', '--url', '--use-captured-snapshot', '--verbose', '--version', '--window', '--with-calibration', '--workers', '--yes'], diff --git a/src/core/config.ts b/src/core/config.ts index f3e7d3a27..bc9458dbc 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1110,6 +1110,12 @@ export const KNOWN_CONFIG_KEYS: readonly string[] = [ 'embed.backfill_cooldown_min', 'embed.backfill_max_usd_per_source_24h', 'embed.backfill_max_usd', + // Brain-level default source. Read by source-resolver.ts tier 5 + // (`engine.getConfig('sources.default')`) and written by + // `gbrain sources default `. Listed here so `gbrain config set` + // stops claiming "Nothing in gbrain reads this" for a key the resolver + // reads on every unqualified call. + 'sources.default', ]; /** diff --git a/test/config-set.test.ts b/test/config-set.test.ts index 076d98af8..e914d38a1 100644 --- a/test/config-set.test.ts +++ b/test/config-set.test.ts @@ -33,6 +33,23 @@ describe('KNOWN_CONFIG_KEYS', () => { expect(KNOWN_CONFIG_KEYS).toContain('search.cache.enabled'); }); + // Regression: `sources.default` is read by source-resolver.ts tier 5 on + // every unqualified call and written by `gbrain sources default `, yet + // it was absent from this list — so `gbrain config set sources.default` + // warned "Nothing in gbrain reads this", which is false and misdirects an + // operator away from the one knob that pins brain-level source routing. + test('contains sources.default (read by the resolver, written by `sources default`)', () => { + expect(KNOWN_CONFIG_KEYS).toContain('sources.default'); + }); + + // The fix registers the ONE key the resolver reads, not a `sources.` prefix: + // a prefix would bless arbitrary unread `sources.*` keys and weaken the + // unknown-key guard this list exists to provide. + test('does not bless arbitrary sources.* keys', () => { + expect(KNOWN_CONFIG_KEYS).not.toContain('sources.anything-else'); + expect(KNOWN_CONFIG_KEY_PREFIXES).not.toContain('sources.'); + }); + test('contains the models-tier keys (v0.31.12)', () => { expect(KNOWN_CONFIG_KEYS).toContain('models.default'); expect(KNOWN_CONFIG_KEYS).toContain('models.tier.subagent'); @@ -238,6 +255,69 @@ describe('#2753 — the doctor-proposed gateway-loop command is accepted by `con return { logs, errs, exit }; } + // `sources.default` is the one config key whose value the resolver + // dereferences on every unqualified call (tier 5 → assertSourceExists). + // Registering it in KNOWN_CONFIG_KEYS without a set-time check would make + // `config set` a way around the validation `gbrain sources default ` + // already performs, and a typo would surface later as a throw on unrelated + // commands. These pin that `config set` refuses the same inputs. + function sourcesEngine(registered: string[]): { engine: BrainEngine; setCalls: Array<[string, string]> } { + const setCalls: Array<[string, string]> = []; + const engine = { + getConfig: async () => null, + setConfig: async (k: string, v: string) => { setCalls.push([k, v]); }, + executeRaw: async (_sql: string, params?: unknown[]) => { + const id = String((params ?? [])[0] ?? ''); + return registered.includes(id) ? [{ id, name: id }] : []; + }, + } as unknown as BrainEngine; + return { engine, setCalls }; + } + + test('sources.default: refuses an id that is not a valid source id', async () => { + const { engine, setCalls } = sourcesEngine(['wiki']); + const { errs, exit } = await runConfigCapture(engine, ['set', 'sources.default', 'Not A Source']); + expect(exit).toBe(1); + expect(errs.join('\n')).toContain('lowercase alphanumerics'); + expect(setCalls).toEqual([]); + }); + + test('sources.default: refuses an unregistered source instead of writing it', async () => { + const { engine, setCalls } = sourcesEngine(['wiki']); + const { errs, exit } = await runConfigCapture(engine, ['set', 'sources.default', 'ghost']); + expect(exit).toBe(1); + expect(errs.join('\n')).toContain('not registered'); + expect(setCalls).toEqual([]); + }); + + test('sources.default: accepts a registered source without --force', async () => { + const { engine, setCalls } = sourcesEngine(['wiki']); + const { errs, exit } = await runConfigCapture(engine, ['set', 'sources.default', 'wiki']); + expect(exit).toBeNull(); + // The false "Nothing in gbrain reads this" line is the bug this fixes. + expect(errs.join('\n')).not.toContain('Nothing in gbrain reads this'); + expect(setCalls).toEqual([['sources.default', 'wiki']]); + }); + + // A DB failure must not be laundered into "source is not registered" — that + // would send an operator chasing a source-registration problem that doesn't + // exist while the real fault (connection, permissions, SQL regression) is + // swallowed. + test('sources.default: a lookup failure propagates instead of reading as unregistered', async () => { + const setCalls: Array<[string, string]> = []; + const engine = { + getConfig: async () => null, + setConfig: async (k: string, v: string) => { setCalls.push([k, v]); }, + executeRaw: async () => { throw new Error('connection terminated unexpectedly'); }, + } as unknown as BrainEngine; + // The real error must escape rather than be reshaped into a validation + // message, so this rejects instead of returning an exit code. + await expect( + runConfigCapture(engine, ['set', 'sources.default', 'wiki']), + ).rejects.toThrow('connection terminated unexpectedly'); + expect(setCalls).toEqual([]); + }); + test('doctor-proposed command round-trips through `config set` without --force', async () => { const check = await withEnv( { GBRAIN_HOME: home, GBRAIN_CHAT_MODEL: undefined, ANTHROPIC_API_KEY: undefined },