diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 56e41dc6fe..c737e67127 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -633,16 +633,8 @@ describe("shared ACPX engine runtime behavior", () => { stateDir, }; - await runExecutor({ - ...baseConfig, - agent: "custom-a", - env: { PAPERCLIP_API_KEY: "old-key" }, - }); - await runExecutor({ - ...baseConfig, - agent: "custom-b", - env: { PAPERCLIP_API_KEY: "new-key" }, - }); + await runExecutor({ ...baseConfig, agent: "custom-a" }, { authToken: "old-key" }); + await runExecutor({ ...baseConfig, agent: "custom-b" }, { authToken: "new-key" }); const wrappers = await fs.readdir(path.join(stateDir, "wrappers")); expect(wrappers.filter((name) => name.endsWith(".sh"))).toHaveLength(2); @@ -677,6 +669,10 @@ describe("shared ACPX engine runtime behavior", () => { OPENROUTER_API_KEY: "resolved-secret-value", // Reserved-namespace config keys must not clobber runtime identity/wake. PAPERCLIP_TASK_ID: "attacker-issue", + // PAPERCLIP_API_KEY is never accepted from config. + PAPERCLIP_API_KEY: "config-key", + // A PAPERCLIP_*-named key the harness does not assign flows through. + PAPERCLIP_CLOUD_PROVIDER_TOKEN: "cloud-token", }, }, { @@ -694,6 +690,11 @@ describe("shared ACPX engine runtime behavior", () => { // Runtime PAPERCLIP_TASK_ID (from the wake context) wins over config. expect(env).toContain("PAPERCLIP_TASK_ID='issue-real'"); expect(env).not.toContain("attacker-issue"); + // The harness-minted run token is the only PAPERCLIP_API_KEY source. + expect(env).toContain("PAPERCLIP_API_KEY='runtime-secret-token'"); + expect(env).not.toContain("config-key"); + // A PAPERCLIP_*-named user key the harness does not assign passes through. + expect(env).toContain("PAPERCLIP_CLOUD_PROVIDER_TOKEN='cloud-token'"); }); it("busts the session fingerprint when resolved adapter env changes but not across wakes", async () => { @@ -731,17 +732,17 @@ describe("shared ACPX engine runtime behavior", () => { const stateDir = path.join(root, "state"); const baseConfig = { agentCommand: "node ./fake-acp.js", stateDir }; - // An explicitly configured PAPERCLIP_API_KEY is stable per-run config (not a - // per-wake runtime var): rotating it must invalidate a warm/resumable session - // so the next launch sources the new key, even across an otherwise-identical - // wake context. + // A configured PAPERCLIP_*-named value the harness does not assign (e.g. a + // cloud provider token binding) is stable per-run config: rotating it must + // invalidate a warm/resumable session so the next launch sources the new + // value, even across an otherwise-identical wake context. const context = { taskId: "issue-1", wakeReason: "issue_assigned" }; const withKey = await runExecutor( - { ...baseConfig, env: { PAPERCLIP_API_KEY: "explicit-key-1" } }, + { ...baseConfig, env: { PAPERCLIP_CLOUD_PROVIDER_TOKEN: "explicit-key-1" } }, { context }, ); const rotatedKey = await runExecutor( - { ...baseConfig, env: { PAPERCLIP_API_KEY: "explicit-key-2" } }, + { ...baseConfig, env: { PAPERCLIP_CLOUD_PROVIDER_TOKEN: "explicit-key-2" } }, { context }, ); @@ -812,11 +813,7 @@ describe("shared ACPX engine runtime behavior", () => { stateDir, }; - await runExecutor({ - ...baseConfig, - agent: "custom-a", - env: { PAPERCLIP_API_KEY: "old-key" }, - }); + await runExecutor({ ...baseConfig, agent: "custom-a" }, { authToken: "old-key" }); const oldDate = new Date(Date.now() - 16 * 60 * 1000); await Promise.all( (await fs.readdir(wrappersDir)) @@ -824,11 +821,7 @@ describe("shared ACPX engine runtime behavior", () => { .map((name) => fs.utimes(path.join(wrappersDir, name), oldDate, oldDate)), ); - await runExecutor({ - ...baseConfig, - agent: "custom-b", - env: { PAPERCLIP_API_KEY: "new-key" }, - }); + await runExecutor({ ...baseConfig, agent: "custom-b" }, { authToken: "new-key" }); const wrappers = await fs.readdir(wrappersDir); expect(wrappers.filter((name) => name.endsWith(".sh"))).toHaveLength(1); @@ -846,14 +839,8 @@ describe("shared ACPX engine runtime behavior", () => { stateDir, }; - await runExecutor({ - ...baseConfig, - env: { PAPERCLIP_API_KEY: "first-key" }, - }); - await runExecutor({ - ...baseConfig, - env: { PAPERCLIP_API_KEY: "second-key" }, - }); + await runExecutor(baseConfig, { authToken: "first-key" }); + await runExecutor(baseConfig, { authToken: "second-key" }); const envFileNames = (await fs.readdir(path.join(stateDir, "wrappers"))).filter((name) => name.endsWith(".env")); expect(envFileNames).toHaveLength(2); diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index d23841e35f..7078a51490 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -33,6 +33,7 @@ import { ensureAbsoluteDirectory, ensurePathInEnv, ensurePaperclipSkillSymlink, + isForbiddenConfigEnvKey, isPaperclipRuntimeEnvKey, joinPromptSections, materializePaperclipSkillCopy, @@ -1044,8 +1045,6 @@ async function buildRuntime(input: { await fs.mkdir(stateDir, { recursive: true }); const envConfig = parseObject(config.env); - const hasExplicitApiKey = - typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; const env: Record = { ...buildPaperclipEnv(agent), PAPERCLIP_RUN_ID: runId }; const wakeTaskId = (typeof context.taskId === "string" && context.taskId.trim()) || @@ -1100,14 +1099,16 @@ async function buildRuntime(input: { for (const [key, value] of Object.entries(shapedEnvConfig)) { if (typeof value !== "string") continue; // Runtime PAPERCLIP_* always wins over config: skip a PAPERCLIP_* key that - // Paperclip has already assigned this run. A PAPERCLIP_* key Paperclip did - // NOT set (e.g. an explicitly configured PAPERCLIP_API_KEY, applied here) is - // stable per-run config, so it applies and feeds the fingerprint hash below. + // Paperclip has already assigned this run. PAPERCLIP_API_KEY is never + // accepted from config — the harness-minted run token is the only source. + // A PAPERCLIP_* key Paperclip did NOT set is stable per-run config, so it + // applies and feeds the fingerprint hash below. + if (isForbiddenConfigEnvKey(key)) continue; if (isPaperclipRuntimeEnvKey(key) && key in env) continue; env[key] = value; resolvedAdapterEnv[key] = value; } - if (!hasExplicitApiKey && authToken) env.PAPERCLIP_API_KEY = authToken; + if (authToken) env.PAPERCLIP_API_KEY = authToken; // For the claude agent, set model via ANTHROPIC_MODEL at startup rather than // via session/set_config_option — the ACP server's set_config_option handler // validates the value against its internal available-models list and rejects diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 4ce82bbbe6..7dccd3fa59 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -2147,6 +2147,22 @@ describe("refreshPaperclipWorkspaceEnvForExecution", () => { it("applies a configured PAPERCLIP_* key only when Paperclip has not set it", () => { const env: Record = {}; + refreshPaperclipWorkspaceEnvForExecution({ + env, + envConfig: { + PAPERCLIP_CLOUD_PROVIDER_TOKEN: "cloud-token", + }, + workspaceCwd: null, + }); + + // Paperclip did not assign this PAPERCLIP_*-named key for the run, so the + // configured value flows through to the spawned process. + expect(env.PAPERCLIP_CLOUD_PROVIDER_TOKEN).toBe("cloud-token"); + }); + + it("never accepts PAPERCLIP_API_KEY from config env", () => { + const env: Record = {}; + refreshPaperclipWorkspaceEnvForExecution({ env, envConfig: { @@ -2155,10 +2171,9 @@ describe("refreshPaperclipWorkspaceEnvForExecution", () => { workspaceCwd: null, }); - // Paperclip did not assign PAPERCLIP_API_KEY before the merge, so an - // explicitly configured value is allowed through (adapters apply the run - // token here only when no explicit key was configured). - expect(env.PAPERCLIP_API_KEY).toBe("explicit-key"); + // The harness-minted run token is the only PAPERCLIP_API_KEY source; + // a configured value is dropped even when Paperclip has not set one. + expect(env.PAPERCLIP_API_KEY).toBeUndefined(); }); }); diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index d3e6b4e73a..164d80fbe1 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -114,6 +114,14 @@ const REDACTED_LOG_VALUE = "***REDACTED***"; export function isPaperclipRuntimeEnvKey(key: string): boolean { return key.startsWith("PAPERCLIP_"); } + +// PAPERCLIP_API_KEY is never accepted from adapter/user config env: the +// harness-minted run token is the only source of Paperclip API identity. +// Other PAPERCLIP_*-named config keys are allowed as long as Paperclip has +// not assigned the same key for the run (runtime vars always win). +export function isForbiddenConfigEnvKey(key: string): boolean { + return key === "PAPERCLIP_API_KEY"; +} const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [ "../../skills", "../../../../../skills", @@ -2044,9 +2052,11 @@ export function refreshPaperclipWorkspaceEnvForExecution(input: { // runtime variable. Non-PAPERCLIP_* keys (plain values and resolved // secret_ref values) always forward to the spawned process; a PAPERCLIP_* // key from config only applies when Paperclip has NOT already assigned it - // for this run (e.g. an explicitly configured PAPERCLIP_API_KEY that the - // adapter applies after this merge). This keeps runtime identity, wake, and - // workspace vars authoritative regardless of what a config binding sets. + // for this run. PAPERCLIP_API_KEY is never accepted from config — the + // harness-minted run token is the only source. This keeps runtime + // identity, wake, and workspace vars authoritative regardless of what a + // config binding sets. + if (isForbiddenConfigEnvKey(key)) continue; if (isPaperclipRuntimeEnvKey(key) && key in input.env) continue; input.env[key] = value; } diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index b2d5848aed..fa02bfb7e2 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -36,6 +36,8 @@ import { buildInvocationEnvForLogs, ensureAbsoluteDirectory, ensurePathInEnv, + isForbiddenConfigEnvKey, + isPaperclipRuntimeEnvKey, refreshPaperclipWorkspaceEnvForExecution, renderTemplate, renderPaperclipWakePrompt, @@ -202,8 +204,6 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise 0; const env: Record = { ...buildPaperclipEnv(agent) }; env.PAPERCLIP_RUN_ID = runId; @@ -287,10 +287,16 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise 0; const env: Record = { ...paperclipBaseEnv }; env.PAPERCLIP_RUN_ID = runId; const wakeTaskId = @@ -776,7 +774,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode; - if (!trimNullable(env.PAPERCLIP_API_KEY) && authToken) { + if (authToken) { env.PAPERCLIP_API_KEY = authToken; } diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index b2b5cac742..587f3f45fd 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -237,8 +237,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; let env: Record = { ...buildPaperclipEnv(agent) }; env.PAPERCLIP_RUN_ID = runId; const wakeTaskId = @@ -303,7 +301,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const env: Record = { ...buildPaperclipEnv(agent) }; env.PAPERCLIP_RUN_ID = runId; const wakeTaskId = @@ -312,7 +310,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const env: Record = { ...buildPaperclipEnv(agent) }; env.PAPERCLIP_RUN_ID = runId; const wakeTaskId = @@ -292,7 +290,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { }; expect(opts.onSpawn).toBeDefined(); }); + + it("does not inherit PAPERCLIP_API_KEY without a harness token", async () => { + const previousApiKey = process.env.PAPERCLIP_API_KEY; + process.env.PAPERCLIP_API_KEY = "parent-process-key"; + + try { + const { ctx } = makeCtx(); + await execute(ctx as any); + + const mocked = vi.mocked(serverUtils.runChildProcess); + const lastCall = mocked.mock.calls[mocked.mock.calls.length - 1]; + const opts = lastCall[3] as { env: Record }; + expect(opts.env.PAPERCLIP_API_KEY).toBeUndefined(); + } finally { + if (previousApiKey === undefined) delete process.env.PAPERCLIP_API_KEY; + else process.env.PAPERCLIP_API_KEY = previousApiKey; + } + }); }); diff --git a/packages/adapters/hermes/src/server/execute.ts b/packages/adapters/hermes/src/server/execute.ts index fd4c7d03bd..f7b743abff 100644 --- a/packages/adapters/hermes/src/server/execute.ts +++ b/packages/adapters/hermes/src/server/execute.ts @@ -465,7 +465,9 @@ export async function execute( if (ctx.runId) env.PAPERCLIP_RUN_ID = ctx.runId; - // BUG FIX: Inject authToken as PAPERCLIP_API_KEY (matches adapter-claude-local behavior) + // PAPERCLIP_API_KEY is never accepted from config — the harness-minted run + // token is the only source of Paperclip API identity. + delete env.PAPERCLIP_API_KEY; if ((ctx as any).authToken) env.PAPERCLIP_API_KEY = (ctx as any).authToken; // BUG FIX: Read task context from ctx.context (wake context), not ctx.config (adapter config) diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 4f05a6611f..91f953f2c3 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -252,8 +252,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const env: Record = { ...buildPaperclipEnv(agent) }; env.PAPERCLIP_RUN_ID = runId; const wakeTaskId = @@ -307,7 +305,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const env: Record = { ...buildPaperclipEnv(agent) }; env.PAPERCLIP_RUN_ID = runId; @@ -316,7 +314,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { }); }); - it("drops Paperclip runtime-owned env before resolving environment, agent, project, and routine overlays", async () => { + it("drops PAPERCLIP_API_KEY bindings but forwards other PAPERCLIP_-named env to resolution", async () => { const resolveAdapterConfigForRuntime = vi.fn(async (_companyId, config: Record) => ({ config: { ...config, @@ -158,24 +158,24 @@ describe("resolveExecutionRunAdapterConfig", () => { environmentId: "environment-1", environmentEnv: { PAPERCLIP_API_KEY: "environment-api-key", - PAPERCLIP_AGENT_ID: "environment-agent", + PAPERCLIP_CLOUD_PROVIDER_TOKEN_ENV: "environment-cloud", ENV_ONLY: "environment-only", }, executionRunConfig: { env: { PAPERCLIP_API_KEY: { type: "secret_ref", secretId: "secret-api-key", version: "latest" }, - PAPERCLIP_AGENT_ID: "spoofed-agent", + PAPERCLIP_CLOUD_PROVIDER_TOKEN_AGENT: "agent-cloud", AGENT_ONLY: "agent-only", }, }, projectEnv: { PAPERCLIP_API_KEY: "project-api-key", - PAPERCLIP_COMPANY_ID: "spoofed-company", + PAPERCLIP_CLOUD_PROVIDER_TOKEN_PROJECT: "project-cloud", PROJECT_ONLY: "project-only", }, routineEnv: { PAPERCLIP_API_KEY: "routine-api-key", - PAPERCLIP_RUN_ID: "spoofed-run", + PAPERCLIP_CLOUD_PROVIDER_TOKEN_ROUTINE: "routine-cloud", ROUTINE_ONLY: "routine-only", }, routineId: "routine-1", @@ -186,26 +186,34 @@ describe("resolveExecutionRunAdapterConfig", () => { }); expect(resolveEnvBindings.mock.calls[0]?.[1]).toEqual({ + PAPERCLIP_CLOUD_PROVIDER_TOKEN_ENV: "environment-cloud", ENV_ONLY: "environment-only", }); expect(resolveAdapterConfigForRuntime.mock.calls[0]?.[1]).toEqual({ env: { + PAPERCLIP_CLOUD_PROVIDER_TOKEN_AGENT: "agent-cloud", AGENT_ONLY: "agent-only", }, }); expect(resolveEnvBindings.mock.calls[1]?.[1]).toEqual({ + PAPERCLIP_CLOUD_PROVIDER_TOKEN_PROJECT: "project-cloud", PROJECT_ONLY: "project-only", }); expect(resolveEnvBindings.mock.calls[2]?.[1]).toEqual({ + PAPERCLIP_CLOUD_PROVIDER_TOKEN_ROUTINE: "routine-cloud", ROUTINE_ONLY: "routine-only", }); expect(result.resolvedConfig.env).toEqual({ + PAPERCLIP_CLOUD_PROVIDER_TOKEN_ENV: "environment-cloud", ENV_ONLY: "environment-only", + PAPERCLIP_CLOUD_PROVIDER_TOKEN_AGENT: "agent-cloud", AGENT_ONLY: "agent-only", + PAPERCLIP_CLOUD_PROVIDER_TOKEN_PROJECT: "project-cloud", PROJECT_ONLY: "project-only", + PAPERCLIP_CLOUD_PROVIDER_TOKEN_ROUTINE: "routine-cloud", ROUTINE_ONLY: "routine-only", }); - expect(JSON.stringify(result.resolvedConfig.env)).not.toContain("PAPERCLIP_"); + expect(JSON.stringify(result.resolvedConfig.env)).not.toContain("PAPERCLIP_API_KEY"); }); it("skips project env resolution when the project has no bindings", async () => { diff --git a/server/src/adapters/process/execute.ts b/server/src/adapters/process/execute.ts index c16d37422f..6f4c6ba893 100644 --- a/server/src/adapters/process/execute.ts +++ b/server/src/adapters/process/execute.ts @@ -5,6 +5,8 @@ import { asStringArray, parseObject, buildPaperclipEnv, + isForbiddenConfigEnvKey, + isPaperclipRuntimeEnvKey, buildInvocationEnvForLogs, ensurePathInEnv, resolveCommandForLogs, @@ -23,10 +25,20 @@ export async function execute(ctx: AdapterExecutionContext): Promise | null { +function stripForbiddenEnvBindings(envValue: unknown): Record | null { const record = parseObject(envValue); const filtered = Object.fromEntries( - Object.entries(record).filter(([key]) => !isPaperclipRuntimeEnvKey(key)), + Object.entries(record).filter(([key]) => !FORBIDDEN_ENV_BINDING_KEYS.has(key)), ); return Object.keys(filtered).length > 0 ? filtered : null; } -function stripPaperclipRuntimeEnvFromAdapterConfig(config: Record): Record { +function stripForbiddenEnvFromAdapterConfig(config: Record): Record { if (!Object.prototype.hasOwnProperty.call(config, "env")) return config; return { ...config, - env: stripPaperclipRuntimeEnvBindings(config.env) ?? {}, + env: stripForbiddenEnvBindings(config.env) ?? {}, }; } function assertLowTrustEnvConfigAllowed(envValue: unknown, source: string) { - const record = stripPaperclipRuntimeEnvBindings(envValue); + const record = stripForbiddenEnvBindings(envValue); if (!record) return; for (const [key, rawBinding] of Object.entries(record)) { const parsed = envBindingSchema.safeParse(rawBinding); @@ -673,10 +679,10 @@ export async function resolveExecutionRunAdapterConfig(input: { remediation: string; }; }) { - const executionRunConfig = stripPaperclipRuntimeEnvFromAdapterConfig(input.executionRunConfig); - const environmentEnv = stripPaperclipRuntimeEnvBindings(input.environmentEnv); - const projectEnv = stripPaperclipRuntimeEnvBindings(input.projectEnv); - const routineEnv = stripPaperclipRuntimeEnvBindings(input.routineEnv); + const executionRunConfig = stripForbiddenEnvFromAdapterConfig(input.executionRunConfig); + const environmentEnv = stripForbiddenEnvBindings(input.environmentEnv); + const projectEnv = stripForbiddenEnvBindings(input.projectEnv); + const routineEnv = stripForbiddenEnvBindings(input.routineEnv); const agentEnv = parseObject(executionRunConfig.env); const lowTrustAllowedBindingIds = input.trustPreset?.kind === "low_trust_review" ? input.trustPreset.boundary.allowedSecretBindingIds ?? []