From 8104a75b088fff82ee7d5213b8316748df8d518c Mon Sep 17 00:00:00 2001 From: Ben Gao Date: Sun, 6 Sep 2026 13:24:26 +0800 Subject: [PATCH] fix(tui): stop fleet readonly schema probe from auto-vivifying null enums `project_readonly_evidence_schema` used `schema["properties"]["action"]["enum"]` to probe for an action enum. serde_json's IndexMut auto-vivifies missing keys by inserting Null, so schemas with no action property (e.g. lowercase `bash`) ended up with `properties.action = {"enum": null}`. Strict OpenAI-compatible validators then rejected the whole request with `Invalid schema for function 'bash': null is not of type "array"`, observed on Fleet read-only workers. Probe with non-mutating `get_mut` instead, and add a regression test that asserts a reviewer wire catalog carries no null schema fields. --- crates/tui/src/tools/registry.rs | 15 +++++- crates/tui/src/tools/registry/tests.rs | 67 ++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index 83c5e0d826..dd8d75d288 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -510,7 +510,20 @@ fn project_readonly_evidence_schema(name: &str, schema: &mut Value) { } return; } - let Some(actions) = schema["properties"]["action"]["enum"].as_array_mut() else { + // Probe with `get_mut`, never `schema["properties"]["action"]["enum"]`: + // serde_json's IndexMut auto-vivifies missing keys by inserting Null, so + // the old probe left `properties.action = {"enum": null}` inside schemas + // that have no action property (e.g. lowercase `bash`). Strict + // OpenAI-compatible validators then reject the whole request with + // `Invalid schema for function 'bash': null is not of type "array"` + // (observed on Fleet read-only workers; see registry tests). + let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else { + return; + }; + let Some(action) = properties.get_mut("action") else { + return; + }; + let Some(actions) = action.get_mut("enum").and_then(Value::as_array_mut) else { return; }; match name { diff --git a/crates/tui/src/tools/registry/tests.rs b/crates/tui/src/tools/registry/tests.rs index b8891b4748..0dd12dcf53 100644 --- a/crates/tui/src/tools/registry/tests.rs +++ b/crates/tui/src/tools/registry/tests.rs @@ -1690,3 +1690,70 @@ fn rlm_family_removes_legacy_aliases() { ); } } + +/// Regression probe for the fleet-52663788 class of provider 400 +/// (`Invalid schema for function 'bash': null is not of type "array"`): +/// a read-only Fleet worker (reviewer) projects its tool schemas before the +/// wire; no projected schema may carry a JSON null, because strict +/// OpenAI-compatible validators reject null where arrays/objects are typed. +#[test] +fn fleet_readonly_reviewer_wire_catalog_carries_no_null_schema_fields() { + use crate::tools::spec::{ + ToolMutationAuthority, ToolShellAuthority, ToolVerificationAuthority, + }; + + fn collect_null_paths(value: &Value, path: String, out: &mut Vec) { + match value { + Value::Null => out.push(path), + Value::Object(map) => { + for (key, child) in map { + collect_null_paths(child, format!("{path}.{key}"), out); + } + } + Value::Array(items) => { + for (index, child) in items.iter().enumerate() { + collect_null_paths(child, format!("{path}[{index}]"), out); + } + } + _ => {} + } + } + + let tmp = tempdir().expect("tempdir"); + let reviewer_authority = ToolAuthorityEnvelope { + schema_version: 1, + owner: "reviewer".to_string(), + authority: ToolMutationAuthority::ReadOnly, + network_access: Some(false), + shell: ToolShellAuthority::ReadOnly, + verification: ToolVerificationAuthority::None, + writable_roots: Vec::new(), + writable_files: Vec::new(), + coordination_contracts: Vec::new(), + }; + let context = ToolContext::new(tmp.path().to_path_buf()) + .with_tool_authority(reviewer_authority) + .expect("reviewer authority"); + + let registry = ToolRegistryBuilder::new() + .with_file_tools() + .with_foreground_shell_tools() + .with_search_tools() + .build(context); + let tools = registry.to_api_tools(); + assert!( + tools.iter().any(|tool| tool.name == "bash"), + "reviewer keeps classifier-bounded bash" + ); + + for tool in &tools { + let mut nulls = Vec::new(); + collect_null_paths(&tool.input_schema, "$".to_string(), &mut nulls); + assert!( + nulls.is_empty(), + "tool {} schema carries null at {nulls:?}: {}", + tool.name, + tool.input_schema + ); + } +}