From 7fed41a66d887c4c6ecb3cce2743c4cd3ce47703 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 6 Oct 2026 12:22:50 -0500 Subject: [PATCH] fix(connections): protect native gateway keys and allow personal provider setup Keep custom OpenCode authentication in a session-scoped selected-model proxy rather than an agent-readable config. Permit ordinary members to create native personal accounts before an agent exists. Co-Authored-By: Paperclip --- doc/connections/AI-CONNECTIONS.md | 8 + .../opencode/opencode-server-driver.test.ts | 60 ++- .../opencode/opencode-server-driver.ts | 358 +++++++++++------- .../ai-connections/AiProviderSetup.test.tsx | 16 + .../ai-connections/AiProviderSetup.tsx | 2 +- 5 files changed, 310 insertions(+), 134 deletions(-) diff --git a/doc/connections/AI-CONNECTIONS.md b/doc/connections/AI-CONNECTIONS.md index 21910ad8bea..151d14ddbad 100644 --- a/doc/connections/AI-CONNECTIONS.md +++ b/doc/connections/AI-CONNECTIONS.md @@ -554,6 +554,14 @@ personal defaults. Reconnect replaces credentials and preserves destination; changing destination requires a separate connection. Credentials are never submitted to a new URL as part of reconnect. +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 +from shell tools reading the configuration; it is not an OS isolation boundary +against a process debugger running as the runner user. + Only fixed official provider endpoints receive control-plane key checks. Custom endpoints and Bedrock are exercised by the selected harness in the selected execution environment, through **Run test**. Saving a custom connection records 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 71f01d7ca47..e42e3b933e5 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 @@ -12,6 +12,9 @@ import { writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; +import { createServer as createHttpServer } from "node:http"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { join, resolve } from "node:path"; import { afterAll, afterEach, describe, expect, it } from "vitest"; @@ -628,14 +631,69 @@ describe("OpenCodeServerDriver", () => { const configPath = join(root, "routing", "config", "opencode", "opencode.json"); expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ model: "paperclip/team/model-alias", small_model: "paperclip/team/model-alias", plugin: [], - provider: { paperclip: { npm: "@ai-sdk/openai-compatible", options: { baseURL: "https://gateway.example/v1", apiKey: "selected-gateway-key" }, models: { "team/model-alias": { name: "team/model-alias" } } } }, + provider: { paperclip: { npm: "@ai-sdk/openai-compatible", options: { baseURL: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/v1$/), apiKey: expect.any(String) }, models: { "team/model-alias": { name: "team/model-alias" } } } }, }); expect((await stat(configPath)).mode & 0o777).toBe(0o600); + const shell = await promisify(execFile)("sh", ["-c", 'cat "$1"', "sh", configPath]); + expect(shell.stdout).not.toContain("selected-gateway-key"); + const environment = JSON.parse(await readFile(join(root, "routing", "data", "fake-environment.json"), "utf8")); + expect(environment.keys).not.toContain("PAPERCLIP_AI_PROVIDER_KEY"); } finally { await session.close({ reason: "test" }); } }); + it.each(["gateway-key", ""])("forwards selected-model streams without exposing the reusable key and revokes the proxy on close (%s)", async key => { + const received: Array<{ path: string; authorization?: string; body: unknown }> = []; + const upstream = createHttpServer((request, response) => { + void (async () => { + const chunks = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + received.push({ path: request.url!, authorization: request.headers.authorization, body }); + response.writeHead(200, { "content-type": "text/event-stream", "x-provider-private-header": "private" }); + response.write('data: {"choices":[]}\n\n'); + if (body.messages[0].content !== "hold") response.end('data: [DONE]\n\n'); + })().catch(() => response.destroy()); + }); + await new Promise(resolve => upstream.listen(0, "127.0.0.1", resolve)); + const address = upstream.address(); + if (!address || typeof address === "string") throw new Error("Missing fixture address"); + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-proxy-")); + roots.push(root); + let session: Awaited> | undefined; + try { + const driver = new OpenCodeServerDriver({ + model: "paperclip/team/model-alias", runtimeDirectory: root, command: fixture, + environment: { PATH: process.env.PATH, PAPERCLIP_AI_PROVIDER_URL: `http://127.0.0.1:${address.port}/custom/v1`, PAPERCLIP_AI_PROVIDER_KEY: key }, + }); + session = await driver.openSession({ runId: "proxy", normalizedSessionId: "proxy", workingDirectory: root }); + const config = JSON.parse(await readFile(join(root, "proxy", "config", "opencode", "opencode.json"), "utf8")); + const { baseURL, apiKey } = config.provider.paperclip.options; + expect(apiKey).not.toBe(key); + const url = `${baseURL}/chat/completions`; + const headers = { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }; + const payload = { model: "team/model-alias", stream: true, messages: [{ role: "user", content: "test" }] }; + expect((await fetch(url, { method: "POST", body: JSON.stringify(payload) })).status).toBe(401); + expect((await fetch(`${baseURL}/models`, { headers })).status).toBe(404); + expect((await fetch(url, { method: "POST", headers, body: JSON.stringify({ ...payload, model: "other" }) })).status).toBe(400); + expect(received).toHaveLength(0); + const result = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) }); + expect(result.headers.get("x-provider-private-header")).toBeNull(); + expect(await result.text()).toBe('data: {"choices":[]}\n\ndata: [DONE]\n\n'); + expect(received).toEqual([{ path: "/custom/v1/chat/completions", authorization: key ? `Bearer ${key}` : undefined, body: payload }]); + const pending = await fetch(url, { method: "POST", headers, body: JSON.stringify({ ...payload, messages: [{ role: "user", content: "hold" }] }) }); + const pendingText = pending.text().then(() => "unexpected completion", () => "aborted"); + await session.close({ reason: "test" }); + session = undefined; + expect(await pendingText).toBe("aborted"); + await expect(fetch(url, { method: "POST", headers, body: JSON.stringify(payload) })).rejects.toThrow(); + } finally { + await session?.close({ reason: "test" }); + await new Promise(resolve => { upstream.close(() => resolve()); upstream.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 9602d7060ae..6f4c2b047d7 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -10,6 +10,9 @@ import { writeFile, } from "node:fs/promises"; import { createServer } from "node:net"; +import { createServer as createHttpServer } from "node:http"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; import { dirname, join, resolve } from "node:path"; import { @@ -1970,6 +1973,82 @@ 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) { + const upstreamUrl = new URL(`${baseUrl.replace(/\/+$/, "")}/chat/completions`); + const token = randomBytes(32).toString("base64url"); + const controllers = new Set(); + const server = createHttpServer((request, response) => { + const controller = new AbortController(); + controllers.add(controller); + response.once("close", () => controller.abort()); + void (async () => { + if (request.headers.authorization !== `Bearer ${token}`) { + response.writeHead(401).end(); + return; + } + if (request.method !== "POST" || request.url !== "/v1/chat/completions") { + response.writeHead(404).end(); + return; + } + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of request) { + bytes += chunk.length; + if (bytes > 16 * 1024 * 1024) { + response.writeHead(413).end(); + return; + } + chunks.push(Buffer.from(chunk)); + } + const body = Buffer.concat(chunks); + let payload: Record; + try { payload = JSON.parse(body.toString("utf8")); } + catch { response.writeHead(400).end(); return; } + if (!payload || typeof payload !== "object" || payload.model !== model) { + 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)]), + }); + // 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; + } + response.writeHead(upstream.status, headers); + if (upstream.body) await pipeline(Readable.fromWeb(upstream.body as Parameters[0]), response); + else response.end(); + })().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(); }); + }); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("Could not bind OpenCode provider proxy"); + } + return { + baseURL: `http://127.0.0.1:${address.port}/v1`, + token, + close: async () => { + for (const controller of controllers) controller.abort(); + await new Promise((resolve) => { server.close(() => resolve()); server.closeAllConnections(); }); + }, + }; +} + async function startRuntime(input: { options: OpenCodeServerDriverOptions; root: string; @@ -2043,135 +2122,148 @@ async function startRuntime(input: { if (instructionRoot) externalDirectories[`${instructionRoot}/**`] = "allow"; const [modelProvider, ...modelIdParts] = input.options.model.split("/"); const providerModelId = modelIdParts.join("/"); - const config = { - $schema: "https://opencode.ai/config.json", - model: input.options.model, - small_model: input.options.model, - share: "disabled", - // The configured entry is already composed exactly once into the session - // system prompt; siblings remain available through the read-only root. - instructions: [], - plugin: [], - // OpenCode's bundled models.dev snapshot can lag behind OpenRouter's live - // catalog. Bind the already-qualified exact model slug into the built-in - // provider instead of silently falling back or rejecting a newer model. - provider: { - [modelProvider!]: { - ...(modelProvider === "paperclip" && input.options.environment?.PAPERCLIP_AI_PROVIDER_URL ? { - npm: "@ai-sdk/openai-compatible", - name: "Paperclip connection", - options: { - baseURL: input.options.environment.PAPERCLIP_AI_PROVIDER_URL, - apiKey: input.options.environment.PAPERCLIP_AI_PROVIDER_KEY ?? "", + 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 bridge.close().catch(() => {}); + await rm(isolatedHome, { recursive: true, force: true }).catch(() => {}); + throw error; + }) + : null; + if (providerProxy) { + sensitiveValues.push(providerProxy.token); + input.trace?.addSensitiveValues([providerProxy.token]); + } + let child: ChildProcess | undefined; + try { + const config = { + $schema: "https://opencode.ai/config.json", + model: input.options.model, + small_model: input.options.model, + share: "disabled", + // The configured entry is already composed exactly once into the session + // system prompt; siblings remain available through the read-only root. + instructions: [], + plugin: [], + // OpenCode's bundled models.dev snapshot can lag behind OpenRouter's live + // catalog. Bind the already-qualified exact model slug into the built-in + // provider instead of silently falling back or rejecting a newer model. + provider: { + [modelProvider!]: { + ...(providerProxy ? { + npm: "@ai-sdk/openai-compatible", + name: "Paperclip connection", + options: { + baseURL: providerProxy.baseURL, + apiKey: providerProxy.token, + }, + } : {}), + models: { + [providerModelId]: { name: providerModelId }, }, - } : {}), - models: { - [providerModelId]: { name: providerModelId }, }, }, - }, - tools: { - question: true, - }, - permission: { - "*": input.options.permissionMode ?? "allow", - question: "allow", - "paperclip_*": "allow", - "mcp__paperclip__*": "allow", - external_directory: externalDirectories, - }, - mcp: { - paperclip: { - type: "remote", - url: bridge.url, - enabled: true, - oauth: false, - headers: { Authorization: `Bearer ${bridge.secret}` }, - timeout: 30_000, + tools: { + question: true, }, - ...(assignedMcp - ? { - [assignedMcp.name]: { - type: "remote", - url: assignedMcp.url, - enabled: true, - oauth: false, - headers: { Authorization: `Bearer ${assignedMcp.token}` }, - timeout: 30_000, - }, - } - : {}), - }, - }; - await writeFile( - join(configHome, "opencode", "opencode.json"), - `${JSON.stringify(config, null, 2)}\n`, - { mode: 0o600 }, - ); - const environment = sanitizedEnvironment( - input.options.environment ?? process.env, - { - HOME: isolatedHome, - XDG_CONFIG_HOME: configHome, - XDG_DATA_HOME: dataHome, - XDG_CACHE_HOME: cacheHome, - OPENCODE_DISABLE_PROJECT_CONFIG: "true", - OPENCODE_SERVER_USERNAME: username, - OPENCODE_SERVER_PASSWORD: password, - }, - ); - const isolateProcessGroup = input.options.isolateProcessGroup ?? true; - const stdio: Array<"ignore" | "pipe" | number> = ["ignore", "ignore", "pipe"]; - if (input.options.commandFd !== undefined) { - while (stdio.length <= input.options.commandFd) stdio.push("ignore"); - stdio[input.options.commandFd] = input.options.commandFd; - } - input.options.commandLifecycle?.beforeSpawn(); - const child = spawn( - input.options.command ?? "opencode", - ["serve", "--hostname", "127.0.0.1", "--port", String(port)], - { - cwd: input.cwd, - env: environment, - stdio, - detached: globalThis.process.platform !== "win32" && isolateProcessGroup, - }, - ); - if (child.pid !== undefined) { - try { - input.options.commandLifecycle?.afterSpawn(); - } catch (error) { - child.kill("SIGKILL"); - throw error; + permission: { + "*": input.options.permissionMode ?? "allow", + question: "allow", + "paperclip_*": "allow", + "mcp__paperclip__*": "allow", + external_directory: externalDirectories, + }, + mcp: { + paperclip: { + type: "remote", + url: bridge.url, + enabled: true, + oauth: false, + headers: { Authorization: `Bearer ${bridge.secret}` }, + timeout: 30_000, + }, + ...(assignedMcp + ? { + [assignedMcp.name]: { + type: "remote", + url: assignedMcp.url, + enabled: true, + oauth: false, + headers: { Authorization: `Bearer ${assignedMcp.token}` }, + timeout: 30_000, + }, + } + : {}), + }, + }; + await writeFile( + join(configHome, "opencode", "opencode.json"), + `${JSON.stringify(config, null, 2)}\n`, + { mode: 0o600 }, + ); + const environment = sanitizedEnvironment( + input.options.environment ?? process.env, + { + HOME: isolatedHome, + XDG_CONFIG_HOME: configHome, + XDG_DATA_HOME: dataHome, + XDG_CACHE_HOME: cacheHome, + OPENCODE_DISABLE_PROJECT_CONFIG: "true", + OPENCODE_SERVER_USERNAME: username, + OPENCODE_SERVER_PASSWORD: password, + }, + ); + const isolateProcessGroup = input.options.isolateProcessGroup ?? true; + const stdio: Array<"ignore" | "pipe" | number> = ["ignore", "ignore", "pipe"]; + if (input.options.commandFd !== undefined) { + while (stdio.length <= input.options.commandFd) stdio.push("ignore"); + stdio[input.options.commandFd] = input.options.commandFd; } - } - let diagnostics = ""; - child.stderr?.on("data", (chunk) => { - const raw = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); - const redactedDiagnostic = redact(raw.toString("utf8"), sensitiveValues); - diagnostics = `${diagnostics}${redactedDiagnostic}`.slice(-8_192); - const frameId = input.trace?.frame({ - direction: "provider_stderr", - raw, - transport: "process_stderr", - nativeMethod: "opencode serve stderr", - }); - if (frameId) { - input.trace?.interpretation({ - frameId, - stage: "typescript_opencode_process_transport", - ruleId: "opencode.stderr", - disposition: "operator_only", - reason: - "OpenCode stderr is retained only in the restricted trace sidecar", + input.options.commandLifecycle?.beforeSpawn(); + child = spawn( + input.options.command ?? "opencode", + ["serve", "--hostname", "127.0.0.1", "--port", String(port)], + { + cwd: input.cwd, + env: environment, + stdio, + detached: globalThis.process.platform !== "win32" && isolateProcessGroup, + }, + ); + if (child.pid !== undefined) { + try { + input.options.commandLifecycle?.afterSpawn(); + } catch (error) { + child.kill("SIGKILL"); + throw error; + } + } + let diagnostics = ""; + child.stderr?.on("data", (chunk) => { + const raw = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + const redactedDiagnostic = redact(raw.toString("utf8"), sensitiveValues); + diagnostics = `${diagnostics}${redactedDiagnostic}`.slice(-8_192); + const frameId = input.trace?.frame({ + direction: "provider_stderr", + raw, + transport: "process_stderr", + nativeMethod: "opencode serve stderr", }); - } - input.options.onDiagnostic?.(redactedDiagnostic); - }); - try { + if (frameId) { + input.trace?.interpretation({ + frameId, + stage: "typescript_opencode_process_transport", + ruleId: "opencode.stderr", + disposition: "operator_only", + reason: + "OpenCode stderr is retained only in the restricted trace sidecar", + }); + } + input.options.onDiagnostic?.(redactedDiagnostic); + }); + const providerChild = child; await new Promise((resolve, reject) => { - child.once("spawn", resolve); - child.once("error", reject); + providerChild.once("spawn", resolve); + providerChild.once("error", reject); }); if (child.pid) await input.options.onSpawn?.({ @@ -2213,24 +2305,25 @@ async function startRuntime(input: { trace: input.trace, sensitiveValues, close: async (closeInput = {}) => { + await providerProxy?.close(); await bridge.close().catch(() => {}); - if (child.exitCode === null && child.signalCode === null && child.pid) { + if (providerChild.exitCode === null && providerChild.signalCode === null && providerChild.pid) { try { if (globalThis.process.platform === "win32" || !isolateProcessGroup) - child.kill("SIGTERM"); - else globalThis.process.kill(-child.pid, "SIGTERM"); + providerChild.kill("SIGTERM"); + else globalThis.process.kill(-providerChild.pid, "SIGTERM"); } catch { - child.kill("SIGTERM"); + providerChild.kill("SIGTERM"); } } - await waitForExit(child, 2_000); - if (child.exitCode === null && child.signalCode === null && child.pid) { + await waitForExit(providerChild, 2_000); + if (providerChild.exitCode === null && providerChild.signalCode === null && providerChild.pid) { try { if (globalThis.process.platform === "win32" || !isolateProcessGroup) - child.kill("SIGKILL"); - else globalThis.process.kill(-child.pid, "SIGKILL"); + providerChild.kill("SIGKILL"); + else globalThis.process.kill(-providerChild.pid, "SIGKILL"); } catch { - child.kill("SIGKILL"); + providerChild.kill("SIGKILL"); } } await rm(join(configHome, "opencode", "opencode.json"), { @@ -2245,8 +2338,9 @@ async function startRuntime(input: { }, }; } catch (error) { + await providerProxy?.close(); await bridge.close().catch(() => {}); - child.kill("SIGKILL"); + child?.kill("SIGKILL"); await rm(join(configHome, "opencode", "opencode.json"), { force: true, }).catch(() => undefined); diff --git a/ui/src/components/ai-connections/AiProviderSetup.test.tsx b/ui/src/components/ai-connections/AiProviderSetup.test.tsx index 96e04233935..e1f8851a301 100644 --- a/ui/src/components/ai-connections/AiProviderSetup.test.tsx +++ b/ui/src/components/ai-connections/AiProviderSetup.test.tsx @@ -122,3 +122,19 @@ it("allows an ordinary member to save a personal gateway before the agent exists await act(async () => root.unmount()); client.clear(); } }); + + +it.each(["google", "openai", "anthropic", "xai"] as const)("allows an ordinary member to connect a personal %s account before an agent exists", async provider => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + client.setQueryData(["ai-connections", "company", undefined], { canManageConnections: false, connections: [] }); + client.setQueryData(["agents", "company", "provider-access"], []); + const container = document.createElement("div"); + const root = createRoot(container); + credentialProps = undefined; + try { + await act(async () => root.render( {}} onComplete={() => {}} />)); + expect(credentialProps).toMatchObject({ provider, ownership: "personal", allAgents: false, agentIds: [], disabled: false }); + } finally { + await act(async () => root.unmount()); client.clear(); + } +}); diff --git a/ui/src/components/ai-connections/AiProviderSetup.tsx b/ui/src/components/ai-connections/AiProviderSetup.tsx index 13de861d187..0bafcf0c3aa 100644 --- a/ui/src/components/ai-connections/AiProviderSetup.tsx +++ b/ui/src/components/ai-connections/AiProviderSetup.tsx @@ -312,7 +312,7 @@ export function AiProviderSetup({ environmentId={environmentId} onCancel={cancel} defaults={accessDefaults} - disabled={!reconnect && !allAgents && agentIds.size === 0} + disabled={!reconnect && ownership === "shared" && !allAgents && agentIds.size === 0} onComplete={complete} /> ) : (