Carry ACP permission requests through durable runner resolution

Validate provider-supported choices, fence resolutions to the active turn, retain pending state until sidecar acknowledgement, and preserve complete bounded plan documents.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-28 11:10:36 -05:00
1 parent 8193cb2571
commit c810a25db2
25 files changed
+516 -59

No files matched your search

+1 -1
View File
@@ -74,7 +74,7 @@
"typecheck:browser": "tsc -p tsconfig.browser.json --noEmit",
"test": "pnpm run test:typescript && pnpm run test:rust",
"test:typescript": "pnpm run test:typescript:prep && vitest run",
"test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs",
"test:typescript:prep": "pnpm run ensure:eval-build-deps && pnpm run build:rust && node --test test/protocol-contract.test.mjs test/acpx-sidecar-contract.test.mjs test/acpx-codex-package-contract.test.mjs test/acpx-rich-extensions-package-contract.test.mjs scripts/aws-agentcore-provisioning.test.mjs scripts/build-verified-provider-entrypoints.test.mjs scripts/local-provider-smoke-environment.test.mjs scripts/materialize-opencode-binary.test.mjs",
"test:typescript:vitest": "node ./scripts/run-pr-vitest-lane.mjs",
"test:rust": "cargo test --release --manifest-path runner/Cargo.toml --locked --workspace",
"test:codex": "cargo test --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --test codex_provider",
@@ -85,7 +85,7 @@
{
"path": "schemas/question-set.schema.json",
"id": "https://paperclip.dev/schemas/prp/v1/question-set.schema.json",
"sha256": "42b5441a3d388851dacb6e4500dfd4a17d878eded2e724228078b647e7440d3f"
"sha256": "6aeb2b880a60356cb48acfe26605acd9412a1df65f5308397d3ec521db5ea70f"
},
{
"path": "schemas/request.schema.json",
@@ -7,7 +7,7 @@
"properties": {
"schema": { "const": "paperclip.question_set.v1" },
"title": { "type": "string", "maxLength": 1000 },
"description": { "type": "string", "maxLength": 4000 },
"description": { "type": "string", "maxLength": 100000 },
"submitLabel": { "type": "string", "maxLength": 200 },
"questions": {
"type": "array",
@@ -1204,31 +1204,52 @@ impl AcpxCommandExecutor {
.get("requestId")
.and_then(Value::as_str)
.ok_or_else(|| DurableRunnerError::invalid("request.resolve requires requestId"))?;
let response = payload
.get("response")
.ok_or_else(|| DurableRunnerError::invalid("request.resolve requires response"))?;
let turn_id = self
.state
.as_ref()
.and_then(|state| state.active_turn_id.clone())
.ok_or_else(|| DurableRunnerError::invalid("ACPX provider has no active turn"))?;
self.session
if payload
.get("turnId")
.and_then(Value::as_str)
.is_some_and(|id| id != self.context.turn_id)
{
return Err(DurableRunnerError::invalid(
"ACPX runtime response belongs to a stale turn",
));
}
let session = self
.session
.as_mut()
.ok_or_else(|| DurableRunnerError::invalid("ACPX session is unavailable"))?
.resolve_input(
request_id,
&turn_id,
&json!({"action": "submit", "response": response}),
)
.map_err(|error| {
DurableRunnerError::invalid(format!("ACPX runtime response failed: {error}"))
.ok_or_else(|| DurableRunnerError::invalid("ACPX session is unavailable"))?;
let resolution = payload
.get("resolution")
.cloned()
.or_else(|| {
payload
.get("response")
.map(|response| json!({"action":"submit","response":response}))
})
.ok_or_else(|| {
DurableRunnerError::invalid("request.resolve requires resolution or response")
})?;
let permission = session.state().pending_permission(request_id).is_some();
(if permission {
session.resolve_permission(request_id, &turn_id, &resolution)
} else {
session.resolve_input(request_id, &turn_id, &resolution)
})
.map_err(|error| {
DurableRunnerError::invalid(format!("ACPX runtime response failed: {error}"))
})?;
Ok(CommandExecution {
result: json!({"status": "delivered", "requestId": request_id}),
events: vec![(
"runtime_request.resolved".to_owned(),
EventPriority::P0,
json!({"provider": "acpx", "requestId": request_id, "status": "delivered"}),
json!({"provider": "acpx", "requestId": request_id, "status": "delivered",
"requestKind": if permission {"permission_approval"} else {"runtime"},
"turnId":self.context.turn_id,"itemId":self.context.item_id,"action":resolution.get("action")}),
)],
})
}
@@ -561,9 +561,16 @@ impl AcpxProviderSession {
);
}
AcpxProviderStateEvent::PermissionRequest { .. } => {
return Err(self.fail_closed(LocalRunnerError::invalid(
"ACPX permission request violated the pinned runner policy",
)));
if self.config.agent == "codex"
|| matches!(
self.config.permission_mode,
AcpxPermissionMode::ApproveAll | AcpxPermissionMode::DenyAll
)
{
return Err(self.fail_closed(LocalRunnerError::invalid(
"ACPX permission request violated the pinned runner policy",
)));
}
}
_ => {}
}
@@ -628,6 +635,52 @@ impl AcpxProviderSession {
Ok(())
}
pub fn resolve_permission(
&mut self,
request_id: &str,
turn_id: &str,
resolution: &Value,
) -> Result<(), LocalRunnerError> {
self.ensure_bound_turn(turn_id)?;
let details = self.state.pending_permission(request_id).ok_or_else(|| {
LocalRunnerError::invalid("ACPX permission request is stale or unknown")
})?;
let object = resolution.as_object().ok_or_else(|| {
LocalRunnerError::invalid("ACPX permission resolution must be an object")
})?;
let action = object.get("action").and_then(Value::as_str).unwrap_or("");
if object.len() != 1
|| !matches!(
action,
"accept" | "accept_for_session" | "decline" | "cancel"
)
|| !details
.get("choices")
.and_then(Value::as_array)
.is_some_and(|choices| {
choices
.iter()
.any(|choice| choice.get("key").and_then(Value::as_str) == Some(action))
})
{
return Err(LocalRunnerError::invalid(
"ACPX permission resolution is not an offered choice",
));
}
let mut next_state = self.state.clone();
next_state.complete_permission(request_id)?;
let response = match self.transport.request(
GeneratedAcpxSidecarCommand::PermissionResolve,
json!({"requestId":request_id,"turnId":turn_id,"resolution":resolution}),
) {
Ok(response) => response,
Err(error) => return Err(self.fail_closed(error)),
};
self.verify_resolution(&response, "permission")?;
self.state = next_state;
Ok(())
}
pub fn resolve_input(
&mut self,
request_id: &str,
@@ -101,7 +101,7 @@ pub struct AcpxProviderState {
assistant_message_id: Option<String>,
pending_tools: BTreeMap<String, AcpxPendingTool>,
pending_tool_input_bytes: usize,
pending_permissions: BTreeMap<String, usize>,
pending_permissions: BTreeMap<String, (usize, Value)>,
pending_inputs: BTreeMap<String, PendingInput>,
pending_runtime_request_bytes: usize,
semantic_result: Option<AcpxSemanticResult>,
@@ -224,7 +224,7 @@ impl AcpxProviderState {
));
}
self.pending_permissions
.insert(request_id.clone(), value_bytes);
.insert(request_id.clone(), (value_bytes, details.clone()));
self.pending_runtime_request_bytes += value_bytes;
Ok(vec![AcpxProviderStateEvent::PermissionRequest {
request_id,
@@ -379,7 +379,7 @@ impl AcpxProviderState {
}
pub fn complete_permission(&mut self, request_id: &str) -> Result<(), LocalRunnerError> {
let value_bytes = self.pending_permissions.remove(request_id).ok_or_else(|| {
let (value_bytes, _) = self.pending_permissions.remove(request_id).ok_or_else(|| {
LocalRunnerError::invalid("ACPX permission result has no pending request")
})?;
self.pending_runtime_request_bytes = self
@@ -388,6 +388,12 @@ impl AcpxProviderState {
Ok(())
}
pub fn pending_permission(&self, request_id: &str) -> Option<&Value> {
self.pending_permissions
.get(request_id)
.map(|(_, details)| details)
}
pub fn complete_input(&mut self, request_id: &str) -> Result<(), LocalRunnerError> {
let provider_request_id = self
.pending_inputs
@@ -226,6 +226,8 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
| "turns-mismatched-reserved-result-terminal"
| "turns-unauthorized-tool"
| "turns-permission"
| "permissions-interactive"
| "permissions-wrong-ack"
| "resolutions"
| "resolutions-error-redaction"
| "resolutions-projected-id"
@@ -387,7 +389,12 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
)?;
next_sequence += 1;
}
if command == "turn.start" && mode == "turns-permission" {
if command == "turn.start"
&& matches!(
mode,
"turns-permission" | "permissions-interactive" | "permissions-wrong-ack"
)
{
write_turn_event(
&mut stdout,
next_sequence,
@@ -398,6 +405,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
"requestId":"permission-1",
"kind":"execute",
"title":"Run a command?",
"choices":[{"key":"accept","label":"Allow once"},{"key":"cancel","label":"Cancel"}],
}),
)?;
next_sequence += 1;
@@ -671,6 +679,9 @@ fn bootstrap_success(
profile_digest: &str,
) -> Value {
if command == "permission.resolve" {
if mode.starts_with("permissions-") {
return json!({"protocolVersion": GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION, "id": id, "ok":true, "result":{"resolved": mode != "permissions-wrong-ack"}});
}
return json!({
"protocolVersion": GENERATED_ACPX_SIDECAR_PROTOCOL_VERSION,
"id": id,
@@ -195,9 +195,47 @@ pub fn project_acpx_state_event(
AcpxProviderStateEvent::ToolResult(result) => {
Ok(vec![project_acpx_tool_result(context, result)?])
}
AcpxProviderStateEvent::PermissionRequest { .. } => Err(LocalRunnerError::invalid(
"ACPX permission request reached projection outside the pinned runner policy",
)),
AcpxProviderStateEvent::PermissionRequest {
request_id,
title,
details,
..
} => {
validate_projection_identity(request_id, "permission request", SHORT_STABLE_ID_CHARS)?;
let choices = details
.get("choices")
.and_then(Value::as_array)
.filter(|choices| !choices.is_empty() && choices.len() <= 4)
.ok_or_else(|| {
LocalRunnerError::invalid("ACPX permission request omitted its choices")
})?;
let mut seen = std::collections::HashSet::new();
for choice in choices {
let key = choice.get("key").and_then(Value::as_str).unwrap_or("");
if !matches!(key, "accept" | "accept_for_session" | "decline" | "cancel")
|| !seen.insert(key)
|| choice
.get("label")
.and_then(Value::as_str)
.is_none_or(|label| label.is_empty() || label.len() > 500)
{
return Err(LocalRunnerError::invalid(
"ACPX permission request contains invalid choices",
));
}
}
one(
"runtime_request.created",
EventPriority::P0,
json!({"request": {
"schema":"paperclip.runtime_request.v2", "requestKind":"permission_approval",
"requestId":request_id, "turnId":context.turn_id, "itemId":context.item_id,
"type":"permission", "status":"pending", "prompt":title, "choices":choices,
"details":details,
"origin":{"adapter":"acpx-runtime-sidecar","provider":"acpx","method":"session/request_permission"},
}}),
)
}
AcpxProviderStateEvent::InputRequest {
request_id,
question_set,
@@ -366,7 +366,29 @@ fn projects_assistant_terminal_and_diagnostic_events_fail_closed() {
assert!(project_acpx_state_event(&context(), &permission)
.unwrap_err()
.to_string()
.contains("pinned runner policy"));
.contains("omitted its choices"));
}
#[test]
fn projects_only_the_permission_choices_offered_by_the_provider() {
let events = project(AcpxProviderStateEvent::PermissionRequest {
request_id: "permission-1".to_owned(),
kind: "write".to_owned(),
title: "Edit source".to_owned(),
details: json!({"choices":[{"key":"accept","label":"Allow once"},{"key":"cancel","label":"Cancel"}]}),
});
assert_eq!(events[0].event_type, "runtime_request.created");
assert_eq!(
events[0].payload["request"]["requestKind"],
"permission_approval"
);
assert_eq!(
events[0].payload["request"]["choices"]
.as_array()
.unwrap()
.len(),
2
);
}
#[test]
@@ -260,3 +260,42 @@ fn fails_closed_when_a_resolution_is_not_acknowledged() {
assert!(error.contains("did not confirm tool resolution"), "{error}");
assert!(session.shutdown("already closed").is_ok());
}
#[test]
fn interactive_permission_requires_an_offered_action_and_acknowledgement() {
for mode in ["permissions-interactive", "permissions-wrong-ack"] {
let mut cfg = config(mode);
cfg.agent = "claude".to_owned();
cfg.model = "claude-sonnet-5".to_owned();
let mut session = AcpxProviderSession::start(&cfg).unwrap();
session
.start_turn("turn-1", "Run validation", &std::env::temp_dir())
.unwrap();
session.poll_event(Duration::from_secs(1)).unwrap().unwrap();
assert!(session.state().pending_permission("permission-1").is_some());
assert!(session
.resolve_permission("permission-1", "old-turn", &json!({"action":"accept"}))
.is_err());
assert!(session
.resolve_permission(
"permission-1",
"turn-1",
&json!({"action":"accept_for_session"})
)
.is_err());
assert!(session.state().pending_permission("permission-1").is_some());
let result =
session.resolve_permission("permission-1", "turn-1", &json!({"action":"accept"}));
if mode == "permissions-wrong-ack" {
assert!(result.is_err());
assert!(session.state().pending_permission("permission-1").is_some());
} else {
result.unwrap();
assert!(session.state().pending_permission("permission-1").is_none());
assert!(session
.resolve_permission("permission-1", "turn-1", &json!({"action":"accept"}))
.is_err());
}
session.shutdown("verified").unwrap();
}
}
@@ -7,6 +7,8 @@ import type {
AcpElicitationRequest,
AcpElicitationResponse,
AcpRuntimeEvent,
AcpPermissionRequest,
AcpPermissionDecision,
} from "acpx/runtime";
import { createAcpxToolEventNormalizer } from "../provider-events.js";
@@ -25,6 +27,7 @@ import {
} from "../drivers/acpx/acp-question-adapter.js";
import { openCodexAcpxRuntime } from "../drivers/acpx/codex-runtime-adapter.js";
import { AcpxApprovalRequiredError } from "../drivers/acpx/permission-policy.js";
import { normalizeAcpxPermission, type NormalizedAcpxPermission } from "../drivers/acpx/acp-permission-adapter.js";
import { acpxGoalProjection } from "../drivers/acpx/session-goals.js";
import { acpxProviderSessionIdentity } from "../drivers/acpx/recovery-identity.js";
import {
@@ -119,6 +122,13 @@ interface PendingInput {
cleanup(): void;
}
interface PendingPermission {
turnId: string;
normalized: NormalizedAcpxPermission;
settle(response: AcpPermissionDecision): void;
cleanup(): void;
}
let host: AcpxRuntimeHost | null = null;
let activeHostCleanup: Promise<void> | null = null;
let failedAdmissionCleanup: Promise<void> | null = null;
@@ -136,6 +146,7 @@ let initializedModel: string | null = null;
let inputClosed = false;
const tools = new Map<string, PendingTool>();
const inputs = new Map<string, PendingInput>();
const permissions = new Map<string, PendingPermission>();
const lines = createInterface({
input: process.stdin,
@@ -339,6 +350,8 @@ async function dispatch(
text: boundedText(request.params.message, "message", 1024 * 1024),
onElicitation: (providerRequest, context) =>
waitForInput(currentTurnId, providerRequest, context),
onPermissionRequest: (providerRequest, context) =>
waitForPermission(currentTurnId, providerRequest, context.signal),
});
} catch (error) {
turnId = null;
@@ -360,9 +373,18 @@ async function dispatch(
return { cancelled: true };
}
if (request.command === "permission.resolve") {
throw new Error(
"ACPX permissions are resolved by the admitted runner policy",
);
const requestId = boundedIdentity(request.params.requestId, "requestId");
const expectedTurnId = boundedIdentity(request.params.turnId, "turnId");
const pending = permissions.get(requestId);
if (!pending || pending.turnId !== expectedTurnId || turnId !== expectedTurnId) {
throw new Error("permission request is stale or unknown");
}
const resolution = parseHarnessRuntimeRequestResolution("permission_approval", request.params.resolution);
const decision = pending.normalized.resolve(resolution);
if (!permissions.delete(requestId)) throw new Error("permission request lost its settlement race");
pending.cleanup();
pending.settle(decision);
return { resolved: true };
}
if (request.command === "input.resolve") {
const requestId = boundedIdentity(request.params.requestId, "requestId");
@@ -668,6 +690,35 @@ async function waitForTool(call: RunnerToolCall): Promise<unknown> {
});
}
async function waitForPermission(
activeTurnId: string,
request: AcpPermissionRequest,
signal: AbortSignal,
): Promise<AcpPermissionDecision> {
if (turnId !== activeTurnId || signal.aborted || permissions.size >= MAX_PENDING_INPUTS) {
return { outcome: "cancel" };
}
const normalized = normalizeAcpxPermission(request);
const requestId = stableRequestId(activeTurnId, ++requestSequence, normalized.toolCallId);
return await new Promise((settle) => {
const abort = () => {
if (!permissions.delete(requestId)) return;
signal.removeEventListener("abort", abort);
settle({ outcome: "cancel" });
};
permissions.set(requestId, {
turnId: activeTurnId, normalized, settle,
cleanup: () => signal.removeEventListener("abort", abort),
});
signal.addEventListener("abort", abort, { once: true });
if (signal.aborted) { abort(); return; }
emit("runtime.permission_requested", {
requestId, kind: normalized.kind, title: normalized.title,
toolCallId: normalized.toolCallId, choices: normalized.choices,
}, activeTurnId);
});
}
async function waitForInput(
activeTurnId: string,
request: AcpElicitationRequest,
@@ -761,6 +812,11 @@ function elicitationResponse(
}
function rejectTurnWaiters(terminalTurnId: string, message: string): void {
for (const [requestId, pending] of permissions) {
if (pending.turnId !== terminalTurnId || !permissions.delete(requestId)) continue;
pending.cleanup();
pending.settle({ outcome: "cancel" });
}
for (const [callId, pending] of tools) {
if (pending.turnId !== terminalTurnId || !tools.delete(callId)) continue;
pending.cleanup();
@@ -262,8 +262,8 @@ export function parsePaperclipQuestionSet(value: unknown): PaperclipQuestionSet
...(optionalText(candidate.title, "/input/title", 1_000) !== undefined
? { title: optionalText(candidate.title, "/input/title", 1_000) }
: {}),
...(optionalText(candidate.description, "/input/description") !== undefined
? { description: optionalText(candidate.description, "/input/description") }
...(optionalText(candidate.description, "/input/description", 100_000) !== undefined
? { description: optionalText(candidate.description, "/input/description", 100_000) }
: {}),
...(optionalText(candidate.submitLabel, "/input/submitLabel", 200) !== undefined
? { submitLabel: optionalText(candidate.submitLabel, "/input/submitLabel", 200) }
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import type { AcpPermissionRequest } from "acpx/runtime";
import { normalizeAcpxPermission } from "./acp-permission-adapter.js";
function request(kinds = ["allow_once", "allow_always", "reject_once"]): AcpPermissionRequest {
return { sessionId: "session", inferredKind: "edit", raw: {
sessionId: "session", toolCall: { toolCallId: "call", title: "Edit source" },
options: kinds.map((kind, i) => ({ kind, optionId: `option-${i}`, name: kind })),
} } as AcpPermissionRequest;
}
describe("ACP permission normalization", () => {
it("offers only native choices and preserves cancellation", () => {
const value = normalizeAcpxPermission(request(["allow_once"]));
expect(value.choices.map(c => c.key)).toEqual(["accept", "cancel"]);
expect(value.resolve({ action: "accept" })).toEqual({ outcome: "allow_once" });
expect(value.resolve({ action: "cancel" })).toEqual({ outcome: "cancel" });
expect(() => value.resolve({ action: "accept_for_session" })).toThrow("offered choice");
expect(() => value.resolve({ action: "submit", content: { accept: true } })).toThrow("offered choice");
});
it("maps once, session and denial distinctly", () => {
const value = normalizeAcpxPermission(request());
expect(value.resolve({ action: "accept_for_session" })).toEqual({ outcome: "allow_always" });
expect(value.resolve({ action: "decline" })).toEqual({ outcome: "reject_once" });
});
it("does not turn a one-time denial into permanent rejection", () => {
expect(normalizeAcpxPermission(request(["reject_always"])).choices).toEqual([{ key: "cancel", label: "Cancel" }]);
});
it("rejects ambiguous provider choices before any resolution", () => {
expect(() => normalizeAcpxPermission(request(["allow_once", "allow_once"]))).toThrow("ambiguous");
const duplicate = request(); duplicate.raw.options[1]!.optionId = duplicate.raw.options[0]!.optionId;
expect(() => normalizeAcpxPermission(duplicate)).toThrow("ambiguous");
});
});
@@ -0,0 +1,61 @@
import type { AcpPermissionDecision, AcpPermissionRequest } from "acpx/runtime";
import type { HarnessRuntimeRequestResolution } from "../../contracts/harness-driver.js";
export type AcpxPermissionAction = "accept" | "accept_for_session" | "decline" | "cancel";
export interface NormalizedAcpxPermission {
title: string;
kind: string;
toolCallId: string;
choices: Array<{ key: AcpxPermissionAction; label: string }>;
resolve(resolution: HarnessRuntimeRequestResolution): AcpPermissionDecision;
}
/** Provider options describe choices, never authority to execute an operation. */
export function normalizeAcpxPermission(request: AcpPermissionRequest): NormalizedAcpxPermission {
const raw = request.raw;
if (!raw || !Array.isArray(raw.options) || raw.options.length > 32) {
throw new Error("ACP permission request has invalid options");
}
const byKind = new Map<string, { optionId: string; name: string }>();
const ids = new Set<string>();
for (const option of raw.options) {
if (!option || !["allow_once", "allow_always", "reject_once", "reject_always"].includes(option.kind)
|| typeof option.optionId !== "string" || !option.optionId || option.optionId.length > 160
|| typeof option.name !== "string" || !option.name || option.name.length > 500
|| ids.has(option.optionId) || byKind.has(option.kind)) {
// ACPX resolves by kind. Ambiguous kinds must not select a different option.
throw new Error("ACP permission request has ambiguous or unsupported options");
}
ids.add(option.optionId);
byKind.set(option.kind, { optionId: option.optionId, name: option.name });
}
const bindings = new Map<AcpxPermissionAction, AcpPermissionDecision["outcome"]>();
const choices: NormalizedAcpxPermission["choices"] = [];
for (const [key, kind, label] of [
["accept", "allow_once", "Allow once"],
["accept_for_session", "allow_always", "Allow for session"],
["decline", "reject_once", "Deny"],
] as const) {
if (byKind.has(kind)) { bindings.set(key, kind); choices.push({ key, label }); }
}
// A permanent rejection is not silently substituted for a one-time denial.
bindings.set("cancel", "cancel");
choices.push({ key: "cancel", label: "Cancel" });
const call = raw.toolCall;
if (!call || typeof call.toolCallId !== "string" || !call.toolCallId || call.toolCallId.length > 240) {
throw new Error("ACP permission request omitted its tool identity");
}
return {
title: typeof call.title === "string" ? call.title.slice(0, 4_000) : "Approve provider operation",
kind: request.inferredKind ?? "other",
toolCallId: call.toolCallId,
choices,
resolve(resolution) {
const outcome = bindings.get(resolution.action as AcpxPermissionAction);
if (!outcome || "answers" in resolution || "content" in resolution || "response" in resolution) {
throw new Error("ACP permission resolution is not an offered choice");
}
return { outcome } as AcpPermissionDecision;
},
};
}
@@ -1459,6 +1459,40 @@ describe("Codex ACPX harness driver", () => {
});
});
it("delivers only offered permission choices and rejects stale or duplicate answers", async () => {
const fixture = driverFixture({ agent: "claude", model: "claude-sonnet-5" });
const session = await fixture.driver.openSession({
runId: "run-permission", normalizedSessionId: "session-1", workingDirectory: "/workspace",
});
const created = collectUntil(session.events(), "runtime_request.created");
const { turnId } = await session.startTurn({ message: { role: "user", text: "Run validation." } });
const callback = fixture.host.startTurn.mock.calls[0]![0].onPermissionRequest!;
const response = callback({
inferredKind: "execute", raw: {
sessionId: "agent-session-1", toolCall: { toolCallId: "tool-permission", title: "Run validation" },
options: [{ optionId: "once", kind: "allow_once", name: "Allow once" }],
},
} as Parameters<typeof callback>[0], { signal: new AbortController().signal });
const events = await created;
const request = session.pendingRuntimeRequests!()[0]!;
expect(events.at(-1)?.payload).toMatchObject({ request: {
type: "permission", choices: [{ key: "accept" }, { key: "cancel" }],
origin: { method: "session/request_permission" },
} });
await expect(session.resolveRuntimeRequest!({ requestId: request.requestId, turnId: "old-turn",
resolution: { action: "accept" } })).rejects.toThrow();
await expect(session.resolveRuntimeRequest!({ requestId: request.requestId, turnId,
resolution: { action: "accept_for_session" } })).rejects.toThrow("offered choice");
expect(session.pendingRuntimeRequests!()).toHaveLength(1);
await session.resolveRuntimeRequest!({ requestId: request.requestId, turnId, resolution: { action: "accept" } });
await expect(response).resolves.toEqual({ outcome: "allow_once" });
await expect(session.resolveRuntimeRequest!({ requestId: request.requestId, turnId,
resolution: { action: "accept" } })).rejects.toThrow("no longer pending");
fixture.finishTurn({ status: "completed", stopReason: "end_turn" });
await collectUntil(session.events(), "turn.completed");
await session.close({ reason: "permission verified" });
});
it("round-trips a provider-neutral ACP form through the runtime request boundary", async () => {
const fixture = driverFixture();
const session = await fixture.driver.openSession({
@@ -5,6 +5,8 @@ import type {
AcpElicitationRequest,
AcpElicitationResponse,
AcpRuntimeEvent,
AcpPermissionRequest,
AcpPermissionDecision,
} from "acpx/runtime";
import {
@@ -51,6 +53,7 @@ import {
DEFAULT_CODEX_ACPX_RUNTIME_SHUTDOWN_BOUND_MS,
openCodexAcpxRuntime,
} from "./codex-runtime-adapter.js";
import { normalizeAcpxPermission } from "./acp-permission-adapter.js";
import {
normalizeAcpFormElicitation,
type NormalizedAcpForm,
@@ -108,8 +111,8 @@ const QUARANTINED_HOST_ADMISSION_GRACE_MS =
interface PendingAcpxRuntimeRequest {
request: HarnessRuntimeRequest;
normalized: NormalizedAcpForm;
settle(response: AcpElicitationResponse): void;
prepareResolution(resolution: HarnessRuntimeRequestResolution): () => void;
cancel(): void;
cleanup(): void;
settling: boolean;
}
@@ -860,6 +863,8 @@ class CodexAcpxSession implements HarnessSession {
turn = this.#host.startTurn({
text: input.message.text,
requestId: `${safeId(this.#input.runId, "run")}:${turnId}`,
onPermissionRequest: (request, context) =>
this.#handlePermission(turnId, request, context.signal),
onElicitation: (request, context) =>
this.#handleElicitation(turnId, request, context),
});
@@ -940,10 +945,7 @@ class CodexAcpxSession implements HarnessSession {
input.resolution,
pending.request.input,
);
const providerResponse = acpElicitationResponse(
pending.normalized,
resolution,
);
const deliver = pending.prepareResolution(resolution);
if (!this.#pendingRuntimeRequests.delete(input.requestId)) return;
pending.cleanup();
this.#emit(
@@ -956,7 +958,7 @@ class CodexAcpxSession implements HarnessSession {
}),
{ turnId: input.turnId, itemId: pending.request.itemId },
);
pending.settle(providerResponse);
deliver();
} catch (error) {
pending.settling = false;
throw error;
@@ -1000,7 +1002,7 @@ class CodexAcpxSession implements HarnessSession {
);
}
pending.cleanup();
pending.settle({ action: "cancel" });
pending.cancel();
const cleanup = Promise.resolve()
.then(() =>
this.#host.interruptActiveTurn(
@@ -1579,6 +1581,53 @@ class CodexAcpxSession implements HarnessSession {
}
}
async #handlePermission(
turnId: string,
request: AcpPermissionRequest,
signal: AbortSignal,
): Promise<AcpPermissionDecision> {
if (this.#closed || this.#activeTurnId !== turnId || signal.aborted
|| this.#pendingRuntimeRequests.size >= MAX_PENDING_RUNTIME_REQUESTS) {
return { outcome: "cancel" };
}
const normalized = normalizeAcpxPermission(request);
const requestId = stableId("acpx-permission", `${turnId}:${++this.#runtimeRequestSequence}:${normalized.toolCallId}`);
const runtimeRequest: HarnessRuntimeRequest = {
requestId, requestKind: "permission_approval", method: "session/request_permission",
turnId, itemId: requestId, status: "pending", prompt: normalized.title,
details: { choices: normalized.choices, toolCallId: normalized.toolCallId, kind: normalized.kind },
origin: { adapter: "acpx-runtime", provider: this.#agent, method: "session/request_permission" },
};
return await new Promise<AcpPermissionDecision>((settle) => {
const cancel = () => {
const pending = this.#pendingRuntimeRequests.get(requestId);
if (!pending || pending.settling || !this.#pendingRuntimeRequests.delete(requestId)) return;
pending.cleanup();
this.#emit("runtime_request.cancelled", harnessRuntimeRequestOutcome(runtimeRequest, {
action: "cancel", reason: "provider request aborted",
}), { turnId, itemId: requestId });
settle({ outcome: "cancel" });
};
this.#pendingRuntimeRequests.set(requestId, {
request: runtimeRequest,
prepareResolution: (resolution) => {
const response = normalized.resolve(resolution);
return () => settle(response);
},
cancel: () => settle({ outcome: "cancel" }),
cleanup: () => signal.removeEventListener("abort", cancel),
settling: false,
});
signal.addEventListener("abort", cancel, { once: true });
if (!this.#emit("runtime_request.created", { request: {
schema: PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2, requestKind: "permission_approval",
requestId, type: "permission", status: "pending", prompt: normalized.title,
choices: normalized.choices, details: runtimeRequest.details,
origin: runtimeRequest.origin, turnId, itemId: requestId,
} }, { turnId, itemId: requestId }) || signal.aborted) cancel();
});
}
async #handleElicitation(
turnId: string,
request: AcpElicitationRequest,
@@ -1700,8 +1749,11 @@ class CodexAcpxSession implements HarnessSession {
context.signal.addEventListener("abort", cancel, { once: true });
this.#pendingRuntimeRequests.set(requestId, {
request: runtimeRequest,
normalized,
settle,
prepareResolution: (resolution) => {
const response = acpElicitationResponse(normalized, resolution);
return () => settle(response);
},
cancel: () => settle({ action: "cancel" }),
cleanup: () => context.signal.removeEventListener("abort", cancel),
settling: false,
});
@@ -1725,7 +1777,7 @@ class CodexAcpxSession implements HarnessSession {
itemId: pending.request.itemId,
},
);
pending.settle({ action: "cancel" });
pending.cancel();
}
}
@@ -245,7 +245,10 @@ export async function openQualifiedAcpxRuntime(
.filter((server) => server.runnerOwned)
.map((server) => server.name),
);
const permissionBoundary: { active: AbortController | null } = { active: null };
const permissionBoundary: {
active: AbortController | null;
handler?: AcpRuntimeOptions["onPermissionRequest"];
} = { active: null };
const goalState: AcpxRuntimeGoalState = {
capability: null,
snapshot: null,
@@ -301,7 +304,7 @@ export async function openQualifiedAcpxRuntime(
{ name: "Authorization", value: `Bearer ${server.bearerToken}` },
],
})),
onPermissionRequest: async (request) => {
onPermissionRequest: async (request, context) => {
const disposition = decideAcpxPermission(
options.profile.agent,
options.permissionMode,
@@ -314,6 +317,19 @@ export async function openQualifiedAcpxRuntime(
},
);
if (disposition === "delegate") {
const active = permissionBoundary.active;
const handler = permissionBoundary.handler;
if (active && handler && !active.signal.aborted && !context.signal.aborted) {
// Capture this turn's callback before awaiting. A session-lifetime
// callback must never acquire the next turn's approval authority.
const decision = await handler(request, {
signal: AbortSignal.any([active.signal, context.signal]),
});
if (permissionBoundary.active !== active || active.signal.aborted || context.signal.aborted) {
return { outcome: "cancel" };
}
return decision ?? { outcome: "cancel" };
}
// This runtime has no interactive approval bridge. Stop the active
// turn instead of asking the model to recover from an unexplained
// denial or wait for an approval that nobody can answer.
@@ -881,7 +897,7 @@ function runtimePort(
runtimeCloseTimeoutMs: number,
goalState: AcpxRuntimeGoalState,
commandLaunches: { count: number; refreshConsumedCommand?: () => Promise<void> },
permissionBoundary: { active: AbortController | null },
permissionBoundary: { active: AbortController | null; handler?: AcpRuntimeOptions["onPermissionRequest"] },
): AcpxRuntimePort {
type RuntimeCloseAttempt = {
readonly outcome: Promise<unknown | null>;
@@ -1207,6 +1223,7 @@ function runtimePort(
startTurn(input) {
const approval = new AbortController();
permissionBoundary.active = approval;
permissionBoundary.handler = input.onPermissionRequest;
const finishOwnershipAdmission =
children.beginLifetimeOwnershipAdmission();
let turn: AcpxRuntimeTurn;
@@ -1224,7 +1241,7 @@ function runtimePort(
: {}),
});
} catch (error) {
if (permissionBoundary.active === approval) permissionBoundary.active = null;
if (permissionBoundary.active === approval) { permissionBoundary.active = null; permissionBoundary.handler = undefined; }
void finishOwnershipAdmission().catch(() => undefined);
throw error;
}
@@ -1233,7 +1250,7 @@ function runtimePort(
(value) => { approval.signal.throwIfAborted(); return value; },
(error: unknown) => { approval.signal.throwIfAborted(); throw error; },
).finally(() => {
if (permissionBoundary.active === approval) permissionBoundary.active = null;
if (permissionBoundary.active === approval) { permissionBoundary.active = null; permissionBoundary.handler = undefined; approval.abort(); }
});
void result.catch(() => undefined);
return {
@@ -4,6 +4,7 @@ import { nativeMcpLaunchBinding } from "../native-mcp.js";
import type {
AcpElicitationHandler,
AcpRuntimeOptions,
AcpRuntimeEvent,
AcpRuntimeTurnResult,
} from "acpx/runtime";
@@ -99,6 +100,7 @@ export interface AcpxRuntimeTurnInput {
requestId: string;
signal?: AbortSignal;
onElicitation?: AcpElicitationHandler;
onPermissionRequest?: AcpRuntimeOptions["onPermissionRequest"];
}
export interface AcpxRuntimeTurn {
@@ -660,6 +662,7 @@ export class AcpxRuntimeHost {
requestId,
...(input.signal ? { signal: input.signal } : {}),
...(input.onElicitation ? { onElicitation: input.onElicitation } : {}),
...(input.onPermissionRequest ? { onPermissionRequest: input.onPermissionRequest } : {}),
});
this.#activeTurn = turn;
void turn.result
@@ -70,7 +70,7 @@ export function runtimeRequestKind(method: string): HarnessRuntimeRequestKind |
) {
return "file_approval";
}
if (method === "item/permissions/requestApproval")
if (method === "item/permissions/requestApproval" || method === "session/request_permission")
return "permission_approval";
if (
method === "item/tool/requestUserInput" ||
@@ -447,6 +447,9 @@ export function normalizeCodexQuestionSet(
}
export function runtimeRequestProtocolPayload(request: HarnessRuntimeRequest): Record<string, unknown> {
if (request.method === "session/request_permission") {
return { ...request, schema: PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2, type: "permission", choices: request.details.choices };
}
if (request.input !== undefined) {
return {
schema: PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2,
@@ -554,7 +557,7 @@ export function runtimeRequestResponse(
): Record<string, unknown> {
// The durable transport sends this canonical resolution to the ACPX sidecar,
// which owns conversion back to the original provider form values.
if (request.method === "elicitation/create") return structuredClone(resolution);
if (request.method === "elicitation/create" || request.method === "session/request_permission") return structuredClone(resolution);
if (
request.requestKind === "command_approval" ||
request.requestKind === "file_approval"
@@ -292,7 +292,7 @@ async function handleServerRequestBody(
prompt: runtimeRequestPrompt(requestKind, request.params),
details: record(redactCodexValue(boundedCodexValue(request.params))),
...(input !== null ? { input } : {}),
origin: request.method === "elicitation/create" ? {
origin: request.method === "elicitation/create" || request.method === "session/request_permission" ? {
adapter: "acpx-runtime-sidecar",
provider: text(record(request.params.origin).provider, "acpx"),
method: request.method,
@@ -204,6 +204,7 @@ export function safeCodexRequestResponse(
method: string,
action: "decline" | "cancel" = "decline",
): Record<string, unknown> {
if (method === "session/request_permission") return { action: "cancel" };
if (method === "item/permissions/requestApproval") {
return { permissions: {}, scope: "turn" };
}
@@ -3344,7 +3344,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
readonly #traceFrameIndex = new RunnerdTraceFrameIndex();
#pendingTraceRehydrations: PendingTraceRehydration[] = [];
#pendingDriverTraceInterpretations: PendingDriverTraceInterpretation[] = [];
readonly #bridgedRuntimeInputs = new Map<string, { durableTurnId: string }>();
readonly #bridgedRuntimeInputs = new Map<string, { durableTurnId: string; permission?: boolean }>();
constructor(readonly options: CapabilityRunnerdCodexTransportOptions) {
if (options.adoptExistingRunner && !options.stateDirectory?.trim()) {
@@ -3872,7 +3872,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
throw new Error(
`PRP runtime request ${input.requestId} is no longer pending`,
);
if (!("response" in input.resolution)) {
if (!pending.permission && !("response" in input.resolution) && input.resolution.action !== "cancel" && input.resolution.action !== "decline") {
throw new Error(
"runnerd-native runtime requests require a canonical question response",
);
@@ -3886,7 +3886,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
{
requestId: input.requestId,
turnId: pending.durableTurnId,
response: input.resolution.response,
...("response" in input.resolution ? { response: input.resolution.response } : { resolution: input.resolution }),
},
commandId,
);
@@ -5907,7 +5907,13 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
: "";
const origin = record(normalizedRequest.origin);
const method = typeof origin.method === "string" ? origin.method : "";
const params = bridgedCodexQuestionParams(
const permission = normalizedRequest.type === "permission" && normalizedRequest.requestKind === "permission_approval"
&& method === "session/request_permission";
const params = permission ? {
threadId: this.#threadId, turnId: this.#turnId,
itemId: normalizedRequest.itemId, reason: normalizedRequest.prompt,
choices: normalizedRequest.choices, origin,
} : bridgedCodexQuestionParams(
normalizedRequest,
method,
this.#threadId,
@@ -5919,10 +5925,11 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
(method === "item/tool/requestUserInput" ||
method === "tool/requestUserInput" ||
method === "mcpServer/elicitation/request" ||
method === "elicitation/create") &&
method === "elicitation/create" || permission) &&
!this.#bridgedRuntimeInputs.has(requestId)
) {
this.#bridgedRuntimeInputs.set(requestId, {
permission,
durableTurnId:
typeof event.envelope.turnId === "string"
? event.envelope.turnId
@@ -2625,7 +2625,7 @@ export const questionSetSchema = {
},
"description": {
"type": "string",
"maxLength": 4000
"maxLength": 100000
},
"submitLabel": {
"type": "string",
@@ -4,4 +4,4 @@
import equalModule from "ajv/dist/runtime/equal.js";
import ucs2LengthModule from "ajv/dist/runtime/ucs2length.js";
"use strict";export const fixtureValidator: unknown = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/fixture.schema.json","title":"PRP scripted replay fixture","type":"object","required":["schema","fixtureVersion","protocolVersion","name","description","identity","capabilities","commands","events","result"],"properties":{"schema":{"const":"paperclip.prp.fixture.v1"},"fixtureVersion":{"const":1},"protocolVersion":{"enum":[1,2]},"name":{"type":"string","minLength":1,"maxLength":120},"description":{"type":"string","minLength":1,"maxLength":1000},"identity":{"$ref":"https://paperclip.dev/schemas/prp/v1/identity.schema.json"},"capabilities":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/capabilities.schema.json"}]},"commands":{"type":"array","items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/command.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/command.schema.json"}]}},"events":{"type":"array","minItems":1,"items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/event.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/event.schema.json"}]}},"requests":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/request.schema.json"}},"result":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}},"additionalProperties":true};const schema41 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json","title":"PRP negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v1"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"typedEventFamilies":{"type":"array","maxItems":64,"items":{"type":"object","required":["family","version","availability","detailLevel"],"properties":{"family":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.-]*$"},"version":{"const":1},"availability":{"enum":["available","unsupported","policy_disabled"]},"detailLevel":{"enum":["summary","structured"]}},"additionalProperties":false}},"semanticTools":{"type":"object","required":["schema","schemaVersion","operations"],"properties":{"schema":{"const":"paperclip.prp.semantic_tools.v1"},"schemaVersion":{"const":1},"operations":{"type":"array","items":{"type":"object","required":["operationId","version","availability","redactionDisposition"],"properties":{"operationId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"version":{"const":1},"availability":{"enum":["available","denied","unsupported"]},"redactionDisposition":{"const":"digest_only"},"requiredClaims":{"type":"array","items":{"type":"string","minLength":1,"maxLength":160},"uniqueItems":true},"reasonCode":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"}},"additionalProperties":true}}},"additionalProperties":true},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true}},"additionalProperties":true};const schema45 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/command.schema.json","title":"PRP runner command","type":"object","required":["schema","commandId","controllerSeq","type","issuedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.command.v1"},"commandId":{"type":"string","minLength":1,"maxLength":160},"controllerSeq":{"type":"integer","minimum":1},"type":{"enum":["run.prepare","run.attach","session.open","turn.start","turn.steer","turn.interrupt","turn.stop","request.resolve","interaction.receipt","semantic_tool.result","session.snapshot","session.close","session.budget.increase","session.destroy","run.cancel","runner.drain","runner.suspend","runner.shutdown"]},"issuedAt":{"type":"string","format":"date-time"},"deadlineAt":{"type":"string","format":"date-time"},"precondition":{"type":"object","properties":{"runnerState":{"type":"array","items":{"type":"string"}},"runState":{"type":"array","items":{"type":"string"}},"sessionState":{"type":"array","items":{"type":"string"}},"activeTurnId":{"type":["string","null"]}},"additionalProperties":true},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true};const schema46 = {"$schema":"https://json-schema.org/draft/2020-1 Line truncated
"use strict";export const fixtureValidator: unknown = validate20;const schema31 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/fixture.schema.json","title":"PRP scripted replay fixture","type":"object","required":["schema","fixtureVersion","protocolVersion","name","description","identity","capabilities","commands","events","result"],"properties":{"schema":{"const":"paperclip.prp.fixture.v1"},"fixtureVersion":{"const":1},"protocolVersion":{"enum":[1,2]},"name":{"type":"string","minLength":1,"maxLength":120},"description":{"type":"string","minLength":1,"maxLength":1000},"identity":{"$ref":"https://paperclip.dev/schemas/prp/v1/identity.schema.json"},"capabilities":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/capabilities.schema.json"}]},"commands":{"type":"array","items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/command.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/command.schema.json"}]}},"events":{"type":"array","minItems":1,"items":{"oneOf":[{"$ref":"https://paperclip.dev/schemas/prp/v1/event.schema.json"},{"$ref":"https://paperclip.dev/schemas/prp/v2/event.schema.json"}]}},"requests":{"type":"array","items":{"$ref":"https://paperclip.dev/schemas/prp/v1/request.schema.json"}},"result":{"$ref":"https://paperclip.dev/schemas/prp/v1/result.schema.json"}},"additionalProperties":true};const schema41 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/capabilities.schema.json","title":"PRP negotiated capabilities","type":"object","required":["schema","sessionReusePolicy","driver","steer","interrupt","resume","runtimeRequests","structuredResult","typedEvents"],"properties":{"schema":{"const":"paperclip.prp.capabilities.v1"},"sessionReusePolicy":{"enum":["new_per_run","reuse_per_issue","reuse_per_workspace"]},"driver":{"type":"object","required":["kind","version"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":80},"version":{"type":"string","minLength":1,"maxLength":80}},"additionalProperties":true},"steer":{"type":"boolean"},"interrupt":{"type":"boolean"},"resume":{"type":"boolean"},"runtimeRequests":{"type":"boolean"},"structuredResult":{"type":"boolean"},"typedEvents":{"type":"boolean"},"typedEventFamilies":{"type":"array","maxItems":64,"items":{"type":"object","required":["family","version","availability","detailLevel"],"properties":{"family":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.-]*$"},"version":{"const":1},"availability":{"enum":["available","unsupported","policy_disabled"]},"detailLevel":{"enum":["summary","structured"]}},"additionalProperties":false}},"semanticTools":{"type":"object","required":["schema","schemaVersion","operations"],"properties":{"schema":{"const":"paperclip.prp.semantic_tools.v1"},"schemaVersion":{"const":1},"operations":{"type":"array","items":{"type":"object","required":["operationId","version","availability","redactionDisposition"],"properties":{"operationId":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[A-Za-z0-9][A-Za-z0-9._:-]*$"},"version":{"const":1},"availability":{"enum":["available","denied","unsupported"]},"redactionDisposition":{"const":"digest_only"},"requiredClaims":{"type":"array","items":{"type":"string","minLength":1,"maxLength":160},"uniqueItems":true},"reasonCode":{"type":"string","minLength":1,"maxLength":160,"pattern":"^[a-z][a-z0-9_.:-]*$"}},"additionalProperties":true}}},"additionalProperties":true},"unsupported":{"type":"array","items":{"type":"string","minLength":1},"uniqueItems":true}},"additionalProperties":true};const schema45 = {"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://paperclip.dev/schemas/prp/v1/command.schema.json","title":"PRP runner command","type":"object","required":["schema","commandId","controllerSeq","type","issuedAt","payload"],"properties":{"schema":{"const":"paperclip.prp.command.v1"},"commandId":{"type":"string","minLength":1,"maxLength":160},"controllerSeq":{"type":"integer","minimum":1},"type":{"enum":["run.prepare","run.attach","session.open","turn.start","turn.steer","turn.interrupt","turn.stop","request.resolve","interaction.receipt","semantic_tool.result","session.snapshot","session.close","session.budget.increase","session.destroy","run.cancel","runner.drain","runner.suspend","runner.shutdown"]},"issuedAt":{"type":"string","format":"date-time"},"deadlineAt":{"type":"string","format":"date-time"},"precondition":{"type":"object","properties":{"runnerState":{"type":"array","items":{"type":"string"}},"runState":{"type":"array","items":{"type":"string"}},"sessionState":{"type":"array","items":{"type":"string"}},"activeTurnId":{"type":["string","null"]}},"additionalProperties":true},"payload":{"type":"object","additionalProperties":true}},"additionalProperties":true};const schema46 = {"$schema":"https://json-schema.org/draft/2020-1 Line truncated
+1 -1
View File
@@ -1329,7 +1329,7 @@ export const paperclipQuestionSetPayloadSchema = z
.object({
schema: z.literal("paperclip.question_set.v1"),
title: z.string().max(1000).optional(),
description: z.string().max(4000).optional(),
description: z.string().max(100_000).optional(),
submitLabel: z.string().max(200).optional(),
questions: z.array(paperclipQuestionSchema).min(1).max(64),
})