| 1 | //! Conformance corpus for native DSH bundle import. The cases mirror the |
| 2 | //! bundle-mode contracts of `scripts/test_convert_plugin.py` (converter |
| 3 | //! 0.10.1); the pinned upstream package is shared with that suite. |
| 4 | |
| 5 | use super::*; |
| 6 | use serde_json::json; |
| 7 | use std::path::PathBuf; |
| 8 | |
| 9 | const MCP: &str = "@deepseek-ai/dsh-mcp-client"; |
| 10 | const SKILLS: &str = "@deepseek-ai/dsh-skill-filesystem"; |
| 11 | |
| 12 | struct Fixture { |
| 13 | _root: tempfile::TempDir, |
| 14 | bundle: PathBuf, |
| 15 | output: PathBuf, |
| 16 | } |
| 17 | |
| 18 | fn fixture(patch: Option<&str>, manifest: Option<Json>) -> Fixture { |
| 19 | let root = tempfile::tempdir().unwrap(); |
| 20 | let bundle = root.path().join("dsh-bundle"); |
| 21 | fs::create_dir(&bundle).unwrap(); |
| 22 | let mut package = json!({ |
| 23 | "name": "@demo/tools-dsh", |
| 24 | "version": "1.2.3", |
| 25 | "dsh": {"bundle": {"patch": "./cordis.patch.yml"}}, |
| 26 | }); |
| 27 | if let Some(Json::Object(extra)) = manifest { |
| 28 | for (key, value) in extra { |
| 29 | package[key] = value; |
| 30 | } |
| 31 | } |
| 32 | fs::write(bundle.join("package.json"), package.to_string()).unwrap(); |
| 33 | if let Some(patch) = patch { |
| 34 | fs::write(bundle.join("cordis.patch.yml"), patch).unwrap(); |
| 35 | } |
| 36 | let output = root.path().join("out"); |
| 37 | Fixture { |
| 38 | _root: root, |
| 39 | bundle, |
| 40 | output, |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | fn docs_row(extra: &str) -> String { |
| 45 | format!( |
| 46 | " - id: docs-entry\n name: '{MCP}'\n{extra} config:\n serverName: docs\n transport: streamable-http\n url: https://docs.example.invalid/mcp\n" |
| 47 | ) |
| 48 | } |
| 49 | |
| 50 | fn servers(output: &Path) -> Json { |
| 51 | let text = fs::read_to_string(output.join("mcp.json")).unwrap(); |
| 52 | serde_json::from_str::<Json>(&text).unwrap()["mcpServers"].clone() |
| 53 | } |
| 54 | |
| 55 | fn receipt(output: &Path) -> String { |
| 56 | fs::read_to_string(output.join("CONVERSION.md")).unwrap() |
| 57 | } |
| 58 | |
| 59 | fn refused(fixture: &Fixture, needle: &str) { |
| 60 | let error = convert_package(&fixture.bundle, &fixture.output) |
| 61 | .unwrap_err() |
| 62 | .to_string(); |
| 63 | assert!(error.contains(needle), "expected `{needle}` in: {error}"); |
| 64 | assert!( |
| 65 | !fixture.output.exists(), |
| 66 | "a refusal leaves no partial output" |
| 67 | ); |
| 68 | } |
| 69 | |
| 70 | #[test] |
| 71 | fn evaluates_patches_and_skips_foreign_rows() { |
| 72 | let f = fixture( |
| 73 | Some(&format!( |
| 74 | "- insert:\n - id: docs-entry\n name: '{MCP}'\n config:\n serverName: docs\n transport: streamable-http\n url: https://docs.example.invalid/mcp\n toolCallTimeoutMs: 19000\n - id: skin\n name: '@deepseek-ai/dsh-client-ui-theme'\n config: {{hue: 4}}\n- id: docs-entry\n disabled: true\n- id: ghost\n disabled: true\n" |
| 75 | )), |
| 76 | None, |
| 77 | ); |
| 78 | let conversion = convert_package(&f.bundle, &f.output).unwrap(); |
| 79 | assert_eq!(conversion.plugin_name, "tools-dsh"); |
| 80 | assert_eq!( |
| 81 | servers(&f.output)["docs"], |
| 82 | json!({"type": "streamable-http", "url": "https://docs.example.invalid/mcp", |
| 83 | "extensions": {"net.codewhale": {"execute_timeout": 19, "disabled": true}}}) |
| 84 | ); |
| 85 | let text = receipt(&f.output); |
| 86 | for fact in ["@demo/tools-dsh@1.2.3", "skin", "ghost"] { |
| 87 | assert!(text.contains(fact), "{fact}: {text}"); |
| 88 | } |
| 89 | let manifest: Json = |
| 90 | serde_json::from_str(&fs::read_to_string(f.output.join("plugin.json")).unwrap()).unwrap(); |
| 91 | assert_eq!(manifest["name"], "tools-dsh"); |
| 92 | assert_eq!(manifest["version"], "1.2.3"); |
| 93 | assert_eq!( |
| 94 | manifest["extensions"]["net.codewhale"]["capabilities"]["network_hosts"], |
| 95 | json!(["docs.example.invalid"]) |
| 96 | ); |
| 97 | } |
| 98 | |
| 99 | #[test] |
| 100 | fn environment_expressions_are_never_evaluated_and_host_paths_never_copied() { |
| 101 | let f = fixture( |
| 102 | Some(&format!( |
| 103 | "- insert:\n - id: local-entry\n name: '{MCP}'\n config:\n serverName: localdocs\n transport: stdio\n command: !!js process.execPath\n args:\n - !!js process.env.DSH_UNSET_7391 || '/tmp/server.mjs'\n - id: env-url\n name: '{MCP}'\n config:\n serverName: envdocs\n transport: streamable-http\n url: !!js '`https://example.invalid/${{process.env.DSH_CANARY}}/mcp`'\n{}", |
| 104 | docs_row("") |
| 105 | )), |
| 106 | None, |
| 107 | ); |
| 108 | convert_package(&f.bundle, &f.output).unwrap(); |
| 109 | let names: Vec<_> = servers(&f.output) |
| 110 | .as_object() |
| 111 | .unwrap() |
| 112 | .keys() |
| 113 | .cloned() |
| 114 | .collect(); |
| 115 | assert_eq!(names, ["docs"]); |
| 116 | assert!(!f.output.join("mcp").exists()); |
| 117 | let text = receipt(&f.output); |
| 118 | assert!( |
| 119 | text.contains("local-entry") && text.contains("reads an environment value"), |
| 120 | "{text}" |
| 121 | ); |
| 122 | assert!(text.contains("No environment variable values were resolved")); |
| 123 | } |
| 124 | |
| 125 | #[test] |
| 126 | fn a_relative_entry_missing_from_the_package_is_skipped_not_searched_for() { |
| 127 | let f = fixture( |
| 128 | Some(&format!( |
| 129 | "- insert:\n - id: local-entry\n name: '{MCP}'\n config:\n serverName: localdocs\n transport: stdio\n command: !!js process.execPath\n args: ['./server.mjs']\n" |
| 130 | )), |
| 131 | None, |
| 132 | ); |
| 133 | refused(&f, "No portable components"); |
| 134 | } |
| 135 | |
| 136 | #[test] |
| 137 | fn disabled_ancestry_disables_children_and_is_receipted() { |
| 138 | let f = fixture( |
| 139 | Some(&format!( |
| 140 | "- insert:\n - id: outer\n group: true\n disabled: true\n config:\n - id: inner\n group: true\n config:\n - id: docs-entry\n name: '{MCP}'\n config: {{serverName: docs, transport: streamable-http, url: 'https://docs.example.invalid/mcp'}}\n - id: skills-row\n name: '{SKILLS}'\n config: {{customSkillDirs: [pack-skills]}}\n - id: live-entry\n name: '{MCP}'\n config: {{serverName: live, transport: streamable-http, url: 'https://live.example.invalid/mcp'}}\n" |
| 141 | )), |
| 142 | None, |
| 143 | ); |
| 144 | let skill = f.bundle.join("pack-skills/guide"); |
| 145 | fs::create_dir_all(&skill).unwrap(); |
| 146 | fs::write( |
| 147 | skill.join("SKILL.md"), |
| 148 | "---\nname: guide\ndescription: Bundled skill\n---\nBody.\n", |
| 149 | ) |
| 150 | .unwrap(); |
| 151 | convert_package(&f.bundle, &f.output).unwrap(); |
| 152 | let servers = servers(&f.output); |
| 153 | assert_eq!( |
| 154 | servers["docs"]["extensions"]["net.codewhale"], |
| 155 | json!({"disabled": true}) |
| 156 | ); |
| 157 | assert_eq!(servers["live"]["extensions"]["net.codewhale"], json!({})); |
| 158 | assert!(!f.output.join("skills").exists()); |
| 159 | let text = receipt(&f.output); |
| 160 | assert!(text.contains("disabled by ancestor `outer`"), "{text}"); |
| 161 | assert!(text.contains("skipped-disabled"), "{text}"); |
| 162 | } |
| 163 | |
| 164 | #[test] |
| 165 | fn unresolved_conditional_gates_refuse_but_foreign_gates_are_skipped() { |
| 166 | let gated_group = format!( |
| 167 | "- insert:\n - id: gate\n name: '@deepseek-ai/cordis-plugin-group'\n group: true\n disabled: !!js process.env.DISABLE_THIS\n config:\n - id: docs-entry\n name: '{MCP}'\n config: {{serverName: docs, transport: streamable-http, url: 'https://docs.example.invalid/mcp'}}\n" |
| 168 | ); |
| 169 | let gated_row = format!( |
| 170 | "- insert:\n - id: docs-entry\n name: '{MCP}'\n disabled: !!js process.env.DISABLE_THIS\n config: {{serverName: docs, transport: streamable-http, url: 'https://docs.example.invalid/mcp'}}\n" |
| 171 | ); |
| 172 | let string_gate = format!( |
| 173 | "- insert:\n - id: docs-entry\n name: '{MCP}'\n disabled: 'yes'\n config: {{serverName: docs, transport: streamable-http, url: 'https://docs.example.invalid/mcp'}}\n" |
| 174 | ); |
| 175 | for patch in [gated_group, gated_row, string_gate] { |
| 176 | refused(&fixture(Some(&patch), None), "conditional or non-boolean"); |
| 177 | } |
| 178 | let control = fixture( |
| 179 | Some(&format!( |
| 180 | "- insert:\n - id: tool-bash\n name: '@deepseek-ai/dsh-tool-bash'\n disabled: !!js process.platform === 'win32'\n{}", |
| 181 | docs_row("") |
| 182 | )), |
| 183 | None, |
| 184 | ); |
| 185 | convert_package(&control.bundle, &control.output).unwrap(); |
| 186 | let text = receipt(&control.output); |
| 187 | assert!( |
| 188 | text.contains("tool-bash") |
| 189 | && text.contains("conditional `disabled` gate was not evaluated"), |
| 190 | "{text}" |
| 191 | ); |
| 192 | } |
| 193 | |
| 194 | #[test] |
| 195 | fn ordered_layers_replace_config_and_record_provenance() { |
| 196 | let f = fixture( |
| 197 | Some(&format!( |
| 198 | "- insert:\n - id: docs-entry\n name: '{MCP}'\n config: {{serverName: docs, transport: streamable-http, url: 'https://first.example.invalid/mcp', toolCallTimeoutMs: 19000}}\n" |
| 199 | )), |
| 200 | Some( |
| 201 | json!({"dsh": {"bundle": {"patch": ["./cordis.patch.yml", "./presets/overlay.patch.yml"]}}}), |
| 202 | ), |
| 203 | ); |
| 204 | fs::create_dir(f.bundle.join("presets")).unwrap(); |
| 205 | fs::write( |
| 206 | f.bundle.join("presets/overlay.patch.yml"), |
| 207 | "- id: docs-entry\n disabled: true\n config: {serverName: docs, transport: streamable-http, url: 'https://second.example.invalid/mcp'}\n", |
| 208 | ) |
| 209 | .unwrap(); |
| 210 | convert_package(&f.bundle, &f.output).unwrap(); |
| 211 | assert_eq!( |
| 212 | servers(&f.output)["docs"], |
| 213 | json!({"type": "streamable-http", "url": "https://second.example.invalid/mcp", |
| 214 | "extensions": {"net.codewhale": {"disabled": true}}}) |
| 215 | ); |
| 216 | let text = receipt(&f.output); |
| 217 | assert!(text.contains("applied in declaration order")); |
| 218 | assert!(text.contains(&format!("Converter version {CONVERTER_VERSION}"))); |
| 219 | assert!(text.contains("Source package: @demo/tools-dsh@1.2.3")); |
| 220 | assert!(text.contains(&sha256_hex( |
| 221 | &fs::read(f.bundle.join("package.json")).unwrap() |
| 222 | ))); |
| 223 | for relative in ["./cordis.patch.yml", "./presets/overlay.patch.yml"] { |
| 224 | let content = fs::read(f.bundle.join(relative.trim_start_matches("./"))).unwrap(); |
| 225 | assert!( |
| 226 | text.contains(&format!( |
| 227 | "{relative} (sha256 {}, {} bytes)", |
| 228 | sha256_hex(&content), |
| 229 | content.len() |
| 230 | )), |
| 231 | "{relative}: {text}" |
| 232 | ); |
| 233 | } |
| 234 | let structured: Json = |
| 235 | serde_json::from_str(&fs::read_to_string(f.output.join("CONVERSION.json")).unwrap()) |
| 236 | .unwrap(); |
| 237 | assert_eq!(structured["schema"], "codewhale.plugin-conversion.v1"); |
| 238 | } |
| 239 | |
| 240 | #[test] |
| 241 | fn skipped_patch_operations_are_structured_manual_ports() { |
| 242 | let f = fixture( |
| 243 | Some(&format!("- insert:\n{}", docs_row(""))), |
| 244 | Some(json!({"dsh": {"bundle": {"patch": ["./cordis.patch.yml", "./overlay.yml"]}}})), |
| 245 | ); |
| 246 | fs::write( |
| 247 | f.bundle.join("overlay.yml"), |
| 248 | "- {id: missing-group, insert: []}\n- {disabled: true}\n- {id: missing-row, disabled: true}\n- {id: docs-entry, name: wrong-package, disabled: true}\n", |
| 249 | ) |
| 250 | .unwrap(); |
| 251 | convert_package(&f.bundle, &f.output).unwrap(); |
| 252 | let structured: Json = |
| 253 | serde_json::from_str(&fs::read_to_string(f.output.join("CONVERSION.json")).unwrap()) |
| 254 | .unwrap(); |
| 255 | let skipped = structured["required_manual_ports"].as_array().unwrap(); |
| 256 | assert_eq!( |
| 257 | skipped |
| 258 | .iter() |
| 259 | .map(|row| row["row"].clone()) |
| 260 | .collect::<Vec<_>>(), |
| 261 | [ |
| 262 | json!("missing-group"), |
| 263 | Json::Null, |
| 264 | json!("missing-row"), |
| 265 | json!("docs-entry") |
| 266 | ] |
| 267 | ); |
| 268 | assert_eq!( |
| 269 | skipped |
| 270 | .iter() |
| 271 | .map(|row| row["patch"].clone()) |
| 272 | .collect::<Vec<_>>(), |
| 273 | [json!(1), json!(2), json!(3), json!(4)] |
| 274 | ); |
| 275 | for row in skipped { |
| 276 | assert_eq!( |
| 277 | (row["kind"].as_str(), row["layer"].as_str()), |
| 278 | (Some("patch"), Some("./overlay.yml")) |
| 279 | ); |
| 280 | assert!(receipt(&f.output).contains(row["reason"].as_str().unwrap())); |
| 281 | } |
| 282 | assert!( |
| 283 | servers(&f.output)["docs"]["extensions"]["net.codewhale"] |
| 284 | .get("disabled") |
| 285 | .is_none() |
| 286 | ); |
| 287 | } |
| 288 | |
| 289 | #[test] |
| 290 | fn patch_layer_list_is_bounded_and_contained() { |
| 291 | let portable = format!("- insert:\n{}", docs_row("")); |
| 292 | let attempt = |declared: Json, files: &[(&str, String)]| { |
| 293 | let f = fixture(None, Some(json!({"dsh": {"bundle": {"patch": declared}}}))); |
| 294 | for (name, text) in files { |
| 295 | let destination = f.bundle.join(name); |
| 296 | fs::create_dir_all(destination.parent().unwrap()).unwrap(); |
| 297 | fs::write(destination, text).unwrap(); |
| 298 | } |
| 299 | f |
| 300 | }; |
| 301 | let ok = attempt(json!(["./a.yml"]), &[("a.yml", portable.clone())]); |
| 302 | convert_package(&ok.bundle, &ok.output).unwrap(); |
| 303 | refused( |
| 304 | &attempt( |
| 305 | json!(["./a.yml", "./a.yml"]), |
| 306 | &[("a.yml", portable.clone())], |
| 307 | ), |
| 308 | "listed once", |
| 309 | ); |
| 310 | refused( |
| 311 | &attempt(json!(["a.yml", "./a.yml"]), &[("a.yml", portable.clone())]), |
| 312 | "listed once", |
| 313 | ); |
| 314 | refused(&attempt(json!(["/tmp/a.yml"]), &[]), "relative path"); |
| 315 | refused(&attempt(json!(["C:\\a.yml"]), &[]), "relative path"); |
| 316 | refused( |
| 317 | &attempt( |
| 318 | json!(["./a.yml", "../outside.yml"]), |
| 319 | &[("a.yml", portable.clone())], |
| 320 | ), |
| 321 | "relative path", |
| 322 | ); |
| 323 | refused(&attempt(json!(["./missing.yml"]), &[]), "inside the bundle"); |
| 324 | refused(&attempt(json!([]), &[]), "non-empty ordered list"); |
| 325 | let many: Vec<String> = (0..65).map(|i| format!("./layer-{i}.yml")).collect(); |
| 326 | refused(&attempt(json!(many), &[]), "At most 64"); |
| 327 | refused( |
| 328 | &attempt( |
| 329 | json!(["./a.yml", "./b.yml"]), |
| 330 | &[ |
| 331 | ("a.yml", format!("[]\n{}", " ".repeat(1024 * 1024 - 3))), |
| 332 | ("b.yml", "[]\n".into()), |
| 333 | ], |
| 334 | ), |
| 335 | "aggregate patch limit", |
| 336 | ); |
| 337 | } |
| 338 | |
| 339 | #[test] |
| 340 | fn pinned_upstream_multifile_package_parses_without_promoting_rows() { |
| 341 | let bundle = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../scripts/fixtures/dsh-web-app"); |
| 342 | let upstream: Json = |
| 343 | serde_json::from_str(&fs::read_to_string(bundle.join("UPSTREAM.json")).unwrap()).unwrap(); |
| 344 | assert_eq!( |
| 345 | upstream["commit"], |
| 346 | "00102833dfaee1da9f48a3a8eae9d34005a75218" |
| 347 | ); |
| 348 | for (relative, digest) in upstream["files"].as_object().unwrap() { |
| 349 | assert_eq!( |
| 350 | &json!(sha256_hex(&fs::read(bundle.join(relative)).unwrap())), |
| 351 | digest, |
| 352 | "{relative}" |
| 353 | ); |
| 354 | } |
| 355 | let package = Package::open(&bundle).unwrap(); |
| 356 | let loaded = load_bundle(&package).unwrap(); |
| 357 | assert_eq!(loaded.layers.len(), 5); |
| 358 | assert!(!loaded.entries.is_empty()); |
| 359 | let mut components = Components::default(); |
| 360 | components.walk(&package, &loaded.entries, None).unwrap(); |
| 361 | assert!( |
| 362 | components.servers.is_empty() |
| 363 | && components.hosts.is_empty() |
| 364 | && components.skill_dirs.is_empty() |
| 365 | ); |
| 366 | assert!( |
| 367 | components |
| 368 | .outcomes |
| 369 | .iter() |
| 370 | .all(|o| !o.outcome.starts_with("converted")) |
| 371 | ); |
| 372 | assert!( |
| 373 | components |
| 374 | .notes |
| 375 | .iter() |
| 376 | .any(|note| note.contains("conditional")) |
| 377 | ); |
| 378 | } |
| 379 | |
| 380 | #[test] |
| 381 | fn group_children_and_relative_stdio_entries_convert_from_the_declared_cwd() { |
| 382 | let f = fixture( |
| 383 | Some(&format!( |
| 384 | "- insert:\n - {{id: grouped, group: true, config: []}}\n- id: grouped\n insert:\n - id: in-group\n name: '{MCP}'\n config: {{serverName: inner, transport: stdio, command: node, args: [server.mjs], cwd: packaged}}\n" |
| 385 | )), |
| 386 | None, |
| 387 | ); |
| 388 | fs::write(f.bundle.join("server.mjs"), "wrong source").unwrap(); |
| 389 | fs::create_dir(f.bundle.join("packaged")).unwrap(); |
| 390 | fs::write(f.bundle.join("packaged/server.mjs"), "correct source").unwrap(); |
| 391 | let conversion = convert_package(&f.bundle, &f.output).unwrap(); |
| 392 | assert!(conversion.requires_node); |
| 393 | assert_eq!(conversion.local_servers, ["inner"]); |
| 394 | assert_eq!(servers(&f.output)["inner"]["cwd"], "mcp/inner"); |
| 395 | assert_eq!( |
| 396 | fs::read_to_string(f.output.join("mcp/inner/server.mjs")).unwrap(), |
| 397 | "correct source" |
| 398 | ); |
| 399 | } |
| 400 | |
| 401 | #[test] |
| 402 | fn custom_skill_dirs_import_and_disabled_skills_are_omitted() { |
| 403 | let build = |disabled: &str| { |
| 404 | let f = fixture( |
| 405 | Some(&format!( |
| 406 | "- insert:\n - id: skills-row\n name: '{SKILLS}'\n{disabled} config: {{customSkillDirs: [pack-skills]}}\n{}", |
| 407 | docs_row("") |
| 408 | )), |
| 409 | None, |
| 410 | ); |
| 411 | let skill = f.bundle.join("pack-skills/guide"); |
| 412 | fs::create_dir_all(&skill).unwrap(); |
| 413 | fs::write( |
| 414 | skill.join("SKILL.md"), |
| 415 | "---\nname: guide\ndescription: Bundled skill\n---\nBody.\n", |
| 416 | ) |
| 417 | .unwrap(); |
| 418 | fs::write(skill.join("notes.txt"), "companion").unwrap(); |
| 419 | f |
| 420 | }; |
| 421 | let enabled = build(""); |
| 422 | let conversion = convert_package(&enabled.bundle, &enabled.output).unwrap(); |
| 423 | assert_eq!(conversion.skills, ["guide"]); |
| 424 | let skill = fs::read_to_string(enabled.output.join("skills/guide/SKILL.md")).unwrap(); |
| 425 | assert!( |
| 426 | skill.starts_with("---\nname: guide\ndescription: |-\n Bundled skill\n---"), |
| 427 | "{skill}" |
| 428 | ); |
| 429 | assert_eq!( |
| 430 | fs::read_to_string(enabled.output.join("skills/guide/notes.txt")).unwrap(), |
| 431 | "companion" |
| 432 | ); |
| 433 | let disabled = build(" disabled: true\n"); |
| 434 | assert!( |
| 435 | convert_package(&disabled.bundle, &disabled.output) |
| 436 | .unwrap() |
| 437 | .skills |
| 438 | .is_empty() |
| 439 | ); |
| 440 | let text = receipt(&disabled.output); |
| 441 | assert!( |
| 442 | text.contains("skipped-disabled") && text.contains("preserved by omission"), |
| 443 | "{text}" |
| 444 | ); |
| 445 | } |
| 446 | |
| 447 | #[test] |
| 448 | fn js_is_never_executed_and_unlowerable_rows_are_recorded() { |
| 449 | let sentinel = tempfile::tempdir().unwrap().path().join("expression-ran"); |
| 450 | let f = fixture( |
| 451 | Some(&format!( |
| 452 | "- insert:\n - id: bad\n name: '{MCP}'\n config:\n serverName: bad\n transport: streamable-http\n url: !!js require('node:fs').writeFileSync('{}', 'ran')\n - id: ok\n name: '{MCP}'\n config: {{serverName: ok, transport: streamable-http, url: 'https://ok.example.invalid/mcp'}}\n", |
| 453 | sentinel.display() |
| 454 | )), |
| 455 | None, |
| 456 | ); |
| 457 | convert_package(&f.bundle, &f.output).unwrap(); |
| 458 | assert!(!sentinel.exists()); |
| 459 | let names: Vec<_> = servers(&f.output) |
| 460 | .as_object() |
| 461 | .unwrap() |
| 462 | .keys() |
| 463 | .cloned() |
| 464 | .collect(); |
| 465 | assert_eq!(names, ["ok"]); |
| 466 | assert!(receipt(&f.output).contains("bad")); |
| 467 | } |
| 468 | |
| 469 | #[test] |
| 470 | fn manifests_must_declare_contained_patches() { |
| 471 | refused(&fixture(None, None), "inside the bundle"); |
| 472 | refused( |
| 473 | &fixture( |
| 474 | None, |
| 475 | Some(json!({"dsh": {"bundle": {"patch": "../outside.yml"}}})), |
| 476 | ), |
| 477 | "relative path", |
| 478 | ); |
| 479 | let plain = fixture(None, None); |
| 480 | fs::write( |
| 481 | plain.bundle.join("package.json"), |
| 482 | json!({"name": "plain"}).to_string(), |
| 483 | ) |
| 484 | .unwrap(); |
| 485 | refused(&plain, "dsh.bundle.patch"); |
| 486 | assert!(!is_dsh_package(&plain.bundle)); |
| 487 | assert!(is_dsh_package(&fixture(None, None).bundle)); |
| 488 | } |
| 489 | |
| 490 | #[test] |
| 491 | fn policy_and_dependency_fields_never_widen_activation() { |
| 492 | for field in [ |
| 493 | "inject: [approvals]", |
| 494 | "intercept: {tools: true}", |
| 495 | "isolate: {tools: private}", |
| 496 | "unknownGate: true", |
| 497 | ] { |
| 498 | for disabled in ["false", "true"] { |
| 499 | let rows = [ |
| 500 | format!( |
| 501 | " - {{id: docs-entry, name: '{MCP}', {field}, disabled: {disabled}, config: {{serverName: docs, transport: streamable-http, url: 'https://docs.example.invalid/mcp'}}}}\n" |
| 502 | ), |
| 503 | format!( |
| 504 | " - {{id: skills, name: '{SKILLS}', {field}, disabled: {disabled}, config: {{customSkillDirs: [skills]}}}}\n" |
| 505 | ), |
| 506 | format!( |
| 507 | " - {{id: group, group: true, {field}, disabled: {disabled}, config: []}}\n" |
| 508 | ), |
| 509 | ]; |
| 510 | for row in rows { |
| 511 | refused( |
| 512 | &fixture(Some(&format!("- insert:\n{row}")), None), |
| 513 | "unsupported entry policy or dependency", |
| 514 | ); |
| 515 | } |
| 516 | } |
| 517 | } |
| 518 | } |
| 519 | |
| 520 | #[test] |
| 521 | fn skipped_stdio_rows_never_copy_their_source() { |
| 522 | let f = fixture( |
| 523 | Some(&format!( |
| 524 | "- insert:\n{} - id: unportable\n name: '{MCP}'\n config: {{serverName: local, transport: stdio, command: node, args: [server.mjs], cwd: packaged, env: {{TOKEN: canary-9f2}}}}\n", |
| 525 | docs_row("") |
| 526 | )), |
| 527 | None, |
| 528 | ); |
| 529 | fs::create_dir(f.bundle.join("packaged")).unwrap(); |
| 530 | fs::write( |
| 531 | f.bundle.join("packaged/server.mjs"), |
| 532 | "throw new Error('never run');\n", |
| 533 | ) |
| 534 | .unwrap(); |
| 535 | convert_package(&f.bundle, &f.output).unwrap(); |
| 536 | assert!(!f.output.join("mcp").exists()); |
| 537 | let structured = fs::read_to_string(f.output.join("CONVERSION.json")).unwrap(); |
| 538 | let parsed: Json = serde_json::from_str(&structured).unwrap(); |
| 539 | assert_eq!(parsed["required_manual_ports"][0]["row"], "unportable"); |
| 540 | assert!(!structured.contains("canary-9f2")); |
| 541 | } |
| 542 | |
| 543 | #[test] |
| 544 | fn a_duplicate_server_cannot_discard_a_disabled_row() { |
| 545 | let f = fixture( |
| 546 | Some(&format!( |
| 547 | "- insert:\n{}{}", |
| 548 | docs_row(""), |
| 549 | docs_row(" disabled: true\n").replace("id: docs-entry", "id: disabled-copy") |
| 550 | )), |
| 551 | None, |
| 552 | ); |
| 553 | refused(&f, "Duplicate MCP server"); |
| 554 | } |
| 555 | |
| 556 | #[test] |
| 557 | fn literal_lowering_never_evaluates_or_changes_escaped_strings() { |
| 558 | for expression in [ |
| 559 | "'https://example.invalid/mcp'", |
| 560 | "`https://example.invalid/mcp`", |
| 561 | ] { |
| 562 | assert_eq!( |
| 563 | lower_js(expression, "url").unwrap(), |
| 564 | "https://example.invalid/mcp" |
| 565 | ); |
| 566 | } |
| 567 | assert_eq!(lower_js("process.execPath", "command").unwrap(), "node"); |
| 568 | for expression in [ |
| 569 | r"'https://example.invalid/\\x41'", |
| 570 | "`x` + `y`", |
| 571 | "process.env.TOKEN", |
| 572 | "process.env.TOKEN || 'literal'", |
| 573 | "`${process.env.TOKEN}`", |
| 574 | "process.env['TOKEN']", |
| 575 | ] { |
| 576 | assert!(lower_js(expression, "url").is_err(), "{expression}"); |
| 577 | } |
| 578 | } |
| 579 | |
| 580 | #[test] |
| 581 | fn data_parsing_is_closed() { |
| 582 | assert!( |
| 583 | parse_json(r#"{"a": 1, "a": 2}"#).is_err(), |
| 584 | "duplicate JSON keys" |
| 585 | ); |
| 586 | assert!( |
| 587 | parse_yaml("a: 1\na: 2\n", false).is_err(), |
| 588 | "duplicate YAML keys" |
| 589 | ); |
| 590 | assert!(parse_yaml("a: &x 1\nb: *x\n", false).is_err(), "aliases"); |
| 591 | assert!(parse_yaml("a: !!str 1\n", false).is_err(), "explicit tags"); |
| 592 | assert!( |
| 593 | parse_yaml("a: !!js process.execPath\n", false).is_err(), |
| 594 | "js without permission" |
| 595 | ); |
| 596 | assert_eq!( |
| 597 | parse_yaml("a: !!js process.execPath\n", true) |
| 598 | .unwrap() |
| 599 | .get("a"), |
| 600 | Some(&Value::Js("process.execPath".into())) |
| 601 | ); |
| 602 | assert!(parse_yaml("- !!js x\n", true).unwrap() == Value::Seq(vec![Value::Js("x".into())])); |
| 603 | assert!( |
| 604 | parse_yaml("!!js [a]\n", true).is_err(), |
| 605 | "tagged collections" |
| 606 | ); |
| 607 | assert!( |
| 608 | parse_yaml("a: 1\n---\nb: 2\n", false).is_err(), |
| 609 | "multiple documents" |
| 610 | ); |
| 611 | assert!(parse_yaml("{__jsExpr: x}\n", false).is_err()); |
| 612 | assert!( |
| 613 | parse_yaml(&format!("{}1{}", "[".repeat(33), "]".repeat(33)), false).is_err(), |
| 614 | "depth" |
| 615 | ); |
| 616 | assert_eq!( |
| 617 | parse_yaml("{a: yes, b: true, c: 0x10, d: 1.5, e: ~, f: '1'}\n", false).unwrap(), |
| 618 | Value::Map(vec![ |
| 619 | ("a".into(), Value::Str("yes".into())), |
| 620 | ("b".into(), Value::Bool(true)), |
| 621 | ("c".into(), Value::Int(16)), |
| 622 | ("d".into(), Value::Float(1.5)), |
| 623 | ("e".into(), Value::Null), |
| 624 | ("f".into(), Value::Str("1".into())), |
| 625 | ]) |
| 626 | ); |
| 627 | } |
| 628 | |
| 629 | #[test] |
| 630 | fn endpoints_are_literal_https_or_loopback_with_canonical_hosts() { |
| 631 | for (url, host) in [ |
| 632 | ("https://docs.example.invalid/mcp", "docs.example.invalid"), |
| 633 | ( |
| 634 | "https://Docs.Example.Invalid:8443/mcp", |
| 635 | "docs.example.invalid", |
| 636 | ), |
| 637 | ("http://127.0.0.1:9000/mcp", "127.0.0.1"), |
| 638 | ("http://[::1]:9000/mcp", "[::1]"), |
| 639 | ("https://10.0.0.1/mcp", "10.0.0.1"), |
| 640 | ] { |
| 641 | assert_eq!(endpoint_host(url).unwrap(), host, "{url}"); |
| 642 | } |
| 643 | for url in [ |
| 644 | "http://docs.example.invalid/mcp", |
| 645 | "https://user:secret@docs.example.invalid/mcp", |
| 646 | "https://docs.example.invalid/mcp?token=x", |
| 647 | "https://docs.example.invalid/mcp#x", |
| 648 | "https://127.1/mcp", |
| 649 | "https://0x7f.0.0.1/mcp", |
| 650 | "https://docs.example.invalid:0/mcp", |
| 651 | "https://docs.example.invalid/${x}", |
| 652 | "ftp://docs.example.invalid/mcp", |
| 653 | ] { |
| 654 | assert!(endpoint_host(url).is_err(), "{url}"); |
| 655 | } |
| 656 | } |
| 657 | |
| 658 | #[test] |
| 659 | fn links_inside_the_package_are_refused() { |
| 660 | #[cfg(unix)] |
| 661 | { |
| 662 | let f = fixture(Some(&format!("- insert:\n{}", docs_row(""))), None); |
| 663 | let outside = tempfile::tempdir().unwrap(); |
| 664 | fs::write(outside.path().join("layer.yml"), "[]\n").unwrap(); |
| 665 | std::os::unix::fs::symlink( |
| 666 | outside.path().join("layer.yml"), |
| 667 | f.bundle.join("linked.yml"), |
| 668 | ) |
| 669 | .unwrap(); |
| 670 | fs::write( |
| 671 | f.bundle.join("package.json"), |
| 672 | json!({"name": "@demo/tools-dsh", "dsh": {"bundle": {"patch": ["./linked.yml"]}}}) |
| 673 | .to_string(), |
| 674 | ) |
| 675 | .unwrap(); |
| 676 | refused(&f, "links or reparse points"); |
| 677 | } |
| 678 | } |
| 679 | |
| 680 | #[test] |
| 681 | fn the_converted_bundle_is_a_valid_native_plugin() { |
| 682 | let f = fixture(Some(&format!("- insert:\n{}", docs_row(""))), None); |
| 683 | convert_package(&f.bundle, &f.output).unwrap(); |
| 684 | let manifest = |
| 685 | crate::plugins::agent_plugin::resolve_manifest_path(&f.output).expect("manifest"); |
| 686 | let validated = |
| 687 | crate::plugins::manifest::PluginManifest::validate_from_path(&manifest).unwrap(); |
| 688 | assert_eq!(validated.manifest.plugin.name, "tools-dsh"); |
| 689 | } |
| 690 | |
| 691 | #[test] |
| 692 | fn derived_names_are_native_plugin_names() { |
| 693 | assert_eq!( |
| 694 | derived_plugin_name("@deepseek-ai/bundle-web-app").as_deref(), |
| 695 | Some("bundle-web-app") |
| 696 | ); |
| 697 | assert_eq!( |
| 698 | derived_plugin_name("Tools_DSH").as_deref(), |
| 699 | Some("tools-dsh") |
| 700 | ); |
| 701 | assert_eq!(derived_plugin_name("@x/--"), None); |
| 702 | } |
| 703 | |
| 704 | #[test] |
| 705 | fn raw_agent_presets_import_emits_exact_native_entries_and_catalog() { |
| 706 | let source = Path::new(env!("CARGO_MANIFEST_DIR")) |
| 707 | .join("tests/fixtures/extension_host/raw-agent-presets/source"); |
| 708 | let root = tempfile::tempdir().unwrap(); |
| 709 | let output = root.path().join("raw-preset-output"); |
| 710 | let converted = convert_package(&source, &output).unwrap(); |
| 711 | assert!(converted.requires_native); |
| 712 | assert_eq!(converted.native_rows, ["@deepseek-ai/dsh-agent-presets"]); |
| 713 | let manifest: Json = |
| 714 | serde_json::from_slice(&fs::read(output.join("plugin.json")).unwrap()).unwrap(); |
| 715 | assert_eq!( |
| 716 | manifest["extensions"]["net.codewhale"]["native"]["paths"], |
| 717 | json!(["native/presets/a.mjs", "native/presets/b.mjs"]) |
| 718 | ); |
| 719 | assert!( |
| 720 | !output.join("native/index.mjs").exists(), |
| 721 | "no unselected global composition consumer remains" |
| 722 | ); |
| 723 | let catalog: Json = |
| 724 | serde_json::from_slice(&fs::read(output.join("native/presets.json")).unwrap()).unwrap(); |
| 725 | assert_eq!(catalog["default"], "a"); |
| 726 | for row in catalog["presets"].as_array().unwrap() { |
| 727 | let entry = row["entry"]["path"].as_str().unwrap(); |
| 728 | let bytes = fs::read(output.join(entry)).unwrap(); |
| 729 | assert_eq!(row["entry"]["sha256"], sha256_hex(&bytes)); |
| 730 | let data = crate::plugins::native_presets::metadata_from_bytes(&bytes).unwrap(); |
| 731 | assert_eq!(data.id, row["id"].as_str().unwrap()); |
| 732 | } |
| 733 | assert!( |
| 734 | crate::plugins::manifest::PluginManifest::validate_from_path(&output.join("plugin.json")) |
| 735 | .is_ok() |
| 736 | ); |
| 737 | } |
| 738 | |
| 739 | #[test] |
| 740 | fn contained_bare_modules_never_walk_ambient_packages_or_escape_exports() { |
| 741 | let f = fixture(None, None); |
| 742 | let package = Package::open(&f.bundle).unwrap(); |
| 743 | assert!( |
| 744 | presets::contained_module(&package, "js-yaml").is_err(), |
| 745 | "installed host dependency is not a package receipt" |
| 746 | ); |
| 747 | let dir = f.bundle.join("node_modules/@demo/profile"); |
| 748 | fs::create_dir_all(&dir).unwrap(); |
| 749 | fs::write(dir.join("package.json"), r#"{"name":"@demo/profile","type":"module","exports":{".":{"require":"./not.cjs","import":"./index.mjs"},"./escape":"../../../outside.mjs"}}"#).unwrap(); |
| 750 | fs::write(dir.join("index.mjs"), "export function apply() {}\n").unwrap(); |
| 751 | assert_eq!( |
| 752 | presets::contained_module(&package, "@demo/profile").unwrap(), |
| 753 | dir.join("index.mjs").canonicalize().unwrap() |
| 754 | ); |
| 755 | assert!(presets::contained_module(&package, "@demo/profile/escape").is_err()); |
| 756 | assert!(presets::contained_module(&package, "@demo/profile/../../outside").is_err()); |
| 757 | fs::write(dir.join("package.json"),r#"{"name":"@demo/profile","type":"module","exports":{"node":null,"import":"./index.mjs"}}"#).unwrap(); |
| 758 | assert!( |
| 759 | presets::contained_module(&package, "@demo/profile").is_err(), |
| 760 | "matched null export must not fall through" |
| 761 | ); |
| 762 | } |
| 763 | |
| 764 | #[test] |
| 765 | fn contained_bare_native_row_imports_but_an_unresolved_sibling_never_installs_a_partial_graph() { |
| 766 | let build = |mixed: bool| { |
| 767 | let f = fixture( |
| 768 | Some(&format!( |
| 769 | "- insert:\n - {{id: native-row, name: '@demo/profile'}}\n{}", |
| 770 | if mixed { |
| 771 | " - {id: missing, name: absent-package}\n" |
| 772 | } else { |
| 773 | "" |
| 774 | } |
| 775 | )), |
| 776 | None, |
| 777 | ); |
| 778 | let dir = f.bundle.join("node_modules/@demo/profile"); |
| 779 | fs::create_dir_all(&dir).unwrap(); |
| 780 | fs::write( |
| 781 | dir.join("package.json"), |
| 782 | r#"{"name":"@demo/profile","type":"module","exports":"./index.mjs"}"#, |
| 783 | ) |
| 784 | .unwrap(); |
| 785 | fs::write(dir.join("index.mjs"),"export const inject=['prompt'];export function apply(ctx){ctx.prompt.registerSection({id:'profile',text:'actual closed bare module'})}\n").unwrap(); |
| 786 | f |
| 787 | }; |
| 788 | let valid = build(false); |
| 789 | let converted = convert_package(&valid.bundle, &valid.output).unwrap(); |
| 790 | assert!(converted.requires_native); |
| 791 | assert_eq!(converted.native_rows, ["@demo/profile"]); |
| 792 | let spec: Json = |
| 793 | serde_json::from_slice(&fs::read(valid.output.join("native/composition.json")).unwrap()) |
| 794 | .unwrap(); |
| 795 | assert_eq!(spec["modules"][0]["name"], "@demo/profile"); |
| 796 | assert_eq!( |
| 797 | spec["modules"][0]["path"], |
| 798 | "node_modules/@demo/profile/index.mjs" |
| 799 | ); |
| 800 | assert!( |
| 801 | crate::plugins::manifest::PluginManifest::validate_from_path( |
| 802 | &valid.output.join("plugin.json") |
| 803 | ) |
| 804 | .is_ok() |
| 805 | ); |
| 806 | refused(&build(true), "no partial graph"); |
| 807 | } |
| 808 | |
| 809 | #[test] |
| 810 | fn raw_catalog_broken_rows_remain_structured_manual_ports_beside_healthy_entries() { |
| 811 | let source = Path::new(env!("CARGO_MANIFEST_DIR")) |
| 812 | .join("tests/fixtures/extension_host/raw-agent-presets/source"); |
| 813 | let root = tempfile::tempdir().unwrap(); |
| 814 | let bundle = root.path().join("source"); |
| 815 | fs::create_dir(&bundle).unwrap(); |
| 816 | walk_files(&source, |path, is_dir| { |
| 817 | let target = bundle.join(path.strip_prefix(&source).unwrap()); |
| 818 | if is_dir { |
| 819 | fs::create_dir_all(target)?; |
| 820 | } else { |
| 821 | fs::create_dir_all(target.parent().unwrap())?; |
| 822 | fs::copy(path, target)?; |
| 823 | } |
| 824 | Ok(()) |
| 825 | }) |
| 826 | .unwrap(); |
| 827 | fs::create_dir_all(bundle.join("presets/broken")).unwrap(); |
| 828 | fs::write( |
| 829 | bundle.join("presets/broken/agent.cordis.yml"), |
| 830 | "[invalid yaml\n", |
| 831 | ) |
| 832 | .unwrap(); |
| 833 | let output = root.path().join("converted"); |
| 834 | let converted = convert_package(&bundle, &output).unwrap(); |
| 835 | assert!(converted.requires_native); |
| 836 | let manual: Vec<_> = converted |
| 837 | .outcomes |
| 838 | .iter() |
| 839 | .filter(|row| row.kind == "native-preset") |
| 840 | .collect(); |
| 841 | assert_eq!(manual.len(), 1); |
| 842 | assert_eq!(manual[0].row.as_deref(), Some("broken")); |
| 843 | assert!(manual[0].needs_manual_port()); |
| 844 | let catalog: Json = |
| 845 | serde_json::from_slice(&fs::read(output.join("native/presets.json")).unwrap()).unwrap(); |
| 846 | let broken = catalog["presets"] |
| 847 | .as_array() |
| 848 | .unwrap() |
| 849 | .iter() |
| 850 | .find(|row| row["id"] == "broken") |
| 851 | .unwrap(); |
| 852 | assert!(broken.get("broken").is_some()); |
| 853 | assert!(broken.get("entry").is_none()); |
| 854 | assert_eq!( |
| 855 | catalog["presets"] |
| 856 | .as_array() |
| 857 | .unwrap() |
| 858 | .iter() |
| 859 | .filter(|row| row.get("entry").is_some()) |
| 860 | .count(), |
| 861 | 2 |
| 862 | ); |
| 863 | let receipt: Json = |
| 864 | serde_json::from_slice(&fs::read(output.join("CONVERSION.json")).unwrap()).unwrap(); |
| 865 | assert!( |
| 866 | receipt["required_manual_ports"] |
| 867 | .as_array() |
| 868 | .unwrap() |
| 869 | .iter() |
| 870 | .any(|row| row["row"] == "broken") |
| 871 | ); |
| 872 | } |
| 873 |