feat: add ACPx and AgentCore runner support

This commit is contained in:
Dotta committed 2026-08-21 20:48:43 -05:00
1 parent 39c4fbe421
commit b50eaea4bb
133 files changed
+56352 -488

No files matched your search

+1 -1
View File
@@ -80,7 +80,7 @@ WORKDIR /app
# (the single most expensive layer: four CLI toolchains + apt, per arch) can
# never hit the layer cache and rebuilds on every build.
RUN echo "cli-tools-epoch: ${CLI_TOOLS_CACHE_EPOCH}" \
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai@1.18.17 @google/gemini-cli@latest \
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@0.148.0 opencode-ai@1.18.17 @google/gemini-cli@latest \
&& apt-get update \
&& apt-get install -y --no-install-recommends openssh-client jq \
&& rm -rf /var/lib/apt/lists/* \
@@ -0,0 +1,45 @@
# ACPX as a First-Class Paperclip Runner Provider
Status: implementation in progress on `PAP-16679-paperclip-runner` and the existing `paperclip-evals` working tree.
## Goal
Add the closed `acpx_runtime` driver under `paperclip_runner`, with qualified Pi and Claude production profiles and Codex as a conformance control. ACPX is the transport/runtime; the ACP agent and effective model remain separately attributed in state, events, UI, and billing.
## Fixed v1 profiles
| Agent | ACP server | Harness | Qualified model |
| --- | --- | --- | --- |
| Pi | `pi-acp@0.0.33` | `@earendil-works/pi-coding-agent@0.84.2` | `openrouter/deepseek/deepseek-v4-flash-0731` |
| Claude | `@agentclientprotocol/claude-agent-acp@0.70.0` | Managed Claude credential profile | `claude-sonnet-5` |
| Codex control | `@agentclientprotocol/codex-acp@1.6.2` | Existing managed Codex profile | `gpt-5.6-sol` |
ACPX is pinned to `0.13.1`. Arbitrary commands, argv, agents, and model fallback are excluded from v1.
## Implementation checklist
- [ ] Add strict ACPX configuration and immutable resolved-profile state to TypeScript and Rust.
- [ ] Build a provider-neutral `AcpxRuntimeHost` around `acpx/runtime`.
- [ ] Add a framed Node sidecar and a distinct Rust `AcpxProvider`; never identify it as Codex.
- [ ] Rebase the ACPX runtime patch for spawn PID, stderr, cwd, notifications, client operations, permission requests, and ephemeral spawn environment injection.
- [ ] Isolate ACPX and agent state under the Paperclip instance runtime with `0700` directories and `0600` files.
- [ ] Generalize the authenticated runner tool bridge and implement Pi extension and Claude MCP/permission profiles.
- [ ] Normalize ACP updates to PRP text, tool, workspace, plan, model, usage, runtime-request, completion, and provider-notice events.
- [ ] Support per-turn checkpoint/load and warm reuse; keep permission waits alive and block safely after provider loss.
- [ ] Add adapter/environment configuration, Runner Lab selection/status, and the five `ax-acpx-*` scenarios.
- [ ] Add deterministic Pi-like, Claude-like, Codex-like, malformed, and hanging fake ACP servers.
- [ ] Add provider-aware `paperclip-evals` configs and immutable artifact/report metadata.
- [ ] Run package, protocol, Rust, server, Lab, browser, live smoke, and paid qualification gates.
## Security invariants
- Only exact bundled profile executables are resolved; no `npx` or first-match `PATH` fallback.
- ACP child environments are allowlisted and injected just in time. Paperclip API keys and unrelated ambient secrets never reach children.
- Raw ACP JSON-RPC, hidden reasoning, stdin, unchecked host paths, bearer values, and unbounded output never enter durable evidence.
- The active Paperclip run attachment and tool catalog are authoritative. Stale provider tools are denied even if cached by an agent.
- A permission decision is delivered exactly once. Process loss while waiting blocks recoverably and never replays the prompt or approval.
- Existing Codex, OpenCode, Claude Managed, `codex_local`, and `claude_local` persisted state remains compatible.
## Completion gates
Pi and Claude must each complete cold, warm, suspended, restored, semantic-tool, permission-wait, and clean-shutdown qualification. Both 35-case rosters must have zero runner, ACPX, ACP-adapter, or bridge infrastructure failures; model misses remain measured behavior. Codex ACP completes smoke/conformance only.
+1 -1
View File
@@ -3,7 +3,7 @@ ARG BASE_TAG=dev
FROM paperclipai/agent-runtime-base:${BASE_TAG}
USER root
RUN npm install -g @openai/codex@latest \
RUN npm install -g @openai/codex@0.148.0 \
&& { chown -R 1000:1000 /usr/lib/node_modules || true; }
USER 1000:1000
+1 -1
View File
@@ -18,7 +18,7 @@ RUN apt-get update \
RUN ln -sf /usr/bin/fdfind /usr/local/bin/fd
RUN corepack enable \
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest
&& npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@0.148.0
RUN useradd --create-home --shell /bin/bash reviewer
+2 -1
View File
@@ -84,7 +84,8 @@
"pnpm": {
"patchedDependencies": {
"embedded-postgres@18.1.0-beta.16": "patches/embedded-postgres@18.1.0-beta.16.patch",
"acpx@0.12.0": "patches/acpx@0.12.0.patch"
"acpx@0.13.1": "patches/acpx@0.13.1.patch",
"pi-acp@0.0.33": "patches/pi-acp@0.0.33.patch"
},
"overrides": {
"rollup": ">=4.59.0",
+1 -1
View File
@@ -44,7 +44,7 @@
},
"dependencies": {
"@paperclipai/shared": "workspace:*",
"acpx": "0.12.0",
"acpx": "0.13.1",
"picocolors": "^1.1.1"
},
"devDependencies": {
+52 -1
View File
@@ -518,6 +518,52 @@ export interface ServerAdapterModule {
// UI types (moved from ui/src/adapters/types.ts)
// ---------------------------------------------------------------------------
export type ProviderActivityFamily =
| "plan"
| "tool_execution"
| "research"
| "delegation"
| "model_identity"
| "context"
| "artifact"
| "review"
| "hook"
| "memory"
| "safety"
| "terminal"
| "wait"
| "provider_notice";
export type ProviderActivityStatus =
| "running"
| "completed"
| "failed"
| "interrupted"
| "informational";
export interface TranscriptWorkspaceChangeFile {
path: string;
operation: "create" | "modify" | "delete" | "rename" | "mode_change";
previousPath: string | null;
additions: number | null;
deletions: number | null;
binary: boolean;
diff: string | null;
}
export interface TranscriptRunVerification {
commandOrCheck: string;
status: "passed" | "failed" | "not_run";
detail?: string;
artifactRef?: string;
}
export interface TranscriptRunArtifact {
kind: string;
ref: string;
title?: string;
}
export type TranscriptEntry =
| { kind: "assistant"; ts: string; text: string; delta?: boolean; channel?: "progress" | "final" | "unknown" }
| { kind: "thinking"; ts: string; text: string; delta?: boolean; channel?: "summary" | "detail" | "unknown" }
@@ -530,7 +576,12 @@ export type TranscriptEntry =
| { kind: "system"; ts: string; text: string }
| { kind: "stdout"; ts: string; text: string }
| { kind: "diff"; ts: string; changeType: "add" | "remove" | "context" | "hunk" | "file_header" | "truncation"; text: string }
| { kind: "provider_activity"; ts: string; family: "plan" | "tool_execution" | "research" | "delegation" | "model_identity" | "context" | "artifact" | "review" | "hook" | "memory" | "safety" | "terminal" | "wait" | "provider_notice"; eventType: string; status: "running" | "completed" | "failed" | "interrupted" | "informational"; title: string; summary: string; payload: Record<string, unknown> };
| { kind: "provider_activity"; ts: string; family: ProviderActivityFamily; eventType: string; status: ProviderActivityStatus; title: string; summary: string; payload: Record<string, unknown> }
| { kind: "workspace_change"; ts: string; changeSetId: string; revision: number; source: "harness_reported" | "runner_verified"; complete: boolean; files: TranscriptWorkspaceChangeFile[]; totals: { files: number; additions: number | null; deletions: number | null }; patchArtifactRef: string | null }
| { kind: "workspace_file_reference"; ts: string; referenceId: string; source: "harness_reported" | "runner_verified"; path: string; displayName: string; mediaType: string | null; presentation: "document" | "code" | "image" | "generic"; line: number | null; preview: string | null; previewTruncated: boolean; contentDigest: string | null }
| { kind: "runtime_request"; ts: string; requestId: string; requestKind: "command_approval" | "file_approval" | "permission_approval" | "user_input" | "elicitation" | null; turnId: string | null; requestType: "permission" | "input"; status: "pending" | "resolved" | "expired" | "cancelled"; prompt: string; choices: Array<{ key: string; label: string }>; fields: Array<{ name: string; label: string; placeholder: string | null }> }
| { kind: "run_result"; ts: string; disposition: "done" | "blocked" | "needs_review" | "yielded"; summary: string; objectiveSatisfied: boolean | null; verification: TranscriptRunVerification[]; remainingWork: Array<{ description: string; blocksCompletion: boolean }>; blocker: { reasonCode: string; unblockAction: string; scope: "current_track" | "task_wide" } | null; artifacts: TranscriptRunArtifact[] }
| { kind: "run_terminal"; ts: string; turnState: "completed" | "failed" | "interrupted" | "cancelled"; runState: "succeeded" | "failed" | "cancelled"; disposition: "done" | "blocked" | "needs_review" | "yielded"; stopReason?: string };
export type StdoutLineParser = (line: string, ts: string) => TranscriptEntry[];
+21
View File
@@ -1405,4 +1405,25 @@ describeEmbeddedPostgres("applyPendingMigrations", () => {
},
20_000,
);
it(
"replays migration 0228 when remote agent profiles already exist",
async () => {
const connectionString = await createTempDatabase();
await applyPendingMigrations(connectionString);
const hash = await migrationHash("0228_late_landau.sql");
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
try {
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${hash}`;
} finally {
await sql.end();
}
await expect(applyPendingMigrations(connectionString)).resolves.toBeUndefined();
await expect(inspectMigrations(connectionString)).resolves.toMatchObject({
status: "upToDate",
});
},
30_000,
);
});
@@ -0,0 +1,48 @@
CREATE TABLE IF NOT EXISTS "remote_agent_profiles" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"profile_key" text NOT NULL,
"display_name" text NOT NULL,
"service" text NOT NULL,
"configuration" jsonb DEFAULT '{}'::jsonb NOT NULL,
"credential_secret_id" uuid,
"enabled" boolean DEFAULT false NOT NULL,
"retention_acknowledged" boolean DEFAULT false NOT NULL,
"qualification" jsonb DEFAULT '{}'::jsonb NOT NULL,
"qualified_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "remote_agent_profiles_service_check" CHECK ("remote_agent_profiles"."service" in ('anthropic_managed_agents', 'aws_bedrock_agentcore_harness'))
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "remote_agent_profiles" ADD CONSTRAINT "remote_agent_profiles_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "remote_agent_profiles" ADD CONSTRAINT "remote_agent_profiles_credential_secret_id_company_secrets_id_fk" FOREIGN KEY ("credential_secret_id") REFERENCES "public"."company_secrets"("id") ON DELETE restrict ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "remote_agent_profiles_company_idx" ON "remote_agent_profiles" USING btree ("company_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "remote_agent_profiles_company_key_uq" ON "remote_agent_profiles" USING btree ("company_id","profile_key");--> statement-breakpoint
INSERT INTO "remote_agent_profiles" (
"id", "company_id", "profile_key", "display_name", "service", "configuration",
"credential_secret_id", "enabled", "retention_acknowledged", "qualification",
"qualified_at", "created_at", "updated_at"
)
SELECT
"id", "company_id", "profile_key", "display_name", 'anthropic_managed_agents',
jsonb_build_object(
'anthropicAgentId', "anthropic_agent_id",
'agentVersion', "agent_version",
'environmentId', "environment_id",
'betaVersion', "beta_version",
'defaultModel', "default_model",
'defaultMaxListCostCents', "default_max_list_cost_cents"
),
"api_key_secret_id", "enabled", "retention_acknowledged", "qualification",
"qualified_at", "created_at", "updated_at"
FROM "managed_agent_profiles"
ON CONFLICT ("company_id", "profile_key") DO NOTHING;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -1583,6 +1583,13 @@
"when": 1787352614045,
"tag": "0227_melodic_expediter",
"breakpoints": true
},
{
"idx": 228,
"version": "7",
"when": 1787359498156,
"tag": "0228_late_landau",
"breakpoints": true
}
]
}
+1
View File
@@ -128,6 +128,7 @@ export { companySecretProviderConfigs } from "./company_secret_provider_configs.
export { userSecretDefinitions } from "./user_secret_definitions.js";
export { companySecrets } from "./company_secrets.js";
export { managedAgentProfiles } from "./managed_agent_profiles.js";
export { remoteAgentProfiles } from "./remote_agent_profiles.js";
export { companySecretVersions } from "./company_secret_versions.js";
export { companySecretBindings } from "./company_secret_bindings.js";
export { companySecretProposals } from "./company_secret_proposals.js";
@@ -0,0 +1,30 @@
import { sql } from "drizzle-orm";
import { boolean, check, index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { companySecrets } from "./company_secrets.js";
/**
* Provider-neutral company resource profile. `configuration` is a closed,
* nonsecret provider snapshot; credentials remain optional references to the
* company secret store (AWS commonly uses workload identity instead).
*/
export const remoteAgentProfiles = pgTable("remote_agent_profiles", {
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
profileKey: text("profile_key").notNull(),
displayName: text("display_name").notNull(),
service: text("service").notNull(),
configuration: jsonb("configuration").$type<Record<string, unknown>>().notNull().default({}),
credentialSecretId: uuid("credential_secret_id").references(() => companySecrets.id, { onDelete: "restrict" }),
enabled: boolean("enabled").notNull().default(false),
retentionAcknowledged: boolean("retention_acknowledged").notNull().default(false),
qualification: jsonb("qualification").$type<Record<string, unknown>>().notNull().default({}),
qualifiedAt: timestamp("qualified_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
companyIdx: index("remote_agent_profiles_company_idx").on(table.companyId),
companyKeyUq: uniqueIndex("remote_agent_profiles_company_key_uq").on(table.companyId, table.profileKey),
serviceCheck: check("remote_agent_profiles_service_check", sql`${table.service} in ('anthropic_managed_agents', 'aws_bedrock_agentcore_harness')`),
}));
+47
View File
@@ -97,6 +97,48 @@ Live console provider-backed routes are loopback-only and reject wildcard/LAN
binds. Browser mutations require same-origin Fetch Metadata, matching Origin,
and JSON content; see the protocol-server tutorial for direct `curl` examples.
## AWS AgentCore Harness proof of concept
The AWS remote-provider path uses the same runnerd, PRP tool bridge, durable
state, and Runner Lab UI as the local and Claude Agent providers. AWS runs the
agent loop; Paperclip executes only the exact caller-side inline-tool catalog
authorized for the attached run. The Harness is never given Paperclip
credentials, MCP servers, shell, filesystem, browser, skills, or Gateway tools.
Provision the development stack with an AWS CLI v2 profile, then launch the
existing Runner Lab:
```sh
pnpm --filter @paperclipai/paperclip-runner aws-agentcore:provision -- \
--aws-profile <profile> \
--region <region> \
--mode development
pnpm --filter @paperclipai/paperclip-runner aws-agentcore:probe
pnpm --filter @paperclipai/paperclip-runner aws-agentcore:lab
```
Open `http://127.0.0.1:4184/#/chat`, select **AWS AgentCore**, and start a new
chat. Provisioning writes only profile and nonsecret resource metadata to the
ignored `.paperclip-local/aws-agentcore.env` file with mode `0600`; it never
writes AWS access keys or session tokens. The default Harness model is
`global.anthropic.claude-sonnet-4-6`, Memory retention is 90 days, and the UI's
dollar ceiling is an estimate rather than an AWS-enforced currency limit.
Use `--mode private` to provision the isolated two-subnet VPC variant. Inspect
changes without applying them with `aws-agentcore:provision -- --dry-run ...`.
The teardown command is intentionally interactive and deletes the named
endpoint before the CloudFormation stack:
```sh
pnpm --filter @paperclipai/paperclip-runner aws-agentcore:destroy
```
After provisioning, the live two-turn route-level check is:
```sh
pnpm --filter @paperclipai/paperclip-runner smoke:capability:aws-agentcore
```
## Package-owned commands
| Command | Purpose |
@@ -135,6 +177,11 @@ and JSON content; see the protocol-server tutorial for direct `curl` examples.
| `trace:live-runner` | Run the real runnerd/Codex semantic loop against the mock control plane. |
| `demo:scenarios` | Start the Capability scenario explorer over the mock control plane on `127.0.0.1:4183`. |
| `console:issue-thread` | Start the Paperclip-style issue thread on `127.0.0.1:4184`. |
| `aws-agentcore:provision` | Deploy or update the AgentCore Harness proof-of-concept stack and write ignored local metadata. |
| `aws-agentcore:probe` | Read-only qualification probe for the configured Harness, endpoint, Memory, model, and invocation role. |
| `aws-agentcore:lab` | Source the ignored AgentCore metadata and start the existing Runner Lab on `127.0.0.1:4184`. |
| `aws-agentcore:destroy` | Confirm, delete the pinned Harness endpoint, and tear down the proof-of-concept stack. |
| `smoke:capability:aws-agentcore` | Send two real messages through Runner Lab routes, assert session continuity, and exercise one governed read tool. |
| `test:scenarios` | Run the scenario index, run-artifact, parity, explorer component, and route tests. |
| `test:browser:scenarios` | Exercise both the scenario explorer and issue-thread browser contracts. |
| `record:capability` | Record the twelve-route screenshot acceptance set at both viewports. |
@@ -737,6 +737,44 @@ test.describe("Capability clean-room chat", () => {
await expect(page.locator("body")).not.toContainText("Codex is thinking");
});
test("AWS AgentCore options are exclusive and expose remote-runtime retention controls", async ({ page }) => {
await page.addInitScript(() => window.localStorage.clear());
await page.route(CLEAN_ROOM_API, async (route) => {
const payload = cleanRoomPayload("MCK-1402");
payload.view.identity = {
...payload.view.identity,
agentLabel: "Real AWS AgentCore",
};
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
...payload,
configuration: {
provider: "aws_agentcore",
model: "global.anthropic.claude-sonnet-4-6",
agentCoreProfileId: "qualified-agentcore",
maxEstimatedSessionCostUsd: 1,
lifecyclePolicy: { mode: "warm", idleTimeoutMs: 300_000 },
},
}),
});
});
await page.goto("/#/chat");
await expect(page.locator('[data-thread-state="settled"]')).toBeVisible();
await expect(page.getByTestId("agent-chip")).toHaveText("Real AWS AgentCore");
await expect(page.getByTestId("chat-active-harness")).toContainText("AWS cloud · no provider PID");
await expect(page.getByTestId("chat-agentcore-profile")).toHaveValue("qualified-agentcore");
await expect(page.getByTestId("chat-agentcore-spend-cap")).toHaveValue("1");
await expect(page.getByTestId("chat-managed-profile")).toHaveCount(0);
await expect(page.getByTestId("chat-managed-spend-cap")).toHaveCount(0);
await expect(page.getByTestId("chat-agentcore-delete-session")).toBeVisible();
await expect(page.getByTestId("chat-agentcore-retention-notice")).toContainText("90 days");
await expect(page.getByTestId("chat-agentcore-retention-notice")).toContainText("Paperclip estimate");
await expect(page.locator("body")).not.toContainText("Codex is thinking");
});
test("evidence stays collapsed until it is asked for", async ({ page }) => {
await openCleanRoom(page);
@@ -48,14 +48,27 @@ const REPLAY_STEP_MS = 800;
const PENDING_ANNOUNCEMENT = "A request is waiting for your answer.";
const ANSWERED_ANNOUNCEMENT = "Your answer was recorded.";
const SETTLED_ANNOUNCEMENT = "The pending request is resolved.";
const SCENARIOS = ["hb-baseline", "dp-documents", "ix-interactions", "ar-artifacts", "wc-workspace-changes", "fr-file-reference", "pe-structured-plan", "te-command-execution", "mp-mcp-progress", "rs-web-research", "da-subagent-delegation", "mr-model-routing", "cc-context-compaction", "ag-generated-artifact", "rv-review-mode", "hk-hook-lifecycle", "mc-memory-citations", "sr-safety-review", "ti-terminal-input", "wt-intentional-wait", "pn-provider-notices", "cm-stream-reconcile", "cm-session-continuity", "cm-tool-read", "cm-tool-mutation", "cm-completion-control", "cm-tool-denied", "cm-tool-duplicate-delivery", "cm-sse-recovery", "cm-interrupt", "cm-warm-restore", "cm-budget", "cm-terminated-deleted", "cm-provisioning-auth", "cm-redacted-failure", "cm-provider-labels"];
const SCENARIOS = ["hb-baseline", "dp-documents", "ix-interactions", "ar-artifacts", "wc-workspace-changes", "fr-file-reference", "pe-structured-plan", "te-command-execution", "mp-mcp-progress", "rs-web-research", "da-subagent-delegation", "mr-model-routing", "cc-context-compaction", "ag-generated-artifact", "rv-review-mode", "hk-hook-lifecycle", "mc-memory-citations", "sr-safety-review", "ti-terminal-input", "wt-intentional-wait", "pn-provider-notices", "ax-acpx-lifecycle", "ax-acpx-permissions", "ax-acpx-tools", "ax-acpx-events", "ax-acpx-failures", "cm-stream-reconcile", "cm-session-continuity", "cm-tool-read", "cm-tool-mutation", "cm-completion-control", "cm-tool-denied", "cm-tool-duplicate-delivery", "cm-sse-recovery", "cm-interrupt", "cm-warm-restore", "cm-budget", "cm-terminated-deleted", "cm-provisioning-auth", "cm-redacted-failure", "cm-provider-labels", "ac-streaming", "ac-session-continuity", "ac-tool-read", "ac-tool-mutation", "ac-completion-control", "ac-tool-denied", "ac-tool-duplicate", "ac-eventstream-recovery", "ac-memory-reconciliation", "ac-interrupt", "ac-warm-restore", "ac-limits", "ac-session-delete", "ac-iam-profile", "ac-redacted-failure"];
const CHAT_HISTORY_KEY = "paperclip-runner.capability.chat.history.v1";
const CHAT_HARNESS_KEY = "paperclip-runner.capability.chat.harness.v1";
const MODEL_PRESETS = {
codex: ["gpt-5.4-mini", "gpt-5.4"],
opencode: ["openrouter/deepseek/deepseek-v4-flash-0731"],
claude_managed: ["claude-sonnet-5"],
aws_agentcore: ["global.anthropic.claude-sonnet-4-6"],
acpx: ["openrouter/deepseek/deepseek-v4-flash-0731", "claude-sonnet-5", "gpt-5.6-sol"],
} as const;
const ACPX_AGENT_MODELS = {
pi: "openrouter/deepseek/deepseek-v4-flash-0731",
claude: "claude-sonnet-5",
codex: "gpt-5.6-sol",
} as const;
function acpxLabel(configuration: CapabilityHarnessConfiguration | null | undefined): string {
if (configuration?.provider !== "acpx") return configuration?.provider ?? "starting";
const agent = configuration.acpxAgent ?? "pi";
return `Real ${agent === "pi" ? "Pi" : agent === "claude" ? "Claude" : "Codex"} via ACPX`;
}
interface EmbeddedEvalCheck {
id: string;
@@ -113,14 +126,18 @@ interface StoredChatSession {
identity: CapabilityCleanRoomIdentity | null;
updatedAt: string;
configuration?: CapabilityHarnessConfiguration;
runtime?: Awaited<ReturnType<typeof capabilityLiveClient.newCleanRoom>>["runtime"];
}
function defaultHarness(provider: CapabilityHarnessConfiguration["provider"] = "codex"): CapabilityHarnessConfiguration {
return {
provider,
model: MODEL_PRESETS[provider][0],
...(provider === "acpx" ? { acpxAgent: "pi" as const } : {}),
...(provider === "claude_managed"
? { managedProfileId: "default", maxSessionListCostUsd: 1 }
: provider === "aws_agentcore"
? { agentCoreProfileId: "default", maxEstimatedSessionCostUsd: 1 }
: {}),
lifecyclePolicy: { mode: "warm", idleTimeoutMs: 300_000 },
};
@@ -129,7 +146,7 @@ function defaultHarness(provider: CapabilityHarnessConfiguration["provider"] = "
function readHarnessConfiguration(): CapabilityHarnessConfiguration {
try {
const value = JSON.parse(window.localStorage.getItem(CHAT_HARNESS_KEY) ?? "null") as Partial<CapabilityHarnessConfiguration> | null;
if (value && (value.provider === "codex" || value.provider === "opencode" || value.provider === "claude_managed") && typeof value.model === "string") {
if (value && (value.provider === "codex" || value.provider === "opencode" || value.provider === "claude_managed" || value.provider === "aws_agentcore" || value.provider === "acpx") && typeof value.model === "string") {
const lifecyclePolicy = value.lifecyclePolicy?.mode === "per_turn"
? { mode: "per_turn" as const, idleTimeoutMs: null }
: value.lifecyclePolicy?.mode === "warm" && Number.isSafeInteger(value.lifecyclePolicy.idleTimeoutMs) && Number(value.lifecyclePolicy.idleTimeoutMs) > 0
@@ -138,6 +155,9 @@ function readHarnessConfiguration(): CapabilityHarnessConfiguration {
return {
provider: value.provider,
model: value.model,
...(value.provider === "acpx" ? {
acpxAgent: value.acpxAgent === "claude" || value.acpxAgent === "codex" ? value.acpxAgent : "pi",
} : {}),
...(value.provider === "claude_managed" ? {
managedProfileId: typeof value.managedProfileId === "string" && value.managedProfileId.trim()
? value.managedProfileId.trim()
@@ -145,6 +165,13 @@ function readHarnessConfiguration(): CapabilityHarnessConfiguration {
maxSessionListCostUsd: typeof value.maxSessionListCostUsd === "number" && value.maxSessionListCostUsd > 0
? value.maxSessionListCostUsd
: 1,
} : value.provider === "aws_agentcore" ? {
agentCoreProfileId: typeof value.agentCoreProfileId === "string" && value.agentCoreProfileId.trim()
? value.agentCoreProfileId.trim()
: "default",
maxEstimatedSessionCostUsd: typeof value.maxEstimatedSessionCostUsd === "number" && value.maxEstimatedSessionCostUsd > 0
? value.maxEstimatedSessionCostUsd
: 1,
} : {}),
lifecyclePolicy,
};
@@ -302,6 +329,7 @@ export function App() {
const initialHarnessRef = useRef<CapabilityHarnessConfiguration>(readHarnessConfiguration());
const [harness, setHarness] = useState<CapabilityHarnessConfiguration>(initialHarnessRef.current);
const [activeHarness, setActiveHarness] = useState<CapabilityHarnessConfiguration | null>(null);
const [providerRuntime, setProviderRuntime] = useState<Awaited<ReturnType<typeof capabilityLiveClient.newCleanRoom>>["runtime"]>(undefined);
const [chatHistory, setChatHistory] = useState<StoredChatSession[]>(readChatHistory);
const [historicSessionId, setHistoricSessionId] = useState<string | null>(null);
const liveRoomRef = useRef<StoredChatSession | null>(null);
@@ -384,6 +412,7 @@ export function App() {
rememberSession(response.sessionId, "cleanroom");
setIdentity(response.identity ?? null);
setActiveHarness(response.configuration ?? initialHarnessRef.current);
setProviderRuntime(response.runtime);
setHistoricSessionId(null);
setSnapshot(response.view);
setSnapshotSurface("chat");
@@ -500,6 +529,7 @@ export function App() {
identity,
updatedAt: snapshot.renderedAt,
...(activeHarness === null ? {} : { configuration: activeHarness }),
...(providerRuntime === undefined ? {} : { runtime: providerRuntime }),
};
liveRoomRef.current = record;
setChatHistory((current) => {
@@ -507,7 +537,7 @@ export function App() {
persistChatHistory(next);
return next;
});
}, [activeHarness, chat, historicSessionId, identity, snapshot?.renderedAt, snapshot?.sessionId]);
}, [activeHarness, chat, historicSessionId, identity, providerRuntime, snapshot?.renderedAt, snapshot?.sessionId]);
useEffect(() => {
if (snapshot === null) return;
@@ -670,7 +700,10 @@ export function App() {
},
})
.then((next) => {
if (turnGenerationRef.current === generation) setSnapshot(next.view);
if (turnGenerationRef.current === generation) {
setSnapshot(next.view);
setProviderRuntime(next.runtime);
}
})
.catch((cause) => {
if (turnGenerationRef.current !== generation || controller.signal.aborted) return;
@@ -726,6 +759,7 @@ export function App() {
rememberSession(next.sessionId, "cleanroom");
setIdentity(next.identity ?? null);
setActiveHarness(next.configuration ?? harness);
setProviderRuntime(next.runtime);
setHistoricSessionId(null);
setSnapshot(next.view);
setDevtools(null);
@@ -745,6 +779,13 @@ export function App() {
setActionError("OpenCode models must use provider/model form.");
return;
}
if (harness.provider === "acpx") {
const agent = harness.acpxAgent ?? "pi";
if (model !== ACPX_AGENT_MODELS[agent]) {
setActionError(`The qualified ACPX ${agent} profile requires exact model ${ACPX_AGENT_MODELS[agent]}.`);
return;
}
}
if (harness.provider === "claude_managed") {
if (!harness.managedProfileId?.trim()) {
setActionError("Choose a qualified Managed Agent profile before starting a Claude Agent chat.");
@@ -755,6 +796,16 @@ export function App() {
return;
}
}
if (harness.provider === "aws_agentcore") {
if (!harness.agentCoreProfileId?.trim()) {
setActionError("Choose a qualified AWS AgentCore profile before starting a chat.");
return;
}
if (!(typeof harness.maxEstimatedSessionCostUsd === "number" && harness.maxEstimatedSessionCostUsd > 0)) {
setActionError("AWS AgentCore chats require a positive estimated session ceiling.");
return;
}
}
const configuration = { ...harness, model };
persistHarnessConfiguration(configuration);
setConfirmReset(false);
@@ -769,9 +820,11 @@ export function App() {
}, [abandonTurn, adoptCleanRoom, harness, snapshot]);
const increaseManagedBudget = useCallback(() => {
if (snapshot === null || activeHarness?.provider !== "claude_managed") return;
if (snapshot === null || (activeHarness?.provider !== "claude_managed" && activeHarness?.provider !== "aws_agentcore")) return;
const nextCap = Number(managedBudgetInput);
const currentCap = activeHarness.maxSessionListCostUsd ?? 0;
const currentCap = activeHarness.provider === "aws_agentcore"
? activeHarness.maxEstimatedSessionCostUsd ?? 0
: activeHarness.maxSessionListCostUsd ?? 0;
if (!Number.isFinite(nextCap) || nextCap <= currentCap) {
setActionError(`Enter a spend ceiling greater than the current $${currentCap.toFixed(2)} cap.`);
return;
@@ -781,17 +834,20 @@ export function App() {
setSnapshot(next.view);
setActiveHarness((current) => current === null ? current : {
...current,
maxSessionListCostUsd: nextCap,
...(current.provider === "aws_agentcore"
? { maxEstimatedSessionCostUsd: nextCap }
: { maxSessionListCostUsd: nextCap }),
});
setActionError(null);
setAnnouncement(`Claude Agent session spend ceiling raised to $${nextCap.toFixed(2)}.`);
setAnnouncement(`${activeHarness.provider === "aws_agentcore" ? "AWS AgentCore estimated" : "Claude Agent"} session ceiling raised to $${nextCap.toFixed(2)}.`);
})
.catch((cause) => setActionError(describe(cause)));
}, [activeHarness, managedBudgetInput, snapshot]);
const deleteManagedSession = useCallback(() => {
if (snapshot === null || activeHarness?.provider !== "claude_managed") return;
if (!window.confirm("Permanently delete this Anthropic Managed Agent session? It cannot be resumed.")) return;
if (snapshot === null || (activeHarness?.provider !== "claude_managed" && activeHarness?.provider !== "aws_agentcore")) return;
const providerLabel = activeHarness.provider === "aws_agentcore" ? "AWS AgentCore" : "Anthropic Managed Agent";
if (!window.confirm(`Permanently delete this ${providerLabel} session and its remote memory? It cannot be resumed.`)) return;
abandonTurn();
const deletedSessionId = snapshot.sessionId;
void capabilityLiveClient.deleteManagedSession(deletedSessionId)
@@ -803,7 +859,7 @@ export function App() {
return retained;
});
adoptCleanRoom(next);
setAnnouncement("Remote Claude Agent session deleted. A new clean room is ready.");
setAnnouncement(`Remote ${providerLabel} session deleted. A new clean room is ready.`);
})
.catch((cause) => setActionError(describe(cause)));
}, [abandonTurn, activeHarness, adoptCleanRoom, harness, snapshot]);
@@ -814,6 +870,7 @@ export function App() {
setSnapshot(live.snapshot);
setIdentity(live.identity);
setActiveHarness(live.configuration ?? null);
setProviderRuntime(live.runtime);
setDevtools(null);
setHistoricSessionId(null);
setActionError(null);
@@ -829,6 +886,7 @@ export function App() {
setSnapshot(restored.view);
setIdentity(restored.identity ?? archived.identity);
setActiveHarness(restored.configuration ?? configuration);
setProviderRuntime(restored.runtime);
setDevtools(null);
setHistoricSessionId(null);
setActionError(null);
@@ -1065,8 +1123,32 @@ export function App() {
<option value="codex">Codex</option>
<option value="opencode">OpenCode</option>
<option value="claude_managed">Claude Agent</option>
<option value="aws_agentcore">AWS AgentCore</option>
<option value="acpx">ACPX</option>
</select>
</label>
{harness.provider === "acpx" ? (
<label>
<span>ACP agent</span>
<select
data-testid="chat-acpx-agent"
value={harness.acpxAgent ?? "pi"}
disabled={streamingTurn}
onChange={(event) => {
const acpxAgent = event.target.value as NonNullable<CapabilityHarnessConfiguration["acpxAgent"]>;
setHarness((current) => ({
...current,
acpxAgent,
model: ACPX_AGENT_MODELS[acpxAgent],
}));
}}
>
<option value="pi">Pi</option>
<option value="claude">Claude</option>
<option value="codex">Codex (control)</option>
</select>
</label>
) : null}
<label>
<span>Execution</span>
<select
@@ -1104,7 +1186,7 @@ export function App() {
/>
</label>
) : null}
{harness.provider === "claude_managed" || activeHarness?.provider === "claude_managed" ? (
{harness.provider === "claude_managed" ? (
<>
<label>
<span>Managed profile</span>
@@ -1136,6 +1218,32 @@ export function App() {
</label>
</>
) : null}
{harness.provider === "aws_agentcore" ? (
<>
<label>
<span>AgentCore profile</span>
<input
data-testid="chat-agentcore-profile"
value={harness.agentCoreProfileId ?? ""}
disabled={streamingTurn}
placeholder="qualified profile ID"
onChange={(event) => setHarness((current) => ({ ...current, agentCoreProfileId: event.target.value }))}
/>
</label>
<label>
<span>Estimated ceiling (USD)</span>
<input
data-testid="chat-agentcore-spend-cap"
type="number"
min="0.01"
step="0.01"
value={harness.maxEstimatedSessionCostUsd ?? 1}
disabled={streamingTurn}
onChange={(event) => setHarness((current) => ({ ...current, maxEstimatedSessionCostUsd: Number(event.target.value) }))}
/>
</label>
</>
) : null}
<label className="pit-harness-model">
<span>Model</span>
<input
@@ -1143,7 +1251,7 @@ export function App() {
list={`chat-models-${harness.provider}`}
value={harness.model ?? ""}
disabled={streamingTurn}
placeholder={harness.provider === "opencode" ? "provider/model" : "model name"}
placeholder={harness.provider === "opencode" || (harness.provider === "acpx" && harness.acpxAgent === "pi") ? "provider/model" : "model name"}
onChange={(event) => setHarness((current) => ({ ...current, model: event.target.value }))}
/>
<datalist id={`chat-models-${harness.provider}`}>
@@ -1161,19 +1269,29 @@ export function App() {
Start new chat
</button>
<span className="pit-harness-active" data-testid="chat-active-harness">
Active: {activeHarness?.provider === "claude_managed" ? "Claude Agent" : activeHarness?.provider ?? "starting"}
Active: {activeHarness?.provider === "claude_managed" ? "Claude Agent" : activeHarness?.provider === "aws_agentcore" ? "AWS AgentCore" : acpxLabel(activeHarness)}
{activeHarness?.model ? ` · ${activeHarness.model}` : ""}
{activeHarness?.provider === "claude_managed" ? " · Anthropic cloud · no provider PID" : ""}
{activeHarness?.provider === "aws_agentcore" ? " · AWS cloud · no provider PID" : ""}
{activeHarness ? ` · ${activeHarness.lifecyclePolicy.mode === "warm" ? `warm ${Math.round(activeHarness.lifecyclePolicy.idleTimeoutMs / 1_000)}s` : "turn by turn"}` : ""}
{providerRuntime?.runnerPid ? ` · runner PID ${providerRuntime.runnerPid}` : ""}
{activeHarness?.provider === "acpx" && providerRuntime?.sidecarPid ? ` · sidecar PID ${providerRuntime.sidecarPid}` : ""}
{activeHarness?.provider === "acpx" && providerRuntime?.agentPid ? ` · agent PID ${providerRuntime.agentPid}` : ""}
{activeHarness?.provider === "acpx" && providerRuntime?.driverSessionId ? ` · ACPX record ${providerRuntime.driverSessionId}` : ""}
{providerRuntime?.providerSessionId ? ` · session ${providerRuntime.providerSessionId}` : ""}
{activeHarness?.provider === "acpx" && providerRuntime?.providerVersion ? ` · ACPX ${providerRuntime.providerVersion}` : ""}
{activeHarness?.provider === "acpx" && providerRuntime?.agentServerVersion ? ` · agent ${providerRuntime.agentServerVersion}` : ""}
{activeHarness?.provider === "acpx" && providerRuntime?.acpProtocolVersion ? ` · ACP ${providerRuntime.acpProtocolVersion}` : ""}
{providerRuntime?.status ? ` · ${providerRuntime.status}` : ""}
</span>
{activeHarness?.provider === "claude_managed" && snapshot !== null && historicSessionId === null ? (
<div className="pit-managed-session-actions" aria-label="Active Claude Agent session controls">
{(activeHarness?.provider === "claude_managed" || activeHarness?.provider === "aws_agentcore") && snapshot !== null && historicSessionId === null ? (
<div className="pit-managed-session-actions" aria-label={`Active ${activeHarness.provider === "aws_agentcore" ? "AWS AgentCore" : "Claude Agent"} session controls`}>
<label>
<span>Raise active cap (USD)</span>
<input
data-testid="chat-managed-active-spend-cap"
type="number"
min={(activeHarness.maxSessionListCostUsd ?? 1) + 0.01}
min={(activeHarness.provider === "aws_agentcore" ? activeHarness.maxEstimatedSessionCostUsd ?? 1 : activeHarness.maxSessionListCostUsd ?? 1) + 0.01}
step="0.01"
value={managedBudgetInput}
disabled={streamingTurn}
@@ -1193,19 +1311,24 @@ export function App() {
type="button"
className="pit-button"
data-variant="danger"
data-testid="chat-managed-delete-session"
data-testid={activeHarness.provider === "aws_agentcore" ? "chat-agentcore-delete-session" : "chat-managed-delete-session"}
disabled={streamingTurn}
onClick={deleteManagedSession}
>
Delete remote session
Delete {activeHarness.provider === "aws_agentcore" ? "AWS session" : "remote session"}
</button>
</div>
) : null}
{harness.provider === "claude_managed" || activeHarness?.provider === "claude_managed" ? (
{harness.provider === "claude_managed" ? (
<p className="pit-harness-notice" role="note" data-testid="chat-managed-retention-notice">
Managed Agent sessions are retained by Anthropic and are not eligible for ZDR or HIPAA modes. Paperclip tools still execute only in runnerd.
</p>
) : null}
{harness.provider === "aws_agentcore" ? (
<p className="pit-harness-notice" role="note" data-testid="chat-agentcore-retention-notice">
AgentCore Memory retains this chat for 90 days. Cost is a Paperclip estimate; model tokens, Runtime active time, and Memory are billed by AWS. Paperclip tools execute only in runnerd.
</p>
) : null}
</section>
) : null}
@@ -42,10 +42,13 @@ export interface CapabilityCleanRoomIdentity {
}
export interface CapabilityHarnessConfiguration {
provider: "codex" | "opencode" | "claude_managed";
provider: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx";
model: string | null;
acpxAgent?: "pi" | "claude" | "codex";
managedProfileId?: string | null;
maxSessionListCostUsd?: number | null;
agentCoreProfileId?: string | null;
maxEstimatedSessionCostUsd?: number | null;
lifecyclePolicy:
| { mode: "per_turn"; idleTimeoutMs: null }
| { mode: "warm"; idleTimeoutMs: number };
@@ -60,6 +63,20 @@ export interface CapabilityLiveResponse {
limits?: { maxTurns: number; maxMessageBytes: number };
turns?: number;
configuration?: CapabilityHarnessConfiguration;
runtime?: {
providerSessionId: string | null;
driverSessionId?: string | null;
runnerPid: number | null;
providerPid: number | null;
sidecarPid?: number | null;
agentPid?: number | null;
providerVersion?: string | null;
agentServerVersion?: string | null;
agentRuntimeVersion?: string | null;
acpProtocolVersion?: number | null;
executionKind: "local_process" | "remote_service";
status: string;
};
}
export interface CapabilityToolTestResponse extends CapabilityLiveResponse {
@@ -175,10 +192,15 @@ export const capabilityLiveClient = {
const query = new URLSearchParams({ provider: configuration.provider });
if (sessionId !== null) query.set("sessionId", sessionId);
if (configuration.model !== null) query.set("model", configuration.model);
if (configuration.acpxAgent) query.set("acpxAgent", configuration.acpxAgent);
if (configuration.managedProfileId) query.set("managedProfileId", configuration.managedProfileId);
if (configuration.maxSessionListCostUsd !== null && configuration.maxSessionListCostUsd !== undefined) {
query.set("maxSessionListCostUsd", String(configuration.maxSessionListCostUsd));
}
if (configuration.agentCoreProfileId) query.set("agentCoreProfileId", configuration.agentCoreProfileId);
if (configuration.maxEstimatedSessionCostUsd !== null && configuration.maxEstimatedSessionCostUsd !== undefined) {
query.set("maxEstimatedSessionCostUsd", String(configuration.maxEstimatedSessionCostUsd));
}
query.set("lifecycleMode", configuration.lifecyclePolicy.mode);
if (configuration.lifecyclePolicy.mode === "warm") {
query.set("idleTimeoutMs", String(configuration.lifecyclePolicy.idleTimeoutMs));
@@ -0,0 +1,375 @@
AWSTemplateFormatVersion: "2010-09-09"
Description: Paperclip proof-of-concept Amazon Bedrock AgentCore Harness and least-privilege invocation roles.
Parameters:
EnvironmentName:
Type: String
Default: development
AllowedPattern: "^[a-z][a-z0-9-]{1,20}$"
DeploymentMode:
Type: String
Default: development
AllowedValues: [development, private]
TrustedRunnerPrincipalArn:
Type: String
Description: Stable IAM user or role ARN allowed to assume the Paperclip invocation role.
AllowedPattern: "^arn:aws(-[^:]+)?:iam::[0-9]{12}:(role|user)/.+$"
BedrockModelId:
Type: String
Default: global.anthropic.claude-sonnet-4-6
BedrockModelResourceArn:
Type: String
Description: Exact application/system inference-profile ARN for the selected model.
AllowedPattern: "^arn:aws(-[^:]+)?:bedrock:[^:]+:[0-9]{12}:(inference-profile|application-inference-profile)/.+$"
BedrockFoundationModelResourceArn:
Type: String
Description: Selected foundation-model ARN; the region segment may be * for cross-region inference.
AllowedPattern: "^arn:aws(-[^:]+)?:bedrock:[^:]*::foundation-model/.+$"
HarnessName:
Type: String
Default: PaperclipAgentCoreHarness
AllowedPattern: "^[a-zA-Z][a-zA-Z0-9_]{0,39}$"
MemoryName:
Type: String
Default: PaperclipAgentCoreMemory
AllowedPattern: "^[a-zA-Z][a-zA-Z0-9_]{0,47}$"
Conditions:
IsPrivate: !Equals [!Ref DeploymentMode, private]
Resources:
AgentMemory:
Type: AWS::BedrockAgentCore::Memory
Properties:
Name: !Ref MemoryName
Description: Short-term Paperclip Runner chat continuity; no long-term strategies.
EventExpiryDuration: 90
Tags:
paperclip:owned: "true"
paperclip:environment: !Ref EnvironmentName
paperclip:cost-center: runner-lab
HarnessExecutionRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub paperclip-agentcore-harness-${EnvironmentName}-${AWS::Region}
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: bedrock-agentcore.amazonaws.com
Action: sts:AssumeRole
Condition:
StringEquals:
aws:SourceAccount: !Ref AWS::AccountId
Policies:
- PolicyName: BedrockModelAndMemory
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: InvokePinnedBedrockModel
Effect: Allow
Action:
- bedrock:InvokeModel
- bedrock:InvokeModelWithResponseStream
Resource:
- !Ref BedrockModelResourceArn
- !Ref BedrockFoundationModelResourceArn
- Sid: ShortTermMemory
Effect: Allow
Action:
- bedrock-agentcore:CreateEvent
- bedrock-agentcore:GetEvent
- bedrock-agentcore:ListEvents
Resource: !GetAtt AgentMemory.MemoryArn
- !If
- IsPrivate
- Sid: PullManagedHarnessImage
Effect: Allow
Action: ecr:GetAuthorizationToken
Resource: "*"
Condition:
StringEquals:
aws:RequestedRegion: !Ref AWS::Region
- !Ref AWS::NoValue
- !If
- IsPrivate
- Sid: PullManagedHarnessLayers
Effect: Allow
Action:
- ecr:BatchCheckLayerAvailability
- ecr:GetDownloadUrlForLayer
- ecr:BatchGetImage
Resource: !Sub arn:${AWS::Partition}:ecr:${AWS::Region}:*:repository/*
- !Ref AWS::NoValue
- Sid: NeverOpenRuntimeCommandChannel
Effect: Deny
Action: bedrock-agentcore:InvokeAgentRuntimeCommand
Resource: "*"
Tags:
- Key: paperclip:owned
Value: "true"
- Key: paperclip:environment
Value: !Ref EnvironmentName
AgentHarness:
Type: AWS::BedrockAgentCore::Harness
Properties:
HarnessName: !Ref HarnessName
ExecutionRoleArn: !GetAtt HarnessExecutionRole.Arn
Model:
BedrockModelConfig:
ModelId: !Ref BedrockModelId
ApiFormat: converse_stream
MaxTokens: 4096
SystemPrompt:
- Text: >-
You are a Paperclip-governed remote agent. Use only caller-supplied inline
functions. Never claim access to Paperclip, a local workspace, shell,
filesystem, browser, network, MCP, skills, or other agents. Follow the
current turn's completion contract and use its finish or block function.
Memory:
AgentCoreMemoryConfiguration:
Arn: !GetAtt AgentMemory.MemoryArn
MessagesCount: 100
MaxIterations: 8
MaxTokens: 4096
TimeoutSeconds: 300
Tools: []
Skills: []
EnvironmentVariables: {}
Environment:
AgentCoreRuntimeEnvironment:
LifecycleConfiguration:
IdleRuntimeSessionTimeout: 300
MaxLifetime: 28800
NetworkConfiguration:
NetworkMode: !If [IsPrivate, VPC, PUBLIC]
NetworkModeConfig: !If
- IsPrivate
- SecurityGroups: [!Ref HarnessSecurityGroup]
Subnets: [!Ref PrivateSubnetA, !Ref PrivateSubnetB]
- !Ref AWS::NoValue
Tags:
- Key: paperclip:owned
Value: "true"
- Key: paperclip:environment
Value: !Ref EnvironmentName
- Key: paperclip:cost-center
Value: runner-lab
RunnerInvocationRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub paperclip-agentcore-runner-${EnvironmentName}-${AWS::Region}
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
AWS: !Ref TrustedRunnerPrincipalArn
Action: sts:AssumeRole
Policies:
- PolicyName: InvokeAndOperatePinnedHarness
PolicyDocument:
Version: "2012-10-17"
Statement:
- Sid: InvokePinnedHarnessAndRuntime
Effect: Allow
Action:
- bedrock-agentcore:InvokeHarness
- bedrock-agentcore:InvokeAgentRuntime
- bedrock-agentcore:StopRuntimeSession
Resource:
- !GetAtt AgentHarness.Arn
- !GetAtt AgentHarness.Environment.AgentCoreRuntimeEnvironment.AgentRuntimeArn
- Sid: ReadPinnedHarness
Effect: Allow
Action:
- bedrock-agentcore:GetHarness
- bedrock-agentcore:GetHarnessEndpoint
- bedrock-agentcore:ListHarnessEndpoints
Resource: !GetAtt AgentHarness.Arn
- Sid: ReadAndPurgeSessionMemory
Effect: Allow
Action:
- bedrock-agentcore:GetMemory
- bedrock-agentcore:GetEvent
- bedrock-agentcore:ListEvents
- bedrock-agentcore:DeleteEvent
Resource: !GetAtt AgentMemory.MemoryArn
- Sid: DiscoverBedrockModels
Effect: Allow
Action:
- bedrock:ListFoundationModels
- bedrock:GetFoundationModel
Resource: "*"
- Sid: NeverOpenRuntimeCommandChannel
Effect: Deny
Action: bedrock-agentcore:InvokeAgentRuntimeCommand
Resource: "*"
Tags:
- Key: paperclip:owned
Value: "true"
- Key: paperclip:environment
Value: !Ref EnvironmentName
PrivateVpc:
Type: AWS::EC2::VPC
Condition: IsPrivate
Properties:
CidrBlock: 10.87.0.0/16
EnableDnsHostnames: true
EnableDnsSupport: true
Tags:
- Key: Name
Value: !Sub paperclip-agentcore-${EnvironmentName}
- Key: paperclip:owned
Value: "true"
PrivateSubnetA:
Type: AWS::EC2::Subnet
Condition: IsPrivate
Properties:
VpcId: !Ref PrivateVpc
CidrBlock: 10.87.1.0/24
AvailabilityZone: !Select [0, !GetAZs ""]
MapPublicIpOnLaunch: false
PrivateSubnetB:
Type: AWS::EC2::Subnet
Condition: IsPrivate
Properties:
VpcId: !Ref PrivateVpc
CidrBlock: 10.87.2.0/24
AvailabilityZone: !Select [1, !GetAZs ""]
MapPublicIpOnLaunch: false
PrivateRouteTable:
Type: AWS::EC2::RouteTable
Condition: IsPrivate
Properties:
VpcId: !Ref PrivateVpc
PrivateSubnetRouteA:
Type: AWS::EC2::SubnetRouteTableAssociation
Condition: IsPrivate
Properties:
RouteTableId: !Ref PrivateRouteTable
SubnetId: !Ref PrivateSubnetA
PrivateSubnetRouteB:
Type: AWS::EC2::SubnetRouteTableAssociation
Condition: IsPrivate
Properties:
RouteTableId: !Ref PrivateRouteTable
SubnetId: !Ref PrivateSubnetB
HarnessSecurityGroup:
Type: AWS::EC2::SecurityGroup
Condition: IsPrivate
Properties:
GroupDescription: AgentCore Harness egress only to VPC endpoints
VpcId: !Ref PrivateVpc
SecurityGroupEgress: []
EndpointSecurityGroup:
Type: AWS::EC2::SecurityGroup
Condition: IsPrivate
Properties:
GroupDescription: HTTPS from the AgentCore Harness security group
VpcId: !Ref PrivateVpc
SecurityGroupIngress: []
HarnessToEndpointsEgress:
Type: AWS::EC2::SecurityGroupEgress
Condition: IsPrivate
Properties:
GroupId: !Ref HarnessSecurityGroup
IpProtocol: tcp
FromPort: 443
ToPort: 443
DestinationSecurityGroupId: !Ref EndpointSecurityGroup
EndpointsFromHarnessIngress:
Type: AWS::EC2::SecurityGroupIngress
Condition: IsPrivate
Properties:
GroupId: !Ref EndpointSecurityGroup
IpProtocol: tcp
FromPort: 443
ToPort: 443
SourceSecurityGroupId: !Ref HarnessSecurityGroup
EcrApiEndpoint:
Type: AWS::EC2::VPCEndpoint
Condition: IsPrivate
Properties:
VpcId: !Ref PrivateVpc
ServiceName: !Sub com.amazonaws.${AWS::Region}.ecr.api
VpcEndpointType: Interface
PrivateDnsEnabled: true
SubnetIds: [!Ref PrivateSubnetA, !Ref PrivateSubnetB]
SecurityGroupIds: [!Ref EndpointSecurityGroup]
EcrDkrEndpoint:
Type: AWS::EC2::VPCEndpoint
Condition: IsPrivate
Properties:
VpcId: !Ref PrivateVpc
ServiceName: !Sub com.amazonaws.${AWS::Region}.ecr.dkr
VpcEndpointType: Interface
PrivateDnsEnabled: true
SubnetIds: [!Ref PrivateSubnetA, !Ref PrivateSubnetB]
SecurityGroupIds: [!Ref EndpointSecurityGroup]
BedrockRuntimeEndpoint:
Type: AWS::EC2::VPCEndpoint
Condition: IsPrivate
Properties:
VpcId: !Ref PrivateVpc
ServiceName: !Sub com.amazonaws.${AWS::Region}.bedrock-runtime
VpcEndpointType: Interface
PrivateDnsEnabled: true
SubnetIds: [!Ref PrivateSubnetA, !Ref PrivateSubnetB]
SecurityGroupIds: [!Ref EndpointSecurityGroup]
S3Endpoint:
Type: AWS::EC2::VPCEndpoint
Condition: IsPrivate
Properties:
VpcId: !Ref PrivateVpc
ServiceName: !Sub com.amazonaws.${AWS::Region}.s3
VpcEndpointType: Gateway
RouteTableIds: [!Ref PrivateRouteTable]
Outputs:
AccountId:
Value: !Ref AWS::AccountId
Region:
Value: !Ref AWS::Region
DeploymentMode:
Value: !Ref DeploymentMode
BedrockModelId:
Value: !Ref BedrockModelId
MemoryArn:
Value: !GetAtt AgentMemory.MemoryArn
MemoryId:
Value: !GetAtt AgentMemory.MemoryId
MemoryEventExpiryDays:
Value: "90"
HarnessArn:
Value: !GetAtt AgentHarness.Arn
HarnessId:
Value: !GetAtt AgentHarness.HarnessId
HarnessVersion:
Value: !GetAtt AgentHarness.Version
AgentRuntimeArn:
Value: !GetAtt AgentHarness.Environment.AgentCoreRuntimeEnvironment.AgentRuntimeArn
AgentRuntimeId:
Value: !GetAtt AgentHarness.Environment.AgentCoreRuntimeEnvironment.AgentRuntimeId
RunnerInvocationRoleArn:
Value: !GetAtt RunnerInvocationRole.Arn
QualificationRevision:
Value: aws-agentcore-harness-v1
+16 -5
View File
@@ -6,7 +6,8 @@
"type": "module",
"bin": {
"paperclip-runner-eval-session": "./dist/cli/eval-session.js",
"paperclip-runner-opencode-proxy": "./dist/cli/opencode-app-server-proxy.js"
"paperclip-runner-opencode-proxy": "./dist/cli/opencode-app-server-proxy.js",
"paperclip-runner-acpx-sidecar": "./dist/cli/acpx-runtime-sidecar.js"
},
"exports": {
".": {
@@ -67,7 +68,7 @@
"typecheck:typescript": "pnpm run check:protocol-types && tsc -p tsconfig.json --noEmit",
"typecheck:rust": "cargo check --manifest-path runner/Cargo.toml --locked --workspace",
"typecheck:browser": "tsc -p tsconfig.browser.json --noEmit",
"test": "pnpm run build:runner-binaries && pnpm run test:typescript && pnpm run test:rust && node --test scripts/check-forbidden-imports.test.mjs scripts/check-numbered-milestones.test.mjs scripts/check-tracked-imports.test.mjs scripts/record-codex-evidence.test.mjs scripts/validate-doc-links.test.mjs test/capability-contract.test.mjs",
"test": "pnpm run build:runner-binaries && pnpm run test:typescript && pnpm run test:rust && node --test scripts/aws-agentcore-provisioning.test.mjs scripts/check-forbidden-imports.test.mjs scripts/check-numbered-milestones.test.mjs scripts/check-tracked-imports.test.mjs scripts/record-codex-evidence.test.mjs scripts/validate-doc-links.test.mjs test/capability-contract.test.mjs",
"test:typescript": "vitest run",
"test:rust": "cargo test --manifest-path runner/Cargo.toml --locked --workspace",
"test:browser": "pnpm run build:typescript && pnpm run build:runner-binaries && pnpm run build:browser && playwright test --config devtools/browser/playwright.config.ts",
@@ -78,8 +79,8 @@
"test:browser:issue-thread": "pnpm run build:typescript && pnpm run build:issue-thread && playwright test --config devtools/issue-thread/playwright.config.ts",
"record:sdk:codex": "pnpm run build:typescript && playwright test --config examples/playwright.real.config.ts",
"build:runner-binaries": "cargo build --manifest-path runner/Cargo.toml --locked --workspace --bins",
"generate:protocol-types": "node scripts/generate-protocol-schema-module.mjs",
"check:protocol-types": "node scripts/generate-protocol-schema-module.mjs --check",
"generate:protocol-types": "node scripts/generate-protocol-schema-module.mjs && node scripts/generate-acpx-sidecar-contract.mjs",
"check:protocol-types": "node scripts/generate-protocol-schema-module.mjs --check && node scripts/generate-acpx-sidecar-contract.mjs --check",
"generate:replay-goldens": "pnpm run build:typescript && node scripts/generate-replay-goldens.mjs",
"check:replay-goldens": "node scripts/generate-replay-goldens.mjs --check",
"check:browser-tokens": "node scripts/check-browser-tokens.mjs",
@@ -113,6 +114,11 @@
"demo:standalone": "pnpm run build:typescript && vite --config vite.standalone.config.ts --host 127.0.0.1 --port 4182",
"demo:scenarios": "pnpm run build:typescript && vite --config vite.scenarios.config.ts --host 127.0.0.1 --port 4183",
"console:issue-thread": "pnpm run build:typescript && vite --config vite.issue-thread.config.ts --host 127.0.0.1 --port 4184",
"aws-agentcore:provision": "bash scripts/aws-agentcore.sh provision",
"aws-agentcore:probe": "bash scripts/aws-agentcore.sh probe",
"aws-agentcore:lab": "bash scripts/aws-agentcore.sh lab",
"aws-agentcore:destroy": "bash scripts/aws-agentcore.sh destroy",
"smoke:capability:aws-agentcore": "pnpm run build:typescript && cargo build --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd && node scripts/capability-aws-agentcore-smoke.mjs",
"demo:live-console": "pnpm run build:typescript && node scripts/live-console-demo-server.mjs",
"record:live-console": "pnpm run build:typescript && node scripts/record-live-console-evidence.mjs",
"record:codex": "pnpm run build:typescript && node scripts/record-codex-evidence.mjs",
@@ -135,7 +141,12 @@
"verify:rootless": "bash scripts/verify-rootless-linux.sh"
},
"dependencies": {
"ajv": "^8.20.0"
"@agentclientprotocol/claude-agent-acp": "0.70.0",
"@agentclientprotocol/codex-acp": "1.6.2",
"@earendil-works/pi-coding-agent": "0.84.2",
"acpx": "0.13.1",
"ajv": "^8.20.0",
"pi-acp": "0.0.33"
},
"peerDependencies": {
"react": ">=18",
@@ -0,0 +1,70 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://paperclip.dev/schemas/acpx-sidecar/v1/message.schema.json",
"title": "Paperclip ACPX sidecar message",
"oneOf": [
{ "$ref": "#/$defs/request" },
{ "$ref": "#/$defs/response" },
{ "$ref": "#/$defs/event" }
],
"$defs": {
"command": {
"enum": [
"initialize", "session.open", "run.attach", "turn.start", "turn.cancel",
"permission.resolve", "tool.resolve", "session.read", "session.snapshot",
"session.suspend", "session.close"
]
},
"eventType": {
"enum": [
"runtime.event", "runtime.permission_requested", "runtime.tool_called",
"runtime.turn_terminal", "runtime.process", "runtime.diagnostic"
]
},
"request": {
"type": "object",
"required": ["protocolVersion", "id", "command", "params"],
"properties": {
"protocolVersion": { "const": 1 },
"id": { "type": "integer", "minimum": 1 },
"command": { "$ref": "#/$defs/command" },
"params": { "type": "object" }
},
"additionalProperties": false
},
"response": {
"type": "object",
"required": ["protocolVersion", "id", "ok"],
"properties": {
"protocolVersion": { "const": 1 },
"id": { "type": "integer", "minimum": 1 },
"ok": { "type": "boolean" },
"result": { "type": "object" },
"error": {
"type": "object",
"required": ["code", "message", "retryable"],
"properties": {
"code": { "type": "string", "minLength": 1, "maxLength": 160 },
"message": { "type": "string", "maxLength": 8192 },
"retryable": { "type": "boolean" }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"event": {
"type": "object",
"required": ["protocolVersion", "sequence", "eventType", "runId", "turnId", "payload"],
"properties": {
"protocolVersion": { "const": 1 },
"sequence": { "type": "integer", "minimum": 1 },
"eventType": { "$ref": "#/$defs/eventType" },
"runId": { "type": ["string", "null"], "maxLength": 160 },
"turnId": { "type": ["string", "null"], "maxLength": 160 },
"payload": { "type": "object" }
},
"additionalProperties": false
}
}
}
@@ -5,14 +5,26 @@
"type": "object",
"required": ["provider", "driver", "model", "executionKind", "providerVersion"],
"properties": {
"provider": { "enum": ["codex", "opencode", "claude_managed"] },
"driver": { "enum": ["codex_app_server", "opencode_server", "claude_managed_agents_api"] },
"provider": { "enum": ["codex", "opencode", "claude_managed", "aws_agentcore", "acpx"] },
"driver": { "enum": ["codex_app_server", "opencode_server", "claude_managed_agents_api", "aws_agentcore_harness_api", "acpx_runtime"] },
"model": { "type": ["string", "null"], "maxLength": 240 },
"executionKind": { "enum": ["local_process", "remote_service"] },
"providerVersion": { "type": "string", "minLength": 1, "maxLength": 120 },
"service": { "enum": ["anthropic_managed_agents"] },
"service": { "enum": ["anthropic_managed_agents", "aws_bedrock_agentcore_harness"] },
"providerSessionId": { "type": ["string", "null"], "maxLength": 240 },
"processId": { "type": ["integer", "null"], "minimum": 1 }
"processId": { "type": ["integer", "null"], "minimum": 1 },
"endpointArn": { "type": "string", "minLength": 1, "maxLength": 2048 },
"endpointQualifier": { "type": "string", "minLength": 1, "maxLength": 240 },
"memoryId": { "type": "string", "minLength": 1, "maxLength": 240 },
"eventExpiryDays": { "const": 90 },
"agent": { "enum": ["pi", "claude", "codex"] },
"requestedModel": { "type": "string", "minLength": 1, "maxLength": 240 },
"acpProtocolVersion": { "const": 1 },
"agentServerPackage": { "type": "string", "minLength": 1, "maxLength": 240 },
"agentServerVersion": { "type": "string", "minLength": 1, "maxLength": 120 },
"agentRuntimePackage": { "type": ["string", "null"], "maxLength": 240 },
"agentRuntimeVersion": { "type": ["string", "null"], "maxLength": 120 },
"acpxRecordId": { "type": ["string", "null"], "maxLength": 240 }
},
"allOf": [
{
@@ -25,6 +37,23 @@
{
"if": { "properties": { "executionKind": { "const": "local_process" } } },
"then": { "properties": { "service": false } }
},
{
"if": { "properties": { "provider": { "const": "claude_managed" } }, "required": ["provider"] },
"then": { "properties": { "service": { "const": "anthropic_managed_agents" } }, "required": ["service"] }
},
{
"if": { "properties": { "provider": { "const": "aws_agentcore" } }, "required": ["provider"] },
"then": {
"properties": { "service": { "const": "aws_bedrock_agentcore_harness" } },
"required": ["service", "endpointArn", "endpointQualifier", "memoryId", "eventExpiryDays"]
}
},
{
"if": { "properties": { "provider": { "const": "acpx" } }, "required": ["provider"] },
"then": {
"required": ["agent", "requestedModel", "acpProtocolVersion", "agentServerPackage", "agentServerVersion", "acpxRecordId"]
}
}
],
"additionalProperties": false
File diff suppressed because it is too large. Load diff
@@ -16,3 +16,10 @@ serde_json = "1.0"
sha2 = "0.10"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
jsonschema = { version = "0.50", default-features = false }
aws-config = { version = "1.11", features = ["behavior-version-latest"] }
aws-sdk-bedrockagentcore = "1.63"
aws-sdk-sts = "1.112"
aws-smithy-types = "1.6"
aws-types = "1.3"
tokio = { version = "1.50", features = ["rt-multi-thread", "sync", "time"] }
uuid = { version = "1.22", features = ["v4"] }
@@ -13,6 +13,13 @@ serde_json.workspace = true
sha2.workspace = true
reqwest.workspace = true
jsonschema.workspace = true
aws-config.workspace = true
aws-sdk-bedrockagentcore.workspace = true
aws-sdk-sts.workspace = true
aws-smithy-types.workspace = true
aws-types.workspace = true
tokio.workspace = true
uuid.workspace = true
[[bin]]
name = "conformance-tracer"
@@ -0,0 +1,725 @@
use std::collections::{BTreeMap, VecDeque};
use std::path::Path;
use std::time::Duration;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use crate::codex_provider::{
AcpxProviderConfig, Provider, ProviderEvent, ProviderKind, ProviderRuntimeIdentity,
ProviderSessionIdentity,
};
use crate::generated_acpx_sidecar_contract::{
GeneratedAcpxSidecarCommand, GeneratedAcpxSidecarEventType,
GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
};
use crate::local_runner::LocalRunnerError;
use crate::process_supervisor::SupervisedProcess;
use crate::provider_bridge::{AuthorizedTool, ToolResult};
const ACPX_SIDECAR_MAX_FRAME_BYTES: usize = 1024 * 1024;
#[derive(Clone, Debug)]
struct PendingTool {
operation_id: String,
input: Value,
turn_id: String,
}
pub struct AcpxProvider {
process: SupervisedProcess,
next_request_id: u64,
inbox: VecDeque<ProviderEvent>,
pending_tools: BTreeMap<String, PendingTool>,
config: AcpxProviderConfig,
tools: Vec<AuthorizedTool>,
catalog_revision: u64,
acpx_record_id: String,
backend_session_id: String,
agent_session_id: String,
active_turn_id: Option<String>,
last_sequence: u64,
assistant_text: String,
provider_requests: u64,
}
impl AcpxProvider {
pub fn start(
config: &AcpxProviderConfig,
tools: Vec<AuthorizedTool>,
expected_identity: Option<&ProviderSessionIdentity>,
) -> Result<Self, LocalRunnerError> {
validate_config(config)?;
let process = SupervisedProcess::spawn(
Path::new(&config.sidecar_command),
&config.sidecar_args,
Duration::from_secs(3),
ACPX_SIDECAR_MAX_FRAME_BYTES,
)?;
let mut provider = Self {
process,
next_request_id: 1,
inbox: VecDeque::new(),
pending_tools: BTreeMap::new(),
config: config.clone(),
tools,
catalog_revision: 1,
acpx_record_id: String::new(),
backend_session_id: String::new(),
agent_session_id: String::new(),
active_turn_id: None,
last_sequence: 0,
assistant_text: String::new(),
provider_requests: 0,
};
provider.request(
GeneratedAcpxSidecarCommand::Initialize,
json!({ "agent": config.agent, "model": config.model }),
)?;
let opened = provider.request(
GeneratedAcpxSidecarCommand::SessionOpen,
json!({
"runtimeDirectory": config.runtime_directory,
"normalizedSessionId": config.normalized_session_id,
"workingDirectory": config.cwd,
"agent": config.agent,
"model": config.model,
"tools": provider.tools,
"expectedIdentity": expected_identity,
}),
)?;
let identity = opened.get("identity").unwrap_or(&Value::Null);
provider.acpx_record_id = required_text(identity, "acpxRecordId", "ACPX record")?;
provider.backend_session_id =
required_text(identity, "backendSessionId", "backend session")?;
provider.agent_session_id = required_text(identity, "agentSessionId", "agent session")?;
if identity.get("effectiveModel").and_then(Value::as_str) != Some(config.model.as_str()) {
return Err(LocalRunnerError::invalid(
"ACPX sidecar did not verify the configured effective model",
));
}
provider.request(
GeneratedAcpxSidecarCommand::RunAttach,
json!({
"runId": config.run_id,
"catalogRevision": provider.catalog_revision,
"tools": provider.tools,
}),
)?;
Ok(provider)
}
fn request(
&mut self,
command: GeneratedAcpxSidecarCommand,
params: Value,
) -> Result<Value, LocalRunnerError> {
let id = self.next_request_id;
self.next_request_id += 1;
self.process.send(&json!({
"protocolVersion": GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
"id": id,
"command": command.as_str(),
"params": params,
}))?;
loop {
let line = self
.process
.receive_stdout_line(Duration::from_secs(30))?
.ok_or_else(|| {
LocalRunnerError::invalid(format!(
"ACPX sidecar {} response timed out",
command.as_str()
))
})?;
let message = parse_frame(&line)?;
if message.get("id").and_then(Value::as_u64) == Some(id) {
if message.get("ok").and_then(Value::as_bool) != Some(true) {
return Err(LocalRunnerError::invalid(format!(
"ACPX sidecar {} failed: {}",
command.as_str(),
message
.pointer("/error/message")
.and_then(Value::as_str)
.unwrap_or("unknown error")
)));
}
return Ok(message.get("result").cloned().unwrap_or_else(|| json!({})));
}
if let Some(event) = self.map_frame(&message)? {
self.inbox.push_back(event);
}
}
}
fn map_frame(&mut self, message: &Value) -> Result<Option<ProviderEvent>, LocalRunnerError> {
let Some(event_type_text) = message.get("eventType").and_then(Value::as_str) else {
return Ok(None);
};
let event_type: GeneratedAcpxSidecarEventType =
serde_json::from_value(Value::String(event_type_text.to_owned())).map_err(|_| {
LocalRunnerError::invalid("ACPX sidecar emitted an unclassified event type")
})?;
let sequence = message
.get("sequence")
.and_then(Value::as_u64)
.filter(|value| *value > 0)
.ok_or_else(|| LocalRunnerError::invalid("ACPX sidecar event omitted its sequence"))?;
if sequence <= self.last_sequence {
return Ok(None);
}
if sequence != self.last_sequence + 1 {
return Err(LocalRunnerError::invalid(
"ACPX sidecar event sequence has a gap",
));
}
self.last_sequence = sequence;
let event_run_id = message.get("runId").and_then(Value::as_str);
if let Some(event_run_id) = event_run_id {
if event_run_id != self.config.run_id {
return Err(LocalRunnerError::invalid(
"ACPX sidecar event named a stale run",
));
}
} else if !matches!(
event_type,
GeneratedAcpxSidecarEventType::RuntimeProcess
| GeneratedAcpxSidecarEventType::RuntimeDiagnostic
) {
return Err(LocalRunnerError::invalid(
"ACPX sidecar event omitted its run binding",
));
}
if matches!(
event_type,
GeneratedAcpxSidecarEventType::RuntimeToolCalled
| GeneratedAcpxSidecarEventType::RuntimePermissionRequested
| GeneratedAcpxSidecarEventType::RuntimeTurnTerminal
| GeneratedAcpxSidecarEventType::RuntimeEvent
) {
let event_turn_id = message
.get("turnId")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
LocalRunnerError::invalid("ACPX sidecar event omitted its turn binding")
})?;
if self.active_turn_id.as_deref() != Some(event_turn_id) {
return Err(LocalRunnerError::invalid(
"ACPX sidecar event named a stale turn",
));
}
}
let payload = message.get("payload").cloned().unwrap_or_else(|| json!({}));
match event_type {
GeneratedAcpxSidecarEventType::RuntimeToolCalled => {
let call_id = required_text(&payload, "callId", "tool call")?;
let operation_id = required_text(&payload, "operationId", "tool operation")?;
let input = payload.get("input").cloned().unwrap_or(Value::Null);
let turn_id = message
.get("turnId")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
if self
.pending_tools
.insert(
call_id.clone(),
PendingTool {
operation_id: operation_id.clone(),
input: input.clone(),
turn_id,
},
)
.is_some()
{
return Err(LocalRunnerError::invalid(
"ACPX reused a pending tool call id",
));
}
Ok(Some(ProviderEvent::ToolCall {
call_id,
operation_id,
input,
}))
}
GeneratedAcpxSidecarEventType::RuntimePermissionRequested => {
Ok(Some(ProviderEvent::RuntimeRequest {
request_id: required_text(&payload, "requestId", "permission request")?,
request_kind: permission_kind(payload.get("kind").and_then(Value::as_str)),
title: payload
.get("title")
.and_then(Value::as_str)
.unwrap_or("ACP permission request")
.to_owned(),
details: payload,
}))
}
GeneratedAcpxSidecarEventType::RuntimeTurnTerminal => {
let turn_id = message
.get("turnId")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
if self.active_turn_id.as_deref() == Some(turn_id.as_str()) {
self.active_turn_id = None;
}
let status = payload
.get("status")
.and_then(Value::as_str)
.unwrap_or("failed");
let terminal = ProviderEvent::Notification {
method: "turn/completed".to_owned(),
params: json!({ "turn": { "id": turn_id, "status": status }, "acpx": payload }),
};
if self.assistant_text.is_empty() {
Ok(Some(terminal))
} else {
let text = std::mem::take(&mut self.assistant_text);
self.inbox.push_back(terminal);
Ok(Some(ProviderEvent::Notification {
method: "item/completed".to_owned(),
params: json!({ "item": { "id": format!("acpx-message-{turn_id}"), "type": "agentMessage", "text": text } }),
}))
}
}
GeneratedAcpxSidecarEventType::RuntimeEvent => {
if payload.get("type").and_then(Value::as_str) == Some("text_delta") {
self.assistant_text.push_str(
payload
.get("text")
.and_then(Value::as_str)
.unwrap_or_default(),
);
}
Ok(Some(map_runtime_event(payload, self.provider_requests)))
}
GeneratedAcpxSidecarEventType::RuntimeProcess => {
Ok(Some(ProviderEvent::Notification {
method: "acpx/process".to_owned(),
params: payload,
}))
}
GeneratedAcpxSidecarEventType::RuntimeDiagnostic => {
Ok(Some(ProviderEvent::Notification {
method: "acpx/diagnostic".to_owned(),
params: payload,
}))
}
}
}
}
impl Provider for AcpxProvider {
fn kind(&self) -> ProviderKind {
ProviderKind::Acpx
}
fn runtime_identity(&self) -> ProviderRuntimeIdentity {
ProviderRuntimeIdentity::LocalProcess {
process_id: self.process.id(),
provider_session_id: self.agent_session_id.clone(),
}
}
fn session_identity(&self) -> &str {
&self.acpx_record_id
}
fn provider_session_id(&self) -> Option<&str> {
Some(&self.agent_session_id)
}
fn durable_session_identity(&self) -> Option<ProviderSessionIdentity> {
Some(ProviderSessionIdentity::Acpx {
normalized_session_id: self.config.normalized_session_id.clone(),
acpx_record_id: self.acpx_record_id.clone(),
backend_session_id: self.backend_session_id.clone(),
agent_session_id: self.agent_session_id.clone(),
profile_digest: self.config.command_digest.clone(),
workspace_digest: format!("sha256:{:x}", Sha256::digest(self.config.cwd.as_bytes())),
requested_model: self.config.model.clone(),
effective_model: self.config.model.clone(),
})
}
fn attach_run(
&mut self,
run_id: &str,
tools: Vec<AuthorizedTool>,
) -> Result<(), LocalRunnerError> {
if self.active_turn_id.is_some() || !self.pending_tools.is_empty() {
return Err(LocalRunnerError::invalid(
"cannot attach an ACPX run while work is active",
));
}
self.catalog_revision += 1;
self.tools = tools;
let previous_run_id = std::mem::replace(&mut self.config.run_id, run_id.to_owned());
let attached = self.request(
GeneratedAcpxSidecarCommand::RunAttach,
json!({
"runId": run_id,
"catalogRevision": self.catalog_revision,
"tools": self.tools,
}),
);
if let Err(error) = attached {
self.config.run_id = previous_run_id;
return Err(error);
}
Ok(())
}
fn configure_tools(&mut self, tools: Vec<AuthorizedTool>) -> Result<(), LocalRunnerError> {
let run_id = self.config.run_id.clone();
self.attach_run(&run_id, tools)
}
fn start_turn(
&mut self,
message: &str,
cwd: &str,
turn_id: &str,
) -> Result<Value, LocalRunnerError> {
if cwd != self.config.cwd {
return Err(LocalRunnerError::invalid(
"ACPX turn cwd differs from its immutable workspace",
));
}
if self.active_turn_id.is_some() {
return Err(LocalRunnerError::invalid("ACPX already has an active turn"));
}
self.active_turn_id = Some(turn_id.to_owned());
self.assistant_text.clear();
self.provider_requests += 1;
match self.request(
GeneratedAcpxSidecarCommand::TurnStart,
json!({ "turnId": turn_id, "message": message }),
) {
Ok(response) => Ok(response),
Err(error) => {
if self.active_turn_id.as_deref() == Some(turn_id) {
self.active_turn_id = None;
}
self.provider_requests = self.provider_requests.saturating_sub(1);
Err(error)
}
}
}
fn interrupt_turn(&mut self, turn_id: &str) -> Result<Value, LocalRunnerError> {
self.request(
GeneratedAcpxSidecarCommand::TurnCancel,
json!({ "turnId": turn_id, "reason": "Paperclip interruption" }),
)
}
fn read(&mut self) -> Result<Value, LocalRunnerError> {
self.request(GeneratedAcpxSidecarCommand::SessionRead, json!({}))
}
fn poll(&mut self) -> Result<Option<ProviderEvent>, LocalRunnerError> {
if let Some(event) = self.inbox.pop_front() {
return Ok(Some(event));
}
let Some(line) = self.process.receive_stdout_line(Duration::from_millis(1))? else {
return if self.process.try_wait()?.is_some() {
Ok(Some(ProviderEvent::Exited))
} else {
Ok(None)
};
};
let frame = parse_frame(&line)?;
self.map_frame(&frame)
}
fn deliver_tool_result(&mut self, result: &ToolResult) -> Result<(), LocalRunnerError> {
let pending = self
.pending_tools
.get(&result.call_id)
.cloned()
.ok_or_else(|| {
LocalRunnerError::invalid("ACPX tool result has no pending sidecar call")
})?;
if pending.operation_id != result.operation_id {
return Err(LocalRunnerError::invalid(
"ACPX tool result operation mismatch",
));
}
self.request(GeneratedAcpxSidecarCommand::ToolResolve, json!({
"callId": result.call_id,
"turnId": pending.turn_id,
"result": result.result,
"error": if result.is_error { json!({"message":"Paperclip semantic operation failed"}) } else { Value::Null },
}))?;
self.pending_tools.remove(&result.call_id);
if !result.is_error
&& ["paperclip_finish", "paperclip_block"].contains(&result.operation_id.as_str())
{
self.inbox.push_back(ProviderEvent::SemanticResult {
result: pending.input,
item_id: Some(result.call_id.clone()),
});
}
Ok(())
}
fn resolve_runtime_request(
&mut self,
request_id: &str,
turn_id: &str,
resolution: &Value,
) -> Result<(), LocalRunnerError> {
let action = resolution
.get("action")
.and_then(Value::as_str)
.unwrap_or("cancel");
let outcome = match action {
"accept" => "allow_once",
"accept_for_session" => "allow_always",
"decline" => "reject_once",
"cancel" => "cancel",
_ => {
return Err(LocalRunnerError::invalid(
"unsupported ACPX permission resolution",
))
}
};
self.request(
GeneratedAcpxSidecarCommand::PermissionResolve,
json!({
"requestId": request_id,
"turnId": turn_id,
"decision": { "outcome": outcome },
}),
)?;
Ok(())
}
fn shutdown(&mut self) -> Result<(), LocalRunnerError> {
if self.active_turn_id.is_none() {
let _ = self.request(
GeneratedAcpxSidecarCommand::SessionSuspend,
json!({ "reason": "Paperclip runner suspension" }),
);
}
self.process.terminate_group().map(|_| ())
}
}
fn validate_config(config: &AcpxProviderConfig) -> Result<(), LocalRunnerError> {
let (server_package, server_version, runtime_package, runtime_version, model, command_digest) =
match config.agent.as_str() {
"pi" => (
"pi-acp",
"0.0.33",
Some("@earendil-works/pi-coding-agent"),
Some("0.84.2"),
"openrouter/deepseek/deepseek-v4-flash-0731",
"sha256:896d0f734998529087bc2af0854112b2064c83047c4bc59359420510abd14791",
),
"claude" => (
"@agentclientprotocol/claude-agent-acp",
"0.70.0",
None,
None,
"claude-sonnet-5",
"sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
),
"codex" => (
"@agentclientprotocol/codex-acp",
"1.6.2",
None,
None,
"gpt-5.6-sol",
"sha256:8c7fc8af156596668a95ce23d52309f70ad576e75bac6dc209d30378bdbb8ebe",
),
_ => {
return Err(LocalRunnerError::invalid(
"ACPX agent must be pi, claude, or codex",
))
}
};
if config.acpx_version != "0.13.1"
|| config.agent_server_package != server_package
|| config.agent_server_version != server_version
|| config.agent_runtime_package.as_deref() != runtime_package
|| config.agent_runtime_version.as_deref() != runtime_version
|| config.model != model
|| config.permission_policy != "interactive"
|| config.command_digest != command_digest
|| config.normalized_session_id.is_empty()
|| config.run_id.is_empty()
|| config.cwd.is_empty()
{
return Err(LocalRunnerError::invalid(
"ACPX config does not match a qualified profile",
));
}
Ok(())
}
fn parse_frame(line: &str) -> Result<Value, LocalRunnerError> {
let value: Value = serde_json::from_str(line).map_err(|error| {
LocalRunnerError::invalid(format!("ACPX sidecar emitted invalid JSON: {error}"))
})?;
if value.get("protocolVersion").and_then(Value::as_u64)
!= Some(GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION)
{
return Err(LocalRunnerError::invalid(
"ACPX sidecar protocol version mismatch",
));
}
Ok(value)
}
fn required_text(value: &Value, key: &str, label: &str) -> Result<String, LocalRunnerError> {
value
.get(key)
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_owned)
.ok_or_else(|| LocalRunnerError::invalid(format!("ACPX sidecar omitted {label} identity")))
}
fn permission_kind(value: Option<&str>) -> String {
let kind = value.unwrap_or_default().to_ascii_lowercase();
if kind.contains("write")
|| kind.contains("edit")
|| kind.contains("delete")
|| kind.contains("move")
{
"file_approval".to_owned()
} else if kind.contains("execute") || kind.contains("terminal") {
"command_approval".to_owned()
} else {
"permission_approval".to_owned()
}
}
fn map_runtime_event(payload: Value, provider_requests: u64) -> ProviderEvent {
match payload
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"text_delta" => ProviderEvent::Notification {
method: "item/delta".to_owned(),
params: json!({
"itemId": payload.get("messageId").and_then(Value::as_str).unwrap_or("acpx-agent-message"),
"delta": payload.get("text").and_then(Value::as_str).unwrap_or_default(),
"authoritative": true,
}),
},
"thinking" => ProviderEvent::Notification {
method: "item/started".to_owned(),
params: json!({ "item": { "type": "reasoning", "status": "inProgress" } }),
},
"status" if payload.get("tag").and_then(Value::as_str) == Some("usage_update") => {
let breakdown = payload
.get("breakdown")
.cloned()
.unwrap_or_else(|| json!({}));
let provider_cost_usd = payload
.pointer("/cost/amount")
.and_then(Value::as_f64)
.unwrap_or(0.0);
ProviderEvent::Notification {
method: "thread/tokenUsage/updated".to_owned(),
params: json!({
"tokenUsage": { "total": {
"inputTokens": breakdown.get("inputTokens"),
"outputTokens": breakdown.get("outputTokens"),
"cacheReadTokens": breakdown.get("cachedReadTokens"),
"cacheWriteTokens": breakdown.get("cachedWriteTokens"),
"reasoningTokens": breakdown.get("thoughtTokens"),
"providerCostUsd": provider_cost_usd,
"requests": provider_requests,
}},
}),
}
}
"tool_call" => {
let completed = matches!(
payload.get("status").and_then(Value::as_str),
Some("completed" | "failed" | "cancelled" | "canceled")
);
ProviderEvent::Notification {
method: if completed {
"item/completed"
} else {
"item/started"
}
.to_owned(),
params: json!({ "item": {
"id": payload.get("toolCallId"),
"type": "commandExecution",
"command": payload.get("title"),
"status": payload.get("status"),
"locations": payload.get("locations"),
"aggregatedOutput": payload.get("output"),
"outputBytes": payload.get("outputBytes"),
"outputTruncated": payload.get("outputTruncated"),
"outputDigest": payload.get("outputDigest"),
}}),
}
}
"provider_notice" => ProviderEvent::Notification {
method: "warning".to_owned(),
params: json!({
"code": payload.get("category").and_then(Value::as_str).unwrap_or("unclassified_acp_update"),
"message": payload.get("summary").and_then(Value::as_str).unwrap_or("The qualified ACP agent emitted an unclassified runtime update."),
}),
},
_ => ProviderEvent::Notification {
method: "warning".to_owned(),
params: json!({
"code": "unclassified_acp_runtime_event",
"message": "The ACPX sidecar emitted an unclassified bounded runtime event.",
}),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_unqualified_profiles() {
let config = AcpxProviderConfig {
agent: "pi".to_owned(),
model: "fallback".to_owned(),
acpx_version: "0.13.1".to_owned(),
agent_server_package: "pi-acp".to_owned(),
agent_server_version: "0.0.33".to_owned(),
agent_runtime_package: Some("@earendil-works/pi-coding-agent".to_owned()),
agent_runtime_version: Some("0.84.2".to_owned()),
command_digest: "sha256:test".to_owned(),
sidecar_command: "node".into(),
sidecar_args: vec![],
runtime_directory: "/tmp/acpx".to_owned(),
normalized_session_id: "session".to_owned(),
run_id: "run".to_owned(),
cwd: "/tmp/workspace".to_owned(),
instructions: "safe".to_owned(),
permission_policy: "interactive".to_owned(),
};
assert!(validate_config(&config).is_err());
}
#[test]
fn maps_thought_without_retaining_hidden_text() {
let event = map_runtime_event(
json!({ "type": "thinking", "text": "secret chain of thought" }),
1,
);
match event {
ProviderEvent::Notification { params, .. } => {
assert!(!params.to_string().contains("secret"))
}
_ => panic!("expected notification"),
}
}
}
File diff suppressed because it is too large. Load diff
@@ -26,6 +26,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
linger_after_turn_start: args.iter().any(|arg| arg == "--linger-after-turn-start"),
};
let mut turn_count = 0_u64;
let mut planning_thread = false;
let mut pending_tool_turns = BTreeMap::<String, String>::new();
let stdin = io::stdin();
for line in stdin.lock().lines() {
@@ -41,6 +42,10 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
"initialize" => {
send(json!({"id": id, "result": {"user": {"sessionId": "fake-session"}}}))?
}
"collaborationMode/list" => send(json!({
"id": id,
"result": {"data": [{"name": "Plan", "mode": "plan", "model": "gpt-test", "reasoning_effort": "high"}]}
}))?,
"thread/start" | "thread/resume" => {
let tools = request
.pointer("/params/dynamicTools")
@@ -53,10 +58,13 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
return Err("authorized tool was not registered".into());
}
let config = request.pointer("/params/config").and_then(Value::as_object);
planning_thread = config
.and_then(|value| value.get("include_collaboration_mode_instructions"))
.and_then(Value::as_bool)
== Some(true);
for key in [
"skills.include_instructions",
"include_apps_instructions",
"include_collaboration_mode_instructions",
"features.apps",
"features.plugins",
"features.multi_agent",
@@ -89,6 +97,25 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
}))?;
}
"turn/start" => {
if planning_thread {
let collaboration_mode = request.pointer("/params/collaborationMode");
if collaboration_mode
.and_then(|value| value.get("mode"))
.and_then(Value::as_str)
!= Some("plan")
|| collaboration_mode
.and_then(|value| value.pointer("/settings/model"))
.and_then(Value::as_str)
!= Some("gpt-test")
|| !collaboration_mode
.and_then(|value| value.pointer("/settings/developer_instructions"))
.is_some_and(Value::is_null)
{
return Err(
"planning turn omitted the qualified collaboration mode payload".into(),
);
}
}
turn_count += 1;
let turn_id = if turn_count == 1 {
"fake-turn".to_owned()
@@ -11,11 +11,15 @@ use crate::provider_bridge::{AuthorizedTool, ToolResult};
pub const CODEX_APP_SERVER_MAX_FRAME_BYTES: usize = 4 * 1024 * 1024;
fn skillless_thread_config() -> Value {
fn default_collaboration_mode() -> String {
"default".to_owned()
}
fn skillless_thread_config(collaboration_mode: &str) -> Value {
json!({
"skills.include_instructions": false,
"include_apps_instructions": false,
"include_collaboration_mode_instructions": false,
"include_collaboration_mode_instructions": collaboration_mode == "plan",
"features.apps": false,
"features.plugins": false,
"features.multi_agent": false,
@@ -31,6 +35,8 @@ pub enum ProviderKind {
Codex,
Opencode,
ClaudeManaged,
AwsAgentcore,
Acpx,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
@@ -44,6 +50,8 @@ pub struct LocalProviderConfig {
pub cwd: String,
pub model: Option<String>,
pub instructions: String,
#[serde(default = "default_collaboration_mode")]
pub collaboration_mode: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
@@ -58,10 +66,57 @@ pub struct ClaudeManagedProviderConfig {
pub max_session_list_cost_usd: f64,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AwsAgentCoreProviderConfig {
pub model: String,
pub profile_id: String,
pub region: String,
pub account_id: String,
pub harness_arn: String,
pub harness_version: String,
pub endpoint_arn: String,
pub endpoint_qualifier: String,
pub agent_runtime_arn: String,
pub memory_arn: String,
pub memory_id: String,
pub invocation_role_arn: String,
pub qualification_revision: String,
pub event_expiry_days: u16,
pub max_estimated_session_cost_usd: f64,
pub max_iterations: u32,
pub max_output_tokens: u32,
pub timeout_seconds: u32,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AcpxProviderConfig {
pub agent: String,
pub model: String,
pub acpx_version: String,
pub agent_server_package: String,
pub agent_server_version: String,
pub agent_runtime_package: Option<String>,
pub agent_runtime_version: Option<String>,
pub command_digest: String,
pub sidecar_command: PathBuf,
#[serde(default)]
pub sidecar_args: Vec<String>,
pub runtime_directory: String,
pub normalized_session_id: String,
pub run_id: String,
pub cwd: String,
pub instructions: String,
pub permission_policy: String,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ProviderConfig {
Local(LocalProviderConfig),
ClaudeManaged(ClaudeManagedProviderConfig),
AwsAgentcore(AwsAgentCoreProviderConfig),
Acpx(AcpxProviderConfig),
}
impl ProviderConfig {
@@ -69,6 +124,8 @@ impl ProviderConfig {
match self {
Self::Local(config) => config.kind,
Self::ClaudeManaged(_) => ProviderKind::ClaudeManaged,
Self::AwsAgentcore(_) => ProviderKind::AwsAgentcore,
Self::Acpx(_) => ProviderKind::Acpx,
}
}
@@ -76,6 +133,8 @@ impl ProviderConfig {
match self {
Self::Local(config) => Some(&config.cwd),
Self::ClaudeManaged(_) => None,
Self::AwsAgentcore(_) => None,
Self::Acpx(config) => Some(&config.cwd),
}
}
}
@@ -98,6 +157,22 @@ impl Serialize for ProviderConfig {
);
value.serialize(serializer)
}
Self::AwsAgentcore(config) => {
let mut value = serde_json::to_value(config).map_err(serde::ser::Error::custom)?;
value
.as_object_mut()
.expect("AgentCore config serializes as an object")
.insert("kind".to_owned(), Value::String("aws_agentcore".to_owned()));
value.serialize(serializer)
}
Self::Acpx(config) => {
let mut value = serde_json::to_value(config).map_err(serde::ser::Error::custom)?;
value
.as_object_mut()
.expect("ACPX config serializes as an object")
.insert("kind".to_owned(), Value::String("acpx".to_owned()));
value.serialize(serializer)
}
}
}
}
@@ -117,6 +192,22 @@ impl<'de> Deserialize<'de> for ProviderConfig {
serde_json::from_value(value)
.map(Self::ClaudeManaged)
.map_err(serde::de::Error::custom)
} else if kind == "aws_agentcore" {
value
.as_object_mut()
.expect("provider config must be an object")
.remove("kind");
serde_json::from_value(value)
.map(Self::AwsAgentcore)
.map_err(serde::de::Error::custom)
} else if kind == "acpx" {
value
.as_object_mut()
.expect("provider config must be an object")
.remove("kind");
serde_json::from_value(value)
.map(Self::Acpx)
.map_err(serde::de::Error::custom)
} else {
serde_json::from_value(value)
.map(Self::Local)
@@ -141,17 +232,53 @@ pub enum ProviderRuntimeIdentity {
},
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum ProviderSessionIdentity {
#[serde(rename = "acpx")]
Acpx {
normalized_session_id: String,
acpx_record_id: String,
backend_session_id: String,
agent_session_id: String,
profile_digest: String,
workspace_digest: String,
requested_model: String,
effective_model: String,
},
}
pub trait Provider {
fn kind(&self) -> ProviderKind;
fn runtime_identity(&self) -> ProviderRuntimeIdentity;
fn session_identity(&self) -> &str;
fn provider_session_id(&self) -> Option<&str>;
fn durable_session_identity(&self) -> Option<ProviderSessionIdentity> {
None
}
fn durable_event_cursor(&self) -> Option<&str> {
None
}
fn configure_tools(&mut self, _tools: Vec<AuthorizedTool>) -> Result<(), LocalRunnerError> {
Ok(())
}
fn attach_run(
&mut self,
_run_id: &str,
tools: Vec<AuthorizedTool>,
) -> Result<(), LocalRunnerError> {
self.configure_tools(tools)
}
fn resolve_runtime_request(
&mut self,
_request_id: &str,
_turn_id: &str,
_resolution: &Value,
) -> Result<(), LocalRunnerError> {
Err(LocalRunnerError::invalid(
"provider does not support runtime request resolution",
))
}
fn increase_budget(&mut self, _max_list_cost_usd: f64) -> Result<Value, LocalRunnerError> {
Err(LocalRunnerError::invalid(
"provider does not support a remote session budget",
@@ -190,6 +317,12 @@ pub enum ProviderEvent {
result: Value,
item_id: Option<String>,
},
RuntimeRequest {
request_id: String,
request_kind: String,
title: String,
details: Value,
},
Exited,
}
@@ -200,6 +333,8 @@ pub struct CodexProvider {
thread_id: String,
session_id: Option<String>,
pending_tool_requests: BTreeMap<String, Value>,
collaboration_mode: String,
collaboration_mode_payload: Option<Value>,
}
impl CodexProvider {
@@ -224,6 +359,8 @@ impl CodexProvider {
thread_id: String::new(),
session_id: None,
pending_tool_requests: BTreeMap::new(),
collaboration_mode: config.collaboration_mode.clone(),
collaboration_mode_payload: None,
};
let initialized = provider.request("initialize", json!({
"clientInfo": { "name": "paperclip-runnerd", "title": "Paperclip Runner", "version": "1" },
@@ -239,6 +376,16 @@ impl CodexProvider {
})
})
.collect::<Vec<_>>();
if config.collaboration_mode != "default" && config.collaboration_mode != "plan" {
return Err(LocalRunnerError::invalid(
"unsupported Codex collaboration mode",
));
}
let permission_profile = if config.collaboration_mode == "plan" {
"paperclip-runner-workspace-read-only"
} else {
"paperclip-runner-workspace-only"
};
let opened = if let Some(thread_id) = resume_thread_id {
provider.request(
"thread/resume",
@@ -247,9 +394,9 @@ impl CodexProvider {
"cwd": config.cwd,
"model": config.model,
"approvalPolicy": "never",
"permissions": "paperclip-runner-workspace-only",
"permissions": permission_profile,
"runtimeWorkspaceRoots": [config.cwd],
"config": skillless_thread_config(),
"config": skillless_thread_config(&config.collaboration_mode),
"baseInstructions": config.instructions,
"dynamicTools": dynamic_tools,
"experimentalRawEvents": true,
@@ -263,9 +410,9 @@ impl CodexProvider {
"cwd": config.cwd,
"model": config.model,
"approvalPolicy": "never",
"permissions": "paperclip-runner-workspace-only",
"permissions": permission_profile,
"runtimeWorkspaceRoots": [config.cwd],
"config": skillless_thread_config(),
"config": skillless_thread_config(&config.collaboration_mode),
"baseInstructions": config.instructions,
"dynamicTools": dynamic_tools,
"experimentalRawEvents": true,
@@ -273,6 +420,43 @@ impl CodexProvider {
}),
)?
};
if config.collaboration_mode == "plan" {
let presets = provider
.request("collaborationMode/list", json!({}))
.map_err(|error| {
LocalRunnerError::invalid(format!(
"planning_mode_unsupported: Codex collaborationMode/list failed: {error}"
))
})?;
let preset = presets
.get("data")
.and_then(Value::as_array)
.and_then(|items| {
items.iter().find(|item| item.get("mode").and_then(Value::as_str) == Some("plan"))
})
.ok_or_else(|| LocalRunnerError::invalid(
"planning_mode_unsupported: installed Codex app-server did not advertise a native plan preset",
))?;
let model = preset
.get("model")
.and_then(Value::as_str)
.or_else(|| opened.get("model").and_then(Value::as_str))
.or(config.model.as_deref())
.filter(|value| !value.is_empty())
.ok_or_else(|| {
LocalRunnerError::invalid(
"planning_mode_unsupported: native plan preset did not resolve a model",
)
})?;
provider.collaboration_mode_payload = Some(json!({
"mode": "plan",
"settings": {
"model": model,
"reasoning_effort": preset.get("reasoning_effort").cloned().unwrap_or(Value::Null),
"developer_instructions": Value::Null,
}
}));
}
provider.thread_id = opened
.pointer("/thread/id")
.and_then(Value::as_str)
@@ -302,16 +486,25 @@ impl CodexProvider {
pub fn start_turn(&mut self, message: &str, cwd: &str) -> Result<Value, LocalRunnerError> {
let thread_id = self.thread_id.clone();
self.request(
"turn/start",
json!({
"threadId": thread_id,
"cwd": cwd,
"permissions": "paperclip-runner-workspace-only",
"runtimeWorkspaceRoots": [cwd],
"input": [{"type": "text", "text": message, "text_elements": []}],
}),
)
let permission_profile = if self.collaboration_mode == "plan" {
"paperclip-runner-workspace-read-only"
} else {
"paperclip-runner-workspace-only"
};
let mut params = json!({
"threadId": thread_id,
"cwd": cwd,
"permissions": permission_profile,
"runtimeWorkspaceRoots": [cwd],
"input": [{"type": "text", "text": message, "text_elements": []}],
});
if let Some(collaboration_mode) = self.collaboration_mode_payload.clone() {
params
.as_object_mut()
.expect("turn parameters are an object")
.insert("collaborationMode".to_owned(), collaboration_mode);
}
self.request("turn/start", params)
}
pub fn interrupt_turn(&mut self, turn_id: &str) -> Result<Value, LocalRunnerError> {
@@ -231,6 +231,7 @@ pub fn run_durable_runner(
}
let mut revoke_deadline: Option<Instant> = None;
let mut revoke_control_drained = false;
let disconnected = loop {
poll_provider(&mut provider, &mut state, &store, &config)?;
if started.elapsed() >= config.max_runtime && !state.active_turn {
@@ -254,7 +255,10 @@ pub fn run_durable_runner(
if provider.is_some() {
send_outbox(&mut client, &state, &mut sent_source_seq)?;
}
if (state.stop_after_flush || state.lifecycle == "revoked") && state.outbox.is_empty() {
if (state.stop_after_flush || state.lifecycle == "revoked")
&& state.outbox.is_empty()
&& (state.lifecycle != "revoked" || revoke_control_drained || revoke_deadline.is_none())
{
if let Some(mut runtime) = provider.take() {
runtime.shutdown().map_err(|error| {
DurableRunnerError::invalid(format!(
@@ -275,6 +279,10 @@ pub fn run_durable_runner(
return Ok(());
}
if revoke_deadline.is_some_and(|deadline| Instant::now() >= deadline) {
if state.outbox.is_empty() {
revoke_control_drained = true;
continue;
}
state.record_diagnostic(
"revocation flush deadline elapsed; unacked durable events remain preserved",
);
@@ -362,6 +370,10 @@ pub fn run_durable_runner(
store.save(&state)?;
break true;
}
// A revoked connection remains open until the bounded drain
// deadline even after the final command result is written. A
// successful socket write is not a peer acknowledgement, and
// exiting here can race the controller's durable commit.
}
Err(error) => {
state.record_diagnostic(error.to_string());
@@ -500,8 +512,49 @@ fn start_configured_provider(
})?
},
),
crate::codex_provider::ProviderConfig::AwsAgentcore(mut agentcore) => Box::new({
if let Some(value) = state.provider_budget_ceiling_usd {
agentcore.max_estimated_session_cost_usd = value;
}
crate::aws_agentcore_provider::AwsAgentCoreHarnessProvider::start(
&agentcore,
tools,
resume_thread_id.as_deref(),
state.provider_event_cursor.as_deref(),
)
.map_err(|error| {
DurableRunnerError::invalid(format!(
"failed to start AWS AgentCore provider: {error}"
))
})?
}),
crate::codex_provider::ProviderConfig::Acpx(acpx) => Box::new(
crate::acpx_provider::AcpxProvider::start(
&acpx,
tools,
state.provider_session_identity.as_ref(),
).map_err(|error| {
DurableRunnerError::invalid(format!("failed to start ACPX provider: {error}"))
})?,
),
};
if let Some(expected) = state.provider_session_id.as_deref() {
if expected != runtime.session_identity() {
return Err(DurableRunnerError::invalid(
"provider recovery returned a different durable session identity",
));
}
}
let durable_identity = runtime.durable_session_identity();
if let Some(expected) = state.provider_session_identity.as_ref() {
if Some(expected) != durable_identity.as_ref() {
return Err(DurableRunnerError::invalid(
"provider recovery returned a different tagged session identity",
));
}
}
state.provider_session_id = Some(runtime.session_identity().to_owned());
state.provider_session_identity = durable_identity;
Ok(Some(runtime))
}
@@ -510,6 +563,8 @@ fn provider_kind_name(kind: crate::codex_provider::ProviderKind) -> &'static str
crate::codex_provider::ProviderKind::Codex => "codex",
crate::codex_provider::ProviderKind::Opencode => "opencode",
crate::codex_provider::ProviderKind::ClaudeManaged => "claude_managed",
crate::codex_provider::ProviderKind::AwsAgentcore => "aws_agentcore",
crate::codex_provider::ProviderKind::Acpx => "acpx",
}
}
@@ -529,6 +584,40 @@ fn provider_descriptor(
"providerSessionId": runtime.and_then(|item| item.provider_session_id()),
"processId": Value::Null,
}),
Some(crate::codex_provider::ProviderConfig::AwsAgentcore(value)) => json!({
"provider": "aws_agentcore",
"driver": "aws_agentcore_harness_api",
"model": value.model,
"executionKind": "remote_service",
"providerVersion": value.harness_version,
"service": "aws_bedrock_agentcore_harness",
"providerSessionId": runtime.and_then(|item| item.provider_session_id()),
"processId": Value::Null,
"endpointArn": value.endpoint_arn,
"endpointQualifier": value.endpoint_qualifier,
"memoryId": value.memory_id,
"eventExpiryDays": value.event_expiry_days,
}),
Some(crate::codex_provider::ProviderConfig::Acpx(value)) => json!({
"provider": "acpx",
"driver": "acpx_runtime",
"agent": value.agent,
"model": value.model,
"requestedModel": value.model,
"executionKind": "local_process",
"providerVersion": value.acpx_version,
"acpProtocolVersion": 1,
"agentServerPackage": value.agent_server_package,
"agentServerVersion": value.agent_server_version,
"agentRuntimePackage": value.agent_runtime_package,
"agentRuntimeVersion": value.agent_runtime_version,
"providerSessionId": runtime.and_then(|item| item.provider_session_id()),
"acpxRecordId": runtime.map(|item| item.session_identity()),
"processId": match runtime_identity {
Some(crate::codex_provider::ProviderRuntimeIdentity::LocalProcess { process_id, .. }) => Some(process_id),
_ => None,
},
}),
Some(crate::codex_provider::ProviderConfig::Local(value)) => json!({
"provider": provider_kind_name(value.kind),
"driver": if value.kind == crate::codex_provider::ProviderKind::Opencode { "opencode_server" } else { "codex_app_server" },
@@ -571,12 +660,16 @@ fn process_command_and_provider(
if !fresh
&& processed.status == "completed"
&& command.get("type").and_then(Value::as_str) == Some("semantic_tool.result")
&& provider.as_ref().is_some_and(|runtime| runtime.kind() == crate::codex_provider::ProviderKind::ClaudeManaged)
&& provider.as_ref().is_some_and(|runtime| matches!(
runtime.kind(),
crate::codex_provider::ProviderKind::ClaudeManaged
| crate::codex_provider::ProviderKind::AwsAgentcore
))
{
let result: crate::provider_bridge::ToolResult = serde_json::from_value(command["payload"].clone())
.map_err(|error| DurableRunnerError::invalid(format!("semantic tool result is invalid: {error}")))?;
provider.as_mut().expect("checked above").deliver_tool_result(&result)
.map_err(|error| DurableRunnerError::invalid(format!("failed to reconcile Claude Agent tool result: {error}")))?;
.map_err(|error| DurableRunnerError::invalid(format!("failed to reconcile remote provider tool result: {error}")))?;
}
return Ok(processed);
}
@@ -654,9 +747,15 @@ fn process_command_and_provider(
})?;
}
Some("run.attach") => {
if !state.pending_provider_runtime_requests.is_empty() {
return Err(DurableRunnerError::invalid(
"cannot attach a run while a provider runtime request is pending",
));
}
if let Some(runtime) = provider.as_mut() {
runtime
.configure_tools(
.attach_run(
&state.run_id.clone(),
state
.provider_tool_bridge
.authorized_tools()
@@ -670,6 +769,63 @@ fn process_command_and_provider(
})?;
}
}
Some("request.resolve") => {
let request_id = command
.pointer("/payload/requestId")
.and_then(Value::as_str)
.ok_or_else(|| {
DurableRunnerError::invalid("request.resolve payload.requestId is required")
})?;
let turn_id = command
.pointer("/payload/turnId")
.and_then(Value::as_str)
.unwrap_or(state.turn_id.as_str());
let resolution = command
.pointer("/payload/resolution")
.unwrap_or(&command["payload"]);
if matches!(
state.provider_config,
Some(crate::codex_provider::ProviderConfig::Acpx(_))
) {
let pending = state
.pending_provider_runtime_requests
.get(request_id)
.ok_or_else(|| {
DurableRunnerError::invalid(
"request.resolve named a stale or expired ACPX permission request",
)
})?;
if pending.turn_id != turn_id {
return Err(DurableRunnerError::invalid(
"request.resolve named a stale ACPX permission turn",
));
}
}
provider
.as_mut()
.ok_or_else(|| DurableRunnerError::invalid("request.resolve requires a provider"))?
.resolve_runtime_request(request_id, turn_id, resolution)
.map_err(|error| {
DurableRunnerError::invalid(format!(
"failed to resolve provider runtime request: {error}"
))
})?;
state.pending_provider_runtime_requests.remove(request_id);
state.lifecycle = "active".to_owned();
enqueue_event(
state,
config,
"runtime_request.resolved",
0,
json!({
"requestId": request_id,
"turnId": turn_id,
"action": resolution.get("action"),
}),
Some(&state.item_id.clone()),
)?;
store.save(state)?;
}
Some("semantic_tool.result") => {
let result: crate::provider_bridge::ToolResult =
serde_json::from_value(command["payload"].clone()).map_err(|error| {
@@ -808,6 +964,7 @@ fn poll_provider(
Ok(None) => break,
Err(error) => {
let detail = error.to_string();
expire_acpx_permission_requests_after_provider_loss(state, config)?;
persist_provider_failure(state, store, &detail)?;
return Err(DurableRunnerError::invalid(format!(
"provider failed: {detail}"
@@ -897,6 +1054,14 @@ fn poll_provider(
"cumulative": cumulative,
"runDelta": run_delta,
})
} else if method == "acpx/process" {
json!({
"providerMethod": method,
"role": params.get("role").and_then(Value::as_str),
"pid": params.get("pid").and_then(Value::as_u64),
"processGroupId": params.get("processGroupId").and_then(Value::as_u64),
"startedAt": params.get("startedAt").and_then(Value::as_str),
})
} else if matches!(method.as_str(), "turn/started" | "item/started" | "item/delta" | "item/completed" | "turn/completed" | "provider/budgetReached") {
params.clone()
} else {
@@ -907,6 +1072,11 @@ fn poll_provider(
state.lifecycle = "waiting_input".to_owned();
}
if method == "turn/completed" {
cancel_pending_runtime_requests_on_terminal(
state,
config,
"provider_turn_became_terminal",
)?;
state.active_turn = false;
if state.lifecycle_mode == "warm" {
state.lifecycle = "warm_idle".to_owned();
@@ -937,7 +1107,51 @@ fn poll_provider(
)?;
store.save(state)?;
}
crate::codex_provider::ProviderEvent::RuntimeRequest {
request_id,
request_kind,
title,
details,
} => {
state.last_activity_at_unix_ms = current_unix_ms()?;
if state.pending_provider_runtime_requests.contains_key(&request_id) {
return Err(DurableRunnerError::invalid(
"provider reused a pending runtime request id",
));
}
state.pending_provider_runtime_requests.insert(
request_id.clone(),
PendingProviderRuntimeRequest {
request_id: request_id.clone(),
turn_id: state.turn_id.clone(),
request_kind: request_kind.clone(),
created_at_unix_ms: state.last_activity_at_unix_ms,
},
);
state.lifecycle = "waiting_input".to_owned();
enqueue_event(
state,
config,
"runtime_request.created",
0,
json!({
"request": {
"schema": "paperclip.runtime_request.v1",
"requestKind": request_kind,
"requestId": request_id,
"type": "permission",
"status": "pending",
"prompt": title,
"turnId": state.turn_id,
"details": details,
}
}),
Some(&state.item_id.clone()),
)?;
store.save(state)?;
}
crate::codex_provider::ProviderEvent::Exited => {
expire_acpx_permission_requests_after_provider_loss(state, config)?;
persist_provider_failure(state, store, "local provider exited unexpectedly")?;
return Err(DurableRunnerError::invalid(
"native_runner_process_exited: local provider exited unexpectedly",
@@ -956,6 +1170,8 @@ fn provider_model(config: &crate::codex_provider::ProviderConfig) -> Option<&str
match config {
crate::codex_provider::ProviderConfig::Local(value) => value.model.as_deref(),
crate::codex_provider::ProviderConfig::ClaudeManaged(value) => Some(value.model.as_str()),
crate::codex_provider::ProviderConfig::AwsAgentcore(value) => Some(value.model.as_str()),
crate::codex_provider::ProviderConfig::Acpx(value) => Some(value.model.as_str()),
}
}
@@ -1038,12 +1254,91 @@ fn should_suspend_for_idle_at(
{
return Ok(false);
}
if state.lifecycle == "waiting_input"
&& matches!(
state.provider_config,
Some(crate::codex_provider::ProviderConfig::Acpx(_))
)
{
return Ok(false);
}
let Some(timeout_ms) = state.idle_timeout_ms else {
return Ok(false);
};
Ok(now_unix_ms.saturating_sub(state.last_activity_at_unix_ms) >= timeout_ms)
}
fn expire_acpx_permission_requests_after_provider_loss(
state: &mut DurableRunnerState,
config: &DurableRunnerConfig,
) -> Result<(), DurableRunnerError> {
if !matches!(
state.provider_config,
Some(crate::codex_provider::ProviderConfig::Acpx(_))
) || state.pending_provider_runtime_requests.is_empty()
{
return Ok(());
}
let pending = std::mem::take(&mut state.pending_provider_runtime_requests);
for request in pending.into_values() {
enqueue_event(
state,
config,
"runtime_request.expired",
0,
json!({
"requestId": request.request_id,
"turnId": request.turn_id,
"requestKind": request.request_kind,
"reason": "acpx_provider_process_lost",
"replayAllowed": false,
}),
Some(&state.item_id.clone()),
)?;
}
state.active_turn = false;
enqueue_event(
state,
config,
"turn.failed",
0,
json!({
"turn": { "id": state.turn_id, "status": "failed" },
"error": {
"code": "acpx_permission_transport_lost",
"message": "The ACP process exited while permission was pending; the prompt and approval will not be replayed.",
"recoverable": true,
}
}),
Some(&state.item_id.clone()),
)?;
Ok(())
}
fn cancel_pending_runtime_requests_on_terminal(
state: &mut DurableRunnerState,
config: &DurableRunnerConfig,
reason: &str,
) -> Result<(), DurableRunnerError> {
let pending = std::mem::take(&mut state.pending_provider_runtime_requests);
for request in pending.into_values() {
enqueue_event(
state,
config,
"runtime_request.cancelled",
0,
json!({
"requestId": request.request_id,
"turnId": request.turn_id,
"requestKind": request.request_kind,
"reason": reason,
}),
Some(&state.item_id.clone()),
)?;
}
Ok(())
}
fn begin_automatic_suspend(
state: &mut DurableRunnerState,
store: &DurableStateStore,
@@ -1251,6 +1546,29 @@ mod tests {
assert!(!should_suspend_for_idle_at(&state, 1_299_999).unwrap());
assert!(should_suspend_for_idle_at(&state, 1_300_000).unwrap());
}
state.provider_config = Some(crate::codex_provider::ProviderConfig::Acpx(
crate::codex_provider::AcpxProviderConfig {
agent: "pi".to_owned(),
model: "openrouter/deepseek/deepseek-v4-flash-0731".to_owned(),
acpx_version: "0.13.1".to_owned(),
agent_server_package: "pi-acp".to_owned(),
agent_server_version: "0.0.33".to_owned(),
agent_runtime_package: Some("@earendil-works/pi-coding-agent".to_owned()),
agent_runtime_version: Some("0.84.2".to_owned()),
command_digest: "sha256:qualified".to_owned(),
sidecar_command: PathBuf::from("node"),
sidecar_args: Vec::new(),
runtime_directory: "/tmp/acpx".to_owned(),
normalized_session_id: "session".to_owned(),
run_id: "run".to_owned(),
cwd: "/tmp/workspace".to_owned(),
instructions: "safe".to_owned(),
permission_policy: "interactive".to_owned(),
},
));
state.lifecycle = "waiting_input".to_owned();
assert!(!should_suspend_for_idle_at(&state, 86_400_000).unwrap());
state.provider_config = None;
for lifecycle in ["active", "ready", "suspending", "suspended"] {
state.lifecycle = lifecycle.to_owned();
assert!(!should_suspend_for_idle_at(&state, 2_000_000).unwrap());
@@ -1261,6 +1579,60 @@ mod tests {
let _ = fs::remove_dir_all(root);
}
#[test]
fn acpx_process_loss_expires_pending_permission_without_replay() {
let root = temporary_root("acpx-permission-loss");
let runner_config = config(&root);
let store = DurableStateStore::new(&root).unwrap();
let (mut state, _) = store.load_or_create(&runner_config).unwrap();
state.provider_config = Some(crate::codex_provider::ProviderConfig::Acpx(
crate::codex_provider::AcpxProviderConfig {
agent: "pi".to_owned(),
model: "openrouter/deepseek/deepseek-v4-flash-0731".to_owned(),
acpx_version: "0.13.1".to_owned(),
agent_server_package: "pi-acp".to_owned(),
agent_server_version: "0.0.33".to_owned(),
agent_runtime_package: Some("@earendil-works/pi-coding-agent".to_owned()),
agent_runtime_version: Some("0.84.2".to_owned()),
command_digest: "sha256:qualified".to_owned(),
sidecar_command: PathBuf::from("node"),
sidecar_args: Vec::new(),
runtime_directory: "/tmp/acpx".to_owned(),
normalized_session_id: "session".to_owned(),
run_id: "run".to_owned(),
cwd: "/tmp/workspace".to_owned(),
instructions: "safe".to_owned(),
permission_policy: "interactive".to_owned(),
},
));
state.active_turn = true;
state.lifecycle = "waiting_input".to_owned();
state.pending_provider_runtime_requests.insert(
"permission-1".to_owned(),
PendingProviderRuntimeRequest {
request_id: "permission-1".to_owned(),
turn_id: state.turn_id.clone(),
request_kind: "file_approval".to_owned(),
created_at_unix_ms: 1,
},
);
expire_acpx_permission_requests_after_provider_loss(&mut state, &runner_config).unwrap();
assert!(state.pending_provider_runtime_requests.is_empty());
assert!(!state.active_turn);
assert!(state.outbox.iter().any(|event| {
event.envelope["payload"]["eventType"] == "runtime_request.expired"
&& event.envelope["payload"]["payload"]["replayAllowed"] == false
}));
assert!(state.outbox.iter().any(|event| {
event.envelope["payload"]["eventType"] == "turn.failed"
&& event.envelope["payload"]["payload"]["error"]["code"]
== "acpx_permission_transport_lost"
}));
let _ = fs::remove_dir_all(root);
}
#[test]
fn oversized_provider_frame_persists_a_recoverable_failure() {
let root = temporary_root("oversized-provider-frame");
@@ -156,6 +156,15 @@ pub struct ProcessedCommand {
pub result: Value,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PendingProviderRuntimeRequest {
pub request_id: String,
pub turn_id: String,
pub request_kind: String,
pub created_at_unix_ms: u64,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DurableRunnerState {
@@ -202,6 +211,8 @@ pub struct DurableRunnerState {
#[serde(default, alias = "codexProviderThreadId")]
pub provider_session_id: Option<String>,
#[serde(default)]
pub provider_session_identity: Option<crate::codex_provider::ProviderSessionIdentity>,
#[serde(default)]
pub provider_event_cursor: Option<String>,
#[serde(default)]
pub provider_usage_cumulative: Option<Value>,
@@ -209,6 +220,8 @@ pub struct DurableRunnerState {
pub provider_usage_run_baseline: Option<Value>,
#[serde(default)]
pub provider_budget_ceiling_usd: Option<f64>,
#[serde(default)]
pub pending_provider_runtime_requests: BTreeMap<String, PendingProviderRuntimeRequest>,
}
impl DurableRunnerState {
@@ -246,10 +259,12 @@ impl DurableRunnerState {
provider_tool_bridge: ProviderToolBridge::default(),
provider_config: None,
provider_session_id: None,
provider_session_identity: None,
provider_event_cursor: None,
provider_usage_cumulative: None,
provider_usage_run_baseline: None,
provider_budget_ceiling_usd: None,
pending_provider_runtime_requests: BTreeMap::new(),
}
}
@@ -1224,6 +1239,8 @@ fn execute_command_effect(
if state.provider_budget_ceiling_usd.is_none() {
if let ProviderConfig::ClaudeManaged(managed) = &provider {
state.provider_budget_ceiling_usd = Some(managed.max_session_list_cost_usd);
} else if let ProviderConfig::AwsAgentcore(agentcore) = &provider {
state.provider_budget_ceiling_usd = Some(agentcore.max_estimated_session_cost_usd);
}
}
state.provider_config = Some(provider);
@@ -1276,6 +1293,7 @@ fn execute_command_effect(
state.run_id = run_id;
state.turn_id = turn_id;
state.item_id = item_id;
state.stop_after_flush = false;
state.lifecycle = "ready".to_owned();
enqueue_event(
state,
@@ -0,0 +1,52 @@
// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.
pub const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION: u64 = 1;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GeneratedAcpxSidecarCommand {
Initialize,
SessionOpen,
RunAttach,
TurnStart,
TurnCancel,
PermissionResolve,
ToolResolve,
SessionRead,
SessionSnapshot,
SessionSuspend,
SessionClose,
}
impl GeneratedAcpxSidecarCommand {
pub const fn as_str(self) -> &'static str {
match self {
Self::Initialize => "initialize",
Self::SessionOpen => "session.open",
Self::RunAttach => "run.attach",
Self::TurnStart => "turn.start",
Self::TurnCancel => "turn.cancel",
Self::PermissionResolve => "permission.resolve",
Self::ToolResolve => "tool.resolve",
Self::SessionRead => "session.read",
Self::SessionSnapshot => "session.snapshot",
Self::SessionSuspend => "session.suspend",
Self::SessionClose => "session.close",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize)]
pub enum GeneratedAcpxSidecarEventType {
#[serde(rename = "runtime.event")]
RuntimeEvent,
#[serde(rename = "runtime.permission_requested")]
RuntimePermissionRequested,
#[serde(rename = "runtime.tool_called")]
RuntimeToolCalled,
#[serde(rename = "runtime.turn_terminal")]
RuntimeTurnTerminal,
#[serde(rename = "runtime.process")]
RuntimeProcess,
#[serde(rename = "runtime.diagnostic")]
RuntimeDiagnostic,
}
@@ -1,9 +1,12 @@
#![forbid(unsafe_code)]
pub mod acpx_provider;
pub mod aws_agentcore_provider;
pub mod claude_managed_provider;
pub mod codex_provider;
pub mod durable;
pub mod fake_harness;
pub mod generated_acpx_sidecar_contract;
pub mod local_runner;
pub mod process_supervisor;
pub mod provider_bridge;
@@ -228,6 +228,10 @@ impl SupervisedProcess {
"ALL_PROXY",
"SSL_CERT_DIR",
"OPENROUTER_API_KEY",
"ANTHROPIC_API_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
"OPENAI_API_KEY",
"CODEX_API_KEY",
"PAPERCLIP_OPENCODE_COMMAND",
"PAPERCLIP_OPENCODE_RUNTIME_DIR",
"PAPERCLIP_RUNNER_INSTANCE_ID",
@@ -86,13 +86,35 @@ pub fn canonical_provider_events(method: &str, params: &Value) -> Vec<(String, V
_ => "process",
};
let mut payload = json!({"schema":"paperclip.tool.execution.v1","executionId":item_id,"transport":transport,"operation":if kind == "commandExecution" {"execute"} else {"unknown"},"name":item.get("tool").or_else(|| item.get("command")).and_then(Value::as_str),"target":Value::Null,"namespace":item.get("server").and_then(Value::as_str),"readOnly":item.get("readOnlyHint").and_then(Value::as_bool),"status":status(text(item.get("status")), complete),"durationMs":item.get("durationMs").and_then(Value::as_u64),"exitCode":item.get("exitCode").and_then(Value::as_i64),"progress":Value::Null});
if let (Some(object), Value::Object(output)) = (
payload.as_object_mut(),
bounded_output(text(
if let Some(object) = payload.as_object_mut() {
if item.get("outputBytes").and_then(Value::as_u64).is_some()
&& item
.get("outputTruncated")
.and_then(Value::as_bool)
.is_some()
&& item.get("outputDigest").and_then(Value::as_str).is_some()
{
object.insert(
"output".to_owned(),
item.get("aggregatedOutput").cloned().unwrap_or(Value::Null),
);
object.insert(
"outputBytes".to_owned(),
item.get("outputBytes").cloned().unwrap_or(json!(0)),
);
object.insert(
"outputTruncated".to_owned(),
item.get("outputTruncated").cloned().unwrap_or(json!(false)),
);
object.insert(
"outputDigest".to_owned(),
item.get("outputDigest").cloned().unwrap_or(Value::Null),
);
} else if let Value::Object(output) = bounded_output(text(
item.get("aggregatedOutput").or_else(|| item.get("output")),
)),
) {
object.extend(output);
)) {
object.extend(output);
}
}
push(
&mut result,
@@ -7,6 +7,36 @@ use paperclip_runner_core::codex_provider::{
use paperclip_runner_core::provider_bridge::{AuthorizedTool, ToolResult};
use serde_json::json;
fn test_tool() -> AuthorizedTool {
AuthorizedTool {
operation_id: "get_task_context".to_owned(),
version: 1,
description: "Read task context.".to_owned(),
input_schema: json!({"type": "object"}),
response_schema: json!({"type": "object"}),
}
}
#[test]
fn codex_plan_mode_is_qualified_and_selected_on_turn_start() {
let mut provider = CodexProvider::start(
&LocalProviderConfig {
kind: ProviderKind::Codex,
command: PathBuf::from(env!("CARGO_BIN_EXE_fake-codex-app-server")),
args: Vec::new(),
cwd: "/tmp".to_owned(),
model: None,
instructions: "Author a plan without editing files.".to_owned(),
collaboration_mode: "plan".to_owned(),
},
vec![test_tool()].into_iter(),
None,
)
.unwrap();
provider.start_turn("Plan the work.", "/tmp").unwrap();
provider.shutdown().unwrap();
}
#[test]
fn codex_dynamic_tool_round_trips_through_the_rust_provider_boundary() {
let mut provider = CodexProvider::start(
@@ -17,6 +47,7 @@ fn codex_dynamic_tool_round_trips_through_the_rust_provider_boundary() {
cwd: "/tmp".to_owned(),
model: None,
instructions: "Use the authorized Paperclip tools.".to_owned(),
collaboration_mode: "default".to_owned(),
},
vec![AuthorizedTool {
operation_id: "get_task_context".to_owned(),
@@ -92,6 +123,7 @@ fn provider_contract_preserves_the_opencode_tag() {
cwd: "/tmp".to_owned(),
model: Some("openrouter/deepseek/deepseek-v4-flash-0731".to_owned()),
instructions: "Use the authorized Paperclip tools.".to_owned(),
collaboration_mode: "default".to_owned(),
},
vec![AuthorizedTool {
operation_id: "get_task_context".to_owned(),
@@ -118,6 +150,7 @@ fn image_like_prompt_stays_skillless_and_accepts_a_valid_one_point_two_megabyte_
cwd: "/tmp".to_owned(),
model: None,
instructions: "Use only the authorized Paperclip tools.".to_owned(),
collaboration_mode: "default".to_owned(),
},
vec![AuthorizedTool {
operation_id: "get_task_context".to_owned(),
@@ -176,6 +209,7 @@ fn codex_provider_rejects_an_event_above_the_four_megabyte_hard_limit() {
cwd: "/tmp".to_owned(),
model: None,
instructions: "Use only the authorized Paperclip tools.".to_owned(),
collaboration_mode: "default".to_owned(),
},
vec![AuthorizedTool {
operation_id: "get_task_context".to_owned(),
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import test from "node:test";
const templateUrl = new URL("../infra/aws-agentcore-paperclip.yaml", import.meta.url);
const wrapperUrl = new URL("./aws-agentcore.sh", import.meta.url);
test("AgentCore template has closed development/private resources and explicit command denial", async () => {
const source = await readFile(templateUrl, "utf8");
for (const resource of [
"AWS::BedrockAgentCore::Harness",
"AWS::BedrockAgentCore::Memory",
"AWS::EC2::VPC",
"com.amazonaws.${AWS::Region}.ecr.api",
"com.amazonaws.${AWS::Region}.ecr.dkr",
"com.amazonaws.${AWS::Region}.s3",
"com.amazonaws.${AWS::Region}.bedrock-runtime",
]) assert.match(source, new RegExp(resource.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.match(source, /AllowedValues: \[development, private\]/);
assert.match(source, /Effect: Deny\s+Action: bedrock-agentcore:InvokeAgentRuntimeCommand/g);
assert.doesNotMatch(source, /Effect: Allow\s+Action: bedrock-agentcore:InvokeAgentRuntimeCommand/);
assert.doesNotMatch(source, /AWS::EC2::NatGateway|AWS::EC2::InternetGateway/);
assert.match(source, /EventExpiryDuration: 90/);
assert.match(source, /MaxIterations: 8/);
assert.match(source, /MaxTokens: 4096/);
assert.match(source, /TimeoutSeconds: 300/);
assert.match(source, /Tools: \[\]/);
assert.match(source, /Skills: \[\]/);
assert.doesNotMatch(source, /BedrockModelResourceArn:\s+[\s\S]{0,100}Default: "\*"/);
assert.match(source, /BedrockFoundationModelResourceArn/);
});
test("AgentCore wrapper is valid shell and writes only nonsecret profile metadata", async () => {
execFileSync("bash", ["-n", wrapperUrl.pathname]);
const source = await readFile(wrapperUrl, "utf8");
const generatedBlock = source.slice(source.indexOf('"AWS_PROFILE=$AWS_PROFILE_NAME"'), source.indexOf('>"$tmp"'));
assert.ok(generatedBlock.length > 0);
assert.doesNotMatch(generatedBlock, /AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN|Authorization|X-Amz-Signature/);
assert.match(source, /chmod 600 "\$tmp"/);
assert.match(source, /cloudformation deploy/);
assert.match(source, /CAPABILITY_NAMED_IAM/);
assert.match(source, /--\) shift ;;/);
assert.match(source, /iam get-role --role-name "\$role_name"/);
assert.doesNotMatch(source, /printf 'arn:%s:iam::%s:role\/%s/);
assert.match(source, /existing_status.*ROLLBACK_COMPLETE/s);
assert.match(source, /cloudformation wait stack-delete-complete/);
assert.ok(source.indexOf("delete-harness-endpoint") < source.indexOf("cloudformation delete-stack"));
});
+344
View File
@@ -0,0 +1,344 @@
#!/usr/bin/env bash
set -euo pipefail
PACKAGE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$PACKAGE_DIR/infra/aws-agentcore-paperclip.yaml"
LOCAL_DIR="$PACKAGE_DIR/.paperclip-local"
ENV_FILE="$LOCAL_DIR/aws-agentcore.env"
ACTIVE_FILE="$LOCAL_DIR/aws-agentcore-lab.pid"
ACTION="${1:-}"
if [[ -n "$ACTION" ]]; then shift; fi
AWS_PROFILE_NAME="${AWS_PROFILE:-default}"
AWS_REGION_NAME="${AWS_REGION:-${AWS_DEFAULT_REGION:-us-east-1}}"
DEPLOYMENT_MODE="development"
STACK_NAME="paperclip-agentcore-development"
ENDPOINT_NAME="paperclip"
MODEL_ID="global.anthropic.claude-sonnet-4-6"
TRUSTED_PRINCIPAL=""
DRY_RUN=false
FORCE=false
YES=false
usage() {
printf '%s\n' \
"Usage: aws-agentcore.sh <provision|probe|lab|destroy> [options]" \
" --aws-profile NAME AWS CLI v2 profile (default: $AWS_PROFILE_NAME)" \
" --region REGION commercial AWS region (default: $AWS_REGION_NAME)" \
" --mode MODE development or private" \
" --stack-name NAME CloudFormation stack name" \
" --model MODEL_ID Bedrock-native model ID" \
" --principal ARN stable IAM role/user trusted to assume runner role" \
" --dry-run validate and print changes without deployment" \
" --yes confirm destructive teardown" \
" --force destroy even when a recorded Lab process is active"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--) shift ;;
--aws-profile) AWS_PROFILE_NAME="$2"; shift 2 ;;
--region) AWS_REGION_NAME="$2"; shift 2 ;;
--mode) DEPLOYMENT_MODE="$2"; shift 2 ;;
--stack-name) STACK_NAME="$2"; shift 2 ;;
--model) MODEL_ID="$2"; shift 2 ;;
--principal) TRUSTED_PRINCIPAL="$2"; shift 2 ;;
--dry-run) DRY_RUN=true; shift ;;
--force) FORCE=true; shift ;;
--yes) YES=true; shift ;;
-h|--help) usage; exit 0 ;;
*) printf 'Unknown option: %s\n' "$1" >&2; usage >&2; exit 2 ;;
esac
done
if [[ "$ACTION" != "provision" && "$ACTION" != "probe" && "$ACTION" != "lab" && "$ACTION" != "destroy" ]]; then
usage >&2
exit 2
fi
if [[ "$DEPLOYMENT_MODE" != "development" && "$DEPLOYMENT_MODE" != "private" ]]; then
printf 'Mode must be development or private.\n' >&2
exit 2
fi
if [[ ! "$AWS_PROFILE_NAME" =~ ^[A-Za-z0-9_.@+-]+$ ]]; then
printf 'AWS profile contains unsupported characters.\n' >&2
exit 2
fi
if [[ ! "$AWS_REGION_NAME" =~ ^[a-z]{2}(-gov)?-[a-z]+-[0-9]+$ || "$AWS_REGION_NAME" == cn-* || "$AWS_REGION_NAME" == us-gov-* ]]; then
printf 'This proof of concept currently supports commercial AWS regions only.\n' >&2
exit 2
fi
aws_cli() {
aws --profile "$AWS_PROFILE_NAME" --region "$AWS_REGION_NAME" --no-cli-pager "$@"
}
require_aws() {
command -v aws >/dev/null || { printf 'AWS CLI v2 is required.\n' >&2; exit 1; }
local version
version="$(aws --version 2>&1)"
[[ "$version" == aws-cli/2.* ]] || { printf 'AWS CLI v2 is required; found %s\n' "$version" >&2; exit 1; }
}
json_field() {
local key="$1"
node -e 'const fs=require("fs");const o=JSON.parse(fs.readFileSync(0,"utf8"));let v=o;for(const k of process.argv[1].split("."))v=v?.[k];if(v!==undefined&&v!==null)process.stdout.write(String(v));' "$key"
}
stack_output() {
local key="$1"
aws_cli cloudformation describe-stacks --stack-name "$STACK_NAME" \
--query "Stacks[0].Outputs[?OutputKey=='$key'].OutputValue | [0]" --output text
}
wait_for_harness_status() {
local harness_id="$1" harness_version="$2" status=""
for _ in {1..60}; do
status="$(aws_cli bedrock-agentcore-control get-harness --harness-id "$harness_id" --harness-version "$harness_version" --query status --output text 2>/dev/null || true)"
if [[ "$status" == "READY" ]]; then return 0; fi
if [[ "$status" == "FAILED" || "$status" == "CREATE_FAILED" || "$status" == "UPDATE_FAILED" ]]; then
printf 'Harness %s entered terminal status %s.\n' "$harness_id" "$status" >&2
return 1
fi
sleep 5
done
printf 'Timed out waiting for Harness %s (last status: %s).\n' "$harness_id" "${status:-unknown}" >&2
return 1
}
wait_for_memory_status() {
local memory_id="$1" status=""
for _ in {1..60}; do
status="$(aws_cli bedrock-agentcore-control get-memory --memory-id "$memory_id" --query status --output text 2>/dev/null || true)"
[[ "$status" == "ACTIVE" ]] && return 0
[[ "$status" == "FAILED" ]] && { printf 'Memory %s entered FAILED.\n' "$memory_id" >&2; return 1; }
sleep 5
done
printf 'Timed out waiting for Memory %s (last status: %s).\n' "$memory_id" "${status:-unknown}" >&2
return 1
}
wait_for_endpoint_status() {
local harness_id="$1" endpoint_name="$2" status=""
for _ in {1..60}; do
status="$(aws_cli bedrock-agentcore-control get-harness-endpoint --harness-id "$harness_id" --endpoint-name "$endpoint_name" --query status --output text 2>/dev/null || true)"
[[ "$status" == "READY" ]] && return 0
[[ "$status" == "FAILED" ]] && { printf 'Harness endpoint %s entered FAILED.\n' "$endpoint_name" >&2; return 1; }
sleep 5
done
printf 'Timed out waiting for Harness endpoint %s (last status: %s).\n' "$endpoint_name" "${status:-unknown}" >&2
return 1
}
normalize_principal() {
local arn="$1"
if [[ "$arn" =~ ^arn:(aws[^:]*):sts::([0-9]{12}):assumed-role/(.+)/[^/]+$ ]]; then
# STS omits an IAM role's path from assumed-role ARNs. This matters for
# Identity Center roles, whose canonical ARN lives under
# /aws-reserved/sso.amazonaws.com/. Resolve it instead of constructing a
# role ARN that may not exist.
local role_name canonical_arn
role_name="${BASH_REMATCH[3]##*/}"
canonical_arn="$(aws_cli iam get-role --role-name "$role_name" --query Role.Arn --output text)"
[[ "$canonical_arn" =~ ^arn:(aws[^:]*):iam::[0-9]{12}:role/.+$ ]] || {
printf 'Could not resolve the canonical IAM role ARN for %s.\n' "$role_name" >&2
exit 1
}
printf '%s\n' "$canonical_arn"
elif [[ "$arn" =~ ^arn:(aws[^:]*):iam::[0-9]{12}:(role|user)/.+$ ]]; then
printf '%s\n' "$arn"
else
printf 'The caller ARN cannot be normalized to a stable IAM role/user: %s\n' "$arn" >&2
exit 1
fi
}
load_local_env() {
[[ -f "$ENV_FILE" ]] || { printf 'Missing %s; run aws-agentcore:provision first.\n' "$ENV_FILE" >&2; exit 1; }
# This file is generated locally, mode 0600, and contains nonsecret metadata only.
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
AWS_PROFILE_NAME="${AWS_PROFILE:-$AWS_PROFILE_NAME}"
AWS_REGION_NAME="${AWS_REGION:-$AWS_REGION_NAME}"
STACK_NAME="${PAPERCLIP_AWS_AGENTCORE_STACK_NAME:-$STACK_NAME}"
}
assume_runner_role() {
local role_arn="$1"
local response access secret token
if ! response="$(aws_cli sts assume-role --role-arn "$role_arn" --role-session-name paperclip-agentcore-probe --duration-seconds 900 --output json 2>&1)"; then
local caller
caller="$(aws_cli sts get-caller-identity --query Arn --output text 2>/dev/null || printf '<caller-arn>')"
printf 'Cannot assume %s. Add this statement to the caller policy for %s:\n' "$role_arn" "$caller" >&2
printf '{"Effect":"Allow","Action":"sts:AssumeRole","Resource":"%s"}\n' "$role_arn" >&2
printf '%s\n' "$response" | sed -E 's/(ASIA|AKIA)[A-Z0-9]+/[REDACTED]/g' >&2
return 1
fi
access="$(printf '%s' "$response" | json_field Credentials.AccessKeyId)"
secret="$(printf '%s' "$response" | json_field Credentials.SecretAccessKey)"
token="$(printf '%s' "$response" | json_field Credentials.SessionToken)"
export AWS_ACCESS_KEY_ID="$access" AWS_SECRET_ACCESS_KEY="$secret" AWS_SESSION_TOKEN="$token"
unset AWS_PROFILE
}
probe() {
load_local_env
local profile_save="$AWS_PROFILE_NAME"
assume_runner_role "$PAPERCLIP_AWS_AGENTCORE_INVOCATION_ROLE_ARN"
AWS_PROFILE_NAME=""
aws --region "$AWS_REGION_NAME" --no-cli-pager bedrock-agentcore-control get-harness \
--harness-id "$PAPERCLIP_AWS_AGENTCORE_HARNESS_ID" \
--harness-version "$PAPERCLIP_AWS_AGENTCORE_HARNESS_VERSION" >/dev/null
aws --region "$AWS_REGION_NAME" --no-cli-pager bedrock-agentcore-control get-harness-endpoint \
--harness-id "$PAPERCLIP_AWS_AGENTCORE_HARNESS_ID" \
--endpoint-name "$PAPERCLIP_AWS_AGENTCORE_ENDPOINT_QUALIFIER" >/dev/null
aws --region "$AWS_REGION_NAME" --no-cli-pager bedrock-agentcore-control get-memory \
--memory-id "$PAPERCLIP_AWS_AGENTCORE_MEMORY_ID" --view full >/dev/null
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
AWS_PROFILE_NAME="$profile_save"
printf 'AWS AgentCore profile qualified: %s (%s, Harness %s, Memory %s; 90-day retention).\n' \
"$PAPERCLIP_AWS_AGENTCORE_PROFILE_ID" "$AWS_REGION_NAME" \
"$PAPERCLIP_AWS_AGENTCORE_HARNESS_VERSION" "$PAPERCLIP_AWS_AGENTCORE_MEMORY_ID"
}
provision() {
require_aws
local identity caller_arn account_id partition model_resource_arn foundation_model_id foundation_model_resource_arn
identity="$(aws_cli sts get-caller-identity --output json)"
caller_arn="$(printf '%s' "$identity" | json_field Arn)"
account_id="$(printf '%s' "$identity" | json_field Account)"
partition="$(printf '%s' "$caller_arn" | cut -d: -f2)"
model_resource_arn="arn:$partition:bedrock:$AWS_REGION_NAME:$account_id:inference-profile/$MODEL_ID"
foundation_model_id="$(printf '%s' "$MODEL_ID" | sed -E 's/^(global|us|eu|apac)\.//')"
foundation_model_resource_arn="arn:$partition:bedrock:*::foundation-model/$foundation_model_id"
if [[ -z "$TRUSTED_PRINCIPAL" ]]; then TRUSTED_PRINCIPAL="$(normalize_principal "$caller_arn")"; fi
if [[ "$DEPLOYMENT_MODE" == "private" ]]; then
aws_cli bedrock-agentcore-control list-harnesses --max-results 1 >/dev/null || {
printf 'Private mode is unavailable because AgentCore Harness is not enabled in %s.\n' "$AWS_REGION_NAME" >&2
exit 1
}
local services
services="$(aws_cli ec2 describe-vpc-endpoint-services --query 'ServiceNames' --output text)"
for service in "com.amazonaws.$AWS_REGION_NAME.ecr.api" "com.amazonaws.$AWS_REGION_NAME.ecr.dkr" "com.amazonaws.$AWS_REGION_NAME.s3" "com.amazonaws.$AWS_REGION_NAME.bedrock-runtime"; do
[[ " $services " == *" $service "* ]] || { printf 'Private mode is unavailable: missing VPC endpoint service %s\n' "$service" >&2; exit 1; }
done
fi
aws_cli cloudformation validate-template --template-body "file://$TEMPLATE" >/dev/null
printf 'Stack: %s\nRegion: %s\nMode: %s\nModel: %s\nTrusted principal: %s\n' \
"$STACK_NAME" "$AWS_REGION_NAME" "$DEPLOYMENT_MODE" "$MODEL_ID" "$TRUSTED_PRINCIPAL"
printf 'Expected charges: Bedrock model tokens, AgentCore Runtime active time, Memory storage/requests, and (private mode) VPC endpoints.\n'
if $DRY_RUN; then
printf 'Dry run complete; the template is syntactically valid and no resources were changed.\n'
return
fi
local existing_status
existing_status="$(aws_cli cloudformation describe-stacks --stack-name "$STACK_NAME" --query 'Stacks[0].StackStatus' --output text 2>/dev/null || true)"
if [[ "$existing_status" == "ROLLBACK_COMPLETE" ]]; then
printf 'Removing failed initial stack %s before retrying provisioning.\n' "$STACK_NAME"
aws_cli cloudformation delete-stack --stack-name "$STACK_NAME"
aws_cli cloudformation wait stack-delete-complete --stack-name "$STACK_NAME"
fi
aws_cli cloudformation deploy \
--template-file "$TEMPLATE" \
--stack-name "$STACK_NAME" \
--capabilities CAPABILITY_NAMED_IAM \
--parameter-overrides \
"EnvironmentName=development" "DeploymentMode=$DEPLOYMENT_MODE" \
"TrustedRunnerPrincipalArn=$TRUSTED_PRINCIPAL" "BedrockModelId=$MODEL_ID" \
"BedrockModelResourceArn=$model_resource_arn" \
"BedrockFoundationModelResourceArn=$foundation_model_resource_arn" \
--tags paperclip:owned=true paperclip:environment=development paperclip:cost-center=runner-lab
local harness_id harness_arn harness_version runtime_arn memory_id memory_arn role_arn
harness_id="$(stack_output HarnessId)"
harness_arn="$(stack_output HarnessArn)"
harness_version="$(stack_output HarnessVersion)"
runtime_arn="$(stack_output AgentRuntimeArn)"
memory_id="$(stack_output MemoryId)"
memory_arn="$(stack_output MemoryArn)"
role_arn="$(stack_output RunnerInvocationRoleArn)"
wait_for_harness_status "$harness_id" "$harness_version"
wait_for_memory_status "$memory_id"
if aws_cli bedrock-agentcore-control get-harness-endpoint --harness-id "$harness_id" --endpoint-name "$ENDPOINT_NAME" >/dev/null 2>&1; then
aws_cli bedrock-agentcore-control update-harness-endpoint --harness-id "$harness_id" --endpoint-name "$ENDPOINT_NAME" \
--target-version "$harness_version" --description "Paperclip pinned Harness endpoint" \
--client-token "paperclip-update-$harness_id-$harness_version" >/dev/null
else
aws_cli bedrock-agentcore-control create-harness-endpoint --harness-id "$harness_id" --endpoint-name "$ENDPOINT_NAME" \
--target-version "$harness_version" --description "Paperclip pinned Harness endpoint" \
--client-token "paperclip-create-$harness_id-$harness_version" \
--tags paperclip:owned=true,paperclip:environment=development >/dev/null
fi
wait_for_endpoint_status "$harness_id" "$ENDPOINT_NAME"
local endpoint_json endpoint_arn
endpoint_json="$(aws_cli bedrock-agentcore-control get-harness-endpoint --harness-id "$harness_id" --endpoint-name "$ENDPOINT_NAME" --output json)"
endpoint_arn="$(printf '%s' "$endpoint_json" | node -e 'const fs=require("fs");const o=JSON.parse(fs.readFileSync(0,"utf8"));process.stdout.write(o.harnessEndpointArn||o.endpointArn||o.arn||"")')"
[[ -n "$endpoint_arn" ]] || endpoint_arn="$harness_arn/endpoint/$ENDPOINT_NAME"
mkdir -p "$LOCAL_DIR"
umask 077
local tmp="$ENV_FILE.tmp.$$"
printf '%s\n' \
"AWS_PROFILE=$AWS_PROFILE_NAME" \
"AWS_REGION=$AWS_REGION_NAME" \
"AWS_DEFAULT_REGION=$AWS_REGION_NAME" \
"PAPERCLIP_AWS_AGENTCORE_STACK_NAME=$STACK_NAME" \
"PAPERCLIP_AWS_AGENTCORE_PROFILE_ID=$STACK_NAME" \
"PAPERCLIP_AWS_AGENTCORE_ACCOUNT_ID=$account_id" \
"PAPERCLIP_AWS_AGENTCORE_HARNESS_ARN=$harness_arn" \
"PAPERCLIP_AWS_AGENTCORE_HARNESS_ID=$harness_id" \
"PAPERCLIP_AWS_AGENTCORE_HARNESS_VERSION=$harness_version" \
"PAPERCLIP_AWS_AGENTCORE_ENDPOINT_ARN=$endpoint_arn" \
"PAPERCLIP_AWS_AGENTCORE_ENDPOINT_QUALIFIER=$ENDPOINT_NAME" \
"PAPERCLIP_AWS_AGENTCORE_RUNTIME_ARN=$runtime_arn" \
"PAPERCLIP_AWS_AGENTCORE_MEMORY_ARN=$memory_arn" \
"PAPERCLIP_AWS_AGENTCORE_MEMORY_ID=$memory_id" \
"PAPERCLIP_AWS_AGENTCORE_INVOCATION_ROLE_ARN=$role_arn" \
"PAPERCLIP_AWS_AGENTCORE_MODEL=$MODEL_ID" \
"PAPERCLIP_AWS_AGENTCORE_QUALIFICATION_REVISION=aws-agentcore-harness-v1" \
"PAPERCLIP_AWS_AGENTCORE_EVENT_EXPIRY_DAYS=90" >"$tmp"
chmod 600 "$tmp"
mv "$tmp" "$ENV_FILE"
probe
printf 'Wrote nonsecret local profile metadata to %s (0600).\n' "$ENV_FILE"
}
lab() {
load_local_env
if [[ -f "$ACTIVE_FILE" ]] && kill -0 "$(cat "$ACTIVE_FILE")" 2>/dev/null; then
printf 'Runner Lab is already active as PID %s.\n' "$(cat "$ACTIVE_FILE")" >&2
exit 1
fi
printf '%s\n' "$$" >"$ACTIVE_FILE"
chmod 600 "$ACTIVE_FILE"
trap 'rm -f "$ACTIVE_FILE"' EXIT INT TERM
cd "$PACKAGE_DIR"
pnpm console:issue-thread
}
destroy() {
require_aws
if [[ -f "$ACTIVE_FILE" ]] && kill -0 "$(cat "$ACTIVE_FILE")" 2>/dev/null && ! $FORCE; then
printf 'Runner Lab PID %s is active; stop it or pass --force.\n' "$(cat "$ACTIVE_FILE")" >&2
exit 1
fi
if ! $YES; then
printf 'Delete the pinned AgentCore endpoint and CloudFormation stack %s? [y/N] ' "$STACK_NAME"
read -r answer
[[ "$answer" == y || "$answer" == Y ]] || { printf 'Cancelled.\n'; return; }
fi
local harness_id
harness_id="$(stack_output HarnessId)"
aws_cli bedrock-agentcore-control delete-harness-endpoint --harness-id "$harness_id" --endpoint-name "$ENDPOINT_NAME" \
--client-token "paperclip-delete-$harness_id-$ENDPOINT_NAME" >/dev/null 2>&1 || true
aws_cli cloudformation delete-stack --stack-name "$STACK_NAME"
aws_cli cloudformation wait stack-delete-complete --stack-name "$STACK_NAME"
rm -f "$ENV_FILE" "$ACTIVE_FILE"
printf 'Deleted AgentCore endpoint and stack. Local profile metadata was removed.\n'
}
case "$ACTION" in
provision) provision ;;
probe) require_aws; probe ;;
lab) lab ;;
destroy) destroy ;;
esac
@@ -0,0 +1,127 @@
#!/usr/bin/env node
/** Live, two-turn AWS AgentCore smoke through the exact Runner Lab HTTP path. */
import { createServer } from "node:http";
import { once } from "node:events";
import { readFile } from "node:fs/promises";
import { createCapabilityCookieJar } from "./capability-cookie-jar.mjs";
import { createCapabilityIssueThreadMiddleware } from "./capability-issue-thread-server.mjs";
const { readCapabilityTurnStream, CAPABILITY_TURN_STREAM_ACCEPT } = await import(
new URL("../dist/live/index.js", import.meta.url).href
);
const envPath = new URL("../.paperclip-local/aws-agentcore.env", import.meta.url);
function assert(condition, message) {
if (!condition) throw new Error(`assertion failed: ${message}`);
}
function assistantText(view) {
return view.turns.flatMap((turn) => turn.items)
.filter((item) => item.kind === "agent_message")
.map((item) => item.body)
.join("\n").trim();
}
async function loadProfileEnvironment() {
let source;
try {
source = await readFile(envPath, "utf8");
} catch {
throw new Error("AWS AgentCore profile is missing; run aws-agentcore:provision first");
}
for (const line of source.split(/\r?\n/)) {
if (!line || line.startsWith("#")) continue;
const separator = line.indexOf("=");
if (separator < 1) throw new Error("generated AWS AgentCore profile contains an invalid line");
const key = line.slice(0, separator);
const value = line.slice(separator + 1);
if (!/^[A-Z][A-Z0-9_]*$/.test(key)) throw new Error("generated AWS AgentCore profile contains an invalid key");
process.env[key] = value;
}
}
async function main() {
await loadProfileEnvironment();
let providerFailure = null;
const middleware = createCapabilityIssueThreadMiddleware({
bindHost: "127.0.0.1",
onTurnError(error) { providerFailure = error; },
});
const server = createServer((request, response) => middleware(request, response, () => {
response.statusCode = 404;
response.end("not found");
}));
server.listen(0, "127.0.0.1");
await once(server, "listening");
const { port } = server.address();
const jar = createCapabilityCookieJar(`http://127.0.0.1:${port}`);
const post = async (path, body, accept) => {
const response = await jar.fetch(`/api/capability/ui${path}`, {
method: "POST",
headers: { "content-type": "application/json", ...(accept ? { accept } : {}) },
body: JSON.stringify(body),
});
if (!response.ok) throw new Error(`${path} failed with HTTP ${response.status}: ${await response.text()}`);
return response;
};
const turn = async (sessionId, message) => {
const response = await post("/message", { sessionId, message }, CAPABILITY_TURN_STREAM_ACCEPT);
try {
return await readCapabilityTurnStream(response, () => undefined);
} catch (error) {
if (providerFailure) throw new Error("AWS AgentCore provider turn failed", { cause: providerFailure });
throw error;
}
};
try {
const configuration = {
provider: "aws_agentcore",
model: process.env.PAPERCLIP_AWS_AGENTCORE_MODEL,
agentCoreProfileId: process.env.PAPERCLIP_AWS_AGENTCORE_PROFILE_ID,
maxEstimatedSessionCostUsd: 1,
lifecyclePolicy: { mode: "warm", idleTimeoutMs: 300_000 },
};
const opened = await (await post("/cleanroom/session", configuration)).json();
assert(opened.configuration?.provider === "aws_agentcore", "the AWS provider was selected without fallback");
assert(opened.view.identity.agentLabel === "Real AWS AgentCore", "the live AWS identity is visible");
assert(opened.runtime?.providerPid === null, "a remote provider never reports a provider PID");
const first = await turn(opened.sessionId, "Reply with exactly: AgentCore first response");
assert(assistantText(first.view).length > 0, "the first AWS response is nonempty");
assert(first.runtime?.providerSessionId, "the first response carries a runtimeSessionId");
const second = await turn(opened.sessionId, "Reply with exactly: AgentCore second response");
assert(assistantText(second.view).length > assistantText(first.view).length, "the second AWS response is visible");
assert(second.runtime?.providerSessionId === first.runtime.providerSessionId, "both turns reuse one AgentCore session");
const read = await (await post("/tool", {
sessionId: opened.sessionId,
operationId: "get_task_context",
input: {},
})).json();
assert(read.toolResult && typeof read.toolResult === "object", "the mock Paperclip read operation succeeded");
const serialized = JSON.stringify({ opened, first, second, read });
for (const pattern of [/AWS_ACCESS_KEY_ID/i, /AWS_SECRET_ACCESS_KEY/i, /AWS_SESSION_TOKEN/i, /X-Amz-Signature/i, /Authorization/i]) {
assert(!pattern.test(serialized), `browser payload excludes ${pattern}`);
}
process.stdout.write(`${JSON.stringify({
schema: "paperclip.capability.aws-agentcore-smoke.v1",
sessionId: opened.sessionId,
runtimeSessionId: second.runtime.providerSessionId,
model: configuration.model,
turns: second.view.turns.length,
mockRead: true,
assertions: { realProvider: true, twoResponses: true, sessionContinuity: true, noCredentialLeak: true },
}, null, 2)}\n`);
} finally {
await middleware.close();
server.close();
}
}
await main();
@@ -39,6 +39,11 @@ const MAX_MESSAGE_BYTES = 8 * 1024;
* chatty provider cannot turn one turn into unbounded socket writes.
*/
const MAX_TURN_STREAM_FRAMES = 600;
const ACPX_QUALIFIED_MODELS = Object.freeze({
pi: "openrouter/deepseek/deepseek-v4-flash-0731",
claude: "claude-sonnet-5",
codex: "gpt-5.6-sol",
});
/**
* Per-browser session capability (track 7U).
@@ -204,8 +209,8 @@ class RouteError extends Error {
function harnessConfiguration(source, fallbackModel) {
const provider = source.provider === undefined ? "codex" : String(source.provider).trim();
if (provider !== "codex" && provider !== "opencode" && provider !== "claude_managed") {
throw new RouteError(400, "invalid_provider", "Provider must be codex, opencode, or claude_managed.");
if (provider !== "codex" && provider !== "opencode" && provider !== "claude_managed" && provider !== "aws_agentcore" && provider !== "acpx") {
throw new RouteError(400, "invalid_provider", "Provider must be codex, opencode, claude_managed, aws_agentcore, or acpx.");
}
const rawModel = source.model === undefined ? fallbackModel : source.model;
const model = rawModel === undefined || rawModel === null ? "" : String(rawModel).trim();
@@ -216,9 +221,25 @@ function harnessConfiguration(source, fallbackModel) {
if (provider === "claude_managed" && !model) {
throw new RouteError(400, "invalid_model", "Claude Agent requires a model value.");
}
const managedProfileId = source.managedProfileId === undefined || source.managedProfileId === null
if (provider === "aws_agentcore" && !model) {
throw new RouteError(400, "invalid_model", "AWS AgentCore requires a Bedrock model value.");
}
const acpxAgent = source.acpxAgent === undefined ? "pi" : String(source.acpxAgent).trim();
if (provider === "acpx") {
if (!(acpxAgent in ACPX_QUALIFIED_MODELS)) {
throw new RouteError(400, "invalid_acpx_agent", "ACPX agent must be pi, claude, or codex.");
}
if (model !== ACPX_QUALIFIED_MODELS[acpxAgent]) {
throw new RouteError(400, "invalid_model", `The qualified ACPX ${acpxAgent} profile requires exact model ${ACPX_QUALIFIED_MODELS[acpxAgent]}.`);
}
}
const requestedManagedProfileId = source.managedProfileId === undefined || source.managedProfileId === null
? "default"
: String(source.managedProfileId).trim();
const configuredManagedProfileId = process.env.PAPERCLIP_CLAUDE_MANAGED_PROFILE_ID?.trim();
const managedProfileId = provider === "claude_managed" && requestedManagedProfileId === "default" && configuredManagedProfileId
? configuredManagedProfileId
: requestedManagedProfileId;
const maxSessionListCostUsd = source.maxSessionListCostUsd === undefined || source.maxSessionListCostUsd === null
? 1
: Number(source.maxSessionListCostUsd);
@@ -228,6 +249,22 @@ function harnessConfiguration(source, fallbackModel) {
if (provider === "claude_managed" && (!Number.isFinite(maxSessionListCostUsd) || maxSessionListCostUsd <= 0)) {
throw new RouteError(400, "invalid_spend_cap", "Claude Agent requires a positive session spend ceiling.");
}
const requestedAgentCoreProfileId = source.agentCoreProfileId === undefined || source.agentCoreProfileId === null
? "default"
: String(source.agentCoreProfileId).trim();
const configuredAgentCoreProfileId = process.env.PAPERCLIP_AWS_AGENTCORE_PROFILE_ID?.trim();
const agentCoreProfileId = provider === "aws_agentcore" && requestedAgentCoreProfileId === "default" && configuredAgentCoreProfileId
? configuredAgentCoreProfileId
: requestedAgentCoreProfileId;
const maxEstimatedSessionCostUsd = source.maxEstimatedSessionCostUsd === undefined || source.maxEstimatedSessionCostUsd === null
? 1
: Number(source.maxEstimatedSessionCostUsd);
if (provider === "aws_agentcore" && !agentCoreProfileId) {
throw new RouteError(400, "invalid_agentcore_profile", "AWS AgentCore requires a qualified profile ID.");
}
if (provider === "aws_agentcore" && (!Number.isFinite(maxEstimatedSessionCostUsd) || maxEstimatedSessionCostUsd <= 0)) {
throw new RouteError(400, "invalid_spend_cap", "AWS AgentCore requires a positive estimated session ceiling.");
}
const suppliedLifecycle = source.lifecyclePolicy && typeof source.lifecyclePolicy === "object"
? source.lifecyclePolicy
: source;
@@ -253,11 +290,45 @@ function harnessConfiguration(source, fallbackModel) {
return {
provider,
model: model || null,
...(provider === "acpx" ? { acpxAgent } : {}),
...(provider === "claude_managed" ? { managedProfileId, maxSessionListCostUsd } : {}),
...(provider === "aws_agentcore" ? { agentCoreProfileId, maxEstimatedSessionCostUsd } : {}),
lifecyclePolicy,
};
}
function resolveAgentCoreProfile(configuration) {
if (configuration.provider !== "aws_agentcore") return undefined;
const required = (name) => {
const value = process.env[name]?.trim();
if (!value) throw new RouteError(503, "agentcore_profile_unavailable", `AWS AgentCore profile is missing ${name}. Run aws-agentcore:provision and aws-agentcore:lab.`);
return value;
};
const profileId = required("PAPERCLIP_AWS_AGENTCORE_PROFILE_ID");
if (profileId !== configuration.agentCoreProfileId) {
throw new RouteError(400, "agentcore_profile_not_found", "The selected AWS AgentCore profile is not configured on this Runner Lab server.");
}
return {
profileId,
region: required("AWS_REGION"),
accountId: required("PAPERCLIP_AWS_AGENTCORE_ACCOUNT_ID"),
harnessArn: required("PAPERCLIP_AWS_AGENTCORE_HARNESS_ARN"),
harnessVersion: required("PAPERCLIP_AWS_AGENTCORE_HARNESS_VERSION"),
endpointArn: required("PAPERCLIP_AWS_AGENTCORE_ENDPOINT_ARN"),
endpointQualifier: required("PAPERCLIP_AWS_AGENTCORE_ENDPOINT_QUALIFIER"),
agentRuntimeArn: required("PAPERCLIP_AWS_AGENTCORE_RUNTIME_ARN"),
memoryArn: required("PAPERCLIP_AWS_AGENTCORE_MEMORY_ARN"),
memoryId: required("PAPERCLIP_AWS_AGENTCORE_MEMORY_ID"),
invocationRoleArn: required("PAPERCLIP_AWS_AGENTCORE_INVOCATION_ROLE_ARN"),
qualificationRevision: required("PAPERCLIP_AWS_AGENTCORE_QUALIFICATION_REVISION"),
eventExpiryDays: 90,
maxEstimatedSessionCostUsd: configuration.maxEstimatedSessionCostUsd,
maxIterations: 8,
maxOutputTokens: 4096,
timeoutSeconds: 300,
};
}
function resolveManagedProfile(configuration) {
if (configuration.provider !== "claude_managed") return undefined;
const profileId = process.env.PAPERCLIP_CLAUDE_MANAGED_PROFILE_ID?.trim() || configuration.managedProfileId;
@@ -355,7 +426,11 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
const provider = snapshot.config.provider ?? "codex";
const expectedAgentLabel = provider === "claude_managed"
? "Claude Agent"
: provider === "opencode" ? "Real OpenCode" : "Real Codex";
: provider === "aws_agentcore" ? "Real AWS AgentCore"
: provider === "opencode" ? "Real OpenCode"
: provider === "acpx"
? `Real ${snapshot.config.acpxAgent === "claude" ? "Claude" : snapshot.config.acpxAgent === "codex" ? "Codex" : "Pi"} via ACPX`
: "Real Codex";
const projected = view(runner, entry);
if (
projected.mode !== "live"
@@ -464,6 +539,10 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
...(configuration.provider === "claude_managed"
? { managedProfile: resolveManagedProfile(configuration) }
: {}),
...(configuration.provider === "aws_agentcore"
? { agentCoreProfile: resolveAgentCoreProfile(configuration) }
: {}),
...(configuration.provider === "acpx" ? { acpxAgent: configuration.acpxAgent } : {}),
lifecyclePolicy: configuration.lifecyclePolicy,
...(configuration.model === null ? {} : { requestedModel: configuration.model }),
});
@@ -497,6 +576,10 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
managedProfileId: snapshot.config.managedProfile.profileId,
maxSessionListCostUsd: snapshot.config.managedProfile.maxSessionListCostUsd,
}),
...(snapshot.config.agentCoreProfile === undefined ? {} : {
agentCoreProfileId: snapshot.config.agentCoreProfile.profileId,
maxEstimatedSessionCostUsd: snapshot.config.agentCoreProfile.maxEstimatedSessionCostUsd,
}),
lifecyclePolicy: snapshot.config.lifecyclePolicy ?? { mode: "per_turn", idleTimeoutMs: null },
};
if (
@@ -504,6 +587,8 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
&& (entry.configuration.provider !== configuration.provider
|| entry.configuration.model !== configuration.model
|| entry.configuration.managedProfileId !== configuration.managedProfileId
|| entry.configuration.agentCoreProfileId !== configuration.agentCoreProfileId
|| entry.configuration.maxEstimatedSessionCostUsd !== configuration.maxEstimatedSessionCostUsd
|| entry.configuration.maxSessionListCostUsd !== configuration.maxSessionListCostUsd
|| JSON.stringify(entry.configuration.lifecyclePolicy) !== JSON.stringify(configuration.lifecyclePolicy))
) {
@@ -520,6 +605,24 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
limits: { maxTurns: MAX_TURNS_PER_SESSION, maxMessageBytes: MAX_MESSAGE_BYTES },
turns: entry.turns,
configuration,
runtime: {
providerSessionId: snapshot.providerSessionId ?? null,
driverSessionId: snapshot.providerThreadId ?? null,
runnerPid: snapshot.process?.runnerPid ?? null,
providerPid: configuration.provider === "claude_managed" || configuration.provider === "aws_agentcore"
? null
: snapshot.process?.providerPid ?? snapshot.process?.codexPid ?? null,
sidecarPid: snapshot.process?.sidecarPid ?? null,
agentPid: snapshot.process?.agentPid ?? null,
providerVersion: snapshot.process?.providerVersion ?? null,
agentServerVersion: snapshot.process?.agentServerVersion ?? null,
agentRuntimeVersion: snapshot.process?.agentRuntimeVersion ?? null,
acpProtocolVersion: snapshot.process?.acpProtocolVersion ?? null,
executionKind: configuration.provider === "claude_managed" || configuration.provider === "aws_agentcore"
? "remote_service"
: "local_process",
status: snapshot.status,
},
view: liveView(runner, entry),
};
}
@@ -669,8 +772,11 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
const requestedHarness = harnessConfiguration({
provider: body.provider ?? url.searchParams.get("provider") ?? undefined,
model: body.model ?? url.searchParams.get("model") ?? undefined,
acpxAgent: body.acpxAgent ?? url.searchParams.get("acpxAgent") ?? undefined,
managedProfileId: body.managedProfileId ?? url.searchParams.get("managedProfileId") ?? undefined,
maxSessionListCostUsd: body.maxSessionListCostUsd ?? url.searchParams.get("maxSessionListCostUsd") ?? undefined,
agentCoreProfileId: body.agentCoreProfileId ?? url.searchParams.get("agentCoreProfileId") ?? undefined,
maxEstimatedSessionCostUsd: body.maxEstimatedSessionCostUsd ?? url.searchParams.get("maxEstimatedSessionCostUsd") ?? undefined,
lifecyclePolicy: body.lifecyclePolicy,
lifecycleMode: body.lifecycleMode ?? url.searchParams.get("lifecycleMode") ?? undefined,
idleTimeoutMs: body.idleTimeoutMs ?? url.searchParams.get("idleTimeoutMs") ?? undefined,
@@ -829,6 +935,7 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
seed,
workingDirectory: forkDirectory,
provider: snapshot.config.provider ?? "codex",
...(snapshot.config.acpxAgent === undefined ? {} : { acpxAgent: snapshot.config.acpxAgent }),
scenario: snapshot.config.scenario,
capabilities: snapshot.config.capabilities,
explicitClaims: snapshot.config.explicitClaims,
@@ -839,6 +946,12 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
...(snapshot.config.requestedModel === undefined
? {}
: { requestedModel: snapshot.config.requestedModel }),
...(snapshot.config.managedProfile === undefined
? {}
: { managedProfile: snapshot.config.managedProfile }),
...(snapshot.config.agentCoreProfile === undefined
? {}
: { agentCoreProfile: snapshot.config.agentCoreProfile }),
});
} catch (error) {
await rm(forkDirectory, { recursive: true, force: true }).catch(() => undefined);
@@ -901,7 +1014,8 @@ export function createCapabilityIssueThreadMiddleware(options = {}) {
throw new RouteError(400, "invalid_spend_cap", "The new managed-session spend ceiling must be positive.");
}
await entry.session.increaseManagedSessionBudget(nextCap);
if (entry.configuration) entry.configuration.maxSessionListCostUsd = nextCap;
if (entry.configuration?.provider === "claude_managed") entry.configuration.maxSessionListCostUsd = nextCap;
if (entry.configuration?.provider === "aws_agentcore") entry.configuration.maxEstimatedSessionCostUsd = nextCap;
} else if (route === "managed-session-delete") {
if (body.confirm !== true) {
throw new RouteError(400, "confirmation_required", "Remote session deletion requires explicit confirmation.");
@@ -0,0 +1,29 @@
import { readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const schema = JSON.parse(await readFile(resolve(root, "protocol/schemas/acpx-sidecar.schema.json"), "utf8"));
const commands = schema.$defs.command.enum;
const events = schema.$defs.eventType.enum;
const quote = (value) => JSON.stringify(value);
const rustVariant = (value) => value.split(/[._-]/).map((part) => part[0].toUpperCase() + part.slice(1)).join("");
const typescript = `// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.\n\nexport const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION = 1 as const;\nexport const GENERATED_ACPX_SIDECAR_COMMANDS = [\n${commands.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\nexport type GeneratedAcpxSidecarCommand = (typeof GENERATED_ACPX_SIDECAR_COMMANDS)[number];\n\nexport const GENERATED_ACPX_SIDECAR_EVENT_TYPES = [\n${events.map((value) => ` ${quote(value)},`).join("\n")}\n] as const;\nexport type GeneratedAcpxSidecarEventType = (typeof GENERATED_ACPX_SIDECAR_EVENT_TYPES)[number];\n`;
const rust = `// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.\n\npub const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION: u64 = 1;\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq)]\npub enum GeneratedAcpxSidecarCommand {\n${commands.map((value) => ` ${rustVariant(value)},`).join("\n")}\n}\n\nimpl GeneratedAcpxSidecarCommand {\n pub const fn as_str(self) -> &'static str {\n match self {\n${commands.map((value) => ` Self::${rustVariant(value)} => ${quote(value)},`).join("\n")}\n }\n }\n}\n\n#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize)]\npub enum GeneratedAcpxSidecarEventType {\n${events.map((value) => ` #[serde(rename = ${quote(value)})]\n ${rustVariant(value)},`).join("\n")}\n}\n`;
const outputs = [
[resolve(root, "src/drivers/acpx/generated-sidecar-contract.ts"), typescript],
[resolve(root, "runner/crates/runner-core/src/generated_acpx_sidecar_contract.rs"), rust],
];
if (process.argv.includes("--check")) {
const stale = [];
for (const [path, content] of outputs) if (await readFile(path, "utf8").catch(() => "") !== content) stale.push(path);
if (stale.length > 0) {
process.stderr.write(`Generated ACPX sidecar contracts are stale: ${stale.join(", ")}\n`);
process.exitCode = 1;
} else process.stdout.write("Generated TypeScript/Rust ACPX sidecar contracts match the schema.\n");
} else {
await Promise.all(outputs.map(([path, content]) => writeFile(path, content)));
process.stdout.write("Generated TypeScript/Rust ACPX sidecar contracts.\n");
}
@@ -0,0 +1,30 @@
import { createCodexTaskEnvelope } from "../contracts/codex.js";
import type { NativeExecutionInput } from "../contracts/native-execution.js";
import type { NativeSessionBackend } from "../contracts/native-session-backend.js";
import { AcpxRuntimeDriver, type AcpxRuntimeDriverOptions } from "../drivers/acpx/acpx-runtime-driver.js";
import { HarnessDriverBackend } from "./harness-driver-backend.js";
export function createAcpxNativeSessionBackend(
input: NativeExecutionInput,
options: Omit<AcpxRuntimeDriverOptions, "agent" | "model" | "taskEnvelope">,
): NativeSessionBackend {
if (input.provider.kind !== "acpx") {
throw new Error("ACPX native backend requires a persisted ACPX provider profile");
}
return new HarnessDriverBackend(new AcpxRuntimeDriver({
...options,
agent: input.provider.agent,
model: input.provider.model,
taskEnvelope: createCodexTaskEnvelope({
objective: input.completionContract.contract.objective,
contractRevision: input.completionContract.contract.revision,
criteria: input.completionContract.contract.criteria,
constraints: [
"Work only inside the supplied working directory.",
"Use only runner-authorized semantic Paperclip tools.",
"Do not discover user or project configuration, skills, plugins, or MCP servers.",
"Return one semantic completion result through paperclip_finish or paperclip_block.",
],
}),
}));
}
@@ -1,5 +1,5 @@
import { createCodexTaskEnvelope } from "../contracts/codex.js";
import type { NativeExecutionInputV1 } from "../contracts/native-execution.js";
import type { NativeExecutionInput } from "../contracts/native-execution.js";
import type { NativeSessionBackend } from "../contracts/native-session-backend.js";
import { CodexAppServerDriver } from "../drivers/codex/codex-app-server-driver.js";
import type { CodexAppServerTransport } from "../drivers/codex/app-server-transport.js";
@@ -10,7 +10,7 @@ import { HarnessDriverBackend } from "./harness-driver-backend.js";
* closed native input; construction of the concrete Codex driver stays here.
*/
export function createCodexNativeSessionBackend(
input: NativeExecutionInputV1,
input: NativeExecutionInput,
options: {
runnerInstanceId?: string;
onSpawn?: (meta: {
@@ -30,12 +30,19 @@ export function createCodexNativeSessionBackend(
} = {},
): NativeSessionBackend {
return new HarnessDriverBackend(new CodexAppServerDriver({
requestedCollaborationMode: "executionMode" in input ? input.executionMode : "default",
taskEnvelope: createCodexTaskEnvelope({
objective: input.completionContract.contract.objective,
contractRevision: input.completionContract.contract.revision,
criteria: input.completionContract.contract.criteria,
constraints: [
"Work only inside the supplied working directory.",
...("executionMode" in input && input.executionMode === "plan" ? [
"Use native plan collaboration mode and do not modify workspace files.",
"Treat the supplied Paperclip planning context as the canonical pinned base revision.",
"Complete one structured provider plan item; Paperclip will synchronize it after completion.",
"Keep the final response to a short synchronization summary instead of repeating the full plan.",
] : []),
"Do not discover or invoke skills.",
"Do not call a control-plane API.",
"Return one semantic completion result.",
@@ -86,6 +86,31 @@ describe("HarnessDriverBackend", () => {
for await (const event of session.events()) events.push(event);
expect(events).toHaveLength(2);
await expect(session.result()).resolves.toMatchObject({ result, turnId: "turn-1", terminal: { runTerminalState: "succeeded" } });
await expect(session.snapshot()).resolves.toMatchObject({ sessionId: "driver-1", providerSessionId: "provider-1" });
await expect(session.snapshot()).resolves.toMatchObject({
driverKind: "fake",
sessionId: "driver-1",
providerSessionId: "provider-1",
});
});
it("passes the persisted harness driver kind through recovery", async () => {
let recoveredDriverKind: string | null = null;
const recoveryDriver: HarnessDriver = {
...driver,
async recoverSession(snapshot) {
recoveredDriverKind = snapshot.driverKind;
return { recovered: true, session: new FakeHarnessSession() };
},
};
const backend = new HarnessDriverBackend(recoveryDriver);
const recovery = await backend.recoverSession({
backendKind: "runner",
driverKind: "fake",
sessionId: "driver-1",
providerSessionId: "provider-1",
identity: { runId: "run-2", sessionId: "session-1", companyId: "company-1", issueId: "issue-1", agentId: "agent-1" },
});
expect(recovery.recovered).toBe(true);
expect(recoveredDriverKind).toBe("fake");
});
});
@@ -51,7 +51,7 @@ export class HarnessDriverBackend implements NativeSessionBackend {
return { recovered: false, reason: "driver does not support recovery" };
}
const persisted: PersistedHarnessSession = {
driverKind: snapshot.backendKind,
driverKind: snapshot.driverKind ?? snapshot.backendKind,
driverSessionId: snapshot.sessionId,
providerSessionId: snapshot.providerSessionId,
runId: snapshot.identity.runId,
@@ -188,6 +188,7 @@ class HarnessNativeSession implements NativeSession {
const snapshot = await this.#session.snapshot();
return {
backendKind: "runner",
driverKind: snapshot.driverKind,
sessionId: snapshot.driverSessionId,
identity: structuredClone(this.#input.identity),
providerSessionId: snapshot.providerSessionId,
@@ -1,8 +1,10 @@
import type { NativeExecutionInputV1 } from "../contracts/native-execution.js";
import type { NativeExecutionInput } from "../contracts/native-execution.js";
import type { NativeSessionBackend } from "../contracts/native-session-backend.js";
import type { CodexAppServerTransport } from "../drivers/codex/app-server-transport.js";
import { createCodexNativeSessionBackend } from "./codex-native-backend.js";
import { createOpenCodeNativeSessionBackend } from "./opencode-native-backend.js";
import { createAcpxNativeSessionBackend } from "./acpx-native-backend.js";
import type { AcpxRuntimeFactory } from "../drivers/acpx/acpx-runtime-host.js";
export interface NativeBackendFactoryOptions {
runnerInstanceId?: string;
@@ -19,10 +21,12 @@ export interface NativeBackendFactoryOptions {
opencodeRuntimeDirectory?: string;
environment?: NodeJS.ProcessEnv;
opencodeCommand?: string;
acpxRuntimeDirectory?: string;
acpxRuntimeFactory?: AcpxRuntimeFactory;
}
export function createNativeSessionBackend(
input: NativeExecutionInputV1,
input: NativeExecutionInput,
options: NativeBackendFactoryOptions = {},
): NativeSessionBackend {
if (input.provider.kind === "codex") {
@@ -34,9 +38,9 @@ export function createNativeSessionBackend(
transportFactory: options.codexTransportFactory,
});
}
if (input.provider.kind === "claude_managed") {
if (input.provider.kind === "claude_managed" || input.provider.kind === "aws_agentcore") {
if (!options.codexTransportFactory) {
throw new Error("Claude Agent native backend requires the runnerd remote-provider transport");
throw new Error(`${input.provider.kind === "claude_managed" ? "Claude Agent" : "AWS AgentCore"} native backend requires the runnerd remote-provider transport`);
}
return createCodexNativeSessionBackend(input, {
runnerInstanceId: options.runnerInstanceId,
@@ -46,6 +50,18 @@ export function createNativeSessionBackend(
transportFactory: options.codexTransportFactory,
});
}
if (input.provider.kind === "acpx") {
if (!options.acpxRuntimeDirectory) throw new Error("ACPX native backend requires its own instance runtime directory");
return createAcpxNativeSessionBackend(input, {
runnerInstanceId: options.runnerInstanceId,
onSpawn: options.onSpawn,
runtimeDirectory: options.acpxRuntimeDirectory,
environment: options.environment,
dynamicTools: options.dynamicTools,
dynamicToolHandler: options.dynamicToolHandler,
runtimeFactory: options.acpxRuntimeFactory,
});
}
if (!options.opencodeRuntimeDirectory) {
throw new Error("OpenCode native backend requires an instance runtime directory");
}
@@ -1,11 +1,11 @@
import { createCodexTaskEnvelope } from "../contracts/codex.js";
import type { NativeExecutionInputV1 } from "../contracts/native-execution.js";
import type { NativeExecutionInput } from "../contracts/native-execution.js";
import type { NativeSessionBackend } from "../contracts/native-session-backend.js";
import { OpenCodeServerDriver, type OpenCodeServerDriverOptions } from "../drivers/opencode/opencode-server-driver.js";
import { HarnessDriverBackend } from "./harness-driver-backend.js";
export function createOpenCodeNativeSessionBackend(
input: NativeExecutionInputV1,
input: NativeExecutionInput,
options: Omit<OpenCodeServerDriverOptions, "model" | "taskEnvelope">,
): NativeSessionBackend {
if (input.provider.kind !== "opencode" || !input.provider.model) {
@@ -0,0 +1,412 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { createInterface } from "node:readline";
import { isAbsolute, relative, resolve } from "node:path";
import type { AcpPermissionDecision, AcpRuntimeEvent } from "acpx/runtime";
import { AcpxRuntimeHost } from "../drivers/acpx/acpx-runtime-host.js";
import { resolveQualifiedAcpxProfile, type QualifiedAcpxAgent } from "../drivers/acpx/qualified-profiles.js";
import {
ACPX_SIDECAR_MAX_FRAME_BYTES,
ACPX_SIDECAR_PROTOCOL_VERSION,
boundedSidecarValue,
parseAcpxSidecarRequest,
record,
text,
type AcpxSidecarEvent,
type AcpxSidecarOpenParams,
type AcpxSidecarRequest,
type AcpxSidecarResponse,
} from "../drivers/acpx/sidecar-protocol.js";
interface PendingResolution<T> {
turnId: string;
settle: (value: T) => void;
reject: (error: Error) => void;
}
let host: AcpxRuntimeHost | null = null;
let openParams: AcpxSidecarOpenParams | null = null;
let runId: string | null = null;
let turnId: string | null = null;
let sequence = 0;
let closing = false;
const permissions = new Map<string, PendingResolution<AcpPermissionDecision | undefined>>();
const tools = new Map<string, PendingResolution<unknown>>();
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
lines.on("line", (line) => void receiveLine(line));
lines.on("close", () => void shutdown("sidecar stdin closed"));
process.once("SIGTERM", () => void shutdown("sidecar received SIGTERM"));
process.once("SIGINT", () => void shutdown("sidecar received SIGINT"));
async function receiveLine(line: string): Promise<void> {
if (!line.trim()) return;
if (Buffer.byteLength(line) > ACPX_SIDECAR_MAX_FRAME_BYTES) {
diagnostic("oversized_frame", "Rejected an oversized sidecar request.");
return;
}
let request: AcpxSidecarRequest;
try {
request = parseAcpxSidecarRequest(JSON.parse(line));
} catch (error) {
diagnostic("malformed_frame", safeMessage(error));
return;
}
try {
response(request.id, true, await dispatch(request));
} catch (error) {
response(request.id, false, undefined, {
code: text(record(error).code, "acpx_sidecar_command_failed"),
message: safeMessage(error),
retryable: record(error).retryable === true,
});
}
}
async function dispatch(request: AcpxSidecarRequest): Promise<Record<string, unknown>> {
if (request.command === "initialize") {
const agent = requiredAgent(request.params.agent);
const model = requiredText(request.params.model, "model");
const profile = resolveQualifiedAcpxProfile(agent, model);
return {
protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION,
sidecarPid: process.pid,
profile,
capabilities: {
persistentSessions: true,
exactModelVerification: true,
permissions: agent === "pi" ? "runner_extension" : "native_acp",
semanticTools: agent === "pi" ? "runner_extension" : "runner_bridge",
},
};
}
if (request.command === "session.open") {
if (host) throw new Error("ACPX sidecar already owns a session");
const params = parseOpenParams(request.params);
openParams = params;
host = await openRuntime(params);
const identity = host.identity();
emit("runtime.process", { role: "sidecar", pid: process.pid, processGroupId: null, startedAt: new Date().toISOString() });
return { identity, sidecarPid: process.pid, status: await host.status() };
}
if (request.command === "run.attach") {
requireHost();
if (turnId) throw new Error("cannot attach a run while an ACPX turn is active");
const nextRunId = requiredText(request.params.runId, "runId");
const previousRunId = runId;
runId = nextRunId;
const nextTools = Array.isArray(request.params.tools)
? request.params.tools.slice(0, 512).map(record)
: openParams?.tools ?? [];
try {
if (openParams && JSON.stringify(nextTools) !== JSON.stringify(openParams.tools)) {
const nextParams = { ...openParams, tools: nextTools };
await host!.close({ reason: "Paperclip tool catalog changed", discardPersistentState: false });
host = await openRuntime(nextParams);
openParams = nextParams;
}
} catch (error) {
runId = previousRunId;
throw error;
}
return { runId, catalogRevision: positiveInteger(request.params.catalogRevision, "catalogRevision") };
}
if (request.command === "turn.start") {
const activeHost = requireHost();
if (!runId) throw new Error("attach a Paperclip run before starting a turn");
if (turnId) throw new Error("ACPX sidecar already has an active turn");
turnId = requiredText(request.params.turnId, "turnId");
const currentTurnId = turnId;
const runtimeTurn = activeHost.startTurn({
requestId: `${runId}:${currentTurnId}`,
text: requiredText(request.params.message, "message"),
});
void pumpTurn(currentTurnId, runtimeTurn);
return { turnId: currentTurnId };
}
if (request.command === "turn.cancel") {
const expected = requiredText(request.params.turnId, "turnId");
if (expected !== turnId) throw new Error("cannot cancel a stale ACPX turn");
await requireHost().cancel(text(request.params.reason, "Paperclip cancellation"));
return { cancelled: true };
}
if (request.command === "permission.resolve") {
const requestId = requiredText(request.params.requestId, "requestId");
const pending = permissions.get(requestId);
if (!pending || pending.turnId !== requiredText(request.params.turnId, "turnId")) throw new Error("permission request is stale or unknown");
permissions.delete(requestId);
pending.settle(parsePermissionDecision(request.params.decision));
return { resolved: true };
}
if (request.command === "tool.resolve") {
const callId = requiredText(request.params.callId, "callId");
const pending = tools.get(callId);
if (!pending || pending.turnId !== requiredText(request.params.turnId, "turnId")) throw new Error("tool call is stale or unknown");
tools.delete(callId);
if (request.params.error) pending.reject(new Error(text(record(request.params.error).message, "Paperclip tool failed")));
else pending.settle(structuredClone(request.params.result));
return { resolved: true };
}
if (request.command === "session.read") {
return { identity: requireHost().identity(), status: await requireHost().status() };
}
if (request.command === "session.snapshot") {
return { identity: requireHost().identity(), status: await requireHost().status(), runId, turnId, sequence };
}
if (request.command === "session.suspend") {
if (turnId) throw new Error("ACPX session is not at a safe suspension point");
const identity = requireHost().identity();
await host!.close({ reason: text(request.params.reason, "Paperclip suspension"), discardPersistentState: false });
host = null;
return { suspended: true, identity };
}
if (request.command === "session.close") {
const discard = request.params.discardPersistentState === true;
if (host) await host.close({ reason: text(request.params.reason, "Paperclip close"), discardPersistentState: discard });
host = null;
return { closed: true, discarded: discard };
}
throw new Error("unreachable sidecar command");
}
async function openRuntime(params: AcpxSidecarOpenParams): Promise<AcpxRuntimeHost> {
return AcpxRuntimeHost.open({
...params,
environment: process.env,
dynamicTools: params.tools,
dynamicToolHandler: (call) => waitForTool(call),
onPermissionRequest: (context) => waitForPermission(context.request, context.signal),
onSpawn: async (identity) => emit("runtime.process", {
role: "acp_agent",
...identity,
}),
onDiagnostic: (message) => diagnostic("acp_agent_stderr", message),
});
}
async function pumpTurn(currentTurnId: string, runtimeTurn: ReturnType<AcpxRuntimeHost["startTurn"]>): Promise<void> {
try {
for await (const event of runtimeTurn.events) emit("runtime.event", sanitizeRuntimeEvent(event), currentTurnId);
const result = await runtimeTurn.result;
emit("runtime.turn_terminal", boundedSidecarValue(result), currentTurnId);
} catch (error) {
emit("runtime.turn_terminal", { status: "failed", error: { message: safeMessage(error), retryable: false } }, currentTurnId);
} finally {
rejectTurnWaiters(currentTurnId, "ACPX turn became terminal");
if (turnId === currentTurnId) turnId = null;
}
}
function waitForTool(call: { tool: string; callId: string; arguments: unknown }): Promise<unknown> {
if (!turnId || tools.has(call.callId)) throw new Error("ACPX tool call is unbound or duplicated");
emit("runtime.tool_called", {
callId: call.callId,
operationId: call.tool,
input: boundedSidecarValue(record(call.arguments)),
});
return new Promise((settle, reject) => tools.set(call.callId, { turnId: turnId!, settle, reject }));
}
function waitForPermission(request: import("acpx/runtime").AcpPermissionRequest, signal: AbortSignal): Promise<AcpPermissionDecision | undefined> {
if (!turnId || signal.aborted) return Promise.resolve({ outcome: "cancel" });
const raw = record(request.raw);
const toolCall = record(raw.toolCall);
const requestId = requiredText(toolCall.toolCallId ?? toolCall.id ?? `permission-${sequence + 1}`, "permission request id");
if (permissions.has(requestId)) return Promise.resolve({ outcome: "reject_once" });
emit("runtime.permission_requested", {
requestId,
kind: text(request.inferredKind, "unknown"),
title: text(toolCall.title, "ACP permission request").slice(0, 4000),
optionCount: Array.isArray(raw.options) ? Math.min(raw.options.length, 32) : 0,
});
return new Promise((settle, reject) => {
const abort = () => {
if (!permissions.delete(requestId)) return;
settle({ outcome: "cancel" });
};
signal.addEventListener("abort", abort, { once: true });
permissions.set(requestId, {
turnId: turnId!,
settle: (decision) => { signal.removeEventListener("abort", abort); settle(decision); },
reject,
});
});
}
function sanitizeRuntimeEvent(event: AcpRuntimeEvent): Record<string, unknown> {
const runtimeType = text(record(event).type);
if (!["text_delta", "status", "tool_call", "error", "done"].includes(runtimeType)) {
return {
type: "provider_notice",
category: `unclassified_acp_${runtimeType || "unknown"}`.slice(0, 160),
summary: "The qualified ACP agent emitted an unclassified runtime update.",
};
}
if (event.type === "text_delta") {
return event.stream === "thought" || event.tag === "agent_thought_chunk"
? { type: "thinking", state: "active", tag: event.tag ?? null }
: { type: "text_delta", text: event.text.slice(0, 64 * 1024), stream: "output", tag: event.tag ?? null, messageId: event.messageId?.slice(0, 200) ?? null };
}
if (event.type === "status") {
return boundedSidecarValue({
type: "status",
text: event.text.slice(0, 4000),
tag: event.tag ?? null,
used: event.used ?? null,
size: event.size ?? null,
cost: event.cost ?? null,
breakdown: event.breakdown ?? null,
});
}
if (event.type === "tool_call") {
return boundedSidecarValue({
type: "tool_call",
toolCallId: event.toolCallId?.slice(0, 200) ?? null,
status: event.status?.slice(0, 100) ?? null,
title: event.title?.slice(0, 4000) ?? null,
kind: event.kind ?? null,
locations: safeLocations(event.locations),
...safeOutput(event.rawOutput),
}, 128 * 1024);
}
if (event.type === "error") return { type: "error", code: event.code ?? null, message: safeText(event.message), retryable: event.retryable ?? false };
if (event.type === "done") return { type: "done", stopReason: event.stopReason ?? null };
return { type: "provider_notice", category: "unclassified_acp_unknown", summary: "The qualified ACP agent emitted an unclassified runtime update." };
}
function safeOutput(value: unknown): Record<string, unknown> {
if (value === undefined || value === null) return { output: null, outputBytes: 0, outputTruncated: false, outputDigest: null };
const raw = typeof value === "string" ? value : JSON.stringify(value);
if (!raw) return { output: null, outputBytes: 0, outputTruncated: false, outputDigest: null };
const redacted = raw.replace(/(key|token|secret|password|authorization)\s*[:=]\s*[^\s,}\]]+/gi, "$1=[REDACTED]");
const bytes = Buffer.from(redacted);
return {
output: bytes.subarray(Math.max(0, bytes.length - 64 * 1024)).toString("utf8"),
outputBytes: bytes.length,
outputTruncated: bytes.length > 64 * 1024,
outputDigest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
};
}
function safeLocations(locations: Extract<AcpRuntimeEvent, { type: "tool_call" }>["locations"]): Array<Record<string, unknown>> {
if (!openParams) return [];
const cwd = resolve(openParams.workingDirectory);
return (locations ?? []).slice(0, 2_000).flatMap((location): Array<Record<string, unknown>> => {
const candidate = record(location);
const rawPath = text(candidate.path, text(candidate.uri));
if (!rawPath) return [];
const absolute = isAbsolute(rawPath) ? resolve(rawPath) : resolve(cwd, rawPath);
const local = relative(cwd, absolute).replaceAll("\\", "/");
if (!local || local === ".." || local.startsWith("../")) return [];
return [{ path: local.slice(0, 4000), line: candidate.line ?? null }];
});
}
function parseOpenParams(value: Record<string, unknown>): AcpxSidecarOpenParams {
const agent = requiredAgent(value.agent);
const model = requiredText(value.model, "model");
resolveQualifiedAcpxProfile(agent, model);
return {
runtimeDirectory: requiredText(value.runtimeDirectory, "runtimeDirectory"),
normalizedSessionId: requiredText(value.normalizedSessionId, "normalizedSessionId"),
workingDirectory: requiredText(value.workingDirectory, "workingDirectory"),
agent,
model,
tools: Array.isArray(value.tools) ? value.tools.slice(0, 512).map(record) : [],
...(value.expectedIdentity === undefined || value.expectedIdentity === null
? {}
: { expectedIdentity: parseExpectedIdentity(value.expectedIdentity) }),
};
}
function parseExpectedIdentity(value: unknown): NonNullable<AcpxSidecarOpenParams["expectedIdentity"]> {
const input = record(value);
if (input.kind !== "acpx") throw new Error("ACPX recovery identity kind is invalid");
return {
kind: "acpx",
normalizedSessionId: requiredText(input.normalizedSessionId, "expected normalizedSessionId"),
acpxRecordId: requiredText(input.acpxRecordId, "expected acpxRecordId"),
backendSessionId: requiredText(input.backendSessionId, "expected backendSessionId"),
agentSessionId: requiredText(input.agentSessionId, "expected agentSessionId"),
profileDigest: requiredText(input.profileDigest, "expected profileDigest"),
workspaceDigest: requiredText(input.workspaceDigest, "expected workspaceDigest"),
requestedModel: requiredText(input.requestedModel, "expected requestedModel"),
effectiveModel: requiredText(input.effectiveModel, "expected effectiveModel"),
};
}
function parsePermissionDecision(value: unknown): AcpPermissionDecision {
const outcome = text(record(value).outcome);
if (!["allow_once", "allow_always", "reject_once", "reject_always", "cancel"].includes(outcome)) throw new Error("unsupported ACP permission decision");
return { outcome } as AcpPermissionDecision;
}
function emit(eventType: AcpxSidecarEvent["eventType"], payload: Record<string, unknown>, eventTurnId = turnId): void {
writeFrame({ protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION, sequence: ++sequence, eventType, runId, turnId: eventTurnId, payload } satisfies AcpxSidecarEvent);
}
function diagnostic(code: string, message: string): void {
process.stderr.write(`[paperclip-acpx-sidecar] ${code}: ${safeText(message)}\n`);
emit("runtime.diagnostic", { code, message: safeText(message) });
}
function response(id: number, ok: boolean, result?: Record<string, unknown>, error?: AcpxSidecarResponse["error"]): void {
writeFrame({ protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION, id, ok, ...(result ? { result } : {}), ...(error ? { error } : {}) } satisfies AcpxSidecarResponse);
}
function writeFrame(value: AcpxSidecarEvent | AcpxSidecarResponse): void {
const line = JSON.stringify(value);
if (Buffer.byteLength(line) > ACPX_SIDECAR_MAX_FRAME_BYTES) {
process.stderr.write("[paperclip-acpx-sidecar] output_frame_too_large\n");
return;
}
process.stdout.write(`${line}\n`);
}
function requireHost(): AcpxRuntimeHost {
if (!host) throw new Error("ACPX session is not open");
return host;
}
function requiredAgent(value: unknown): QualifiedAcpxAgent {
if (value !== "pi" && value !== "claude" && value !== "codex") throw new Error("agent must be pi, claude, or codex");
return value;
}
function requiredText(value: unknown, field: string): string {
const result = text(value).trim();
if (!result) throw new Error(`${field} is required`);
return result;
}
function positiveInteger(value: unknown, field: string): number {
if (!Number.isSafeInteger(value) || Number(value) < 1) throw new Error(`${field} must be a positive integer`);
return Number(value);
}
function safeText(value: unknown): string {
return text(value, String(value)).replace(/(key|token|secret|password|authorization)\s*[:=]\s*[^\s,}\]]+/gi, "$1=[REDACTED]").slice(0, 8192);
}
function safeMessage(error: unknown): string { return safeText(error instanceof Error ? error.message : error); }
function rejectTurnWaiters(terminalTurnId: string, message: string): void {
for (const [id, pending] of permissions) if (pending.turnId === terminalTurnId) {
permissions.delete(id);
pending.settle({ outcome: "cancel" });
}
for (const [id, pending] of tools) if (pending.turnId === terminalTurnId) {
tools.delete(id);
pending.reject(new Error(message));
}
}
async function shutdown(reason: string): Promise<void> {
if (closing) return;
closing = true;
rejectTurnWaiters(turnId ?? "", reason);
if (host) await host.close({ reason, discardPersistentState: false }).catch(() => {});
process.exitCode = 0;
}
@@ -15,15 +15,39 @@ import {
reconcileCapabilityLiveUsage,
type CreateCapabilityLiveSessionInput,
} from "../live/live-session.js";
import {
resolveQualifiedAcpxProfile,
type QualifiedAcpxAgent,
} from "../drivers/acpx/qualified-profiles.js";
interface EvalSessionRequest {
schema: "paperclip-runner/eval-session-request/v1";
attemptId: string;
prompt: string;
model: string;
provider?: "codex" | "opencode";
driver?: "codex_app_server" | "opencode_server";
provider?: "codex" | "opencode" | "aws_agentcore" | "acpx";
driver?: "codex_app_server" | "opencode_server" | "aws_agentcore_harness_api" | "acpx_runtime";
opencodeVersion?: string;
acpxAgent?: QualifiedAcpxAgent;
agentCoreProfile?: {
profileId: string;
region: string;
accountId: string;
harnessArn: string;
harnessVersion: string;
endpointArn: string;
endpointQualifier: string;
agentRuntimeArn: string;
memoryArn: string;
memoryId: string;
invocationRoleArn: string;
qualificationRevision: string;
eventExpiryDays: 90;
maxEstimatedSessionCostUsd: number;
maxIterations: number;
maxOutputTokens: number;
timeoutSeconds: number;
};
runnerd: { path: string; sha256: string };
limits: { turnTimeoutMs: number; maxAgentTurns: number; maxEstimatedCostNanodollars: number };
session: CreateCapabilityLiveSessionInput;
@@ -43,6 +67,9 @@ async function sha256(path: string): Promise<string> {
const requestPath = argument("--request");
const outputPath = argument("--output");
const request = JSON.parse(await readFile(requestPath, "utf8")) as EvalSessionRequest;
const acpxProfile = request.provider === "acpx"
? resolveQualifiedAcpxProfile(request.acpxAgent ?? "pi", request.model)
: null;
const evalStartedAt = new Date().toISOString();
const evalStartedAtMs = Date.now();
if (request.schema !== "paperclip-runner/eval-session-request/v1") throw new Error("unsupported request schema");
@@ -65,7 +92,9 @@ try {
prompt: request.prompt,
model: request.model,
provider: request.provider ?? "codex",
acpxAgent: request.acpxAgent,
opencodeVersion: request.opencodeVersion,
agentCoreProfile: request.agentCoreProfile,
runnerBinaryPath: runnerdPath,
seed: request.session.seed ?? {},
actorId: request.session.actorId ?? "",
@@ -98,8 +127,9 @@ try {
runnerd: { path: "[withheld]", sha256: `sha256:${actualDigest}` },
requestedModel: request.model,
provider: request.provider ?? "codex",
driver: request.driver ?? (request.provider === "opencode" ? "opencode_server" : "codex_app_server"),
providerVersion: request.provider === "opencode" ? request.opencodeVersion ?? "1.18.17" : null,
driver: request.driver ?? (request.provider === "opencode" ? "opencode_server" : request.provider === "aws_agentcore" ? "aws_agentcore_harness_api" : request.provider === "acpx" ? "acpx_runtime" : "codex_app_server"),
providerVersion: request.provider === "opencode" ? request.opencodeVersion ?? "1.18.17" : request.provider === "aws_agentcore" ? request.agentCoreProfile?.harnessVersion ?? null : request.provider === "acpx" ? acpxProfile!.acpxVersion : null,
...(acpxProfile === null ? {} : { acpxAgent: acpxProfile.agent, acpxProfile }),
turn,
snapshot,
devtools: projectCapabilityDevtools(snapshot),
@@ -130,8 +160,9 @@ try {
runnerd: { path: "[withheld]", sha256: `sha256:${actualDigest}` },
requestedModel: request.model,
provider: request.provider ?? "codex",
driver: request.driver ?? (request.provider === "opencode" ? "opencode_server" : "codex_app_server"),
providerVersion: request.provider === "opencode" ? request.opencodeVersion ?? "1.18.17" : null,
driver: request.driver ?? (request.provider === "opencode" ? "opencode_server" : request.provider === "aws_agentcore" ? "aws_agentcore_harness_api" : request.provider === "acpx" ? "acpx_runtime" : "codex_app_server"),
providerVersion: request.provider === "opencode" ? request.opencodeVersion ?? "1.18.17" : request.provider === "aws_agentcore" ? request.agentCoreProfile?.harnessVersion ?? null : request.provider === "acpx" ? acpxProfile!.acpxVersion : null,
...(acpxProfile === null ? {} : { acpxAgent: acpxProfile.agent, acpxProfile }),
timing: { startedAt: evalStartedAt, finishedAt: new Date().toISOString(), durationMs: Date.now() - evalStartedAtMs },
}, null, 2)}\n`);
process.exitCode = 2;
@@ -38,6 +38,7 @@ export interface CodexModelContextSnapshot {
model: string;
modelProvider: string;
workingDirectory: string;
collaborationMode: "default" | "plan";
sandbox: unknown;
approvalPolicy: unknown;
baseInstructions: string;
@@ -45,7 +46,7 @@ export interface CodexModelContextSnapshot {
instructionPolicy: {
skillInstructions: false;
appInstructions: false;
collaborationInstructions: false;
collaborationInstructions: boolean;
};
environmentKeys: string[];
dynamicToolNames: string[];
@@ -316,7 +317,6 @@ export function isSkilllessCodexContext(
) &&
context.instructionPolicy.skillInstructions === false &&
context.instructionPolicy.appInstructions === false &&
context.instructionPolicy.collaborationInstructions === false &&
!serialized.includes("paperclip_api_key") &&
!serialized.includes("authorization: bearer") &&
!serialized.includes("/api/issues/") &&
@@ -300,6 +300,20 @@ export interface PersistedHarnessTurnTerminal {
fingerprint: string;
}
export interface AcpxSessionIdentity {
kind: "acpx";
normalizedSessionId: string;
acpxRecordId: string;
backendSessionId: string;
agentSessionId: string;
profileDigest: string;
workspaceDigest: string;
requestedModel: string;
effectiveModel: string;
}
export type PersistedHarnessProviderIdentity = AcpxSessionIdentity;
export interface PersistedHarnessSession {
driverKind: string;
driverSessionId: string;
@@ -313,6 +327,8 @@ export interface PersistedHarnessSession {
goal?: HarnessThreadGoal | null;
lineage?: HarnessThreadLineageEntry[];
lastSourceSequence?: number;
/** Tagged provider identity used to reject cross-profile recovery. */
providerIdentity?: PersistedHarnessProviderIdentity;
}
export interface HarnessSessionRecoveryResult {
@@ -329,7 +345,10 @@ export interface HarnessSession {
};
events(): AsyncIterable<PrpEvent>;
attachRun?(input: { runId: string }): Promise<void> | void;
startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }>;
startTurn(input: {
message: NativeUserMessage;
requestedCollaborationMode?: "default" | "plan";
}): Promise<{ turnId: string; effectiveCollaborationMode?: "default" | "plan" }>;
steer?(input: { turnId: string; message: NativeUserMessage }): Promise<void>;
interrupt?(input: { turnId?: string; reason?: string }): Promise<void>;
pendingRuntimeRequests?(): HarnessRuntimeRequest[];
@@ -124,6 +124,88 @@ describe("NativeExecutionInputV1", () => {
})).toThrow("betaVersion");
});
it("accepts a closed AWS AgentCore Harness snapshot and rejects drift or unsafe limits", () => {
const awsAgentCore = {
...input,
session: { ...input.session, driverKind: "aws_agentcore_harness_api" },
provider: {
kind: "aws_agentcore",
model: "global.anthropic.claude-sonnet-4-6",
agentCoreProfile: {
profileId: "agentcore-development",
region: "us-east-1",
accountId: "123456789012",
harnessArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:harness/harness-1",
harnessVersion: "3",
endpointArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:harness-endpoint/harness-1/paperclip",
endpointQualifier: "paperclip",
agentRuntimeArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-1",
memoryArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/memory-1",
memoryId: "memory-1",
invocationRoleArn: "arn:aws:iam::123456789012:role/paperclip-agentcore-runner",
qualificationRevision: "aws-agentcore-harness-v1",
eventExpiryDays: 90,
},
maxEstimatedSessionCostUsd: 1,
invocationLimits: { maxIterations: 8, maxOutputTokens: 4096, timeoutSeconds: 300 },
},
} as const;
const parsed = parseNativeExecutionInput(awsAgentCore);
expect(parsed.provider).toEqual(awsAgentCore.provider);
expect(buildNativeModelEnvelope(parsed).workspace).toBeNull();
expect(JSON.stringify(parsed)).not.toContain("AWS_SECRET_ACCESS_KEY");
expect(() => parseNativeExecutionInput({
...awsAgentCore,
session: { ...awsAgentCore.session, driverKind: "codex_app_server" },
})).toThrow("does not match");
expect(() => parseNativeExecutionInput({
...awsAgentCore,
provider: { ...awsAgentCore.provider, invocationLimits: { ...awsAgentCore.provider.invocationLimits, maxIterations: 9 } },
})).toThrow("maxIterations");
expect(() => parseNativeExecutionInput({
...awsAgentCore,
provider: { ...awsAgentCore.provider, agentCoreProfile: { ...awsAgentCore.provider.agentCoreProfile, eventExpiryDays: 30 } },
})).toThrow("eventExpiryDays");
});
it("accepts only a closed ACPX profile matching the driver and agent", () => {
const provider = {
kind: "acpx",
agent: "pi",
model: "openrouter/deepseek/deepseek-v4-flash-0731",
permissionPolicy: "interactive",
profile: {
driverKind: "acpx_runtime",
protocolVersion: 1,
acpxVersion: "0.13.1",
agent: "pi",
agentProfileVersion: 1,
agentServerPackage: "pi-acp",
agentServerVersion: "0.0.33",
agentRuntimePackage: "@earendil-works/pi-coding-agent",
agentRuntimeVersion: "0.84.2",
commandDigest: "sha256:24ff73fda6e3c76ddce2d359a79f5c4b8f292eb290e4d2ab85aac94676b2c2dc",
},
} as const;
const parsed = parseNativeExecutionInput({
...input,
session: { ...input.session, driverKind: "acpx_runtime" },
provider,
});
expect(parsed.provider).toEqual(provider);
expect(buildNativeModelEnvelope(parsed).workspace).toEqual({ cwd: "/safe/workspace" });
expect(() => parseNativeExecutionInput({
...input,
session: { ...input.session, driverKind: "acpx_runtime" },
provider: { ...provider, profile: { ...provider.profile, agent: "claude" } },
})).toThrow("qualified ACPX v1 profile");
expect(() => parseNativeExecutionInput({
...input,
session: { ...input.session, driverKind: "opencode_server" },
provider,
})).toThrow("does not match");
});
it("defaults legacy lifecycle state to per-turn and validates warm timeouts", () => {
const legacy = structuredClone(input) as Record<string, unknown>;
delete (legacy.session as Record<string, unknown>).lifecyclePolicy;
@@ -147,3 +229,47 @@ describe("NativeExecutionInputV1", () => {
})).toThrow("positive integer");
});
});
describe("NativeExecutionInputV2 planning", () => {
const planning = {
...input,
schema: "paperclip.native-execution-input.v2",
executionMode: "plan",
task: { ...input.task, workMode: "planning" },
planningContext: {
documentId: "document-1",
baseRevisionId: "revision-3",
baseRevisionNumber: 3,
markdown: "# Existing plan",
sha256: "plan-digest",
reviewContext: { threads: [{ id: "annotation-1" }] },
},
} as const;
it("round-trips native plan mode and its pinned canonical revision into model context", () => {
const parsed = parseNativeExecutionInput(planning);
expect(parsed).toMatchObject({ schema: "paperclip.native-execution-input.v2", executionMode: "plan" });
expect(buildNativeModelEnvelope(parsed)).toMatchObject({
schema: "paperclip.native-model-envelope.v2",
executionMode: "plan",
planningContext: { baseRevisionId: "revision-3", baseRevisionNumber: 3 },
});
});
it("fails closed when plan mode omits its pinned Paperclip context", () => {
expect(() => parseNativeExecutionInput({ ...planning, planningContext: null }))
.toThrow("planningContext is required");
expect(() => parseNativeExecutionInput({
...planning,
task: { ...planning.task, workMode: "standard" },
})).toThrow("requires planning work mode");
});
it("allows an accepted planning issue to continue in fresh default execution mode", () => {
expect(parseNativeExecutionInput({
...planning,
executionMode: "default",
planningContext: null,
})).toMatchObject({ task: { workMode: "planning" }, executionMode: "default" });
});
});
@@ -1,7 +1,20 @@
import type { PrpStructuredRunResult, PrpTerminalState } from "../protocol/replay-contract.js";
export const NATIVE_EXECUTION_INPUT_SCHEMA = "paperclip.native-execution-input.v1" as const;
export const NATIVE_MODEL_ENVELOPE_SCHEMA = "paperclip.native-model-envelope.v1" as const;
export const NATIVE_EXECUTION_INPUT_SCHEMA_V1 = "paperclip.native-execution-input.v1" as const;
export const NATIVE_EXECUTION_INPUT_SCHEMA = "paperclip.native-execution-input.v2" as const;
export const NATIVE_MODEL_ENVELOPE_SCHEMA_V1 = "paperclip.native-model-envelope.v1" as const;
export const NATIVE_MODEL_ENVELOPE_SCHEMA = "paperclip.native-model-envelope.v2" as const;
export type NativeExecutionMode = "default" | "plan";
export interface NativePlanningContext {
documentId: string | null;
baseRevisionId: string | null;
baseRevisionNumber: number;
markdown: string;
sha256: string;
reviewContext: Record<string, unknown>;
}
export interface StrictCompletionContractInput {
revision: string;
@@ -35,6 +48,37 @@ export interface NativeManagedAgentProfileSnapshot {
betaVersion: "managed-agents-2026-04-01";
}
export interface NativeAwsAgentCoreProfileSnapshot {
profileId: string;
region: string;
accountId: string;
harnessArn: string;
harnessVersion: string;
endpointArn: string;
endpointQualifier: string;
agentRuntimeArn: string;
memoryArn: string;
memoryId: string;
invocationRoleArn: string;
qualificationRevision: string;
eventExpiryDays: 90;
}
export type NativeAcpxAgent = "pi" | "claude" | "codex";
export interface NativeAcpxProfileSnapshot {
driverKind: "acpx_runtime";
protocolVersion: 1;
acpxVersion: "0.13.1";
agent: NativeAcpxAgent;
agentProfileVersion: 1;
agentServerPackage: string;
agentServerVersion: string;
agentRuntimePackage: string | null;
agentRuntimeVersion: string | null;
commandDigest: string;
}
export type NativeProviderConfig =
| { kind: "codex"; model: string | null }
| { kind: "opencode"; model: string }
@@ -43,10 +87,28 @@ export type NativeProviderConfig =
model: string;
managedProfile: NativeManagedAgentProfileSnapshot;
maxSessionListCostUsd: number;
}
| {
kind: "aws_agentcore";
model: string;
agentCoreProfile: NativeAwsAgentCoreProfileSnapshot;
maxEstimatedSessionCostUsd: number;
invocationLimits: {
maxIterations: number;
maxOutputTokens: number;
timeoutSeconds: number;
};
}
| {
kind: "acpx";
agent: NativeAcpxAgent;
model: string;
permissionPolicy: "interactive";
profile: NativeAcpxProfileSnapshot;
};
export interface NativeExecutionInputV1 {
schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA;
schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA_V1;
binding: {
companyId: string;
runId: string;
@@ -70,7 +132,7 @@ export interface NativeExecutionInputV1 {
};
session: {
normalizedSessionId: string | null;
driverKind: "codex_app_server" | "opencode_server" | "claude_managed_agents_api";
driverKind: "codex_app_server" | "opencode_server" | "claude_managed_agents_api" | "aws_agentcore_harness_api" | "acpx_runtime";
protocolVersion: 1;
lifecyclePolicy: NativeSessionLifecyclePolicy;
};
@@ -85,9 +147,20 @@ export interface NativeExecutionInputV1 {
credentialBindings: NativeCredentialBindingRef[];
}
export interface NativeExecutionInputV2 extends Omit<NativeExecutionInputV1, "schema" | "task"> {
schema: typeof NATIVE_EXECUTION_INPUT_SCHEMA;
executionMode: NativeExecutionMode;
task: Omit<NativeExecutionInputV1["task"], "workMode"> & {
workMode: "standard" | "planning";
};
planningContext: NativePlanningContext | null;
}
export type NativeExecutionInput = NativeExecutionInputV1 | NativeExecutionInputV2;
/** The only task data that may enter provider-visible model input. */
export interface NativeModelEnvelopeV1 {
schema: typeof NATIVE_MODEL_ENVELOPE_SCHEMA;
schema: typeof NATIVE_MODEL_ENVELOPE_SCHEMA_V1;
task: NativeExecutionInputV1["task"];
/** Remote providers have no Paperclip workspace mounted in their service. */
workspace: Pick<NativeExecutionInputV1["workspace"], "cwd"> | null;
@@ -95,6 +168,16 @@ export interface NativeModelEnvelopeV1 {
interactionResponses: NativeInteractionResponseEnvelope[];
}
export interface NativeModelEnvelopeV2 {
schema: typeof NATIVE_MODEL_ENVELOPE_SCHEMA;
task: NativeExecutionInputV2["task"];
executionMode: NativeExecutionMode;
planningContext: NativePlanningContext | null;
workspace: Pick<NativeExecutionInputV2["workspace"], "cwd"> | null;
completionContract: StrictCompletionContractInput;
interactionResponses: NativeInteractionResponseEnvelope[];
}
export interface NativeSessionExecutionResult {
result: PrpStructuredRunResult;
terminal: PrpTerminalState;
@@ -148,8 +231,9 @@ function nullableText(value: unknown, path: string): string | null {
* intentionally not an extensible metadata bag: new fields require a contract
* revision and an explicit security review.
*/
export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV1 {
export function parseNativeExecutionInput(value: unknown): NativeExecutionInput {
const input = record(value, "input");
const isV2 = input.schema === NATIVE_EXECUTION_INPUT_SCHEMA;
exactKeys(input, [
"schema",
"binding",
@@ -160,9 +244,12 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV
"completionContract",
"interactionResponses",
"credentialBindings",
...(isV2 ? ["executionMode", "planningContext"] : []),
], "input");
if (input.schema !== NATIVE_EXECUTION_INPUT_SCHEMA) {
throw new NativeExecutionInputError(`input.schema must be ${NATIVE_EXECUTION_INPUT_SCHEMA}`);
if (!isV2 && input.schema !== NATIVE_EXECUTION_INPUT_SCHEMA_V1) {
throw new NativeExecutionInputError(
`input.schema must be ${NATIVE_EXECUTION_INPUT_SCHEMA_V1} or ${NATIVE_EXECUTION_INPUT_SCHEMA}`,
);
}
const binding = record(input.binding, "input.binding");
@@ -178,17 +265,57 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV
const contract = record(completionContract.contract, "input.completionContract.contract");
exactKeys(contract, ["revision", "objective", "criteria"], "input.completionContract.contract");
if (task.workMode !== "standard") throw new NativeExecutionInputError("input.task.workMode must be standard");
if (task.workMode !== "standard" && (!isV2 || task.workMode !== "planning")) {
throw new NativeExecutionInputError("input.task.workMode must be standard or planning");
}
const executionMode: NativeExecutionMode = isV2 && input.executionMode === "plan" ? "plan" : "default";
if (isV2 && input.executionMode !== "default" && input.executionMode !== "plan") {
throw new NativeExecutionInputError("input.executionMode must be default or plan");
}
if (isV2 && task.workMode === "standard" && executionMode === "plan") {
throw new NativeExecutionInputError("plan execution mode requires planning work mode");
}
let planningContext: NativePlanningContext | null = null;
if (isV2 && input.planningContext !== null) {
const context = record(input.planningContext, "input.planningContext");
exactKeys(context, [
"documentId", "baseRevisionId", "baseRevisionNumber", "markdown", "sha256", "reviewContext",
], "input.planningContext");
if (!Number.isSafeInteger(context.baseRevisionNumber) || Number(context.baseRevisionNumber) < 0) {
throw new NativeExecutionInputError("input.planningContext.baseRevisionNumber must be a non-negative integer");
}
planningContext = {
documentId: context.documentId === null ? null : text(context.documentId, "input.planningContext.documentId"),
baseRevisionId: context.baseRevisionId === null
? null
: text(context.baseRevisionId, "input.planningContext.baseRevisionId"),
baseRevisionNumber: Number(context.baseRevisionNumber),
markdown: typeof context.markdown === "string"
? context.markdown
: (() => { throw new NativeExecutionInputError("input.planningContext.markdown must be a string"); })(),
sha256: text(context.sha256, "input.planningContext.sha256"),
reviewContext: structuredClone(record(context.reviewContext, "input.planningContext.reviewContext")),
};
}
if (isV2 && executionMode === "plan" && planningContext === null) {
throw new NativeExecutionInputError("input.planningContext is required in plan execution mode");
}
if (isV2 && executionMode === "default" && input.planningContext !== null) {
throw new NativeExecutionInputError("input.planningContext is only valid in plan execution mode");
}
if (
(
session.driverKind !== "codex_app_server"
&& session.driverKind !== "opencode_server"
&& session.driverKind !== "claude_managed_agents_api"
&& session.driverKind !== "aws_agentcore_harness_api"
&& session.driverKind !== "acpx_runtime"
)
|| session.protocolVersion !== 1
) {
throw new NativeExecutionInputError("input.session must select a supported protocol version 1 driver");
}
const driverKind = session.driverKind as NativeExecutionInputV1["session"]["driverKind"];
const lifecyclePolicyValue = session.lifecyclePolicy === undefined
? { mode: "per_turn", idleTimeoutMs: null }
: record(session.lifecyclePolicy, "input.session.lifecyclePolicy");
@@ -210,13 +337,23 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV
const provider = input.provider === undefined
? { kind: "codex", model: null }
: record(input.provider, "input.provider");
if (provider.kind !== "codex" && provider.kind !== "opencode" && provider.kind !== "claude_managed") {
throw new NativeExecutionInputError("input.provider.kind must be codex, opencode, or claude_managed");
if (
provider.kind !== "codex"
&& provider.kind !== "opencode"
&& provider.kind !== "claude_managed"
&& provider.kind !== "aws_agentcore"
&& provider.kind !== "acpx"
) {
throw new NativeExecutionInputError("input.provider.kind must be codex, opencode, claude_managed, aws_agentcore, or acpx");
}
exactKeys(
provider,
provider.kind === "claude_managed"
? ["kind", "model", "managedProfile", "maxSessionListCostUsd"]
: provider.kind === "aws_agentcore"
? ["kind", "model", "agentCoreProfile", "maxEstimatedSessionCostUsd", "invocationLimits"]
: provider.kind === "acpx"
? ["kind", "agent", "model", "permissionPolicy", "profile"]
: ["kind", "model"],
"input.provider",
);
@@ -224,6 +361,8 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV
(provider.kind === "codex" && session.driverKind !== "codex_app_server")
|| (provider.kind === "opencode" && session.driverKind !== "opencode_server")
|| (provider.kind === "claude_managed" && session.driverKind !== "claude_managed_agents_api")
|| (provider.kind === "aws_agentcore" && session.driverKind !== "aws_agentcore_harness_api")
|| (provider.kind === "acpx" && session.driverKind !== "acpx_runtime")
) {
throw new NativeExecutionInputError("input.provider.kind does not match input.session.driverKind");
}
@@ -271,6 +410,116 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV
},
maxSessionListCostUsd: provider.maxSessionListCostUsd,
};
} else if (provider.kind === "aws_agentcore") {
if (providerModel === null) {
throw new NativeExecutionInputError("input.provider.model is required for aws_agentcore");
}
const profile = record(provider.agentCoreProfile, "input.provider.agentCoreProfile");
exactKeys(profile, [
"profileId", "region", "accountId", "harnessArn", "harnessVersion", "endpointArn",
"endpointQualifier", "agentRuntimeArn", "memoryArn", "memoryId", "invocationRoleArn",
"qualificationRevision", "eventExpiryDays",
], "input.provider.agentCoreProfile");
if (profile.eventExpiryDays !== 90) {
throw new NativeExecutionInputError("input.provider.agentCoreProfile.eventExpiryDays must be 90");
}
if (
typeof provider.maxEstimatedSessionCostUsd !== "number"
|| !Number.isFinite(provider.maxEstimatedSessionCostUsd)
|| provider.maxEstimatedSessionCostUsd <= 0
) {
throw new NativeExecutionInputError("input.provider.maxEstimatedSessionCostUsd must be a positive finite number");
}
const limits = record(provider.invocationLimits, "input.provider.invocationLimits");
exactKeys(limits, ["maxIterations", "maxOutputTokens", "timeoutSeconds"], "input.provider.invocationLimits");
const positiveInteger = (value: unknown, path: string): number => {
if (!Number.isSafeInteger(value) || Number(value) <= 0) {
throw new NativeExecutionInputError(`${path} must be a positive integer`);
}
return Number(value);
};
const maxIterations = positiveInteger(limits.maxIterations, "input.provider.invocationLimits.maxIterations");
const maxOutputTokens = positiveInteger(limits.maxOutputTokens, "input.provider.invocationLimits.maxOutputTokens");
const timeoutSeconds = positiveInteger(limits.timeoutSeconds, "input.provider.invocationLimits.timeoutSeconds");
if (maxIterations > 8) throw new NativeExecutionInputError("input.provider.invocationLimits.maxIterations exceeds 8");
if (maxOutputTokens > 4096) throw new NativeExecutionInputError("input.provider.invocationLimits.maxOutputTokens exceeds 4096");
if (timeoutSeconds > 300) throw new NativeExecutionInputError("input.provider.invocationLimits.timeoutSeconds exceeds 300");
parsedProvider = {
kind: "aws_agentcore",
model: providerModel,
agentCoreProfile: {
profileId: text(profile.profileId, "input.provider.agentCoreProfile.profileId"),
region: text(profile.region, "input.provider.agentCoreProfile.region"),
accountId: text(profile.accountId, "input.provider.agentCoreProfile.accountId"),
harnessArn: text(profile.harnessArn, "input.provider.agentCoreProfile.harnessArn"),
harnessVersion: text(profile.harnessVersion, "input.provider.agentCoreProfile.harnessVersion"),
endpointArn: text(profile.endpointArn, "input.provider.agentCoreProfile.endpointArn"),
endpointQualifier: text(profile.endpointQualifier, "input.provider.agentCoreProfile.endpointQualifier"),
agentRuntimeArn: text(profile.agentRuntimeArn, "input.provider.agentCoreProfile.agentRuntimeArn"),
memoryArn: text(profile.memoryArn, "input.provider.agentCoreProfile.memoryArn"),
memoryId: text(profile.memoryId, "input.provider.agentCoreProfile.memoryId"),
invocationRoleArn: text(profile.invocationRoleArn, "input.provider.agentCoreProfile.invocationRoleArn"),
qualificationRevision: text(profile.qualificationRevision, "input.provider.agentCoreProfile.qualificationRevision"),
eventExpiryDays: 90,
},
maxEstimatedSessionCostUsd: provider.maxEstimatedSessionCostUsd,
invocationLimits: { maxIterations, maxOutputTokens, timeoutSeconds },
};
} else if (provider.kind === "acpx") {
if (providerModel === null) {
throw new NativeExecutionInputError("input.provider.model is required for acpx");
}
if (provider.agent !== "pi" && provider.agent !== "claude" && provider.agent !== "codex") {
throw new NativeExecutionInputError("input.provider.agent must be pi, claude, or codex");
}
if (provider.permissionPolicy !== "interactive") {
throw new NativeExecutionInputError("input.provider.permissionPolicy must be interactive");
}
const profile = record(provider.profile, "input.provider.profile");
exactKeys(profile, [
"driverKind",
"protocolVersion",
"acpxVersion",
"agent",
"agentProfileVersion",
"agentServerPackage",
"agentServerVersion",
"agentRuntimePackage",
"agentRuntimeVersion",
"commandDigest",
], "input.provider.profile");
if (
profile.driverKind !== "acpx_runtime"
|| profile.protocolVersion !== 1
|| profile.acpxVersion !== "0.13.1"
|| profile.agent !== provider.agent
|| profile.agentProfileVersion !== 1
) {
throw new NativeExecutionInputError("input.provider.profile does not match the qualified ACPX v1 profile");
}
const runtimePackage = nullableText(profile.agentRuntimePackage, "input.provider.profile.agentRuntimePackage");
const runtimeVersion = nullableText(profile.agentRuntimeVersion, "input.provider.profile.agentRuntimeVersion");
if ((runtimePackage === null) !== (runtimeVersion === null)) {
throw new NativeExecutionInputError("input.provider.profile agent runtime package and version must both be present or null");
}
parsedProvider = {
kind: "acpx",
agent: provider.agent,
model: providerModel,
permissionPolicy: "interactive",
profile: {
driverKind: "acpx_runtime",
protocolVersion: 1,
acpxVersion: "0.13.1",
agent: provider.agent,
agentProfileVersion: 1,
agentServerPackage: text(profile.agentServerPackage, "input.provider.profile.agentServerPackage"),
agentServerVersion: text(profile.agentServerVersion, "input.provider.profile.agentServerVersion"),
agentRuntimePackage: runtimePackage,
agentRuntimeVersion: runtimeVersion,
commandDigest: text(profile.commandDigest, "input.provider.profile.commandDigest"),
},
};
} else if (provider.kind === "opencode") {
parsedProvider = { kind: "opencode", model: providerModel! };
} else {
@@ -313,8 +562,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV
};
});
return {
schema: NATIVE_EXECUTION_INPUT_SCHEMA,
const common = {
binding: {
companyId: text(binding.companyId, "input.binding.companyId"),
runId: text(binding.runId, "input.binding.runId"),
@@ -330,7 +578,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV
// Recovery must retain those runs, so derive the same bounded model
// prompt from their task fields instead of making them unrecoverable.
prompt: text(task.prompt ?? task.description ?? task.title, "input.task.prompt"),
workMode: "standard",
workMode: task.workMode as "standard" | "planning",
},
workspace: {
cwd: text(workspace.cwd, "input.workspace.cwd"),
@@ -340,8 +588,8 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV
},
session: {
normalizedSessionId: nullableText(session.normalizedSessionId, "input.session.normalizedSessionId"),
driverKind: session.driverKind,
protocolVersion: 1,
driverKind,
protocolVersion: 1 as const,
lifecyclePolicy,
},
provider: parsedProvider,
@@ -358,13 +606,41 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInputV
interactionResponses,
credentialBindings,
};
if (!isV2) {
return {
...common,
schema: NATIVE_EXECUTION_INPUT_SCHEMA_V1,
task: { ...common.task, workMode: "standard" },
};
}
return {
...common,
schema: NATIVE_EXECUTION_INPUT_SCHEMA,
executionMode,
planningContext,
};
}
export function buildNativeModelEnvelope(input: NativeExecutionInputV1): NativeModelEnvelopeV1 {
export function buildNativeModelEnvelope(input: NativeExecutionInput): NativeModelEnvelopeV1 | NativeModelEnvelopeV2 {
if (input.schema === NATIVE_EXECUTION_INPUT_SCHEMA_V1) {
return {
schema: NATIVE_MODEL_ENVELOPE_SCHEMA_V1,
task: structuredClone(input.task),
workspace: input.provider.kind === "claude_managed" || input.provider.kind === "aws_agentcore"
? null
: { cwd: input.workspace.cwd },
completionContract: structuredClone(input.completionContract.contract),
interactionResponses: structuredClone(input.interactionResponses),
};
}
return {
schema: NATIVE_MODEL_ENVELOPE_SCHEMA,
task: structuredClone(input.task),
workspace: input.provider.kind === "claude_managed" ? null : { cwd: input.workspace.cwd },
executionMode: input.executionMode,
planningContext: structuredClone(input.planningContext),
workspace: input.provider.kind === "claude_managed" || input.provider.kind === "aws_agentcore"
? null
: { cwd: input.workspace.cwd },
completionContract: structuredClone(input.completionContract.contract),
interactionResponses: structuredClone(input.interactionResponses),
};
@@ -28,6 +28,7 @@ export interface OpenNativeSessionInput {
export interface PersistedNativeSession {
backendKind: NativeSessionBackendDescriptor["kind"];
driverKind?: string | null;
sessionId: string;
identity: NativeRunIdentity;
providerSessionId?: string | null;
@@ -51,7 +52,10 @@ export interface NativeSession {
capabilities(): Promise<NativeSessionCapabilities>;
attachRun?(input: { identity: NativeRunIdentity }): Promise<void>;
events(input?: { afterCursor?: string | null }): AsyncIterable<PrpEvent>;
startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }>;
startTurn(input: {
message: NativeUserMessage;
requestedCollaborationMode?: "default" | "plan";
}): Promise<{ turnId: string; effectiveCollaborationMode?: "default" | "plan" }>;
steer?(input: { turnId: string; message: NativeUserMessage }): Promise<void>;
interrupt?(input: { turnId?: string; reason?: string }): Promise<void>;
cancel?(input: { reason: string }): Promise<void>;
@@ -37,6 +37,7 @@ export interface NativeSessionCapabilities {
runtimeRequestResolution?: boolean;
goals?: boolean;
threadLineage?: boolean;
collaborationModes?: Array<"default" | "plan">;
unsupported?: string[];
}
@@ -36,6 +36,11 @@ import type { CapabilityFixtureSeed } from "../mock-core/capability-control-plan
import { capabilitySemanticToolDescriptor } from "../semantic-tools/catalog.js";
import { CapabilitySemanticDispatcher } from "../semantic-tools/dispatcher.js";
import { CAPABILITY_DISCOVERY_GATEWAY_DEFINITIONS } from "../semantic-tools/discovery.js";
import {
resolveQualifiedAcpxProfile,
type QualifiedAcpxAgent,
} from "../drivers/acpx/qualified-profiles.js";
import { createSanitizedAcpxEnvironment } from "../drivers/acpx/environment.js";
import {
DURABLE_RECOVERY_FAULTS,
@@ -1252,6 +1257,12 @@ export class DurablePrpControlPlane {
command.result = structuredClone(result);
if (command.status === "completed" && command.type === "run.attach") {
this.#applyRunAttachment(command.payload);
this.store.save();
// The existing channel authenticated the previous immutable run
// binding. Force the runner to reconnect using the rotated ticket/lease
// identity before accepting any attached-run events or commands.
this.disconnectActiveRunner();
return;
}
this.store.save();
this.#sendNextCommand(connection);
@@ -1698,10 +1709,31 @@ export interface DurableEvalSessionInput {
explicitClaims: string[];
turnTimeoutMs: number;
toolExposure?: "eager" | "lazy";
provider?: "codex" | "opencode";
provider?: "codex" | "opencode" | "aws_agentcore" | "acpx";
acpxAgent?: QualifiedAcpxAgent;
acpxSidecarPath?: string;
opencodeVersion?: string;
opencodeCommand?: string;
opencodeProxyPath?: string;
agentCoreProfile?: {
profileId: string;
region: string;
accountId: string;
harnessArn: string;
harnessVersion: string;
endpointArn: string;
endpointQualifier: string;
agentRuntimeArn: string;
memoryArn: string;
memoryId: string;
invocationRoleArn: string;
qualificationRevision: string;
eventExpiryDays: 90;
maxEstimatedSessionCostUsd: number;
maxIterations: number;
maxOutputTokens: number;
timeoutSeconds: number;
};
}
export type DurableEvalInfrastructureFailureClass =
@@ -1782,6 +1814,20 @@ function projectedSemanticResult(operationId: unknown, result: unknown): unknown
return result;
}
function sanitizedAwsEvalEnvironment(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const result: NodeJS.ProcessEnv = {};
for (const key of [
"PATH", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "SSL_CERT_FILE", "SSL_CERT_DIR",
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy",
"RUST_BACKTRACE", "AWS_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_CONFIG_FILE",
"AWS_SHARED_CREDENTIALS_FILE", "AWS_WEB_IDENTITY_TOKEN_FILE", "AWS_ROLE_ARN",
"AWS_ROLE_SESSION_NAME", "AWS_CONTAINER_CREDENTIALS_FULL_URI", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
]) {
if (typeof source[key] === "string") result[key] = source[key];
}
return result;
}
/** Production-topology eval: the only Paperclip process in the sandbox is runnerd. */
export async function runDurableEvalSession(input: DurableEvalSessionInput): Promise<Record<string, unknown>> {
const identity = {
@@ -1864,15 +1910,45 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
"app-server",
];
const providerKind = input.provider ?? "codex";
if (providerKind === "aws_agentcore" && input.agentCoreProfile === undefined) {
throw new Error("AWS AgentCore eval requires an immutable qualified profile snapshot");
}
const opencodeProxyPath = input.opencodeProxyPath
?? fileURLToPath(new URL("../cli/opencode-app-server-proxy.js", import.meta.url));
const acpxSidecarPath = input.acpxSidecarPath
?? fileURLToPath(new URL("../cli/acpx-runtime-sidecar.js", import.meta.url));
const acpxProfile = providerKind === "acpx"
? resolveQualifiedAcpxProfile(input.acpxAgent ?? "pi", input.model)
: null;
core.queueCommand("run.prepare", {
authorizedTools: {
schema: "paperclip.runner.authorized-tools.v1", schemaVersion: 1,
catalogDigest: `sha256:${createHash("sha256").update(canonicalJson(operations)).digest("hex")}`,
operations: exposedOperations,
},
provider: {
provider: providerKind === "aws_agentcore" ? {
kind: "aws_agentcore",
model: input.model,
...input.agentCoreProfile,
} : providerKind === "acpx" ? {
kind: "acpx",
agent: acpxProfile!.agent,
model: input.model,
acpxVersion: acpxProfile!.acpxVersion,
agentServerPackage: acpxProfile!.agentServerPackage,
agentServerVersion: acpxProfile!.agentServerVersion,
agentRuntimePackage: acpxProfile!.agentRuntimePackage,
agentRuntimeVersion: acpxProfile!.agentRuntimeVersion,
commandDigest: acpxProfile!.commandDigest,
sidecarCommand: process.execPath,
sidecarArgs: [acpxSidecarPath],
runtimeDirectory: resolve(root, "acpx"),
normalizedSessionId: identity.normalizedSessionId,
runId: identity.runId,
cwd: tmpdir(),
instructions: "You are a Paperclip agent. Use only the supplied Paperclip tools for company state. Follow the user request, then reply concisely. Do not use shell or filesystem tools.",
permissionPolicy: "interactive",
} : {
kind: providerKind,
command: providerKind === "opencode" ? process.execPath : process.env.PAPERCLIP_CODEX_COMMAND ?? "codex",
args: providerKind === "opencode" ? [opencodeProxyPath] : providerArgs,
@@ -1897,7 +1973,11 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
PAPERCLIP_RUN_ID: identity.runId,
PAPERCLIP_NORMALIZED_SESSION_ID: identity.normalizedSessionId,
}
: process.env,
: providerKind === "aws_agentcore"
? sanitizedAwsEvalEnvironment(process.env)
: providerKind === "acpx"
? createSanitizedAcpxEnvironment(process.env, acpxProfile!.agent)
: process.env,
});
try {
const deadline = Date.now() + input.turnTimeoutMs;
@@ -1945,9 +2025,14 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
let assistantText = "";
let semanticResult: Record<string, unknown> | null = null;
let usage: Record<string, number> = {};
let providerSessionId: string | null = null;
const projectedCalls = new Map<string, string>();
for (const record of records) {
const payload = record.payload as Record<string, unknown> | undefined;
if (record.eventType === "harness.ready") {
const descriptor = payload?.providerDescriptor as Record<string, unknown> | undefined;
if (typeof descriptor?.providerSessionId === "string") providerSessionId = descriptor.providerSessionId;
}
if (record.eventType === "run.result.proposed" && payload && semanticResult === null) {
semanticResult = structuredClone(payload);
}
@@ -1990,6 +2075,14 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
};
}
}
if (record.eventType === "item.delta") {
const delta = payload?.delta;
if (typeof delta === "string") assistantText += delta;
}
if (record.eventType === "item.completed") {
const item = payload?.item as Record<string, unknown> | undefined;
if (item?.type === "agentMessage" && typeof item.text === "string") assistantText = item.text;
}
if (record.eventType === "usage.reported") {
const runDelta = payload?.runDelta as Record<string, number> | undefined;
if (runDelta) {
@@ -2033,12 +2126,28 @@ export async function runDurableEvalSession(input: DurableEvalSessionInput): Pro
turn: { turnId: identity.turnId, status: "completed", assistantText },
snapshot: {
schema: "paperclip.capability.live-session.v1", revision: authority.snapshot().revision,
sessionId: identity.normalizedSessionId, providerThreadId: "withheld", providerSessionId: null,
providerModel: { id: input.model, provider: providerKind === "opencode" ? input.model.split("/", 1)[0] : "openai" }, status: "idle", activeTurnId: null,
sessionId: identity.normalizedSessionId, providerThreadId: "withheld", providerSessionId,
providerModel: {
id: input.model,
provider: providerKind === "opencode"
? input.model.split("/", 1)[0]
: providerKind === "aws_agentcore"
? "amazon-bedrock"
: providerKind === "acpx"
? acpxProfile!.agent === "pi" ? "openrouter" : acpxProfile!.agent === "claude" ? "anthropic" : "openai"
: "openai",
},
modelHistory: [{
requestedModel: input.model,
effectiveModel: input.model,
at: now,
source: "provider_verified",
}],
status: "idle", activeTurnId: null,
semanticResult,
createdAt: now, updatedAt: now,
authority: { active: true, runId: identity.runId, companyId, actorId: input.actorId, taskId: input.taskId, sessionId: identity.normalizedSessionId, scenarioId: input.attemptId, capabilities: input.capabilities, explicitClaims: input.explicitClaims },
config: { workingDirectory: tmpdir(), requestedModel: input.model, provider: providerKind, driver: providerKind === "opencode" ? "opencode_server" : "codex_app_server", providerVersion: providerKind === "opencode" ? input.opencodeVersion ?? "1.18.17" : null, scenario: { id: input.attemptId, claims: input.capabilities }, capabilities: input.capabilities, explicitClaims: input.explicitClaims, seedState: initialState, turnTimeoutMs: input.turnTimeoutMs },
config: { workingDirectory: providerKind === "aws_agentcore" ? null : tmpdir(), requestedModel: input.model, provider: providerKind, driver: providerKind === "opencode" ? "opencode_server" : providerKind === "aws_agentcore" ? "aws_agentcore_harness_api" : providerKind === "acpx" ? "acpx_runtime" : "codex_app_server", providerVersion: providerKind === "opencode" ? input.opencodeVersion ?? "1.18.17" : providerKind === "aws_agentcore" ? input.agentCoreProfile?.harnessVersion ?? null : providerKind === "acpx" ? acpxProfile!.acpxVersion : null, ...(acpxProfile === null ? {} : { acpxAgent: acpxProfile.agent, acpxProfile }), scenario: { id: input.attemptId, claims: input.capabilities }, capabilities: input.capabilities, explicitClaims: input.explicitClaims, seedState: initialState, turnTimeoutMs: input.turnTimeoutMs },
mockState: finalState,
transcript: [
{ id: "transcript-user", role: "user", text: input.prompt, turnId: identity.turnId, at: now },
@@ -2122,6 +2231,20 @@ export async function runDurableRecoveryRecovery(
`Durable recovery runner exited with code ${String(result.code)}: ${result.stderr.trim()}`,
);
}
if (fault === "revoke") {
// The runner can exit immediately after it sends the final rejection while
// the mock core is still committing that HTTP request. Wait for the
// durable command result before stopping the server and reading evidence.
const deadline = Date.now() + 1_000;
while (
core.store.state.commands.find(
(command) => command.commandId === "command_after_revoke",
)?.status === "pending" &&
Date.now() < deadline
) {
await new Promise((resolveDelay) => setTimeout(resolveDelay, 5));
}
}
} finally {
await core.stop();
}
@@ -97,7 +97,7 @@ function publicCompanyState(serialized: string): CapabilityJsonValue {
function boundary(kind: string): string {
if (kind === "tool_call" || kind === "tool_result" || kind === "tool_discovery") return "semantic tool";
if (kind === "provider_event") return "Codex app-server";
if (kind === "provider_event") return "provider harness";
if (kind === "process") return "runnerd process";
if (kind === "interaction") return "mock control plane";
return "live session";
@@ -142,6 +142,7 @@ export function projectCapabilityDevtools(snapshot: CapabilityLiveSessionSnapsho
runtime: safe({
status: snapshot.status,
activeTurnId: snapshot.activeTurnId,
providerSessionId: snapshot.providerSessionId,
providerModel: snapshot.providerModel ?? null,
process: snapshot.process === null ? null : {
runnerPid: snapshot.process.runnerPid,
@@ -0,0 +1,798 @@
import { createHash, randomBytes } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import type { AcpPermissionDecision, AcpPermissionRequest, AcpRuntimeEvent } from "acpx/runtime";
import {
CODEX_BLOCK_TOOL_NAME,
CODEX_COMPLETION_TOOL_NAME,
createCodexTaskEnvelope,
type CodexTaskEnvelope,
} from "../../contracts/codex.js";
import {
HarnessCapabilityUnavailableError,
HarnessStaleTurnError,
harnessRuntimeRequestOutcome,
parseHarnessRuntimeRequestResolution,
type HarnessDriver,
type HarnessDriverConfigValidation,
type HarnessDriverDescriptor,
type HarnessRuntimeRequest,
type HarnessRuntimeRequestResolution,
type HarnessSession,
type HarnessSessionRecoveryResult,
type HarnessTranscriptSnapshot,
type OpenHarnessSessionInput,
type PersistedHarnessSession,
} from "../../contracts/harness-driver.js";
import type { NativeSessionCapabilities, NativeUserMessage } from "../../contracts/types.js";
import { paperclipWorkspaceFileReferencesFromText } from "../../live/workspace-file-reference.js";
import type { PrpEvent, PrpStructuredRunResult } from "../../protocol/replay-contract.js";
import { validatePrpStructuredRunResult } from "../../protocol/replay-contract.js";
import {
canonicalProviderEventsFromAcpxRuntimeEvent,
providerFamilyCapabilities,
} from "../../provider-events.js";
import { canonicalRunnerToolName } from "../runner-tool-bridge.js";
import {
AcpxRuntimeHost,
type AcpxRuntimeFactory,
type AcpxRuntimeIdentity,
type AcpxRuntimePermissionContext,
} from "./acpx-runtime-host.js";
import {
ACPX_DRIVER_KIND,
QUALIFIED_ACPX_VERSION,
resolveQualifiedAcpxProfile,
type QualifiedAcpxAgent,
} from "./qualified-profiles.js";
type DynamicToolHandler = (call: {
tool: string;
callId: string;
threadId: string;
turnId: string;
arguments: unknown;
}) => Promise<unknown>;
export interface AcpxRuntimeDriverOptions {
agent: QualifiedAcpxAgent;
model: string;
runtimeDirectory: string;
taskEnvelope?: CodexTaskEnvelope;
environment?: NodeJS.ProcessEnv;
dynamicTools?: readonly Readonly<Record<string, unknown>>[];
dynamicToolHandler?: DynamicToolHandler;
runnerInstanceId?: string;
runtimeFactory?: AcpxRuntimeFactory;
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
onDiagnostic?: (message: string) => void;
now?: () => Date;
}
const BASE_CAPABILITIES: NativeSessionCapabilities = {
resume: true,
typedEvents: true,
typedEventFamilies: providerFamilyCapabilities({
plan: "available",
tool_execution: "available",
model_identity: "available",
review: "available",
provider_notice: "available",
artifact: "policy_disabled",
}),
steering: false,
interruption: true,
structuredResult: true,
read: true,
reconciliation: true,
usage: true,
dynamicTools: true,
runtimeRequestResolution: true,
goals: false,
threadLineage: false,
unsupported: ["steering", "goals", "threadLineage"],
};
interface PendingPermission {
request: HarnessRuntimeRequest;
settle: (decision: AcpPermissionDecision | undefined) => void;
}
export class AcpxRuntimeDriver implements HarnessDriver {
readonly #options: AcpxRuntimeDriverOptions;
constructor(options: AcpxRuntimeDriverOptions) {
this.#options = options;
}
async descriptor(): Promise<HarnessDriverDescriptor> {
return {
kind: ACPX_DRIVER_KIND,
displayName: `${displayAgent(this.#options.agent)} via ACPX`,
version: QUALIFIED_ACPX_VERSION,
protocolVersion: "acp/v1",
capabilities: structuredClone(BASE_CAPABILITIES),
};
}
async validateConfig(value: unknown): Promise<HarnessDriverConfigValidation> {
const config = record(value);
const agent = text(config.agent);
const model = text(config.model);
const issues: Array<{ path: string; code: string; message: string }> = [];
if (!["pi", "claude", "codex"].includes(agent)) {
issues.push({ path: "agent", code: "invalid_agent", message: "ACPX agent must be pi, claude, or codex." });
} else {
try { resolveQualifiedAcpxProfile(agent as QualifiedAcpxAgent, model); }
catch (error) { issues.push({ path: "model", code: "invalid_model", message: String(error) }); }
}
return issues.length === 0
? { ok: true, config: { agent, model, permissionPolicy: "interactive" }, issues: [] }
: { ok: false, config: null, issues };
}
async openSession(input: OpenHarnessSessionInput): Promise<HarnessSession> {
return this.#open(input, null);
}
async recoverSession(snapshot: PersistedHarnessSession): Promise<HarnessSessionRecoveryResult> {
if (
snapshot.driverKind !== ACPX_DRIVER_KIND
|| !snapshot.runId
|| !snapshot.normalizedSessionId
|| snapshot.providerIdentity?.kind !== "acpx"
) return { recovered: false, reason: "persisted ACPX session identity is incomplete" };
try {
const workspace = (await readFile(
join(sessionRoot(this.#options.runtimeDirectory, snapshot.normalizedSessionId), "workspace"),
"utf8",
)).trim();
const session = await this.#open({
runId: snapshot.runId,
normalizedSessionId: snapshot.normalizedSessionId,
workingDirectory: workspace,
}, snapshot);
return { recovered: true, session };
} catch (error) {
return { recovered: false, reason: redact(String(error), this.#options.environment) };
}
}
async #open(input: OpenHarnessSessionInput, snapshot: PersistedHarnessSession | null): Promise<HarnessSession> {
let session: AcpxHarnessSession | null = null;
const host = await AcpxRuntimeHost.open({
runtimeDirectory: this.#options.runtimeDirectory,
normalizedSessionId: input.normalizedSessionId,
workingDirectory: input.workingDirectory,
agent: this.#options.agent,
model: this.#options.model,
...(snapshot?.providerIdentity?.kind === "acpx"
? { expectedIdentity: snapshot.providerIdentity }
: {}),
environment: this.#options.environment,
dynamicTools: this.#options.dynamicTools,
dynamicToolHandler: (call) => {
if (!session) throw new Error("ACPX session is not ready for semantic tool calls");
return session.dispatchTool(call);
},
onPermissionRequest: (context) => {
if (!session) return Promise.resolve({ outcome: "reject_once" });
return session.requestPermission(context);
},
runtimeFactory: this.#options.runtimeFactory,
onSpawn: this.#options.onSpawn,
onDiagnostic: this.#options.onDiagnostic,
});
try {
if (snapshot?.providerIdentity?.kind === "acpx") {
assertRecoveryIdentity(snapshot.providerIdentity, host.identity(), input.workingDirectory);
}
session = new AcpxHarnessSession({
host,
runId: input.runId,
normalizedSessionId: input.normalizedSessionId,
workingDirectory: input.workingDirectory,
taskEnvelope: this.#options.taskEnvelope ?? createCodexTaskEnvelope({ objective: "Complete the supplied task." }),
dynamicToolHandler: this.#options.dynamicToolHandler,
runnerInstanceId: this.#options.runnerInstanceId ?? `paperclip-acpx-${input.runId}`,
snapshot,
now: this.#options.now ?? (() => new Date()),
});
return session;
} catch (error) {
await host.close({ reason: "ACPX session initialization failed" }).catch(() => {});
throw error;
}
}
}
class AcpxHarnessSession implements HarnessSession {
readonly #host: AcpxRuntimeHost;
#runId: string;
readonly #normalizedSessionId: string;
readonly #workingDirectory: string;
readonly #taskEnvelope: CodexTaskEnvelope;
readonly #dynamicToolHandler?: DynamicToolHandler;
readonly #runnerInstanceId: string;
readonly #now: () => Date;
readonly #events = new AsyncQueue<PrpEvent>();
readonly #transcript: PrpEvent[] = [];
readonly #terminalTurns = new Map<string, string>();
readonly #pendingPermissions = new Map<string, PendingPermission>();
readonly #emittedFileReferences = new Set<string>();
#sourceSequence: number;
#activeTurnId: string | null;
#activeTurnAbort: AbortController | null = null;
#result: PrpStructuredRunResult | null;
#resultFingerprint: string | null;
#resultCallId: string | null;
#resultTurnId: string | null;
#usage: Record<string, unknown> | null = null;
#assistantText = "";
#thinking = false;
#closed = false;
constructor(input: {
host: AcpxRuntimeHost;
runId: string;
normalizedSessionId: string;
workingDirectory: string;
taskEnvelope: CodexTaskEnvelope;
dynamicToolHandler?: DynamicToolHandler;
runnerInstanceId: string;
snapshot: PersistedHarnessSession | null;
now: () => Date;
}) {
this.#host = input.host;
this.#runId = input.runId;
this.#normalizedSessionId = input.normalizedSessionId;
this.#workingDirectory = resolve(input.workingDirectory);
this.#taskEnvelope = input.taskEnvelope;
this.#dynamicToolHandler = input.dynamicToolHandler;
this.#runnerInstanceId = input.runnerInstanceId;
this.#now = input.now;
this.#sourceSequence = input.snapshot?.lastSourceSequence ?? 0;
this.#activeTurnId = input.snapshot?.activeTurnId ?? null;
this.#result = input.snapshot?.semanticResult?.result ?? null;
this.#resultFingerprint = input.snapshot?.semanticResult?.fingerprint ?? null;
this.#resultCallId = input.snapshot?.semanticResult?.callId ?? null;
this.#resultTurnId = input.snapshot?.semanticResult?.turnId ?? null;
for (const terminal of input.snapshot?.terminalTurns ?? []) {
this.#terminalTurns.set(terminal.turnId, terminal.fingerprint);
}
}
ids() {
const identity = this.#host.identity();
return {
driverSessionId: identity.acpxRecordId,
providerSessionId: identity.agentSessionId,
displayId: identity.agentSessionId,
};
}
events(): AsyncIterable<PrpEvent> { return this.#events; }
attachRun(input: { runId: string }): void {
if (this.#activeTurnId !== null || this.#pendingPermissions.size > 0) throw new Error("acpx_run_attach_busy");
if (!input.runId.trim()) throw new Error("acpx_run_attach_invalid");
this.#runId = input.runId;
this.#result = null;
this.#resultFingerprint = null;
this.#resultCallId = null;
this.#resultTurnId = null;
this.#assistantText = "";
this.#emit("run.attached", { runId: input.runId, sameSession: true });
}
async startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }> {
if (this.#closed) throw new Error("ACPX session is closed");
if (this.#activeTurnId !== null) throw new Error("ACPX session already has an active turn");
const turnId = `turn-${randomBytes(12).toString("hex")}`;
this.#activeTurnId = turnId;
this.#assistantText = "";
this.#activeTurnAbort = new AbortController();
this.#emit("turn.submitted", { envelopeSchema: this.#taskEnvelope.schema, text: input.message.text });
this.#emit("turn.accepted", { turnId }, { turnId });
this.#emit("turn.started", { status: "inProgress" }, { turnId });
const turn = this.#host.startTurn({
requestId: `${this.#runId}:${turnId}`,
text: JSON.stringify({ task: this.#taskEnvelope, message: input.message.text }),
signal: this.#activeTurnAbort.signal,
});
void this.#pumpTurn(turnId, turn);
return { turnId };
}
async interrupt(input: { turnId?: string; reason?: string }): Promise<void> {
if (input.turnId && this.#activeTurnId !== input.turnId) throw new HarnessStaleTurnError(input.turnId);
if (this.#activeTurnId === null) throw new HarnessCapabilityUnavailableError("interruption", "there is no active ACPX turn");
await this.#host.cancel(input.reason);
}
pendingRuntimeRequests(): HarnessRuntimeRequest[] {
return [...this.#pendingPermissions.values()].map((pending) => structuredClone(pending.request));
}
async resolveRuntimeRequest(input: {
requestId: string;
turnId: string;
resolution: HarnessRuntimeRequestResolution;
}): Promise<void> {
const pending = this.#pendingPermissions.get(input.requestId);
if (!pending) throw new HarnessCapabilityUnavailableError("runtime request resolution", `request ${input.requestId} is no longer pending`);
if (pending.request.turnId !== input.turnId || this.#activeTurnId !== input.turnId) {
throw new HarnessStaleTurnError(input.turnId);
}
const resolution = parseHarnessRuntimeRequestResolution(pending.request.requestKind, input.resolution);
const decision = permissionDecision(resolution.action);
this.#pendingPermissions.delete(input.requestId);
this.#emit("runtime_request.resolved", harnessRuntimeRequestOutcome(pending.request, { action: resolution.action }), {
turnId: input.turnId,
itemId: pending.request.itemId,
});
pending.settle(decision);
}
async requestPermission(context: AcpxRuntimePermissionContext): Promise<AcpPermissionDecision | undefined> {
const turnId = this.#activeTurnId;
if (!turnId || context.signal.aborted) return { outcome: "cancel" };
const raw = record(context.request.raw);
const toolCall = record(raw.toolCall);
const requestId = safeId(text(toolCall.toolCallId, text(toolCall.id)), `permission-${randomBytes(8).toString("hex")}`);
if (this.#pendingPermissions.has(requestId)) return { outcome: "reject_once" };
const inferred = text(context.request.inferredKind).toLowerCase();
const requestKind = /write|edit|delete|move/.test(inferred) ? "file_approval"
: /execute|terminal/.test(inferred) ? "command_approval"
: "permission_approval";
const runtimeRequest: HarnessRuntimeRequest = {
requestId,
requestKind,
method: "acp/session/request_permission",
turnId,
itemId: safeId(text(toolCall.toolCallId, requestId), requestId),
status: "pending",
prompt: text(toolCall.title, `Allow ${inferred || "requested capability"}?`).slice(0, 4000),
details: {
toolKind: inferred || "unknown",
toolTitle: text(toolCall.title).slice(0, 4000) || null,
optionCount: Array.isArray(raw.options) ? Math.min(raw.options.length, 32) : 0,
},
};
this.#emit("runtime_request.created", { request: { ...runtimeRequest, type: runtimeRequest.method } }, {
turnId,
itemId: runtimeRequest.itemId,
});
return new Promise((settle) => {
const abort = () => {
const pending = this.#pendingPermissions.get(requestId);
if (!pending) return;
this.#pendingPermissions.delete(requestId);
this.#emit("runtime_request.cancelled", harnessRuntimeRequestOutcome(runtimeRequest, { reason: "provider_cancelled" }), {
turnId,
itemId: runtimeRequest.itemId,
});
settle({ outcome: "cancel" });
};
context.signal.addEventListener("abort", abort, { once: true });
this.#pendingPermissions.set(requestId, {
request: runtimeRequest,
settle: (decision) => {
context.signal.removeEventListener("abort", abort);
settle(decision);
},
});
});
}
async dispatchTool(call: { tool: string; callId: string; arguments: unknown }): Promise<unknown> {
const turnId = this.#activeTurnId;
if (!turnId) throw new Error("ACPX semantic tool call is not bound to an active turn");
const tool = canonicalRunnerToolName(call.tool);
this.#emit("item.started", {
kind: "dynamicToolCall",
item: { type: "tool_call", id: call.callId, name: tool, arguments: bounded(call.arguments) },
}, { turnId, itemId: call.callId });
if (tool === CODEX_COMPLETION_TOOL_NAME || tool === CODEX_BLOCK_TOOL_NAME) {
const validation = validatePrpStructuredRunResult(call.arguments);
if (!validation.ok) throw new Error("Invalid semantic result");
if (
(tool === CODEX_BLOCK_TOOL_NAME && validation.result.reportedWorkDisposition !== "blocked")
|| (tool === CODEX_COMPLETION_TOOL_NAME && validation.result.reportedWorkDisposition === "blocked")
) throw new Error("Semantic result disposition does not match the terminal tool");
if (validation.result.completionClaim.contractRevision !== this.#taskEnvelope.completionContract.revision) {
throw new Error("Semantic result completion contract revision does not match");
}
const fingerprint = canonicalJson(validation.result);
if (this.#resultFingerprint && this.#resultFingerprint !== fingerprint) throw new Error("A different semantic result was already committed");
if (!this.#resultFingerprint) {
this.#result = structuredClone(validation.result);
this.#resultFingerprint = fingerprint;
this.#resultCallId = call.callId;
this.#resultTurnId = turnId;
this.#emit("run.result.proposed", validation.result, { turnId, itemId: call.callId });
}
this.#emit("item.completed", {
kind: "dynamicToolCall",
item: { type: "tool_result", id: call.callId, tool_use_id: call.callId, result: "Semantic completion accepted." },
}, { turnId, itemId: call.callId });
return { accepted: true };
}
if (!this.#dynamicToolHandler) throw new Error("Unsupported Paperclip operation");
try {
const result = await this.#dynamicToolHandler({
tool,
callId: call.callId,
threadId: this.#host.identity().agentSessionId,
turnId,
arguments: call.arguments,
});
this.#emit("item.completed", {
kind: "dynamicToolCall",
item: { type: "tool_result", id: call.callId, tool_use_id: call.callId, result: bounded(result) },
}, { turnId, itemId: call.callId });
return result;
} catch (error) {
this.#emit("item.completed", {
kind: "dynamicToolCall",
item: { type: "tool_result", id: call.callId, tool_use_id: call.callId, error: redact(String(error)), is_error: true },
}, { turnId, itemId: call.callId });
throw error;
}
}
async read(): Promise<Record<string, unknown>> {
return { identity: this.#host.identity(), status: await this.#host.status() };
}
async reconcile(): Promise<Record<string, unknown>> {
const status = await this.#host.status();
const identity = this.#host.identity();
if (status.agentSessionId && status.agentSessionId !== identity.agentSessionId) {
throw new Error("ACPX reconciliation returned a different agent session");
}
return { identity, status };
}
async usage(): Promise<Record<string, unknown> | null> {
if (this.#usage) return structuredClone(this.#usage);
const status = await this.#host.status();
return status.usage ? bounded(status.usage) : null;
}
async transcript(): Promise<HarnessTranscriptSnapshot> {
return {
schema: "paperclip-runner/harness-transcript/v1",
complete: true,
eventCount: this.#transcript.length,
events: structuredClone(this.#transcript),
omissionReason: null,
};
}
async snapshot(): Promise<PersistedHarnessSession> {
const identity = this.#host.identity();
return {
driverKind: ACPX_DRIVER_KIND,
driverSessionId: identity.acpxRecordId,
providerSessionId: identity.agentSessionId,
providerIdentity: {
kind: "acpx",
normalizedSessionId: this.#normalizedSessionId,
acpxRecordId: identity.acpxRecordId,
backendSessionId: identity.backendSessionId,
agentSessionId: identity.agentSessionId,
profileDigest: identity.profile.commandDigest,
workspaceDigest: workspaceDigest(this.#workingDirectory),
requestedModel: identity.requestedModel,
effectiveModel: identity.effectiveModel,
},
runId: this.#runId,
normalizedSessionId: this.#normalizedSessionId,
activeTurnId: this.#activeTurnId,
semanticResult: this.#result && this.#resultFingerprint && this.#resultTurnId
? { result: structuredClone(this.#result), fingerprint: this.#resultFingerprint, callId: this.#resultCallId, turnId: this.#resultTurnId }
: null,
terminalTurns: [...this.#terminalTurns].map(([turnId, fingerprint]) => ({ turnId, fingerprint })),
pendingRuntimeRequests: this.pendingRuntimeRequests(),
lastSourceSequence: this.#sourceSequence,
};
}
async close(input: { reason: string; force?: boolean }): Promise<void> {
if (this.#closed) return;
this.#closed = true;
for (const pending of this.#pendingPermissions.values()) {
this.#emit("runtime_request.cancelled", harnessRuntimeRequestOutcome(pending.request, { reason: "session_closed" }), {
turnId: pending.request.turnId,
itemId: pending.request.itemId,
});
pending.settle({ outcome: "cancel" });
}
this.#pendingPermissions.clear();
this.#events.close();
await this.#host.close({ reason: input.reason, discardPersistentState: false });
}
async #pumpTurn(turnId: string, turn: ReturnType<AcpxRuntimeHost["startTurn"]>): Promise<void> {
try {
let index = 0;
for await (const event of turn.events) this.#mapRuntimeEvent(event, turnId, ++index);
const result = await turn.result;
if (this.#thinking) {
this.#emit("item.completed", { kind: "thinking", text: "Reasoning completed." }, { turnId, itemId: `${turnId}:thinking` });
this.#thinking = false;
}
const status = await this.#host.status();
this.#captureUsage(status.usage, turnId, `${turnId}:usage-final`);
const effectiveModel = status.models?.currentModelId;
const identity = this.#host.identity();
if (effectiveModel && effectiveModel !== identity.effectiveModel) {
this.#emit("model.route.changed", {
schema: "paperclip.model.route_changed.v1",
routeId: `${turnId}:model-route`,
provider: biller(identity.profile.agent),
requestedModel: identity.requestedModel,
fromModel: identity.effectiveModel,
effectiveModel,
reason: "ACP agent reported an effective model change",
}, { turnId, itemId: `${turnId}:model-route` });
}
if (result.status === "completed") {
this.#terminalTurns.set(turnId, canonicalJson({ status: "completed", semanticResult: this.#resultFingerprint }));
this.#emit("turn.completed", { status: "completed", stopReason: result.stopReason ?? null }, { turnId });
} else if (result.status === "cancelled") {
this.#terminalTurns.set(turnId, canonicalJson({ status: "interrupted" }));
this.#emit("turn.interrupted", { status: "interrupted", stopReason: result.stopReason ?? "cancelled" }, { turnId });
} else {
this.#terminalTurns.set(turnId, canonicalJson({ status: "failed" }));
this.#emit("provider.notice.recorded", {
schema: "paperclip.provider.notice.v1",
noticeId: `${turnId}:failure`,
severity: "error",
category: "acpx_turn_failed",
scope: "turn",
recoverable: result.error.retryable ?? false,
userActionable: true,
summary: redact(result.error.message, this.#optionsEnvironment()).slice(0, 4000),
}, { turnId, itemId: `${turnId}:failure` });
this.#emit("turn.failed", { status: "failed", error: { code: result.error.code ?? null, message: redact(result.error.message) } }, { turnId });
}
} catch (error) {
this.#terminalTurns.set(turnId, canonicalJson({ status: "failed" }));
this.#emit("turn.failed", { status: "failed", error: { message: redact(String(error)) } }, { turnId });
} finally {
if (this.#activeTurnId === turnId) this.#activeTurnId = null;
this.#activeTurnAbort = null;
}
}
#mapRuntimeEvent(event: AcpRuntimeEvent, turnId: string, index: number): void {
const itemId = event.type === "tool_call" && event.toolCallId
? safeId(event.toolCallId, `${turnId}:acp:${index}`)
: `${turnId}:acp:${index}`;
if (event.type === "text_delta") {
if (event.stream === "thought" || event.tag === "agent_thought_chunk") {
if (!this.#thinking) {
this.#thinking = true;
this.#emit("item.started", { kind: "thinking", text: "Reasoning started." }, { turnId, itemId: `${turnId}:thinking` });
}
return;
}
const output = event.text.slice(0, 64 * 1024);
this.#assistantText = `${this.#assistantText}${output}`.slice(-256 * 1024);
this.#emit("item.delta", { kind: "agent_message", text: output }, { turnId, itemId });
for (const reference of paperclipWorkspaceFileReferencesFromText(this.#workingDirectory, output, turnId)) {
if (this.#emittedFileReferences.has(reference.referenceId)) continue;
this.#emittedFileReferences.add(reference.referenceId);
this.#emit("workspace.file.referenced", { ...reference }, { turnId, itemId: reference.referenceId });
}
}
if (event.type === "status" && event.tag === "usage_update") {
this.#captureUsage({
cumulative: event.breakdown,
cost: event.cost,
}, turnId, itemId);
}
if (event.type === "tool_call") this.#emitToolLocations(event, turnId, itemId);
for (const canonical of canonicalProviderEventsFromAcpxRuntimeEvent(event, itemId)) {
this.#emit(canonical.eventType, canonical.payload, { turnId, itemId: canonical.itemId });
}
if (event.type === "error") {
this.#emit("provider.notice.recorded", {
schema: "paperclip.provider.notice.v1",
noticeId: itemId,
severity: "error",
category: event.code ?? "acpx_runtime_error",
scope: "turn",
recoverable: event.retryable ?? false,
userActionable: true,
summary: redact(event.message).slice(0, 4000),
}, { turnId, itemId });
}
}
#emitToolLocations(event: Extract<AcpRuntimeEvent, { type: "tool_call" }>, turnId: string, itemId: string): void {
const files = (event.locations ?? []).slice(0, 2_000).flatMap((location): Record<string, unknown>[] => {
const candidate = record(location);
const path = safeWorkspacePath(text(candidate.path, text(candidate.uri)));
if (!path) return [];
return [{ path, operation: toolOperation(event.kind), previousPath: null, additions: null, deletions: null, binary: false, diff: null }];
});
if (files.length === 0) return;
const payload = {
schema: "paperclip.workspace.diff.v1",
changeSetId: `${turnId}:workspace:${itemId}`,
revision: 1,
source: "harness_reported",
complete: canonicalToolTerminal(event.status),
files,
totals: { files: files.length, additions: null, deletions: null },
patchArtifactRef: null,
};
this.#emit(canonicalToolTerminal(event.status) ? "workspace.diff.recorded" : "workspace.change.updated", payload, {
turnId,
itemId: `${turnId}:workspace:${itemId}`,
});
}
#captureUsage(value: unknown, turnId: string, itemId: string): void {
const usage = record(value);
if (Object.keys(usage).length === 0) return;
const identity = this.#host.identity();
this.#usage = bounded({
...usage,
provider: "acpx",
agent: identity.profile.agent,
biller: biller(identity.profile.agent),
requestedModel: identity.requestedModel,
effectiveModel: identity.effectiveModel,
acpxVersion: identity.profile.acpxVersion,
agentServerVersion: identity.profile.agentServerVersion,
});
this.#emit("item.completed", { kind: "usage", usage: this.#usage }, { turnId, itemId });
}
#emit(eventType: PrpEvent["eventType"], payload: Record<string, unknown>, refs: { turnId?: string; itemId?: string } = {}): void {
const sourceSeq = ++this.#sourceSequence;
const event: PrpEvent = {
schema: "paperclip.prp.event.v1",
sourceEventId: `${this.#runnerInstanceId}:${this.#runId}:${sourceSeq}`,
sourceSeq,
sourceInstanceId: this.#runnerInstanceId,
sourceKind: "runner",
runId: this.#runId,
normalizedSessionId: this.#normalizedSessionId,
...(refs.turnId ? { turnId: refs.turnId } : {}),
...(refs.itemId ? { itemId: refs.itemId } : {}),
eventType,
schemaVersion: 1,
priority: eventType === "run.result.proposed" ? 0 : 1,
emittedAt: this.#now().toISOString(),
payload,
};
this.#transcript.push(structuredClone(event));
this.#events.push(event);
}
#optionsEnvironment(): NodeJS.ProcessEnv { return {}; }
}
function assertRecoveryIdentity(
expected: Extract<PersistedHarnessSession["providerIdentity"], { kind: "acpx" }>,
actual: AcpxRuntimeIdentity,
workspace: string,
): void {
if (
expected.acpxRecordId !== actual.acpxRecordId
|| expected.backendSessionId !== actual.backendSessionId
|| expected.agentSessionId !== actual.agentSessionId
|| expected.profileDigest !== actual.profile.commandDigest
|| expected.workspaceDigest !== workspaceDigest(workspace)
|| expected.requestedModel !== actual.requestedModel
|| expected.effectiveModel !== actual.effectiveModel
) throw new Error("ACPX recovery identity does not match the immutable persisted session");
}
function permissionDecision(action: HarnessRuntimeRequestResolution["action"]): AcpPermissionDecision {
if (action === "accept") return { outcome: "allow_once" };
if (action === "accept_for_session") return { outcome: "allow_always" };
if (action === "decline") return { outcome: "reject_once" };
return { outcome: "cancel" };
}
function displayAgent(agent: QualifiedAcpxAgent): string {
return agent === "pi" ? "Pi" : agent === "claude" ? "Claude" : "Codex";
}
function biller(agent: QualifiedAcpxAgent): "openrouter" | "anthropic" | "openai" {
return agent === "pi" ? "openrouter" : agent === "claude" ? "anthropic" : "openai";
}
function workspaceDigest(cwd: string): string {
return `sha256:${createHash("sha256").update(resolve(cwd)).digest("hex")}`;
}
function sessionRoot(runtimeDirectory: string, normalizedSessionId: string): string {
const safe = normalizedSessionId.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120);
return join(resolve(runtimeDirectory), "acpx", safe);
}
function toolOperation(kind: unknown): "create" | "modify" | "delete" {
const value = text(kind).toLowerCase();
return value.includes("delete") ? "delete" : value.includes("create") ? "create" : "modify";
}
function canonicalToolTerminal(status: unknown): boolean {
return ["completed", "failed", "cancelled", "canceled"].includes(text(status).toLowerCase());
}
function safeWorkspacePath(value: string): string | null {
const path = value.replaceAll("\\", "/");
if (!path || path.startsWith("/") || path.split("/").includes("..") || /^[a-z]+:\/\//i.test(path)) return null;
return path.slice(0, 4000);
}
function safeId(value: string, fallback: string): string {
const candidate = value.replace(/[^A-Za-z0-9._:-]/g, "-").slice(0, 160);
return /^[A-Za-z0-9]/.test(candidate) ? candidate : fallback;
}
function bounded(value: unknown): Record<string, unknown> {
const serialized = JSON.stringify(value);
if (!serialized || Buffer.byteLength(serialized) > 64 * 1024) return { omitted: true, reason: "payload_limit" };
const parsed: unknown = JSON.parse(serialized);
return record(parsed);
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function text(value: unknown, fallback = ""): string { return typeof value === "string" ? value : fallback; }
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (typeof value === "object" && value !== null) {
const object = value as Record<string, unknown>;
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`).join(",")}}`;
}
return JSON.stringify(value) ?? "undefined";
}
function redact(value: string, environment: NodeJS.ProcessEnv = {}): string {
let redacted = value;
for (const [key, secret] of Object.entries(environment)) {
if (secret && /(?:KEY|TOKEN|SECRET|PASSWORD)$/i.test(key) && secret.length >= 4) redacted = redacted.split(secret).join("[REDACTED]");
}
return redacted.replace(/(key|token|secret|password|authorization)\s*[:=]\s*[^\s,}\]]+/gi, "$1=[REDACTED]").slice(0, 8192);
}
class AsyncQueue<T> implements AsyncIterable<T> {
#items: T[] = [];
#waiters: Array<{ resolve: (result: IteratorResult<T>) => void; reject: (error: unknown) => void }> = [];
#closed = false;
push(item: T): void {
if (this.#closed) return;
const waiter = this.#waiters.shift();
if (waiter) waiter.resolve({ done: false, value: item });
else this.#items.push(item);
}
close(): void {
if (this.#closed) return;
this.#closed = true;
for (const waiter of this.#waiters.splice(0)) waiter.resolve({ done: true, value: undefined });
}
[Symbol.asyncIterator](): AsyncIterator<T> {
return {
next: () => {
const item = this.#items.shift();
if (item !== undefined) return Promise.resolve({ done: false, value: item });
if (this.#closed) return Promise.resolve({ done: true, value: undefined });
return new Promise((resolve, reject) => this.#waiters.push({ resolve, reject }));
},
};
}
}
@@ -0,0 +1,175 @@
import { createHash } from "node:crypto";
import { mkdtemp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import type {
AcpRuntime,
AcpRuntimeHandle,
AcpRuntimeOptions,
AcpRuntimeTurn,
} from "acpx/runtime";
import { describe, expect, it } from "vitest";
import { AcpxRuntimeHost } from "./acpx-runtime-host.js";
function completedTurn(requestId: string): AcpRuntimeTurn {
return {
requestId,
promptStarted: Promise.resolve(),
events: { async *[Symbol.asyncIterator]() {} },
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
closeStream: async () => {},
};
}
describe("AcpxRuntimeHost", () => {
it("resolves a pinned profile, isolates state, and keeps credentials out of session records", async () => {
const root = await mkdtemp(join(tmpdir(), "paperclip-acpx-host-"));
const workspace = join(root, "workspace");
await mkdir(workspace);
let captured: AcpRuntimeOptions | null = null;
let ensureInput: Parameters<AcpRuntime["ensureSession"]>[0] | null = null;
const handle: AcpRuntimeHandle = {
sessionKey: "session-key",
backend: "acpx",
runtimeSessionName: "runtime-name",
acpxRecordId: "record-1",
backendSessionId: "backend-1",
agentSessionId: "agent-1",
};
const runtimeFactory = (options: AcpRuntimeOptions): AcpRuntime => {
captured = options;
return {
ensureSession: async (input) => { ensureInput = input; return handle; },
startTurn: (input) => completedTurn(input.requestId),
runTurn: async function* () {},
getStatus: async () => ({
acpxRecordId: "record-1",
backendSessionId: "backend-1",
agentSessionId: "agent-1",
models: {
currentModelId: "openrouter/deepseek/deepseek-v4-flash-0731",
availableModelIds: ["openrouter/deepseek/deepseek-v4-flash-0731"],
},
}),
cancel: async () => {},
close: async () => {},
};
};
const host = await AcpxRuntimeHost.open({
runtimeDirectory: root,
normalizedSessionId: "company/agent/session",
workingDirectory: workspace,
agent: "pi",
model: "openrouter/deepseek/deepseek-v4-flash-0731",
dynamicTools: [{
operationId: "get_task_context",
description: "Read task context.",
inputSchema: { type: "object", additionalProperties: false },
}],
environment: {
PATH: process.env.PATH,
LANG: "en_US.UTF-8",
OPENROUTER_API_KEY: "openrouter-canary-secret",
PAPERCLIP_API_KEY: "paperclip-must-not-pass",
DATABASE_URL: "database-must-not-pass",
},
dynamicToolHandler: async () => ({ ok: true }),
runtimeFactory,
});
try {
expect(host.identity()).toMatchObject({
acpxRecordId: "record-1",
agentSessionId: "agent-1",
requestedModel: "openrouter/deepseek/deepseek-v4-flash-0731",
effectiveModel: "openrouter/deepseek/deepseek-v4-flash-0731",
});
const options = captured as AcpRuntimeOptions & { spawnEnvironment?: () => Record<string, string> };
const childEnvironment = options.spawnEnvironment?.() ?? {};
expect(childEnvironment.OPENROUTER_API_KEY).toBe("openrouter-canary-secret");
expect(JSON.parse(childEnvironment.PI_ACP_PI_ARGS_JSON ?? "[]")).toEqual(expect.arrayContaining([
"--no-extensions", "--no-skills", "--no-prompt-templates", "--no-context-files",
]));
expect(childEnvironment.PI_SKIP_VERSION_CHECK).toBe("1");
expect(childEnvironment.PI_TELEMETRY).toBe("0");
const catalogResponse = await fetch(childEnvironment.PAPERCLIP_RUNNER_BRIDGE_URL!, {
method: "POST",
headers: {
authorization: `Bearer ${childEnvironment.PAPERCLIP_RUNNER_BRIDGE_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify({ jsonrpc: "2.0", id: "catalog", method: "tools/list", params: {} }),
});
const catalog = await catalogResponse.json() as { result: { tools: Array<{ name: string }> } };
expect(catalog.result.tools.map((tool) => tool.name)).toContain("get_task_context");
expect(JSON.stringify(childEnvironment)).not.toContain("paperclip-must-not-pass");
expect(JSON.stringify(childEnvironment)).not.toContain("database-must-not-pass");
expect(JSON.stringify(ensureInput)).not.toContain("openrouter-canary-secret");
expect(JSON.stringify(ensureInput)).not.toContain("paperclip-must-not-pass");
expect((await stat(host.runtimeRoot())).mode & 0o777).toBe(0o700);
const settingsPath = join(host.runtimeRoot(), "pi-home", "settings.json");
expect(JSON.parse(await readFile(settingsPath, "utf8"))).toMatchObject({
defaultProjectTrust: "never",
enableInstallTelemetry: false,
});
expect((await stat(settingsPath)).mode & 0o777).toBe(0o600);
} finally {
await host.close({ reason: "test complete" });
}
});
it("rejects model fallback before starting the runtime", async () => {
await expect(AcpxRuntimeHost.open({
runtimeDirectory: await mkdtemp(join(tmpdir(), "paperclip-acpx-model-")),
normalizedSessionId: "session",
workingDirectory: process.cwd(),
agent: "pi",
model: "openrouter/another-model",
dynamicToolHandler: async () => ({}),
runtimeFactory: () => { throw new Error("runtime must not start"); },
})).rejects.toThrow("requires exact model");
});
it("rejects recovery identity drift before constructing or spawning the runtime", async () => {
const root = await mkdtemp(join(tmpdir(), "paperclip-acpx-recovery-"));
const workspace = join(root, "workspace");
await mkdir(workspace);
const sessionRoot = join(root, "acpx", "session");
await mkdir(sessionRoot, { recursive: true });
await writeFile(join(sessionRoot, "identity.json"), JSON.stringify({
acpxRecordId: "record-real",
backendSessionId: "backend-1",
agentSessionId: "agent-1",
requestedModel: "openrouter/deepseek/deepseek-v4-flash-0731",
effectiveModel: "openrouter/deepseek/deepseek-v4-flash-0731",
profileDigest: "sha256:896d0f734998529087bc2af0854112b2064c83047c4bc59359420510abd14791",
}));
let runtimeConstructed = false;
await expect(AcpxRuntimeHost.open({
runtimeDirectory: root,
normalizedSessionId: "session",
workingDirectory: workspace,
agent: "pi",
model: "openrouter/deepseek/deepseek-v4-flash-0731",
expectedIdentity: {
kind: "acpx",
normalizedSessionId: "session",
acpxRecordId: "record-stale",
backendSessionId: "backend-1",
agentSessionId: "agent-1",
profileDigest: "sha256:896d0f734998529087bc2af0854112b2064c83047c4bc59359420510abd14791",
workspaceDigest: `sha256:${createHash("sha256").update(resolve(workspace)).digest("hex")}`,
requestedModel: "openrouter/deepseek/deepseek-v4-flash-0731",
effectiveModel: "openrouter/deepseek/deepseek-v4-flash-0731",
},
dynamicToolHandler: async () => ({}),
runtimeFactory: () => {
runtimeConstructed = true;
throw new Error("runtime must not start");
},
})).rejects.toThrow("does not match the persisted runtime record");
expect(runtimeConstructed).toBe(false);
});
});
@@ -0,0 +1,606 @@
import { createHash } from "node:crypto";
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
createAcpRuntime,
createAgentRegistry,
createRuntimeStore,
type AcpPermissionDecision,
type AcpPermissionRequest,
type AcpRuntime,
type AcpRuntimeEvent,
type AcpRuntimeHandle,
type AcpRuntimeOptions,
type AcpRuntimeStatus,
type AcpRuntimeTurn,
} from "acpx/runtime";
import {
startRunnerToolBridge,
type RunnerToolBridge,
} from "../runner-tool-bridge.js";
import {
resolveQualifiedAcpxProfile,
type QualifiedAcpxAgent,
type QualifiedAcpxProfile,
} from "./qualified-profiles.js";
const PI_PERMISSION_TOOL = "__paperclip_runtime_permission";
type DynamicToolHandler = (call: {
tool: string;
callId: string;
arguments: unknown;
}) => Promise<unknown>;
export interface AcpxRuntimeProcessIdentity {
pid: number;
processGroupId: number | null;
startedAt: string;
}
export interface AcpxRuntimePermissionContext {
request: AcpPermissionRequest;
signal: AbortSignal;
}
export type AcpxRuntimeFactory = (options: AcpRuntimeOptions) => AcpRuntime;
interface SecureAcpRuntimeOptions extends AcpRuntimeOptions {
/** Paperclip's 0.13.1 patch: evaluated immediately before ACP child spawn. */
spawnEnvironment?: () => Record<string, string>;
/** Paperclip's 0.13.1 patch: never persisted in ACPX session records. */
onAgentSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void> | void;
onAgentStderr?: (chunk: string) => void;
spawnCwd?: string;
}
export interface AcpxRuntimeHostOptions {
runtimeDirectory: string;
normalizedSessionId: string;
workingDirectory: string;
agent: QualifiedAcpxAgent;
model: string;
expectedIdentity?: {
kind: "acpx";
normalizedSessionId: string;
acpxRecordId: string;
backendSessionId: string;
agentSessionId: string;
profileDigest: string;
workspaceDigest: string;
requestedModel: string;
effectiveModel: string;
};
environment?: NodeJS.ProcessEnv;
dynamicTools?: readonly Readonly<Record<string, unknown>>[];
dynamicToolHandler: DynamicToolHandler;
runtimeFactory?: AcpxRuntimeFactory;
onPermissionRequest?: (
context: AcpxRuntimePermissionContext,
) => Promise<AcpPermissionDecision | undefined>;
onSpawn?: (meta: AcpxRuntimeProcessIdentity) => Promise<void>;
onDiagnostic?: (message: string) => void;
}
export interface AcpxRuntimeIdentity {
acpxRecordId: string;
backendSessionId: string;
agentSessionId: string;
requestedModel: string;
effectiveModel: string;
profile: QualifiedAcpxProfile;
commandPath: string;
commandDigest: string;
}
export async function inspectQualifiedAcpxInstallation(
agent: QualifiedAcpxAgent,
model: string,
): Promise<{
profile: QualifiedAcpxProfile;
commandPath: string;
commandDigest: string;
agentRuntimePath: string | null;
}> {
const profile = resolveQualifiedAcpxProfile(agent, model);
const command = await resolveAndVerifyCommand(profile);
return {
profile,
commandPath: command.path,
commandDigest: command.digest,
agentRuntimePath: agent === "pi" ? await resolvePiRuntimeCommand(profile) : null,
};
}
export class AcpxRuntimeHost {
readonly #runtime: AcpRuntime;
readonly #handle: AcpRuntimeHandle;
readonly #bridge: RunnerToolBridge;
readonly #identity: AcpxRuntimeIdentity;
readonly #root: string;
#activeTurn: AcpRuntimeTurn | null = null;
#closed = false;
private constructor(input: {
runtime: AcpRuntime;
handle: AcpRuntimeHandle;
bridge: RunnerToolBridge;
identity: AcpxRuntimeIdentity;
root: string;
}) {
this.#runtime = input.runtime;
this.#handle = input.handle;
this.#bridge = input.bridge;
this.#identity = input.identity;
this.#root = input.root;
}
static async open(options: AcpxRuntimeHostOptions): Promise<AcpxRuntimeHost> {
const profile = resolveQualifiedAcpxProfile(options.agent, options.model);
const cwd = validateWorkspace(options.workingDirectory);
const root = sessionRoot(options.runtimeDirectory, options.normalizedSessionId);
const stateDir = join(root, "acpx-state");
const homeDir = join(root, "home");
const configDir = join(root, "config");
const dataDir = join(root, "data");
const cacheDir = join(root, "cache");
const agentHome = join(root, `${options.agent}-home`);
for (const directory of [root, stateDir, homeDir, configDir, dataDir, cacheDir, agentHome]) {
await mkdir(directory, { recursive: true, mode: 0o700 });
await chmod(directory, 0o700);
}
if (options.expectedIdentity) {
await verifyExpectedIdentity(options.expectedIdentity, root, options.normalizedSessionId, cwd, profile, options.model);
}
await writeFile(join(root, "workspace"), `${cwd}\n`, { mode: 0o600 });
if (options.agent === "pi") {
await writeFile(join(agentHome, "settings.json"), `${JSON.stringify({
quietStartup: true,
defaultProjectTrust: "never",
enableInstallTelemetry: false,
})}\n`, { mode: 0o600 });
}
const command = await resolveAndVerifyCommand(profile);
const piCommand = options.agent === "pi" ? await resolvePiRuntimeCommand(profile) : null;
const piExtension = fileURLToPath(new URL("./pi-paperclip-extension.js", import.meta.url));
const piPermissionRules = new Set<string>();
const bridge = await startRunnerToolBridge({
tools: normalizeBridgeTools(options.dynamicTools ?? []),
privateTools: options.agent === "pi" ? [{
name: PI_PERMISSION_TOOL,
description: "Runner-internal Pi permission gate.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["operation", "target", "inputClass"],
properties: {
operation: { enum: ["write", "edit", "bash"] },
target: { type: ["string", "null"], maxLength: 4_096 },
inputClass: { enum: ["workspace_mutation", "command"] },
},
},
}] : [],
handler: async (call) => {
if (call.tool !== PI_PERMISSION_TOOL) return options.dynamicToolHandler(call);
return requestPiPermission(options, call.callId, call.arguments, call.signal, piPermissionRules);
},
});
const agentEnvironment = sanitizedAgentEnvironment(options.environment ?? process.env, {
HOME: homeDir,
XDG_CONFIG_HOME: configDir,
XDG_DATA_HOME: dataDir,
XDG_CACHE_HOME: cacheDir,
...(options.agent === "pi" ? {
PI_CODING_AGENT_DIR: agentHome,
PI_SKIP_VERSION_CHECK: "1",
PI_TELEMETRY: "0",
PI_ACP_PI_COMMAND: piCommand!,
PI_ACP_PI_ARGS_JSON: JSON.stringify([
"--no-extensions", "--extension", piExtension,
"--no-skills", "--no-prompt-templates", "--no-themes", "--no-context-files",
]),
PAPERCLIP_RUNNER_BRIDGE_URL: bridge.url,
PAPERCLIP_RUNNER_BRIDGE_TOKEN: bridge.secret,
PAPERCLIP_WORKSPACE_ROOT: cwd,
PAPERCLIP_RUNTIME_ROOT: root,
} : {}),
...(options.agent === "claude" ? { CLAUDE_CONFIG_DIR: agentHome } : {}),
PAPERCLIP_ACPX_PROFILE: options.agent,
}, options.agent);
const safeSessionEnvironment = withoutCredentials(agentEnvironment);
const mcpServers: NonNullable<AcpRuntimeOptions["mcpServers"]> = options.agent === "pi" ? [] : [{
type: "http",
name: "paperclip",
url: bridge.url,
headers: [{ name: "Authorization", value: `Bearer ${bridge.secret}` }],
}];
const runtimeOptions: SecureAcpRuntimeOptions = {
cwd,
spawnCwd: cwd,
sessionStore: createRuntimeStore({ stateDir }),
agentRegistry: createAgentRegistry({
overrides: { [options.agent]: [process.execPath, command.path] },
}),
mcpServers,
permissionMode: "approve-reads",
nonInteractivePermissions: "fail",
permissionPolicy: {
autoApprove: ["read", "search", "list"],
escalate: ["execute", "write", "edit", "delete", "move"],
defaultAction: "escalate",
},
elicitationModes: ["form", "url"],
onPermissionRequest: options.onPermissionRequest
? (request, context) => options.onPermissionRequest!({ request, signal: context.signal })
: async () => ({ outcome: "reject_once" }),
spawnEnvironment: () => ({ ...agentEnvironment }),
onAgentStderr: (chunk) => options.onDiagnostic?.(redactDiagnostic(chunk, agentEnvironment)),
onAgentSpawn: async (meta) => options.onSpawn?.({
pid: meta.pid,
processGroupId: null,
startedAt: meta.startedAt,
}),
};
const runtimeFactory = options.runtimeFactory ?? ((input) => createAcpRuntime(input));
const runtime = runtimeFactory(runtimeOptions);
let handle: AcpRuntimeHandle | null = null;
try {
handle = await runtime.ensureSession({
sessionKey: profileSessionKey(options.normalizedSessionId, cwd, profile, options.model),
agent: options.agent,
mode: "persistent",
cwd,
sessionOptions: {
model: options.model,
systemPrompt: { append: paperclipAcpSystemPrompt() },
env: safeSessionEnvironment,
},
});
const status = await requireVerifiedModel(runtime, handle, options.model);
const backendSessionId = requiredIdentity(status.backendSessionId ?? handle.backendSessionId, "backend session");
const identity: AcpxRuntimeIdentity = {
acpxRecordId: requiredIdentity(status.acpxRecordId ?? handle.acpxRecordId, "ACPX record"),
backendSessionId,
// ACPX 0.13.1 exposes the ACP protocol session as backendSessionId for
// agents (including pi-acp) that do not advertise a second native
// thread identity. Preserve that real ACP session identity explicitly
// instead of inventing a random or Paperclip-owned identifier.
agentSessionId: requiredIdentity(status.agentSessionId ?? handle.agentSessionId ?? backendSessionId, "agent session"),
requestedModel: options.model,
effectiveModel: requiredIdentity(status.models?.currentModelId, "effective model"),
profile,
commandPath: command.path,
commandDigest: command.digest,
};
await writeFile(join(root, "identity.json"), `${JSON.stringify({
acpxRecordId: identity.acpxRecordId,
backendSessionId: identity.backendSessionId,
agentSessionId: identity.agentSessionId,
requestedModel: identity.requestedModel,
effectiveModel: identity.effectiveModel,
profileDigest: profile.commandDigest,
}, null, 2)}\n`, { mode: 0o600 });
return new AcpxRuntimeHost({ runtime, handle, bridge, identity, root });
} catch (error) {
if (handle) {
await runtime.close({ handle, reason: "ACPX startup failed", discardPersistentState: false }).catch(() => {});
}
await bridge.close().catch(() => {});
throw error;
}
}
identity(): AcpxRuntimeIdentity {
return structuredClone(this.#identity);
}
async status(): Promise<AcpRuntimeStatus> {
return this.#runtime.getStatus
? this.#runtime.getStatus({ handle: this.#handle })
: {};
}
startTurn(input: { requestId: string; text: string; signal?: AbortSignal }): AcpRuntimeTurn {
if (this.#closed) throw new Error("ACPX runtime host is closed");
if (this.#activeTurn) throw new Error("ACPX runtime host already has an active turn");
const turn = this.#runtime.startTurn({
handle: this.#handle,
text: input.text,
mode: "prompt",
requestId: input.requestId,
signal: input.signal,
});
this.#activeTurn = turn;
void turn.result.finally(() => {
if (this.#activeTurn === turn) this.#activeTurn = null;
});
return turn;
}
async cancel(reason = "Paperclip turn interrupted"): Promise<void> {
const turn = this.#activeTurn;
if (turn) await turn.cancel({ reason });
else await this.#runtime.cancel({ handle: this.#handle, reason });
}
async close(input: { reason: string; discardPersistentState?: boolean }): Promise<void> {
if (this.#closed) return;
this.#closed = true;
await this.#activeTurn?.cancel({ reason: input.reason }).catch(() => {});
await this.#runtime.close({
handle: this.#handle,
reason: input.reason,
discardPersistentState: input.discardPersistentState ?? false,
}).finally(() => this.#bridge.close()).catch(async (error) => {
await this.#bridge.close().catch(() => {});
throw error;
});
}
runtimeRoot(): string { return this.#root; }
}
async function verifyExpectedIdentity(
expected: NonNullable<AcpxRuntimeHostOptions["expectedIdentity"]>,
root: string,
normalizedSessionId: string,
cwd: string,
profile: QualifiedAcpxProfile,
model: string,
): Promise<void> {
if (
expected.normalizedSessionId !== normalizedSessionId
|| expected.profileDigest !== profile.commandDigest
|| expected.workspaceDigest !== workspaceDigest(cwd)
|| expected.requestedModel !== model
|| expected.effectiveModel !== model
) throw new Error("ACPX recovery identity conflicts with the immutable session configuration");
let persisted: Record<string, unknown>;
try {
persisted = JSON.parse(await readFile(join(root, "identity.json"), "utf8")) as Record<string, unknown>;
} catch {
throw new Error("ACPX recovery identity record is missing or malformed");
}
if (
persisted.acpxRecordId !== expected.acpxRecordId
|| persisted.backendSessionId !== expected.backendSessionId
|| persisted.agentSessionId !== expected.agentSessionId
|| persisted.profileDigest !== expected.profileDigest
|| persisted.requestedModel !== expected.requestedModel
|| persisted.effectiveModel !== expected.effectiveModel
) throw new Error("ACPX recovery identity does not match the persisted runtime record");
}
async function requireVerifiedModel(
runtime: AcpRuntime,
handle: AcpRuntimeHandle,
requestedModel: string,
): Promise<AcpRuntimeStatus> {
if (!runtime.getStatus) throw new Error("ACPX agent cannot verify its effective model");
let status = await runtime.getStatus({ handle });
if (status.models?.currentModelId !== requestedModel && runtime.setConfigOption) {
await runtime.setConfigOption({ handle, key: "model", value: requestedModel });
status = await runtime.getStatus({ handle });
}
if (status.models?.currentModelId !== requestedModel) {
throw new Error(
`ACPX effective model mismatch: requested ${requestedModel}, received ${status.models?.currentModelId ?? "unverified"}`,
);
}
return status;
}
async function resolveAndVerifyCommand(profile: QualifiedAcpxProfile): Promise<{ path: string; digest: string }> {
const packageJsonPath = resolvePackageJson(profile.agentServerPackage);
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as {
version?: string;
bin?: string | Record<string, string>;
};
if (packageJson.version !== profile.agentServerVersion) {
throw new Error(
`ACPX ${profile.agent} package version mismatch: expected ${profile.agentServerVersion}, received ${packageJson.version ?? "unknown"}`,
);
}
const relativeBin = typeof packageJson.bin === "string"
? packageJson.bin
: packageJson.bin?.[Object.keys(packageJson.bin)[0] ?? ""];
if (!relativeBin) throw new Error(`ACPX ${profile.agent} package does not expose an executable`);
const path = resolve(dirname(packageJsonPath), relativeBin);
const digest = `sha256:${createHash("sha256").update(await readFile(path)).digest("hex")}`;
if (digest !== profile.commandDigest) {
throw new Error(`ACPX ${profile.agent} executable digest does not match the qualified profile`);
}
return { path, digest };
}
async function resolvePiRuntimeCommand(profile: QualifiedAcpxProfile): Promise<string> {
if (!profile.agentRuntimePackage || !profile.agentRuntimeVersion) {
throw new Error("Qualified Pi profile omitted its harness runtime");
}
const packageJsonPath = resolvePackageJsonFromEntrypoint(profile.agentRuntimePackage);
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as {
version?: string;
bin?: string | Record<string, string>;
};
if (packageJson.version !== profile.agentRuntimeVersion) {
throw new Error(`Pi runtime version mismatch: expected ${profile.agentRuntimeVersion}`);
}
const relativeBin = typeof packageJson.bin === "string"
? packageJson.bin
: packageJson.bin?.pi;
if (!relativeBin) throw new Error("Qualified Pi runtime does not expose the pi executable");
return resolve(dirname(packageJsonPath), relativeBin);
}
function resolvePackageJson(packageName: string): string {
return createRequire(import.meta.url).resolve(`${packageName}/package.json`);
}
function resolvePackageJsonFromEntrypoint(packageName: string): string {
// The qualified Pi runtime intentionally has no package root export. It is
// a direct dependency of this package, so resolve its owned installation
// relative to both source and built driver locations without consulting PATH.
return fileURLToPath(new URL(`../../../node_modules/${packageName}/package.json`, import.meta.url));
}
async function requestPiPermission(
options: AcpxRuntimeHostOptions,
callId: string,
input: unknown,
signal: AbortSignal,
acceptedRules: Set<string>,
): Promise<{ decision: "accept" | "accept_for_session" | "decline" | "cancel" }> {
const value = input !== null && typeof input === "object" && !Array.isArray(input)
? input as Record<string, unknown>
: {};
const operation = value.operation === "write" || value.operation === "edit" || value.operation === "bash"
? value.operation
: "unknown";
const target = typeof value.target === "string" ? value.target.slice(0, 4_096) : null;
const rule = JSON.stringify({ operation, target });
if (acceptedRules.has(rule)) return { decision: "accept_for_session" };
if (!options.onPermissionRequest || signal.aborted) return { decision: "cancel" };
const inferredKind = operation === "bash" ? "execute" : "edit";
const decision = await options.onPermissionRequest({
request: {
sessionId: options.normalizedSessionId,
inferredKind,
raw: {
sessionId: options.normalizedSessionId,
toolCall: {
toolCallId: callId,
kind: inferredKind,
status: "pending",
title: operation === "bash"
? "Allow Pi to run a command in the workspace?"
: `Allow Pi to ${operation} ${target ?? "a workspace file"}?`,
},
options: [
{ optionId: "accept", name: "Allow once", kind: "allow_once" },
{ optionId: "accept_for_session", name: "Allow for this session", kind: "allow_always" },
{ optionId: "decline", name: "Decline", kind: "reject_once" },
],
},
},
signal,
});
if (decision?.outcome === "allow_always") {
acceptedRules.add(rule);
return { decision: "accept_for_session" };
}
if (decision?.outcome === "allow_once") return { decision: "accept" };
if (decision?.outcome === "cancel") return { decision: "cancel" };
return { decision: "decline" };
}
function sanitizedAgentEnvironment(
source: NodeJS.ProcessEnv,
overrides: Record<string, string>,
agent: QualifiedAcpxAgent,
): Record<string, string> {
const allowedExact = new Set([
"PATH", "LANG", "LANGUAGE", "TZ", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
"ALL_PROXY", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS",
]);
const credentialNames = agent === "pi"
? ["OPENROUTER_API_KEY"]
: agent === "claude"
? ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"]
: ["OPENAI_API_KEY", "CODEX_API_KEY"];
const environment: Record<string, string> = {};
for (const [key, value] of Object.entries(source)) {
if (!value) continue;
if (allowedExact.has(key) || key.startsWith("LC_") || credentialNames.includes(key)) {
environment[key] = value;
}
}
Object.assign(environment, overrides);
return environment;
}
function withoutCredentials(environment: Record<string, string>): Record<string, string> {
const safe: Record<string, string> = {};
for (const [key, value] of Object.entries(environment)) {
if (/(?:KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)$/i.test(key)) continue;
safe[key] = value;
}
return safe;
}
function redactDiagnostic(value: string, environment: Record<string, string>): string {
let redacted = value;
for (const [key, secret] of Object.entries(environment)) {
if (!/(?:KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)$/i.test(key) || secret.length < 4) continue;
redacted = redacted.split(secret).join("[REDACTED]");
}
return redacted.replace(/(key|token|secret|password|authorization)\s*[:=]\s*[^\s,}\]]+/gi, "$1=[REDACTED]").slice(-8_192);
}
function sessionRoot(runtimeDirectory: string, normalizedSessionId: string): string {
const safe = normalizedSessionId.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120);
if (!safe || safe === "." || safe === "..") throw new Error("Invalid normalized ACPX session id");
return join(resolve(runtimeDirectory), "acpx", safe);
}
function workspaceDigest(cwd: string): string {
return `sha256:${createHash("sha256").update(resolve(cwd)).digest("hex")}`;
}
function validateWorkspace(value: string): string {
const cwd = resolve(value);
if (!value.trim() || cwd === dirname(cwd)) throw new Error("ACPX working directory must not be a filesystem root");
return cwd;
}
function profileSessionKey(
normalizedSessionId: string,
cwd: string,
profile: QualifiedAcpxProfile,
model: string,
): string {
const digest = createHash("sha256")
.update(JSON.stringify({
normalizedSessionId,
cwd,
agent: profile.agent,
model,
commandDigest: profile.commandDigest,
driverKind: profile.driverKind,
protocolVersion: profile.protocolVersion,
}))
.digest("hex");
return `paperclip-${digest}`;
}
function requiredIdentity(value: string | undefined, label: string): string {
if (!value?.trim()) throw new Error(`${label} identity is missing`);
return value;
}
function paperclipAcpSystemPrompt(): string {
return [
"You are running inside Paperclip Runner.",
"Use only the semantic Paperclip tools made available for the attached run.",
"Work only inside the supplied workspace.",
"Do not discover user or project skills, plugins, MCP servers, prompts, or configuration.",
"Finish through paperclip_finish or paperclip_block exactly once.",
].join("\n");
}
/** PRP names semantic tools by operationId; MCP and Pi name them `name`. */
function normalizeBridgeTools(
tools: readonly Readonly<Record<string, unknown>>[],
): Array<Readonly<Record<string, unknown>>> {
return tools.map((tool) => ({
name: typeof tool.name === "string" ? tool.name : tool.operationId,
description: tool.description,
inputSchema: tool.inputSchema,
}));
}
export type { AcpRuntimeEvent, AcpRuntimeTurn, AcpRuntimeStatus };
@@ -0,0 +1,32 @@
import type { QualifiedAcpxAgent } from "./qualified-profiles.js";
/**
* Build the complete environment visible to the ACPX sidecar. Agent-specific
* homes and bridge secrets are injected just in time by AcpxRuntimeHost and
* never inherited from the Paperclip server process.
*/
export function createSanitizedAcpxEnvironment(
environment: NodeJS.ProcessEnv | undefined,
agent: QualifiedAcpxAgent,
): NodeJS.ProcessEnv {
const source = environment ?? process.env;
const result: NodeJS.ProcessEnv = {};
const credentialNames = agent === "pi"
? ["OPENROUTER_API_KEY"]
: agent === "claude"
? ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"]
: ["OPENAI_API_KEY", "CODEX_API_KEY"];
const allowed = new Set([
"PATH", "LANG", "LANGUAGE", "TZ", "TMPDIR", "TEMP", "TMP",
"SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS",
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY",
"http_proxy", "https_proxy", "no_proxy", "all_proxy",
"RUST_BACKTRACE",
...credentialNames,
]);
for (const [key, value] of Object.entries(source)) {
if (typeof value !== "string") continue;
if (allowed.has(key) || key.startsWith("LC_")) result[key] = value;
}
return result;
}
@@ -0,0 +1,27 @@
// Generated by scripts/generate-acpx-sidecar-contract.mjs. Do not edit.
export const GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION = 1 as const;
export const GENERATED_ACPX_SIDECAR_COMMANDS = [
"initialize",
"session.open",
"run.attach",
"turn.start",
"turn.cancel",
"permission.resolve",
"tool.resolve",
"session.read",
"session.snapshot",
"session.suspend",
"session.close",
] as const;
export type GeneratedAcpxSidecarCommand = (typeof GENERATED_ACPX_SIDECAR_COMMANDS)[number];
export const GENERATED_ACPX_SIDECAR_EVENT_TYPES = [
"runtime.event",
"runtime.permission_requested",
"runtime.tool_called",
"runtime.turn_terminal",
"runtime.process",
"runtime.diagnostic",
] as const;
export type GeneratedAcpxSidecarEventType = (typeof GENERATED_ACPX_SIDECAR_EVENT_TYPES)[number];
@@ -0,0 +1,74 @@
import { mkdtemp, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import paperclipPiExtension from "./pi-paperclip-extension.js";
afterEach(() => {
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
describe("paperclip Pi extension", () => {
it("registers only the public catalog and gates built-ins without exposing command content", async () => {
const root = await mkdtemp(join(tmpdir(), "paperclip-pi-extension-"));
const workspace = join(root, "workspace");
const runtime = join(root, "runtime");
await mkdir(workspace);
await mkdir(runtime);
vi.stubEnv("PAPERCLIP_RUNNER_BRIDGE_URL", "http://127.0.0.1:43123/mcp");
vi.stubEnv("PAPERCLIP_RUNNER_BRIDGE_TOKEN", "a".repeat(64));
vi.stubEnv("PAPERCLIP_WORKSPACE_ROOT", workspace);
vi.stubEnv("PAPERCLIP_RUNTIME_ROOT", runtime);
const requests: Array<Record<string, unknown>> = [];
vi.stubGlobal("fetch", vi.fn(async (_url: URL, init?: RequestInit) => {
const request = JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
requests.push(request);
if (request.method === "tools/list") {
return new Response(JSON.stringify({
jsonrpc: "2.0",
id: request.id,
result: {
tools: [{ name: "paperclip_finish", inputSchema: { type: "object" } }],
},
}), { status: 200 });
}
if (request.method === "tools/call") {
return new Response(JSON.stringify({
jsonrpc: "2.0",
id: request.id,
result: { content: [{ type: "text", text: JSON.stringify({ decision: "accept" }) }] },
}), { status: 200 });
}
return request.id === undefined
? new Response(null, { status: 202 })
: new Response(JSON.stringify({ jsonrpc: "2.0", id: request.id, result: {} }), { status: 200 });
}));
const tools: Array<Record<string, unknown>> = [];
let toolCallHandler: ((event: Record<string, unknown>) => Promise<unknown>) | null = null;
await paperclipPiExtension({
registerTool(tool) { tools.push(tool as unknown as Record<string, unknown>); },
on(event, handler) {
if (event === "tool_call") toolCallHandler = handler as unknown as typeof toolCallHandler;
},
} as never);
expect(tools.map((tool) => tool.name)).toEqual(["paperclip_finish"]);
expect(JSON.stringify(tools)).not.toContain("__paperclip_runtime_permission");
expect(await toolCallHandler!({ toolName: "read", toolCallId: "read-1", input: { path: "/etc/passwd" } })).toMatchObject({ block: true });
expect(await toolCallHandler!({ toolName: "bash", toolCallId: "bash-1", input: { command: "printf hello" } })).toBeUndefined();
const permissionCall = requests.find((request) =>
request.method === "tools/call"
&& (request.params as Record<string, unknown>)?.name === "__paperclip_runtime_permission"
);
const permissionArguments = ((permissionCall?.params as Record<string, unknown>)?.arguments ?? {}) as Record<string, unknown>;
expect(permissionArguments.target).toMatch(/^command-sha256:[a-f0-9]{64}$/);
expect(JSON.stringify(requests)).not.toContain("printf hello");
expect(await toolCallHandler!({ toolName: "bash", toolCallId: "bash-2", input: { command: "cat /etc/passwd" } })).toMatchObject({ block: true });
});
});
@@ -0,0 +1,219 @@
import { createHash } from "node:crypto";
import { existsSync, realpathSync } from "node:fs";
import { dirname, isAbsolute, relative, resolve } from "node:path";
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
const PRIVATE_PERMISSION_TOOL = "__paperclip_runtime_permission";
const BUILTIN_READ_TOOLS = new Set(["read", "grep", "find", "ls"]);
const BUILTIN_GOVERNED_TOOLS = new Set(["write", "edit", "bash"]);
const MAX_RESPONSE_BYTES = 1_048_576;
interface McpTool {
name: string;
description?: string;
inputSchema?: Record<string, unknown>;
}
interface McpResponse {
result?: {
tools?: McpTool[];
content?: Array<{ type?: string; text?: string }>;
isError?: boolean;
};
error?: { message?: string };
}
/**
* Qualified Pi extension. Pi deliberately has neither MCP nor permission
* prompts, so this small runner-owned adapter supplies both without granting
* Pi direct Paperclip credentials or control-plane network access.
*/
export default async function paperclipPiExtension(pi: ExtensionAPI): Promise<void> {
const endpoint = requiredLoopbackEndpoint(process.env.PAPERCLIP_RUNNER_BRIDGE_URL);
const token = requiredSecret(process.env.PAPERCLIP_RUNNER_BRIDGE_TOKEN);
const workspace = resolve(requiredValue(process.env.PAPERCLIP_WORKSPACE_ROOT, "workspace root"));
const runtimeRoot = resolve(requiredValue(process.env.PAPERCLIP_RUNTIME_ROOT, "runtime root"));
const rpc = createMcpClient(endpoint, token);
await rpc("initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "paperclip-pi-extension", version: "1" },
}, "paperclip-pi-initialize");
await rpc("notifications/initialized", {}, null);
const catalog = await rpc("tools/list", {}, "paperclip-pi-tools");
for (const tool of catalog.result?.tools ?? []) {
if (!safeTool(tool) || tool.name === PRIVATE_PERMISSION_TOOL) continue;
pi.registerTool({
name: tool.name,
label: semanticLabel(tool.name),
description: tool.description ?? `Paperclip semantic operation ${tool.name}`,
parameters: (tool.inputSchema ?? { type: "object", additionalProperties: true }) as ToolDefinition["parameters"],
async execute(toolCallId, input, signal) {
const response = await rpcWithCancellation(rpc, tool.name, toolCallId, input, signal);
if (response.result?.isError) throw new Error(safeResultText(response) || "Paperclip operation failed");
return {
content: response.result?.content?.flatMap((item) =>
item.type === "text" && typeof item.text === "string"
? [{ type: "text" as const, text: item.text.slice(0, MAX_RESPONSE_BYTES) }]
: [],
) ?? [],
details: { operationId: tool.name, callId: toolCallId },
};
},
});
}
pi.on("tool_call", async (event) => {
if (BUILTIN_READ_TOOLS.has(event.toolName)) {
const unsafe = referencedPaths(event.input).find((candidate) => !safeWorkspaceTarget(workspace, runtimeRoot, candidate, false));
return unsafe ? { block: true, reason: "Paperclip denied a path outside the authorized workspace." } : undefined;
}
if (!BUILTIN_GOVERNED_TOOLS.has(event.toolName)) return undefined;
const pathTarget = referencedPaths(event.input)[0] ?? null;
if (pathTarget !== null && !safeWorkspaceTarget(workspace, runtimeRoot, pathTarget, true)) {
return { block: true, reason: "Paperclip denied a protected or outside-workspace target." };
}
const command = event.toolName === "bash" ? commandText(event.input) : null;
if (command !== null && unsafeCommandPath(command, workspace, runtimeRoot)) {
return { block: true, reason: "Paperclip denied a command that names a protected or outside-workspace path." };
}
const target = command === null
? pathTarget
: `command-sha256:${createHash("sha256").update(command).digest("hex")}`;
const response = await rpc("tools/call", {
name: PRIVATE_PERMISSION_TOOL,
arguments: {
operation: event.toolName,
target: target === null ? null : relative(workspace, resolve(workspace, target)),
inputClass: event.toolName === "bash" ? "command" : "workspace_mutation",
},
}, `permission:${event.toolCallId}`);
const decision = decodedResult(response).decision;
return decision === "accept" || decision === "accept_for_session"
? undefined
: { block: true, reason: decision === "cancel" ? "Paperclip cancelled this operation." : "Paperclip declined this operation." };
});
}
function createMcpClient(endpoint: URL, token: string) {
return async (method: string, params: Record<string, unknown>, id: string | null): Promise<McpResponse> => {
const response = await fetch(endpoint, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", ...(id === null ? {} : { id }), method, params }),
});
if (!response.ok && response.status !== 202) throw new Error(`Paperclip runner bridge returned HTTP ${response.status}`);
if (id === null || response.status === 202) return {};
const body = await response.text();
if (Buffer.byteLength(body) > MAX_RESPONSE_BYTES) throw new Error("Paperclip runner bridge response exceeded its bound");
const parsed = JSON.parse(body) as McpResponse;
if (parsed.error) throw new Error(String(parsed.error.message ?? "Paperclip runner bridge rejected the request").slice(0, 4_000));
return parsed;
};
}
async function rpcWithCancellation(
rpc: ReturnType<typeof createMcpClient>,
tool: string,
callId: string,
input: unknown,
signal: AbortSignal | undefined,
): Promise<McpResponse> {
if (signal?.aborted) throw new Error("Paperclip operation cancelled");
const cancel = () => { void rpc("notifications/cancelled", { requestId: callId }, null).catch(() => undefined); };
signal?.addEventListener("abort", cancel, { once: true });
try {
return await rpc("tools/call", { name: tool, arguments: input }, callId);
} finally {
signal?.removeEventListener("abort", cancel);
}
}
function decodedResult(response: McpResponse): Record<string, unknown> {
const value = safeResultText(response);
if (!value) return {};
try {
const decoded = JSON.parse(value) as unknown;
return decoded !== null && typeof decoded === "object" && !Array.isArray(decoded)
? decoded as Record<string, unknown>
: {};
} catch {
return {};
}
}
function safeResultText(response: McpResponse): string {
return response.result?.content?.find((item) => item.type === "text")?.text?.slice(0, MAX_RESPONSE_BYTES) ?? "";
}
function referencedPaths(input: unknown): string[] {
if (input === null || typeof input !== "object" || Array.isArray(input)) return [];
const record = input as Record<string, unknown>;
return [record.path, record.file_path, record.filePath, record.cwd]
.filter((value): value is string => typeof value === "string" && value.trim().length > 0);
}
function inside(root: string, candidate: string): boolean {
const value = isAbsolute(candidate) ? resolve(candidate) : resolve(root, candidate);
const result = relative(root, value);
return result === "" || (!result.startsWith("..") && !isAbsolute(result));
}
function safeWorkspaceTarget(workspace: string, runtimeRoot: string, candidate: string, mayCreate: boolean): boolean {
const absolute = isAbsolute(candidate) ? resolve(candidate) : resolve(workspace, candidate);
if (!inside(workspace, absolute) || inside(runtimeRoot, absolute)) return false;
try {
const existing = existsSync(absolute) ? absolute : mayCreate ? dirname(absolute) : absolute;
const physical = realpathSync(existing);
return inside(workspace, physical) && !inside(runtimeRoot, physical);
} catch {
return false;
}
}
function commandText(input: unknown): string | null {
if (input === null || typeof input !== "object" || Array.isArray(input)) return null;
const value = input as Record<string, unknown>;
const command = [value.command, value.cmd, value.script].find((candidate): candidate is string => typeof candidate === "string");
return command?.slice(0, 64 * 1024) ?? null;
}
function unsafeCommandPath(command: string, workspace: string, runtimeRoot: string): boolean {
if (/(^|[\s'"`=])~(?:\/|\s|$)/.test(command) || /(^|[\s'"`=])\.\.(?:\/|\s|$)/.test(command)) return true;
const absolutePaths = command.match(/\/(?:[^\s'"`;&|()<>]|\\.)+/g) ?? [];
return absolutePaths.some((candidate) => !inside(workspace, candidate) || inside(runtimeRoot, candidate));
}
function safeTool(value: McpTool): boolean {
return /^[a-zA-Z0-9_.-]{1,128}$/.test(value.name)
&& value.inputSchema !== null
&& typeof value.inputSchema === "object"
&& !Array.isArray(value.inputSchema);
}
function semanticLabel(name: string): string {
if (/read|get|list|search|find/i.test(name)) return `Read · ${name}`;
if (/write|create|update|edit|comment|finish|block/i.test(name)) return `Change · ${name}`;
return `Paperclip · ${name}`;
}
function requiredLoopbackEndpoint(value: string | undefined): URL {
const endpoint = new URL(requiredValue(value, "runner bridge URL"));
if (endpoint.protocol !== "http:" || endpoint.hostname !== "127.0.0.1" || endpoint.pathname !== "/mcp") {
throw new Error("Paperclip Pi extension requires an authenticated loopback bridge");
}
return endpoint;
}
function requiredSecret(value: string | undefined): string {
const secret = requiredValue(value, "runner bridge credential");
if (secret.length < 32) throw new Error("Paperclip runner bridge credential is invalid");
return secret;
}
function requiredValue(value: string | undefined, name: string): string {
if (!value?.trim()) throw new Error(`Paperclip Pi extension is missing ${name}`);
return value;
}
@@ -0,0 +1,83 @@
export const QUALIFIED_ACPX_VERSION = "0.13.1" as const;
export const ACPX_DRIVER_KIND = "acpx_runtime" as const;
export const ACPX_DRIVER_PROTOCOL_VERSION = 1 as const;
export type QualifiedAcpxAgent = "pi" | "claude" | "codex";
export interface QualifiedAcpxProfile {
driverKind: typeof ACPX_DRIVER_KIND;
protocolVersion: typeof ACPX_DRIVER_PROTOCOL_VERSION;
acpxVersion: typeof QUALIFIED_ACPX_VERSION;
agent: QualifiedAcpxAgent;
agentProfileVersion: 1;
agentServerPackage: string;
agentServerVersion: string;
agentRuntimePackage: string | null;
agentRuntimeVersion: string | null;
commandDigest: string;
qualificationModel: string;
permissionPolicy: "interactive";
}
/**
* Digests bind the closed profile declaration (package, version, runtime and
* model), not a caller-controlled executable. The environment probe separately
* verifies the resolved package files before a billable prompt is admitted.
*/
export const QUALIFIED_ACPX_PROFILES: Readonly<Record<QualifiedAcpxAgent, QualifiedAcpxProfile>> = {
pi: {
driverKind: ACPX_DRIVER_KIND,
protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION,
acpxVersion: QUALIFIED_ACPX_VERSION,
agent: "pi",
agentProfileVersion: 1,
agentServerPackage: "pi-acp",
agentServerVersion: "0.0.33",
agentRuntimePackage: "@earendil-works/pi-coding-agent",
agentRuntimeVersion: "0.84.2",
commandDigest: "sha256:896d0f734998529087bc2af0854112b2064c83047c4bc59359420510abd14791",
qualificationModel: "openrouter/deepseek/deepseek-v4-flash-0731",
permissionPolicy: "interactive",
},
claude: {
driverKind: ACPX_DRIVER_KIND,
protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION,
acpxVersion: QUALIFIED_ACPX_VERSION,
agent: "claude",
agentProfileVersion: 1,
agentServerPackage: "@agentclientprotocol/claude-agent-acp",
agentServerVersion: "0.70.0",
agentRuntimePackage: null,
agentRuntimeVersion: null,
commandDigest: "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
qualificationModel: "claude-sonnet-5",
permissionPolicy: "interactive",
},
codex: {
driverKind: ACPX_DRIVER_KIND,
protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION,
acpxVersion: QUALIFIED_ACPX_VERSION,
agent: "codex",
agentProfileVersion: 1,
agentServerPackage: "@agentclientprotocol/codex-acp",
agentServerVersion: "1.6.2",
agentRuntimePackage: null,
agentRuntimeVersion: null,
commandDigest: "sha256:8c7fc8af156596668a95ce23d52309f70ad576e75bac6dc209d30378bdbb8ebe",
qualificationModel: "gpt-5.6-sol",
permissionPolicy: "interactive",
},
};
export function resolveQualifiedAcpxProfile(
agent: QualifiedAcpxAgent,
requestedModel: string,
): QualifiedAcpxProfile {
const profile = QUALIFIED_ACPX_PROFILES[agent];
if (requestedModel !== profile.qualificationModel) {
throw new Error(
`ACPX ${agent} profile requires exact model ${profile.qualificationModel}; received ${requestedModel}`,
);
}
return structuredClone(profile);
}
@@ -0,0 +1,86 @@
import type { QualifiedAcpxAgent } from "./qualified-profiles.js";
import {
GENERATED_ACPX_SIDECAR_COMMANDS,
GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
type GeneratedAcpxSidecarCommand,
type GeneratedAcpxSidecarEventType,
} from "./generated-sidecar-contract.js";
export const ACPX_SIDECAR_PROTOCOL_VERSION = GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION;
export const ACPX_SIDECAR_MAX_FRAME_BYTES = 1024 * 1024;
export interface AcpxSidecarRequest {
protocolVersion: typeof ACPX_SIDECAR_PROTOCOL_VERSION;
id: number;
command: GeneratedAcpxSidecarCommand;
params: Record<string, unknown>;
}
export interface AcpxSidecarResponse {
protocolVersion: typeof ACPX_SIDECAR_PROTOCOL_VERSION;
id: number;
ok: boolean;
result?: Record<string, unknown>;
error?: { code: string; message: string; retryable: boolean };
}
export interface AcpxSidecarEvent {
protocolVersion: typeof ACPX_SIDECAR_PROTOCOL_VERSION;
sequence: number;
eventType: GeneratedAcpxSidecarEventType;
runId: string | null;
turnId: string | null;
payload: Record<string, unknown>;
}
export interface AcpxSidecarOpenParams {
runtimeDirectory: string;
normalizedSessionId: string;
workingDirectory: string;
agent: QualifiedAcpxAgent;
model: string;
tools: readonly Readonly<Record<string, unknown>>[];
expectedIdentity?: AcpxExpectedSessionIdentity;
}
export interface AcpxExpectedSessionIdentity {
kind: "acpx";
normalizedSessionId: string;
acpxRecordId: string;
backendSessionId: string;
agentSessionId: string;
profileDigest: string;
workspaceDigest: string;
requestedModel: string;
effectiveModel: string;
}
export function parseAcpxSidecarRequest(value: unknown): AcpxSidecarRequest {
const request = record(value);
if (request.protocolVersion !== ACPX_SIDECAR_PROTOCOL_VERSION) {
throw new Error("unsupported ACPX sidecar protocol version");
}
if (!Number.isSafeInteger(request.id) || Number(request.id) < 1) throw new Error("sidecar request id must be a positive integer");
const command = text(request.command);
if (!(GENERATED_ACPX_SIDECAR_COMMANDS as readonly string[]).includes(command)) throw new Error("unsupported ACPX sidecar command");
return {
protocolVersion: ACPX_SIDECAR_PROTOCOL_VERSION,
id: Number(request.id),
command: command as AcpxSidecarRequest["command"],
params: structuredClone(record(request.params)),
};
}
export function boundedSidecarValue(value: unknown, maxBytes = 64 * 1024): Record<string, unknown> {
const serialized = JSON.stringify(value);
if (!serialized || Buffer.byteLength(serialized) > maxBytes) return { omitted: true, reason: "payload_limit" };
return record(JSON.parse(serialized));
}
export function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
export function text(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
@@ -81,6 +81,7 @@ class FakeCodexTransport implements CodexAppServerTransport {
readResponse: Record<string, unknown> | null = null;
turnStartResponse: Promise<Record<string, unknown>> | null = null;
goalState: Record<string, unknown> | null = null;
confirmCollaborationMode = true;
constructor(
readonly threadId = "thread-1",
@@ -94,7 +95,13 @@ class FakeCodexTransport implements CodexAppServerTransport {
if (method === "initialize") {
return { userAgent: "codex-cli/0.132.0", codexHome: "/isolated/codex", platformFamily: "unix", platformOs: "linux" };
}
if (method === "collaborationMode/list") {
return this.confirmCollaborationMode
? { data: [{ name: "Plan", mode: "plan", model: "gpt-test", reasoning_effort: "high" }] }
: { data: [{ name: "Default", mode: "default", model: "gpt-test" }] };
}
if (method === "thread/start" || method === "thread/resume") {
const planMode = params.permissions === "paperclip-runner-workspace-read-only";
return {
thread: {
id: this.threadId,
@@ -102,7 +109,7 @@ class FakeCodexTransport implements CodexAppServerTransport {
modelProvider: "openai",
cwd: "/workspace",
turns: [],
activePermissionProfile: { id: "paperclip-runner-workspace-only" },
activePermissionProfile: { id: planMode ? "paperclip-runner-workspace-read-only" : "paperclip-runner-workspace-only" },
},
model: "gpt-test",
modelProvider: "openai",
@@ -343,7 +350,10 @@ describe("Codex app-server Codex driver", () => {
});
await expect(
session.startTurn({ message: { role: "user", text: "And a follow-up" } }),
).resolves.toEqual({ turnId: "turn-2" });
).resolves.toEqual({
turnId: "turn-2",
effectiveCollaborationMode: "default",
});
});
it("passes the common typed-event contract and reports one provider turn terminal", async () => {
@@ -414,6 +424,34 @@ describe("Codex app-server Codex driver", () => {
expect(await session.usage?.()).toMatchObject({ modelContextWindow: 128000 });
});
it("accepts a thread usage snapshot replayed before a resumed turn starts", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({
runId: "run-usage-replay",
normalizedSessionId: "normalized-usage-replay",
workingDirectory: "/workspace",
});
transport.push("thread/tokenUsage/updated", {
threadId: "thread-1",
turnId: "prior-turn",
tokenUsage: { total: { inputTokens: 10, outputTokens: 4 } },
});
const turn = await session.startTurn({ message: { role: "user", text: "Continue." } });
transport.push("turn/started", {
threadId: "thread-1",
turn: { id: turn.turnId, status: "inProgress" },
});
transport.push("turn/completed", {
threadId: "thread-1",
turn: { id: turn.turnId, status: "completed", items: [] },
});
const events = await collectUntilTerminal(session.events());
expect(events.some((event) => event.eventType === "session.failed")).toBe(false);
expect(await session.usage()).toMatchObject({ total: { inputTokens: 10, outputTokens: 4 } });
});
it("normalizes provider message and reasoning phases onto every streamed item event", async () => {
const transport = new FakeCodexTransport();
const session = await makeDriver([transport]).openSession({
@@ -521,6 +559,44 @@ describe("Codex app-server Codex driver", () => {
});
});
it("negotiates genuine plan mode with collaboration instructions and read-only workspace access", async () => {
const transport = new FakeCodexTransport();
const driver = makeDriver([transport], { requestedCollaborationMode: "plan" });
const session = await driver.openSession({ runId: "run-plan", normalizedSessionId: "session-plan", workingDirectory: "/workspace" });
expect(transport.calls.find((call) => call.method === "thread/start")?.params).toMatchObject({
permissions: "paperclip-runner-workspace-read-only",
config: { include_collaboration_mode_instructions: true },
});
expect(transport.calls.find((call) => call.method === "thread/start")?.params)
.not.toHaveProperty("collaborationMode");
expect(transport.calls.some((call) => call.method === "collaborationMode/list")).toBe(true);
await expect(session.startTurn({
message: { role: "user", text: "Author a plan." },
requestedCollaborationMode: "plan",
})).resolves.toMatchObject({ effectiveCollaborationMode: "plan" });
expect(transport.calls.find((call) => call.method === "turn/start")?.params).toMatchObject({
collaborationMode: {
mode: "plan",
settings: {
model: "gpt-test",
reasoning_effort: "high",
developer_instructions: null,
},
},
permissions: "paperclip-runner-workspace-read-only",
});
});
it("fails closed when the installed app-server does not confirm plan mode", async () => {
const transport = new FakeCodexTransport();
transport.confirmCollaborationMode = false;
await expect(makeDriver([transport], { requestedCollaborationMode: "plan" }).openSession({
runId: "run-plan-unsupported",
normalizedSessionId: "session-plan-unsupported",
workingDirectory: "/workspace",
})).rejects.toThrow("planning_mode_unsupported");
});
it("refuses to turn host credential roots into model-writable workspaces", async () => {
await expect(makeDriver([]).openSession({
runId: "run-codex-home",
@@ -683,7 +759,10 @@ describe("Codex app-server Codex driver", () => {
await new Promise((resolve) => setTimeout(resolve, 0));
await session.interrupt?.({ reason: "operator" });
releaseStart({ turn: { id: "turn-1", status: "inProgress", items: [] } });
await expect(starting).resolves.toEqual({ turnId: "turn-1" });
await expect(starting).resolves.toEqual({
turnId: "turn-1",
effectiveCollaborationMode: "default",
});
expect(transport.calls.map(({ method }) => method)).toEqual(expect.arrayContaining([
"turn/start", "turn/interrupt",
]));
@@ -61,6 +61,7 @@ import {
const DRIVER_KIND = "codex_app_server";
const DRIVER_VERSION = "codex-v2";
const SKILLLESS_PERMISSION_PROFILE = "paperclip-runner-workspace-only";
const PLANNING_PERMISSION_PROFILE = "paperclip-runner-workspace-read-only";
const MAX_RETAINED_CODEX_PAYLOAD_BYTES = 64 * 1024;
const MAX_RETAINED_CODEX_STRING_CHARS = 32 * 1024;
@@ -97,6 +98,7 @@ class AsyncQueue<T> implements AsyncIterable<T> {
export interface CodexAppServerDriverOptions {
taskEnvelope: CodexTaskEnvelope;
conversationMode?: "task" | "direct";
requestedCollaborationMode?: "default" | "plan";
transportFactory?: () => CodexAppServerTransport;
/** Additional control-plane tools exposed to the provider for this run. */
dynamicTools?: readonly Readonly<Record<string, unknown>>[];
@@ -145,6 +147,7 @@ interface TerminalReplayConflict {
interface OpenedCodexThread {
threadId: string;
providerSessionId: string | null;
collaborationMode: Record<string, unknown> | null;
context: CodexModelContextSnapshot;
lineage: HarnessThreadLineageEntry;
}
@@ -234,6 +237,10 @@ function blockToolSpec(): Record<string, unknown> {
};
}
export function codexSemanticToolSpecs(): readonly Readonly<Record<string, unknown>>[] {
return [finishToolSpec(), blockToolSpec()];
}
function dynamicToolResponse(value: unknown): Record<string, unknown> {
return {
success: true,
@@ -271,6 +278,13 @@ export function createSkilllessCodexThreadConfig(
return { ...SKILLLESS_BASE_CONFIG };
}
function collaborationThreadConfig(mode: "default" | "plan") {
return {
...SKILLLESS_BASE_CONFIG,
include_collaboration_mode_instructions: mode === "plan",
};
}
function tomlString(value: string): string {
return JSON.stringify(value);
}
@@ -288,6 +302,13 @@ export function createIsolatedCodexAppServerArgs(
...deniedHostRoots.map((path) => `${tomlString(path)}=\"none\"`),
`\":workspace_roots\"={\".\"=\"write\"}`,
].join(",");
const planningFilesystemRules = [
`\":root\"=\"none\"`,
`\":minimal\"=\"read\"`,
`\":tmpdir\"=\"none\"`,
...deniedHostRoots.map((path) => `${tomlString(path)}=\"none\"`),
`\":workspace_roots\"={\".\"=\"read\"}`,
].join(",");
const commandEnv = Object.entries(commandEnvironment(source))
.map(([key, value]) => `${key}=${tomlString(value)}`)
.join(",");
@@ -299,6 +320,10 @@ export function createIsolatedCodexAppServerArgs(
"-c",
`permissions.${SKILLLESS_PERMISSION_PROFILE}.network.enabled=false`,
"-c",
`permissions.${PLANNING_PERMISSION_PROFILE}.filesystem={${planningFilesystemRules}}`,
"-c",
`permissions.${PLANNING_PERMISSION_PROFILE}.network.enabled=false`,
"-c",
`shell_environment_policy.inherit=\"none\"`,
"-c",
"shell_environment_policy.ignore_default_excludes=false",
@@ -314,11 +339,13 @@ export function createIsolatedCodexAppServerArgs(
function securedThreadParams(
workingDirectory: string,
source: NodeJS.ProcessEnv = process.env,
mode: "default" | "plan" = "default",
): Record<string, unknown> {
const permissionProfile = mode === "plan" ? PLANNING_PERMISSION_PROFILE : SKILLLESS_PERMISSION_PROFILE;
return {
cwd: workingDirectory,
config: createSkilllessCodexThreadConfig(workingDirectory, source),
permissions: SKILLLESS_PERMISSION_PROFILE,
config: collaborationThreadConfig(mode),
permissions: permissionProfile,
runtimeWorkspaceRoots: [workingDirectory],
};
}
@@ -387,6 +414,7 @@ export class CodexAppServerDriver implements HarnessDriver {
runtimeRequestResolution: this.#caps.runtimeRequestResolution,
goals: this.#caps.goals,
threadLineage: this.#caps.threadLineage,
collaborationModes: ["default", "plan"],
unsupported,
},
};
@@ -398,19 +426,25 @@ export class CodexAppServerDriver implements HarnessDriver {
try {
await this.#persistProcessOwnership(transport);
const initialize = await this.#initialize(transport);
const requestedMode = this.#options.requestedCollaborationMode ?? "default";
const response = await transport.request("thread/start", {
...securedThreadParams(workingDirectory, this.#options.environment),
...securedThreadParams(workingDirectory, this.#options.environment, requestedMode),
approvalPolicy: "never",
...(this.#direct() ? {} : { baseInstructions: CODEX_SKILLLESS_BASE_INSTRUCTIONS }),
dynamicTools: this.#direct()
? []
: this.#caps.dynamicTools
? [...(this.#options.dynamicTools ?? []), finishToolSpec(), blockToolSpec()]
? [...(this.#options.dynamicTools ?? []), ...codexSemanticToolSpecs()]
: [],
experimentalRawEvents: false,
persistExtendedHistory: false,
});
const opened = this.#openedThread(response, initialize, workingDirectory);
const collaborationMode = await this.#negotiateCollaborationMode(
transport,
response,
requestedMode,
);
const opened = this.#openedThread(response, initialize, workingDirectory, collaborationMode);
const goal = await this.#discoverGoal(transport, opened.threadId);
if (opened.context.liveConsole) opened.context.liveConsole.goals = this.#caps.goals;
return this.#session({
@@ -459,11 +493,20 @@ export class CodexAppServerDriver implements HarnessDriver {
);
const response = await transport.request("thread/resume", {
threadId: snapshot.driverSessionId,
...securedThreadParams(workingDirectory, this.#options.environment),
...securedThreadParams(
workingDirectory,
this.#options.environment,
this.#options.requestedCollaborationMode ?? "default",
),
baseInstructions: this.#direct() ? "" : CODEX_SKILLLESS_BASE_INSTRUCTIONS,
persistExtendedHistory: false,
});
const opened = this.#openedThread(response, initialize, workingDirectory);
const collaborationMode = await this.#negotiateCollaborationMode(
transport,
response,
this.#options.requestedCollaborationMode ?? "default",
);
const opened = this.#openedThread(response, initialize, workingDirectory, collaborationMode);
if (opened.threadId !== snapshot.driverSessionId) {
await transport.close();
return { recovered: false, reason: "provider resumed a different session" };
@@ -512,6 +555,40 @@ export class CodexAppServerDriver implements HarnessDriver {
);
}
async #negotiateCollaborationMode(
transport: CodexAppServerTransport,
threadResponse: Record<string, unknown>,
requested: "default" | "plan",
): Promise<Record<string, unknown> | null> {
if (requested !== "plan") return null;
try {
const response = await transport.request("collaborationMode/list", {});
const preset = Array.isArray(response.data)
? response.data.map(record).find((candidate) => text(candidate.mode) === "plan")
: undefined;
if (!preset) throw new Error("plan preset is absent");
const model = text(
preset.model,
text(threadResponse.model, text(record(threadResponse.thread).model)),
);
if (model.length === 0) throw new Error("plan preset did not resolve a model");
return {
mode: "plan",
settings: {
model,
reasoning_effort: preset.reasoning_effort ?? null,
developer_instructions: null,
},
};
} catch (cause) {
const error = new Error(
`planning_mode_unsupported: installed Codex app-server did not expose a usable native plan collaboration mode (${redactCodexDiagnostic(String(cause))})`,
);
error.name = "PlanningModeUnsupportedError";
throw error;
}
}
async #persistProcessOwnership(transport: CodexAppServerTransport): Promise<void> {
if (!this.#options.onSpawn) return;
const processInfo: CodexTransportProcessInfo | undefined = transport.processInfo?.();
@@ -564,21 +641,26 @@ export class CodexAppServerDriver implements HarnessDriver {
response: Record<string, unknown>,
initialize: Record<string, unknown>,
workingDirectory: string,
collaborationMode: Record<string, unknown> | null,
): OpenedCodexThread {
const thread = record(response.thread);
const threadId = text(thread.id);
if (threadId.length === 0) throw new Error("Codex thread response omitted thread.id");
const activePermissionProfile = record(thread.activePermissionProfile);
const permissionProfileId = text(activePermissionProfile.id);
const requestedMode = this.#options.requestedCollaborationMode ?? "default";
const requiredPermissionProfile = requestedMode === "plan"
? PLANNING_PERMISSION_PROFILE
: SKILLLESS_PERMISSION_PROFILE;
if (
permissionProfileId.length > 0 &&
permissionProfileId !== SKILLLESS_PERMISSION_PROFILE
permissionProfileId !== requiredPermissionProfile
) {
throw new Error("Codex thread did not activate the required filesystem permission profile");
}
const configuredPermissionProfile = {
...activePermissionProfile,
id: SKILLLESS_PERMISSION_PROFILE,
id: requiredPermissionProfile,
};
const returnedWorkingDirectory = text(response.cwd, workingDirectory);
if (resolve(returnedWorkingDirectory) !== workingDirectory) {
@@ -587,6 +669,7 @@ export class CodexAppServerDriver implements HarnessDriver {
return {
threadId,
providerSessionId: text(thread.sessionId) || null,
collaborationMode,
context: {
protocolVersion: CODEX_CODEX_PROTOCOL_VERSION,
codexVersion: boundedText(initialize.userAgent),
@@ -598,12 +681,13 @@ export class CodexAppServerDriver implements HarnessDriver {
model: boundedText(response.model),
modelProvider: boundedText(response.modelProvider, boundedText(thread.modelProvider)),
workingDirectory,
collaborationMode: collaborationMode === null ? "default" : "plan",
sandbox: {
permissionProfile: boundedCodexValue(configuredPermissionProfile),
legacyPolicy: boundedCodexValue(response.sandbox ?? null),
rootAccess: "none",
minimalRuntimeAccess: "read",
workspaceAccess: "write",
workspaceAccess: requestedMode === "plan" ? "read" : "write",
networkAccess: false,
},
approvalPolicy: boundedCodexValue(response.approvalPolicy ?? "never"),
@@ -617,7 +701,7 @@ export class CodexAppServerDriver implements HarnessDriver {
instructionPolicy: {
skillInstructions: false,
appInstructions: false,
collaborationInstructions: false,
collaborationInstructions: requestedMode === "plan",
},
environmentKeys: Object.keys(
commandEnvironment(this.#options.environment),
@@ -833,7 +917,10 @@ class CodexHarnessSession implements HarnessSession {
return this.#events;
}
async startTurn(input: { message: NativeUserMessage }): Promise<{ turnId: string }> {
async startTurn(input: {
message: NativeUserMessage;
requestedCollaborationMode?: "default" | "plan";
}): Promise<{ turnId: string; effectiveCollaborationMode: "default" | "plan" }> {
if (this.#terminal || this.#protocolFailed || this.#activeTurnId !== null || this.#turnStartPending) {
throw this.#unsupported(
"turn start",
@@ -845,21 +932,35 @@ class CodexHarnessSession implements HarnessSession {
const taskText = this.#conversationMode === "direct"
? input.message.text
: JSON.stringify({ task: this.#taskEnvelope, message: input.message.text });
const effectiveCollaborationMode = this.#opened.context.collaborationMode;
if (input.requestedCollaborationMode && input.requestedCollaborationMode !== effectiveCollaborationMode) {
throw new Error(
`collaboration_mode_mismatch: requested ${input.requestedCollaborationMode}, effective ${effectiveCollaborationMode}`,
);
}
// The submitted text is part of the canonical record so a tracer can show
// the operator's own message without keeping shadow state next to the
// reducer.
this.#emit("turn.submitted", {
envelopeSchema: this.#taskEnvelope.schema,
text: input.message.text,
requestedCollaborationMode: input.requestedCollaborationMode ?? effectiveCollaborationMode,
effectiveCollaborationMode,
});
this.#turnStartPending = true;
let response: Record<string, unknown>;
const requestedMode = this.#opened.context.collaborationMode;
try {
response = await this.#transport.request("turn/start", {
threadId: this.#opened.threadId,
cwd: this.#opened.context.workingDirectory,
permissions: SKILLLESS_PERMISSION_PROFILE,
permissions: requestedMode === "plan"
? PLANNING_PERMISSION_PROFILE
: SKILLLESS_PERMISSION_PROFILE,
runtimeWorkspaceRoots: [this.#opened.context.workingDirectory],
...(this.#opened.collaborationMode === null
? {}
: { collaborationMode: this.#opened.collaborationMode }),
input: [userInput({ role: "user", text: taskText })],
...(this.#conversationMode === "direct" ? {} : { outputSchema: CODEX_RESULT_OUTPUT_SCHEMA }),
});
@@ -879,7 +980,10 @@ class CodexHarnessSession implements HarnessSession {
this.#interruptQueued = false;
await this.#sendInterrupt(turnId, "queued_before_start");
}
return { turnId };
return {
turnId,
effectiveCollaborationMode,
};
}
async steer(input: { turnId: string; message: NativeUserMessage }): Promise<void> {
@@ -1383,8 +1487,11 @@ class CodexHarnessSession implements HarnessSession {
return;
}
if (notification.method === "thread/tokenUsage/updated") {
if (!this.#notificationNamesActiveTurn(turnId, "usage update")) return;
this.#usage = boundedPayload(record(params.tokenUsage));
// Codex can replay a thread-scoped usage snapshot while a resumed thread
// is being attached, before the next turn has started. Keep the snapshot,
// but do not turn that benign replay into a fatal turn-binding violation.
if (this.#activeTurnId === null || turnId !== this.#activeTurnId) return;
this.#emit("item.completed", { kind: "usage", usage: this.#usage }, {
turnId,
itemId: `${turnId}:usage:${this.#sourceSequence + 1}`,
@@ -44,6 +44,24 @@ describe("OpenCode MCP bridge", () => {
});
});
it("admits runner-private extension operations without advertising them to the model", async () => {
const handler = vi.fn(async ({ tool }) => ({ decision: tool === "__paperclip_permission" ? "accept" : "decline" }));
const bridge = await startOpenCodeMcpBridge({
tools: [{ name: "documents.read", inputSchema: { type: "object" } }],
privateTools: [{ name: "__paperclip_permission", inputSchema: { type: "object" } }],
handler,
});
bridges.push(bridge);
const listed = await (await rpc(bridge, { id: 1, method: "tools/list" })).json() as { result: { tools: Array<{ name: string }> } };
expect(listed.result.tools.map((tool) => tool.name)).not.toContain("__paperclip_permission");
expect(await (await rpc(bridge, {
id: "permission-1",
method: "tools/call",
params: { name: "__paperclip_permission", arguments: {} },
})).json()).toMatchObject({ result: { content: [{ text: "{\"decision\":\"accept\"}" }] } });
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ tool: "__paperclip_permission" }));
});
it("maps prefixed names and replays identical duplicate calls exactly once", async () => {
const handler = vi.fn(async () => ({ value: 7 }));
const bridge = await startOpenCodeMcpBridge({
@@ -24,6 +24,8 @@ export interface OpenCodeMcpCall {
export interface OpenCodeMcpBridgeOptions {
tools?: readonly Readonly<Record<string, unknown>>[];
/** Runner-owned operations callable only by a trusted harness extension and never advertised to the model. */
privateTools?: readonly Readonly<Record<string, unknown>>[];
handler: (call: OpenCodeMcpCall) => Promise<unknown>;
timeoutMs?: number;
maxBodyBytes?: number;
@@ -53,13 +55,16 @@ export async function startOpenCodeMcpBridge(
): Promise<OpenCodeMcpBridge> {
const secret = options.secret ?? randomBytes(32).toString("base64url");
const tools = normalizeTools(options.tools ?? []);
const validators = compileValidators(tools);
const privateTools = normalizeTools(options.privateTools ?? [], false);
const admittedTools = [...tools, ...privateTools.filter((candidate) => !tools.some((tool) => tool.name === candidate.name))];
const validators = compileValidators(admittedTools);
const calls = new Map<string, { fingerprint: string; promise: Promise<unknown> }>();
const controllers = new Map<string, AbortController>();
const server = createServer((request, response) => {
void handleRequest(request, response, {
secret,
tools,
admittedTools,
validators,
calls,
controllers,
@@ -90,7 +95,10 @@ export async function startOpenCodeMcpBridge(
};
}
function normalizeTools(input: readonly Readonly<Record<string, unknown>>[]): OpenCodeMcpToolDefinition[] {
function normalizeTools(
input: readonly Readonly<Record<string, unknown>>[],
includeTerminalTools = true,
): OpenCodeMcpToolDefinition[] {
const tools: OpenCodeMcpToolDefinition[] = [];
const seen = new Set<string>();
for (const raw of input) {
@@ -104,7 +112,7 @@ function normalizeTools(input: readonly Readonly<Record<string, unknown>>[]): Op
});
seen.add(name);
}
for (const terminal of [
if (includeTerminalTools) for (const terminal of [
{ name: CODEX_COMPLETION_TOOL_NAME, description: "Return the semantic completion result.", inputSchema: CODEX_RESULT_OUTPUT_SCHEMA },
{ name: CODEX_BLOCK_TOOL_NAME, description: "Return the semantic blocked result.", inputSchema: CODEX_BLOCK_RESULT_OUTPUT_SCHEMA },
]) {
@@ -124,6 +132,7 @@ async function handleRequest(
context: {
secret: string;
tools: OpenCodeMcpToolDefinition[];
admittedTools: OpenCodeMcpToolDefinition[];
validators: Map<string, ValidateFunction>;
calls: Map<string, { fingerprint: string; promise: Promise<unknown> }>;
controllers: Map<string, AbortController>;
@@ -202,7 +211,7 @@ async function handleRequest(
const params = isRecord(message.params) ? message.params : {};
const rawName = typeof params.name === "string" ? params.name : "";
const tool = canonicalOpenCodeMcpToolName(rawName);
if (!context.tools.some((entry) => entry.name === tool)) {
if (!context.admittedTools.some((entry) => entry.name === tool)) {
writeRpc(response, id, { isError: true, content: [{ type: "text", text: "Unsupported tool." }] });
return;
}
@@ -0,0 +1,11 @@
import {
canonicalOpenCodeMcpToolName,
startOpenCodeMcpBridge,
type OpenCodeMcpBridge,
} from "./opencode/mcp-bridge.js";
/** Provider-neutral name for the authenticated semantic bridge. */
export type RunnerToolBridge = OpenCodeMcpBridge;
export const canonicalRunnerToolName = canonicalOpenCodeMcpToolName;
export const startRunnerToolBridge = startOpenCodeMcpBridge;
@@ -16,6 +16,10 @@ const RATES: Readonly<Record<string, TokenRatesUsdPerMillion>> = Object.freeze({
"gpt-5.6-luna": { input: 0.2, cachedInput: 0.02, output: 1.2 },
// OpenRouter routed list price published for DeepSeek V4 Flash 0731.
"openrouter/deepseek/deepseek-v4-flash-0731": { input: 0.065, cachedInput: 0.02, output: 0.18 },
// Qualified Anthropic direct profile. Actual invoice discounts are intentionally excluded.
"claude-sonnet-5": { input: 3, cachedInput: 0.3, output: 15 },
// Amazon Bedrock global cross-region list price for Claude Sonnet 4.6.
"global.anthropic.claude-sonnet-4-6": { input: 3, cachedInput: 0.3, output: 15 },
});
export interface EstimatedModelCost {
+6
View File
@@ -9,6 +9,7 @@ export * from "./contracts/types.js";
export * from "./backends/harness-driver-backend.js";
export * from "./backends/codex-native-backend.js";
export * from "./backends/opencode-native-backend.js";
export * from "./backends/acpx-native-backend.js";
export * from "./backends/native-backend-factory.js";
export * from "./native-session-runtime.js";
export * from "./provider-events.js";
@@ -20,6 +21,11 @@ export * from "./drivers/codex/app-server-transport.js";
export * from "./drivers/codex/codex-app-server-driver.js";
export * from "./drivers/opencode/opencode-server-driver.js";
export * from "./drivers/opencode/mcp-bridge.js";
export * from "./drivers/acpx/qualified-profiles.js";
export * from "./drivers/acpx/acpx-runtime-host.js";
export * from "./drivers/acpx/acpx-runtime-driver.js";
export * from "./drivers/acpx/sidecar-protocol.js";
export * from "./drivers/runner-tool-bridge.js";
export {
createRunnerdCodexTransport,
defaultCapabilityRunnerdBinary,
@@ -45,6 +45,11 @@ export const CAPABILITY_UI_SHOT_SLUGS = [
"ti-terminal-input",
"wt-intentional-wait",
"pn-provider-notices",
"ax-acpx-lifecycle",
"ax-acpx-permissions",
"ax-acpx-tools",
"ax-acpx-events",
"ax-acpx-failures",
"cm-stream-reconcile",
"cm-session-continuity",
"cm-tool-read",
@@ -60,6 +65,21 @@ export const CAPABILITY_UI_SHOT_SLUGS = [
"cm-provisioning-auth",
"cm-redacted-failure",
"cm-provider-labels",
"ac-streaming",
"ac-session-continuity",
"ac-tool-read",
"ac-tool-mutation",
"ac-completion-control",
"ac-tool-denied",
"ac-tool-duplicate",
"ac-eventstream-recovery",
"ac-memory-reconciliation",
"ac-interrupt",
"ac-warm-restore",
"ac-limits",
"ac-session-delete",
"ac-iam-profile",
"ac-redacted-failure",
] as const;
export type CapabilityUiShotSlug = (typeof CAPABILITY_UI_SHOT_SLUGS)[number];
@@ -1178,6 +1198,11 @@ const PROVIDER_SCENARIOS: Record<string, { family: ProviderFixtureFamily; eventT
"ti-terminal-input": { family: "terminal", eventType: "terminal.input.sent", title: "Terminal input", payload: { schema: "paperclip.terminal.input_sent.v1", executionId: "exec-5", origin: "agent", inputClass: "control", byteCount: 1 } },
"wt-intentional-wait": { family: "wait", eventType: "wait.completed", title: "Intentional wait", payload: { schema: "paperclip.wait.v1", waitId: "wait-1", reason: "timer", status: "interrupted", plannedDurationMs: 30000, elapsedDurationMs: 8200 } },
"pn-provider-notices": { family: "provider_notice", eventType: "provider.notice.recorded", title: "Provider notice", payload: { schema: "paperclip.provider.notice.v1", noticeId: "notice-1", severity: "warning", category: "deprecation", scope: "environment", recoverable: true, userActionable: false, summary: "A provider option is deprecated; the turn can continue." } },
"ax-acpx-lifecycle": { family: "context", eventType: "acpx.session.restored", title: "ACPX session lifecycle", payload: { provider: "acpx", driver: "acpx_runtime", agent: "pi", acpxVersion: "0.13.1", acpProtocolVersion: 1, acpxRecordId: "acpx_record_fixture", providerSessionId: "pi_session_fixture", runnerPid: 48121, sidecarPid: 48122, agentPid: 48123, coldTurnMs: 1850, warmTurnMs: 410, restoredTurnMs: 920, warmPidContinuous: true, restoredSessionContinuous: true, status: "completed", summary: "A warm second run retained the ACP child; per-turn suspension later loaded the same persistent ACP session." } },
"ax-acpx-permissions": { family: "safety", eventType: "acpx.permission.resolved", title: "ACPX permission", payload: { provider: "acpx", nativeAgent: "claude", extensionAgent: "pi", requestKinds: ["command_approval", "file_approval"], decisions: ["accept", "accept_for_session", "decline", "cancel"], waitedMs: 86400000, contentRetained: false, processLossDisposition: "recoverably_blocked", status: "completed", summary: "Native Claude and Pi-extension requests use the same durable, exactly-once Paperclip approval path." } },
"ax-acpx-tools": { family: "tool_execution", eventType: "acpx.semantic_tool.completed", title: "ACPX semantic tools", payload: { provider: "acpx", bridge: "loopback_authenticated", operationId: "report_progress", callId: "acpx-call-4", catalogRevision: 3, duplicateDeliveries: 2, executionCount: 1, staleCatalogDenied: true, unauthorizedCallDenied: true, finishAndBlockStructured: true, status: "completed", summary: "The current attached run catalog remained authoritative across MCP refresh and ACP session reload." } },
"ax-acpx-events": { family: "model_identity", eventType: "acpx.event.normalized", title: "ACPX normalized events", payload: { provider: "acpx", agent: "claude", requestedModel: "claude-sonnet-5", effectiveModel: "claude-sonnet-5", mapped: ["assistant_delta", "tool_progress", "workspace_diff", "file_reference", "plan.updated", "usage"], hiddenReasoningRetained: false, rawAcpRetained: false, replayDuplicates: 0, unknownVariant: "provider.notice.recorded", status: "completed", summary: "Bounded ACP notifications were normalized once with run, turn, item, sequence, and model attribution." } },
"ax-acpx-failures": { family: "provider_notice", eventType: "acpx.failure.recorded", title: "ACPX failure isolation", payload: { provider: "acpx", failures: ["missing_auth", "unsupported_model", "profile_digest_mismatch", "malformed_frame", "oversized_update", "agent_crash", "hung_prompt"], descendantsReaped: ["sidecar", "acp_server", "agent"], credentialValuesRetained: false, rawPayloadRetained: false, status: "failed", summary: "Qualification and runtime failures fail closed, redact diagnostics, block unsafe recovery, and reap the full process group." } },
"cm-stream-reconcile": { family: "model_identity", eventType: "managed_agent.message.reconciled", title: "Claude Agent stream reconciliation", payload: { provider: "claude_managed", driver: "claude_managed_agents_api", executionKind: "remote_service", providerSessionId: "session_managed_01", preview: "Draft response…", authoritative: "Authoritative response from the buffered agent.message event.", replacedPreview: true, summary: "The authoritative Managed Agent message replaced its provisional preview." } },
"cm-session-continuity": { family: "context", eventType: "managed_agent.session.continued", title: "Managed session continuity", payload: { providerSessionId: "session_managed_01", paperclipRuns: ["run-managed-1", "run-managed-2"], runnerPid: 48121, providerPid: null, warmTurnMs: 612, summary: "Two Paperclip runs used one Anthropic cloud session with isolated run authority." } },
"cm-tool-read": { family: "tool_execution", eventType: "semantic_tool.result", title: "Authorized managed read", payload: { schema: "paperclip.semantic_tool.result.v1", callId: "sevt_read_1", operationId: "get_task_context", status: "completed", duplicate: false, result: { identifier: "MCK-31", stateRevision: 4 }, summary: "runnerd validated and executed an authorized read without exposing Paperclip to Anthropic." } },
@@ -1193,6 +1218,21 @@ const PROVIDER_SCENARIOS: Record<string, { family: ProviderFixtureFamily; eventT
"cm-provisioning-auth": { family: "provider_notice", eventType: "managed_agent.profile.qualified", title: "Provisioning and retention", payload: { betaVersion: "managed-agents-2026-04-01", environmentNetworking: "limited", allowedHosts: [], builtInTools: [], mcpServers: [], retentionAcknowledged: true, authentication: "secret reference", summary: "A read-only probe verified the pinned Agent, version, Environment, model, and account capability." } },
"cm-redacted-failure": { family: "provider_notice", eventType: "managed_agent.failure.redacted", title: "Redacted remote failure", payload: { severity: "error", category: "authentication", recoverable: true, userActionable: true, redactedFields: ["x-api-key", "authorization", "request headers"], retainedRemoteObject: false, summary: "Anthropic credentials and raw error objects were removed before durable evidence." } },
"cm-provider-labels": { family: "model_identity", eventType: "managed_agent.identity.displayed", title: "Provider-neutral labels", payload: { label: "Claude Agent", providerRuntime: "Anthropic cloud", runnerPid: 48121, providerPid: null, workingLabel: "Claude Agent is working", forbiddenLabelsAbsent: ["Real Codex", "Codex is thinking", "Claude Code"], summary: "The Lab distinguishes the local runner process from the remote provider runtime." } },
"ac-streaming": { family: "model_identity", eventType: "agentcore.message.reconciled", title: "AgentCore streaming", payload: { provider: "aws_agentcore", driver: "aws_agentcore_harness_api", executionKind: "remote_service", runtimeSessionId: "paperclip-4a7e-session", eventStreamFrames: 14, preview: "Streaming response…", authoritative: "The complete AgentCore response.", summary: "AWS EventStream text was normalized into provisional deltas and one authoritative completed item." } },
"ac-session-continuity": { family: "context", eventType: "agentcore.session.continued", title: "AgentCore session continuity", payload: { runtimeSessionId: "paperclip-4a7e-session", paperclipRuns: ["run-aws-1", "run-aws-2"], runnerPid: 48121, providerPid: null, warmTurnMs: 744, summary: "Two isolated Paperclip run attachments reused one AWS runtime session and its Memory history." } },
"ac-tool-read": { family: "tool_execution", eventType: "semantic_tool.result", title: "Authorized AgentCore read", payload: { schema: "paperclip.semantic_tool.result.v1", callId: "aws-tool-read-1", operationId: "get_task_context", status: "completed", duplicate: false, result: { identifier: "MCK-31", stateRevision: 4 }, allowedToolsCount: 8, summary: "runnerd validated and executed an exact-catalog read; AWS received only its inline result." } },
"ac-tool-mutation": { family: "tool_execution", eventType: "semantic_tool.result", title: "Authorized AgentCore mutation", payload: { schema: "paperclip.semantic_tool.result.v1", callId: "aws-tool-write-1", operationId: "report_progress", status: "completed", executionCount: 1, result: { stateRevision: 5 }, summary: "The durable Paperclip result preceded the AgentCore continuation and the mutation ran exactly once." } },
"ac-completion-control": { family: "review", eventType: "agentcore.completion.controlled", title: "AgentCore finish, block, and yield", payload: { accepted: "finish_task", alternatives: ["block_task", "yield_turn"], endTurnAloneAccepted: false, freeChatEndTurnAccepted: true, summary: "Governed runs require a structured disposition while Runner Lab free chat may end normally." } },
"ac-tool-denied": { family: "safety", eventType: "semantic_tool.denied", title: "AgentCore tool rejected", payload: { operationId: "shell", allowedTools: ["pc_get_task_context_a73f0a"], reasons: ["not in attached catalog", "schema-invalid input"], paperclipExecuted: false, status: "failed", summary: "Unknown or malformed inline functions fail closed before control-plane execution." } },
"ac-tool-duplicate": { family: "tool_execution", eventType: "semantic_tool.delivery.reconciled", title: "AgentCore duplicate-safe delivery", payload: { toolUseId: "tooluse-aws-44", executionCount: 1, deliveryAttempts: 2, assistantToolUseRestored: true, userToolResultRestored: true, status: "completed", summary: "A durable result was reused and the governed mutation was never repeated." } },
"ac-eventstream-recovery": { family: "context", eventType: "agentcore.eventstream.recovered", title: "EventStream recovery", payload: { invocationId: "invoke-aws-9", chunkedFrames: 11, replayedFrames: 3, duplicatesIgnored: 2, status: "completed", summary: "Chunked AWS EventStream output resumed without duplicating normalized PRP items." } },
"ac-memory-reconciliation": { family: "context", eventType: "agentcore.memory.reconciled", title: "Memory reconciliation", payload: { toolUseId: "tooluse-aws-44", snapshotEventId: "mem-before-208", stopRuntimeRequested: true, committedAfterAmbiguity: true, replayedMutation: false, status: "completed", summary: "A response-loss ambiguity was checked against AgentCore Memory before the runner advanced." } },
"ac-interrupt": { family: "provider_notice", eventType: "agentcore.turn.interrupted", title: "AgentCore turn interrupted", payload: { stopRuntimeSession: true, phases: ["generation", "waiting_for_tool"], status: "interrupted", recoverable: true, runtimeSessionIdRetained: true, summary: "Interrupt stopped AWS compute while retaining the resumable Memory-backed session." } },
"ac-warm-restore": { family: "context", eventType: "agentcore.session.restored", title: "AgentCore warm suspension and cold restore", payload: { runtimeSessionId: "paperclip-4a7e-session", warmRunnerReused: true, runtimeStopped: true, runnerRestarted: true, memoryReused: true, status: "completed", summary: "Warm and per-turn lifecycles preserve one AgentCore identity across Runtime restarts." } },
"ac-limits": { family: "provider_notice", eventType: "agentcore.limit.reached", title: "AgentCore invocation limits", payload: { currency: "USD", costSource: "paperclip_estimate", estimatedCost: 0.17, ceiling: 1, maxIterations: 8, maxOutputTokens: 4096, timeoutSeconds: 300, status: "completed", recoverable: true, summary: "Token, iteration, and time bounds are hard; the visible dollar ceiling is explicitly an estimate." } },
"ac-session-delete": { family: "provider_notice", eventType: "agentcore.session.deleted", title: "Delete AWS session", payload: { stopRuntimeSession: true, memoryEventsDeleted: 12, paginationComplete: true, localPointerRemovedAfterRemoteSuccess: true, status: "completed", summary: "Confirmed deletion stopped compute and purged every matching short-term Memory event." } },
"ac-iam-profile": { family: "provider_notice", eventType: "agentcore.profile.qualified", title: "AgentCore IAM and profile drift", payload: { harnessVersion: "3", endpointQualifier: "paperclip", invocationRoleAssumed: true, runtimeCommandDenied: true, retentionDays: 90, driftDetected: false, status: "completed", summary: "The probe verified the pinned Harness, endpoint, Memory, model, and least-privilege invocation role." } },
"ac-redacted-failure": { family: "provider_notice", eventType: "agentcore.failure.redacted", title: "Redacted AWS failure", payload: { severity: "error", category: "authentication", recoverable: true, userActionable: true, redactedFields: ["authorization", "x-amz-security-token", "x-amz-signature", "access key"], retainedSignedRequest: false, providerRuntime: "AWS cloud", providerPid: null, forbiddenLabelsAbsent: ["Real Codex", "Codex is thinking"], status: "failed", summary: "AWS credentials, signed headers, and raw remote errors never entered durable evidence." } },
};
function providerScenario(slug: string, spec: (typeof PROVIDER_SCENARIOS)[string]): CapabilityIssueThreadSnapshot {
@@ -63,8 +63,8 @@ function credentialLike(value: unknown): string[] {
describe("Capability issue-thread fixtures", () => {
it("exposes every screenshot slug exactly once", () => {
expect(CAPABILITY_UI_SHOT_SLUGS).toHaveLength(44);
expect(new Set(CAPABILITY_UI_SHOT_SLUGS).size).toBe(44);
expect(CAPABILITY_UI_SHOT_SLUGS).toHaveLength(64);
expect(new Set(CAPABILITY_UI_SHOT_SLUGS).size).toBe(64);
});
it("builds a schema-valid snapshot for every slug", () => {
@@ -756,8 +756,12 @@ export function projectCapabilityIssueThread(
const agentLabel = mode === "live"
? snapshot.config.provider === "claude_managed" || snapshot.config.driver === "claude_managed_agents_api"
? "Claude Agent"
: snapshot.config.provider === "aws_agentcore" || snapshot.config.driver === "aws_agentcore_harness_api"
? "Real AWS AgentCore"
: snapshot.config.provider === "opencode" || snapshot.config.driver === "opencode_server"
? "Real OpenCode"
: snapshot.config.provider === "acpx" || snapshot.config.driver === "acpx_runtime"
? `Real ${snapshot.config.acpxAgent === "claude" ? "Claude" : snapshot.config.acpxAgent === "codex" ? "Codex" : "Pi"} via ACPX`
: "Real Codex"
: mode === "replay" ? "Replay" : "Fake agent";
const connection = input.connection ?? { state: "connected", attempt: 0 };
@@ -162,6 +162,8 @@ class FakeCleanRoomTransport implements CodexAppServerTransport {
this.#handler = handler;
}
async attachRun(): Promise<void> {}
async close(): Promise<void> {
this.#closed = true;
this.queue.close();
@@ -56,6 +56,7 @@ interface FakeProviderState {
lastToolResult: string;
transports: FakeCapabilityCodexTransport[];
turns: Map<string, string>;
attachments: Array<{ runId: string; turnId: string; itemId: string }>;
holdAfterTool: boolean;
onTurnStart?: () => Promise<void>;
}
@@ -142,6 +143,10 @@ class FakeCapabilityCodexTransport implements CodexAppServerTransport {
this.#handler = handler;
}
async attachRun(input: { runId: string; turnId: string; itemId: string }): Promise<void> {
this.state.attachments.push(structuredClone(input));
}
invokeServerRequest(request: CodexRpcServerRequest): Promise<Record<string, unknown>> {
return this.#handler(request);
}
@@ -287,6 +292,7 @@ function providerState(): FakeProviderState {
lastToolResult: "nothing yet",
transports: [],
turns: new Map(),
attachments: [],
holdAfterTool: false,
};
}
@@ -431,6 +437,8 @@ describe("Capability live runnerd and Codex session", () => {
expect(first.assistantText).toContain("stateRevision");
expect(second.assistantText).toContain("stateRevision");
expect(first.snapshot.providerThreadId).toBe(second.snapshot.providerThreadId);
expect(state.attachments).toHaveLength(1);
expect(second.snapshot.providerRunBinding).toEqual(state.attachments[0]);
expect(second.snapshot.usageLedger.slice(-2)).toMatchObject([
{ providerRequests: 1, inputTokens: 100, outputTokens: 10 },
{ providerRequests: 1, inputTokens: 100, outputTokens: 10 },
@@ -8,6 +8,11 @@ import type {
} from "../drivers/codex/app-server-transport.js";
import { redactCodexDiagnostic } from "../drivers/codex/app-server-transport.js";
import { createSkilllessCodexThreadConfig } from "../drivers/codex/codex-app-server-driver.js";
import {
resolveQualifiedAcpxProfile,
type QualifiedAcpxAgent,
type QualifiedAcpxProfile,
} from "../drivers/acpx/qualified-profiles.js";
import { canonicalProviderEventsFromCodex } from "../provider-events.js";
import type {
CapabilityFixtureInteraction,
@@ -110,9 +115,11 @@ export interface CapabilityLiveSessionConfigSnapshot {
/** Exact model requested by the caller; omitted only for legacy interactive sessions. */
requestedModel?: string;
/** Safe harness identity used by server-side projections and eval metadata. */
provider?: "codex" | "opencode" | "claude_managed";
driver?: "codex_app_server" | "opencode_server" | "claude_managed_agents_api";
provider?: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx";
driver?: "codex_app_server" | "opencode_server" | "claude_managed_agents_api" | "aws_agentcore_harness_api" | "acpx_runtime";
providerVersion?: string | null;
acpxAgent?: QualifiedAcpxAgent;
acpxProfile?: QualifiedAcpxProfile;
managedProfile?: {
profileId: string;
anthropicAgentId: string;
@@ -121,6 +128,25 @@ export interface CapabilityLiveSessionConfigSnapshot {
betaVersion: "managed-agents-2026-04-01";
maxSessionListCostUsd: number;
};
agentCoreProfile?: {
profileId: string;
region: string;
accountId: string;
harnessArn: string;
harnessVersion: string;
endpointArn: string;
endpointQualifier: string;
agentRuntimeArn: string;
memoryArn: string;
memoryId: string;
invocationRoleArn: string;
qualificationRevision: string;
eventExpiryDays: 90;
maxEstimatedSessionCostUsd: number;
maxIterations: number;
maxOutputTokens: number;
timeoutSeconds: number;
};
scenario: CapabilitySemanticScenarioPolicy;
capabilities: string[];
explicitClaims: string[];
@@ -210,6 +236,8 @@ export interface CapabilityLiveSessionSnapshot {
semanticResult?: Record<string, CapabilityJsonValue> | null;
status: CapabilityLiveSessionStatus;
activeTurnId: string | null;
/** Latest immutable PRP attachment retained independently of the provider session. */
providerRunBinding?: { runId: string; turnId: string; itemId: string };
createdAt: string;
updatedAt: string;
authority: CapabilityLiveAuthoritySnapshot;
@@ -242,9 +270,11 @@ export interface CapabilityLiveSessionSnapshot {
export interface CreateCapabilityLiveSessionInput {
seed?: CapabilityFixtureSeed | CapabilityFixtureState;
workingDirectory?: string;
provider?: "codex" | "opencode" | "claude_managed";
provider?: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx";
acpxAgent?: QualifiedAcpxAgent;
requestedModel?: string;
managedProfile?: CapabilityLiveSessionConfigSnapshot["managedProfile"];
agentCoreProfile?: CapabilityLiveSessionConfigSnapshot["agentCoreProfile"];
scenario?: CapabilitySemanticScenarioPolicy;
capabilities?: string[];
explicitClaims?: string[];
@@ -452,6 +482,14 @@ export function assertCapabilityLiveSessionSnapshot(
if (authority.sessionId !== snapshot.sessionId) {
throw new Error("capability_live_checkpoint_binding_mismatch");
}
if (snapshot.providerRunBinding !== undefined) {
const binding = record(snapshot.providerRunBinding);
for (const name of ["runId", "turnId", "itemId"] as const) {
if (text(binding[name]).length === 0) {
throw new Error(`capability_live_checkpoint_corrupt: missing providerRunBinding.${name}`);
}
}
}
const config = record(snapshot.config);
const persistedProvider = config.provider;
if (
@@ -459,13 +497,17 @@ export function assertCapabilityLiveSessionSnapshot(
persistedProvider !== "codex" &&
persistedProvider !== "opencode" &&
persistedProvider !== "claude_managed"
&& persistedProvider !== "aws_agentcore"
&& persistedProvider !== "acpx"
) {
throw new Error("capability_live_checkpoint_corrupt: invalid config.provider");
}
const provider = persistedProvider ?? "codex";
const expectedDriver = provider === "opencode"
? "opencode_server"
: provider === "claude_managed" ? "claude_managed_agents_api" : "codex_app_server";
: provider === "claude_managed" ? "claude_managed_agents_api"
: provider === "aws_agentcore" ? "aws_agentcore_harness_api"
: provider === "acpx" ? "acpx_runtime" : "codex_app_server";
if (config.driver !== undefined && config.driver !== expectedDriver) {
throw new Error("capability_live_checkpoint_corrupt: provider/driver mismatch");
}
@@ -480,8 +522,18 @@ export function assertCapabilityLiveSessionSnapshot(
if (provider === "opencode" && !requestedModel.includes("/")) {
throw new Error("capability_live_checkpoint_corrupt: invalid OpenCode model");
}
} else if (provider === "opencode" || provider === "claude_managed") {
throw new Error(`capability_live_checkpoint_corrupt: missing ${provider === "opencode" ? "OpenCode" : "Claude Agent"} model`);
} else if (provider === "opencode" || provider === "claude_managed" || provider === "aws_agentcore" || provider === "acpx") {
throw new Error(`capability_live_checkpoint_corrupt: missing ${provider === "opencode" ? "OpenCode" : provider === "claude_managed" ? "Claude Agent" : provider === "aws_agentcore" ? "AWS AgentCore" : "ACPX"} model`);
}
if (provider === "acpx") {
const agent = config.acpxAgent;
if (agent !== "pi" && agent !== "claude" && agent !== "codex") {
throw new Error("capability_live_checkpoint_corrupt: invalid config.acpxAgent");
}
const expected = resolveQualifiedAcpxProfile(agent, text(config.requestedModel));
if (JSON.stringify(record(config.acpxProfile)) !== JSON.stringify(expected)) {
throw new Error("capability_live_checkpoint_corrupt: ACPX profile drift");
}
}
if (provider === "claude_managed") {
const profile = record(config.managedProfile);
@@ -497,6 +549,20 @@ export function assertCapabilityLiveSessionSnapshot(
throw new Error("capability_live_checkpoint_corrupt: invalid managed session spend ceiling");
}
}
if (provider === "aws_agentcore") {
const profile = record(config.agentCoreProfile);
for (const field of ["profileId", "region", "accountId", "harnessArn", "harnessVersion", "endpointArn", "endpointQualifier", "agentRuntimeArn", "memoryArn", "memoryId", "invocationRoleArn", "qualificationRevision"] as const) {
if (text(profile[field]).trim().length === 0) {
throw new Error(`capability_live_checkpoint_corrupt: missing agentCoreProfile.${field}`);
}
}
if (profile.eventExpiryDays !== 90) throw new Error("capability_live_checkpoint_corrupt: invalid AgentCore Memory expiry");
for (const field of ["maxIterations", "maxOutputTokens", "timeoutSeconds"] as const) {
if (!Number.isSafeInteger(profile[field]) || Number(profile[field]) <= 0) {
throw new Error(`capability_live_checkpoint_corrupt: invalid agentCoreProfile.${field}`);
}
}
}
if (
config.providerVersion !== undefined &&
config.providerVersion !== null &&
@@ -700,6 +766,9 @@ export class CapabilityLiveSessionService {
const state = port.snapshot();
const sessionId = input.sessionId ?? randomUUID();
const runId = input.runId ?? randomUUID();
const acpxProfile = input.provider === "acpx"
? resolveQualifiedAcpxProfile(input.acpxAgent ?? "pi", requireNonEmpty(input.requestedModel ?? "", "requested_model"))
: null;
const capabilities = [...(input.capabilities ?? [])];
const scenario = input.scenario ?? { id: "capability-live-default" };
const authority: CapabilityLiveAuthoritySnapshot = {
@@ -733,11 +802,20 @@ export class CapabilityLiveSessionService {
provider: input.provider ?? "codex",
driver: input.provider === "opencode"
? "opencode_server"
: input.provider === "claude_managed" ? "claude_managed_agents_api" : "codex_app_server",
: input.provider === "claude_managed" ? "claude_managed_agents_api"
: input.provider === "aws_agentcore" ? "aws_agentcore_harness_api"
: input.provider === "acpx" ? "acpx_runtime" : "codex_app_server",
providerVersion: input.provider === "opencode"
? "1.18.17"
: input.provider === "claude_managed" ? "managed-agents-2026-04-01" : null,
: input.provider === "claude_managed" ? "managed-agents-2026-04-01"
: input.provider === "aws_agentcore" ? input.agentCoreProfile?.harnessVersion ?? null
: input.provider === "acpx" ? acpxProfile!.acpxVersion : null,
...(acpxProfile === null ? {} : {
acpxAgent: acpxProfile.agent,
acpxProfile: structuredClone(acpxProfile),
}),
...(input.managedProfile === undefined ? {} : { managedProfile: structuredClone(input.managedProfile) }),
...(input.agentCoreProfile === undefined ? {} : { agentCoreProfile: structuredClone(input.agentCoreProfile) }),
...(input.requestedModel === undefined
? {}
: { requestedModel: requireNonEmpty(input.requestedModel, "requested_model") }),
@@ -946,6 +1024,7 @@ export class CapabilityLiveSession {
#providerModel: { id: string; provider: string } | undefined;
#status: CapabilityLiveSessionStatus = "starting";
#activeTurnId: string | null = null;
#providerRunBinding: { runId: string; turnId: string; itemId: string } | null;
#revision = 0;
#updatedAt: string;
#entryCounter = 0;
@@ -955,7 +1034,14 @@ export class CapabilityLiveSession {
readonly #listeners = new Set<CapabilityLiveTurnListener>();
#eventSeq = 0;
#turnEventCount = 0;
readonly #rawUsageByTurn = new Map<string, { providerRequests: number; inputTokens: number; outputTokens: number; cachedInputTokens: number; reasoningTokens: number }>();
readonly #rawUsageByTurn = new Map<string, {
providerRequests: number;
inputTokens: number;
outputTokens: number;
cachedInputTokens: number;
reasoningTokens: number;
costNanodollars: number;
}>();
#latestCumulativeUsage: Record<string, unknown> = {};
#idleTimer: ReturnType<typeof setTimeout> | null = null;
readonly #loadedOperationIds = new Set<string>();
@@ -1005,6 +1091,9 @@ export class CapabilityLiveSession {
? undefined
: structuredClone(options.snapshot.providerModel);
this.#activeTurnId = options.snapshot?.activeTurnId ?? null;
this.#providerRunBinding = options.snapshot?.providerRunBinding === undefined
? null
: structuredClone(options.snapshot.providerRunBinding);
this.#revision = options.snapshot?.revision ?? 0;
this.#entryCounter = this.#transcript.length + this.#evidence.length;
}
@@ -1149,6 +1238,9 @@ export class CapabilityLiveSession {
: { providerModel: structuredClone(this.#providerModel) }),
status: this.#status,
activeTurnId: this.#activeTurnId,
...(this.#providerRunBinding === null
? {}
: { providerRunBinding: structuredClone(this.#providerRunBinding) }),
createdAt: this.#createdAt,
updatedAt: this.#updatedAt,
authority: structuredClone(this.#authority),
@@ -1238,6 +1330,20 @@ export class CapabilityLiveSession {
if (this.#activeTurnId !== null || this.#turnWaiter !== null) {
throw new Error("Capability live session already has an active turn");
}
if (this.#terminalTurns.length > 0) {
if (this.#transport.attachRun === undefined) {
throw new Error("capability_live_multi_run_attachment_unavailable");
}
const bindingId = randomUUID().replaceAll("-", "");
const binding = {
runId: `run_lab_${bindingId}`,
turnId: `turn_lab_${bindingId}`,
itemId: `item_lab_${bindingId}`,
};
await this.#transport.attachRun(binding);
this.#providerRunBinding = binding;
await this.#persist();
}
this.#status = "running";
this.#clearIdleTimer();
this.#turnEventCount = 0;
@@ -1322,7 +1428,7 @@ export class CapabilityLiveSession {
},
});
}
await this.#captureTurnUsage(result.turnId);
await this.#captureTurnUsage(result.turnId, result.status !== "completed");
await this.#persist();
await this.#afterTurnSettled();
return { ...result, snapshot: this.snapshot() };
@@ -1356,7 +1462,7 @@ export class CapabilityLiveSession {
}, timeoutMs);
}
async #captureTurnUsage(turnId: string): Promise<void> {
async #captureTurnUsage(turnId: string, allowEmpty = false): Promise<void> {
if (this.#transport === null) throw new Error("capability_live_usage_transport_missing");
// Codex can emit rawResponse/completed immediately after turn/completed.
// Give the notification pump a short bounded window before falling back to
@@ -1390,7 +1496,7 @@ export class CapabilityLiveSession {
cachedInputTokens: integer("cachedInputTokens", "cached_input_tokens"),
reasoningTokens: integer("reasoningOutputTokens", "reasoningTokens", "reasoning_output_tokens"),
};
if (cumulative.inputTokens + cumulative.outputTokens === 0) {
if (cumulative.inputTokens + cumulative.outputTokens === 0 && captured === undefined && !allowEmpty) {
throw new Error(`capability_live_usage_missing:${JSON.stringify({
captured: captured !== undefined,
readKeys: Object.keys(read).sort(),
@@ -1417,7 +1523,7 @@ export class CapabilityLiveSession {
outputTokens: delta.outputTokens,
cachedInputTokens: delta.cachedInputTokens,
reasoningTokens: delta.reasoningTokens,
costNanodollars: 0,
costNanodollars: captured?.costNanodollars ?? 0,
});
}
@@ -1526,14 +1632,15 @@ export class CapabilityLiveSession {
}
async increaseManagedSessionBudget(maxSessionListCostUsd: number): Promise<CapabilityLiveSessionSnapshot> {
if (this.#config.provider !== "claude_managed" || this.#transport === null) {
throw new Error("managed session budget updates require a connected Claude Agent session");
if ((this.#config.provider !== "claude_managed" && this.#config.provider !== "aws_agentcore") || this.#transport === null) {
throw new Error("remote session budget updates require a connected remote provider session");
}
if (!Number.isFinite(maxSessionListCostUsd) || maxSessionListCostUsd <= 0) {
throw new Error("managed session spend ceiling must be positive");
}
await this.#transport.request("session/budget/increase", { maxSessionListCostUsd });
if (this.#config.managedProfile) this.#config.managedProfile.maxSessionListCostUsd = maxSessionListCostUsd;
if (this.#config.agentCoreProfile) this.#config.agentCoreProfile.maxEstimatedSessionCostUsd = maxSessionListCostUsd;
this.#status = this.#activeTurnId === null ? "warm_idle" : "running";
this.#appendEvidence("session", this.#activeTurnId, {
action: "budget_increased",
@@ -1544,8 +1651,8 @@ export class CapabilityLiveSession {
}
async deleteManagedRemoteSession(): Promise<CapabilityLiveSessionSnapshot> {
if (this.#config.provider !== "claude_managed" || this.#transport === null) {
throw new Error("remote session deletion requires a connected Claude Agent session");
if ((this.#config.provider !== "claude_managed" && this.#config.provider !== "aws_agentcore") || this.#transport === null) {
throw new Error("remote session deletion requires a connected remote provider session");
}
if (this.#activeTurnId !== null) {
throw new Error("interrupt the active turn before deleting the remote session");
@@ -1650,24 +1757,39 @@ export class CapabilityLiveSession {
async #connect(resume: boolean): Promise<void> {
this.#status = resume ? "restoring" : "starting";
const identityDigest = createHash("sha256").update(this.id).digest("hex").slice(0, 20);
const providerRunBinding = this.#providerRunBinding ?? {
runId: this.#authority.runId,
turnId: `turn_lab_${identityDigest}`,
itemId: `item_lab_${identityDigest}`,
};
this.#providerRunBinding = providerRunBinding;
const transportBundle = this.#transportFactory({
...this.#transportOptions,
provider: this.#config.provider ?? this.#transportOptions.provider ?? "codex",
...(this.#config.provider === "acpx" && this.#config.acpxAgent ? {
acpxAgent: this.#config.acpxAgent,
} : {}),
...(this.#config.provider === "claude_managed" && this.#config.managedProfile ? {
managedProfile: {
...this.#config.managedProfile,
model: this.#config.requestedModel ?? "claude-sonnet-5",
},
} : {}),
...(this.#config.provider === "aws_agentcore" && this.#config.agentCoreProfile ? {
agentCoreProfile: {
...this.#config.agentCoreProfile,
model: this.#config.requestedModel ?? "global.anthropic.claude-sonnet-4-6",
},
} : {}),
lifecyclePolicy: this.#config.lifecyclePolicy ?? { mode: "per_turn", idleTimeoutMs: null },
stateDirectory: resolve(this.#config.workingDirectory, ".paperclip-runner-prp", identityDigest),
prpIdentity: {
runnerInstanceId: `runner_lab_${identityDigest}`,
environmentLeaseId: `lease_lab_${identityDigest}`,
runId: this.#authority.runId,
runId: providerRunBinding.runId,
normalizedSessionId: this.id,
turnId: `turn_lab_${identityDigest}`,
itemId: `item_lab_${identityDigest}`,
turnId: providerRunBinding.turnId,
itemId: providerRunBinding.itemId,
},
onDiagnostic: (message) => {
this.#transportOptions.onDiagnostic?.(message);
@@ -1810,6 +1932,9 @@ export class CapabilityLiveSession {
runnerExited: this.#processEvidence?.runnerExited ?? true,
runnerPid: this.#processEvidence?.runnerPid ?? null,
codexPid: this.#processEvidence?.codexPid ?? null,
providerPid: this.#processEvidence?.providerPid ?? null,
sidecarPid: this.#processEvidence?.sidecarPid ?? null,
agentPid: this.#processEvidence?.agentPid ?? null,
});
}
@@ -1990,6 +2115,29 @@ export class CapabilityLiveSession {
this.#latestCumulativeUsage = record(
tokenUsage.total ?? tokenUsage.totalTokenUsage ?? tokenUsage.total_token_usage ?? tokenUsage,
);
const runDelta = record(tokenUsage.runDelta ?? params.runDelta);
if (turnId.length > 0 && Object.keys(runDelta).length > 0) {
const integer = (...names: string[]): number => {
for (const name of names) {
const value = runDelta[name];
if (Number.isSafeInteger(value) && Number(value) >= 0) return Number(value);
}
return 0;
};
const providerCostUsd = typeof runDelta.providerCostUsd === "number"
&& Number.isFinite(runDelta.providerCostUsd)
&& runDelta.providerCostUsd >= 0
? runDelta.providerCostUsd
: 0;
this.#rawUsageByTurn.set(turnId, {
providerRequests: integer("requests", "providerRequests"),
inputTokens: integer("inputTokens", "input_tokens"),
outputTokens: integer("outputTokens", "output_tokens"),
cachedInputTokens: integer("cacheReadTokens", "cachedInputTokens", "cache_read_input_tokens"),
reasoningTokens: integer("reasoningTokens", "reasoning_tokens"),
costNanodollars: Math.round(providerCostUsd * 1_000_000_000),
});
}
}
this.#appendEvidence("provider_event", turnId || null, {
method: notification.method,
@@ -2005,13 +2153,14 @@ export class CapabilityLiveSession {
const previous = this.#rawUsageByTurn.get(usageTurnId);
const value = (name: string): number => Number.isSafeInteger(rawUsage[name]) && Number(rawUsage[name]) >= 0 ? Number(rawUsage[name]) : 0;
if (notification.method === "rawResponse/completed") {
const base = previous ?? { providerRequests: 0, inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0 };
const base = previous ?? { providerRequests: 0, inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, costNanodollars: 0 };
this.#rawUsageByTurn.set(usageTurnId, {
providerRequests: base.providerRequests + 1,
inputTokens: base.inputTokens + value("inputTokens"),
outputTokens: base.outputTokens + value("outputTokens"),
cachedInputTokens: base.cachedInputTokens + value("cachedInputTokens"),
reasoningTokens: base.reasoningTokens + value("reasoningOutputTokens"),
costNanodollars: base.costNanodollars,
});
} else if (previous === undefined) {
// Some provider versions publish only a terminal per-turn receipt.
@@ -2021,6 +2170,7 @@ export class CapabilityLiveSession {
outputTokens: value("outputTokens"),
cachedInputTokens: value("cachedInputTokens"),
reasoningTokens: value("reasoningOutputTokens"),
costNanodollars: 0,
});
}
}
@@ -1,4 +1,4 @@
import { mkdtemp, rm } from "node:fs/promises";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
@@ -6,12 +6,35 @@ import { expect, it } from "vitest";
import {
createCapabilityRunnerdCodexTransport,
createSanitizedAwsAgentCoreEnvironment,
defaultCapabilityRunnerdBinary,
rehydrateRunnerdUsageNotification,
unwrapRunnerdProviderNotification,
unwrapRunnerdProviderNotifications,
} from "./runnerd-codex-transport.js";
it("passes only workload-identity metadata to the AgentCore runner", () => {
const sanitized = createSanitizedAwsAgentCoreEnvironment({
PATH: "/bin",
AWS_PROFILE: "paperclip-dev",
AWS_REGION: "us-east-1",
AWS_WEB_IDENTITY_TOKEN_FILE: "/var/run/token",
AWS_ROLE_ARN: "arn:aws:iam::123456789012:role/workload",
AWS_ACCESS_KEY_ID: "AKIACANARY",
AWS_SECRET_ACCESS_KEY: "secret-canary",
AWS_SESSION_TOKEN: "token-canary",
PAPERCLIP_API_KEY: "paperclip-canary",
ANTHROPIC_API_KEY: "anthropic-canary",
});
expect(sanitized).toEqual({
PATH: "/bin",
AWS_PROFILE: "paperclip-dev",
AWS_REGION: "us-east-1",
AWS_WEB_IDENTITY_TOKEN_FILE: "/var/run/token",
AWS_ROLE_ARN: "arn:aws:iam::123456789012:role/workload",
});
});
it("rehydrates normalized usage with the provider thread binding", () => {
expect(rehydrateRunnerdUsageNotification({
providerSessionId: "thread-1",
@@ -166,7 +189,7 @@ it("cold-restores a suspended provider session under a new run binding", async (
codexCommand: fakeCodex,
codexArgs: [],
stateDirectory,
lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 60_000 },
lifecyclePolicy: { mode: "per_turn" as const, idleTimeoutMs: null },
};
const dynamicTools = [{
name: "get_task_context",
@@ -187,6 +210,7 @@ it("cold-restores a suspended provider session under a new run binding", async (
const restored = createCapabilityRunnerdCodexTransport({
...options,
resumeDynamicTools: dynamicTools,
prpIdentity: {
...baseIdentity,
runId: "run-cold-restored",
@@ -198,6 +222,15 @@ it("cold-restores a suspended provider session under a new run binding", async (
try {
const read = await restored.transport.request("thread/read", {});
expect(read.thread).toMatchObject({ id: "fake-thread" });
const durable = JSON.parse(await readFile(
join(stateDirectory, "runner", "runner-state.json"),
"utf8",
)) as { providerToolBridge?: { authorized?: Record<string, unknown> } };
expect(Object.keys(durable.providerToolBridge?.authorized ?? {})).toEqual(expect.arrayContaining([
"get_task_context",
"paperclip_finish",
"paperclip_block",
]));
await restored.transport.request("turn/start", { input: [{ type: "text", text: "restored process" }] });
for await (const event of restored.transport.notifications()) {
if (event.method === "turn/completed") break;
@@ -12,7 +12,10 @@ import type {
CodexTransportProcessInfo,
} from "../drivers/codex/app-server-transport.js";
import { createSanitizedCodexEnvironment } from "../drivers/codex/app-server-transport.js";
import { createIsolatedCodexAppServerArgs } from "../drivers/codex/codex-app-server-driver.js";
import {
codexSemanticToolSpecs,
createIsolatedCodexAppServerArgs,
} from "../drivers/codex/codex-app-server-driver.js";
import type { DurableRecoveryIdentity } from "../contracts/durable-recovery.js";
import {
DurablePrpControlPlane,
@@ -21,6 +24,8 @@ import {
waitForProcess,
type RunnerProcessHandle,
} from "../control-plane/durable-prp-control-plane.js";
import { resolveQualifiedAcpxProfile, type QualifiedAcpxAgent } from "../drivers/acpx/qualified-profiles.js";
import { createSanitizedAcpxEnvironment } from "../drivers/acpx/environment.js";
const packageRoot = fileURLToPath(new URL("../..", import.meta.url));
const executableSuffix = process.platform === "win32" ? ".exe" : "";
@@ -46,9 +51,18 @@ function recoveredControlPlaneIdentity(
export interface CapabilityRunnerdProcessEvidence {
runnerPid: number | null;
runnerProcessGroupId: number | null;
providerPid: number | null;
codexPid: number | null;
sidecarPid: number | null;
agentPid: number | null;
providerDriver: string | null;
providerVersion: string | null;
acpxAgent: QualifiedAcpxAgent | null;
agentServerVersion: string | null;
agentRuntimeVersion: string | null;
acpProtocolVersion: number | null;
providerExecutionKind: "local_process" | "remote_service" | null;
providerService: "anthropic_managed_agents" | null;
providerService: "anthropic_managed_agents" | "aws_bedrock_agentcore_harness" | null;
runnerExited: boolean;
runnerExitCode: number | null;
runnerSignal: NodeJS.Signals | null;
@@ -57,7 +71,10 @@ export interface CapabilityRunnerdProcessEvidence {
}
export interface CapabilityRunnerdCodexTransportOptions {
provider?: "codex" | "opencode" | "claude_managed";
provider?: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx";
acpxAgent?: QualifiedAcpxAgent;
acpxSidecarPath?: string;
acpxRuntimeDirectory?: string;
managedProfile?: {
profileId: string;
anthropicAgentId: string;
@@ -67,6 +84,26 @@ export interface CapabilityRunnerdCodexTransportOptions {
maxSessionListCostUsd: number;
model: string;
};
agentCoreProfile?: {
profileId: string;
region: string;
accountId: string;
harnessArn: string;
harnessVersion: string;
endpointArn: string;
endpointQualifier: string;
agentRuntimeArn: string;
memoryArn: string;
memoryId: string;
invocationRoleArn: string;
qualificationRevision: string;
eventExpiryDays: 90;
maxEstimatedSessionCostUsd: number;
maxIterations: number;
maxOutputTokens: number;
timeoutSeconds: number;
model: string;
};
runnerBinary?: string;
codexCommand?: string;
codexArgs?: string[];
@@ -240,6 +277,21 @@ function createSanitizedClaudeManagedEnvironment(environment: NodeJS.ProcessEnv
return result;
}
export function createSanitizedAwsAgentCoreEnvironment(environment: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
const source = environment ?? process.env;
const result: NodeJS.ProcessEnv = {};
for (const key of [
"PATH", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "SSL_CERT_FILE", "SSL_CERT_DIR",
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy",
"RUST_BACKTRACE", "AWS_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_CONFIG_FILE",
"AWS_SHARED_CREDENTIALS_FILE", "AWS_WEB_IDENTITY_TOKEN_FILE", "AWS_ROLE_ARN",
"AWS_ROLE_SESSION_NAME", "AWS_CONTAINER_CREDENTIALS_FULL_URI", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
]) {
if (typeof source[key] === "string") result[key] = source[key];
}
return result;
}
function unwrapToolResponse(response: Record<string, unknown>): Record<string, unknown> {
const items = Array.isArray(response.contentItems) ? response.contentItems : [];
const value = record(items[0]).text;
@@ -282,13 +334,25 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
this.#ownsRoot = options.stateDirectory === undefined;
this.#root = options.stateDirectory ?? mkdtempSync(resolve(tmpdir(), "paperclip-runner-lab-prp-"));
if (options.resumeDynamicTools !== undefined) {
this.#authorizedTools = authorizedToolSet(options.resumeDynamicTools);
this.#authorizedTools = authorizedToolSet([
...options.resumeDynamicTools,
...codexSemanticToolSpecs(),
]);
}
mkdirSync(this.#root, { recursive: true, mode: 0o700 });
this.#evidence = {
runnerPid: null,
runnerProcessGroupId: null,
providerPid: null,
codexPid: null,
sidecarPid: null,
agentPid: null,
providerDriver: null,
providerVersion: null,
acpxAgent: null,
agentServerVersion: null,
agentRuntimeVersion: null,
acpProtocolVersion: null,
providerExecutionKind: null,
providerService: null,
runnerExited: false,
@@ -297,6 +361,10 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
childEnvironmentKeys: Object.keys(
options.provider === "claude_managed"
? createSanitizedClaudeManagedEnvironment(options.environment)
: options.provider === "aws_agentcore"
? createSanitizedAwsAgentCoreEnvironment(options.environment)
: options.provider === "acpx"
? createSanitizedAcpxEnvironment(options.environment, options.acpxAgent ?? "pi")
: createSanitizedCodexEnvironment(options.environment),
).sort(),
diagnostics: ["lab transport selected authenticated durable PRP"],
@@ -312,6 +380,14 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
this.#throwIfFailed();
if (method === "initialize") return { user: {} };
if (method === "thread/start") return this.#start(params);
if (method === "collaborationMode/list") {
// runnerd already negotiated the real preset with the provider during
// session.open. This transport-level mask confirms that closed boundary;
// turn/start remains runner-managed and never forwards this sentinel.
return this.options.provider === undefined || this.options.provider === "codex"
? { data: [{ name: "Plan", mode: "plan", model: "runner-managed", reasoning_effort: null }] }
: { data: [] };
}
if (method === "turn/start") return this.#startTurn(params);
if (method === "turn/interrupt") {
await this.#command("turn.interrupt", params);
@@ -319,7 +395,18 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
}
if (method === "thread/read") {
if (this.#core === null) await this.#resume();
return this.#commandResult("provider.thread.read", {});
const result = await this.#commandResult("provider.thread.read", {});
if (this.options.provider !== "claude_managed" && this.options.provider !== "aws_agentcore") return result;
const session = record(result.session);
return {
...result,
thread: {
id: this.#threadId,
sessionId: this.#sessionId,
turns: [],
...(session.usage === undefined ? {} : { tokenUsage: session.usage }),
},
};
}
if (method === "session/budget/increase") {
await this.#command("session.budget.increase", params);
@@ -362,6 +449,11 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
itemId: input.itemId,
...(this.#authorizedTools === null ? {} : { authorizedTools: this.#authorizedTools }),
});
await this.#waitForAttachment(input.runId);
this.#turnId = input.turnId;
}
async #waitForAttachment(runId: string): Promise<void> {
const core = this.#core;
const attachDeadline = Date.now() + 10_000;
let attachmentDrained = false;
@@ -369,7 +461,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
const durable = record(JSON.parse(readFileSync(resolve(this.#root, "runner", "runner-state.json"), "utf8")));
const attached = core.store.state.committedEvents.find((event) =>
event.eventType === "run.attached"
&& record(record(event.envelope.payload).payload).runId === input.runId
&& record(record(event.envelope.payload).payload).runId === runId
);
if (attached && Number(durable.ackedSourceSeq ?? 0) >= attached.sourceSeq) {
attachmentDrained = true;
@@ -379,14 +471,23 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
await new Promise((resolveWait) => setTimeout(resolveWait, 10));
}
if (!attachmentDrained) throw new Error("runnerd did not durably acknowledge the run attachment");
this.#turnId = input.turnId;
}
async close(): Promise<void> {
if (this.#closed) return;
this.#closed = true;
if (this.#core !== null && this.#handle !== null) {
this.#core.queueCommand("runner.suspend", {}, undefined, true);
const perTurnAlreadySuspending = this.options.lifecyclePolicy?.mode === "per_turn"
&& this.#core.store.state.committedEvents.some((event) =>
event.eventType === "runner.suspending"
|| event.eventType === "turn.completed"
|| event.eventType === "turn.failed"
|| event.eventType === "turn.interrupted"
|| event.eventType === "turn.cancelled"
);
if (!perTurnAlreadySuspending) {
this.#core.queueCommand("runner.suspend", {}, undefined, true);
}
try {
const result = await waitForProcess(this.#handle, this.options.closeGraceMs ?? 10_000);
this.#evidence.runnerExited = true;
@@ -441,11 +542,22 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
const provider = this.options.provider ?? "codex";
const opencodeProxyPath = this.options.opencodeProxyPath
?? fileURLToPath(new URL("../cli/opencode-app-server-proxy.js", import.meta.url));
const acpxSidecarPath = this.options.acpxSidecarPath
?? fileURLToPath(new URL("../cli/acpx-runtime-sidecar.js", import.meta.url));
this.#authorizedTools = authorizedToolSet(dynamicTools);
const managed = this.options.managedProfile;
const agentCore = this.options.agentCoreProfile;
if (provider === "claude_managed" && managed === undefined) {
throw new Error("Claude Agent runner transport requires a managed profile snapshot");
}
if (provider === "aws_agentcore" && agentCore === undefined) {
throw new Error("AWS AgentCore runner transport requires a qualified profile snapshot");
}
const acpxAgent = provider === "acpx" ? this.options.acpxAgent ?? "pi" : null;
const requestedModel = typeof params.model === "string" ? params.model : "";
const acpxProfile = provider === "acpx"
? resolveQualifiedAcpxProfile(acpxAgent!, requestedModel)
: null;
core.queueCommand("run.prepare", {
authorizedTools: this.#authorizedTools,
provider: provider === "claude_managed" ? {
@@ -457,6 +569,44 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
environmentId: managed!.environmentId,
betaVersion: managed!.betaVersion,
maxSessionListCostUsd: managed!.maxSessionListCostUsd,
} : provider === "aws_agentcore" ? {
kind: "aws_agentcore",
model: agentCore!.model,
profileId: agentCore!.profileId,
region: agentCore!.region,
accountId: agentCore!.accountId,
harnessArn: agentCore!.harnessArn,
harnessVersion: agentCore!.harnessVersion,
endpointArn: agentCore!.endpointArn,
endpointQualifier: agentCore!.endpointQualifier,
agentRuntimeArn: agentCore!.agentRuntimeArn,
memoryArn: agentCore!.memoryArn,
memoryId: agentCore!.memoryId,
invocationRoleArn: agentCore!.invocationRoleArn,
qualificationRevision: agentCore!.qualificationRevision,
eventExpiryDays: agentCore!.eventExpiryDays,
maxEstimatedSessionCostUsd: agentCore!.maxEstimatedSessionCostUsd,
maxIterations: agentCore!.maxIterations,
maxOutputTokens: agentCore!.maxOutputTokens,
timeoutSeconds: agentCore!.timeoutSeconds,
} : provider === "acpx" ? {
kind: "acpx",
agent: acpxProfile!.agent,
model: requestedModel,
acpxVersion: acpxProfile!.acpxVersion,
agentServerPackage: acpxProfile!.agentServerPackage,
agentServerVersion: acpxProfile!.agentServerVersion,
agentRuntimePackage: acpxProfile!.agentRuntimePackage,
agentRuntimeVersion: acpxProfile!.agentRuntimeVersion,
commandDigest: acpxProfile!.commandDigest,
sidecarCommand: process.execPath,
sidecarArgs: [acpxSidecarPath],
runtimeDirectory: this.options.acpxRuntimeDirectory ?? resolve(this.#root, "acpx"),
normalizedSessionId: identity.normalizedSessionId,
runId: identity.runId,
cwd: String(params.cwd ?? tmpdir()),
instructions: String(params.baseInstructions ?? "You are a Paperclip agent."),
permissionPolicy: "interactive",
} : {
kind: provider,
command: provider === "opencode" ? process.execPath : this.options.codexCommand ?? "codex",
@@ -464,6 +614,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
cwd: String(params.cwd ?? tmpdir()),
model: typeof params.model === "string" ? params.model : null,
instructions: String(params.baseInstructions ?? "You are a Paperclip agent."),
collaborationMode: params.permissions === "paperclip-runner-workspace-read-only"
? "plan"
: "default",
},
});
core.queueCommand("session.open", { reuse: "same_session" });
@@ -492,8 +645,17 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
PAPERCLIP_RUN_ID: identity.runId,
PAPERCLIP_NORMALIZED_SESSION_ID: identity.normalizedSessionId,
}
: provider === "acpx"
? {
...createSanitizedAcpxEnvironment(this.options.environment, acpxAgent!),
PAPERCLIP_RUNNER_INSTANCE_ID: identity.runnerInstanceId,
PAPERCLIP_RUN_ID: identity.runId,
PAPERCLIP_NORMALIZED_SESSION_ID: identity.normalizedSessionId,
}
: provider === "claude_managed"
? createSanitizedClaudeManagedEnvironment(this.options.environment)
: provider === "aws_agentcore"
? createSanitizedAwsAgentCoreEnvironment(this.options.environment)
: this.options.environment,
});
this.#handle = handle;
@@ -513,8 +675,12 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
model: params.model,
modelProvider: provider === "claude_managed"
? "anthropic"
: provider === "aws_agentcore"
? "amazon-bedrock"
: provider === "opencode" && typeof params.model === "string"
? params.model.split("/", 1)[0]
: provider === "acpx"
? acpxAgent === "pi" ? "openrouter" : acpxAgent === "claude" ? "anthropic" : "openai"
: "openai",
},
};
@@ -527,6 +693,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
}
const controlPlaneDirectory = resolve(this.#root, "control-plane");
const identity = recoveredControlPlaneIdentity(controlPlaneDirectory, desiredIdentity);
const attachingNewRun = identity.runId !== desiredIdentity.runId;
const core = new DurablePrpControlPlane({
stateDirectory: controlPlaneDirectory,
identity,
@@ -562,6 +729,10 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
runnerBinaryPath: this.options.runnerBinary ?? defaultCapabilityRunnerdBinary(),
environment: this.options.provider === "claude_managed"
? createSanitizedClaudeManagedEnvironment(this.options.environment)
: this.options.provider === "aws_agentcore"
? createSanitizedAwsAgentCoreEnvironment(this.options.environment)
: this.options.provider === "acpx"
? createSanitizedAcpxEnvironment(this.options.environment, this.options.acpxAgent ?? "pi")
: this.options.environment,
});
this.#handle = handle;
@@ -572,7 +743,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
this.#pump = setInterval(() => this.#pumpEventsSafely(), 5);
await this.#waitForProviderIdentity();
if (
identity.runId !== desiredIdentity.runId
attachingNewRun
|| identity.turnId !== desiredIdentity.turnId
|| identity.itemId !== desiredIdentity.itemId
) {
@@ -653,7 +824,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
this.#pumpEvents();
if (
this.#threadId.length > 0
&& (this.#evidence.providerExecutionKind === "remote_service" || this.#evidence.codexPid !== null)
&& (this.#evidence.providerExecutionKind === "remote_service" || this.#evidence.providerPid !== null)
) return;
if (this.#handle?.child.exitCode !== null) throw new Error("runnerd exited before provider startup");
await new Promise((resolveWait) => setTimeout(resolveWait, 10));
@@ -692,19 +863,36 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
const threadId = started.threadId;
const sessionId = started.sessionId;
if (typeof pid === "number") {
this.#evidence.codexPid = pid;
this.#evidence.providerPid = pid;
if (descriptor.driver === "acpx_runtime") this.#evidence.sidecarPid = pid;
else this.#evidence.codexPid = pid;
}
if (typeof descriptor.driver === "string") this.#evidence.providerDriver = descriptor.driver;
if (typeof descriptor.providerVersion === "string") this.#evidence.providerVersion = descriptor.providerVersion;
if (descriptor.agent === "pi" || descriptor.agent === "claude" || descriptor.agent === "codex") this.#evidence.acpxAgent = descriptor.agent;
if (typeof descriptor.agentServerVersion === "string") this.#evidence.agentServerVersion = descriptor.agentServerVersion;
if (typeof descriptor.agentRuntimeVersion === "string") this.#evidence.agentRuntimeVersion = descriptor.agentRuntimeVersion;
if (typeof descriptor.acpProtocolVersion === "number") this.#evidence.acpProtocolVersion = descriptor.acpProtocolVersion;
if (runtimeIdentity.executionKind === "local_process" || runtimeIdentity.executionKind === "remote_service") {
this.#evidence.providerExecutionKind = runtimeIdentity.executionKind;
}
if (runtimeIdentity.service === "anthropic_managed_agents") {
this.#evidence.providerService = "anthropic_managed_agents";
} else if (runtimeIdentity.service === "aws_bedrock_agentcore_harness") {
this.#evidence.providerService = "aws_bedrock_agentcore_harness";
}
if (typeof threadId === "string") this.#threadId = threadId;
if (typeof sessionId === "string") this.#sessionId = sessionId;
this.#publish();
continue;
}
if (event.eventType === "harness.diagnostic") {
const diagnostic = record(record(event.envelope.payload).payload);
if (diagnostic.providerMethod === "acpx/process" && diagnostic.role === "acp_agent" && typeof diagnostic.pid === "number") {
this.#evidence.agentPid = diagnostic.pid;
this.#publish();
}
}
const eventPayload = record(event.envelope.payload).payload;
const sessionUpdatePayload = record(eventPayload);
const canonicalMethod = ({
@@ -108,6 +108,60 @@ function highestContiguous(events: PrpEvent[]): number {
}
describe("executeNativeSession recovery", () => {
it("contains a consumer rejection when starting the turn fails first", async () => {
let closeStream = () => {};
const streamClosed = new Promise<void>((resolve) => { closeStream = resolve; });
const session: NativeSession = {
identity: () => identity,
async capabilities() {
return { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true };
},
async *events() { await streamClosed; },
async startTurn() { throw new Error("start turn failed"); },
async result() { return null; },
async snapshot() {
return {
backendKind: "mock",
sessionId: "driver-recovery",
identity,
providerSessionId: "provider-recovery",
cursor: null,
activeTurnId: null,
pendingRuntimeRequests: [],
lineage: [],
};
},
async close() { closeStream(); },
};
const backend: NativeSessionBackend = {
async descriptor() {
return {
kind: "mock",
name: "recovery-backend",
version: "1",
capabilities: { resume: true, typedEvents: true, steering: false, interruption: true, structuredResult: true },
};
},
async openSession() { return session; },
};
const port: ControlPlanePort = {
async openRun() {},
async checkpointSession() {},
async appendEvent() { throw new Error("unexpected event"); },
async replayEvents() { return { events: [], highestContiguousSourceSeq: 0 }; },
async completeRun() {},
};
await expect(executeNativeSession({
input,
backend,
controlPlane: port,
runnerInstanceId: "runner-recovery",
controlPlaneInstanceId: "control-recovery",
})).rejects.toThrow("start turn failed");
await new Promise<void>((resolve) => setImmediate(resolve));
});
it("continues a provider-reported active turn without starting a duplicate turn", async () => {
const checkpoint: PersistedNativeSession = {
backendKind: "mock",
@@ -1,14 +1,14 @@
import { randomUUID } from "node:crypto";
import type { ControlPlanePort } from "./contracts/control-plane-port.js";
import type { NativeExecutionInputV1, NativeSessionExecutionResult } from "./contracts/native-execution.js";
import type { NativeExecutionInput, NativeSessionExecutionResult } from "./contracts/native-execution.js";
import { buildNativeModelEnvelope, parseNativeExecutionInput } from "./contracts/native-execution.js";
import type { NativeSession, NativeSessionBackend } from "./contracts/native-session-backend.js";
import type { PersistedNativeSession } from "./contracts/native-session-backend.js";
import type { PrpEvent, PrpTerminalState } from "./protocol/replay-contract.js";
export interface ExecuteNativeSessionOptions {
input: NativeExecutionInputV1;
input: NativeExecutionInput;
backend: NativeSessionBackend;
controlPlane: ControlPlanePort;
runnerInstanceId: string;
@@ -151,10 +151,17 @@ export async function executeNativeSession(options: ExecuteNativeSessionOptions)
: null;
if (!completed) {
const consuming = consumeTurn(session, options.controlPlane, options.timeoutMs ?? 900_000);
// Event consumption must begin before startTurn so an eager provider cannot
// outrun us. Observe its rejection immediately, though: if startTurn or
// checkpointing fails first, the outer finally closes the session and the
// abandoned consumer will reject when its stream closes. Without a handler
// that later rejection becomes process-fatal under Node's strict policy.
void consuming.catch(() => undefined);
const recoveredActiveTurnId = recoveredSnapshot.activeTurnId ?? persistedSession?.activeTurnId ?? null;
if (!recovered || !recoveredActiveTurnId) {
await session.startTurn({
message: { role: "user", text: JSON.stringify(buildNativeModelEnvelope(input)) },
requestedCollaborationMode: "executionMode" in input ? input.executionMode : "default",
});
await checkpoint();
}
@@ -347,14 +347,18 @@ export const providerDescriptorSchema = {
"enum": [
"codex",
"opencode",
"claude_managed"
"claude_managed",
"aws_agentcore",
"acpx"
]
},
"driver": {
"enum": [
"codex_app_server",
"opencode_server",
"claude_managed_agents_api"
"claude_managed_agents_api",
"aws_agentcore_harness_api",
"acpx_runtime"
]
},
"model": {
@@ -377,7 +381,8 @@ export const providerDescriptorSchema = {
},
"service": {
"enum": [
"anthropic_managed_agents"
"anthropic_managed_agents",
"aws_bedrock_agentcore_harness"
]
},
"providerSessionId": {
@@ -393,6 +398,70 @@ export const providerDescriptorSchema = {
"null"
],
"minimum": 1
},
"endpointArn": {
"type": "string",
"minLength": 1,
"maxLength": 2048
},
"endpointQualifier": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"memoryId": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"eventExpiryDays": {
"const": 90
},
"agent": {
"enum": [
"pi",
"claude",
"codex"
]
},
"requestedModel": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"acpProtocolVersion": {
"const": 1
},
"agentServerPackage": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"agentServerVersion": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"agentRuntimePackage": {
"type": [
"string",
"null"
],
"maxLength": 240
},
"agentRuntimeVersion": {
"type": [
"string",
"null"
],
"maxLength": 120
},
"acpxRecordId": {
"type": [
"string",
"null"
],
"maxLength": 240
}
},
"allOf": [
@@ -428,6 +497,76 @@ export const providerDescriptorSchema = {
"service": false
}
}
},
{
"if": {
"properties": {
"provider": {
"const": "claude_managed"
}
},
"required": [
"provider"
]
},
"then": {
"properties": {
"service": {
"const": "anthropic_managed_agents"
}
},
"required": [
"service"
]
}
},
{
"if": {
"properties": {
"provider": {
"const": "aws_agentcore"
}
},
"required": [
"provider"
]
},
"then": {
"properties": {
"service": {
"const": "aws_bedrock_agentcore_harness"
}
},
"required": [
"service",
"endpointArn",
"endpointQualifier",
"memoryId",
"eventExpiryDays"
]
}
},
{
"if": {
"properties": {
"provider": {
"const": "acpx"
}
},
"required": [
"provider"
]
},
"then": {
"required": [
"agent",
"requestedModel",
"acpProtocolVersion",
"agentServerPackage",
"agentServerVersion",
"acpxRecordId"
]
}
}
],
"additionalProperties": false
@@ -0,0 +1,42 @@
import Ajv2020 from "ajv/dist/2020.js";
import { describe, expect, it } from "vitest";
import { providerDescriptorSchema } from "./generated/schema-bundle.js";
const validate = new Ajv2020({ allErrors: true, strict: false }).compile(providerDescriptorSchema);
describe("provider runtime descriptor", () => {
it("accepts the closed AWS AgentCore remote identity", () => {
expect(validate({
provider: "aws_agentcore",
driver: "aws_agentcore_harness_api",
model: "global.anthropic.claude-sonnet-4-6",
executionKind: "remote_service",
providerVersion: "3",
service: "aws_bedrock_agentcore_harness",
providerSessionId: "paperclip-session-1",
processId: null,
endpointArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:harness-endpoint/example",
endpointQualifier: "paperclip",
memoryId: "memory-1",
eventExpiryDays: 90,
})).toBe(true);
});
it("rejects an AWS descriptor using the wrong remote service", () => {
expect(validate({
provider: "aws_agentcore",
driver: "aws_agentcore_harness_api",
model: "global.anthropic.claude-sonnet-4-6",
executionKind: "remote_service",
providerVersion: "3",
service: "anthropic_managed_agents",
providerSessionId: "paperclip-session-1",
processId: null,
endpointArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:harness-endpoint/example",
endpointQualifier: "paperclip",
memoryId: "memory-1",
eventExpiryDays: 90,
})).toBe(false);
});
});
@@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
import type { AcpRuntimeEvent } from "acpx/runtime";
export const PROVIDER_EVENT_FAMILIES = [
"plan", "tool_execution", "research", "delegation", "model_identity",
@@ -270,6 +271,130 @@ export function canonicalProviderEventsFromOpenCodePart(partValue: unknown): Can
return [];
}
/** Maps ACPX's bounded public runtime events; raw ACP JSON-RPC never enters PRP. */
export function canonicalProviderEventsFromAcpxRuntimeEvent(
event: AcpRuntimeEvent,
fallbackItemId: string,
): CanonicalProviderEvent[] {
const runtimeType = text(record(event).type);
if (!["text_delta", "status", "tool_call", "error", "done"].includes(runtimeType)) {
return [{
eventType: "provider.notice.recorded",
payload: {
schema: "paperclip.provider.notice.v1",
noticeId: safeId(`${fallbackItemId}:${runtimeType || "unknown"}`, "acpx-unclassified-update"),
severity: "warning",
category: `unclassified_acp_${runtimeType || "unknown"}`.slice(0, 160),
scope: "turn",
recoverable: true,
userActionable: false,
summary: "The qualified ACP agent emitted an unclassified runtime update.",
},
itemId: fallbackItemId,
}];
}
const itemId = safeId(
event.type === "tool_call" ? text(event.toolCallId, fallbackItemId) : fallbackItemId,
"acpx-item",
);
if (event.type === "tool_call") {
const kind = text(event.kind).toLowerCase();
const status = canonicalStatus(event.status, "running");
const output = typeof event.rawOutput === "string"
? event.rawOutput
: event.rawOutput === undefined
? ""
: JSON.stringify(event.rawOutput);
const operation = /read/.test(kind) ? "read"
: /search/.test(kind) ? "search"
: /list/.test(kind) ? "list"
: /edit|write|delete|move/.test(kind) ? "edit"
: /execute|terminal/.test(kind) ? "execute"
: "unknown";
const payload = {
schema: "paperclip.tool.execution.v1",
executionId: itemId,
transport: "built_in",
operation,
name: text(event.title) || null,
target: safeAcpLocation(event.locations?.[0]),
namespace: null,
readOnly: ["read", "search", "list"].includes(operation),
status,
durationMs: null,
exitCode: null,
progress: status === "running" ? event.text.slice(0, 4000) || null : null,
...boundedOutput(output),
};
const terminal = status !== "running";
return [{
eventType: terminal ? "tool.execution.completed" : event.tag === "tool_call" ? "tool.execution.started" : "tool.execution.progressed",
payload,
itemId,
}];
}
if (event.type === "status" && event.tag === "plan") {
const steps = event.text.split("\n").map((line) => line.trim()).filter(Boolean).slice(0, 256)
.map((body, index) => ({ stepId: `step-${index + 1}`, body: body.slice(0, 4000), status: "pending" }));
return [{
eventType: "plan.updated",
payload: {
schema: "paperclip.plan.updated.v1",
planId: itemId,
revision: 1,
explanation: null,
steps,
complete: true,
syncStatus: "pending",
documentRevision: null,
},
itemId,
}];
}
if (event.type === "status" && event.tag === "current_mode_update") {
const state = /review|plan/i.test(event.text) ? "entered" : "exited";
return [{
eventType: "review.mode.changed",
payload: {
schema: "paperclip.review.mode_changed.v1",
reviewId: itemId,
state,
scope: event.text.slice(0, 4000) || null,
},
itemId,
}];
}
if (event.type === "status" && event.tag && ![
"usage_update",
"available_commands_update",
"config_option_update",
"session_info_update",
].includes(event.tag)) {
return [{
eventType: "provider.notice.recorded",
payload: {
schema: "paperclip.provider.notice.v1",
noticeId: itemId,
severity: "info",
category: `acp_${event.tag}`.slice(0, 160),
scope: "turn",
recoverable: true,
userActionable: false,
summary: event.text.slice(0, 4000) || "ACP provider update",
},
itemId,
}];
}
return [];
}
function safeAcpLocation(value: unknown): string | null {
const location = record(value);
const raw = text(location.path, text(location.uri)).replaceAll("\\", "/");
if (!raw || raw.startsWith("/") || raw.split("/").includes("..") || /^[a-z]+:\/\//i.test(raw)) return null;
return raw.slice(0, 4000);
}
export function providerFamilyCapabilities(
availability: Partial<Record<ProviderEventFamily, ProviderEventAvailability>>,
): TypedEventFamilyCapability[] {
+192
View File
@@ -0,0 +1,192 @@
diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js
index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..5ddcd5cfcb80fd3417172a5e618f1e983e200644 100644
--- a/dist/live-checkpoint-BSIrfgVo.js
+++ b/dist/live-checkpoint-BSIrfgVo.js
@@ -1661,7 +1661,7 @@ const ZED_TAG_KEYS = /* @__PURE__ */ new Set([
"RedactedThinking",
"ToolUse"
]);
-const MAP_OBJECT_PATHS = /* @__PURE__ */ new Set(["request_token_usage", "messages.Agent.tool_results"]);
+const MAP_OBJECT_PATHS = /* @__PURE__ */ new Set(["request_token_usage", "messages.Agent.tool_results", "acpx.session_options.env"]);
const OPAQUE_VALUE_PATHS = /* @__PURE__ */ new Set([
"agent_capabilities",
"messages.Agent.content.ToolUse.input",
@@ -3135,8 +3135,8 @@ function promotePrefixedAuthEnvironment(env) {
}
return protectedKeys;
}
-function buildAgentEnvironment(authCredentials, sessionEnv) {
- const env = { ...process.env };
+function buildAgentEnvironment(authCredentials, sessionEnv, spawnEnvironment) {
+ const env = spawnEnvironment ? { ...spawnEnvironment } : { ...process.env };
const protectedAuthEnvKeys = promotePrefixedAuthEnvironment(env);
if (authCredentials) for (const [methodId, credential] of Object.entries(authCredentials)) {
addAuthCredentialEnvKeys(protectedAuthEnvKeys, methodId, credential);
@@ -3178,10 +3178,10 @@ function resolveConfiguredAuthCredential(methodId, authCredentials) {
const configCredentials = authCredentials ?? {};
return configCredentials[methodId] ?? configCredentials[toEnvToken(methodId)];
}
-function buildAgentSpawnOptions(cwd, authCredentials, sessionEnv) {
+function buildAgentSpawnOptions(cwd, authCredentials, sessionEnv, spawnEnvironment) {
return {
cwd,
- env: buildAgentEnvironment(authCredentials, sessionEnv),
+ env: buildAgentEnvironment(authCredentials, sessionEnv, spawnEnvironment),
stdio: [
"pipe",
"pipe",
@@ -4216,9 +4216,21 @@ var AcpClient = class {
this.lastAgentExit = void 0;
this.lastKnownPid = child.pid ?? void 0;
this.attachAgentLifecycleObservers(child);
+ if (this.options.onAgentSpawn) {
+ try {
+ if (!child.pid) throw new Error("ACPX agent spawn did not expose a valid process id.");
+ await this.options.onAgentSpawn({ pid: child.pid, startedAt: this.agentStartedAt });
+ } catch (error) {
+ try {
+ child.kill("SIGKILL");
+ } catch {}
+ throw error;
+ }
+ }
const startupStderr = [];
child.stderr.on("data", (chunk) => {
this.captureStartupStderr(startupStderr, chunk);
+ this.options.onAgentStderr?.(String(chunk));
if (!this.options.verbose) return;
process.stderr.write(chunk);
});
@@ -4253,7 +4265,12 @@ var AcpClient = class {
geminiAcp: isGeminiAcpCommand(spawnCommand, args),
copilotAcp: isCopilotAcpCommand(spawnCommand, args),
claudeAcp: isClaudeAcpCommand(spawnCommand, args),
- spawnOptions: buildAgentSpawnOptions(this.options.cwd, this.options.authCredentials, this.options.sessionOptions?.env)
+ spawnOptions: buildAgentSpawnOptions(
+ this.options.spawnCwd ?? this.options.cwd,
+ this.options.authCredentials,
+ this.options.sessionOptions?.env,
+ this.options.spawnEnvironment?.()
+ )
};
}
logAgentLaunch(plan) {
@@ -5028,6 +5045,12 @@ var AcpClient = class {
attachAgentLifecycleObservers(child) {
child.once("exit", (exitCode, signal) => {
this.recordAgentExit("process_exit", exitCode, signal);
+ this.options.onAgentExit?.({
+ pid: child.pid ?? this.lastKnownPid,
+ exitCode,
+ signal,
+ exitedAt: isoNow$1()
+ });
});
child.once("close", (exitCode, signal) => {
this.recordAgentExit("process_close", exitCode, signal);
diff --git a/dist/runtime.d.ts b/dist/runtime.d.ts
index e8102acb03c4c38830ad5ec22f356125eb0423b7..efea5921f1c5dd5523325bc443469faef45e2233 100644
--- a/dist/runtime.d.ts
+++ b/dist/runtime.d.ts
@@ -1,7 +1,7 @@
import { _ as SessionRecord, a as AcpElicitationHandler, c as AcpElicitationResponse, f as McpServer$1, h as PermissionPolicy, i as AcpElicitationContext, l as AcpPermissionDecision, m as PermissionMode, n as SystemPromptOption, o as AcpElicitationMode, p as NonInteractivePermissionPolicy, s as AcpElicitationRequest, t as SessionAgentOptions, u as AcpPermissionRequest } from "./session-options-DwRDODlr.js";
import { a as RequestedModelUnsupportedErrorCode, i as RequestedModelUnsupportedError, n as REQUESTED_MODEL_UNSUPPORTED_ERROR_CODE, o as RequestedModelUnsupportedReason, r as REQUESTED_MODEL_UNSUPPORTED_REASONS, s as isRequestedModelUnsupportedError, t as AcpClient } from "./client-CxNllqui.js";
import fs from "node:fs";
-import { ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk";
+import { SessionNotification, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk";
//#region src/agent-registry.d.ts
declare const DEFAULT_AGENT_NAME = "codex";
//#endregion
@@ -313,6 +313,28 @@ type AcpRuntimeOptions = {
onPermissionRequest?: (req: AcpPermissionRequest, ctx: {
signal: AbortSignal;
}) => Promise<AcpPermissionDecision | undefined>;
+ /** Ephemeral allowlisted environment evaluated immediately before child spawn. */
+ spawnEnvironment?: () => Record<string, string>;
+ /** Host-only spawn cwd; does not change the cwd advertised in session/new. */
+ spawnCwd?: string;
+ onAgentSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void> | void;
+ onAgentStderr?: (chunk: string) => void;
+ onAgentExit?: (meta: {
+ pid?: number;
+ exitCode: number | null;
+ signal: NodeJS.Signals | null;
+ exitedAt: string;
+ }) => void;
+ /** Ephemeral full ACP notification callback; never written to session records. */
+ onSessionNotification?: (notification: SessionNotification) => void;
+ /** Ephemeral normalized ACP client filesystem/terminal operation callback. */
+ onClientOperation?: (operation: {
+ method: string;
+ status: string;
+ summary: string;
+ details?: string;
+ timestamp: string;
+ }) => void;
};
type AcpFileSessionStoreOptions = {
stateDir: string;
diff --git a/dist/runtime.js b/dist/runtime.js
index a1f4a70a003792c6eacf68b6b038f37bfec1db53..ee3e155db800222aaf2353906dbad93698d17b74 100644
--- a/dist/runtime.js
+++ b/dist/runtime.js
@@ -812,7 +812,15 @@ var AcpRuntimeManager = class {
this.deps = deps;
}
createClient(options) {
- return this.deps.clientFactory?.(options) ?? new AcpClient(options);
+ const patchedOptions = {
+ ...options,
+ spawnCwd: this.options.spawnCwd,
+ spawnEnvironment: this.options.spawnEnvironment,
+ onAgentSpawn: this.options.onAgentSpawn,
+ onAgentStderr: this.options.onAgentStderr,
+ onAgentExit: this.options.onAgentExit
+ };
+ return this.deps.clientFactory?.(patchedOptions) ?? new AcpClient(patchedOptions);
}
createSessionOwner(input) {
const owner = {
@@ -827,6 +835,9 @@ var AcpRuntimeManager = class {
return owner;
}
routeOwnedSessionUpdate(owner, notification) {
+ try {
+ this.options.onSessionNotification?.(notification);
+ } catch {}
const active = owner.activeTurn;
if (active) {
const { task, turn } = active;
@@ -863,6 +874,9 @@ var AcpRuntimeManager = class {
projection.checkpoint.request();
}
routeOwnedClientOperation(owner, operation) {
+ try {
+ this.options.onClientOperation?.(operation);
+ } catch {}
const active = owner.activeTurn;
if (!active) return;
const { task, turn } = active;
diff --git a/dist/session-options-DwRDODlr.d.ts b/dist/session-options-DwRDODlr.d.ts
index c3da1645235bbea22de3f8484149051cd7dca56b..a69d7327b8eaade69ee90a6bc2493b682f724084 100644
--- a/dist/session-options-DwRDODlr.d.ts
+++ b/dist/session-options-DwRDODlr.d.ts
@@ -116,6 +116,18 @@ type AcpClientOptions = {
};
env?: Record<string, string>;
};
+ /** Ephemeral child environment factory; its return value is never persisted. */
+ spawnEnvironment?: () => Record<string, string>;
+ /** Host-only child cwd, separate from the cwd advertised to ACP. */
+ spawnCwd?: string;
+ onAgentSpawn?: (meta: { pid: number; startedAt: string }) => Promise<void> | void;
+ onAgentStderr?: (chunk: string) => void;
+ onAgentExit?: (meta: {
+ pid?: number;
+ exitCode: number | null;
+ signal: NodeJS.Signals | null;
+ exitedAt: string;
+ }) => void;
onAcpMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void;
onAcpOutputMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void;
onSessionUpdate?: (notification: SessionNotification) => void;
+56
View File
@@ -0,0 +1,56 @@
diff --git a/dist/index.js b/dist/index.js
index ead9809cabc9f3b203f1769db3cabb8327dd758a..113b166120711705edcabaca2d63ae40f46c7819 100755
--- a/dist/index.js
+++ b/dist/index.js
@@ -68,6 +68,15 @@ function shouldUseShellForPiCommand(cmd) {
const normalized = cmd.trim().toLowerCase();
return normalized.endsWith(".cmd") || normalized.endsWith(".bat");
}
+function paperclipPiArgs() {
+ const raw = process.env.PI_ACP_PI_ARGS_JSON;
+ if (!raw) return [];
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed) || parsed.length > 32 || parsed.some((value) => typeof value !== "string" || value.length > 4096)) {
+ throw new Error("PI_ACP_PI_ARGS_JSON must be a bounded JSON string array");
+ }
+ return parsed;
+}
// src/pi-rpc/process.ts
var PiRpcSpawnError = class extends Error {
@@ -132,7 +141,7 @@ var PiRpcProcess = class _PiRpcProcess {
}
static async spawn(params) {
const cmd = getPiCommand(params.piCommand);
- const args = ["--mode", "rpc", "--no-themes"];
+ const args = ["--mode", "rpc", "--no-themes", ...paperclipPiArgs()];
if (params.sessionPath) args.push("--session", params.sessionPath);
const child = spawn(cmd, args, {
cwd: params.cwd,
@@ -949,6 +958,26 @@ var PiAcpSession = class {
switch (type) {
case "message_update": {
const ame = ev.assistantMessageEvent;
+ if (ev.usage && typeof ev.usage === "object") {
+ const usage = ev.usage;
+ const fingerprint = JSON.stringify(usage);
+ if (fingerprint !== this.paperclipUsageFingerprint && (usage.totalTokens ?? 0) > 0) {
+ this.paperclipUsageFingerprint = fingerprint;
+ this.emit({
+ sessionUpdate: "usage_update",
+ used: typeof usage.totalTokens === "number" ? usage.totalTokens : void 0,
+ cost: typeof usage.cost?.total === "number" ? { amount: usage.cost.total, currency: "USD" } : void 0,
+ _meta: { usage: {
+ inputTokens: typeof usage.input === "number" ? usage.input : void 0,
+ outputTokens: typeof usage.output === "number" ? usage.output : void 0,
+ cachedReadTokens: typeof usage.cacheRead === "number" ? usage.cacheRead : void 0,
+ cachedWriteTokens: typeof usage.cacheWrite === "number" ? usage.cacheWrite : void 0,
+ thoughtTokens: typeof usage.reasoning === "number" ? usage.reasoning : void 0,
+ totalTokens: typeof usage.totalTokens === "number" ? usage.totalTokens : void 0
+ } }
+ });
+ }
+ }
if (ame?.type === "text_delta" && typeof ame.delta === "string") {
this.emit({
sessionUpdate: "agent_message_chunk",
+983 -119
View File
File diff suppressed because it is too large. Load diff
+24 -3
View File
@@ -25,6 +25,11 @@ const rootPackage = JSON.parse(await readFile(new URL("../package.json", import.
const adapterUtilsPackage = JSON.parse(
await readFile(new URL("../packages/adapter-utils/package.json", import.meta.url), "utf8"),
);
const runnerPackage = JSON.parse(
await readFile(new URL("../packages/paperclip-runner/package.json", import.meta.url), "utf8"),
);
const acpxRuntimePatch = await readFile(new URL("../patches/acpx@0.13.1.patch", import.meta.url), "utf8");
const piAcpPatch = await readFile(new URL("../patches/pi-acp@0.0.33.patch", import.meta.url), "utf8");
const dbPackage = JSON.parse(
await readFile(new URL("../packages/db/package.json", import.meta.url), "utf8"),
);
@@ -34,15 +39,31 @@ const buildNpmScript = await readFile(new URL("./build-npm.sh", import.meta.url)
test("published packages preserve the patched ACPX runtime", () => {
assert.equal(
rootPackage.pnpm.patchedDependencies["acpx@0.12.0"],
"patches/acpx@0.12.0.patch",
rootPackage.pnpm.patchedDependencies["acpx@0.13.1"],
"patches/acpx@0.13.1.patch",
);
assert.equal(adapterUtilsPackage.dependencies.acpx, "0.12.0");
assert.equal(adapterUtilsPackage.dependencies.acpx, "0.13.1");
assert.deepEqual(adapterUtilsPackage.bundleDependencies, ["acpx"]);
assert.equal(bundledCliNpmDependencies.has("acpx"), true);
assert.equal(cliEsbuildConfig.external.includes("acpx"), false);
});
test("Paperclip Runner pins the qualified ACPX host and Pi isolation callbacks", () => {
assert.equal(rootPackage.pnpm.patchedDependencies["acpx@0.13.1"], "patches/acpx@0.13.1.patch");
assert.equal(rootPackage.pnpm.patchedDependencies["pi-acp@0.0.33"], "patches/pi-acp@0.0.33.patch");
assert.equal(runnerPackage.dependencies.acpx, "0.13.1");
assert.equal(runnerPackage.dependencies["pi-acp"], "0.0.33");
assert.equal(runnerPackage.dependencies["@earendil-works/pi-coding-agent"], "0.84.2");
assert.equal(runnerPackage.dependencies["@agentclientprotocol/claude-agent-acp"], "0.70.0");
assert.equal(runnerPackage.dependencies["@agentclientprotocol/codex-acp"], "1.6.2");
for (const callback of [
"spawnEnvironment", "spawnCwd", "onAgentSpawn", "onAgentStderr", "onAgentExit",
"onSessionNotification", "onClientOperation",
]) assert.match(acpxRuntimePatch, new RegExp(callback));
assert.match(piAcpPatch, /PI_ACP_PI_ARGS_JSON/);
assert.match(piAcpPatch, /sessionUpdate: "usage_update"/);
});
test("published packages preserve the patched embedded-postgres runtime", () => {
assert.equal(
rootPackage.pnpm.patchedDependencies["embedded-postgres@18.1.0-beta.16"],
+265 -6
View File
@@ -4,7 +4,16 @@ import type {
ServerAdapterModule,
} from "./types.js";
import { execFile } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import {
AcpxRuntimeHost,
inspectQualifiedAcpxInstallation,
resolveQualifiedAcpxProfile,
type QualifiedAcpxAgent,
} from "../vendor/paperclip-runner/index.js";
import { parseAdapterModelsEnv } from "../services/adapter-models-env.js";
import { stampClaudeAgentIdHeader } from "./claude-agent-id-header.js";
import {
@@ -188,6 +197,113 @@ async function testPaperclipRunnerOpenCodeEnvironment(
}
}
async function testPaperclipRunnerAcpxEnvironment(
context: Parameters<typeof codexTestEnvironment>[0],
) {
const testedAt = new Date().toISOString();
const agent = context.config.acpxAgent;
const model = typeof context.config.model === "string" ? context.config.model.trim() : "";
if (context.config.permissionPolicy !== undefined && context.config.permissionPolicy !== "interactive") {
return {
adapterType: "paperclip_runner",
status: "fail" as const,
testedAt,
checks: [{ code: "acpx_permission_policy_invalid", level: "error" as const, message: "ACPX v1 requires permissionPolicy=interactive." }],
};
}
if (agent !== "pi" && agent !== "claude" && agent !== "codex") {
return {
adapterType: "paperclip_runner",
status: "fail" as const,
testedAt,
checks: [{ code: "acpx_agent_invalid", level: "error" as const, message: "ACPX agent must be Pi, Claude, or Codex." }],
};
}
let profile;
try {
profile = resolveQualifiedAcpxProfile(agent, model);
} catch (error) {
return {
adapterType: "paperclip_runner",
status: "fail" as const,
testedAt,
checks: [{ code: "acpx_model_unqualified", level: "error" as const, message: error instanceof Error ? error.message : "ACPX model is not qualified." }],
};
}
const nodeParts = process.versions.node.split(".").map(Number);
if ((nodeParts[0] ?? 0) < 22 || ((nodeParts[0] ?? 0) === 22 && (nodeParts[1] ?? 0) < 13)) {
return {
adapterType: "paperclip_runner",
status: "fail" as const,
testedAt,
checks: [{ code: "acpx_node_too_old", level: "error" as const, message: `ACPX 0.13.1 requires Node 22.13 or newer; found ${process.versions.node}.` }],
};
}
const configuredEnv = context.config.env && typeof context.config.env === "object"
? context.config.env as Record<string, unknown>
: {};
const environment = { ...process.env };
for (const [key, value] of Object.entries(configuredEnv)) if (typeof value === "string") environment[key] = value;
const credentialReady = agent === "pi"
? Boolean(environment.OPENROUTER_API_KEY)
: agent === "claude"
? Boolean(environment.ANTHROPIC_API_KEY || environment.CLAUDE_CODE_OAUTH_TOKEN)
: Boolean(environment.OPENAI_API_KEY || environment.CODEX_API_KEY);
if (!credentialReady) {
const required = agent === "pi"
? "OPENROUTER_API_KEY"
: agent === "claude"
? "ANTHROPIC_API_KEY or the managed Claude credential"
: "the managed Codex credential or OPENAI_API_KEY";
return {
adapterType: "paperclip_runner",
status: "fail" as const,
testedAt,
checks: [{ code: "acpx_auth_missing", level: "error" as const, message: `ACPX ${agent} requires ${required}.` }],
};
}
let probeRoot: string | null = null;
try {
const installation = await inspectQualifiedAcpxInstallation(agent as QualifiedAcpxAgent, model);
probeRoot = await mkdtemp(join(tmpdir(), "paperclip-acpx-probe-"));
const host = await AcpxRuntimeHost.open({
runtimeDirectory: probeRoot,
normalizedSessionId: "environment-probe",
workingDirectory: probeRoot,
agent: agent as QualifiedAcpxAgent,
model,
environment,
dynamicTools: [],
dynamicToolHandler: async () => { throw new Error("environment probe exposes no semantic tools"); },
});
const identity = host.identity();
await host.close({ reason: "environment probe complete", discardPersistentState: true });
return {
adapterType: "paperclip_runner",
status: "pass" as const,
testedAt,
checks: [{
code: "acpx_profile_qualified",
level: "info" as const,
message: `Verified ACPX ${profile.acpxVersion}, ${profile.agentServerPackage}@${profile.agentServerVersion}, exact model ${identity.effectiveModel}, semantic bridge startup, and clean shutdown (digest ${installation.commandDigest}).`,
}, {
code: "acpx_auth_ready",
level: "info" as const,
message: `Credential readiness was verified for ACPX ${agent}; no credential value was retained.`,
}],
};
} catch (error) {
return {
adapterType: "paperclip_runner",
status: "fail" as const,
testedAt,
checks: [{ code: "acpx_handshake_failed", level: "error" as const, message: error instanceof Error ? error.message : "ACPX handshake failed." }],
};
} finally {
if (probeRoot) await rm(probeRoot, { recursive: true, force: true });
}
}
async function testPaperclipRunnerClaudeManagedEnvironment(
context: Parameters<typeof codexTestEnvironment>[0],
) {
@@ -337,6 +453,68 @@ async function testPaperclipRunnerClaudeManagedEnvironment(
}
}
async function testPaperclipRunnerAwsAgentCoreEnvironment(
context: Parameters<typeof codexTestEnvironment>[0],
) {
const testedAt = new Date().toISOString();
const required = [
["agentCoreProfileId", "AgentCore profile ID"], ["awsRegion", "AWS region"],
["harnessId", "Harness ID"], ["harnessVersion", "Harness version"],
["endpointQualifier", "endpoint qualifier"], ["memoryId", "Memory ID"],
["invocationRoleArn", "invocation role ARN"], ["model", "Bedrock model"],
] as const;
const missing = required.filter(([key]) => typeof context.config[key] !== "string" || String(context.config[key]).trim().length === 0);
if (missing.length > 0 || context.config.agentCoreRetentionAcknowledged !== true) {
return {
adapterType: "paperclip_runner", status: "fail" as const, testedAt,
checks: [{ code: "aws_agentcore_profile_incomplete", level: "error" as const,
message: missing.length > 0
? `AWS AgentCore profile is missing: ${missing.map(([, label]) => label).join(", ")}.`
: "AWS AgentCore requires an administrator acknowledgement of 90-day Memory retention." }],
};
}
const cap = Number(context.config.maxEstimatedSessionCostUsd);
if (!Number.isFinite(cap) || cap <= 0) {
return { adapterType: "paperclip_runner", status: "fail" as const, testedAt,
checks: [{ code: "aws_agentcore_estimated_cap_invalid", level: "error" as const, message: "AWS AgentCore requires a positive estimated session ceiling." }] };
}
const sourceEnvironment = context.config.env && typeof context.config.env === "object"
? { ...process.env, ...(context.config.env as Record<string, string>) }
: process.env;
const region = String(context.config.awsRegion);
try {
const version = await execFileAsync("aws", ["--version"], { timeout: 5_000, env: sourceEnvironment });
if (!`${version.stdout}${version.stderr}`.includes("aws-cli/2.")) throw new Error("AWS CLI v2 is required");
const assumed = await execFileAsync("aws", ["--region", region, "sts", "assume-role", "--role-arn", String(context.config.invocationRoleArn), "--role-session-name", "paperclip-environment-probe", "--duration-seconds", "900", "--output", "json"], { timeout: 15_000, env: sourceEnvironment });
const credentials = (JSON.parse(assumed.stdout) as { Credentials?: { AccessKeyId?: string; SecretAccessKey?: string; SessionToken?: string } }).Credentials;
if (!credentials?.AccessKeyId || !credentials.SecretAccessKey || !credentials.SessionToken) throw new Error("STS omitted temporary credentials");
const env = {
...sourceEnvironment,
AWS_ACCESS_KEY_ID: credentials.AccessKeyId,
AWS_SECRET_ACCESS_KEY: credentials.SecretAccessKey,
AWS_SESSION_TOKEN: credentials.SessionToken,
AWS_REGION: region,
AWS_DEFAULT_REGION: region,
AWS_PROFILE: undefined,
};
await Promise.all([
execFileAsync("aws", ["--region", region, "bedrock-agentcore-control", "get-harness", "--harness-id", String(context.config.harnessId), "--harness-version", String(context.config.harnessVersion)], { timeout: 15_000, env }),
execFileAsync("aws", ["--region", region, "bedrock-agentcore-control", "get-harness-endpoint", "--harness-id", String(context.config.harnessId), "--endpoint-name", String(context.config.endpointQualifier)], { timeout: 15_000, env }),
execFileAsync("aws", ["--region", region, "bedrock-agentcore-control", "get-memory", "--memory-id", String(context.config.memoryId), "--view", "full"], { timeout: 15_000, env }),
]);
return {
adapterType: "paperclip_runner", status: "warn" as const, testedAt,
checks: [
{ code: "aws_agentcore_profile_qualified", level: "info" as const, message: `Verified pinned Harness ${String(context.config.harnessId)} version ${String(context.config.harnessVersion)} and Memory ${String(context.config.memoryId)} without creating a paid session.` },
{ code: "aws_agentcore_retention_notice", level: "warn" as const, message: "AgentCore Memory retains short-term events for 90 days; the session cost shown by Paperclip is an estimate, not an AWS currency hard stop." },
],
};
} catch {
return { adapterType: "paperclip_runner", status: "fail" as const, testedAt,
checks: [{ code: "aws_agentcore_probe_failed", level: "error" as const, message: "AWS AgentCore read-only qualification failed. Verify AWS CLI v2, the source profile, sts:AssumeRole, region, Harness endpoint, and Memory identifiers." }] };
}
}
function compareSemver(left: string, right: string): number {
const a = left.split(".").map(Number);
const b = right.split(".").map(Number);
@@ -553,18 +731,32 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
timedOut: false,
errorMessage: message,
errorCode: "paperclip_runner_coordinator_required",
provider: "codex",
provider: ctx.config.provider === "acpx"
? "acpx"
: ctx.config.provider === "opencode"
? "opencode"
: ctx.config.provider === "claude_managed"
? "anthropic"
: ctx.config.provider === "aws_agentcore"
? "amazon-bedrock"
: "codex",
summary: message,
};
},
async testEnvironment(context) {
const provider = context.config.provider === "opencode"
? "opencode"
: context.config.provider === "claude_managed" ? "claude_managed" : "codex";
: context.config.provider === "claude_managed" ? "claude_managed"
: context.config.provider === "aws_agentcore" ? "aws_agentcore"
: context.config.provider === "acpx" ? "acpx" : "codex";
const result = provider === "claude_managed"
? await testPaperclipRunnerClaudeManagedEnvironment(context)
: provider === "aws_agentcore"
? await testPaperclipRunnerAwsAgentCoreEnvironment(context)
: provider === "opencode"
? await testPaperclipRunnerOpenCodeEnvironment(context)
: provider === "acpx"
? await testPaperclipRunnerAcpxEnvironment(context)
: await codexTestEnvironment(context);
return { ...result, adapterType: "paperclip_runner" };
},
@@ -575,6 +767,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
...codexModels,
{ id: "openrouter/deepseek/deepseek-v4-flash-0731", label: "OpenRouter · DeepSeek V4 Flash 0731" },
{ id: "claude-sonnet-5", label: "Claude Sonnet 5" },
{ id: "global.anthropic.claude-sonnet-4-6", label: "Amazon Bedrock · Claude Sonnet 4.6 (global)" },
],
modelProfiles: codexModelProfiles,
listModels: listCodexModels,
@@ -583,12 +776,12 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: false,
getRuntimeCommandSpec: (config) => config.provider === "claude_managed"
getRuntimeCommandSpec: (config) => config.provider === "claude_managed" || config.provider === "aws_agentcore" || config.provider === "acpx"
? { command: "paperclip-runnerd", detectCommand: null, installCommand: null }
: config.provider === "opencode"
? buildNpmRuntimeCommandSpec(config, "opencode", "opencode-ai@1.18.17")
: buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex"),
agentConfigurationDoc: `# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, or a remote Claude Agent through the native Paperclip runner and authenticated PRP control-plane connection. Claude Agent credentials remain in runnerd; Anthropic never receives a Paperclip credential or endpoint.\n`,
: buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex@0.148.0"),
agentConfigurationDoc: `# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns native Codex, OpenCode, Claude Managed, AWS AgentCore, or a qualified Pi/Claude/Codex ACP agent through ACPX 0.13.1 and authenticated PRP. Provider processes never receive a Paperclip credential or unrestricted server environment.\n`,
getConfigSchema: () => ({
fields: [{
key: "provider",
@@ -599,8 +792,29 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
{ value: "codex", label: "Codex" },
{ value: "opencode", label: "OpenCode 1.18.17" },
{ value: "claude_managed", label: "Claude Agent" },
{ value: "aws_agentcore", label: "AWS AgentCore" },
{ value: "acpx", label: "ACPX" },
],
hint: "Select a local harness or an Anthropic cloud-managed Claude Agent.",
hint: "Select a local harness, ACPX agent, Anthropic Claude Agent, or AWS AgentCore Harness.",
}, {
key: "acpxAgent",
label: "ACP agent",
type: "select",
default: "pi",
options: [
{ value: "pi", label: "Pi via ACPX" },
{ value: "claude", label: "Claude via ACPX" },
{ value: "codex", label: "Codex via ACPX (control)" },
],
hint: "Qualified ACP server profile. Configuration is immutable after session creation.",
meta: { provider: "acpx" },
}, {
key: "permissionPolicy",
label: "Permission policy",
type: "text",
default: "interactive",
hint: "ACPX v1 supports the interactive policy only.",
meta: { provider: "acpx", readOnly: true },
}, {
key: "lifecycleMode",
label: "Runner lifecycle",
@@ -649,6 +863,51 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
}, {
key: "managedAgentsRetentionAcknowledged", label: "Acknowledge beta retention", type: "toggle", default: false,
hint: "Managed Agents is stateful and is not eligible for ZDR or HIPAA modes.", meta: { provider: "claude_managed" },
}, {
key: "agentCoreProfileId", label: "AgentCore profile", type: "text", required: true,
hint: "Company-scoped provisioned profile identifier.", meta: { provider: "aws_agentcore" },
}, {
key: "awsRegion", label: "AWS region", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "awsAccountId", label: "AWS account ID", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "harnessArn", label: "Harness ARN", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "harnessId", label: "Harness ID", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "harnessVersion", label: "Pinned Harness version", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "endpointQualifier", label: "Harness endpoint", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "endpointArn", label: "Harness endpoint ARN", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "agentRuntimeArn", label: "Underlying Runtime ARN", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "memoryId", label: "AgentCore Memory ID", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "memoryArn", label: "AgentCore Memory ARN", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "invocationRoleArn", label: "Invocation role ARN", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "maxEstimatedSessionCostUsd", label: "Estimated session ceiling (USD)", type: "number", default: 1,
hint: "Paperclip estimate; AWS has no per-session currency hard stop.", meta: { provider: "aws_agentcore" },
}, {
key: "qualificationRevision", label: "Qualification revision", type: "text", default: "aws-agentcore-harness-v1",
meta: { provider: "aws_agentcore" },
}, {
key: "agentCoreRetentionAcknowledged", label: "Acknowledge 90-day Memory retention", type: "toggle", default: false,
hint: "AgentCore short-term Memory retains events for 90 days.", meta: { provider: "aws_agentcore" },
}],
}),
loginCapability: codexLoginCapability,
+2
View File
@@ -83,6 +83,7 @@ import { pluginRoutes } from "./routes/plugins.js";
import { mcpGatewayProtocolRoutes, toolGatewayRoutes } from "./routes/tool-gateway.js";
import { adapterRoutes } from "./routes/adapters.js";
import { managedAgentProfileRoutes } from "./routes/managed-agent-profiles.js";
import { remoteAgentProfileRoutes } from "./routes/remote-agent-profiles.js";
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
import { readBrandedStaticIndexHtml } from "./static-index-html.js";
import { applyUiBranding } from "./ui-branding.js";
@@ -524,6 +525,7 @@ export async function createApp(
api.use(approvalRoutes(db, { pluginWorkerManager: workerManager }));
api.use(secretRoutes(db));
api.use(managedAgentProfileRoutes(db));
api.use(remoteAgentProfileRoutes(db));
const trustedLocalStdioRuntimeHost =
process.env.PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST
?? process.env.PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST
+38
View File
@@ -9,7 +9,9 @@ import {
type DurablePrpControlPlane,
} from "../vendor/paperclip-runner/index.js";
import {
queueRunnerPrpRuntimeRequestResolution,
registerRunnerPrpAuthority,
RunnerPrpRuntimeRequestResolutionError,
runnerPrpWebSocketInternals,
setupRunnerPrpWebSocketServer,
} from "./runner-prp-ws.js";
@@ -57,6 +59,42 @@ describe("runner PRP websocket route", () => {
server.close();
});
it("queues one idempotent, turn-bound runtime request resolution", async () => {
const server = createServer();
setupRunnerPrpWebSocketServer(server, { port: 3212 });
const queueCommand = vi.fn(() => ({ commandId: "command-resolution-1" }));
const runId = "00000000-0000-4000-8000-000000000780";
const registration = await registerRunnerPrpAuthority({
runId,
authority: { queueCommand } as unknown as DurablePrpControlPlane,
});
const input = {
runId,
requestId: "request-1",
turnId: "turn-1",
resolution: { action: "accept" as const },
};
expect(queueRunnerPrpRuntimeRequestResolution(input)).toEqual({ commandId: "command-resolution-1" });
expect(queueRunnerPrpRuntimeRequestResolution(input)).toEqual({ commandId: "command-resolution-1" });
expect(queueCommand).toHaveBeenCalledTimes(1);
expect(queueCommand).toHaveBeenCalledWith("request.resolve", {
requestId: "request-1",
turnId: "turn-1",
resolution: { action: "accept" },
}, undefined, true);
expect(() => queueRunnerPrpRuntimeRequestResolution({
...input,
resolution: { action: "decline" },
})).toThrowError(RunnerPrpRuntimeRequestResolutionError);
await registration.release();
expect(() => queueRunnerPrpRuntimeRequestResolution(input)).toThrowError(
"runner_prp_authority_not_active",
);
server.close();
});
it("carries an authorized runnerd tool call over the shared Paperclip route", async () => {
const server = createServer();
await new Promise<void>((resolveListen) => server.listen(0, "127.0.0.1", resolveListen));
+55 -2
View File
@@ -1,6 +1,9 @@
import type { IncomingMessage, Server } from "node:http";
import type { Duplex } from "node:stream";
import type { DurablePrpControlPlane } from "../vendor/paperclip-runner/index.js";
import type {
DurablePrpControlPlane,
HarnessRuntimeRequestResolution,
} from "../vendor/paperclip-runner/index.js";
import { logger } from "../middleware/logger.js";
const CONNECT_PATH_PREFIX = "/api/runner/v1/connect/";
@@ -8,6 +11,7 @@ const CONNECT_PATH_PREFIX = "/api/runner/v1/connect/";
type Registration = {
authority: DurablePrpControlPlane;
generation: symbol;
runtimeRequestResolutions: Map<string, { fingerprint: string; commandId: string }>;
};
const registrations = new Map<string, Registration>();
@@ -56,7 +60,11 @@ export async function registerRunnerPrpAuthority(input: {
if (loopbackOrigin === null) throw new Error("runner_prp_websocket_server_not_configured");
if (registrations.has(input.runId)) throw new Error("runner_prp_authority_already_registered");
const generation = Symbol(input.runId);
registrations.set(input.runId, { authority: input.authority, generation });
registrations.set(input.runId, {
authority: input.authority,
generation,
runtimeRequestResolutions: new Map(),
});
return {
connectUrl: `${loopbackOrigin}${CONNECT_PATH_PREFIX}${input.runId}`,
release: async () => {
@@ -65,6 +73,51 @@ export async function registerRunnerPrpAuthority(input: {
};
}
export class RunnerPrpRuntimeRequestResolutionError extends Error {
constructor(
readonly code: "runner_prp_authority_not_active" | "runtime_request_resolution_conflict",
) {
super(code);
this.name = "RunnerPrpRuntimeRequestResolutionError";
}
}
/**
* Queues one turn-bound runtime response on the active durable PRP authority.
* Replayed identical browser submissions return the original command instead
* of answering the provider twice; a different answer for the same request
* fails closed.
*/
export function queueRunnerPrpRuntimeRequestResolution(input: {
runId: string;
requestId: string;
turnId: string;
resolution: HarnessRuntimeRequestResolution;
}): { commandId: string } {
const registration = registrations.get(input.runId);
if (!registration) {
throw new RunnerPrpRuntimeRequestResolutionError("runner_prp_authority_not_active");
}
const fingerprint = JSON.stringify({ turnId: input.turnId, resolution: input.resolution });
const previous = registration.runtimeRequestResolutions.get(input.requestId);
if (previous) {
if (previous.fingerprint !== fingerprint) {
throw new RunnerPrpRuntimeRequestResolutionError("runtime_request_resolution_conflict");
}
return { commandId: previous.commandId };
}
const command = registration.authority.queueCommand("request.resolve", {
requestId: input.requestId,
turnId: input.turnId,
resolution: input.resolution,
}, undefined, true);
registration.runtimeRequestResolutions.set(input.requestId, {
fingerprint,
commandId: command.commandId,
});
return { commandId: command.commandId };
}
export const runnerPrpWebSocketInternals = {
connectPathPrefix: CONNECT_PATH_PREFIX,
resetForTests(): void {
+74
View File
@@ -94,6 +94,16 @@ import {
} from "../adapters/index.js";
import { redactEventPayload } from "../redaction.js";
import { redactCurrentUserValue } from "../log-redaction.js";
import {
HarnessRuntimeRequestResolutionError,
parseHarnessRuntimeRequestResolution,
type HarnessRuntimeRequestKind,
type HarnessRuntimeRequestResolution,
} from "../vendor/paperclip-runner/index.js";
import {
queueRunnerPrpRuntimeRequestResolution,
RunnerPrpRuntimeRequestResolutionError,
} from "../realtime/runner-prp-ws.js";
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js";
import {
instanceSettingsService,
@@ -5015,6 +5025,70 @@ export function agentRoutes(
res.json(run);
});
router.post("/heartbeat-runs/:runId/runtime-requests/:requestId/resolve", async (req, res) => {
assertBoard(req);
const runId = req.params.runId as string;
const requestId = req.params.requestId as string;
const existing = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found");
if (!existing) return;
if (existing.runtimeMode !== "native" || existing.status !== "running") {
throw conflict("This runner session is no longer accepting runtime responses.");
}
const turnId = typeof req.body?.turnId === "string" ? req.body.turnId.trim() : "";
const rawRequestKind = typeof req.body?.requestKind === "string" ? req.body.requestKind : "";
const runtimeRequestKinds: readonly HarnessRuntimeRequestKind[] = [
"command_approval",
"file_approval",
"permission_approval",
"user_input",
"elicitation",
];
if (!requestId || requestId.length > 160 || !turnId || turnId.length > 160) {
throw badRequest("Runtime request and turn identifiers are required.");
}
if (!runtimeRequestKinds.includes(rawRequestKind as HarnessRuntimeRequestKind)) {
throw badRequest("Unsupported runtime request kind.");
}
let resolution: HarnessRuntimeRequestResolution;
try {
resolution = parseHarnessRuntimeRequestResolution(
rawRequestKind as HarnessRuntimeRequestKind,
req.body?.resolution,
);
} catch (error) {
if (error instanceof HarnessRuntimeRequestResolutionError) {
throw badRequest("Invalid runtime request response.");
}
throw error;
}
try {
const queued = queueRunnerPrpRuntimeRequestResolution({
runId,
requestId,
turnId,
resolution,
});
await logActivity(db, {
companyId: existing.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "heartbeat.runtime_request_resolution_queued",
entityType: "heartbeat_run",
entityId: existing.id,
details: { requestId, action: resolution.action },
});
res.status(202).json({ accepted: true, commandId: queued.commandId });
} catch (error) {
if (error instanceof RunnerPrpRuntimeRequestResolutionError) {
throw conflict(error.code === "runtime_request_resolution_conflict"
? "A different response was already submitted for this runtime request."
: "The runner session is no longer accepting runtime responses.");
}
throw error;
}
});
router.post("/heartbeat-runs/:runId/watchdog-decisions", async (req, res) => {
const runId = req.params.runId as string;
const existing = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found");
+1
View File
@@ -40,3 +40,4 @@ export { accessRoutes } from "./access.js";
export { instanceSettingsRoutes } from "./instance-settings.js";
export { instanceDatabaseBackupRoutes } from "./instance-database-backups.js";
export { managedAgentProfileRoutes } from "./managed-agent-profiles.js";
export { remoteAgentProfileRoutes } from "./remote-agent-profiles.js";
+20
View File
@@ -4447,6 +4447,26 @@ registry.registerPath({
responses: { 200: r.ok(), 401: r.unauthorized },
});
registry.registerPath({
method: "post",
path: "/api/heartbeat-runs/{runId}/runtime-requests/{requestId}/resolve",
tags: ["runs"],
summary: "Resolve a pending Paperclip runner runtime request",
request: {
params: z.object({ runId: z.string(), requestId: z.string() }),
body: jsonBody(z.object({
turnId: z.string().min(1).max(160),
requestKind: z.enum(["command_approval", "file_approval", "permission_approval", "user_input", "elicitation"]),
resolution: z.union([
z.object({ action: z.enum(["accept", "accept_for_session", "decline", "cancel"]) }),
z.object({ action: z.literal("submit"), answers: z.record(z.string(), z.object({ answers: z.array(z.string()) })) }),
z.object({ action: z.literal("submit"), content: z.record(z.string(), z.unknown()) }),
]),
})),
},
responses: { 202: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound, 409: r.conflict },
});
registry.registerPath({
method: "post",
path: "/api/heartbeat-runs/{runId}/watchdog-decisions",
@@ -0,0 +1,46 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { remoteAgentProfileService, type RemoteAgentProfileInput, type RemoteAgentService } from "../services/remote-agent-profiles.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { logActivity } from "../services/activity-log.js";
import { unprocessable } from "../errors.js";
function profileInput(value: unknown): RemoteAgentProfileInput {
if (!value || typeof value !== "object") throw unprocessable("Remote Agent profile body is required");
const body = value as Record<string, unknown>;
const service = body.service;
if (service !== "anthropic_managed_agents" && service !== "aws_bedrock_agentcore_harness") throw unprocessable("Unsupported remote agent service");
return {
profileKey: String(body.profileKey ?? ""), displayName: String(body.displayName ?? ""),
service: service as RemoteAgentService,
configuration: body.configuration && typeof body.configuration === "object" && !Array.isArray(body.configuration) ? body.configuration as Record<string, unknown> : {},
credentialSecretId: typeof body.credentialSecretId === "string" ? body.credentialSecretId : null,
enabled: body.enabled === true, retentionAcknowledged: body.retentionAcknowledged === true,
qualification: body.qualification && typeof body.qualification === "object" && !Array.isArray(body.qualification) ? body.qualification as Record<string, unknown> : {},
};
}
export function remoteAgentProfileRoutes(db: Db) {
const router = Router();
const profiles = remoteAgentProfileService(db);
router.get("/companies/:companyId/remote-agent-profiles", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const service = typeof req.query.service === "string" ? req.query.service as RemoteAgentService : undefined;
res.json(await profiles.list(companyId, service));
});
router.post("/companies/:companyId/remote-agent-profiles", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const profile = await profiles.upsert(companyId, profileInput(req.body));
const actor = getActorInfo(req);
await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId,
action: "remote_agent_profile.qualified", entityType: "remote_agent_profile", entityId: profile.id,
details: { profileKey: profile.profileKey, service: profile.service, enabled: profile.enabled, retentionAcknowledged: profile.retentionAcknowledged } });
res.status(201).json(profile);
});
return router;
}
+150 -15
View File
@@ -83,6 +83,7 @@ import {
// git-credentials module became its canonical home; existing importers keep working.
export { scrubGitCredentialText };
import { publishLiveEvent } from "./live-events.js";
import { documentService } from "./documents.js";
import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js";
import {
buildNativeProviderEnvironment,
@@ -92,13 +93,15 @@ import {
ensureNativeCompletionContract,
executePaperclipNativeSession,
finalizeNativeRun,
isNativeSessionId,
materializeNativeInteractionResponses,
rebindNativeSessionCheckpoint,
reconcileNativeFinalizations,
resolveHeartbeatNativeRuntimeMode,
} from "./native-runtime/index.js";
import {
parseNativeExecutionInput,
type NativeExecutionInputV1,
type NativeExecutionInput,
type NativeSessionBackend,
} from "../vendor/paperclip-runner/index.js";
import { normalizeResponsibleUserDenialCode } from "./responsible-user-denial-run-outcomes.js";
@@ -305,6 +308,7 @@ import { environmentService } from "./environments.js";
import { parseExecutionPolicyBootstrapEnv } from "./execution-policy-bootstrap.js";
import { environmentRuntimeService } from "./environment-runtime.js";
import { managedAgentProfileService } from "./managed-agent-profiles.js";
import { remoteAgentProfileService } from "./remote-agent-profiles.js";
import { skillVersionSelectionMap } from "./runtime-skill-selections.js";
import { environmentRunOrchestrator } from "./environment-run-orchestrator.js";
import { isUnsafeSessionWorkspaceCwd } from "./session-workspace-cwd.js";
@@ -6677,7 +6681,7 @@ export interface HeartbeatServiceOptions {
* the seam here exercises the production reaper, claim, execution, package
* session loop, persistence port, and finalizer without spawning a provider.
*/
nativeSessionBackendFactory?: (execution: NativeExecutionInputV1) => NativeSessionBackend;
nativeSessionBackendFactory?: (execution: NativeExecutionInput) => NativeSessionBackend;
}
type WorkspaceReadyCommentWriter = {
@@ -13607,9 +13611,21 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
if (now.getTime() - refTime < staleThresholdMs) continue;
}
const tracksLocalChild = isTrackedLocalChildProcessAdapter(adapterType);
const processPidAlive = tracksLocalChild && run.processPid && isProcessAlive(run.processPid);
const processGroupAlive = tracksLocalChild && run.processGroupId && isProcessGroupAlive(run.processGroupId);
const nativeLocalChild = run.runtimeMode === "native";
const tracksLocalChild = nativeLocalChild || isTrackedLocalChildProcessAdapter(adapterType);
let processPidAlive = tracksLocalChild && run.processPid && isProcessAlive(run.processPid);
let processGroupAlive = tracksLocalChild && run.processGroupId && isProcessGroupAlive(run.processGroupId);
if (nativeLocalChild && (processPidAlive || processGroupAlive)) {
// A native runner's reconnect URL is bound to the old run authority.
// If startup resumption did not adopt it above, leaving it alive makes
// the next run fight an orphan for the same provider thread writer.
await terminateHeartbeatRunProcess({
pid: run.processPid,
processGroupId: run.processGroupId,
});
processPidAlive = false;
processGroupAlive = false;
}
if (
(processPidAlive || processGroupAlive) &&
readHotRestartAdoptionMetadata(parseObject(run.resultJson))
@@ -15876,7 +15892,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
target: executionTarget,
workspaceId: persistedExecutionWorkspace?.id ?? null,
});
let nativeExecution: ReturnType<typeof buildNativeExecutionInput> | null = null;
let nativeExecution: NativeExecutionInput | null = null;
let nativeRunnerInstanceId: string | null = null;
if (nativeRuntimeResolution.kind === "native") {
if (!issueRef) {
@@ -15902,8 +15918,35 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
actorId: agent.id,
immediateRequest: safeWakeCommentContext?.body ?? null,
});
nativeRunnerInstanceId = run.runnerInstanceId ?? randomUUID();
const nativeSessionId = run.nativeSessionId ?? randomUUID();
const taskNativeSessionId = readNonEmptyString(taskSessionDecodedParams?.sessionId);
const resumableTaskSessionId =
taskSessionForRun?.lastRunId
&& taskSessionForRun.lastRunId !== run.id
&& isNativeSessionId(taskNativeSessionId)
? taskNativeSessionId
: null;
const previousNativeRun = resumableTaskSessionId && taskSessionForRun?.lastRunId
? await db.select({
id: heartbeatRuns.id,
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
runnerInstanceId: heartbeatRuns.runnerInstanceId,
nativeSessionId: heartbeatRuns.nativeSessionId,
runnerProfileJson: heartbeatRuns.runnerProfileJson,
}).from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, taskSessionForRun.lastRunId),
eq(heartbeatRuns.companyId, agent.companyId),
eq(heartbeatRuns.agentId, agent.id),
eq(heartbeatRuns.nativeSessionId, resumableTaskSessionId),
)).limit(1).then((rows) => rows[0] ?? null)
: null;
nativeRunnerInstanceId =
previousNativeRun?.runnerInstanceId
&& previousNativeRun.nativeSessionId === (run.nativeSessionId ?? resumableTaskSessionId)
? previousNativeRun.runnerInstanceId
: run.runnerInstanceId ?? randomUUID();
let nativeSessionId = run.nativeSessionId ?? resumableTaskSessionId ?? randomUUID();
let nativeResumeCheckpoint: ReturnType<typeof rebindNativeSessionCheckpoint> = null;
const persistedProfile = persistedRunnerProfile;
if (persistedNativeExecutionInput) {
nativeExecution = persistedNativeExecutionInput;
@@ -15932,6 +15975,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
readNonEmptyString(parseObject(runtimeConfig).managedProfileId) ?? "",
)
: null;
const agentCoreProfile = nativeRuntimeResolution.profile.backend === "aws_agentcore_harness_api"
? await remoteAgentProfileService(db).requireQualified(
agent.companyId,
readNonEmptyString(parseObject(agent.adapterConfig).agentCoreProfileId) ?? "",
"aws_bedrock_agentcore_harness",
)
: null;
const agentCoreConfig = parseObject(agentCoreProfile?.configuration);
if (managedProfile) {
const rawApiKeyBinding = parseObject(parseObject(agent.adapterConfig).env).ANTHROPIC_API_KEY;
const boundSecretId = typeof rawApiKeyBinding === "object" && rawApiKeyBinding !== null
@@ -15952,6 +16003,22 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
);
}
}
const executionMode = issueRef.workMode === "planning" && !acceptedPlanContinuationWake
? "plan" as const
: "default" as const;
const pinnedPlan = executionMode === "plan"
? await documentService(db).getIssueDocumentByKey(issueRef.id, "plan")
: null;
const pinnedReviewContext = executionMode === "plan"
? await buildPlanReviewContext({
db,
companyId: agent.companyId,
issueId: issueRef.id,
issueWorkMode: issueRef.workMode,
interactionId: readNonEmptyString(context.interactionId),
})
: null;
const pinnedPlanMarkdown = pinnedPlan?.body ?? "";
nativeExecution = buildNativeExecutionInput({
companyId: agent.companyId,
runId: run.id,
@@ -15970,14 +16037,32 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
branchName: executionWorkspace.branchName,
},
normalizedSessionId: nativeSessionId,
executionMode,
planningContext: executionMode === "plan" ? {
documentId: pinnedPlan?.id ?? null,
baseRevisionId: pinnedPlan?.latestRevisionId ?? null,
baseRevisionNumber: pinnedPlan?.latestRevisionNumber ?? 0,
markdown: pinnedPlanMarkdown,
sha256: createHash("sha256").update(pinnedPlanMarkdown).digest("hex"),
reviewContext: pinnedReviewContext
? structuredClone(pinnedReviewContext) as unknown as Record<string, unknown>
: {},
} : null,
provider: nativeRuntimeResolution.profile.backend === "opencode_server"
? "opencode"
: nativeRuntimeResolution.profile.backend === "claude_managed_agents_api"
? "claude_managed"
: nativeRuntimeResolution.profile.backend === "aws_agentcore_harness_api"
? "aws_agentcore"
: nativeRuntimeResolution.profile.backend === "acpx_runtime"
? "acpx"
: "codex",
model: typeof parseObject(runtimeConfig).model === "string"
? String(parseObject(runtimeConfig).model)
: managedProfile?.defaultModel ?? null,
...(nativeRuntimeResolution.profile.backend === "acpx_runtime" ? {
acpxAgent: parseObject(runtimeConfig).acpxAgent as "pi" | "claude" | "codex",
} : {}),
model: typeof parseObject(agent.adapterConfig).model === "string"
? String(parseObject(agent.adapterConfig).model)
: managedProfile?.defaultModel ?? readNonEmptyString(agentCoreConfig.defaultModel) ?? null,
...(nativeRuntimeResolution.profile.backend === "claude_managed_agents_api" ? {
managedProfile: {
profileId: managedProfile!.id,
@@ -15991,7 +16076,30 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
?? managedProfile!.defaultMaxListCostCents / 100,
),
} : {}),
lifecyclePolicy: parseObject(runtimeConfig).lifecycleMode === "warm"
...(nativeRuntimeResolution.profile.backend === "aws_agentcore_harness_api" ? {
agentCoreProfile: {
profileId: agentCoreProfile!.id,
region: readNonEmptyString(agentCoreConfig.region) ?? "",
accountId: readNonEmptyString(agentCoreConfig.accountId) ?? "",
harnessArn: readNonEmptyString(agentCoreConfig.harnessArn) ?? "",
harnessVersion: readNonEmptyString(agentCoreConfig.harnessVersion) ?? "",
endpointArn: readNonEmptyString(agentCoreConfig.endpointArn) ?? "",
endpointQualifier: readNonEmptyString(agentCoreConfig.endpointQualifier) ?? "",
agentRuntimeArn: readNonEmptyString(agentCoreConfig.agentRuntimeArn) ?? "",
memoryArn: readNonEmptyString(agentCoreConfig.memoryArn) ?? "",
memoryId: readNonEmptyString(agentCoreConfig.memoryId) ?? "",
invocationRoleArn: readNonEmptyString(agentCoreConfig.invocationRoleArn) ?? "",
qualificationRevision: readNonEmptyString(agentCoreConfig.qualificationRevision) ?? "aws-agentcore-harness-v1",
eventExpiryDays: 90 as const,
},
maxEstimatedSessionCostUsd: Number(parseObject(agent.adapterConfig).maxEstimatedSessionCostUsd ?? agentCoreConfig.defaultMaxEstimatedSessionCostUsd ?? 1),
invocationLimits: {
maxIterations: Math.min(8, Number(parseObject(agent.adapterConfig).maxIterations ?? 8)),
maxOutputTokens: Math.min(4096, Number(parseObject(agent.adapterConfig).maxOutputTokens ?? 4096)),
timeoutSeconds: Math.min(300, Number(parseObject(agent.adapterConfig).timeoutSeconds ?? 300)),
},
} : {}),
lifecyclePolicy: parseObject(agent.adapterConfig).lifecycleMode === "warm"
? {
mode: "warm",
idleTimeoutMs: Number.isSafeInteger(parseObject(agent.adapterConfig).idleTimeoutMs)
@@ -16008,6 +16116,22 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
contract: completionContract.contract,
},
});
if (previousNativeRun && nativeSessionId === resumableTaskSessionId) {
nativeResumeCheckpoint = rebindNativeSessionCheckpoint({
previousRun: previousNativeRun,
currentExecution: nativeExecution,
});
if (!nativeResumeCheckpoint) {
nativeSessionId = randomUUID();
nativeExecution = parseNativeExecutionInput({
...nativeExecution,
session: {
...nativeExecution.session,
normalizedSessionId: nativeSessionId,
},
});
}
}
}
await db.transaction(async (tx) => {
const lockedRun = await tx.select().from(heartbeatRuns)
@@ -16017,6 +16141,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
if (lockedRun.runtimeModeResolvedAt && lockedRun.runtimeMode !== "native") {
throw new Error("native_runtime_mode_conflict");
}
const lockedProfile = parseObject(lockedRun.runnerProfileJson);
await tx.update(heartbeatRuns).set({
runtimeMode: "native",
runtimeModeResolverVersion: lockedRun.runtimeModeResolverVersion ?? nativeRuntimeResolution.resolverVersion,
@@ -16024,10 +16149,20 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
runtimeModeResolvedAt: lockedRun.runtimeModeResolvedAt ?? new Date(),
runnerProfileJson: {
...nativeRuntimeResolution.profile,
...parseObject(lockedRun.runnerProfileJson),
nativeExecutionInput: parseObject(lockedRun.runnerProfileJson).nativeExecutionInput ?? nativeExecution,
...lockedProfile,
nativeExecutionInput: lockedProfile.nativeExecutionInput ?? nativeExecution,
...(lockedProfile.sessionCheckpoint !== undefined
? { sessionCheckpoint: lockedProfile.sessionCheckpoint }
: nativeResumeCheckpoint
? { sessionCheckpoint: nativeResumeCheckpoint as unknown as Record<string, unknown> }
: {}),
},
runnerInstanceId: lockedRun.runnerInstanceId ?? nativeRunnerInstanceId,
runnerInstanceId:
previousNativeRun?.runnerInstanceId
&& lockedRun.nativeSessionId !== null
&& lockedRun.nativeSessionId === previousNativeRun.nativeSessionId
? previousNativeRun.runnerInstanceId
: lockedRun.runnerInstanceId ?? nativeRunnerInstanceId,
nativeSessionId: lockedRun.nativeSessionId ?? nativeSessionId,
driverKind: lockedRun.driverKind ?? nativeExecution?.session.driverKind ?? "codex_app_server",
driverVersion: lockedRun.driverVersion ?? "phase6-v1",
+5
View File
@@ -197,3 +197,8 @@ export {
CLAUDE_MANAGED_BETA_VERSION,
type ManagedAgentProfileInput,
} from "./managed-agent-profiles.js";
export {
remoteAgentProfileService,
type RemoteAgentProfileInput,
type RemoteAgentService,
} from "./remote-agent-profiles.js";
@@ -2,6 +2,7 @@ export * from "./runtime-mode.js";
export * from "./completion-contracts.js";
export * from "./native-execution-input.js";
export * from "./native-session-executor.js";
export * from "./native-session-resume.js";
export * from "./native-interaction-bridge.js";
export * from "./paperclip-control-plane-port.js";
export * from "./native-run-finalizer.js";
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more