From 7721662f205e5ea0a5e50b017b8b800cc29bd4ce Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 28 Sep 2026 13:34:02 -0500 Subject: [PATCH] Preserve ACPX credential bindings through final runner launch Keep candidate credentials explicit across server fallbacks and reject unqualified direct product backends before driver construction. Co-Authored-By: Paperclip --- .../src/backends/codex-acpx-native-backend.ts | 5 ++ .../backends/native-backend-factory.test.ts | 13 +++-- .../durable-prp-control-plane.test.ts | 50 ++++++++++++++++++- .../durable-prp-control-plane.ts | 6 ++- .../src/drivers/acpx/environment.ts | 2 +- .../native-session-executor.test.ts | 19 +++++++ .../native-runtime/native-session-executor.ts | 23 +++++++-- 7 files changed, 107 insertions(+), 11 deletions(-) diff --git a/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts b/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts index 5374fb6792f..19483fb4253 100644 --- a/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts +++ b/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts @@ -5,6 +5,7 @@ import { type CodexAcpxDriverOptions, } from "../drivers/acpx/codex-acpx-driver.js"; import { resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js"; +import { ACPX_CAPABILITY_PROFILES } from "../drivers/acpx/capability-profiles.js"; import { HarnessDriverBackend } from "./harness-driver-backend.js"; import { nativeSystemInstructions, @@ -53,6 +54,10 @@ export function createAcpxNativeSessionBackend( } } + if (ACPX_CAPABILITY_PROFILES[input.provider.agent].qualification !== "qualified") { + throw new Error("ACPX candidate direct execution requires completed qualification; use the host-controlled runnerd evaluation path"); + } + const constraints = nativeTaskConstraints(input); const systemInstructions = [ nativeSystemInstructions(input), diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts index 375a80eeaef..d5425b254d1 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -1,3 +1,4 @@ +import { QUALIFIED_ACPX_PROFILES, resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js"; import { describe, expect, it } from "vitest"; import type { NativeExecutionInput } from "../contracts/native-execution.js"; @@ -487,9 +488,15 @@ describe("native backend factory", () => { }, ); - it("constructs a Pi candidate descriptor while runtime installation remains separately gated", async () => { - const backend = createNativeSessionBackend(acpxExecution("pi"), { acpxRuntimeDirectory: "/runtime" }); - await expect(backend.descriptor()).resolves.toMatchObject({ name: "acpx_runtime", version: "0.13.1" }); + it.each(["pi", "cursor", "copilot"] as const)("rejects unqualified %s direct execution even with an exact persisted profile", agent => { + const input = acpxExecution(); + if (input.provider.kind !== "acpx") throw new Error("invalid fixture"); + const model = agent === "pi" ? QUALIFIED_ACPX_PROFILES.pi.qualificationModel : "explicit-fixture-model"; + const profile = resolveQualifiedAcpxProfile(agent, model); + Object.assign(input.provider, { agent, model, profile }); + expect(() => createNativeSessionBackend(input, { acpxRuntimeDirectory: "/runtime", + acpxEnvironment: { COPILOT_GITHUB_TOKEN: "explicit-fixture", CURSOR_API_KEY: "explicit-fixture", OPENROUTER_API_KEY: "explicit-fixture" }, + })).toThrow("ACPX candidate direct execution requires completed qualification"); }); it("rejects a Codex ACPX snapshot that drifts from its qualified profile", () => { diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts index 47f958febd0..283429f55d6 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts @@ -20,7 +20,8 @@ import { resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { NativeSessionProtocolIntegrityError } from "../contracts/native-session-backend.js"; -import { createCapabilityRunnerdCodexTransport } from "../live/runnerd-codex-transport.js"; +import { createCapabilityRunnerdCodexTransport, createCapabilityRunnerdProviderEnvironment } from "../live/runnerd-codex-transport.js"; +import { ACPX_CREDENTIAL_BINDING_ENV, createAcpxSidecarHostEnvironment } from "../drivers/acpx/environment.js"; import { validatePrpEvent } from "../protocol/replay-contract.js"; import { digestPaperclipSemanticContent } from "../semantic-tools/receipts.js"; import { @@ -533,6 +534,53 @@ it("preserves the controller-selected ACPX provider package root", () => { expect(launches[0]!.environment.NODE_PATH).toBeUndefined(); }); +it.each([ + ["pi", "OPENROUTER_API_KEY"], + ["cursor", "CURSOR_API_KEY"], + ["cursor", "CURSOR_AUTH_TOKEN"], + ["copilot", "COPILOT_GITHUB_TOKEN"], +] as const)("preserves explicit %s %s binding through the actual runner launch boundary", (agent, key) => { + const launches: RunnerProcessLaunchSpec[] = []; + vi.stubEnv(key, "ambient-must-not-cross"); + vi.stubEnv(ACPX_CREDENTIAL_BINDING_ENV, "ambient-forged-marker"); + try { + for (const explicit of [true, false]) { + const environment = createCapabilityRunnerdProviderEnvironment({ + provider: "acpx", identity, codexHome: "/fixture/home", + runtimeContextPath: "/fixture/context.json", hasRuntimeContext: false, + options: { acpxAgent: agent, + environment: explicit ? { [key]: "explicit-fixture-credential", [ACPX_CREDENTIAL_BINDING_ENV]: "caller-forged-marker", DATABASE_URL: "must-not-cross" } : undefined }, + }); + const handle = spawnRunner({ + connection: { mode: "connect", connectUrl: "ws://127.0.0.1:43127" }, + stateDirectory: "/tmp/paperclip-runner-test", identity, + ticket: "bootstrap-ticket", maxOutboxBytes: 256 * 1024, p0ReserveBytes: 64 * 1024, + runnerVersion: expectedRunnerVersion, runnerDigest: expectedRunnerDigest, environment, + processLauncher: spec => { + launches.push(spec); + return { child: { pid: 42, exitCode: null, signalCode: null, kill: () => true }, + completion: Promise.resolve({ code: 0, signal: null, stdout: "", stderr: "" }) }; + }, + }); + handle.restart("replacement-ticket"); + for (const launch of launches.splice(0)) { + expect(launch.environment[key]).toBe(explicit ? "explicit-fixture-credential" : undefined); + const receipt = launch.environment[ACPX_CREDENTIAL_BINDING_ENV]; + expect(receipt).toBeDefined(); + expect(JSON.parse(receipt!)).toEqual({ schema: "paperclip.acpx_credential_binding.v1", agent, + sessionId: identity.normalizedSessionId, names: explicit ? [key] : [] }); + expect(receipt).not.toContain("fixture-credential"); + expect(launch.environment.DATABASE_URL).toBeUndefined(); + const provider = createAcpxSidecarHostEnvironment(launch.environment, agent, identity.normalizedSessionId); + expect(provider[key]).toBe(explicit ? "explicit-fixture-credential" : undefined); + expect(provider[ACPX_CREDENTIAL_BINDING_ENV]).toBeUndefined(); + expect(() => createAcpxSidecarHostEnvironment(launch.environment, agent, "stale-session")) + .toThrow("explicit matching session binding"); + } + } + } finally { vi.unstubAllEnvs(); } +}); + it("preserves file-backed AWS workload identity at the runner spawn boundary", () => { const launches: RunnerProcessLaunchSpec[] = []; spawnRunner({ diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts index 5729c4dded4..8045236ce31 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts @@ -29,6 +29,7 @@ import type { Duplex } from "node:stream"; import { fileURLToPath } from "node:url"; import { NativeSessionProtocolIntegrityError } from "../contracts/native-session-backend.js"; +import { ACPX_CREDENTIAL_BINDING_ENV, ACPX_CREDENTIAL_NAMES } from "../drivers/acpx/environment.js"; import { githubCredentialEnvironment } from "../github-credential-environment.js"; import { validatePrpEvent, @@ -3277,7 +3278,10 @@ const runnerPlatformEnvironmentKeys = [ ] as const; const runnerExplicitProviderEnvironmentKeys = [ - "OPENROUTER_API_KEY", + ...ACPX_CREDENTIAL_NAMES.pi, + ...ACPX_CREDENTIAL_NAMES.cursor, + ...ACPX_CREDENTIAL_NAMES.copilot, + ACPX_CREDENTIAL_BINDING_ENV, "ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "OPENAI_API_KEY", diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.ts b/packages/paperclip-runner/src/drivers/acpx/environment.ts index 9c7046632e8..3c63eb959e6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.ts @@ -1,7 +1,7 @@ import type { QualifiedAcpxAgent } from "./qualified-profiles.js"; export const ACPX_CREDENTIAL_BINDING_ENV = "PAPERCLIP_ACPX_CREDENTIAL_BINDING"; -const ACPX_CREDENTIAL_NAMES: Readonly> = { +export const ACPX_CREDENTIAL_NAMES: Readonly> = { pi: ["OPENROUTER_API_KEY"], cursor: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"], copilot: ["COPILOT_GITHUB_TOKEN"], diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 2dfb40ce78c..b4667b0e965 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -31,6 +31,7 @@ import { validatePrpEvent, parseNativeExecutionInput, type NativeExecutionInputV1, + type NativeExecutionInput, type PrpEvent, } from "@paperclipai/paperclip-runner"; import { createHash } from "node:crypto"; @@ -254,6 +255,7 @@ vi.mock("./native-codex-runner.js", () => ({ import { continuingPendingInteractionIds, buildNativeProviderEnvironment, + resolveNativeProviderEnvironment, buildNativeHarnessBackupManifest, cancelNativeSession, closeWarmNativeSessionsForEnvironment, @@ -2408,6 +2410,23 @@ describe("runtime question fallback", () => { }); describe("native provider bootstrap environment", () => { + it.each(["pi", "cursor", "copilot"] as const)("does not promote ambient %s credentials when bindings are omitted", agent => { + const provider = { kind: "acpx", agent } as NativeExecutionInput["provider"]; + const host = { PATH: "/host/bin", HOME: "/host/home", OPENROUTER_API_KEY: "ambient-pi", + CURSOR_API_KEY: "ambient-cursor", CURSOR_AUTH_TOKEN: "ambient-cursor-login", COPILOT_GITHUB_TOKEN: "ambient-copilot", + PAPERCLIP_ACPX_CREDENTIAL_BINDING: "ambient-forged-binding" }; + expect(resolveNativeProviderEnvironment(provider, undefined, host)).toEqual({ PATH: "/host/bin", HOME: "/host/home" }); + const explicit = { COPILOT_GITHUB_TOKEN: "explicit-company-binding" }; + expect(resolveNativeProviderEnvironment(provider, explicit, host)).toBe(explicit); + }); + + it("preserves existing qualified and legacy missing-environment behavior", () => { + const host = { OPENAI_API_KEY: "existing-host-key", OPENROUTER_API_KEY: "existing-opencode-key" }; + for (const provider of [{ kind: "codex" }, { kind: "opencode" }, { kind: "acpx", agent: "codex" }, { kind: "acpx", agent: "claude" }]) { + expect(resolveNativeProviderEnvironment(provider as NativeExecutionInput["provider"], undefined, host)).toBe(host); + } + }); + it("inherits the host executable and credential-home context", () => { expect( buildNativeProviderEnvironment( diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index f0ad096c78e..0c80ed10652 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -579,6 +579,19 @@ export function buildNativeProviderEnvironment( return environment; } +/** Missing candidate bindings must not turn the controller's ambient credentials into explicit input. */ +export function resolveNativeProviderEnvironment( + provider: NativeExecutionInput["provider"], + configured: NodeJS.ProcessEnv | undefined, + host: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + if (configured !== undefined) return configured; + if (provider.kind === "acpx" && ["pi", "cursor", "copilot"].includes(provider.agent)) { + return buildNativeProviderEnvironment({}, host); + } + return host; +} + type PlanSynchronization = { eventId: string; planId: string; @@ -8119,7 +8132,7 @@ async function executePaperclipNativeSessionWithinScope( scope: input.execution.binding, target: input.runnerExecutionTarget, cwd: input.execution.workspace.cwd, - env: input.runnerEnvironment ?? process.env, + env: resolveNativeProviderEnvironment(input.execution.provider, input.runnerEnvironment), resolveCredentials: (binding) => resolveGitHubOperationCredentials(input.db, binding), onLog: input.onLog, }); @@ -8184,8 +8197,8 @@ async function executePaperclipNativeSessionWithinScope( createNativeSessionBackend(input.execution, { runnerInstanceId: input.runnerInstanceId, onSpawn: input.onSpawn, - opencodeEnvironment: input.runnerEnvironment ?? process.env, - acpxEnvironment: input.runnerEnvironment ?? process.env, + opencodeEnvironment: resolveNativeProviderEnvironment(input.execution.provider, input.runnerEnvironment), + acpxEnvironment: resolveNativeProviderEnvironment(input.execution.provider, input.runnerEnvironment), opencodeRuntimeDirectory: resolve( resolvePaperclipInstanceRoot(), "runtime", @@ -12099,7 +12112,7 @@ async function createRunnerdBackendWithinSessionClaim( } : input.execution; const effectiveRunnerEnvironmentBase: NodeJS.ProcessEnv = { - ...(input.runnerEnvironment ?? process.env), + ...resolveNativeProviderEnvironment(input.execution.provider, input.runnerEnvironment), }; if (relayAssignedMcp) { // The server-held tool authority owns this credential. Do not deliver a @@ -12340,7 +12353,7 @@ async function createRunnerdBackendWithinSessionClaim( runnerBinary: controllerRunnerBinary, codexCommand: remoteCodexBinary ?? undefined, sourceCodexHome: remoteTarget - ? resolveSourceCodexHome(input.runnerEnvironment ?? process.env) + ? resolveSourceCodexHome(resolveNativeProviderEnvironment(input.execution.provider, input.runnerEnvironment)) : undefined, runnerProcessLauncher: remoteProcessLauncher, runnerReconnectGraceMs: remoteTarget ? 120_000 : undefined,