mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 05:16:50 +01:00
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:
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.",
|
||||
"",
|
||||
"",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user