mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
feat: add ACPx and AgentCore runner support
This commit is contained in:
1 parent
39c4fbe421
commit
b50eaea4bb
133 files changed
+56352
-488
No files matched your search
+1
-1
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"acpx": "0.12.0",
|
||||
"acpx": "0.13.1",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -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;
|
||||
+41419
File diff suppressed because it is too large.
Load diff
+7
@@ -1583,6 +1583,13 @@
|
||||
"when": 1787352614045,
|
||||
"tag": "0227_melodic_expediter",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 228,
|
||||
"version": "7",
|
||||
"when": 1787359498156,
|
||||
"tag": "0228_late_landau",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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')`),
|
||||
}));
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+1251
-37
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
+28
-1
@@ -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,
|
||||
|
||||
+52
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
Generated
+983
-119
File diff suppressed because it is too large.
Load diff
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in new issue
Block a user