1use serde_json::Value;
2
3use crate::runtime::api::{normalize_sandbox_mode_alias, summarize_sandbox_policy_wire_value};
4use crate::runtime::errors::RpcError;
5use crate::runtime::turn_output::{parse_thread_id, parse_turn_id};
6
7pub mod methods {
9 pub const THREAD_START: &str = "thread/start";
10 pub const THREAD_RESUME: &str = "thread/resume";
11 pub const THREAD_FORK: &str = "thread/fork";
12 pub const THREAD_ARCHIVE: &str = "thread/archive";
13 pub const THREAD_READ: &str = "thread/read";
14 pub const THREAD_LIST: &str = "thread/list";
15 pub const THREAD_LOADED_LIST: &str = "thread/loaded/list";
16 pub const THREAD_ROLLBACK: &str = "thread/rollback";
17 pub const SKILLS_LIST: &str = "skills/list";
18 pub const COMMAND_EXEC: &str = "command/exec";
19 pub const COMMAND_EXEC_WRITE: &str = "command/exec/write";
20 pub const COMMAND_EXEC_TERMINATE: &str = "command/exec/terminate";
21 pub const COMMAND_EXEC_RESIZE: &str = "command/exec/resize";
22 pub const TURN_START: &str = "turn/start";
23 pub const TURN_INTERRUPT: &str = "turn/interrupt";
24
25 pub const ITEM_COMMAND_EXECUTION_REQUEST_APPROVAL: &str =
27 "item/commandExecution/requestApproval";
28 pub const ITEM_FILE_CHANGE_REQUEST_APPROVAL: &str = "item/fileChange/requestApproval";
29 pub const ITEM_TOOL_REQUEST_USER_INPUT: &str = "item/tool/requestUserInput";
30 pub const ITEM_TOOL_CALL: &str = "item/tool/call";
31 pub const ACCOUNT_CHATGPT_AUTH_TOKENS_REFRESH: &str = "account/chatgptAuthTokens/refresh";
32
33 pub const THREAD_STARTED: &str = "thread/started";
35 pub const TURN_STARTED: &str = "turn/started";
36 pub const TURN_COMPLETED: &str = "turn/completed";
37 pub const TURN_FAILED: &str = "turn/failed";
38 pub const TURN_CANCELLED: &str = "turn/cancelled";
39 pub const TURN_INTERRUPTED: &str = "turn/interrupted";
40 pub const TURN_DIFF_UPDATED: &str = "turn/diff/updated";
41 pub const TURN_PLAN_UPDATED: &str = "turn/plan/updated";
42 pub const ITEM_STARTED: &str = "item/started";
43 pub const ITEM_AGENT_MESSAGE_DELTA: &str = "item/agentMessage/delta";
44 pub const ITEM_COMMAND_EXECUTION_OUTPUT_DELTA: &str = "item/commandExecution/outputDelta";
45 pub const COMMAND_EXEC_OUTPUT_DELTA: &str = "command/exec/outputDelta";
46 pub const ITEM_COMPLETED: &str = "item/completed";
47 pub const APPROVAL_ACK: &str = "approval/ack";
48 pub const SKILLS_CHANGED: &str = "skills/changed";
49
50 pub const KNOWN: [&str; 15] = [
51 THREAD_START,
52 THREAD_RESUME,
53 THREAD_FORK,
54 THREAD_ARCHIVE,
55 THREAD_READ,
56 THREAD_LIST,
57 THREAD_LOADED_LIST,
58 THREAD_ROLLBACK,
59 SKILLS_LIST,
60 COMMAND_EXEC,
61 COMMAND_EXEC_WRITE,
62 COMMAND_EXEC_TERMINATE,
63 COMMAND_EXEC_RESIZE,
64 TURN_START,
65 TURN_INTERRUPT,
66 ];
67}
68
69#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
71pub enum RpcValidationMode {
72 None,
74 #[default]
76 KnownMethods,
77}
78
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
81pub enum RpcRequestContract {
82 Object,
83 ThreadStart,
84 ThreadId,
85 ThreadIdAndTurnId,
86 ProcessId,
87 CommandExec,
88 CommandExecWrite,
89 CommandExecResize,
90}
91
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub enum RpcResponseContract {
95 Object,
96 ThreadId,
97 TurnId,
98 DataArray,
99 CommandExec,
100}
101
102#[derive(Clone, Copy, Debug, PartialEq, Eq)]
104pub struct RpcContractDescriptor {
105 pub method: &'static str,
106 pub request: RpcRequestContract,
107 pub response: RpcResponseContract,
108}
109
110const RPC_CONTRACT_DESCRIPTORS: [RpcContractDescriptor; 15] = [
111 RpcContractDescriptor {
112 method: methods::THREAD_START,
113 request: RpcRequestContract::ThreadStart,
114 response: RpcResponseContract::ThreadId,
115 },
116 RpcContractDescriptor {
117 method: methods::THREAD_RESUME,
118 request: RpcRequestContract::ThreadId,
119 response: RpcResponseContract::ThreadId,
120 },
121 RpcContractDescriptor {
122 method: methods::THREAD_FORK,
123 request: RpcRequestContract::ThreadId,
124 response: RpcResponseContract::ThreadId,
125 },
126 RpcContractDescriptor {
127 method: methods::THREAD_ARCHIVE,
128 request: RpcRequestContract::ThreadId,
129 response: RpcResponseContract::Object,
130 },
131 RpcContractDescriptor {
132 method: methods::THREAD_READ,
133 request: RpcRequestContract::ThreadId,
134 response: RpcResponseContract::ThreadId,
135 },
136 RpcContractDescriptor {
137 method: methods::THREAD_LIST,
138 request: RpcRequestContract::Object,
139 response: RpcResponseContract::DataArray,
140 },
141 RpcContractDescriptor {
142 method: methods::THREAD_LOADED_LIST,
143 request: RpcRequestContract::Object,
144 response: RpcResponseContract::DataArray,
145 },
146 RpcContractDescriptor {
147 method: methods::THREAD_ROLLBACK,
148 request: RpcRequestContract::ThreadId,
149 response: RpcResponseContract::ThreadId,
150 },
151 RpcContractDescriptor {
152 method: methods::SKILLS_LIST,
153 request: RpcRequestContract::Object,
154 response: RpcResponseContract::DataArray,
155 },
156 RpcContractDescriptor {
157 method: methods::COMMAND_EXEC,
158 request: RpcRequestContract::CommandExec,
159 response: RpcResponseContract::CommandExec,
160 },
161 RpcContractDescriptor {
162 method: methods::COMMAND_EXEC_WRITE,
163 request: RpcRequestContract::CommandExecWrite,
164 response: RpcResponseContract::Object,
165 },
166 RpcContractDescriptor {
167 method: methods::COMMAND_EXEC_TERMINATE,
168 request: RpcRequestContract::ProcessId,
169 response: RpcResponseContract::Object,
170 },
171 RpcContractDescriptor {
172 method: methods::COMMAND_EXEC_RESIZE,
173 request: RpcRequestContract::CommandExecResize,
174 response: RpcResponseContract::Object,
175 },
176 RpcContractDescriptor {
177 method: methods::TURN_START,
178 request: RpcRequestContract::ThreadId,
179 response: RpcResponseContract::TurnId,
180 },
181 RpcContractDescriptor {
182 method: methods::TURN_INTERRUPT,
183 request: RpcRequestContract::ThreadIdAndTurnId,
184 response: RpcResponseContract::Object,
185 },
186];
187
188pub fn rpc_contract_descriptors() -> &'static [RpcContractDescriptor] {
190 &RPC_CONTRACT_DESCRIPTORS
191}
192
193pub fn rpc_contract_descriptor(method: &str) -> Option<&'static RpcContractDescriptor> {
195 RPC_CONTRACT_DESCRIPTORS
196 .iter()
197 .find(|descriptor| descriptor.method == method)
198}
199
200pub fn validate_rpc_request(
205 method: &str,
206 params: &Value,
207 mode: RpcValidationMode,
208) -> Result<(), RpcError> {
209 validate_method_name(method)?;
210
211 if mode == RpcValidationMode::None {
212 return Ok(());
213 }
214
215 match rpc_contract_descriptor(method) {
216 Some(descriptor) => validate_request_by_descriptor(method, params, *descriptor),
217 None => Ok(()),
218 }
219}
220
221pub fn validate_rpc_response(
225 method: &str,
226 result: &Value,
227 mode: RpcValidationMode,
228) -> Result<(), RpcError> {
229 validate_method_name(method)?;
230
231 if mode == RpcValidationMode::None {
232 return Ok(());
233 }
234
235 match rpc_contract_descriptor(method) {
236 Some(descriptor) => validate_response_by_descriptor(method, result, *descriptor),
237 None => Ok(()),
238 }
239}
240
241fn validate_request_by_descriptor(
242 method: &str,
243 params: &Value,
244 descriptor: RpcContractDescriptor,
245) -> Result<(), RpcError> {
246 match descriptor.request {
247 RpcRequestContract::Object => {
248 require_object(params, method, "params")?;
249 Ok(())
250 }
251 RpcRequestContract::ThreadStart => validate_thread_start_request(params, method),
252 RpcRequestContract::ThreadId => require_string(params, method, "threadId", "params"),
253 RpcRequestContract::ThreadIdAndTurnId => {
254 require_string(params, method, "threadId", "params")?;
255 require_string(params, method, "turnId", "params")
256 }
257 RpcRequestContract::ProcessId => require_string(params, method, "processId", "params"),
258 RpcRequestContract::CommandExec => validate_command_exec_request(params, method),
259 RpcRequestContract::CommandExecWrite => validate_command_exec_write_request(params, method),
260 RpcRequestContract::CommandExecResize => {
261 validate_command_exec_resize_request(params, method)
262 }
263 }
264}
265
266fn validate_response_by_descriptor(
267 method: &str,
268 result: &Value,
269 descriptor: RpcContractDescriptor,
270) -> Result<(), RpcError> {
271 match descriptor.response {
272 RpcResponseContract::Object => {
273 require_object(result, method, "result")?;
274 Ok(())
275 }
276 RpcResponseContract::ThreadId => {
277 if parse_thread_id(result).is_none() {
278 Err(invalid_response(
279 method,
280 "result is missing thread id",
281 result,
282 ))
283 } else {
284 Ok(())
285 }
286 }
287 RpcResponseContract::TurnId => {
288 if parse_turn_id(result).is_none() {
289 Err(invalid_response(
290 method,
291 "result is missing turn id",
292 result,
293 ))
294 } else {
295 Ok(())
296 }
297 }
298 RpcResponseContract::DataArray => {
299 let obj = require_object(result, method, "result")?;
300 match obj.get("data") {
301 Some(Value::Array(_)) => Ok(()),
302 _ => Err(invalid_response(
303 method,
304 "result.data must be an array",
305 result,
306 )),
307 }
308 }
309 RpcResponseContract::CommandExec => validate_command_exec_response(result, method),
310 }
311}
312
313fn validate_method_name(method: &str) -> Result<(), RpcError> {
314 if method.trim().is_empty() {
315 return Err(RpcError::InvalidRequest(
316 "json-rpc method must not be empty".to_owned(),
317 ));
318 }
319 Ok(())
320}
321
322fn require_object<'a>(
323 value: &'a Value,
324 method: &str,
325 field_name: &str,
326) -> Result<&'a serde_json::Map<String, Value>, RpcError> {
327 value
328 .as_object()
329 .ok_or_else(|| invalid_request(method, &format!("{field_name} must be an object"), value))
330}
331
332fn require_string(
333 value: &Value,
334 method: &str,
335 key: &str,
336 field_name: &str,
337) -> Result<(), RpcError> {
338 let obj = require_object(value, method, field_name)?;
339 match obj.get(key).and_then(Value::as_str) {
340 Some(v) if !v.trim().is_empty() => Ok(()),
341 _ => Err(invalid_request(
342 method,
343 &format!("{field_name}.{key} must be a non-empty string"),
344 value,
345 )),
346 }
347}
348
349fn validate_thread_start_request(params: &Value, method: &str) -> Result<(), RpcError> {
350 let obj = require_object(params, method, "params")?;
351
352 if let Some(sandbox_mode) = obj.get("sandbox") {
353 validate_thread_sandbox_mode(sandbox_mode, method, params)?;
354 }
355 Ok(())
356}
357
358fn validate_thread_sandbox_mode(
359 sandbox_mode: &Value,
360 method: &str,
361 payload: &Value,
362) -> Result<(), RpcError> {
363 let Some(raw_mode) = sandbox_mode.as_str() else {
364 return Err(invalid_request(
365 method,
366 "params.sandbox must be a non-empty string",
367 payload,
368 ));
369 };
370 let normalized = normalize_sandbox_mode_alias(raw_mode).ok_or_else(|| {
371 invalid_request(
372 method,
373 "params.sandbox must be one of read-only, workspace-write, danger-full-access",
374 payload,
375 )
376 })?;
377 if normalized.is_empty() {
378 return Err(invalid_request(
379 method,
380 "params.sandbox must be a non-empty string",
381 payload,
382 ));
383 }
384 Ok(())
385}
386
387fn validate_command_exec_request(params: &Value, method: &str) -> Result<(), RpcError> {
388 let obj = require_object(params, method, "params")?;
389 let command = obj
390 .get("command")
391 .and_then(Value::as_array)
392 .ok_or_else(|| invalid_request(method, "params.command must be an array", params))?;
393 if command.is_empty() {
394 return Err(invalid_request(
395 method,
396 "params.command must not be empty",
397 params,
398 ));
399 }
400 if command.iter().any(|value| value.as_str().is_none()) {
401 return Err(invalid_request(
402 method,
403 "params.command items must be strings",
404 params,
405 ));
406 }
407
408 let process_id = get_optional_non_empty_string(obj, "processId")
409 .map_err(|reason| invalid_request(method, &reason, params))?;
410 let tty = get_bool(obj, "tty");
411 let stream_stdin = get_bool(obj, "streamStdin");
412 let stream_stdout_stderr = get_bool(obj, "streamStdoutStderr");
413 let effective_stream_stdin = tty || stream_stdin;
414 let effective_stream_stdout_stderr = tty || stream_stdout_stderr;
415
416 if (tty || effective_stream_stdin || effective_stream_stdout_stderr) && process_id.is_none() {
417 return Err(invalid_request(
418 method,
419 "params.processId is required when tty or streaming is enabled",
420 params,
421 ));
422 }
423 if get_bool(obj, "disableOutputCap") && obj.get("outputBytesCap").is_some() {
424 return Err(invalid_request(
425 method,
426 "params.disableOutputCap cannot be combined with params.outputBytesCap",
427 params,
428 ));
429 }
430 if get_bool(obj, "disableTimeout") && obj.get("timeoutMs").is_some() {
431 return Err(invalid_request(
432 method,
433 "params.disableTimeout cannot be combined with params.timeoutMs",
434 params,
435 ));
436 }
437 if let Some(timeout_ms) = obj.get("timeoutMs").and_then(Value::as_i64) {
438 if timeout_ms < 0 {
439 return Err(invalid_request(
440 method,
441 "params.timeoutMs must be >= 0",
442 params,
443 ));
444 }
445 }
446 if let Some(output_bytes_cap) = obj.get("outputBytesCap").and_then(Value::as_u64) {
447 if output_bytes_cap == 0 {
448 return Err(invalid_request(
449 method,
450 "params.outputBytesCap must be > 0",
451 params,
452 ));
453 }
454 }
455 if let Some(size) = obj.get("size") {
456 if !tty {
457 return Err(invalid_request(
458 method,
459 "params.size is only valid when params.tty is true",
460 params,
461 ));
462 }
463 validate_command_exec_size(size, method, params)?;
464 }
465 if let Some(sandbox_policy) = obj.get("sandboxPolicy") {
466 summarize_sandbox_policy_wire_value(sandbox_policy, "params.sandboxPolicy")
467 .map_err(|reason| invalid_request(method, &reason, params))?;
468 }
469
470 Ok(())
471}
472
473fn validate_command_exec_write_request(params: &Value, method: &str) -> Result<(), RpcError> {
474 require_string(params, method, "processId", "params")?;
475 let obj = require_object(params, method, "params")?;
476 let has_delta = obj.get("deltaBase64").and_then(Value::as_str).is_some();
477 let close_stdin = get_bool(obj, "closeStdin");
478 if !has_delta && !close_stdin {
479 return Err(invalid_request(
480 method,
481 "params must include deltaBase64, closeStdin, or both",
482 params,
483 ));
484 }
485 Ok(())
486}
487
488fn validate_command_exec_resize_request(params: &Value, method: &str) -> Result<(), RpcError> {
489 require_string(params, method, "processId", "params")?;
490 let obj = require_object(params, method, "params")?;
491 let size = obj
492 .get("size")
493 .ok_or_else(|| invalid_request(method, "params.size must be an object", params))?;
494 validate_command_exec_size(size, method, params)
495}
496
497fn validate_command_exec_response(result: &Value, method: &str) -> Result<(), RpcError> {
498 let obj = require_object(result, method, "result")?;
499 match obj.get("exitCode").and_then(Value::as_i64) {
500 Some(code) if i32::try_from(code).is_ok() => {}
501 _ => {
502 return Err(invalid_response(
503 method,
504 "result.exitCode must be an i32-compatible integer",
505 result,
506 ));
507 }
508 }
509 if obj.get("stdout").and_then(Value::as_str).is_none() {
510 return Err(invalid_response(
511 method,
512 "result.stdout must be a string",
513 result,
514 ));
515 }
516 if obj.get("stderr").and_then(Value::as_str).is_none() {
517 return Err(invalid_response(
518 method,
519 "result.stderr must be a string",
520 result,
521 ));
522 }
523 Ok(())
524}
525
526fn validate_command_exec_size(size: &Value, method: &str, payload: &Value) -> Result<(), RpcError> {
527 let size_obj = size
528 .as_object()
529 .ok_or_else(|| invalid_request(method, "params.size must be an object", payload))?;
530 let rows = size_obj.get("rows").and_then(Value::as_u64).unwrap_or(0);
531 let cols = size_obj.get("cols").and_then(Value::as_u64).unwrap_or(0);
532 if rows == 0 {
533 return Err(invalid_request(
534 method,
535 "params.size.rows must be > 0",
536 payload,
537 ));
538 }
539 if cols == 0 {
540 return Err(invalid_request(
541 method,
542 "params.size.cols must be > 0",
543 payload,
544 ));
545 }
546 Ok(())
547}
548
549fn get_optional_non_empty_string<'a>(
550 obj: &'a serde_json::Map<String, Value>,
551 key: &str,
552) -> Result<Option<&'a str>, String> {
553 match obj.get(key) {
554 Some(Value::String(text)) if !text.trim().is_empty() => Ok(Some(text)),
555 Some(Value::String(_)) => Err(format!("params.{key} must be a non-empty string")),
556 Some(_) => Err(format!("params.{key} must be a string")),
557 None => Ok(None),
558 }
559}
560
561fn get_bool(obj: &serde_json::Map<String, Value>, key: &str) -> bool {
562 obj.get(key).and_then(Value::as_bool).unwrap_or(false)
563}
564
565fn invalid_request(method: &str, reason: &str, payload: &Value) -> RpcError {
566 RpcError::InvalidRequest(format!(
567 "invalid json-rpc request for {method}: {reason}; payload={}",
568 payload_summary(payload)
569 ))
570}
571
572fn invalid_response(method: &str, reason: &str, payload: &Value) -> RpcError {
573 RpcError::InvalidRequest(format!(
574 "invalid json-rpc response for {method}: {reason}; payload={}",
575 payload_summary(payload)
576 ))
577}
578
579pub(crate) fn payload_summary(payload: &Value) -> String {
580 const MAX_KEYS: usize = 6;
581 match payload {
582 Value::Object(map) => {
583 let mut keys: Vec<&str> = map.keys().map(|key| key.as_str()).collect();
584 keys.sort_unstable();
585 let preview: Vec<&str> = keys.into_iter().take(MAX_KEYS).collect();
586 let more = if map.len() > MAX_KEYS { ",..." } else { "" };
587 format!("object(keys=[{}{}])", preview.join(","), more)
588 }
589 Value::Array(items) => format!("array(len={})", items.len()),
590 Value::String(text) => format!("string(len={})", text.len()),
591 Value::Number(_) => "number".to_owned(),
592 Value::Bool(_) => "bool".to_owned(),
593 Value::Null => "null".to_owned(),
594 }
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600 use serde_json::json;
601
602 #[test]
603 fn rejects_empty_method() {
604 let err = validate_rpc_request("", &json!({}), RpcValidationMode::KnownMethods)
605 .expect_err("empty method must fail");
606 assert!(matches!(err, RpcError::InvalidRequest(_)));
607 }
608
609 #[test]
610 fn validates_turn_interrupt_params_shape() {
611 let err = validate_rpc_request(
612 "turn/interrupt",
613 &json!({"threadId":"thr"}),
614 RpcValidationMode::KnownMethods,
615 )
616 .expect_err("missing turnId must fail");
617 assert!(matches!(err, RpcError::InvalidRequest(_)));
618
619 validate_rpc_request(
620 "turn/interrupt",
621 &json!({"threadId":"thr", "turnId":"turn"}),
622 RpcValidationMode::KnownMethods,
623 )
624 .expect("valid params");
625 }
626
627 #[test]
628 fn validates_thread_start_accepts_string_sandbox_mode() {
629 validate_rpc_request(
630 "thread/start",
631 &json!({"cwd":"/tmp","sandbox":"read-only"}),
632 RpcValidationMode::KnownMethods,
633 )
634 .expect("thread/start should accept sandbox mode");
635 }
636
637 #[test]
638 fn validates_thread_start_rejects_non_string_sandbox_mode() {
639 let err = validate_rpc_request(
640 "thread/start",
641 &json!({"cwd":"/tmp","sandbox":{"type":"readOnly"}}),
642 RpcValidationMode::KnownMethods,
643 )
644 .expect_err("thread/start must reject non-string sandbox");
645 assert!(matches!(err, RpcError::InvalidRequest(_)));
646 }
647
648 #[test]
649 fn validates_thread_start_rejects_unknown_sandbox_mode() {
650 let err = validate_rpc_request(
651 "thread/start",
652 &json!({"cwd":"/tmp","sandbox":"external-sandbox"}),
653 RpcValidationMode::KnownMethods,
654 )
655 .expect_err("thread/start must reject unknown sandbox mode");
656 assert!(matches!(err, RpcError::InvalidRequest(_)));
657 }
658
659 #[test]
660 fn validates_thread_start_response_thread_id() {
661 let err = validate_rpc_response(
662 "thread/start",
663 &json!({"thread": {}}),
664 RpcValidationMode::KnownMethods,
665 )
666 .expect_err("missing thread id must fail");
667 assert!(matches!(err, RpcError::InvalidRequest(_)));
668
669 validate_rpc_response(
670 "thread/start",
671 &json!({"thread": {"id":"thr_1"}}),
672 RpcValidationMode::KnownMethods,
673 )
674 .expect("valid response");
675 }
676
677 #[test]
678 fn validates_turn_start_response_turn_id() {
679 let err = validate_rpc_response(
680 "turn/start",
681 &json!({"turn": {}}),
682 RpcValidationMode::KnownMethods,
683 )
684 .expect_err("missing turn id must fail");
685 assert!(matches!(err, RpcError::InvalidRequest(_)));
686
687 validate_rpc_response(
688 "turn/start",
689 &json!({"turn": {"id":"turn_1"}}),
690 RpcValidationMode::KnownMethods,
691 )
692 .expect("valid response");
693 }
694
695 #[test]
696 fn validates_skills_list_response_shape() {
697 let err = validate_rpc_response(
698 "skills/list",
699 &json!({"skills":[]}),
700 RpcValidationMode::KnownMethods,
701 )
702 .expect_err("missing result.data must fail");
703 assert!(matches!(err, RpcError::InvalidRequest(_)));
704
705 validate_rpc_response(
706 "skills/list",
707 &json!({"data":[]}),
708 RpcValidationMode::KnownMethods,
709 )
710 .expect("valid response");
711 }
712
713 #[test]
714 fn validates_command_exec_request_constraints() {
715 let err = validate_rpc_request(
716 "command/exec",
717 &json!({"command":["bash"],"tty":true}),
718 RpcValidationMode::KnownMethods,
719 )
720 .expect_err("tty without processId must fail");
721 assert!(matches!(err, RpcError::InvalidRequest(_)));
722
723 let err = validate_rpc_request(
724 "command/exec",
725 &json!({"command":["bash"],"disableTimeout":true,"timeoutMs":1}),
726 RpcValidationMode::KnownMethods,
727 )
728 .expect_err("disableTimeout + timeoutMs must fail");
729 assert!(matches!(err, RpcError::InvalidRequest(_)));
730
731 validate_rpc_request(
732 "command/exec",
733 &json!({"command":["bash"],"processId":"proc-1","tty":true}),
734 RpcValidationMode::KnownMethods,
735 )
736 .expect("tty with processId should pass");
737 }
738
739 #[test]
740 fn validates_command_exec_response_shape() {
741 let err = validate_rpc_response(
742 "command/exec",
743 &json!({"exitCode":0,"stdout":"ok"}),
744 RpcValidationMode::KnownMethods,
745 )
746 .expect_err("stderr missing must fail");
747 assert!(matches!(err, RpcError::InvalidRequest(_)));
748
749 validate_rpc_response(
750 "command/exec",
751 &json!({"exitCode":0,"stdout":"ok","stderr":""}),
752 RpcValidationMode::KnownMethods,
753 )
754 .expect("valid command exec response");
755 }
756
757 #[test]
758 fn passes_unknown_method_in_known_mode() {
759 validate_rpc_request(
760 "echo/custom",
761 &json!({"k":"v"}),
762 RpcValidationMode::KnownMethods,
763 )
764 .expect("unknown method request should pass");
765 validate_rpc_response(
766 "echo/custom",
767 &json!({"ok":true}),
768 RpcValidationMode::KnownMethods,
769 )
770 .expect("unknown method response should pass");
771 }
772
773 #[test]
774 fn known_method_catalog_is_stable() {
775 assert_eq!(
776 methods::KNOWN,
777 [
778 methods::THREAD_START,
779 methods::THREAD_RESUME,
780 methods::THREAD_FORK,
781 methods::THREAD_ARCHIVE,
782 methods::THREAD_READ,
783 methods::THREAD_LIST,
784 methods::THREAD_LOADED_LIST,
785 methods::THREAD_ROLLBACK,
786 methods::SKILLS_LIST,
787 methods::COMMAND_EXEC,
788 methods::COMMAND_EXEC_WRITE,
789 methods::COMMAND_EXEC_TERMINATE,
790 methods::COMMAND_EXEC_RESIZE,
791 methods::TURN_START,
792 methods::TURN_INTERRUPT,
793 ]
794 );
795 }
796
797 #[test]
798 fn descriptor_catalog_matches_known_method_catalog() {
799 let descriptor_methods: Vec<&'static str> = rpc_contract_descriptors()
800 .iter()
801 .map(|descriptor| descriptor.method)
802 .collect();
803 assert_eq!(descriptor_methods, methods::KNOWN);
804 }
805
806 #[test]
807 fn default_validation_mode_is_known_methods() {
808 assert_eq!(
809 RpcValidationMode::default(),
810 RpcValidationMode::KnownMethods
811 );
812 }
813
814 #[test]
815 fn skips_validation_in_none_mode() {
816 validate_rpc_request("", &json!(null), RpcValidationMode::None)
817 .expect_err("empty method must still fail");
818
819 validate_rpc_request("turn/start", &json!(null), RpcValidationMode::None)
820 .expect("none mode skips params shape");
821 validate_rpc_response("turn/start", &json!(null), RpcValidationMode::None)
822 .expect("none mode skips result shape");
823 }
824
825 #[test]
826 fn invalid_request_error_redacts_payload_values() {
827 let err = validate_rpc_request(
828 "turn/interrupt",
829 &json!({"threadId":"thr_sensitive","secret":"token-123"}),
830 RpcValidationMode::KnownMethods,
831 )
832 .expect_err("missing turnId must fail");
833
834 let RpcError::InvalidRequest(message) = err else {
835 panic!("expected invalid request");
836 };
837 assert!(message.contains("invalid json-rpc request for turn/interrupt"));
838 assert!(message.contains("params.turnId must be a non-empty string"));
839 assert!(message.contains("payload=object(keys=[secret,threadId])"));
840 assert!(!message.contains("token-123"));
841 assert!(!message.contains("thr_sensitive"));
842 }
843
844 #[test]
845 fn invalid_response_error_redacts_payload_values() {
846 let err = validate_rpc_response(
847 "thread/start",
848 &json!({"thread": {}, "secret": {"token":"abc"}}),
849 RpcValidationMode::KnownMethods,
850 )
851 .expect_err("missing thread id must fail");
852
853 let RpcError::InvalidRequest(message) = err else {
854 panic!("expected invalid request");
855 };
856 assert!(message.contains("invalid json-rpc response for thread/start"));
857 assert!(message.contains("result is missing thread id"));
858 assert!(message.contains("payload=object(keys=[secret,thread])"));
859 assert!(!message.contains("abc"));
860 }
861
862 #[test]
863 fn rejects_response_scalar_id_fallback() {
864 let err = validate_rpc_response(
865 "thread/start",
866 &json!("thr_scalar"),
867 RpcValidationMode::KnownMethods,
868 )
869 .expect_err("scalar id fallback must not be accepted");
870 assert!(matches!(err, RpcError::InvalidRequest(_)));
871 }
872}