Name the received JSON type in non-object params errors
ci / check (push) Failing after 2m38s

Keep -32602 and existing validation order; tell callers to send the object itself.
Cover host routing, stdout fast paths, SDK calls and generated dispatch.

Card: SAU-317
This commit is contained in:
SAU-317 implementer
2026-10-28 19:16:39 +00:00
parent 9ef73e6bc7
commit c54edea596
7 changed files with 198 additions and 6 deletions
+19 -2
View File
@@ -7,6 +7,23 @@ use serde_json::{Value, json};
use tokio::sync::{mpsc, oneshot, watch};
use uuid::Uuid;
fn params_not_object(params: &Value) -> Value {
let kind = match params {
Value::String(_) => "a string",
Value::Array(_) => "an array",
Value::Number(_) => "a number",
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Object(_) => "an object",
};
error(
-32602,
&format!(
"params must be a JSON object, got {kind}. Send the object itself, not a JSON-encoded string."
),
)
}
impl Host {
/// Queue an incoming response or notification for its destination.
pub(crate) fn send(&self, pid: &str, value: Value) {
@@ -69,7 +86,7 @@ impl Host {
let host = self.clone();
Box::pin(async move {
if !params.is_object() {
return Err(error(-32602, "params must be an object"));
return Err(params_not_object(&params));
}
if caller != "console" && caller != "core" {
let state = host.0.state.lock().unwrap();
@@ -238,7 +255,7 @@ impl Host {
// Deliver progress before a following response removes its destination.
let params = v.get("params").cloned().unwrap_or(json!({}));
let result = if !params.is_object() {
Err(error(-32602, "params must be an object"))
Err(params_not_object(&params))
} else if method == "core.unlock" {
if let Some(path) = params["path"].as_str() {
self.unlock(pid, path);
+107 -2
View File
@@ -500,14 +500,119 @@ async fn unimplemented_core_methods_return_method_not_found() {
async fn params_must_be_objects() {
let w = TestWorld::new();
let h = w.start().await;
for (params, kind) in [
(json!("{\"text\":\"hello\"}"), "a string"),
(json!([]), "an array"),
(json!(42), "a number"),
(json!(null), "null"),
(json!(true), "a boolean"),
] {
let expected = json!({
"code": -32602,
"message": format!("params must be a JSON object, got {kind}. Send the object itself, not a JSON-encoded string.")
});
for method in ["probe.echo", "core.ps"] {
assert_eq!(h.call(method, params.clone()).await.unwrap_err(), expected);
}
assert_eq!(
h.call("core.call", json!({"method":"probe.echo", "params":params}))
.await
.unwrap_err(),
expected
);
}
let object = json!({"text":"hello"});
assert_eq!(call(&h, "probe.echo", object.clone()).await, object);
assert_eq!(
h.call("probe.echo", json!([])).await.unwrap_err()["code"],
-32602
call(
&h,
"core.call",
json!({"method":"probe.echo", "params":object})
)
.await,
object
);
assert_eq!(
call(&h, "core.call", json!({"method":"probe.echo"})).await,
json!({})
);
h.stop().await;
}
/// PROTOCOL 3.1: stdout fast paths reject non-objects without changing defaults.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stdout_params_must_be_objects() {
let w = TestWorld::new();
let h = w.start().await;
let mut notifications = h.subscribe();
for method in ["core.notify", "core.unlock", "probe.echo"] {
for (params, kind) in [
(json!("{}"), "a string"),
(json!([]), "an array"),
(json!(1.5), "a number"),
(json!(null), "null"),
(json!(false), "a boolean"),
] {
let response = call(
&h,
"probe.raw_call",
json!({
"jsonrpc":"2.0", "id":1, "method":method, "params":params
}),
)
.await;
assert_eq!(response["id"], 1);
assert_eq!(
response["error"],
json!({
"code": -32602,
"message": format!("params must be a JSON object, got {kind}. Send the object itself, not a JSON-encoded string.")
})
);
}
let params = match method {
"core.notify" => json!({"name":"valid", "params":{"n":1}}),
"core.unlock" => json!({"path":"/state/not-held"}),
_ => json!({"n":1}),
};
let response = call(
&h,
"probe.raw_call",
json!({
"jsonrpc":"2.0", "id":1, "method":method, "params":params
}),
)
.await;
assert_eq!(
response["result"],
if method == "probe.echo" {
params
} else {
Value::Null
}
);
assert!(response.get("error").is_none());
let omitted = call(
&h,
"probe.raw_call",
json!({
"jsonrpc":"2.0", "id":1, "method":method
}),
)
.await;
match method {
"core.notify" => {
assert_eq!(omitted["error"]["message"], "name must be a string");
assert_eq!(notifications.try_recv().unwrap()["params"]["name"], "valid");
}
"core.unlock" => assert_eq!(omitted["error"]["message"], "path must be a string"),
_ => assert_eq!(omitted["result"], json!({})),
}
}
h.stop().await;
}
/// PROTOCOL 3.9: exit without answer reports status and last logs.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exit_without_answer_reports_status_and_last_logs() {
+4
View File
@@ -470,6 +470,10 @@ fn dispatch(request: &Value, lines: &mut Lines) -> Option<Value> {
"network" => network(params),
"read" => read_file(params),
"write" => write_file(params),
"raw_call" => {
send(params.clone());
receive(lines)
}
"call" => outgoing_call(params, lines),
"lock" => lock(params, lines),
_ => {