feat(runner): activate qualified Claude ACPX runtime (#12590)

## Thinking Path

> - Paperclip Runner already has a hardened ACPX path for Codex.
> - Claude can reuse that protocol only with an exact package/model
profile and provider-lifetime fencing.
> - Pi needs a separately spawned runtime whose executable does not yet
have the descriptor-confined verified launch used by the ACP server.
> - This pull request therefore activates Claude only and keeps Pi
unavailable before installation or process launch.

## Linked Issues or Issue Description

**Subsystem affected**

Paperclip Runner ACPX driver, runtime host, sidecar, backend factory,
package dependency, and provider conformance tests.

**Problem or motivation**

The production ACPX backend was Codex-only. Claude needs the same
fail-closed model, authorization, cancellation, cleanup, and recovery
boundaries without exposing an unsafe secondary runtime path.

**Proposed solution**

Generalize the hardened ACPX runtime for the exact qualified `claude`
profile, add the pinned Claude ACP package and reviewed isolation patch,
and reject Pi before installation, backend construction, sidecar
initialization, Rust session admission, or process creation.

**Alternatives considered**

Activating Pi in this PR was rejected after security review because its
secondary runtime executable was pathname-based and lacked the verified
descriptor/snapshot boundary. Pi is deferred to a dedicated follow-up.
Replaying the older generic ACPX implementation was rejected because it
predates current hardening.

**Roadmap alignment**

ROADMAP.md does not list a conflicting ACPX-provider project. This
extends the existing Runner provider architecture.

## What Changed

- Generalized the ACPX backend, driver, runtime adapter, host, and
sidecar for the qualified Claude profile.
- Added Claude ACPX activation through its exact pinned package/model
pair and isolated-settings patch.
- Added provider-lifetime fencing for non-Codex qualified ACPX sessions.
- Kept Pi dependencies and its patch out of the package and build
configuration.
- Added fail-closed Pi rejection at driver validation, backend
construction, runtime-host admission, sidecar initialization, and Rust
session validation.
- Added focused tests for Claude selection, model enforcement, lifecycle
fencing, cancellation, recovery, and Pi rejection.
- Did not change or commit `pnpm-lock.yaml`; CI regenerates the PR
lockfile under the existing repository policy.

## Verification

- GitHub Actions is the authoritative verification environment for this
PR.
- CI runs dependency policy, runner package checks, protocol parity,
typecheck, build, security, and stack policy.
- Local tests were not run because this checkout is resource
constrained, per the requested workflow.

## Risks

- Claude package behavior can drift from the qualified protocol; the
package and patch are pinned and admission verifies the exact profile.
- Unsupported providers and models fail closed.
- Pi remains unavailable until descriptor-confined verified launch
exists for its separate runtime.
- Existing Codex ACPX behavior remains covered by shared conformance
tests.

> 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.

## Model Used

OpenAI Codex, GPT-5.6, with repository tools, code execution, and
parallel agent review.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used
- [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
- [x] I have described the issue in the PR
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name contains no internal task identifier
- [ ] I have run tests locally and they pass — GitHub Actions is
authoritative for this resource-constrained checkout
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation
- [x] I have considered and documented risks
- [ ] All applicable Paperclip CI gates are green
- [ ] Greptile is 5/5 with no actionable findings

## Stack

- Position: lowest unmerged PR
- Base: `master`
- Previous:
[#12588](https://github.com/paperclipai/paperclip/pull/12588), merged
qualified OpenCode runtime
- Next: [#12591](https://github.com/paperclipai/paperclip/pull/12591),
native application integration
This commit is contained in:
Dotta 2026-08-31 11:26:08 -05:00 committed by GitHub
parent 2e5a24e177
commit ad0ad43cf4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 519 additions and 151 deletions

View File

@ -89,6 +89,7 @@
"embedded-postgres@18.1.0-beta.16": "patches/embedded-postgres@18.1.0-beta.16.patch",
"acpx@0.12.0": "patches/acpx@0.12.0.patch",
"acpx@0.13.1": "patches/acpx@0.13.1.patch",
"@agentclientprotocol/claude-agent-acp@0.70.0": "patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch",
"@agentclientprotocol/codex-acp@1.6.2": "patches/@agentclientprotocol__codex-acp@1.6.2.patch"
},
"overrides": {

View File

@ -20,15 +20,15 @@ The package also publishes the canonical semantic action declarations and
their input and output schemas. Its package-local dispatcher projects only
bound, run-authorized actions and emits redacted semantic receipts.
The production-selected provider remains Codex. The package also contains the
qualified OpenCode 1.18.17 driver, authenticated loopback MCP bridge, and
runnerd-compatible proxy, but this slice does not authorize the server to
select OpenCode for a fresh native run. Dynamic semantic tools remain
undiscoverable unless the hidden server coordinator projects one of the five
same-task read bindings for an already persisted native Codex run. Catalog
membership alone does not grant authority. The server can now create and start
a Codex-backed native run only through the default-off `paperclip_runner`
adapter. See
The first and only provider selected directly by runnerd remains Codex. The
package also contains the qualified OpenCode 1.18.17 proxy and the bounded ACPX
sidecar for Codex and Claude, but this slice does not authorize the server
to select those additional providers for a fresh native run. Dynamic semantic
tools remain undiscoverable unless the hidden server coordinator projects one
of the five same-task read bindings for an already persisted native Codex run.
Catalog membership alone does not grant authority. The server can now create
and start a Codex-backed native run only through the default-off
`paperclip_runner` adapter. See
[`SEMANTIC_ACTIONS.md`](SEMANTIC_ACTIONS.md) for the catalog boundary.
The package has two initial public surfaces:
@ -60,10 +60,13 @@ and preserves exact provider session identity for recovery. The backend factory
requires an explicit runtime directory before constructing this provider.
The package also builds `paperclip-runner-acpx-sidecar`. This bounded v2
stdin/stdout bridge admits the qualified Codex ACPX profile only. It validates
the exact model, session identity, tool catalog, structured input, and terminal
settlement at the process boundary. Runnerd and the server do not select this
sidecar in this slice. Other ACPX agents remain unavailable.
stdin/stdout bridge admits the closed Claude and Codex ACPX profiles. It
validates each agent's exact package/model pair, session identity, tool catalog,
structured input, and terminal settlement at the process boundary. Claude runs
without ambient project or local settings. Pi remains unavailable until its
separately spawned runtime can use the same descriptor-confined verified launch
boundary as the ACP server. Runnerd and the server do not select this sidecar
in this slice.
The Rust core includes a bounded client for this sidecar protocol. It enforces
request identity, event order, frame and queue limits, timeouts, redacted
@ -100,7 +103,7 @@ unresolved turn-scoped requests. This reducer still does not select ACPX in
runnerd.
The package-local session bootstrap starts the bounded sidecar transport,
verifies the Codex-only capability handshake and effective model, opens one
verifies the selected qualified capability handshake and effective model, opens one
identity-bound session, and confirms its run attachment. Any failed bootstrap
terminates the process; session shutdown preserves persistent provider state.
The session can then start one immutable-workspace turn, request interruption,
@ -118,9 +121,8 @@ against the exact persisted question IDs, answer modes, options, required
answers, custom-answer policy, and text constraints before provider delivery.
Tool results and structured question responses then use two-phase resolution:
validate retained identity and schema, require the exact sidecar
acknowledgement, and only then clear pending local state. Codex permission
requests violate its pinned sidecar policy and terminate the session fail
closed.
acknowledgement, and only then clear pending local state. Permission events that
escape the pinned sidecar policy terminate the session fail closed.
Safe suspension is available only with no active turn or pending request. The
sidecar must return the exact persistent session identity before runnerd
terminates the local process.

View File

@ -56,6 +56,7 @@
"trace:conformance:rust": "cargo run --quiet --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin conformance-tracer"
},
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "0.70.0",
"@agentclientprotocol/codex-acp": "1.6.2",
"acpx": "0.13.1",
"ajv": "^8.20.0",

View File

@ -69,10 +69,20 @@ pub struct AcpxProviderSessionConfig {
impl AcpxProviderSessionConfig {
pub fn validate(&self) -> Result<(), LocalRunnerError> {
self.transport.validate()?;
if self.agent != "codex" {
return Err(LocalRunnerError::invalid(
"the initial ACPX provider session supports Codex only",
));
let qualified_model = match self.agent.as_str() {
"claude" => "claude-sonnet-5",
"codex" => "gpt-5.6-sol",
_ => {
return Err(LocalRunnerError::invalid(
"ACPX agent must be claude or codex",
))
}
};
if self.model != qualified_model {
return Err(LocalRunnerError::invalid(format!(
"ACPX {} profile requires exact model {qualified_model}",
self.agent
)));
}
validate_text(&self.model, MAX_MODEL_CHARS, "ACPX model")?;
validate_stable_id(&self.run_id, SHORT_STABLE_ID_CHARS, "ACPX run id")?;
@ -513,7 +523,7 @@ impl AcpxProviderSession {
}
AcpxProviderStateEvent::PermissionRequest { .. } => {
return Err(self.fail_closed(LocalRunnerError::invalid(
"ACPX Codex permission request violated the pinned runner policy",
"ACPX permission request violated the pinned runner policy",
)));
}
_ => {}

View File

@ -135,7 +135,8 @@ fn admits_recovery_only_for_the_exact_run_catalog_and_provider_identity() {
operations,
};
let mut changed_model = config.clone();
changed_model.model = "gpt-5.6-sol-mini".to_owned();
changed_model.agent = "claude".to_owned();
changed_model.model = "claude-sonnet-5".to_owned();
let mut changed_permission = config.clone();
changed_permission.permission_mode = AcpxPermissionMode::ApproveAll;
let mut changed_expected_identity = config.clone();

View File

@ -87,10 +87,10 @@ fn bootstraps_a_codex_session_and_confirms_run_identity() {
}
#[test]
fn validates_codex_policy_and_tool_catalog_before_spawning() {
fn validates_qualified_policy_and_tool_catalog_before_spawning() {
let mut invalid_agent = config("bootstrap");
invalid_agent.agent = "opencode".to_owned();
assert!(start_error(&invalid_agent).contains("Codex only"));
invalid_agent.agent = "pi".to_owned();
assert!(start_error(&invalid_agent).contains("claude or codex"));
let mut unpinned = config("bootstrap");
unpinned.permission_mode_pinned = false;
@ -101,6 +101,24 @@ fn validates_codex_policy_and_tool_catalog_before_spawning() {
assert!(start_error(&invalid_tools).contains("authorized tools"));
}
#[test]
fn admits_each_exact_qualified_agent_model_pair() {
for (agent, model) in [("codex", "gpt-5.6-sol"), ("claude", "claude-sonnet-5")] {
let mut qualified = config("bootstrap");
qualified.agent = agent.to_owned();
qualified.model = model.to_owned();
qualified.validate().unwrap();
}
let mut drifted = config("bootstrap");
drifted.agent = "claude".to_owned();
assert!(drifted
.validate()
.unwrap_err()
.to_string()
.contains("exact model"));
}
#[test]
fn rejects_a_sidecar_that_reports_another_effective_model() {
let error = start_error(&config("bootstrap-wrong-model"));

View File

@ -16,21 +16,27 @@ export interface CodexAcpxNativeSessionBackendOptions extends Omit<
"model" | "permissionMode" | "systemInstructions"
> {}
export type AcpxNativeSessionBackendOptions =
CodexAcpxNativeSessionBackendOptions;
/**
* Constructs the qualified Codex ACPX backend. Other ACPX agents remain
* unavailable until their runtime, policy, and conformance slices ship.
* Constructs a backend only after the persisted ACPX snapshot matches the
* closed, package-owned qualification profile for its selected agent.
*/
export function createCodexAcpxNativeSessionBackend(
export function createAcpxNativeSessionBackend(
input: NativeExecutionInput,
options: CodexAcpxNativeSessionBackendOptions,
options: AcpxNativeSessionBackendOptions,
): NativeSessionBackend {
if (input.provider.kind !== "acpx" || input.provider.agent !== "codex") {
if (input.provider.kind !== "acpx") {
throw new Error("ACPX backend requires provider kind acpx");
}
if (input.provider.agent === "pi") {
throw new Error(
"Codex ACPX backend requires provider kind acpx with agent codex",
"Pi ACPX backend is unavailable until descriptor-confined verified launch is implemented",
);
}
const qualifiedProfile = resolveQualifiedAcpxProfile(
"codex",
input.provider.agent,
input.provider.model,
);
for (const field of [
@ -47,7 +53,7 @@ export function createCodexAcpxNativeSessionBackend(
] as const) {
if (input.provider.profile[field] !== qualifiedProfile[field]) {
throw new Error(
`Persisted Codex ACPX profile does not match the qualified ${field}`,
`Persisted ${input.provider.agent} ACPX profile does not match the qualified ${field}`,
);
}
}
@ -63,9 +69,23 @@ export function createCodexAcpxNativeSessionBackend(
return new HarnessDriverBackend(
new CodexAcpxDriver({
...options,
agent: input.provider.agent,
model: input.provider.model,
permissionMode: input.provider.permissionMode ?? "approve-reads",
systemInstructions,
}),
);
}
/** Backward-compatible Codex-specific constructor. */
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",
);
}
return createAcpxNativeSessionBackend(input, options);
}

View File

@ -55,7 +55,9 @@ function execution(
};
}
function acpxExecution(agent: "codex" | "pi" = "codex"): NativeExecutionInput {
function acpxExecution(
agent: "codex" | "pi" | "claude" = "codex",
): NativeExecutionInput {
return {
...execution(),
session: {
@ -70,7 +72,9 @@ function acpxExecution(agent: "codex" | "pi" = "codex"): NativeExecutionInput {
model:
agent === "codex"
? "gpt-5.6-sol"
: "openrouter/deepseek/deepseek-v4-flash-0731",
: agent === "pi"
? "openrouter/deepseek/deepseek-v4-flash-0731"
: "claude-sonnet-5",
permissionPolicy: "interactive",
profile: {
driverKind: "acpx_runtime",
@ -79,15 +83,22 @@ function acpxExecution(agent: "codex" | "pi" = "codex"): NativeExecutionInput {
agent,
agentProfileVersion: 1,
agentServerPackage:
agent === "codex" ? "@agentclientprotocol/codex-acp" : "pi-acp",
agentServerVersion: agent === "codex" ? "1.6.2" : "0.0.33",
agent === "codex"
? "@agentclientprotocol/codex-acp"
: agent === "pi"
? "pi-acp"
: "@agentclientprotocol/claude-agent-acp",
agentServerVersion:
agent === "codex" ? "1.6.2" : agent === "pi" ? "0.0.33" : "0.70.0",
agentRuntimePackage:
agent === "codex" ? null : "@earendil-works/pi-coding-agent",
agentRuntimeVersion: agent === "codex" ? null : "0.84.2",
agent === "pi" ? "@earendil-works/pi-coding-agent" : null,
agentRuntimeVersion: agent === "pi" ? "0.84.2" : null,
commandDigest:
agent === "codex"
? "sha256:94049b3e3c3aee87de62703786e4fa81d031d7bd979f99bdf516d84f28791a79"
: "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f",
: agent === "pi"
? "sha256:8c696f38296d53d0061fa11534570c5ddd951b63532aed30e0f1fcc676dc169f"
: "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
},
},
};
@ -168,15 +179,32 @@ describe("native backend factory", () => {
});
});
it("requires an explicit runtime root and keeps other ACPX agents disabled", () => {
it("requires an explicit runtime root", () => {
expect(() => createNativeSessionBackend(acpxExecution())).toThrow(
"requires an instance runtime directory",
);
});
it.each(["claude" as const])(
"constructs the qualified %s ACPX backend",
async (agent) => {
const backend = createNativeSessionBackend(acpxExecution(agent), {
acpxRuntimeDirectory: "/runtime",
});
await expect(backend.descriptor()).resolves.toMatchObject({
name: "acpx_runtime",
version: "0.13.1",
});
},
);
it("rejects Pi before constructing an ACPX backend", () => {
expect(() =>
createNativeSessionBackend(acpxExecution("pi"), {
acpxRuntimeDirectory: "/runtime",
}),
).toThrow("ACPX backend for pi is not included");
).toThrow("descriptor-confined verified launch");
});
it("rejects a Codex ACPX snapshot that drifts from its qualified profile", () => {

View File

@ -9,7 +9,7 @@ import {
type CodexNativeSessionBackendOptions,
} from "./codex-native-backend.js";
import {
createCodexAcpxNativeSessionBackend,
createAcpxNativeSessionBackend,
type CodexAcpxNativeSessionBackendOptions,
} from "./codex-acpx-native-backend.js";
import { createOpenCodeNativeSessionBackend } from "./opencode-native-backend.js";
@ -56,21 +56,23 @@ export function createNativeSessionBackend(
});
}
if (input.provider.kind === "acpx") {
if (input.provider.agent !== "codex") {
if (input.provider.agent === "pi") {
throw new Error(
`Native ACPX backend for ${input.provider.agent} is not included in the Codex-first runner`,
"Native ACPX backend for pi is unavailable until descriptor-confined verified launch is implemented",
);
}
if (!options.acpxRuntimeDirectory?.trim()) {
throw new Error(
"Codex ACPX backend requires an instance runtime directory",
);
throw new Error("ACPX backend requires an instance runtime directory");
}
return createCodexAcpxNativeSessionBackend(input, {
return createAcpxNativeSessionBackend(input, {
runtimeDirectory: options.acpxRuntimeDirectory,
environment: options.acpxEnvironment,
managedCodexCredentialSourcePath:
options.acpxManagedCodexCredentialSourcePath,
...(input.provider.agent === "codex"
? {
managedCodexCredentialSourcePath:
options.acpxManagedCodexCredentialSourcePath,
}
: {}),
dynamicTools: options.dynamicTools,
dynamicToolHandler: options.acpxDynamicToolHandler,
});

View File

@ -28,7 +28,7 @@ afterEach(async () => {
children.clear();
});
describe("Codex ACPX runtime sidecar", () => {
describe("qualified ACPX runtime sidecar", () => {
it("keeps session admission closed while any cleanup owner remains", () => {
const cleanup = Promise.resolve();
@ -428,9 +428,31 @@ describe("Codex ACPX runtime sidecar", () => {
});
});
it.each([["claude", "claude-sonnet-5"]])(
"reports the qualified %s profile",
async (agent, model) => {
const sidecar = startSidecar();
sidecar.write(initializeRequest(1, agent, model));
await expect(
sidecar.next((frame) => frame.id === 1),
).resolves.toMatchObject({
id: 1,
ok: true,
result: { profile: { agent, qualificationModel: model } },
});
},
);
it("fails closed after an unsupported provider bootstrap", async () => {
const sidecar = startSidecar();
sidecar.write(initializeRequest(1, "pi"));
sidecar.write(
initializeRequest(
1,
"pi",
"openrouter/deepseek/deepseek-v4-flash-0731",
),
);
await expect(
sidecar.next((frame) => frame.id === 1),
@ -439,7 +461,7 @@ describe("Codex ACPX runtime sidecar", () => {
ok: false,
error: {
code: "acpx_sidecar_command_failed",
message: "This production ACPX sidecar supports Codex only",
message: "ACPX agent must be claude or codex",
retryable: false,
},
});
@ -461,12 +483,16 @@ describe("Codex ACPX runtime sidecar", () => {
});
});
function initializeRequest(id: number, agent: string): Record<string, unknown> {
function initializeRequest(
id: number,
agent: string,
model = "gpt-5.6-sol",
): Record<string, unknown> {
return {
protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION,
id,
command: "initialize",
params: { agent, model: "gpt-5.6-sol" },
params: { agent, model },
};
}

View File

@ -22,7 +22,10 @@ import {
type NormalizedAcpForm,
} from "../drivers/acpx/acp-question-adapter.js";
import { openCodexAcpxRuntime } from "../drivers/acpx/codex-runtime-adapter.js";
import { resolveQualifiedAcpxProfile } from "../drivers/acpx/qualified-profiles.js";
import {
resolveQualifiedAcpxProfile,
type QualifiedAcpxAgent,
} from "../drivers/acpx/qualified-profiles.js";
import {
AcpxRuntimeHost,
type AcpxRetainedCleanupFailure,
@ -114,6 +117,7 @@ let closing = false;
let shutdownRequested = false;
let pendingInput = Promise.resolve();
let bootstrapFailure: Error | null = null;
let initializedAgent: QualifiedAcpxAgent | null = null;
let initializedModel: string | null = null;
const tools = new Map<string, PendingTool>();
const inputs = new Map<string, PendingInput>();
@ -194,9 +198,10 @@ async function dispatch(
if (request.command === "initialize") {
if (initializedModel)
throw new Error("ACPX sidecar is already initialized");
requireCodexAgent(request.params.agent);
const agent = requireQualifiedAgent(request.params.agent);
const model = requiredText(request.params.model, "model");
const profile = resolveQualifiedAcpxProfile("codex", model);
const profile = resolveQualifiedAcpxProfile(agent, model);
initializedAgent = agent;
initializedModel = model;
return {
protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION,
@ -223,15 +228,15 @@ async function dispatch(
}
if (!initializedModel) throw new Error("initialize the ACPX sidecar first");
const params = parseOpenParams(request.params);
if (params.model !== initializedModel) {
throw new Error("ACPX session model differs from its initialization");
if (params.agent !== initializedAgent || params.model !== initializedModel) {
throw new Error("ACPX session profile differs from its initialization");
}
const openedHost = await AcpxRuntimeHost.open(
{
runtimeDirectory: params.runtimeDirectory,
normalizedSessionId: params.normalizedSessionId,
workingDirectory: params.workingDirectory,
agent: "codex",
agent: params.agent,
model: params.model,
permissionMode: params.permissionMode,
systemInstructions: params.systemInstructions,
@ -322,7 +327,7 @@ async function dispatch(
}
if (request.command === "permission.resolve") {
throw new Error(
"Codex ACPX permissions are resolved by the admitted runner policy",
"ACPX permissions are resolved by the admitted runner policy",
);
}
if (request.command === "input.resolve") {
@ -424,7 +429,7 @@ async function dispatch(
if (request.command === "session.close") {
if (request.params.discardPersistentState === true) {
throw new Error(
"Codex ACPX persistent state cannot be discarded by this sidecar",
"ACPX persistent state cannot be discarded by this sidecar",
);
}
const closingTurnId = turnId;
@ -577,7 +582,7 @@ async function waitForInput(
if (!normalized) {
diagnostic(
"runtime_input_unsupported",
"The Codex ACPX provider requested an unsupported input mode.",
"The ACPX provider requested an unsupported input mode.",
);
return { action: "cancel" };
}
@ -604,7 +609,7 @@ async function waitForInput(
questionSet: normalized.questionSet,
origin: {
adapter: "acpx-runtime-sidecar",
provider: "codex",
provider: openParams?.agent ?? initializedAgent ?? "unknown",
method: "elicitation/create",
},
},
@ -848,12 +853,12 @@ function safeOutput(value: unknown): Record<string, unknown> {
function parseOpenParams(
value: Record<string, unknown>,
): AcpxSidecarOpenParams {
requireCodexAgent(value.agent);
const agent = requireQualifiedAgent(value.agent);
const model = requiredText(value.model, "model");
resolveQualifiedAcpxProfile("codex", model);
resolveQualifiedAcpxProfile(agent, model);
if (value.runtimeContext !== undefined && value.runtimeContext !== null) {
throw new Error(
"Codex ACPX sidecar runtime context must be pre-materialized",
"ACPX sidecar runtime context must be pre-materialized",
);
}
if (
@ -861,7 +866,7 @@ function parseOpenParams(
value.providerSessionKey !== null
) {
throw new Error(
"Codex ACPX replacement provider sessions are not available in this release",
"ACPX replacement provider sessions are not available in this release",
);
}
return {
@ -871,7 +876,7 @@ function parseOpenParams(
"normalizedSessionId",
),
workingDirectory: requiredText(value.workingDirectory, "workingDirectory"),
agent: "codex",
agent,
model,
permissionMode: requiredPermissionMode(value.permissionMode),
permissionModePinned: value.permissionModePinned === true,
@ -1004,10 +1009,11 @@ function requireHost(
return requireSidecarCommandHost(host, activeHostCleanup, options);
}
function requireCodexAgent(value: unknown): void {
if (value !== "codex") {
throw new Error("This production ACPX sidecar supports Codex only");
function requireQualifiedAgent(value: unknown): QualifiedAcpxAgent {
if (value !== "codex" && value !== "claude") {
throw new Error("ACPX agent must be claude or codex");
}
return value;
}
function requiredText(value: unknown, field: string): string {

View File

@ -130,7 +130,7 @@ describe("Codex ACPX harness driver", () => {
await session.close({ reason: "construction cleanup recovered" });
});
it("advertises only the implemented Codex production surface", async () => {
it("binds validation to the configured qualified agent", async () => {
const fixture = driverFixture();
const descriptor = await fixture.driver.descriptor();
@ -162,6 +162,38 @@ describe("Codex ACPX harness driver", () => {
});
});
it("opens the qualified Claude profile through the shared driver", async () => {
const fixture = driverFixture({
agent: "claude",
model: "claude-sonnet-5",
});
await expect(fixture.driver.descriptor()).resolves.toMatchObject({
displayName: "Claude via ACPX",
});
await expect(
fixture.driver.validateConfig({
agent: "claude",
model: "claude-sonnet-5",
permissionMode: "approve-reads",
}),
).resolves.toMatchObject({ ok: true });
const session = await fixture.driver.openSession({
runId: "run-claude",
normalizedSessionId: "session-1",
workingDirectory: "/workspace",
});
expect(fixture.hostOptions).toMatchObject({
agent: "claude",
model: "claude-sonnet-5",
});
expect(
fixture.hostOptions?.managedCodexCredentialSourcePath,
).toBeUndefined();
await session.close({ reason: "qualified Claude driver verified" });
});
it("maps one turn, dispatches tools, and commits one semantic result", async () => {
const dynamicToolHandler = vi.fn(async () => ({ title: "Document" }));
const fixture = driverFixture({ dynamicToolHandler });

View File

@ -59,6 +59,7 @@ import {
acpxDriverDescriptor,
validateAcpxDriverConfig,
} from "./driver-profile.js";
import type { QualifiedAcpxAgent } from "./qualified-profiles.js";
import {
ACPX_TURN_CANCELLATION_SHUTDOWN_BOUND_MS,
AcpxRuntimeHost,
@ -120,6 +121,8 @@ export interface CodexAcpxDynamicToolCall {
}
export interface CodexAcpxDriverOptions {
/** Defaults to Codex for backward compatibility. */
agent?: QualifiedAcpxAgent;
runtimeDirectory: string;
model: string;
permissionMode?: NativeAcpxPermissionMode;
@ -166,7 +169,7 @@ export interface CodexAcpxDriverDependencies {
}) => Promise<AcpxRecoveryWorkspaceLease>;
}
/** Codex-only HarnessDriver backed by the admitted ACPX runtime host. */
/** Qualified HarnessDriver backed by the admitted ACPX runtime host. */
export class CodexAcpxDriver implements HarnessDriver {
readonly #options: CodexAcpxDriverOptions;
readonly #openHost: NonNullable<CodexAcpxDriverDependencies["openHost"]>;
@ -183,8 +186,14 @@ export class CodexAcpxDriver implements HarnessDriver {
options: CodexAcpxDriverOptions,
dependencies: CodexAcpxDriverDependencies = {},
) {
if (options.agent === "pi") {
throw new Error(
"Pi ACPX driver is unavailable until descriptor-confined verified launch is implemented",
);
}
this.#options = {
...options,
agent: options.agent ?? "codex",
...(options.environment
? { environment: { ...options.environment } }
: {}),
@ -214,10 +223,9 @@ export class CodexAcpxDriver implements HarnessDriver {
}
async descriptor(): Promise<HarnessDriverDescriptor> {
const descriptor = acpxDriverDescriptor("codex");
const descriptor = acpxDriverDescriptor(this.#options.agent ?? "codex");
return {
...descriptor,
displayName: "Codex via ACPX",
runtimeContextCapabilities: {
instructions: "native",
skills: "unsupported",
@ -235,7 +243,10 @@ export class CodexAcpxDriver implements HarnessDriver {
async validateConfig(value: unknown): Promise<HarnessDriverConfigValidation> {
const validation = validateAcpxDriverConfig(value);
if (!validation.ok || validation.config.agent === "codex")
if (
!validation.ok ||
validation.config.agent === (this.#options.agent ?? "codex")
)
return validation;
return {
ok: false,
@ -244,7 +255,7 @@ export class CodexAcpxDriver implements HarnessDriver {
{
path: "agent",
code: "unsupported_agent",
message: "The production ACPX driver currently supports Codex only.",
message: `This ACPX driver is bound to ${this.#options.agent ?? "codex"}.`,
},
],
};
@ -355,7 +366,7 @@ export class CodexAcpxDriver implements HarnessDriver {
runtimeDirectory: this.#options.runtimeDirectory,
normalizedSessionId: input.normalizedSessionId,
workingDirectory: input.workingDirectory,
agent: "codex",
agent: this.#options.agent ?? "codex",
model: this.#options.model,
permissionMode: this.#options.permissionMode ?? "approve-reads",
systemInstructions: this.#options.systemInstructions,
@ -383,6 +394,7 @@ export class CodexAcpxDriver implements HarnessDriver {
input.signal?.throwIfAborted();
session = new CodexAcpxSession({
host,
agent: this.#options.agent ?? "codex",
input,
dynamicToolHandler: this.#options.dynamicToolHandler,
now: this.#options.now ?? (() => new Date()),
@ -625,6 +637,7 @@ export class CodexAcpxDriver implements HarnessDriver {
class CodexAcpxSession implements HarnessSession {
readonly #host: CodexAcpxHost;
readonly #agent: QualifiedAcpxAgent;
readonly #input: OpenHarnessSessionInput;
readonly #dynamicToolHandler?: CodexAcpxDriverOptions["dynamicToolHandler"];
readonly #now: () => Date;
@ -680,6 +693,7 @@ class CodexAcpxSession implements HarnessSession {
constructor(input: {
host: CodexAcpxHost;
agent: QualifiedAcpxAgent;
input: OpenHarnessSessionInput;
dynamicToolHandler?: CodexAcpxDriverOptions["dynamicToolHandler"];
now: () => Date;
@ -695,6 +709,7 @@ class CodexAcpxSession implements HarnessSession {
throw new Error("Codex ACPX host returned a different session identity");
}
this.#host = input.host;
this.#agent = input.agent;
this.#input = structuredClone(input.input);
this.#dynamicToolHandler = input.dynamicToolHandler;
this.#now = input.now;
@ -1545,7 +1560,7 @@ class CodexAcpxSession implements HarnessSession {
input: structuredClone(normalized.questionSet),
origin: {
adapter: "acpx-runtime",
provider: "codex",
provider: this.#agent,
method: "elicitation/create",
},
};

View File

@ -111,35 +111,72 @@ let nextCredentialLeaseGeneration = 0;
export type ManagedCodexCredentialMode =
"api_key" | "inline_json" | "managed_file";
export interface ManagedCodexCredentialLease {
readonly path: string;
readonly mode: ManagedCodexCredentialMode;
export interface AcpxProviderLifetimeLease {
/** Duplicate both quorum listeners into the provider lifetime sentinel. */
readonly lifetimeFenceFds: readonly [number, number];
/** Validate the guardian while the credential quorum is still held. */
/** Validate the guardian while the provider-lifetime quorum is still held. */
activateLifetimeOwner(pid: number): Promise<void>;
close(): Promise<void>;
}
export interface ManagedCodexCredentialLease
extends AcpxProviderLifetimeLease {
readonly path: string;
readonly mode: ManagedCodexCredentialMode;
}
/**
* Acquire the same kernel-backed provider lifetime ownership used by Codex
* when an ACPX agent has no staged credential home of its own.
*/
export async function acquireAcpxProviderLifetimeLease(input: {
agentHomeDirectory: string;
}): Promise<AcpxProviderLifetimeLease> {
const home = await resolvePrivateAgentHome(input.agentHomeDirectory);
const lock = await acquireCredentialHomeLock(home);
let closed = false;
let closeAttempt: Promise<void> | null = null;
let lifetimeOwnerAttempt: Promise<void> | null = null;
return Object.freeze({
lifetimeFenceFds: lock.inheritanceFds(),
async activateLifetimeOwner(pid: number): Promise<void> {
if (closed || closeAttempt !== null) {
throw new Error("ACPX provider lifetime lease is closing");
}
if (lifetimeOwnerAttempt !== null) return await lifetimeOwnerAttempt;
const attempt = lock.activateLifetimeOwner(pid);
lifetimeOwnerAttempt = attempt;
try {
await attempt;
} finally {
if (lifetimeOwnerAttempt === attempt) lifetimeOwnerAttempt = null;
}
},
async close(): Promise<void> {
if (closed) return;
if (closeAttempt !== null) return await closeAttempt;
const attempt = (async () => {
await lifetimeOwnerAttempt?.catch(() => undefined);
await lock.release();
closed = true;
})();
closeAttempt = attempt;
try {
await attempt;
} finally {
if (closeAttempt === attempt) closeAttempt = null;
}
},
});
}
/** Stage one explicit Codex authentication source in its isolated runtime home. */
export async function stageManagedCodexCredential(input: {
agentHomeDirectory: string;
environment?: NodeJS.ProcessEnv;
sourcePath?: string;
}): Promise<ManagedCodexCredentialLease> {
const home = await realpath(input.agentHomeDirectory);
const homeMetadata = await lstat(home);
if (!homeMetadata.isDirectory() || homeMetadata.isSymbolicLink()) {
throw new Error("Managed Codex credential home must be a real directory");
}
if (
process.platform !== "win32" &&
((homeMetadata.mode & 0o077) !== 0 ||
(typeof process.getuid === "function" &&
homeMetadata.uid !== process.getuid()))
) {
throw new Error("Managed Codex credential home permissions are unsafe");
}
const home = await resolvePrivateAgentHome(input.agentHomeDirectory);
// Join an older failed close before claiming the next generation. This
// keeps quarantine recovery authoritative over the shared paths without
// mistaking a waiting admission for an already-active successor.
@ -166,6 +203,23 @@ export async function stageManagedCodexCredential(input: {
}
}
async function resolvePrivateAgentHome(directory: string): Promise<string> {
const home = await realpath(directory);
const homeMetadata = await lstat(home);
if (!homeMetadata.isDirectory() || homeMetadata.isSymbolicLink()) {
throw new Error("ACPX agent home must be a real directory");
}
if (
process.platform !== "win32" &&
((homeMetadata.mode & 0o077) !== 0 ||
(typeof process.getuid === "function" &&
homeMetadata.uid !== process.getuid()))
) {
throw new Error("ACPX agent home permissions are unsafe");
}
return home;
}
async function stageClaimedManagedCodexCredential(
input: {
environment?: NodeJS.ProcessEnv;

View File

@ -13,6 +13,7 @@ import { describe, expect, it, vi } from "vitest";
import type { VerifiedAcpxCommandLease } from "./installation-integrity.js";
import { openCodexAcpxRuntime } from "./codex-runtime-adapter.js";
import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js";
import type { AcpxRuntimePortOpenOptions } from "./runtime-host.js";
const HANDLE: AcpRuntimeHandle = {
@ -100,6 +101,35 @@ describe("Codex ACPX runtime adapter", () => {
});
});
it.each([["claude" as const, "claude-sonnet-5"]])(
"opens the qualified %s session through the verified lease",
async (agent, model) => {
const runtime = fakeRuntime();
const command = fakeCommand();
const options = openOptions(command);
options.profile = resolveQualifiedAcpxProfile(agent, model);
options.launchEnvironment = { PATH: "/verified/bin" };
await openCodexAcpxRuntime(options, {
createRegistry: ({ overrides }) => {
expect(overrides).toEqual({
[agent]: ["paperclip-verified-acpx-command"],
});
return registry();
},
createStore: () => store(),
createRuntime: () => runtime,
});
expect(runtime.ensureSession).toHaveBeenCalledWith(
expect.objectContaining({
agent,
sessionOptions: expect.objectContaining({ model }),
}),
);
},
);
it("launches only through the verified command lease", async () => {
const runtime = fakeRuntime();
const command = fakeCommand();
@ -1137,7 +1167,7 @@ describe("Codex ACPX runtime adapter", () => {
{ createRuntime },
),
).rejects.toThrow(
"The production ACPX runtime requires an inherited credential-home fence",
"The production ACPX runtime requires an inherited provider-lifetime fence",
);
expect(createRuntime).not.toHaveBeenCalled();
@ -1157,7 +1187,7 @@ describe("Codex ACPX runtime adapter", () => {
{ createRuntime },
),
).rejects.toThrow(
"The production ACPX runtime requires an inherited credential-home fence",
"The production ACPX runtime requires an inherited provider-lifetime fence",
);
expect(createRuntime).not.toHaveBeenCalled();
@ -2397,22 +2427,6 @@ describe("Codex ACPX runtime adapter", () => {
await retainCleanup.mock.calls[3]?.[0];
});
it("rejects non-Codex profiles before constructing ACPX", async () => {
const createRuntime = vi.fn();
await expect(
openCodexAcpxRuntime(
{
...openOptions(fakeCommand()),
profile: {
...openOptions(fakeCommand()).profile,
agent: "claude",
},
},
{ createRuntime },
),
).rejects.toThrow("currently supports Codex only");
expect(createRuntime).not.toHaveBeenCalled();
});
});
function openOptions(

View File

@ -73,7 +73,7 @@ class AcpxSessionHandshakeTimeoutError extends Error {
}
}
export interface CodexAcpxRuntimeDependencies {
export interface QualifiedAcpxRuntimeDependencies {
createRuntime?: (options: AcpRuntimeOptions) => AcpRuntime;
createRegistry?: (input: {
overrides: Record<string, string | string[]>;
@ -92,14 +92,17 @@ export interface CodexAcpxRuntimeDependencies {
platform?: NodeJS.Platform;
}
/** @deprecated Use QualifiedAcpxRuntimeDependencies for provider-neutral ACPX runtimes. */
export type CodexAcpxRuntimeDependencies = QualifiedAcpxRuntimeDependencies;
/**
* Adapt the pinned ACPX library to Paperclip's admitted runtime port. The
* executable, launch environment, and spawn cwd stay host-owned and are never
* persisted in ACPX's session options.
*/
export async function openCodexAcpxRuntime(
export async function openQualifiedAcpxRuntime(
options: AcpxRuntimePortOpenOptions,
dependencies: CodexAcpxRuntimeDependencies = {},
dependencies: QualifiedAcpxRuntimeDependencies = {},
): Promise<AcpxRuntimePort> {
if (options.signal?.aborted) {
// The host may have already transferred its staged credential to this
@ -116,11 +119,6 @@ export async function openCodexAcpxRuntime(
// bounded sidecar exit and retained ownership of an unresponsive process
// tree when Node cannot safely signal a verified provider process group.
assertVerifiedAcpxProviderPlatform(dependencies.platform ?? process.platform);
if (options.profile.agent !== "codex") {
throw new Error(
"The production ACPX runtime currently supports Codex only",
);
}
options.signal?.throwIfAborted();
const credentialFenceFds = options.credentialFenceFds;
if (
@ -133,7 +131,7 @@ export async function openCodexAcpxRuntime(
typeof options.activateCredentialFenceOwner !== "function"
) {
throw new Error(
"The production ACPX runtime requires an inherited credential-home fence",
"The production ACPX runtime requires an inherited provider-lifetime fence",
);
}
@ -174,7 +172,7 @@ export async function openCodexAcpxRuntime(
backend: "acpx",
runtimeSessionName: encodeAcpxRuntimeHandleState({
name: runtimeSessionName,
agent: "codex",
agent: options.profile.agent,
cwd: record.cwd,
mode: "persistent",
acpxRecordId: record.acpxRecordId,
@ -221,7 +219,7 @@ export async function openCodexAcpxRuntime(
cwd: options.cwd,
sessionStore,
agentRegistry: createRegistry({
overrides: { codex: [VERIFIED_COMMAND_SENTINEL] },
overrides: { [options.profile.agent]: [VERIFIED_COMMAND_SENTINEL] },
}),
permissionMode: options.permissionMode,
elicitationModes: ["form"],
@ -282,7 +280,7 @@ export async function openCodexAcpxRuntime(
const handshake = Promise.resolve().then(() =>
runtime.ensureSession({
sessionKey: options.providerSessionKey,
agent: "codex",
agent: options.profile.agent,
mode: "persistent",
cwd: options.cwd,
sessionOptions: {
@ -380,6 +378,9 @@ export async function openCodexAcpxRuntime(
}
}
/** Backward-compatible name retained for existing Codex-only consumers. */
export const openCodexAcpxRuntime = openQualifiedAcpxRuntime;
function raceRuntimeHandshakeWithAbort<T>(
handshake: Promise<T>,
signal: AbortSignal,

View File

@ -46,7 +46,6 @@ describe("ACPX driver profile", () => {
});
it.each([
["pi", "openrouter/deepseek/deepseek-v4-flash-0731"],
["claude", "claude-sonnet-5"],
["codex", "gpt-5.6-sol"],
] as const)("accepts the exact qualified %s model", (agent, model) => {
@ -78,6 +77,15 @@ describe("ACPX driver profile", () => {
ok: false,
issues: [{ path: "model", code: "invalid_model" }],
});
expect(
validateAcpxDriverConfig({
agent: "pi",
model: "openrouter/deepseek/deepseek-v4-flash-0731",
}),
).toMatchObject({
ok: false,
issues: [{ path: "agent", code: "invalid_agent" }],
});
expect(
validateAcpxDriverConfig({
agent: "claude",

View File

@ -12,7 +12,7 @@ import {
type QualifiedAcpxAgent,
} from "./qualified-profiles.js";
const ACPX_AGENTS = ["pi", "claude", "codex"] as const;
const ACPX_AGENTS = ["claude", "codex"] as const;
const ACPX_PERMISSION_MODES = [
"approve-all",
"approve-reads",
@ -95,7 +95,7 @@ export function validateAcpxDriverConfig(
return invalid(
"agent",
"invalid_agent",
"ACPX agent must be pi, claude, or codex.",
"ACPX agent must be claude or codex.",
);
}
const model = text(config.model);

View File

@ -275,6 +275,29 @@ describe("ACPX runtime host", () => {
).rejects.toThrow();
});
it("rejects Pi before installation or runtime launch", async () => {
const fixture = await hostFixture();
const verifyInstallation = vi.fn();
const openRuntime = vi.fn();
await expect(
AcpxRuntimeHost.open(
{
...fixture.options,
agent: "pi" as never,
model: "openrouter/deepseek/deepseek-v4-flash-0731",
permissionMode: "approve-reads",
},
{
verifyInstallation,
openRuntime,
reportRetainedCleanupFailure: vi.fn(),
},
),
).rejects.toThrow("descriptor-confined verified launch");
expect(verifyInstallation).not.toHaveBeenCalled();
expect(openRuntime).not.toHaveBeenCalled();
});
it("selects and verifies Claude's qualified reported model", async () => {
const fixture = await hostFixture();
let selected = false;

View File

@ -11,8 +11,9 @@ import {
type RunnerToolBridgeOptions,
} from "../runner-tool-bridge.js";
import {
acquireAcpxProviderLifetimeLease,
stageManagedCodexCredential,
type ManagedCodexCredentialLease,
type AcpxProviderLifetimeLease,
} from "./codex-credentials.js";
import {
verifyQualifiedAcpxInstallation,
@ -124,7 +125,12 @@ export interface AcpxMcpServerBinding {
export type AcpxSemanticToolSession = Omit<RunnerToolBridgeOptions, "secret">;
export interface AcpxRetainedCleanupFailure {
resource: "credential" | "command" | "runtime" | "tool_bridge";
resource:
| "credential"
| "provider_lifetime"
| "command"
| "runtime"
| "tool_bridge";
attempt: number;
error: unknown;
}
@ -175,7 +181,7 @@ const RETAINED_CLEANUP_RETRY_INITIAL_DELAY_MS = 10;
const RETAINED_CLEANUP_RETRY_MAX_DELAY_MS = 1_000;
interface RetainedRejectedRuntimeAdmission {
readonly credential: ManagedCodexCredentialLease;
readonly credential: AcpxProviderLifetimeLease;
cleanup: Promise<void>;
}
@ -189,7 +195,7 @@ const retainedRejectedRuntimeAdmissions =
interface RetainedAcpxAdmissionCleanup {
readonly runtime: AcpxRuntimePort | null;
readonly toolBridge: RunnerToolBridge | null;
readonly credential: ManagedCodexCredentialLease | null;
readonly credential: AcpxProviderLifetimeLease | null;
readonly command: VerifiedAcpxCommandLease | null;
readonly reason: string;
recovery: Promise<void> | null;
@ -208,7 +214,7 @@ export class AcpxRuntimeHost {
readonly #binding: AcpxRecoveryBinding;
readonly #identity: AcpxIdentityRecord;
readonly #sandbox: AcpxRuntimeSandbox;
readonly #credential: ManagedCodexCredentialLease | null;
readonly #credential: AcpxProviderLifetimeLease | null;
readonly #command: VerifiedAcpxCommandLease;
readonly #toolBridge: RunnerToolBridge | null;
#activeTurn: AcpxRuntimeTurn | null = null;
@ -221,7 +227,7 @@ export class AcpxRuntimeHost {
binding: AcpxRecoveryBinding;
identity: AcpxIdentityRecord;
sandbox: AcpxRuntimeSandbox;
credential: ManagedCodexCredentialLease | null;
credential: AcpxProviderLifetimeLease | null;
command: VerifiedAcpxCommandLease;
toolBridge: RunnerToolBridge | null;
}) {
@ -239,6 +245,11 @@ export class AcpxRuntimeHost {
dependencies: AcpxRuntimeHostDependencies,
): Promise<AcpxRuntimeHost> {
options.signal?.throwIfAborted();
if (options.agent === "pi") {
throw new Error(
"ACPX pi is unavailable until its runtime has descriptor-confined verified launch",
);
}
const profile = resolveQualifiedAcpxProfile(options.agent, options.model);
const binding = await runAbortableAdmissionStage(options.signal, () =>
createAcpxRecoveryBinding({
@ -272,7 +283,7 @@ export class AcpxRuntimeHost {
throw new Error("Verified ACPX installation does not match its profile");
}
let command: VerifiedAcpxCommandLease | null = null;
let credential: ManagedCodexCredentialLease | null = null;
let credential: AcpxProviderLifetimeLease | null = null;
let toolBridge: RunnerToolBridge | null = null;
let runtime: AcpxRuntimePort | null = null;
let pendingRuntimeOwnsCredential = false;
@ -334,6 +345,18 @@ export class AcpxRuntimeHost {
reportFailure: (failure) =>
dependencies.reportRetainedCleanupFailure(failure),
});
} else {
credential = await acquireAbortableAdmissionResource({
signal: options.signal,
acquire: () =>
acquireAcpxProviderLifetimeLease({
agentHomeDirectory: sandbox.agentHomeDirectory,
}),
resource: "provider_lifetime",
releaseLate: (lateLifetime) => lateLifetime.close(),
reportFailure: (failure) =>
dependencies.reportRetainedCleanupFailure(failure),
});
}
command = await acquireAbortableAdmissionResource({
signal: options.signal,
@ -353,6 +376,10 @@ export class AcpxRuntimeHost {
dependencies.reportRetainedCleanupFailure(failure),
})
: null;
const admittedLifetime = credential;
if (admittedLifetime === null) {
throw new Error("ACPX provider lifetime lease is unavailable");
}
runtime = await acquireAbortableAdmissionResource({
signal: options.signal,
acquire: () => {
@ -368,11 +395,9 @@ export class AcpxRuntimeHost {
binding.permissionMode,
),
launchEnvironment: sandbox.launchEnvironment,
credentialFenceFds: credential?.lifetimeFenceFds ?? null,
credentialFenceFds: admittedLifetime.lifetimeFenceFds,
activateCredentialFenceOwner:
typeof credential?.activateLifetimeOwner === "function"
? credential.activateLifetimeOwner.bind(credential)
: null,
admittedLifetime.activateLifetimeOwner.bind(admittedLifetime),
systemInstructions: boundedInstructions(options.systemInstructions),
...(options.assertWorkspaceHeld === undefined
? {}
@ -674,7 +699,7 @@ function raceAdmissionWithAbort<T>(
function retainAbortedRuntimeAdmissionCleanup(input: {
pendingRuntime: Promise<AcpxRuntimePort>;
credential: ManagedCodexCredentialLease | null;
credential: AcpxProviderLifetimeLease | null;
reason: string;
failedAdmissionCleanupTransfer: Promise<void>;
}): void {
@ -688,7 +713,7 @@ function retainAbortedRuntimeAdmissionCleanup(input: {
async function cleanupAbortedRuntimeAdmission(
runtime: AcpxRuntimePort | null,
credential: ManagedCodexCredentialLease | null,
credential: AcpxProviderLifetimeLease | null,
reason: string,
): Promise<void> {
const cleanupError = await cleanupRuntimeResources(
@ -711,7 +736,7 @@ async function cleanupAbortedRuntimeAdmission(
function retainFailedAcpxAdmissionCleanup(input: {
runtime: AcpxRuntimePort | null;
toolBridge: RunnerToolBridge | null;
credential: ManagedCodexCredentialLease | null;
credential: AcpxProviderLifetimeLease | null;
command: VerifiedAcpxCommandLease | null;
reason: string;
}): void {
@ -913,7 +938,7 @@ async function boundedCancellation(
async function cleanupRuntimeResources(
runtime: AcpxRuntimePort | null,
toolBridge: RunnerToolBridge | null,
credential: ManagedCodexCredentialLease | null,
credential: AcpxProviderLifetimeLease | null,
command: VerifiedAcpxCommandLease | null,
reason: string,
): Promise<AggregateError | null> {

View File

@ -23,17 +23,23 @@ const codexPatch = await readFile(
),
"utf8",
);
const claudePatch = await readFile(
new URL(
"../../../patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch",
import.meta.url,
),
"utf8",
);
test("the runner pins only the Codex ACPX production dependencies", () => {
test("the runner pins every qualified ACPX production dependency", () => {
assert.equal(runnerPackage.dependencies.acpx, "0.13.1");
assert.equal(
runnerPackage.dependencies["@agentclientprotocol/codex-acp"],
"1.6.2",
);
assert.equal(runnerPackage.dependencies["pi-acp"], undefined);
assert.equal(
runnerPackage.dependencies["@agentclientprotocol/claude-agent-acp"],
undefined,
"0.70.0",
);
});
@ -50,6 +56,12 @@ test("old and new pnpm configuration both apply the exact runtime patches", () =
rootPackage.pnpm.patchedDependencies["acpx@0.13.1"],
"patches/acpx@0.13.1.patch",
);
assert.equal(
rootPackage.pnpm.patchedDependencies[
"@agentclientprotocol/claude-agent-acp@0.70.0"
],
"patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch",
);
assert.equal(
rootPackage.pnpm.patchedDependencies[
"@agentclientprotocol/codex-acp@1.6.2"
@ -61,6 +73,10 @@ test("old and new pnpm configuration both apply the exact runtime patches", () =
workspace,
/codex-acp@1\.6\.2': patches\/@agentclientprotocol__codex-acp@1\.6\.2\.patch/,
);
assert.match(
workspace,
/claude-agent-acp@0\.70\.0': patches\/@agentclientprotocol__claude-agent-acp@0\.70\.0\.patch/,
);
});
test("the ACPX patch preserves launch-only state and verified spawning", () => {
@ -110,3 +126,16 @@ test("the Codex patch enforces isolated instructions, tools, and skills", () =>
);
}
});
test("the Claude patch removes ambient project and local configuration", () => {
for (const token of [
"PAPERCLIP_ACPX_ISOLATED_CONTEXT",
'settingSources: ["user"]',
"userProvidedOptions?.mcpServers",
]) {
assert.match(
claudePatch,
new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
);
}
});

View File

@ -0,0 +1,51 @@
diff --git a/dist/acp-agent.js b/dist/acp-agent.js
--- a/dist/acp-agent.js
+++ b/dist/acp-agent.js
@@ -2681,10 +2681,20 @@
cost: {
amount: message.total_cost_usd,
currency: "USD",
},
- ...(message.origin && {
- _meta: { "_claude/origin": message.origin },
- }),
+ _meta: {
+ ...(message.origin && { "_claude/origin": message.origin }),
+ // ACP's core Usage shape exposes context occupancy and
+ // cost, but not the billable input/output split. Keep the
+ // SDK's bounded aggregate in extension metadata so ACPX can
+ // normalize it without persisting a raw provider message.
+ usage: {
+ input_tokens: message.usage.input_tokens,
+ output_tokens: message.usage.output_tokens,
+ cache_read_input_tokens: message.usage.cache_read_input_tokens,
+ cache_creation_input_tokens: message.usage.cache_creation_input_tokens,
+ },
+ },
},
});
}
@@ -4866,8 +4876,12 @@
const options = {
systemPrompt,
settingSources: ["user", "project", "local"],
...(thinking !== undefined && { thinking }),
...userProvidedOptions,
+ // Paperclip Runner owns the complete session context. Its isolated
+ // user root contains only assigned skills; project/local settings
+ // would reintroduce host prompts, plugins, skills, and MCP servers.
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1" && { settingSources: ["user"] }),
...(settings && { settings }),
env,
// Override certain fields that must be controlled by ACP
@@ -4875,7 +4889,9 @@
includePartialMessages: true,
forwardSubagentText,
mcpServers: {
- ...(userProvidedOptions?.mcpServers || {}),
+ ...(process.env.PAPERCLIP_ACPX_ISOLATED_CONTEXT === "1"
+ ? {}
+ : (userProvidedOptions?.mcpServers || {})),
...mcpServers,
...(fileChangeAuditSupport
? { [FILE_CHANGE_AUDIT_SERVER_NAME]: fileChangeAuditSupport.mcpServer }

View File

@ -19,4 +19,5 @@ patchedDependencies:
embedded-postgres@18.1.0-beta.16: patches/embedded-postgres@18.1.0-beta.16.patch
acpx@0.12.0: patches/acpx@0.12.0.patch
acpx@0.13.1: patches/acpx@0.13.1.patch
'@agentclientprotocol/claude-agent-acp@0.70.0': patches/@agentclientprotocol__claude-agent-acp@0.70.0.patch
'@agentclientprotocol/codex-acp@1.6.2': patches/@agentclientprotocol__codex-acp@1.6.2.patch