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;