diff --git a/.changeset/device-authorization-url.md b/.changeset/device-authorization-url.md new file mode 100644 index 000000000000..2bbf73cb05d6 --- /dev/null +++ b/.changeset/device-authorization-url.md @@ -0,0 +1,5 @@ +--- +"@opencode-ai/core": patch +--- + +Fix OpenCode Console device authorization URLs when the server returns an origin-rooted verification path. diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index f65feb9b229d..c3b7f1ce915f 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -46,15 +46,18 @@ function oauth(http: HttpClient.HttpClient) { Effect.gen(function* () { const server = yield* normalizeServer(answer.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)")) - } + const verification = yield* Effect.try({ + try: () => { + const url = new URL(device.verification_uri_complete, `${server}/`) + 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: verification?.href ?? `${server}/${device.verification_uri_complete.replace(/^\/+/, "")}`, + url: verification.href, instructions: `Enter code: ${device.user_code}`, callback: poll(http, server, 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 784196cc7d70..2a1fac997813 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -105,7 +105,7 @@ describe("OpencodePlugin", () => { return Response.json({ device_code: "device", user_code: "user", - verification_uri_complete: `${url.origin}/verify`, + verification_uri_complete: "/console/device?user_code=user&client_id=opencode-cli", expires_in: 60, interval: 0, }) @@ -130,7 +130,7 @@ describe("OpencodePlugin", () => { methodID: Integration.MethodID.make("device"), answer: { server: `${server.url.origin}/console///?ignored=true#ignored` }, }) - expect(attempt.url).toBe(`${server.url.origin}/verify`) + expect(attempt.url).toBe(`${server.url.origin}/console/device?user_code=user&client_id=opencode-cli`) yield* eventually( integrations.oauth.status({ integrationID, attemptID: attempt.attemptID }), (status) => status.status === "complete", @@ -148,6 +148,38 @@ describe("OpencodePlugin", () => { ), ) + it.live("rejects malformed device verification URLs", () => + Effect.acquireUseRelease( + Effect.sync(() => + Bun.serve({ + port: 0, + fetch: () => + Response.json({ + device_code: "device", + user_code: "user", + verification_uri_complete: "http://[::1", + expires_in: 60, + interval: 0, + }), + }), + ), + (server) => + Effect.gen(function* () { + yield* addPlugin() + const error = yield* (yield* Integration.Service).oauth + .connect({ + integrationID: Integration.ID.make("opencode"), + methodID: Integration.MethodID.make("device"), + answer: { server: server.url.origin }, + }) + .pipe(Effect.flip) + expect(error).toBeInstanceOf(Integration.AuthorizationError) + expect(String(error.cause)).toContain("Invalid device verification URL") + }), + (server) => Effect.promise(() => server.stop(true)), + ), + ) + it.effect("rejects non-HTTP OpenCode servers", () => Effect.gen(function* () { yield* addPlugin()