diff --git a/packages/core/src/credential.ts b/packages/core/src/credential.ts index 77bece2776f0..9811965e4cbc 100644 --- a/packages/core/src/credential.ts +++ b/packages/core/src/credential.ts @@ -34,6 +34,24 @@ export const Info = Schema.Union([OAuth, Key]) .annotate({ identifier: "Credential.Info" }) export type Info = Schema.Schema.Type +export const LegacySuccessOAuth = Schema.Struct({ + type: Schema.Literal("success"), + access: Schema.String, + refresh: Schema.optional(Schema.String), + expires: Schema.optional(Schema.Number), + accountId: Schema.optional(Schema.String), + enterpriseUrl: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) + +export const LegacySuccessKey = Schema.Struct({ + type: Schema.Literal("success"), + key: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) + +export const LegacySuccessValue = Schema.Union([LegacySuccessOAuth, LegacySuccessKey]) + export class Stored extends Schema.Class("Credential.Stored")({ id: ID, integrationID: IntegrationSchema.ID, diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index a4bb3fd58ef5..c55f43c2bd67 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -1,6 +1,6 @@ export * as Integration from "./integration" -import { Cause, Clock, Context, Duration, Effect, Exit, Layer, Schedule, Schema, Scope, SynchronizedRef } from "effect" +import { Cause, Clock, Context, Duration, Effect, Exit, Layer, Option, Schedule, Schema, Scope, SynchronizedRef } from "effect" import { castDraft, enableMapSet, type Draft } from "immer" import { Credential } from "./credential" import { IntegrationSchema } from "./integration/schema" @@ -362,6 +362,9 @@ export const locationLayer = Layer.effect( return error instanceof Error ? error.message : String(error) } + // Decoder for legacy dynamic plugin auth success callback payloads (Ref: Issue #32101) + const decodeLegacySuccess = Schema.decodeUnknownOption(Credential.LegacySuccessValue) + const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit) { const now = yield* Clock.currentTimeMillis const result = yield* SynchronizedRef.modify(attempts, (current) => { @@ -374,13 +377,38 @@ export const locationLayer = Layer.effect( }) if (!result) return if (Exit.isSuccess(exit)) { + let value = exit.value + const decodedOption = decodeLegacySuccess(value) + if (Option.isSome(decodedOption)) { + const legacy = decodedOption.value + if ("access" in legacy) { + value = new Credential.OAuth({ + type: "oauth", + access: legacy.access, + refresh: legacy.refresh ?? "", + expires: legacy.expires ?? 0, + metadata: { + ...(legacy.accountId ? { clientId: legacy.accountId } : {}), + ...(legacy.enterpriseUrl ? { clientSecret: legacy.enterpriseUrl } : {}), + ...(legacy.metadata ?? {}), + }, + }) + } else { + value = new Credential.Key({ + type: "key", + key: legacy.key, + metadata: legacy.metadata, + }) + } + } + yield* credentials.create({ integrationID: result.integrationID, label: result.label, value: - exit.value.type === "oauth" - ? new Credential.OAuth({ ...exit.value, methodID: result.methodID }) - : exit.value, + value.type === "oauth" + ? new Credential.OAuth({ ...value, methodID: result.methodID }) + : value, }) } yield* close(result.scope) diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index fa05e23d95eb..96c056acd6c3 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -296,6 +296,59 @@ describe("Integration", () => { }).pipe(Effect.provide(connectionLayer(created))) }) + it.effect("completes legacy success callback OAuth and translates it to Credential.Value", () => { + const created: Array<{ + integrationID: Integration.ID + label?: string + value: Credential.Info + }> = [] + return Effect.gen(function* () { + const integrations = yield* Integration.Service + const integrationID = Integration.ID.make("openai") + const methodID = Integration.MethodID.make("legacy") + yield* integrations.update((editor) => + editor.method.update({ + integrationID, + method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Legacy" }), + authorize: () => + Effect.succeed({ + mode: "auto" as const, + url: "https://example.com/authorize", + instructions: "Sign in", + callback: Effect.succeed({ + type: "success", + access: "token-legacy", + refresh: "refresh-legacy", + expires: 100, + accountId: "legacy-client-id", + enterpriseUrl: "legacy-client-secret", + metadata: { customField: "val" }, + } as any), + }), + }), + ) + + const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) + yield* Effect.yieldNow + expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({ + status: "complete", + time: attempt.time, + }) + expect(created).toHaveLength(1) + expect(created[0].value.type).toBe("oauth") + if (created[0].value.type === "oauth") { + expect(created[0].value.access).toBe("token-legacy") + expect(created[0].value.refresh).toBe("refresh-legacy") + expect(created[0].value.expires).toBe(100) + expect(created[0].value.metadata).toEqual({ + clientId: "legacy-client-id", + clientSecret: "legacy-client-secret", + customField: "val", + }) + } + }).pipe(Effect.provide(connectionLayer(created))) + }) + it.effect("expires abandoned OAuth attempts", () => { const created: Array<{ integrationID: Integration.ID diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 5c18bc3caadf..8030a386b249 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -4,6 +4,8 @@ import { Effect, Layer, Record, Result, Schema, Context } from "effect" import { NonNegativeInt } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" import { FSUtil } from "@opencode-ai/core/fs-util" +import { Credential } from "@opencode-ai/core/credential" +import { IntegrationSchema } from "@opencode-ai/core/integration/schema" export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" @@ -53,6 +55,7 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const fsys = yield* FSUtil.Service + const credentials = yield* Credential.Service const decode = Schema.decodeUnknownOption(Info) const all = Effect.fn("Auth.all")(function* () { @@ -78,6 +81,31 @@ export const layer = Layer.effect( yield* fsys .writeJson(file, { ...data, [norm]: info }, 0o600) .pipe(Effect.mapError(fail("Failed to write auth data"))) + + yield* credentials + .create({ + integrationID: IntegrationSchema.ID.make(norm), + label: "default", + value: + info.type === "api" + ? new Credential.Key({ type: "key", key: info.key, metadata: info.metadata }) + : info.type === "oauth" + ? new Credential.OAuth({ + type: "oauth", + access: info.access, + refresh: info.refresh, + expires: info.expires, + metadata: { + ...(info.accountId ? { clientId: info.accountId } : {}), + ...(info.enterpriseUrl ? { clientSecret: info.enterpriseUrl } : {}), + }, + }) + : new Credential.Key({ + type: "key", + key: info.token, + }), + }) + .pipe(Effect.orDie) }) const remove = Effect.fn("Auth.remove")(function* (key: string) { @@ -86,13 +114,21 @@ export const layer = Layer.effect( delete data[key] delete data[norm] yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data"))) + + const existing = yield* credentials.list(IntegrationSchema.ID.make(norm)).pipe(Effect.orDie) + for (const item of existing) { + yield* credentials.remove(item.id).pipe(Effect.orDie) + } }) return Service.of({ get, all, set, remove }) }), ) -export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Credential.defaultLayer), +) export const node = LayerNode.make(layer, [FSUtil.node]) diff --git a/packages/opencode/test/auth/auth.test.ts b/packages/opencode/test/auth/auth.test.ts index 58ce6ea718d0..896049651a2e 100644 --- a/packages/opencode/test/auth/auth.test.ts +++ b/packages/opencode/test/auth/auth.test.ts @@ -2,6 +2,8 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Auth } from "../../src/auth" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Credential } from "@opencode-ai/core/credential" +import { IntegrationSchema } from "@opencode-ai/core/integration/schema" import { testEffect } from "../lib/effect" const node = CrossSpawnSpawner.defaultLayer @@ -74,4 +76,31 @@ describe("Auth", () => { expect(after["anthropic"]).toBeUndefined() }), ) + + it.instance("set and remove synchronize with SQLite Credential.Service in real-time", () => + Effect.gen(function* () { + const auth = yield* Auth.Service + const credentials = yield* Credential.Service + + // 1. Verify that 'set' on Auth also creates the credential in SQLite + yield* auth.set("anthropic-test", { + type: "api", + key: "sk-test-sqlite", + metadata: { customField: "test" }, + }) + + const list = yield* credentials.list(IntegrationSchema.ID.make("anthropic-test")) + expect(list.length).toBe(1) + expect(list[0].value.type).toBe("key") + if (list[0].value.type === "key") { + expect(list[0].value.key).toBe("sk-test-sqlite") + expect(list[0].value.metadata?.customField).toBe("test") + } + + // 2. Verify that 'remove' on Auth also deletes the credential from SQLite + yield* auth.remove("anthropic-test") + const listAfter = yield* credentials.list(IntegrationSchema.ID.make("anthropic-test")) + expect(listAfter.length).toBe(0) + }), + ) })