feat(runner): compose ACPX runtime admission (#12399)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The ACPX pieces already verify profiles, installations, recovery identity, permissions, runtime files, credentials, and models independently. > - A production host must compose those checks in one fail-closed order and clean every acquired resource on partial startup. > - Directly importing a third-party ACP runtime here would mix dependency adoption with the security lifecycle. > - This pull request defines a narrow injected runtime port and admits it only after all package-local boundaries pass. > - The benefit is a testable host lifecycle without adding `acpx`, changing the lockfile, or making the adapter selectable. ## Linked Issues or Issue Description **Agent or provider** The qualified Pi, Claude, and Codex ACPX profiles; Codex additionally uses the managed credential lease. **Why this adapter is useful** The runner needs one owner for startup ordering, immutable identity checks, exact model verification, and cleanup. Otherwise a failure after credential staging or command admission can leave secret files or executable leases alive, and a resumed provider can attach to a different profile, workspace, model, or permission mode. **How the agent is invoked** A later dependency-adapter pull request will implement the injected runtime port with the pinned ACPX library. This host passes that adapter an opaque verified command lease, canonical workspace, private state directory, profile-bound session key, qualified permission policy, launch-only environment, and bounded instructions. It does not expose the runtime directly or add a user-selectable adapter. **Additional context** This pull request is stacked on #12398. Installation verification has a production default; only the third-party runtime opener is injected. Tests use a fake port so this boundary remains package-local and dependency-free. ## What Changed - Add a minimal ACP runtime port for identity, status, model selection, and bounded shutdown. - Derive the qualified profile and canonical recovery binding before any provider startup. - Reject expected-identity drift and irrelevant managed-Codex inputs before opening the provider. - Verify that even an injected installation result matches the closed profile digest. - Prepare the private sandbox and stage Codex credentials only for the Codex profile. - Acquire an opaque verified command lease and pass only the composed launch boundary to the runtime port. - Apply the canonical permission policy and collision-resistant provider session key. - Select and verify the exact effective model before returning an admitted host. - Create a strict versioned identity record and compare resumed provider identifiers with the expected record. - Keep the runtime private and expose only cloned identity, binding, runtime-root, and persistence-safe environment views. - On startup or shutdown failure, attempt runtime close, credential cleanup, and command-lease cleanup in order and aggregate every error. - Add tests for Codex secret isolation, Claude selector verification, recovery drift, injected digest drift, partial-start cleanup, and cleanup retry. ## Verification - Runner TypeScript typecheck — passed. - Runner protocol and TypeScript tests — passed: 12 protocol tests and 426 Vitest tests, including 6 runtime-host tests. - `pnpm -r typecheck` — passed for all applicable workspaces. - `pnpm build` — passed, including runner binary, server, UI, and workspace packages. - Prettier and `git diff --check` — passed. - The diff contains 2 files and does not change `pnpm-lock.yaml`, a workflow, a dependency, a public package export, server selection, or UI behavior. ## Risks The main risk is leaking a partially admitted resource when a later admission step fails. Resource acquisition is linear and all failure paths use the same ordered cleanup routine. The runtime port is deliberately minimal and privately owned by the host; it cannot bypass profile, model, recovery, sandbox, credential, or command admission. The actual ACPX implementation and its process-supervision behavior remain a separate review unit. ## Model Used OpenAI Codex with GPT-5 and repository tool use. ## 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 linked an existing public item or described the issue in this PR - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal task identifier - [x] I have run the affected tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have documented the admission and cleanup boundary - [ ] All applicable GitHub Actions are green - [ ] Greptile is 5/5 with every actionable comment resolved - [x] I will address all review findings before requesting merge
This commit is contained in:
parent
dba1a2d4f5
commit
d0718c226c
|
|
@ -0,0 +1,489 @@
|
|||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
VerifiedAcpxCommandLease,
|
||||
VerifiedAcpxInstallation,
|
||||
} from "./installation-integrity.js";
|
||||
import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
import {
|
||||
AcpxRuntimeHost,
|
||||
type AcpxRuntimeHostDependencies,
|
||||
type AcpxRuntimePort,
|
||||
} from "./runtime-host.js";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => rm(directory, { force: true, recursive: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("ACPX runtime host", () => {
|
||||
it("rejects a pre-aborted admission before acquiring provider resources", async () => {
|
||||
const fixture = await hostFixture();
|
||||
const controller = new AbortController();
|
||||
const cancellation = new Error("admission cancelled before start");
|
||||
controller.abort(cancellation);
|
||||
const openRuntime = vi.fn(async () => runtimePort());
|
||||
const verifyInstallation = vi.fn(
|
||||
fixture.dependencies({ openRuntime }).verifyInstallation!,
|
||||
);
|
||||
|
||||
await expect(
|
||||
AcpxRuntimeHost.open(
|
||||
{
|
||||
...fixture.options,
|
||||
agent: "claude",
|
||||
model: "claude-sonnet-5",
|
||||
permissionMode: "deny-all",
|
||||
signal: controller.signal,
|
||||
},
|
||||
{
|
||||
...fixture.dependencies({ openRuntime }),
|
||||
verifyInstallation,
|
||||
},
|
||||
),
|
||||
).rejects.toBe(cancellation);
|
||||
|
||||
expect(verifyInstallation).not.toHaveBeenCalled();
|
||||
expect(openRuntime).not.toHaveBeenCalled();
|
||||
expect(fixture.commandClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("composes admission, isolation, model verification, and cleanup", async () => {
|
||||
const fixture = await hostFixture();
|
||||
let capturedEnvironment: Readonly<NodeJS.ProcessEnv> = {};
|
||||
const runtime = runtimePort({
|
||||
onClose: vi.fn(async () => undefined),
|
||||
});
|
||||
const dependencies = fixture.dependencies({
|
||||
openRuntime: async (options) => {
|
||||
capturedEnvironment = options.launchEnvironment;
|
||||
await writeFile(
|
||||
join(options.launchEnvironment.CODEX_HOME!, "auth.json"),
|
||||
'{"provider_generated":true}',
|
||||
);
|
||||
return runtime;
|
||||
},
|
||||
});
|
||||
|
||||
const host = await AcpxRuntimeHost.open(
|
||||
{
|
||||
...fixture.options,
|
||||
agent: "codex",
|
||||
model: "gpt-5.6-sol",
|
||||
permissionMode: "approve-reads",
|
||||
environment: {
|
||||
PATH: process.env.PATH,
|
||||
OPENAI_API_KEY: "launch-secret",
|
||||
HTTPS_PROXY: "https://proxy-user:proxy-secret@example.test",
|
||||
},
|
||||
systemInstructions: "Use the supplied runtime context.",
|
||||
},
|
||||
dependencies,
|
||||
);
|
||||
expect(host.identity()).toMatchObject({
|
||||
schema: "paperclip.runner.acpx-identity.v1",
|
||||
acpxRecordId: "record-1",
|
||||
requestedModel: "gpt-5.6-sol",
|
||||
permissionMode: "approve-reads",
|
||||
});
|
||||
expect(capturedEnvironment.OPENAI_API_KEY).toBe("launch-secret");
|
||||
expect(host.persistedEnvironment().OPENAI_API_KEY).toBeUndefined();
|
||||
expect(host.persistedEnvironment().HTTPS_PROXY).toBeUndefined();
|
||||
const authPath = join(host.runtimeRoot(), "codex-home", "auth.json");
|
||||
await expect(readFile(authPath, "utf8")).resolves.toContain(
|
||||
"provider_generated",
|
||||
);
|
||||
|
||||
await host.close({ reason: "test complete" });
|
||||
await expect(readFile(authPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(runtime.close).toHaveBeenCalledOnce();
|
||||
expect(fixture.commandClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("selects and verifies Claude's qualified reported model", async () => {
|
||||
const fixture = await hostFixture();
|
||||
let selected = false;
|
||||
const setModel = vi.fn(async (model: string) => {
|
||||
expect(model).toBe("claude-sonnet-5");
|
||||
selected = true;
|
||||
});
|
||||
const runtime = runtimePort({
|
||||
getStatus: async () => ({
|
||||
models: {
|
||||
currentModelId: selected ? "sonnet" : "default",
|
||||
availableModelIds: ["default", "sonnet"],
|
||||
},
|
||||
}),
|
||||
setModel,
|
||||
});
|
||||
const host = await AcpxRuntimeHost.open(
|
||||
{
|
||||
...fixture.options,
|
||||
agent: "claude",
|
||||
model: "claude-sonnet-5",
|
||||
permissionMode: "deny-all",
|
||||
},
|
||||
fixture.dependencies({ openRuntime: async () => runtime }),
|
||||
);
|
||||
|
||||
expect(setModel).toHaveBeenCalledOnce();
|
||||
expect(host.identity().effectiveModel).toBe("claude-sonnet-5");
|
||||
await host.close({ reason: "verified" });
|
||||
});
|
||||
|
||||
it("rejects recovery drift before opening the provider", async () => {
|
||||
const fixture = await hostFixture();
|
||||
const openRuntime = vi.fn(async () => runtimePort());
|
||||
|
||||
await expect(
|
||||
AcpxRuntimeHost.open(
|
||||
{
|
||||
...fixture.options,
|
||||
agent: "claude",
|
||||
model: "claude-sonnet-5",
|
||||
permissionMode: "approve-reads",
|
||||
expectedIdentity: {
|
||||
kind: "acpx",
|
||||
normalizedSessionId: fixture.options.normalizedSessionId,
|
||||
acpxRecordId: "record-1",
|
||||
backendSessionId: "backend-1",
|
||||
agentSessionId: "agent-1",
|
||||
profileDigest: resolveQualifiedAcpxProfile(
|
||||
"claude",
|
||||
"claude-sonnet-5",
|
||||
).commandDigest,
|
||||
workspaceDigest: `sha256:${"0".repeat(64)}`,
|
||||
requestedModel: "claude-sonnet-5",
|
||||
effectiveModel: "claude-sonnet-5",
|
||||
permissionMode: "approve-reads",
|
||||
},
|
||||
},
|
||||
fixture.dependencies({ openRuntime }),
|
||||
),
|
||||
).rejects.toThrow(/immutable session configuration/);
|
||||
expect(openRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an injected installation that does not match the profile", async () => {
|
||||
const fixture = await hostFixture();
|
||||
const openRuntime = vi.fn(async () => runtimePort());
|
||||
const dependencies = fixture.dependencies({ openRuntime });
|
||||
dependencies.verifyInstallation = async () => ({
|
||||
commandDigest: `sha256:${"f".repeat(64)}`,
|
||||
agentServerPackageJsonPath: join(fixture.root, "package.json"),
|
||||
agentRuntimePackageJsonPath: null,
|
||||
openCommand: async () => {
|
||||
throw new Error("mismatched installation must not open");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
AcpxRuntimeHost.open(
|
||||
{
|
||||
...fixture.options,
|
||||
agent: "claude",
|
||||
model: "claude-sonnet-5",
|
||||
permissionMode: "approve-all",
|
||||
},
|
||||
dependencies,
|
||||
),
|
||||
).rejects.toThrow(/does not match its profile/);
|
||||
expect(openRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cleans credentials and command leases when provider open fails", async () => {
|
||||
const fixture = await hostFixture();
|
||||
let authPath = "";
|
||||
await expect(
|
||||
AcpxRuntimeHost.open(
|
||||
{
|
||||
...fixture.options,
|
||||
agent: "codex",
|
||||
model: "gpt-5.6-sol",
|
||||
permissionMode: "approve-all",
|
||||
environment: {
|
||||
PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET:
|
||||
'{"tokens":{"access_token":"canary"}}',
|
||||
},
|
||||
},
|
||||
fixture.dependencies({
|
||||
openRuntime: async (options) => {
|
||||
authPath = join(options.launchEnvironment.CODEX_HOME!, "auth.json");
|
||||
throw new Error("provider failed");
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("provider failed");
|
||||
await expect(readFile(authPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(fixture.commandClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("attempts every cleanup when runtime shutdown fails", async () => {
|
||||
const fixture = await hostFixture();
|
||||
let failClose = true;
|
||||
const runtime = runtimePort({
|
||||
onClose: vi.fn(async () => {
|
||||
if (failClose) throw new Error("runtime close failed");
|
||||
}),
|
||||
});
|
||||
const host = await AcpxRuntimeHost.open(
|
||||
{
|
||||
...fixture.options,
|
||||
agent: "codex",
|
||||
model: "gpt-5.6-sol",
|
||||
permissionMode: "approve-all",
|
||||
environment: {
|
||||
PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET: "{}",
|
||||
},
|
||||
},
|
||||
fixture.dependencies({ openRuntime: async () => runtime }),
|
||||
);
|
||||
const authPath = join(host.runtimeRoot(), "codex-home", "auth.json");
|
||||
|
||||
await expect(host.close({ reason: "first close" })).rejects.toThrow(
|
||||
/cleanup failed/,
|
||||
);
|
||||
await expect(readFile(authPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(fixture.commandClose).toHaveBeenCalledOnce();
|
||||
failClose = false;
|
||||
await expect(
|
||||
host.close({ reason: "retry close" }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("closes a command lease that resolves after admission is aborted", async () => {
|
||||
const fixture = await hostFixture();
|
||||
const commandAdmission = deferred<VerifiedAcpxCommandLease>();
|
||||
const lateCommandClose = vi.fn(async () => undefined);
|
||||
const openCommand = vi.fn(() => commandAdmission.promise);
|
||||
const openRuntime = vi.fn(async () => runtimePort());
|
||||
const controller = new AbortController();
|
||||
const cancellation = new Error("command admission cancelled");
|
||||
const profile = resolveQualifiedAcpxProfile("claude", "claude-sonnet-5");
|
||||
const opening = AcpxRuntimeHost.open(
|
||||
{
|
||||
...fixture.options,
|
||||
agent: "claude",
|
||||
model: "claude-sonnet-5",
|
||||
permissionMode: "deny-all",
|
||||
signal: controller.signal,
|
||||
},
|
||||
{
|
||||
verifyInstallation: async () => ({
|
||||
commandDigest: profile.commandDigest,
|
||||
agentServerPackageJsonPath: join(fixture.root, "package.json"),
|
||||
agentRuntimePackageJsonPath: null,
|
||||
openCommand,
|
||||
}),
|
||||
openRuntime,
|
||||
reportRetainedCleanupFailure: vi.fn(),
|
||||
},
|
||||
);
|
||||
await vi.waitFor(() => expect(openCommand).toHaveBeenCalledOnce());
|
||||
|
||||
controller.abort(cancellation);
|
||||
await expect(opening).rejects.toBe(cancellation);
|
||||
commandAdmission.resolve({
|
||||
spawn: () => {
|
||||
throw new Error("late command must not spawn");
|
||||
},
|
||||
close: lateCommandClose,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(lateCommandClose).toHaveBeenCalledOnce());
|
||||
expect(openRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes a credential lease that resolves after admission is aborted", async () => {
|
||||
const fixture = await hostFixture();
|
||||
const lateCredentialPath = join(fixture.root, "late-auth.json");
|
||||
await writeFile(lateCredentialPath, '{"access_token":"canary"}');
|
||||
const credentialAdmission = deferred<{
|
||||
path: string;
|
||||
mode: "inline_json";
|
||||
close(): Promise<void>;
|
||||
}>();
|
||||
const cleanupFailure = new Error("transient credential cleanup failure");
|
||||
let cleanupAttempts = 0;
|
||||
const lateCredentialClose = vi.fn(async () => {
|
||||
cleanupAttempts += 1;
|
||||
if (cleanupAttempts === 1) throw cleanupFailure;
|
||||
await rm(lateCredentialPath);
|
||||
});
|
||||
const reportRetainedCleanupFailure = vi.fn();
|
||||
const stageCredential = vi.fn(() => credentialAdmission.promise);
|
||||
const openRuntime = vi.fn(async () => runtimePort());
|
||||
const controller = new AbortController();
|
||||
const cancellation = new Error("credential admission cancelled");
|
||||
const opening = AcpxRuntimeHost.open(
|
||||
{
|
||||
...fixture.options,
|
||||
agent: "codex",
|
||||
model: "gpt-5.6-sol",
|
||||
permissionMode: "deny-all",
|
||||
signal: controller.signal,
|
||||
},
|
||||
{
|
||||
...fixture.dependencies({
|
||||
openRuntime,
|
||||
reportRetainedCleanupFailure,
|
||||
}),
|
||||
stageCredential,
|
||||
},
|
||||
);
|
||||
await vi.waitFor(() => expect(stageCredential).toHaveBeenCalledOnce());
|
||||
|
||||
controller.abort(cancellation);
|
||||
await expect(opening).rejects.toBe(cancellation);
|
||||
credentialAdmission.resolve({
|
||||
path: lateCredentialPath,
|
||||
mode: "inline_json",
|
||||
close: lateCredentialClose,
|
||||
});
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(lateCredentialClose).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
await vi.waitFor(async () =>
|
||||
expect(readFile(lateCredentialPath)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
}),
|
||||
);
|
||||
expect(reportRetainedCleanupFailure).toHaveBeenCalledOnce();
|
||||
expect(reportRetainedCleanupFailure).toHaveBeenCalledWith({
|
||||
resource: "credential",
|
||||
attempt: 1,
|
||||
error: cleanupFailure,
|
||||
});
|
||||
expect(openRuntime).not.toHaveBeenCalled();
|
||||
expect(fixture.commandClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards cancellation and closes a runtime that resolves after abort", async () => {
|
||||
const fixture = await hostFixture();
|
||||
const runtimeAdmission = deferred<AcpxRuntimePort>();
|
||||
const lateRuntime = runtimePort();
|
||||
let receivedSignal: AbortSignal | undefined;
|
||||
const openRuntime = vi.fn((options) => {
|
||||
receivedSignal = options.signal;
|
||||
return runtimeAdmission.promise;
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const cancellation = new Error("runtime admission cancelled");
|
||||
const opening = AcpxRuntimeHost.open(
|
||||
{
|
||||
...fixture.options,
|
||||
agent: "codex",
|
||||
model: "gpt-5.6-sol",
|
||||
permissionMode: "deny-all",
|
||||
environment: {
|
||||
PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET: "{}",
|
||||
},
|
||||
signal: controller.signal,
|
||||
},
|
||||
fixture.dependencies({ openRuntime }),
|
||||
);
|
||||
await vi.waitFor(() => expect(openRuntime).toHaveBeenCalledOnce());
|
||||
expect(receivedSignal).toBe(controller.signal);
|
||||
|
||||
controller.abort(cancellation);
|
||||
await expect(opening).rejects.toBe(cancellation);
|
||||
expect(fixture.commandClose).toHaveBeenCalledOnce();
|
||||
runtimeAdmission.resolve(lateRuntime);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(lateRuntime.close).toHaveBeenCalledWith({
|
||||
reason: "ACPX runtime admission aborted",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function runtimePort(
|
||||
input: {
|
||||
getStatus?: AcpxRuntimePort["getStatus"];
|
||||
setModel?: NonNullable<AcpxRuntimePort["setModel"]>;
|
||||
onClose?: AcpxRuntimePort["close"];
|
||||
} = {},
|
||||
): AcpxRuntimePort & { close: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
identity: async () => ({
|
||||
acpxRecordId: "record-1",
|
||||
backendSessionId: "backend-1",
|
||||
agentSessionId: "agent-1",
|
||||
}),
|
||||
getStatus:
|
||||
input.getStatus ??
|
||||
(async () => ({
|
||||
models: {
|
||||
currentModelId: "gpt-5.6-sol",
|
||||
availableModelIds: ["gpt-5.6-sol"],
|
||||
},
|
||||
})),
|
||||
...(input.setModel ? { setModel: input.setModel } : {}),
|
||||
close: vi.fn(input.onClose ?? (async () => undefined)),
|
||||
};
|
||||
}
|
||||
|
||||
async function hostFixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-acpx-host-"));
|
||||
temporaryDirectories.push(root);
|
||||
const runtimeDirectory = join(root, "runtime");
|
||||
const workingDirectory = join(root, "workspace");
|
||||
await Promise.all([mkdir(runtimeDirectory), mkdir(workingDirectory)]);
|
||||
const commandClose = vi.fn(async () => undefined);
|
||||
const command: VerifiedAcpxCommandLease = {
|
||||
spawn: () => {
|
||||
throw new Error("test command is not spawnable");
|
||||
},
|
||||
close: commandClose,
|
||||
};
|
||||
return {
|
||||
root,
|
||||
commandClose,
|
||||
options: {
|
||||
runtimeDirectory,
|
||||
normalizedSessionId: "normalized-session-1",
|
||||
workingDirectory,
|
||||
},
|
||||
dependencies(
|
||||
input: Pick<AcpxRuntimeHostDependencies, "openRuntime"> &
|
||||
Partial<
|
||||
Pick<AcpxRuntimeHostDependencies, "reportRetainedCleanupFailure">
|
||||
>,
|
||||
): AcpxRuntimeHostDependencies {
|
||||
return {
|
||||
verifyInstallation: async (profile) =>
|
||||
({
|
||||
commandDigest: profile.commandDigest,
|
||||
agentServerPackageJsonPath: join(root, "package.json"),
|
||||
agentRuntimePackageJsonPath: null,
|
||||
openCommand: async () => command,
|
||||
}) satisfies VerifiedAcpxInstallation,
|
||||
openRuntime: input.openRuntime,
|
||||
reportRetainedCleanupFailure:
|
||||
input.reportRetainedCleanupFailure ?? vi.fn(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve(value: T): void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((settle) => {
|
||||
resolve = settle;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
|
@ -0,0 +1,443 @@
|
|||
import type { NativeAcpxPermissionMode } from "../../contracts/native-execution.js";
|
||||
import {
|
||||
stageManagedCodexCredential,
|
||||
type ManagedCodexCredentialLease,
|
||||
} from "./codex-credentials.js";
|
||||
import {
|
||||
verifyQualifiedAcpxInstallation,
|
||||
type VerifiedAcpxCommandLease,
|
||||
type VerifiedAcpxInstallation,
|
||||
} from "./installation-integrity.js";
|
||||
import {
|
||||
requireVerifiedAcpxModel,
|
||||
type AcpxModelStatus,
|
||||
} from "./model-verification.js";
|
||||
import { acpxRuntimePermissionPolicy } from "./permission-policy.js";
|
||||
import {
|
||||
resolveQualifiedAcpxProfile,
|
||||
type QualifiedAcpxAgent,
|
||||
type QualifiedAcpxProfile,
|
||||
} from "./qualified-profiles.js";
|
||||
import {
|
||||
createAcpxIdentityRecord,
|
||||
createAcpxRecoveryBinding,
|
||||
verifyExpectedAcpxIdentity,
|
||||
type AcpxIdentityRecord,
|
||||
type AcpxRecoveryBinding,
|
||||
} from "./recovery-identity.js";
|
||||
import {
|
||||
prepareAcpxRuntimeSandbox,
|
||||
type AcpxRuntimeSandbox,
|
||||
} from "./runtime-sandbox.js";
|
||||
import type { AcpxExpectedSessionIdentity } from "./sidecar-protocol.js";
|
||||
|
||||
export interface AcpxRuntimePortIdentity {
|
||||
acpxRecordId: string;
|
||||
backendSessionId: string;
|
||||
agentSessionId: string;
|
||||
}
|
||||
|
||||
/** Minimal third-party ACP runtime surface admitted by the host boundary. */
|
||||
export interface AcpxRuntimePort {
|
||||
identity(): Promise<AcpxRuntimePortIdentity>;
|
||||
getStatus(): Promise<AcpxModelStatus>;
|
||||
setModel?(model: string): Promise<void>;
|
||||
close(input: { reason: string }): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AcpxRuntimePortOpenOptions {
|
||||
command: VerifiedAcpxCommandLease;
|
||||
profile: QualifiedAcpxProfile;
|
||||
cwd: string;
|
||||
stateDirectory: string;
|
||||
providerSessionKey: string;
|
||||
permissionMode: NativeAcpxPermissionMode;
|
||||
permissionPolicy: ReturnType<typeof acpxRuntimePermissionPolicy>;
|
||||
launchEnvironment: Readonly<NodeJS.ProcessEnv>;
|
||||
systemInstructions: string;
|
||||
/** Abort provider admission and clean any runtime that resolves too late. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface AcpxRetainedCleanupFailure {
|
||||
resource: "credential" | "command" | "runtime";
|
||||
attempt: number;
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
export interface AcpxRuntimeHostDependencies {
|
||||
verifyInstallation?: (
|
||||
profile: QualifiedAcpxProfile,
|
||||
) => Promise<VerifiedAcpxInstallation>;
|
||||
/** Internal test seam for aborting credential acquisition. */
|
||||
stageCredential?: typeof stageManagedCodexCredential;
|
||||
openRuntime(options: AcpxRuntimePortOpenOptions): Promise<AcpxRuntimePort>;
|
||||
/**
|
||||
* Required observability channel for resources acquired after admission was
|
||||
* aborted. Implementations must not throw from this callback.
|
||||
*/
|
||||
reportRetainedCleanupFailure(failure: AcpxRetainedCleanupFailure): void;
|
||||
}
|
||||
|
||||
export interface OpenAcpxRuntimeHostOptions {
|
||||
runtimeDirectory: string;
|
||||
normalizedSessionId: string;
|
||||
workingDirectory: string;
|
||||
agent: QualifiedAcpxAgent;
|
||||
model: string;
|
||||
permissionMode: NativeAcpxPermissionMode;
|
||||
systemInstructions?: string;
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
managedCodexCredentialSourcePath?: string;
|
||||
expectedIdentity?: AcpxExpectedSessionIdentity;
|
||||
/** Abort admission without admitting resources that resolve afterward. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const activeRuntimeHostCleanupOwners = new Set<Promise<unknown>>();
|
||||
const RETAINED_CLEANUP_RETRY_INITIAL_DELAY_MS = 10;
|
||||
const RETAINED_CLEANUP_RETRY_MAX_DELAY_MS = 1_000;
|
||||
|
||||
export class AcpxRuntimeHost {
|
||||
readonly #runtime: AcpxRuntimePort;
|
||||
readonly #binding: AcpxRecoveryBinding;
|
||||
readonly #identity: AcpxIdentityRecord;
|
||||
readonly #sandbox: AcpxRuntimeSandbox;
|
||||
readonly #credential: ManagedCodexCredentialLease | null;
|
||||
readonly #command: VerifiedAcpxCommandLease;
|
||||
#closed = false;
|
||||
|
||||
private constructor(input: {
|
||||
runtime: AcpxRuntimePort;
|
||||
binding: AcpxRecoveryBinding;
|
||||
identity: AcpxIdentityRecord;
|
||||
sandbox: AcpxRuntimeSandbox;
|
||||
credential: ManagedCodexCredentialLease | null;
|
||||
command: VerifiedAcpxCommandLease;
|
||||
}) {
|
||||
this.#runtime = input.runtime;
|
||||
this.#binding = input.binding;
|
||||
this.#identity = input.identity;
|
||||
this.#sandbox = input.sandbox;
|
||||
this.#credential = input.credential;
|
||||
this.#command = input.command;
|
||||
}
|
||||
|
||||
static async open(
|
||||
options: OpenAcpxRuntimeHostOptions,
|
||||
dependencies: AcpxRuntimeHostDependencies,
|
||||
): Promise<AcpxRuntimeHost> {
|
||||
options.signal?.throwIfAborted();
|
||||
const profile = resolveQualifiedAcpxProfile(options.agent, options.model);
|
||||
const binding = await runAbortableAdmissionStage(options.signal, () =>
|
||||
createAcpxRecoveryBinding({
|
||||
runtimeDirectory: options.runtimeDirectory,
|
||||
normalizedSessionId: options.normalizedSessionId,
|
||||
workingDirectory: options.workingDirectory,
|
||||
profile,
|
||||
requestedModel: options.model,
|
||||
permissionMode: options.permissionMode,
|
||||
}),
|
||||
);
|
||||
if (options.expectedIdentity) {
|
||||
verifyExpectedAcpxIdentity(options.expectedIdentity, binding, null);
|
||||
}
|
||||
if (
|
||||
options.agent !== "codex" &&
|
||||
options.managedCodexCredentialSourcePath !== undefined
|
||||
) {
|
||||
throw new Error(
|
||||
"Managed Codex credentials require the Codex ACPX profile",
|
||||
);
|
||||
}
|
||||
|
||||
const installation = await runAbortableAdmissionStage(options.signal, () =>
|
||||
(dependencies.verifyInstallation ?? verifyQualifiedAcpxInstallation)(
|
||||
profile,
|
||||
),
|
||||
);
|
||||
if (installation.commandDigest !== profile.commandDigest) {
|
||||
throw new Error("Verified ACPX installation does not match its profile");
|
||||
}
|
||||
let command: VerifiedAcpxCommandLease | null = null;
|
||||
let credential: ManagedCodexCredentialLease | null = null;
|
||||
let runtime: AcpxRuntimePort | null = null;
|
||||
try {
|
||||
const sandbox = await runAbortableAdmissionStage(options.signal, () =>
|
||||
prepareAcpxRuntimeSandbox({
|
||||
binding,
|
||||
agent: options.agent,
|
||||
environment: options.environment,
|
||||
}),
|
||||
);
|
||||
if (options.agent === "codex") {
|
||||
credential = await acquireAbortableAdmissionResource({
|
||||
signal: options.signal,
|
||||
acquire: () =>
|
||||
(dependencies.stageCredential ?? stageManagedCodexCredential)({
|
||||
agentHomeDirectory: sandbox.agentHomeDirectory,
|
||||
environment: options.environment,
|
||||
sourcePath: options.managedCodexCredentialSourcePath,
|
||||
}),
|
||||
resource: "credential",
|
||||
releaseLate: (lateCredential) => lateCredential.close(),
|
||||
reportFailure: (failure) =>
|
||||
dependencies.reportRetainedCleanupFailure(failure),
|
||||
});
|
||||
}
|
||||
command = await acquireAbortableAdmissionResource({
|
||||
signal: options.signal,
|
||||
acquire: () => installation.openCommand(),
|
||||
resource: "command",
|
||||
releaseLate: (lateCommand) => lateCommand.close(),
|
||||
reportFailure: (failure) =>
|
||||
dependencies.reportRetainedCleanupFailure(failure),
|
||||
});
|
||||
runtime = await acquireAbortableAdmissionResource({
|
||||
signal: options.signal,
|
||||
acquire: () =>
|
||||
dependencies.openRuntime({
|
||||
command: command!,
|
||||
profile,
|
||||
cwd: binding.workspacePath,
|
||||
stateDirectory: sandbox.stateDirectory,
|
||||
providerSessionKey: binding.profileSessionKey,
|
||||
permissionMode: binding.permissionMode,
|
||||
permissionPolicy: acpxRuntimePermissionPolicy(
|
||||
binding.permissionMode,
|
||||
),
|
||||
launchEnvironment: sandbox.launchEnvironment,
|
||||
systemInstructions: boundedInstructions(options.systemInstructions),
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
}),
|
||||
resource: "runtime",
|
||||
releaseLate: (lateRuntime) =>
|
||||
lateRuntime.close({
|
||||
reason: "ACPX runtime admission aborted",
|
||||
}),
|
||||
reportFailure: (failure) =>
|
||||
dependencies.reportRetainedCleanupFailure(failure),
|
||||
});
|
||||
await runAbortableAdmissionStage(options.signal, () =>
|
||||
requireVerifiedAcpxModel(runtime!, profile),
|
||||
);
|
||||
const runtimeIdentity = await runAbortableAdmissionStage(
|
||||
options.signal,
|
||||
() => runtime!.identity(),
|
||||
);
|
||||
const observedIdentity: AcpxExpectedSessionIdentity = {
|
||||
kind: "acpx",
|
||||
normalizedSessionId: binding.normalizedSessionId,
|
||||
...runtimeIdentity,
|
||||
profileDigest: binding.profileDigest,
|
||||
workspaceDigest: binding.workspaceDigest,
|
||||
requestedModel: binding.requestedModel,
|
||||
effectiveModel: binding.effectiveModel,
|
||||
permissionMode: binding.permissionMode,
|
||||
};
|
||||
const identity = createAcpxIdentityRecord(observedIdentity, binding);
|
||||
if (options.expectedIdentity) {
|
||||
verifyExpectedAcpxIdentity(options.expectedIdentity, binding, identity);
|
||||
}
|
||||
options.signal?.throwIfAborted();
|
||||
return new AcpxRuntimeHost({
|
||||
runtime,
|
||||
binding,
|
||||
identity,
|
||||
sandbox,
|
||||
credential,
|
||||
command,
|
||||
});
|
||||
} catch (error) {
|
||||
const cleanupError = await cleanupRuntimeResources(
|
||||
runtime,
|
||||
credential,
|
||||
command,
|
||||
"ACPX runtime initialization failed",
|
||||
);
|
||||
if (cleanupError) {
|
||||
throw new AggregateError(
|
||||
[error, ...cleanupError.errors],
|
||||
"ACPX runtime initialization and cleanup failed",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
identity(): AcpxIdentityRecord {
|
||||
return structuredClone(this.#identity);
|
||||
}
|
||||
|
||||
binding(): AcpxRecoveryBinding {
|
||||
return structuredClone(this.#binding);
|
||||
}
|
||||
|
||||
runtimeRoot(): string {
|
||||
return this.#sandbox.root;
|
||||
}
|
||||
|
||||
persistedEnvironment(): Readonly<NodeJS.ProcessEnv> {
|
||||
return Object.freeze({ ...this.#sandbox.persistedEnvironment });
|
||||
}
|
||||
|
||||
async close(input: { reason: string }): Promise<void> {
|
||||
if (this.#closed) return;
|
||||
const error = await cleanupRuntimeResources(
|
||||
this.#runtime,
|
||||
this.#credential,
|
||||
this.#command,
|
||||
boundedReason(input.reason),
|
||||
);
|
||||
if (error) throw error;
|
||||
this.#closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function runAbortableAdmissionStage<T>(
|
||||
signal: AbortSignal | undefined,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (signal === undefined) return await operation();
|
||||
signal.throwIfAborted();
|
||||
const pending = Promise.resolve().then(operation);
|
||||
return await raceAdmissionWithAbort(pending, signal);
|
||||
}
|
||||
|
||||
async function acquireAbortableAdmissionResource<T>(input: {
|
||||
signal: AbortSignal | undefined;
|
||||
acquire: () => Promise<T>;
|
||||
resource: AcpxRetainedCleanupFailure["resource"];
|
||||
releaseLate: (resource: T) => Promise<void>;
|
||||
reportFailure: (failure: AcpxRetainedCleanupFailure) => void;
|
||||
}): Promise<T> {
|
||||
if (input.signal === undefined) return await input.acquire();
|
||||
input.signal.throwIfAborted();
|
||||
const pending = Promise.resolve().then(input.acquire);
|
||||
try {
|
||||
return await raceAdmissionWithAbort(pending, input.signal);
|
||||
} catch (error) {
|
||||
if (input.signal.aborted) {
|
||||
retainRuntimeHostCleanup(
|
||||
pending.then((resource) =>
|
||||
releaseRetainedAdmissionResource({
|
||||
resource,
|
||||
resourceKind: input.resource,
|
||||
release: input.releaseLate,
|
||||
reportFailure: input.reportFailure,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function raceAdmissionWithAbort<T>(
|
||||
pending: Promise<T>,
|
||||
signal: AbortSignal,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = (operation: () => void): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
operation();
|
||||
};
|
||||
const onAbort = (): void => settle(() => reject(signal.reason));
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
void pending.then(
|
||||
(value) => settle(() => resolve(value)),
|
||||
(error: unknown) => settle(() => reject(error)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function releaseRetainedAdmissionResource<T>(input: {
|
||||
resource: T;
|
||||
resourceKind: AcpxRetainedCleanupFailure["resource"];
|
||||
release: (resource: T) => Promise<void>;
|
||||
reportFailure: (failure: AcpxRetainedCleanupFailure) => void;
|
||||
}): Promise<void> {
|
||||
let attempt = 0;
|
||||
let retryDelayMs = RETAINED_CLEANUP_RETRY_INITIAL_DELAY_MS;
|
||||
for (;;) {
|
||||
attempt += 1;
|
||||
try {
|
||||
await input.release(input.resource);
|
||||
return;
|
||||
} catch (error) {
|
||||
try {
|
||||
input.reportFailure({
|
||||
resource: input.resourceKind,
|
||||
attempt,
|
||||
error,
|
||||
});
|
||||
} catch {
|
||||
// The required reporter is observational. A broken reporter must not
|
||||
// relinquish ownership of the resource that still needs cleanup.
|
||||
}
|
||||
await waitForRetainedCleanupRetry(retryDelayMs);
|
||||
retryDelayMs = Math.min(
|
||||
retryDelayMs * 2,
|
||||
RETAINED_CLEANUP_RETRY_MAX_DELAY_MS,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRetainedCleanupRetry(delayMs: number): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, delayMs);
|
||||
timer.unref?.();
|
||||
});
|
||||
}
|
||||
|
||||
function retainRuntimeHostCleanup(cleanup: Promise<unknown>): void {
|
||||
activeRuntimeHostCleanupOwners.add(cleanup);
|
||||
void cleanup
|
||||
.finally(() => activeRuntimeHostCleanupOwners.delete(cleanup))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
async function cleanupRuntimeResources(
|
||||
runtime: AcpxRuntimePort | null,
|
||||
credential: ManagedCodexCredentialLease | null,
|
||||
command: VerifiedAcpxCommandLease | null,
|
||||
reason: string,
|
||||
): Promise<AggregateError | null> {
|
||||
const errors: unknown[] = [];
|
||||
for (const close of [
|
||||
runtime ? () => runtime.close({ reason }) : null,
|
||||
credential ? () => credential.close() : null,
|
||||
command ? () => command.close() : null,
|
||||
]) {
|
||||
if (!close) continue;
|
||||
try {
|
||||
await close();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
return errors.length > 0
|
||||
? new AggregateError(errors, "ACPX runtime cleanup failed")
|
||||
: null;
|
||||
}
|
||||
|
||||
function boundedInstructions(value: string | undefined): string {
|
||||
const instructions = value ?? "";
|
||||
if (Buffer.byteLength(instructions) > 256 * 1024) {
|
||||
throw new Error("ACPX system instructions exceed their bounded size");
|
||||
}
|
||||
return instructions;
|
||||
}
|
||||
|
||||
function boundedReason(value: string): string {
|
||||
const reason = value.trim().slice(0, 1_000);
|
||||
return reason || "ACPX runtime closed";
|
||||
}
|
||||
Loading…
Reference in New Issue