Merge pull request #5944 from gaord/fix/fleet-bash-schema-null

fix(tui): stop fleet readonly schema probe from auto-vivifying null enums
This commit is contained in:
Hunter Bown authored and GitHub committed 2026-09-06 16:08:44 -07:00
commit f9746854cb
4 files changed
+97 -2

No files matched your search

+8
View File
@@ -565,6 +565,14 @@ Reports and reproductions that shaped this release:
### Fixed
- Read-only Fleet workers no longer send `"action": {"enum": null}` in their
projected `bash` schema. The read-only projection probed the action enum
with a mutating index, which auto-vivified the key on schemas that have no
action property, and strict OpenAI-compatible validators then rejected the
whole request (`null is not of type "array"`). The probe is non-mutating
now, in both the read-only projection and the `Run` arm next to it, and a
regression test walks the whole projected catalog for nulls
(#5944, thanks @gaord).
- Fast typing no longer corrupts the composer. The paste-burst heuristic ran
on every session until a real bracketed paste arrived, holding, buffering,
retro-grabbing, and absorbing Enter on timing guesses; it is now
+8
View File
@@ -565,6 +565,14 @@ Reports and reproductions that shaped this release:
### Fixed
- Read-only Fleet workers no longer send `"action": {"enum": null}` in their
projected `bash` schema. The read-only projection probed the action enum
with a mutating index, which auto-vivified the key on schemas that have no
action property, and strict OpenAI-compatible validators then rejected the
whole request (`null is not of type "array"`). The probe is non-mutating
now, in both the read-only projection and the `Run` arm next to it, and a
regression test walks the whole projected catalog for nulls
(#5944, thanks @gaord).
- Fast typing no longer corrupts the composer. The paste-burst heuristic ran
on every session until a real bracketed paste arrived, holding, buffering,
retro-grabbing, and absorbing Enter on timing guesses; it is now
+14 -2
View File
@@ -505,12 +505,24 @@ fn project_readonly_evidence_schema(name: &str, schema: &mut Value) {
if name == "Run" {
// The shared classifier remains authoritative for `args`; the schema
// removes the only field that can name verifier programs.
if let Some(properties) = schema["properties"].as_object_mut() {
if let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) {
properties.remove("commands");
}
return;
}
let Some(actions) = schema["properties"]["action"]["enum"].as_array_mut() else {
// Probe with `pointer_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).
// The same probe idiom lives in `tools/subagent` (grep `pointer_mut(
// "/properties/action/enum")`); keep the two sites greppable as one.
let Some(actions) = schema
.pointer_mut("/properties/action/enum")
.and_then(Value::as_array_mut)
else {
return;
};
match name {
+67
View File
@@ -1803,3 +1803,70 @@ fn a_builder_upgrade_replaces_the_tool_instead_of_registering_it_twice() {
"the upgrade still adds apply_patch"
);
}
/// 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<String>) {
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
);
}
}