feat(runner): wire the Codex ACPX backend (#12406)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The runner package gives provider runtimes one normalized session
boundary.
> - The merged Codex ACPX driver implements that boundary and keeps its
cleanup ownership bounded.
> - The package backend factory still needs a narrow route from a
persisted Codex ACPX execution input to that qualified driver.
> - The route must validate the stored qualification snapshot and reject
unsupported providers before runtime admission.
> - This pull request wires only the qualified Codex ACPX driver into
the package-local backend factory.
> - The benefit is a fail-closed backend construction path without
enabling a server adapter, changing execution selection, or adding
another provider.

## Linked Issues or Issue Description

Refs #12405

This pull request builds on the Codex ACPX harness driver merged in
#12405. It adds only the package-local backend factory route for that
driver.

## What Changed

- Add an internal Codex ACPX native backend constructor.
- Require provider kind `acpx` and agent `codex` at the
provider-specific boundary.
- Resolve the qualified Codex ACPX profile for the requested model.
- Compare the persisted driver kind, protocol version, ACPX version,
agent profile, package versions, runtime package fields, and command
digest with the qualified profile.
- Compose the existing native system instructions and task constraints
for the Codex ACPX driver.
- Route qualified Codex ACPX inputs through the native backend factory
only when the caller supplies an explicit instance runtime directory.
- Pass the scoped environment, managed credential source, dynamic tools,
and ACPX tool handler through the factory boundary.
- Keep Pi, Claude, OpenCode, managed Claude, AgentCore, and every other
deferred ACPX agent unavailable.
- Add factory tests for qualified construction, the explicit
runtime-root requirement, unsupported ACPX agents, and
qualification-snapshot drift.
- Keep ACPX construction lazy. Reading the backend descriptor does not
start ACPX transport or a provider process.

## Verification

- Replay base: `74aabb7ea6c5cf373f2254e5f854a5233b745ddc` (`master`
after #12405 merged).
- Exact replay head: `827090a0cff870eedb99fa1b5cfda8bb87efa515`.
- Stable patch ID for the exact replay delta:
`e0f6733a5a124a4fb93155257358b966c354a176` (identical to the original
narrow source patch).
- The exact pull request delta contains exactly three files:
- `packages/paperclip-runner/src/backends/codex-acpx-native-backend.ts`
  - `packages/paperclip-runner/src/backends/native-backend-factory.ts`
-
`packages/paperclip-runner/src/backends/native-backend-factory.test.ts`
- The exact source delta is 193 additions and 10 deletions.
- This delta does not change dependencies, `pnpm-lock.yaml`, workspace
configuration, workflows, migrations, server selection, UI behavior, or
public package exports.
- Focused GitHub test coverage: **PASS**. The exact-head Build job
passed all six native backend factory tests and all 720 TypeScript
runner tests.
- GitHub Actions: **PASS** for exact head
`827090a0cff870eedb99fa1b5cfda8bb87efa515`. Every applicable job passed.
Failed-job-only reruns cleared unrelated database timeout and server
concurrency flakes without changing the patch. The Storybook visual
regression skipped intentionally because this backend-only delta does
not touch UI or Storybook paths.
- Security checks: **PASS** for the exact head. Superagent, Snyk, both
Socket checks, and contributor trust completed successfully.
- Greptile: **5/5** for the exact head, with no open P1/P2 findings,
recommendations, or follow-ups.
- No local test result is claimed. GitHub Actions is the authoritative
verification environment for the replayed revision.

## Risks

This change has low package-local runtime risk. It adds a new branch to
the native backend factory, but no server or runnerd factory selects an
ACPX execution in this pull request. The route fails closed when the
runtime directory is absent, the agent is not Codex, or the persisted
qualification snapshot differs from the qualified Codex profile. The
constructor repeats the provider-kind and agent checks as a second
boundary. Existing direct adapters do not use this route. The change
does not add a migration, dependency, lockfile update, workflow, UI
surface, public export, or production rollout flag.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex with GPT-5.6, extended reasoning, repository tool use, and
code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`) and contains
no internal Paperclip ticket id or instance-derived details
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P1/P2 findings, recommendations, or
follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Dotta 2026-08-30 21:11:36 -05:00 committed by GitHub
parent 74aabb7ea6
commit 30ef14edd4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 193 additions and 10 deletions

View File

@ -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,
}),
);
}

View File

@ -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");
});
});

View File

@ -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<CodexNativeSessionBackendOptions, "transportFactory"> {
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`,