From 05f0201ec717a2120ca643ce4390ea5994dddc34 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 8 Jun 2026 15:01:48 +0900 Subject: [PATCH] fix: preserve runtime config validation compatibility --- rust/crates/runtime/src/config_validate.rs | 129 ++++++++++++++++----- 1 file changed, 103 insertions(+), 26 deletions(-) diff --git a/rust/crates/runtime/src/config_validate.rs b/rust/crates/runtime/src/config_validate.rs index ca4ec84a..e37a3c46 100644 --- a/rust/crates/runtime/src/config_validate.rs +++ b/rust/crates/runtime/src/config_validate.rs @@ -92,6 +92,8 @@ enum FieldType { Bool, Object, StringArray, + HookArray, + RulesImport, Number, } @@ -102,6 +104,8 @@ impl FieldType { Self::Bool => "a boolean", Self::Object => "an object", Self::StringArray => "an array of strings", + Self::RulesImport => "a string or an array of strings", + Self::HookArray => "an array of strings or hook objects", Self::Number => "a number", } } @@ -114,6 +118,13 @@ impl FieldType { Self::StringArray => value .as_array() .is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())), + Self::HookArray => true, + Self::RulesImport => { + value.as_str().is_some() + || value + .as_array() + .is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())) + } Self::Number => value.as_i64().is_some(), } } @@ -201,6 +212,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[ name: "provider", expected: FieldType::Object, }, + FieldSpec { + name: "rulesImport", + expected: FieldType::RulesImport, + }, FieldSpec { name: "subagentModel", expected: FieldType::String, @@ -210,15 +225,15 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[ const HOOKS_FIELDS: &[FieldSpec] = &[ FieldSpec { name: "PreToolUse", - expected: FieldType::StringArray, + expected: FieldType::HookArray, }, FieldSpec { name: "PostToolUse", - expected: FieldType::StringArray, + expected: FieldType::HookArray, }, FieldSpec { name: "PostToolUseFailure", - expected: FieldType::StringArray, + expected: FieldType::HookArray, }, ]; @@ -410,9 +425,8 @@ fn validate_object_keys( } else if DEPRECATED_FIELDS.iter().any(|d| d.name == key) { // Deprecated key — handled separately, not an unknown-key error. } else { - // Unknown key. let suggestion = suggest_field(key, &known_names); - result.errors.push(ConfigDiagnostic { + result.warnings.push(ConfigDiagnostic { path: path_display.to_string(), field: field_path, line: find_key_line(source, key), @@ -591,10 +605,11 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert_eq!(result.errors.len(), 1); - assert_eq!(result.errors[0].field, "unknownField"); + assert!(result.errors.is_empty()); + assert_eq!(result.warnings.len(), 1); + assert_eq!(result.warnings[0].field, "unknownField"); assert!(matches!( - result.errors[0].kind, + result.warnings[0].kind, DiagnosticKind::UnknownKey { .. } )); } @@ -674,9 +689,10 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert_eq!(result.errors.len(), 1); - assert_eq!(result.errors[0].line, Some(3)); - assert_eq!(result.errors[0].field, "badKey"); + assert!(result.errors.is_empty()); + assert_eq!(result.warnings.len(), 1); + assert_eq!(result.warnings[0].line, Some(3)); + assert_eq!(result.warnings[0].field, "badKey"); } #[test] @@ -697,7 +713,7 @@ mod tests { #[test] fn validates_nested_hooks_keys() { // given - let source = r#"{"hooks": {"PreToolUse": ["cmd"], "BadHook": ["x"]}}"#; + let source = r#"{"hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"cmd"}]}], "BadHook": ["x"]}}"#; let parsed = JsonValue::parse(source).expect("valid json"); let object = parsed.as_object().expect("object"); @@ -705,8 +721,64 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then + assert!(result.errors.is_empty()); + assert_eq!( + result.warnings.len(), + 1, + "expected only the unknown key warning, got {:?}", + result.warnings + ); + assert_eq!(result.warnings[0].field, "hooks.BadHook"); + } + + #[test] + fn validates_object_style_hook_entries() { + let source = r#"{"hooks":{"PreToolUse":["legacy",{"matcher":"Bash","hooks":[{"type":"command","command":"echo ok"}]}]}}"#; + let parsed = JsonValue::parse(source).expect("valid json"); + let object = parsed.as_object().expect("object"); + + let result = validate_config_file(object, source, &test_path()); + + assert!(result.errors.is_empty(), "{:?}", result.errors); + } + + #[test] + fn allows_wrong_hook_entry_types_for_partial_runtime_validation_441() { + let source = r#"{"hooks":{"PreToolUse":[42]}}"#; + let parsed = JsonValue::parse(source).expect("valid json"); + let object = parsed.as_object().expect("object"); + + let result = validate_config_file(object, source, &test_path()); + + assert!(result.errors.is_empty(), "{:?}", result.errors); + } + + #[test] + fn validates_rules_import_string_and_array_forms() { + for source in [ + r#"{"rulesImport":"auto"}"#, + r#"{"rulesImport":"none"}"#, + r#"{"rulesImport":["cursor","copilot"]}"#, + ] { + let parsed = JsonValue::parse(source).expect("valid json"); + let object = parsed.as_object().expect("object"); + + let result = validate_config_file(object, source, &test_path()); + + assert!(result.errors.is_empty(), "{source}: {:?}", result.errors); + } + } + + #[test] + fn rejects_rules_import_wrong_type() { + let source = r#"{"rulesImport":42}"#; + let parsed = JsonValue::parse(source).expect("valid json"); + let object = parsed.as_object().expect("object"); + + let result = validate_config_file(object, source, &test_path()); + assert_eq!(result.errors.len(), 1); - assert_eq!(result.errors[0].field, "hooks.BadHook"); + assert_eq!(result.errors[0].field, "rulesImport"); } #[test] @@ -720,8 +792,9 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert_eq!(result.errors.len(), 1); - assert_eq!(result.errors[0].field, "permissions.denyAll"); + assert!(result.errors.is_empty()); + assert_eq!(result.warnings.len(), 1); + assert_eq!(result.warnings[0].field, "permissions.denyAll"); } #[test] @@ -735,8 +808,9 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert_eq!(result.errors.len(), 1); - assert_eq!(result.errors[0].field, "sandbox.containerMode"); + assert!(result.errors.is_empty()); + assert_eq!(result.warnings.len(), 1); + assert_eq!(result.warnings[0].field, "sandbox.containerMode"); } #[test] @@ -750,8 +824,9 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert_eq!(result.errors.len(), 1); - assert_eq!(result.errors[0].field, "plugins.autoUpdate"); + assert!(result.errors.is_empty()); + assert_eq!(result.warnings.len(), 1); + assert_eq!(result.warnings[0].field, "plugins.autoUpdate"); } #[test] @@ -765,8 +840,9 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert_eq!(result.errors.len(), 1); - assert_eq!(result.errors[0].field, "oauth.secret"); + assert!(result.errors.is_empty()); + assert_eq!(result.warnings.len(), 1); + assert_eq!(result.warnings[0].field, "oauth.secret"); } #[test] @@ -774,7 +850,7 @@ mod tests { // given let source = r#"{ "model": "opus", - "hooks": {"PreToolUse": ["guard"]}, + "hooks": {"PreToolUse": [{"hooks":[{"type":"command","command":"guard"}]}]}, "permissions": {"defaultMode": "plan", "allow": ["Read"]}, "mcpServers": {}, "sandbox": {"enabled": false} @@ -801,8 +877,9 @@ mod tests { let result = validate_config_file(object, source, &test_path()); // then - assert_eq!(result.errors.len(), 1); - match &result.errors[0].kind { + assert!(result.errors.is_empty()); + assert_eq!(result.warnings.len(), 1); + match &result.warnings[0].kind { DiagnosticKind::UnknownKey { suggestion: Some(s), } => assert_eq!(s, "model"), @@ -813,7 +890,7 @@ mod tests { #[test] fn format_diagnostics_includes_all_entries() { // given - let source = r#"{"permissionMode": "plan", "badKey": 1}"#; + let source = r#"{"model": 42, "badKey": 1}"#; let parsed = JsonValue::parse(source).expect("valid json"); let object = parsed.as_object().expect("object"); let result = validate_config_file(object, source, &test_path()); @@ -825,7 +902,7 @@ mod tests { assert!(output.contains("warning:")); assert!(output.contains("error:")); assert!(output.contains("badKey")); - assert!(output.contains("permissionMode")); + assert!(output.contains("model")); } #[test]