diff --git a/packages/opencode-config/plugin/lib/request.ts b/packages/opencode-config/plugin/lib/request.ts index 90df50fe..5025a501 100644 --- a/packages/opencode-config/plugin/lib/request.ts +++ b/packages/opencode-config/plugin/lib/request.ts @@ -1,3 +1,7 @@ +import http from "http" +import https from "https" +import { Readable } from "stream" + export type PluginEvent = { type: string properties?: Record @@ -16,7 +20,8 @@ export function getCodeNomadConfig(): CodeNomadConfig { } export function createCodeNomadRequester(config: CodeNomadConfig) { - const baseUrl = config.baseUrl.replace(/\/+$/, "") + const rawBaseUrl = (config.baseUrl ?? "").trim() + const baseUrl = rawBaseUrl.replace(/\/+$/, "") const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin` const authorization = buildInstanceAuthorizationHeader() @@ -42,10 +47,10 @@ export function createCodeNomadRequester(config: CodeNomadConfig) { const hasBody = init?.body !== undefined const headers = buildHeaders(init?.headers, hasBody) - return fetch(url, { - ...init, - headers, - }) + // The CodeNomad plugin only talks to the local CodeNomad server. + // Use a single request implementation that tolerates custom/self-signed certs + // without disabling TLS verification for the whole Node process. + return nodeFetch(url, { ...init, headers }, { rejectUnauthorized: false }) } const requestJson = async (path: string, init?: RequestInit): Promise => { @@ -87,6 +92,91 @@ export function createCodeNomadRequester(config: CodeNomadConfig) { } } +async function nodeFetch( + url: string, + init: RequestInit & { headers?: Record }, + tls: { rejectUnauthorized: boolean }, +): Promise { + const parsed = new URL(url) + const isHttps = parsed.protocol === "https:" + const requestFn = isHttps ? https.request : http.request + + const method = (init.method ?? "GET").toUpperCase() + const headers = init.headers ?? {} + const body = init.body + + return await new Promise((resolve, reject) => { + const req = requestFn( + { + protocol: parsed.protocol, + hostname: parsed.hostname, + port: parsed.port ? Number(parsed.port) : undefined, + path: `${parsed.pathname}${parsed.search}`, + method, + headers, + ...(isHttps ? { rejectUnauthorized: tls.rejectUnauthorized } : {}), + }, + (res) => { + const responseHeaders = new Headers() + for (const [key, value] of Object.entries(res.headers)) { + if (value === undefined) continue + if (Array.isArray(value)) { + responseHeaders.set(key, value.join(", ")) + } else { + responseHeaders.set(key, String(value)) + } + } + + // Convert Node stream -> Web ReadableStream for Response. + const webBody = Readable.toWeb(res) as unknown as ReadableStream + resolve(new Response(webBody, { status: res.statusCode ?? 0, headers: responseHeaders })) + }, + ) + + const signal = init.signal + const abort = () => { + const err = new Error("Request aborted") + ;(err as any).name = "AbortError" + req.destroy(err) + reject(err) + } + + if (signal) { + if (signal.aborted) { + abort() + return + } + signal.addEventListener("abort", abort, { once: true }) + req.once("close", () => signal.removeEventListener("abort", abort)) + } + + req.once("error", reject) + + if (body === undefined || body === null) { + req.end() + return + } + + if (typeof body === "string") { + req.end(body) + return + } + + if (body instanceof Uint8Array) { + req.end(Buffer.from(body)) + return + } + + if (body instanceof ArrayBuffer) { + req.end(Buffer.from(new Uint8Array(body))) + return + } + + // Fallback for less common BodyInit types. + req.end(String(body)) + }) +} + function requireEnv(key: string): string { const value = process.env[key] if (!value || !value.trim()) {