diff --git a/.changeset/console-device-verification-url.md b/.changeset/console-device-verification-url.md new file mode 100644 index 0000000..6407f1b --- /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 the `/console` path. diff --git a/src/auth/auth.test.ts b/src/auth/auth.test.ts index 27d70af..c9cce15 100644 --- a/src/auth/auth.test.ts +++ b/src/auth/auth.test.ts @@ -18,6 +18,25 @@ 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("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..9e8271c 100644 --- a/src/transport/protocol.test.ts +++ b/src/transport/protocol.test.ts @@ -1,6 +1,20 @@ 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 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(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); +}); + +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..4ea97f1 100644 --- a/src/transport/protocol.ts +++ b/src/transport/protocol.ts @@ -7,6 +7,19 @@ export const OPENCODE_CLIENT = "opencode-copilot-chat"; 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 url.href; +} + export function apiBaseForMode(mode: OpenCodeMode): string { return mode === "go" ? GO_API_BASE_URL : ZEN_API_BASE_URL; }