feat: add ACPx and AgentCore runner support

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

No files matched your search

+265 -6
View File
@@ -4,7 +4,16 @@ import type {
ServerAdapterModule,
} from "./types.js";
import { execFile } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import {
AcpxRuntimeHost,
inspectQualifiedAcpxInstallation,
resolveQualifiedAcpxProfile,
type QualifiedAcpxAgent,
} from "../vendor/paperclip-runner/index.js";
import { parseAdapterModelsEnv } from "../services/adapter-models-env.js";
import { stampClaudeAgentIdHeader } from "./claude-agent-id-header.js";
import {
@@ -188,6 +197,113 @@ async function testPaperclipRunnerOpenCodeEnvironment(
}
}
async function testPaperclipRunnerAcpxEnvironment(
context: Parameters<typeof codexTestEnvironment>[0],
) {
const testedAt = new Date().toISOString();
const agent = context.config.acpxAgent;
const model = typeof context.config.model === "string" ? context.config.model.trim() : "";
if (context.config.permissionPolicy !== undefined && context.config.permissionPolicy !== "interactive") {
return {
adapterType: "paperclip_runner",
status: "fail" as const,
testedAt,
checks: [{ code: "acpx_permission_policy_invalid", level: "error" as const, message: "ACPX v1 requires permissionPolicy=interactive." }],
};
}
if (agent !== "pi" && agent !== "claude" && agent !== "codex") {
return {
adapterType: "paperclip_runner",
status: "fail" as const,
testedAt,
checks: [{ code: "acpx_agent_invalid", level: "error" as const, message: "ACPX agent must be Pi, Claude, or Codex." }],
};
}
let profile;
try {
profile = resolveQualifiedAcpxProfile(agent, model);
} catch (error) {
return {
adapterType: "paperclip_runner",
status: "fail" as const,
testedAt,
checks: [{ code: "acpx_model_unqualified", level: "error" as const, message: error instanceof Error ? error.message : "ACPX model is not qualified." }],
};
}
const nodeParts = process.versions.node.split(".").map(Number);
if ((nodeParts[0] ?? 0) < 22 || ((nodeParts[0] ?? 0) === 22 && (nodeParts[1] ?? 0) < 13)) {
return {
adapterType: "paperclip_runner",
status: "fail" as const,
testedAt,
checks: [{ code: "acpx_node_too_old", level: "error" as const, message: `ACPX 0.13.1 requires Node 22.13 or newer; found ${process.versions.node}.` }],
};
}
const configuredEnv = context.config.env && typeof context.config.env === "object"
? context.config.env as Record<string, unknown>
: {};
const environment = { ...process.env };
for (const [key, value] of Object.entries(configuredEnv)) if (typeof value === "string") environment[key] = value;
const credentialReady = agent === "pi"
? Boolean(environment.OPENROUTER_API_KEY)
: agent === "claude"
? Boolean(environment.ANTHROPIC_API_KEY || environment.CLAUDE_CODE_OAUTH_TOKEN)
: Boolean(environment.OPENAI_API_KEY || environment.CODEX_API_KEY);
if (!credentialReady) {
const required = agent === "pi"
? "OPENROUTER_API_KEY"
: agent === "claude"
? "ANTHROPIC_API_KEY or the managed Claude credential"
: "the managed Codex credential or OPENAI_API_KEY";
return {
adapterType: "paperclip_runner",
status: "fail" as const,
testedAt,
checks: [{ code: "acpx_auth_missing", level: "error" as const, message: `ACPX ${agent} requires ${required}.` }],
};
}
let probeRoot: string | null = null;
try {
const installation = await inspectQualifiedAcpxInstallation(agent as QualifiedAcpxAgent, model);
probeRoot = await mkdtemp(join(tmpdir(), "paperclip-acpx-probe-"));
const host = await AcpxRuntimeHost.open({
runtimeDirectory: probeRoot,
normalizedSessionId: "environment-probe",
workingDirectory: probeRoot,
agent: agent as QualifiedAcpxAgent,
model,
environment,
dynamicTools: [],
dynamicToolHandler: async () => { throw new Error("environment probe exposes no semantic tools"); },
});
const identity = host.identity();
await host.close({ reason: "environment probe complete", discardPersistentState: true });
return {
adapterType: "paperclip_runner",
status: "pass" as const,
testedAt,
checks: [{
code: "acpx_profile_qualified",
level: "info" as const,
message: `Verified ACPX ${profile.acpxVersion}, ${profile.agentServerPackage}@${profile.agentServerVersion}, exact model ${identity.effectiveModel}, semantic bridge startup, and clean shutdown (digest ${installation.commandDigest}).`,
}, {
code: "acpx_auth_ready",
level: "info" as const,
message: `Credential readiness was verified for ACPX ${agent}; no credential value was retained.`,
}],
};
} catch (error) {
return {
adapterType: "paperclip_runner",
status: "fail" as const,
testedAt,
checks: [{ code: "acpx_handshake_failed", level: "error" as const, message: error instanceof Error ? error.message : "ACPX handshake failed." }],
};
} finally {
if (probeRoot) await rm(probeRoot, { recursive: true, force: true });
}
}
async function testPaperclipRunnerClaudeManagedEnvironment(
context: Parameters<typeof codexTestEnvironment>[0],
) {
@@ -337,6 +453,68 @@ async function testPaperclipRunnerClaudeManagedEnvironment(
}
}
async function testPaperclipRunnerAwsAgentCoreEnvironment(
context: Parameters<typeof codexTestEnvironment>[0],
) {
const testedAt = new Date().toISOString();
const required = [
["agentCoreProfileId", "AgentCore profile ID"], ["awsRegion", "AWS region"],
["harnessId", "Harness ID"], ["harnessVersion", "Harness version"],
["endpointQualifier", "endpoint qualifier"], ["memoryId", "Memory ID"],
["invocationRoleArn", "invocation role ARN"], ["model", "Bedrock model"],
] as const;
const missing = required.filter(([key]) => typeof context.config[key] !== "string" || String(context.config[key]).trim().length === 0);
if (missing.length > 0 || context.config.agentCoreRetentionAcknowledged !== true) {
return {
adapterType: "paperclip_runner", status: "fail" as const, testedAt,
checks: [{ code: "aws_agentcore_profile_incomplete", level: "error" as const,
message: missing.length > 0
? `AWS AgentCore profile is missing: ${missing.map(([, label]) => label).join(", ")}.`
: "AWS AgentCore requires an administrator acknowledgement of 90-day Memory retention." }],
};
}
const cap = Number(context.config.maxEstimatedSessionCostUsd);
if (!Number.isFinite(cap) || cap <= 0) {
return { adapterType: "paperclip_runner", status: "fail" as const, testedAt,
checks: [{ code: "aws_agentcore_estimated_cap_invalid", level: "error" as const, message: "AWS AgentCore requires a positive estimated session ceiling." }] };
}
const sourceEnvironment = context.config.env && typeof context.config.env === "object"
? { ...process.env, ...(context.config.env as Record<string, string>) }
: process.env;
const region = String(context.config.awsRegion);
try {
const version = await execFileAsync("aws", ["--version"], { timeout: 5_000, env: sourceEnvironment });
if (!`${version.stdout}${version.stderr}`.includes("aws-cli/2.")) throw new Error("AWS CLI v2 is required");
const assumed = await execFileAsync("aws", ["--region", region, "sts", "assume-role", "--role-arn", String(context.config.invocationRoleArn), "--role-session-name", "paperclip-environment-probe", "--duration-seconds", "900", "--output", "json"], { timeout: 15_000, env: sourceEnvironment });
const credentials = (JSON.parse(assumed.stdout) as { Credentials?: { AccessKeyId?: string; SecretAccessKey?: string; SessionToken?: string } }).Credentials;
if (!credentials?.AccessKeyId || !credentials.SecretAccessKey || !credentials.SessionToken) throw new Error("STS omitted temporary credentials");
const env = {
...sourceEnvironment,
AWS_ACCESS_KEY_ID: credentials.AccessKeyId,
AWS_SECRET_ACCESS_KEY: credentials.SecretAccessKey,
AWS_SESSION_TOKEN: credentials.SessionToken,
AWS_REGION: region,
AWS_DEFAULT_REGION: region,
AWS_PROFILE: undefined,
};
await Promise.all([
execFileAsync("aws", ["--region", region, "bedrock-agentcore-control", "get-harness", "--harness-id", String(context.config.harnessId), "--harness-version", String(context.config.harnessVersion)], { timeout: 15_000, env }),
execFileAsync("aws", ["--region", region, "bedrock-agentcore-control", "get-harness-endpoint", "--harness-id", String(context.config.harnessId), "--endpoint-name", String(context.config.endpointQualifier)], { timeout: 15_000, env }),
execFileAsync("aws", ["--region", region, "bedrock-agentcore-control", "get-memory", "--memory-id", String(context.config.memoryId), "--view", "full"], { timeout: 15_000, env }),
]);
return {
adapterType: "paperclip_runner", status: "warn" as const, testedAt,
checks: [
{ code: "aws_agentcore_profile_qualified", level: "info" as const, message: `Verified pinned Harness ${String(context.config.harnessId)} version ${String(context.config.harnessVersion)} and Memory ${String(context.config.memoryId)} without creating a paid session.` },
{ code: "aws_agentcore_retention_notice", level: "warn" as const, message: "AgentCore Memory retains short-term events for 90 days; the session cost shown by Paperclip is an estimate, not an AWS currency hard stop." },
],
};
} catch {
return { adapterType: "paperclip_runner", status: "fail" as const, testedAt,
checks: [{ code: "aws_agentcore_probe_failed", level: "error" as const, message: "AWS AgentCore read-only qualification failed. Verify AWS CLI v2, the source profile, sts:AssumeRole, region, Harness endpoint, and Memory identifiers." }] };
}
}
function compareSemver(left: string, right: string): number {
const a = left.split(".").map(Number);
const b = right.split(".").map(Number);
@@ -553,18 +731,32 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
timedOut: false,
errorMessage: message,
errorCode: "paperclip_runner_coordinator_required",
provider: "codex",
provider: ctx.config.provider === "acpx"
? "acpx"
: ctx.config.provider === "opencode"
? "opencode"
: ctx.config.provider === "claude_managed"
? "anthropic"
: ctx.config.provider === "aws_agentcore"
? "amazon-bedrock"
: "codex",
summary: message,
};
},
async testEnvironment(context) {
const provider = context.config.provider === "opencode"
? "opencode"
: context.config.provider === "claude_managed" ? "claude_managed" : "codex";
: context.config.provider === "claude_managed" ? "claude_managed"
: context.config.provider === "aws_agentcore" ? "aws_agentcore"
: context.config.provider === "acpx" ? "acpx" : "codex";
const result = provider === "claude_managed"
? await testPaperclipRunnerClaudeManagedEnvironment(context)
: provider === "aws_agentcore"
? await testPaperclipRunnerAwsAgentCoreEnvironment(context)
: provider === "opencode"
? await testPaperclipRunnerOpenCodeEnvironment(context)
: provider === "acpx"
? await testPaperclipRunnerAcpxEnvironment(context)
: await codexTestEnvironment(context);
return { ...result, adapterType: "paperclip_runner" };
},
@@ -575,6 +767,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
...codexModels,
{ id: "openrouter/deepseek/deepseek-v4-flash-0731", label: "OpenRouter · DeepSeek V4 Flash 0731" },
{ id: "claude-sonnet-5", label: "Claude Sonnet 5" },
{ id: "global.anthropic.claude-sonnet-4-6", label: "Amazon Bedrock · Claude Sonnet 4.6 (global)" },
],
modelProfiles: codexModelProfiles,
listModels: listCodexModels,
@@ -583,12 +776,12 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
supportsInstructionsBundle: true,
instructionsPathKey: "instructionsFilePath",
requiresMaterializedRuntimeSkills: false,
getRuntimeCommandSpec: (config) => config.provider === "claude_managed"
getRuntimeCommandSpec: (config) => config.provider === "claude_managed" || config.provider === "aws_agentcore" || config.provider === "acpx"
? { command: "paperclip-runnerd", detectCommand: null, installCommand: null }
: config.provider === "opencode"
? buildNpmRuntimeCommandSpec(config, "opencode", "opencode-ai@1.18.17")
: buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex"),
agentConfigurationDoc: `# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, or a remote Claude Agent through the native Paperclip runner and authenticated PRP control-plane connection. Claude Agent credentials remain in runnerd; Anthropic never receives a Paperclip credential or endpoint.\n`,
: buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex@0.148.0"),
agentConfigurationDoc: `# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns native Codex, OpenCode, Claude Managed, AWS AgentCore, or a qualified Pi/Claude/Codex ACP agent through ACPX 0.13.1 and authenticated PRP. Provider processes never receive a Paperclip credential or unrestricted server environment.\n`,
getConfigSchema: () => ({
fields: [{
key: "provider",
@@ -599,8 +792,29 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
{ value: "codex", label: "Codex" },
{ value: "opencode", label: "OpenCode 1.18.17" },
{ value: "claude_managed", label: "Claude Agent" },
{ value: "aws_agentcore", label: "AWS AgentCore" },
{ value: "acpx", label: "ACPX" },
],
hint: "Select a local harness or an Anthropic cloud-managed Claude Agent.",
hint: "Select a local harness, ACPX agent, Anthropic Claude Agent, or AWS AgentCore Harness.",
}, {
key: "acpxAgent",
label: "ACP agent",
type: "select",
default: "pi",
options: [
{ value: "pi", label: "Pi via ACPX" },
{ value: "claude", label: "Claude via ACPX" },
{ value: "codex", label: "Codex via ACPX (control)" },
],
hint: "Qualified ACP server profile. Configuration is immutable after session creation.",
meta: { provider: "acpx" },
}, {
key: "permissionPolicy",
label: "Permission policy",
type: "text",
default: "interactive",
hint: "ACPX v1 supports the interactive policy only.",
meta: { provider: "acpx", readOnly: true },
}, {
key: "lifecycleMode",
label: "Runner lifecycle",
@@ -649,6 +863,51 @@ const paperclipRunnerAdapter: ServerAdapterModule = {
}, {
key: "managedAgentsRetentionAcknowledged", label: "Acknowledge beta retention", type: "toggle", default: false,
hint: "Managed Agents is stateful and is not eligible for ZDR or HIPAA modes.", meta: { provider: "claude_managed" },
}, {
key: "agentCoreProfileId", label: "AgentCore profile", type: "text", required: true,
hint: "Company-scoped provisioned profile identifier.", meta: { provider: "aws_agentcore" },
}, {
key: "awsRegion", label: "AWS region", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "awsAccountId", label: "AWS account ID", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "harnessArn", label: "Harness ARN", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "harnessId", label: "Harness ID", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "harnessVersion", label: "Pinned Harness version", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "endpointQualifier", label: "Harness endpoint", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "endpointArn", label: "Harness endpoint ARN", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "agentRuntimeArn", label: "Underlying Runtime ARN", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "memoryId", label: "AgentCore Memory ID", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "memoryArn", label: "AgentCore Memory ARN", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "invocationRoleArn", label: "Invocation role ARN", type: "text", required: true,
meta: { provider: "aws_agentcore" },
}, {
key: "maxEstimatedSessionCostUsd", label: "Estimated session ceiling (USD)", type: "number", default: 1,
hint: "Paperclip estimate; AWS has no per-session currency hard stop.", meta: { provider: "aws_agentcore" },
}, {
key: "qualificationRevision", label: "Qualification revision", type: "text", default: "aws-agentcore-harness-v1",
meta: { provider: "aws_agentcore" },
}, {
key: "agentCoreRetentionAcknowledged", label: "Acknowledge 90-day Memory retention", type: "toggle", default: false,
hint: "AgentCore short-term Memory retains events for 90 days.", meta: { provider: "aws_agentcore" },
}],
}),
loginCapability: codexLoginCapability,
+2
View File
@@ -83,6 +83,7 @@ import { pluginRoutes } from "./routes/plugins.js";
import { mcpGatewayProtocolRoutes, toolGatewayRoutes } from "./routes/tool-gateway.js";
import { adapterRoutes } from "./routes/adapters.js";
import { managedAgentProfileRoutes } from "./routes/managed-agent-profiles.js";
import { remoteAgentProfileRoutes } from "./routes/remote-agent-profiles.js";
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
import { readBrandedStaticIndexHtml } from "./static-index-html.js";
import { applyUiBranding } from "./ui-branding.js";
@@ -524,6 +525,7 @@ export async function createApp(
api.use(approvalRoutes(db, { pluginWorkerManager: workerManager }));
api.use(secretRoutes(db));
api.use(managedAgentProfileRoutes(db));
api.use(remoteAgentProfileRoutes(db));
const trustedLocalStdioRuntimeHost =
process.env.PAPERCLIP_TRUSTED_MCP_RUNTIME_HOST
?? process.env.PAPERCLIP_TOOL_RUNTIME_TRUSTED_HOST
+38
View File
@@ -9,7 +9,9 @@ import {
type DurablePrpControlPlane,
} from "../vendor/paperclip-runner/index.js";
import {
queueRunnerPrpRuntimeRequestResolution,
registerRunnerPrpAuthority,
RunnerPrpRuntimeRequestResolutionError,
runnerPrpWebSocketInternals,
setupRunnerPrpWebSocketServer,
} from "./runner-prp-ws.js";
@@ -57,6 +59,42 @@ describe("runner PRP websocket route", () => {
server.close();
});
it("queues one idempotent, turn-bound runtime request resolution", async () => {
const server = createServer();
setupRunnerPrpWebSocketServer(server, { port: 3212 });
const queueCommand = vi.fn(() => ({ commandId: "command-resolution-1" }));
const runId = "00000000-0000-4000-8000-000000000780";
const registration = await registerRunnerPrpAuthority({
runId,
authority: { queueCommand } as unknown as DurablePrpControlPlane,
});
const input = {
runId,
requestId: "request-1",
turnId: "turn-1",
resolution: { action: "accept" as const },
};
expect(queueRunnerPrpRuntimeRequestResolution(input)).toEqual({ commandId: "command-resolution-1" });
expect(queueRunnerPrpRuntimeRequestResolution(input)).toEqual({ commandId: "command-resolution-1" });
expect(queueCommand).toHaveBeenCalledTimes(1);
expect(queueCommand).toHaveBeenCalledWith("request.resolve", {
requestId: "request-1",
turnId: "turn-1",
resolution: { action: "accept" },
}, undefined, true);
expect(() => queueRunnerPrpRuntimeRequestResolution({
...input,
resolution: { action: "decline" },
})).toThrowError(RunnerPrpRuntimeRequestResolutionError);
await registration.release();
expect(() => queueRunnerPrpRuntimeRequestResolution(input)).toThrowError(
"runner_prp_authority_not_active",
);
server.close();
});
it("carries an authorized runnerd tool call over the shared Paperclip route", async () => {
const server = createServer();
await new Promise<void>((resolveListen) => server.listen(0, "127.0.0.1", resolveListen));
+55 -2
View File
@@ -1,6 +1,9 @@
import type { IncomingMessage, Server } from "node:http";
import type { Duplex } from "node:stream";
import type { DurablePrpControlPlane } from "../vendor/paperclip-runner/index.js";
import type {
DurablePrpControlPlane,
HarnessRuntimeRequestResolution,
} from "../vendor/paperclip-runner/index.js";
import { logger } from "../middleware/logger.js";
const CONNECT_PATH_PREFIX = "/api/runner/v1/connect/";
@@ -8,6 +11,7 @@ const CONNECT_PATH_PREFIX = "/api/runner/v1/connect/";
type Registration = {
authority: DurablePrpControlPlane;
generation: symbol;
runtimeRequestResolutions: Map<string, { fingerprint: string; commandId: string }>;
};
const registrations = new Map<string, Registration>();
@@ -56,7 +60,11 @@ export async function registerRunnerPrpAuthority(input: {
if (loopbackOrigin === null) throw new Error("runner_prp_websocket_server_not_configured");
if (registrations.has(input.runId)) throw new Error("runner_prp_authority_already_registered");
const generation = Symbol(input.runId);
registrations.set(input.runId, { authority: input.authority, generation });
registrations.set(input.runId, {
authority: input.authority,
generation,
runtimeRequestResolutions: new Map(),
});
return {
connectUrl: `${loopbackOrigin}${CONNECT_PATH_PREFIX}${input.runId}`,
release: async () => {
@@ -65,6 +73,51 @@ export async function registerRunnerPrpAuthority(input: {
};
}
export class RunnerPrpRuntimeRequestResolutionError extends Error {
constructor(
readonly code: "runner_prp_authority_not_active" | "runtime_request_resolution_conflict",
) {
super(code);
this.name = "RunnerPrpRuntimeRequestResolutionError";
}
}
/**
* Queues one turn-bound runtime response on the active durable PRP authority.
* Replayed identical browser submissions return the original command instead
* of answering the provider twice; a different answer for the same request
* fails closed.
*/
export function queueRunnerPrpRuntimeRequestResolution(input: {
runId: string;
requestId: string;
turnId: string;
resolution: HarnessRuntimeRequestResolution;
}): { commandId: string } {
const registration = registrations.get(input.runId);
if (!registration) {
throw new RunnerPrpRuntimeRequestResolutionError("runner_prp_authority_not_active");
}
const fingerprint = JSON.stringify({ turnId: input.turnId, resolution: input.resolution });
const previous = registration.runtimeRequestResolutions.get(input.requestId);
if (previous) {
if (previous.fingerprint !== fingerprint) {
throw new RunnerPrpRuntimeRequestResolutionError("runtime_request_resolution_conflict");
}
return { commandId: previous.commandId };
}
const command = registration.authority.queueCommand("request.resolve", {
requestId: input.requestId,
turnId: input.turnId,
resolution: input.resolution,
}, undefined, true);
registration.runtimeRequestResolutions.set(input.requestId, {
fingerprint,
commandId: command.commandId,
});
return { commandId: command.commandId };
}
export const runnerPrpWebSocketInternals = {
connectPathPrefix: CONNECT_PATH_PREFIX,
resetForTests(): void {
+74
View File
@@ -94,6 +94,16 @@ import {
} from "../adapters/index.js";
import { redactEventPayload } from "../redaction.js";
import { redactCurrentUserValue } from "../log-redaction.js";
import {
HarnessRuntimeRequestResolutionError,
parseHarnessRuntimeRequestResolution,
type HarnessRuntimeRequestKind,
type HarnessRuntimeRequestResolution,
} from "../vendor/paperclip-runner/index.js";
import {
queueRunnerPrpRuntimeRequestResolution,
RunnerPrpRuntimeRequestResolutionError,
} from "../realtime/runner-prp-ws.js";
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js";
import {
instanceSettingsService,
@@ -5015,6 +5025,70 @@ export function agentRoutes(
res.json(run);
});
router.post("/heartbeat-runs/:runId/runtime-requests/:requestId/resolve", async (req, res) => {
assertBoard(req);
const runId = req.params.runId as string;
const requestId = req.params.requestId as string;
const existing = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found");
if (!existing) return;
if (existing.runtimeMode !== "native" || existing.status !== "running") {
throw conflict("This runner session is no longer accepting runtime responses.");
}
const turnId = typeof req.body?.turnId === "string" ? req.body.turnId.trim() : "";
const rawRequestKind = typeof req.body?.requestKind === "string" ? req.body.requestKind : "";
const runtimeRequestKinds: readonly HarnessRuntimeRequestKind[] = [
"command_approval",
"file_approval",
"permission_approval",
"user_input",
"elicitation",
];
if (!requestId || requestId.length > 160 || !turnId || turnId.length > 160) {
throw badRequest("Runtime request and turn identifiers are required.");
}
if (!runtimeRequestKinds.includes(rawRequestKind as HarnessRuntimeRequestKind)) {
throw badRequest("Unsupported runtime request kind.");
}
let resolution: HarnessRuntimeRequestResolution;
try {
resolution = parseHarnessRuntimeRequestResolution(
rawRequestKind as HarnessRuntimeRequestKind,
req.body?.resolution,
);
} catch (error) {
if (error instanceof HarnessRuntimeRequestResolutionError) {
throw badRequest("Invalid runtime request response.");
}
throw error;
}
try {
const queued = queueRunnerPrpRuntimeRequestResolution({
runId,
requestId,
turnId,
resolution,
});
await logActivity(db, {
companyId: existing.companyId,
actorType: "user",
actorId: req.actor.userId ?? "board",
action: "heartbeat.runtime_request_resolution_queued",
entityType: "heartbeat_run",
entityId: existing.id,
details: { requestId, action: resolution.action },
});
res.status(202).json({ accepted: true, commandId: queued.commandId });
} catch (error) {
if (error instanceof RunnerPrpRuntimeRequestResolutionError) {
throw conflict(error.code === "runtime_request_resolution_conflict"
? "A different response was already submitted for this runtime request."
: "The runner session is no longer accepting runtime responses.");
}
throw error;
}
});
router.post("/heartbeat-runs/:runId/watchdog-decisions", async (req, res) => {
const runId = req.params.runId as string;
const existing = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found");
+1
View File
@@ -40,3 +40,4 @@ export { accessRoutes } from "./access.js";
export { instanceSettingsRoutes } from "./instance-settings.js";
export { instanceDatabaseBackupRoutes } from "./instance-database-backups.js";
export { managedAgentProfileRoutes } from "./managed-agent-profiles.js";
export { remoteAgentProfileRoutes } from "./remote-agent-profiles.js";
+20
View File
@@ -4447,6 +4447,26 @@ registry.registerPath({
responses: { 200: r.ok(), 401: r.unauthorized },
});
registry.registerPath({
method: "post",
path: "/api/heartbeat-runs/{runId}/runtime-requests/{requestId}/resolve",
tags: ["runs"],
summary: "Resolve a pending Paperclip runner runtime request",
request: {
params: z.object({ runId: z.string(), requestId: z.string() }),
body: jsonBody(z.object({
turnId: z.string().min(1).max(160),
requestKind: z.enum(["command_approval", "file_approval", "permission_approval", "user_input", "elicitation"]),
resolution: z.union([
z.object({ action: z.enum(["accept", "accept_for_session", "decline", "cancel"]) }),
z.object({ action: z.literal("submit"), answers: z.record(z.string(), z.object({ answers: z.array(z.string()) })) }),
z.object({ action: z.literal("submit"), content: z.record(z.string(), z.unknown()) }),
]),
})),
},
responses: { 202: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound, 409: r.conflict },
});
registry.registerPath({
method: "post",
path: "/api/heartbeat-runs/{runId}/watchdog-decisions",
@@ -0,0 +1,46 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { remoteAgentProfileService, type RemoteAgentProfileInput, type RemoteAgentService } from "../services/remote-agent-profiles.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { logActivity } from "../services/activity-log.js";
import { unprocessable } from "../errors.js";
function profileInput(value: unknown): RemoteAgentProfileInput {
if (!value || typeof value !== "object") throw unprocessable("Remote Agent profile body is required");
const body = value as Record<string, unknown>;
const service = body.service;
if (service !== "anthropic_managed_agents" && service !== "aws_bedrock_agentcore_harness") throw unprocessable("Unsupported remote agent service");
return {
profileKey: String(body.profileKey ?? ""), displayName: String(body.displayName ?? ""),
service: service as RemoteAgentService,
configuration: body.configuration && typeof body.configuration === "object" && !Array.isArray(body.configuration) ? body.configuration as Record<string, unknown> : {},
credentialSecretId: typeof body.credentialSecretId === "string" ? body.credentialSecretId : null,
enabled: body.enabled === true, retentionAcknowledged: body.retentionAcknowledged === true,
qualification: body.qualification && typeof body.qualification === "object" && !Array.isArray(body.qualification) ? body.qualification as Record<string, unknown> : {},
};
}
export function remoteAgentProfileRoutes(db: Db) {
const router = Router();
const profiles = remoteAgentProfileService(db);
router.get("/companies/:companyId/remote-agent-profiles", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const service = typeof req.query.service === "string" ? req.query.service as RemoteAgentService : undefined;
res.json(await profiles.list(companyId, service));
});
router.post("/companies/:companyId/remote-agent-profiles", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const profile = await profiles.upsert(companyId, profileInput(req.body));
const actor = getActorInfo(req);
await logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId,
action: "remote_agent_profile.qualified", entityType: "remote_agent_profile", entityId: profile.id,
details: { profileKey: profile.profileKey, service: profile.service, enabled: profile.enabled, retentionAcknowledged: profile.retentionAcknowledged } });
res.status(201).json(profile);
});
return router;
}
+150 -15
View File
@@ -83,6 +83,7 @@ import {
// git-credentials module became its canonical home; existing importers keep working.
export { scrubGitCredentialText };
import { publishLiveEvent } from "./live-events.js";
import { documentService } from "./documents.js";
import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js";
import {
buildNativeProviderEnvironment,
@@ -92,13 +93,15 @@ import {
ensureNativeCompletionContract,
executePaperclipNativeSession,
finalizeNativeRun,
isNativeSessionId,
materializeNativeInteractionResponses,
rebindNativeSessionCheckpoint,
reconcileNativeFinalizations,
resolveHeartbeatNativeRuntimeMode,
} from "./native-runtime/index.js";
import {
parseNativeExecutionInput,
type NativeExecutionInputV1,
type NativeExecutionInput,
type NativeSessionBackend,
} from "../vendor/paperclip-runner/index.js";
import { normalizeResponsibleUserDenialCode } from "./responsible-user-denial-run-outcomes.js";
@@ -305,6 +308,7 @@ import { environmentService } from "./environments.js";
import { parseExecutionPolicyBootstrapEnv } from "./execution-policy-bootstrap.js";
import { environmentRuntimeService } from "./environment-runtime.js";
import { managedAgentProfileService } from "./managed-agent-profiles.js";
import { remoteAgentProfileService } from "./remote-agent-profiles.js";
import { skillVersionSelectionMap } from "./runtime-skill-selections.js";
import { environmentRunOrchestrator } from "./environment-run-orchestrator.js";
import { isUnsafeSessionWorkspaceCwd } from "./session-workspace-cwd.js";
@@ -6677,7 +6681,7 @@ export interface HeartbeatServiceOptions {
* the seam here exercises the production reaper, claim, execution, package
* session loop, persistence port, and finalizer without spawning a provider.
*/
nativeSessionBackendFactory?: (execution: NativeExecutionInputV1) => NativeSessionBackend;
nativeSessionBackendFactory?: (execution: NativeExecutionInput) => NativeSessionBackend;
}
type WorkspaceReadyCommentWriter = {
@@ -13607,9 +13611,21 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
if (now.getTime() - refTime < staleThresholdMs) continue;
}
const tracksLocalChild = isTrackedLocalChildProcessAdapter(adapterType);
const processPidAlive = tracksLocalChild && run.processPid && isProcessAlive(run.processPid);
const processGroupAlive = tracksLocalChild && run.processGroupId && isProcessGroupAlive(run.processGroupId);
const nativeLocalChild = run.runtimeMode === "native";
const tracksLocalChild = nativeLocalChild || isTrackedLocalChildProcessAdapter(adapterType);
let processPidAlive = tracksLocalChild && run.processPid && isProcessAlive(run.processPid);
let processGroupAlive = tracksLocalChild && run.processGroupId && isProcessGroupAlive(run.processGroupId);
if (nativeLocalChild && (processPidAlive || processGroupAlive)) {
// A native runner's reconnect URL is bound to the old run authority.
// If startup resumption did not adopt it above, leaving it alive makes
// the next run fight an orphan for the same provider thread writer.
await terminateHeartbeatRunProcess({
pid: run.processPid,
processGroupId: run.processGroupId,
});
processPidAlive = false;
processGroupAlive = false;
}
if (
(processPidAlive || processGroupAlive) &&
readHotRestartAdoptionMetadata(parseObject(run.resultJson))
@@ -15876,7 +15892,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
target: executionTarget,
workspaceId: persistedExecutionWorkspace?.id ?? null,
});
let nativeExecution: ReturnType<typeof buildNativeExecutionInput> | null = null;
let nativeExecution: NativeExecutionInput | null = null;
let nativeRunnerInstanceId: string | null = null;
if (nativeRuntimeResolution.kind === "native") {
if (!issueRef) {
@@ -15902,8 +15918,35 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
actorId: agent.id,
immediateRequest: safeWakeCommentContext?.body ?? null,
});
nativeRunnerInstanceId = run.runnerInstanceId ?? randomUUID();
const nativeSessionId = run.nativeSessionId ?? randomUUID();
const taskNativeSessionId = readNonEmptyString(taskSessionDecodedParams?.sessionId);
const resumableTaskSessionId =
taskSessionForRun?.lastRunId
&& taskSessionForRun.lastRunId !== run.id
&& isNativeSessionId(taskNativeSessionId)
? taskNativeSessionId
: null;
const previousNativeRun = resumableTaskSessionId && taskSessionForRun?.lastRunId
? await db.select({
id: heartbeatRuns.id,
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
runnerInstanceId: heartbeatRuns.runnerInstanceId,
nativeSessionId: heartbeatRuns.nativeSessionId,
runnerProfileJson: heartbeatRuns.runnerProfileJson,
}).from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, taskSessionForRun.lastRunId),
eq(heartbeatRuns.companyId, agent.companyId),
eq(heartbeatRuns.agentId, agent.id),
eq(heartbeatRuns.nativeSessionId, resumableTaskSessionId),
)).limit(1).then((rows) => rows[0] ?? null)
: null;
nativeRunnerInstanceId =
previousNativeRun?.runnerInstanceId
&& previousNativeRun.nativeSessionId === (run.nativeSessionId ?? resumableTaskSessionId)
? previousNativeRun.runnerInstanceId
: run.runnerInstanceId ?? randomUUID();
let nativeSessionId = run.nativeSessionId ?? resumableTaskSessionId ?? randomUUID();
let nativeResumeCheckpoint: ReturnType<typeof rebindNativeSessionCheckpoint> = null;
const persistedProfile = persistedRunnerProfile;
if (persistedNativeExecutionInput) {
nativeExecution = persistedNativeExecutionInput;
@@ -15932,6 +15975,14 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
readNonEmptyString(parseObject(runtimeConfig).managedProfileId) ?? "",
)
: null;
const agentCoreProfile = nativeRuntimeResolution.profile.backend === "aws_agentcore_harness_api"
? await remoteAgentProfileService(db).requireQualified(
agent.companyId,
readNonEmptyString(parseObject(agent.adapterConfig).agentCoreProfileId) ?? "",
"aws_bedrock_agentcore_harness",
)
: null;
const agentCoreConfig = parseObject(agentCoreProfile?.configuration);
if (managedProfile) {
const rawApiKeyBinding = parseObject(parseObject(agent.adapterConfig).env).ANTHROPIC_API_KEY;
const boundSecretId = typeof rawApiKeyBinding === "object" && rawApiKeyBinding !== null
@@ -15952,6 +16003,22 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
);
}
}
const executionMode = issueRef.workMode === "planning" && !acceptedPlanContinuationWake
? "plan" as const
: "default" as const;
const pinnedPlan = executionMode === "plan"
? await documentService(db).getIssueDocumentByKey(issueRef.id, "plan")
: null;
const pinnedReviewContext = executionMode === "plan"
? await buildPlanReviewContext({
db,
companyId: agent.companyId,
issueId: issueRef.id,
issueWorkMode: issueRef.workMode,
interactionId: readNonEmptyString(context.interactionId),
})
: null;
const pinnedPlanMarkdown = pinnedPlan?.body ?? "";
nativeExecution = buildNativeExecutionInput({
companyId: agent.companyId,
runId: run.id,
@@ -15970,14 +16037,32 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
branchName: executionWorkspace.branchName,
},
normalizedSessionId: nativeSessionId,
executionMode,
planningContext: executionMode === "plan" ? {
documentId: pinnedPlan?.id ?? null,
baseRevisionId: pinnedPlan?.latestRevisionId ?? null,
baseRevisionNumber: pinnedPlan?.latestRevisionNumber ?? 0,
markdown: pinnedPlanMarkdown,
sha256: createHash("sha256").update(pinnedPlanMarkdown).digest("hex"),
reviewContext: pinnedReviewContext
? structuredClone(pinnedReviewContext) as unknown as Record<string, unknown>
: {},
} : null,
provider: nativeRuntimeResolution.profile.backend === "opencode_server"
? "opencode"
: nativeRuntimeResolution.profile.backend === "claude_managed_agents_api"
? "claude_managed"
: nativeRuntimeResolution.profile.backend === "aws_agentcore_harness_api"
? "aws_agentcore"
: nativeRuntimeResolution.profile.backend === "acpx_runtime"
? "acpx"
: "codex",
model: typeof parseObject(runtimeConfig).model === "string"
? String(parseObject(runtimeConfig).model)
: managedProfile?.defaultModel ?? null,
...(nativeRuntimeResolution.profile.backend === "acpx_runtime" ? {
acpxAgent: parseObject(runtimeConfig).acpxAgent as "pi" | "claude" | "codex",
} : {}),
model: typeof parseObject(agent.adapterConfig).model === "string"
? String(parseObject(agent.adapterConfig).model)
: managedProfile?.defaultModel ?? readNonEmptyString(agentCoreConfig.defaultModel) ?? null,
...(nativeRuntimeResolution.profile.backend === "claude_managed_agents_api" ? {
managedProfile: {
profileId: managedProfile!.id,
@@ -15991,7 +16076,30 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
?? managedProfile!.defaultMaxListCostCents / 100,
),
} : {}),
lifecyclePolicy: parseObject(runtimeConfig).lifecycleMode === "warm"
...(nativeRuntimeResolution.profile.backend === "aws_agentcore_harness_api" ? {
agentCoreProfile: {
profileId: agentCoreProfile!.id,
region: readNonEmptyString(agentCoreConfig.region) ?? "",
accountId: readNonEmptyString(agentCoreConfig.accountId) ?? "",
harnessArn: readNonEmptyString(agentCoreConfig.harnessArn) ?? "",
harnessVersion: readNonEmptyString(agentCoreConfig.harnessVersion) ?? "",
endpointArn: readNonEmptyString(agentCoreConfig.endpointArn) ?? "",
endpointQualifier: readNonEmptyString(agentCoreConfig.endpointQualifier) ?? "",
agentRuntimeArn: readNonEmptyString(agentCoreConfig.agentRuntimeArn) ?? "",
memoryArn: readNonEmptyString(agentCoreConfig.memoryArn) ?? "",
memoryId: readNonEmptyString(agentCoreConfig.memoryId) ?? "",
invocationRoleArn: readNonEmptyString(agentCoreConfig.invocationRoleArn) ?? "",
qualificationRevision: readNonEmptyString(agentCoreConfig.qualificationRevision) ?? "aws-agentcore-harness-v1",
eventExpiryDays: 90 as const,
},
maxEstimatedSessionCostUsd: Number(parseObject(agent.adapterConfig).maxEstimatedSessionCostUsd ?? agentCoreConfig.defaultMaxEstimatedSessionCostUsd ?? 1),
invocationLimits: {
maxIterations: Math.min(8, Number(parseObject(agent.adapterConfig).maxIterations ?? 8)),
maxOutputTokens: Math.min(4096, Number(parseObject(agent.adapterConfig).maxOutputTokens ?? 4096)),
timeoutSeconds: Math.min(300, Number(parseObject(agent.adapterConfig).timeoutSeconds ?? 300)),
},
} : {}),
lifecyclePolicy: parseObject(agent.adapterConfig).lifecycleMode === "warm"
? {
mode: "warm",
idleTimeoutMs: Number.isSafeInteger(parseObject(agent.adapterConfig).idleTimeoutMs)
@@ -16008,6 +16116,22 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
contract: completionContract.contract,
},
});
if (previousNativeRun && nativeSessionId === resumableTaskSessionId) {
nativeResumeCheckpoint = rebindNativeSessionCheckpoint({
previousRun: previousNativeRun,
currentExecution: nativeExecution,
});
if (!nativeResumeCheckpoint) {
nativeSessionId = randomUUID();
nativeExecution = parseNativeExecutionInput({
...nativeExecution,
session: {
...nativeExecution.session,
normalizedSessionId: nativeSessionId,
},
});
}
}
}
await db.transaction(async (tx) => {
const lockedRun = await tx.select().from(heartbeatRuns)
@@ -16017,6 +16141,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
if (lockedRun.runtimeModeResolvedAt && lockedRun.runtimeMode !== "native") {
throw new Error("native_runtime_mode_conflict");
}
const lockedProfile = parseObject(lockedRun.runnerProfileJson);
await tx.update(heartbeatRuns).set({
runtimeMode: "native",
runtimeModeResolverVersion: lockedRun.runtimeModeResolverVersion ?? nativeRuntimeResolution.resolverVersion,
@@ -16024,10 +16149,20 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
runtimeModeResolvedAt: lockedRun.runtimeModeResolvedAt ?? new Date(),
runnerProfileJson: {
...nativeRuntimeResolution.profile,
...parseObject(lockedRun.runnerProfileJson),
nativeExecutionInput: parseObject(lockedRun.runnerProfileJson).nativeExecutionInput ?? nativeExecution,
...lockedProfile,
nativeExecutionInput: lockedProfile.nativeExecutionInput ?? nativeExecution,
...(lockedProfile.sessionCheckpoint !== undefined
? { sessionCheckpoint: lockedProfile.sessionCheckpoint }
: nativeResumeCheckpoint
? { sessionCheckpoint: nativeResumeCheckpoint as unknown as Record<string, unknown> }
: {}),
},
runnerInstanceId: lockedRun.runnerInstanceId ?? nativeRunnerInstanceId,
runnerInstanceId:
previousNativeRun?.runnerInstanceId
&& lockedRun.nativeSessionId !== null
&& lockedRun.nativeSessionId === previousNativeRun.nativeSessionId
? previousNativeRun.runnerInstanceId
: lockedRun.runnerInstanceId ?? nativeRunnerInstanceId,
nativeSessionId: lockedRun.nativeSessionId ?? nativeSessionId,
driverKind: lockedRun.driverKind ?? nativeExecution?.session.driverKind ?? "codex_app_server",
driverVersion: lockedRun.driverVersion ?? "phase6-v1",
+5
View File
@@ -197,3 +197,8 @@ export {
CLAUDE_MANAGED_BETA_VERSION,
type ManagedAgentProfileInput,
} from "./managed-agent-profiles.js";
export {
remoteAgentProfileService,
type RemoteAgentProfileInput,
type RemoteAgentService,
} from "./remote-agent-profiles.js";
@@ -2,6 +2,7 @@ export * from "./runtime-mode.js";
export * from "./completion-contracts.js";
export * from "./native-execution-input.js";
export * from "./native-session-executor.js";
export * from "./native-session-resume.js";
export * from "./native-interaction-bridge.js";
export * from "./paperclip-control-plane-port.js";
export * from "./native-run-finalizer.js";
@@ -1,9 +1,14 @@
import type {
NativeExecutionInputV1,
NativeAcpxAgent,
NativeExecutionInputV2,
NativeInteractionResponseEnvelope,
NativePlanningContext,
StrictCompletionContractInput,
} from "../../vendor/paperclip-runner/index.js";
import { parseNativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
import {
parseNativeExecutionInput,
resolveQualifiedAcpxProfile,
} from "../../vendor/paperclip-runner/index.js";
/** Closed constructor: callers cannot spread legacy context or environment data. */
export function buildNativeExecutionInput(input: {
@@ -26,7 +31,8 @@ export function buildNativeExecutionInput(input: {
branchName: string | null;
};
normalizedSessionId: string | null;
provider?: "codex" | "opencode" | "claude_managed";
provider?: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx";
acpxAgent?: NativeAcpxAgent;
model?: string | null;
managedProfile?: {
profileId: string;
@@ -36,7 +42,26 @@ export function buildNativeExecutionInput(input: {
betaVersion: "managed-agents-2026-04-01";
};
maxSessionListCostUsd?: number;
lifecyclePolicy?: NativeExecutionInputV1["session"]["lifecyclePolicy"];
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;
invocationLimits?: { maxIterations: number; maxOutputTokens: number; timeoutSeconds: number };
lifecyclePolicy?: NativeExecutionInputV2["session"]["lifecyclePolicy"];
executionMode?: "default" | "plan";
planningContext?: NativePlanningContext | null;
interactionResponses?: NativeInteractionResponseEnvelope[];
completionContract: {
id: string;
@@ -44,12 +69,22 @@ export function buildNativeExecutionInput(input: {
schemaVersion: string;
contract: StrictCompletionContractInput;
};
}): NativeExecutionInputV1 {
if (input.issue.workMode !== "standard") {
throw new Error("native_execution_input_invalid: issue work mode must be standard");
}): NativeExecutionInputV2 {
if (input.issue.workMode !== "standard" && input.issue.workMode !== "planning") {
throw new Error("native_execution_input_invalid: issue work mode must be standard or planning");
}
const executionMode = input.executionMode
?? (input.issue.workMode === "planning" ? "plan" : "default");
const acpxProfile = input.provider === "acpx"
? resolveQualifiedAcpxProfile(
input.acpxAgent ?? "pi",
input.model ?? "",
)
: null;
return parseNativeExecutionInput({
schema: "paperclip.native-execution-input.v1",
schema: "paperclip.native-execution-input.v2",
executionMode,
planningContext: input.planningContext ?? null,
binding: {
companyId: input.companyId,
runId: input.runId,
@@ -62,7 +97,7 @@ export function buildNativeExecutionInput(input: {
title: input.issue.title,
description: input.issue.description,
prompt: input.taskPrompt,
workMode: "standard",
workMode: input.issue.workMode,
},
workspace: {
cwd: input.workspace.cwd,
@@ -76,21 +111,54 @@ export function buildNativeExecutionInput(input: {
? "opencode_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",
protocolVersion: 1,
lifecyclePolicy: input.lifecyclePolicy ?? { mode: "per_turn", idleTimeoutMs: null },
},
provider: input.provider === "claude_managed" ? {
kind: "claude_managed",
model: input.model,
managedProfile: input.managedProfile,
maxSessionListCostUsd: input.maxSessionListCostUsd,
} : {
kind: input.provider ?? "codex",
model: input.model ?? null,
},
provider: input.provider === "claude_managed"
? {
kind: "claude_managed",
model: input.model,
managedProfile: input.managedProfile,
maxSessionListCostUsd: input.maxSessionListCostUsd,
}
: input.provider === "aws_agentcore"
? {
kind: "aws_agentcore",
model: input.model,
agentCoreProfile: input.agentCoreProfile,
maxEstimatedSessionCostUsd: input.maxEstimatedSessionCostUsd,
invocationLimits: input.invocationLimits,
}
: input.provider === "acpx"
? {
kind: "acpx",
agent: acpxProfile!.agent,
model: input.model,
permissionPolicy: "interactive",
profile: {
driverKind: acpxProfile!.driverKind,
protocolVersion: acpxProfile!.protocolVersion,
acpxVersion: acpxProfile!.acpxVersion,
agent: acpxProfile!.agent,
agentProfileVersion: acpxProfile!.agentProfileVersion,
agentServerPackage: acpxProfile!.agentServerPackage,
agentServerVersion: acpxProfile!.agentServerVersion,
agentRuntimePackage: acpxProfile!.agentRuntimePackage,
agentRuntimeVersion: acpxProfile!.agentRuntimeVersion,
commandDigest: acpxProfile!.commandDigest,
},
}
: {
kind: input.provider ?? "codex",
model: input.model ?? null,
},
completionContract: input.completionContract,
interactionResponses: input.interactionResponses ?? [],
credentialBindings: [],
});
}) as NativeExecutionInputV2;
}
@@ -34,6 +34,7 @@ import {
nativeSessionFailureDisposition,
nativeSessionFailureSourceCode,
providerPlanMarkdown,
semanticProviderPlanMarkdown,
} from "./native-session-executor.js";
describe("native provider bootstrap environment", () => {
@@ -77,6 +78,23 @@ const execution = {
} as NativeExecutionInputV1;
describe("provider plan synchronization", () => {
it("prefers the provider's completed Markdown when it is available", () => {
expect(providerPlanMarkdown({
markdown: "# Release plan\n\n1. Prepare\n2. Deploy",
explanation: "This fallback must not replace the completed plan.",
steps: [{ body: "Fallback", status: "pending" }],
})).toBe("# Release plan\n\n1. Prepare\n2. Deploy");
});
it("extracts a completed plan from the semantic result artifact", () => {
expect(semanticProviderPlanMarkdown({
artifacts: [{
kind: "native_provider_plan",
ref: "<proposed_plan>\n# Health check\n\n1. Add endpoint\n2. Verify it\n</proposed_plan>",
}],
})).toBe("# Health check\n\n1. Add endpoint\n2. Verify it");
});
it("renders a bounded Markdown checklist without embedding provenance", () => {
const markdown = providerPlanMarkdown({
explanation: "Release safely",
@@ -257,6 +275,9 @@ describe("native session bounded recovery", () => {
expect(nativeSessionFailureSourceCode(new Error(
"provider_transport_failed: invalid JSON-RPC",
))).toBe("provider_transport_failed");
expect(nativeSessionFailureSourceCode(new Error(
"planning_mode_unsupported: installed Codex app-server did not confirm plan mode",
))).toBe("planning_mode_unsupported");
});
it("retries the same run twice and stops at the third failed attempt", () => {
@@ -5,7 +5,7 @@ import { resolve } from "node:path";
import type { AdapterExecutionResult } from "../../adapters/index.js";
import type { NativeFinalizationResult } from "@paperclipai/shared";
import type {
NativeExecutionInputV1,
NativeExecutionInput,
NativeSession,
NativeSessionBackend,
PersistedNativeSession,
@@ -26,6 +26,8 @@ import { persistActivity, publishActivity } from "../activity-log.js";
import { commitNativeStatusDecision } from "./status-decision-committer.js";
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
import { documentService } from "../documents.js";
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
import { issueService } from "../issues.js";
import {
NATIVE_STATUS_ARBITER_POLICY_VERSION,
type NativeAuthoritativeIssueStatus,
@@ -90,12 +92,17 @@ type PlanSynchronization = {
eventId: string;
planId: string;
providerRevision: number;
status: "synchronized" | "already_synchronized" | "conflict" | "invalid";
status: "synchronized" | "already_synchronized" | "conflict" | "invalid" | "approval_failed";
baseRevisionId: string | null;
digest: string;
documentRevision: number | null;
currentRevisionId: string | null;
confirmationId: string | null;
};
export function providerPlanMarkdown(payload: Record<string, unknown>): string {
const completedMarkdown = typeof payload.markdown === "string" ? payload.markdown.trim() : "";
if (completedMarkdown) return completedMarkdown.slice(0, 256_000);
const explanation = typeof payload.explanation === "string" ? payload.explanation.trim() : "";
const steps = Array.isArray(payload.steps) ? payload.steps : [];
const lines = steps.slice(0, 256).flatMap((value) => {
@@ -110,46 +117,126 @@ export function providerPlanMarkdown(payload: Record<string, unknown>): string {
return [explanation, lines.join("\n")].filter(Boolean).join("\n\n").slice(0, 256_000);
}
async function synchronizeCompletedProviderPlan(input: {
export function semanticProviderPlanMarkdown(result: Record<string, unknown>): string | null {
const artifacts = Array.isArray(result.artifacts) ? result.artifacts : [];
for (const value of artifacts) {
const artifact = record(value);
if (artifact.kind !== "native_provider_plan" || typeof artifact.ref !== "string") continue;
const match = artifact.ref.match(/<proposed_plan>\s*([\s\S]*?)\s*<\/proposed_plan>/i);
const markdown = (match?.[1] ?? artifact.ref).trim();
if (markdown) return markdown.slice(0, 256_000);
}
return null;
}
export async function synchronizeCompletedProviderPlan(input: {
db: Db;
execution: NativeExecutionInputV1;
execution: NativeExecutionInput;
event: { sourceEventId: string; turnId?: string; eventType: string; payload: Record<string, unknown> };
}): Promise<PlanSynchronization | null> {
if (input.event.eventType !== "plan.updated" || input.event.payload.complete !== true) return null;
if (!("executionMode" in input.execution) || input.execution.executionMode !== "plan") return null;
const planningContext = input.execution.planningContext;
if (!planningContext) return null;
const planId = typeof input.event.payload.planId === "string" ? input.event.payload.planId : "";
const providerRevision = Number.isSafeInteger(input.event.payload.revision) ? Number(input.event.payload.revision) : 0;
const body = providerPlanMarkdown(input.event.payload);
const digest = createHash("sha256").update(body).digest("hex");
if (!planId || providerRevision < 1 || !body) {
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "invalid", documentRevision: null, currentRevisionId: null };
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "invalid", baseRevisionId: planningContext.baseRevisionId, digest, documentRevision: null, currentRevisionId: null, confirmationId: null };
}
const provenance = `runner-plan-sync:v1 run=${input.execution.binding.runId} turn=${input.event.turnId ?? "unknown"} provider=${input.execution.provider.kind} plan=${planId} revision=${providerRevision}`;
const provenance = `runner-plan-sync:v2 run=${input.execution.binding.runId} turn=${input.event.turnId ?? "unknown"} provider=${input.execution.provider.kind} plan=${planId} revision=${providerRevision} digest=${digest}`;
const existingRevision = await input.db.select({ revisionNumber: documentRevisions.revisionNumber, id: documentRevisions.id })
.from(documentRevisions)
.innerJoin(issueDocuments, eq(issueDocuments.documentId, documentRevisions.documentId))
.where(and(eq(issueDocuments.issueId, input.execution.binding.issueId), eq(issueDocuments.key, "plan"), eq(documentRevisions.changeSummary, provenance)))
.orderBy(desc(documentRevisions.revisionNumber)).limit(1).then((rows) => rows[0] ?? null);
if (existingRevision) {
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "already_synchronized", documentRevision: existingRevision.revisionNumber, currentRevisionId: existingRevision.id };
}
const documents = documentService(input.db);
const current = await documents.getIssueDocumentByKey(input.execution.binding.issueId, "plan");
let revision = existingRevision;
let status: PlanSynchronization["status"] = existingRevision ? "already_synchronized" : "synchronized";
if (!revision) {
const latest = await documents.getIssueDocumentByKey(input.execution.binding.issueId, "plan");
if (latest?.latestRevisionId && latest.body === body) {
const sameRunRevision = await input.db.select({
id: documentRevisions.id,
revisionNumber: documentRevisions.revisionNumber,
createdByRunId: documentRevisions.createdByRunId,
}).from(documentRevisions)
.where(eq(documentRevisions.id, latest.latestRevisionId))
.limit(1).then((rows) => rows[0] ?? null);
if (sameRunRevision?.createdByRunId === input.execution.binding.runId) {
revision = sameRunRevision;
status = "already_synchronized";
}
}
}
try {
const write = await documents.upsertIssueDocument({
issueId: input.execution.binding.issueId,
key: "plan",
title: "Plan",
format: "markdown",
body,
baseRevisionId: current?.latestRevisionId ?? null,
changeSummary: provenance,
createdByAgentId: input.execution.binding.agentId,
createdByRunId: input.execution.binding.runId,
});
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "synchronized", documentRevision: write.document.latestRevisionNumber, currentRevisionId: write.document.latestRevisionId };
if (!revision) {
const write = await documents.upsertIssueDocument({
issueId: input.execution.binding.issueId,
key: "plan",
title: "Plan",
format: "markdown",
body,
baseRevisionId: planningContext.baseRevisionId,
changeSummary: provenance,
createdByAgentId: input.execution.binding.agentId,
createdByRunId: input.execution.binding.runId,
});
revision = { revisionNumber: write.document.latestRevisionNumber, id: write.document.latestRevisionId! };
}
} catch (error) {
if (!(error instanceof HttpError) || error.status !== 409) throw error;
const latest = await documents.getIssueDocumentByKey(input.execution.binding.issueId, "plan");
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "conflict", documentRevision: latest?.latestRevisionNumber ?? null, currentRevisionId: latest?.latestRevisionId ?? null };
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "conflict", baseRevisionId: planningContext.baseRevisionId, digest, documentRevision: latest?.latestRevisionNumber ?? null, currentRevisionId: latest?.latestRevisionId ?? null, confirmationId: null };
}
const current = await documents.getIssueDocumentByKey(input.execution.binding.issueId, "plan");
if (!current || !revision?.id || current.latestRevisionId !== revision.id) {
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "conflict", baseRevisionId: planningContext.baseRevisionId, digest, documentRevision: current?.latestRevisionNumber ?? null, currentRevisionId: current?.latestRevisionId ?? null, confirmationId: null };
}
try {
const confirmation = await issueThreadInteractionService(input.db).create(
{ id: input.execution.binding.issueId, companyId: input.execution.binding.companyId },
{
kind: "request_confirmation",
idempotencyKey: `runner-plan-approval:v1:${input.execution.binding.runId}:${planId}:${providerRevision}:${digest}`,
sourceRunId: input.execution.binding.runId,
title: `Review plan revision ${revision.revisionNumber}`,
summary: "Review the synchronized Paperclip plan.",
continuationPolicy: "wake_assignee_on_accept",
payload: {
version: 1,
prompt: `Approve plan revision ${revision.revisionNumber}?`,
detailsMarkdown: "The completed provider plan has been synchronized to the canonical Plan document.",
acceptLabel: "Approve plan",
rejectLabel: "Request changes",
rejectRequiresReason: true,
supersedeOnUserComment: false,
target: {
type: "issue_document",
issueId: input.execution.binding.issueId,
documentId: current.id,
key: "plan",
revisionId: revision.id,
revisionNumber: revision.revisionNumber,
label: `Plan v${revision.revisionNumber}`,
},
},
} as never,
{ agentId: input.execution.binding.agentId, runId: input.execution.binding.runId },
);
const currentIssue = await input.db.select({ status: issues.status }).from(issues)
.where(eq(issues.id, input.execution.binding.issueId))
.limit(1).then((rows) => rows[0] ?? null);
if (currentIssue && currentIssue.status !== "review") {
await issueService(input.db).update(input.execution.binding.issueId, {
status: "review",
actorAgentId: input.execution.binding.agentId,
});
}
return { eventId: input.event.sourceEventId, planId, providerRevision, status, baseRevisionId: planningContext.baseRevisionId, digest, documentRevision: revision.revisionNumber, currentRevisionId: revision.id, confirmationId: confirmation.id };
} catch {
return { eventId: input.event.sourceEventId, planId, providerRevision, status: "approval_failed", baseRevisionId: planningContext.baseRevisionId, digest, documentRevision: revision.revisionNumber, currentRevisionId: revision.id, confirmationId: null };
}
}
@@ -175,12 +262,41 @@ class SessionToolAuthorityRouter {
const sessionToolRouters = new Map<string, SessionToolAuthorityRouter>();
function nativeSessionKey(execution: NativeExecutionInputV1): string {
function nativeSessionKey(execution: NativeExecutionInput): string {
return execution.session.normalizedSessionId ?? `session-${execution.binding.runId}`;
}
function nativeSessionConfigDigest(execution: NativeExecutionInputV1): string {
const executionLocation = execution.provider.kind === "claude_managed"
function runnerdStateRoot(execution: NativeExecutionInput): string {
return resolve(
process.env.PAPERCLIP_RUNNER_STATE_DIR ?? resolve(tmpdir(), "paperclip-runner"),
createHash("sha256").update(nativeSessionKey(execution)).digest("hex"),
);
}
function loadRunnerdDurableBinding(execution: NativeExecutionInput): {
runnerInstanceId: string;
environmentLeaseId: string;
} | null {
const statePath = resolve(runnerdStateRoot(execution), "control-plane", "mock-core-state.json");
if (!existsSync(statePath)) return null;
try {
const identity = record(record(JSON.parse(readFileSync(statePath, "utf8"))).identity);
if (
identity.normalizedSessionId !== nativeSessionKey(execution)
|| typeof identity.runnerInstanceId !== "string"
|| typeof identity.environmentLeaseId !== "string"
) return null;
return {
runnerInstanceId: identity.runnerInstanceId,
environmentLeaseId: identity.environmentLeaseId,
};
} catch {
return null;
}
}
function nativeSessionConfigDigest(execution: NativeExecutionInput): string {
const executionLocation = execution.provider.kind === "claude_managed" || execution.provider.kind === "aws_agentcore"
? { executionKind: "remote_service", workspace: null }
: {
executionKind: "local_process",
@@ -194,6 +310,7 @@ function nativeSessionConfigDigest(execution: NativeExecutionInputV1): string {
provider: execution.provider,
driverKind: execution.session.driverKind,
lifecyclePolicy: execution.session.lifecyclePolicy,
executionMode: "executionMode" in execution ? execution.executionMode : "default",
})).digest("hex")}`;
}
@@ -222,7 +339,7 @@ function persistWarmNativeCheckpoint(
}
function loadWarmNativeCheckpoint(
execution: NativeExecutionInputV1,
execution: NativeExecutionInput,
configDigest: string,
): PersistedNativeSession | null {
const path = nativeSessionCheckpointPath(nativeSessionKey(execution));
@@ -289,6 +406,7 @@ export function nativeSessionFailureSourceCode(error: unknown):
| "provider_frame_too_large"
| "provider_transport_failed"
| "native_runner_process_exited"
| "planning_mode_unsupported"
| "native_session_interrupted" {
const message = error instanceof Error ? error.message : String(error);
if (/provider_frame_too_large|stdout frame exceeded/i.test(message)) {
@@ -300,6 +418,9 @@ export function nativeSessionFailureSourceCode(error: unknown):
if (/native_runner_process_exited|runnerd exited|runner process failed/i.test(message)) {
return "native_runner_process_exited";
}
if (/planning_mode_unsupported/i.test(message)) {
return "planning_mode_unsupported";
}
return "native_session_interrupted";
}
@@ -495,7 +616,7 @@ export function resolveNativeCancellationStatus(input: {
export async function executePaperclipNativeSession(input: {
db: Db;
execution: NativeExecutionInputV1;
execution: NativeExecutionInput;
runnerInstanceId: string;
leaseOwner?: string;
onSpawn?: (meta: {
@@ -510,7 +631,19 @@ export async function executePaperclipNativeSession(input: {
/** Resolved adapter env; the runner transport applies a provider allowlist before spawn. */
runnerEnvironment?: NodeJS.ProcessEnv;
}): Promise<AdapterExecutionResult> {
const leaseOwner = input.leaseOwner ?? `${input.runnerInstanceId}:${randomUUID()}`;
const durableRunnerBinding = input.useRunnerd ? loadRunnerdDurableBinding(input.execution) : null;
const effectiveRunnerInstanceId = durableRunnerBinding?.runnerInstanceId ?? input.runnerInstanceId;
if (effectiveRunnerInstanceId !== input.runnerInstanceId) {
await input.db.update(heartbeatRuns).set({
runnerInstanceId: effectiveRunnerInstanceId,
updatedAt: new Date(),
}).where(and(
eq(heartbeatRuns.id, input.execution.binding.runId),
eq(heartbeatRuns.companyId, input.execution.binding.companyId),
eq(heartbeatRuns.agentId, input.execution.binding.agentId),
));
}
const leaseOwner = input.leaseOwner ?? `${effectiveRunnerInstanceId}:${randomUUID()}`;
const leaseNow = new Date();
const leaseExpiresAt = new Date(leaseNow.getTime() + 20 * 60_000);
const attempt = await input.db.transaction(async (tx) => {
@@ -545,8 +678,36 @@ export async function executePaperclipNativeSession(input: {
}).where(eq(heartbeatRuns.id, coordinator.runId));
return coordinator.attempt + 1;
});
const controlPlaneInstanceId = `${input.runnerInstanceId}:control`;
const controlPlaneInstanceId = `${effectiveRunnerInstanceId}:control`;
const planSynchronizations: PlanSynchronization[] = [];
const recordPlanSynchronization = async (event: {
sourceEventId: string;
turnId?: string;
eventType: string;
payload: Record<string, unknown>;
}) => {
const synchronization = await synchronizeCompletedProviderPlan({
db: input.db,
execution: input.execution,
event,
});
if (!synchronization) return;
planSynchronizations.push(synchronization);
const activity = await persistActivity(input.db, {
companyId: input.execution.binding.companyId,
actorType: "agent",
actorId: input.execution.binding.agentId,
agentId: input.execution.binding.agentId,
runId: input.execution.binding.runId,
issueId: input.execution.binding.issueId,
action: "issue.document_updated",
entityType: "issue",
entityId: input.execution.binding.issueId,
details: { key: "plan", source: "native_plan_synchronization", synchronization },
});
publishActivity(activity.publication);
if (input.onLog) await input.onLog("stdout", `${JSON.stringify({ type: "paperclip.plan.synchronization", synchronization })}\n`);
};
const controlPlane = new PaperclipControlPlanePort(input.db, {
companyId: input.execution.binding.companyId,
issueId: input.execution.binding.issueId,
@@ -554,20 +715,17 @@ export async function executePaperclipNativeSession(input: {
agentId: input.execution.binding.agentId,
completionContractId: input.execution.completionContract.id,
completionContractSha256: input.execution.completionContract.sha256,
sourceInstanceId: input.runnerInstanceId,
sourceInstanceId: effectiveRunnerInstanceId,
controlPlaneSourceInstanceId: controlPlaneInstanceId,
}, {
onCommittedEvent: async (event) => {
if (input.onLog) await input.onLog("stdout", `${JSON.stringify({ type: "paperclip.prp.event", event })}\n`);
const synchronization = await synchronizeCompletedProviderPlan({
db: input.db,
execution: input.execution,
event: event as { sourceEventId: string; turnId?: string; eventType: string; payload: Record<string, unknown> },
await recordPlanSynchronization(event as {
sourceEventId: string;
turnId?: string;
eventType: string;
payload: Record<string, unknown>;
});
if (synchronization) {
planSynchronizations.push(synchronization);
if (input.onLog) await input.onLog("stdout", `${JSON.stringify({ type: "paperclip.plan.synchronization", synchronization })}\n`);
}
},
});
let native: Awaited<ReturnType<typeof executeNativeSession>>;
@@ -592,7 +750,11 @@ export async function executePaperclipNativeSession(input: {
}
try {
const runnerdBackend = input.useRunnerd && input.backend === undefined
? createRunnerdBackend(input)
? createRunnerdBackend({
...input,
runnerInstanceId: effectiveRunnerInstanceId,
durableEnvironmentLeaseId: durableRunnerBinding?.environmentLeaseId,
})
: null;
native = await executeNativeSession({
input: input.execution,
@@ -603,9 +765,10 @@ export async function executePaperclipNativeSession(input: {
onSpawn: input.onSpawn,
environment: input.runnerEnvironment ?? process.env,
opencodeRuntimeDirectory: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "opencode"),
acpxRuntimeDirectory: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "acpx"),
}),
controlPlane,
runnerInstanceId: input.runnerInstanceId,
runnerInstanceId: effectiveRunnerInstanceId,
controlPlaneInstanceId,
existingSession: existingWarmSession,
persistedSession: persistedWarmSession,
@@ -698,6 +861,29 @@ export async function executePaperclipNativeSession(input: {
});
throw error;
}
if (
planSynchronizations.length === 0
&& "executionMode" in input.execution
&& input.execution.executionMode === "plan"
) {
const markdown = semanticProviderPlanMarkdown(native.result as unknown as Record<string, unknown>);
if (markdown) {
const digest = createHash("sha256").update(markdown).digest("hex");
await recordPlanSynchronization({
sourceEventId: `semantic-plan:${input.execution.binding.runId}:${digest}`,
...(native.turnId ? { turnId: native.turnId } : {}),
eventType: "plan.updated",
payload: {
schema: "paperclip.plan.updated.v1",
planId: `semantic:${native.turnId ?? input.execution.binding.runId}`,
revision: 1,
complete: true,
markdown,
source: "semantic_result_artifact",
},
});
}
}
await input.db.update(nativeRunFinalizations).set({
leaseOwner: null,
leaseExpiresAt: null,
@@ -715,7 +901,7 @@ export async function executePaperclipNativeSession(input: {
result: native.result as unknown as Record<string, unknown>,
terminal: native.terminal,
turnId: native.turnId,
sourceInstanceId: input.runnerInstanceId,
sourceInstanceId: effectiveRunnerInstanceId,
normalizedSessionId: native.normalizedSessionId,
providerSessionId: native.providerSessionId,
driverKind: native.driverKind,
@@ -746,8 +932,16 @@ export async function executePaperclipNativeSession(input: {
sessionDisplayId: native.providerSessionId ?? native.normalizedSessionId,
provider: input.execution.provider.kind === "claude_managed"
? "anthropic"
: input.execution.provider.kind === "aws_agentcore"
? "amazon-bedrock"
: input.execution.provider.kind === "opencode"
? input.execution.provider.model?.split("/", 1)[0] ?? "opencode"
: input.execution.provider.kind === "acpx"
? input.execution.provider.agent === "pi"
? "openrouter"
: input.execution.provider.agent === "claude"
? "anthropic"
: "openai"
: "openai",
model: input.execution.provider.model,
usage: normalizeNativeUsage(native.usage),
@@ -783,8 +977,9 @@ function normalizeNativeUsage(usage: Record<string, unknown> | null) {
function createRunnerdBackend(input: {
db: Db;
execution: NativeExecutionInputV1;
execution: NativeExecutionInput;
runnerInstanceId: string;
durableEnvironmentLeaseId?: string;
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
runnerEnvironment?: NodeJS.ProcessEnv;
}): NativeSessionBackend {
@@ -793,16 +988,14 @@ function createRunnerdBackend(input: {
issueId: input.execution.binding.issueId,
runId: input.execution.binding.runId,
agentId: input.execution.binding.agentId,
workMode: input.execution.task.workMode,
});
const sessionId = nativeSessionKey(input.execution);
const existingRouter = sessionToolRouters.get(sessionId);
const authorityRouter = existingRouter ?? new SessionToolAuthorityRouter(authority);
authorityRouter.bind(authority);
sessionToolRouters.set(sessionId, authorityRouter);
const root = resolve(
process.env.PAPERCLIP_RUNNER_STATE_DIR ?? resolve(tmpdir(), "paperclip-runner"),
createHash("sha256").update(sessionId).digest("hex"),
);
const root = runnerdStateRoot(input.execution);
mkdirSync(root, { recursive: true, mode: 0o700 });
return createNativeSessionBackend(input.execution, {
runnerInstanceId: input.runnerInstanceId,
@@ -812,7 +1005,21 @@ function createRunnerdBackend(input: {
environment: input.runnerEnvironment ?? process.env,
opencodeRuntimeDirectory: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "opencode"),
codexTransportFactory: () => createRunnerdCodexTransport({
provider: input.execution.provider.kind,
provider: input.execution.provider.kind === "codex"
? "codex"
: input.execution.provider.kind === "opencode"
? "opencode"
: input.execution.provider.kind === "claude_managed"
? "claude_managed"
: input.execution.provider.kind === "aws_agentcore"
? "aws_agentcore"
: input.execution.provider.kind === "acpx"
? "acpx"
: undefined,
...(input.execution.provider.kind === "acpx" ? {
acpxAgent: input.execution.provider.agent,
acpxRuntimeDirectory: resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "acpx"),
} : {}),
...(input.execution.provider.kind === "claude_managed" ? {
managedProfile: {
...input.execution.provider.managedProfile,
@@ -820,13 +1027,23 @@ function createRunnerdBackend(input: {
model: input.execution.provider.model,
},
} : {}),
...(input.execution.provider.kind === "aws_agentcore" ? {
agentCoreProfile: {
...input.execution.provider.agentCoreProfile,
maxEstimatedSessionCostUsd: input.execution.provider.maxEstimatedSessionCostUsd,
maxIterations: input.execution.provider.invocationLimits.maxIterations,
maxOutputTokens: input.execution.provider.invocationLimits.maxOutputTokens,
timeoutSeconds: input.execution.provider.invocationLimits.timeoutSeconds,
model: input.execution.provider.model,
},
} : {}),
stateDirectory: root,
environment: input.runnerEnvironment ?? process.env,
lifecyclePolicy: input.execution.session.lifecyclePolicy,
resumeDynamicTools: authorityRouter.definitions(),
prpIdentity: {
runnerInstanceId: input.runnerInstanceId,
environmentLeaseId: input.execution.binding.executionWorkspaceId,
environmentLeaseId: input.durableEnvironmentLeaseId ?? input.execution.binding.executionWorkspaceId,
runId: input.execution.binding.runId,
normalizedSessionId: input.execution.session.normalizedSessionId ?? `session-${input.execution.binding.runId}`,
turnId: `turn-${input.execution.binding.runId}`,
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import { buildNativeExecutionInput } from "./native-execution-input.js";
import { rebindNativeSessionCheckpoint } from "./native-session-resume.js";
const companyId = "10000000-0000-4000-8000-000000000001";
const issueId = "20000000-0000-4000-8000-000000000002";
const agentId = "30000000-0000-4000-8000-000000000003";
const previousRunId = "40000000-0000-4000-8000-000000000004";
const currentRunId = "50000000-0000-4000-8000-000000000005";
const normalizedSessionId = "60000000-0000-4000-8000-000000000006";
function execution(runId: string, cwd = "/workspace") {
return buildNativeExecutionInput({
companyId,
runId,
issue: { id: issueId, identifier: "DOT-2", title: "Test", description: null, workMode: "standard" },
taskPrompt: "Only the current turn",
agentId,
workspace: { id: runId, cwd, repoUrl: null, repoRef: null, branchName: null },
normalizedSessionId,
provider: "codex",
completionContract: {
id: "70000000-0000-4000-8000-000000000007",
sha256: `sha256:${"a".repeat(64)}`,
schemaVersion: "paperclip.run-result.v1",
contract: {
revision: "1",
objective: "Test session resumption",
criteria: [{ id: "objective", requirement: "Answer the current turn" }],
},
},
});
}
function planningExecution(runId: string, revisionId: string) {
return buildNativeExecutionInput({
companyId,
runId,
issue: { id: issueId, identifier: "DOT-2", title: "Test", description: null, workMode: "planning" },
taskPrompt: "Revise the plan",
agentId,
workspace: { id: runId, cwd: "/workspace", repoUrl: null, repoRef: null, branchName: null },
normalizedSessionId,
provider: "codex",
executionMode: "plan",
planningContext: {
documentId: "80000000-0000-4000-8000-000000000008",
baseRevisionId: revisionId,
baseRevisionNumber: revisionId.endsWith("9") ? 9 : 8,
markdown: `Plan at ${revisionId}`,
sha256: `sha256:${"b".repeat(64)}`,
reviewContext: {},
},
completionContract: execution(runId).completionContract,
});
}
function previousRun(overrides: Record<string, unknown> = {}) {
return {
id: previousRunId,
companyId,
agentId,
nativeSessionId: normalizedSessionId,
runnerProfileJson: {
nativeExecutionInput: execution(previousRunId),
sessionCheckpoint: {
backendKind: "runner",
driverKind: "codex_app_server",
sessionId: "provider-thread-123",
providerSessionId: "provider-thread-123",
cursor: "42",
identity: { runId: previousRunId, sessionId: normalizedSessionId, companyId, issueId, agentId },
semanticResult: { schema: "paperclip.run-result.v1", reportedWorkDisposition: "done", summary: "old" },
terminal: { schema: "paperclip.prp.terminal.v1", turnTerminalState: "completed", runTerminalState: "succeeded", reportedWorkDisposition: "done" },
activeTurnId: "old-turn",
terminalTurns: [{ turnId: "old-turn", state: "completed" }],
pendingRuntimeRequests: [{ requestId: "old-request" }],
lineage: [{ threadId: "provider-thread-123" }],
},
...overrides,
},
};
}
describe("rebindNativeSessionCheckpoint", () => {
it("retains provider identity but clears prior turn and event state", () => {
const rebound = rebindNativeSessionCheckpoint({
previousRun: previousRun(),
currentExecution: execution(currentRunId),
});
expect(rebound).toMatchObject({
sessionId: "provider-thread-123",
providerSessionId: "provider-thread-123",
driverKind: "codex_app_server",
cursor: null,
semanticResult: null,
terminal: null,
activeTurnId: null,
terminalTurns: [],
pendingRuntimeRequests: [],
identity: { runId: currentRunId, sessionId: normalizedSessionId, companyId, issueId, agentId },
});
});
it("refuses to resume when the workspace changes", () => {
expect(rebindNativeSessionCheckpoint({
previousRun: previousRun(),
currentExecution: execution(currentRunId, "/different-workspace"),
})).toBeNull();
});
it("refuses a checkpoint whose prior-run binding was rewritten", () => {
const source = previousRun();
const profile = source.runnerProfileJson as Record<string, unknown>;
const checkpoint = profile.sessionCheckpoint as Record<string, unknown>;
checkpoint.identity = { ...(checkpoint.identity as Record<string, unknown>), runId: currentRunId };
expect(rebindNativeSessionCheckpoint({ previousRun: source, currentExecution: execution(currentRunId) })).toBeNull();
});
it("reuses plan mode across canonical revisions but never across a mode change", () => {
const source = previousRun({ nativeExecutionInput: planningExecution(previousRunId, "revision-8") });
expect(rebindNativeSessionCheckpoint({
previousRun: source,
currentExecution: planningExecution(currentRunId, "revision-9"),
})).not.toBeNull();
expect(rebindNativeSessionCheckpoint({
previousRun: source,
currentExecution: execution(currentRunId),
})).toBeNull();
});
});
@@ -0,0 +1,93 @@
import type {
NativeExecutionInput,
PersistedNativeSession,
} from "../../vendor/paperclip-runner/index.js";
import { parseNativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
export function isNativeSessionId(value: unknown): value is string {
return typeof value === "string"
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}
function sameProvider(
previous: NativeExecutionInput["provider"],
current: NativeExecutionInput["provider"],
): boolean {
return JSON.stringify(previous) === JSON.stringify(current);
}
/**
* Rebind a completed prior run's provider checkpoint to a new heartbeat run.
* The provider/driver session identity is retained, while every per-turn and
* per-event field is reset so the new run starts one clean turn via resume.
*/
export function rebindNativeSessionCheckpoint(input: {
previousRun: {
id: string;
companyId: string;
agentId: string;
nativeSessionId: string | null;
runnerProfileJson: unknown;
};
currentExecution: NativeExecutionInput;
}): PersistedNativeSession | null {
const previousProfile = record(input.previousRun.runnerProfileJson);
const rawCheckpoint = record(previousProfile.sessionCheckpoint);
const checkpointIdentity = record(rawCheckpoint.identity);
const current = input.currentExecution;
const normalizedSessionId = current.session.normalizedSessionId;
if (
!isNativeSessionId(normalizedSessionId)
|| input.previousRun.companyId !== current.binding.companyId
|| input.previousRun.agentId !== current.binding.agentId
|| input.previousRun.nativeSessionId !== normalizedSessionId
|| typeof rawCheckpoint.sessionId !== "string"
|| checkpointIdentity.runId !== input.previousRun.id
|| checkpointIdentity.companyId !== current.binding.companyId
|| checkpointIdentity.issueId !== current.binding.issueId
|| checkpointIdentity.agentId !== current.binding.agentId
|| checkpointIdentity.sessionId !== normalizedSessionId
) return null;
let previousExecution: NativeExecutionInput;
try {
previousExecution = parseNativeExecutionInput(previousProfile.nativeExecutionInput);
} catch {
return null;
}
if (
previousExecution.binding.runId !== input.previousRun.id
|| previousExecution.binding.companyId !== current.binding.companyId
|| previousExecution.binding.issueId !== current.binding.issueId
|| previousExecution.binding.agentId !== current.binding.agentId
|| previousExecution.session.normalizedSessionId !== normalizedSessionId
|| previousExecution.session.driverKind !== current.session.driverKind
|| previousExecution.workspace.cwd !== current.workspace.cwd
|| ("executionMode" in previousExecution ? previousExecution.executionMode : "default")
!== ("executionMode" in current ? current.executionMode : "default")
|| !sameProvider(previousExecution.provider, current.provider)
) return null;
return {
...(structuredClone(rawCheckpoint) as unknown as PersistedNativeSession),
identity: {
runId: current.binding.runId,
sessionId: normalizedSessionId,
companyId: current.binding.companyId,
issueId: current.binding.issueId,
agentId: current.binding.agentId,
},
cursor: null,
semanticResult: null,
terminal: null,
activeTurnId: null,
terminalTurns: [],
pendingRuntimeRequests: [],
};
}
@@ -21,6 +21,7 @@ type Binding = {
issueId: string;
runId: string;
agentId: string;
workMode?: "standard" | "planning";
};
type ToolReceipt = {
@@ -48,8 +49,12 @@ export class PaperclipRunnerToolAuthority {
constructor(readonly db: Db, readonly binding: Binding) {}
definitions(): Array<Record<string, unknown>> {
const workMode = this.binding.workMode ?? "standard";
return CAPABILITY_SEMANTIC_TOOL_CATALOG
.filter((descriptor) => IMPLEMENTED_OPERATIONS.has(descriptor.operationId))
.filter((descriptor) =>
IMPLEMENTED_OPERATIONS.has(descriptor.operationId)
&& descriptor.allowedModes.includes(workMode)
)
.map((descriptor) => ({
name: descriptor.operationId,
description: descriptor.description,
@@ -60,6 +65,10 @@ export class PaperclipRunnerToolAuthority {
async execute(call: { tool: string; callId: string; arguments: unknown }): Promise<unknown> {
if (!IMPLEMENTED_OPERATIONS.has(call.tool)) throw new Error("paperclip_runner_tool_not_advertised");
const context = await this.#boundContext();
const descriptor = CAPABILITY_SEMANTIC_TOOL_CATALOG.find((candidate) => candidate.operationId === call.tool);
if (!descriptor?.allowedModes.includes(context.issue.workMode as "standard" | "planning")) {
throw new Error("paperclip_runner_tool_mode_denied");
}
const input = record(call.arguments);
switch (call.tool) {
case "get_task_context": return {
@@ -35,7 +35,7 @@ describe("resolveNativeRuntimeMode", () => {
runtimeConfig: {},
adapterConfig: { provider: "claude" },
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
})).toThrow(/provider must be codex, opencode, or claude_managed/);
})).toThrow(/provider must be codex, opencode, claude_managed, aws_agentcore, or acpx/);
});
it("selects OpenCode only with a provider/model value", () => {
@@ -125,4 +125,18 @@ describe("resolveNativeRuntimeMode", () => {
adapterConfig: { provider: "codex" },
})).toEqual(expect.objectContaining({ kind: "native" }));
});
it("admits planning only through paperclip_runner", () => {
expect(resolveNativeRuntimeMode({
...eligible,
issue: { id: "plan-issue", workMode: "planning" },
runtimeConfig: {},
adapterConfig: { provider: "codex" },
agent: { ...eligible.agent, adapterType: "paperclip_runner" },
})).toMatchObject({ kind: "native", profile: { backend: "codex_app_server" } });
expect(() => resolveNativeRuntimeMode({
...eligible,
issue: { id: "plan-issue", workMode: "planning" },
})).toThrow("planning requires the paperclip_runner adapter");
});
});
@@ -19,7 +19,7 @@ export type NativeRuntimeResolution =
reason: "eligible_opt_in";
profile: {
mode: "native";
backend: "codex_app_server" | "opencode_server" | "claude_managed_agents_api";
backend: "codex_app_server" | "opencode_server" | "claude_managed_agents_api" | "aws_agentcore_harness_api" | "acpx_runtime";
protocolVersion: 1;
};
authorityDecision: NativeStatusDecision;
@@ -51,8 +51,8 @@ export function resolveNativeRuntimeMode(input: {
const nativeRunner = record(record(input.runtimeConfig).nativeRunner);
const runnerAdapterSelected = input.agent.adapterType === "paperclip_runner";
const runnerProvider = record(input.adapterConfig).provider ?? "codex";
if (runnerAdapterSelected && !["codex", "opencode", "claude_managed"].includes(String(runnerProvider))) {
throw new NativeRuntimeEligibilityError("paperclip_runner provider must be codex, opencode, or claude_managed");
if (runnerAdapterSelected && !["codex", "opencode", "claude_managed", "aws_agentcore", "acpx"].includes(String(runnerProvider))) {
throw new NativeRuntimeEligibilityError("paperclip_runner provider must be codex, opencode, claude_managed, aws_agentcore, or acpx");
}
if (runnerAdapterSelected && runnerProvider === "opencode") {
const model = record(input.adapterConfig).model;
@@ -75,6 +75,39 @@ export function resolveNativeRuntimeMode(input: {
throw new NativeRuntimeEligibilityError("paperclip_runner Claude Agent provider requires a positive spend ceiling");
}
}
if (runnerAdapterSelected && runnerProvider === "aws_agentcore") {
const config = record(input.adapterConfig);
for (const key of ["agentCoreProfileId", "model"]) {
if (typeof config[key] !== "string" || String(config[key]).trim().length === 0) {
throw new NativeRuntimeEligibilityError(`paperclip_runner AWS AgentCore provider requires ${key}`);
}
}
if (config.agentCoreRetentionAcknowledged !== true) {
throw new NativeRuntimeEligibilityError("paperclip_runner AWS AgentCore provider requires the 90-day retention acknowledgement");
}
const cap = Number(config.maxEstimatedSessionCostUsd);
if (!Number.isFinite(cap) || cap <= 0) {
throw new NativeRuntimeEligibilityError("paperclip_runner AWS AgentCore provider requires a positive estimated spend ceiling");
}
}
if (runnerAdapterSelected && runnerProvider === "acpx") {
const config = record(input.adapterConfig);
const agent = config.acpxAgent;
if (agent !== "pi" && agent !== "claude" && agent !== "codex") {
throw new NativeRuntimeEligibilityError("paperclip_runner ACPX provider requires acpxAgent pi, claude, or codex");
}
if (typeof config.model !== "string" || config.model.trim().length === 0) {
throw new NativeRuntimeEligibilityError("paperclip_runner ACPX provider requires an exact model");
}
const qualifiedModel = agent === "pi"
? "openrouter/deepseek/deepseek-v4-flash-0731"
: agent === "claude"
? "claude-sonnet-5"
: "gpt-5.6-sol";
if (config.model !== qualifiedModel) {
throw new NativeRuntimeEligibilityError(`paperclip_runner ACPX ${agent} profile requires model ${qualifiedModel}`);
}
}
const mode = runnerAdapterSelected ? "native" : nativeRunner.mode;
if (!runnerAdapterSelected && !input.enabled) {
const compatibility = resolveNativeCompatibilityStatus({
@@ -105,11 +138,14 @@ export function resolveNativeRuntimeMode(input: {
) {
throw new NativeRuntimeEligibilityError("agent must be an active Codex-backed native agent");
}
if (!input.issue || input.issue.workMode !== "standard") {
throw new NativeRuntimeEligibilityError("run must be bound to a standard issue");
if (!input.issue || !["standard", "planning"].includes(input.issue.workMode)) {
throw new NativeRuntimeEligibilityError("run must be bound to a standard or planning issue");
}
if (input.issue.workMode === "planning" && !runnerAdapterSelected) {
throw new NativeRuntimeEligibilityError("native planning requires the paperclip_runner adapter");
}
if (
(!input.workspaceId && !runnerAdapterSelected && runnerProvider !== "claude_managed")
(!input.workspaceId && !runnerAdapterSelected && runnerProvider !== "claude_managed" && runnerProvider !== "aws_agentcore")
|| input.target?.kind && input.target.kind !== "local"
) {
throw new NativeRuntimeEligibilityError("a realized local workspace is required");
@@ -132,6 +168,10 @@ export function resolveNativeRuntimeMode(input: {
? "opencode_server"
: runnerProvider === "claude_managed"
? "claude_managed_agents_api"
: runnerProvider === "aws_agentcore"
? "aws_agentcore_harness_api"
: runnerProvider === "acpx"
? "acpx_runtime"
: "codex_app_server",
protocolVersion: 1,
},
@@ -171,6 +211,10 @@ export function resolveHeartbeatNativeRuntimeMode(input: {
? "opencode_server"
: input.persisted.driverKind === "claude_managed_agents_api"
? "claude_managed_agents_api"
: input.persisted.driverKind === "aws_agentcore_harness_api"
? "aws_agentcore_harness_api"
: input.persisted.driverKind === "acpx_runtime"
? "acpx_runtime"
: "codex_app_server",
protocolVersion: 1,
},
@@ -0,0 +1,91 @@
import { and, asc, eq, ne } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { companySecrets, remoteAgentProfiles } from "@paperclipai/db";
import { conflict, notFound, unprocessable } from "../errors.js";
export type RemoteAgentService = "anthropic_managed_agents" | "aws_bedrock_agentcore_harness";
export interface RemoteAgentProfileInput {
profileKey: string;
displayName: string;
service: RemoteAgentService;
configuration: Record<string, unknown>;
credentialSecretId?: string | null;
enabled: boolean;
retentionAcknowledged: boolean;
qualification?: Record<string, unknown>;
}
function required(value: unknown, label: string): string {
if (typeof value !== "string" || value.trim().length === 0) throw unprocessable(`${label} is required`);
return value.trim();
}
function validateConfiguration(service: RemoteAgentService, configuration: Record<string, unknown>) {
const keys = service === "aws_bedrock_agentcore_harness"
? ["region", "accountId", "harnessArn", "harnessVersion", "endpointArn", "endpointQualifier", "agentRuntimeArn", "memoryArn", "memoryId", "invocationRoleArn", "qualificationRevision", "defaultModel"]
: ["anthropicAgentId", "agentVersion", "environmentId", "betaVersion", "defaultModel"];
for (const key of keys) required(configuration[key], `configuration.${key}`);
if (service === "aws_bedrock_agentcore_harness" && configuration.eventExpiryDays !== 90) {
throw unprocessable("AWS AgentCore profile requires a 90-day Memory expiry");
}
if (service === "anthropic_managed_agents" && configuration.betaVersion !== "managed-agents-2026-04-01") {
throw unprocessable("Anthropic profile beta version is not qualified");
}
}
export function remoteAgentProfileService(db: Db) {
async function list(companyId: string, service?: RemoteAgentService) {
return db.select().from(remoteAgentProfiles)
.where(service
? and(eq(remoteAgentProfiles.companyId, companyId), eq(remoteAgentProfiles.service, service))
: eq(remoteAgentProfiles.companyId, companyId))
.orderBy(asc(remoteAgentProfiles.displayName));
}
async function get(companyId: string, profileIdOrKey: string) {
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(profileIdOrKey);
const rows = await db.select().from(remoteAgentProfiles).where(and(
eq(remoteAgentProfiles.companyId, companyId),
isUuid ? eq(remoteAgentProfiles.id, profileIdOrKey) : eq(remoteAgentProfiles.profileKey, profileIdOrKey),
)).limit(1);
return rows[0] ?? null;
}
async function requireQualified(companyId: string, profileIdOrKey: string, service?: RemoteAgentService) {
const profile = await get(companyId, profileIdOrKey);
if (!profile || service && profile.service !== service) throw notFound("Remote Agent profile not found");
if (!profile.enabled || !profile.retentionAcknowledged || !profile.qualifiedAt) throw conflict("Remote Agent profile is not enabled and qualified");
return profile;
}
async function upsert(companyId: string, input: RemoteAgentProfileInput) {
const profileKey = required(input.profileKey, "Profile key");
const displayName = required(input.displayName, "Display name");
validateConfiguration(input.service, input.configuration);
if (input.enabled && !input.retentionAcknowledged) throw unprocessable("Enabling a remote agent requires retention acknowledgement");
if (input.service === "anthropic_managed_agents" && !input.credentialSecretId) throw unprocessable("Anthropic profiles require a credential secret reference");
if (input.credentialSecretId) {
const secret = await db.select({ id: companySecrets.id }).from(companySecrets).where(and(
eq(companySecrets.companyId, companyId), eq(companySecrets.id, input.credentialSecretId),
eq(companySecrets.scope, "company"), ne(companySecrets.status, "deleted"),
)).limit(1);
if (!secret[0]) throw unprocessable("Remote Agent credential secret reference is invalid");
}
const values = {
companyId, profileKey, displayName, service: input.service,
configuration: structuredClone(input.configuration),
credentialSecretId: input.credentialSecretId ?? null,
enabled: input.enabled, retentionAcknowledged: input.retentionAcknowledged,
qualification: input.qualification ?? {}, qualifiedAt: input.enabled ? new Date() : null,
updatedAt: new Date(),
} as const;
const [row] = await db.insert(remoteAgentProfiles).values(values).onConflictDoUpdate({
target: [remoteAgentProfiles.companyId, remoteAgentProfiles.profileKey], set: values,
}).returning();
return row!;
}
return { list, get, requireQualified, upsert };
}