mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
f80c312cdd
commit
7721662f20
7 files changed
+107
-11
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Record<QualifiedAcpxAgent, readonly string[]>> = {
|
||||
export const ACPX_CREDENTIAL_NAMES: Readonly<Record<QualifiedAcpxAgent, readonly string[]>> = {
|
||||
pi: ["OPENROUTER_API_KEY"],
|
||||
cursor: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"],
|
||||
copilot: ["COPILOT_GITHUB_TOKEN"],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user