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:
committed by
GitHub
parent
c1763e2b64
commit
50c5218bca
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user