diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 99a9a81ab..2fb9038b0 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -23,7 +23,7 @@ export const AcpCommand = cmd({ process.env.OPENCODE_CLIENT = "acp" await bootstrap(process.cwd(), async () => { const opts = await resolveNetworkOptions(args) - const server = Server.listen(opts) + const server = await Server.listen(opts) const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}`, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f86d8d32a..fdb4dc107 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -90,6 +90,11 @@ export namespace Config { } export async function installDependencies(dir: string, input?: InstallInput) { + if (process.env.OPENCODE_DISABLE_CONFIG_DEP_INSTALL === "1") { + log.info("skipping dependency install", { dir, reason: "disabled_by_env" }) + return + } + if (!(await needsInstall(dir))) return await using _ = await Flock.acquire(`config-install:${Filesystem.resolve(dir)}`, { diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index b05dd8625..f2afc5979 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -50,7 +50,10 @@ export namespace Plugin { export class Service extends ServiceMap.Service()("@opencode/Plugin") {} // Built-in plugins that are directly imported (not installed from npm) - const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin] + const INTERNAL_PLUGINS: PluginInstance[] = + process.env.OPENCODE_ENABLE_INTERNAL_AUTH_PLUGINS === "1" + ? [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin] + : [] function isServerPlugin(value: unknown): value is PluginInstance { return typeof value === "function" @@ -128,7 +131,9 @@ export namespace Plugin { get serverUrl(): URL { return Server.url ?? new URL("http://localhost:4096") }, - $: Bun.$, + $: ((..._args: unknown[]) => { + throw new Error("Bun shell is unavailable in the Node ACP build") + }) as PluginInput["$"], } for (const plugin of INTERNAL_PLUGINS) { diff --git a/packages/opencode/src/server/instance.ts b/packages/opencode/src/server/instance.ts index 4bb6efaf9..fc634f5c2 100644 --- a/packages/opencode/src/server/instance.ts +++ b/packages/opencode/src/server/instance.ts @@ -18,7 +18,6 @@ import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { ProjectRoutes } from "./routes/project" import { SessionRoutes } from "./routes/session" -import { PtyRoutes } from "./routes/pty" import { McpRoutes } from "./routes/mcp" import { FileRoutes } from "./routes/file" import { ConfigRoutes } from "./routes/config" @@ -44,7 +43,6 @@ export const InstanceRoutes = (app?: Hono) => (app ?? new Hono()) .onError(errorHandler(log)) .route("/project", ProjectRoutes()) - .route("/pty", PtyRoutes()) .route("/config", ConfigRoutes()) .route("/experimental", ExperimentalRoutes()) .route("/session", SessionRoutes()) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index ec245ed59..8df8fe679 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -9,7 +9,6 @@ import { Auth } from "../auth" import { Flag } from "../flag/flag" import { ProviderID } from "../provider/schema" import { WorkspaceRouterMiddleware } from "./router" -import { websocket } from "hono/bun" import { errors } from "./error" import { GlobalRoutes } from "./routes/global" import { MDNS } from "./mdns" @@ -17,6 +16,9 @@ import { lazy } from "@/util/lazy" import { errorHandler } from "./middleware" import { InstanceRoutes } from "./instance" import { initProjectors } from "./projectors" +import { createServer, type IncomingMessage, type ServerResponse } from "node:http" +import type { AddressInfo } from "node:net" +import { Readable } from "node:stream" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -264,7 +266,106 @@ export namespace Server { /** @deprecated do not use this dumb shit */ export let url: URL - export function listen(opts: { + type NodeServeHandle = { + hostname: string + port: number + stop: (closeActiveConnections?: boolean) => Promise + } + + function toRequestUrl(req: IncomingMessage, fallbackHost: string, fallbackPort: number) { + const host = req.headers.host ?? `${fallbackHost}:${fallbackPort}` + return new URL(req.url ?? "/", `http://${host}`) + } + + function toHeaders(req: IncomingMessage) { + const headers = new Headers() + for (const [key, value] of Object.entries(req.headers)) { + if (Array.isArray(value)) { + for (const item of value) headers.append(key, item) + continue + } + if (typeof value === "string") headers.set(key, value) + } + return headers + } + + async function writeResponse(res: ServerResponse, response: Response) { + res.statusCode = response.status + res.statusMessage = response.statusText + response.headers.forEach((value, key) => { + res.setHeader(key, value) + }) + + if (!response.body) { + res.end() + return + } + + await new Promise((resolve, reject) => { + const body = Readable.fromWeb(response.body as globalThis.ReadableStream) + body.on("error", reject) + res.on("error", reject) + res.on("close", resolve) + body.pipe(res) + }) + } + + async function serveNode(opts: { + hostname: string + port: number + fetch: (request: Request) => Response | Promise + }): Promise { + const server = createServer(async (req, res) => { + try { + const request = new Request(toRequestUrl(req, opts.hostname, opts.port), { + method: req.method, + headers: toHeaders(req), + body: + req.method && req.method !== "GET" && req.method !== "HEAD" + ? (Readable.toWeb(req) as globalThis.ReadableStream) + : undefined, + duplex: "half", + }) + const response = await opts.fetch(request) + await writeResponse(res, response) + } catch (error) { + log.error("node server request failed", { error }) + if (!res.headersSent) { + res.statusCode = 500 + res.setHeader("content-type", "text/plain; charset=utf-8") + } + res.end("Internal Server Error") + } + }) + + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(opts.port, opts.hostname, () => { + server.off("error", reject) + resolve() + }) + }) + + const address = server.address() + if (!address || typeof address === "string") { + throw new Error("Failed to resolve server address") + } + + return { + hostname: opts.hostname, + port: (address as AddressInfo).port, + stop() { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error) + else resolve() + }) + }) + }, + } + } + + export async function listen(opts: { port: number hostname: string mdns?: boolean @@ -273,20 +374,18 @@ export namespace Server { }) { url = new URL(`http://${opts.hostname}:${opts.port}`) const app = ControlPlaneRoutes({ cors: opts.cors }) - const args = { - hostname: opts.hostname, - idleTimeout: 0, - fetch: app.fetch, - websocket: websocket, - } as const - const tryServe = (port: number) => { + const tryServe = async (port: number) => { try { - return Bun.serve({ ...args, port }) + return await serveNode({ + hostname: opts.hostname, + port, + fetch: app.fetch, + }) } catch { return undefined } } - const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port) + const server = opts.port === 0 ? ((await tryServe(4096)) ?? (await tryServe(0))) : await tryServe(opts.port) if (!server) throw new Error(`Failed to start server on port ${opts.port}`) const shouldPublishMDNS = @@ -296,7 +395,7 @@ export namespace Server { opts.hostname !== "localhost" && opts.hostname !== "::1" if (shouldPublishMDNS) { - MDNS.publish(server.port!, opts.mdnsDomain) + MDNS.publish(server.port, opts.mdnsDomain) } else if (opts.mdns) { log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") } @@ -307,6 +406,7 @@ export namespace Server { return originalStop(closeActiveConnections) } + url = new URL(`http://${opts.hostname}:${server.port}`) return server } }