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" },