mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native Runner starts ACP providers (Claude, Codex, Pi and others) through the ACPX sidecar. `session.open` starts the provider session. > - When the provider session does not start, Runner reports `classification=session_ensure_failed`. If the provider failed with a plain error and not with an ACP `RequestError`, no provider cause crosses the sidecar. Then the run error, the run events and the run log do not say why the session did not start. > - A provider can also type the failure, for example with ACP's authentication-required code. Runner reported that failure with the same generic classification. The server could not tell a limit or a sign-in failure at session start from other startup failures. > - On one instance, a Claude agent hit its provider usage limit. The first run failed in the turn with a typed limit failure. Each later run for many hours failed at `session.open` with only `session_ensure_failed`. Operators could only guess the cause from the timing. > - This pull request keeps a redacted, bounded cause for every session ensure rejection. It names a typed provider category in the closed classification. > - The benefit: an operator can read why a session did not start, and a later recovery change can treat a typed startup failure by its category. ## Linked Issues or Issue Description Refs #18622, which added the `ProviderFailure` cause for startup and turn failures. This change closes the remaining startup gaps. Refs #17543, which is a related open change to the same classifier. It keeps the JSON-RPC code range as a closed code. The two changes edit the same lines, so the second one to merge needs a small rebase. **What happened?** A native ACPX run failed before its first prompt with `ACPX sidecar command session.open was rejected (retryable=false, classification=session_ensure_failed)`. When the ensure rejection was not an ACP `RequestError`, the run had no other cause data. When the provider typed the failure (for example ACP `-32000` "Authentication required"), the classification stayed the same generic value. **Expected behavior** Every session ensure rejection keeps a redacted provider cause that an operator can read on the run. A typed provider failure keeps its category in the cause and in the closed classification. Provider text never goes into the closed error text. **Steps to reproduce** 1. Make `runtime.ensureSession` reject with a plain `Error`, or with an ACP error that has code `-32000`. 2. Open a qualified ACPX runtime through the sidecar. 3. Before this change, the plain error has no `providerFailure`, and the `-32000` error has category `unknown` and `classification=session_ensure_failed`. **Paperclip version or commit** `master` at `e0276e0166c`. ## What Changed - `acpxProviderFailure` reads ACP's reserved authentication-required code (`-32000`) as category `authentication`. It also reads the `errorKind` that Claude ACP puts in ACP error data (`rate_limit`, `billing_error`, `authentication_failed`, `overloaded` and others). The category table is now a `Map`, so an `errorKind` such as `constructor` cannot select an inherited object key. - The session ensure path in `codex-runtime-adapter.ts` keeps a cause for every rejection. A rejection without an ACP error keeps its own message, redacted with the launch environment and the existing credential patterns, with category `unknown`. An abort reason is Runner cancellation, so it does not become a provider cause. A non-`Error` rejection now keeps its cause on the wrapper error. - `acpx_sidecar_transport.rs`: for `ACPX_SESSION_ENSURE_FAILED`, a schema-valid cause with a typed category gives `classification=provider_<category>` (for example `provider_quota_exhausted`, `provider_rate_limit`, `provider_authentication`). An untyped or invalid cause keeps `session_ensure_failed`. Only the validated enum value is used. The cause's text never goes into the error string. - `runnerd-codex-transport.ts`: when a command result has a provider cause, the reported error now keeps Runner's closed ACPX rejection text, so the classification stays in the run error. Before, it changed that text to "Provider request failed". Any other message (for example a Codex `RPC failed: ...` message) still gets the generic text. Provider text still travels only in `providerFailure`. - The credential-free Pi probe (`pi-closed-startup.test.mjs`, `scripts/pi-public-install-probe.mjs`) now expects `provider_authentication`. Pi reports its missing credential with ACP's authentication-required error. - Docs: `doc/run-log-events.md` and `doc/architecture/runner-pi-capabilities.md`. **What the Claude ACP agent returns at session start** (`@agentclientprotocol/claude-agent-acp` 0.73.0 with the repository patch, and the ACPX 0.13.1 patch): - `newSession` and `loadSession` call `createSession`. It throws `RequestError.authRequired` (`-32000`) only for a claude.ai subscription when gateway auth is hidden. It throws `invalidParams` for a bad `cwd` and `resourceNotFound` for a missing resumed session. - Any other error, for example a failed SDK `initializationResult()`, becomes a generic `-32603 Internal error`. The ACP SDK puts the raw message in `data.details`, or the parsed JSON body in `data` when the message is JSON. - The typed AIR `sessionFailure` (category `limit`, `access` and others) is sent only on prompt responses and on `session_info_update` notifications. `loadSession` history replay also sends one for each earlier usage-limit message, but that notification does not show whether the limit is still active. The `errorKind` data is attached only by the prompt loop. - Thus a usage limit at session start has no typed signal on the ACP error. The cause keeps the provider's message, and the existing `API error: NNN` status parse can type it. This change does not add more free-text matching. **Shape for a recovery consumer:** the server reads the cause with `providerFailureFromError(error)`, as it does now. A startup cause has `phase: "startup"`, a `category` from the `ProviderFailure` schema and its `retryability`. The run error keeps `classification=provider_<category>` for a typed ensure failure, or `session_ensure_failed` when the category is `unknown`. ## Verification - `npx vitest run src/drivers/acpx src/cli src/drivers/provider-failure.test.ts src/live/runnerd-codex-transport.test.ts src/live/runnerd-codex-transport-settlement.test.ts` in `packages/paperclip-runner`: 103 of 104 files passed, 2214 tests. The 2 failures are in `installation-integrity.test.ts`. They fail the same way on unmodified `origin/master` in this checkout (a temporary `server.js` cannot resolve a module). - New tests: a plain ensure error keeps its message with the launch-environment secret and an `sk-ant-` value redacted; a `-32000` ensure error gives category `authentication`; a non-`Error` rejection with `errorKind: "billing_error"` gives `quota_exhausted`; an aborted handshake gets no cause; typed and untyped ACP errors; a closed ACPX rejection keeps its classification and a changed one gets the generic text. - `npx vitest run src/control-plane/acpx-controller-recovery.test.ts`: 8 passed. - `cargo test -p paperclip-runner-core --no-fail-fast`: all passed. New unit test: typed categories give `provider_<category>`; `unknown`, no cause, a category outside the schema and an extra field each keep `session_ensure_failed`; other closed codes keep their own classification. New pipe test through the fake sidecar: the cause survives the rejection, the classification is typed or generic, and the cause text is not in the error string. In the first full run, two `stdio_mcp` relay tests timed out. They passed in the second full run. They do not use the changed code. - `node --test test/acpx-sidecar-contract.test.mjs test/protocol-contract.test.mjs test/pi-closed-startup.test.mjs`: 29 passed. The Pi probe is skipped without the Pi distribution. - `pnpm --filter @paperclipai/paperclip-runner typecheck` (TypeScript, `cargo fmt --check`, `cargo check`): passed. `pnpm --filter @paperclipai/paperclip-runner build`: passed. - Server, after the runner build: native provider failure, run completion, task interruption presentation, Antigravity probe and the related native session executor tests: 639 passed. - Not verified: a live Claude account at its usage limit. I could not run the credential-free Pi probe, because this checkout has no Pi distribution. Its new expectation follows from the Pi patch, which raises `RequestError.authRequired` for the missing credential. ## Risks - The run error for an ACPX startup rejection with a cause now shows Runner's closed rejection text, not "Provider request failed". This text has only closed codes and is the same text that the run error showed before #18622. Provider text stays in `providerFailure`. - A plain ensure error message now becomes a displayed provider cause. Redaction applies before the 4,000-character bound. A new credential format that the patterns do not know can still need a new pattern. The same risk applies to all provider causes. - The classification of a typed ensure failure changes from `session_ensure_failed` to `provider_<category>`. No server code matches `session_ensure_failed`. The Pi probe expectation is updated. - The category and retryability are evidence only. This change does not change retry policy. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) through Claude Code, with tool use (shell, file edits, test runs). The runtime does not expose the context window or reasoning configuration. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
3588 lines
147 KiB
TypeScript
3588 lines
147 KiB
TypeScript
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { EventEmitter } from "node:events";
|
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { createServer } from "node:http";
|
|
import { createRequire } from "node:module";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import type {
|
|
AcpAgentRegistry,
|
|
AcpRuntime,
|
|
AcpRuntimeHandle,
|
|
AcpRuntimeOptions,
|
|
AcpSessionStore,
|
|
} from "acpx/runtime";
|
|
import { decodeAcpxRuntimeHandleState } from "acpx/runtime";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import type { VerifiedAcpxCommandLease } from "./installation-integrity.js";
|
|
import { openCodexAcpxRuntime } from "./codex-runtime-adapter.js";
|
|
import { cursorInstructionBinding } from "./cursor-instructions.js";
|
|
import { createAcpxCommandLeaseOwner } from "./command-lease-owner.js";
|
|
import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js";
|
|
import type { AcpxRuntimePortOpenOptions } from "./runtime-host.js";
|
|
|
|
const HANDLE: AcpRuntimeHandle = {
|
|
sessionKey: "session-key",
|
|
backend: "acpx",
|
|
runtimeSessionName: "runtime-name",
|
|
cwd: "/workspace",
|
|
acpxRecordId: "record-1",
|
|
backendSessionId: "backend-1",
|
|
agentSessionId: "agent-1",
|
|
};
|
|
|
|
describe("Codex ACPX runtime adapter", () => {
|
|
it("retains the provider cause before assigning the generic session failure code", async () => {
|
|
const runtime = fakeRuntime();
|
|
const rejection = Object.assign(new Error("Internal error"), { code: -32603,
|
|
data: { details: "API error: 429 This request would exceed your account's rate limit." } });
|
|
vi.mocked(runtime.ensureSession).mockRejectedValue(rejection);
|
|
const options = openOptions(fakeCommand());
|
|
options.profile = { ...options.profile, agent: "claude" };
|
|
await expect(openCodexAcpxRuntime(options, { createRegistry: () => registry(), createStore: () => store(),
|
|
createRuntime: () => runtime })).rejects.toMatchObject({
|
|
code: "ACPX_SESSION_ENSURE_FAILED",
|
|
providerFailure: { provider: "anthropic", category: "rate_limit", httpStatus: 429,
|
|
message: rejection.data.details, phase: "startup" },
|
|
});
|
|
expect(runtime.startTurn).not.toHaveBeenCalled();
|
|
});
|
|
it("keeps a redacted explanation for an ensure rejection that carries no ACP error", async () => {
|
|
const runtime = fakeRuntime();
|
|
const options = openOptions(fakeCommand());
|
|
options.profile = { ...options.profile, agent: "claude" };
|
|
options.launchEnvironment = { ANTHROPIC_API_KEY: "fixture-anthropic-secret" };
|
|
const rejection = new Error("Claude Code exited during startup for fixture-anthropic-secret (sk-ant-api03-fixtureprivate)");
|
|
vi.mocked(runtime.ensureSession).mockRejectedValue(rejection);
|
|
const failure = await openCodexAcpxRuntime(options, { createRegistry: () => registry(), createStore: () => store(),
|
|
createRuntime: () => runtime }).catch((error: unknown) => error);
|
|
expect(failure).toBe(rejection);
|
|
expect(failure).toMatchObject({ code: "ACPX_SESSION_ENSURE_FAILED", providerFailure: { provider: "anthropic",
|
|
phase: "startup", category: "unknown", message: "Claude Code exited during startup for [REDACTED] ([REDACTED])" } });
|
|
expect(JSON.stringify((failure as { providerFailure: unknown }).providerFailure)).not.toMatch(/fixture-anthropic-secret|fixtureprivate/);
|
|
});
|
|
it("classifies an ACP authentication-required ensure rejection", async () => {
|
|
const runtime = fakeRuntime();
|
|
const message = "Authentication required: This integration does not support using claude.ai subscriptions.";
|
|
vi.mocked(runtime.ensureSession).mockRejectedValue(Object.assign(new Error(message), { code: -32000 }));
|
|
const options = openOptions(fakeCommand());
|
|
options.profile = { ...options.profile, agent: "claude" };
|
|
await expect(openCodexAcpxRuntime(options, { createRegistry: () => registry(), createStore: () => store(),
|
|
createRuntime: () => runtime })).rejects.toMatchObject({ code: "ACPX_SESSION_ENSURE_FAILED",
|
|
providerFailure: { category: "authentication", message, phase: "startup", retryability: "requires_action" } });
|
|
});
|
|
it("attaches a provider cause to a non-error ensure rejection", async () => {
|
|
const runtime = fakeRuntime();
|
|
vi.mocked(runtime.ensureSession).mockRejectedValue({ code: -32603, message: "Internal error", data: { errorKind: "billing_error" } });
|
|
const options = openOptions(fakeCommand());
|
|
options.profile = { ...options.profile, agent: "claude" };
|
|
await expect(openCodexAcpxRuntime(options, { createRegistry: () => registry(), createStore: () => store(),
|
|
createRuntime: () => runtime })).rejects.toMatchObject({ code: "ACPX_SESSION_ENSURE_NON_ERROR",
|
|
providerFailure: { category: "quota_exhausted", providerCode: "billing_error", phase: "startup" } });
|
|
});
|
|
it("does not report Runner cancellation as a provider explanation", async () => {
|
|
const runtime = fakeRuntime();
|
|
const controller = new AbortController();
|
|
const reason = new Error("runtime admission cancelled");
|
|
const rejection = new Error("ACP connection closed");
|
|
vi.mocked(runtime.ensureSession).mockImplementation(async () => {
|
|
controller.abort(reason);
|
|
throw rejection;
|
|
});
|
|
const options = openOptions(fakeCommand());
|
|
options.signal = controller.signal;
|
|
await expect(openCodexAcpxRuntime(options, { createRegistry: () => registry(), createStore: () => store(),
|
|
createRuntime: () => runtime })).rejects.toBe(reason);
|
|
await vi.waitFor(() => expect(rejection).toHaveProperty("code", "ACPX_SESSION_ENSURE_FAILED"));
|
|
expect(rejection).not.toHaveProperty("providerFailure");
|
|
expect(reason).not.toHaveProperty("providerFailure");
|
|
});
|
|
it.each([false, true])("acknowledges configured Claude effort before admission (restoring=%s)", async restoring => {
|
|
const runtime = fakeRuntime();
|
|
const child = fakeChild();
|
|
const options = openOptions({ spawn: () => child, close: async () => undefined });
|
|
options.profile = { ...options.profile, agent: "claude" };
|
|
options.claudeEffort = "high";
|
|
options.restoringSession = restoring;
|
|
options.refreshConsumedCommand = vi.fn(async () => undefined);
|
|
let created!: AcpRuntimeOptions;
|
|
const spawnProvider = () => created.spawnAgent!({ command: "ignored", args: [], options: {} });
|
|
vi.mocked(runtime.ensureSession).mockImplementation(async () => {
|
|
if (!restoring) spawnProvider();
|
|
return HANDLE;
|
|
});
|
|
vi.mocked(runtime.setConfigOption).mockImplementation(async input => {
|
|
if (restoring) spawnProvider();
|
|
const guard = created.protocolGuardFactory!();
|
|
guard("outbound", { id: 0, method: "session/load", params: { sessionId: "backend-1" } });
|
|
guard("inbound", { id: 0, result: { configOptions: [{ id: "effort", currentValue: "default" }] } });
|
|
guard("outbound", { id: 1, method: "session/set_config_option", params: { sessionId: "backend-1", configId: input.key, value: input.value } });
|
|
guard("inbound", { id: 1, result: { configOptions: [{ id: "effort", currentValue: input.value }] } });
|
|
});
|
|
const port = await openCodexAcpxRuntime(options, {
|
|
createRegistry: () => registry(), createStore: () => store(), createRuntime: value => { created = value; return runtime; },
|
|
awaitProviderOwnership: providerOwnershipEstablished,
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
});
|
|
expect(runtime.setConfigOption).toHaveBeenCalledExactlyOnceWith({ handle: HANDLE, key: "effort", value: "high" });
|
|
expect(await port.identity()).toMatchObject({ claudeEffort: "high" });
|
|
if (restoring) expect(options.refreshConsumedCommand).toHaveBeenCalledOnce();
|
|
else expect(options.refreshConsumedCommand).not.toHaveBeenCalled();
|
|
expect(runtime.startTurn).not.toHaveBeenCalled();
|
|
await port.close({ reason: "test complete" });
|
|
});
|
|
it.each([false, true])("acknowledges configured Grok effort before admission (restoring=%s)", async restoring => {
|
|
const runtime = Object.assign(fakeRuntime(), { requestExtension: vi.fn(async () => ({})) });
|
|
const child = fakeChild();
|
|
const options = openOptions({ spawn: () => child, close: async () => undefined });
|
|
options.profile = resolveQualifiedAcpxProfile("grok", "grok-4.7");
|
|
options.grokEffort = "high";
|
|
options.restoringSession = restoring;
|
|
options.refreshConsumedCommand = vi.fn(async () => undefined);
|
|
let created!: AcpRuntimeOptions;
|
|
const spawnProvider = () => created.spawnAgent!({ command: "ignored", args: [], options: {} });
|
|
const acknowledge = () => {
|
|
const guard = created.protocolGuardFactory!();
|
|
guard("outbound", { id: 0, method: "session/load", params: { sessionId: "backend-1" } });
|
|
guard("inbound", { id: 0, result: { models: { currentModelId: "grok-4.7" },
|
|
_meta: { "x.ai/sessionConfig": { options: [{ id: "high", category: "mode", selected: true }] } } } });
|
|
};
|
|
vi.mocked(runtime.ensureSession).mockImplementation(async input => {
|
|
expect(input.sessionOptions).toMatchObject({ model: "grok-4.7" });
|
|
if (!restoring) { spawnProvider(); acknowledge(); }
|
|
return HANDLE;
|
|
});
|
|
runtime.requestExtension.mockImplementation(async () => {
|
|
spawnProvider(); acknowledge(); return {};
|
|
});
|
|
const port = await openCodexAcpxRuntime(options, {
|
|
createRegistry: () => registry(), createStore: () => store(), createRuntime: value => { created = value; return runtime; },
|
|
awaitProviderOwnership: providerOwnershipEstablished,
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
});
|
|
expect(runtime.setConfigOption).not.toHaveBeenCalled();
|
|
expect(await port.identity()).toMatchObject({ grokEffort: "high" });
|
|
if (restoring) {
|
|
expect(runtime.requestExtension).toHaveBeenCalledExactlyOnceWith({ handle: HANDLE, method: "_x.ai/session/info",
|
|
params: { sessionId: "backend-1" }, sessionMode: "persistent" });
|
|
expect(options.refreshConsumedCommand).toHaveBeenCalledOnce();
|
|
} else {
|
|
expect(runtime.requestExtension).not.toHaveBeenCalled();
|
|
expect(options.refreshConsumedCommand).not.toHaveBeenCalled();
|
|
}
|
|
expect(runtime.startTurn).not.toHaveBeenCalled();
|
|
await port.close({ reason: "test complete" });
|
|
});
|
|
|
|
it("admits an exact Claude model through the real ACP without a user model allowlist", async () => {
|
|
const root = await mkdtemp(join(tmpdir(), "preconfigured-claude-model-"));
|
|
const home = join(root, "home"), cwd = join(root, "workspace"), config = join(home, ".claude");
|
|
await Promise.all([mkdir(config, { recursive: true }), mkdir(cwd)]);
|
|
const settings = JSON.stringify({ model: "claude-sonnet-5-5", permissions: { deny: ["Bash"] } });
|
|
await writeFile(join(config, "settings.json"), settings);
|
|
// Model validation can reach only this local fixture, never a provider.
|
|
const api = createServer(async (request, response) => {
|
|
for await (const _chunk of request) { /* drain validation input */ }
|
|
response.writeHead(200, { "Content-Type": "application/json" });
|
|
response.end(JSON.stringify({ id: "msg_fixture", type: "message", role: "assistant", model: "claude-sonnet-5-5",
|
|
content: [{ type: "text", text: "OK" }], stop_reason: "end_turn", usage: { input_tokens: 1, output_tokens: 1 } }));
|
|
});
|
|
await new Promise<void>(resolve => api.listen(0, "127.0.0.1", resolve));
|
|
const address = api.address() as { port: number };
|
|
const entry = createRequire(import.meta.url).resolve("@agentclientprotocol/claude-agent-acp/dist/index.js");
|
|
const command: VerifiedAcpxCommandLease = {
|
|
spawn: (args, input) => spawn(process.execPath, [entry, ...(args ?? [])], { ...input, detached: true, stdio: ["pipe", "pipe", "pipe"] }),
|
|
close: async () => undefined,
|
|
};
|
|
const options = { ...openOptions(command), profile: resolveQualifiedAcpxProfile("claude", "claude-sonnet-5-5"),
|
|
cwd, stateDirectory: join(root, "state"), permissionMode: "approve-all" as const,
|
|
launchEnvironment: { HOME: home, CLAUDE_CONFIG_DIR: config, PATH: "/usr/bin:/bin", TMPDIR: root,
|
|
PAPERCLIP_RUNNER_HOME_MODE: "preconfigured", ANTHROPIC_API_KEY: "local-fixture-only",
|
|
ANTHROPIC_BASE_URL: `http://127.0.0.1:${address.port}`, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1" },
|
|
};
|
|
let port: Awaited<ReturnType<typeof openCodexAcpxRuntime>> | undefined;
|
|
const priorSettingsFlag = process.env.ACPX_CLAUDE_INCLUDE_USER_SETTINGS;
|
|
process.env.ACPX_CLAUDE_INCLUDE_USER_SETTINGS = "1";
|
|
try {
|
|
port = await openCodexAcpxRuntime(options, {
|
|
// This test exercises ACP/model admission; lifetime-fence tests are separate.
|
|
awaitProviderOwnership: async () => undefined,
|
|
awaitProviderExit: child => new Promise<void>(resolve => child.once("exit", () => resolve())),
|
|
});
|
|
expect(await port.getStatus()).toMatchObject({ models: { currentModelId: "claude-sonnet-5-5" } });
|
|
expect(await readFile(join(config, "settings.json"), "utf8")).toBe(settings);
|
|
} finally {
|
|
try { await port?.close({ reason: "exact model verified" }); }
|
|
finally {
|
|
if (priorSettingsFlag === undefined) delete process.env.ACPX_CLAUDE_INCLUDE_USER_SETTINGS;
|
|
else process.env.ACPX_CLAUDE_INCLUDE_USER_SETTINGS = priorSettingsFlag;
|
|
api.closeAllConnections();
|
|
await new Promise<void>(resolve => api.close(() => resolve()));
|
|
// Provider shutdown can flush metadata just after the ACP process exits.
|
|
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
}
|
|
}
|
|
}, 30_000);
|
|
|
|
it("rejects preconfigured Codex before creating a provider runtime", async () => {
|
|
const createRuntime = vi.fn();
|
|
const options = openOptions(fakeCommand());
|
|
options.launchEnvironment = { HOME: "/users/executor", PAPERCLIP_RUNNER_HOME_MODE: "preconfigured" };
|
|
await expect(openCodexAcpxRuntime(options, { createRuntime })).rejects.toThrow("cannot protect Runner state");
|
|
expect(createRuntime).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["blocked", "ended"] as const)("settles a %s Copilot stream on verified provider death without replaying the permission", async (streamState) => {
|
|
const pending = pendingExtensionTurn("death-turn");
|
|
if (streamState === "ended") pending.turn.events = (async function* () {})();
|
|
const runtime = fakeRuntime();
|
|
vi.mocked(runtime.startTurn).mockReturnValue(pending.turn);
|
|
const child = fakeChild();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValue(child);
|
|
let exit!: () => void;
|
|
const providerExit = new Promise<void>(resolve => { exit = resolve; });
|
|
let created!: AcpRuntimeOptions;
|
|
const options = openOptions(command);
|
|
options.profile = { ...options.profile, agent: "copilot" };
|
|
options.permissionMode = "approve-paperclip";
|
|
const port = await openCodexAcpxRuntime(options, {
|
|
createRegistry: () => registry(), createStore: () => store(),
|
|
awaitProviderOwnership: providerOwnershipEstablished,
|
|
awaitProviderExit: () => providerExit,
|
|
createRuntime: value => { created = value; return runtimeWithProvider(runtime, value); },
|
|
});
|
|
let answer!: (value: { outcome: "allow_once" }) => void;
|
|
let callbackSignal!: AbortSignal;
|
|
const handler = vi.fn((_request, context) => {
|
|
callbackSignal = context.signal;
|
|
return new Promise<{ outcome: "allow_once" }>(resolve => { answer = resolve; });
|
|
});
|
|
const turn = port.startTurn({ text: "Write the file", requestId: "death-turn", onPermissionRequest: handler });
|
|
await turn.promptStarted;
|
|
const events = turn.events[Symbol.asyncIterator]();
|
|
const draining = expect(events.next()).rejects.toMatchObject({ code: "ACPX_PROVIDER_PROCESS_LOST" });
|
|
const settled = expect(turn.result).rejects.toMatchObject({ code: "ACPX_PROVIDER_PROCESS_LOST" });
|
|
const request = created.onPermissionRequest!({ sessionId: "backend-1", raw: { sessionId: "backend-1" }, inferredKind: "edit" } as never, { signal: new AbortController().signal });
|
|
await vi.waitFor(() => expect(handler).toHaveBeenCalledOnce());
|
|
// The guardian remains live: only independent provider EOF proves death.
|
|
expect(child.exitCode).toBeNull();
|
|
exit();
|
|
await Promise.all([draining, settled]);
|
|
expect(callbackSignal.aborted).toBe(true);
|
|
answer({ outcome: "allow_once" });
|
|
await expect(request).resolves.toEqual({ outcome: "cancel" });
|
|
expect(() => port.startTurn({ text: "Replay", requestId: "replay" })).toThrow("provider exited");
|
|
expect(runtime.startTurn).toHaveBeenCalledOnce();
|
|
pending.settle();
|
|
await port.close({ reason: "test finished" });
|
|
expect(child.kill).toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses Copilot replay when native disconnection precedes the independent exit proof", async () => {
|
|
const pending = pendingExtensionTurn("disconnect-turn");
|
|
let disconnect!: () => void;
|
|
const failed = new Promise<{ status: "failed"; error: { code: "RUNTIME"; detailCode: "AGENT_DISCONNECTED"; message: string } }>(resolve => {
|
|
disconnect = () => resolve({ status: "failed", error: { code: "RUNTIME", detailCode: "AGENT_DISCONNECTED", message: "Native ACP transport disconnected" } });
|
|
});
|
|
const runtime = fakeRuntime();
|
|
vi.mocked(runtime.startTurn).mockReturnValue({ ...pending.turn, result: failed });
|
|
const child = fakeChild();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValue(child);
|
|
let exit!: () => void;
|
|
const providerExit = new Promise<void>(resolve => { exit = resolve; });
|
|
let created!: AcpRuntimeOptions;
|
|
const options = openOptions(command);
|
|
options.profile = { ...options.profile, agent: "copilot" };
|
|
options.permissionMode = "approve-paperclip";
|
|
const port = await openCodexAcpxRuntime(options, {
|
|
createRegistry: () => registry(), createStore: () => store(),
|
|
awaitProviderOwnership: providerOwnershipEstablished,
|
|
awaitProviderExit: () => providerExit,
|
|
createRuntime: value => { created = value; return runtimeWithProvider(runtime, value); },
|
|
});
|
|
let answer!: (value: { outcome: "allow_once" }) => void;
|
|
let callbackSignal!: AbortSignal;
|
|
const handler = vi.fn((_request, context) => {
|
|
callbackSignal = context.signal;
|
|
return new Promise<{ outcome: "allow_once" }>(resolve => { answer = resolve; });
|
|
});
|
|
try {
|
|
const turn = port.startTurn({ text: "Write", requestId: "disconnect-turn", onPermissionRequest: handler });
|
|
await turn.promptStarted;
|
|
const request = created.onPermissionRequest!({ sessionId: "backend-1", raw: { sessionId: "backend-1" }, inferredKind: "edit" } as never, { signal: new AbortController().signal });
|
|
await vi.waitFor(() => expect(handler).toHaveBeenCalledOnce());
|
|
disconnect();
|
|
await expect(turn.result).rejects.toMatchObject({ code: "ACPX_PROVIDER_PROCESS_LOST" });
|
|
await expect((async () => { for await (const _event of turn.events) { /* drain */ } })())
|
|
.rejects.toMatchObject({ code: "ACPX_PROVIDER_PROCESS_LOST" });
|
|
expect(callbackSignal.aborted).toBe(true);
|
|
answer({ outcome: "allow_once" });
|
|
await expect(request).resolves.toEqual({ outcome: "cancel" });
|
|
expect(() => port.startTurn({ text: "Replay", requestId: "replay" })).toThrow("provider exited");
|
|
expect(runtime.startTurn).toHaveBeenCalledOnce();
|
|
// A typed disconnect does not manufacture independent process-exit proof.
|
|
// Cleanup still owns the live guardian and waits for the separate proof.
|
|
expect(child.exitCode).toBeNull();
|
|
} finally {
|
|
pending.settle();
|
|
exit();
|
|
await port.close({ reason: "disconnect race verified" });
|
|
}
|
|
});
|
|
|
|
it("keeps a Copilot callback deliverable when only the guardian exits", async () => {
|
|
const pending = pendingExtensionTurn("preserved-turn");
|
|
const runtime = fakeRuntime();
|
|
vi.mocked(runtime.startTurn).mockReturnValue(pending.turn);
|
|
let created!: AcpRuntimeOptions;
|
|
const child = fakeChild();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValue(child);
|
|
let exit!: () => void;
|
|
const providerExit = new Promise<void>(resolve => { exit = resolve; });
|
|
const options = openOptions(command);
|
|
options.profile = { ...options.profile, agent: "copilot" };
|
|
options.permissionMode = "approve-paperclip";
|
|
const port = await openCodexAcpxRuntime(options, {
|
|
createRegistry: () => registry(), createStore: () => store(),
|
|
awaitProviderOwnership: providerOwnershipEstablished, awaitProviderExit: () => providerExit,
|
|
createRuntime: value => { created = value; return runtimeWithProvider(runtime, value); },
|
|
});
|
|
let answer!: (value: { outcome: "allow_once" }) => void;
|
|
const handler = vi.fn(() => new Promise<{ outcome: "allow_once" }>(resolve => { answer = resolve; }));
|
|
const turn = port.startTurn({ text: "Write", requestId: "preserved-turn", onPermissionRequest: handler });
|
|
const request = created.onPermissionRequest!({ sessionId: "backend-1", raw: { sessionId: "backend-1" }, inferredKind: "edit" } as never, { signal: new AbortController().signal });
|
|
await vi.waitFor(() => expect(handler).toHaveBeenCalledOnce());
|
|
child.exitCode = 0;
|
|
child.emit("exit", 0, null);
|
|
// Guardian exit cannot replace the provider-only lifetime observation.
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
answer({ outcome: "allow_once" });
|
|
await expect(request).resolves.toEqual({ outcome: "allow_once" });
|
|
pending.settle();
|
|
await expect(turn.result).resolves.toMatchObject({ status: "completed" });
|
|
exit();
|
|
await port.close({ reason: "test finished" });
|
|
});
|
|
|
|
it("installs per-connection policy authority only for Copilot and rejects native commands before a turn starts", async () => {
|
|
for (const agent of ["copilot", "codex", "claude", "grok"] as const) {
|
|
const runtime = fakeRuntime();
|
|
let created!: AcpRuntimeOptions;
|
|
const options = openOptions(fakeCommand());
|
|
options.profile = { ...options.profile, agent };
|
|
const port = await openCodexAcpxRuntime(options, {
|
|
createRegistry: () => registry(), createStore: () => store(), createRuntime: value => { created = value; return runtime; },
|
|
});
|
|
if (agent === "copilot") {
|
|
expect(created.protocolGuardFactory).toBeTypeOf("function");
|
|
expect(created.protocolGuardFactory!()).not.toBe(created.protocolGuardFactory!());
|
|
expect(() => port.startTurn({ text: "\n/yolo on", requestId: "bad" })).toThrow(/admitted permission policy/);
|
|
expect(runtime.startTurn).not.toHaveBeenCalled();
|
|
} else expect(created.protocolGuardFactory).toBeUndefined();
|
|
await port.close({ reason: "test complete" });
|
|
}
|
|
});
|
|
it.each(["claude"] as const)("rejects approvals without a workflow in preconfigured %s sessions", async (agent) => {
|
|
const runtime = fakeRuntime();
|
|
runtime.setMode = vi.fn().mockResolvedValue(undefined);
|
|
let created!: AcpRuntimeOptions;
|
|
const options = openOptions(fakeCommand());
|
|
options.profile = { ...options.profile, agent };
|
|
options.permissionMode = "approve-all";
|
|
options.launchEnvironment = { HOME: "/users/executor", PAPERCLIP_RUNNER_HOME_MODE: "preconfigured" };
|
|
options.mcpServers = [
|
|
{ name: "paperclip_runner_fixture", url: "http://127.0.0.1:3211/mcp", bearerToken: "runner-fixture", runnerOwned: true },
|
|
{ name: "paperclip", url: "http://127.0.0.1:3212/mcp", bearerToken: "external-fixture", runnerOwned: false },
|
|
];
|
|
const port = await openCodexAcpxRuntime(options, {
|
|
createRegistry: () => registry(), createStore: () => store(), createRuntime: value => { created = value; return runtime; },
|
|
});
|
|
const sessionOptions = vi.mocked(runtime.ensureSession).mock.calls[0]?.[0].sessionOptions;
|
|
if (agent === "claude") expect(sessionOptions?.allowedTools).toEqual(["mcp__paperclip_runner_fixture__*"]);
|
|
else expect(sessionOptions?.allowedTools).toBeUndefined();
|
|
expect(created.nonInteractivePermissions).toBe("deny");
|
|
expect(created.permissionPolicy).toMatchObject({ defaultAction: "deny", autoApprove: undefined, escalate: undefined });
|
|
expect(created.spawnEnvironment!().PAPERCLIP_ACPX_ISOLATED_CONTEXT).toBeUndefined();
|
|
if (agent === "claude") expect(created.spawnEnvironment!()).toMatchObject({
|
|
ANTHROPIC_MODEL: options.profile.reportedModelId, ANTHROPIC_CUSTOM_MODEL_OPTION: options.profile.reportedModelId,
|
|
});
|
|
else expect(created.spawnEnvironment!().ANTHROPIC_CUSTOM_MODEL_OPTION).toBeUndefined();
|
|
await expect(created.onPermissionRequest!({ sessionId: "backend-1", raw: {}, inferredKind: "execute" } as never,
|
|
{ signal: new AbortController().signal })).resolves.toEqual({ outcome: "reject_once" });
|
|
expect(runtime.setMode).not.toHaveBeenCalled();
|
|
await port.close({ reason: "verified" });
|
|
});
|
|
|
|
it("rejects forged permission session identifiers before delegating or applying full-auto policy", async () => {
|
|
const pending = pendingExtensionTurn("turn-1");
|
|
const runtime = fakeRuntime(); vi.mocked(runtime.startTurn).mockReturnValue(pending.turn);
|
|
let created!: AcpRuntimeOptions;
|
|
const options = openOptions(fakeCommand());
|
|
const port = await openCodexAcpxRuntime(options, {
|
|
createRegistry: () => registry(), createStore: () => store(), createRuntime: (value) => { created = value; return runtime; },
|
|
});
|
|
const handler = vi.fn(async () => ({ outcome: "allow_once" as const }));
|
|
const turn = port.startTurn({ text: "Write", requestId: "turn-1", onPermissionRequest: handler });
|
|
const signal = new AbortController().signal;
|
|
for (const [sessionId, rawSessionId] of [["forged", "backend-1"], ["backend-1", "forged"]]) {
|
|
const request = { sessionId, raw: { sessionId: rawSessionId }, inferredKind: "edit" as const };
|
|
await expect(created.onPermissionRequest!(request as never, { signal })).resolves.toEqual({ outcome: "reject_once" });
|
|
}
|
|
expect(handler).not.toHaveBeenCalled();
|
|
const responseDelivery = Promise.resolve();
|
|
await expect(created.onPermissionRequest!({ sessionId: "backend-1", raw: { sessionId: "backend-1" }, inferredKind: "edit" } as never, { signal, responseDelivery })).resolves.toEqual({ outcome: "allow_once" });
|
|
expect(handler).toHaveBeenCalledOnce();
|
|
expect(handler).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ responseDelivery }));
|
|
options.permissionMode = "approve-all";
|
|
await expect(created.onPermissionRequest!({ sessionId: "forged", raw: {}, inferredKind: "edit" } as never, { signal })).resolves.toEqual({ outcome: "reject_once" });
|
|
pending.settle(); await turn.result; await port.close({ reason: "session checks complete" });
|
|
});
|
|
it("cancels an outstanding native permission callback on the exact active turn", async () => {
|
|
const pending = pendingExtensionTurn("turn-1");
|
|
const runtime = fakeRuntime(); vi.mocked(runtime.startTurn).mockReturnValue(pending.turn);
|
|
let created!: AcpRuntimeOptions;
|
|
const options = openOptions(fakeCommand());
|
|
const port = await openCodexAcpxRuntime(options, {
|
|
createRegistry: () => registry(), createStore: () => store(), createRuntime: value => { created = value; return runtime; },
|
|
});
|
|
let callbackSignal!: AbortSignal;
|
|
const handler = vi.fn((_request, context) => new Promise<{ outcome: "cancel" }>(resolve => {
|
|
callbackSignal = context.signal;
|
|
context.signal.addEventListener("abort", () => resolve({ outcome: "cancel" }), { once: true });
|
|
}));
|
|
const turn = port.startTurn({ text: "Write", requestId: "turn-1", onPermissionRequest: handler });
|
|
const permission = created.onPermissionRequest!({ sessionId: "backend-1", raw: { sessionId: "backend-1" }, inferredKind: "edit" } as never,
|
|
{ signal: new AbortController().signal, responseDelivery: Promise.resolve() });
|
|
expect(callbackSignal.aborted).toBe(false);
|
|
const oldMode = options.permissionMode;
|
|
options.permissionMode = "approve-all";
|
|
const expiredContext = new AbortController(); expiredContext.abort();
|
|
await expect(created.onPermissionRequest!({ sessionId: "backend-1", raw: {}, inferredKind: "edit" } as never,
|
|
{ signal: expiredContext.signal })).resolves.toEqual({ outcome: "cancel" });
|
|
options.permissionMode = oldMode;
|
|
await turn.cancel();
|
|
expect(callbackSignal.aborted).toBe(true);
|
|
await expect(permission).resolves.toEqual({ outcome: "cancel" });
|
|
await expect(created.onPermissionRequest!({ sessionId: "backend-1", raw: {}, inferredKind: "edit" } as never,
|
|
{ signal: new AbortController().signal })).resolves.toEqual({ outcome: "cancel" });
|
|
expect(handler).toHaveBeenCalledOnce();
|
|
pending.settle(); await turn.result;
|
|
// The active-turn pointer has been cleared. Even full-auto policy cannot
|
|
// authorize a late callback from the retired turn.
|
|
options.permissionMode = "approve-all";
|
|
await expect(created.onPermissionRequest!({ sessionId: "backend-1", raw: {}, inferredKind: "edit" } as never,
|
|
{ signal: new AbortController().signal })).resolves.toEqual({ outcome: "cancel" });
|
|
await port.close({ reason: "test complete" });
|
|
});
|
|
|
|
it("fails Cursor adapter admission when the provider never acknowledges instructions", async () => {
|
|
const runtime = fakeRuntime();
|
|
const options = openOptions(fakeCommand());
|
|
options.profile = { ...options.profile, agent: "cursor" };
|
|
await expect(openCodexAcpxRuntime(options, {
|
|
createRegistry: () => registry(), createStore: () => store(), createRuntime: () => runtime,
|
|
})).rejects.toThrow("Cursor instruction admission failed");
|
|
expect(runtime.startTurn).not.toHaveBeenCalled();
|
|
expect(runtime.close).toHaveBeenCalled();
|
|
});
|
|
it.each(["valid", "missing", "unsupported"])("cold Cursor adapter admission requires %s load/config and renews only on success", async mode => {
|
|
const runtime = fakeRuntime();
|
|
const options = openOptions(fakeCommand()); options.profile = { ...options.profile, agent: "cursor" };
|
|
options.refreshConsumedCommand = vi.fn(async () => undefined);
|
|
let created!: AcpRuntimeOptions;
|
|
vi.mocked(runtime.setConfigOption).mockImplementation(async () => {
|
|
const guard = created.protocolGuardFactory!();
|
|
const binding = cursorInstructionBinding(options.systemInstructions);
|
|
guard("outbound", { id: 0, method: "session/load", params: { sessionId: "backend-1" } });
|
|
guard("inbound", { id: 0, result: mode === "missing" ? {} : { modes: { currentModeId: "agent" }, configOptions: [{ id: "mode", currentValue: "agent" }], _meta: { paperclipCursorInstructions: {
|
|
schema: "paperclip.cursor.instructions.v1", digest: binding.digest, byteLength: binding.byteLength,
|
|
} } } });
|
|
if (mode === "unsupported") throw new Error("Exact model unsupported");
|
|
});
|
|
const opening = openCodexAcpxRuntime(options, {
|
|
createRegistry: () => registry(), createStore: () => store(), createRuntime: value => { created = value; return runtime; },
|
|
});
|
|
if (mode === "valid") {
|
|
const port = await opening; expect(options.refreshConsumedCommand).toHaveBeenCalledOnce(); await port.close({ reason: "fixture cleanup" });
|
|
} else {
|
|
await expect(opening).rejects.toThrow(mode === "missing" ? /Cursor instruction admission failed/ : /Exact model unsupported/);
|
|
expect(options.refreshConsumedCommand).not.toHaveBeenCalled(); expect(runtime.close).toHaveBeenCalled();
|
|
}
|
|
expect(runtime.setConfigOption).toHaveBeenCalledExactlyOnceWith({ handle: HANDLE, key: "model", value: options.profile.reportedModelId });
|
|
expect(runtime.startTurn).not.toHaveBeenCalled();
|
|
});
|
|
it.each(["plan", "ask"] as const)("admission explicitly selects %s and renews temporary command authority", async selected => {
|
|
const runtime = fakeRuntime(); const options = openOptions(fakeCommand());
|
|
options.profile = { ...options.profile, agent: "cursor" }; options.mode = selected;
|
|
options.refreshConsumedCommand = vi.fn(async () => undefined);
|
|
let created!: AcpRuntimeOptions;
|
|
const binding = cursorInstructionBinding(options.systemInstructions);
|
|
const configs = (mode: string) => [{ id: "mode", currentValue: mode }];
|
|
vi.mocked(runtime.setConfigOption).mockImplementation(async input => {
|
|
const guard = created.protocolGuardFactory!();
|
|
guard("outbound", { id: 0, method: "session/load", params: { sessionId: "backend-1" } });
|
|
guard("inbound", { id: 0, result: { modes: { currentModeId: "agent" }, configOptions: configs("agent"), _meta: { paperclipCursorInstructions: {
|
|
schema: "paperclip.cursor.instructions.v1", digest: binding.digest, byteLength: binding.byteLength,
|
|
} } } });
|
|
guard("outbound", { id: 1, method: "session/set_config_option", params: { sessionId: "backend-1", configId: input.key, value: input.value } });
|
|
guard("inbound", { id: 1, result: { configOptions: configs(input.key === "mode" ? selected : "agent") } });
|
|
});
|
|
const port = await openCodexAcpxRuntime(options, {
|
|
createRegistry: () => registry(), createStore: () => store(), createRuntime: value => { created = value; return runtime; },
|
|
});
|
|
expect(vi.mocked(runtime.setConfigOption).mock.calls.map(([call]) => [call.key, call.value])).toEqual([["model", options.profile.reportedModelId], ["mode", selected]]);
|
|
expect(options.refreshConsumedCommand).toHaveBeenCalledTimes(2);
|
|
expect(await port.identity()).toMatchObject({ mode: selected });
|
|
expect(runtime.startTurn).not.toHaveBeenCalled();
|
|
await port.close({ reason: "mode fixture cleanup" });
|
|
});
|
|
|
|
it("routes only profile-allowed extensions to the owning turn and expires late responses", async () => {
|
|
const runtime = fakeRuntime();
|
|
const first = pendingExtensionTurn("turn-1");
|
|
const second = pendingExtensionTurn("turn-2");
|
|
vi.mocked(runtime.startTurn).mockReturnValueOnce(first.turn).mockReturnValueOnce(second.turn);
|
|
let created!: AcpRuntimeOptions;
|
|
const options = openOptions(fakeCommand());
|
|
options.profile = { ...options.profile, agent: "cursor" };
|
|
options.clientCapabilities = { _meta: { cursor: { test: true } } };
|
|
const port = await openCodexAcpxRuntime(options, {
|
|
createRegistry: () => registry(), createStore: () => store(),
|
|
createRuntime: (value) => {
|
|
created = value;
|
|
const guard = value.protocolGuardFactory!();
|
|
const binding = cursorInstructionBinding(options.systemInstructions);
|
|
guard("outbound", { id: 0, method: "session/new", params: {} });
|
|
guard("inbound", { id: 0, result: { sessionId: "backend-1", modes: { currentModeId: "agent" }, configOptions: [{ id: "mode", currentValue: "agent" }], _meta: { paperclipCursorInstructions: { schema: "paperclip.cursor.instructions.v1", digest: binding.digest, byteLength: binding.byteLength } } } });
|
|
return runtime;
|
|
},
|
|
});
|
|
expect(created.clientCapabilities).toEqual(options.clientCapabilities);
|
|
expect(JSON.stringify(vi.mocked(runtime.ensureSession).mock.calls)).not.toContain("clientCapabilities");
|
|
const notification = vi.fn();
|
|
const request = vi.fn(async (_method, _params, context) => ({ requestId: context.requestId }));
|
|
const admittedFirst = port.startTurn({ text: "First", requestId: "turn-1", onExtensionRequest: request, onExtensionNotification: notification });
|
|
const signal = new AbortController().signal;
|
|
await expect(created.onExtensionRequest!("cursor/ask_question", { sessionId: "backend-1" }, { requestId: 0, signal })).resolves.toEqual({ requestId: 0 });
|
|
const sessionless = { toolCallId: "native-cursor-plan", plan: "Review the complete plan" };
|
|
const responseDelivery = Promise.resolve();
|
|
await expect(created.onExtensionRequest!("cursor/create_plan", sessionless, { requestId: "plan", signal, responseDelivery })).resolves.toEqual({ requestId: "plan" });
|
|
expect(request).toHaveBeenLastCalledWith("cursor/create_plan", { ...sessionless, sessionId: "backend-1" }, expect.objectContaining({ requestId: "plan", responseDelivery }));
|
|
expect(sessionless).not.toHaveProperty("sessionId");
|
|
await expect(created.onExtensionRequest!("pi/steer", {}, { requestId: 1, signal })).rejects.toThrow("admitted active turn");
|
|
await expect(created.onExtensionRequest!("cursor/ask_question", { sessionId: "other" }, { requestId: 1, signal })).rejects.toThrow("admitted active turn");
|
|
created.onExtensionNotification!("cursor/task", { sessionId: "backend-1", taskId: "t" });
|
|
created.onExtensionNotification!("cursor/task", { sessionId: "other" });
|
|
created.onExtensionNotification!("cursor/ask_question", { sessionId: "backend-1" });
|
|
expect(notification).toHaveBeenCalledExactlyOnceWith("cursor/task", { sessionId: "backend-1", taskId: "t" });
|
|
created.onExtensionNotification!("cursor/task", { toolCallId: "native-child" });
|
|
expect(notification).toHaveBeenLastCalledWith("cursor/task", { sessionId: "backend-1", toolCallId: "native-child" });
|
|
created.onExtensionNotification!("cursor/task", { sessionId: "agent-1" });
|
|
expect(notification).toHaveBeenCalledTimes(2);
|
|
request.mockImplementationOnce(() => new Promise(() => undefined));
|
|
const pending = created.onExtensionRequest!("cursor/ask_question", {}, { requestId: 2, signal });
|
|
const rejected = expect(pending).rejects.toThrow("expired");
|
|
first.settle(); await admittedFirst.result; await rejected;
|
|
const nextRequest = vi.fn(async () => ({ accepted: true }));
|
|
const admittedSecond = port.startTurn({ text: "Second", requestId: "turn-2", onExtensionRequest: nextRequest });
|
|
expect(nextRequest).not.toHaveBeenCalled();
|
|
await admittedSecond.cancel({ reason: "cancelled" });
|
|
await expect(created.onExtensionRequest!("cursor/ask_question", {}, { requestId: 3, signal })).rejects.toThrow("admitted active turn");
|
|
second.settle(); await admittedSecond.result; await port.close({ reason: "test finished" });
|
|
});
|
|
|
|
it("requires the Pi capability handshake and acknowledgments, preserves follow-up order, and never starts a second prompt", async () => {
|
|
const pending = pendingExtensionTurn("turn-1");
|
|
const runtime = Object.assign(fakeRuntime(), { requestExtension: vi.fn(async () => ({ accepted: true })) });
|
|
vi.mocked(runtime.startTurn).mockReturnValue(pending.turn);
|
|
let created!: AcpRuntimeOptions & { onAgentInitialize?: (result: unknown) => void };
|
|
const options = openOptions(fakeCommand());
|
|
options.profile = { ...options.profile, agent: "pi" };
|
|
options.piThinkingLevel = "low";
|
|
vi.mocked(runtime.setConfigOption).mockImplementation(async input => {
|
|
const guard = created.protocolGuardFactory!();
|
|
guard("outbound", { id: 0, method: "session/load", params: { sessionId: "backend-1" } });
|
|
guard("inbound", { id: 0, result: { modes: { currentModeId: "high" }, configOptions: [{ id: "thought_level", currentValue: "high" }] } });
|
|
guard("outbound", { id: 1, method: "session/set_config_option", params: { sessionId: "backend-1", configId: input.key, value: input.value } });
|
|
guard("inbound", { id: 1, result: { configOptions: [{ id: "thought_level", currentValue: "low" }] } });
|
|
});
|
|
const port = await openCodexAcpxRuntime(options, {
|
|
createRegistry: () => registry(), createStore: () => store(),
|
|
createRuntime: (value) => { created = value; return runtime; },
|
|
});
|
|
const turn = port.startTurn({ text: "Initial", requestId: "turn-1" });
|
|
await expect(port.steerActiveTurn!("Update", "turn-1")).rejects.toThrow("supported active turn");
|
|
created.onAgentInitialize!({ agentCapabilities: { _meta: { paperclipPi: { version: 1, steering: true, queuedFollowUp: true } } } });
|
|
expect(port.steeringCapability!()).toEqual({ steering: true, queuedFollowUp: true });
|
|
await expect(port.steerActiveTurn!("Update", "wrong")).rejects.toThrow("supported active turn");
|
|
await port.steerActiveTurn!("Update", "turn-1");
|
|
await Promise.all([port.queueFollowUp!("One", "turn-1"), port.queueFollowUp!("Two", "turn-1")]);
|
|
expect(runtime.requestExtension.mock.calls.map((call) => call[0])).toEqual([
|
|
{ handle: HANDLE, method: "pi/steer", params: { sessionId: "backend-1", message: "Update" }, sessionMode: "persistent" },
|
|
{ handle: HANDLE, method: "pi/follow_up", params: { sessionId: "backend-1", message: "One" }, sessionMode: "persistent" },
|
|
{ handle: HANDLE, method: "pi/follow_up", params: { sessionId: "backend-1", message: "Two" }, sessionMode: "persistent" },
|
|
]);
|
|
expect(runtime.startTurn).toHaveBeenCalledTimes(1);
|
|
runtime.requestExtension.mockResolvedValueOnce({ accepted: false });
|
|
await expect(port.steerActiveTurn!("Denied", "turn-1")).rejects.toThrow("not acknowledged");
|
|
runtime.requestExtension.mockImplementationOnce(() => new Promise(() => undefined));
|
|
const stale = port.queueFollowUp!("Stale", "turn-1");
|
|
const rejected = expect(stale).rejects.toThrow("expired");
|
|
pending.settle(); await turn.result; await rejected;
|
|
await expect(port.queueFollowUp!("Late", "turn-1")).rejects.toThrow("supported active turn");
|
|
await port.close({ reason: "test finished" });
|
|
});
|
|
|
|
it("does not grant steering to Copilot or Cursor when an untrusted initialize result claims Pi support", async () => {
|
|
const options = openOptions(fakeCommand()); options.profile = { ...options.profile, agent: "copilot" };
|
|
const runtime = fakeRuntime();
|
|
const port = await openCodexAcpxRuntime(options, {
|
|
createRegistry: () => registry(), createStore: () => store(),
|
|
createRuntime: (created) => {
|
|
(created as AcpRuntimeOptions & { onAgentInitialize: (result: unknown) => void }).onAgentInitialize({ agentCapabilities: { _meta: { paperclipPi: { version: 1, steering: true, queuedFollowUp: true } } } });
|
|
return runtime;
|
|
},
|
|
});
|
|
expect(port.steeringCapability!()).toBeNull();
|
|
await port.close({ reason: "test finished" });
|
|
});
|
|
it.each(["approve-reads", "approve-paperclip"] as const)("%s stops unassigned operations when approval has no handler", async (permissionMode) => {
|
|
const runtime = fakeRuntime();
|
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
|
let signal: AbortSignal | undefined;
|
|
let settle!: () => void;
|
|
const finished = new Promise<void>((resolve) => { settle = resolve; });
|
|
vi.mocked(runtime.startTurn).mockImplementation((input) => {
|
|
signal = input.signal;
|
|
signal?.addEventListener("abort", settle, { once: true });
|
|
return {
|
|
requestId: "permission-test",
|
|
promptStarted: Promise.resolve(),
|
|
events: (async function* () { await finished; })(),
|
|
result: finished.then(() => ({ status: "cancelled" as const })),
|
|
cancel: async () => settle(),
|
|
closeStream: async () => settle(),
|
|
} as ReturnType<AcpRuntime["startTurn"]>;
|
|
});
|
|
const options = openOptions(fakeCommand());
|
|
options.permissionMode = permissionMode;
|
|
const port = await openCodexAcpxRuntime(options, {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: (created) => { runtimeOptions = created; return runtime; },
|
|
});
|
|
expect(runtimeOptions!.permissionMode).toBe("approve-reads");
|
|
expect(runtimeOptions!.permissionPolicy).toMatchObject({ defaultAction: "escalate" });
|
|
const turn = port.startTurn({ text: "Attempt a write.", requestId: "permission-test" });
|
|
try {
|
|
await runtimeOptions!.onPermissionRequest!({
|
|
sessionId: "backend-1", inferredKind: "write", raw: {},
|
|
}, { signal: new AbortController().signal });
|
|
expect(signal?.aborted).toBe(true);
|
|
await expect(turn.result).rejects.toThrow("Approval required");
|
|
await expect((async () => { for await (const _event of turn.events) { /* drain */ } })())
|
|
.rejects.toThrow("Approval required");
|
|
} finally {
|
|
settle();
|
|
await port.close({ reason: "permission failure verified" });
|
|
}
|
|
});
|
|
|
|
it.each(["PERMISSION_PROMPT_UNAVAILABLE", "AGENT_DISCONNECTED"])("preserves the meaning of ACPX terminal failure %s", async (code) => {
|
|
const runtime = fakeRuntime();
|
|
const result = { status: "failed" as const, error: { code, message: "Provider failed" } };
|
|
vi.mocked(runtime.startTurn).mockReturnValue({
|
|
requestId: "permission-fallback", promptStarted: Promise.resolve(),
|
|
events: { async *[Symbol.asyncIterator]() {} }, result: Promise.resolve(result),
|
|
cancel: vi.fn(), closeStream: vi.fn(),
|
|
});
|
|
const port = await openCodexAcpxRuntime(openOptions(fakeCommand()), {
|
|
createRegistry: () => registry(), createStore: () => store(), createRuntime: () => runtime,
|
|
});
|
|
try {
|
|
const turn = port.startTurn({ text: "Attempt a write", requestId: "permission-fallback" });
|
|
if (code === "PERMISSION_PROMPT_UNAVAILABLE") {
|
|
await expect(turn.result).rejects.toMatchObject({ code: "approval_required" });
|
|
} else {
|
|
await expect(turn.result).resolves.toEqual(result);
|
|
}
|
|
} finally { await port.close({ reason: "terminal permission outcome verified" }); }
|
|
});
|
|
|
|
it("rejects a pre-aborted admission before constructing or spawning ACPX", async () => {
|
|
const cancellation = new Error("runtime admission cancelled");
|
|
const controller = new AbortController();
|
|
controller.abort(cancellation);
|
|
const command = fakeCommand();
|
|
const createRuntime = vi.fn();
|
|
const retainFailedAdmissionCleanup = vi.fn();
|
|
|
|
await expect(
|
|
openCodexAcpxRuntime(
|
|
{
|
|
...openOptions(command),
|
|
signal: controller.signal,
|
|
retainFailedAdmissionCleanup,
|
|
},
|
|
{ createRuntime },
|
|
),
|
|
).rejects.toBe(cancellation);
|
|
|
|
expect(retainFailedAdmissionCleanup).toHaveBeenCalledOnce();
|
|
await expect(
|
|
retainFailedAdmissionCleanup.mock.calls[0]?.[0],
|
|
).resolves.toBeUndefined();
|
|
expect(createRuntime).not.toHaveBeenCalled();
|
|
expect(command.spawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("opens a persistent Codex session without persisting launch secrets", async () => {
|
|
const runtime = fakeRuntime();
|
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
|
const command = fakeCommand();
|
|
const port = await openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: ({ overrides }) => {
|
|
expect(overrides).toEqual({
|
|
codex: ["paperclip-verified-acpx-command"],
|
|
});
|
|
return registry();
|
|
},
|
|
createStore: ({ stateDir }) => {
|
|
expect(stateDir).toBe("/runtime/state");
|
|
return store();
|
|
},
|
|
createRuntime: (options) => {
|
|
runtimeOptions = options;
|
|
return runtime;
|
|
},
|
|
});
|
|
|
|
expect(runtime.ensureSession).toHaveBeenCalledWith({
|
|
sessionKey: "provider-key",
|
|
agent: "codex",
|
|
mode: "persistent",
|
|
cwd: "/workspace",
|
|
sessionOptions: {
|
|
model: "gpt-5.6-sol",
|
|
systemPrompt: { append: "Use Paperclip tools." },
|
|
},
|
|
});
|
|
expect(
|
|
JSON.stringify(vi.mocked(runtime.ensureSession).mock.calls[0]?.[0]),
|
|
).not.toContain("credential-secret");
|
|
expect(runtimeOptions?.spawnEnvironment?.()).toEqual({
|
|
CODEX_HOME: "/runtime/agent-home",
|
|
OPENAI_API_KEY: "credential-secret",
|
|
});
|
|
expect(runtimeOptions?.spawnCwd).toBe("/workspace");
|
|
expect(runtimeOptions?.elicitationModes).toEqual(["form"]);
|
|
expect(await port.identity()).toEqual({
|
|
acpxRecordId: "record-1",
|
|
backendSessionId: "backend-1",
|
|
agentSessionId: "agent-1",
|
|
});
|
|
});
|
|
|
|
it.each([["claude" as const, "claude-sonnet-5", "claude-sonnet-5"]])(
|
|
"opens the qualified %s session through the verified lease",
|
|
async (agent, model, providerModel) => {
|
|
const runtime = fakeRuntime();
|
|
const command = fakeCommand();
|
|
const options = openOptions(command);
|
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
|
options.profile = resolveQualifiedAcpxProfile(agent, model);
|
|
options.launchEnvironment = { PATH: "/verified/bin" };
|
|
|
|
await openCodexAcpxRuntime(options, {
|
|
createRegistry: ({ overrides }) => {
|
|
expect(overrides).toEqual({
|
|
[agent]: [agent === "claude" ? "/paperclip-verified/claude-agent-acp" : "paperclip-verified-acpx-command"],
|
|
});
|
|
return registry();
|
|
},
|
|
createStore: () => store(),
|
|
createRuntime: (createdOptions) => {
|
|
runtimeOptions = createdOptions;
|
|
return runtime;
|
|
},
|
|
});
|
|
|
|
expect(runtimeOptions?.spawnEnvironment?.()).toEqual({
|
|
PATH: "/verified/bin",
|
|
PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1",
|
|
PAPERCLIP_ACPX_TASK_TOOL_BRIDGE_URL: "",
|
|
});
|
|
expect(runtime.ensureSession).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
agent,
|
|
sessionOptions: expect.objectContaining({ model: providerModel }),
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each(["runner-owned", "unowned", "absent"])(
|
|
"pins Claude completion authority to the %s task bridge",
|
|
async (binding) => {
|
|
const options = openOptions(fakeCommand());
|
|
options.profile = resolveQualifiedAcpxProfile("claude", "claude-sonnet-5");
|
|
options.launchEnvironment = { PAPERCLIP_ACPX_TASK_TOOL_BRIDGE_URL: "http://untrusted.invalid/mcp" };
|
|
options.mcpServers = binding === "absent" ? [] : [{
|
|
name: "paperclip", url: "http://127.0.0.1:3210/mcp",
|
|
bearerToken: "bridge-secret", runnerOwned: binding === "runner-owned",
|
|
}];
|
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
|
await openCodexAcpxRuntime(options, {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: (created) => {
|
|
runtimeOptions = created;
|
|
return fakeRuntime();
|
|
},
|
|
});
|
|
expect(runtimeOptions?.spawnEnvironment?.()).toEqual({
|
|
PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1",
|
|
PAPERCLIP_ACPX_TASK_TOOL_BRIDGE_URL: binding === "runner-owned" ? "http://127.0.0.1:3210/mcp" : "",
|
|
});
|
|
},
|
|
);
|
|
|
|
it("launches only through the verified command lease", async () => {
|
|
const runtime = fakeRuntime();
|
|
const command = fakeCommand();
|
|
const child = fakeChild();
|
|
vi.mocked(command.spawn).mockReturnValue(child);
|
|
const spawnOptions = { cwd: "/runtime/spawn" };
|
|
await openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderOwnership: providerOwnershipEstablished,
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
createRuntime: (options) => {
|
|
vi.mocked(runtime.ensureSession).mockImplementation(async () => {
|
|
expect(
|
|
options.spawnAgent?.({
|
|
command: "/attacker/replacement",
|
|
args: ["--stdio"],
|
|
options: spawnOptions,
|
|
}),
|
|
).toBe(child);
|
|
return HANDLE;
|
|
});
|
|
return runtime;
|
|
},
|
|
});
|
|
expect(command.spawn).toHaveBeenCalledWith(["--stdio"], spawnOptions, {
|
|
credentialFenceFds: [42, 43],
|
|
activateCredentialFenceOwner: expect.any(Function),
|
|
});
|
|
});
|
|
|
|
it("revalidates a recovered workspace immediately before provider spawn", async () => {
|
|
const runtime = fakeRuntime();
|
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
|
const command = fakeCommand();
|
|
const workspaceSubstituted = new Error("recovered workspace substituted");
|
|
const assertWorkspaceHeld = vi.fn(() => {
|
|
throw workspaceSubstituted;
|
|
});
|
|
await openCodexAcpxRuntime(
|
|
{ ...openOptions(command), assertWorkspaceHeld },
|
|
{
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: (options) => {
|
|
runtimeOptions = options;
|
|
return runtime;
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(() =>
|
|
runtimeOptions?.spawnAgent?.({
|
|
command: "/attacker/replacement",
|
|
args: ["--stdio"],
|
|
options: {},
|
|
}),
|
|
).toThrow(workspaceSubstituted);
|
|
expect(assertWorkspaceHeld).toHaveBeenCalledOnce();
|
|
expect(command.spawn).not.toHaveBeenCalled();
|
|
});
|
|
it("reads verified status from durable state without draining live updates", async () => {
|
|
const runtime = fakeRuntime();
|
|
vi.mocked(runtime.getStatus!).mockReturnValue(new Promise(() => {}));
|
|
const durableRecord = {
|
|
acpxRecordId: "record-1",
|
|
acpSessionId: "backend-1",
|
|
agentSessionId: "agent-1",
|
|
lastRequestId: "run:turn-1",
|
|
request_token_usage: { prompt: { input_tokens: 12, output_tokens: 30 } },
|
|
cumulative_cost: { amount: 0.1, currency: "USD" },
|
|
messages: [{ User: { id: "prompt", content: [] } }],
|
|
cursor_prompt_usage: { request_id: "run:turn-1", prompt_message_id: "prompt", receipt: { diagnostic: "fixture" } },
|
|
acpx: {
|
|
current_model_id: "gpt-5.6-sol",
|
|
available_models: ["gpt-5.6-sol"],
|
|
},
|
|
} as never;
|
|
const durableStore: AcpSessionStore = {
|
|
load: vi.fn(async () => structuredClone(durableRecord)),
|
|
save: vi.fn(),
|
|
};
|
|
const port = await openCodexAcpxRuntime(openOptions(fakeCommand()), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => durableStore,
|
|
createRuntime: () => runtime,
|
|
});
|
|
|
|
expect(await port.getStatus()).toMatchObject({
|
|
acpxRecordId: "record-1",
|
|
backendSessionId: "backend-1",
|
|
agentSessionId: "agent-1",
|
|
lastRequestId: "run:turn-1",
|
|
requestTokenUsage: { prompt: { input_tokens: 12, output_tokens: 30 } },
|
|
usageCost: { amount: 0.1, currency: "USD" },
|
|
promptMessageIds: ["prompt"],
|
|
cursorPromptUsage: { request_id: "run:turn-1", prompt_message_id: "prompt", receipt: { diagnostic: "fixture" } },
|
|
models: {
|
|
currentModelId: "gpt-5.6-sol",
|
|
availableModelIds: ["gpt-5.6-sol"],
|
|
},
|
|
});
|
|
expect(durableStore.load).toHaveBeenCalledWith("record-1");
|
|
expect(runtime.getStatus).not.toHaveBeenCalled();
|
|
await port.setModel?.("gpt-5.6-sol");
|
|
expect(runtime.setConfigOption).toHaveBeenCalledWith({
|
|
handle: HANDLE,
|
|
key: "model",
|
|
value: "gpt-5.6-sol",
|
|
});
|
|
await port.close({ reason: "test complete" });
|
|
expect(runtime.close).toHaveBeenCalledWith({
|
|
handle: HANDLE,
|
|
reason: "test complete",
|
|
discardPersistentState: false,
|
|
});
|
|
});
|
|
|
|
it("never overlaps a retained protocol close that has not settled", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const runtime = fakeRuntime();
|
|
const runtimeClose = new Promise<void>(() => {});
|
|
vi.mocked(runtime.close)
|
|
.mockReturnValueOnce(runtimeClose)
|
|
.mockResolvedValueOnce(undefined);
|
|
const child = fakeChild();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValue(child);
|
|
const port = await openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderOwnership: providerOwnershipEstablished,
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
createRuntime: (options) => {
|
|
return runtimeWithProvider(runtime, options);
|
|
},
|
|
});
|
|
|
|
const firstClose = expect(
|
|
port.close({ reason: "runtime close stalled" }),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed");
|
|
await Promise.resolve();
|
|
expect(runtime.close).toHaveBeenCalledOnce();
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
await firstClose;
|
|
|
|
// Repeated callers inherit the same bounded observation. A permanently
|
|
// pending exact close remains the sole protocol attempt for this handle.
|
|
expect(runtime.close).toHaveBeenCalledOnce();
|
|
const secondClose = expect(
|
|
port.close({ reason: "idempotent terminal close" }),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed");
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
await secondClose;
|
|
expect(runtime.close).toHaveBeenCalledOnce();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("allows a fresh close after a retained attempt rejects late", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const runtime = fakeRuntime();
|
|
let rejectRuntimeClose!: (error: unknown) => void;
|
|
const runtimeClose = new Promise<void>((_resolve, reject) => {
|
|
rejectRuntimeClose = reject;
|
|
});
|
|
vi.mocked(runtime.close)
|
|
.mockReturnValueOnce(runtimeClose)
|
|
.mockResolvedValueOnce(undefined);
|
|
const child = fakeChild();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValue(child);
|
|
const port = await openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderOwnership: providerOwnershipEstablished,
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
createRuntime: (options) => {
|
|
return runtimeWithProvider(runtime, options);
|
|
},
|
|
});
|
|
|
|
const firstClose = expect(
|
|
port.close({ reason: "runtime close stalled" }),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed");
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
await firstClose;
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
|
|
const protocolFailure = new Error("late protocol close failure");
|
|
rejectRuntimeClose(protocolFailure);
|
|
// The late-settlement observer schedules reconciliation asynchronously.
|
|
// Wait for that fresh attempt instead of racing another caller against
|
|
// the already-settled retained failure.
|
|
await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledTimes(2));
|
|
await expect(
|
|
port.close({ reason: "retry after retained failure" }),
|
|
).resolves.toBeUndefined();
|
|
expect(runtime.close).toHaveBeenLastCalledWith({
|
|
handle: HANDLE,
|
|
reason: "ACPX late protocol cleanup reconciliation 1",
|
|
discardPersistentState: false,
|
|
});
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("reconciles a retained close only after its late failure settles", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const runtime = fakeRuntime();
|
|
let rejectFirstClose!: (error: unknown) => void;
|
|
const firstRuntimeClose = new Promise<void>((_resolve, reject) => {
|
|
rejectFirstClose = reject;
|
|
});
|
|
vi.mocked(runtime.close)
|
|
.mockReturnValueOnce(firstRuntimeClose)
|
|
.mockResolvedValueOnce(undefined)
|
|
.mockResolvedValueOnce(undefined);
|
|
const child = fakeChild();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValue(child);
|
|
const port = await openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderOwnership: providerOwnershipEstablished,
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
createRuntime: (options) => {
|
|
return runtimeWithProvider(runtime, options);
|
|
},
|
|
});
|
|
|
|
const firstClose = expect(
|
|
port.close({ reason: "first protocol close stalls" }),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed");
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
await firstClose;
|
|
|
|
const inheritedClose = expect(
|
|
port.close({ reason: "observe pending protocol close" }),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed");
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
await inheritedClose;
|
|
expect(runtime.close).toHaveBeenCalledOnce();
|
|
|
|
rejectFirstClose(new Error("older protocol close failed late"));
|
|
await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledTimes(2));
|
|
expect(runtime.close).toHaveBeenLastCalledWith({
|
|
handle: HANDLE,
|
|
reason: "ACPX late protocol cleanup reconciliation 1",
|
|
discardPersistentState: false,
|
|
});
|
|
await expect(
|
|
port.close({ reason: "observe reconciled cleanup" }),
|
|
).resolves.toBeUndefined();
|
|
expect(runtime.close).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("shares a pending close across concurrent callers before reconciliation", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const runtime = fakeRuntime();
|
|
let rejectFirstClose!: (error: unknown) => void;
|
|
const firstRuntimeClose = new Promise<void>((_resolve, reject) => {
|
|
rejectFirstClose = reject;
|
|
});
|
|
let resolveFreshClose!: () => void;
|
|
const freshRuntimeClose = new Promise<void>((resolve) => {
|
|
resolveFreshClose = resolve;
|
|
});
|
|
vi.mocked(runtime.close)
|
|
.mockReturnValueOnce(firstRuntimeClose)
|
|
.mockReturnValueOnce(freshRuntimeClose)
|
|
.mockResolvedValueOnce(undefined);
|
|
const child = fakeChild();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValue(child);
|
|
const port = await openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderOwnership: providerOwnershipEstablished,
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
createRuntime: (options) => {
|
|
return runtimeWithProvider(runtime, options);
|
|
},
|
|
});
|
|
|
|
const firstClose = expect(
|
|
port.close({ reason: "first protocol close stalls" }),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed");
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
await firstClose;
|
|
|
|
const inheritedClose = expect(
|
|
port.close({ reason: "observe pending protocol close" }),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed");
|
|
expect(runtime.close).toHaveBeenCalledOnce();
|
|
rejectFirstClose(new Error("retained protocol close failed"));
|
|
await inheritedClose;
|
|
await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledTimes(2));
|
|
|
|
resolveFreshClose();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(runtime.close).toHaveBeenCalledTimes(2);
|
|
expect(runtime.close).toHaveBeenLastCalledWith({
|
|
handle: HANDLE,
|
|
reason: "ACPX late protocol cleanup reconciliation 1",
|
|
discardPersistentState: false,
|
|
});
|
|
await expect(
|
|
port.close({ reason: "observe reconciled cleanup" }),
|
|
).resolves.toBeUndefined();
|
|
expect(runtime.close).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("does not let late settlements bypass the reconciliation retry bound", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const runtime = fakeRuntime();
|
|
let rejectInitialClose!: (error: unknown) => void;
|
|
const initialClose = new Promise<void>((_resolve, reject) => {
|
|
rejectInitialClose = reject;
|
|
});
|
|
vi.mocked(runtime.close)
|
|
.mockReturnValueOnce(initialClose)
|
|
.mockRejectedValueOnce(new Error("reconciliation 1 failed"))
|
|
.mockRejectedValueOnce(new Error("reconciliation 2 failed"))
|
|
.mockRejectedValueOnce(new Error("reconciliation 3 failed"));
|
|
const port = await openCodexAcpxRuntime(openOptions(fakeCommand()), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: () => runtime,
|
|
});
|
|
|
|
const firstClose = expect(
|
|
port.close({ reason: "first protocol close stalls" }),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed");
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
await firstClose;
|
|
rejectInitialClose(new Error("initial protocol close failed late"));
|
|
await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledTimes(4));
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(runtime.close).toHaveBeenCalledTimes(4);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("keeps timed-out reconciliation failures in their originating budget", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const runtime = fakeRuntime();
|
|
let rejectInitialClose!: (error: unknown) => void;
|
|
const initialClose = new Promise<void>((_resolve, reject) => {
|
|
rejectInitialClose = reject;
|
|
});
|
|
const reconciliationRejectors: Array<(error: unknown) => void> = [];
|
|
const reconciliationAttempts = Array.from(
|
|
{ length: 3 },
|
|
() =>
|
|
new Promise<void>((_resolve, reject) => {
|
|
reconciliationRejectors.push(reject);
|
|
}),
|
|
);
|
|
vi.mocked(runtime.close)
|
|
.mockReturnValueOnce(initialClose)
|
|
.mockReturnValueOnce(reconciliationAttempts[0]!)
|
|
.mockReturnValueOnce(reconciliationAttempts[1]!)
|
|
.mockReturnValueOnce(reconciliationAttempts[2]!)
|
|
.mockResolvedValueOnce(undefined);
|
|
const port = await openCodexAcpxRuntime(openOptions(fakeCommand()), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: () => runtime,
|
|
runtimeCloseTimeoutMs: 1,
|
|
});
|
|
|
|
const firstClose = expect(
|
|
port.close({ reason: "external protocol close stalls" }),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed");
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await firstClose;
|
|
rejectInitialClose(new Error("external close failed late"));
|
|
|
|
for (let index = 0; index < reconciliationRejectors.length; index += 1) {
|
|
await vi.waitFor(() =>
|
|
expect(runtime.close).toHaveBeenCalledTimes(index + 2),
|
|
);
|
|
expect(runtime.close).toHaveBeenLastCalledWith({
|
|
handle: HANDLE,
|
|
reason: `ACPX late protocol cleanup reconciliation ${index + 1}`,
|
|
discardPersistentState: false,
|
|
});
|
|
const overlappingExternalClose =
|
|
index === 0
|
|
? expect(
|
|
port.close({
|
|
reason: "external observer of reconciliation",
|
|
}),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed")
|
|
: null;
|
|
// The external observer coalesces onto reconciliation attempt one. It
|
|
// must not relabel that immutable attempt as an external generation.
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
if (overlappingExternalClose) await overlappingExternalClose;
|
|
reconciliationRejectors[index]!(
|
|
new Error(`reconciliation ${index + 1} failed late`),
|
|
);
|
|
}
|
|
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
await Promise.resolve();
|
|
expect(runtime.close).toHaveBeenCalledTimes(4);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("renews one budget when an external close joins the final reconciliation", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const runtime = fakeRuntime();
|
|
let rejectInitialClose!: (error: unknown) => void;
|
|
const initialClose = new Promise<void>((_resolve, reject) => {
|
|
rejectInitialClose = reject;
|
|
});
|
|
let rejectFinalReconciliation!: (error: unknown) => void;
|
|
const finalReconciliation = new Promise<void>((_resolve, reject) => {
|
|
rejectFinalReconciliation = reject;
|
|
});
|
|
vi.mocked(runtime.close)
|
|
.mockReturnValueOnce(initialClose)
|
|
.mockRejectedValueOnce(new Error("reconciliation 1 failed"))
|
|
.mockRejectedValueOnce(new Error("reconciliation 2 failed"))
|
|
.mockReturnValueOnce(finalReconciliation)
|
|
.mockResolvedValueOnce(undefined);
|
|
const port = await openCodexAcpxRuntime(openOptions(fakeCommand()), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: () => runtime,
|
|
runtimeCloseTimeoutMs: 1,
|
|
});
|
|
|
|
const initialObserver = expect(
|
|
port.close({ reason: "external protocol close stalls" }),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed");
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await initialObserver;
|
|
rejectInitialClose(new Error("external close failed late"));
|
|
await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledTimes(4));
|
|
|
|
const finalObserver = expect(
|
|
port.close({ reason: "external observer of final reconciliation" }),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed");
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await finalObserver;
|
|
expect(runtime.close).toHaveBeenCalledTimes(4);
|
|
|
|
rejectFinalReconciliation(new Error("final reconciliation failed late"));
|
|
await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledTimes(5));
|
|
expect(runtime.close).toHaveBeenLastCalledWith({
|
|
handle: HANDLE,
|
|
reason: "ACPX late protocol cleanup reconciliation 1",
|
|
discardPersistentState: false,
|
|
});
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(runtime.close).toHaveBeenCalledTimes(5);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("batches coalesced external closes when the final reconciliation fails", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const runtime = fakeRuntime();
|
|
let rejectInitialClose!: (error: unknown) => void;
|
|
const initialClose = new Promise<void>((_resolve, reject) => {
|
|
rejectInitialClose = reject;
|
|
});
|
|
let rejectFinalReconciliation!: (error: unknown) => void;
|
|
const finalReconciliation = new Promise<void>((_resolve, reject) => {
|
|
rejectFinalReconciliation = reject;
|
|
});
|
|
vi.mocked(runtime.close)
|
|
.mockReturnValueOnce(initialClose)
|
|
.mockRejectedValueOnce(new Error("reconciliation 1 failed"))
|
|
.mockRejectedValueOnce(new Error("reconciliation 2 failed"))
|
|
.mockReturnValueOnce(finalReconciliation)
|
|
.mockRejectedValueOnce(new Error("renewed reconciliation 1 failed"))
|
|
.mockRejectedValueOnce(new Error("renewed reconciliation 2 failed"))
|
|
.mockRejectedValueOnce(new Error("renewed reconciliation 3 failed"));
|
|
const port = await openCodexAcpxRuntime(openOptions(fakeCommand()), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: () => runtime,
|
|
runtimeCloseTimeoutMs: 1,
|
|
});
|
|
|
|
const initialObserver = expect(
|
|
port.close({ reason: "external protocol close stalls" }),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed");
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await initialObserver;
|
|
rejectInitialClose(new Error("external close failed late"));
|
|
await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledTimes(4));
|
|
|
|
const coalescedObservers = ["first", "second", "third"].map((label) =>
|
|
expect(
|
|
port.close({ reason: `${label} external observer` }),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed"),
|
|
);
|
|
rejectFinalReconciliation(
|
|
new Error("final reconciliation failed with joined observers"),
|
|
);
|
|
expect(runtime.close).toHaveBeenCalledTimes(4);
|
|
await Promise.all(coalescedObservers);
|
|
await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledTimes(7));
|
|
expect(
|
|
vi
|
|
.mocked(runtime.close)
|
|
.mock.calls.slice(4)
|
|
.map(([input]) => input.reason),
|
|
).toEqual([
|
|
"ACPX late protocol cleanup reconciliation 1",
|
|
"ACPX late protocol cleanup reconciliation 2",
|
|
"ACPX late protocol cleanup reconciliation 3",
|
|
]);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
await Promise.resolve();
|
|
expect(runtime.close).toHaveBeenCalledTimes(7);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("consumes an external intent when a timed-out final reconciliation succeeds late", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const runtime = fakeRuntime();
|
|
let rejectInitialClose!: (error: unknown) => void;
|
|
const initialClose = new Promise<void>((_resolve, reject) => {
|
|
rejectInitialClose = reject;
|
|
});
|
|
let resolveFinalReconciliation!: () => void;
|
|
const finalReconciliation = new Promise<void>((resolve) => {
|
|
resolveFinalReconciliation = resolve;
|
|
});
|
|
vi.mocked(runtime.close)
|
|
.mockReturnValueOnce(initialClose)
|
|
.mockRejectedValueOnce(new Error("reconciliation 1 failed"))
|
|
.mockRejectedValueOnce(new Error("reconciliation 2 failed"))
|
|
.mockReturnValueOnce(finalReconciliation);
|
|
const port = await openCodexAcpxRuntime(openOptions(fakeCommand()), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: () => runtime,
|
|
runtimeCloseTimeoutMs: 1,
|
|
});
|
|
|
|
const initialObserver = expect(
|
|
port.close({ reason: "external protocol close stalls" }),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed");
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await initialObserver;
|
|
rejectInitialClose(new Error("external close failed late"));
|
|
await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledTimes(4));
|
|
|
|
const finalObserver = expect(
|
|
port.close({
|
|
reason: "external observer of successful reconciliation",
|
|
}),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed");
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await finalObserver;
|
|
expect(runtime.close).toHaveBeenCalledTimes(4);
|
|
|
|
resolveFinalReconciliation();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
await Promise.resolve();
|
|
expect(runtime.close).toHaveBeenCalledTimes(4);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("renews an external intent when bounded protocol success cannot terminate the provider", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const runtime = fakeRuntime();
|
|
let rejectInitialClose!: (error: unknown) => void;
|
|
const initialClose = new Promise<void>((_resolve, reject) => {
|
|
rejectInitialClose = reject;
|
|
});
|
|
let resolveFinalReconciliation!: () => void;
|
|
const finalReconciliation = new Promise<void>((resolve) => {
|
|
resolveFinalReconciliation = resolve;
|
|
});
|
|
let resolveFinalReconciliationStarted!: () => void;
|
|
const finalReconciliationStarted = new Promise<void>((resolve) => {
|
|
resolveFinalReconciliationStarted = resolve;
|
|
});
|
|
let resolveRenewedReconciliation!: () => void;
|
|
const renewedReconciliation = new Promise<void>((resolve) => {
|
|
resolveRenewedReconciliation = resolve;
|
|
});
|
|
let resolveRenewedReconciliationStarted!: () => void;
|
|
const renewedReconciliationStarted = new Promise<void>((resolve) => {
|
|
resolveRenewedReconciliationStarted = resolve;
|
|
});
|
|
vi.mocked(runtime.close)
|
|
.mockReturnValueOnce(initialClose)
|
|
.mockRejectedValueOnce(new Error("reconciliation 1 failed"))
|
|
.mockRejectedValueOnce(new Error("reconciliation 2 failed"))
|
|
.mockImplementationOnce(() => {
|
|
resolveFinalReconciliationStarted();
|
|
return finalReconciliation;
|
|
})
|
|
.mockImplementationOnce(() => {
|
|
resolveRenewedReconciliationStarted();
|
|
return renewedReconciliation;
|
|
});
|
|
const child = failingSignalChild();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValue(child.child);
|
|
const port = await openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderOwnership: providerOwnershipEstablished,
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
createRuntime: (options) => {
|
|
return runtimeWithProvider(runtime, options);
|
|
},
|
|
runtimeCloseTimeoutMs: 1,
|
|
});
|
|
|
|
const initialObserver = expect(
|
|
port.close({ reason: "external protocol close stalls" }),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed");
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await initialObserver;
|
|
rejectInitialClose(new Error("external close failed late"));
|
|
await finalReconciliationStarted;
|
|
expect(runtime.close).toHaveBeenCalledTimes(4);
|
|
|
|
const finalObserver = expect(
|
|
port.close({ reason: "external observer of failed process cleanup" }),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed");
|
|
resolveFinalReconciliation();
|
|
await finalObserver;
|
|
await renewedReconciliationStarted;
|
|
expect(runtime.close).toHaveBeenCalledTimes(5);
|
|
expect(runtime.close).toHaveBeenLastCalledWith({
|
|
handle: HANDLE,
|
|
reason: "ACPX late protocol cleanup reconciliation 1",
|
|
discardPersistentState: false,
|
|
});
|
|
child.child.signalCode = "SIGKILL";
|
|
child.child.emit("exit", null, "SIGKILL");
|
|
resolveRenewedReconciliation();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
await Promise.resolve();
|
|
expect(runtime.close).toHaveBeenCalledTimes(5);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("renews an external intent when late protocol success follows process cleanup failure", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const runtime = fakeRuntime();
|
|
let rejectInitialClose!: (error: unknown) => void;
|
|
const initialClose = new Promise<void>((_resolve, reject) => {
|
|
rejectInitialClose = reject;
|
|
});
|
|
let resolveFinalReconciliation!: () => void;
|
|
const finalReconciliation = new Promise<void>((resolve) => {
|
|
resolveFinalReconciliation = resolve;
|
|
});
|
|
let resolveFinalReconciliationStarted!: () => void;
|
|
const finalReconciliationStarted = new Promise<void>((resolve) => {
|
|
resolveFinalReconciliationStarted = resolve;
|
|
});
|
|
let resolveRenewedReconciliationStarted!: () => void;
|
|
const renewedReconciliationStarted = new Promise<void>((resolve) => {
|
|
resolveRenewedReconciliationStarted = resolve;
|
|
});
|
|
vi.mocked(runtime.close)
|
|
.mockReturnValueOnce(initialClose)
|
|
.mockRejectedValueOnce(new Error("reconciliation 1 failed"))
|
|
.mockRejectedValueOnce(new Error("reconciliation 2 failed"))
|
|
.mockImplementationOnce(() => {
|
|
resolveFinalReconciliationStarted();
|
|
return finalReconciliation;
|
|
})
|
|
.mockImplementationOnce(() => {
|
|
resolveRenewedReconciliationStarted();
|
|
return Promise.resolve();
|
|
});
|
|
const child = failingSignalChild();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValue(child.child);
|
|
const port = await openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderOwnership: providerOwnershipEstablished,
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
createRuntime: (options) => {
|
|
return runtimeWithProvider(runtime, options);
|
|
},
|
|
runtimeCloseTimeoutMs: 1,
|
|
});
|
|
|
|
const initialObserver = expect(
|
|
port.close({ reason: "external protocol close stalls" }),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed");
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await initialObserver;
|
|
rejectInitialClose(new Error("external close failed late"));
|
|
await finalReconciliationStarted;
|
|
expect(runtime.close).toHaveBeenCalledTimes(4);
|
|
|
|
const finalObserver = expect(
|
|
port.close({ reason: "external observer of failed process cleanup" }),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed");
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await vi.advanceTimersByTimeAsync(4_000);
|
|
await finalObserver;
|
|
expect(runtime.close).toHaveBeenCalledTimes(4);
|
|
|
|
child.child.signalCode = "SIGKILL";
|
|
child.child.emit("exit", null, "SIGKILL");
|
|
resolveFinalReconciliation();
|
|
await renewedReconciliationStarted;
|
|
expect(runtime.close).toHaveBeenCalledTimes(5);
|
|
expect(runtime.close).toHaveBeenLastCalledWith({
|
|
handle: HANDLE,
|
|
reason: "ACPX late protocol cleanup reconciliation 1",
|
|
discardPersistentState: false,
|
|
});
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(runtime.close).toHaveBeenCalledTimes(5);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("shares one reconciliation budget across repeated bounded observers", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const runtime = fakeRuntime();
|
|
let rejectRetainedClose!: (error: unknown) => void;
|
|
const retainedClose = new Promise<void>((_resolve, reject) => {
|
|
rejectRetainedClose = reject;
|
|
});
|
|
vi.mocked(runtime.close)
|
|
.mockReturnValueOnce(retainedClose)
|
|
.mockRejectedValueOnce(new Error("reconciliation 1 failed"))
|
|
.mockRejectedValueOnce(new Error("reconciliation 2 failed"))
|
|
.mockRejectedValueOnce(new Error("reconciliation 3 failed"));
|
|
const port = await openCodexAcpxRuntime(openOptions(fakeCommand()), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: () => runtime,
|
|
runtimeCloseTimeoutMs: 1,
|
|
});
|
|
|
|
for (const reason of [
|
|
"first observer",
|
|
"second observer",
|
|
"third observer",
|
|
]) {
|
|
const close = expect(port.close({ reason })).rejects.toThrow(
|
|
"ACPX runtime and provider cleanup failed",
|
|
);
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await close;
|
|
}
|
|
expect(runtime.close).toHaveBeenCalledOnce();
|
|
rejectRetainedClose(new Error("retained close failed late"));
|
|
await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledTimes(4));
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(runtime.close).toHaveBeenCalledTimes(4);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("gives each late failure generation a bounded reconciliation budget", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const runtime = fakeRuntime();
|
|
let rejectInitialClose!: (error: unknown) => void;
|
|
const initialClose = new Promise<void>((_resolve, reject) => {
|
|
rejectInitialClose = reject;
|
|
});
|
|
let rejectNewerClose!: (error: unknown) => void;
|
|
const newerClose = new Promise<void>((_resolve, reject) => {
|
|
rejectNewerClose = reject;
|
|
});
|
|
vi.mocked(runtime.close)
|
|
.mockReturnValueOnce(initialClose)
|
|
.mockRejectedValueOnce(new Error("reconciliation 1 failed"))
|
|
.mockRejectedValueOnce(new Error("reconciliation 2 failed"))
|
|
.mockRejectedValueOnce(new Error("reconciliation 3 failed"))
|
|
.mockReturnValueOnce(newerClose)
|
|
.mockResolvedValueOnce(undefined);
|
|
const port = await openCodexAcpxRuntime(openOptions(fakeCommand()), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: () => runtime,
|
|
runtimeCloseTimeoutMs: 1,
|
|
});
|
|
|
|
const firstClose = expect(
|
|
port.close({ reason: "first protocol close stalls" }),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed");
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await firstClose;
|
|
rejectInitialClose(new Error("initial close failed late"));
|
|
await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledTimes(4));
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
const secondClose = expect(
|
|
port.close({ reason: "newer protocol close stalls" }),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed");
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await secondClose;
|
|
expect(runtime.close).toHaveBeenCalledTimes(5);
|
|
|
|
rejectNewerClose(new Error("newer close failed late"));
|
|
await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledTimes(6));
|
|
expect(runtime.close).toHaveBeenLastCalledWith({
|
|
handle: HANDLE,
|
|
reason: "ACPX late protocol cleanup reconciliation 1",
|
|
discardPersistentState: false,
|
|
});
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("marks a retained protocol close terminal when it succeeds late", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const runtime = fakeRuntime();
|
|
let resolveRetainedClose!: () => void;
|
|
const retainedClose = new Promise<void>((resolve) => {
|
|
resolveRetainedClose = resolve;
|
|
});
|
|
vi.mocked(runtime.close).mockReturnValueOnce(retainedClose);
|
|
const port = await openCodexAcpxRuntime(openOptions(fakeCommand()), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: () => runtime,
|
|
runtimeCloseTimeoutMs: 1,
|
|
});
|
|
|
|
const close = expect(
|
|
port.close({ reason: "protocol close stalls" }),
|
|
).rejects.toThrow("ACPX runtime and provider cleanup failed");
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await close;
|
|
expect(runtime.close).toHaveBeenCalledOnce();
|
|
|
|
resolveRetainedClose();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
await expect(
|
|
port.close({ reason: "observe late success" }),
|
|
).resolves.toBeUndefined();
|
|
expect(runtime.close).toHaveBeenCalledOnce();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("retains cleanup after guardian exit until provider exit is proven", async () => {
|
|
const runtime = fakeRuntime();
|
|
const child = fakeChild();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValue(child);
|
|
let proveProviderExit!: () => void;
|
|
const providerExit = new Promise<void>((resolve) => {
|
|
proveProviderExit = resolve;
|
|
});
|
|
const port = await openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderOwnership: providerOwnershipEstablished,
|
|
awaitProviderExit: async () => await providerExit,
|
|
createRuntime: (options) => runtimeWithProvider(runtime, options),
|
|
});
|
|
|
|
let settled = false;
|
|
const closing = port.close({ reason: "guardian exited first" });
|
|
void closing.then(
|
|
() => {
|
|
settled = true;
|
|
},
|
|
() => {
|
|
settled = true;
|
|
},
|
|
);
|
|
await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith("SIGTERM"));
|
|
await Promise.resolve();
|
|
|
|
expect(child.signalCode).toBe("SIGTERM");
|
|
expect(settled).toBe(false);
|
|
|
|
proveProviderExit();
|
|
await expect(closing).resolves.toBeUndefined();
|
|
expect(settled).toBe(true);
|
|
});
|
|
|
|
it("kills a TERM-resistant provider through its live guardian", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const runtime = fakeRuntime();
|
|
const child = childThatExitsOnKill();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValue(child);
|
|
const port = await openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderOwnership: providerOwnershipEstablished,
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
createRuntime: (options) => {
|
|
return runtimeWithProvider(runtime, options);
|
|
},
|
|
});
|
|
|
|
const closing = expect(
|
|
port.close({ reason: "provider ignored shutdown" }),
|
|
).rejects.toMatchObject({
|
|
errors: [
|
|
expect.objectContaining({
|
|
message: "ACPX provider did not exit after SIGTERM",
|
|
}),
|
|
],
|
|
});
|
|
await vi.advanceTimersByTimeAsync(4_000);
|
|
await closing;
|
|
|
|
expect(child.kill).toHaveBeenNthCalledWith(1, "SIGTERM");
|
|
expect(child.kill).toHaveBeenNthCalledWith(2, "SIGKILL");
|
|
expect(child.kill).toHaveBeenCalledTimes(2);
|
|
expect(child.signalCode).toBe("SIGKILL");
|
|
// Cleanup never transfers a copied numeric PGID or releases local
|
|
// ownership before the verified guardian's exit is observed.
|
|
expect(child.unref).not.toHaveBeenCalled();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("retains local ownership when guardian group exit cannot be confirmed", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const runtime = fakeRuntime();
|
|
const child = stubbornChild();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValue(child);
|
|
const port = await openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderOwnership: providerOwnershipEstablished,
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
createRuntime: (options) => {
|
|
return runtimeWithProvider(runtime, options);
|
|
},
|
|
});
|
|
|
|
const closing = expect(
|
|
port.close({ reason: "provider group exit unconfirmed" }),
|
|
).rejects.toMatchObject({
|
|
errors: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
message: "ACPX provider did not exit after SIGKILL",
|
|
}),
|
|
]),
|
|
});
|
|
await vi.advanceTimersByTimeAsync(4_000);
|
|
await closing;
|
|
|
|
expect(child.kill).toHaveBeenNthCalledWith(1, "SIGTERM");
|
|
expect(child.kill).toHaveBeenNthCalledWith(2, "SIGKILL");
|
|
expect(child.unref).not.toHaveBeenCalled();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("rejects Windows before runtime construction or provider spawn", async () => {
|
|
const command = fakeCommand();
|
|
const createRuntime = vi.fn(() => fakeRuntime());
|
|
|
|
await expect(
|
|
openCodexAcpxRuntime(openOptions(command), {
|
|
platform: "win32",
|
|
createRuntime,
|
|
}),
|
|
).rejects.toThrow(
|
|
"The production ACPX runtime is unavailable on Windows because verified provider launch requires atomic no-follow file opening",
|
|
);
|
|
|
|
expect(createRuntime).not.toHaveBeenCalled();
|
|
expect(command.spawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
["one fence", [42]],
|
|
["duplicate fences", [42, 42]],
|
|
["an invalid fence", [42, -1]],
|
|
])("rejects %s before runtime construction", async (_label, fences) => {
|
|
const command = fakeCommand();
|
|
const createRuntime = vi.fn(() => fakeRuntime());
|
|
|
|
await expect(
|
|
openCodexAcpxRuntime(
|
|
{
|
|
...openOptions(command),
|
|
credentialFenceFds: fences as unknown as readonly [number, number],
|
|
},
|
|
{ createRuntime },
|
|
),
|
|
).rejects.toThrow(
|
|
"The production ACPX runtime requires an inherited provider-lifetime fence",
|
|
);
|
|
|
|
expect(createRuntime).not.toHaveBeenCalled();
|
|
expect(command.spawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a missing credential owner activation callback", async () => {
|
|
const command = fakeCommand();
|
|
const createRuntime = vi.fn(() => fakeRuntime());
|
|
|
|
await expect(
|
|
openCodexAcpxRuntime(
|
|
{
|
|
...openOptions(command),
|
|
activateCredentialFenceOwner: null,
|
|
},
|
|
{ createRuntime },
|
|
),
|
|
).rejects.toThrow(
|
|
"The production ACPX runtime requires an inherited provider-lifetime fence",
|
|
);
|
|
|
|
expect(createRuntime).not.toHaveBeenCalled();
|
|
expect(command.spawn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects and independently cleans providers spawned during termination", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const runtime = fakeRuntime();
|
|
const firstChild = childThatExitsOnKill();
|
|
const lateChild = childThatExitsOnKill();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn)
|
|
.mockReturnValueOnce(firstChild)
|
|
.mockReturnValueOnce(lateChild);
|
|
const retainedCleanups: Promise<void>[] = [];
|
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
|
const port = await openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderOwnership: providerOwnershipEstablished,
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
retainCleanup: (cleanup) => retainedCleanups.push(cleanup),
|
|
createRuntime: (options) => {
|
|
runtimeOptions = options;
|
|
return runtimeWithProvider(runtime, options);
|
|
},
|
|
});
|
|
|
|
let settled = false;
|
|
const closing = port.close({ reason: "join late provider" }).then(
|
|
() => {
|
|
settled = true;
|
|
},
|
|
() => {
|
|
settled = true;
|
|
},
|
|
);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(firstChild.kill).toHaveBeenCalledWith("SIGTERM");
|
|
await vi.advanceTimersByTimeAsync(1_500);
|
|
expect(() =>
|
|
runtimeOptions?.spawnAgent?.({
|
|
command: "ignored",
|
|
args: ["--stdio"],
|
|
options: {},
|
|
}),
|
|
).toThrow("provider spawned after cleanup was sealed");
|
|
expect(lateChild.kill).toHaveBeenCalledWith("SIGKILL");
|
|
expect(retainedCleanups).toHaveLength(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(firstChild.kill).toHaveBeenCalledWith("SIGKILL");
|
|
await closing;
|
|
await retainedCleanups[0];
|
|
expect(settled).toBe(true);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("maps prompt turns to the admitted ACPX handle", async () => {
|
|
const runtime = fakeRuntime();
|
|
const turn = {
|
|
requestId: "turn-1",
|
|
promptStarted: Promise.resolve(),
|
|
events: { async *[Symbol.asyncIterator]() {} },
|
|
result: Promise.resolve({ status: "completed" as const }),
|
|
cancel: vi.fn(),
|
|
closeStream: vi.fn(),
|
|
};
|
|
vi.mocked(runtime.startTurn).mockReturnValue(turn);
|
|
const port = await openCodexAcpxRuntime(openOptions(fakeCommand()), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: () => runtime,
|
|
});
|
|
const signal = new AbortController().signal;
|
|
const onElicitation = vi.fn();
|
|
|
|
const admittedTurn = port.startTurn({
|
|
text: "Complete the task.",
|
|
requestId: "turn-1",
|
|
signal,
|
|
onElicitation,
|
|
});
|
|
expect(admittedTurn.requestId).toBe(turn.requestId);
|
|
await expect(admittedTurn.promptStarted).resolves.toBeUndefined();
|
|
await expect(admittedTurn.result).resolves.toEqual({ status: "completed" });
|
|
expect(runtime.startTurn).toHaveBeenCalledWith({
|
|
handle: HANDLE,
|
|
text: "Complete the task.",
|
|
mode: "prompt",
|
|
requestId: "turn-1",
|
|
signal: expect.any(AbortSignal),
|
|
onElicitation,
|
|
});
|
|
});
|
|
|
|
it("observes prompt admission rejection when the sidecar consumes only events and the result", async () => {
|
|
const runtime = fakeRuntime();
|
|
const failure = new Error("Recovered provider could not start the prompt");
|
|
vi.mocked(runtime.startTurn).mockImplementation(() => ({
|
|
requestId: "turn-recovered-failure",
|
|
promptStarted: Promise.reject(failure),
|
|
events: { async *[Symbol.asyncIterator]() { throw failure; } },
|
|
result: Promise.reject(failure),
|
|
cancel: vi.fn(),
|
|
closeStream: vi.fn(),
|
|
}));
|
|
const port = await openCodexAcpxRuntime(openOptions(fakeCommand()), {
|
|
createRegistry: () => registry(), createStore: () => store(), createRuntime: () => runtime,
|
|
});
|
|
const turn = port.startTurn({ text: "Resume", requestId: "turn-recovered-failure" });
|
|
const eventDrain = (async () => { for await (const _event of turn.events) { /* drain */ } })();
|
|
await expect(eventDrain).rejects.toBe(failure);
|
|
// The sidecar does not await promptStarted. Leave it unconsumed across a
|
|
// full event-loop turn so an unobserved derived rejection fails this test.
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
// Observing internally must not replace failure with successful admission.
|
|
await expect(turn.result).rejects.toBe(failure);
|
|
await expect(turn.promptStarted).rejects.toBe(failure);
|
|
await port.close({ reason: "test complete" });
|
|
});
|
|
|
|
it("verifies a lazy recovered provider spawned by model selection before returning", async () => {
|
|
const runtime = fakeRuntime();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValue(fakeChild());
|
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
|
let acknowledgeOwnership!: () => void;
|
|
const ownership = new Promise<void>((resolve) => { acknowledgeOwnership = resolve; });
|
|
vi.mocked(runtime.setConfigOption!).mockImplementation(async () => {
|
|
await Promise.resolve();
|
|
runtimeOptions?.spawnAgent?.({ command: "ignored", args: ["--stdio"], options: {} });
|
|
});
|
|
const port = await openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(), createStore: () => store(),
|
|
awaitProviderOwnership: () => ownership,
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
createRuntime: (options) => { runtimeOptions = options; return runtime; },
|
|
});
|
|
let admitted = false;
|
|
const selection = port.setModel!("gpt-5.6-sol").then(() => { admitted = true; });
|
|
void selection.catch(() => undefined);
|
|
await vi.waitFor(() => expect(command.spawn).toHaveBeenCalledOnce());
|
|
expect(admitted).toBe(false);
|
|
acknowledgeOwnership();
|
|
await selection;
|
|
expect(admitted).toBe(true);
|
|
expect(() => runtimeOptions?.spawnAgent?.({ command: "ignored", args: [], options: {} }))
|
|
.toThrow("provider spawned after ownership admission was sealed");
|
|
await port.close({ reason: "test complete" });
|
|
});
|
|
|
|
it("uses a fresh single-use command after a cold model control consumes its launch", async () => {
|
|
const runtime = fakeRuntime();
|
|
const freshCommand = () => {
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValueOnce(fakeChild()).mockImplementation(() => {
|
|
throw new Error("Verified ACPX command lease is closed");
|
|
});
|
|
return command;
|
|
};
|
|
const first = freshCommand();
|
|
const second = freshCommand();
|
|
const openCommand = vi.fn(async () => second);
|
|
const owner = createAcpxCommandLeaseOwner(first, openCommand);
|
|
let runtimeOptions: AcpRuntimeOptions;
|
|
vi.mocked(runtime.setConfigOption!).mockImplementation(async () => {
|
|
runtimeOptions.spawnAgent!({ command: "ignored", args: [], options: {} });
|
|
});
|
|
vi.mocked(runtime.startTurn).mockImplementation(() => {
|
|
runtimeOptions.spawnAgent!({ command: "ignored", args: [], options: {} });
|
|
return {
|
|
requestId: "cold-turn",
|
|
promptStarted: Promise.resolve(),
|
|
events: { async *[Symbol.asyncIterator]() {} },
|
|
result: Promise.resolve({ status: "completed" }),
|
|
cancel: vi.fn(),
|
|
closeStream: vi.fn(),
|
|
};
|
|
});
|
|
const port = await openCodexAcpxRuntime(
|
|
{
|
|
...openOptions(owner.command),
|
|
refreshConsumedCommand: owner.refreshConsumedCommand,
|
|
},
|
|
{
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderOwnership: providerOwnershipEstablished,
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
createRuntime: (options) => {
|
|
runtimeOptions = options;
|
|
return runtime;
|
|
},
|
|
},
|
|
);
|
|
await port.setModel!("gpt-5.6-sol");
|
|
expect(openCommand).toHaveBeenCalledOnce();
|
|
const turn = port.startTurn({ text: "Resume", requestId: "cold-turn" });
|
|
await expect(turn.result).resolves.toMatchObject({ status: "completed" });
|
|
expect(first.spawn).toHaveBeenCalledOnce();
|
|
expect(second.spawn).toHaveBeenCalledOnce();
|
|
await port.close({ reason: "test complete" });
|
|
await owner.command.close();
|
|
});
|
|
|
|
it("admits a verified provider that starts with the first recovered turn", async () => {
|
|
const runtime = fakeRuntime();
|
|
const child = fakeChild();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValue(child);
|
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
|
let resolvePromptStarted: (() => void) | undefined;
|
|
const promptStarted = new Promise<void>((resolve) => {
|
|
resolvePromptStarted = resolve;
|
|
});
|
|
const rawTurn = {
|
|
requestId: "turn-recovered",
|
|
promptStarted,
|
|
events: { async *[Symbol.asyncIterator]() {} },
|
|
result: new Promise<never>(() => undefined),
|
|
cancel: vi.fn(),
|
|
closeStream: vi.fn(),
|
|
};
|
|
vi.mocked(runtime.startTurn).mockImplementation(() => {
|
|
queueMicrotask(() => {
|
|
runtimeOptions?.spawnAgent?.({
|
|
command: "ignored",
|
|
args: ["--stdio"],
|
|
options: {},
|
|
});
|
|
resolvePromptStarted?.();
|
|
});
|
|
return rawTurn;
|
|
});
|
|
const port = await openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderOwnership: providerOwnershipEstablished,
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
createRuntime: (options) => {
|
|
runtimeOptions = options;
|
|
return runtime;
|
|
},
|
|
});
|
|
|
|
const turn = port.startTurn({
|
|
text: "Resume the task.",
|
|
requestId: "turn-recovered",
|
|
});
|
|
await expect(turn.promptStarted).resolves.toBeUndefined();
|
|
expect(command.spawn).toHaveBeenCalledTimes(1);
|
|
expect(() =>
|
|
runtimeOptions?.spawnAgent?.({
|
|
command: "ignored",
|
|
args: ["--stdio"],
|
|
options: {},
|
|
}),
|
|
).toThrow("provider spawned after ownership admission was sealed");
|
|
});
|
|
|
|
it("projects only ephemeral MCP bindings and applies fail-closed permissions", async () => {
|
|
const runtime = fakeRuntime();
|
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
|
const port = await openCodexAcpxRuntime(
|
|
{
|
|
...openOptions(fakeCommand()),
|
|
permissionMode: "deny-all",
|
|
mcpServers: [
|
|
{
|
|
name: "paperclip",
|
|
url: "http://127.0.0.1:3210/mcp",
|
|
bearerToken: "bridge-secret",
|
|
runnerOwned: true,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: (options) => {
|
|
runtimeOptions = options;
|
|
return runtime;
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(runtimeOptions?.mcpServers).toEqual([
|
|
{
|
|
type: "http",
|
|
name: "paperclip",
|
|
url: "http://127.0.0.1:3210/mcp",
|
|
headers: [{ name: "Authorization", value: "Bearer bridge-secret" }],
|
|
},
|
|
]);
|
|
await expect(
|
|
runtimeOptions?.onPermissionRequest?.(
|
|
{
|
|
sessionId: "session-1",
|
|
inferredKind: "execute",
|
|
raw: { _meta: { is_mcp_tool_approval: true } },
|
|
},
|
|
{ signal: new AbortController().signal },
|
|
),
|
|
).resolves.toEqual({ outcome: "reject_once" });
|
|
await expect(
|
|
runtimeOptions?.onPermissionRequest?.(
|
|
{
|
|
sessionId: "session-1",
|
|
inferredKind: "execute",
|
|
raw: {},
|
|
},
|
|
{ signal: new AbortController().signal },
|
|
),
|
|
).resolves.toEqual({ outcome: "reject_once" });
|
|
expect(
|
|
JSON.stringify(vi.mocked(runtime.ensureSession).mock.calls),
|
|
).not.toContain("bridge-secret");
|
|
await port.close({ reason: "complete" });
|
|
});
|
|
|
|
it("rejects permissions that require an unavailable coordinator", async () => {
|
|
const runtime = fakeRuntime();
|
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
|
const port = await openCodexAcpxRuntime(openOptions(fakeCommand()), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: (options) => {
|
|
runtimeOptions = options;
|
|
return runtime;
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
runtimeOptions?.onPermissionRequest?.(
|
|
{
|
|
sessionId: "session-1",
|
|
inferredKind: "write",
|
|
raw: {},
|
|
},
|
|
{ signal: new AbortController().signal },
|
|
),
|
|
).resolves.toEqual({ outcome: "reject_once" });
|
|
await port.close({ reason: "complete" });
|
|
});
|
|
|
|
it.each([
|
|
["codex", "gpt-5.6-sol", "approve-all", { outcome: "allow_once" }],
|
|
["codex", "gpt-5.6-sol", "approve-reads", { outcome: "reject_once" }],
|
|
["codex", "gpt-5.6-sol", "deny-all", { outcome: "reject_once" }],
|
|
["claude", "claude-sonnet-5", "approve-all", { outcome: "allow_once" }],
|
|
["claude", "claude-sonnet-5", "approve-reads", { outcome: "reject_once" }],
|
|
["claude", "claude-sonnet-5", "approve-paperclip", { outcome: "reject_once" }],
|
|
["claude", "claude-sonnet-5", "deny-all", { outcome: "reject_once" }],
|
|
] as const)(
|
|
"applies the %s/%s ACPX profile's %s mode without an implicit prompt bridge",
|
|
async (agent, model, permissionMode, expected) => {
|
|
const runtime = fakeRuntime();
|
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
|
const options = openOptions(fakeCommand());
|
|
options.profile = resolveQualifiedAcpxProfile(agent, model);
|
|
options.permissionMode = permissionMode;
|
|
const port = await openCodexAcpxRuntime(options, {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: (createdOptions) => {
|
|
runtimeOptions = createdOptions;
|
|
return runtime;
|
|
},
|
|
});
|
|
|
|
expect(runtimeOptions?.nonInteractivePermissions).toBe("fail");
|
|
await expect(
|
|
runtimeOptions?.onPermissionRequest?.(
|
|
{
|
|
sessionId: "backend-1",
|
|
inferredKind: "write",
|
|
raw: {},
|
|
},
|
|
{ signal: new AbortController().signal },
|
|
),
|
|
).resolves.toEqual(expected);
|
|
await port.close({ reason: "permission policy verified" });
|
|
},
|
|
);
|
|
|
|
it("uses the real ACP session when no second agent identity is advertised", async () => {
|
|
const runtime = fakeRuntime({ ...HANDLE, agentSessionId: undefined });
|
|
const durableStore: AcpSessionStore = {
|
|
load: vi.fn(async () =>
|
|
structuredClone({
|
|
acpxRecordId: "record-1",
|
|
acpSessionId: "backend-1",
|
|
acpx: { current_model_id: "gpt-5.6-sol" },
|
|
} as never),
|
|
),
|
|
save: vi.fn(),
|
|
};
|
|
const port = await openCodexAcpxRuntime(openOptions(fakeCommand()), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => durableStore,
|
|
createRuntime: () => runtime,
|
|
});
|
|
|
|
await expect(port.identity()).resolves.toEqual({
|
|
acpxRecordId: "record-1",
|
|
backendSessionId: "backend-1",
|
|
agentSessionId: "backend-1",
|
|
});
|
|
await expect(port.getStatus()).resolves.toMatchObject({
|
|
backendSessionId: "backend-1",
|
|
agentSessionId: "backend-1",
|
|
models: { currentModelId: "gpt-5.6-sol" },
|
|
});
|
|
expect(runtime.close).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("bounds invalid-identity cleanup before terminating the provider", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const runtime = fakeRuntime({
|
|
...HANDLE,
|
|
backendSessionId: undefined,
|
|
agentSessionId: undefined,
|
|
});
|
|
vi.mocked(runtime.close).mockImplementation(
|
|
() => new Promise<void>(() => undefined),
|
|
);
|
|
const child = fakeChild();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValue(child);
|
|
const opening = openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
createRuntime: (options) => ({
|
|
...runtime,
|
|
ensureSession: vi.fn(async () => {
|
|
options.spawnAgent?.({
|
|
command: "ignored",
|
|
args: ["--stdio"],
|
|
options: {},
|
|
});
|
|
return {
|
|
...HANDLE,
|
|
backendSessionId: undefined,
|
|
agentSessionId: undefined,
|
|
};
|
|
}),
|
|
}),
|
|
});
|
|
const rejected = expect(opening).rejects.toThrow(
|
|
"identity validation and cleanup failed",
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
await rejected;
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("terminates a provider spawned before the session handshake rejects", async () => {
|
|
const child = fakeChild();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValue(child);
|
|
const failure = new Error("ACP handshake rejected");
|
|
const runtime = fakeRuntime();
|
|
|
|
await expect(
|
|
openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
createRuntime: (options) => {
|
|
vi.mocked(runtime.ensureSession).mockImplementation(async () => {
|
|
options.spawnAgent?.({
|
|
command: "ignored",
|
|
args: ["--stdio"],
|
|
options: {},
|
|
});
|
|
throw failure;
|
|
});
|
|
return runtime;
|
|
},
|
|
}),
|
|
).rejects.toBe(failure);
|
|
expect(runtime.close).not.toHaveBeenCalled();
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
});
|
|
|
|
it("aborts a blocked handshake, reaps its provider, and closes a late session", async () => {
|
|
let resolveHandshake: ((handle: AcpRuntimeHandle) => void) | undefined;
|
|
const blockedHandshake = new Promise<AcpRuntimeHandle>((resolve) => {
|
|
resolveHandshake = resolve;
|
|
});
|
|
const child = fakeChild();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValue(child);
|
|
const runtime = fakeRuntime();
|
|
const controller = new AbortController();
|
|
const cancellation = new Error("runtime admission cancelled");
|
|
|
|
const opening = openCodexAcpxRuntime(
|
|
{ ...openOptions(command), signal: controller.signal },
|
|
{
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
createRuntime: (runtimeOptions) => {
|
|
vi.mocked(runtime.ensureSession).mockImplementation(async () => {
|
|
runtimeOptions.spawnAgent?.({
|
|
command: "ignored",
|
|
args: ["--stdio"],
|
|
options: {},
|
|
});
|
|
return await blockedHandshake;
|
|
});
|
|
return runtime;
|
|
},
|
|
},
|
|
);
|
|
await vi.waitFor(() => expect(command.spawn).toHaveBeenCalledOnce());
|
|
|
|
controller.abort(cancellation);
|
|
await expect(opening).rejects.toBe(cancellation);
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
|
|
resolveHandshake?.(HANDLE);
|
|
await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledOnce());
|
|
expect(runtime.close).toHaveBeenCalledWith({
|
|
handle: HANDLE,
|
|
reason: "ACPX runtime admission aborted",
|
|
discardPersistentState: false,
|
|
});
|
|
});
|
|
|
|
it("retries retained admission cleanup after the first close times out", async () => {
|
|
let rejectHandshake: ((error: Error) => void) | undefined;
|
|
let firstCloseSettled = false;
|
|
let overlappingClose = false;
|
|
const blockedHandshake = new Promise<AcpRuntimeHandle>(
|
|
(_resolve, reject) => {
|
|
rejectHandshake = reject;
|
|
},
|
|
);
|
|
const runtime = fakeRuntime();
|
|
vi.mocked(runtime.ensureSession).mockReturnValue(blockedHandshake);
|
|
vi.mocked(runtime.close)
|
|
.mockImplementationOnce(
|
|
() =>
|
|
new Promise<void>((_resolve, reject) => {
|
|
setTimeout(() => {
|
|
firstCloseSettled = true;
|
|
reject(new Error("late close rejected after its timeout"));
|
|
}, 8);
|
|
}),
|
|
)
|
|
.mockImplementationOnce(async () => {
|
|
overlappingClose = !firstCloseSettled;
|
|
});
|
|
const controller = new AbortController();
|
|
const cancellation = new Error("runtime admission cancelled");
|
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
|
|
|
const opening = openCodexAcpxRuntime(
|
|
{
|
|
...openOptions(fakeCommand()),
|
|
signal: controller.signal,
|
|
},
|
|
{
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: (options) => {
|
|
runtimeOptions = options;
|
|
return runtime;
|
|
},
|
|
runtimeCloseTimeoutMs: 5,
|
|
},
|
|
);
|
|
await vi.waitFor(() =>
|
|
expect(runtime.ensureSession).toHaveBeenCalledOnce(),
|
|
);
|
|
|
|
controller.abort(cancellation);
|
|
const openingFailure = opening.catch((error: unknown) => error);
|
|
await runtimeOptions!.sessionStore.save({
|
|
acpxRecordId: "late-record",
|
|
acpSessionId: "late-backend-session",
|
|
agentSessionId: "late-agent-session",
|
|
name: "late-runtime-name",
|
|
cwd: "/workspace",
|
|
} as never);
|
|
|
|
await expect(openingFailure).resolves.toBeInstanceOf(Error);
|
|
await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledTimes(2));
|
|
expect(firstCloseSettled).toBe(true);
|
|
expect(overlappingClose).toBe(false);
|
|
expect(runtime.close).toHaveBeenCalledTimes(2);
|
|
expect(vi.mocked(runtime.close).mock.calls[1]?.[0]).toEqual(
|
|
vi.mocked(runtime.close).mock.calls[0]?.[0],
|
|
);
|
|
rejectHandshake?.(new Error("test handshake stopped"));
|
|
});
|
|
|
|
it("retries a rejected cleanup for a session returned after admission aborts", async () => {
|
|
let resolveHandshake: ((handle: AcpRuntimeHandle) => void) | undefined;
|
|
const blockedHandshake = new Promise<AcpRuntimeHandle>((resolve) => {
|
|
resolveHandshake = resolve;
|
|
});
|
|
const firstCloseFailure = new Error("late runtime close failed");
|
|
const runtime = fakeRuntime();
|
|
vi.mocked(runtime.ensureSession).mockReturnValue(blockedHandshake);
|
|
vi.mocked(runtime.close)
|
|
.mockRejectedValueOnce(firstCloseFailure)
|
|
.mockResolvedValueOnce(undefined);
|
|
const controller = new AbortController();
|
|
const cancellation = new Error("runtime admission cancelled");
|
|
const retainedAdmissionCleanups: Promise<void>[] = [];
|
|
|
|
const opening = openCodexAcpxRuntime(
|
|
{
|
|
...openOptions(fakeCommand()),
|
|
signal: controller.signal,
|
|
retainFailedAdmissionCleanup: (cleanup) =>
|
|
retainedAdmissionCleanups.push(cleanup),
|
|
},
|
|
{
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: () => runtime,
|
|
},
|
|
);
|
|
await vi.waitFor(() =>
|
|
expect(runtime.ensureSession).toHaveBeenCalledOnce(),
|
|
);
|
|
|
|
controller.abort(cancellation);
|
|
await expect(opening).rejects.toBe(cancellation);
|
|
expect(retainedAdmissionCleanups).toHaveLength(1);
|
|
resolveHandshake?.(HANDLE);
|
|
|
|
await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledTimes(2));
|
|
await expect(retainedAdmissionCleanups[0]).resolves.toBeUndefined();
|
|
expect(vi.mocked(runtime.close).mock.calls[0]?.[0]).toEqual({
|
|
handle: HANDLE,
|
|
reason: "ACPX runtime admission aborted",
|
|
discardPersistentState: false,
|
|
});
|
|
expect(vi.mocked(runtime.close).mock.calls[1]?.[0]).toEqual(
|
|
vi.mocked(runtime.close).mock.calls[0]?.[0],
|
|
);
|
|
});
|
|
|
|
it("terminalizes a never-settling late close without overlapping it", async () => {
|
|
let resolveHandshake: ((handle: AcpRuntimeHandle) => void) | undefined;
|
|
const blockedHandshake = new Promise<AcpRuntimeHandle>((resolve) => {
|
|
resolveHandshake = resolve;
|
|
});
|
|
const runtime = fakeRuntime();
|
|
vi.mocked(runtime.ensureSession).mockReturnValue(blockedHandshake);
|
|
vi.mocked(runtime.close).mockImplementation(
|
|
() => new Promise<void>(() => undefined),
|
|
);
|
|
const controller = new AbortController();
|
|
const cancellation = new Error("runtime admission cancelled");
|
|
const retainedCleanups: Promise<void>[] = [];
|
|
|
|
const opening = openCodexAcpxRuntime(
|
|
{ ...openOptions(fakeCommand()), signal: controller.signal },
|
|
{
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: () => runtime,
|
|
runtimeCloseTimeoutMs: 5,
|
|
retainCleanup: (cleanup) => retainedCleanups.push(cleanup),
|
|
},
|
|
);
|
|
await vi.waitFor(() =>
|
|
expect(runtime.ensureSession).toHaveBeenCalledOnce(),
|
|
);
|
|
|
|
controller.abort(cancellation);
|
|
await expect(opening).rejects.toBe(cancellation);
|
|
// The exact late-handshake owner and the host-facing aggregate proof are
|
|
// distinct retained promises over the same cleanup obligation.
|
|
expect(retainedCleanups).toHaveLength(2);
|
|
resolveHandshake?.(HANDLE);
|
|
|
|
await expect(retainedCleanups[0]).rejects.toMatchObject({
|
|
name: "AcpxRuntimeCloseFinalTimeoutError",
|
|
});
|
|
expect(runtime.close).toHaveBeenCalledOnce();
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
expect(runtime.close).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("coalesces store and late-handshake cleanup while a timed-out close is active", async () => {
|
|
let resolveHandshake: ((handle: AcpRuntimeHandle) => void) | undefined;
|
|
let firstCloseSettled = false;
|
|
let overlappingClose = false;
|
|
const blockedHandshake = new Promise<AcpRuntimeHandle>((resolve) => {
|
|
resolveHandshake = resolve;
|
|
});
|
|
const runtime = fakeRuntime();
|
|
vi.mocked(runtime.ensureSession).mockReturnValue(blockedHandshake);
|
|
vi.mocked(runtime.close)
|
|
.mockImplementationOnce(
|
|
() =>
|
|
new Promise<void>((_resolve, reject) => {
|
|
setTimeout(() => {
|
|
firstCloseSettled = true;
|
|
reject(new Error("timed-out close eventually rejected"));
|
|
}, 8);
|
|
}),
|
|
)
|
|
.mockImplementationOnce(async () => {
|
|
overlappingClose = !firstCloseSettled;
|
|
});
|
|
const controller = new AbortController();
|
|
const cancellation = new Error("runtime admission cancelled");
|
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
|
|
|
const opening = openCodexAcpxRuntime(
|
|
{
|
|
...openOptions(fakeCommand()),
|
|
signal: controller.signal,
|
|
},
|
|
{
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: (options) => {
|
|
runtimeOptions = options;
|
|
return runtime;
|
|
},
|
|
runtimeCloseTimeoutMs: 5,
|
|
},
|
|
);
|
|
await vi.waitFor(() =>
|
|
expect(runtime.ensureSession).toHaveBeenCalledOnce(),
|
|
);
|
|
|
|
controller.abort(cancellation);
|
|
await expect(opening).rejects.toBe(cancellation);
|
|
await runtimeOptions!.sessionStore.save({
|
|
acpxRecordId: "record-1",
|
|
acpSessionId: "backend-1",
|
|
agentSessionId: "agent-1",
|
|
name: "stored-runtime-name",
|
|
cwd: "/workspace",
|
|
} as never);
|
|
// The returned handle uses a different runtimeSessionName representation,
|
|
// but its durable record identity must join the store-published cleanup.
|
|
resolveHandshake?.(HANDLE);
|
|
await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledTimes(2));
|
|
expect(overlappingClose).toBe(false);
|
|
});
|
|
|
|
it("promotes a session fallback into the later durable record lifecycle", async () => {
|
|
let resolveHandshake: ((handle: AcpRuntimeHandle) => void) | undefined;
|
|
let firstCloseSettled = false;
|
|
let overlappingClose = false;
|
|
const blockedHandshake = new Promise<AcpRuntimeHandle>((resolve) => {
|
|
resolveHandshake = resolve;
|
|
});
|
|
const runtime = fakeRuntime();
|
|
vi.mocked(runtime.ensureSession).mockReturnValue(blockedHandshake);
|
|
vi.mocked(runtime.close)
|
|
.mockImplementationOnce(
|
|
() =>
|
|
new Promise<void>((_resolve, reject) => {
|
|
setTimeout(() => {
|
|
firstCloseSettled = true;
|
|
reject(new Error("fallback close rejected after timeout"));
|
|
}, 15);
|
|
}),
|
|
)
|
|
.mockImplementationOnce(async () => {
|
|
overlappingClose = !firstCloseSettled;
|
|
});
|
|
const controller = new AbortController();
|
|
const cancellation = new Error("runtime admission cancelled");
|
|
const retainedCleanups: Promise<void>[] = [];
|
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
|
|
|
const opening = openCodexAcpxRuntime(
|
|
{
|
|
...openOptions(fakeCommand()),
|
|
signal: controller.signal,
|
|
},
|
|
{
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: (options) => {
|
|
runtimeOptions = options;
|
|
return runtime;
|
|
},
|
|
runtimeCloseTimeoutMs: 10,
|
|
retainCleanup: (cleanup) => retainedCleanups.push(cleanup),
|
|
},
|
|
);
|
|
await vi.waitFor(() =>
|
|
expect(runtime.ensureSession).toHaveBeenCalledOnce(),
|
|
);
|
|
|
|
controller.abort(cancellation);
|
|
await expect(opening).rejects.toBe(cancellation);
|
|
resolveHandshake?.({
|
|
...HANDLE,
|
|
sessionKey: "provider-key",
|
|
acpxRecordId: undefined,
|
|
agentSessionId: "fallback-agent-session",
|
|
} as never);
|
|
for (let turn = 0; turn < 5; turn += 1) await Promise.resolve();
|
|
|
|
await runtimeOptions!.sessionStore.save({
|
|
acpxRecordId: "promoted-record",
|
|
acpSessionId: "promoted-backend-session",
|
|
name: "promoted-runtime-name",
|
|
cwd: "/workspace",
|
|
} as never);
|
|
await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledTimes(2));
|
|
expect(overlappingClose).toBe(false);
|
|
expect(vi.mocked(runtime.close).mock.calls[1]?.[0].handle).toMatchObject({
|
|
acpxRecordId: "promoted-record",
|
|
backendSessionId: "promoted-backend-session",
|
|
agentSessionId: "fallback-agent-session",
|
|
});
|
|
expect(
|
|
decodeAcpxRuntimeHandleState(
|
|
vi.mocked(runtime.close).mock.calls[1]![0].handle.runtimeSessionName,
|
|
),
|
|
).toMatchObject({ name: "promoted-runtime-name" });
|
|
await Promise.all(retainedCleanups);
|
|
});
|
|
|
|
it("keeps exact record ids distinct when whitespace differs", async () => {
|
|
let rejectHandshake: ((error: Error) => void) | undefined;
|
|
const blockedHandshake = new Promise<AcpRuntimeHandle>(
|
|
(_resolve, reject) => {
|
|
rejectHandshake = reject;
|
|
},
|
|
);
|
|
const runtime = fakeRuntime();
|
|
vi.mocked(runtime.ensureSession).mockReturnValue(blockedHandshake);
|
|
const controller = new AbortController();
|
|
const cancellation = new Error("runtime admission cancelled");
|
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
|
|
|
const opening = openCodexAcpxRuntime(
|
|
{ ...openOptions(fakeCommand()), signal: controller.signal },
|
|
{
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: (options) => {
|
|
runtimeOptions = options;
|
|
return runtime;
|
|
},
|
|
},
|
|
);
|
|
await vi.waitFor(() =>
|
|
expect(runtime.ensureSession).toHaveBeenCalledOnce(),
|
|
);
|
|
|
|
controller.abort(cancellation);
|
|
await expect(opening).rejects.toBe(cancellation);
|
|
for (const acpxRecordId of ["record-id", " record-id "]) {
|
|
await runtimeOptions!.sessionStore.save({
|
|
acpxRecordId,
|
|
acpSessionId: `${acpxRecordId}-backend`,
|
|
agentSessionId: `${acpxRecordId}-agent`,
|
|
name: `${acpxRecordId}-runtime`,
|
|
cwd: "/workspace",
|
|
} as never);
|
|
}
|
|
|
|
await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledTimes(2));
|
|
expect(
|
|
vi
|
|
.mocked(runtime.close)
|
|
.mock.calls.map(([input]) => input.handle.acpxRecordId),
|
|
).toEqual(["record-id", " record-id "]);
|
|
rejectHandshake?.(new Error("test handshake stopped"));
|
|
});
|
|
|
|
it("exhausts the full retained admission close retry budget", async () => {
|
|
let rejectHandshake: ((error: Error) => void) | undefined;
|
|
const blockedHandshake = new Promise<AcpRuntimeHandle>(
|
|
(_resolve, reject) => {
|
|
rejectHandshake = reject;
|
|
},
|
|
);
|
|
const runtime = fakeRuntime();
|
|
const closeFailure = new Error("runtime close failed");
|
|
const retainedCleanups: Promise<void>[] = [];
|
|
vi.mocked(runtime.ensureSession).mockReturnValue(blockedHandshake);
|
|
vi.mocked(runtime.close).mockRejectedValue(closeFailure);
|
|
const controller = new AbortController();
|
|
const cancellation = new Error("runtime admission cancelled");
|
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
|
|
|
const opening = openCodexAcpxRuntime(
|
|
{
|
|
...openOptions(fakeCommand()),
|
|
signal: controller.signal,
|
|
},
|
|
{
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
createRuntime: (options) => {
|
|
runtimeOptions = options;
|
|
return runtime;
|
|
},
|
|
retainCleanup: (cleanup) => retainedCleanups.push(cleanup),
|
|
},
|
|
);
|
|
await vi.waitFor(() =>
|
|
expect(runtime.ensureSession).toHaveBeenCalledOnce(),
|
|
);
|
|
|
|
controller.abort(cancellation);
|
|
await runtimeOptions!.sessionStore.save({
|
|
acpxRecordId: "late-record",
|
|
acpSessionId: "late-backend-session",
|
|
agentSessionId: "late-agent-session",
|
|
name: "late-runtime-name",
|
|
cwd: "/workspace",
|
|
} as never);
|
|
|
|
await expect(opening).rejects.toBeInstanceOf(Error);
|
|
expect(retainedCleanups).toHaveLength(3);
|
|
const recoveryOutcome = retainedCleanups[0]!.catch(
|
|
(error: unknown) => error,
|
|
);
|
|
const compositeOutcome = retainedCleanups[2]!.catch(
|
|
(error: unknown) => error,
|
|
);
|
|
await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledTimes(4));
|
|
await expect(recoveryOutcome).resolves.toMatchObject({
|
|
message: "ACPX failed-admission cleanup exhausted 3 retry attempts",
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
expect(runtime.close).toHaveBeenCalledTimes(4);
|
|
|
|
rejectHandshake?.(new Error("test handshake stopped"));
|
|
await expect(retainedCleanups[1]).resolves.toBeUndefined();
|
|
await expect(compositeOutcome).resolves.toMatchObject({
|
|
message: "ACPX failed-admission cleanup exhausted 3 retry attempts",
|
|
});
|
|
});
|
|
|
|
it("aggregates asynchronous provider signal errors after a failed handshake", async () => {
|
|
const child = failingSignalChild();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValue(child.child);
|
|
const handshakeError = new Error("ACP handshake rejected");
|
|
const runtime = fakeRuntime();
|
|
const retainedCleanups: Promise<void>[] = [];
|
|
|
|
const result = openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
retainCleanup: (cleanup) => retainedCleanups.push(cleanup),
|
|
createRuntime: (options) => {
|
|
vi.mocked(runtime.ensureSession).mockImplementation(async () => {
|
|
options.spawnAgent?.({
|
|
command: "ignored",
|
|
args: ["--stdio"],
|
|
options: {},
|
|
});
|
|
throw handshakeError;
|
|
});
|
|
return runtime;
|
|
},
|
|
});
|
|
|
|
await expect(result).rejects.toMatchObject({
|
|
errors: [
|
|
handshakeError,
|
|
child.errors[0],
|
|
expect.objectContaining({
|
|
message: "ACPX provider did not exit after SIGTERM",
|
|
}),
|
|
child.errors[1],
|
|
expect.objectContaining({
|
|
message: "ACPX provider did not exit after SIGKILL",
|
|
}),
|
|
],
|
|
});
|
|
expect(child.child.kill).toHaveBeenNthCalledWith(1, "SIGTERM");
|
|
expect(child.child.kill).toHaveBeenNthCalledWith(2, "SIGKILL");
|
|
expect(retainedCleanups).toHaveLength(1);
|
|
child.child.signalCode = "SIGKILL";
|
|
child.child.emit("exit", null, "SIGKILL");
|
|
await expect(retainedCleanups[0]).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("retains failed-admission cleanup until the provider exits", async () => {
|
|
const child = childThatExitsAfterSignalCalls(12);
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValue(child);
|
|
const handshakeError = new Error("ACP handshake rejected");
|
|
const runtime = fakeRuntime();
|
|
const retainedCleanups: Promise<void>[] = [];
|
|
|
|
await expect(
|
|
openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
retainCleanup: (cleanup) => retainedCleanups.push(cleanup),
|
|
createRuntime: (options) => {
|
|
vi.mocked(runtime.ensureSession).mockImplementation(async () => {
|
|
options.spawnAgent?.({
|
|
command: "ignored",
|
|
args: ["--stdio"],
|
|
options: {},
|
|
});
|
|
throw handshakeError;
|
|
});
|
|
return runtime;
|
|
},
|
|
}),
|
|
).rejects.toThrow("session handshake and runtime cleanup failed");
|
|
|
|
expect(retainedCleanups).toHaveLength(1);
|
|
await expect(retainedCleanups[0]).resolves.toBeUndefined();
|
|
expect(child.kill).toHaveBeenCalledTimes(12);
|
|
expect(child.signalCode).toBe("SIGKILL");
|
|
});
|
|
|
|
it("closes a recovered session when its handshake rejects before another save", async () => {
|
|
const runtime = fakeRuntime();
|
|
const recoveredStore = store();
|
|
vi.mocked(recoveredStore.load).mockResolvedValue({
|
|
acpxRecordId: "recovered-record",
|
|
acpSessionId: "recovered-backend-session",
|
|
agentSessionId: "recovered-agent-session",
|
|
name: "recovered-runtime-name",
|
|
cwd: "/workspace",
|
|
} as never);
|
|
const failure = new Error("recovered ACP handshake rejected");
|
|
|
|
await expect(
|
|
openCodexAcpxRuntime(openOptions(fakeCommand()), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => recoveredStore,
|
|
createRuntime: (runtimeOptions) => {
|
|
vi.mocked(runtime.ensureSession).mockImplementation(async () => {
|
|
await runtimeOptions.sessionStore.load("provider-key");
|
|
throw failure;
|
|
});
|
|
return runtime;
|
|
},
|
|
}),
|
|
).rejects.toBe(failure);
|
|
expect(failure).toMatchObject({ code: "ACPX_SESSION_ENSURE_FAILED" });
|
|
expect(runtime.close).toHaveBeenCalledOnce();
|
|
const recoveredClose = vi.mocked(runtime.close).mock.calls[0]![0];
|
|
expect(recoveredClose).toMatchObject({
|
|
handle: {
|
|
sessionKey: "provider-key",
|
|
backend: "acpx",
|
|
cwd: "/workspace",
|
|
acpxRecordId: "recovered-record",
|
|
backendSessionId: "recovered-backend-session",
|
|
agentSessionId: "recovered-agent-session",
|
|
},
|
|
reason: "ACPX session handshake failed",
|
|
discardPersistentState: false,
|
|
});
|
|
expect(
|
|
decodeAcpxRuntimeHandleState(recoveredClose.handle.runtimeSessionName),
|
|
).toEqual({
|
|
name: "recovered-runtime-name",
|
|
agent: "codex",
|
|
cwd: "/workspace",
|
|
mode: "persistent",
|
|
acpxRecordId: "recovered-record",
|
|
backendSessionId: "recovered-backend-session",
|
|
agentSessionId: "recovered-agent-session",
|
|
});
|
|
expect(recoveredStore.save).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("closes a newly created session when its record save rejects", async () => {
|
|
const runtime = fakeRuntime();
|
|
const failingStore = store();
|
|
const failure = new Error("session store unavailable");
|
|
vi.mocked(failingStore.save).mockRejectedValue(failure);
|
|
|
|
await expect(
|
|
openCodexAcpxRuntime(openOptions(fakeCommand()), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => failingStore,
|
|
createRuntime: (runtimeOptions) => {
|
|
vi.mocked(runtime.ensureSession).mockImplementation(async () => {
|
|
await runtimeOptions.sessionStore.save({
|
|
acpxRecordId: "new-record",
|
|
acpSessionId: "new-backend-session",
|
|
agentSessionId: "new-agent-session",
|
|
name: "new-runtime-name",
|
|
cwd: "/workspace",
|
|
} as never);
|
|
return HANDLE;
|
|
});
|
|
return runtime;
|
|
},
|
|
}),
|
|
).rejects.toBe(failure);
|
|
expect(runtime.close).toHaveBeenCalledOnce();
|
|
const failedSaveClose = vi.mocked(runtime.close).mock.calls[0]![0];
|
|
expect(failedSaveClose).toMatchObject({
|
|
handle: {
|
|
sessionKey: "provider-key",
|
|
backend: "acpx",
|
|
cwd: "/workspace",
|
|
acpxRecordId: "new-record",
|
|
backendSessionId: "new-backend-session",
|
|
agentSessionId: "new-agent-session",
|
|
},
|
|
reason: "ACPX session handshake failed",
|
|
discardPersistentState: false,
|
|
});
|
|
expect(
|
|
decodeAcpxRuntimeHandleState(failedSaveClose.handle.runtimeSessionName),
|
|
).toMatchObject({ name: "new-runtime-name", agent: "codex" });
|
|
});
|
|
|
|
it("bounds a stalled runtime close before terminating a failed-handshake provider", async () => {
|
|
const child = fakeChild();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValue(child);
|
|
const runtime = fakeRuntime();
|
|
vi.mocked(runtime.close).mockImplementation(
|
|
() => new Promise<void>(() => undefined),
|
|
);
|
|
|
|
await expect(
|
|
openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
createRuntime: (runtimeOptions) => {
|
|
vi.mocked(runtime.ensureSession).mockImplementation(async () => {
|
|
await runtimeOptions.sessionStore.save({
|
|
acpxRecordId: "actual-record",
|
|
acpSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
name: "actual-runtime-name",
|
|
cwd: "/workspace",
|
|
} as never);
|
|
runtimeOptions.spawnAgent?.({
|
|
command: "ignored",
|
|
args: ["--stdio"],
|
|
options: {},
|
|
});
|
|
throw new Error("ACP handshake rejected");
|
|
});
|
|
return runtime;
|
|
},
|
|
runtimeCloseTimeoutMs: 5,
|
|
}),
|
|
).rejects.toThrow("ACPX session handshake and runtime cleanup failed");
|
|
expect(runtime.close).toHaveBeenCalledOnce();
|
|
const stalledClose = vi.mocked(runtime.close).mock.calls[0]![0];
|
|
expect(stalledClose).toMatchObject({
|
|
handle: {
|
|
sessionKey: "provider-key",
|
|
backend: "acpx",
|
|
cwd: "/workspace",
|
|
acpxRecordId: "actual-record",
|
|
backendSessionId: "backend-session",
|
|
agentSessionId: "agent-session",
|
|
},
|
|
reason: "ACPX session handshake failed",
|
|
discardPersistentState: false,
|
|
});
|
|
expect(
|
|
decodeAcpxRuntimeHandleState(stalledClose.handle.runtimeSessionName),
|
|
).toMatchObject({ name: "actual-runtime-name", agent: "codex" });
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
});
|
|
|
|
it("waits for a timed-out admission close before retrying it sequentially", async () => {
|
|
let rejectFirstClose!: (error: Error) => void;
|
|
const firstClose = new Promise<void>((_resolve, reject) => {
|
|
rejectFirstClose = reject;
|
|
});
|
|
const runtime = fakeRuntime();
|
|
vi.mocked(runtime.close)
|
|
.mockImplementationOnce(() => firstClose)
|
|
.mockResolvedValueOnce(undefined);
|
|
const retainedCleanups: Promise<void>[] = [];
|
|
|
|
await expect(
|
|
openCodexAcpxRuntime(openOptions(fakeCommand()), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
runtimeCloseTimeoutMs: 1,
|
|
retainCleanup: (cleanup) => retainedCleanups.push(cleanup),
|
|
createRuntime: (runtimeOptions) => {
|
|
vi.mocked(runtime.ensureSession).mockImplementation(async () => {
|
|
await runtimeOptions.sessionStore.save({
|
|
acpxRecordId: "retry-record",
|
|
acpSessionId: "retry-backend-session",
|
|
agentSessionId: "retry-agent-session",
|
|
name: "retry-runtime-name",
|
|
cwd: "/workspace",
|
|
} as never);
|
|
throw new Error("handshake failed before cleanup retry");
|
|
});
|
|
return runtime;
|
|
},
|
|
}),
|
|
).rejects.toThrow("session handshake and runtime cleanup failed");
|
|
|
|
expect(runtime.close).toHaveBeenCalledOnce();
|
|
expect(retainedCleanups).toHaveLength(1);
|
|
let recoverySettled = false;
|
|
void retainedCleanups[0]!.then(() => {
|
|
recoverySettled = true;
|
|
});
|
|
await Promise.resolve();
|
|
expect(recoverySettled).toBe(false);
|
|
|
|
rejectFirstClose(new Error("late first close failure"));
|
|
await vi.waitFor(() => expect(runtime.close).toHaveBeenCalledTimes(2));
|
|
await expect(retainedCleanups[0]).resolves.toBeUndefined();
|
|
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
|
expect(runtime.close).toHaveBeenCalledTimes(2);
|
|
expect(runtime.close).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({
|
|
reason: "ACPX session handshake failed",
|
|
discardPersistentState: false,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects close with asynchronous provider signal errors", async () => {
|
|
const runtime = fakeRuntime();
|
|
const command = fakeCommand();
|
|
const child = failingSignalChild();
|
|
vi.mocked(command.spawn).mockReturnValue(child.child);
|
|
const port = await openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderOwnership: providerOwnershipEstablished,
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
createRuntime: (options) => {
|
|
return runtimeWithProvider(runtime, options);
|
|
},
|
|
});
|
|
|
|
await expect(port.close({ reason: "test complete" })).rejects.toMatchObject(
|
|
{
|
|
errors: [
|
|
child.errors[0],
|
|
expect.objectContaining({
|
|
message: "ACPX provider did not exit after SIGTERM",
|
|
}),
|
|
child.errors[1],
|
|
expect.objectContaining({
|
|
message: "ACPX provider did not exit after SIGKILL",
|
|
}),
|
|
],
|
|
},
|
|
);
|
|
expect(child.child.kill).toHaveBeenNthCalledWith(1, "SIGTERM");
|
|
expect(child.child.kill).toHaveBeenNthCalledWith(2, "SIGKILL");
|
|
expect(child.child.kill).toHaveBeenCalledTimes(2);
|
|
child.child.signalCode = "SIGKILL";
|
|
child.child.emit("exit", null, "SIGKILL");
|
|
});
|
|
|
|
it("retains a provider error after close removes the child", async () => {
|
|
const runtime = fakeRuntime();
|
|
const command = fakeCommand();
|
|
const child = fakeChild();
|
|
const providerError = new Error("provider spawn failed");
|
|
vi.mocked(command.spawn).mockReturnValue(child);
|
|
const port = await openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderOwnership: providerOwnershipEstablished,
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
createRuntime: (options) => {
|
|
return runtimeWithProvider(runtime, options);
|
|
},
|
|
});
|
|
|
|
child.emit("error", providerError);
|
|
// A real ChildProcess has committed its terminal status before `close`.
|
|
// Model that ordering so the process tracker can prove this child no
|
|
// longer needs a cleanup signal while retaining its earlier error.
|
|
child.exitCode = 1;
|
|
child.emit("close", 1, null);
|
|
|
|
await expect(port.close({ reason: "test complete" })).rejects.toMatchObject(
|
|
{
|
|
errors: [providerError],
|
|
},
|
|
);
|
|
expect(child.kill).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("propagates ownership failure from a provider spawned during verification", async () => {
|
|
const firstChild = fakeChild();
|
|
const racingChild = fakeChild();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn)
|
|
.mockReturnValueOnce(firstChild)
|
|
.mockReturnValueOnce(racingChild);
|
|
let resolveFirstOwnership!: () => void;
|
|
const firstOwnership = new Promise<void>((resolve) => {
|
|
resolveFirstOwnership = resolve;
|
|
});
|
|
const racingOwnershipFailure = new Error(
|
|
"racing guardian ownership failed",
|
|
);
|
|
const runtime = fakeRuntime();
|
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
|
const awaitProviderOwnership = vi.fn((child: ChildProcess) => {
|
|
if (child !== firstChild) return Promise.reject(racingOwnershipFailure);
|
|
return firstOwnership.then(() => {
|
|
// This callback runs while verification still awaits its initial
|
|
// ownership batch, reproducing the exact append-after-splice race.
|
|
runtimeOptions?.spawnAgent?.({
|
|
command: "ignored",
|
|
args: ["--stdio"],
|
|
options: {},
|
|
});
|
|
});
|
|
});
|
|
vi.mocked(runtime.ensureSession).mockImplementation(async () => {
|
|
runtimeOptions?.spawnAgent?.({
|
|
command: "ignored",
|
|
args: ["--stdio"],
|
|
options: {},
|
|
});
|
|
return HANDLE;
|
|
});
|
|
|
|
const opening = openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
awaitProviderOwnership,
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
createRuntime: (options) => {
|
|
runtimeOptions = options;
|
|
return runtime;
|
|
},
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(awaitProviderOwnership).toHaveBeenCalledOnce(),
|
|
);
|
|
resolveFirstOwnership();
|
|
|
|
await expect(opening).rejects.toBe(racingOwnershipFailure);
|
|
expect(awaitProviderOwnership).toHaveBeenCalledTimes(2);
|
|
expect(firstChild.kill).toHaveBeenCalledWith("SIGTERM");
|
|
expect(racingChild.kill).toHaveBeenCalledWith("SIGTERM");
|
|
});
|
|
|
|
it("bounds a stalled session handshake and terminates its provider", async () => {
|
|
const child = fakeChild();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn).mockReturnValue(child);
|
|
const runtime = fakeRuntime();
|
|
|
|
await expect(
|
|
openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
sessionHandshakeTimeoutMs: 1,
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
createRuntime: (options) => {
|
|
vi.mocked(runtime.ensureSession).mockImplementation(() => {
|
|
options.spawnAgent?.({
|
|
command: "ignored",
|
|
args: ["--stdio"],
|
|
options: {},
|
|
});
|
|
return new Promise<AcpRuntimeHandle>(() => undefined);
|
|
});
|
|
return runtime;
|
|
},
|
|
}),
|
|
).rejects.toThrow("session handshake exceeded its admission deadline");
|
|
expect(runtime.close).not.toHaveBeenCalled();
|
|
expect(child.kill).toHaveBeenCalledWith("SIGTERM");
|
|
});
|
|
|
|
it("rejects provider children created after handshake cleanup is sealed", async () => {
|
|
const child = fakeChild();
|
|
const postCleanupChild = fakeChild();
|
|
const command = fakeCommand();
|
|
vi.mocked(command.spawn)
|
|
.mockReturnValueOnce(child)
|
|
.mockReturnValueOnce(postCleanupChild);
|
|
const runtime = fakeRuntime();
|
|
const retainCleanup = vi.fn<(cleanup: Promise<void>) => void>();
|
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
|
let resolveHandshake: ((handle: AcpRuntimeHandle) => void) | undefined;
|
|
vi.mocked(runtime.ensureSession).mockImplementation(
|
|
() =>
|
|
new Promise<AcpRuntimeHandle>((resolve) => {
|
|
resolveHandshake = resolve;
|
|
}),
|
|
);
|
|
|
|
await expect(
|
|
openCodexAcpxRuntime(openOptions(command), {
|
|
createRegistry: () => registry(),
|
|
createStore: () => store(),
|
|
sessionHandshakeTimeoutMs: 1,
|
|
awaitProviderExit: providerOwnershipEstablished,
|
|
retainCleanup,
|
|
createRuntime: (options) => {
|
|
runtimeOptions = options;
|
|
return runtime;
|
|
},
|
|
}),
|
|
).rejects.toThrow("session handshake exceeded its admission deadline");
|
|
|
|
expect(retainCleanup).toHaveBeenCalledTimes(2);
|
|
const lateHandshakeCleanup = retainCleanup.mock.calls[0]?.[0];
|
|
const cleanupProof = retainCleanup.mock.calls[1]?.[0];
|
|
expect(lateHandshakeCleanup).toBeDefined();
|
|
expect(cleanupProof).toBeDefined();
|
|
|
|
expect(() =>
|
|
runtimeOptions?.spawnAgent?.({
|
|
command: "ignored",
|
|
args: ["--stdio"],
|
|
options: {},
|
|
}),
|
|
).toThrow("provider spawned after cleanup was sealed");
|
|
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
|
|
expect(retainCleanup).toHaveBeenCalledTimes(3);
|
|
await retainCleanup.mock.calls[2]?.[0];
|
|
resolveHandshake?.(HANDLE);
|
|
|
|
await lateHandshakeCleanup;
|
|
await cleanupProof;
|
|
expect(runtime.close).toHaveBeenCalledWith({
|
|
handle: HANDLE,
|
|
reason: "ACPX session handshake completed after its admission deadline",
|
|
discardPersistentState: false,
|
|
});
|
|
expect(() =>
|
|
runtimeOptions?.spawnAgent?.({
|
|
command: "ignored",
|
|
args: ["--stdio"],
|
|
options: {},
|
|
}),
|
|
).toThrow("provider spawned after cleanup was sealed");
|
|
expect(postCleanupChild.kill).toHaveBeenCalledWith("SIGKILL");
|
|
expect(retainCleanup).toHaveBeenCalledTimes(4);
|
|
await retainCleanup.mock.calls[3]?.[0];
|
|
});
|
|
});
|
|
|
|
function openOptions(
|
|
command: VerifiedAcpxCommandLease,
|
|
): AcpxRuntimePortOpenOptions {
|
|
return {
|
|
command,
|
|
profile: {
|
|
driverKind: "acpx_runtime",
|
|
protocolVersion: 1,
|
|
acpxVersion: "0.13.1",
|
|
agent: "codex",
|
|
agentProfileVersion: 1,
|
|
agentServerPackage: "@agentclientprotocol/codex-acp",
|
|
agentServerVersion: "1.6.2",
|
|
agentRuntimePackage: null,
|
|
agentRuntimeVersion: null,
|
|
commandDigest: "sha256:test",
|
|
qualificationModel: "gpt-5.6-sol",
|
|
reportedModelId: "gpt-5.6-sol",
|
|
permissionPolicy: "interactive",
|
|
},
|
|
cwd: "/workspace",
|
|
stateDirectory: "/runtime/state",
|
|
providerSessionKey: "provider-key",
|
|
permissionMode: "approve-reads",
|
|
permissionPolicy: {
|
|
autoApprove: ["read"],
|
|
escalate: ["write"],
|
|
defaultAction: "escalate",
|
|
},
|
|
launchEnvironment: {
|
|
CODEX_HOME: "/runtime/agent-home",
|
|
OPENAI_API_KEY: "credential-secret",
|
|
OMITTED: undefined,
|
|
},
|
|
// Fake command leases do not inherit these descriptors; production host
|
|
// supplies both live credential-quorum listener descriptors.
|
|
credentialFenceFds: [42, 43] as const,
|
|
activateCredentialFenceOwner: async () => undefined,
|
|
systemInstructions: "Use Paperclip tools.",
|
|
mcpServers: [],
|
|
retainFailedAdmissionCleanup: vi.fn(),
|
|
};
|
|
}
|
|
|
|
function fakeRuntime(handle: AcpRuntimeHandle = HANDLE): AcpRuntime {
|
|
return {
|
|
ensureSession: vi.fn().mockResolvedValue(handle),
|
|
startTurn: vi.fn(),
|
|
runTurn: vi.fn(),
|
|
getStatus: vi.fn(),
|
|
setConfigOption: vi.fn(),
|
|
cancel: vi.fn(),
|
|
close: vi.fn(),
|
|
};
|
|
}
|
|
|
|
function pendingExtensionTurn(requestId: string) {
|
|
let settle!: () => void;
|
|
const result = new Promise<void>((resolve) => { settle = resolve; });
|
|
return { settle, turn: {
|
|
requestId, promptStarted: Promise.resolve(),
|
|
events: (async function* () { await result; })(),
|
|
result: result.then(() => ({ status: "completed" as const })),
|
|
cancel: async () => undefined, closeStream: async () => undefined,
|
|
} };
|
|
}
|
|
|
|
function runtimeWithProvider(
|
|
runtime: AcpRuntime,
|
|
options: AcpRuntimeOptions,
|
|
): AcpRuntime {
|
|
vi.mocked(runtime.ensureSession).mockImplementationOnce(async () => {
|
|
options.spawnAgent?.({
|
|
command: "ignored",
|
|
args: ["--stdio"],
|
|
options: {},
|
|
});
|
|
return HANDLE;
|
|
});
|
|
return runtime;
|
|
}
|
|
|
|
async function providerOwnershipEstablished(): Promise<void> {}
|
|
|
|
function fakeCommand(): VerifiedAcpxCommandLease {
|
|
return { spawn: vi.fn(), close: vi.fn() };
|
|
}
|
|
|
|
function fakeChild(): ChildProcess {
|
|
const child = new EventEmitter() as ChildProcess;
|
|
Object.defineProperties(child, {
|
|
exitCode: { value: null, writable: true },
|
|
signalCode: { value: null, writable: true },
|
|
});
|
|
child.kill = vi.fn(() => {
|
|
child.signalCode = "SIGTERM";
|
|
queueMicrotask(() => child.emit("exit", null, "SIGTERM"));
|
|
return true;
|
|
});
|
|
return child;
|
|
}
|
|
|
|
function failingSignalChild(): {
|
|
child: ChildProcess;
|
|
errors: [Error, Error];
|
|
} {
|
|
const child = new EventEmitter() as ChildProcess;
|
|
const errors: [Error, Error] = [
|
|
new Error("SIGTERM delivery failed"),
|
|
new Error("SIGKILL delivery failed"),
|
|
];
|
|
Object.defineProperties(child, {
|
|
exitCode: { value: null, writable: true },
|
|
signalCode: { value: null, writable: true },
|
|
});
|
|
child.kill = vi.fn((signal) => {
|
|
const error = signal === "SIGTERM" ? errors[0] : errors[1];
|
|
queueMicrotask(() => child.emit("error", error));
|
|
return true;
|
|
});
|
|
child.unref = vi.fn(() => child);
|
|
return { child, errors };
|
|
}
|
|
|
|
function childThatExitsAfterSignalCalls(exitAfter: number): ChildProcess {
|
|
const child = new EventEmitter() as ChildProcess;
|
|
Object.defineProperties(child, {
|
|
exitCode: { value: null, writable: true },
|
|
signalCode: { value: null, writable: true },
|
|
});
|
|
child.kill = vi.fn((signal?: NodeJS.Signals | number) => {
|
|
const calls = vi.mocked(child.kill).mock.calls.length;
|
|
if (calls >= exitAfter) {
|
|
child.signalCode = signal as NodeJS.Signals;
|
|
queueMicrotask(() => child.emit("exit", null, signal));
|
|
} else {
|
|
queueMicrotask(() =>
|
|
child.emit("error", new Error(`signal attempt ${calls} failed`)),
|
|
);
|
|
}
|
|
return true;
|
|
});
|
|
child.unref = vi.fn(() => child);
|
|
return child;
|
|
}
|
|
|
|
function stubbornChild(): ChildProcess {
|
|
const child = new EventEmitter() as ChildProcess;
|
|
Object.defineProperties(child, {
|
|
pid: { value: 71_001 },
|
|
exitCode: { value: null, writable: true },
|
|
signalCode: { value: null, writable: true },
|
|
stdin: { value: { destroy: vi.fn() } },
|
|
stdout: { value: { destroy: vi.fn() } },
|
|
stderr: { value: { destroy: vi.fn() } },
|
|
});
|
|
child.kill = vi.fn(() => true);
|
|
child.unref = vi.fn(() => child);
|
|
return child;
|
|
}
|
|
|
|
function childThatExitsOnKill(): ChildProcess {
|
|
const child = new EventEmitter() as ChildProcess;
|
|
Object.defineProperties(child, {
|
|
pid: { value: 71_002 },
|
|
exitCode: { value: null, writable: true },
|
|
signalCode: { value: null, writable: true },
|
|
});
|
|
child.kill = vi.fn((signal?: NodeJS.Signals | number) => {
|
|
if (signal === "SIGKILL") {
|
|
child.signalCode = "SIGKILL";
|
|
queueMicrotask(() => child.emit("exit", null, "SIGKILL"));
|
|
}
|
|
return true;
|
|
});
|
|
child.unref = vi.fn(() => child);
|
|
return child;
|
|
}
|
|
|
|
function registry(): AcpAgentRegistry {
|
|
return { resolve: vi.fn(), list: vi.fn() };
|
|
}
|
|
|
|
function store(): AcpSessionStore {
|
|
return { load: vi.fn(), save: vi.fn() };
|
|
}
|