Skip to main content

codex_runtime/runtime/
rpc_contract.rs

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
7/// Canonical method catalog shared by facade constants and known-method validation.
8pub 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    // Server-request methods (runtime inbound requests requiring a client response)
26    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    // Server-pushed notification events (not client requests)
34    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/// Validation mode for JSON-RPC data integrity checks.
70#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
71pub enum RpcValidationMode {
72    /// Skip all contract checks.
73    None,
74    /// Validate only methods known to the current app-server contract.
75    #[default]
76    KnownMethods,
77}
78
79/// Request-shape rule for one RPC method contract descriptor.
80#[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/// Response-shape rule for one RPC method contract descriptor.
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub enum RpcResponseContract {
95    Object,
96    ThreadId,
97    TurnId,
98    DataArray,
99    CommandExec,
100}
101
102/// Single-source descriptor for one app-server RPC contract method.
103#[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
188/// Canonical RPC contract descriptor list (single source of truth).
189pub fn rpc_contract_descriptors() -> &'static [RpcContractDescriptor] {
190    &RPC_CONTRACT_DESCRIPTORS
191}
192
193/// Contract descriptor for one method, when the method is known.
194pub fn rpc_contract_descriptor(method: &str) -> Option<&'static RpcContractDescriptor> {
195    RPC_CONTRACT_DESCRIPTORS
196        .iter()
197        .find(|descriptor| descriptor.method == method)
198}
199
200/// Validate outgoing JSON-RPC request payload for one method.
201///
202/// - Always validates that method name is non-empty.
203/// - In `KnownMethods` mode, validates request shape for known methods.
204pub 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
221/// Validate incoming JSON-RPC result payload for one method.
222///
223/// In `KnownMethods` mode this enforces minimum shape invariants for known methods.
224pub 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}