Refuse a request field the server does not know

A misspelled field was accepted and dropped. Sending `permission_mode`
instead of `permissionMode` produced a 200 and a session running in the
default permission mode -- so the caller's setting was gone, and nothing
anywhere said so. That is the expensive shape: indistinguishable from
success at the place you are looking. It cost an hour here, chasing a
"startup race" that was a key serde had silently discarded; with the name
spelled the way the API asks, a bypassPermissions session runs a `sleep`
loop with no prompt at all.

So every request body refuses unknown fields, not just the one that bit.
Axum's message names the offending field and lists what was expected,
which is the whole of what the caller needs.

Query strings are deliberately left permissive: a stale link carrying an
extra parameter is not a mistake worth failing a request over.

53 tests, clippy and rustfmt clean; verified against the running server
that the misspelling is now a 422 naming the field and the correct
spelling still spawns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-29 06:13:18 -04:00
1 parent d8570f4d5a
commit 446fb92ba3
1 file changed
+19
+19
View File
@@ -186,8 +186,19 @@ fn info_for(setup: crate::config::SetupConfig) -> SetupInfo {
/// Note what is absent: nothing here names a program. Providers are found /// Note what is absent: nothing here names a program. Providers are found
/// by asking the machine (`crate::setups`), never sent, so the enrolled /// by asking the machine (`crate::setups`), never sent, so the enrolled
/// token cannot introduce something to run. /// token cannot introduce something to run.
/// Every request body below refuses fields it does not know.
///
/// Silently ignoring one is the worst available answer: a caller that
/// misspells `permissionMode` gets a session in the default permission mode
/// and a 200 saying it worked, which is indistinguishable from success at
/// the place they are looking. It cost an hour of chasing a "race" that was
/// a snake_case key serde had dropped on the floor. A 400 naming the field
/// is the whole fix, and it belongs on all of them rather than on the one
/// that bit -- query strings are deliberately excluded, since a stale link
/// carrying an extra parameter is not a mistake worth failing.
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct SshRequest { struct SshRequest {
address: String, address: String,
#[serde(default)] #[serde(default)]
@@ -225,6 +236,7 @@ impl SshRequest {
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct AddSetupRequest { struct AddSetupRequest {
name: String, name: String,
/// Absent means this machine. /// Absent means this machine.
@@ -239,6 +251,7 @@ struct AddSetupRequest {
/// form that caused it, rather than at the first spawn. /// form that caused it, rather than at the first spawn.
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct ProbeRequest { struct ProbeRequest {
#[serde(default)] #[serde(default)]
ssh: Option<SshRequest>, ssh: Option<SshRequest>,
@@ -320,6 +333,7 @@ async fn read_setup(
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct UpdateSetupRequest { struct UpdateSetupRequest {
#[serde(default)] #[serde(default)]
name: Option<String>, name: Option<String>,
@@ -365,6 +379,7 @@ async fn delete_setup(
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct SpawnRequest { struct SpawnRequest {
/// Which machine, and which of the things it offers. /// Which machine, and which of the things it offers.
setup: String, setup: String,
@@ -576,6 +591,7 @@ async fn delete_session(
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct MessageRequest { struct MessageRequest {
text: String, text: String,
/// Ids from `POST /attachments`, uploaded before the message that /// Ids from `POST /attachments`, uploaded before the message that
@@ -599,6 +615,7 @@ async fn message(
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct AnswerRequest { struct AnswerRequest {
question_id: String, question_id: String,
answer: String, answer: String,
@@ -658,6 +675,7 @@ async fn usage(
} }
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ModelRequest { struct ModelRequest {
model: String, model: String,
} }
@@ -675,6 +693,7 @@ async fn set_model(
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
struct PermissionModeRequest { struct PermissionModeRequest {
mode: String, mode: String,
} }