fix(core): clarify integration auth errors (#44786)

Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-08-24 16:53:14 -05:00
committed by GitHub
parent c1763e2b64
commit 50c5218bca
3 changed files with 156 additions and 8 deletions
+67 -6
View File
@@ -1,6 +1,7 @@
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
import type { Server } from "node:http"
import { App } from "../../app.js"
import { Credential } from "../../credential.js"
import { Bus } from "../../bus.js"
@@ -12,6 +13,9 @@ import type { PluginInternal } from "../internal.js"
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
const issuer = "https://auth.openai.com"
const callbackPort = 1455
const callbackFallbackPort = 1457
const callbackBindAttempts = 10
const callbackBindRetryDelay = 200
const pollingSafetyMargin = 3000
const codexBaseURL = "https://chatgpt.com/backend-api/codex"
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
@@ -55,11 +59,10 @@ const browser = (app: App.Info) =>
const pkce = yield* Effect.promise(generatePKCE)
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
const code = yield* Deferred.make<string, Error>()
const redirect = `http://localhost:${callbackPort}/auth/callback`
// Lazy so runtimes without a loopback listener (workerd) never evaluate node:http.
const { createServer } = yield* Effect.promise(() => import("node:http"))
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
const url = new URL(request.url ?? "/", "http://localhost")
if (url.pathname !== "/auth/callback") {
response.writeHead(404).end("Not found")
return
@@ -86,11 +89,9 @@ const browser = (app: App.Info) =>
.writeHead(200, { "Content-Type": "text/html" })
.end(OauthCallbackPage.success({ provider: "ChatGPT" }))
})
yield* Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(callbackPort, "localhost", () => resume(Effect.void))
})
const port = yield* listen(server)
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
const redirect = `http://localhost:${port}/auth/callback`
return {
mode: "auto" as const,
url: authorizeURL(redirect, pkce, state),
@@ -104,6 +105,66 @@ const browser = (app: App.Info) =>
refresh: (value) => refresh(browserMethodID, value, app),
}) satisfies IntegrationOAuthMethodRegistration
function listen(server: Server) {
return bind(server, callbackPort).pipe(
Effect.as(callbackPort),
Effect.catchIf(addressInUse, () =>
cancel(callbackPort).pipe(
Effect.ignore,
Effect.andThen(Effect.sleep(callbackBindRetryDelay)),
Effect.andThen(bindWithRetry(server, callbackPort, callbackBindAttempts - 1)),
Effect.as(callbackPort),
Effect.catchIf(addressInUse, () =>
bindWithRetry(server, callbackFallbackPort, callbackBindAttempts).pipe(
Effect.as(callbackFallbackPort),
Effect.catchIf(addressInUse, () =>
Effect.fail(
new Error(
`OpenAI browser login needs local port ${callbackPort} or ${callbackFallbackPort}, but both are already in use. Stop the processes using those ports or choose ChatGPT Pro/Plus (headless), then try again.`,
),
),
),
),
),
),
),
)
}
function bindWithRetry(server: Server, port: number, attempts: number): Effect.Effect<void, Error> {
return bind(server, port).pipe(
Effect.catchIf(
(error) => addressInUse(error) && attempts > 1,
() => Effect.sleep(callbackBindRetryDelay).pipe(Effect.andThen(bindWithRetry(server, port, attempts - 1))),
),
)
}
function bind(server: Server, port: number) {
return Effect.callback<void, Error>((resume) => {
const onError = (error: Error) => resume(Effect.fail(error))
server.once("error", onError)
server.listen(port, "localhost", () => {
server.off("error", onError)
resume(Effect.void)
})
})
}
function cancel(port: number) {
return Effect.tryPromise({
try: (signal) =>
fetch(`http://localhost:${port}/cancel`, {
signal: AbortSignal.any([signal, AbortSignal.timeout(2000)]),
}),
catch: (cause) => cause,
})
}
function addressInUse(error: Error) {
return "code" in error && error.code === "EADDRINUSE"
}
const headless = (app: App.Info) =>
({
integrationID: Integration.ID.make("openai"),
+3 -2
View File
@@ -9,9 +9,10 @@ import { WellKnown } from "@opencode-ai/core/wellknown"
const authorize = <A, R>(effect: Effect.Effect<A, Integration.AuthorizationError, R>) =>
effect.pipe(
Effect.mapError(
() =>
(error) =>
new InvalidRequestError({
message: "Authentication failed",
message:
error.cause instanceof Error && error.cause.message.trim() ? error.cause.message : "Authentication failed",
kind: "integration_authorization",
}),
),
+86
View File
@@ -1,4 +1,5 @@
import { expect } from "bun:test"
import { createServer, type Server } from "node:http"
import { Workspace } from "@opencode-ai/core/workspace"
import { Effect } from "effect"
import { it } from "../../core/test/lib/effect"
@@ -10,6 +11,29 @@ const options = {
fs: { filewatcher: false },
} as const
type Handler = (request: Request) => Promise<Response>
function occupy(server: Server, port: number) {
return Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(port, "localhost", () => resume(Effect.void))
})
}
const ready = (handler: Handler) =>
Effect.promise(() => handler(new Request("http://opencode.local/api/model/default")))
const connectOpenAI = (handler: Handler) =>
Effect.promise(() =>
handler(
new Request("http://opencode.local/api/integration/openai/connect/oauth", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ methodID: "chatgpt-browser" }),
}),
),
)
it.live("serves the HttpApi and enforces Basic auth like the Node server", () =>
Effect.gen(function* () {
const handler = yield* ServerFetch.make({ ...options, password: "secret" })
@@ -53,6 +77,68 @@ it.live("serves unauthenticated and answers CORS preflight when no password is c
}).pipe(Effect.scoped),
)
it.live("cancels a stale OpenAI OAuth callback server before falling back", () =>
Effect.gen(function* () {
const requests: string[] = []
const blocker = createServer((request, response) => {
requests.push(request.url ?? "")
response.end("cancelled", () => blocker.close())
})
yield* occupy(blocker, 1455)
yield* Effect.addFinalizer(() => Effect.sync(() => blocker.close()))
const handler = yield* ServerFetch.make(options)
yield* ready(handler)
const response = yield* connectOpenAI(handler)
expect(response.status).toBe(200)
expect(requests).toContain("/cancel")
const body = (yield* Effect.promise(() => response.json())) as { data: { url: string } }
expect(new URL(body.data.url).searchParams.get("redirect_uri")).toBe("http://localhost:1455/auth/callback")
}).pipe(Effect.scoped),
)
it.live("falls back to port 1457 when OpenAI OAuth port 1455 remains busy", () =>
Effect.gen(function* () {
const requests: string[] = []
const blocker = createServer((request, response) => {
requests.push(request.url ?? "")
response.end("still running")
})
yield* occupy(blocker, 1455)
yield* Effect.addFinalizer(() => Effect.sync(() => blocker.close()))
const handler = yield* ServerFetch.make(options)
yield* ready(handler)
const response = yield* connectOpenAI(handler)
expect(response.status).toBe(200)
expect(requests).toContain("/cancel")
const body = (yield* Effect.promise(() => response.json())) as { data: { url: string } }
expect(new URL(body.data.url).searchParams.get("redirect_uri")).toBe("http://localhost:1457/auth/callback")
}).pipe(Effect.scoped),
)
it.live("explains how to recover when both OpenAI OAuth callback ports are busy", () =>
Effect.gen(function* () {
const preferred = createServer((_request, response) => response.end("still running"))
const fallback = createServer()
yield* occupy(preferred, 1455)
yield* occupy(fallback, 1457)
yield* Effect.addFinalizer(() => Effect.sync(() => preferred.close()))
yield* Effect.addFinalizer(() => Effect.sync(() => fallback.close()))
const handler = yield* ServerFetch.make(options)
yield* ready(handler)
const response = yield* connectOpenAI(handler)
expect(response.status).toBe(400)
expect(yield* Effect.promise(() => response.json())).toEqual({
_tag: "InvalidRequestError",
message:
"OpenAI browser login needs local port 1455 or 1457, but both are already in use. Stop the processes using those ports or choose ChatGPT Pro/Plus (headless), then try again.",
kind: "integration_authorization",
})
}).pipe(Effect.scoped),
)
it.live("treats destroying a missing workspace as success", () =>
Effect.gen(function* () {
const handler = yield* ServerFetch.make(options)