From 5b6945401cc02b85edc60afb0e9755f4f82b53f7 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 6 Oct 2026 12:40:11 -0500 Subject: [PATCH] fix(opencode): retain runtime proxy and certificate settings Use isolated Node HTTP and HTTPS agents for authenticated gateway requests, preserving proxy fallbacks, bypasses, and selected certificate trust. Keep harness loopback traffic outside outbound proxies. Co-Authored-By: Paperclip --- doc/connections/AI-CONNECTIONS.md | 5 +- .../opencode/opencode-server-driver.test.ts | 40 ++++++++++ .../opencode/opencode-server-driver.ts | 75 +++++++++++++------ 3 files changed, 97 insertions(+), 23 deletions(-) diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index 151d14ddbad..c9987c6efdd 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -558,7 +558,10 @@ Native OpenCode custom gateways keep the reusable key in the runner process. The harness configuration contains a session-scoped loopback capability, limited to the configured model's Chat Completions endpoint. Streaming and provider error status are preserved; redirects are rejected. Closing or failing the harness -revokes the capability and aborts outstanding requests. This bounds key exposure +revokes the capability and aborts outstanding requests. Upstream requests use +session-local Node HTTP/HTTPS agents with the runtime's HTTP_PROXY, HTTPS_PROXY, +ALL_PROXY fallback, NO_PROXY bypasses, and SSL_CERT_FILE/SSL_CERT_DIR trust. The +harness bypasses outgoing proxies for loopback broker/MCP calls. This bounds key exposure from shell tools reading the configuration; it is not an OS isolation boundary against a process debugger running as the runner user. diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts index e42e3b933e5..60859602964 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts @@ -694,6 +694,46 @@ describe("OpenCodeServerDriver", () => { } }); + it.each(["HTTP_PROXY", "ALL_PROXY"])("uses the runtime's %s and honors NO_PROXY without changing global transport", async proxySetting => { + const requests: string[] = []; + const outgoingProxy = createHttpServer((request, response) => { + requests.push(request.url!); + request.resume(); + response.writeHead(200, { "content-type": "application/json" }).end('{"choices":[]}'); + }); + await new Promise(resolve => outgoingProxy.listen(0, "127.0.0.1", resolve)); + const address = outgoingProxy.address(); + if (!address || typeof address === "string") throw new Error("Missing proxy fixture address"); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-outgoing-proxy-")); + roots.push(root); + const headersAndUrl = async (sessionId: string, noProxy: string) => { + const driver = new OpenCodeServerDriver({ + model: "paperclip/team/model-alias", runtimeDirectory: root, command: fixture, + environment: { PATH: process.env.PATH, PAPERCLIP_AI_PROVIDER_URL: "http://gateway.invalid/v1", PAPERCLIP_AI_PROVIDER_KEY: "fixture-key", [proxySetting]: `http://127.0.0.1:${address.port}`, NO_PROXY: noProxy }, + }); + const session = await driver.openSession({ runId: sessionId, normalizedSessionId: sessionId, workingDirectory: root }); + const config = JSON.parse(await readFile(join(root, sessionId, "config", "opencode", "opencode.json"), "utf8")); + return { session, url: `${config.provider.paperclip.options.baseURL}/chat/completions`, headers: { Authorization: `Bearer ${config.provider.paperclip.options.apiKey}`, "Content-Type": "application/json" } }; + }; + try { + const proxied = await headersAndUrl("proxied", ""); + try { + const response = await fetch(proxied.url, { method: "POST", headers: proxied.headers, body: JSON.stringify({ model: "team/model-alias", messages: [] }) }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ choices: [] }); + expect(requests).toEqual(["http://gateway.invalid/v1/chat/completions"]); + } finally { await proxied.session.close({ reason: "test" }); } + const bypassed = await headersAndUrl("bypassed", "gateway.invalid"); + try { + const response = await fetch(bypassed.url, { method: "POST", headers: bypassed.headers, body: JSON.stringify({ model: "team/model-alias", messages: [] }) }); + expect(response.status).toBe(502); + expect(requests).toHaveLength(1); + } finally { await bypassed.session.close({ reason: "test" }); } + } finally { + await new Promise(resolve => { outgoingProxy.close(() => resolve()); outgoingProxy.closeAllConnections(); }); + } + }); + it("starts an authenticated isolated server, creates a session, streams usage, aborts, and cleans up", async () => { await chmod(fixture, 0o755); const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-driver-")); diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index 6f4c2b047d7..a99079bac8b 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -5,13 +5,15 @@ import { mkdir, mkdtemp, readFile, + readdir, realpath, rm, writeFile, } from "node:fs/promises"; import { createServer } from "node:net"; -import { createServer as createHttpServer } from "node:http"; -import { Readable } from "node:stream"; +import { Agent as HttpAgent, createServer as createHttpServer, request as requestHttp, type IncomingMessage } from "node:http"; +import { Agent as HttpsAgent, request as requestHttps } from "node:https"; +import { getCACertificates } from "node:tls"; import { pipeline } from "node:stream/promises"; import { dirname, join, resolve } from "node:path"; @@ -1974,9 +1976,26 @@ class OpenCodeHarnessSession implements HarnessSession { } /** Retain the reusable gateway key in the runner; the harness gets a session-scoped capability. */ -async function startOpenCodeProviderProxy(baseUrl: string, key: string, model: string) { +async function startOpenCodeProviderProxy(baseUrl: string, key: string, model: string, environment: NodeJS.ProcessEnv) { const upstreamUrl = new URL(`${baseUrl.replace(/\/+$/, "")}/chat/completions`); const token = randomBytes(32).toString("base64url"); + // Agent-local settings keep one runtime's transport configuration out of other sessions. + const proxyEnv = { + HTTP_PROXY: environment.http_proxy ?? environment.HTTP_PROXY ?? environment.all_proxy ?? environment.ALL_PROXY, + HTTPS_PROXY: environment.https_proxy ?? environment.HTTPS_PROXY ?? environment.http_proxy ?? environment.HTTP_PROXY ?? environment.all_proxy ?? environment.ALL_PROXY, + NO_PROXY: environment.no_proxy ?? environment.NO_PROXY, + }; + const ca = getCACertificates("default"); + if (environment.SSL_CERT_FILE) ca.push(await readFile(environment.SSL_CERT_FILE, "utf8")); + if (environment.SSL_CERT_DIR) { + for (const entry of await readdir(environment.SSL_CERT_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) { + const certificate = await readFile(join(environment.SSL_CERT_DIR, entry.name), "utf8"); + if (certificate.includes("-----BEGIN CERTIFICATE-----")) ca.push(certificate); + } + } + } + const agent = upstreamUrl.protocol === "https:" ? new HttpsAgent({ proxyEnv, ca }) : new HttpAgent({ proxyEnv }); const controllers = new Set(); const server = createHttpServer((request, response) => { const controller = new AbortController(); @@ -2009,34 +2028,44 @@ async function startOpenCodeProviderProxy(baseUrl: string, key: string, model: s response.writeHead(400).end(); return; } - const upstream = await globalThis.fetch(upstreamUrl, { - method: "POST", - headers: { "Content-Type": "application/json", ...(key ? { Authorization: `Bearer ${key}` } : {}) }, - body, - redirect: "error", - signal: AbortSignal.any([controller.signal, AbortSignal.timeout(600_000)]), + const upstream = await new Promise((resolve, reject) => { + const outgoing = (upstreamUrl.protocol === "https:" ? requestHttps : requestHttp)(upstreamUrl, { + method: "POST", + agent, + headers: { "Content-Type": "application/json", "Content-Length": body.length, ...(key ? { Authorization: `Bearer ${key}` } : {}) }, + signal: AbortSignal.any([controller.signal, AbortSignal.timeout(600_000)]), + }, resolve); + outgoing.once("error", reject); + outgoing.end(body); }); - // Do not expose upstream credentials via redirects or arbitrary response headers. - const headers: Record = {}; - for (const name of ["content-type", "cache-control", "retry-after"]) { - const value = upstream.headers.get(name); - if (value) headers[name] = value; + // Never forward authentication to a redirect destination. + const status = upstream.statusCode ?? 502; + if (status >= 300 && status < 400) { + upstream.destroy(); + throw new Error("Provider redirects are not supported"); } - response.writeHead(upstream.status, headers); - if (upstream.body) await pipeline(Readable.fromWeb(upstream.body as Parameters[0]), response); - else response.end(); + const headers: Record = {}; + for (const name of ["content-type", "content-encoding", "cache-control", "retry-after"]) { + const value = upstream.headers[name]; + if (typeof value === "string") headers[name] = value; + } + response.writeHead(status, headers); + await pipeline(upstream, response); })().catch(() => { if (!response.headersSent) response.writeHead(502).end("Provider request failed"); else response.destroy(); }).finally(() => controllers.delete(controller)); }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); }); - }); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); }); + }); + } catch (error) { agent.destroy(); throw error; } const address = server.address(); if (!address || typeof address === "string") { server.close(); + agent.destroy(); throw new Error("Could not bind OpenCode provider proxy"); } return { @@ -2044,6 +2073,7 @@ async function startOpenCodeProviderProxy(baseUrl: string, key: string, model: s token, close: async () => { for (const controller of controllers) controller.abort(); + agent.destroy(); await new Promise((resolve) => { server.close(() => resolve()); server.closeAllConnections(); }); }, }; @@ -2123,7 +2153,7 @@ async function startRuntime(input: { const [modelProvider, ...modelIdParts] = input.options.model.split("/"); const providerModelId = modelIdParts.join("/"); const providerProxy = modelProvider === "paperclip" && input.options.environment?.PAPERCLIP_AI_PROVIDER_URL - ? await startOpenCodeProviderProxy(input.options.environment.PAPERCLIP_AI_PROVIDER_URL, input.options.environment.PAPERCLIP_AI_PROVIDER_KEY ?? "", providerModelId).catch(async error => { + ? await startOpenCodeProviderProxy(input.options.environment.PAPERCLIP_AI_PROVIDER_URL, input.options.environment.PAPERCLIP_AI_PROVIDER_KEY ?? "", providerModelId, input.options.environment).catch(async error => { await bridge.close().catch(() => {}); await rm(isolatedHome, { recursive: true, force: true }).catch(() => {}); throw error; @@ -2210,6 +2240,7 @@ async function startRuntime(input: { OPENCODE_DISABLE_PROJECT_CONFIG: "true", OPENCODE_SERVER_USERNAME: username, OPENCODE_SERVER_PASSWORD: password, + ...(providerProxy ? { NO_PROXY: [input.options.environment?.no_proxy ?? input.options.environment?.NO_PROXY, "127.0.0.1", "localhost"].filter(Boolean).join(",") } : {}), }, ); const isolateProcessGroup = input.options.isolateProcessGroup ?? true;