diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index d1feddce264..d77075fafdc 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -15,6 +15,7 @@ import type { } from "@paperclipai/adapter-utils"; import { adapterExecutionTargetSessionIdentity, + apiAccessHelperIsStaged, describeAdapterExecutionTarget, adapterExecutionTargetDuplexObservabilityRecorder, adapterExecutionTargetEnablesSandboxDuplexBridge, @@ -2903,17 +2904,6 @@ function renderPaperclipEnvNote(env: Record): string { ].join("\n"); } -/** - * Read the staged program list, not only the launcher directory: a directory - * can exist for a call that staged `git`/`gh` alone. `PAPERCLIP_GITHUB_LAUNCHER_PROGRAMS` - * is set by the same run-preparation step that stages programs onto `PATH` - * (see `prepareGitHubOperationLaunchers` in `execution-target.ts`), so the - * note and the staging step read the same fact and cannot drift apart. - */ -function apiAccessHelperIsStaged(env: Record): boolean { - return (env.PAPERCLIP_GITHUB_LAUNCHER_PROGRAMS ?? "").split(",").includes("paperclip-api"); -} - function renderApiAccessNote(env: Record): string { if (!env.PAPERCLIP_API_URL || !env.PAPERCLIP_API_KEY || !apiAccessHelperIsStaged(env)) return ""; const lines = [ diff --git a/packages/adapter-utils/src/execution-target.test.ts b/packages/adapter-utils/src/execution-target.test.ts index 21b1e9ba088..1b46f507218 100644 --- a/packages/adapter-utils/src/execution-target.test.ts +++ b/packages/adapter-utils/src/execution-target.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import * as ssh from "./ssh.js"; import * as serverUtils from "./server-utils.js"; import { + apiAccessHelperIsStaged, cleanupGitHubOperationLaunchers, prepareGitHubOperationLaunchers, adapterExecutionTargetUsesManagedHome, @@ -494,3 +495,16 @@ describe("GitHub launcher lifecycle", () => { } }); }); + +describe("apiAccessHelperIsStaged", () => { + it("is true only when the staged program list names paperclip-api", () => { + expect(apiAccessHelperIsStaged({ PAPERCLIP_GITHUB_LAUNCHER_PROGRAMS: "paperclip-api" })).toBe(true); + expect(apiAccessHelperIsStaged({ PAPERCLIP_GITHUB_LAUNCHER_PROGRAMS: "git,gh,paperclip-api" })).toBe(true); + }); + + it("is false when the staged program list omits paperclip-api, is empty, or is absent", () => { + expect(apiAccessHelperIsStaged({ PAPERCLIP_GITHUB_LAUNCHER_PROGRAMS: "git,gh" })).toBe(false); + expect(apiAccessHelperIsStaged({ PAPERCLIP_GITHUB_LAUNCHER_PROGRAMS: "" })).toBe(false); + expect(apiAccessHelperIsStaged({})).toBe(false); + }); +}); diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 395af4856f7..2161c98785b 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -1708,6 +1708,19 @@ const OPERATION_LAUNCHER_SOURCE: Record string> "paperclip-api": paperclipApiHelperSource, }; +/** + * Check the staged program list, not only the launcher directory: a + * directory can exist for a run that staged `git`/`gh` alone. + * `PAPERCLIP_GITHUB_LAUNCHER_PROGRAMS` is the one fact `prepareGitHubOperationLaunchers` + * writes when it stages a program onto `PATH`. Every prompt builder that + * decides whether to teach the `paperclip-api` helper must call this + * function instead of parsing the list on its own, so the builders cannot + * drift from the staging step or from each other. + */ +export function apiAccessHelperIsStaged(env: Record): boolean { + return (env.PAPERCLIP_GITHUB_LAUNCHER_PROGRAMS ?? "").split(",").includes("paperclip-api"); +} + /** * Stage token-free launchers next to the execution, not in shared global Git * config. The directory holds one program per requested name, all reached diff --git a/packages/adapters/gemini-local/src/server/api-access-note.test.ts b/packages/adapters/gemini-local/src/server/api-access-note.test.ts new file mode 100644 index 00000000000..d7deda10134 --- /dev/null +++ b/packages/adapters/gemini-local/src/server/api-access-note.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "vitest"; + +import { renderApiAccessNote } from "./execute.js"; + +function baseEnv(overrides: Record = {}): Record { + return { + PAPERCLIP_API_URL: "http://paperclip.local/api", + PAPERCLIP_API_KEY: "test-token", + PAPERCLIP_GITHUB_LAUNCHER_PROGRAMS: "paperclip-api", + ...overrides, + }; +} + +test("teaches the paperclip-api helper form when the helper is staged", () => { + const note = renderApiAccessNote(baseEnv()); + + expect(note).toContain("Call the Paperclip API with run_shell_command and the paperclip-api helper program on your PATH. Do not use curl for this."); + expect(note).toContain("The token never appears as a command-line argument."); + expect(note).toContain('run_shell_command({ command: "paperclip-api GET /api/agents/me" })'); + expect(note).not.toContain("curl -"); + expect(note).not.toContain("Authorization: Bearer"); +}); + +test("renders nothing when the staged program list omits paperclip-api", () => { + const note = renderApiAccessNote(baseEnv({ PAPERCLIP_GITHUB_LAUNCHER_PROGRAMS: "git,gh" })); + + expect(note).toBe(""); +}); + +test("renders nothing when no program is staged at all", () => { + const note = renderApiAccessNote(baseEnv({ PAPERCLIP_GITHUB_LAUNCHER_PROGRAMS: "" })); + + expect(note).toBe(""); +}); + +test("renders nothing when the run has no API key", () => { + const note = renderApiAccessNote(baseEnv({ PAPERCLIP_API_KEY: "" })); + + expect(note).toBe(""); +}); diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index de9257c1f47..304df87501b 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -12,6 +12,7 @@ import { adapterExecutionTargetSessionMatches, adapterExecutionTargetUsesManagedHome, adapterExecutionTargetUsesPaperclipBridge, + apiAccessHelperIsStaged, describeAdapterExecutionTarget, ensureAdapterExecutionTargetCommandResolvable, ensureAdapterExecutionTargetRuntimeCommandInstalled, @@ -120,15 +121,21 @@ function renderPaperclipEnvNote(env: Record): string { ].join("\n"); } -function renderApiAccessNote(env: Record): string { - if (!hasNonEmptyEnvValue(env, "PAPERCLIP_API_URL") || !hasNonEmptyEnvValue(env, "PAPERCLIP_API_KEY")) return ""; +export function renderApiAccessNote(env: Record): string { + if ( + !hasNonEmptyEnvValue(env, "PAPERCLIP_API_URL") || + !hasNonEmptyEnvValue(env, "PAPERCLIP_API_KEY") || + !apiAccessHelperIsStaged(env) + ) + return ""; return [ "Paperclip API access note:", - "Use run_shell_command with curl to make Paperclip API requests.", + "Call the Paperclip API with run_shell_command and 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:", - ` run_shell_command({ command: "curl -s -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" \\"$PAPERCLIP_API_URL/api/agents/me\\"" })`, + ` run_shell_command({ command: "paperclip-api GET /api/agents/me" })`, "POST/PATCH example:", - ` run_shell_command({ command: "curl -s -X POST -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" -H 'Content-Type: application/json' -H \\"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\\" -d '{...}' \\"$PAPERCLIP_API_URL/api/issues/$PAPERCLIP_TASK_ID/checkout\\"" })`, + ` run_shell_command({ command: "paperclip-api POST /api/issues/$PAPERCLIP_TASK_ID/comments -d '{\\"body\\":\\"Status update from agent.\\"}'" })`, "When PAPERCLIP_TASK_ID is not set, substitute a real issue id from the current context; never send a placeholder like {id} in the URL.", "", "", diff --git a/packages/adapters/hermes/README.md b/packages/adapters/hermes/README.md index 64f6ae39dd9..954ad976aef 100644 --- a/packages/adapters/hermes/README.md +++ b/packages/adapters/hermes/README.md @@ -161,12 +161,12 @@ Hermes receives Paperclip runtime identity through environment variables: - `PAPERCLIP_API_KEY` - `PAPERCLIP_RUN_ID` -Prompts should reference those variables directly. Command output may redact -secret values, so do not copy printed tokens into comments or config. Use -`Authorization: Bearer $PAPERCLIP_API_KEY` on Paperclip API requests and -`X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID` on mutating issue requests. For -multiline comments or status updates, preserve newlines with a heredoc plus -`jq --arg`. +When Paperclip has staged the `paperclip-api` helper program on the run's +`PATH`, the prompt teaches that helper instead of `curl`. The helper reads +the access token from its own environment and adds it to the request +itself, so the token never appears as a command-line argument. For +multiline comments or status updates, build the JSON body in a shell +variable first, then pass it with `-d`. ### Hermes-originated Paperclip tasks diff --git a/packages/adapters/hermes/src/server/execute.ts b/packages/adapters/hermes/src/server/execute.ts index ea54e574e51..6339b7b8bcc 100644 --- a/packages/adapters/hermes/src/server/execute.ts +++ b/packages/adapters/hermes/src/server/execute.ts @@ -31,6 +31,7 @@ import { runChildProcess, buildPaperclipEnv, buildRuntimeToolsEnv, + parseObject, renderTemplate, ensureAbsoluteDirectory, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, @@ -42,6 +43,8 @@ import { isPaperclipRecoveryWakePayload, } from "@paperclipai/adapter-utils/server-utils"; +import { apiAccessHelperIsStaged } from "@paperclipai/adapter-utils/execution-target"; + import { HERMES_CLI, DEFAULT_TIMEOUT_SEC, @@ -92,19 +95,14 @@ const HERMES_DEFAULT_PROMPT_TEMPLATE = [ "- Run ID: {{run.id}}", "- API base: {{paperclipApiUrl}}", "", + "{{#paperclipApiHelperStaged}}", "Paperclip API guidance:", - "- Use `curl` from the terminal for Paperclip API calls; browser/web extraction tools may not reach localhost.", - "- Use `$PAPERCLIP_API_URL`, `$PAPERCLIP_API_KEY`, and `$PAPERCLIP_RUN_ID`; do not hard-code local ports or copy secrets into comments.", - "- Displayed command logs may redact secrets; rely on environment variables instead of printed token values.", - "- Include `-H \"Authorization: Bearer $PAPERCLIP_API_KEY\"` on API requests.", - "- Include `-H \"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\"` on mutating issue requests.", - "- For multiline comments or status updates, preserve newlines with `jq --arg` or a heredoc-fed helper rather than hand-escaping JSON.", + "- 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.", + "- For multiline comments or status updates, build the JSON body in a shell variable first, then pass it with -d.", "", "Safe multiline update pattern:", "```bash", - "api=\"${PAPERCLIP_API_URL%/}\"", - "case \"$api\" in */api) ;; *) api=\"$api/api\" ;; esac", - "", "body=$(cat <<'MD'", "Summary line", "", @@ -112,14 +110,11 @@ const HERMES_DEFAULT_PROMPT_TEMPLATE = [ "- Detail two", "MD", ")", - "jq -n --arg status done --arg comment \"$body\" '{status:$status, comment:$comment}' | \\", - " curl -sS -X PATCH \"$api/issues/{{context.issueId}}\" \\", - " -H \"Authorization: Bearer $PAPERCLIP_API_KEY\" \\", - " -H \"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\" \\", - " -H \"Content-Type: application/json\" \\", - " --data-binary @-", + "payload=$(jq -n --arg status done --arg comment \"$body\" '{status:$status, comment:$comment}')", + "paperclip-api PATCH /api/issues/{{context.issueId}} -d \"$payload\"", "```", "", + "{{/paperclipApiHelperStaged}}", DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, ].join("\n"); @@ -176,6 +171,8 @@ export function buildPrompt( }); const sessionHandoffMarkdown = cfgString(context.paperclipSessionHandoffMarkdown)?.trim() || ""; const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake) || ""; + const configEnv = parseObject(config.env) as Record; + const paperclipApiHelperStaged = apiAccessHelperIsStaged(configEnv); const vars: Record = { agentId: ctx.agent?.id || "", @@ -201,6 +198,7 @@ export function buildPrompt( wakePayloadJson, paperclipApiKeyEnv: "PAPERCLIP_API_KEY", paperclipRunIdEnv: "PAPERCLIP_RUN_ID", + paperclipApiHelperStaged, }; const rendered = isPaperclipRecoveryWakePayload(context.paperclipWake) diff --git a/packages/adapters/hermes/src/server/prompt-rendering.test.ts b/packages/adapters/hermes/src/server/prompt-rendering.test.ts index 1cdfe21b7e4..773427a8502 100644 --- a/packages/adapters/hermes/src/server/prompt-rendering.test.ts +++ b/packages/adapters/hermes/src/server/prompt-rendering.test.ts @@ -202,19 +202,31 @@ test("keeps authoritative parent and ancestor context from task markdown", () => expect(prompt).not.toContain("check the issue body or comments for references"); }); -test("renders safe Paperclip API examples from environment variables with multiline update preservation", () => { +test("renders the paperclip-api helper form with multiline update preservation when the helper is staged", () => { const prompt = buildPrompt(baseContext(), { paperclipApiUrl: "http://paperclip.local/api", + env: { PAPERCLIP_GITHUB_LAUNCHER_PROGRAMS: "paperclip-api" }, }); - expect(prompt).toContain("Use `$PAPERCLIP_API_URL`, `$PAPERCLIP_API_KEY`, and `$PAPERCLIP_RUN_ID`"); - expect(prompt).toContain("Displayed command logs may redact secrets"); - expect(prompt).toContain('-H "Authorization: Bearer $PAPERCLIP_API_KEY"'); - expect(prompt).toContain('-H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID"'); + expect(prompt).toContain("Call the Paperclip API with the paperclip-api helper program on your PATH. Do not use curl for this."); + expect(prompt).toContain("The token never appears as a command-line argument."); expect(prompt).toContain("body=$(cat <<'MD'"); - expect(prompt).toContain("jq -n --arg status done --arg comment \"$body\""); - expect(prompt).toContain("--data-binary @-"); - expect(prompt).not.toContain("Authorization: Bearer <"); + expect(prompt).toContain('payload=$(jq -n --arg status done --arg comment "$body"'); + expect(prompt).toContain('paperclip-api PATCH /api/issues/issue-1 -d "$payload"'); + expect(prompt).not.toContain("Authorization: Bearer"); + expect(prompt).not.toContain("curl -"); +}); + +test("renders no Paperclip API guidance when the paperclip-api helper is not staged", () => { + const prompt = buildPrompt(baseContext(), { + paperclipApiUrl: "http://paperclip.local/api", + env: { PAPERCLIP_GITHUB_LAUNCHER_PROGRAMS: "git,gh" }, + }); + + expect(prompt).not.toContain("Paperclip API guidance:"); + expect(prompt).not.toContain("paperclip-api PATCH"); + expect(prompt).not.toContain("Authorization: Bearer"); + expect(prompt).not.toContain("curl -"); }); test("preserves custom prompt templates while exposing runtime and wake variables", () => {