1use std::collections::BTreeMap;
47
48use serde::{Deserialize, Serialize};
49use serde_json::Value;
50
51use crate::subagents::{QueuedApproval, QueuedApprovalOutcome};
52use crate::{HarnessEvent, HarnessId};
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum ApprovalKind {
58 Live,
60 Stored,
63 Proposal,
66}
67
68impl ApprovalKind {
69 pub const fn as_str(self) -> &'static str {
71 match self {
72 Self::Live => "live",
73 Self::Stored => "stored",
74 Self::Proposal => "proposal",
75 }
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum ApprovalStatus {
83 Pending,
85 Allowed,
87 Denied,
89 Expired,
91 Cancelled,
93}
94
95impl ApprovalStatus {
96 pub const fn as_str(self) -> &'static str {
98 match self {
99 Self::Pending => "pending",
100 Self::Allowed => "allowed",
101 Self::Denied => "denied",
102 Self::Expired => "expired",
103 Self::Cancelled => "cancelled",
104 }
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct ApprovalOption {
116 pub id: String,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub label: Option<String>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub kind: Option<String>,
124}
125
126impl ApprovalOption {
127 fn bare(id: &str) -> Self {
128 Self {
129 id: id.to_string(),
130 label: None,
131 kind: None,
132 }
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct ApprovalRow {
139 pub id: String,
143 pub harness: HarnessId,
145 pub kind: ApprovalKind,
147 pub status: ApprovalStatus,
149 pub subject: String,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub session_id: Option<String>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub runtime_id: Option<String>,
157 pub requested_at_ms: i64,
159 pub age_ms: i64,
161 #[serde(default)]
164 pub options: Vec<ApprovalOption>,
165}
166
167#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(default)]
170pub struct ApprovalsQuery {
171 #[serde(skip_serializing_if = "Option::is_none")]
173 pub harness: Option<String>,
174 #[serde(skip_serializing_if = "Option::is_none")]
177 pub session: Option<String>,
178}
179
180impl ApprovalsQuery {
181 pub fn matches(&self, row: &ApprovalRow) -> bool {
183 if let Some(harness) = self.harness.as_deref() {
184 if row.harness.as_str() != harness {
185 return false;
186 }
187 }
188 if let Some(session) = self.session.as_deref() {
189 let hit = row.session_id.as_deref() == Some(session)
190 || row.runtime_id.as_deref() == Some(session);
191 if !hit {
192 return false;
193 }
194 }
195 true
196 }
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(rename_all = "snake_case")]
203pub enum ApprovalDecision {
204 AllowOnce,
206 AllowAlways,
208 Deny,
210}
211
212impl ApprovalDecision {
213 pub const ALL: [Self; 3] = [Self::AllowOnce, Self::AllowAlways, Self::Deny];
215
216 pub const fn as_str(self) -> &'static str {
218 match self {
219 Self::AllowOnce => "allow_once",
220 Self::AllowAlways => "allow_always",
221 Self::Deny => "deny",
222 }
223 }
224
225 pub fn parse(text: &str) -> Option<Self> {
228 let normalized = text.trim().to_ascii_lowercase().replace('-', "_");
229 Self::ALL
230 .into_iter()
231 .find(|decision| decision.as_str() == normalized)
232 }
233}
234
235#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(default)]
242pub struct ApprovalsResolveParams {
243 pub id: String,
245 #[serde(skip_serializing_if = "Option::is_none")]
247 pub decision: Option<ApprovalDecision>,
248 #[serde(skip_serializing_if = "Option::is_none")]
250 pub option_id: Option<String>,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq)]
255pub enum ApprovalChoice {
256 Decision(ApprovalDecision),
258 Option(String),
260}
261
262impl ApprovalChoice {
263 pub fn asked(&self) -> &str {
265 match self {
266 Self::Decision(decision) => decision.as_str(),
267 Self::Option(option) => option.as_str(),
268 }
269 }
270}
271
272#[derive(Debug, Clone, PartialEq, Eq)]
279pub struct ApprovalResolution {
280 pub connection: String,
282 pub request_id: Value,
284 pub option_id: String,
286 pub response: Value,
288}
289
290#[derive(Debug, Clone, PartialEq, Eq)]
292pub enum ApprovalResolveError {
293 UnknownId(String),
295 QueuedSubagentRow(String),
298 NotOffered {
300 asked: String,
302 offered: Vec<String>,
304 },
305 NoOptions {
309 door: &'static str,
311 },
312}
313
314impl std::fmt::Display for ApprovalResolveError {
315 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
316 match self {
317 Self::UnknownId(id) => write!(
318 formatter,
319 "no approval request `{id}` is waiting on this service — a live request \
320 exists only inside the process driving the runtime whose turn it blocks, \
321 and only until it is answered. Answer it there: the SDK client or TUI that \
322 started the runtime, or `harness.v1.approvals.resolve` over that same \
323 `supercode harness serve` stdio session (SUP-62: no cross-process relay)"
324 ),
325 Self::QueuedSubagentRow(id) => write!(
326 formatter,
327 "`{id}` is a queued subagent record — supercode's own audit trail of a \
328 request the parent's own handler answers (the terminal's modal, or the \
329 frontend request broker). Answer it on the door that raised it: the \
330 `request` envelope row of the joined supercode runtime"
331 ),
332 Self::NotOffered { asked, offered } => write!(
333 formatter,
334 "this request does not offer `{asked}` — it offers: {}",
335 if offered.is_empty() {
336 "(nothing)".to_string()
337 } else {
338 offered.join(", ")
339 }
340 ),
341 Self::NoOptions { door } => write!(
342 formatter,
343 "this `{door}` request enumerates no answers, so there is no option to \
344 select — answer it with `harness.v1.runtimes.respond` and that door's own \
345 reply body"
346 ),
347 }
348 }
349}
350
351impl std::error::Error for ApprovalResolveError {}
352
353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
360pub enum ApprovalDoor {
361 Acp,
364 Opencode,
366 Codex,
368 ClaudeCode,
372 SupercodeFrontend,
374}
375
376impl ApprovalDoor {
377 pub const fn as_str(self) -> &'static str {
379 match self {
380 Self::Acp => "acp",
381 Self::Opencode => "opencode",
382 Self::Codex => "codex",
383 Self::ClaudeCode => "claude-code",
384 Self::SupercodeFrontend => "supercode-frontend",
385 }
386 }
387
388 const fn spellings(self, decision: ApprovalDecision) -> &'static [&'static str] {
410 match (self, decision) {
411 (Self::Acp, ApprovalDecision::AllowOnce) => &["allow_once"],
412 (Self::Acp, ApprovalDecision::AllowAlways) => &["allow_always"],
413 (Self::Acp, ApprovalDecision::Deny) => &["reject_once", "reject_always"],
414 (Self::Opencode, ApprovalDecision::AllowOnce) => &["once"],
415 (Self::Opencode, ApprovalDecision::AllowAlways) => &["always"],
416 (Self::Opencode, ApprovalDecision::Deny) => &["reject"],
417 (Self::ClaudeCode, ApprovalDecision::AllowOnce) => &["allow"],
418 (Self::ClaudeCode, ApprovalDecision::AllowAlways) => &[],
419 (Self::ClaudeCode, ApprovalDecision::Deny) => &["deny"],
420 (Self::SupercodeFrontend, ApprovalDecision::AllowOnce) => &["allow"],
421 (Self::SupercodeFrontend, ApprovalDecision::AllowAlways) => &["allow_for_session"],
422 (Self::SupercodeFrontend, ApprovalDecision::Deny) => &["deny"],
423 (Self::Codex, _) => &[],
424 }
425 }
426
427 fn reply(self, option_id: &str) -> Option<Value> {
431 match self {
432 Self::Acp => Some(serde_json::json!({
437 "outcome": {"outcome": "selected", "optionId": option_id},
438 })),
439 Self::Opencode => Some(serde_json::json!({"response": option_id})),
442 Self::SupercodeFrontend => Some(serde_json::json!({"decision": option_id})),
445 Self::ClaudeCode if option_id == "deny" => Some(serde_json::json!({
452 "behavior": "deny",
453 "message": "denied through supercode approvals",
454 })),
455 Self::ClaudeCode => Some(serde_json::json!({"behavior": option_id})),
456 Self::Codex => None,
457 }
458 }
459}
460
461pub fn plan_reply(
467 door: ApprovalDoor,
468 options: &[ApprovalOption],
469 choice: &ApprovalChoice,
470) -> Result<(String, Value), ApprovalResolveError> {
471 if options.is_empty() {
472 return Err(ApprovalResolveError::NoOptions {
473 door: door.as_str(),
474 });
475 }
476 let offered = || {
477 options
478 .iter()
479 .map(|option| option.id.clone())
480 .collect::<Vec<_>>()
481 };
482 let chosen = match choice {
483 ApprovalChoice::Option(option_id) => options
486 .iter()
487 .find(|option| &option.id == option_id)
488 .ok_or_else(|| ApprovalResolveError::NotOffered {
489 asked: option_id.clone(),
490 offered: offered(),
491 })?,
492 ApprovalChoice::Decision(decision) => door
493 .spellings(*decision)
494 .iter()
495 .find_map(|spelling| {
496 options.iter().find(|option| {
497 option.kind.as_deref() == Some(*spelling) || option.id == *spelling
498 })
499 })
500 .ok_or_else(|| ApprovalResolveError::NotOffered {
501 asked: decision.as_str().to_string(),
502 offered: offered(),
503 })?,
504 };
505 let response = door
506 .reply(&chosen.id)
507 .ok_or(ApprovalResolveError::NoOptions {
508 door: door.as_str(),
509 })?;
510 Ok((chosen.id.clone(), response))
511}
512
513#[derive(Debug, Clone, PartialEq, Eq)]
515pub struct LiveRequest {
516 pub request_id: Value,
518 pub door: ApprovalDoor,
520 pub session_id: Option<String>,
522 pub subject: String,
524 pub options: Vec<ApprovalOption>,
526}
527
528pub fn classify_live_request(kind: &str, payload: &Value) -> Option<LiveRequest> {
535 match kind {
536 "session/request_permission" => acp_request(payload),
539 "permission.asked" => opencode_request(payload),
542 "request" => supercode_request(payload),
547 "control_request" => claude_code_request(payload),
551 _ if kind.ends_with("Approval") => codex_request(payload),
556 _ => None,
557 }
558}
559
560fn request_id(payload: &Value) -> Option<Value> {
561 payload
562 .get("id")
563 .filter(|id| !id.is_null())
564 .filter(|id| id.is_string() || id.is_number())
565 .cloned()
566}
567
568fn acp_request(payload: &Value) -> Option<LiveRequest> {
569 let request_id = request_id(payload)?;
570 let params = payload.get("params").unwrap_or(&Value::Null);
571 let tool_call = params.get("toolCall");
572 let subject = tool_call
573 .and_then(|call| call.get("title"))
574 .and_then(Value::as_str)
575 .map(str::to_string)
576 .or_else(|| {
577 tool_call
578 .and_then(|call| call.get("rawInput"))
579 .and_then(command_line)
580 })
581 .or_else(|| {
582 tool_call
583 .and_then(|call| call.get("kind"))
584 .and_then(Value::as_str)
585 .map(str::to_string)
586 })
587 .unwrap_or_else(|| "permission request".to_string());
588 let options = params
589 .get("options")
590 .and_then(Value::as_array)
591 .map(|options| {
592 options
593 .iter()
594 .filter_map(|option| {
595 Some(ApprovalOption {
596 id: option.get("optionId").and_then(Value::as_str)?.to_string(),
597 label: option
598 .get("name")
599 .and_then(Value::as_str)
600 .map(str::to_string),
601 kind: option
602 .get("kind")
603 .and_then(Value::as_str)
604 .map(str::to_string),
605 })
606 })
607 .collect()
608 })
609 .unwrap_or_default();
610 Some(LiveRequest {
611 request_id,
612 door: ApprovalDoor::Acp,
613 session_id: params
614 .get("sessionId")
615 .and_then(Value::as_str)
616 .map(str::to_string),
617 subject: one_line(&subject),
618 options,
619 })
620}
621
622fn opencode_request(payload: &Value) -> Option<LiveRequest> {
623 let properties = payload.get("properties").unwrap_or(payload);
624 let permission = properties
625 .get("permission")
626 .filter(|value| value.is_object())
627 .unwrap_or(properties);
628 let id = permission.get("id").and_then(Value::as_str)?;
629 let subject = permission
630 .get("title")
631 .and_then(Value::as_str)
632 .or_else(|| permission.get("pattern").and_then(Value::as_str))
633 .or_else(|| permission.get("type").and_then(Value::as_str))
634 .unwrap_or("permission request");
635 Some(LiveRequest {
636 request_id: Value::String(id.to_string()),
637 door: ApprovalDoor::Opencode,
638 session_id: permission
639 .get("sessionID")
640 .and_then(Value::as_str)
641 .map(str::to_string),
642 subject: one_line(subject),
643 options: ["once", "always", "reject"]
647 .into_iter()
648 .map(ApprovalOption::bare)
649 .collect(),
650 })
651}
652
653fn supercode_request(payload: &Value) -> Option<LiveRequest> {
654 let request = payload.get("request")?;
655 if request.get("kind").and_then(Value::as_str) != Some("approval") {
658 return None;
659 }
660 let request_id = request
661 .get("id")
662 .filter(|id| id.is_number())
663 .cloned()
664 .filter(|id| !id.is_null())?;
665 let inner = request.get("payload").unwrap_or(&Value::Null);
666 let subject = inner
667 .get("subject")
668 .and_then(Value::as_str)
669 .filter(|subject| !subject.is_empty())
670 .or_else(|| inner.get("tool").and_then(Value::as_str))
671 .unwrap_or("permission request");
672 Some(LiveRequest {
673 request_id,
674 door: ApprovalDoor::SupercodeFrontend,
675 session_id: inner
678 .get("child_agent_id")
679 .and_then(Value::as_str)
680 .map(str::to_string),
681 subject: one_line(subject),
682 options: FRONTEND_DECISIONS
685 .into_iter()
686 .map(ApprovalOption::bare)
687 .collect(),
688 })
689}
690
691fn claude_code_request(payload: &Value) -> Option<LiveRequest> {
699 let request = payload.get("request")?;
700 if request.get("subtype").and_then(Value::as_str) != Some("can_use_tool") {
701 return None;
702 }
703 let request_id = payload
704 .get("request_id")
705 .filter(|id| id.is_string())
706 .cloned()?;
707 let tool = request
708 .get("tool_name")
709 .and_then(Value::as_str)
710 .or_else(|| request.get("display_name").and_then(Value::as_str))
711 .unwrap_or("tool");
712 let detail = request
713 .get("input")
714 .and_then(command_line)
715 .or_else(|| {
716 request
717 .get("description")
718 .and_then(Value::as_str)
719 .map(str::to_string)
720 })
721 .or_else(|| {
722 request
723 .get("blocked_path")
724 .and_then(Value::as_str)
725 .map(str::to_string)
726 });
727 let subject = match detail {
728 Some(detail) if !detail.is_empty() => format!("{tool} {detail}"),
729 _ => tool.to_string(),
730 };
731 Some(LiveRequest {
732 request_id,
733 door: ApprovalDoor::ClaudeCode,
734 session_id: None,
737 subject: one_line(&subject),
738 options: CLAUDE_CODE_BEHAVIORS
741 .into_iter()
742 .map(ApprovalOption::bare)
743 .collect(),
744 })
745}
746
747fn codex_request(payload: &Value) -> Option<LiveRequest> {
748 let request_id = request_id(payload)?;
749 let params = payload.get("params").unwrap_or(&Value::Null);
750 let subject = params
751 .get("command")
752 .and_then(command_line)
753 .or_else(|| {
754 params
755 .get("fileChanges")
756 .and_then(Value::as_object)
757 .map(|changes| {
758 let files = changes.keys().cloned().collect::<Vec<_>>().join(", ");
759 if files.is_empty() {
760 "apply patch".to_string()
761 } else {
762 format!("apply patch: {files}")
763 }
764 })
765 })
766 .or_else(|| {
767 params
768 .get("reason")
769 .and_then(Value::as_str)
770 .map(str::to_string)
771 })
772 .unwrap_or_else(|| "approval request".to_string());
773 Some(LiveRequest {
774 request_id,
775 door: ApprovalDoor::Codex,
776 session_id: ["threadId", "conversationId", "sessionId"]
777 .into_iter()
778 .find_map(|key| params.get(key).and_then(Value::as_str))
779 .map(str::to_string),
780 subject: one_line(&subject),
781 options: Vec::new(),
784 })
785}
786
787fn command_line(value: &Value) -> Option<String> {
789 match value {
790 Value::String(text) => Some(text.clone()),
791 Value::Array(parts) => {
792 let joined = parts
793 .iter()
794 .filter_map(Value::as_str)
795 .collect::<Vec<_>>()
796 .join(" ");
797 (!joined.is_empty()).then_some(joined)
798 }
799 Value::Object(object) => object
800 .get("command")
801 .or_else(|| object.get("cmd"))
802 .and_then(command_line),
803 _ => None,
804 }
805}
806
807fn one_line(text: &str) -> String {
808 let flattened = text.split_whitespace().collect::<Vec<_>>().join(" ");
809 if flattened.is_empty() {
810 "permission request".to_string()
811 } else {
812 flattened
813 }
814}
815
816fn id_segment(request_id: &Value) -> String {
819 match request_id {
820 Value::String(text) => text.clone(),
821 other => other.to_string(),
822 }
823}
824
825#[derive(Debug, Clone)]
826struct LiveEntry {
827 request_id: Value,
828 door: ApprovalDoor,
829 harness: HarnessId,
830 runtime_id: String,
831 session_id: Option<String>,
832 subject: String,
833 options: Vec<ApprovalOption>,
834 requested_at_ms: i64,
835}
836
837#[derive(Debug, Default)]
843pub struct ApprovalRegistry {
844 entries: BTreeMap<String, Vec<LiveEntry>>,
846}
847
848impl ApprovalRegistry {
849 pub fn new() -> Self {
851 Self::default()
852 }
853
854 pub fn observe(
859 &mut self,
860 connection: &str,
861 harness: &HarnessId,
862 runtime_id: &str,
863 event: &HarnessEvent,
864 now_ms: i64,
865 ) -> bool {
866 let Some(request) = classify_live_request(&event.kind, &event.payload) else {
867 return false;
868 };
869 let entries = self.entries.entry(connection.to_string()).or_default();
870 if entries
871 .iter()
872 .any(|entry| entry.request_id == request.request_id)
873 {
874 return false;
875 }
876 entries.push(LiveEntry {
877 request_id: request.request_id,
878 door: request.door,
879 harness: harness.clone(),
880 runtime_id: runtime_id.to_string(),
881 session_id: request.session_id,
882 subject: request.subject,
883 options: request.options,
884 requested_at_ms: now_ms,
885 });
886 true
887 }
888
889 pub fn answered(&mut self, connection: &str, request_id: &Value) -> bool {
891 let Some(entries) = self.entries.get_mut(connection) else {
892 return false;
893 };
894 let before = entries.len();
895 entries.retain(|entry| &entry.request_id != request_id);
896 let removed = entries.len() < before;
897 if entries.is_empty() {
898 self.entries.remove(connection);
899 }
900 removed
901 }
902
903 pub fn forget(&mut self, connection: &str) {
905 self.entries.remove(connection);
906 }
907
908 pub fn is_empty(&self) -> bool {
910 self.entries.values().all(Vec::is_empty)
911 }
912
913 pub fn resolution(
921 &self,
922 row_id: &str,
923 choice: &ApprovalChoice,
924 ) -> Result<ApprovalResolution, ApprovalResolveError> {
925 if row_id.starts_with(SUBAGENT_ROW_PREFIX) {
928 return Err(ApprovalResolveError::QueuedSubagentRow(row_id.to_string()));
929 }
930 let (connection, entry) = self
931 .entries
932 .iter()
933 .flat_map(|(connection, entries)| entries.iter().map(move |entry| (connection, entry)))
934 .find(|(connection, entry)| {
935 format!("{connection}/{}", id_segment(&entry.request_id)) == row_id
936 })
937 .ok_or_else(|| ApprovalResolveError::UnknownId(row_id.to_string()))?;
938 let (option_id, response) = plan_reply(entry.door, &entry.options, choice)?;
939 Ok(ApprovalResolution {
940 connection: connection.clone(),
941 request_id: entry.request_id.clone(),
942 option_id,
943 response,
944 })
945 }
946
947 pub fn rows(&self, now_ms: i64) -> Vec<ApprovalRow> {
949 self.entries
950 .iter()
951 .flat_map(|(connection, entries)| {
952 entries.iter().map(move |entry| ApprovalRow {
953 id: format!("{connection}/{}", id_segment(&entry.request_id)),
954 harness: entry.harness.clone(),
955 kind: ApprovalKind::Live,
956 status: ApprovalStatus::Pending,
957 subject: entry.subject.clone(),
958 session_id: entry.session_id.clone(),
959 runtime_id: Some(entry.runtime_id.clone()),
960 requested_at_ms: entry.requested_at_ms,
961 age_ms: now_ms.saturating_sub(entry.requested_at_ms).max(0),
962 options: entry.options.clone(),
963 })
964 })
965 .collect()
966 }
967}
968
969const FRONTEND_DECISIONS: [&str; 3] = ["allow", "allow_for_session", "deny"];
973
974const CLAUDE_CODE_BEHAVIORS: [&str; 2] = ["allow", "deny"];
981
982const SUBAGENT_ROW_PREFIX: &str = "supercode/subagent/";
984
985pub fn subagent_rows(queued: &[QueuedApproval], now_ms: i64) -> Vec<ApprovalRow> {
993 queued
994 .iter()
995 .enumerate()
996 .map(|(index, record)| {
997 let status = match record.outcome {
998 None => ApprovalStatus::Pending,
999 Some(QueuedApprovalOutcome::Allowed) => ApprovalStatus::Allowed,
1000 Some(QueuedApprovalOutcome::Denied) => ApprovalStatus::Denied,
1001 };
1002 let subject = match record.subject.as_deref() {
1003 Some(subject) if !subject.is_empty() => {
1004 format!("{} {}", record.tool, subject)
1005 }
1006 _ => record.tool.clone(),
1007 };
1008 ApprovalRow {
1009 id: format!(
1010 "{SUBAGENT_ROW_PREFIX}{}/{}/{index}",
1011 record.child_agent_id, record.queued_at_ms
1012 ),
1013 harness: HarnessId::from(HarnessId::SUPERCODE),
1014 kind: ApprovalKind::Live,
1015 status,
1016 subject: one_line(&subject),
1017 session_id: Some(record.child_agent_id.clone()),
1018 runtime_id: None,
1019 requested_at_ms: record.queued_at_ms,
1020 age_ms: now_ms.saturating_sub(record.queued_at_ms).max(0),
1021 options: if status == ApprovalStatus::Pending {
1022 FRONTEND_DECISIONS
1023 .into_iter()
1024 .map(ApprovalOption::bare)
1025 .collect()
1026 } else {
1027 Vec::new()
1028 },
1029 }
1030 })
1031 .collect()
1032}
1033
1034pub fn lists_approvals(harness: &str) -> bool {
1040 crate::support::harness_support(harness)
1041 .is_some_and(|descriptor| descriptor.runtime.capabilities.respond_to_requests)
1042}
1043
1044pub fn approval_harnesses() -> Vec<String> {
1046 crate::support::harness_support_registry()
1047 .harnesses
1048 .into_iter()
1049 .filter(|descriptor| descriptor.runtime.capabilities.respond_to_requests)
1050 .map(|descriptor| descriptor.id.as_str().to_string())
1051 .collect()
1052}
1053
1054pub fn now_ms() -> i64 {
1056 std::time::SystemTime::now()
1057 .duration_since(std::time::UNIX_EPOCH)
1058 .map(|elapsed| elapsed.as_millis() as i64)
1059 .unwrap_or_default()
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064 use super::*;
1065 use serde_json::json;
1066
1067 fn event(kind: &str, payload: Value) -> HarnessEvent {
1068 HarnessEvent {
1069 sequence: None,
1070 kind: kind.to_string(),
1071 payload,
1072 }
1073 }
1074
1075 fn acp_permission(id: u64, title: &str) -> HarnessEvent {
1076 event(
1077 "session/request_permission",
1078 json!({
1079 "jsonrpc": "2.0",
1080 "id": id,
1081 "method": "session/request_permission",
1082 "params": {
1083 "sessionId": "acp-session",
1084 "toolCall": {"toolCallId": "call-1", "title": title, "kind": "execute"},
1085 "options": [
1086 {"optionId": "allow_once", "name": "Allow once", "kind": "allow_once"},
1087 {"optionId": "deny", "name": "Deny", "kind": "reject_once"},
1088 ],
1089 },
1090 }),
1091 )
1092 }
1093
1094 #[test]
1095 fn acp_permission_requests_carry_subject_session_and_option_ids() {
1096 let request = classify_live_request(
1097 "session/request_permission",
1098 &acp_permission(7, "rm -rf build").payload,
1099 )
1100 .expect("an ACP permission request is recognized");
1101 assert_eq!(request.request_id, json!(7));
1102 assert_eq!(request.session_id.as_deref(), Some("acp-session"));
1103 assert_eq!(request.subject, "rm -rf build");
1104 assert_eq!(
1105 request
1106 .options
1107 .iter()
1108 .map(|option| option.id.as_str())
1109 .collect::<Vec<_>>(),
1110 vec!["allow_once", "deny"],
1111 );
1112 }
1113
1114 #[test]
1115 fn opencode_and_codex_requests_use_their_own_protocol_spellings() {
1116 let opencode = classify_live_request(
1117 "permission.asked",
1118 &json!({
1119 "type": "permission.asked",
1120 "properties": {
1121 "id": "perm-9",
1122 "sessionID": "oc-session",
1123 "title": "git push origin main",
1124 },
1125 }),
1126 )
1127 .expect("an opencode permission ask is recognized");
1128 assert_eq!(opencode.request_id, json!("perm-9"));
1129 assert_eq!(opencode.session_id.as_deref(), Some("oc-session"));
1130 assert_eq!(opencode.subject, "git push origin main");
1131 assert_eq!(
1132 opencode
1133 .options
1134 .iter()
1135 .map(|option| option.id.as_str())
1136 .collect::<Vec<_>>(),
1137 vec!["once", "always", "reject"],
1138 );
1139
1140 let codex = classify_live_request(
1141 "execCommandApproval",
1142 &json!({
1143 "jsonrpc": "2.0",
1144 "id": "req-3",
1145 "method": "execCommandApproval",
1146 "params": {"threadId": "cx-thread", "command": ["cargo", "test"]},
1147 }),
1148 )
1149 .expect("a Codex approval reverse request is recognized");
1150 assert_eq!(codex.subject, "cargo test");
1151 assert_eq!(codex.session_id.as_deref(), Some("cx-thread"));
1152 assert!(codex.options.is_empty());
1155 }
1156
1157 #[test]
1158 fn a_joined_supercode_runtime_publishes_its_own_request_envelope() {
1159 let request = classify_live_request(
1160 "request",
1161 &json!({
1162 "type": "request",
1163 "request": {
1164 "id": 4,
1165 "kind": "approval",
1166 "payload": {
1167 "tool": "shell",
1168 "subject": "cargo publish --dry-run",
1169 "child_agent_id": "child-2",
1170 },
1171 },
1172 }),
1173 )
1174 .expect("supercode's own frontend request is recognized");
1175 assert_eq!(request.request_id, json!(4));
1176 assert_eq!(request.subject, "cargo publish --dry-run");
1177 assert_eq!(request.session_id.as_deref(), Some("child-2"));
1178 assert_eq!(
1179 request
1180 .options
1181 .iter()
1182 .map(|option| option.id.as_str())
1183 .collect::<Vec<_>>(),
1184 vec!["allow", "allow_for_session", "deny"],
1185 );
1186 assert!(classify_live_request(
1188 "request",
1189 &json!({"type": "request", "request": {"id": 5, "kind": "elicitation", "payload": {}}})
1190 )
1191 .is_none());
1192 }
1193
1194 fn claude_can_use_tool() -> Value {
1197 json!({
1198 "type": "control_request",
1199 "request_id": "053f8a2d-3445-4011-a259-4261b31c7326",
1200 "request": {
1201 "subtype": "can_use_tool",
1202 "tool_name": "Bash",
1203 "display_name": "Bash",
1204 "input": {"command": "touch probe-artifact.txt", "description": "probe"},
1205 "description": "probe",
1206 "permission_suggestions": [{
1207 "type": "addRules",
1208 "rules": [{"toolName": "Bash", "ruleContent": "touch probe-artifact.txt"}],
1209 "behavior": "allow",
1210 "destination": "localSettings",
1211 }],
1212 "blocked_path": "/tmp/work/probe-artifact.txt",
1213 "tool_use_id": "toolu_mock_1",
1214 },
1215 })
1216 }
1217
1218 #[test]
1219 fn claude_code_can_use_tool_is_a_live_request_with_the_protocols_two_behaviors() {
1220 let request = classify_live_request("control_request", &claude_can_use_tool())
1221 .expect("a can_use_tool control request is a permission request");
1222 assert_eq!(request.door, ApprovalDoor::ClaudeCode);
1223 assert_eq!(
1224 request.request_id,
1225 json!("053f8a2d-3445-4011-a259-4261b31c7326")
1226 );
1227 assert_eq!(request.subject, "Bash touch probe-artifact.txt");
1228 assert_eq!(
1229 request
1230 .options
1231 .iter()
1232 .map(|option| option.id.as_str())
1233 .collect::<Vec<_>>(),
1234 vec!["allow", "deny"],
1235 );
1236
1237 assert!(classify_live_request(
1239 "control_request",
1240 &json!({"type":"control_request","request_id":"x","request":{"subtype":"hook_callback"}})
1241 )
1242 .is_none());
1243 assert!(classify_live_request(
1245 "control_response",
1246 &json!({"type":"control_response","response":{"subtype":"success"}})
1247 )
1248 .is_none());
1249 }
1250
1251 #[test]
1256 fn claude_code_decisions_translate_onto_the_permission_result_the_cli_accepts() {
1257 let request = classify_live_request("control_request", &claude_can_use_tool()).unwrap();
1258 let (option, reply) = plan_reply(
1259 request.door,
1260 &request.options,
1261 &ApprovalChoice::Decision(ApprovalDecision::AllowOnce),
1262 )
1263 .unwrap();
1264 assert_eq!(option, "allow");
1265 assert_eq!(reply, json!({"behavior": "allow"}));
1266
1267 let (option, reply) = plan_reply(
1268 request.door,
1269 &request.options,
1270 &ApprovalChoice::Decision(ApprovalDecision::Deny),
1271 )
1272 .unwrap();
1273 assert_eq!(option, "deny");
1274 assert_eq!(reply["behavior"], "deny");
1275 assert!(reply["message"].as_str().is_some_and(|m| !m.is_empty()));
1277
1278 let error = plan_reply(
1279 request.door,
1280 &request.options,
1281 &ApprovalChoice::Decision(ApprovalDecision::AllowAlways),
1282 )
1283 .unwrap_err();
1284 assert_eq!(
1285 error,
1286 ApprovalResolveError::NotOffered {
1287 asked: "allow_always".into(),
1288 offered: vec!["allow".into(), "deny".into()],
1289 }
1290 );
1291 }
1292
1293 #[test]
1294 fn ordinary_events_and_id_less_notifications_are_not_approvals() {
1295 assert!(classify_live_request(
1296 "session/update",
1297 &json!({"method": "session/update", "params": {}})
1298 )
1299 .is_none());
1300 assert!(classify_live_request(
1302 "execCommandApproval",
1303 &json!({"method": "execCommandApproval", "params": {}})
1304 )
1305 .is_none());
1306 }
1307
1308 #[test]
1309 fn a_recorded_request_lists_once_and_leaves_when_answered() {
1310 let mut registry = ApprovalRegistry::new();
1311 let harness = HarnessId::from(HarnessId::HERMES);
1312 let event = acp_permission(7, "rm -rf build");
1313 assert!(registry.observe("runtime-1", &harness, "acp-session", &event, 1_000));
1314 assert!(!registry.observe("runtime-1", &harness, "acp-session", &event, 2_000));
1316
1317 let rows = registry.rows(1_500);
1318 assert_eq!(rows.len(), 1);
1319 assert_eq!(rows[0].id, "runtime-1/7");
1320 assert_eq!(rows[0].harness.as_str(), HarnessId::HERMES);
1321 assert_eq!(rows[0].kind, ApprovalKind::Live);
1322 assert_eq!(rows[0].status, ApprovalStatus::Pending);
1323 assert_eq!(rows[0].age_ms, 500);
1324
1325 assert!(registry.answered("runtime-1", &json!(7)));
1326 assert!(registry.is_empty());
1327 assert!(!registry.answered("runtime-1", &json!(7)));
1328 }
1329
1330 #[test]
1331 fn a_closed_connection_takes_its_requests_with_it() {
1332 let mut registry = ApprovalRegistry::new();
1333 let harness = HarnessId::from(HarnessId::OPENCLAW);
1334 registry.observe(
1335 "runtime-2",
1336 &harness,
1337 "acp-session",
1338 &acp_permission(1, "write src/main.rs"),
1339 10,
1340 );
1341 registry.forget("runtime-2");
1342 assert!(registry.rows(20).is_empty());
1343 }
1344
1345 #[test]
1346 fn queued_subagent_rows_report_the_outcome_the_record_holds() {
1347 let queued = vec![
1348 QueuedApproval {
1349 child_agent_id: "child-1".into(),
1350 tool: "shell".into(),
1351 subject: Some("git push".into()),
1352 queued_at_ms: 100,
1353 outcome: None,
1354 },
1355 QueuedApproval {
1356 child_agent_id: "child-2".into(),
1357 tool: "write_file".into(),
1358 subject: None,
1359 queued_at_ms: 200,
1360 outcome: Some(QueuedApprovalOutcome::Denied),
1361 },
1362 ];
1363 let rows = subagent_rows(&queued, 500);
1364 assert_eq!(rows[0].id, "supercode/subagent/child-1/100/0");
1365 assert_eq!(rows[0].harness.as_str(), HarnessId::SUPERCODE);
1366 assert_eq!(rows[0].status, ApprovalStatus::Pending);
1367 assert_eq!(rows[0].subject, "shell git push");
1368 assert_eq!(rows[0].age_ms, 400);
1369 assert_eq!(rows[0].options.len(), 3);
1370 assert_eq!(rows[1].status, ApprovalStatus::Denied);
1371 assert_eq!(rows[1].subject, "write_file");
1372 assert!(rows[1].options.is_empty());
1374 }
1375
1376 #[test]
1377 fn only_harnesses_whose_runtime_can_answer_are_listed() {
1378 assert!(lists_approvals(HarnessId::HERMES));
1379 assert!(lists_approvals(HarnessId::OPENCLAW));
1380 assert!(lists_approvals(HarnessId::CODEX));
1381 assert!(lists_approvals(HarnessId::OPENCODE));
1382 assert!(lists_approvals(HarnessId::CLAUDE_CODE));
1385 assert!(!lists_approvals("notaharness"));
1386 let harnesses = approval_harnesses();
1387 assert!(harnesses.iter().any(|id| id == HarnessId::HERMES));
1388 assert!(harnesses.iter().any(|id| id == HarnessId::CLAUDE_CODE));
1389 }
1390
1391 #[test]
1394 fn each_door_spells_a_uniform_decision_in_its_own_vocabulary() {
1395 let acp = vec![
1397 ApprovalOption {
1398 id: "proceed-once".into(),
1399 label: Some("Allow once".into()),
1400 kind: Some("allow_once".into()),
1401 },
1402 ApprovalOption {
1403 id: "refuse".into(),
1404 label: Some("Deny".into()),
1405 kind: Some("reject_once".into()),
1406 },
1407 ];
1408 let (option, response) = plan_reply(
1409 ApprovalDoor::Acp,
1410 &acp,
1411 &ApprovalChoice::Decision(ApprovalDecision::AllowOnce),
1412 )
1413 .expect("the request offers an allow-once option");
1414 assert_eq!(option, "proceed-once");
1415 assert_eq!(
1416 response,
1417 json!({"outcome": {"outcome": "selected", "optionId": "proceed-once"}}),
1418 );
1419 let (option, _) = plan_reply(
1420 ApprovalDoor::Acp,
1421 &acp,
1422 &ApprovalChoice::Decision(ApprovalDecision::Deny),
1423 )
1424 .expect("`reject_once` is how ACP spells deny");
1425 assert_eq!(option, "refuse");
1426
1427 let opencode = ["once", "always", "reject"]
1429 .map(ApprovalOption::bare)
1430 .to_vec();
1431 let (option, response) = plan_reply(
1432 ApprovalDoor::Opencode,
1433 &opencode,
1434 &ApprovalChoice::Decision(ApprovalDecision::AllowAlways),
1435 )
1436 .expect("opencode offers `always`");
1437 assert_eq!(option, "always");
1438 assert_eq!(response, json!({"response": "always"}));
1439
1440 let frontend = FRONTEND_DECISIONS.map(ApprovalOption::bare).to_vec();
1442 let (option, response) = plan_reply(
1443 ApprovalDoor::SupercodeFrontend,
1444 &frontend,
1445 &ApprovalChoice::Decision(ApprovalDecision::AllowAlways),
1446 )
1447 .expect("the frontend offers `allow_for_session`");
1448 assert_eq!(option, "allow_for_session");
1449 assert_eq!(response, json!({"decision": "allow_for_session"}));
1450 }
1451
1452 #[test]
1453 fn a_decision_the_request_does_not_offer_names_the_ones_it_does() {
1454 let options = vec![
1455 ApprovalOption {
1456 id: "allow_once".into(),
1457 label: None,
1458 kind: Some("allow_once".into()),
1459 },
1460 ApprovalOption {
1461 id: "deny".into(),
1462 label: None,
1463 kind: Some("reject_once".into()),
1464 },
1465 ];
1466 let error = plan_reply(
1467 ApprovalDoor::Acp,
1468 &options,
1469 &ApprovalChoice::Decision(ApprovalDecision::AllowAlways),
1470 )
1471 .expect_err("this request has no allow-always option");
1472 assert_eq!(
1473 error,
1474 ApprovalResolveError::NotOffered {
1475 asked: "allow_always".into(),
1476 offered: vec!["allow_once".into(), "deny".into()],
1477 },
1478 );
1479 let message = error.to_string();
1480 assert!(message.contains("allow_always"), "{message}");
1481 assert!(message.contains("allow_once, deny"), "{message}");
1482
1483 let error = plan_reply(
1485 ApprovalDoor::Acp,
1486 &options,
1487 &ApprovalChoice::Option("allow_always".into()),
1488 )
1489 .expect_err("an unoffered token is not passed through");
1490 assert!(matches!(error, ApprovalResolveError::NotOffered { .. }));
1491
1492 let (option, _) = plan_reply(
1494 ApprovalDoor::Acp,
1495 &options,
1496 &ApprovalChoice::Option("deny".into()),
1497 )
1498 .expect("`deny` is offered");
1499 assert_eq!(option, "deny");
1500 }
1501
1502 #[test]
1503 fn a_door_that_enumerates_nothing_is_refused_rather_than_guessed_at() {
1504 let error = plan_reply(
1507 ApprovalDoor::Codex,
1508 &[],
1509 &ApprovalChoice::Decision(ApprovalDecision::AllowOnce),
1510 )
1511 .expect_err("nothing to select");
1512 assert_eq!(error, ApprovalResolveError::NoOptions { door: "codex" });
1513 assert!(
1514 error.to_string().contains("harness.v1.runtimes.respond"),
1515 "{error}"
1516 );
1517 }
1518
1519 #[test]
1520 fn the_registry_plans_an_answer_for_the_row_id_it_published() {
1521 let mut registry = ApprovalRegistry::new();
1522 let harness = HarnessId::from(HarnessId::HERMES);
1523 registry.observe(
1524 "runtime-1",
1525 &harness,
1526 "acp-session",
1527 &acp_permission(7, "rm -rf build"),
1528 1_000,
1529 );
1530 let row_id = registry.rows(1_000)[0].id.clone();
1531 assert_eq!(row_id, "runtime-1/7");
1532
1533 let resolution = registry
1534 .resolution(
1535 &row_id,
1536 &ApprovalChoice::Decision(ApprovalDecision::AllowOnce),
1537 )
1538 .expect("the listed row plans an answer");
1539 assert_eq!(resolution.connection, "runtime-1");
1540 assert_eq!(resolution.request_id, json!(7));
1541 assert_eq!(resolution.option_id, "allow_once");
1542 assert_eq!(
1543 resolution.response,
1544 json!({"outcome": {"outcome": "selected", "optionId": "allow_once"}}),
1545 );
1546 assert_eq!(registry.rows(1_000).len(), 1);
1548
1549 let error = registry
1550 .resolution(
1551 "runtime-1/999",
1552 &ApprovalChoice::Decision(ApprovalDecision::Deny),
1553 )
1554 .expect_err("no such row");
1555 assert_eq!(
1556 error,
1557 ApprovalResolveError::UnknownId("runtime-1/999".into())
1558 );
1559
1560 let error = registry
1562 .resolution(
1563 "supercode/subagent/child-1/100/0",
1564 &ApprovalChoice::Decision(ApprovalDecision::AllowOnce),
1565 )
1566 .expect_err("an audit record is not a door");
1567 assert!(matches!(
1568 error,
1569 ApprovalResolveError::QueuedSubagentRow(ref id)
1570 if id == "supercode/subagent/child-1/100/0"
1571 ));
1572 assert!(error.to_string().contains("audit trail"), "{error}");
1573 }
1574
1575 #[test]
1576 fn a_decision_parses_from_both_the_wire_and_the_cli_spelling() {
1577 assert_eq!(
1578 ApprovalDecision::parse("allow-once"),
1579 Some(ApprovalDecision::AllowOnce)
1580 );
1581 assert_eq!(
1582 ApprovalDecision::parse("ALLOW_ALWAYS"),
1583 Some(ApprovalDecision::AllowAlways)
1584 );
1585 assert_eq!(
1586 ApprovalDecision::parse("deny"),
1587 Some(ApprovalDecision::Deny)
1588 );
1589 assert_eq!(ApprovalDecision::parse("maybe"), None);
1590 assert_eq!(
1591 serde_json::to_value(ApprovalDecision::AllowAlways).unwrap(),
1592 json!("allow_always"),
1593 );
1594 }
1595
1596 #[test]
1597 fn a_query_filters_by_harness_and_by_session() {
1598 let rows = subagent_rows(
1599 &[QueuedApproval {
1600 child_agent_id: "child-1".into(),
1601 tool: "shell".into(),
1602 subject: None,
1603 queued_at_ms: 1,
1604 outcome: None,
1605 }],
1606 2,
1607 );
1608 let query = ApprovalsQuery {
1609 harness: Some(HarnessId::SUPERCODE.into()),
1610 session: Some("child-1".into()),
1611 };
1612 assert!(query.matches(&rows[0]));
1613 let other = ApprovalsQuery {
1614 harness: Some(HarnessId::HERMES.into()),
1615 session: None,
1616 };
1617 assert!(!other.matches(&rows[0]));
1618 }
1619}