diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx index b11ad6a734..09bb492f63 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx @@ -9,6 +9,7 @@ import { useToast } from "../ui/toast" import { useKeybind } from "../context/keybind" import { DialogSessionList } from "./workspace/dialog-session-list" import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import { setTimeout as sleep } from "node:timers/promises" async function openWorkspace(input: { dialog: ReturnType @@ -56,7 +57,7 @@ async function openWorkspace(input: { return } if (result.response.status >= 500 && result.response.status < 600) { - await Bun.sleep(1000) + await sleep(1000) continue } if (!result.data) { diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 8c76fbdab9..80c378cebb 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -1,4 +1,5 @@ import z from "zod" +import { setTimeout as sleep } from "node:timers/promises" import { Identifier } from "@/id/id" import { fn } from "@/util/fn" import { Database, eq } from "@/storage/db" @@ -116,7 +117,7 @@ export namespace Workspace { const adaptor = await getAdaptor(space.type) const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined) if (!res || !res.ok || !res.body) { - await Bun.sleep(1000) + await sleep(1000) continue } await parseSSE(res.body, stop, (event) => { @@ -126,7 +127,7 @@ export namespace Workspace { }) }) // Wait 250ms and retry if SSE connection fails - await Bun.sleep(250) + await sleep(250) } } diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index e48a42a8b3..bf5a0d3ce7 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -11,6 +11,7 @@ import { } from "@modelcontextprotocol/sdk/types.js" import { Config } from "../config/config" import { Log } from "../util/log" +import { Process } from "../util/process" import { NamedError } from "@opencode-ai/util/error" import z from "zod/v4" import { Instance } from "../project/instance" @@ -166,14 +167,10 @@ export namespace MCP { const queue = [pid] while (queue.length > 0) { const current = queue.shift()! - const proc = Bun.spawn(["pgrep", "-P", String(current)], { stdout: "pipe", stderr: "pipe" }) - const [code, out] = await Promise.all([proc.exited, new Response(proc.stdout).text()]).catch( - () => [-1, ""] as const, - ) - if (code !== 0) continue - for (const tok of out.trim().split(/\s+/)) { + const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true }) + for (const tok of lines) { const cpid = parseInt(tok, 10) - if (!isNaN(cpid) && pids.indexOf(cpid) === -1) { + if (!isNaN(cpid) && !pids.includes(cpid)) { pids.push(cpid) queue.push(cpid) } diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index db8e621d6c..14506610e8 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -1,4 +1,5 @@ import { createConnection } from "net" +import { createServer } from "http" import { Log } from "../util/log" import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider" @@ -52,11 +53,74 @@ interface PendingAuth { } export namespace McpOAuthCallback { - let server: ReturnType | undefined + let server: ReturnType | undefined const pendingAuths = new Map() const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes + function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) { + const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`) + + if (url.pathname !== OAUTH_CALLBACK_PATH) { + res.writeHead(404) + res.end("Not found") + return + } + + const code = url.searchParams.get("code") + const state = url.searchParams.get("state") + const error = url.searchParams.get("error") + const errorDescription = url.searchParams.get("error_description") + + log.info("received oauth callback", { hasCode: !!code, state, error }) + + // Enforce state parameter presence + if (!state) { + const errorMsg = "Missing required state parameter - potential CSRF attack" + log.error("oauth callback missing state parameter", { url: url.toString() }) + res.writeHead(400, { "Content-Type": "text/html" }) + res.end(HTML_ERROR(errorMsg)) + return + } + + if (error) { + const errorMsg = errorDescription || error + if (pendingAuths.has(state)) { + const pending = pendingAuths.get(state)! + clearTimeout(pending.timeout) + pendingAuths.delete(state) + pending.reject(new Error(errorMsg)) + } + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(HTML_ERROR(errorMsg)) + return + } + + if (!code) { + res.writeHead(400, { "Content-Type": "text/html" }) + res.end(HTML_ERROR("No authorization code provided")) + return + } + + // Validate state parameter + if (!pendingAuths.has(state)) { + const errorMsg = "Invalid or expired state parameter - potential CSRF attack" + log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) }) + res.writeHead(400, { "Content-Type": "text/html" }) + res.end(HTML_ERROR(errorMsg)) + return + } + + const pending = pendingAuths.get(state)! + + clearTimeout(pending.timeout) + pendingAuths.delete(state) + pending.resolve(code) + + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(HTML_SUCCESS) + } + export async function ensureRunning(): Promise { if (server) return @@ -66,75 +130,14 @@ export namespace McpOAuthCallback { return } - server = Bun.serve({ - port: OAUTH_CALLBACK_PORT, - fetch(req) { - const url = new URL(req.url) - - if (url.pathname !== OAUTH_CALLBACK_PATH) { - return new Response("Not found", { status: 404 }) - } - - const code = url.searchParams.get("code") - const state = url.searchParams.get("state") - const error = url.searchParams.get("error") - const errorDescription = url.searchParams.get("error_description") - - log.info("received oauth callback", { hasCode: !!code, state, error }) - - // Enforce state parameter presence - if (!state) { - const errorMsg = "Missing required state parameter - potential CSRF attack" - log.error("oauth callback missing state parameter", { url: url.toString() }) - return new Response(HTML_ERROR(errorMsg), { - status: 400, - headers: { "Content-Type": "text/html" }, - }) - } - - if (error) { - const errorMsg = errorDescription || error - if (pendingAuths.has(state)) { - const pending = pendingAuths.get(state)! - clearTimeout(pending.timeout) - pendingAuths.delete(state) - pending.reject(new Error(errorMsg)) - } - return new Response(HTML_ERROR(errorMsg), { - headers: { "Content-Type": "text/html" }, - }) - } - - if (!code) { - return new Response(HTML_ERROR("No authorization code provided"), { - status: 400, - headers: { "Content-Type": "text/html" }, - }) - } - - // Validate state parameter - if (!pendingAuths.has(state)) { - const errorMsg = "Invalid or expired state parameter - potential CSRF attack" - log.error("oauth callback with invalid state", { state, pendingStates: Array.from(pendingAuths.keys()) }) - return new Response(HTML_ERROR(errorMsg), { - status: 400, - headers: { "Content-Type": "text/html" }, - }) - } - - const pending = pendingAuths.get(state)! - - clearTimeout(pending.timeout) - pendingAuths.delete(state) - pending.resolve(code) - - return new Response(HTML_SUCCESS, { - headers: { "Content-Type": "text/html" }, - }) - }, + server = createServer(handleRequest) + await new Promise((resolve, reject) => { + server!.listen(OAUTH_CALLBACK_PORT, () => { + log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT }) + resolve() + }) + server!.on("error", reject) }) - - log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT }) } export function waitForCallback(oauthState: string): Promise { @@ -174,7 +177,7 @@ export namespace McpOAuthCallback { export async function stop(): Promise { if (server) { - server.stop() + await new Promise((resolve) => server!.close(() => resolve())) server = undefined log.info("oauth callback server stopped") } diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 5c0140e570..219d6bdec4 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -5,6 +5,7 @@ import { Auth, OAUTH_DUMMY_KEY } from "../auth" import os from "os" import { ProviderTransform } from "@/provider/transform" import { setTimeout as sleep } from "node:timers/promises" +import { createServer } from "http" const log = Log.create({ service: "plugin.codex" }) @@ -240,7 +241,7 @@ interface PendingOAuth { reject: (error: Error) => void } -let oauthServer: ReturnType | undefined +let oauthServer: ReturnType | undefined let pendingOAuth: PendingOAuth | undefined async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> { @@ -248,77 +249,83 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` } } - oauthServer = Bun.serve({ - port: OAUTH_PORT, - fetch(req) { - const url = new URL(req.url) + oauthServer = createServer((req, res) => { + const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`) - if (url.pathname === "/auth/callback") { - const code = url.searchParams.get("code") - const state = url.searchParams.get("state") - const error = url.searchParams.get("error") - const errorDescription = url.searchParams.get("error_description") + if (url.pathname === "/auth/callback") { + const code = url.searchParams.get("code") + const state = url.searchParams.get("state") + const error = url.searchParams.get("error") + const errorDescription = url.searchParams.get("error_description") - if (error) { - const errorMsg = errorDescription || error - pendingOAuth?.reject(new Error(errorMsg)) - pendingOAuth = undefined - return new Response(HTML_ERROR(errorMsg), { - headers: { "Content-Type": "text/html" }, - }) - } - - if (!code) { - const errorMsg = "Missing authorization code" - pendingOAuth?.reject(new Error(errorMsg)) - pendingOAuth = undefined - return new Response(HTML_ERROR(errorMsg), { - status: 400, - headers: { "Content-Type": "text/html" }, - }) - } - - if (!pendingOAuth || state !== pendingOAuth.state) { - const errorMsg = "Invalid state - potential CSRF attack" - pendingOAuth?.reject(new Error(errorMsg)) - pendingOAuth = undefined - return new Response(HTML_ERROR(errorMsg), { - status: 400, - headers: { "Content-Type": "text/html" }, - }) - } - - const current = pendingOAuth + if (error) { + const errorMsg = errorDescription || error + pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth = undefined - - exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce) - .then((tokens) => current.resolve(tokens)) - .catch((err) => current.reject(err)) - - return new Response(HTML_SUCCESS, { - headers: { "Content-Type": "text/html" }, - }) + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(HTML_ERROR(errorMsg)) + return } - if (url.pathname === "/cancel") { - pendingOAuth?.reject(new Error("Login cancelled")) + if (!code) { + const errorMsg = "Missing authorization code" + pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth = undefined - return new Response("Login cancelled", { status: 200 }) + res.writeHead(400, { "Content-Type": "text/html" }) + res.end(HTML_ERROR(errorMsg)) + return } - return new Response("Not found", { status: 404 }) - }, + if (!pendingOAuth || state !== pendingOAuth.state) { + const errorMsg = "Invalid state - potential CSRF attack" + pendingOAuth?.reject(new Error(errorMsg)) + pendingOAuth = undefined + res.writeHead(400, { "Content-Type": "text/html" }) + res.end(HTML_ERROR(errorMsg)) + return + } + + const current = pendingOAuth + pendingOAuth = undefined + + exchangeCodeForTokens(code, `http://localhost:${OAUTH_PORT}/auth/callback`, current.pkce) + .then((tokens) => current.resolve(tokens)) + .catch((err) => current.reject(err)) + + res.writeHead(200, { "Content-Type": "text/html" }) + res.end(HTML_SUCCESS) + return + } + + if (url.pathname === "/cancel") { + pendingOAuth?.reject(new Error("Login cancelled")) + pendingOAuth = undefined + res.writeHead(200) + res.end("Login cancelled") + return + } + + res.writeHead(404) + res.end("Not found") + }) + + await new Promise((resolve, reject) => { + oauthServer!.listen(OAUTH_PORT, () => { + log.info("codex oauth server started", { port: OAUTH_PORT }) + resolve() + }) + oauthServer!.on("error", reject) }) - log.info("codex oauth server started", { port: OAUTH_PORT }) return { port: OAUTH_PORT, redirectUri: `http://localhost:${OAUTH_PORT}/auth/callback` } } function stopOAuthServer() { if (oauthServer) { - oauthServer.stop() + oauthServer.close(() => { + log.info("codex oauth server stopped") + }) oauthServer = undefined - log.info("codex oauth server stopped") } }