mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-18 15:39:47 +02:00
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:
1 parent
8193cb2571
commit
c810a25db2
25 files changed
+516
-59
No files matched your search
@@ -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]
|
||||
|
||||
+39
@@ -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
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user