Browse Source

feat(v2/cli): add console login (#35969)

Simon Klee 1 month ago
parent
commit
19f42f7102

+ 1 - 0
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:",

+ 1 - 0
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:",

+ 11 - 0
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: [

+ 116 - 0
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<A>(task: (signal: AbortSignal) => Promise<A>) {
+  return Effect.tryPromise({
+    try: task,
+    catch: (cause) => cause,
+  })
+}
+
+function required<A>(value: A | null | undefined, message: string) {
+  return value === null || value === undefined ? Effect.fail(new Error(message)) : Effect.succeed(value)
+}
+
+function errorMessage(cause: Cause.Cause<unknown>) {
+  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)
+}

+ 3 - 0
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"),

+ 242 - 0
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<void>
+  item(text: string): Promise<void>
+  pending(text: string): Promise<void>
+  success(text: string): Promise<void>
+  failure(text: string): Promise<void>
+  outro(text: string): Promise<void>
+  close(): Promise<void>
+}
+
+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<RowKind, { marker: string; color: ColorInput; connector: boolean }> = {
+  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(
+    () => (
+      <box width="100%" minHeight={1} flexDirection="column">
+        <box width="100%" minHeight={1} flexDirection="row" gap={1}>
+          <text fg={style.color} flexShrink={0}>
+            {style.marker}
+          </text>
+          <text fg={COLORS.foreground} wrapMode="word">
+            {value}
+          </text>
+        </box>
+        <Show when={style.connector}>
+          <text fg={COLORS.muted}>│</text>
+        </Show>
+      </box>
+    ),
+    { 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 (
+    <box width="100%" height={1} flexDirection="row" gap={1}>
+      <Show when={props.pending()}>
+        {(text) => (
+          <>
+            <spinner frames={SPINNER_FRAMES} interval={80} color={COLORS.accent} />
+            <text fg={COLORS.foreground} wrapMode="none" truncate>
+              {text()}
+            </text>
+          </>
+        )}
+      </Show>
+    </box>
+  )
+}
+
+function bounded(task: Promise<unknown>) {
+  return new Promise<void>((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<void> {
+  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<TimelineHost> {
+  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<void> | undefined
+    let closeTask: Promise<void> | 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<void>((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<string>()
+    const renderTask = render(() => <TimelineFooter pending={pending} cancel={cancel} />, activeRenderer)
+    void renderTask.catch(cancel)
+    await bounded(activeRenderer.idle())
+
+    let closed = false
+    let writing = false
+    let active: Promise<void> | undefined
+    let closeTask: Promise<void> | 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
+  }
+}

+ 59 - 23
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<Credential.OAuth, unknown>) {
-      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]

+ 23 - 4
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<Record<string, unknown>> | 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),

+ 61 - 1
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

+ 67 - 0
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(() => {