From 94323128e0b24c8b48175339ccdf8ef21383eaee Mon Sep 17 00:00:00 2001 From: Bertrand GRESSIER Date: Fri, 12 Jun 2026 22:17:46 +0200 Subject: [PATCH 1/8] fix(core): map legacy success auth callback results to Credential.Value --- packages/core/src/integration.ts | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index a4bb3fd58ef5..33c1df674c41 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -374,13 +374,36 @@ export const locationLayer = Layer.effect( }) if (!result) return if (Exit.isSuccess(exit)) { + let value = exit.value + if ((value as any).type === "success") { + if ("access" in value) { + value = new Credential.OAuth({ + type: "oauth", + access: (value as any).access, + refresh: (value as any).refresh || "", + expires: (value as any).expires || 0, + metadata: { + ...((value as any).accountId ? { clientId: (value as any).accountId } : {}), + ...((value as any).enterpriseUrl ? { clientSecret: (value as any).enterpriseUrl } : {}), + ...((value as any).metadata ?? {}), + }, + }) + } else { + value = new Credential.Key({ + type: "key", + key: (value as any).key, + metadata: (value as any).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) From 936d1f5cc82ce6a91efce1bf70de0cf0ddc0283c Mon Sep 17 00:00:00 2001 From: Bertrand GRESSIER Date: Fri, 12 Jun 2026 22:22:33 +0200 Subject: [PATCH 2/8] refactor(core): make legacy success auth callback decoding 100% type-safe using Schema --- packages/core/src/integration.ts | 41 ++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 33c1df674c41..d513949d6a2c 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -362,6 +362,25 @@ export const locationLayer = Layer.effect( return error instanceof Error ? error.message : String(error) } + 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)), + }) + + const LegacySuccessKey = Schema.Struct({ + type: Schema.Literal("success"), + key: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), + }) + + const LegacySuccessValue = Schema.Union([LegacySuccessOAuth, LegacySuccessKey]) + const decodeLegacySuccess = Schema.decodeUnknownOption(LegacySuccessValue) + const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit) { const now = yield* Clock.currentTimeMillis const result = yield* SynchronizedRef.modify(attempts, (current) => { @@ -375,24 +394,26 @@ export const locationLayer = Layer.effect( if (!result) return if (Exit.isSuccess(exit)) { let value = exit.value - if ((value as any).type === "success") { - if ("access" in value) { + const decodedOption = decodeLegacySuccess(value) + if (decodedOption._tag === "Some") { + const legacy = decodedOption.value + if ("access" in legacy) { value = new Credential.OAuth({ type: "oauth", - access: (value as any).access, - refresh: (value as any).refresh || "", - expires: (value as any).expires || 0, + access: legacy.access, + refresh: legacy.refresh ?? "", + expires: legacy.expires ?? 0, metadata: { - ...((value as any).accountId ? { clientId: (value as any).accountId } : {}), - ...((value as any).enterpriseUrl ? { clientSecret: (value as any).enterpriseUrl } : {}), - ...((value as any).metadata ?? {}), + ...(legacy.accountId ? { clientId: legacy.accountId } : {}), + ...(legacy.enterpriseUrl ? { clientSecret: legacy.enterpriseUrl } : {}), + ...(legacy.metadata ?? {}), }, }) } else { value = new Credential.Key({ type: "key", - key: (value as any).key, - metadata: (value as any).metadata, + key: legacy.key, + metadata: legacy.metadata, }) } } From 55508d7a3e9b64370b93101cfe06e2cf79963373 Mon Sep 17 00:00:00 2001 From: Bertrand GRESSIER Date: Fri, 12 Jun 2026 22:25:58 +0200 Subject: [PATCH 3/8] refactor(core): move legacy success callback schemas to credential.ts for better modularity --- packages/core/src/credential.ts | 18 ++++++++++++++++++ packages/core/src/integration.ts | 19 +------------------ 2 files changed, 19 insertions(+), 18 deletions(-) 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 d513949d6a2c..79dc3ade0723 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -362,24 +362,7 @@ export const locationLayer = Layer.effect( return error instanceof Error ? error.message : String(error) } - 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)), - }) - - const LegacySuccessKey = Schema.Struct({ - type: Schema.Literal("success"), - key: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), - }) - - const LegacySuccessValue = Schema.Union([LegacySuccessOAuth, LegacySuccessKey]) - const decodeLegacySuccess = Schema.decodeUnknownOption(LegacySuccessValue) + const decodeLegacySuccess = Schema.decodeUnknownOption(Credential.LegacySuccessValue) const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit) { const now = yield* Clock.currentTimeMillis From a04cfe08f8cc3788ecd0737b396ee5dc21a4737c Mon Sep 17 00:00:00 2001 From: Bertrand GRESSIER Date: Fri, 12 Jun 2026 22:33:08 +0200 Subject: [PATCH 4/8] docs(core): add issue #32101 reference comment to integration.ts --- packages/core/src/integration.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 79dc3ade0723..6da2e94ba82b 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -362,6 +362,7 @@ 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) { From 9630e2a0ec7276b69591606bd764b76cf3094d42 Mon Sep 17 00:00:00 2001 From: Bertrand GRESSIER Date: Fri, 12 Jun 2026 22:37:46 +0200 Subject: [PATCH 5/8] refactor(core): use idiomatic Option.isSome check for legacy success callback decoding --- packages/core/src/integration.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 6da2e94ba82b..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" @@ -379,7 +379,7 @@ export const locationLayer = Layer.effect( if (Exit.isSuccess(exit)) { let value = exit.value const decodedOption = decodeLegacySuccess(value) - if (decodedOption._tag === "Some") { + if (Option.isSome(decodedOption)) { const legacy = decodedOption.value if ("access" in legacy) { value = new Credential.OAuth({ From a6faf7080f0d488fd5ca3485f3fbc480529fef87 Mon Sep 17 00:00:00 2001 From: Bertrand GRESSIER Date: Fri, 12 Jun 2026 22:42:50 +0200 Subject: [PATCH 6/8] fix(opencode): sync legacy auth writes and deletes directly to SQLite credential store --- packages/opencode/src/auth/index.ts | 38 ++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) 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]) From ba55f09cbbf25c0156596b3a0dd6bb3f43d35610 Mon Sep 17 00:00:00 2001 From: Bertrand GRESSIER Date: Fri, 12 Jun 2026 22:48:14 +0200 Subject: [PATCH 7/8] test(opencode): add unit tests for Auth.Service real-time SQLite synchronization --- packages/opencode/test/auth/auth.test.ts | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) 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) + }), + ) }) From 2535cfc73ff72dcb584020093fa3b6281c4d2401 Mon Sep 17 00:00:00 2001 From: Bertrand GRESSIER Date: Fri, 12 Jun 2026 22:53:02 +0200 Subject: [PATCH 8/8] test(core): add integration test for legacy success callback OAuth translation --- packages/core/test/integration.test.ts | 53 ++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) 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