diff --git a/bun.lock b/bun.lock
index df33000ba53..6e8f024ea2d 100644
--- a/bun.lock
+++ b/bun.lock
@@ -114,6 +114,7 @@
"effect": "catalog:",
"fuzzysort": "catalog:",
"jsonc-parser": "3.3.1",
+ "open": "10.1.2",
"opentui-spinner": "catalog:",
"semver": "catalog:",
"solid-js": "catalog:",
diff --git a/packages/cli/package.json b/packages/cli/package.json
index e11986e5b2e..d924dd6a3a2 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -46,6 +46,7 @@
"effect": "catalog:",
"fuzzysort": "catalog:",
"jsonc-parser": "3.3.1",
+ "open": "10.1.2",
"opentui-spinner": "catalog:",
"semver": "catalog:",
"solid-js": "catalog:",
diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts
index a3b49b3d06f..d3a5e0b8136 100644
--- a/packages/cli/src/commands/commands.ts
+++ b/packages/cli/src/commands/commands.ts
@@ -75,6 +75,17 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
description: "Debugging and troubleshooting tools",
commands: [Spec.make("agents", { description: "List all agents" })],
}),
+ Spec.make("console", {
+ description: "Manage OpenCode Console access",
+ commands: [
+ Spec.make("login", {
+ description: "Log in to OpenCode Console",
+ params: {
+ url: Argument.string("url").pipe(Argument.withDescription("Console server URL"), Argument.optional),
+ },
+ }),
+ ],
+ }),
Spec.make("mcp", {
description: "Manage MCP (Model Context Protocol) servers",
commands: [
diff --git a/packages/cli/src/commands/handlers/console/login.ts b/packages/cli/src/commands/handlers/console/login.ts
new file mode 100644
index 00000000000..959263e89db
--- /dev/null
+++ b/packages/cli/src/commands/handlers/console/login.ts
@@ -0,0 +1,116 @@
+import { Cause, Effect, Exit, Option } from "effect"
+import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
+import { AppProcess } from "@opencode-ai/core/process"
+import { Commands } from "../../commands"
+import { Runtime } from "../../../framework/runtime"
+import { Daemon } from "../../../daemon"
+import { createTimelineHost, type TimelineHost } from "../../../ui/timeline"
+
+const integrationID = "opencode"
+const location = { directory: process.cwd() }
+
+export default Runtime.handler(
+ Commands.commands.console.commands.login,
+ Effect.fn("cli.console.login")(function* (input) {
+ const timeline = yield* Effect.acquireRelease(
+ Effect.promise(() => createTimelineHost()),
+ (value) => request(() => value.close()).pipe(Effect.ignore),
+ )
+ const exit = yield* login(timeline, Option.getOrUndefined(input.url)).pipe(
+ Effect.raceFirst(AppProcess.waitForAbort(timeline.signal)),
+ Effect.exit,
+ )
+ if (Exit.isSuccess(exit)) return
+
+ const cancelled = timeline.signal.aborted
+ yield* request(() => timeline.failure(cancelled ? "Authorization cancelled" : errorMessage(exit.cause))).pipe(
+ Effect.ignore,
+ )
+ process.exitCode = cancelled ? 130 : 1
+ }),
+)
+
+const login = Effect.fn("cli.console.login.run")(function* (timeline: TimelineHost, server?: string) {
+ yield* request(() => timeline.intro("Log in"))
+ yield* request(() => timeline.pending("Connecting to OpenCode..."))
+
+ const transport = yield* Daemon.transport({ mode: "shared" })
+ const client = OpenCode.make({ baseUrl: transport.url, headers: transport.headers })
+ const found = yield* request((signal) => client.integration.get({ integrationID, location }, { signal }))
+ const integration = yield* required(found.data, "OpenCode Console integration is unavailable")
+ const method = yield* required(
+ integration.methods.find((candidate) => candidate.type === "oauth"),
+ "OpenCode Console login is unavailable",
+ )
+
+ yield* request(() => timeline.pending("Starting authorization..."))
+ const started = yield* request((signal) =>
+ client.integration.connect.oauth(
+ {
+ integrationID,
+ methodID: method.id,
+ inputs: server ? { server } : {},
+ location,
+ },
+ { signal },
+ ),
+ )
+ const attempt = started.data
+ yield* Effect.addFinalizer(() =>
+ request(() =>
+ client.integration.attempt.cancel(
+ { attemptID: attempt.attemptID, location },
+ { signal: AbortSignal.timeout(5_000) },
+ ),
+ ).pipe(Effect.ignore),
+ )
+ if (attempt.mode !== "auto") yield* Effect.fail(new Error("OpenCode Console requires a device login"))
+
+ yield* request(() => timeline.item(`Go to: ${attempt.url}`))
+ yield* request(() => timeline.item(attempt.instructions))
+ yield* request(async () => {
+ const { default: open } = await import("open")
+ await open(attempt.url)
+ }).pipe(Effect.ignore)
+ yield* request(() => timeline.pending("Waiting for authorization..."))
+
+ const status = yield* waitForConsoleLogin(client, attempt.attemptID)
+ if (status.status === "failed") yield* Effect.fail(new Error(status.message))
+ if (status.status === "expired") yield* Effect.fail(new Error("Device code expired"))
+
+ yield* request(() => timeline.success("Connected to OpenCode Console"))
+ yield* request(() => timeline.outro("Done"))
+})
+
+const waitForConsoleLogin = Effect.fn("cli.console.login.wait")(function* (
+ client: OpenCodeClient,
+ attemptID: string,
+) {
+ while (true) {
+ const response = yield* request((signal) =>
+ client.integration.attempt.status({ attemptID, location }, { signal }),
+ )
+ if (response.data.status !== "pending") return response.data
+ yield* Effect.sleep(500)
+ }
+})
+
+function request(task: (signal: AbortSignal) => Promise) {
+ return Effect.tryPromise({
+ try: task,
+ catch: (cause) => cause,
+ })
+}
+
+function required(value: A | null | undefined, message: string) {
+ return value === null || value === undefined ? Effect.fail(new Error(message)) : Effect.succeed(value)
+}
+
+function errorMessage(cause: Cause.Cause) {
+ const error = Cause.squash(cause)
+ if (error instanceof Error) return error.message
+ if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") {
+ return error.message
+ }
+ return String(error)
+}
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 5b851d7f497..d6d758c95de 100755
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -24,6 +24,9 @@ const Handlers = Runtime.handlers(Commands, {
debug: {
agents: () => import("./commands/handlers/debug/agents"),
},
+ console: {
+ login: () => import("./commands/handlers/console/login"),
+ },
mcp: {
list: () => import("./commands/handlers/mcp/list"),
add: () => import("./commands/handlers/mcp/add"),
diff --git a/packages/cli/src/ui/timeline.tsx b/packages/cli/src/ui/timeline.tsx
new file mode 100644
index 00000000000..8c7e6612c67
--- /dev/null
+++ b/packages/cli/src/ui/timeline.tsx
@@ -0,0 +1,242 @@
+/** @jsxImportSource @opentui/solid */
+import { createCliRenderer, RGBA, type CliRenderer, type ColorInput, type ScrollbackWriter } from "@opentui/core"
+import { createScrollbackWriter, render, useKeyboard } from "@opentui/solid"
+import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
+import { Show, createSignal } from "solid-js"
+
+registerOpencodeSpinner()
+
+export type TimelineHost = {
+ readonly signal: AbortSignal
+ intro(text: string): Promise
+ item(text: string): Promise
+ pending(text: string): Promise
+ success(text: string): Promise
+ failure(text: string): Promise
+ outro(text: string): Promise
+ close(): Promise
+}
+
+type RowKind = "intro" | "item" | "success" | "failure" | "outro"
+
+const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
+const IDLE_TIMEOUT = 1_000
+const COLORS = {
+ accent: RGBA.fromIndex(6),
+ error: RGBA.fromIndex(1),
+ foreground: RGBA.defaultForeground(),
+ muted: RGBA.fromIndex(8),
+ success: RGBA.fromIndex(2),
+}
+const ROWS: Record = {
+ intro: { marker: "┌", color: COLORS.muted, connector: true },
+ item: { marker: "●", color: COLORS.accent, connector: true },
+ success: { marker: "◇", color: COLORS.success, connector: true },
+ failure: { marker: "■", color: COLORS.error, connector: false },
+ outro: { marker: "└", color: COLORS.muted, connector: false },
+}
+
+function row(kind: RowKind, value: string): ScrollbackWriter {
+ const style = ROWS[kind]
+ return createScrollbackWriter(
+ () => (
+
+
+
+ {style.marker}
+
+
+ {value}
+
+
+
+ │
+
+
+ ),
+ { startOnNewLine: true, trailingNewline: !style.connector },
+ )
+}
+
+function TimelineFooter(props: { pending: () => string | undefined; cancel: () => void }) {
+ useKeyboard((event) => {
+ if (event.name !== "escape" && !(event.ctrl && event.name === "c")) return
+ event.preventDefault()
+ props.cancel()
+ })
+
+ return (
+
+
+ {(text) => (
+ <>
+
+
+ {text()}
+
+ >
+ )}
+
+
+ )
+}
+
+function bounded(task: Promise) {
+ return new Promise((resolve) => {
+ const timer = setTimeout(resolve, IDLE_TIMEOUT)
+ timer.unref()
+ const finish = () => {
+ clearTimeout(timer)
+ resolve()
+ }
+ void task.then(finish, finish)
+ })
+}
+
+async function shutdown(renderer: CliRenderer): Promise {
+ await bounded(renderer.idle())
+ try {
+ renderer.externalOutputMode = "passthrough"
+ } finally {
+ try {
+ renderer.screenMode = "main-screen"
+ } finally {
+ if (!renderer.isDestroyed) renderer.destroy()
+ }
+ }
+}
+
+export async function createTimelineHost(): Promise {
+ const stdout = process.stdout
+ const controller = new AbortController()
+ const signals: NodeJS.Signals[] = ["SIGINT", "SIGHUP", "SIGQUIT"]
+ const cancel = () => {
+ if (!controller.signal.aborted) controller.abort()
+ }
+ signals.forEach((signal) => process.on(signal, cancel))
+
+ if (!stdout.isTTY || !process.stdin.isTTY) {
+ let closed = false
+ let writing = false
+ let active: Promise | undefined
+ let closeTask: Promise | undefined
+ const write = async (kind: RowKind | "pending", text: string) => {
+ if (closed) throw new Error("timeline closed")
+ if (writing) throw new Error("timeline write already in progress")
+ writing = true
+ try {
+ const style = kind === "pending" ? undefined : ROWS[kind]
+ const marker = kind === "pending" ? "." : ROWS[kind].marker
+ const connector = style?.connector ? "│\n" : ""
+ active = new Promise((resolve, reject) => {
+ stdout.write(`${marker} ${text}\n${connector}`, (error) => (error ? reject(error) : resolve()))
+ })
+ await active
+ } finally {
+ writing = false
+ active = undefined
+ }
+ }
+ const close = () => {
+ if (closeTask) return closeTask
+ closed = true
+ closeTask = (async () => {
+ await active?.catch(() => { })
+ signals.forEach((signal) => process.off(signal, cancel))
+ })()
+ return closeTask
+ }
+ return {
+ signal: controller.signal,
+ intro: (text) => write("intro", text),
+ item: (text) => write("item", text),
+ pending: (text) => write("pending", text),
+ success: (text) => write("success", text),
+ failure: (text) => write("failure", text),
+ outro: (text) => write("outro", text),
+ close,
+ }
+ }
+
+ let renderer: CliRenderer | undefined
+
+ try {
+ // Start on a fresh row so delayed SSH cursor reports cannot make
+ // split-footer overwrite the shell command.
+ process.stdout.write("\n")
+ renderer = await createCliRenderer({
+ stdin: process.stdin,
+ useMouse: false,
+ autoFocus: false,
+ openConsoleOnError: false,
+ exitOnCtrlC: false,
+ exitSignals: [],
+ screenMode: "split-footer",
+ footerHeight: 1,
+ externalOutputMode: "capture-stdout",
+ consoleMode: "disabled",
+ clearOnShutdown: false,
+ })
+ const activeRenderer = renderer
+ const [pending, setPending] = createSignal()
+ const renderTask = render(() => , activeRenderer)
+ void renderTask.catch(cancel)
+ await bounded(activeRenderer.idle())
+
+ let closed = false
+ let writing = false
+ let active: Promise | undefined
+ let closeTask: Promise | undefined
+ const write = (kind: RowKind | "pending", text: string) => {
+ if (closed) return Promise.reject(new Error("timeline closed"))
+ if (writing) return Promise.reject(new Error("timeline write already in progress"))
+ writing = true
+ active = (async () => {
+ if (kind === "pending") {
+ setPending(text)
+ activeRenderer.requestRender()
+ } else {
+ if (kind === "success" || kind === "failure" || kind === "outro") setPending(undefined)
+ activeRenderer.writeToScrollback(row(kind, text))
+ activeRenderer.requestRender()
+ }
+ await bounded(activeRenderer.idle())
+ })().finally(() => {
+ writing = false
+ active = undefined
+ })
+ return active
+ }
+ const close = () => {
+ if (closeTask) return closeTask
+ closed = true
+ closeTask = (async () => {
+ await active?.catch(() => { })
+ try {
+ await shutdown(activeRenderer)
+ await bounded(renderTask)
+ } finally {
+ signals.forEach((signal) => process.off(signal, cancel))
+ }
+ })()
+ return closeTask
+ }
+ return {
+ signal: controller.signal,
+ intro: (text) => write("intro", text),
+ item: (text) => write("item", text),
+ pending: (text) => write("pending", text),
+ success: (text) => write("success", text),
+ failure: (text) => write("failure", text),
+ outro: (text) => write("outro", text),
+ close,
+ }
+ } catch (error) {
+ try {
+ if (renderer) await shutdown(renderer)
+ } finally {
+ signals.forEach((signal) => process.off(signal, cancel))
+ }
+ throw error
+ }
+}
diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts
index 12dad07dd34..c4f7c3b8f2d 100644
--- a/packages/core/src/integration.ts
+++ b/packages/core/src/integration.ts
@@ -203,6 +203,7 @@ type AttemptTime = { created: number; expires: number }
type PendingAttempt = {
status: "pending"
completing: boolean
+ persisting: boolean
authorization: OAuthAuthorization
integrationID: ID
methodID: MethodID
@@ -320,27 +321,61 @@ const layer = Layer.effect(
}
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit) {
- const now = yield* Clock.currentTimeMillis
- const result = yield* SynchronizedRef.modify(attempts, (current) => {
- const attempt = current.get(attemptID)
- if (!attempt || attempt.status !== "pending") return [undefined, current]
- const terminal: TerminalAttempt = Exit.isSuccess(exit)
- ? { status: "complete", time: attempt.time, removeAt: now + terminalRetention }
- : { status: "failed", message: message(exit.cause), time: attempt.time, removeAt: now + terminalRetention }
- return [attempt, new Map(current).set(attemptID, terminal)]
- })
- if (!result) return
- if (Exit.isSuccess(exit)) {
- const implementation = state.get().integrations.get(result.integrationID)?.implementations.get(result.methodID)
- yield* credentials.create({
- integrationID: result.integrationID,
- label: result.label ?? implementation?.label?.(exit.value),
- value: exit.value,
- })
- yield* events.publish(Event.ConnectionUpdated, { integrationID: result.integrationID })
- yield* events.publish(Event.Updated, {})
- }
- yield* close(result.scope)
+ return yield* Effect.uninterruptible(
+ Effect.gen(function* () {
+ const now = yield* Clock.currentTimeMillis
+ const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
+ const match = current.get(attemptID)
+ if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
+ const next = Exit.isSuccess(exit)
+ ? { ...match, persisting: true }
+ : {
+ status: "failed" as const,
+ message: message(exit.cause),
+ time: match.time,
+ removeAt: now + terminalRetention,
+ }
+ return [match, new Map(current).set(attemptID, next)]
+ })
+ if (!attempt) return
+ if (Exit.isFailure(exit)) {
+ yield* close(attempt.scope)
+ return
+ }
+
+ yield* Effect.gen(function* () {
+ const implementation = state
+ .get()
+ .integrations.get(attempt.integrationID)
+ ?.implementations.get(attempt.methodID)
+ const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
+ Effect.flatMap((label) =>
+ credentials.create({
+ integrationID: attempt.integrationID,
+ label,
+ value: exit.value,
+ }),
+ ),
+ Effect.asVoid,
+ Effect.exit,
+ )
+ const settledAt = yield* Clock.currentTimeMillis
+ const terminal: TerminalAttempt = Exit.isSuccess(persistence)
+ ? { status: "complete", time: attempt.time, removeAt: settledAt + terminalRetention }
+ : {
+ status: "failed",
+ message: message(persistence.cause),
+ time: attempt.time,
+ removeAt: settledAt + terminalRetention,
+ }
+ // Persisting attempts cannot be cancelled, expired, or claimed again.
+ yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
+ if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
+ yield* events.publish(Event.ConnectionUpdated, { integrationID: attempt.integrationID })
+ yield* events.publish(Event.Updated, {})
+ }).pipe(Effect.ensuring(close(attempt.scope)))
+ }),
+ )
})
const scrub = Effect.fnUntraced(function* () {
@@ -349,7 +384,7 @@ const layer = Layer.effect(
const next = new Map(current)
const scopes: Scope.Closeable[] = []
for (const [id, attempt] of current) {
- if (attempt.status === "pending" && attempt.time.expires <= now) {
+ if (attempt.status === "pending" && !attempt.persisting && attempt.time.expires <= now) {
scopes.push(attempt.scope)
next.set(id, { status: "expired", time: attempt.time, removeAt: now + terminalRetention })
continue
@@ -432,6 +467,7 @@ const layer = Layer.effect(
new Map(current).set(id, {
status: "pending",
completing: authorization.mode === "auto",
+ persisting: false,
authorization,
integrationID: input.integrationID,
methodID: input.methodID,
@@ -506,7 +542,7 @@ const layer = Layer.effect(
cancel: Effect.fn("Integration.attempt.cancel")(function* (attemptID) {
const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
const match = current.get(attemptID)
- if (!match || match.status !== "pending") return [undefined, current]
+ if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
const next = new Map(current)
next.delete(attemptID)
return [match, next]
diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts
index cca6a2dfa73..6092903b41a 100644
--- a/packages/core/src/plugin/provider/opencode.ts
+++ b/packages/core/src/plugin/provider/opencode.ts
@@ -43,14 +43,21 @@ function oauth(http: HttpClient.HttpClient) {
type: "oauth",
label: "OpenCode Console account",
},
- authorize: () =>
+ authorize: (inputs) =>
Effect.gen(function* () {
- const device = yield* post(http, `${defaultServer}/auth/device/code`, { client_id: clientID }, Device)
+ const server = yield* normalizeServer(inputs.server ?? defaultServer)
+ const device = yield* post(http, `${server}/auth/device/code`, { client_id: clientID }, Device)
+ const verification = URL.canParse(device.verification_uri_complete)
+ ? new URL(device.verification_uri_complete)
+ : undefined
+ if (verification && verification.protocol !== "http:" && verification.protocol !== "https:") {
+ return yield* Effect.fail(new Error("Invalid device verification URL: expected HTTP(S)"))
+ }
return {
mode: "auto" as const,
- url: `${defaultServer}${device.verification_uri_complete}`,
+ url: verification?.href ?? `${server}/${device.verification_uri_complete.replace(/^\/+/, "")}`,
instructions: `Enter code: ${device.user_code}`,
- callback: poll(http, defaultServer, device.device_code, Duration.seconds(device.interval)),
+ callback: poll(http, server, device.device_code, Duration.seconds(device.interval)),
}
}),
refresh: (credential) =>
@@ -219,6 +226,18 @@ function withoutCredentials(body: Readonly> | undefined)
return Object.fromEntries(Object.entries(body ?? {}).filter(([key]) => key !== "apiKey" && key !== "headers"))
}
+function normalizeServer(input: string) {
+ return Effect.try({
+ try: () => {
+ const url = new URL(input)
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("expected HTTP(S)")
+ return `${url.origin}${url.pathname.replace(/\/+$/, "")}`
+ },
+ catch: (cause) =>
+ new Error(`Invalid OpenCode server URL: ${cause instanceof Error ? cause.message : String(cause)}`),
+ })
+}
+
function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) {
const base = {
input: Money.USDPerMillionTokens.make(input.input),
diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts
index 376d2f9523b..dc20303f001 100644
--- a/packages/core/test/integration.test.ts
+++ b/packages/core/test/integration.test.ts
@@ -1,14 +1,33 @@
import { describe, expect } from "bun:test"
-import { Duration, Effect, Exit, Fiber, Scope, Stream } from "effect"
+import { Cause, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
+import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Integration } from "@opencode-ai/core/integration"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node])))
+const failingCredentialNode = makeGlobalNode({
+ service: Credential.Service,
+ layer: Layer.succeed(
+ Credential.Service,
+ Credential.Service.of({
+ all: () => Effect.succeed([]),
+ list: () => Effect.succeed([]),
+ get: () => Effect.succeed(undefined),
+ create: () => Effect.die(new Error("credential persistence failed")),
+ update: () => Effect.void,
+ remove: () => Effect.void,
+ }),
+ ),
+ deps: [],
+})
+const failingIt = testEffect(
+ AppNodeBuilder.build(LayerNode.group([Integration.node, EventV2.node]), [[Credential.node, failingCredentialNode]]),
+)
describe("Integration", () => {
it.effect("registers integrations through the editor", () =>
@@ -254,6 +273,47 @@ describe("Integration", () => {
}),
)
+ failingIt.effect("fails the attempt when credential persistence fails", () =>
+ Effect.gen(function* () {
+ const integrations = yield* Integration.Service
+ const integrationID = Integration.ID.make("openai")
+ const methodID = Integration.MethodID.make("chatgpt")
+ yield* integrations.transform((editor) =>
+ editor.method.update({
+ integrationID,
+ method: { id: methodID, type: "oauth", label: "ChatGPT" },
+ authorize: () =>
+ Effect.succeed({
+ mode: "code" as const,
+ url: "https://example.com/authorize",
+ instructions: "Paste the code",
+ callback: () =>
+ Effect.succeed(
+ Credential.OAuth.make({
+ type: "oauth",
+ methodID,
+ access: "access",
+ refresh: "refresh",
+ expires: 1,
+ }),
+ ),
+ }),
+ }),
+ )
+
+ const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
+ const exit = yield* integrations.attempt
+ .complete({ attemptID: attempt.attemptID, code: "1234" })
+ .pipe(Effect.exit)
+ expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true)
+ expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({
+ status: "failed",
+ message: "credential persistence failed",
+ time: attempt.time,
+ })
+ }),
+ )
+
it.effect("expires abandoned OAuth attempts", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts
index f8334026f90..0b3e30cd319 100644
--- a/packages/core/test/plugin/provider-opencode.test.ts
+++ b/packages/core/test/plugin/provider-opencode.test.ts
@@ -92,6 +92,73 @@ describe("OpencodePlugin", () => {
}),
)
+ it.live("uses a canonical custom server throughout device authorization", () =>
+ Effect.acquireUseRelease(
+ Effect.sync(() => {
+ const requests: string[] = []
+ const server = Bun.serve({
+ port: 0,
+ fetch: (request) => {
+ const url = new URL(request.url)
+ requests.push(`${request.method} ${url.pathname}`)
+ if (url.pathname.endsWith("/auth/device/code")) {
+ return Response.json({
+ device_code: "device",
+ user_code: "user",
+ verification_uri_complete: `${url.origin}/verify`,
+ expires_in: 60,
+ interval: 0,
+ })
+ }
+ if (url.pathname.endsWith("/auth/device/token")) {
+ return Response.json({ access_token: "access", refresh_token: "refresh", expires_in: 600 })
+ }
+ if (url.pathname.endsWith("/api/user")) return Response.json({ id: "user", email: "user@example.com" })
+ if (url.pathname.endsWith("/api/orgs")) return Response.json([{ id: "org", name: "Org" }])
+ return new Response("Not found", { status: 404 })
+ },
+ })
+ return { requests, server }
+ }),
+ ({ requests, server }) =>
+ Effect.gen(function* () {
+ yield* addPlugin()
+ const integrations = yield* Integration.Service
+ const attempt = yield* integrations.connection.oauth({
+ integrationID: Integration.ID.make("opencode"),
+ methodID: Integration.MethodID.make("device"),
+ inputs: { server: `${server.url.origin}/console///?ignored=true#ignored` },
+ })
+ expect(attempt.url).toBe(`${server.url.origin}/verify`)
+ yield* eventually(integrations.attempt.status(attempt.attemptID), (status) => status.status === "complete")
+
+ expect(requests).toContain("POST /console/auth/device/code")
+ expect(requests).toContain("POST /console/auth/device/token")
+ expect(requests).toContain("GET /console/api/user")
+ expect(requests).toContain("GET /console/api/orgs")
+ expect((yield* (yield* Credential.Service).list(Integration.ID.make("opencode")))[0]?.value).toMatchObject({
+ metadata: { server: `${server.url.origin}/console` },
+ })
+ }),
+ ({ server }) => Effect.promise(() => server.stop(true)),
+ ),
+ )
+
+ it.effect("rejects non-HTTP OpenCode servers", () =>
+ Effect.gen(function* () {
+ yield* addPlugin()
+ const error = yield* (yield* Integration.Service).connection
+ .oauth({
+ integrationID: Integration.ID.make("opencode"),
+ methodID: Integration.MethodID.make("device"),
+ inputs: { server: "ftp://console.example.com" },
+ })
+ .pipe(Effect.flip)
+ expect(error).toBeInstanceOf(Integration.AuthorizationError)
+ expect(String(error.cause)).toContain("Invalid OpenCode server URL: expected HTTP(S)")
+ }),
+ )
+
it.live("loads providers and models from the connected OpenCode server", () =>
Effect.acquireUseRelease(
Effect.sync(() => {