fix(adapters): teach the token-free API helper in gemini-local and hermes

Both adapters still taught curl with the bearer token as a command-line
argument. Rewrite both onto the paperclip-api helper form, gated on the
same staged-program-list fact the main prompt builder reads, and export
that check once from adapter-utils so all three builders share it.

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
nickyleachandPaperclip committed 2026-09-16 09:54:46 +00:00
1 parent 27bdb45fae
commit 86c6ef57ad
8 files changed
+119 -45

No files matched your search

@@ -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, string>): 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<string, string>): boolean {
return (env.PAPERCLIP_GITHUB_LAUNCHER_PROGRAMS ?? "").split(",").includes("paperclip-api");
}
function renderApiAccessNote(env: Record<string, string>): string {
if (!env.PAPERCLIP_API_URL || !env.PAPERCLIP_API_KEY || !apiAccessHelperIsStaged(env)) return "";
const lines = [
@@ -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);
});
});
@@ -1708,6 +1708,19 @@ const OPERATION_LAUNCHER_SOURCE: Record<OperationLauncherProgram, () => 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<string, string>): 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
@@ -0,0 +1,40 @@
import { expect, test } from "vitest";
import { renderApiAccessNote } from "./execute.js";
function baseEnv(overrides: Record<string, string> = {}): Record<string, string> {
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("");
});
@@ -12,6 +12,7 @@ import {
adapterExecutionTargetSessionMatches,
adapterExecutionTargetUsesManagedHome,
adapterExecutionTargetUsesPaperclipBridge,
apiAccessHelperIsStaged,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
@@ -120,15 +121,21 @@ function renderPaperclipEnvNote(env: Record<string, string>): string {
].join("\n");
}
function renderApiAccessNote(env: Record<string, string>): string {
if (!hasNonEmptyEnvValue(env, "PAPERCLIP_API_URL") || !hasNonEmptyEnvValue(env, "PAPERCLIP_API_KEY")) return "";
export function renderApiAccessNote(env: Record<string, string>): 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.",
"",
"",
+6 -6
View File
@@ -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
+13 -15
View File
@@ -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<string, string>;
const paperclipApiHelperStaged = apiAccessHelperIsStaged(configEnv);
const vars: Record<string, unknown> = {
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)
@@ -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", () => {