From 8fc4f4fe9ea9b062e74191d2ea2d265a81b7f29b Mon Sep 17 00:00:00 2001 From: biscober <50845461+biscober@users.noreply.github.com> Date: Wed, 11 Nov 2026 00:44:17 -0500 Subject: [PATCH] fix: goose launcher reads config.yaml + custom_providers/*.json (goose's real layout) --- src/client-config.ts | 89 ++++++++++++++++++++++++++++++++----- src/launcher.ts | 23 +++++++--- tests/launcher.test.ts | 77 ++++++++++++++++++++++++++++++++ tests/model-windows.test.ts | 9 ++++ 4 files changed, 182 insertions(+), 16 deletions(-) diff --git a/src/client-config.ts b/src/client-config.ts index 39a090e7a4..e3851c41ab 100644 --- a/src/client-config.ts +++ b/src/client-config.ts @@ -240,11 +240,20 @@ export interface AiderConfig { * plain HTTP instead: built-in openai/anthropic via their `*_HOST` env * overrides, custom declarative providers via a regenerated config overlay. */ export interface GooseConfig { - /** `active_provider` from config.toml (legacy GOOSE_PROVIDER env wins). */ + /** `active_provider` from goose's `config.yaml` (its `config.toml` spelling + * and the legacy GOOSE_PROVIDER env are read as fallbacks). */ activeProvider?: string; - /** Custom declarative providers: file name (without .toml) → raw - * `base_url` from `/custom_providers/.toml`. */ + /** Custom declarative providers: file name (without extension) → raw + * `base_url` from `/custom_providers/.json` — goose + * writes declarative providers as JSON (its `declarative.rs` loader + * filters `extension == "json"`, and `config/declarative_providers.rs` + * writes `{id}.json`). The `.toml` spelling is kept as a fallback for any + * build that migrates formats. */ customProviders: Record; + /** Per-model context windows the declarative providers declare + * (`models[].context_limit`) — the launcher's window denominator for + * `bili goose`, same role as every other scoped client's model windows. */ + models?: ModelWindow[]; } /** crush.json discovery result — kept module-local: nobody imports the type @@ -1358,32 +1367,85 @@ export function resolveGooseDirs(env: NodeJS.ProcessEnv = process.env): GooseDir return { configDir, dataDir, stateDir, agentsDir: path.join(dataDir, ".agents") }; } +/** goose's `config.yaml` top-level scalar (no YAML dependency — same targeted + * discipline as parseMcodeYaml). Only unindented lines qualify: goose keeps its + * settings flat, and an indented key of the same name belongs to some block. */ +function gooseYamlTopLevel(text: string, key: string): string | undefined { + for (const raw of text.split(/\r?\n/)) { + if (/^\s/.test(raw)) continue; + const line = yamlLineContent(raw); + if (!line) continue; + const m = new RegExp(`^${key}\\s*:\\s*(.+)$`).exec(line); + const value = m?.[1] ? unquoteYaml(m[1]) : undefined; + if (value) return value; + } + return undefined; +} + +/** goose's declarative provider files carry `base_url` at the top level. Read + * from the line rather than by JSON.parse, so a half-written file still yields + * its url: the `^` branch matches goose's `to_string_pretty` spelling (the key + * on its own line), the `[,{]` branch a hand-written compact one. launcher.ts + * rewrites with this SAME pattern — what gets discovered here must always be + * something the overlay can re-point, or the route would mislead. */ +export const GOOSE_JSON_BASE_URL_RE = /(^[\s]*|[,{][\s]*)"base_url"(\s*:\s*)("[^"]*")/; + +/** First `base_url` string in a goose provider JSON, line-scanned so the match + * shape stays identical to the launcher's rewrite. */ +function gooseJsonBaseUrl(text: string): string | undefined { + for (const line of text.split(/\r?\n/)) { + const m = GOOSE_JSON_BASE_URL_RE.exec(line); + const url = m?.[3]?.slice(1, -1); + if (url) return url; + } + return undefined; +} + export function readGooseConfig(dirs: GooseDirs, env: NodeJS.ProcessEnv = process.env): GooseConfig { const result: GooseConfig = { customProviders: {} }; if (nonEmpty(env.GOOSE_PROVIDER)) { result.activeProvider = env.GOOSE_PROVIDER!; } else { try { - const text = fs.readFileSync(path.join(dirs.configDir, "config.toml"), "utf8"); - const m = /^active_provider\s*=\s*["']([^"']+)["']/m.exec(text); - if (m?.[1]) result.activeProvider = m[1]; + const yaml = gooseYamlTopLevel(fs.readFileSync(path.join(dirs.configDir, "config.yaml"), "utf8"), "active_provider"); + if (yaml) { + result.activeProvider = yaml; + } } catch {} + if (!result.activeProvider) { + try { + const text = fs.readFileSync(path.join(dirs.configDir, "config.toml"), "utf8"); + const m = /^active_provider\s*=\s*["']([^"']+)["']/m.exec(text); + if (m?.[1]) result.activeProvider = m[1]; + } catch {} + } } let entries: string[] = []; try { entries = fs.readdirSync(path.join(dirs.configDir, "custom_providers")); } catch {} + const windows: ModelWindow[] = []; for (const name of entries.sort()) { - if (!name.endsWith(".toml")) continue; + const json = name.endsWith(".json"); + if (!json && !name.endsWith(".toml")) continue; + const file = path.join(dirs.configDir, "custom_providers", name); let txt: string; try { - txt = fs.readFileSync(path.join(dirs.configDir, "custom_providers", name), "utf8"); + txt = fs.readFileSync(file, "utf8"); } catch { continue; } - const m = /^\s*base_url\s*=\s*["']([^"']+)["']/m.exec(txt); - if (m?.[1]) result.customProviders[name.replace(/\.toml$/, "")] = m[1]; + const url = json ? gooseJsonBaseUrl(txt) : /^\s*base_url\s*=\s*["']([^"']+)["']/m.exec(txt)?.[1]; + if (url) result.customProviders[name.replace(/\.(json|toml)$/, "")] = url; + if (!json) continue; + const obj = readJsonObject(file); + for (const model of Array.isArray(obj?.models) ? obj!.models as unknown[] : []) { + const rec = model && typeof model === "object" && !Array.isArray(model) ? (model as Record) : {}; + const win = toModelWindow(rec.name, rec.context_limit); + if (win) windows.push(win); + } } + if (windows.length > 0) result.models = windows; return result; } @@ -2143,7 +2205,8 @@ type ModelWindowScope = "claude" | "codex" | "pi" | "omp" | "opencode" | "hermes /** Collect per-model context windows from client configs the launcher can * read (pi models.json, omp models.yml, opencode opencode.json, codex - * config.toml). With `scope`, ONLY that client's declarations are collected — + * config.toml, goose custom_providers JSON). With `scope`, ONLY that client's + * declarations are collected — * the launched client's config is authoritative for its own proxy (#436: * cross-client max-wins silently discarded the user's smaller configured * window). Without `scope`, merges every client (legacy behavior). Same model @@ -2164,6 +2227,7 @@ export function collectModelWindows(config: ClientConfig, scope?: ModelWindowSco else if (scope === "codebuddy") add(config.codebuddy?.models); else if (scope === "kimi") add(config.kimi?.models); else if (scope === "mcode") add(config.mcode?.models); + else if (scope === "goose") add(config.goose?.models); return out; } for (const p of Object.values(config.pi?.providers ?? {})) add(p.models); @@ -2173,6 +2237,7 @@ export function collectModelWindows(config: ClientConfig, scope?: ModelWindowSco add(config.codebuddy?.models); add(config.kimi?.models); add(config.mcode?.models); + add(config.goose?.models); return out; } @@ -2195,6 +2260,7 @@ export function collectModelMaxOutputs(config: ClientConfig, scope?: ModelWindow else if (scope === "codebuddy") add(config.codebuddy?.models); else if (scope === "kimi") add(config.kimi?.models); else if (scope === "mcode") add(config.mcode?.models); + else if (scope === "goose") add(config.goose?.models); return out; } for (const p of Object.values(config.pi?.providers ?? {})) add(p.models); @@ -2204,5 +2270,6 @@ export function collectModelMaxOutputs(config: ClientConfig, scope?: ModelWindow add(config.codebuddy?.models); add(config.kimi?.models); add(config.mcode?.models); + add(config.goose?.models); return out; } diff --git a/src/launcher.ts b/src/launcher.ts index 285a07a6c6..8edff0e358 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -77,7 +77,7 @@ import { winCmdUnsafeToken, winCmdRefusalError } from "./win-cmd.js"; function selfDistFile(name: string): string { return path.join(selfPackageRoot(), "dist", name); } -import { nonEmpty, resolvePiHome, resolveOmpHome, resolveDshHome, resolveCodexHome, resolveCodexEffectiveView, loadClientConfig, collectModelWindows, collectModelMaxOutputs, type ClientConfig, type CodexConfig, resolveOpencodeConfigFile, readOpencodeConfigRoot, opencodePluginBaseDir, type OpencodeConfig, type OpencodeProvider, type HermesConfig, type HermesProvider, qoderIsCnSite, QODER_DEFAULT_MODEL_HOSTS, resolveTraeHome, readTraeConfig, TRAE_DEFAULT_MODEL_HOSTS, JCODE_DEFAULT_MODEL_HOSTS, type TraeConfig, resolveKimiHome, readKimiConfig, parseKimiToml, KIMI_DEFAULT_MODEL_HOSTS, QWEN_DEFAULT_MODEL_HOSTS, type KimiConfig, type KimiProvider, readOpencodeProjectLayer, type OpencodeProjectLayer, readMcodeConfig, resolveMcodeInstallDir, MCODE_DEFAULT_MODEL_HOSTS, type McodeConfig, discoverAiderArgUrls, AIDER_DEFAULT_MODEL_HOSTS, COPILOT_DEFAULT_MODEL_HOSTS, AMP_DEFAULT_MODEL_HOSTS, CRUSH_DEFAULT_MODEL_HOSTS, ZED_DEFAULT_MODEL_HOSTS, readZedConfig, resolveGooseDirs, readGooseConfig, type GooseConfig, type GooseDirs } from "./client-config.js"; +import { nonEmpty, resolvePiHome, resolveOmpHome, resolveDshHome, resolveCodexHome, resolveCodexEffectiveView, loadClientConfig, collectModelWindows, collectModelMaxOutputs, type ClientConfig, type CodexConfig, resolveOpencodeConfigFile, readOpencodeConfigRoot, opencodePluginBaseDir, type OpencodeConfig, type OpencodeProvider, type HermesConfig, type HermesProvider, qoderIsCnSite, QODER_DEFAULT_MODEL_HOSTS, resolveTraeHome, readTraeConfig, TRAE_DEFAULT_MODEL_HOSTS, JCODE_DEFAULT_MODEL_HOSTS, type TraeConfig, resolveKimiHome, readKimiConfig, parseKimiToml, KIMI_DEFAULT_MODEL_HOSTS, QWEN_DEFAULT_MODEL_HOSTS, type KimiConfig, type KimiProvider, readOpencodeProjectLayer, type OpencodeProjectLayer, readMcodeConfig, resolveMcodeInstallDir, MCODE_DEFAULT_MODEL_HOSTS, type McodeConfig, discoverAiderArgUrls, AIDER_DEFAULT_MODEL_HOSTS, COPILOT_DEFAULT_MODEL_HOSTS, AMP_DEFAULT_MODEL_HOSTS, CRUSH_DEFAULT_MODEL_HOSTS, ZED_DEFAULT_MODEL_HOSTS, readZedConfig, resolveGooseDirs, readGooseConfig, GOOSE_JSON_BASE_URL_RE, type GooseConfig, type GooseDirs } from "./client-config.js"; import { loadRoutes, resolveConfiguredContextLimit, lookupContextLimit, resolveNativeAttachExternal, resolveNativeAttachLanePriority, resolveNativeAttachPolicy, resolveNativeAttachUrl, resolveMitmDomains, resolveNonHttpProviders, resolveRecordedMitmDomains, resolveRecordedPassthrough, type ProviderRoutes } from "./config.js"; import { discoverMitmDomains } from "./discover.js"; import { contextFromRegistry } from "./registry.js"; @@ -2964,7 +2964,10 @@ export function prepareGooseHome(env: NodeJS.ProcessEnv, origin: string, rewrite cpEntries = fs.readdirSync(path.join(cfg, "custom_providers")); } catch {} for (const name of cpEntries) { - if (!name.endsWith(".toml")) continue; + // goose writes declarative providers as `.json` (its loader filters + // `extension == "json"`); the `.toml` spelling is kept as a fallback. + const json = name.endsWith(".json"); + if (!json && !name.endsWith(".toml")) continue; const file = path.join(cfg, "custom_providers", name); let txt: string; try { @@ -2975,11 +2978,21 @@ export function prepareGooseHome(env: NodeJS.ProcessEnv, origin: string, rewrite const eol = txt.includes("\r\n") ? "\r\n" : "\n"; let changed = false; const lines = txt.split(/\r?\n/).map((line) => { + if (json) { + const m = GOOSE_JSON_BASE_URL_RE.exec(line); + if (!m) return line; + const rawUrl = m[3].slice(1, -1); + if (!/^https?:\/\//i.test(rawUrl)) return line; + const real = unwrapUpstream(rawUrl); + if (!wrapSet.has(real)) return line; + changed = true; + const valueStart = m.index + m[0].length - m[3].length; + return line.slice(0, valueStart) + '"' + wrapUpstream(origin, real) + '"' + line.slice(m.index + m[0].length); + } const m = /^(\s*base_url\s*=\s*)(["'])([^"']+)\2(\s*(?:#.*)?)$/.exec(line); if (!m) return line; - const rawUrl = m[3]; - if (!/^https?:\/\//i.test(rawUrl)) return line; - const real = unwrapUpstream(rawUrl); + if (!/^https?:\/\//i.test(m[3])) return line; + const real = unwrapUpstream(m[3]); if (!wrapSet.has(real)) return line; changed = true; return `${m[1]}${m[2]}${wrapUpstream(origin, real)}${m[2]}${m[4] ?? ""}`; diff --git a/tests/launcher.test.ts b/tests/launcher.test.ts index b98142051c..22f739887e 100644 --- a/tests/launcher.test.ts +++ b/tests/launcher.test.ts @@ -6884,6 +6884,83 @@ test("readGooseConfig: active_provider + custom_providers base_urls; GOOSE_PROVI } }); +// goose's shipped layout is `config.yaml` + `custom_providers/.json` (its +// loader filters `extension == "json"`, and config/declarative_providers.rs +// writes `{id}.json`). The TOML-only reader discovered nothing for it, so the +// launcher launched with no route for the custom provider (#1049 follow-up). +test("readGooseConfig: config.yaml + custom_providers/*.json (goose's real layout), incl. model windows", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "bili-goose-json-")); + try { + const cfgDir = path.join(root, "config"); + fs.mkdirSync(path.join(cfgDir, "custom_providers"), { recursive: true }); + fs.writeFileSync(path.join(cfgDir, "config.yaml"), [ + "# goose settings", + "GOOSE_MODEL: some-model", + "active_provider: mine # trailing comment", + "extensions:", + " active_provider: nested-not-a-setting", + ].join("\n")); + fs.writeFileSync(path.join(cfgDir, "custom_providers", "mine.json"), JSON.stringify({ + name: "mine", + engine: "openai", + api_key_env: "MINE_KEY", + base_url: "http://192.168.1.178:8080", + models: [{ name: "qwen-local", context_limit: 131072 }, { name: "no-limit" }, "junk"], + }, null, 2) + "\n"); + fs.writeFileSync(path.join(cfgDir, "custom_providers", "halfwritten.json"), '{ "base_url": "https://half.example/v1",'); + const dirs = resolveGooseDirs({ GOOSE_PATH_ROOT: root }); + const cfg = readGooseConfig(dirs, {}); + assert.equal(cfg.activeProvider, "mine", "config.yaml is goose's settings file; an indented key of the same name is not"); + assert.deepEqual(cfg.customProviders, { halfwritten: "https://half.example/v1", mine: "http://192.168.1.178:8080" }); + assert.deepEqual(cfg.models, [{ id: "qwen-local", contextWindow: 131072 }], "context_limit feeds the launcher's window denominator; a model without one is skipped"); + assert.equal(readGooseConfig(dirs, { GOOSE_PROVIDER: "override" }).activeProvider, "override", "GOOSE_PROVIDER env still wins"); + const empty = readGooseConfig(resolveGooseDirs({ GOOSE_PATH_ROOT: "/nonexistent-bili-test" }), {}); + assert.equal(empty.activeProvider, undefined); + assert.equal(empty.models, undefined, "no declared windows → no models key, so the proxy keeps its own resolution"); + } finally { + rmrf(root); + } +}); + +test("prepareGooseHome/finalizeGooseHome: JSON provider overlay rewrite + config.yaml merge-back (goose's real layout)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-goose-json-home-")); + try { + const cfgDir = path.join(home, ".config", "goose"); + fs.mkdirSync(path.join(cfgDir, "custom_providers"), { recursive: true }); + const provider = (baseUrl: string): string => JSON.stringify({ + name: "x", + engine: "openai", + api_key_env: "X_KEY", + base_url: baseUrl, + models: [{ name: "m", context_limit: 131072 }], + }, null, 2) + "\n"; + fs.writeFileSync(path.join(cfgDir, "config.yaml"), "active_provider: mine\n"); + fs.writeFileSync(path.join(cfgDir, "custom_providers", "mine.json"), provider("http://192.168.1.178:8080")); + fs.writeFileSync(path.join(cfgDir, "custom_providers", "other.json"), provider("https://keep.example/v1")); + fs.writeFileSync(path.join(cfgDir, "custom_providers", "tight.json"), '{"name":"x","engine":"openai","base_url":"https://tight.example/v1"}'); + const origin = "http://127.0.0.1:9999"; + const env: NodeJS.ProcessEnv = { XDG_CONFIG_HOME: path.join(home, ".config"), XDG_DATA_HOME: path.join(home, ".local", "share"), XDG_STATE_HOME: path.join(home, ".local", "state") }; + const overlay = prepareGooseHome(env, origin, [{ key: "mine", realUpstream: "http://192.168.1.178:8080" }, { key: "tight", realUpstream: "https://tight.example/v1" }]); + assert.ok(overlay, "overlay prepared"); + const patchedRaw = fs.readFileSync(path.join(overlay!.root, "config", "custom_providers", "mine.json"), "utf8"); + const patched = JSON.parse(patchedRaw) as { base_url: string; engine: string; models: unknown[] }; + assert.equal(patched.base_url, wrapUpstream(origin, "http://192.168.1.178:8080"), "base_url re-pointed at the proxy, stays valid JSON"); + assert.equal(patched.engine, "openai"); + assert.deepEqual(patched.models, [{ name: "m", context_limit: 131072 }], "everything else in the provider untouched"); + assert.ok(fs.readFileSync(path.join(overlay!.root, "config", "custom_providers", "other.json"), "utf8").includes("https://keep.example/v1"), "provider outside the rewrite set untouched"); + const tight = JSON.parse(fs.readFileSync(path.join(overlay!.root, "config", "custom_providers", "tight.json"), "utf8")) as { base_url: string }; + assert.equal(tight.base_url, wrapUpstream(origin, "https://tight.example/v1"), "compact single-line provider rewritten in place, stays valid JSON"); + const realBefore = fs.readFileSync(path.join(cfgDir, "custom_providers", "mine.json"), "utf8"); + assert.ok(realBefore.includes("http://192.168.1.178:8080"), "real config untouched by prepare"); + fs.writeFileSync(path.join(overlay!.root, "config", "config.yaml"), "active_provider: other\n"); + finalizeGooseHome(overlay!); + assert.ok(fs.readFileSync(path.join(cfgDir, "config.yaml"), "utf8").includes("active_provider: other"), "yaml edit merged back"); + assert.ok(realBefore === fs.readFileSync(path.join(cfgDir, "custom_providers", "mine.json"), "utf8"), "patched provider file did NOT leak into the real config"); + } finally { + rmrf(home); + } +}); + test("prepareGooseHome/finalizeGooseHome: overlay layout, patched urls, merge-back round-trip (#1049)", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-goose-home-")); try { diff --git a/tests/model-windows.test.ts b/tests/model-windows.test.ts index 714db58036..4df95af757 100644 --- a/tests/model-windows.test.ts +++ b/tests/model-windows.test.ts @@ -263,3 +263,12 @@ test("anthropicBetaContextWindow: rejects malformed / non-context tokens", () => assert.equal(anthropicBetaContextWindow({ "anthropic-beta": "context-1x-2025-08-07" }), undefined); assert.equal(anthropicBetaContextWindow({ "anthropic-beta": "my-context-1m-2025-08-07" }), undefined); }); + +test("collectModelWindows: goose scope (custom_providers models[].context_limit)", () => { + const cfg: ClientConfig = { + goose: { customProviders: { mine: "http://192.168.1.178:8080" }, models: [{ id: "qwen-local", contextWindow: 131072 }] }, + }; + assert.deepEqual(collectModelWindows(cfg, "goose"), { "qwen-local": 131072 }, "goose declares its windows like every other scoped client"); + assert.deepEqual(collectModelWindows(cfg), { "qwen-local": 131072 }); + assert.deepEqual(collectModelWindows(cfg, "codex"), {}, "another client's scope must not pick up goose's windows (#436)"); +});