From 2b9acd54903533f1c6e92d87ff94626e358042e5 Mon Sep 17 00:00:00 2001 From: Griko Nibras Date: Wed, 9 Sep 2026 17:12:22 +0700 Subject: [PATCH 1/2] Fix Console device sign-in opening a doubled /console URL Device authorization returns an origin-absolute /console/device path. String-concatenating that onto the Console API base produced console.opencode.ai/console/... (and /console/console on opencode.ai). Resolve the verification URL properly and open opencode.ai/console/device. --- .changeset/console-device-verification-url.md | 5 +++ src/auth/auth.test.ts | 36 +++++++++++++++++++ src/auth/auth.ts | 4 +-- src/transport/protocol.test.ts | 23 +++++++++++- src/transport/protocol.ts | 27 ++++++++++++++ 5 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 .changeset/console-device-verification-url.md diff --git a/.changeset/console-device-verification-url.md b/.changeset/console-device-verification-url.md new file mode 100644 index 0000000..67cbd53 --- /dev/null +++ b/.changeset/console-device-verification-url.md @@ -0,0 +1,5 @@ +--- +"opencode-bridge-copilot-chat": patch +--- + +Open Console device sign-in at `https://opencode.ai/console/device` instead of doubling `/console` onto `console.opencode.ai`. diff --git a/src/auth/auth.test.ts b/src/auth/auth.test.ts index 27d70af..5fb0c0f 100644 --- a/src/auth/auth.test.ts +++ b/src/auth/auth.test.ts @@ -18,6 +18,42 @@ class Secrets { async delete(key: string): Promise { this.values.delete(key); } } +test("opens Console device verification at opencode.ai/console/device", async () => { + const fetcher: typeof fetch = async (input) => { + assert.equal(String(input), "https://opencode.ai/console/auth/device/code"); + return Response.json({ + device_code: "device", + user_code: "ABCD-EFGH", + verification_uri: "/console/device", + verification_uri_complete: "/console/device?user_code=ABCD-EFGH&client_id=opencode-cli", + expires_in: 900, + interval: 5, + }); + }; + const auth = new OpenCodeAuth(new Secrets() as never, fetcher, () => 1_000); + const device = await auth.requestDeviceCode(); + assert.equal(device.verificationUrl, "https://opencode.ai/console/device?user_code=ABCD-EFGH&client_id=opencode-cli"); + assert.equal(device.server, "https://opencode.ai/console"); + assert.equal(device.userCode, "ABCD-EFGH"); +}); + +test("rewrites console.opencode.ai device pages onto opencode.ai/console", async () => { + const fetcher: typeof fetch = async (input) => { + assert.equal(String(input), "https://console.opencode.ai/auth/device/code"); + return Response.json({ + device_code: "device", + user_code: "WXYZ-UVST", + verification_uri_complete: "/console/device?user_code=WXYZ-UVST", + expires_in: 900, + interval: 5, + }); + }; + const auth = new OpenCodeAuth(new Secrets() as never, fetcher, () => 1_000); + const device = await auth.requestDeviceCode("https://console.opencode.ai"); + assert.equal(device.verificationUrl, "https://opencode.ai/console/device?user_code=WXYZ-UVST"); + assert.equal(device.server, "https://console.opencode.ai"); +}); + test("stores Zen and Go keys separately", async () => { const auth = new OpenCodeAuth(new Secrets() as never); await auth.setApiKey("zen", "zen-key"); diff --git a/src/auth/auth.ts b/src/auth/auth.ts index 4683813..8efbb9e 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -1,5 +1,5 @@ import type * as vscode from "vscode"; -import { DEFAULT_CONSOLE_SERVER, OPENCODE_CLIENT_ID, type OpenCodeMode } from "../transport/protocol"; +import { DEFAULT_CONSOLE_SERVER, OPENCODE_CLIENT_ID, resolveConsoleVerificationUrl, type OpenCodeMode } from "../transport/protocol"; const API_KEYS_KEY = "opencode.apiKeys.v1"; const CONSOLE_SESSION_KEY = "opencode.consoleSession.v1"; @@ -151,7 +151,7 @@ export class OpenCodeAuth { return { deviceCode, userCode, - verificationUrl: verification.startsWith("http") ? verification : `${normalized}${verification}`, + verificationUrl: resolveConsoleVerificationUrl(normalized, verification), expiresAt: this.now() + expiresIn * 1000, intervalMs: Math.max(1000, positiveNumber(value.interval, 5) * 1000), server: normalized, diff --git a/src/transport/protocol.test.ts b/src/transport/protocol.test.ts index 17298b8..b2023dd 100644 --- a/src/transport/protocol.test.ts +++ b/src/transport/protocol.test.ts @@ -1,6 +1,27 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { buildAuthHeaders, buildRequestHeaders, endpointUrl, resolveEndpointKind } from "./protocol"; +import { buildAuthHeaders, buildRequestHeaders, DEFAULT_CONSOLE_SERVER, endpointUrl, resolveConsoleVerificationUrl, resolveEndpointKind } from "./protocol"; + +test("resolves Console device verification URLs onto opencode.ai/console", () => { + const complete = "/console/device?user_code=ABCD-EFGH&client_id=opencode-cli"; + const expected = "https://opencode.ai/console/device?user_code=ABCD-EFGH&client_id=opencode-cli"; + assert.equal(resolveConsoleVerificationUrl(DEFAULT_CONSOLE_SERVER, complete), expected); + assert.equal(resolveConsoleVerificationUrl("https://opencode.ai/console/", complete), expected); + assert.equal(resolveConsoleVerificationUrl("https://console.opencode.ai", complete), expected); + assert.equal(resolveConsoleVerificationUrl("https://console.opencode.ai/", complete), expected); + assert.equal(resolveConsoleVerificationUrl("https://console.opencode.ai", "/device?user_code=ABCD-EFGH"), "https://opencode.ai/console/device?user_code=ABCD-EFGH"); + assert.equal(resolveConsoleVerificationUrl(DEFAULT_CONSOLE_SERVER, "device?user_code=ABCD-EFGH"), "https://opencode.ai/console/device?user_code=ABCD-EFGH"); + assert.equal(resolveConsoleVerificationUrl(DEFAULT_CONSOLE_SERVER, expected), expected); + assert.equal( + resolveConsoleVerificationUrl(DEFAULT_CONSOLE_SERVER, "https://console.opencode.ai/console/device?user_code=ABCD-EFGH"), + "https://opencode.ai/console/device?user_code=ABCD-EFGH", + ); +}); + +test("rejects non-HTTP Console verification URLs", () => { + assert.throws(() => resolveConsoleVerificationUrl(DEFAULT_CONSOLE_SERVER, "javascript:alert(1)"), /non-HTTP/); + assert.throws(() => resolveConsoleVerificationUrl(DEFAULT_CONSOLE_SERVER, "http://["), /invalid verification URL/); +}); test("uses OpenCode gateway authentication conventions", () => { assert.deepEqual(buildAuthHeaders("chat-completions", "key"), { Authorization: "Bearer key" }); diff --git a/src/transport/protocol.ts b/src/transport/protocol.ts index a1f2389..794dc32 100644 --- a/src/transport/protocol.ts +++ b/src/transport/protocol.ts @@ -1,12 +1,39 @@ +// Public Console is served at https://opencode.ai/console. The console.opencode.ai +// alias 302-prefixes /console, so using it as a browser origin doubles that path. export const DEFAULT_CONSOLE_SERVER = "https://opencode.ai/console"; export const ZEN_API_BASE_URL = "https://opencode.ai/zen/v1"; export const GO_API_BASE_URL = "https://opencode.ai/zen/go/v1"; export const OPENCODE_CLIENT_ID = "opencode-cli"; export const OPENCODE_CLIENT = "opencode-copilot-chat"; +const PUBLIC_CONSOLE_ALIAS_HOST = "console.opencode.ai"; + export type OpenCodeMode = "zen" | "go" | "console"; export type EndpointKind = "chat-completions" | "messages" | "responses" | "google"; +export function resolveConsoleVerificationUrl(server: string, verification: string): string { + let url: URL; + try { + url = new URL(verification, `${server.replace(/\/+$/, "")}/`); + } catch { + throw new Error("OpenCode Console returned an invalid verification URL"); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("OpenCode Console returned a non-HTTP verification URL"); + } + return canonicalizePublicConsoleUrl(url).href; +} + +function canonicalizePublicConsoleUrl(url: URL): URL { + if (url.hostname !== PUBLIC_CONSOLE_ALIAS_HOST) return url; + const next = new URL(url.href); + next.hostname = "opencode.ai"; + if (next.pathname !== "/console" && !next.pathname.startsWith("/console/")) { + next.pathname = `/console${next.pathname.startsWith("/") ? next.pathname : `/${next.pathname}`}`; + } + return next; +} + export function apiBaseForMode(mode: OpenCodeMode): string { return mode === "go" ? GO_API_BASE_URL : ZEN_API_BASE_URL; } From 223bb8de09011bfe46d62db42b78bfb46afcd12d Mon Sep 17 00:00:00 2001 From: Griko Nibras Date: Wed, 9 Sep 2026 17:17:32 +0700 Subject: [PATCH 2/2] Drop console.opencode.ai rewrite from device verification URLs Sign-in always uses the /console subpath on opencode.ai. Resolving verification_uri_complete against that base is enough; the host alias is not on the runtime path. --- .changeset/console-device-verification-url.md | 2 +- src/auth/auth.test.ts | 17 ----------------- src/transport/protocol.test.ts | 9 +-------- src/transport/protocol.ts | 16 +--------------- 4 files changed, 3 insertions(+), 41 deletions(-) diff --git a/.changeset/console-device-verification-url.md b/.changeset/console-device-verification-url.md index 67cbd53..6407f1b 100644 --- a/.changeset/console-device-verification-url.md +++ b/.changeset/console-device-verification-url.md @@ -2,4 +2,4 @@ "opencode-bridge-copilot-chat": patch --- -Open Console device sign-in at `https://opencode.ai/console/device` instead of doubling `/console` onto `console.opencode.ai`. +Open Console device sign-in at `https://opencode.ai/console/device` instead of doubling the `/console` path. diff --git a/src/auth/auth.test.ts b/src/auth/auth.test.ts index 5fb0c0f..c9cce15 100644 --- a/src/auth/auth.test.ts +++ b/src/auth/auth.test.ts @@ -37,23 +37,6 @@ test("opens Console device verification at opencode.ai/console/device", async () assert.equal(device.userCode, "ABCD-EFGH"); }); -test("rewrites console.opencode.ai device pages onto opencode.ai/console", async () => { - const fetcher: typeof fetch = async (input) => { - assert.equal(String(input), "https://console.opencode.ai/auth/device/code"); - return Response.json({ - device_code: "device", - user_code: "WXYZ-UVST", - verification_uri_complete: "/console/device?user_code=WXYZ-UVST", - expires_in: 900, - interval: 5, - }); - }; - const auth = new OpenCodeAuth(new Secrets() as never, fetcher, () => 1_000); - const device = await auth.requestDeviceCode("https://console.opencode.ai"); - assert.equal(device.verificationUrl, "https://opencode.ai/console/device?user_code=WXYZ-UVST"); - assert.equal(device.server, "https://console.opencode.ai"); -}); - test("stores Zen and Go keys separately", async () => { const auth = new OpenCodeAuth(new Secrets() as never); await auth.setApiKey("zen", "zen-key"); diff --git a/src/transport/protocol.test.ts b/src/transport/protocol.test.ts index b2023dd..9e8271c 100644 --- a/src/transport/protocol.test.ts +++ b/src/transport/protocol.test.ts @@ -2,20 +2,13 @@ import assert from "node:assert/strict"; import test from "node:test"; import { buildAuthHeaders, buildRequestHeaders, DEFAULT_CONSOLE_SERVER, endpointUrl, resolveConsoleVerificationUrl, resolveEndpointKind } from "./protocol"; -test("resolves Console device verification URLs onto opencode.ai/console", () => { +test("resolves Console device verification URLs onto the /console subpath", () => { const complete = "/console/device?user_code=ABCD-EFGH&client_id=opencode-cli"; const expected = "https://opencode.ai/console/device?user_code=ABCD-EFGH&client_id=opencode-cli"; assert.equal(resolveConsoleVerificationUrl(DEFAULT_CONSOLE_SERVER, complete), expected); assert.equal(resolveConsoleVerificationUrl("https://opencode.ai/console/", complete), expected); - assert.equal(resolveConsoleVerificationUrl("https://console.opencode.ai", complete), expected); - assert.equal(resolveConsoleVerificationUrl("https://console.opencode.ai/", complete), expected); - assert.equal(resolveConsoleVerificationUrl("https://console.opencode.ai", "/device?user_code=ABCD-EFGH"), "https://opencode.ai/console/device?user_code=ABCD-EFGH"); assert.equal(resolveConsoleVerificationUrl(DEFAULT_CONSOLE_SERVER, "device?user_code=ABCD-EFGH"), "https://opencode.ai/console/device?user_code=ABCD-EFGH"); assert.equal(resolveConsoleVerificationUrl(DEFAULT_CONSOLE_SERVER, expected), expected); - assert.equal( - resolveConsoleVerificationUrl(DEFAULT_CONSOLE_SERVER, "https://console.opencode.ai/console/device?user_code=ABCD-EFGH"), - "https://opencode.ai/console/device?user_code=ABCD-EFGH", - ); }); test("rejects non-HTTP Console verification URLs", () => { diff --git a/src/transport/protocol.ts b/src/transport/protocol.ts index 794dc32..4ea97f1 100644 --- a/src/transport/protocol.ts +++ b/src/transport/protocol.ts @@ -1,13 +1,9 @@ -// Public Console is served at https://opencode.ai/console. The console.opencode.ai -// alias 302-prefixes /console, so using it as a browser origin doubles that path. export const DEFAULT_CONSOLE_SERVER = "https://opencode.ai/console"; export const ZEN_API_BASE_URL = "https://opencode.ai/zen/v1"; export const GO_API_BASE_URL = "https://opencode.ai/zen/go/v1"; export const OPENCODE_CLIENT_ID = "opencode-cli"; export const OPENCODE_CLIENT = "opencode-copilot-chat"; -const PUBLIC_CONSOLE_ALIAS_HOST = "console.opencode.ai"; - export type OpenCodeMode = "zen" | "go" | "console"; export type EndpointKind = "chat-completions" | "messages" | "responses" | "google"; @@ -21,17 +17,7 @@ export function resolveConsoleVerificationUrl(server: string, verification: stri if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error("OpenCode Console returned a non-HTTP verification URL"); } - return canonicalizePublicConsoleUrl(url).href; -} - -function canonicalizePublicConsoleUrl(url: URL): URL { - if (url.hostname !== PUBLIC_CONSOLE_ALIAS_HOST) return url; - const next = new URL(url.href); - next.hostname = "opencode.ai"; - if (next.pathname !== "/console" && !next.pathname.startsWith("/console/")) { - next.pathname = `/console${next.pathname.startsWith("/") ? next.pathname : `/${next.pathname}`}`; - } - return next; + return url.href; } export function apiBaseForMode(mode: OpenCodeMode): string {