From ea6cb025be0dfa2c62293c55e5eb04610b3bdd4a Mon Sep 17 00:00:00 2001 From: Time Attakc <89218912+time-attack@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:13:51 -0700 Subject: [PATCH] fix(schema,minions): truthful bundled pack inspection + config-aware subagent auth (#3110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(schema): make bundled pack inspection truthful (#2029) Two live bugs: - parseYamlMini had no block-scalar support, so a 'description: |' swallowed every following top-level key — the active gbrain-recommended pack loaded with 0 page types. Add parseBlockScalar for |/|-/|+ and >/>-/>+ in both mapping and sequence-sibling positions. - The bundled-pack list was hand-copied in three places (operations.ts had 2 names, mutate.ts had 3, load-active.ts had 7). New single registry src/core/schema-pack/bundled.ts carries all 7 shipped packs; every consumer derives from it. Takeover of #2029, rebased onto master (schema.ts hunks already landed). Co-authored-by: JiraiyaETH Co-Authored-By: Claude Fable 5 * fix(minions): subagent default client resolves config-stored Anthropic key (#2048) The legacy subagent path constructed a bare new Anthropic() (env-only), so launchd/MCP workers whose key lives in gbrain config (anthropic_api_key) failed auth. anthropic-key.ts now exports resolveAnthropicKey() (env first, then config; hasAnthropicKey delegates) and makeSubagentHandler passes it as apiKey. Partial takeover of #2048 — only the auth patch; the path patches were superseded by the outputRoot mechanism (#2415). Co-authored-by: JiraiyaETH Co-Authored-By: Claude Fable 5 * test(schema): point bundled-registry test at bundled.ts source of truth The bundled pack list moved from load-active.ts to bundled.ts in the truthful-inspection refactor; the T4 registry test still grepped load-active.ts source. Assert BUNDLED_PACK_NAMES directly instead. Co-Authored-By: Claude Fable 5 * fix(schema): block scalars keep '#' as literal content Inside a YAML block scalar '#' is content, not a comment; parseBlockScalar was routing lines through stripComment/isBlank, truncating descriptions like 'see issue #2029' and blanking comment-looking lines. Use the raw line inside the scalar. Adds a pinning test. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Garry Tan Co-authored-by: JiraiyaETH Co-authored-by: Claude Fable 5 --- src/core/ai/anthropic-key.ts | 16 +++++++-- src/core/minions/handlers/subagent.ts | 6 +++- src/core/operations.ts | 3 +- src/core/schema-pack/bundled.ts | 24 +++++++++++++ src/core/schema-pack/load-active.ts | 24 ++----------- src/core/schema-pack/loader.ts | 31 +++++++++++++++++ src/core/schema-pack/mutate.ts | 3 +- test/ai/anthropic-key.test.ts | 34 ++++++++++++++++++- test/lens-pack-manifests.test.ts | 12 +++---- test/operations-schema-pack.test.ts | 3 ++ test/schema-cli.test.ts | 39 ++++++++++++++++++++- test/schema-pack-loader.test.ts | 49 +++++++++++++++++++++++++++ test/schema-pack-mutate.test.ts | 5 ++- 13 files changed, 212 insertions(+), 37 deletions(-) create mode 100644 src/core/schema-pack/bundled.ts diff --git a/src/core/ai/anthropic-key.ts b/src/core/ai/anthropic-key.ts index 62fe069f9..3a63aa695 100644 --- a/src/core/ai/anthropic-key.ts +++ b/src/core/ai/anthropic-key.ts @@ -18,12 +18,22 @@ import { loadConfig } from '../config.ts'; export function hasAnthropicKey(): boolean { - if (process.env.ANTHROPIC_API_KEY) return true; + return resolveAnthropicKey() !== undefined; +} + +/** + * Resolve the actual key value: env first, then the gbrain config file. + * Callers constructing an Anthropic client directly (e.g. the legacy + * subagent path) must pass this as `apiKey` — a bare `new Anthropic()` + * only sees env, so launchd/MCP workers with config-stored keys fail. + */ +export function resolveAnthropicKey(): string | undefined { + if (process.env.ANTHROPIC_API_KEY) return process.env.ANTHROPIC_API_KEY; try { const cfg = loadConfig(); - if (cfg?.anthropic_api_key) return true; + if (cfg?.anthropic_api_key) return cfg.anthropic_api_key; } catch { // loadConfig may throw on first-run installs; treat as no key available. } - return false; + return undefined; } diff --git a/src/core/minions/handlers/subagent.ts b/src/core/minions/handlers/subagent.ts index 3852469ed..d9102dacc 100644 --- a/src/core/minions/handlers/subagent.ts +++ b/src/core/minions/handlers/subagent.ts @@ -48,6 +48,7 @@ import { logSubagentHeartbeat, } from './subagent-audit.ts'; import { resolveModel, isAnthropicProvider, TIER_DEFAULTS } from '../../model-config.ts'; +import { resolveAnthropicKey } from '../../ai/anthropic-key.ts'; import { buildSystemPrompt, DEFAULT_SUBAGENT_SYSTEM } from '../system-prompt.ts'; import { toolLoop as gatewayToolLoop } from '../../ai/gateway.ts'; import type { ChatToolDef, ChatMessage, ChatBlock, ChatResult, ToolHandler } from '../../ai/gateway.ts'; @@ -186,7 +187,10 @@ export function makeSubagentHandler(deps: SubagentDeps) { // lives at sdk.messages.create. Assigning sdk.messages directly gets the // right object; JS method-call semantics preserve `this` at the call // site (subagent.ts invokes client.create(...) with client === sdk.messages). - const makeAnthropic = deps.makeAnthropic ?? (() => new Anthropic()); + // Resolve the key env-first, then config (anthropic_api_key) — a bare + // new Anthropic() only reads env, so launchd/MCP workers whose key lives + // in the gbrain config file would fail auth (#2048). + const makeAnthropic = deps.makeAnthropic ?? (() => new Anthropic({ apiKey: resolveAnthropicKey() })); const client: MessagesClient = deps.client ?? makeAnthropic().messages; const config = deps.config ?? loadConfig() ?? ({ engine: 'postgres' } as GBrainConfig); const rateLeaseKey = deps.rateLeaseKey ?? DEFAULT_RATE_KEY; diff --git a/src/core/operations.ts b/src/core/operations.ts index d7464c190..670d066da 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -4719,7 +4719,8 @@ const list_schema_packs: Operation = { const { existsSync, readdirSync } = await import('node:fs'); const { join } = await import('node:path'); const { gbrainPath } = await import('./config.ts'); - const bundled = ['gbrain-base', 'gbrain-recommended']; + const { BUNDLED_PACK_NAMES } = await import('./schema-pack/bundled.ts'); + const bundled = [...BUNDLED_PACK_NAMES]; const installedDir = gbrainPath('schema-packs'); const installed: string[] = []; if (existsSync(installedDir)) { diff --git a/src/core/schema-pack/bundled.ts b/src/core/schema-pack/bundled.ts new file mode 100644 index 000000000..01c6bf0f0 --- /dev/null +++ b/src/core/schema-pack/bundled.ts @@ -0,0 +1,24 @@ +// Bundled schema-pack registry — single source of truth for the packs that +// ship in src/core/schema-pack/base/. Keep every bundled-pack consumer +// (CLI/MCP inspection, active-pack loading, mutation guards, upgrade +// discovery) on this one list so they cannot drift. +// +// v0.39 T8 — gbrain-base + gbrain-recommended. +// v0.41 T4 — lens packs: creator, investor, engineer, everything (meta-pack). +// v0.42 type-unification — gbrain-base-v2, the 15-type canonical successor. + +export const BUNDLED_PACK_NAMES = [ + 'gbrain-base', + 'gbrain-recommended', + 'gbrain-creator', + 'gbrain-investor', + 'gbrain-engineer', + 'gbrain-everything', + 'gbrain-base-v2', +] as const; + +export type BundledPackName = typeof BUNDLED_PACK_NAMES[number]; + +export function isBundledPackName(name: string): name is BundledPackName { + return (BUNDLED_PACK_NAMES as readonly string[]).includes(name); +} diff --git a/src/core/schema-pack/load-active.ts b/src/core/schema-pack/load-active.ts index 22d1d3658..1afde273f 100644 --- a/src/core/schema-pack/load-active.ts +++ b/src/core/schema-pack/load-active.ts @@ -37,6 +37,7 @@ import { type ResolutionInput, type ResolutionResult, } from './registry.ts'; +import { isBundledPackName } from './bundled.ts'; /** * Inputs the caller (operations.ts handler / engine query path) provides. @@ -92,28 +93,7 @@ export function _resetPackLocatorForTests(): void { * throwing UnknownPackError with a paste-ready install hint. */ function defaultPackLocator(name: string): string | null { - // v0.39 T8 — bundled packs registry. gbrain-base + gbrain-recommended - // ship in src/core/schema-pack/base/. Add a new entry here to bundle - // additional canonical packs. - // - // v0.41 T4 — lens packs join the bundle: creator (atoms + concepts + - // extract_atoms/synthesize_concepts phases), investor (theses + bet - // resolution + 3 calibration domains), engineer (gstack-learnings bridge - // + 3 calibration domains), everything (meta-pack stacking all three - // via extends + borrow_from). Each ships as a real YAML at base/.yaml. - const BUNDLED: ReadonlyArray = [ - 'gbrain-base', - 'gbrain-recommended', - 'gbrain-creator', - 'gbrain-investor', - 'gbrain-engineer', - 'gbrain-everything', - // v0.42 type-unification: 15-type canonical successor to gbrain-base. - // Ships as install default (Lane E T17) + via gbrain onboard pack - // upgrade flow (the unify-types Minion handler). - 'gbrain-base-v2', - ]; - if (BUNDLED.includes(name)) { + if (isBundledPackName(name)) { // Resolve bundled YAML relative to this source file. Works in both // direct-bun execution and bun --compile binaries. const here = dirname(fileURLToPath(import.meta.url)); diff --git a/src/core/schema-pack/loader.ts b/src/core/schema-pack/loader.ts index 2f21b5012..e418a3a9e 100644 --- a/src/core/schema-pack/loader.ts +++ b/src/core/schema-pack/loader.ts @@ -159,6 +159,29 @@ export function parseYamlMini(content: string): unknown { return parseMapping(baseIndent); } + function parseBlockScalar(parentIndent: number, folded: boolean): string { + const contentIndent = parentIndent + 2; + const out: string[] = []; + while (i < lines.length) { + const raw = lines[i]; + // Inside a block scalar everything is literal content — '#' is NOT a + // comment here, so use the raw line (no stripComment / isBlank). + if (raw.trim() === '') { + out.push(''); + i++; + continue; + } + const indent = indentOf(raw); + if (indent <= parentIndent) break; + out.push(raw.slice(Math.min(contentIndent, indent))); + i++; + } + if (folded) { + return out.join(' ').replace(/\s+$/u, ''); + } + return out.join('\n').replace(/\n+$/u, ''); + } + function parseSequence(baseIndent: number): unknown[] { const result: unknown[] = []; while (i < lines.length) { @@ -227,6 +250,10 @@ export function parseYamlMini(content: string): unknown { i++; if (rest2 === '') { map[key2] = parseBlock(nextIndent + 2); + } else if (rest2 === '|' || rest2 === '|-' || rest2 === '|+') { + map[key2] = parseBlockScalar(nextIndent, false); + } else if (rest2 === '>' || rest2 === '>-' || rest2 === '>+') { + map[key2] = parseBlockScalar(nextIndent, true); } else { map[key2] = parseScalar(rest2); } @@ -257,6 +284,10 @@ export function parseYamlMini(content: string): unknown { i++; if (rest === '') { result[key] = parseBlock(indent + 2); + } else if (rest === '|' || rest === '|-' || rest === '|+') { + result[key] = parseBlockScalar(indent, false); + } else if (rest === '>' || rest === '>-' || rest === '>+') { + result[key] = parseBlockScalar(indent, true); } else { result[key] = parseScalar(rest); } diff --git a/src/core/schema-pack/mutate.ts b/src/core/schema-pack/mutate.ts index 7a4bb63a7..eaf375e47 100644 --- a/src/core/schema-pack/mutate.ts +++ b/src/core/schema-pack/mutate.ts @@ -65,6 +65,7 @@ import { invalidateQueryCache } from './query-cache-invalidator.ts'; import { logMutationFailure, logMutationSuccess, type MutationActor, type MutationOp } from './mutate-audit.ts'; import { runFilePlaneLintRules } from './lint-rules.ts'; import { withPackLock, type PackLockOpts } from './pack-lock.ts'; +import { BUNDLED_PACK_NAMES as BUNDLED_PACK_NAME_LIST } from './bundled.ts'; import type { BrainEngine } from '../engine.ts'; export type PackFileFormat = 'json' | 'yaml'; @@ -93,7 +94,7 @@ export class SchemaPackMutationError extends Error { } } -export const BUNDLED_PACK_NAMES = new Set(['gbrain-base', 'gbrain-recommended', 'gbrain-base-v2']); +export const BUNDLED_PACK_NAMES = new Set(BUNDLED_PACK_NAME_LIST); export interface MutateResult { /** Pack name that was mutated. */ diff --git a/test/ai/anthropic-key.test.ts b/test/ai/anthropic-key.test.ts index a43ad7983..a06e9af90 100644 --- a/test/ai/anthropic-key.test.ts +++ b/test/ai/anthropic-key.test.ts @@ -10,7 +10,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { withEnv } from '../helpers/with-env.ts'; -import { hasAnthropicKey } from '../../src/core/ai/anthropic-key.ts'; +import { hasAnthropicKey, resolveAnthropicKey } from '../../src/core/ai/anthropic-key.ts'; const tmpDirs: string[] = []; function freshHome(withConfig?: Record): string { @@ -62,3 +62,35 @@ describe('hasAnthropicKey', () => { ); }); }); + +describe('resolveAnthropicKey (#2048 — subagent config-key auth)', () => { + test('env wins over config', async () => { + const home = freshHome({ anthropic_api_key: 'sk-from-config' }); + await withEnv( + { ANTHROPIC_API_KEY: 'sk-from-env', GBRAIN_HOME: home, DATABASE_URL: undefined, GBRAIN_DATABASE_URL: undefined }, + async () => { + expect(resolveAnthropicKey()).toBe('sk-from-env'); + }, + ); + }); + + test('config key returned when env unset', async () => { + const home = freshHome({ anthropic_api_key: 'sk-from-config' }); + await withEnv( + { ANTHROPIC_API_KEY: undefined, GBRAIN_HOME: home, DATABASE_URL: undefined, GBRAIN_DATABASE_URL: undefined }, + async () => { + expect(resolveAnthropicKey()).toBe('sk-from-config'); + }, + ); + }); + + test('neither → undefined', async () => { + const home = freshHome(); + await withEnv( + { ANTHROPIC_API_KEY: undefined, GBRAIN_HOME: home, DATABASE_URL: undefined, GBRAIN_DATABASE_URL: undefined }, + async () => { + expect(resolveAnthropicKey()).toBeUndefined(); + }, + ); + }); +}); diff --git a/test/lens-pack-manifests.test.ts b/test/lens-pack-manifests.test.ts index 0f79f90cb..78081c30e 100644 --- a/test/lens-pack-manifests.test.ts +++ b/test/lens-pack-manifests.test.ts @@ -55,13 +55,13 @@ describe('v0.41 T4: all 4 bundled lens packs parse cleanly', () => { }); describe('v0.41 T4: bundled registry includes lens packs', () => { - test('load-active.ts BUNDLED array source includes the 4 lens pack names', () => { - const loadActiveSrc = readFileSync( - join(here, '..', 'src', 'core', 'schema-pack', 'load-active.ts'), - 'utf-8', - ); + test('BUNDLED_PACK_NAMES includes the 4 lens pack names', async () => { + // The bundled list moved from load-active.ts to bundled.ts (the + // single source of truth); assert the array directly instead of + // grepping source text. + const { BUNDLED_PACK_NAMES } = await import('../src/core/schema-pack/bundled.ts'); for (const name of PACK_NAMES) { - expect(loadActiveSrc).toContain(`'${name}'`); + expect(BUNDLED_PACK_NAMES).toContain(name); } }); }); diff --git a/test/operations-schema-pack.test.ts b/test/operations-schema-pack.test.ts index 2ed47bbc0..0142a29d9 100644 --- a/test/operations-schema-pack.test.ts +++ b/test/operations-schema-pack.test.ts @@ -149,6 +149,9 @@ describe('list_schema_packs', () => { seedPack('mine'); const result = await operationsByName.list_schema_packs!.handler(ctxOf(), {}) as { bundled: string[]; installed: string[] }; expect(result.bundled).toContain('gbrain-base'); + expect(result.bundled).toContain('gbrain-recommended'); + expect(result.bundled).toContain('gbrain-base-v2'); + expect(result.bundled).toContain('gbrain-investor'); expect(result.installed).toContain('mine'); }); }); diff --git a/test/schema-cli.test.ts b/test/schema-cli.test.ts index 0019a1ffa..b74a941c0 100644 --- a/test/schema-cli.test.ts +++ b/test/schema-cli.test.ts @@ -64,11 +64,14 @@ describe('gbrain schema CLI (Phase C)', () => { expect(r.stdout + r.stderr).toMatch(/schema|active|list|show|validate|use/i); }); - test('schema list shows gbrain-base bundled', () => { + test('schema list shows all bundled packs', () => { const r = gbrain(['schema', 'list']); expect(r.code).toBe(0); expect(r.stdout).toContain('Bundled packs:'); expect(r.stdout).toContain('gbrain-base'); + expect(r.stdout).toContain('gbrain-recommended'); + expect(r.stdout).toContain('gbrain-base-v2'); + expect(r.stdout).toContain('gbrain-investor'); }); test('schema show gbrain-base prints manifest details', () => { @@ -97,6 +100,40 @@ describe('gbrain schema CLI (Phase C)', () => { expect(r.stdout).toContain('valid manifest'); }); + test('schema show/validate exposes bundled gbrain-recommended', () => { + const show = gbrain(['schema', 'show', 'gbrain-recommended']); + expect(show.code).toBe(0); + expect(show.stdout).toContain('gbrain-recommended v1.0.0'); + expect(show.stdout).toContain('Page types ('); + expect(show.stdout).toContain('meeting :: temporal'); + + const validate = gbrain(['schema', 'validate', 'gbrain-recommended']); + expect(validate.code).toBe(0); + expect(validate.stdout).toContain('valid manifest'); + }); + + test('schema show exposes bundled gbrain-base-v2 successor pack', () => { + const r = gbrain(['schema', 'show', 'gbrain-base-v2']); + expect(r.code).toBe(0); + expect(r.stdout).toContain('gbrain-base-v2 v1.0.0'); + expect(r.stdout).toContain('Page types ('); + expect(r.stdout).toContain('Link verbs (14)'); + }); + + test('schema active loads configured gbrain-recommended with real types', () => { + const home = mkdtempSync(join(tmpdir(), 'gbrain-schema-active-recommended-')); + try { + mkdirSync(join(home, '.gbrain'), { recursive: true }); + writeFileSync(join(home, '.gbrain', 'config.json'), JSON.stringify({ schema_pack: 'gbrain-recommended' }), 'utf-8'); + const r = gbrain(['schema', 'active'], { GBRAIN_HOME: home }); + expect(r.code).toBe(0); + expect(r.stdout).toContain('Active pack: gbrain-recommended'); + expect(r.stdout).not.toContain('Page types: 0'); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + test('schema active reports default resolution', () => { const r = gbrain(['schema', 'active']); expect(r.code).toBe(0); diff --git a/test/schema-pack-loader.test.ts b/test/schema-pack-loader.test.ts index e35e5b826..34b164dc3 100644 --- a/test/schema-pack-loader.test.ts +++ b/test/schema-pack-loader.test.ts @@ -345,6 +345,34 @@ describe('YAML mini-parser', () => { expect(result.types[1].weight).toBe(2); }); + test('parses block scalar without swallowing following keys', () => { + const yaml = `name: blocky +description: | + First line. + Second line. +page_types: + - name: meeting + primitive: temporal + path_prefixes: + - meetings/ + aliases: [] + extractable: true + expert_routing: false`; + const result = parseYamlMini(yaml) as { description: string; page_types: Array> }; + expect(result.description).toBe('First line.\nSecond line.'); + expect(result.page_types).toHaveLength(1); + expect(result.page_types[0].name).toBe('meeting'); + }); + + test('block scalar keeps # as literal content, not a comment', () => { + const yaml = `description: | + See issue #2029 for context. +name: hashy`; + const result = parseYamlMini(yaml) as Record; + expect(result.description).toBe('See issue #2029 for context.'); + expect(result.name).toBe('hashy'); + }); + test('strips comments', () => { const result = parseYamlMini('# top comment\nname: value # inline comment') as Record; expect(result.name).toBe('value'); @@ -374,6 +402,27 @@ extends: null`; const pack = loadPackFromString(json, 'fixture.json'); expect(pack.name).toBe('json-pack'); }); + + test('loads block-scalar pack descriptions without losing page types', () => { + const pack = loadPackFromString(`api_version: gbrain-schema-pack-v1 +name: recommended-fixture +version: 1.0.0 +extends: gbrain-base +description: | + Operational starter pack. +page_types: + - name: meeting + primitive: temporal + path_prefixes: + - meetings/ + aliases: [] + extractable: true + expert_routing: false +link_types: []`, 'fixture.yaml'); + expect(pack.name).toBe('recommended-fixture'); + expect(pack.extends).toBe('gbrain-base'); + expect(pack.page_types.map((t) => t.name)).toContain('meeting'); + }); }); describe('ReDoS guard', () => { diff --git a/test/schema-pack-mutate.test.ts b/test/schema-pack-mutate.test.ts index 60ec9a58a..ff737cca5 100644 --- a/test/schema-pack-mutate.test.ts +++ b/test/schema-pack-mutate.test.ts @@ -103,7 +103,10 @@ describe('locateMutablePackFile — bundled guard', () => { expect(BUNDLED_PACK_NAMES.has('gbrain-recommended')).toBe(true); // v0.42 (T22): gbrain-base-v2 joins the bundled set. expect(BUNDLED_PACK_NAMES.has('gbrain-base-v2')).toBe(true); - expect(BUNDLED_PACK_NAMES.size).toBe(3); + // Derived from the single bundled registry — the lens packs (creator, + // investor, engineer, everything) are read-only too. + expect(BUNDLED_PACK_NAMES.has('gbrain-investor')).toBe(true); + expect(BUNDLED_PACK_NAMES.size).toBe(7); }); it('rejects gbrain-base-v2 with PACK_READONLY (bundled guard)', () => {