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,