//! A deliberately deaf JSON-RPC client. //! //! The Codex app-server can send requests *to* its client, including //! `account/chatgptAuthTokens/refresh`, which returns a bare access token. //! AGENTS.md forbids Lumbridge from scraping a harness's private credentials. //! Choosing not to call that method would be a convention; this module makes //! it unrepresentable instead. //! //! There is no free-form method string anywhere in this crate. Outbound //! requests come from a two-variant enum, and every inbound server request is //! answered with `-32601 method not found` regardless of what it asks for. //! Classification exists only so a refusal can be counted and named, which //! makes "we were asked for a credential and refused" an auditable event //! rather than an absence. use serde_json::{Value, json}; /// JSON-RPC's "method not found". The only reply this client ever sends. const METHOD_NOT_FOUND: i64 = -32601; /// Codex returns this for a request that needs an account it does not have. pub(crate) const INVALID_REQUEST: i64 = -32600; /// Every request this crate is able to send. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum OutboundRequest { Initialize, ReadAccountRateLimits, } impl OutboundRequest { pub(crate) const fn method(self) -> &'static str { match self { Self::Initialize => "initialize", Self::ReadAccountRateLimits => "account/rateLimits/read", } } } /// The notifications this client accepts. Anything else is ignored. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum AcceptedNotification { AccountRateLimitsUpdated, } impl AcceptedNotification { pub(crate) fn from_method(method: &str) -> Option { match method { "account/rateLimits/updated" => Some(Self::AccountRateLimitsUpdated), _ => None, } } } /// What a server-to-client request was asking for. /// /// Purely descriptive. Every class is refused identically; the distinction /// exists so a probe can report that it declined a credential request. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ServerRequestClass { /// A request that would hand Lumbridge a credential. Always refused. CredentialRefresh, /// A request to run something on the harness's behalf. Execution, /// A request for a human approval decision. Approval, /// A request for ambient information such as the current time. Ambient, /// Anything this client does not recognise. Unrecognised, } impl ServerRequestClass { pub(crate) fn classify(method: &str) -> Self { match method { "account/chatgptAuthTokens/refresh" => Self::CredentialRefresh, "item/tool/call" | "attestation/generate" => Self::Execution, "item/permissions/requestApproval" => Self::Approval, "currentTime/read" => Self::Ambient, _ => Self::Unrecognised, } } #[must_use] pub const fn label(self) -> &'static str { match self { Self::CredentialRefresh => "credential refresh", Self::Execution => "execution", Self::Approval => "approval", Self::Ambient => "ambient information", Self::Unrecognised => "unrecognised request", } } } /// A classified inbound line. #[derive(Clone, Debug)] pub(crate) enum Inbound { /// A successful response to one of our two requests. Result { id: u64, result: Value }, /// A failed response to one of our two requests. Failure { id: u64, code: i64 }, /// An accepted push notification. Notification { kind: AcceptedNotification, params: Value, }, /// The server asked us for something. It will be refused. ServerRequest { id: Value, class: ServerRequestClass, }, /// A well-formed frame with nothing for us in it. Ignored, } /// Encodes one of the two permitted requests. pub(crate) fn encode_request(id: u64, request: OutboundRequest, params: &Value) -> String { json!({ "jsonrpc": "2.0", "id": id, "method": request.method(), "params": params, }) .to_string() } pub(crate) fn encode_notification(method: &'static str, params: &Value) -> String { json!({ "jsonrpc": "2.0", "method": method, "params": params }).to_string() } /// The refusal sent for every server-to-client request. pub(crate) fn encode_refusal(id: &Value) -> String { json!({ "jsonrpc": "2.0", "id": id, "error": { "code": METHOD_NOT_FOUND, "message": "method not found" }, }) .to_string() } /// Classifies one line of JSON-RPC without interpreting its payload. /// /// Returns `None` when the line is not a JSON object at all, which the caller /// reports as [`crate::HarnessError::Malformed`]. pub(crate) fn classify(line: &str) -> Option { let frame: Value = serde_json::from_str(line).ok()?; let object = frame.as_object()?; let method = object.get("method").and_then(Value::as_str); let id = object.get("id"); match (method, id) { // A method with an id is the server asking us for something. (Some(method), Some(id)) => Some(Inbound::ServerRequest { id: id.clone(), class: ServerRequestClass::classify(method), }), // A method without an id is a notification. (Some(method), None) => Some(AcceptedNotification::from_method(method).map_or( Inbound::Ignored, |kind| Inbound::Notification { kind, params: object.get("params").cloned().unwrap_or(Value::Null), }, )), // An id without a method is a response to one of ours. (None, Some(id)) => { let id = id.as_u64()?; if let Some(error) = object.get("error") { let code = error.get("code").and_then(Value::as_i64).unwrap_or(0); return Some(Inbound::Failure { id, code }); } Some(Inbound::Result { id, result: object.get("result").cloned().unwrap_or(Value::Null), }) } (None, None) => Some(Inbound::Ignored), } } #[cfg(test)] mod tests { use super::{ AcceptedNotification, Inbound, OutboundRequest, ServerRequestClass, classify, encode_refusal, encode_request, }; use serde_json::{Value, json}; #[test] fn only_two_methods_can_be_sent() { assert_eq!(OutboundRequest::Initialize.method(), "initialize"); assert_eq!( OutboundRequest::ReadAccountRateLimits.method(), "account/rateLimits/read" ); } #[test] fn a_credential_refresh_request_is_classified_and_refused() { let line = r#"{"jsonrpc":"2.0","id":9,"method":"account/chatgptAuthTokens/refresh"}"#; let Some(Inbound::ServerRequest { id, class }) = classify(line) else { panic!("a method with an id is a server request"); }; assert_eq!(class, ServerRequestClass::CredentialRefresh); let refusal: Value = serde_json::from_str(&encode_refusal(&id)).expect("the refusal is valid JSON"); assert_eq!(refusal["error"]["code"], json!(-32601)); assert!( refusal.get("result").is_none(), "a refusal must never carry a result" ); } #[test] fn every_server_request_class_is_refused_the_same_way() { for method in [ "account/chatgptAuthTokens/refresh", "item/tool/call", "item/permissions/requestApproval", "currentTime/read", "something/entirely/new", ] { let line = format!(r#"{{"jsonrpc":"2.0","id":1,"method":"{method}"}}"#); let Some(Inbound::ServerRequest { id, .. }) = classify(&line) else { panic!("{method} must classify as a server request"); }; let refusal: Value = serde_json::from_str(&encode_refusal(&id)).expect("valid JSON"); assert_eq!(refusal["error"]["code"], json!(-32601)); } } #[test] fn only_the_rate_limit_notification_is_accepted() { let accepted = classify( r#"{"jsonrpc":"2.0","method":"account/rateLimits/updated","params":{"rateLimits":{}}}"#, ); assert!(matches!( accepted, Some(Inbound::Notification { kind: AcceptedNotification::AccountRateLimitsUpdated, .. }) )); let ignored = classify(r#"{"jsonrpc":"2.0","method":"thread/tokenUsage/updated","params":{}}"#); assert!(matches!(ignored, Some(Inbound::Ignored))); } #[test] fn responses_and_failures_are_separated() { assert!(matches!( classify(r#"{"jsonrpc":"2.0","id":7,"result":{"rateLimits":{}}}"#), Some(Inbound::Result { id: 7, .. }) )); assert!(matches!( classify(r#"{"jsonrpc":"2.0","id":7,"error":{"code":-32600,"message":"nope"}}"#), Some(Inbound::Failure { id: 7, code: -32600 }) )); } #[test] fn malformed_and_non_object_lines_are_rejected() { for line in ["", "not json", "[1,2,3]", "\"a string\"", "{"] { assert!(classify(line).is_none(), "{line:?} must not classify"); } } #[test] fn a_request_encodes_without_a_free_form_method() { let encoded = encode_request(1, OutboundRequest::ReadAccountRateLimits, &json!({})); let frame: Value = serde_json::from_str(&encoded).expect("valid JSON"); assert_eq!(frame["method"], json!("account/rateLimits/read")); assert_eq!(frame["jsonrpc"], json!("2.0")); } }