From e00890c67261a435cee6409366a68999a93393fd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 21 Aug 2026 20:25:56 -0400 Subject: [PATCH] fix: resolve console device URLs (#44029) --- packages/core/src/plugin/provider/opencode.ts | 11 +++- .../test/plugin/provider-opencode.test.ts | 62 ++++++++++++++++++- packages/opencode/src/account/account.ts | 10 ++- .../opencode/test/account/service.test.ts | 35 ++++++++--- 4 files changed, 108 insertions(+), 10 deletions(-) diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 8e1cc1a0a07..7bd78cae415 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -45,9 +45,18 @@ function oauth(http: HttpClient.HttpClient) { authorize: () => Effect.gen(function* () { const device = yield* post(http, `${defaultServer}/auth/device/code`, { client_id: clientID }, Device) + const verification = yield* Effect.try({ + try: () => { + const url = new URL(device.verification_uri_complete, `${defaultServer}/`) + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("expected HTTP(S)") + return url + }, + catch: (cause) => + new Error(`Invalid device verification URL: ${cause instanceof Error ? cause.message : String(cause)}`), + }) return { mode: "auto" as const, - url: `${defaultServer}${device.verification_uri_complete}`, + url: verification.href, instructions: `Enter code: ${device.user_code}`, callback: poll(http, defaultServer, device.device_code, Duration.seconds(device.interval)), } diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 20af84d02f5..e1f8bdd6ec1 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" @@ -14,14 +15,16 @@ import { PluginTestLayer } from "./fixture" const it = testEffect(PluginTestLayer) -const addPlugin = Effect.fn(function* () { +const addPlugin = Effect.fn(function* (http?: HttpClient.HttpClient) { const plugin = yield* PluginV2.Service const host = yield* PluginHost.make(plugin) const events = yield* EventV2.Service const integration = yield* Integration.Service + const client = yield* HttpClient.HttpClient yield* OpencodePlugin.effect(host).pipe( Effect.provideService(EventV2.Service, events), Effect.provideService(Integration.Service, integration), + Effect.provideService(HttpClient.HttpClient, http ?? client), ) }) @@ -82,6 +85,63 @@ describe("OpencodePlugin", () => { }), ) + it.effect("resolves origin-rooted device verification URLs", () => + Effect.gen(function* () { + const http = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + device_code: "device", + user_code: "user", + verification_uri_complete: "/console/device?user_code=user&client_id=opencode-cli", + expires_in: 60, + interval: 60, + }), + ), + ), + ) + yield* addPlugin(http) + const integration = yield* Integration.Service + const attempt = yield* integration.connection.oauth({ + integrationID: Integration.ID.make("opencode"), + methodID: Integration.MethodID.make("device"), + inputs: {}, + }) + expect(attempt.url).toBe("https://opencode.ai/console/device?user_code=user&client_id=opencode-cli") + }), + ) + + it.effect("rejects malformed device verification URLs", () => + Effect.gen(function* () { + const http = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + device_code: "device", + user_code: "user", + verification_uri_complete: "http://[::1", + expires_in: 60, + interval: 60, + }), + ), + ), + ) + yield* addPlugin(http) + const integration = yield* Integration.Service + const error = yield* integration.connection + .oauth({ + integrationID: Integration.ID.make("opencode"), + methodID: Integration.MethodID.make("device"), + inputs: {}, + }) + .pipe(Effect.flip) + expect(error).toBeInstanceOf(Integration.AuthorizationError) + expect(String(error.cause)).toContain("Invalid device verification URL") + }), + ) + it.live("loads providers and models from the connected OpenCode server", () => Effect.acquireUseRelease( Effect.sync(() => { diff --git a/packages/opencode/src/account/account.ts b/packages/opencode/src/account/account.ts index 4b49d2a7489..fb21f878f84 100644 --- a/packages/opencode/src/account/account.ts +++ b/packages/opencode/src/account/account.ts @@ -396,10 +396,18 @@ const layer: Layer.Layer { + const url = new URL(parsed.verification_uri_complete, `${normalizedServer}/`) + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("expected HTTP(S)") + return url.href + }, + catch: (cause) => new AccountServiceError({ message: "Invalid device verification URL", cause }), + }) return new Login({ code: parsed.device_code, user: parsed.user_code, - url: `${normalizedServer}${parsed.verification_uri_complete}`, + url: verification, server: normalizedServer, expiry: parsed.expires_in, interval: parsed.interval, diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index 672d5497162..802e91233a0 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -10,6 +10,7 @@ import { Account } from "../../src/account/account" import { AccessToken, AccountID, + AccountServiceError, AccountTransportError, DeviceCode, Login, @@ -71,18 +72,18 @@ const deviceTokenClient = (body: unknown, status = 400) => const poll = (body: unknown, status = 400) => Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status)))) -it.live("login normalizes trailing slashes in the provided server URL", () => +it.live("login resolves origin-rooted verification URLs from servers with base paths", () => Effect.gen(function* () { const seen: Array = [] const client = HttpClient.make((req) => Effect.gen(function* () { seen.push(`${req.method} ${req.url}`) - if (req.url === "https://one.example.com/auth/device/code") { + if (req.url === "https://one.example.com/console/auth/device/code") { return json(req, { device_code: "device-code", user_code: "user-code", - verification_uri_complete: "/device?user_code=user-code", + verification_uri_complete: "/console/device?user_code=user-code", expires_in: 600, interval: 5, }) @@ -92,11 +93,31 @@ it.live("login normalizes trailing slashes in the provided server URL", () => }), ) - const result = yield* Account.use.login("https://one.example.com/").pipe(Effect.provide(live(client))) + const result = yield* Account.use.login("https://one.example.com/console/").pipe(Effect.provide(live(client))) - expect(seen).toEqual(["POST https://one.example.com/auth/device/code"]) - expect(result.server).toBe("https://one.example.com") - expect(result.url).toBe("https://one.example.com/device?user_code=user-code") + expect(seen).toEqual(["POST https://one.example.com/console/auth/device/code"]) + expect(result.server).toBe("https://one.example.com/console") + expect(result.url).toBe("https://one.example.com/console/device?user_code=user-code") + }), +) + +it.live("login rejects malformed device verification URLs", () => + Effect.gen(function* () { + const client = HttpClient.make((req) => + Effect.succeed( + json(req, { + device_code: "device-code", + user_code: "user-code", + verification_uri_complete: "http://[::1", + expires_in: 600, + interval: 5, + }), + ), + ) + + const error = yield* Effect.flip(Account.use.login("https://one.example.com").pipe(Effect.provide(live(client)))) + expect(error).toBeInstanceOf(AccountServiceError) + if (error instanceof AccountServiceError) expect(error.message).toBe("Invalid device verification URL") }), )