diff --git a/Cargo.lock b/Cargo.lock index 0c383d65808..c70216c83be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -102,6 +102,17 @@ dependencies = [ "libc", ] +[[package]] +name = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width 0.2.2", +] + [[package]] name = "anstream" version = "1.0.0" @@ -223,6 +234,12 @@ dependencies = [ "password-hash 0.6.1", ] +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "arrayref" version = "0.3.9" @@ -1028,7 +1045,6 @@ dependencies = [ "rmcp 1.8.0", "serde", "serde_json", - "serde_yaml", "sha2 0.11.0", "tempfile", "tokio", @@ -1038,6 +1054,7 @@ dependencies = [ "url", "urlencoding", "webbrowser", + "yaml_serde", ] [[package]] @@ -1358,9 +1375,9 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", - "serde_yaml", "tempfile", "thiserror 2.0.18", + "yaml_serde", ] [[package]] @@ -1469,7 +1486,6 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "serde_yaml", "sha2 0.11.0", "sqlx", "subtle", @@ -1604,11 +1620,11 @@ dependencies = [ "reqwest 0.13.4", "serde", "serde_json", - "serde_yaml", "thiserror 2.0.18", "tokio", "tracing", "uuid", + "yaml_serde", ] [[package]] @@ -2979,6 +2995,15 @@ dependencies = [ "cfg-if 1.0.4", ] +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] + [[package]] name = "endi" version = "1.1.1" @@ -3516,9 +3541,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if 1.0.4", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -3623,6 +3650,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "granit-parser" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50ba32164f9e098d5da618776a32afbb32270adcbe3d3d006107dae11e37c91" +dependencies = [ + "arraydeque", + "smallvec", +] + [[package]] name = "group" version = "0.13.0" @@ -4783,9 +4820,9 @@ dependencies = [ [[package]] name = "jsonpath-rust" -version = "0.7.5" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c00ae348f9f8fd2d09f82a98ca381c60df9e0820d8d79fce43e649b4dc3128b" +checksum = "b1c4668c489390e66d586fe2238ee8568473f7ac1607b411342f787c5e1bedd7" dependencies = [ "pest", "pest_derive", @@ -4814,12 +4851,12 @@ dependencies = [ [[package]] name = "k8s-openapi" -version = "0.26.1" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06d9e5e61dd037cdc51da0d7e2b2be10f497478ea7e120d85dad632adb99882b" +checksum = "d9c6922f6afe80418dd6019818af5d0d34584c371780ff09b9752370c25b4abb" dependencies = [ "base64 0.22.1", - "chrono", + "jiff", "serde", "serde_json", ] @@ -4875,9 +4912,9 @@ checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" [[package]] name = "kube" -version = "2.0.1" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e7bb0b6a46502cc20e4575b6ff401af45cfea150b34ba272a3410b78aa014e" +checksum = "4bb9108095346a7096d11feeaff419c75dddcac1b2f59acb38d7bf3d13c3e146" dependencies = [ "k8s-openapi", "kube-client", @@ -4886,16 +4923,14 @@ dependencies = [ [[package]] name = "kube-client" -version = "2.0.1" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4987d57a184d2b5294fdad3d7fc7f278899469d21a4da39a8f6ca16426567a36" +checksum = "d0f628e05bc2264c21fe10d3d675117dc9b43ea3bf4fb07262a222679757537b" dependencies = [ "base64 0.22.1", "bytes", - "chrono", "either", "futures", - "home", "http", "http-body", "http-body-util", @@ -4903,6 +4938,7 @@ dependencies = [ "hyper-rustls", "hyper-timeout", "hyper-util", + "jiff", "jsonpath-rust", "k8s-openapi", "kube-core", @@ -4910,8 +4946,8 @@ dependencies = [ "rustls", "secrecy", "serde", + "serde-saphyr", "serde_json", - "serde_yaml", "thiserror 2.0.18", "tokio", "tokio-util", @@ -4922,14 +4958,14 @@ dependencies = [ [[package]] name = "kube-core" -version = "2.0.1" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "914bbb770e7bb721a06e3538c0edd2babed46447d128f7c21caa68747060ee73" +checksum = "c1b02f5933ba06140d58c7d6727f6c319f0962ec6a344aa5e21e475e891deaa8" dependencies = [ - "chrono", "derive_more", "form_urlencoded", "http", + "jiff", "k8s-openapi", "serde", "serde-value", @@ -5073,6 +5109,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libyaml-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" + [[package]] name = "line-clipping" version = "0.3.7" @@ -6379,6 +6421,12 @@ dependencies = [ "cidr", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -9143,6 +9191,25 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-saphyr" +version = "0.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5897b4c3faadadd35fdb6689f015641f3bc481d5adaaac56231ea15aeb243db3" +dependencies = [ + "ahash", + "annotate-snippets", + "base64 0.22.1", + "encoding_rs_io", + "getrandom 0.3.4", + "granit-parser", + "nohash-hasher", + "num-traits", + "serde", + "smallvec", + "zmij", +] + [[package]] name = "serde-value" version = "0.7.0" @@ -9283,19 +9350,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.14.0", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "serdect" version = "0.4.3" @@ -11261,12 +11315,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47a939edecc3c5a7b83c02e5f6b3c31d2bc69eabcc9a87ab12c6d37ee6dbc856" -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.7.1" @@ -12404,6 +12452,19 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +[[package]] +name = "yaml_serde" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b729a08a9a6be689bbad3e2bf8015926db54b6622cc89c3a5f7dc174b9e918" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "libyaml-rs", + "ryu", + "serde", +] + [[package]] name = "yasna" version = "0.6.0" diff --git a/Cargo.toml b/Cargo.toml index d27c63d234c..f0d858d0718 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,8 +68,8 @@ deadpool-redis = { version = "0.23", features = ["rt_tokio_1"] } # process-level CryptoProvider is installed explicitly at startup, matching # buzz-cli/buzz-acp/buzz-admin/buzz-relay/buzz-dev-mcp — see the comment on the # crate's own rustls dependency. -kube = { version = "2.0", default-features = false, features = ["client", "rustls-tls"] } -k8s-openapi = { version = "0.26", features = ["v1_31"] } +kube = { version = "~4.0", default-features = false, features = ["client", "rustls-tls"] } +k8s-openapi = { version = "0.28", features = ["v1_32"] } # Nostr nostr = { version = "0.44", features = ["nip44", "nip98"] } @@ -81,7 +81,7 @@ postcard = { version = "1", default-features = false, features = ["use-std"] # Inter-relay mesh transport (buzz-relay-mesh) iroh = { version = "1.0.0-rc.0", default-features = false, features = ["tls-ring"] } serde_json = { version = "1", features = ["raw_value"] } -serde_yaml = "0.9" +yaml_serde = "0.10.7" evalexpr = "11" cron = "0.16" # Observability diff --git a/FORK_PATCHES.md b/FORK_PATCHES.md index 8f7541d55bc..77fb58a94ec 100644 --- a/FORK_PATCHES.md +++ b/FORK_PATCHES.md @@ -1205,7 +1205,10 @@ Document owner-dependent device-home migration belongs after fold/detach in work New module: `false`. - Required symbol: `Device-home migration deliberately waits for owner/workspace hydration` +- Required symbol: `yaml_serde::from_str::(frontmatter).ok()?` +- Invocation: `desktop/src-tauri/src/migration.rs` → `from_str`; exact call `yaml_serde::from_str::(frontmatter).ok()?`; behavior test `migration::yaml_tests::runtime_rewrite_preserves_unknown_yaml_values_and_markdown`; verify `cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib migration::yaml_tests` - Verify: `just desktop-tauri-test desktop-tauri-check` +- Verify: `cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib migration::yaml_tests` ## `desktop/src-tauri/src/managed_agents/reconcile/tests.rs` @@ -5778,13 +5781,13 @@ New module: `false`. ## `deny.toml` -Track the owner-approved RUSTSEC-2024-0449 exception for fork-v0.5.26-4 only, with YAML replacement issue19 and mandatory reassessment before another release. Other advisory policies remain unchanged. +Enforce RUSTSEC-2024-0449 after removing the retired YAML graph; preserve all unrelated advisory policies. New module: `false`. -- Required symbol: `RUSTSEC-2024-0449` -- Required symbol: `https://github.com/reynholm/buzz/issues/19` -- Verify: `cargo-deny check` +- Required symbol: `[advisories]` +- Verify: `cargo deny check` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -p test_yaml_dependencies.py -v` ## `crates/buzz-relay/src/mesh_boot.rs` @@ -5855,6 +5858,255 @@ New module: `false`. - Required symbol: `(player as HTMLAudioElement).loop = true;` - Verify: `cd desktop && pnpm exec playwright test --project=smoke tests/e2e/voice-note.spec.ts --retries=0` +## `Cargo.toml` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-agent/Cargo.toml` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-agent/src/hints.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Required symbol: `yaml_serde::from_str(yaml_block).ok()?` +- Invocation: `crates/buzz-agent/src/hints.rs` → `from_str`; exact call `yaml_serde::from_str(yaml_block).ok()?`; behavior test `hints::yaml_tests::skill_yaml_preserves_words_tags_aliases_and_multiline_text`; verify `cargo test --locked -p buzz-agent --lib hints::yaml_tests` +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Verify: `cargo test --locked -p buzz-agent --lib hints::yaml_tests` + +## `crates/buzz-agent/src/hints_yaml_tests.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `true`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-backend-kubernetes/src/client.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Required symbol: `let config = config_for(context).await?;` +- Invocation: `crates/buzz-backend-kubernetes/src/client.rs` → `config_for`; exact call `let config = config_for(context).await?;`; behavior test `client::yaml_tests::kubeconfig_connect_and_exec_lookup_use_the_selected_context`; verify `cargo test --locked -p buzz-backend-kubernetes` +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Verify: `cargo test --locked -p buzz-backend-kubernetes` + +## `crates/buzz-backend-kubernetes/src/client_yaml_tests.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `true`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-backend-kubernetes/src/cluster.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-backend-kubernetes/src/gc.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Required symbol: `let age_nanos = now_nanos - created.0.as_nanosecond();` +- Invocation: `crates/buzz-backend-kubernetes/src/gc.rs` → `as_nanosecond`; exact call `let age_nanos = now_nanos - created.0.as_nanosecond();`; behavior test `gc::tests::age_gate_boundary_is_exact`; verify `cargo test --locked -p buzz-backend-kubernetes` +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Verify: `cargo test --locked -p buzz-backend-kubernetes` + +## `crates/buzz-backend-kubernetes/src/observe.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-backend-kubernetes/src/reconcile.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-persona/Cargo.toml` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-persona/src/persona.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Required symbol: `yaml_serde::from_str(fm_str)?` +- Invocation: `crates/buzz-persona/src/persona.rs` → `from_str`; exact call `yaml_serde::from_str(fm_str)?`; behavior test `persona_yaml_preserves_scalar_types_aliases_and_multiline_unicode`; verify `cargo test --locked -p buzz-persona --test yaml_compatibility` +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Verify: `cargo test --locked -p buzz-persona --test yaml_compatibility` + +## `crates/buzz-persona/src/validate.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Required symbol: `yaml_serde::from_str(fm_str)` +- Invocation: `crates/buzz-persona/src/validate.rs` → `from_str`; exact call `yaml_serde::from_str(fm_str)`; behavior test `pack_validation_preserves_skill_name_warnings_with_unknown_yaml_values`; verify `cargo test --locked -p buzz-persona --test yaml_compatibility` +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Verify: `cargo test --locked -p buzz-persona --test yaml_compatibility` + +## `crates/buzz-persona/tests/yaml_compatibility.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `true`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-workflow/Cargo.toml` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-workflow/src/error.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `crates/buzz-workflow/src/schema.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Required symbol: `yaml_serde::from_str(yaml)?` +- Invocation: `crates/buzz-workflow/src/schema.rs` → `from_str`; exact call `yaml_serde::from_str(yaml)?`; behavior test `schema::yaml_tests::workflow_yaml_preserves_trigger_aliases_and_message_text`; verify `cargo test --locked -p buzz-workflow --lib schema::yaml_tests` +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Verify: `cargo test --locked -p buzz-workflow --lib schema::yaml_tests` + +## `crates/buzz-workflow/src/schema_yaml_tests.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `true`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `desktop/src-tauri/src/commands/workflows.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Required symbol: `yaml_serde::from_str::(yaml)` +- Invocation: `desktop/src-tauri/src/commands/workflows.rs` → `from_str`; exact call `yaml_serde::from_str::(yaml)`; behavior test `commands::workflows::tests::workflow_yaml_json_projection_preserves_words_and_fallbacks`; verify `cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib commands::workflows::tests::workflow_yaml_json_projection_preserves_words_and_fallbacks` +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Verify: `cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib commands::workflows::tests::workflow_yaml_json_projection_preserves_words_and_fallbacks` + +## `desktop/src-tauri/src/commands/workflows_tests.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `desktop/src-tauri/src/managed_agents/config_bridge/goose.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `false`. + +- Required symbol: `yaml_serde::from_str(yaml_str).ok()?` +- Invocation: `desktop/src-tauri/src/managed_agents/config_bridge/goose.rs` → `from_str`; exact call `yaml_serde::from_str(yaml_str).ok()?`; behavior test `managed_agents::config_bridge::goose::yaml_tests::goose_yaml_preserves_nested_providers_and_ignores_unknown_tagged_fields`; verify `cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib managed_agents::config_bridge::goose::yaml_tests` +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` +- Verify: `cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib managed_agents::config_bridge::goose::yaml_tests` + +## `desktop/src-tauri/src/managed_agents/config_bridge/goose_yaml_tests.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `true`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `desktop/src-tauri/src/migration_yaml_tests.rs` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `true`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `docs/fork/YAML_COMPATIBILITY.md` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `true`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + +## `scripts/fork/tests/test_yaml_dependencies.py` + +Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19). + +New module: `true`. + +- Verify: `just ci` +- Verify: `python3 -m unittest discover -s scripts/fork/tests -v` + ## `AGENTS.md` Fork rule: public text is in English and carries no internal workspace details diff --git a/Justfile b/Justfile index 0ce9308fb9e..ecd9bfd8209 100644 --- a/Justfile +++ b/Justfile @@ -364,6 +364,10 @@ test-unit: set -euo pipefail ./scripts/test-ensure-local-relay-key.sh if command -v cargo-nextest &>/dev/null; then + # Both shipping YAML graphs and every persona/workflow consumer must + # remain covered: workspace membership alone does not run these suites. + python3 -m unittest discover -s scripts/fork/tests -p test_yaml_dependencies.py -v + cargo test --locked -p buzz-persona -p buzz-workflow cargo nextest run -p buzz-core -p buzz-auth --lib cargo nextest run -p buzz-audit --lib # S4 cross-pod NIP-FI disconnect payload tests (infra-free). diff --git a/crates/buzz-agent/Cargo.toml b/crates/buzz-agent/Cargo.toml index 91a024a38b3..6777e611111 100644 --- a/crates/buzz-agent/Cargo.toml +++ b/crates/buzz-agent/Cargo.toml @@ -46,7 +46,7 @@ path = "tests/bin/auth_worker.rs" tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-std", "io-util", "sync", "process", "time", "net"] } serde = { workspace = true } serde_json = { workspace = true } -serde_yaml = { workspace = true } +yaml_serde = { workspace = true } reqwest = { workspace = true, features = ["json", "rustls", "form"] } rmcp = { version = "1", default-features = false, features = ["client", "transport-child-process"] } arc-swap = "1" diff --git a/crates/buzz-agent/src/hints.rs b/crates/buzz-agent/src/hints.rs index 9fb99f0ebcf..5f6f88abe1a 100644 --- a/crates/buzz-agent/src/hints.rs +++ b/crates/buzz-agent/src/hints.rs @@ -90,7 +90,7 @@ fn parse_skill_frontmatter(content: &str) -> Option<(String, String)> { let close_pos = rest.find("\n---")?; let yaml_block = &rest[..close_pos]; - let map: HashMap = serde_yaml::from_str(yaml_block).ok()?; + let map: HashMap = yaml_serde::from_str(yaml_block).ok()?; let name = map .get("name") .and_then(|v| v.as_str()) @@ -107,6 +107,10 @@ fn parse_skill_frontmatter(content: &str) -> Option<(String, String)> { Some((name, description)) } +#[cfg(test)] +#[path = "hints_yaml_tests.rs"] +mod yaml_tests; + fn scan_skill_dir(dir: &Path, seen: &mut HashSet, skills: &mut Vec) { let Ok(entries) = std::fs::read_dir(dir) else { return; diff --git a/crates/buzz-agent/src/hints_yaml_tests.rs b/crates/buzz-agent/src/hints_yaml_tests.rs new file mode 100644 index 00000000000..4d948307a14 --- /dev/null +++ b/crates/buzz-agent/src/hints_yaml_tests.rs @@ -0,0 +1,17 @@ +use super::parse_skill_frontmatter; + +#[test] +fn skill_yaml_preserves_words_tags_aliases_and_multiline_text() { + let source = "---\nname: no\ndescription: |\n Привет\n world\nextra: !custom {nested: &a [1, null, true]}\ncopy: *a\n---\nbody"; + assert_eq!( + parse_skill_frontmatter(source), + Some(("no".into(), "Привет\nworld".into())) + ); + for invalid in [ + "---\nname: [unclosed\n---\n", + "---\nname: *missing\n---\n", + "---\nname: 123\n---\n", + ] { + assert_eq!(parse_skill_frontmatter(invalid), None); + } +} diff --git a/crates/buzz-backend-kubernetes/src/client.rs b/crates/buzz-backend-kubernetes/src/client.rs index 0c3bed65b74..fd45ba17f38 100644 --- a/crates/buzz-backend-kubernetes/src/client.rs +++ b/crates/buzz-backend-kubernetes/src/client.rs @@ -9,6 +9,10 @@ use kube::config::{ExecConfig, KubeConfigOptions, Kubeconfig}; use kube::{Client, Config}; use std::path::{Path, PathBuf}; +#[cfg(test)] +#[path = "client_yaml_tests.rs"] +mod yaml_tests; + /// Directories prepended to `PATH` before the client is built. /// /// Kubeconfigs at Block near-universally authenticate through `exec` @@ -105,12 +109,16 @@ fn explain(context: Option<&str>, error: &kube::Error) -> String { /// Build a client for the selected context. pub async fn connect(context: Option<&str>) -> Result { prepend_plugin_path(); + let config = config_for(context).await?; + Client::try_from(config).map_err(|e| explain(context, &e)) +} +async fn config_for(context: Option<&str>) -> Result { let options = KubeConfigOptions { context: context.map(str::to_string), ..Default::default() }; - let config = Config::from_kubeconfig(&options).await.map_err(|e| { + let mut config = Config::from_kubeconfig(&options).await.map_err(|e| { // A named context that does not exist is a user typo, and the // kube-rs message for it is already specific. format!( @@ -119,7 +127,12 @@ pub async fn connect(context: Option<&str>) -> Result { ) })?; - Client::try_from(config).map_err(|e| explain(context, &e)) + // Preserve kube 2's request policy. Kube 4 otherwise removes the read + // bound and retries Retry-After responses inside a single reconciler await, + // masking the API failure until Desktop kills the provider at its deadline. + config.read_timeout = Some(std::time::Duration::from_secs(295)); + config.default_retry = false; + Ok(config) } #[cfg(test)] diff --git a/crates/buzz-backend-kubernetes/src/client_yaml_tests.rs b/crates/buzz-backend-kubernetes/src/client_yaml_tests.rs new file mode 100644 index 00000000000..0e69f9b9b80 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/client_yaml_tests.rs @@ -0,0 +1,127 @@ +use super::*; + +const FIXTURE_MODE: &str = "BUZZ_KUBE_YAML_FIXTURE_MODE"; + +#[test] +fn kubeconfig_connect_and_exec_lookup_use_the_selected_context() { + // Exercise connect's real KUBECONFIG path in a child: PATH/KUBECONFIG + // changes cannot race sibling tests or read the developer's credentials. + let directory = std::env::temp_dir().join(format!("buzz-yaml-kube-{}", std::process::id())); + std::fs::create_dir(&directory).unwrap(); + let path = directory.join("config.yaml"); + for (mode, yaml) in [ + ("valid", "apiVersion: v1\nkind: Config\ncurrent-context: primary\nclusters:\n- name: local\n cluster: {server: 'http://127.0.0.1:9'}\ncontexts:\n- name: primary\n context: {cluster: local, user: token-user, namespace: default}\n- name: exec-context\n context: {cluster: local, user: exec-user}\nusers:\n- name: token-user\n user: {token: fixture-only}\n- name: exec-user\n user:\n exec:\n apiVersion: client.authentication.k8s.io/v1beta1\n command: aws\n args: [eks, get-token, '--cluster-name', 'no']\n env: [{name: EXAMPLE, value: '001'}]\n"), + ("malformed", "contexts: [unclosed\n"), + ] { + std::fs::write(&path, yaml).unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "client::yaml_tests::kubeconfig_fixture_child", "--nocapture"]) + .env("KUBECONFIG", &path) + .env(FIXTURE_MODE, mode) + .env_remove("HTTPS_PROXY") + .env_remove("HTTP_PROXY") + .env_remove("ALL_PROXY") + .env_remove("https_proxy") + .env_remove("http_proxy") + .env_remove("all_proxy") + .output() + .unwrap(); + assert!(output.status.success(), "{mode}: {} {}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); + if mode == "valid" { + for child_mode in ["retry", "exec"] { + let retry = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "client::yaml_tests::kubeconfig_fixture_child", "--nocapture"]) + .env("KUBECONFIG", &path) + .env(FIXTURE_MODE, child_mode) + .env_remove("HTTPS_PROXY").env_remove("HTTP_PROXY").env_remove("ALL_PROXY") + .env_remove("https_proxy").env_remove("http_proxy").env_remove("all_proxy") + .output().unwrap(); + assert!(retry.status.success(), "{child_mode}: {} {}", String::from_utf8_lossy(&retry.stdout), String::from_utf8_lossy(&retry.stderr)); + } + } + } + std::fs::remove_dir_all(directory).unwrap(); +} + +#[test] +fn kubeconfig_fixture_child() { + let Ok(mode) = std::env::var(FIXTURE_MODE) else { + return; + }; + let _ = rustls::crypto::ring::default_provider().install_default(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + if mode == "malformed" { + assert!(connect(Some("primary")).await.err().unwrap().contains("could not load kubeconfig")); + return; + } + if mode == "retry" || mode == "exec" { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let path = std::env::var("KUBECONFIG").unwrap(); + let mut yaml = std::fs::read_to_string(&path).unwrap(); + // A real executable credential plugin, with synthetic credentials: + // successful auth must reach the request's Authorization header. + let uses_exec = mode == "exec"; + if uses_exec { + let args = serde_json::to_string(&["-c", r#"printf '%s' '{"apiVersion":"client.authentication.k8s.io/v1beta1","kind":"ExecCredential","status":{"token":"fixture-exec-token"}}'"#]).unwrap(); + yaml = yaml.replace("user: token-user", "user: exec-user") + .replace("command: aws", "command: /bin/sh") + .replace("args: [eks, get-token, '--cluster-name', 'no']", &format!("args: {args}")); + } + // The preceding retry child may have used another ephemeral port. + let yaml = yaml.lines().map(|line| { + if line.contains("cluster: {server:") { + format!(" cluster: {{server: 'http://{address}'}}") + } else { line.to_string() } + }).collect::>().join("\n"); + std::fs::write(path, yaml).unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0u8; 4096]; + let mut used = 0; + while !request[..used].windows(4).any(|bytes| bytes == b"\r\n\r\n") { + assert!(used < request.len(), "fixture request headers exceeded bound"); + let read = stream.read(&mut request[used..]).await.unwrap(); + assert_ne!(read, 0, "fixture request ended before headers"); + used += read; + } + if uses_exec { + assert!(String::from_utf8_lossy(&request[..used]).to_ascii_lowercase().contains("authorization: bearer fixture-exec-token")); + } + let (status, body) = if uses_exec { + ("200 OK", r#"{"apiVersion":"v1","kind":"PodList","metadata":{},"items":[]}"#) + } else { + ("503 Service Unavailable", r#"{"kind":"Status","apiVersion":"v1","status":"Failure","message":"fixture unavailable","reason":"ServiceUnavailable","code":503}"#) + }; + let response = format!("HTTP/1.1 {status}\r\nRetry-After: 600\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); + stream.write_all(response.as_bytes()).await.unwrap(); + }); + let client = connect(Some("primary")).await.unwrap(); + let pods = kube::Api::::namespaced(client, "default"); + let result = tokio::time::timeout(std::time::Duration::from_secs(2), pods.list(&Default::default())) + .await.expect("provider must return the API response, not hide Retry-After behind its deadline"); + if uses_exec { + assert!(result.unwrap().items.is_empty()); + } else { + assert!(matches!(result.unwrap_err(), kube::Error::Api(response) if response.code == 503)); + } + server.await.unwrap(); + return; + } + // Client construction does not send a request to the fixture endpoint. + let config = config_for(Some("primary")).await.unwrap(); + assert_eq!(config.read_timeout, Some(std::time::Duration::from_secs(295))); + let connection = connect(Some("primary")).await; + assert!(connection.is_ok(), "{:?}", connection.err()); + let exec = exec_plugin_for(Some("exec-context")).unwrap(); + assert_eq!(exec.command.as_deref(), Some("aws")); + assert_eq!(exec.args.unwrap().last().map(String::as_str), Some("no")); + assert_eq!(exec.env.unwrap()[0].get("value").map(String::as_str), Some("001")); + assert!(connect(Some("missing-context")).await.err().unwrap().contains("missing-context")); + }); +} diff --git a/crates/buzz-backend-kubernetes/src/cluster.rs b/crates/buzz-backend-kubernetes/src/cluster.rs index 755f41f6c8a..cc865a22bfc 100644 --- a/crates/buzz-backend-kubernetes/src/cluster.rs +++ b/crates/buzz-backend-kubernetes/src/cluster.rs @@ -20,7 +20,7 @@ use crate::reconcile::{CreateOutcome, DeleteOutcome, Substrate}; use chrono::{DateTime, Utc}; use k8s_openapi::api::core::v1::{Namespace, Pod, Secret}; use kube::api::{Api, DeleteParams, GetParams, ListParams, PostParams, Preconditions}; -use kube::core::ErrorResponse; +use kube::core::Status; use kube::{Client, Resource}; use std::time::{Duration, Instant}; @@ -30,7 +30,7 @@ use std::time::{Duration, Instant}; /// These are wire strings `apimachinery` chooses, not names this crate picks: /// `StatusReasonAlreadyExists`, `StatusReasonConflict`, `StatusReasonNotFound`, /// and `StatusReasonForbidden` in `k8s.io/apimachinery/pkg/apis/meta/v1/types.go`. -/// kube-core types `ErrorResponse::reason` as a bare `String`, so there is no +/// kube-core types `Status::reason` as a bare `String`, so there is no /// upstream constant to bind to and the spelling is pinned by test instead. const REASON_ALREADY_EXISTS: &str = "AlreadyExists"; const REASON_CONFLICT: &str = "Conflict"; @@ -47,7 +47,7 @@ pub struct Cluster { } /// The typed API error underneath a `kube::Error`, if it is one. -fn api_error(error: &kube::Error) -> Option<&ErrorResponse> { +fn api_error(error: &kube::Error) -> Option<&Status> { match error { kube::Error::Api(response) => Some(response), _ => None, @@ -347,12 +347,7 @@ mod tests { /// A typed apiserver error, as kube-rs surfaces it. fn api(reason: &str, code: u16) -> kube::Error { - kube::Error::Api(ErrorResponse { - status: "Failure".into(), - message: String::new(), - reason: reason.into(), - code, - }) + kube::Error::Api(Status::failure("", reason).with_code(code).boxed()) } /// The one discriminator the whole file rests on, and the trap the spec diff --git a/crates/buzz-backend-kubernetes/src/gc.rs b/crates/buzz-backend-kubernetes/src/gc.rs index 2e18825aa47..e013e840ae4 100644 --- a/crates/buzz-backend-kubernetes/src/gc.rs +++ b/crates/buzz-backend-kubernetes/src/gc.rs @@ -105,7 +105,13 @@ pub fn plan( // No server-assigned timestamp means no age proof. Skip. continue; }; - if (now - created.0).num_seconds() >= ORPHAN_SECRET_MIN_AGE_SECS { + // OpenAPI timestamps use Jiff, while the authoritative Date header + // uses Chrono. Compare exact instants without losing the age gate's + // subsecond boundary or limiting valid timestamps to i64 nanos. + let now_nanos = i128::from(now.timestamp()) * 1_000_000_000 + + i128::from(now.timestamp_subsec_nanos()); + let age_nanos = now_nanos - created.0.as_nanosecond(); + if age_nanos >= i128::from(ORPHAN_SECRET_MIN_AGE_SECS) * 1_000_000_000 { plan.secrets.push(name.to_string()); } } @@ -119,7 +125,7 @@ mod tests { use super::*; use crate::naming::{ANNOTATION_PUBKEY_FULL, LABEL_MANAGED_BY}; use k8s_openapi::api::core::v1::{Container, EnvFromSource, PodSpec, SecretEnvSource}; - use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, Time}; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use std::collections::BTreeMap; fn identity() -> AgentIdentity { @@ -176,7 +182,12 @@ mod tests { .into_iter() .collect::>(), ), - creation_timestamp: Some(Time(now - chrono::Duration::seconds(age_secs))), + creation_timestamp: Some( + serde_json::from_value(serde_json::json!((now + - chrono::Duration::seconds(age_secs)) + .to_rfc3339())) + .unwrap(), + ), ..Default::default() }, ..Default::default() @@ -292,7 +303,7 @@ mod tests { #[test] fn age_gate_boundary_is_exact() { let id = identity(); - let now = Utc::now(); + let now = "2026-10-16T12:00:00.500Z".parse::>().unwrap(); let just_under = secret_named(&id, "under", ORPHAN_SECRET_MIN_AGE_SECS - 1, now); let exactly = secret_named(&id, "exact", ORPHAN_SECRET_MIN_AGE_SECS, now); assert!(plan(&id, &[], &[just_under], never, Some(now)) @@ -302,6 +313,20 @@ mod tests { plan(&id, &[], &[exactly], never, Some(now)).secrets, ["exact"] ); + let just_under = secret_named(&id, "fractional", ORPHAN_SECRET_MIN_AGE_SECS, now); + assert!(plan( + &id, + &[], + &[just_under], + never, + Some(now - chrono::Duration::nanoseconds(1)) + ) + .secrets + .is_empty()); + let future = secret_named(&id, "future", -1, now); + assert!(plan(&id, &[], &[future], never, Some(now)) + .secrets + .is_empty()); } /// The same-clock rule. No apiserver `Date` header → skip the orphan diff --git a/crates/buzz-backend-kubernetes/src/observe.rs b/crates/buzz-backend-kubernetes/src/observe.rs index 1c6c8835633..84cd82ad9b9 100644 --- a/crates/buzz-backend-kubernetes/src/observe.rs +++ b/crates/buzz-backend-kubernetes/src/observe.rs @@ -281,7 +281,7 @@ mod tests { ContainerState, ContainerStateRunning, ContainerStateTerminated, ContainerStateWaiting, ContainerStatus, PodStatus, }; - use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, Time}; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use std::collections::BTreeMap; fn identity() -> AgentIdentity { @@ -526,7 +526,9 @@ mod tests { fn verified_pod_carries_the_fence_and_deletion_mark() { let id = identity(); let mut pod = base_pod(&id); - pod.metadata.deletion_timestamp = Some(Time(chrono::Utc::now())); + pod.metadata.deletion_timestamp = Some( + serde_json::from_value(serde_json::json!(chrono::Utc::now().to_rfc3339())).unwrap(), + ); let verified = verify(&pod, &id, Startup::Started).unwrap(); assert_eq!(verified.fence.uid, "uid-1"); assert_eq!(verified.fence.resource_version, "100"); diff --git a/crates/buzz-backend-kubernetes/src/reconcile.rs b/crates/buzz-backend-kubernetes/src/reconcile.rs index df2f99789ff..bce75e2307f 100644 --- a/crates/buzz-backend-kubernetes/src/reconcile.rs +++ b/crates/buzz-backend-kubernetes/src/reconcile.rs @@ -496,7 +496,6 @@ mod tests { ContainerState, ContainerStateRunning, ContainerStateTerminated, ContainerStateWaiting, ContainerStatus, PodStatus, }; - use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time; use std::cell::RefCell; use std::future::Future; use std::pin::pin; @@ -1057,7 +1056,8 @@ mod tests { let id = identity(); let cfg = config(); let mut dying = our_pod(&id, &cfg, Some(running())); - dying.metadata.deletion_timestamp = Some(Time(Utc::now())); + dying.metadata.deletion_timestamp = + Some(serde_json::from_value(serde_json::json!(Utc::now().to_rfc3339())).unwrap()); let fake = Fake::default().with_pod(dying); fake.on_poll.borrow_mut().push({ let name = id.pod_name(); diff --git a/crates/buzz-persona/Cargo.toml b/crates/buzz-persona/Cargo.toml index 884965065b9..16765a29376 100644 --- a/crates/buzz-persona/Cargo.toml +++ b/crates/buzz-persona/Cargo.toml @@ -9,7 +9,7 @@ repository = "https://github.com/block/sprout" [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" -serde_yaml = "0.9" +yaml_serde = "0.10.7" thiserror = "2" [dev-dependencies] diff --git a/crates/buzz-persona/src/persona.rs b/crates/buzz-persona/src/persona.rs index b7a49d46b44..0d19246da5f 100644 --- a/crates/buzz-persona/src/persona.rs +++ b/crates/buzz-persona/src/persona.rs @@ -41,7 +41,7 @@ pub enum PersonaError { TooLarge(String), #[error("failed to parse YAML frontmatter: {0}")] - Yaml(#[from] serde_yaml::Error), + Yaml(#[from] yaml_serde::Error), #[error("missing required field: {0}")] MissingField(String), @@ -215,7 +215,7 @@ pub fn parse_persona_md(content: &str) -> Result { return Err(PersonaError::BodyTooLarge); } - let fm: Frontmatter = serde_yaml::from_str(fm_str)?; + let fm: Frontmatter = yaml_serde::from_str(fm_str)?; let name = fm.name.ok_or(PersonaError::MissingField("name".into()))?; let display_name = fm diff --git a/crates/buzz-persona/src/validate.rs b/crates/buzz-persona/src/validate.rs index 12e5887662e..d3c65c67c39 100644 --- a/crates/buzz-persona/src/validate.rs +++ b/crates/buzz-persona/src/validate.rs @@ -404,7 +404,7 @@ fn advisory_check_skill_names( None => continue, }; - let yaml_val: serde_yaml::Value = match serde_yaml::from_str(fm_str) { + let yaml_val: yaml_serde::Value = match yaml_serde::from_str(fm_str) { Ok(v) => v, Err(_) => continue, }; @@ -414,7 +414,7 @@ fn advisory_check_skill_names( None => continue, }; - if let Some(name_val) = mapping.get(serde_yaml::Value::String("name".into())) { + if let Some(name_val) = mapping.get(yaml_serde::Value::String("name".into())) { if let Some(name_str) = name_val.as_str() { if let Some(dir_name) = skill_dir.file_name().and_then(|n| n.to_str()) { if name_str != dir_name { diff --git a/crates/buzz-persona/tests/yaml_compatibility.rs b/crates/buzz-persona/tests/yaml_compatibility.rs new file mode 100644 index 00000000000..86527819ccd --- /dev/null +++ b/crates/buzz-persona/tests/yaml_compatibility.rs @@ -0,0 +1,62 @@ +use buzz_persona::persona::{parse_persona_md, PersonaError}; + +#[test] +fn pack_validation_preserves_skill_name_warnings_with_unknown_yaml_values() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + for path in [".plugin", "agents", "skills/folder"] { + std::fs::create_dir_all(root.join(path)).unwrap(); + } + std::fs::write(root.join(".plugin/plugin.json"), r#"{"id":"test.yaml","name":"YAML Pack","version":"0.1.0","personas":["agents/bot.persona.md"]}"#).unwrap(); + std::fs::write( + root.join("agents/bot.persona.md"), + "---\nname: bot\ndisplay_name: Bot\ndescription: test\n---\nPrompt\n", + ) + .unwrap(); + std::fs::write( + root.join("skills/folder/SKILL.md"), + "---\nname: no\ndescription: test\nextra: !custom {number: 1, nullish: null}\n---\nSkill\n", + ) + .unwrap(); + let report = buzz_persona::validate::validate_pack(root); + assert!(!report.has_errors(), "{report}"); + assert!( + report + .to_string() + .contains("name \"no\" differs from directory name \"folder\""), + "{report}" + ); +} + +#[test] +fn persona_yaml_preserves_scalar_types_aliases_and_multiline_unicode() { + let source = "---\nname: no\ndisplay_name: &label Соломон\ndescription: |\n first\n мир\nmodel: *label\nversion: '001'\nthread_replies: false\ntemperature: 0.25\n---\n# Prompt\n"; + let parsed = parse_persona_md(source).unwrap(); + assert_eq!(parsed.name, "no"); + assert_eq!(parsed.display_name, "Соломон"); + assert_eq!(parsed.model.as_deref(), Some("Соломон")); + assert_eq!(parsed.description, "first\nмир\n"); + assert_eq!(parsed.version.as_deref(), Some("001")); + assert_eq!(parsed.thread_replies, Some(false)); + assert_eq!(parsed.temperature, Some(0.25)); + assert_eq!(parsed.prompt, "# Prompt\n"); +} + +#[test] +fn persona_yaml_rejects_unknown_fields_duplicates_and_bad_aliases() { + let base = "name: bot\ndisplay_name: Bot\ndescription: test\n"; + for extra in [ + "unknown: true\n", + "name: other\n", + "model: *missing\n", + "model: [unclosed\n", + ] { + let error = parse_persona_md(&format!("---\n{base}{extra}---\n")).unwrap_err(); + assert!(matches!(error, PersonaError::Yaml(_)), "{extra}: {error}"); + } + let source = format!("---\n{base}---\nname: other\n---\n"); + assert_eq!( + parse_persona_md(&source).unwrap().prompt, + "name: other\n---\n" + ); +} diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index df5b972b0ca..0a8b77f0bae 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -70,7 +70,6 @@ s3 = { version = "0.37", package = "rust-s3", default-features = false, features tempfile = "3" bytes = "1" infer = "0.19" -serde_yaml = { workspace = true } sha2 = { workspace = true } hmac = { workspace = true } subtle = { workspace = true } diff --git a/crates/buzz-workflow/Cargo.toml b/crates/buzz-workflow/Cargo.toml index 7d361b1477a..e4a6ebc038c 100644 --- a/crates/buzz-workflow/Cargo.toml +++ b/crates/buzz-workflow/Cargo.toml @@ -14,7 +14,7 @@ buzz-deletion = { workspace = true } hex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -serde_yaml = { workspace = true } +yaml_serde = { workspace = true } dashmap = { workspace = true } moka = { workspace = true } evalexpr = "11" diff --git a/crates/buzz-workflow/src/error.rs b/crates/buzz-workflow/src/error.rs index 109d4a2cb3d..af3454923a4 100644 --- a/crates/buzz-workflow/src/error.rs +++ b/crates/buzz-workflow/src/error.rs @@ -19,7 +19,7 @@ pub struct PartialProgress { pub enum WorkflowError { /// The workflow YAML/JSON could not be parsed. #[error("invalid YAML: {0}")] - InvalidYaml(#[from] serde_yaml::Error), + InvalidYaml(#[from] yaml_serde::Error), /// The workflow definition violates a semantic invariant. #[error("invalid definition: {0}")] diff --git a/crates/buzz-workflow/src/schema.rs b/crates/buzz-workflow/src/schema.rs index 0e8dfdb52ef..97cc9f3e8c5 100644 --- a/crates/buzz-workflow/src/schema.rs +++ b/crates/buzz-workflow/src/schema.rs @@ -9,6 +9,10 @@ use serde::{Deserialize, Serialize}; use crate::error::WorkflowError; +#[cfg(test)] +#[path = "schema_yaml_tests.rs"] +mod yaml_tests; + /// Top-level workflow definition, authored in YAML and stored as canonical JSON. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkflowDef { @@ -309,7 +313,7 @@ pub(crate) fn normalize_cron(expr: &str) -> String { /// /// Returns `(WorkflowDef, canonical_json)` on success. pub fn parse_yaml(yaml: &str) -> Result<(WorkflowDef, String), WorkflowError> { - let def: WorkflowDef = serde_yaml::from_str(yaml)?; + let def: WorkflowDef = yaml_serde::from_str(yaml)?; def.validate()?; let json = serde_json::to_string(&def).map_err(|e| WorkflowError::InvalidDefinition(e.to_string()))?; @@ -741,7 +745,7 @@ mod tests { // Unknown trigger `on:` value should fail deserialization. let yaml = "name: Bad Trigger\ntrigger:\n on: unknown_trigger_type\nsteps:\n - id: s1\n action: delay\n duration: 1m\n"; let err = parse_yaml(yaml).unwrap_err(); - // serde_yaml will return an InvalidYaml error for unknown enum variant. + // yaml_serde will return an InvalidYaml error for unknown enum variant. assert!( matches!( err, @@ -983,16 +987,16 @@ mod tests { #[test] fn diff_posted_trigger_roundtrips_yaml() { let yaml = "on: diff_posted\n"; - let trigger: TriggerDef = serde_yaml::from_str(yaml).unwrap(); + let trigger: TriggerDef = yaml_serde::from_str(yaml).unwrap(); assert!(matches!(trigger, TriggerDef::DiffPosted { filter: None })); - let back = serde_yaml::to_string(&trigger).unwrap(); + let back = yaml_serde::to_string(&trigger).unwrap(); assert!(back.contains("diff_posted")); } #[test] fn diff_posted_trigger_with_filter_roundtrips_yaml() { let yaml = "on: diff_posted\nfilter: 'str_contains(trigger_text, \"src/\")'\n"; - let trigger: TriggerDef = serde_yaml::from_str(yaml).unwrap(); + let trigger: TriggerDef = yaml_serde::from_str(yaml).unwrap(); assert!(matches!( trigger, TriggerDef::DiffPosted { filter: Some(_) } diff --git a/crates/buzz-workflow/src/schema_yaml_tests.rs b/crates/buzz-workflow/src/schema_yaml_tests.rs new file mode 100644 index 00000000000..dee04b35df7 --- /dev/null +++ b/crates/buzz-workflow/src/schema_yaml_tests.rs @@ -0,0 +1,34 @@ +use super::*; + +#[test] +fn workflow_yaml_preserves_trigger_aliases_and_message_text() { + let source = "name: no\ntrigger:\n on: message_posted\nsteps:\n - id: first\n action: send_message\n text: &message |\n Привет\n world\n - id: second\n action: send_message\n text: *message\nenabled: false\n"; + let (definition, json) = parse_yaml(source).unwrap(); + assert_eq!(definition.name, "no"); + assert!(!definition.enabled); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(value["trigger"]["on"], "message_posted"); + assert_eq!(value["steps"][0]["text"], "Привет\nworld\n"); + assert_eq!(value["steps"][0]["text"], value["steps"][1]["text"]); + let emitted = yaml_serde::to_string(&definition).unwrap(); + let (_, roundtrip) = parse_yaml(&emitted).unwrap(); + assert_eq!( + serde_json::from_str::(&roundtrip).unwrap(), + value + ); +} + +#[test] +fn workflow_yaml_rejects_malformed_duplicate_and_unknown_trigger() { + for source in [ + "name: [unclosed", + "name: one\nname: two", + "name: bad\ntrigger: {on: unknown}\nsteps: []", + "name: bad\ntrigger: *missing\nsteps: []", + ] { + assert!( + matches!(parse_yaml(source), Err(WorkflowError::InvalidYaml(_))), + "{source}" + ); + } +} diff --git a/deny.toml b/deny.toml index a69d78a0af4..d3c5fcd4bc7 100644 --- a/deny.toml +++ b/deny.toml @@ -1,9 +1,5 @@ [advisories] ignore = [ - # Release-only approval: Andrey B., 2026-10-15, Buzz event - # 14da571fb65bf9ab1946c2be8ca6c3a621709f07a5ac5423e49fe6c64894c028. - # Reassess before the next release; remove after the YAML replacement. - { id = "RUSTSEC-2024-0449", reason = "unsafe-libyaml unmaintained; no safe upgrade. Approved only for fork-v0.5.26-4; replacement tracked in https://github.com/reynholm/buzz/issues/19" }, # instant 0.1.13 — unmaintained crate. Transitive dep: nostr → instant. # Will be resolved when nostr crate updates its dependencies. { id = "RUSTSEC-2024-0384", reason = "transitive dep via nostr; no upstream fix available" }, diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 843c11a65ee..2f50ca78985 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1085,7 +1085,6 @@ dependencies = [ "rmcp 1.8.0", "serde", "serde_json", - "serde_yaml", "sha2 0.11.0", "tokio", "tracing", @@ -1093,6 +1092,7 @@ dependencies = [ "url", "urlencoding", "webbrowser", + "yaml_serde", ] [[package]] @@ -1179,7 +1179,6 @@ dependencies = [ "security-framework 3.7.0", "serde", "serde_json", - "serde_yaml", "sha2 0.11.0", "sherpa-onnx", "strip-ansi-escapes", @@ -1208,6 +1207,7 @@ dependencies = [ "webkit2gtk", "window-vibrancy", "windows-sys 0.61.2", + "yaml_serde", "zeroize", "zip 8.6.0", ] @@ -1249,8 +1249,8 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", - "serde_yaml", "thiserror 2.0.18", + "yaml_serde", ] [[package]] @@ -2388,7 +2388,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.118", ] [[package]] @@ -4970,6 +4970,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libyaml-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" + [[package]] name = "line-clipping" version = "0.3.7" @@ -9626,19 +9632,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.14.0", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "serdect" version = "0.4.3" @@ -11115,7 +11108,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -12130,12 +12123,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47a939edecc3c5a7b83c02e5f6b3c31d2bc69eabcc9a87ab12c6d37ee6dbc856" -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" @@ -13745,6 +13732,19 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "yaml_serde" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b729a08a9a6be689bbad3e2bf8015926db54b6622cc89c3a5f7dc174b9e918" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "libyaml-rs", + "ryu", + "serde", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 97aac1b9670..8254515f41d 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -97,7 +97,7 @@ opus = "0.3" neteq = { version = "0.8", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" -serde_yaml = "0.9" +yaml_serde = "0.10.7" toml = "0.8" nostr = { version = "0.44", features = ["nip44", "nip49"] } # OS-entropy source for backup passphrase generation (already in the tree as a diff --git a/desktop/src-tauri/src/commands/workflows.rs b/desktop/src-tauri/src/commands/workflows.rs index c4e5d38c8ba..cd951152bb5 100644 --- a/desktop/src-tauri/src/commands/workflows.rs +++ b/desktop/src-tauri/src/commands/workflows.rs @@ -412,7 +412,7 @@ fn tag_value(ev: &nostr::Event, name: &str) -> Option { /// empty object rather than failing the whole list query — a single malformed /// workflow must not break the page. fn parse_definition(yaml: &str) -> Value { - match serde_yaml::from_str::(yaml) { + match yaml_serde::from_str::(yaml) { Ok(v @ Value::Object(_)) => v, _ => Value::Object(serde_json::Map::new()), } diff --git a/desktop/src-tauri/src/commands/workflows_tests.rs b/desktop/src-tauri/src/commands/workflows_tests.rs index 6523d458629..f3f848985c5 100644 --- a/desktop/src-tauri/src/commands/workflows_tests.rs +++ b/desktop/src-tauri/src/commands/workflows_tests.rs @@ -4,6 +4,27 @@ // create/update record shaping. use super::*; + +#[test] +fn workflow_yaml_json_projection_preserves_words_and_fallbacks() { + let value = + parse_definition("name: no\ntext: |\n Привет\n world\nunknown: [true, null, 42]\n"); + assert_eq!(value["name"], "no"); + assert_eq!(value["text"], "Привет\nworld\n"); + assert_eq!(value["unknown"], serde_json::json!([true, null, 42])); + for invalid in [ + "name: [unclosed", + "[1, 2]", + "name: !custom tagged", + "name: *missing", + ] { + assert_eq!( + parse_definition(invalid), + serde_json::json!({}), + "{invalid}" + ); + } +} use nostr::{EventBuilder, Keys, Kind, Tag}; /// Build a signed kind:30620 workflow definition event with the given YAML diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs index 2cdc4fed231..7ccf722686c 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/goose.rs @@ -16,8 +16,8 @@ fn read_config_from_path(path: &std::path::Path) -> Option { fn parse_goose_config(yaml_str: &str) -> Option { // TODO: replace hardcoded field extraction with schema_walker once goose publishes // a JSON Schema. Tracked separately. - let map: std::collections::HashMap = - serde_yaml::from_str(yaml_str).ok()?; + let map: std::collections::HashMap = + yaml_serde::from_str(yaml_str).ok()?; let active_provider = yaml_string(&map, "active_provider"); @@ -83,18 +83,22 @@ fn parse_goose_config(yaml_str: &str) -> Option { }) } +#[cfg(test)] +#[path = "goose_yaml_tests.rs"] +mod yaml_tests; + struct NestedProviderFields { model: Option, host: Option, } fn nested_provider_fields( - map: &std::collections::HashMap, + map: &std::collections::HashMap, active_provider: &str, ) -> Option { let providers = map.get("providers").and_then(|v| v.as_mapping())?; let entry = providers - .get(serde_yaml::Value::String(active_provider.to_owned()))? + .get(yaml_serde::Value::String(active_provider.to_owned()))? .as_mapping()?; let model = mapping_string(entry, "model"); @@ -104,7 +108,7 @@ fn nested_provider_fields( } fn parse_extensions( - map: &std::collections::HashMap, + map: &std::collections::HashMap, ) -> Vec { let extensions = match map.get("extensions").and_then(|v| v.as_mapping()) { Some(m) => m, @@ -122,7 +126,7 @@ fn parse_extensions( let enabled = v .as_mapping() .and_then(|m| { - m.get(serde_yaml::Value::String("enabled".to_owned())) + m.get(yaml_serde::Value::String("enabled".to_owned())) .and_then(|v| v.as_bool()) }) .unwrap_or(true); @@ -136,7 +140,7 @@ fn parse_extensions( } fn yaml_string( - map: &std::collections::HashMap, + map: &std::collections::HashMap, key: &str, ) -> Option { map.get(key)? @@ -146,8 +150,8 @@ fn yaml_string( .map(str::to_string) } -fn mapping_string(map: &serde_yaml::Mapping, key: &str) -> Option { - map.get(serde_yaml::Value::String(key.to_owned())) +fn mapping_string(map: &yaml_serde::Mapping, key: &str) -> Option { + map.get(yaml_serde::Value::String(key.to_owned())) .and_then(|v| v.as_str()) .map(str::trim) .filter(|s| !s.is_empty()) diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/goose_yaml_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/goose_yaml_tests.rs new file mode 100644 index 00000000000..8f13552fc5f --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/goose_yaml_tests.rs @@ -0,0 +1,17 @@ +use super::parse_goose_config; + +#[test] +fn goose_yaml_preserves_nested_providers_and_ignores_unknown_tagged_fields() { + let yaml = "active_provider: custom\nproviders:\n custom:\n model: no\n host: https://example.invalid\nGOOSE_MAX_TOKENS: 001\nextensions:\n tool:\n type: stdio\n enabled: false\nunknown: !custom {nested: [null, true, 2]}\n"; + let config = parse_goose_config(yaml).unwrap(); + assert_eq!(config.provider.as_deref(), Some("custom")); + assert_eq!(config.model.as_deref(), Some("no")); + assert_eq!(config.max_output_tokens.as_deref(), Some("001")); + assert_eq!( + config.extra.get("custom.host").map(String::as_str), + Some("https://example.invalid") + ); + assert_eq!(config.extensions.len(), 1); + assert!(!config.extensions[0].enabled); + assert!(parse_goose_config("GOOSE_MODEL: [unclosed").is_none()); +} diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs index 40bfce554aa..2cb5f8d802f 100644 --- a/desktop/src-tauri/src/migration.rs +++ b/desktop/src-tauri/src/migration.rs @@ -1147,17 +1147,21 @@ fn reconcile_legacy_persona_runtimes_in_file(path: &Path) { fn rewrite_legacy_persona_md_runtime(content: &str) -> Option { let (frontmatter, body) = buzz_persona_pkg::persona::split_frontmatter(content).ok()?; - let mut value = serde_yaml::from_str::(frontmatter).ok()?; + let mut value = yaml_serde::from_str::(frontmatter).ok()?; let mapping = value.as_mapping_mut()?; - let runtime = mapping.get_mut(serde_yaml::Value::String("runtime".to_string()))?; + let runtime = mapping.get_mut(yaml_serde::Value::String("runtime".to_string()))?; if runtime.as_str()? != "sprout-agent" { return None; } - *runtime = serde_yaml::Value::String("buzz-agent".to_string()); - let frontmatter = serde_yaml::to_string(&value).ok()?; + *runtime = yaml_serde::Value::String("buzz-agent".to_string()); + let frontmatter = yaml_serde::to_string(&value).ok()?; Some(format!("---\n{frontmatter}---\n{body}")) } +#[cfg(test)] +#[path = "migration_yaml_tests.rs"] +mod yaml_tests; + fn reconcile_legacy_team_persona_runtime_files(dir: &Path) { let Ok(entries) = std::fs::read_dir(dir) else { return; diff --git a/desktop/src-tauri/src/migration_yaml_tests.rs b/desktop/src-tauri/src/migration_yaml_tests.rs new file mode 100644 index 00000000000..8340d6cebcc --- /dev/null +++ b/desktop/src-tauri/src/migration_yaml_tests.rs @@ -0,0 +1,28 @@ +use super::rewrite_legacy_persona_md_runtime; + +#[test] +fn runtime_rewrite_preserves_unknown_yaml_values_and_markdown() { + let frontmatter = "runtime: sprout-agent\nname: no\nunknown: !custom {number: 42, text: '001', nullish: null}\nnumeric: {1: one, true: enabled}\nbase: &a {word: no}\nalias: *a\nmerged: {<<: *a}\nfloat: .inf\ntext: |\n Привет\n world\n"; + let body = "# Prompt\n\n```yaml\nkeep: unchanged\n```\n"; + let source = format!("---\n{frontmatter}---\n{body}"); + let updated = rewrite_legacy_persona_md_runtime(&source).unwrap(); + let (yaml, actual_body) = buzz_persona_pkg::persona::split_frontmatter(&updated).unwrap(); + assert_eq!(actual_body, body); + let (original_yaml, _) = buzz_persona_pkg::persona::split_frontmatter(&source).unwrap(); + let mut expected: yaml_serde::Value = yaml_serde::from_str(original_yaml).unwrap(); + expected["runtime"] = yaml_serde::Value::String("buzz-agent".into()); + assert_eq!( + yaml_serde::from_str::(yaml).unwrap(), + expected + ); + assert!(updated.contains("!custom")); + assert_eq!(rewrite_legacy_persona_md_runtime(&updated), None); + assert_eq!( + rewrite_legacy_persona_md_runtime("---\nruntime: [broken\n---\n"), + None + ); + assert_eq!( + rewrite_legacy_persona_md_runtime("---\nruntime: sprout-agent\nruntime: other\n---\n"), + None + ); +} diff --git a/docs/fork/YAML_COMPATIBILITY.md b/docs/fork/YAML_COMPATIBILITY.md new file mode 100644 index 00000000000..15bdfc300f7 --- /dev/null +++ b/docs/fork/YAML_COMPATIBILITY.md @@ -0,0 +1,68 @@ +# Maintained YAML dependencies + +Issue [#19](https://github.com/reynholm/buzz/issues/19) removes the archived +`serde_yaml` / `unsafe-libyaml` graph reported by +[RUSTSEC-2024-0449](https://rustsec.org/advisories/RUSTSEC-2024-0449.html). +The advisory concerns maintenance; it does not identify a specific exploit. + +Buzz's direct YAML consumers use `yaml_serde` 0.10.7. Both it and its +`libyaml-rs` 0.3 backend are maintained by the YAML Organization: +[yaml-serde](https://github.com/yaml/yaml-serde), +[libyaml-rs](https://github.com/yaml/libyaml-rs). +These are maintained forks, not a claim that the backend is safe Rust: +`libyaml-rs` retains translated unsafe code. We checked both dependency +manifests and exercised the actual Buzz consumers before removing the exception. +Dependency policy still applies to the replacement packages. + +This choice preserves the existing generic YAML value model. In particular, +Desktop's legacy runtime rewrite retains unknown fields, custom tags, non-string +mapping keys, aliases' values, non-finite numbers and Markdown body content. +The rewrite already normalizes YAML formatting and expands alias references; +this change adds no bulk rewrite of existing files. Persona parsing, skill +discovery, workflow definitions and Goose config retain their existing scalar +coercion and error/fallback behavior. JSON workflow projections continue to reject +custom-tagged values rather than silently dropping their tags. Duplicate handling +remains target-dependent: typed structs and generic YAML mappings reject duplicate +keys, while JSON objects and HashMap targets retain the last value. + +`serde-saphyr` was also evaluated. Its defaults differ for `on`/`no`, merge keys, +null-to-string conversion, custom tags and non-finite values, and it has no +drop-in generic YAML Value type. Replacing every direct consumer with it would +require an additional compatibility layer. That work is unnecessary for this +maintenance fix. + +The Kubernetes backend uses upstream `kube` `~4.0` with `k8s-openapi` 0.28. +[Kube 4 removed the deprecated YAML dependency](https://github.com/kube-rs/kube/releases/tag/4.0.0) +in favor of `serde-saphyr`; kube 3.1 still depended on `serde_yaml`. +The minor constraint retains the workspace's declared Rust 1.88 minimum; +kube 4.2 raises it to 1.89. The provider explicitly retains kube 2's 295-second +read timeout and disables kube 4's new automatic retries, so a stalled response +or a long Retry-After cannot hide the API failure until the provider deadline. +The generated API schema changes from `v1_31` to `v1_32`, the earliest schema +available in OpenAPI 0.28. No runtime server-version check is added. Configuration +resolution remains KUBECONFIG → default kubeconfig, with the selected context, +namespace and exec plugin preserved. Tests exercise the actual `connect` and +exec lookup paths in isolated child processes, including a synthetic executable +credential plugin and requests to a local HTTP fixture. No live cluster or real +credentials are used. Live cluster acceptance remains a separate human check. + +## Verification + +- `cargo test --locked -p buzz-persona -p buzz-workflow -p buzz-agent -p buzz-backend-kubernetes` +- `cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --workspace` +- `python3 -m unittest discover -s scripts/fork/tests -v` +- `cargo tree --locked --target all -i yaml_serde` +- `cargo tree --locked --manifest-path desktop/src-tauri/Cargo.toml --target all -i yaml_serde` +- `cargo deny check` and the same command from `desktop/src-tauri` +- `just ci desktop-typecheck` + +The unit CI recipe explicitly includes complete persona and workflow packages, +as well as the lockfile/advisory regression. Kubernetes and agent packages already +run there; Desktop's native workspace runs separately. Test fixtures are in the +actual consumer modules/packages, rather than a test of the parser alone. + +For manual acceptance, load an existing persona pack and a skill with multiline +frontmatter, list a YAML workflow in Desktop, and read an existing Goose config. +For Kubernetes, use a test namespace and the same token/exec kubeconfig used +before the update; verify context selection and agent deployment. Do not use +production credentials in test fixtures. Report the cluster version tested. diff --git a/scripts/fork/patches.json b/scripts/fork/patches.json index dfe8043f038..99032dd6a6b 100644 --- a/scripts/fork/patches.json +++ b/scripts/fork/patches.json @@ -1990,11 +1990,21 @@ "responsibility": "Document owner-dependent device-home migration belongs after fold/detach in workspace apply, never preidentity boot", "is_new": false, "required_symbols": [ - "Device-home migration deliberately waits for owner/workspace hydration" + "Device-home migration deliberately waits for owner/workspace hydration", + "yaml_serde::from_str::(frontmatter).ok()?" + ], + "invocation_seams": [ + { + "caller_path": "desktop/src-tauri/src/migration.rs", + "callee_symbol": "from_str", + "invocation": "yaml_serde::from_str::(frontmatter).ok()?", + "behavior_test": "migration::yaml_tests::runtime_rewrite_preserves_unknown_yaml_values_and_markdown", + "verification_command": "cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib migration::yaml_tests" + } ], - "invocation_seams": [], "verification_commands": [ - "just desktop-tauri-test desktop-tauri-check" + "just desktop-tauri-test desktop-tauri-check", + "cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib migration::yaml_tests" ] }, { @@ -8439,15 +8449,15 @@ }, { "path": "deny.toml", - "responsibility": "Track the owner-approved RUSTSEC-2024-0449 exception for fork-v0.5.26-4 only, with YAML replacement issue19 and mandatory reassessment before another release. Other advisory policies remain unchanged.", + "responsibility": "Enforce RUSTSEC-2024-0449 after removing the retired YAML graph; preserve all unrelated advisory policies.", "is_new": false, "required_symbols": [ - "RUSTSEC-2024-0449", - "https://github.com/reynholm/buzz/issues/19" + "[advisories]" ], "invocation_seams": [], "verification_commands": [ - "cargo-deny check" + "cargo deny check", + "python3 -m unittest discover -s scripts/fork/tests -p test_yaml_dependencies.py -v" ] }, { @@ -8552,6 +8562,369 @@ "cd desktop && pnpm exec playwright test --project=smoke tests/e2e/voice-note.spec.ts --retries=0" ] }, + { + "path": "Cargo.toml", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-agent/Cargo.toml", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-agent/src/hints.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [ + "yaml_serde::from_str(yaml_block).ok()?" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-agent/src/hints.rs", + "callee_symbol": "from_str", + "invocation": "yaml_serde::from_str(yaml_block).ok()?", + "behavior_test": "hints::yaml_tests::skill_yaml_preserves_words_tags_aliases_and_multiline_text", + "verification_command": "cargo test --locked -p buzz-agent --lib hints::yaml_tests" + } + ], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v", + "cargo test --locked -p buzz-agent --lib hints::yaml_tests" + ] + }, + { + "path": "crates/buzz-agent/src/hints_yaml_tests.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-backend-kubernetes/src/client.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [ + "let config = config_for(context).await?;" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-backend-kubernetes/src/client.rs", + "callee_symbol": "config_for", + "invocation": "let config = config_for(context).await?;", + "behavior_test": "client::yaml_tests::kubeconfig_connect_and_exec_lookup_use_the_selected_context", + "verification_command": "cargo test --locked -p buzz-backend-kubernetes" + } + ], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v", + "cargo test --locked -p buzz-backend-kubernetes" + ] + }, + { + "path": "crates/buzz-backend-kubernetes/src/client_yaml_tests.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-backend-kubernetes/src/cluster.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-backend-kubernetes/src/gc.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [ + "let age_nanos = now_nanos - created.0.as_nanosecond();" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-backend-kubernetes/src/gc.rs", + "callee_symbol": "as_nanosecond", + "invocation": "let age_nanos = now_nanos - created.0.as_nanosecond();", + "behavior_test": "gc::tests::age_gate_boundary_is_exact", + "verification_command": "cargo test --locked -p buzz-backend-kubernetes" + } + ], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v", + "cargo test --locked -p buzz-backend-kubernetes" + ] + }, + { + "path": "crates/buzz-backend-kubernetes/src/observe.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-backend-kubernetes/src/reconcile.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-persona/Cargo.toml", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-persona/src/persona.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [ + "yaml_serde::from_str(fm_str)?" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-persona/src/persona.rs", + "callee_symbol": "from_str", + "invocation": "yaml_serde::from_str(fm_str)?", + "behavior_test": "persona_yaml_preserves_scalar_types_aliases_and_multiline_unicode", + "verification_command": "cargo test --locked -p buzz-persona --test yaml_compatibility" + } + ], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v", + "cargo test --locked -p buzz-persona --test yaml_compatibility" + ] + }, + { + "path": "crates/buzz-persona/src/validate.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [ + "yaml_serde::from_str(fm_str)" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-persona/src/validate.rs", + "callee_symbol": "from_str", + "invocation": "yaml_serde::from_str(fm_str)", + "behavior_test": "pack_validation_preserves_skill_name_warnings_with_unknown_yaml_values", + "verification_command": "cargo test --locked -p buzz-persona --test yaml_compatibility" + } + ], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v", + "cargo test --locked -p buzz-persona --test yaml_compatibility" + ] + }, + { + "path": "crates/buzz-persona/tests/yaml_compatibility.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-workflow/Cargo.toml", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-workflow/src/error.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "crates/buzz-workflow/src/schema.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [ + "yaml_serde::from_str(yaml)?" + ], + "invocation_seams": [ + { + "caller_path": "crates/buzz-workflow/src/schema.rs", + "callee_symbol": "from_str", + "invocation": "yaml_serde::from_str(yaml)?", + "behavior_test": "schema::yaml_tests::workflow_yaml_preserves_trigger_aliases_and_message_text", + "verification_command": "cargo test --locked -p buzz-workflow --lib schema::yaml_tests" + } + ], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v", + "cargo test --locked -p buzz-workflow --lib schema::yaml_tests" + ] + }, + { + "path": "crates/buzz-workflow/src/schema_yaml_tests.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "desktop/src-tauri/src/commands/workflows.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [ + "yaml_serde::from_str::(yaml)" + ], + "invocation_seams": [ + { + "caller_path": "desktop/src-tauri/src/commands/workflows.rs", + "callee_symbol": "from_str", + "invocation": "yaml_serde::from_str::(yaml)", + "behavior_test": "commands::workflows::tests::workflow_yaml_json_projection_preserves_words_and_fallbacks", + "verification_command": "cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib commands::workflows::tests::workflow_yaml_json_projection_preserves_words_and_fallbacks" + } + ], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v", + "cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib commands::workflows::tests::workflow_yaml_json_projection_preserves_words_and_fallbacks" + ] + }, + { + "path": "desktop/src-tauri/src/commands/workflows_tests.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "desktop/src-tauri/src/managed_agents/config_bridge/goose.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": false, + "required_symbols": [ + "yaml_serde::from_str(yaml_str).ok()?" + ], + "invocation_seams": [ + { + "caller_path": "desktop/src-tauri/src/managed_agents/config_bridge/goose.rs", + "callee_symbol": "from_str", + "invocation": "yaml_serde::from_str(yaml_str).ok()?", + "behavior_test": "managed_agents::config_bridge::goose::yaml_tests::goose_yaml_preserves_nested_providers_and_ignores_unknown_tagged_fields", + "verification_command": "cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib managed_agents::config_bridge::goose::yaml_tests" + } + ], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v", + "cargo test --locked --manifest-path desktop/src-tauri/Cargo.toml --lib managed_agents::config_bridge::goose::yaml_tests" + ] + }, + { + "path": "desktop/src-tauri/src/managed_agents/config_bridge/goose_yaml_tests.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "desktop/src-tauri/src/migration_yaml_tests.rs", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "docs/fork/YAML_COMPATIBILITY.md", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, + { + "path": "scripts/fork/tests/test_yaml_dependencies.py", + "responsibility": "Preserve YAML consumer compatibility while removing the unmaintained dependency graph (issue19).", + "is_new": true, + "required_symbols": [], + "invocation_seams": [], + "verification_commands": [ + "just ci", + "python3 -m unittest discover -s scripts/fork/tests -v" + ] + }, { "path": "AGENTS.md", "responsibility": "Fork rule: public text is in English and carries no internal workspace details", diff --git a/scripts/fork/tests/test_yaml_dependencies.py b/scripts/fork/tests/test_yaml_dependencies.py new file mode 100644 index 00000000000..7fd5bd59924 --- /dev/null +++ b/scripts/fork/tests/test_yaml_dependencies.py @@ -0,0 +1,21 @@ +"""Shipping lockfiles must not reintroduce the retired YAML parser graph.""" +from pathlib import Path +import tomllib +import unittest + +ROOT = Path(__file__).resolve().parents[3] + + +class YamlDependencyTests(unittest.TestCase): + def test_both_workspaces_exclude_retired_yaml_packages(self): + for relative in ('Cargo.lock', 'desktop/src-tauri/Cargo.lock'): + with self.subTest(lockfile=relative): + packages = tomllib.loads((ROOT / relative).read_text())['package'] + names = {package['name'] for package in packages} + self.assertFalse(names & {'unsafe-libyaml', 'serde_yaml'}, + f'{relative}: retired YAML dependency remains') + + def test_advisory_is_enforced_without_release_exception(self): + ignored = tomllib.loads((ROOT / 'deny.toml').read_text())['advisories']['ignore'] + ids = {item if isinstance(item, str) else item['id'] for item in ignored} + self.assertNotIn('RUSTSEC-2024-0449', ids) diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index f374eefe4a5..e1e2357a016 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -78,6 +78,12 @@ ensure_infra() { run_unit_tests() { section "Unit Tests (no infra required)" + # Match the YAML dependency and complete consumer suites in just test-unit. + run_test_step "shipping YAML dependency policy" \ + python3 -m unittest discover -s scripts/fork/tests -p test_yaml_dependencies.py -v + run_test_step "persona and workflow YAML consumers" \ + cargo test --locked -p buzz-persona -p buzz-workflow + run_test_step "buzz-core tests" \ cargo test -p buzz-core --lib -- --nocapture