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:
parent
2e5a24e177
commit
ad0ad43cf4
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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, "\\$&")),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue