fix(core): refresh console auth before catalog load

This commit is contained in:
starptech
2026-07-17 17:33:35 +02:00
parent 27e1692848
commit 146990cc47
4 changed files with 287 additions and 28 deletions
+38 -19
View File
@@ -168,6 +168,12 @@ export interface Interface extends State.Transformable<Draft> {
readonly resolve: (
connection: IntegrationConnection.Info,
) => Effect.Effect<Credential.Value | undefined, AuthorizationError>
/** Resolves with a refresh implementation that may not be materialized in the registry yet. */
readonly resolveWithRefresh: (
connection: IntegrationConnection.Info,
methodID: MethodID,
refresh: NonNullable<OAuthImplementation["refresh"]>,
) => Effect.Effect<Credential.Value | undefined, AuthorizationError>
/** 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<OAuthImplementation["refresh"]>
},
) {
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()
+28 -6
View File
@@ -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<HttpClient.HttpClient | EventV2.Service | Scope.Scope>({
export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | Integration.Service | Scope.Scope>({
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<HttpClient.HttpClient | EventV2.Service | S
draft.update("opencode", (integration) => {
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<HttpClient.HttpClient | EventV2.Service | S
const item = catalog.provider.get(ProviderV2.ID.opencode)
if (!item) return
if (remoteConfigRequired && providers?.[ProviderV2.ID.opencode] === undefined) {
catalog.provider.update(item.provider.id, (provider) => {
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" }
+1
View File
@@ -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,
@@ -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* () {