diff --git a/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts b/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts new file mode 100644 index 0000000000..caedc8996e --- /dev/null +++ b/packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts @@ -0,0 +1,71 @@ +import type { NativeExecutionInput } from "../contracts/native-execution.js"; +import type { NativeSessionBackend } from "../contracts/native-session-backend.js"; +import { + CodexAcpxDriver, + type CodexAcpxDriverOptions, +} from "../drivers/acpx/codex-acpx-driver.js"; +import { resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js"; +import { HarnessDriverBackend } from "./harness-driver-backend.js"; +import { + nativeSystemInstructions, + nativeTaskConstraints, +} from "./runtime-context.js"; + +export interface CodexAcpxNativeSessionBackendOptions extends Omit< + CodexAcpxDriverOptions, + "model" | "permissionMode" | "systemInstructions" +> {} + +/** + * Constructs the qualified Codex ACPX backend. Other ACPX agents remain + * unavailable until their runtime, policy, and conformance slices ship. + */ +export function createCodexAcpxNativeSessionBackend( + input: NativeExecutionInput, + options: CodexAcpxNativeSessionBackendOptions, +): NativeSessionBackend { + if (input.provider.kind !== "acpx" || input.provider.agent !== "codex") { + throw new Error( + "Codex ACPX backend requires provider kind acpx with agent codex", + ); + } + const qualifiedProfile = resolveQualifiedAcpxProfile( + "codex", + input.provider.model, + ); + for (const field of [ + "driverKind", + "protocolVersion", + "acpxVersion", + "agent", + "agentProfileVersion", + "agentServerPackage", + "agentServerVersion", + "agentRuntimePackage", + "agentRuntimeVersion", + "commandDigest", + ] as const) { + if (input.provider.profile[field] !== qualifiedProfile[field]) { + throw new Error( + `Persisted Codex ACPX profile does not match the qualified ${field}`, + ); + } + } + + const constraints = nativeTaskConstraints(input); + const systemInstructions = [ + nativeSystemInstructions(input), + "", + "Paperclip Runner constraints:", + ...constraints.map((constraint) => `- ${constraint}`), + ].join("\n"); + + return new HarnessDriverBackend( + new CodexAcpxDriver({ + ...options, + model: input.provider.model, + permissionMode: input.provider.permissionMode ?? "approve-reads", + systemInstructions, + }), + ); +} 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 57a644b341..004193d6b4 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -55,6 +55,44 @@ function execution( }; } +function acpxExecution(agent: "codex" | "pi" = "codex"): NativeExecutionInput { + return { + ...execution(), + session: { + normalizedSessionId: "session", + driverKind: "acpx_runtime", + protocolVersion: 1, + lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null }, + }, + provider: { + kind: "acpx", + agent, + model: + agent === "codex" + ? "gpt-5.6-sol" + : "openrouter/deepseek/deepseek-v4-flash-0731", + permissionPolicy: "interactive", + profile: { + driverKind: "acpx_runtime", + protocolVersion: 1, + acpxVersion: "0.13.1", + agent, + agentProfileVersion: 1, + agentServerPackage: + agent === "codex" ? "@agentclientprotocol/codex-acp" : "pi-acp", + agentServerVersion: agent === "codex" ? "1.6.2" : "0.0.33", + agentRuntimePackage: + agent === "codex" ? null : "@earendil-works/pi-coding-agent", + agentRuntimeVersion: agent === "codex" ? null : "0.84.2", + commandDigest: + agent === "codex" + ? "sha256:94049b3e3c3aee87de62703786e4fa81d031d7bd979f99bdf516d84f28791a79" + : "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f", + }, + }, + }; +} + describe("native backend factory", () => { it("constructs the Codex backend without starting its transport", async () => { const backend = createNativeSessionBackend(execution(), { @@ -75,21 +113,65 @@ describe("native backend factory", () => { it("fails closed when a deferred provider reaches the factory", () => { expect(() => - createNativeSessionBackend(execution({ - kind: "opencode", - model: "openrouter/model", - })), + createNativeSessionBackend( + execution({ + kind: "opencode", + model: "openrouter/model", + }), + ), ).toThrow( "Native backend for opencode is not included in the Codex-first runner", ); }); + it("constructs the qualified Codex ACPX backend without starting ACPX", async () => { + const backend = createNativeSessionBackend(acpxExecution(), { + acpxRuntimeDirectory: "/runtime", + }); + + await expect(backend.descriptor()).resolves.toMatchObject({ + kind: "runner", + name: "acpx_runtime", + version: "0.13.1", + capabilities: { + resume: false, + interruption: true, + dynamicTools: true, + }, + }); + }); + + it("requires an explicit runtime root and keeps other ACPX agents disabled", () => { + expect(() => createNativeSessionBackend(acpxExecution())).toThrow( + "requires an instance runtime directory", + ); + expect(() => + createNativeSessionBackend(acpxExecution("pi"), { + acpxRuntimeDirectory: "/runtime", + }), + ).toThrow("ACPX backend for pi is not included"); + }); + + it("rejects a Codex ACPX snapshot that drifts from its qualified profile", () => { + const input = acpxExecution(); + if (input.provider.kind !== "acpx") throw new Error("invalid fixture"); + input.provider.profile.commandDigest = `sha256:${"a".repeat(64)}`; + + expect(() => + createNativeSessionBackend(input, { + acpxRuntimeDirectory: "/runtime", + }), + ).toThrow("does not match the qualified commandDigest"); + }); + it("guards the provider-specific constructor as a second boundary", () => { expect(() => - createCodexNativeSessionBackend(execution({ - kind: "opencode", - model: "openrouter/model", - })), + createCodexNativeSessionBackend( + execution({ + kind: "opencode", + model: "openrouter/model", + }), + ), ).toThrow("Codex native backend requires provider kind codex"); }); }); diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.ts b/packages/paperclip-runner/src/backends/native-backend-factory.ts index 0ce8ebe458..8148034ade 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.ts @@ -8,12 +8,22 @@ import { createCodexNativeSessionBackend, type CodexNativeSessionBackendOptions, } from "./codex-native-backend.js"; +import { + createCodexAcpxNativeSessionBackend, + type CodexAcpxNativeSessionBackendOptions, +} from "./codex-acpx-native-backend.js"; -export interface NativeBackendFactoryOptions - extends Omit { +export interface NativeBackendFactoryOptions extends Omit< + CodexNativeSessionBackendOptions, + "transportFactory" +> { codexTransportFactory?: (context?: { providerRecoveryPolicy?: PersistedNativeSession["providerRecoveryPolicy"]; }) => CodexAppServerTransport; + acpxRuntimeDirectory?: string; + acpxEnvironment?: NodeJS.ProcessEnv; + acpxManagedCodexCredentialSourcePath?: string; + acpxDynamicToolHandler?: CodexAcpxNativeSessionBackendOptions["dynamicToolHandler"]; } /** @@ -25,6 +35,26 @@ export function createNativeSessionBackend( input: NativeExecutionInput, options: NativeBackendFactoryOptions = {}, ): NativeSessionBackend { + if (input.provider.kind === "acpx") { + if (input.provider.agent !== "codex") { + throw new Error( + `Native ACPX backend for ${input.provider.agent} is not included in the Codex-first runner`, + ); + } + if (!options.acpxRuntimeDirectory?.trim()) { + throw new Error( + "Codex ACPX backend requires an instance runtime directory", + ); + } + return createCodexAcpxNativeSessionBackend(input, { + runtimeDirectory: options.acpxRuntimeDirectory, + environment: options.acpxEnvironment, + managedCodexCredentialSourcePath: + options.acpxManagedCodexCredentialSourcePath, + dynamicTools: options.dynamicTools, + dynamicToolHandler: options.acpxDynamicToolHandler, + }); + } if (input.provider.kind !== "codex") { throw new Error( `Native backend for ${input.provider.kind} is not included in the Codex-first runner`,