From a2d014d2efd4423284b4055d30773353e1d3570f Mon Sep 17 00:00:00 2001 From: nickyleach <331803+nickyleach@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:09:36 +0000 Subject: [PATCH] fix(server): stage a token-free API helper and drop curl from the access note The generated Paperclip API access note taught agents to put the run bearer on a curl command line, which any process running as the same user can read. Every run now gets a paperclip-api helper staged onto PATH; the helper reads the token from its own environment and sets the Authorization header itself, so the token never becomes a command-line argument. The helper rejects an absolute URL, a scheme-relative path, and a path containing a backslash, and it verifies the built request resolves to the configured API origin before sending it. Staging now runs for every run, not only a managed-GitHub run, so a host-GitHub run still gets the helper. The note renders only when the helper was actually staged, checked through the one env field the staging step itself sets, so the note and the staging step cannot drift apart. Co-authored-by: Paperclip --- .../src/acpx-engine/execute.test.ts | 44 ++++-- .../adapter-utils/src/acpx-engine/execute.ts | 24 +++- .../src/execution-target.test.ts | 30 ++++ .../adapter-utils/src/execution-target.ts | 31 +++- .../src/paperclip-api-launcher.test.ts | 132 ++++++++++++++++++ .../src/paperclip-api-launcher.ts | 103 ++++++++++++++ server/src/services/heartbeat.ts | 31 ++-- 7 files changed, 362 insertions(+), 33 deletions(-) create mode 100644 packages/adapter-utils/src/paperclip-api-launcher.test.ts create mode 100644 packages/adapter-utils/src/paperclip-api-launcher.ts diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 042e296f556..d6e549880b8 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -561,7 +561,7 @@ describe("shared ACPX engine runtime behavior", () => { it("includes Paperclip env and API access notes in the ACPX prompt without leaking the token", async () => { const { meta } = await runExecutor( - { agent: "custom", agentCommand: "node ./fake-acp.js" }, + { agent: "custom", agentCommand: "node ./fake-acp.js", env: { PAPERCLIP_GITHUB_LAUNCHER_DIR: "/tmp/paperclip-op-launchers" } }, { authToken: "runtime-secret-token", context: { @@ -582,17 +582,41 @@ describe("shared ACPX engine runtime behavior", () => { expect(prompt).toContain("PAPERCLIP_API_KEY"); expect(prompt).toContain("PAPERCLIP_WAKE_PAYLOAD_JSON"); expect(prompt).toContain("Paperclip API access note:"); - expect(prompt).toContain('PAPERCLIP_API_BASE="${PAPERCLIP_API_URL%/}"; PAPERCLIP_API_BASE="${PAPERCLIP_API_BASE%/api}"'); - expect(prompt).toContain("$PAPERCLIP_API_BASE/api/agents/me"); - expect(prompt).toContain("$PAPERCLIP_API_BASE/api/issues/$PAPERCLIP_TASK_ID"); - expect(prompt).toContain("X-Paperclip-Run-Id"); - expect(prompt).not.toContain("$PAPERCLIP_API_URL/api/"); - expect(prompt).not.toContain("/api/issues/{id}"); + expect(prompt).toContain("paperclip-api GET /api/agents/me"); + expect(prompt).toContain("paperclip-api POST /api/issues/$PAPERCLIP_TASK_ID/comments"); + expect(prompt).not.toContain("curl -"); + expect(prompt).not.toContain("Authorization: Bearer $PAPERCLIP_API_KEY"); expect(prompt).not.toContain("-d '{...}'"); expect(prompt).not.toContain("runtime-secret-token"); expect(promptMetrics?.runtimeNoteChars).toBeGreaterThan(0); }); + it("does not teach the paperclip-api helper when it was not staged onto PATH, even with a token and an API URL present", async () => { + // Same token/URL/task shape as the previous test, but no + // PAPERCLIP_GITHUB_LAUNCHER_DIR in env: the run-preparation step that + // stages the helper never ran. The note and the staging step must read + // the same fact, so the note must not teach a command that is not + // installed on this run's PATH. + const { meta } = await runExecutor( + { agent: "custom", agentCommand: "node ./fake-acp.js" }, + { + authToken: "runtime-secret-token", + context: { + taskId: "issue-1", + wakeReason: "issue_assigned", + paperclipWake: { + reason: "issue_assigned", + issue: { id: "issue-1", identifier: "TEST-1" }, + }, + }, + }, + ); + + const prompt = String(meta[0]?.prompt ?? ""); + expect(prompt).not.toContain("Paperclip API access note:"); + expect(prompt).not.toContain("paperclip-api"); + }); + it.each([ ["claude", false], ["codex", false], ["claude", true], ["codex", true], ] as const)("keeps %s ACP conversation policy on fresh, resumed, and reset turns (custom=%s)", async (agent, custom) => { @@ -696,7 +720,7 @@ describe("shared ACPX engine runtime behavior", () => { it("keeps the authenticated API fallback when ACPX has no native wake reader", async () => { const { meta } = await runExecutor( - { agent: "custom", agentCommand: "node ./fake-acp.js" }, + { agent: "custom", agentCommand: "node ./fake-acp.js", env: { PAPERCLIP_GITHUB_LAUNCHER_DIR: "/tmp/paperclip-op-launchers" } }, { authToken: "runtime-secret-token", context: { @@ -783,14 +807,14 @@ describe("shared ACPX engine runtime behavior", () => { it("does not show a scoped issue API command when the task id is unavailable", async () => { const { meta } = await runExecutor( - { agent: "custom", agentCommand: "node ./fake-acp.js" }, + { agent: "custom", agentCommand: "node ./fake-acp.js", env: { PAPERCLIP_GITHUB_LAUNCHER_DIR: "/tmp/paperclip-op-launchers" } }, { authToken: "runtime-secret-token" }, ); const prompt = String(meta[0]?.prompt ?? ""); expect(prompt).toContain("Paperclip API access note:"); expect(prompt).toContain("Use a real issue id from the current context before making issue write requests."); - expect(prompt).not.toContain("$PAPERCLIP_API_BASE/api/issues/$PAPERCLIP_TASK_ID"); + expect(prompt).not.toContain("paperclip-api POST /api/issues/$PAPERCLIP_TASK_ID"); }); it("emits ACP text deltas as stdout transcript records", async () => { diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 3a18977691e..c87a3e77230 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -2899,23 +2899,33 @@ function renderPaperclipEnvNote(env: Record): string { "Paperclip runtime note:", `The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`, "Do not assume these variables are missing without checking your shell environment.", + "Never put PAPERCLIP_API_KEY, or another secret-looking value, on a command line. Every process that runs as your user can read a command line.", ].join("\n"); } +/** + * Set by the same run-preparation step that stages the `paperclip-api` helper + * onto `PATH` (see `prepareGitHubOperationLaunchers` in `execution-target.ts`). + * Its presence is the one signal this note needs, so the note and the staging + * step read the same fact and cannot drift apart. + */ +function apiAccessHelperIsStaged(env: Record): boolean { + return Boolean(env.PAPERCLIP_GITHUB_LAUNCHER_DIR); +} + function renderApiAccessNote(env: Record): string { - if (!env.PAPERCLIP_API_URL || !env.PAPERCLIP_API_KEY) return ""; + if (!env.PAPERCLIP_API_URL || !env.PAPERCLIP_API_KEY || !apiAccessHelperIsStaged(env)) return ""; const lines = [ "Paperclip API access note:", - "Use terminal commands with curl to make Paperclip API requests.", - "Normalize the base URL before adding API paths:", - ` PAPERCLIP_API_BASE="\${PAPERCLIP_API_URL%/}"; PAPERCLIP_API_BASE="\${PAPERCLIP_API_BASE%/api}"`, + "Call the Paperclip API with the paperclip-api helper program on your PATH. Do not use curl for this.", + "The helper reads the access token from its own environment and adds the token to the request itself. The token never appears as a command-line argument. Every process that runs as your user can read a command-line argument, so a command that carries the token there exposes it.", "GET example:", - ` curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" "$PAPERCLIP_API_BASE/api/agents/me"`, + " paperclip-api GET /api/agents/me", ]; if (env.PAPERCLIP_TASK_ID) { lines.push( - "Scoped issue comment example:", - ` curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "Content-Type: application/json" -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" -d '{"body":"Status update from agent."}' "$PAPERCLIP_API_BASE/api/issues/$PAPERCLIP_TASK_ID/comments"`, + "Scoped issue comment example (the helper adds X-Paperclip-Run-Id for you):", + ` paperclip-api POST /api/issues/$PAPERCLIP_TASK_ID/comments -d '{"body":"Status update from agent."}'`, ); } else { lines.push("Use a real issue id from the current context before making issue write requests."); diff --git a/packages/adapter-utils/src/execution-target.test.ts b/packages/adapter-utils/src/execution-target.test.ts index 5d89d97d3f5..994999afa52 100644 --- a/packages/adapter-utils/src/execution-target.test.ts +++ b/packages/adapter-utils/src/execution-target.test.ts @@ -436,4 +436,34 @@ describe("GitHub launcher lifecycle", () => { await expect(cleanupGitHubOperationLaunchers({ runId: "../other", target })).rejects.toThrow("Invalid GitHub launcher run ID"); expect(runner.execute).toHaveBeenCalledTimes(1); }); + + it("stages paperclip-api alongside git and gh, on the same PATH entry", async () => { + const run = { runId: randomUUID(), target: null }; + try { + const staged = await prepareGitHubOperationLaunchers({ + ...run, cwd: "/tmp", env: {}, programs: ["git", "gh", "paperclip-api"], + }); + expect(await readFile(`${staged.PAPERCLIP_GITHUB_LAUNCHER_DIR}/paperclip-api`, "utf8")).toContain("PAPERCLIP_API_KEY"); + expect(staged.PATH?.split(":")[0]).toBe(staged.PAPERCLIP_GITHUB_LAUNCHER_DIR); + expect(staged.GH_CONFIG_DIR).toBeTruthy(); + } finally { + await cleanupGitHubOperationLaunchers(run); + } + }); + + it("stages only paperclip-api, and does not touch GH_CONFIG_DIR, for a host-GitHub-credentials run", async () => { + const run = { runId: randomUUID(), target: null }; + try { + const staged = await prepareGitHubOperationLaunchers({ + ...run, cwd: "/tmp", env: { GH_CONFIG_DIR: "/host/.config/gh" }, programs: ["paperclip-api"], + }); + expect(await readFile(`${staged.PAPERCLIP_GITHUB_LAUNCHER_DIR}/paperclip-api`, "utf8")).toContain("PAPERCLIP_API_KEY"); + await expect(access(`${staged.PAPERCLIP_GITHUB_LAUNCHER_DIR}/git`)).rejects.toMatchObject({ code: "ENOENT" }); + // A host-GitHub run must keep its own GH_CONFIG_DIR: overwriting it with + // an empty staged directory would silently drop the host's gh login. + expect(staged.GH_CONFIG_DIR).toBe("/host/.config/gh"); + } finally { + await cleanupGitHubOperationLaunchers(run); + } + }); }); diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index eaae713b283..23f5d7b168d 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; import { randomBytes, randomUUID } from "node:crypto"; import { githubLauncherSource } from "./github-launcher.js"; +import { paperclipApiHelperSource } from "./paperclip-api-launcher.js"; import type { SshRemoteExecutionSpec } from "./ssh.js"; import { prepareCommandManagedRuntime, @@ -1699,10 +1700,26 @@ printf '\0PAPERCLIP_GIT_CONTEXT_END\0' }; } -/** Stage token-free launchers next to the execution, not in shared global Git config. */ +export type OperationLauncherProgram = "git" | "gh" | "paperclip-api"; + +const OPERATION_LAUNCHER_SOURCE: Record string> = { + git: githubLauncherSource, + gh: githubLauncherSource, + "paperclip-api": paperclipApiHelperSource, +}; + +/** + * Stage token-free launchers next to the execution, not in shared global Git + * config. The directory holds one program per requested name, all reached + * through one `PATH` entry, so adding a program here never opens a second + * staging path with its own lifecycle. + */ export async function prepareGitHubOperationLaunchers(input: { runId: string; target: AdapterExecutionTarget | null | undefined; cwd: string; env: Record; + programs?: readonly OperationLauncherProgram[]; }): Promise> { + const programs = input.programs ?? (["git", "gh"] as const); + const stageGh = programs.includes("gh"); const remote = input.target?.kind === "remote" ? input.target : null; const directory = githubOperationLauncherDirectory(input); const configDirectory = path.posix.join(directory, "gh-config"); @@ -1712,7 +1729,7 @@ export async function prepareGitHubOperationLaunchers(input: { // the managed launchers after startup without loading a host user's profile. const profile = `export PATH=${shellQuote(managedPath)}\n`; const files: Record = Object.fromEntries([ - ...["git", "gh"].map((name) => [name, githubLauncherSource()] as const), + ...programs.map((name) => [name, OPERATION_LAUNCHER_SOURCE[name]()] as const), ...[".zshenv", ".zprofile", ".zshrc", ".bash_profile", ".bashrc", ".profile"].map((name) => [name, profile] as const), ]); if (remote) { @@ -1726,15 +1743,19 @@ export async function prepareGitHubOperationLaunchers(input: { timeoutMs: 15_000, shellCommand: adapterExecutionTargetShellCommand(remote), }); } - const permissions = await runner.execute({ command: "sh", args: ["-c", `chmod 700 ${shellQuote(directory)}/git ${shellQuote(directory)}/gh && mkdir -p ${shellQuote(configDirectory)}`], cwd: remote.remoteCwd, timeoutMs: 15_000 }); + const stagedPaths = programs.map((name) => shellQuote(path.posix.join(directory, name))).join(" "); + const makeGhConfig = stageGh ? ` && mkdir -p ${shellQuote(configDirectory)}` : ""; + const permissions = await runner.execute({ command: "sh", args: ["-c", `chmod 700 ${stagedPaths}${makeGhConfig}`], cwd: remote.remoteCwd, timeoutMs: 15_000 }); if (permissions.exitCode !== 0) throw new Error("Could not prepare managed GitHub launchers"); } else { await fs.mkdir(directory, { recursive: true, mode: 0o700 }); - await fs.mkdir(configDirectory, { recursive: true, mode: 0o700 }); + if (stageGh) await fs.mkdir(configDirectory, { recursive: true, mode: 0o700 }); for (const [program, body] of Object.entries(files)) await fs.writeFile(path.join(directory, program), body, { mode: 0o700 }); } return { ...input.env, PATH: managedPath, ZDOTDIR: directory, BASH_ENV: `${directory}/.bashrc`, - GH_CONFIG_DIR: configDirectory, PAPERCLIP_GITHUB_LAUNCHER_DIR: directory }; + PAPERCLIP_GITHUB_LAUNCHER_DIR: directory, + ...(stageGh ? { GH_CONFIG_DIR: configDirectory } : {}), + }; } function buildBridgeResponseHeaders(response: Response): Record { diff --git a/packages/adapter-utils/src/paperclip-api-launcher.test.ts b/packages/adapter-utils/src/paperclip-api-launcher.test.ts new file mode 100644 index 00000000000..f3e89a3516b --- /dev/null +++ b/packages/adapter-utils/src/paperclip-api-launcher.test.ts @@ -0,0 +1,132 @@ +import { execFile } from "node:child_process"; +import { createServer, type IncomingMessage, type Server } from "node:http"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { paperclipApiHelperSource } from "./paperclip-api-launcher.js"; + +const exec = promisify(execFile); +const SENTINEL_BEARER = "sentinel-bearer-must-never-leave-the-configured-origin"; + +type RecordedRequest = { method: string; url: string; headers: IncomingMessage["headers"]; body: string }; + +async function startRecordingServer( + respond: (req: IncomingMessage) => { status: number; body: string }, +): Promise<{ server: Server; port: number; requests: RecordedRequest[] }> { + const requests: RecordedRequest[] = []; + const server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + requests.push({ method: req.method ?? "", url: req.url ?? "", headers: req.headers, body: Buffer.concat(chunks).toString("utf8") }); + const { status, body } = respond(req); + res.writeHead(status, { "content-type": "application/json" }); + res.end(body); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as { port: number }; + return { server, port, requests }; +} + +describe("paperclip-api helper", () => { + let root: string; + let bin: string; + let configured: Awaited>; + let nonconfigured: Awaited>; + + beforeEach(async () => { + root = await mkdtemp(path.join(os.tmpdir(), "paperclip-api-helper-")); + bin = path.join(root, "paperclip-api"); + await writeFile(bin, paperclipApiHelperSource(), { mode: 0o700 }); + configured = await startRecordingServer(() => ({ status: 200, body: '{"ok":true}' })); + nonconfigured = await startRecordingServer(() => ({ status: 200, body: '{"ok":true}' })); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + await new Promise((resolve) => configured.server.close(() => resolve())); + await new Promise((resolve) => nonconfigured.server.close(() => resolve())); + }); + + function runEnv(overrides: Record = {}) { + return { + ...process.env, + PAPERCLIP_API_URL: `http://127.0.0.1:${configured.port}/api`, + PAPERCLIP_API_KEY: SENTINEL_BEARER, + PAPERCLIP_RUN_ID: "run-123", + ...overrides, + }; + } + + it("makes a GET request, setting the Authorization header exactly once", async () => { + const result = await exec(bin, ["GET", "/api/agents/me"], { env: runEnv() }); + expect(JSON.parse(result.stdout)).toEqual({ ok: true }); + expect(configured.requests).toHaveLength(1); + const request = configured.requests[0]!; + expect(request.method).toBe("GET"); + expect(request.url).toBe("/api/agents/me"); + expect(request.headers.authorization).toBe(`Bearer ${SENTINEL_BEARER}`); + expect(nonconfigured.requests).toHaveLength(0); + }); + + it("posts a scoped issue comment with Content-Type and X-Paperclip-Run-Id set for the caller", async () => { + const result = await exec(bin, ["POST", "/api/issues/PAP-1/comments", "-d", '{"body":"status"}'], { env: runEnv() }); + expect(JSON.parse(result.stdout)).toEqual({ ok: true }); + const request = configured.requests[0]!; + expect(request.method).toBe("POST"); + expect(request.headers["content-type"]).toBe("application/json"); + expect(request.headers["x-paperclip-run-id"]).toBe("run-123"); + expect(request.body).toBe('{"body":"status"}'); + }); + + it("never puts the bearer on the command line", async () => { + const args = ["POST", "/api/issues/PAP-1/comments", "-d", '{"body":"status"}']; + expect(args.join(" ")).not.toContain(SENTINEL_BEARER); + await exec(bin, args, { env: runEnv() }); + expect(configured.requests[0]!.headers.authorization).toBe(`Bearer ${SENTINEL_BEARER}`); + }); + + it.each<[string, (attackerPort: number) => string]>([ + ["an absolute URL to the nonconfigured origin", (port) => `http://127.0.0.1:${port}/api/x`], + ["a scheme-relative //host form", (port) => `//127.0.0.1:${port}/api/x`], + ["a backslash form", (port) => `/\\127.0.0.1:${port}/api/x`], + ["a path outside /api/", () => "/not-api/x"], + ])("rejects %s and never reaches the nonconfigured origin", async (_label, buildPath) => { + const relativePath = buildPath(nonconfigured.port); + await expect(exec(bin, ["GET", relativePath], { env: runEnv() })).rejects.toMatchObject({ code: 2 }); + expect(configured.requests).toHaveLength(0); + expect(nonconfigured.requests).toHaveLength(0); + }); + + it.each(["Authorization", "Host", "Proxy-Authorization", "X-Forwarded-Host"])( + "rejects a caller-supplied %s header", + async (headerName) => { + await expect( + exec(bin, ["GET", "/api/agents/me", "-H", `${headerName}: attacker-value`], { env: runEnv() }), + ).rejects.toMatchObject({ code: 2 }); + expect(configured.requests).toHaveLength(0); + }, + ); + + it("does not follow a redirect to another origin", async () => { + await new Promise((resolve) => configured.server.close(() => resolve())); + configured.server = createServer((req, res) => { + configured.requests.push({ method: req.method ?? "", url: req.url ?? "", headers: req.headers, body: "" }); + res.writeHead(302, { location: `http://127.0.0.1:${nonconfigured.port}/api/x` }); + res.end(); + }); + await new Promise((resolve) => configured.server.listen(configured.port, "127.0.0.1", resolve)); + await expect(exec(bin, ["GET", "/api/agents/me"], { env: runEnv() })).rejects.toBeTruthy(); + expect(nonconfigured.requests).toHaveLength(0); + }); + + it("fails when PAPERCLIP_API_KEY is not set, without making a request", async () => { + const env = runEnv(); + delete (env as Record).PAPERCLIP_API_KEY; + await expect(exec(bin, ["GET", "/api/agents/me"], { env })).rejects.toMatchObject({ code: 2 }); + expect(configured.requests).toHaveLength(0); + }); +}); diff --git a/packages/adapter-utils/src/paperclip-api-launcher.ts b/packages/adapter-utils/src/paperclip-api-launcher.ts new file mode 100644 index 00000000000..a958d876c3e --- /dev/null +++ b/packages/adapter-utils/src/paperclip-api-launcher.ts @@ -0,0 +1,103 @@ +/** Standalone source is staged unchanged on local, SSH, and sandbox runtimes. No secrets in files. */ +export function paperclipApiHelperSource(): string { + return String.raw`#!/usr/bin/env node +// paperclip-api: call the Paperclip API without putting the bearer token on a +// command line. The token stays in the process environment; this program +// reads it there and adds it to the request itself. +const FORBIDDEN_HEADER_NAMES = new Set([ + 'authorization', 'host', 'proxy-authorization', 'proxy-connection', 'forwarded', + 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'x-real-ip', 'via', +]); + +function usageError(message) { + process.stderr.write('paperclip-api: ' + message + '\n'); + process.exit(2); +} + +function parseArgs(argv) { + const method = argv[0]; + const relativePath = argv[1]; + if (!method || !relativePath) usageError('usage: paperclip-api [-H "Name: Value"]... [-d ]'); + if (!/^[A-Z]+$/.test(method)) usageError('the method must be an uppercase HTTP verb'); + const headers = {}; + let body; + for (let i = 2; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '-H') { + const raw = argv[++i]; + const sep = raw ? raw.indexOf(':') : -1; + if (sep < 0) usageError('-H needs a "Name: Value" argument'); + const name = raw.slice(0, sep).trim(); + const value = raw.slice(sep + 1).trim(); + if (FORBIDDEN_HEADER_NAMES.has(name.toLowerCase())) usageError('this program sets the "' + name + '" header itself; the caller must not set it'); + headers[name] = value; + } else if (arg === '-d') { + body = argv[++i]; + if (body === undefined) usageError('-d needs a body argument'); + } else { + usageError('unknown argument "' + arg + '"'); + } + } + return { method, relativePath, headers, body }; +} + +// Accept only a path relative to the configured Paperclip API. Reject an +// absolute URL, a scheme-relative "//host" form, and a backslash. A backslash +// in a path can make a URL parser pick a different host, so this program +// never carries one into a URL. +function assertSafeRelativeApiPath(relativePath) { + if (relativePath.includes('\\')) usageError('the path must not contain a backslash'); + if (!relativePath.startsWith('/') || relativePath.startsWith('//')) usageError('the path must be a single relative path that starts with /api/'); + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(relativePath)) usageError('the path must not carry a URL scheme'); + if (!relativePath.startsWith('/api/')) usageError('the path must start with /api/'); +} + +function readConfiguredOrigin() { + const rawBase = process.env.PAPERCLIP_API_URL; + if (!rawBase) usageError('PAPERCLIP_API_URL is not set'); + const normalized = rawBase.replace(/\/+$/, '').replace(/\/api$/, ''); + let origin; + try { origin = new URL(normalized).origin; } + catch { usageError('PAPERCLIP_API_URL is not a valid URL'); } + return origin; +} + +// Build the final request URL against the normalized configured origin, then +// verify the built URL actually resolved to that same origin. This check +// stands even if a future change to the path check above lets an unexpected +// form through. +function buildRequestUrl(origin, relativePath) { + let url; + try { url = new URL(relativePath, origin + '/'); } + catch { usageError('could not build a request URL from the given path'); } + if (url.origin !== origin) usageError('the given path did not resolve to the configured Paperclip API origin'); + if (!url.pathname.startsWith('/api/')) usageError('the given path did not resolve under /api/'); + return url; +} + +async function main() { + const { method, relativePath, headers, body } = parseArgs(process.argv.slice(2)); + assertSafeRelativeApiPath(relativePath); + const origin = readConfiguredOrigin(); + const url = buildRequestUrl(origin, relativePath); + const bearer = process.env.PAPERCLIP_API_KEY; + if (!bearer) usageError('PAPERCLIP_API_KEY is not set'); + if (body !== undefined && !('Content-Type' in headers)) headers['Content-Type'] = 'application/json'; + if (process.env.PAPERCLIP_RUN_ID && !('X-Paperclip-Run-Id' in headers)) headers['X-Paperclip-Run-Id'] = process.env.PAPERCLIP_RUN_ID; + // Set once, here. Nothing above this line can add or change this header. + headers['Authorization'] = 'Bearer ' + bearer; + let response; + try { + response = await fetch(url, { method, headers, body, redirect: 'error', signal: AbortSignal.timeout(30000) }); + } catch (error) { + process.stderr.write('paperclip-api: the request failed: ' + (error && error.code ? error.code : 'network_error') + '\n'); + process.exitCode = 1; + return; + } + const text = await response.text(); + process.stdout.write(text); + if (!response.ok) process.exitCode = 1; +} +main().catch(() => { process.stderr.write('paperclip-api: an unexpected error stopped the request.\n'); process.exitCode = 1; }); +`; +} diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index c7180d8a2d4..cdd69904298 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -46,6 +46,7 @@ import { prepareGitHubOperationLaunchers, prepareGitHubExecutionEnvironment, startAdapterExecutionTargetPaperclipBridge, + type OperationLauncherProgram, } from "@paperclipai/adapter-utils/execution-target"; import { agentService } from "./agents.js"; import { normalizeLegacyRunnerProvider } from "@paperclipai/adapter-utils"; @@ -22131,6 +22132,12 @@ export function heartbeatService( runtimeConfig = { ...runtimeConfig, env: gitExecutionEnv }; for (const key of MANAGED_GITHUB_TOKEN_KEYS) secretKeys.add(key); context.githubAuthenticationMode = useHostGitHub ? "host" : "managed"; + // Stage the token-free `paperclip-api` helper on every run, not only a + // managed-GitHub run. The generated API access note assumes the helper is + // on `PATH` whenever the run environment carries `PAPERCLIP_API_KEY`, and a + // host-GitHub run still carries that key. + let operationLauncherEnv = gitExecutionEnv; + let operationLauncherPrograms: readonly OperationLauncherProgram[] = ["paperclip-api"]; if (!useHostGitHub) { const githubBrokerToken = createRuntimeToolsToken({ agentId: agent.id, @@ -22139,22 +22146,24 @@ export function heartbeatService( responsibleUserId: responsibleUserId ?? "", scope: "github_credentials", }); - const githubBrokerEnv = githubBrokerEnvironment(gitExecutionEnv, { + operationLauncherEnv = githubBrokerEnvironment(gitExecutionEnv, { url: configuredPaperclipApiBaseUrl() ?? "", token: githubBrokerToken?.token ?? "", }); - githubLauncherLocation = { runId: run.id, target: executionTarget }; - runtimeConfig = { - ...runtimeConfig, - env: await prepareGitHubOperationLaunchers({ - runId: run.id, - target: executionTarget, - cwd: executionWorkspace.cwd, - env: githubBrokerEnv, - }), - }; + operationLauncherPrograms = ["git", "gh", "paperclip-api"]; secretKeys.add("PAPERCLIP_GITHUB_BROKER_TOKEN"); } + githubLauncherLocation = { runId: run.id, target: executionTarget }; + runtimeConfig = { + ...runtimeConfig, + env: await prepareGitHubOperationLaunchers({ + runId: run.id, + target: executionTarget, + cwd: executionWorkspace.cwd, + env: operationLauncherEnv, + programs: operationLauncherPrograms, + }), + }; context.paperclipEnvironment = { id: selectedEnvironment.id, name: selectedEnvironment.name,