From 91a940d1211c76d42ff82bcf830d9ebf50d82c92 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 3 Jun 2026 17:51:08 +0800 Subject: [PATCH] fix(provider): isolate plugin hook mutations from internal provider state Plugin provider.models() hooks received the live internal provider Info object. A plugin mutating its argument (e.g. provider.name / provider.options) corrupted the provider state the rest of the engine reads, since the final provider is built from that same object via mergeDeep. Add a toPublicInfo() JSON-safe deep clone (drops functions/symbols/undefined, stringifies bigint; Info is plain-data so it round-trips) and pass the clone into both plugin entry points that hand out the internal Info: the provider.models() hook and the plugin auth loader. The auth-loader site keeps its existing nullable behavior (clone only when the provider is present), since in PawWork database[provider] can be undefined there and cloning undefined would throw. Adapted from upstream anomalyco/opencode 5e49029e70 (PR #26561, thanks Kit Langton). dev and upstream/dev share no common ancestor; reimplemented, not cherry-picked. Test: added "plugin provider.models hook cannot mutate internal provider state" (regression), proven red (leaked name = mutated-by-plugin) then green. --- packages/opencode/src/provider/provider.ts | 18 +++++++- .../opencode/test/provider/provider.test.ts | 43 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index eecb9ebe3..1e99deba2 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -950,6 +950,19 @@ export const ConfigProvidersResult = Schema.Struct({ export type ConfigProvidersResult = Types.DeepMutable> export type Language = LanguageModelV3 +// JSON-safe deep clone for handing provider Info to plugin hooks, so a plugin +// mutating its argument cannot corrupt internal provider state. Drops functions, +// symbols and undefined; stringifies bigint. Info is plain-data, so it round-trips. +export function toPublicInfo(provider: Info): Info { + return JSON.parse( + JSON.stringify(provider, (_, value) => { + if (typeof value === "function" || typeof value === "symbol" || value === undefined) return undefined + if (typeof value === "bigint") return value.toString() + return value + }), + ) +} + export function defaultModelID }>( provider: T, fallbackID?: string, @@ -1195,7 +1208,7 @@ const layer: Layer.Layer< } provider.models = yield* Effect.promise(async () => { - const next = await models(provider, { auth: pluginAuth }) + const next = await models(toPublicInfo(provider), { auth: pluginAuth }) return Object.fromEntries( Object.entries(next).map(([id, model]) => [ id, @@ -1352,10 +1365,11 @@ const layer: Layer.Layer< if (!stored) continue if (!plugin.auth.loader) continue + const authProvider = database[plugin.auth!.provider] const options = yield* Effect.promise(() => plugin.auth!.loader!( () => bridge.promise(auth.get(providerID).pipe(Effect.orDie)) as any, - database[plugin.auth!.provider], + authProvider ? toPublicInfo(authProvider) : authProvider, ), ) const opts = options ?? {} diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 5ac899929..6d072b455 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1230,6 +1230,49 @@ test("getSmallModel ignores invalid config small_model", async () => { }) }) +test("plugin provider.models hook cannot mutate internal provider state", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const pluginDir = path.join(dir, ".opencode", "plugin") + await mkdir(pluginDir, { recursive: true }) + await Bun.write( + path.join(pluginDir, "provider-models-mutation.ts"), + [ + "export default {", + ' id: "test.provider-models-mutation",', + " server: async () => ({", + " provider: {", + ' id: "anthropic",', + " models: async (provider) => {", + ' provider.name = "mutated-by-plugin"', + " provider.options = { ...provider.options, mutatedByPlugin: true }", + " return provider.models ?? {}", + " },", + " },", + " }),", + "}", + "", + ].join("\n"), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") + }, + fn: async () => { + const anthropic = await getProvider(ProviderID.anthropic) + // The hook mutated its argument; with a deep-cloned input that must not leak + // into internal provider state. + expect(anthropic.name).not.toBe("mutated-by-plugin") + expect((anthropic.options as Record | undefined)?.mutatedByPlugin).not.toBe(true) + // Models still resolve normally after the hook runs. + expect(Object.keys(anthropic.models).length).toBeGreaterThan(0) + }, + }) +}, 30000) + test("provider.sort prioritizes preferred models", () => { const models = [ { id: "random-model", name: "Random" },