Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/core/src/credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ export const Info = Schema.Union([OAuth, Key])
.annotate({ identifier: "Credential.Info" })
export type Info = Schema.Schema.Type<typeof Info>

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<Stored>("Credential.Stored")({
id: ID,
integrationID: IntegrationSchema.ID,
Expand Down
36 changes: 32 additions & 4 deletions packages/core/src/integration.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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<Credential.Info, unknown>) {
const now = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(attempts, (current) => {
Expand All @@ -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)
Expand Down
53 changes: 53 additions & 0 deletions packages/core/test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 37 additions & 1 deletion packages/opencode/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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* () {
Expand All @@ -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) {
Expand All @@ -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])

Expand Down
29 changes: 29 additions & 0 deletions packages/opencode/test/auth/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}),
)
})
Loading