From 146990cc472b9fdf92a2a0947137bb408b769ef6 Mon Sep 17 00:00:00 2001 From: starptech Date: Fri, 17 Jul 2026 17:33:35 +0200 Subject: [PATCH] fix(core): refresh console auth before catalog load --- packages/core/src/integration.ts | 57 +++-- packages/core/src/plugin/provider/opencode.ts | 34 ++- packages/core/test/mcp.test.ts | 1 + .../test/plugin/provider-opencode.test.ts | 223 +++++++++++++++++- 4 files changed, 287 insertions(+), 28 deletions(-) diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 62d637670297..3a1e4799b01e 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -168,6 +168,12 @@ export interface Interface extends State.Transformable { readonly resolve: ( connection: IntegrationConnection.Info, ) => Effect.Effect + /** Resolves with a refresh implementation that may not be materialized in the registry yet. */ + readonly resolveWithRefresh: ( + connection: IntegrationConnection.Info, + methodID: MethodID, + refresh: NonNullable, + ) => Effect.Effect /** Runs a key method and stores the resulting credential. */ readonly key: (input: { /** Integration receiving the credential. */ @@ -656,6 +662,34 @@ const layer = Layer.effect( return CommandAttempt.make({ attemptID, time }) }) + const resolveConnection = Effect.fnUntraced(function* ( + connection: IntegrationConnection.Info, + fallback?: { + readonly methodID: MethodID + readonly refresh: NonNullable + }, + ) { + if (connection.type === "env") { + const key = process.env[connection.name] + return key ? Credential.Key.make({ type: "key", key }) : undefined + } + const credential = yield* credentials.get(connection.id) + if (!credential) return undefined + if (credential.value.type === "key") return credential.value + const implementation = state + .get() + .integrations.get(credential.integrationID) + ?.implementations.get(credential.value.methodID) + const refresh = + implementation?.refresh ?? (fallback?.methodID === credential.value.methodID ? fallback.refresh : undefined) + if (!refresh) return credential.value + const now = yield* Clock.currentTimeMillis + if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value + const value = yield* authorize(refresh(credential.value)) + yield* credentials.update(credential.id, { value }) + return value + }) + return Service.of({ transform: state.transform, reload: state.reload, @@ -675,25 +709,10 @@ const layer = Layer.effect( const entry = state.get().integrations.get(id) return resolveConnections(entry, yield* credentials.list(id))[0] }), - resolve: Effect.fn("Integration.connection.resolve")(function* (connection) { - if (connection.type === "env") { - const key = process.env[connection.name] - return key ? Credential.Key.make({ type: "key", key }) : undefined - } - const credential = yield* credentials.get(connection.id) - if (!credential) return undefined - if (credential.value.type === "key") return credential.value - const implementation = state - .get() - .integrations.get(credential.integrationID) - ?.implementations.get(credential.value.methodID) - if (!implementation?.refresh) return credential.value - const now = yield* Clock.currentTimeMillis - if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value - const value = yield* authorize(implementation.refresh(credential.value)) - yield* credentials.update(credential.id, { value }) - return value - }), + resolve: Effect.fn("Integration.connection.resolve")((connection) => resolveConnection(connection)), + resolveWithRefresh: Effect.fn("Integration.connection.resolveWithRefresh")((connection, methodID, refresh) => + resolveConnection(connection, { methodID, refresh }), + ), key: Effect.fn("Integration.connection.key")(function* (input) { const method = state .get() diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 1e8c52e6fc05..42a858dca9a1 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -2,7 +2,7 @@ import { Duration, Effect, Schema, Semaphore, Stream } from "effect" import type { Scope } from "effect" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import { define } from "@opencode-ai/plugin/v2/effect/plugin" -import type { CredentialValue } from "@opencode-ai/sdk/v2/types" +import type { CredentialOAuth, CredentialValue } from "@opencode-ai/sdk/v2/types" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { EventV2 } from "../../event" import { Credential } from "../../credential" @@ -79,24 +79,40 @@ function oauth(http: HttpClient.HttpClient) { label: (credential) => { return typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined }, - } satisfies IntegrationOAuthMethodRegistration + } satisfies Integration.OAuthImplementation } -export const OpencodePlugin = define({ +export const OpencodePlugin = define({ id: "opencode.provider.opencode", effect: Effect.fn(function* (ctx) { const events = yield* EventV2.Service const http = yield* HttpClient.HttpClient + const integrations = yield* Integration.Service + const method = oauth(http) + const coreCredential = (credential: CredentialOAuth) => + Credential.OAuth.make({ ...credential, methodID: Integration.MethodID.make(credential.methodID) }) + const registration = { + ...method, + refresh: (credential: CredentialOAuth) => method.refresh(coreCredential(credential)), + label: (credential: CredentialOAuth) => method.label(coreCredential(credential)), + } satisfies IntegrationOAuthMethodRegistration const loading = Semaphore.makeUnsafe(1) let connected = false + let remoteConfigRequired = false let providers: typeof ConfigV1.Info.Type.provider | undefined const load = Effect.fn("OpencodePlugin.load")(function* () { - const connection = yield* ctx.integration.connection.active("opencode") + const connection = yield* integrations.connection.active(Integration.ID.make("opencode")) const credential = connection - ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined))) + ? yield* integrations.connection + .resolveWithRefresh(connection, method.method.id, method.refresh) + .pipe(Effect.catch(() => Effect.succeed(undefined))) : undefined connected = connection !== undefined + // Console credentials require its provider overlay; only legacy Zen `sk-` keys may use the models.dev fallback. + remoteConfigRequired = + connection !== undefined && + (credential === undefined || credential.type === "oauth" || credential.key.startsWith("oc_sk_")) providers = credential ? yield* fetchProviders(http, credential).pipe( Effect.catch((cause) => @@ -110,7 +126,7 @@ export const OpencodePlugin = define { integration.name = "OpenCode" }) - draft.method.update(oauth(http)) + draft.method.update(registration) draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } }) }) @@ -175,6 +191,12 @@ export const OpencodePlugin = define { + provider.disabled = true + }) + return + } const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey) catalog.provider.update(item.provider.id, (provider) => { if (!hasKey) provider.settings = { ...provider.settings, apiKey: "public" } diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index fd6f99f53848..1feee4f8e0f1 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -188,6 +188,7 @@ function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effe connection: { active: unusedIntegration, resolve: unusedIntegration, + resolveWithRefresh: unusedIntegration, key: unusedIntegration, update: unusedIntegration, remove: unusedIntegration, diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 1de22a03ddd4..fae79140082b 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -10,6 +10,7 @@ import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode" import { ProviderV2 } from "@opencode-ai/core/provider" +import { State } from "@opencode-ai/core/state" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -20,9 +21,11 @@ const addPlugin = Effect.fn(function* () { const host = yield* PluginHost.make(plugin) const events = yield* EventV2.Service const integration = yield* Integration.Service - yield* OpencodePlugin.effect(host).pipe( - Effect.provideService(EventV2.Service, events), - Effect.provideService(Integration.Service, integration), + yield* State.batch( + OpencodePlugin.effect(host).pipe( + Effect.provideService(EventV2.Service, events), + Effect.provideService(Integration.Service, integration), + ), ) }) @@ -77,6 +80,50 @@ const cost = (input: number, output = 0) => [ }, ] +const fableID = ModelV2.ID.make("claude-fable-5") + +const seedFallback = Effect.fnUntraced(function* () { + const catalog = yield* Catalog.Service + const provider = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.opencode), + integrationID: Integration.ID.make("opencode"), + package: ProviderV2.aisdk("@ai-sdk/openai-compatible"), + settings: { baseURL: "https://opencode.ai/zen/v1" }, + }) + const model = ModelV2.Info.make({ + ...ModelV2.Info.empty(provider.id, fableID), + modelID: fableID, + package: ProviderV2.aisdk("@ai-sdk/anthropic"), + cost: cost(1, 1), + }) + yield* catalog.transform((draft) => { + draft.provider.update(provider.id, (current) => Object.assign(current, provider)) + draft.model.update(provider.id, model.id, (current) => Object.assign(current, model)) + }) +}) + +const remoteConfig = (origin: string) => ({ + config: { + enterprise: { url: origin }, + provider: { + opencode: { + name: "OpenCode", + npm: "@ai-sdk/openai-compatible", + api: `${origin}/inference/openai/v1`, + env: ["OPENCODE_API_KEY"], + options: { apiKey: "{env:OPENCODE_API_KEY}" }, + models: { + "claude-fable-5": { + name: "Claude Fable 5", + provider: { npm: "@ai-sdk/anthropic", api: `${origin}/inference/anthropic/v1` }, + cost: { input: 1, output: 1 }, + }, + }, + }, + }, + }, +}) + describe("OpencodePlugin", () => { it.effect("registers account and service account methods", () => Effect.gen(function* () { @@ -283,6 +330,176 @@ describe("OpencodePlugin", () => { ), ) + it.live("refreshes expired OAuth before loading the Console catalog", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const requests: Array<{ path: string; authorization: string | null }> = [] + const server = Bun.serve({ + port: 0, + fetch: (request) => { + const path = new URL(request.url).pathname + requests.push({ path, authorization: request.headers.get("authorization") }) + if (path === "/auth/device/token") + return Response.json({ + access_token: "fresh-access", + refresh_token: "fresh-refresh", + expires_in: 3600, + }) + if (path === "/api/config") { + if (request.headers.get("authorization") !== "Bearer fresh-access") + return new Response("Unauthorized", { status: 401 }) + return Response.json(remoteConfig(new URL(request.url).origin)) + } + return new Response("Not found", { status: 404 }) + }, + }) + return { requests, server } + }), + ({ requests, server }) => + Effect.gen(function* () { + const credentials = yield* Credential.Service + yield* seedFallback() + yield* credentials.create({ + integrationID: Integration.ID.make("opencode"), + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("device"), + access: "expired-access", + refresh: "expired-refresh", + expires: 1, + metadata: { server: server.url.origin }, + }), + }) + + yield* addPlugin() + + expect(requests).toEqual([ + { path: "/auth/device/token", authorization: null }, + { path: "/api/config", authorization: "Bearer fresh-access" }, + ]) + expect((yield* credentials.list(Integration.ID.make("opencode")))[0]?.value).toMatchObject({ + type: "oauth", + access: "fresh-access", + refresh: "fresh-refresh", + }) + expect( + required(yield* (yield* Catalog.Service).model.get(ProviderV2.ID.opencode, fableID)).settings?.baseURL, + ).toBe(`${server.url.origin}/inference/anthropic/v1`) + }), + ({ server }) => Effect.promise(() => server.stop(true)), + ), + ) + + it.live("does not expose the legacy fallback when Console OAuth config is unavailable", () => + Effect.acquireUseRelease( + Effect.sync(() => + Bun.serve({ + port: 0, + fetch: () => new Response("Unavailable", { status: 503 }), + }), + ), + (server) => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const catalog = yield* Catalog.Service + yield* seedFallback() + yield* credentials.create({ + integrationID: Integration.ID.make("opencode"), + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("device"), + access: "access", + refresh: "refresh", + expires: Date.now() + 60 * 60 * 1000, + metadata: { server: server.url.origin }, + }), + }) + + yield* addPlugin() + + expect((yield* catalog.provider.available()).some((provider) => provider.id === ProviderV2.ID.opencode)).toBe( + false, + ) + expect( + (yield* catalog.model.available()).some( + (model) => model.providerID === ProviderV2.ID.opencode && model.id === fableID, + ), + ).toBe(false) + }), + (server) => Effect.promise(() => server.stop(true)), + ), + ) + + it.live("does not expose the legacy fallback when Console service-account config is unavailable", () => + Effect.acquireUseRelease( + Effect.sync(() => + Bun.serve({ + port: 0, + fetch: () => new Response("Unavailable", { status: 503 }), + }), + ), + (server) => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const catalog = yield* Catalog.Service + yield* seedFallback() + yield* credentials.create({ + integrationID: Integration.ID.make("opencode"), + value: Credential.Key.make({ + type: "key", + key: "oc_sk_0123456789ab_secret", + metadata: { server: server.url.origin }, + }), + }) + + yield* addPlugin() + + expect((yield* catalog.provider.available()).some((provider) => provider.id === ProviderV2.ID.opencode)).toBe( + false, + ) + expect( + (yield* catalog.model.available()).some( + (model) => model.providerID === ProviderV2.ID.opencode && model.id === fableID, + ), + ).toBe(false) + }), + (server) => Effect.promise(() => server.stop(true)), + ), + ) + + it.live("keeps the legacy fallback for an API key when remote provider config is unavailable", () => + Effect.acquireUseRelease( + Effect.sync(() => + Bun.serve({ + port: 0, + fetch: () => new Response("Unauthorized", { status: 401 }), + }), + ), + (server) => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const catalog = yield* Catalog.Service + yield* seedFallback() + yield* credentials.create({ + integrationID: Integration.ID.make("opencode"), + value: Credential.Key.make({ + type: "key", + key: "sk-legacy-key", + metadata: { server: server.url.origin }, + }), + }) + + yield* addPlugin() + + expect(required(yield* catalog.provider.get(ProviderV2.ID.opencode)).disabled).toBeUndefined() + expect(required(yield* catalog.model.get(ProviderV2.ID.opencode, fableID)).settings?.baseURL).toBe( + "https://opencode.ai/zen/v1", + ) + }), + (server) => Effect.promise(() => server.stop(true)), + ), + ) + it.effect("uses a public key and disables paid models without credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () {