diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts index 7a16c72965..77fe859c31 100644 --- a/packages/adapters/opencode-local/src/index.ts +++ b/packages/adapters/opencode-local/src/index.ts @@ -71,8 +71,14 @@ export const DEFAULT_OPENCODE_CHEAP_MODEL = "openai/gpt-5.1-codex-mini"; // PAPERCLIP_OPENCODE_SMALL_MODEL (the auxiliary/title model) is reused as a sensible // fallback so a single setting covers both budget lanes. The default keeps the // upstream behaviour (with the Codex `variant: "low"`). +// +// This module is shared client/server code (the UI imports it for +// DEFAULT_OPENCODE_LOCAL_MODEL etc.), so it must not touch the global `process` +// unguarded: in the browser (Vite dev middleware serves it untransformed) +// a bare `process.env` throws ReferenceError at module load and takes the whole +// app down. Guard with `typeof process` and fall back to an empty env. export function buildOpenCodeModelProfiles( - env: NodeJS.ProcessEnv = process.env, + env: NodeJS.ProcessEnv = typeof process === "undefined" ? {} : process.env, ): AdapterModelProfileDefinition[] { const override = (env.PAPERCLIP_OPENCODE_CHEAP_MODEL ?? env.PAPERCLIP_OPENCODE_SMALL_MODEL)?.trim(); return [ diff --git a/packages/adapters/opencode-local/src/server/runtime-config.test.ts b/packages/adapters/opencode-local/src/server/runtime-config.test.ts index 7c21be7817..e450a0d977 100644 --- a/packages/adapters/opencode-local/src/server/runtime-config.test.ts +++ b/packages/adapters/opencode-local/src/server/runtime-config.test.ts @@ -171,7 +171,7 @@ describe("prepareOpenCodeRuntimeConfig", () => { await prepared.cleanup(); }); - it("ignores malformed PAPERCLIP_OPENCODE_PROVIDERS without writing a provider block", async () => { + it("ignores malformed PAPERCLIP_OPENCODE_PROVIDERS without writing a provider block and surfaces a note", async () => { const configHome = await makeConfigHome({ permission: { read: "allow" } }); const prepared = await prepareOpenCodeRuntimeConfig({ env: { XDG_CONFIG_HOME: configHome, PAPERCLIP_OPENCODE_PROVIDERS: "not json" }, @@ -182,6 +182,26 @@ describe("prepareOpenCodeRuntimeConfig", () => { await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), ) as Record; expect(runtimeConfig.provider).toBeUndefined(); + expect(prepared.notes).toContain( + "PAPERCLIP_OPENCODE_PROVIDERS contains invalid JSON — providers block ignored.", + ); + await prepared.cleanup(); + }); + + it("surfaces a note when PAPERCLIP_OPENCODE_PROVIDERS is valid JSON but not an object", async () => { + const configHome = await makeConfigHome({ permission: { read: "allow" } }); + const prepared = await prepareOpenCodeRuntimeConfig({ + env: { XDG_CONFIG_HOME: configHome, PAPERCLIP_OPENCODE_PROVIDERS: "[1,2,3]" }, + config: {}, + }); + cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); + const runtimeConfig = JSON.parse( + await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), + ) as Record; + expect(runtimeConfig.provider).toBeUndefined(); + expect(prepared.notes).toContain( + "PAPERCLIP_OPENCODE_PROVIDERS is not a JSON object — providers block ignored.", + ); await prepared.cleanup(); }); diff --git a/packages/adapters/opencode-local/src/server/runtime-config.ts b/packages/adapters/opencode-local/src/server/runtime-config.ts index 3415fda891..b897734dc5 100644 --- a/packages/adapters/opencode-local/src/server/runtime-config.ts +++ b/packages/adapters/opencode-local/src/server/runtime-config.ts @@ -50,20 +50,30 @@ function expandEnvPlaceholders(value: T, resolve: (name: string) => string | function parseProviderConfig( raw: unknown, resolveEnv: (name: string) => string | undefined, + notes: string[], ): Record | null { if (typeof raw !== "string" || raw.trim().length === 0) return null; + let parsed: unknown; try { - const parsed = JSON.parse(raw); - if (!isPlainObject(parsed)) return null; - // Only keep provider entries that are themselves objects. - const providers: Record = {}; - for (const [key, value] of Object.entries(parsed)) { - if (isPlainObject(value)) providers[key] = expandEnvPlaceholders(value, resolveEnv); - } - return Object.keys(providers).length > 0 ? providers : null; + parsed = JSON.parse(raw); } catch { + // Surface the misconfiguration instead of silently dropping the provider + // block — an unparseable value would otherwise be undiagnosable. + notes.push("PAPERCLIP_OPENCODE_PROVIDERS contains invalid JSON — providers block ignored."); return null; } + if (!isPlainObject(parsed)) { + notes.push( + "PAPERCLIP_OPENCODE_PROVIDERS is not a JSON object — providers block ignored.", + ); + return null; + } + // Only keep provider entries that are themselves objects. + const providers: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if (isPlainObject(value)) providers[key] = expandEnvPlaceholders(value, resolveEnv); + } + return Object.keys(providers).length > 0 ? providers : null; } async function readJsonObject(filepath: string): Promise> { @@ -141,6 +151,7 @@ export async function prepareOpenCodeRuntimeConfig(input: { const gatewayProviders = parseProviderConfig( input.env.PAPERCLIP_OPENCODE_PROVIDERS ?? process.env.PAPERCLIP_OPENCODE_PROVIDERS, resolveEnv, + notes, ); const existingProvider = isPlainObject(existingConfig.provider) ? existingConfig.provider : {}; const nextProvider = gatewayProviders