Skip to main content

lean_ctx/core/a2a/
a2a_compat.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::path::Path;
4
5use super::task::{
6    REASON_CANCELED_VIA_A2A, REASON_INPUT_RECEIVED, Task, TaskMessage, TaskPart, TaskState,
7    TaskStore,
8};
9
10use crate::core::context_admission::carrier::Withheld;
11
12#[derive(Debug, Deserialize)]
13pub struct JsonRpcRequest {
14    pub jsonrpc: String,
15    pub id: Value,
16    pub method: String,
17    #[serde(default)]
18    pub params: Value,
19}
20
21#[derive(Debug, Serialize)]
22pub struct JsonRpcResponse {
23    pub jsonrpc: String,
24    pub id: Value,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub result: Option<Value>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub error: Option<JsonRpcError>,
29}
30
31#[derive(Debug, Serialize)]
32pub struct JsonRpcError {
33    pub code: i32,
34    pub message: String,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub data: Option<Value>,
37}
38
39impl JsonRpcResponse {
40    fn success(id: Value, result: Value) -> Self {
41        Self {
42            jsonrpc: "2.0".to_string(),
43            id,
44            result: Some(result),
45            error: None,
46        }
47    }
48
49    fn error(id: Value, code: i32, message: &str) -> Self {
50        Self {
51            jsonrpc: "2.0".to_string(),
52            id,
53            result: None,
54            error: Some(JsonRpcError {
55                code,
56                message: message.to_string(),
57                data: None,
58            }),
59        }
60    }
61
62    pub fn server_error(id: Value, message: &str) -> Self {
63        Self::error(id, -32603, message)
64    }
65}
66
67/// The originating agent id of every task created over `/a2a` (#1913).
68///
69/// The endpoint authenticates one principal — the bearer-token holder — and
70/// has no per-agent identity. `message.role` is the A2A `"user" | "agent"`
71/// enum chosen by the caller, so it can never name the sender.
72pub const A2A_CALLER: &str = "a2a-client";
73
74/// Handle a JSON-RPC 2.0 A2A protocol request.
75/// Supported methods: message/send (and its legacy name tasks/send),
76/// tasks/get, tasks/cancel.
77pub fn handle_a2a_jsonrpc(req: &JsonRpcRequest) -> JsonRpcResponse {
78    let path = match TaskStore::default_path() {
79        Ok(path) => path,
80        Err(error) => return storage_error(req, &error),
81    };
82    handle_a2a_jsonrpc_at_path(req, &path)
83}
84
85pub fn handle_a2a_jsonrpc_at_path(req: &JsonRpcRequest, path: &Path) -> JsonRpcResponse {
86    if req.jsonrpc != "2.0" {
87        return JsonRpcResponse::error(req.id.clone(), -32600, "invalid jsonrpc version");
88    }
89
90    match req.method.as_str() {
91        "message/send" | "tasks/send" => handle_send_message(req, path),
92        "tasks/get" => handle_get_task(req, path),
93        "tasks/cancel" => handle_cancel_task(req, path),
94        _ => JsonRpcResponse::error(
95            req.id.clone(),
96            -32601,
97            &format!("method not found: {}", req.method),
98        ),
99    }
100}
101
102/// A task this unauthenticated legacy route may read or mutate: created over
103/// `/a2a` (not local `ctx_task` work, #1913) and not bound to a signed remote
104/// principal. Unknown and foreign ids answer identically so the endpoint
105/// cannot enumerate either kind.
106fn reachable_over_a2a(task: &Task) -> bool {
107    task.from_agent == A2A_CALLER && task.authority_key_id.is_none()
108}
109
110fn task_not_found(req: &JsonRpcRequest) -> JsonRpcResponse {
111    JsonRpcResponse::error(req.id.clone(), -32602, "task not found")
112}
113
114fn handle_send_message(req: &JsonRpcRequest, path: &Path) -> JsonRpcResponse {
115    let params = &req.params;
116    let message = params.get("message");
117
118    let role = match message.and_then(|m| m.get("role")).and_then(Value::as_str) {
119        None => "user",
120        Some(role @ ("user" | "agent")) => role,
121        Some(_) => {
122            return JsonRpcResponse::error(
123                req.id.clone(),
124                -32602,
125                "message.role must be \"user\" or \"agent\"",
126            );
127        }
128    };
129    let to_agent = params
130        .get("to")
131        .and_then(Value::as_str)
132        .unwrap_or("lean-ctx");
133    let parts = extract_message_parts(params);
134    let description = parts
135        .iter()
136        .find_map(|part| match part {
137            TaskPart::Text { text } if !text.is_empty() => Some(text.clone()),
138            _ => None,
139        })
140        .unwrap_or_default();
141
142    if description.is_empty() {
143        return JsonRpcResponse::error(req.id.clone(), -32602, "message text is required");
144    }
145
146    // `message/send` names an existing task in `message.taskId`; the legacy
147    // `tasks/send` used `params.id`.
148    let existing = message
149        .and_then(|m| m.get("taskId"))
150        .or_else(|| params.get("id"))
151        .and_then(Value::as_str);
152
153    // The parts are new caller content: admitted for the store before
154    // anything is written. A refusal rejects inside the locked mutation, so
155    // nothing of the message is saved.
156    let refuse =
157        |withheld: Withheld| LegacyMutationError::rejected(message_withheld(req, &withheld));
158    match TaskStore::mutate_locked(path, |store| {
159        let task_id = if let Some(id) = existing {
160            let Some(task) = store
161                .get_task_mut(id)
162                .filter(|task| reachable_over_a2a(task))
163            else {
164                return Err(LegacyMutationError::rejected(task_not_found(req)));
165            };
166            task.append_message(role, &parts).map_err(refuse)?;
167            if task.state == TaskState::InputRequired {
168                task.transition(TaskState::Working, Some(REASON_INPUT_RECEIVED))
169                    .map_err(std::io::Error::other)?;
170            }
171            id.to_string()
172        } else {
173            let parts = parts
174                .iter()
175                .map(TaskPart::admitted)
176                .collect::<Result<Vec<_>, _>>()
177                .map_err(refuse)?;
178            let task = store
179                .create_admitted(A2A_CALLER, to_agent, &description)
180                .map_err(refuse)?;
181            // `Task::new` seeds the opening message with the originator id as
182            // its role; an A2A message keeps the caller's role and all parts.
183            if let Some(opening) = task.messages.first_mut() {
184                opening.role = role.to_string();
185                opening.parts = parts;
186            }
187            task.id.clone()
188        };
189        Ok(JsonRpcResponse::success(
190            req.id.clone(),
191            task_to_a2a_json(store.get_task(&task_id)),
192        ))
193    }) {
194        Ok(response) => response,
195        Err(LegacyMutationError::Rejected(response)) => *response,
196        Err(LegacyMutationError::Storage(error)) => storage_error(req, &error),
197    }
198}
199
200fn handle_get_task(req: &JsonRpcRequest, path: &Path) -> JsonRpcResponse {
201    let Some(task_id) = req.params.get("id").and_then(Value::as_str) else {
202        return JsonRpcResponse::error(req.id.clone(), -32602, "id is required");
203    };
204
205    match TaskStore::read_locked(path, |store| {
206        store
207            .get_task(task_id)
208            .filter(|task| reachable_over_a2a(task))
209            .map(|task| task_to_a2a_json(Some(task)))
210    }) {
211        Ok(Some(task)) => JsonRpcResponse::success(req.id.clone(), task),
212        Ok(None) => task_not_found(req),
213        Err(error) => storage_error(req, &error),
214    }
215}
216
217fn handle_cancel_task(req: &JsonRpcRequest, path: &Path) -> JsonRpcResponse {
218    let Some(task_id) = req.params.get("id").and_then(Value::as_str) else {
219        return JsonRpcResponse::error(req.id.clone(), -32602, "id is required");
220    };
221
222    match TaskStore::mutate_locked(path, |store| {
223        let Some(task) = store
224            .get_task_mut(task_id)
225            .filter(|task| reachable_over_a2a(task))
226        else {
227            return Err(LegacyMutationError::rejected(task_not_found(req)));
228        };
229        if let Err(error) = task.transition(TaskState::Canceled, Some(REASON_CANCELED_VIA_A2A)) {
230            return Err(LegacyMutationError::rejected(JsonRpcResponse::error(
231                req.id.clone(),
232                -32603,
233                &error,
234            )));
235        }
236        Ok(JsonRpcResponse::success(
237            req.id.clone(),
238            task_to_a2a_json(Some(task)),
239        ))
240    }) {
241        Ok(response) => response,
242        Err(LegacyMutationError::Rejected(response)) => *response,
243        Err(LegacyMutationError::Storage(error)) => storage_error(req, &error),
244    }
245}
246
247// Reject inside the locked mutation so TaskStore does not save a denial.
248enum LegacyMutationError {
249    Storage(std::io::Error),
250    Rejected(Box<JsonRpcResponse>),
251}
252
253impl LegacyMutationError {
254    fn rejected(response: JsonRpcResponse) -> Self {
255        Self::Rejected(Box::new(response))
256    }
257}
258
259impl From<std::io::Error> for LegacyMutationError {
260    fn from(error: std::io::Error) -> Self {
261        Self::Storage(error)
262    }
263}
264
265fn storage_error(req: &JsonRpcRequest, error: &std::io::Error) -> JsonRpcResponse {
266    tracing::warn!("A2A task store error: {error}");
267    JsonRpcResponse::error(req.id.clone(), -32603, "task storage unavailable")
268}
269
270/// New message content the gateway does not admit for the store: typed
271/// reason codes only, never the content.
272fn message_withheld(req: &JsonRpcRequest, withheld: &Withheld) -> JsonRpcResponse {
273    JsonRpcResponse::error(req.id.clone(), -32602, &withheld.message("message"))
274}
275
276/// The task as it leaves towards a peer: the served copy ([`Task::served`]),
277/// so stored text is re-authorized now and a withheld part is the typed
278/// gateway notice. The one renderer for `message/send`, `tasks/get` and
279/// `tasks/cancel`.
280fn task_to_a2a_json(task: Option<&Task>) -> Value {
281    let Some(task) = task else {
282        return Value::Null;
283    };
284    let task = task.served();
285
286    let messages: Vec<Value> = task.messages.iter().map(message_to_a2a_json).collect();
287
288    let artifacts: Vec<Value> = task.artifacts.iter().map(part_to_a2a_json).collect();
289
290    let history: Vec<Value> = task
291        .history
292        .iter()
293        .map(|h| {
294            serde_json::json!({
295                "from": h.from.to_string(),
296                "to": h.to.to_string(),
297                "timestamp": h.timestamp.to_rfc3339(),
298                "reason": h.reason,
299            })
300        })
301        .collect();
302
303    serde_json::json!({
304        "id": task.id,
305        "status": serde_json::to_value(task.status_v1()).unwrap_or_default(),
306        "messages": messages,
307        "artifacts": artifacts,
308        "history": history,
309        "metadata": task.metadata,
310        "tenant_id": task.tenant_id,
311        "project_id": task.project_id,
312        "action": task.action,
313        "idempotency_key": task.idempotency_key,
314        "artifact_refs": task.artifact_refs,
315    })
316}
317
318fn message_to_a2a_json(m: &TaskMessage) -> Value {
319    let parts: Vec<Value> = m.parts.iter().map(part_to_a2a_json).collect();
320    serde_json::json!({
321        "role": m.role,
322        "parts": parts,
323        "timestamp": m.timestamp.to_rfc3339(),
324    })
325}
326
327fn part_to_a2a_json(p: &TaskPart) -> Value {
328    match p {
329        TaskPart::Text { text } => serde_json::json!({"type": "text", "text": text}),
330        TaskPart::Data { mime_type, data } => {
331            serde_json::json!({"type": "data", "mimeType": mime_type, "data": data})
332        }
333        TaskPart::File {
334            name,
335            mime_type,
336            data,
337            uri,
338        } => serde_json::json!({
339            "type": "file",
340            "file": {
341                "name": name,
342                "mimeType": mime_type,
343                "bytes": data,
344                "uri": uri,
345            }
346        }),
347    }
348}
349
350fn extract_message_parts(params: &Value) -> Vec<TaskPart> {
351    params
352        .get("message")
353        .and_then(|m| m.get("parts"))
354        .and_then(|p| p.as_array())
355        .map(|parts| {
356            parts
357                .iter()
358                .filter_map(|p| {
359                    // A2A 0.2+ names the discriminator `kind`; 0.1 used `type`.
360                    let ptype = p.get("kind").or_else(|| p.get("type"))?.as_str()?;
361                    match ptype {
362                        "text" => Some(TaskPart::Text {
363                            text: p.get("text")?.as_str()?.to_string(),
364                        }),
365                        "data" => Some(TaskPart::Data {
366                            mime_type: p
367                                .get("mimeType")
368                                .and_then(Value::as_str)
369                                .unwrap_or("application/octet-stream")
370                                .to_string(),
371                            data: p.get("data")?.as_str()?.to_string(),
372                        }),
373                        _ => None,
374                    }
375                })
376                .collect()
377        })
378        .unwrap_or_default()
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    fn make_request(method: &str, params: Value) -> JsonRpcRequest {
386        JsonRpcRequest {
387            jsonrpc: "2.0".to_string(),
388            id: Value::Number(1.into()),
389            method: method.to_string(),
390            params,
391        }
392    }
393
394    fn send_at(path: &Path, method: &str, message: Value) -> JsonRpcResponse {
395        let mut params = serde_json::Map::new();
396        params.insert("message".to_string(), message);
397        handle_a2a_jsonrpc_at_path(&make_request(method, Value::Object(params)), path)
398    }
399
400    fn task_id(resp: &JsonRpcResponse) -> String {
401        resp.result.as_ref().expect("result")["id"]
402            .as_str()
403            .expect("task id")
404            .to_string()
405    }
406
407    fn stored_tasks(path: &Path) -> Vec<Task> {
408        TaskStore::read_locked(path, |store| store.tasks.clone()).unwrap()
409    }
410
411    #[test]
412    fn rejects_unknown_method() {
413        let req = make_request("tasks/unknown", serde_json::json!({}));
414        let resp = handle_a2a_jsonrpc(&req);
415        assert!(resp.error.is_some());
416        assert_eq!(resp.error.unwrap().code, -32601);
417    }
418
419    #[test]
420    fn rejects_missing_message_text() {
421        let req = make_request(
422            "tasks/send",
423            serde_json::json!({
424                "message": { "role": "user", "parts": [] }
425            }),
426        );
427        let resp = handle_a2a_jsonrpc(&req);
428        assert!(resp.error.is_some());
429    }
430
431    #[test]
432    fn send_creates_task() {
433        let dir = tempfile::tempdir().unwrap();
434        let path = dir.path().join("tasks.json");
435        let req = make_request(
436            "tasks/send",
437            serde_json::json!({
438                "to": "lean-ctx",
439                "message": {
440                    "role": "user",
441                    "parts": [{"type": "text", "text": "Fix the auth bug"}]
442                }
443            }),
444        );
445        let resp = handle_a2a_jsonrpc_at_path(&req, &path);
446        assert!(resp.result.is_some());
447        let result = resp.result.unwrap();
448        assert!(result.get("id").is_some());
449        assert_eq!(
450            result.get("status").unwrap().get("state").unwrap().as_str(),
451            Some("created")
452        );
453    }
454
455    #[test]
456    fn get_nonexistent_task_returns_error() {
457        let dir = tempfile::tempdir().unwrap();
458        let req = make_request(
459            "tasks/get",
460            serde_json::json!({"id": "nonexistent-task-id"}),
461        );
462        let resp = handle_a2a_jsonrpc_at_path(&req, &dir.path().join("tasks.json"));
463        assert!(resp.error.is_some());
464    }
465
466    #[test]
467    fn scoped_send_get_cancel_round_trip() {
468        let dir = tempfile::tempdir().unwrap();
469        let path = dir.path().join("tasks.json");
470        let send = make_request(
471            "tasks/send",
472            serde_json::json!({
473                "to": "server-a",
474                "message": {
475                    "role": "agent",
476                    "parts": [{"type": "text", "text": "ship it"}]
477                }
478            }),
479        );
480        let task_id = task_id(&handle_a2a_jsonrpc_at_path(&send, &path));
481
482        let get = make_request("tasks/get", serde_json::json!({"id": task_id.clone()}));
483        let fetched = handle_a2a_jsonrpc_at_path(&get, &path);
484        assert_eq!(
485            fetched.result.unwrap()["status"]["state"],
486            Value::String("created".to_string())
487        );
488
489        let cancel = make_request("tasks/cancel", serde_json::json!({"id": task_id}));
490        let canceled = handle_a2a_jsonrpc_at_path(&cancel, &path);
491        assert_eq!(
492            canceled.result.unwrap()["status"]["state"],
493            Value::String("canceled".to_string())
494        );
495    }
496
497    // #1913: `message.role` is the A2A user/agent enum, never the sender id.
498    #[test]
499    fn role_never_becomes_the_originating_agent() {
500        let dir = tempfile::tempdir().unwrap();
501        let path = dir.path().join("tasks.json");
502        let resp = send_at(
503            &path,
504            "tasks/send",
505            serde_json::json!({"role": "agent", "parts": [{"type": "text", "text": "x"}]}),
506        );
507        let id = task_id(&resp);
508        let tasks = stored_tasks(&path);
509        let task = tasks
510            .iter()
511            .find(|task| task.id == id)
512            .expect("stored task");
513        assert_eq!(task.from_agent, A2A_CALLER);
514        assert_eq!(task.messages.len(), 1);
515        assert_eq!(task.messages[0].role, "agent");
516    }
517
518    #[test]
519    fn rejects_role_outside_the_a2a_enum() {
520        let dir = tempfile::tempdir().unwrap();
521        let path = dir.path().join("tasks.json");
522        let resp = send_at(
523            &path,
524            "message/send",
525            serde_json::json!({"role": "cursor-agent-1", "parts": [{"kind": "text", "text": "x"}]}),
526        );
527        assert_eq!(resp.error.expect("error").code, -32602);
528        assert!(!path.exists() || stored_tasks(&path).is_empty());
529    }
530
531    #[test]
532    fn message_send_accepts_kind_parts_and_task_id_follow_ups() {
533        let dir = tempfile::tempdir().unwrap();
534        let path = dir.path().join("tasks.json");
535        let first = send_at(
536            &path,
537            "message/send",
538            serde_json::json!({"role": "user", "parts": [{"kind": "text", "text": "review"}]}),
539        );
540        let id = task_id(&first);
541        let follow_up = send_at(
542            &path,
543            "message/send",
544            serde_json::json!({
545                "role": "user",
546                "taskId": id,
547                "parts": [{"kind": "text", "text": "also the tests"}]
548            }),
549        );
550        assert_eq!(task_id(&follow_up), id);
551        let tasks = stored_tasks(&path);
552        assert_eq!(tasks.len(), 1);
553        assert_eq!(tasks[0].messages.len(), 2);
554    }
555
556    // #1913: an A2A caller must not read, cancel or append to tasks that
557    // local agents created through ctx_task.
558    #[test]
559    fn local_tasks_are_not_reachable_through_a2a_mutations() {
560        let dir = tempfile::tempdir().unwrap();
561        let path = dir.path().join("tasks.json");
562        let id = TaskStore::mutate_locked(&path, |store| {
563            Ok::<_, std::io::Error>(store.create_task("cursor-agent-1", "codex-agent-2", "local"))
564        })
565        .unwrap();
566        let before = std::fs::read(&path).unwrap();
567
568        let get = make_request("tasks/get", serde_json::json!({"id": id}));
569        assert_eq!(
570            handle_a2a_jsonrpc_at_path(&get, &path)
571                .error
572                .expect("error")
573                .code,
574            -32602
575        );
576        let cancel = make_request("tasks/cancel", serde_json::json!({"id": id}));
577        assert_eq!(
578            handle_a2a_jsonrpc_at_path(&cancel, &path)
579                .error
580                .expect("error")
581                .code,
582            -32602
583        );
584        let append = send_at(
585            &path,
586            "message/send",
587            serde_json::json!({
588                "role": "user",
589                "taskId": id,
590                "parts": [{"kind": "text", "text": "hijack"}]
591            }),
592        );
593        assert_eq!(append.error.expect("error").code, -32602);
594
595        assert_eq!(std::fs::read(&path).unwrap(), before, "denials never save");
596        let task = stored_tasks(&path).remove(0);
597        assert_eq!(task.state, TaskState::Created);
598        assert_eq!(task.messages.len(), 1, "no A2A message was appended");
599    }
600
601    #[test]
602    fn legacy_route_cannot_read_or_mutate_signed_remote_tasks() {
603        let dir = tempfile::tempdir().unwrap();
604        let path = dir.path().join("tasks.json");
605        // Even a task carrying the A2A caller id stays unreachable once it is
606        // bound to a signed remote principal.
607        let task_id = TaskStore::mutate_locked(&path, |store| {
608            let id = store.create_task(A2A_CALLER, "server-a", "signed work");
609            store.get_task_mut(&id).unwrap().authority_key_id = Some("key-a".to_string());
610            Ok::<_, std::io::Error>(id)
611        })
612        .unwrap();
613
614        let before_get = std::fs::read(&path).unwrap();
615        let get = make_request("tasks/get", serde_json::json!({"id": task_id}));
616        let unknown = make_request("tasks/get", serde_json::json!({"id": "unknown"}));
617        let denied = handle_a2a_jsonrpc_at_path(&get, &path);
618        let missing = handle_a2a_jsonrpc_at_path(&unknown, &path);
619        assert!(denied.result.is_none());
620        assert_eq!(
621            serde_json::to_value(&denied).unwrap(),
622            serde_json::to_value(&missing).unwrap()
623        );
624        assert_eq!(std::fs::read(&path).unwrap(), before_get);
625
626        let append = make_request(
627            "tasks/send",
628            serde_json::json!({
629                "id": task_id,
630                "message": {
631                    "role": "agent",
632                    "parts": [{"type": "text", "text": "tamper"}]
633                }
634            }),
635        );
636        let denied_append = handle_a2a_jsonrpc_at_path(&append, &path);
637        let mut missing_params = append.params.clone();
638        missing_params["id"] = serde_json::json!("unknown");
639        let missing_append =
640            handle_a2a_jsonrpc_at_path(&make_request("tasks/send", missing_params), &path);
641        assert_eq!(
642            serde_json::to_value(&denied_append).unwrap(),
643            serde_json::to_value(&missing_append).unwrap()
644        );
645        assert_eq!(denied_append.error.unwrap().code, -32602);
646
647        let cancel = make_request("tasks/cancel", serde_json::json!({"id": task_id}));
648        assert_eq!(std::fs::read(&path).unwrap(), before_get);
649        let denied_cancel = handle_a2a_jsonrpc_at_path(&cancel, &path);
650        let missing_cancel = handle_a2a_jsonrpc_at_path(
651            &make_request("tasks/cancel", serde_json::json!({"id": "unknown"})),
652            &path,
653        );
654        assert_eq!(
655            serde_json::to_value(&denied_cancel).unwrap(),
656            serde_json::to_value(&missing_cancel).unwrap()
657        );
658        assert_eq!(denied_cancel.error.unwrap().code, -32602);
659        assert_eq!(std::fs::read(&path).unwrap(), before_get);
660        TaskStore::read_locked(&path, |store| {
661            let task = store.get_task(&task_id).unwrap();
662            assert_eq!(task.state, TaskState::Created);
663            assert_eq!(task.messages.len(), 1);
664        })
665        .unwrap();
666    }
667
668    #[test]
669    fn a2a_caller_can_cancel_its_own_task() {
670        let dir = tempfile::tempdir().unwrap();
671        let path = dir.path().join("tasks.json");
672        let resp = send_at(
673            &path,
674            "message/send",
675            serde_json::json!({"parts": [{"kind": "text", "text": "mine"}]}),
676        );
677        let id = task_id(&resp);
678        let cancel = make_request("tasks/cancel", serde_json::json!({"id": id}));
679        assert_eq!(
680            handle_a2a_jsonrpc_at_path(&cancel, &path)
681                .result
682                .expect("result")["status"]["state"]
683                .as_str(),
684            Some("canceled")
685        );
686    }
687
688    #[test]
689    fn corrupt_store_returns_server_error_without_overwrite() {
690        let dir = tempfile::tempdir().unwrap();
691        let path = dir.path().join("tasks.json");
692        std::fs::write(&path, b"broken").unwrap();
693        let req = make_request("tasks/get", serde_json::json!({"id": "task-1"}));
694
695        let response = handle_a2a_jsonrpc_at_path(&req, &path);
696
697        assert_eq!(response.error.unwrap().code, -32603);
698        assert_eq!(std::fs::read(path).unwrap(), b"broken");
699    }
700}