| 1 | //! DSH import through the pinned upstream nonexecuting reviewer. All product |
| 2 | //! preview/install callers share this preparation and existing atomic installer. |
| 3 | //! Portable Rust MCP/skills remain core-owned. Closed relative Native modules |
| 4 | //! mount through the one host Loader. Bare exports come only from exact |
| 5 | //! admitted package manifests; mixed missing/unbridged rows refuse explicitly, |
| 6 | //! never a partial compatibility claim. Native install still lands disabled/untrusted and requires review. |
| 7 | |
| 8 | use std::collections::BTreeMap; |
| 9 | use std::fs; |
| 10 | use std::io::Read as _; |
| 11 | use std::path::{Component, Path, PathBuf}; |
| 12 | |
| 13 | use anyhow::Result; |
| 14 | use regex::Regex; |
| 15 | use serde::Serialize; |
| 16 | use serde_json::{Value as Json, json}; |
| 17 | |
| 18 | mod presets; |
| 19 | |
| 20 | pub(crate) const CONVERTER_VERSION: &str = "0.10.1"; |
| 21 | const MAX_FILES: usize = 4096; |
| 22 | const MAX_BYTES: usize = 64 * 1024 * 1024; |
| 23 | const MAX_DOCUMENT: usize = 1024 * 1024; |
| 24 | const MAX_PATCH_FILES: usize = 64; |
| 25 | const MAX_DEPTH: usize = 32; |
| 26 | const MAX_SERVERS: usize = 64; |
| 27 | const DSH_MCP_CLIENT: &str = "@deepseek-ai/dsh-mcp-client"; |
| 28 | const DSH_SKILL_FILESYSTEM: &str = "@deepseek-ai/dsh-skill-filesystem"; |
| 29 | |
| 30 | fn refuse<T>(message: impl Into<String>) -> Result<T> { |
| 31 | Err(anyhow::Error::msg(message.into())) |
| 32 | } |
| 33 | |
| 34 | fn require(condition: bool, message: &str) -> Result<()> { |
| 35 | if condition { Ok(()) } else { refuse(message) } |
| 36 | } |
| 37 | |
| 38 | fn pattern(source: &str) -> Regex { |
| 39 | Regex::new(source).expect("static pattern") |
| 40 | } |
| 41 | |
| 42 | // ───────────────────────────────────────────────────────────────────────────── |
| 43 | // Closed data model |
| 44 | // ───────────────────────────────────────────────────────────────────────────── |
| 45 | |
| 46 | /// Parsed configuration data. `Js` is an unevaluated `!!js` scalar; it is |
| 47 | /// never executed. Maps keep source order. |
| 48 | #[derive(Debug, Clone, PartialEq)] |
| 49 | enum Value { |
| 50 | Null, |
| 51 | Bool(bool), |
| 52 | Int(i64), |
| 53 | Float(f64), |
| 54 | Str(String), |
| 55 | Js(String), |
| 56 | Seq(Vec<Value>), |
| 57 | Map(Vec<(String, Value)>), |
| 58 | } |
| 59 | |
| 60 | impl Value { |
| 61 | fn get(&self, key: &str) -> Option<&Value> { |
| 62 | match self { |
| 63 | Value::Map(entries) => entries.iter().find(|(k, _)| k == key).map(|(_, v)| v), |
| 64 | _ => None, |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | fn get_mut(&mut self, key: &str) -> Option<&mut Value> { |
| 69 | match self { |
| 70 | Value::Map(entries) => entries.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v), |
| 71 | _ => None, |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | fn set(&mut self, key: &str, value: Value) { |
| 76 | if let Value::Map(entries) = self { |
| 77 | match entries.iter_mut().find(|(k, _)| k == key) { |
| 78 | Some(slot) => slot.1 = value, |
| 79 | None => entries.push((key.to_string(), value)), |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | fn str(&self) -> Option<&str> { |
| 85 | match self { |
| 86 | Value::Str(text) => Some(text), |
| 87 | _ => None, |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | fn keys(&self) -> Vec<&str> { |
| 92 | match self { |
| 93 | Value::Map(entries) => entries.iter().map(|(k, _)| k.as_str()).collect(), |
| 94 | _ => Vec::new(), |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | fn is_true(&self) -> bool { |
| 99 | matches!(self, Value::Bool(true)) |
| 100 | } |
| 101 | |
| 102 | fn free_of_js(&self) -> bool { |
| 103 | match self { |
| 104 | Value::Js(_) => false, |
| 105 | Value::Seq(items) => items.iter().all(Value::free_of_js), |
| 106 | Value::Map(entries) => entries.iter().all(|(_, v)| v.free_of_js()), |
| 107 | _ => true, |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | fn to_json(&self) -> Json { |
| 112 | match self { |
| 113 | Value::Null | Value::Js(_) => Json::Null, |
| 114 | Value::Bool(value) => Json::Bool(*value), |
| 115 | Value::Int(value) => json!(value), |
| 116 | Value::Float(value) => { |
| 117 | serde_json::Number::from_f64(*value).map_or(Json::Null, Json::Number) |
| 118 | } |
| 119 | Value::Str(value) => Json::String(value.clone()), |
| 120 | Value::Seq(items) => Json::Array(items.iter().map(Value::to_json).collect()), |
| 121 | Value::Map(entries) => Json::Object( |
| 122 | entries |
| 123 | .iter() |
| 124 | .map(|(k, v)| (k.clone(), v.to_json())) |
| 125 | .collect(), |
| 126 | ), |
| 127 | } |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | /// Keys must be strings, unique, and limited to `allowed` when given. |
| 132 | fn mapping<'a>(value: &'a Value, allowed: Option<&[&str]>) -> Result<&'a [(String, Value)]> { |
| 133 | let Value::Map(entries) = value else { |
| 134 | return refuse("Expected a configuration object."); |
| 135 | }; |
| 136 | if let Some(allowed) = allowed { |
| 137 | require( |
| 138 | entries |
| 139 | .iter() |
| 140 | .all(|(key, _)| allowed.contains(&key.as_str())), |
| 141 | "Unsupported fields; select only documented portable declarations.", |
| 142 | )?; |
| 143 | } |
| 144 | Ok(entries) |
| 145 | } |
| 146 | |
| 147 | const PARSE_REFUSAL: &str = |
| 148 | "Cannot parse portable data; use plain YAML or JSON for DSH bundle files."; |
| 149 | |
| 150 | /// Closed YAML parsing: no aliases, no explicit tags except `!!js` on |
| 151 | /// scalars (when allowed), no duplicate or non-string keys, at most one |
| 152 | /// document and 32 levels. Parser errors are not echoed: they can contain |
| 153 | /// source lines and credentials. |
| 154 | fn parse_yaml(text: &str, allow_js: bool) -> Result<Value> { |
| 155 | use yaml_rust2::parser::{Event, Parser}; |
| 156 | use yaml_rust2::scanner::TScalarStyle; |
| 157 | |
| 158 | enum Frame { |
| 159 | Seq(Vec<Value>), |
| 160 | Map(Vec<(String, Value)>, Option<String>), |
| 161 | } |
| 162 | |
| 163 | fn place(stack: &mut [Frame], root: &mut Option<Value>, value: Value) -> Result<()> { |
| 164 | match stack.last_mut() { |
| 165 | None => { |
| 166 | *root = Some(value); |
| 167 | Ok(()) |
| 168 | } |
| 169 | Some(Frame::Seq(items)) => { |
| 170 | items.push(value); |
| 171 | Ok(()) |
| 172 | } |
| 173 | Some(Frame::Map(entries, pending)) => match pending.take() { |
| 174 | None => { |
| 175 | let Value::Str(key) = value else { |
| 176 | return refuse("Object keys must be strings."); |
| 177 | }; |
| 178 | require( |
| 179 | !entries.iter().any(|(existing, _)| *existing == key), |
| 180 | "Duplicate or non-string object key.", |
| 181 | )?; |
| 182 | require( |
| 183 | key != "__jsExpr", |
| 184 | "DSH executable expressions require a manual port.", |
| 185 | )?; |
| 186 | *pending = Some(key); |
| 187 | Ok(()) |
| 188 | } |
| 189 | Some(key) => { |
| 190 | entries.push((key, value)); |
| 191 | Ok(()) |
| 192 | } |
| 193 | }, |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | fn plain_scalar(text: &str) -> Value { |
| 198 | match text { |
| 199 | "" | "~" | "null" | "Null" | "NULL" => return Value::Null, |
| 200 | "true" | "True" | "TRUE" => return Value::Bool(true), |
| 201 | "false" | "False" | "FALSE" => return Value::Bool(false), |
| 202 | ".inf" | ".Inf" | ".INF" | "+.inf" | "+.Inf" | "+.INF" => { |
| 203 | return Value::Float(f64::INFINITY); |
| 204 | } |
| 205 | "-.inf" | "-.Inf" | "-.INF" => return Value::Float(f64::NEG_INFINITY), |
| 206 | ".nan" | ".NaN" | ".NAN" => return Value::Float(f64::NAN), |
| 207 | _ => {} |
| 208 | } |
| 209 | let integer = |digits: &str, radix| i64::from_str_radix(digits, radix).ok(); |
| 210 | if let Some(value) = text |
| 211 | .strip_prefix("0x") |
| 212 | .and_then(|digits| integer(digits, 16)) |
| 213 | .or_else(|| { |
| 214 | text.strip_prefix("0o") |
| 215 | .and_then(|digits| integer(digits, 8)) |
| 216 | }) |
| 217 | { |
| 218 | return Value::Int(value); |
| 219 | } |
| 220 | let bytes = text.strip_prefix(['-', '+']).unwrap_or(text); |
| 221 | if !bytes.is_empty() |
| 222 | && bytes.bytes().all(|b| b.is_ascii_digit()) |
| 223 | && let Ok(value) = text.parse::<i64>() |
| 224 | { |
| 225 | return Value::Int(value); |
| 226 | } |
| 227 | let float = pattern(r"^[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?$"); |
| 228 | if float.is_match(text) |
| 229 | && let Ok(value) = text.parse::<f64>() |
| 230 | { |
| 231 | return Value::Float(value); |
| 232 | } |
| 233 | Value::Str(text.to_string()) |
| 234 | } |
| 235 | |
| 236 | let is_js_tag = |tag: &yaml_rust2::parser::Tag| { |
| 237 | tag.suffix == "js" && (tag.handle == "!!" || tag.handle == "tag:yaml.org,2002:") |
| 238 | }; |
| 239 | |
| 240 | let mut parser = Parser::new_from_str(text); |
| 241 | let mut stack: Vec<Frame> = Vec::new(); |
| 242 | let mut root: Option<Value> = None; |
| 243 | let mut documents = 0usize; |
| 244 | loop { |
| 245 | let (event, _) = parser |
| 246 | .next_token() |
| 247 | .map_err(|_| anyhow::Error::msg(PARSE_REFUSAL))?; |
| 248 | match event { |
| 249 | Event::StreamEnd => break, |
| 250 | Event::StreamStart | Event::Nothing | Event::DocumentEnd => {} |
| 251 | Event::DocumentStart => { |
| 252 | documents += 1; |
| 253 | require(documents <= 1, PARSE_REFUSAL)?; |
| 254 | } |
| 255 | Event::Alias(_) => return refuse("YAML aliases are unsupported."), |
| 256 | Event::Scalar(text, style, _anchor, tag) => { |
| 257 | let value = match tag { |
| 258 | Some(tag) if allow_js && is_js_tag(&tag) => Value::Js(text), |
| 259 | Some(_) => { |
| 260 | return refuse( |
| 261 | "YAML aliases and explicit tags (including !!js) are unsupported.", |
| 262 | ); |
| 263 | } |
| 264 | None if matches!(style, TScalarStyle::Plain) => plain_scalar(&text), |
| 265 | None => Value::Str(text), |
| 266 | }; |
| 267 | place(&mut stack, &mut root, value)?; |
| 268 | } |
| 269 | Event::SequenceStart(_, tag) | Event::MappingStart(_, tag) if tag.is_some() => { |
| 270 | return refuse("YAML aliases and explicit tags (including !!js) are unsupported."); |
| 271 | } |
| 272 | Event::SequenceStart(..) => { |
| 273 | require( |
| 274 | stack.len() < MAX_DEPTH, |
| 275 | "Configuration nesting exceeds 32 levels.", |
| 276 | )?; |
| 277 | stack.push(Frame::Seq(Vec::new())); |
| 278 | } |
| 279 | Event::MappingStart(..) => { |
| 280 | require( |
| 281 | stack.len() < MAX_DEPTH, |
| 282 | "Configuration nesting exceeds 32 levels.", |
| 283 | )?; |
| 284 | stack.push(Frame::Map(Vec::new(), None)); |
| 285 | } |
| 286 | Event::SequenceEnd => { |
| 287 | let Some(Frame::Seq(items)) = stack.pop() else { |
| 288 | return refuse(PARSE_REFUSAL); |
| 289 | }; |
| 290 | place(&mut stack, &mut root, Value::Seq(items))?; |
| 291 | } |
| 292 | Event::MappingEnd => { |
| 293 | let Some(Frame::Map(entries, None)) = stack.pop() else { |
| 294 | return refuse(PARSE_REFUSAL); |
| 295 | }; |
| 296 | place(&mut stack, &mut root, Value::Map(entries))?; |
| 297 | } |
| 298 | } |
| 299 | } |
| 300 | Ok(root.unwrap_or(Value::Null)) |
| 301 | } |
| 302 | |
| 303 | /// Strict JSON (no duplicate keys, finite numbers) through the same closed |
| 304 | /// model as the YAML layers. |
| 305 | fn parse_json(text: &str) -> Result<Value> { |
| 306 | serde_json::from_str::<serde::de::IgnoredAny>(text) |
| 307 | .map_err(|_| anyhow::Error::msg(PARSE_REFUSAL))?; |
| 308 | parse_yaml(text, false) |
| 309 | } |
| 310 | |
| 311 | // ───────────────────────────────────────────────────────────────────────────── |
| 312 | // Contained reads |
| 313 | // ───────────────────────────────────────────────────────────────────────────── |
| 314 | |
| 315 | /// A package directory, canonicalized once; every path below it is walked |
| 316 | /// component by component so a link anywhere inside the package refuses. |
| 317 | struct Package { |
| 318 | root: PathBuf, |
| 319 | } |
| 320 | |
| 321 | impl Package { |
| 322 | fn open(path: &Path) -> Result<Self> { |
| 323 | let root = path.canonicalize().map_err(|_| { |
| 324 | anyhow::Error::msg( |
| 325 | "Select a DSH bundle package directory (a directory containing package.json).", |
| 326 | ) |
| 327 | })?; |
| 328 | require( |
| 329 | root.is_dir(), |
| 330 | "Select a DSH bundle package directory (a directory containing package.json).", |
| 331 | )?; |
| 332 | Ok(Self { root }) |
| 333 | } |
| 334 | |
| 335 | /// Join a contained relative path, refusing escapes and links. |
| 336 | fn contained(&self, relative: &Path) -> Result<PathBuf> { |
| 337 | let mut current = self.root.clone(); |
| 338 | for component in relative.components() { |
| 339 | match component { |
| 340 | Component::CurDir => continue, |
| 341 | Component::Normal(part) => current.push(part), |
| 342 | _ => return refuse("Paths must stay inside the selected package."), |
| 343 | } |
| 344 | if let Ok(metadata) = fs::symlink_metadata(¤t) { |
| 345 | require( |
| 346 | !crate::plugins::metadata_is_link_or_reparse(&metadata), |
| 347 | "Source paths must not contain links or reparse points.", |
| 348 | )?; |
| 349 | } |
| 350 | } |
| 351 | Ok(current) |
| 352 | } |
| 353 | |
| 354 | fn relative_of<'a>(&self, path: &'a Path) -> &'a Path { |
| 355 | path.strip_prefix(&self.root).unwrap_or(path) |
| 356 | } |
| 357 | } |
| 358 | |
| 359 | /// Read a regular, singly linked file no larger than `limit`. |
| 360 | fn read_file(path: &Path, limit: usize) -> Result<Vec<u8>> { |
| 361 | let metadata = fs::symlink_metadata(path).map_err(|_| { |
| 362 | anyhow::Error::msg("File operation failed; source files must be accessible regular paths.") |
| 363 | })?; |
| 364 | require( |
| 365 | metadata.is_file() && !crate::plugins::metadata_is_link_or_reparse(&metadata), |
| 366 | "Only regular, non-linked source files are supported.", |
| 367 | )?; |
| 368 | #[cfg(unix)] |
| 369 | { |
| 370 | use std::os::unix::fs::MetadataExt as _; |
| 371 | require( |
| 372 | metadata.nlink() == 1, |
| 373 | "Only regular, non-linked source files are supported.", |
| 374 | )?; |
| 375 | } |
| 376 | require( |
| 377 | usize::try_from(metadata.len()).is_ok_and(|len| len <= limit), |
| 378 | "Source file exceeds the conversion size limit.", |
| 379 | )?; |
| 380 | let mut options = fs::OpenOptions::new(); |
| 381 | options.read(true); |
| 382 | #[cfg(unix)] |
| 383 | { |
| 384 | use std::os::unix::fs::OpenOptionsExt as _; |
| 385 | options.custom_flags(libc::O_NOFOLLOW); |
| 386 | } |
| 387 | let mut file = options.open(path).map_err(|_| { |
| 388 | anyhow::Error::msg("File operation failed; source files must be accessible regular paths.") |
| 389 | })?; |
| 390 | #[cfg(unix)] |
| 391 | { |
| 392 | use std::os::unix::fs::MetadataExt as _; |
| 393 | let opened = file.metadata()?; |
| 394 | require( |
| 395 | (opened.dev(), opened.ino()) == (metadata.dev(), metadata.ino()), |
| 396 | "Source changed during conversion.", |
| 397 | )?; |
| 398 | } |
| 399 | let mut content = Vec::new(); |
| 400 | file.by_ref() |
| 401 | .take(u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1)) |
| 402 | .read_to_end(&mut content)?; |
| 403 | require( |
| 404 | content.len() <= limit, |
| 405 | "Source file exceeds the conversion size limit.", |
| 406 | )?; |
| 407 | Ok(content) |
| 408 | } |
| 409 | |
| 410 | fn utf8(content: Vec<u8>) -> Result<String> { |
| 411 | String::from_utf8(content) |
| 412 | .map_err(|_| anyhow::Error::msg("Configuration and skill entrypoints must be UTF-8.")) |
| 413 | } |
| 414 | |
| 415 | use crate::skills::install::sha256_hex; |
| 416 | |
| 417 | /// Relative path spellings a package may use for its own files. |
| 418 | fn plain_relative(text: &str) -> bool { |
| 419 | !text.is_empty() |
| 420 | // A rooted path without a drive (`/tmp/a.yml`) is not `is_absolute` on |
| 421 | // Windows, but it is not relative to the bundle either. |
| 422 | && !text.starts_with('/') |
| 423 | && !Path::new(text).is_absolute() |
| 424 | && !Path::new(text) |
| 425 | .components() |
| 426 | .any(|c| matches!(c, Component::ParentDir)) |
| 427 | && !text |
| 428 | .chars() |
| 429 | .any(|c| c == '\\' || c == ':' || (c as u32) < 0x20) |
| 430 | } |
| 431 | |
| 432 | // ───────────────────────────────────────────────────────────────────────────── |
| 433 | // Receipts |
| 434 | // ───────────────────────────────────────────────────────────────────────────── |
| 435 | |
| 436 | /// One structured per-row or per-patch outcome. |
| 437 | #[derive(Debug, Clone, PartialEq, Eq, Serialize)] |
| 438 | pub(crate) struct DshOutcome { |
| 439 | pub(crate) row: Option<String>, |
| 440 | pub(crate) package: Option<String>, |
| 441 | pub(crate) kind: String, |
| 442 | pub(crate) outcome: String, |
| 443 | pub(crate) reason: String, |
| 444 | #[serde(skip_serializing_if = "Option::is_none")] |
| 445 | pub(crate) layer: Option<String>, |
| 446 | #[serde(skip_serializing_if = "Option::is_none")] |
| 447 | pub(crate) patch: Option<usize>, |
| 448 | } |
| 449 | |
| 450 | impl DshOutcome { |
| 451 | /// A skipped row or operation is a manual port the reviewer must see. |
| 452 | pub(crate) fn needs_manual_port(&self) -> bool { |
| 453 | self.outcome == "skipped" |
| 454 | } |
| 455 | } |
| 456 | |
| 457 | #[derive(Debug, Clone, PartialEq, Eq, Serialize)] |
| 458 | pub(crate) struct DshLayer { |
| 459 | pub(crate) path: String, |
| 460 | pub(crate) sha256: String, |
| 461 | pub(crate) bytes: usize, |
| 462 | } |
| 463 | |
| 464 | /// What a conversion produced, for preview and install receipts. |
| 465 | #[derive(Debug, Clone, Serialize)] |
| 466 | pub(crate) struct DshConversion { |
| 467 | pub(crate) plugin_name: String, |
| 468 | pub(crate) source_package: Option<String>, |
| 469 | pub(crate) source_version: Option<String>, |
| 470 | pub(crate) manifest_sha256: String, |
| 471 | pub(crate) layers: Vec<DshLayer>, |
| 472 | pub(crate) outcomes: Vec<DshOutcome>, |
| 473 | pub(crate) diagnostics: Vec<String>, |
| 474 | pub(crate) skills: Vec<String>, |
| 475 | pub(crate) remote_servers: Vec<String>, |
| 476 | pub(crate) local_servers: Vec<String>, |
| 477 | pub(crate) network_hosts: Vec<String>, |
| 478 | pub(crate) requires_node: bool, |
| 479 | /// Ordinary reviewed Native entry; remains experimental and disabled at install. |
| 480 | pub(crate) requires_native: bool, |
| 481 | pub(crate) native_rows: Vec<String>, |
| 482 | } |
| 483 | |
| 484 | // ───────────────────────────────────────────────────────────────────────────── |
| 485 | // Bundle loading and patch evaluation |
| 486 | // ───────────────────────────────────────────────────────────────────────────── |
| 487 | |
| 488 | struct LoadedBundle { |
| 489 | composition: Json, |
| 490 | manifest: Value, |
| 491 | manifest_sha256: String, |
| 492 | entries: Vec<Value>, |
| 493 | notes: Vec<String>, |
| 494 | layers: Vec<DshLayer>, |
| 495 | outcomes: Vec<DshOutcome>, |
| 496 | } |
| 497 | |
| 498 | fn load_bundle(package: &Package) -> Result<LoadedBundle> { |
| 499 | let manifest_bytes = read_file(&package.contained(Path::new("package.json"))?, MAX_DOCUMENT) |
| 500 | .map_err(|_| { |
| 501 | anyhow::Error::msg("Not a DSH bundle package: package.json is missing or unreadable.") |
| 502 | })?; |
| 503 | let manifest_sha256 = sha256_hex(&manifest_bytes); |
| 504 | let manifest = parse_json(&utf8(manifest_bytes)?)?; |
| 505 | mapping(&manifest, None)?; |
| 506 | let bundle = manifest.get("dsh").and_then(|dsh| dsh.get("bundle")); |
| 507 | require( |
| 508 | matches!(bundle, Some(Value::Map(_))), |
| 509 | "Not a DSH bundle package: package.json lacks `dsh.bundle.patch`.", |
| 510 | )?; |
| 511 | let mut notes = Vec::new(); |
| 512 | if manifest |
| 513 | .get("dsh") |
| 514 | .and_then(|dsh| dsh.get("client")) |
| 515 | .is_some_and(|client| !matches!(client, Value::Null)) |
| 516 | { |
| 517 | notes.push( |
| 518 | "package declares `dsh.client`; the client UI half has no Codewhale equivalent and was not converted" |
| 519 | .to_string(), |
| 520 | ); |
| 521 | } |
| 522 | let declared: Vec<&Value> = match bundle.and_then(|bundle| bundle.get("patch")) { |
| 523 | Some(single @ Value::Str(_)) => vec![single], |
| 524 | Some(Value::Seq(items)) if !items.is_empty() => items.iter().collect(), |
| 525 | _ => { |
| 526 | return refuse( |
| 527 | "`dsh.bundle.patch` must name a patch file or a non-empty ordered list of patch files.", |
| 528 | ); |
| 529 | } |
| 530 | }; |
| 531 | require( |
| 532 | declared.len() <= MAX_PATCH_FILES, |
| 533 | "At most 64 `dsh.bundle.patch` files are supported.", |
| 534 | )?; |
| 535 | let mut layers = Vec::new(); |
| 536 | let mut raw_layers = Vec::new(); |
| 537 | let mut seen = Vec::<PathBuf>::new(); |
| 538 | let mut total = 0usize; |
| 539 | for entry in declared { |
| 540 | let Some(relative) = entry.str().filter(|text| !text.is_empty()) else { |
| 541 | return refuse("Every `dsh.bundle.patch` entry must be a non-empty relative path."); |
| 542 | }; |
| 543 | require( |
| 544 | plain_relative(relative), |
| 545 | "Every `dsh.bundle.patch` entry must be a relative path inside the bundle directory.", |
| 546 | )?; |
| 547 | let path = package.contained(Path::new(relative))?; |
| 548 | require( |
| 549 | path.is_file(), |
| 550 | "Every `dsh.bundle.patch` entry must resolve to a file inside the bundle directory.", |
| 551 | )?; |
| 552 | require( |
| 553 | !seen.contains(&path), |
| 554 | "Each `dsh.bundle.patch` file may be listed once; a duplicate would apply its layer twice.", |
| 555 | )?; |
| 556 | seen.push(path.clone()); |
| 557 | require( |
| 558 | total < MAX_DOCUMENT, |
| 559 | "Selected patch files exceed the 1 MiB aggregate patch limit.", |
| 560 | )?; |
| 561 | let content = read_file(&path, MAX_DOCUMENT - total)?; |
| 562 | total += content.len(); |
| 563 | let sha256 = sha256_hex(&content); |
| 564 | let bytes = content.len(); |
| 565 | let source = utf8(content)?; |
| 566 | raw_layers.push(json!({"path": relative_slash(&path, &package.root), "sha256": sha256, "source": source})); |
| 567 | layers.push(DshLayer { |
| 568 | path: relative.to_string(), |
| 569 | sha256, |
| 570 | bytes, |
| 571 | }); |
| 572 | } |
| 573 | let composition = json!({"version":1,"layers":raw_layers,"modules":[],"files":{}}); |
| 574 | let reviewed = crate::extension_host::composition_review::review(&composition) |
| 575 | .map_err(anyhow::Error::msg)?; |
| 576 | let entries = reviewed |
| 577 | .get("entries") |
| 578 | .and_then(Json::as_array) |
| 579 | .ok_or_else(|| anyhow::Error::msg("composition reviewer omitted its effective entries"))? |
| 580 | .iter() |
| 581 | .map(reviewed_value) |
| 582 | .collect::<Result<Vec<_>>>()?; |
| 583 | // Upstream warnings contain row identities only, never configuration values. |
| 584 | notes.extend( |
| 585 | reviewed |
| 586 | .get("warnings") |
| 587 | .and_then(Json::as_array) |
| 588 | .into_iter() |
| 589 | .flatten() |
| 590 | .filter_map(Json::as_str) |
| 591 | .map(str::to_string), |
| 592 | ); |
| 593 | let outcomes = reviewed |
| 594 | .get("skipped") |
| 595 | .and_then(Json::as_array) |
| 596 | .into_iter() |
| 597 | .flatten() |
| 598 | .map(|row| { |
| 599 | let review_path = row |
| 600 | .get("layer") |
| 601 | .and_then(Json::as_str) |
| 602 | .ok_or_else(|| anyhow::Error::msg("reviewer omitted patch layer"))?; |
| 603 | let layer = layers |
| 604 | .iter() |
| 605 | .find(|layer| layer.path.trim_start_matches("./") == review_path) |
| 606 | .ok_or_else(|| anyhow::Error::msg("reviewer returned unknown patch layer"))?; |
| 607 | Ok(DshOutcome { |
| 608 | row: row.get("row").and_then(Json::as_str).map(str::to_string), |
| 609 | package: row |
| 610 | .get("package") |
| 611 | .and_then(Json::as_str) |
| 612 | .map(str::to_string), |
| 613 | kind: "patch".into(), |
| 614 | outcome: "skipped".into(), |
| 615 | reason: row |
| 616 | .get("reason") |
| 617 | .and_then(Json::as_str) |
| 618 | .ok_or_else(|| anyhow::Error::msg("reviewer omitted patch reason"))? |
| 619 | .into(), |
| 620 | layer: Some(layer.path.clone()), |
| 621 | patch: Some(usize::try_from( |
| 622 | row.get("patch") |
| 623 | .and_then(Json::as_u64) |
| 624 | .filter(|n| *n > 0) |
| 625 | .ok_or_else(|| anyhow::Error::msg("reviewer omitted patch number"))?, |
| 626 | )?), |
| 627 | }) |
| 628 | }) |
| 629 | .collect::<Result<Vec<_>>>()?; |
| 630 | Ok(LoadedBundle { |
| 631 | composition, |
| 632 | manifest, |
| 633 | manifest_sha256, |
| 634 | entries, |
| 635 | notes, |
| 636 | layers, |
| 637 | outcomes, |
| 638 | }) |
| 639 | } |
| 640 | |
| 641 | /// Upstream expression markers remain expressions, including nested values. |
| 642 | fn reviewed_value(value: &Json) -> Result<Value> { |
| 643 | Ok(match value { |
| 644 | Json::Null => Value::Null, |
| 645 | Json::Bool(value) => Value::Bool(*value), |
| 646 | Json::Number(value) => { |
| 647 | if let Some(value) = value.as_i64() { |
| 648 | Value::Int(value) |
| 649 | } else { |
| 650 | Value::Float( |
| 651 | value |
| 652 | .as_f64() |
| 653 | .ok_or_else(|| anyhow::Error::msg("nonfinite reviewed value"))?, |
| 654 | ) |
| 655 | } |
| 656 | } |
| 657 | Json::String(value) => Value::Str(value.clone()), |
| 658 | Json::Array(values) => { |
| 659 | Value::Seq(values.iter().map(reviewed_value).collect::<Result<_>>()?) |
| 660 | } |
| 661 | Json::Object(values) |
| 662 | if values.len() == 1 && values.get("__jsExpr").is_some_and(Json::is_string) => |
| 663 | { |
| 664 | Value::Js( |
| 665 | values["__jsExpr"] |
| 666 | .as_str() |
| 667 | .expect("checked marker") |
| 668 | .to_string(), |
| 669 | ) |
| 670 | } |
| 671 | Json::Object(values) => Value::Map( |
| 672 | values |
| 673 | .iter() |
| 674 | .map(|(key, value)| Ok((key.clone(), reviewed_value(value)?))) |
| 675 | .collect::<Result<_>>()?, |
| 676 | ), |
| 677 | }) |
| 678 | } |
| 679 | |
| 680 | /// Rows are addressed by index path: `[i]` is a top-level entry, `[i, j]` the |
| 681 | /// `j`th child in entry `i`'s group `config`. |
| 682 | #[cfg(test)] |
| 683 | fn row_at<'a>(entries: &'a mut [Value], path: &[usize]) -> &'a mut Value { |
| 684 | let (first, rest) = path.split_first().expect("non-empty row path"); |
| 685 | let mut row = &mut entries[*first]; |
| 686 | for index in rest { |
| 687 | let Some(Value::Seq(children)) = row.get_mut("config") else { |
| 688 | unreachable!("row paths only descend through group configs"); |
| 689 | }; |
| 690 | row = &mut children[*index]; |
| 691 | } |
| 692 | row |
| 693 | } |
| 694 | |
| 695 | /// `applyEntryPatches` parity over an empty entry list: `insert` appends rows |
| 696 | /// (or appends into a group entry's config); keyed patches replace fields on |
| 697 | /// an earlier inserted row. Skipped patches are recorded with their layer. |
| 698 | #[cfg(test)] |
| 699 | fn evaluate_patches( |
| 700 | patches: Vec<Value>, |
| 701 | notes: &mut Vec<String>, |
| 702 | locations: &[(String, usize)], |
| 703 | ) -> Result<(Vec<Value>, Vec<DshOutcome>)> { |
| 704 | fn index_rows( |
| 705 | rows: &[Value], |
| 706 | base: &[usize], |
| 707 | offset: usize, |
| 708 | index: &mut BTreeMap<String, Vec<usize>>, |
| 709 | ) { |
| 710 | for (position, row) in rows.iter().enumerate() { |
| 711 | let mut path = base.to_vec(); |
| 712 | path.push(offset + position); |
| 713 | if let Some(identifier) = row.get("id").and_then(Value::str) { |
| 714 | index.insert(identifier.to_string(), path.clone()); |
| 715 | } |
| 716 | if (row.get("group").is_some_and(Value::is_true) |
| 717 | || row.get("name").and_then(Value::str).is_some_and(|name| { |
| 718 | matches!(name, "cordis:group" | "@deepseek-ai/cordis-plugin-group") |
| 719 | })) |
| 720 | && let Some(Value::Seq(children)) = row.get("config") |
| 721 | { |
| 722 | index_rows(children, &path, 0, index); |
| 723 | } |
| 724 | } |
| 725 | } |
| 726 | |
| 727 | let mut entries: Vec<Value> = Vec::new(); |
| 728 | let mut index: BTreeMap<String, Vec<usize>> = BTreeMap::new(); |
| 729 | let mut outcomes = Vec::new(); |
| 730 | for (order, patch) in patches.into_iter().enumerate() { |
| 731 | require( |
| 732 | matches!(patch, Value::Map(_)), |
| 733 | "Each DSH patch must be an object.", |
| 734 | )?; |
| 735 | let mut skip = |reason: String| { |
| 736 | notes.push(format!("patch {}: {reason}; skipped", order + 1)); |
| 737 | let (layer, number) = locations[order].clone(); |
| 738 | outcomes.push(DshOutcome { |
| 739 | row: patch.get("id").and_then(Value::str).map(str::to_string), |
| 740 | package: patch.get("name").and_then(Value::str).map(str::to_string), |
| 741 | kind: "patch".to_string(), |
| 742 | outcome: "skipped".to_string(), |
| 743 | reason, |
| 744 | layer: Some(layer), |
| 745 | patch: Some(number), |
| 746 | }); |
| 747 | }; |
| 748 | let identifier = patch.get("id").cloned(); |
| 749 | if let Some(insert) = patch.get("insert") { |
| 750 | let Value::Seq(rows) = insert else { |
| 751 | return refuse("A DSH patch `insert` must be a list of entries."); |
| 752 | }; |
| 753 | require( |
| 754 | rows.iter().all(|row| matches!(row, Value::Map(_))), |
| 755 | "A DSH patch `insert` must be a list of entries.", |
| 756 | )?; |
| 757 | let rows = rows.clone(); |
| 758 | match identifier { |
| 759 | None | Some(Value::Null) => { |
| 760 | let offset = entries.len(); |
| 761 | entries.extend(rows.iter().cloned()); |
| 762 | index_rows(&rows, &[], offset, &mut index); |
| 763 | } |
| 764 | Some(identifier) => { |
| 765 | let target = identifier.str().and_then(|id| index.get(id)).cloned(); |
| 766 | let Some(path) = target.filter(|path| { |
| 767 | row_at(&mut entries, path) |
| 768 | .get("group") |
| 769 | .is_some_and(Value::is_true) |
| 770 | }) else { |
| 771 | let shown = identifier.str().unwrap_or("?").to_string(); |
| 772 | skip(format!("insert target `{shown}` is missing or not a group")); |
| 773 | continue; |
| 774 | }; |
| 775 | let row = row_at(&mut entries, &path); |
| 776 | if !matches!(row.get("config"), Some(Value::Seq(_))) { |
| 777 | row.set("config", Value::Seq(Vec::new())); |
| 778 | } |
| 779 | let Some(Value::Seq(children)) = row.get_mut("config") else { |
| 780 | unreachable!("config was just set to a list"); |
| 781 | }; |
| 782 | let offset = children.len(); |
| 783 | children.extend(rows.iter().cloned()); |
| 784 | index_rows(&rows, &path, offset, &mut index); |
| 785 | } |
| 786 | } |
| 787 | continue; |
| 788 | } |
| 789 | let Some(identifier) = identifier.as_ref().and_then(Value::str).map(str::to_string) else { |
| 790 | skip("non-insert patch without an `id`".to_string()); |
| 791 | continue; |
| 792 | }; |
| 793 | let Some(path) = index.get(&identifier).cloned() else { |
| 794 | skip(format!( |
| 795 | "entry `{identifier}` was not inserted by an earlier layer" |
| 796 | )); |
| 797 | continue; |
| 798 | }; |
| 799 | let target = row_at(&mut entries, &path); |
| 800 | if let Some(name) = patch.get("name") |
| 801 | && !matches!(name, Value::Null) |
| 802 | && Some(name) != target.get("name") |
| 803 | { |
| 804 | skip(format!("`name` does not match entry `{identifier}`")); |
| 805 | continue; |
| 806 | } |
| 807 | let Value::Map(fields) = &patch else { |
| 808 | unreachable!() |
| 809 | }; |
| 810 | let replaces_children = fields |
| 811 | .iter() |
| 812 | .any(|(key, _)| key == "config" || key == "group"); |
| 813 | for (key, value) in fields { |
| 814 | if key != "id" && key != "name" { |
| 815 | target.set(key, value.clone()); |
| 816 | } |
| 817 | } |
| 818 | if replaces_children { |
| 819 | // Rows under a replaced config are detached from the profile, as |
| 820 | // their dict identities are in DSH; later patches cannot reach them. |
| 821 | index.retain(|_, row_path| { |
| 822 | !(row_path.len() > path.len() && row_path.starts_with(&path)) |
| 823 | }); |
| 824 | } |
| 825 | } |
| 826 | Ok((entries, outcomes)) |
| 827 | } |
| 828 | |
| 829 | // ───────────────────────────────────────────────────────────────────────────── |
| 830 | // MCP conversion |
| 831 | // ───────────────────────────────────────────────────────────────────────────── |
| 832 | |
| 833 | fn js_literal(text: &str, label: &str) -> Result<String> { |
| 834 | let text = text.trim(); |
| 835 | let chars: Vec<char> = text.chars().collect(); |
| 836 | if chars.len() >= 2 |
| 837 | && (chars[0] == '"' || chars[0] == '\'') |
| 838 | && chars[chars.len() - 1] == chars[0] |
| 839 | && !chars[1..chars.len() - 1].contains(&chars[0]) |
| 840 | { |
| 841 | let body: String = chars[1..chars.len() - 1].iter().collect(); |
| 842 | require( |
| 843 | !body.contains('\\') && !body.contains('\n'), |
| 844 | &format!("{label} contains JavaScript escapes; author the literal explicitly."), |
| 845 | )?; |
| 846 | return Ok(body); |
| 847 | } |
| 848 | if chars.len() >= 2 && chars[0] == '`' && chars[chars.len() - 1] == '`' { |
| 849 | let body: String = chars[1..chars.len() - 1].iter().collect(); |
| 850 | require( |
| 851 | !body.contains("${") && !body.contains('\\') && !body.contains('`'), |
| 852 | &format!( |
| 853 | "{label} interpolates a value that conversion never evaluates; author the literal explicitly." |
| 854 | ), |
| 855 | )?; |
| 856 | return Ok(body); |
| 857 | } |
| 858 | refuse(format!( |
| 859 | "{label} is not a quoted or template literal; author the value explicitly." |
| 860 | )) |
| 861 | } |
| 862 | |
| 863 | /// Lower only the `!!js` idioms that need no ambient state. |
| 864 | fn lower_js(text: &str, label: &str) -> Result<String> { |
| 865 | let text = text.trim(); |
| 866 | if text == "process.execPath" { |
| 867 | return Ok("node".to_string()); |
| 868 | } |
| 869 | if text.contains("process.env") { |
| 870 | return refuse(format!( |
| 871 | "{label} reads an environment value, and conversion never evaluates this machine's environment; author the literal explicitly" |
| 872 | )); |
| 873 | } |
| 874 | if text.starts_with('`') || text.starts_with('"') || text.starts_with('\'') { |
| 875 | return js_literal(text, label); |
| 876 | } |
| 877 | refuse(format!( |
| 878 | "{label} uses a `!!js` expression with no portable lowering; author the value explicitly." |
| 879 | )) |
| 880 | } |
| 881 | |
| 882 | fn timeout_seconds(value: &Value) -> Result<i64> { |
| 883 | match value { |
| 884 | Value::Int(ms) if (1000..=3_600_000).contains(ms) && ms % 1000 == 0 => Ok(ms / 1000), |
| 885 | _ => refuse( |
| 886 | "Timeouts must be whole seconds expressed in milliseconds (1000–3600000); port other values manually.", |
| 887 | ), |
| 888 | } |
| 889 | } |
| 890 | |
| 891 | fn server_options(config: &Value) -> Result<serde_json::Map<String, Json>> { |
| 892 | let mut extension = serde_json::Map::new(); |
| 893 | require( |
| 894 | config |
| 895 | .get("failOnStartupError") |
| 896 | .is_none_or(|v| *v == Value::Bool(false)), |
| 897 | "DSH startup-failure policy requires a manual port.", |
| 898 | )?; |
| 899 | if let Some(value) = config.get("toolCallTimeoutMs") { |
| 900 | extension.insert( |
| 901 | "execute_timeout".to_string(), |
| 902 | json!(timeout_seconds(value)?), |
| 903 | ); |
| 904 | } |
| 905 | Ok(extension) |
| 906 | } |
| 907 | |
| 908 | /// Parse a literal HTTP(S) endpoint the way the native reviewer will see it, |
| 909 | /// returning the host as it enters the capability set. |
| 910 | fn endpoint_host(url: &str) -> Result<String> { |
| 911 | require( |
| 912 | !url.chars() |
| 913 | .any(|c| c.is_whitespace() || c == '\\' || c == '{' || c == '}'), |
| 914 | "MCP URL must be a literal endpoint without interpolation.", |
| 915 | )?; |
| 916 | require( |
| 917 | url.is_ascii() && url.len() <= 4096, |
| 918 | "Use an ASCII MCP hostname and URL of at most 4096 characters.", |
| 919 | )?; |
| 920 | let (scheme, rest) = url |
| 921 | .split_once("://") |
| 922 | .ok_or_else(|| anyhow::Error::msg("Invalid MCP endpoint URL."))?; |
| 923 | let scheme = scheme.to_ascii_lowercase(); |
| 924 | require( |
| 925 | !rest.contains('?') && !rest.contains('#'), |
| 926 | "MCP URLs must not contain credentials, query strings or fragments.", |
| 927 | )?; |
| 928 | let authority = rest.split('/').next().unwrap_or_default(); |
| 929 | require( |
| 930 | !authority.contains('@'), |
| 931 | "MCP URLs must not contain credentials, query strings or fragments.", |
| 932 | )?; |
| 933 | let (host, port) = if let Some(bracketed) = authority.strip_prefix('[') { |
| 934 | let (host, tail) = bracketed |
| 935 | .split_once(']') |
| 936 | .ok_or_else(|| anyhow::Error::msg("MCP URL needs a valid host and port."))?; |
| 937 | (host.to_string(), tail.strip_prefix(':')) |
| 938 | } else { |
| 939 | match authority.rsplit_once(':') { |
| 940 | Some((host, port)) => (host.to_string(), Some(port)), |
| 941 | None => (authority.to_string(), None), |
| 942 | } |
| 943 | }; |
| 944 | let host = host.to_ascii_lowercase(); |
| 945 | require(!host.is_empty(), "MCP URL needs a valid host and port.")?; |
| 946 | if let Some(port) = port { |
| 947 | require( |
| 948 | port.parse::<u16>().is_ok_and(|port| port != 0), |
| 949 | "MCP URL needs a valid host and port.", |
| 950 | )?; |
| 951 | } |
| 952 | let loopback = matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1"); |
| 953 | require( |
| 954 | scheme == "https" || (scheme == "http" && loopback), |
| 955 | "MCP endpoints need HTTPS (or explicit loopback HTTP).", |
| 956 | )?; |
| 957 | let last_label = host.rsplit('.').next().unwrap_or_default(); |
| 958 | let numeric = host.contains(':') || pattern(r"^(?:[0-9]+|0x[0-9a-f]+)$").is_match(last_label); |
| 959 | if numeric { |
| 960 | let address: std::net::IpAddr = host |
| 961 | .parse() |
| 962 | .map_err(|_| anyhow::Error::msg("Use a canonical numeric MCP address."))?; |
| 963 | require( |
| 964 | address.to_string() == host, |
| 965 | "Use a canonical numeric MCP address.", |
| 966 | )?; |
| 967 | if address.is_ipv6() { |
| 968 | return Ok(format!("[{host}]")); |
| 969 | } |
| 970 | } |
| 971 | Ok(host) |
| 972 | } |
| 973 | |
| 974 | fn remote_server(config: &Value) -> Result<(Json, String)> { |
| 975 | mapping(config, None)?; |
| 976 | require( |
| 977 | config.get("transport").and_then(Value::str) == Some("streamable-http"), |
| 978 | "Unsupported MCP transport.", |
| 979 | )?; |
| 980 | mapping( |
| 981 | config, |
| 982 | Some(&[ |
| 983 | "serverName", |
| 984 | "transport", |
| 985 | "url", |
| 986 | "headers", |
| 987 | "toolCallTimeoutMs", |
| 988 | "failOnStartupError", |
| 989 | ]), |
| 990 | )?; |
| 991 | let extension = server_options(config)?; |
| 992 | let Some(url) = config.get("url").and_then(Value::str) else { |
| 993 | return refuse("MCP URL must be a literal endpoint without interpolation."); |
| 994 | }; |
| 995 | let host = endpoint_host(url)?; |
| 996 | if let Some(headers) = config.get("headers") { |
| 997 | require( |
| 998 | mapping(headers, None)?.is_empty(), |
| 999 | "Literal headers, DSH expressions and file interpolation cannot be converted; author native env_headers manually.", |
| 1000 | )?; |
| 1001 | } |
| 1002 | Ok(( |
| 1003 | json!({"type": "streamable-http", "url": url, "extensions": {"net.codewhale": extension}}), |
| 1004 | host, |
| 1005 | )) |
| 1006 | } |
| 1007 | |
| 1008 | fn stdio_server(config: &Value, name: &str, root: &Path) -> Result<Json> { |
| 1009 | mapping( |
| 1010 | config, |
| 1011 | Some(&[ |
| 1012 | "serverName", |
| 1013 | "transport", |
| 1014 | "command", |
| 1015 | "args", |
| 1016 | "env", |
| 1017 | "cwd", |
| 1018 | "toolCallTimeoutMs", |
| 1019 | "failOnStartupError", |
| 1020 | ]), |
| 1021 | )?; |
| 1022 | if let Some(env) = config.get("env") { |
| 1023 | require( |
| 1024 | mapping(env, None)?.is_empty(), |
| 1025 | "DSH stdio env values and expressions require a manual native port.", |
| 1026 | )?; |
| 1027 | } |
| 1028 | let entry = match ( |
| 1029 | config.get("command").and_then(Value::str), |
| 1030 | config.get("args"), |
| 1031 | ) { |
| 1032 | (Some("node"), Some(Value::Seq(args))) if args.len() == 1 => args[0].str(), |
| 1033 | _ => None, |
| 1034 | }; |
| 1035 | let Some(entry) = entry else { |
| 1036 | return refuse( |
| 1037 | "Only node with one packaged .mjs, .js or .cjs entry is supported; no launcher flags, package managers or shell commands.", |
| 1038 | ); |
| 1039 | }; |
| 1040 | require( |
| 1041 | pattern(r"^(?:\./)?[A-Za-z0-9_][A-Za-z0-9_./-]*\.(?:mjs|js|cjs)$").is_match(entry) |
| 1042 | && !entry.split('/').any(|part| part == ".."), |
| 1043 | "Node entry must be a contained relative .mjs, .js or .cjs file; compile other entry formats before packaging.", |
| 1044 | )?; |
| 1045 | require( |
| 1046 | config |
| 1047 | .get("cwd") |
| 1048 | .is_none_or(|cwd| matches!(cwd.str(), Some("" | "."))), |
| 1049 | "Other cwd values require a manual port.", |
| 1050 | )?; |
| 1051 | require( |
| 1052 | root.join(entry).is_file(), |
| 1053 | "Packaged Node entry does not exist.", |
| 1054 | )?; |
| 1055 | Ok(json!({ |
| 1056 | "type": "stdio", |
| 1057 | "command": "node", |
| 1058 | "args": [entry], |
| 1059 | "cwd": format!("mcp/{name}"), |
| 1060 | "env": {}, |
| 1061 | "extensions": {"net.codewhale": server_options(config)?}, |
| 1062 | })) |
| 1063 | } |
| 1064 | |
| 1065 | // ───────────────────────────────────────────────────────────────────────────── |
| 1066 | // Row conversion |
| 1067 | // ───────────────────────────────────────────────────────────────────────────── |
| 1068 | |
| 1069 | #[derive(Default)] |
| 1070 | struct Components { |
| 1071 | servers: Vec<(String, Json)>, |
| 1072 | hosts: Vec<String>, |
| 1073 | /// Local server name → packaged source root inside the package. |
| 1074 | roots: Vec<(String, PathBuf)>, |
| 1075 | skill_dirs: Vec<PathBuf>, |
| 1076 | notes: Vec<String>, |
| 1077 | outcomes: Vec<DshOutcome>, |
| 1078 | } |
| 1079 | |
| 1080 | impl Components { |
| 1081 | fn record(&mut self, row: &Value, kind: &str, outcome: &str, reason: &str) { |
| 1082 | let identifier = row.get("id").and_then(Value::str).map(str::to_string); |
| 1083 | let label = identifier |
| 1084 | .as_ref() |
| 1085 | .map_or_else(|| "an unlabeled row".to_string(), |id| format!("`{id}`")); |
| 1086 | self.notes.push(format!( |
| 1087 | "{label} [{kind}] {outcome}{}", |
| 1088 | if reason.is_empty() { |
| 1089 | String::new() |
| 1090 | } else { |
| 1091 | format!(": {reason}") |
| 1092 | } |
| 1093 | )); |
| 1094 | self.outcomes.push(DshOutcome { |
| 1095 | row: identifier, |
| 1096 | package: row.get("name").and_then(Value::str).map(str::to_string), |
| 1097 | kind: kind.to_string(), |
| 1098 | outcome: outcome.to_string(), |
| 1099 | reason: reason.to_string(), |
| 1100 | layer: None, |
| 1101 | patch: None, |
| 1102 | }); |
| 1103 | } |
| 1104 | |
| 1105 | fn has_server(&self, name: &str) -> bool { |
| 1106 | self.servers.iter().any(|(existing, _)| existing == name) |
| 1107 | } |
| 1108 | |
| 1109 | fn mcp_row(&mut self, package: &Package, row: &Value, disabled: bool) -> Result<()> { |
| 1110 | let mut config = row.get("config").cloned().unwrap_or(Value::Null); |
| 1111 | mapping(&config, None)?; |
| 1112 | let name = config |
| 1113 | .get("serverName") |
| 1114 | .and_then(Value::str) |
| 1115 | .map(str::to_string); |
| 1116 | let Some(name) = |
| 1117 | name.filter(|name| pattern(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$").is_match(name)) |
| 1118 | else { |
| 1119 | return refuse( |
| 1120 | "dsh-mcp-client config needs a literal `serverName` of 1–32 letters/digits/_/-", |
| 1121 | ); |
| 1122 | }; |
| 1123 | require( |
| 1124 | !self.has_server(&name), |
| 1125 | &format!("Duplicate MCP server name `{name}`; nothing was written for it."), |
| 1126 | )?; |
| 1127 | for field in ["command", "cwd", "url", "serverName"] { |
| 1128 | if let Some(Value::Js(expression)) = config.get(field) { |
| 1129 | let lowered = lower_js(expression, &format!("`{field}` in `{name}`"))?; |
| 1130 | config.set(field, Value::Str(lowered)); |
| 1131 | } |
| 1132 | } |
| 1133 | if let Some(Value::Seq(args)) = config.get("args").cloned() { |
| 1134 | let lowered = args |
| 1135 | .into_iter() |
| 1136 | .map(|arg| match arg { |
| 1137 | Value::Js(expression) => { |
| 1138 | lower_js(&expression, &format!("`args` in `{name}`")).map(Value::Str) |
| 1139 | } |
| 1140 | other => Ok(other), |
| 1141 | }) |
| 1142 | .collect::<Result<Vec<_>>>()?; |
| 1143 | config.set("args", Value::Seq(lowered)); |
| 1144 | } |
| 1145 | let local = config.get("transport").and_then(Value::str) == Some("stdio"); |
| 1146 | let mut root = None; |
| 1147 | if local |
| 1148 | && let Some(Value::Seq(args)) = config.get("args") |
| 1149 | && let [Value::Str(arg)] = args.as_slice() |
| 1150 | { |
| 1151 | let entry = pattern(r"^(?:\./)?[A-Za-z0-9_][A-Za-z0-9_./-]*\.(?:mjs|js|cjs)$"); |
| 1152 | if !entry.is_match(arg) { |
| 1153 | return refuse(format!( |
| 1154 | "`args` in `{name}` names a host path outside the selected package; import never copies an ambient path. Package the server inside the bundle, then import again" |
| 1155 | )); |
| 1156 | } |
| 1157 | if !arg.split('/').any(|part| part == "..") { |
| 1158 | let cwd = config.get("cwd").and_then(Value::str).unwrap_or(""); |
| 1159 | if matches!(cwd, "" | ".") { |
| 1160 | if package.contained(Path::new(arg))?.is_file() { |
| 1161 | root = Some(package.root.clone()); |
| 1162 | } |
| 1163 | } else if plain_relative(cwd) { |
| 1164 | let candidate = package.contained(Path::new(cwd))?; |
| 1165 | if package.contained(&Path::new(cwd).join(arg))?.is_file() { |
| 1166 | root = Some(candidate); |
| 1167 | config.set("cwd", Value::Str(".".to_string())); |
| 1168 | } |
| 1169 | } |
| 1170 | } |
| 1171 | } |
| 1172 | require( |
| 1173 | config.free_of_js(), |
| 1174 | &format!("an unevaluated `!!js` remains in `{name}`; author that field explicitly"), |
| 1175 | )?; |
| 1176 | let mut converted = if local { |
| 1177 | let Some(root) = root else { |
| 1178 | return refuse(format!( |
| 1179 | "`{name}` is a local server whose packaged Node entry is not inside the selected package" |
| 1180 | )); |
| 1181 | }; |
| 1182 | let server = stdio_server(&config, &name, &root)?; |
| 1183 | let shown = package.relative_of(&root).display().to_string(); |
| 1184 | self.notes.push(format!( |
| 1185 | "`{name}`: stdio source root resolved inside the selected package at `{}`", |
| 1186 | if shown.is_empty() { |
| 1187 | ".".to_string() |
| 1188 | } else { |
| 1189 | shown |
| 1190 | } |
| 1191 | )); |
| 1192 | self.roots.push((name.clone(), root)); |
| 1193 | server |
| 1194 | } else { |
| 1195 | let (server, host) = remote_server(&config)?; |
| 1196 | self.hosts.push(host); |
| 1197 | server |
| 1198 | }; |
| 1199 | if disabled { |
| 1200 | converted["extensions"]["net.codewhale"]["disabled"] = json!(true); |
| 1201 | } |
| 1202 | self.servers.push((name, converted)); |
| 1203 | Ok(()) |
| 1204 | } |
| 1205 | |
| 1206 | fn walk( |
| 1207 | &mut self, |
| 1208 | package: &Package, |
| 1209 | rows: &[Value], |
| 1210 | disabled_ancestor: Option<&str>, |
| 1211 | ) -> Result<()> { |
| 1212 | for row in rows { |
| 1213 | require( |
| 1214 | matches!(row, Value::Map(_)), |
| 1215 | "Each DSH group child must be an entry object.", |
| 1216 | )?; |
| 1217 | let identifier = row.get("id").and_then(Value::str); |
| 1218 | let label = |
| 1219 | identifier.map_or_else(|| "an unlabeled row".to_string(), |id| format!("`{id}`")); |
| 1220 | let name = row.get("name").and_then(Value::str); |
| 1221 | let group = row.get("group").is_some_and(Value::is_true); |
| 1222 | let convertible = matches!(name, Some(DSH_MCP_CLIENT | DSH_SKILL_FILESYSTEM)); |
| 1223 | if group || convertible { |
| 1224 | require( |
| 1225 | row.keys() |
| 1226 | .iter() |
| 1227 | .all(|key| matches!(*key, "id" | "name" | "config" | "group" | "disabled")), |
| 1228 | &format!( |
| 1229 | "{label} has unsupported entry policy or dependency fields; port its activation and authority semantics manually before conversion." |
| 1230 | ), |
| 1231 | )?; |
| 1232 | require( |
| 1233 | row.get("group") |
| 1234 | .is_none_or(|flag| matches!(flag, Value::Bool(_))), |
| 1235 | &format!("{label} has a non-boolean group flag; resolve it explicitly."), |
| 1236 | )?; |
| 1237 | } |
| 1238 | if group { |
| 1239 | require( |
| 1240 | matches!(row.get("config"), Some(Value::Seq(_))), |
| 1241 | &format!("{label} needs a list of group entries."), |
| 1242 | )?; |
| 1243 | } |
| 1244 | let disabled = match row.get("disabled") { |
| 1245 | None => false, |
| 1246 | Some(Value::Bool(flag)) => *flag, |
| 1247 | Some(_) => { |
| 1248 | if group || convertible { |
| 1249 | return refuse(format!( |
| 1250 | "{label} gates activation with a conditional or non-boolean `disabled` value that cannot be resolved offline; import refuses to assume the row is enabled. Pre-resolve the gate in a reviewed copy of the patch layer, then import that copy." |
| 1251 | )); |
| 1252 | } |
| 1253 | self.record( |
| 1254 | row, |
| 1255 | "foreign", |
| 1256 | "skipped", |
| 1257 | "no portable representation; its conditional `disabled` gate was not evaluated", |
| 1258 | ); |
| 1259 | continue; |
| 1260 | } |
| 1261 | }; |
| 1262 | if group { |
| 1263 | let Some(Value::Seq(children)) = row.get("config") else { |
| 1264 | unreachable!() |
| 1265 | }; |
| 1266 | let inherited = match disabled_ancestor { |
| 1267 | Some(ancestor) => Some(ancestor.to_string()), |
| 1268 | None if disabled => Some(label.clone()), |
| 1269 | None => None, |
| 1270 | }; |
| 1271 | self.walk(package, children, inherited.as_deref())?; |
| 1272 | continue; |
| 1273 | } |
| 1274 | let reason = if disabled { |
| 1275 | "the source row sets `disabled: true`".to_string() |
| 1276 | } else if let Some(ancestor) = disabled_ancestor { |
| 1277 | format!("the row is disabled by ancestor {ancestor}") |
| 1278 | } else { |
| 1279 | String::new() |
| 1280 | }; |
| 1281 | let effective = disabled || disabled_ancestor.is_some(); |
| 1282 | match name { |
| 1283 | Some(DSH_MCP_CLIENT) => { |
| 1284 | if let Some(server) = row |
| 1285 | .get("config") |
| 1286 | .and_then(|c| c.get("serverName")) |
| 1287 | .and_then(Value::str) |
| 1288 | { |
| 1289 | require( |
| 1290 | !self.has_server(server), |
| 1291 | "Duplicate MCP server name; no entries were written.", |
| 1292 | )?; |
| 1293 | } |
| 1294 | match self.mcp_row(package, row, effective) { |
| 1295 | Ok(()) => self.record( |
| 1296 | row, |
| 1297 | "mcp", |
| 1298 | if effective { |
| 1299 | "converted-disabled" |
| 1300 | } else { |
| 1301 | "converted" |
| 1302 | }, |
| 1303 | &reason, |
| 1304 | ), |
| 1305 | Err(error) => self.record(row, "mcp", "skipped", &error.to_string()), |
| 1306 | } |
| 1307 | } |
| 1308 | Some(DSH_SKILL_FILESYSTEM) => { |
| 1309 | if effective { |
| 1310 | self.record( |
| 1311 | row, |
| 1312 | "skill", |
| 1313 | "skipped-disabled", |
| 1314 | &format!( |
| 1315 | "{reason}; native skills have no disabled state, so the intent is preserved by omission" |
| 1316 | ), |
| 1317 | ); |
| 1318 | continue; |
| 1319 | } |
| 1320 | let dirs = match row.get("config").and_then(|c| c.get("customSkillDirs")) { |
| 1321 | Some(Value::Seq(dirs)) if !dirs.is_empty() => dirs.clone(), |
| 1322 | _ => { |
| 1323 | self.record( |
| 1324 | row, |
| 1325 | "skill", |
| 1326 | "skipped", |
| 1327 | "skill row has no `customSkillDirs` to import", |
| 1328 | ); |
| 1329 | continue; |
| 1330 | } |
| 1331 | }; |
| 1332 | let mut imported = 0; |
| 1333 | for entry in dirs { |
| 1334 | let Value::Str(entry) = entry else { |
| 1335 | self.notes.push(format!( |
| 1336 | "{label}: a `customSkillDirs` entry is not a literal path; skipped" |
| 1337 | )); |
| 1338 | continue; |
| 1339 | }; |
| 1340 | if !plain_relative(&entry) { |
| 1341 | self.notes.push(format!( |
| 1342 | "{label}: `customSkillDirs` entry `{entry}` is outside the bundle; skipped" |
| 1343 | )); |
| 1344 | continue; |
| 1345 | } |
| 1346 | let resolved = package.contained(Path::new(&entry))?; |
| 1347 | if !resolved.is_dir() { |
| 1348 | self.notes.push(format!( |
| 1349 | "{label}: `customSkillDirs` entry `{entry}` does not exist in the bundle; skipped" |
| 1350 | )); |
| 1351 | continue; |
| 1352 | } |
| 1353 | self.skill_dirs.push(resolved); |
| 1354 | imported += 1; |
| 1355 | } |
| 1356 | if imported > 0 { |
| 1357 | self.record( |
| 1358 | row, |
| 1359 | "skill", |
| 1360 | "converted", |
| 1361 | &format!( |
| 1362 | "imported {imported} `customSkillDirs` entries inside the package" |
| 1363 | ), |
| 1364 | ); |
| 1365 | } else { |
| 1366 | self.record( |
| 1367 | row, |
| 1368 | "skill", |
| 1369 | "skipped", |
| 1370 | "no `customSkillDirs` entry inside the package could be imported", |
| 1371 | ); |
| 1372 | } |
| 1373 | } |
| 1374 | _ => { |
| 1375 | let shown = name.unwrap_or("unlabeled"); |
| 1376 | self.record( |
| 1377 | row, |
| 1378 | "foreign", |
| 1379 | "skipped", |
| 1380 | &format!( |
| 1381 | "only dsh-mcp-client and dsh-skill-filesystem rows convert; `{shown}` has no portable representation" |
| 1382 | ), |
| 1383 | ); |
| 1384 | } |
| 1385 | } |
| 1386 | } |
| 1387 | Ok(()) |
| 1388 | } |
| 1389 | } |
| 1390 | |
| 1391 | // ───────────────────────────────────────────────────────────────────────────── |
| 1392 | // Files |
| 1393 | // ───────────────────────────────────────────────────────────────────────────── |
| 1394 | |
| 1395 | #[derive(Default)] |
| 1396 | struct OutputFiles { |
| 1397 | files: BTreeMap<String, Vec<u8>>, |
| 1398 | bytes: usize, |
| 1399 | } |
| 1400 | |
| 1401 | impl OutputFiles { |
| 1402 | fn add(&mut self, relative: String, content: Vec<u8>) -> Result<()> { |
| 1403 | require( |
| 1404 | !self.files.contains_key(&relative), |
| 1405 | "Two converted files collide; nothing was written.", |
| 1406 | )?; |
| 1407 | require( |
| 1408 | self.files.len() < MAX_FILES, |
| 1409 | "Selected components exceed the file budget.", |
| 1410 | )?; |
| 1411 | self.bytes += content.len(); |
| 1412 | require( |
| 1413 | self.bytes <= MAX_BYTES, |
| 1414 | "Output exceeds the 4096-file / 64 MiB bundle budget.", |
| 1415 | )?; |
| 1416 | self.files.insert(relative, content); |
| 1417 | Ok(()) |
| 1418 | } |
| 1419 | |
| 1420 | fn remaining(&self) -> usize { |
| 1421 | MAX_BYTES.saturating_sub(self.bytes) |
| 1422 | } |
| 1423 | } |
| 1424 | |
| 1425 | /// Walk a contained directory without following links, bounded. |
| 1426 | fn walk_files(root: &Path, mut visit: impl FnMut(&Path, bool) -> Result<()>) -> Result<()> { |
| 1427 | let mut pending = vec![root.to_path_buf()]; |
| 1428 | let mut visited = 0usize; |
| 1429 | while let Some(directory) = pending.pop() { |
| 1430 | let mut children: Vec<_> = fs::read_dir(&directory)?.collect::<std::io::Result<_>>()?; |
| 1431 | children.sort_by_key(fs::DirEntry::file_name); |
| 1432 | for child in children { |
| 1433 | visited += 1; |
| 1434 | require( |
| 1435 | visited <= MAX_FILES, |
| 1436 | "The selected source contains too many filesystem entries.", |
| 1437 | )?; |
| 1438 | let path = child.path(); |
| 1439 | let metadata = fs::symlink_metadata(&path)?; |
| 1440 | require( |
| 1441 | !crate::plugins::metadata_is_link_or_reparse(&metadata), |
| 1442 | "Source paths must not contain links or reparse points.", |
| 1443 | )?; |
| 1444 | visit(&path, metadata.is_dir())?; |
| 1445 | if metadata.is_dir() { |
| 1446 | pending.push(path); |
| 1447 | } |
| 1448 | } |
| 1449 | } |
| 1450 | Ok(()) |
| 1451 | } |
| 1452 | |
| 1453 | fn relative_slash(path: &Path, root: &Path) -> String { |
| 1454 | path.strip_prefix(root) |
| 1455 | .unwrap_or(path) |
| 1456 | .components() |
| 1457 | .map(|c| c.as_os_str().to_string_lossy().into_owned()) |
| 1458 | .collect::<Vec<_>>() |
| 1459 | .join("/") |
| 1460 | } |
| 1461 | |
| 1462 | /// Convert one skill directory or Markdown file into native skill files. |
| 1463 | fn skill_files(source: &Path, output: &mut OutputFiles) -> Result<String> { |
| 1464 | let is_dir = source.is_dir(); |
| 1465 | let entry = if is_dir { |
| 1466 | source.join("SKILL.md") |
| 1467 | } else { |
| 1468 | source.to_path_buf() |
| 1469 | }; |
| 1470 | require( |
| 1471 | entry.file_name().is_some_and(|name| name == "SKILL.md") |
| 1472 | || entry.extension().is_some_and(|ext| ext == "md"), |
| 1473 | "Select a skill directory or Markdown skill file.", |
| 1474 | )?; |
| 1475 | let text = utf8(read_file(&entry, MAX_DOCUMENT)?)?; |
| 1476 | let delimiter = pattern(r"(?m)^---\s*$"); |
| 1477 | let parts: Vec<&str> = delimiter.splitn(&text, 3).collect(); |
| 1478 | require( |
| 1479 | parts.len() == 3 && parts[0].trim().is_empty(), |
| 1480 | "Skills need YAML frontmatter with name and description.", |
| 1481 | )?; |
| 1482 | let meta = parse_yaml(parts[1], false)?; |
| 1483 | mapping( |
| 1484 | &meta, |
| 1485 | Some(&[ |
| 1486 | "name", |
| 1487 | "description", |
| 1488 | "license", |
| 1489 | "compatibility", |
| 1490 | "metadata", |
| 1491 | "disable-model-invocation", |
| 1492 | "user-invocable", |
| 1493 | ]), |
| 1494 | )?; |
| 1495 | let Some(name) = meta |
| 1496 | .get("name") |
| 1497 | .and_then(Value::str) |
| 1498 | .filter(|name| name.len() <= 64 && pattern(r"^[a-z0-9]+(?:-[a-z0-9]+)*$").is_match(name)) |
| 1499 | else { |
| 1500 | return refuse("Skill name must be a kebab-case identifier of at most 64 characters."); |
| 1501 | }; |
| 1502 | let Some(description) = meta |
| 1503 | .get("description") |
| 1504 | .and_then(Value::str) |
| 1505 | .filter(|d| !d.trim().is_empty()) |
| 1506 | else { |
| 1507 | return refuse("Skills need a non-empty description."); |
| 1508 | }; |
| 1509 | require( |
| 1510 | !description.contains("---"), |
| 1511 | "Skill description contains a delimiter the native reader cannot preserve.", |
| 1512 | )?; |
| 1513 | require( |
| 1514 | meta.get("user-invocable") |
| 1515 | .is_none_or(|v| *v == Value::Bool(true)), |
| 1516 | "user-invocable:false has no equivalent in the native skill adapter; port it manually.", |
| 1517 | )?; |
| 1518 | let explicit = match meta.get("disable-model-invocation") { |
| 1519 | None => false, |
| 1520 | Some(Value::Bool(flag)) => *flag, |
| 1521 | Some(_) => return refuse("disable-model-invocation must be a boolean."), |
| 1522 | }; |
| 1523 | let mut front = format!("---\nname: {name}\ndescription: |-\n"); |
| 1524 | front.push_str( |
| 1525 | &description |
| 1526 | .lines() |
| 1527 | .map(|line| format!(" {line}")) |
| 1528 | .collect::<Vec<_>>() |
| 1529 | .join("\n"), |
| 1530 | ); |
| 1531 | front.push('\n'); |
| 1532 | if explicit { |
| 1533 | front.push_str("invocation: explicit-only\n"); |
| 1534 | } |
| 1535 | output.add( |
| 1536 | format!("skills/{name}/SKILL.md"), |
| 1537 | format!("{front}---{}", parts[2]).into_bytes(), |
| 1538 | )?; |
| 1539 | let extra: serde_json::Map<String, Json> = ["license", "compatibility", "metadata"] |
| 1540 | .into_iter() |
| 1541 | .filter_map(|key| { |
| 1542 | meta.get(key) |
| 1543 | .map(|value| (key.to_string(), value.to_json())) |
| 1544 | }) |
| 1545 | .collect(); |
| 1546 | if !extra.is_empty() { |
| 1547 | output.add( |
| 1548 | format!("skills/{name}/SOURCE_SKILL_METADATA.json"), |
| 1549 | format!("{}\n", serde_json::to_string_pretty(&extra)?).into_bytes(), |
| 1550 | )?; |
| 1551 | } |
| 1552 | if is_dir { |
| 1553 | walk_files(source, |path, is_dir| { |
| 1554 | let file_name = path.file_name().unwrap_or_default().to_string_lossy(); |
| 1555 | require( |
| 1556 | !matches!(file_name.as_ref(), ".git" | ".env" | ".installed-from"), |
| 1557 | "Remove local secrets, repository metadata or install receipts from the selected skill.", |
| 1558 | )?; |
| 1559 | if is_dir || path == entry { |
| 1560 | return Ok(()); |
| 1561 | } |
| 1562 | let content = read_file(path, output.remaining())?; |
| 1563 | output.add( |
| 1564 | format!("skills/{name}/{}", relative_slash(path, source)), |
| 1565 | content, |
| 1566 | ) |
| 1567 | })?; |
| 1568 | } |
| 1569 | Ok(name.to_string()) |
| 1570 | } |
| 1571 | |
| 1572 | /// Copy a packaged MCP source directory as data; never resolve dependencies. |
| 1573 | fn stdio_files(root: &Path, name: &str, output: &mut OutputFiles) -> Result<()> { |
| 1574 | walk_files(root, |path, is_dir| { |
| 1575 | let lower = path |
| 1576 | .file_name() |
| 1577 | .unwrap_or_default() |
| 1578 | .to_string_lossy() |
| 1579 | .to_ascii_lowercase(); |
| 1580 | let suffix = path |
| 1581 | .extension() |
| 1582 | .map(|ext| ext.to_string_lossy().to_ascii_lowercase()); |
| 1583 | require( |
| 1584 | !lower.starts_with('.') |
| 1585 | && !matches!( |
| 1586 | lower.as_str(), |
| 1587 | "credentials" | "credentials.json" | "secrets.json" | "id_rsa" | "id_ed25519" |
| 1588 | ) |
| 1589 | && !matches!(suffix.as_deref(), Some("pem" | "key" | "p12" | "pfx")), |
| 1590 | "Package MCP source without hidden files, repository metadata or credential files; nothing was copied.", |
| 1591 | )?; |
| 1592 | if is_dir { |
| 1593 | return Ok(()); |
| 1594 | } |
| 1595 | let content = read_file(path, output.remaining())?; |
| 1596 | output.add( |
| 1597 | format!("mcp/{name}/{}", relative_slash(path, root)), |
| 1598 | content, |
| 1599 | ) |
| 1600 | }) |
| 1601 | } |
| 1602 | |
| 1603 | /// A native plugin name from the package name: its last path segment, |
| 1604 | /// lowercased, with other characters replaced by single hyphens. |
| 1605 | fn host_hook_bridge(name: &str) -> bool { |
| 1606 | matches!( |
| 1607 | name, |
| 1608 | "@deepseek-ai/dsh-hooks-claude-code" |
| 1609 | | "@deepseek-ai/dsh-hooks-codex" |
| 1610 | | "@deepseek-ai/dsh-persona" |
| 1611 | | DSH_MCP_CLIENT |
| 1612 | | DSH_SKILL_FILESYSTEM |
| 1613 | ) |
| 1614 | } |
| 1615 | |
| 1616 | /// Closed relative modules and exact contained ESM package exports. Missing |
| 1617 | /// foreign rows remain manual ports for a portable-only import; a live Native |
| 1618 | /// graph refuses unresolved rows and never uses an ambient Node parent-walk. |
| 1619 | fn native_composition_files( |
| 1620 | package: &Package, |
| 1621 | entries: &[Value], |
| 1622 | mut document: Json, |
| 1623 | output: &mut OutputFiles, |
| 1624 | ) -> Result<Vec<String>> { |
| 1625 | fn modules(rows: &[Value], names: &mut Vec<String>) -> Result<()> { |
| 1626 | for row in rows { |
| 1627 | if row.get("group").is_some_and(Value::is_true) |
| 1628 | || row.get("name").and_then(Value::str).is_some_and(|name| { |
| 1629 | matches!(name, "cordis:group" | "@deepseek-ai/cordis-plugin-group") |
| 1630 | }) |
| 1631 | { |
| 1632 | if let Some(Value::Seq(children)) = row.get("config") { |
| 1633 | modules(children, names)?; |
| 1634 | } |
| 1635 | continue; |
| 1636 | } |
| 1637 | let name = row |
| 1638 | .get("name") |
| 1639 | .and_then(Value::str) |
| 1640 | .ok_or_else(|| anyhow::Error::msg("reviewed row has no module name"))?; |
| 1641 | if !names.iter().any(|old| old == name) { |
| 1642 | names.push(name.into()); |
| 1643 | } |
| 1644 | } |
| 1645 | Ok(()) |
| 1646 | } |
| 1647 | let mut names = Vec::new(); |
| 1648 | modules(entries, &mut names)?; |
| 1649 | if names.is_empty() { |
| 1650 | return Ok(names); |
| 1651 | } |
| 1652 | // Portable-only import keeps absent foreign rows visible as manual ports. |
| 1653 | // Once one real Native closure is present, every row must be admitted: |
| 1654 | // refusing a mixed partial graph is stronger than silently pruning it. |
| 1655 | let mut paths = BTreeMap::new(); |
| 1656 | let mut unresolved = Vec::new(); |
| 1657 | for name in &names { |
| 1658 | if name == presets::AGENT_PRESETS || host_hook_bridge(name) { |
| 1659 | continue; |
| 1660 | } |
| 1661 | match presets::contained_module(package, name) { |
| 1662 | Ok(path) |
| 1663 | if crate::plugins::runtime::native_entry_problem(&path, path.is_file()) |
| 1664 | .is_none() => |
| 1665 | { |
| 1666 | paths.insert(name.clone(), path); |
| 1667 | } |
| 1668 | _ => unresolved.push(name), |
| 1669 | } |
| 1670 | } |
| 1671 | if paths.is_empty() |
| 1672 | && !names.iter().any(|name| { |
| 1673 | name == presets::AGENT_PRESETS |
| 1674 | || (host_hook_bridge(name) |
| 1675 | && !matches!(name.as_str(), DSH_MCP_CLIENT | DSH_SKILL_FILESYSTEM)) |
| 1676 | }) |
| 1677 | { |
| 1678 | return Ok(Vec::new()); |
| 1679 | } |
| 1680 | require( |
| 1681 | unresolved.is_empty(), |
| 1682 | "Native composition contains an unsupported or missing row with no exact admitted module; no partial graph was installed.", |
| 1683 | )?; |
| 1684 | let mut files = serde_json::Map::new(); |
| 1685 | walk_files(&package.root, |path, is_dir| { |
| 1686 | let lower = path |
| 1687 | .file_name() |
| 1688 | .unwrap_or_default() |
| 1689 | .to_string_lossy() |
| 1690 | .to_ascii_lowercase(); |
| 1691 | let suffix = path |
| 1692 | .extension() |
| 1693 | .map(|ext| ext.to_string_lossy().to_ascii_lowercase()); |
| 1694 | require( |
| 1695 | (!lower.starts_with('.') || lower == ".agent-presets") |
| 1696 | && !matches!( |
| 1697 | lower.as_str(), |
| 1698 | "credentials" | "credentials.json" | "secrets.json" | "id_rsa" | "id_ed25519" |
| 1699 | ) |
| 1700 | && !matches!(suffix.as_deref(), Some("pem" | "key" | "p12" | "pfx")), |
| 1701 | "Package a closed Native source without hidden files, repository metadata or credential files.", |
| 1702 | )?; |
| 1703 | if is_dir { |
| 1704 | return Ok(()); |
| 1705 | } |
| 1706 | let relative = relative_slash(path, &package.root); |
| 1707 | let bytes = read_file( |
| 1708 | &package.contained(Path::new(&relative))?, |
| 1709 | output.remaining(), |
| 1710 | )?; |
| 1711 | files.insert(relative.clone(), json!(sha256_hex(&bytes))); |
| 1712 | output.add(format!("source/{relative}"), bytes) |
| 1713 | })?; |
| 1714 | let mut selected = Vec::new(); |
| 1715 | for name in &names { |
| 1716 | if name == presets::AGENT_PRESETS || host_hook_bridge(name) { |
| 1717 | continue; |
| 1718 | } |
| 1719 | let path = paths |
| 1720 | .get(name) |
| 1721 | .ok_or_else(|| anyhow::Error::msg("Native module lost its admitted path"))?; |
| 1722 | let relative = relative_slash(path, &package.root); |
| 1723 | let digest = files |
| 1724 | .get(&relative) |
| 1725 | .ok_or_else(|| anyhow::Error::msg("Native module was absent from source closure"))?; |
| 1726 | selected.push(json!({"name":name,"path":relative,"sha256":digest})); |
| 1727 | } |
| 1728 | document["modules"] = json!(selected); |
| 1729 | document["files"] = json!(files); |
| 1730 | if presets::has_roster(entries) { |
| 1731 | presets::prepare(package, &document, output)?; |
| 1732 | return Ok(names); |
| 1733 | } |
| 1734 | output.add( |
| 1735 | "native/composition.json".into(), |
| 1736 | format!("{}\n", serde_json::to_string_pretty(&document)?).into_bytes(), |
| 1737 | )?; |
| 1738 | output.add("native/index.mjs".into(),b"import { mountReviewedComposition } from '@codewhale/dsh-composition';\nimport spec from './composition.json' with { type: 'json' };\nexport async function apply(ctx) { await mountReviewedComposition(ctx, new URL('../source/', import.meta.url).href, spec); }\n".to_vec())?; |
| 1739 | Ok(names) |
| 1740 | } |
| 1741 | |
| 1742 | pub(crate) fn derived_plugin_name(package_name: &str) -> Option<String> { |
| 1743 | let segment = package_name |
| 1744 | .rsplit('/') |
| 1745 | .next() |
| 1746 | .unwrap_or(package_name) |
| 1747 | .to_ascii_lowercase(); |
| 1748 | let mut name = String::new(); |
| 1749 | for c in segment.chars() { |
| 1750 | let c = if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' { |
| 1751 | c |
| 1752 | } else { |
| 1753 | '-' |
| 1754 | }; |
| 1755 | if c == '-' && name.ends_with('-') { |
| 1756 | continue; |
| 1757 | } |
| 1758 | name.push(c); |
| 1759 | } |
| 1760 | let name = name.trim_matches(|c| c == '-' || c == '.').to_string(); |
| 1761 | valid_plugin_name(&name).then_some(name) |
| 1762 | } |
| 1763 | |
| 1764 | fn valid_plugin_name(name: &str) -> bool { |
| 1765 | pattern(r"^[a-z0-9](?:[a-z0-9.-]{0,62}[a-z0-9])?$").is_match(name) |
| 1766 | && !name.contains("..") |
| 1767 | && !name.contains("--") |
| 1768 | } |
| 1769 | |
| 1770 | // ───────────────────────────────────────────────────────────────────────────── |
| 1771 | // Entry point |
| 1772 | // ───────────────────────────────────────────────────────────────────────────── |
| 1773 | |
| 1774 | /// Convert the DSH bundle package at `package` into a fresh native bundle at |
| 1775 | /// `output`. `output` must not exist; nothing is written unless every |
| 1776 | /// component validated, and a failed write removes what it created. |
| 1777 | pub(crate) fn convert_package(package: &Path, output: &Path) -> Result<DshConversion> { |
| 1778 | static CONVERSION: std::sync::Mutex<()> = std::sync::Mutex::new(()); |
| 1779 | let _conversion = CONVERSION |
| 1780 | .lock() |
| 1781 | .map_err(|_| anyhow::Error::msg("DSH preparation is unavailable"))?; |
| 1782 | let package = Package::open(package)?; |
| 1783 | require( |
| 1784 | !output.exists(), |
| 1785 | "Output already exists; choose a fresh directory. Nothing was overwritten.", |
| 1786 | )?; |
| 1787 | if let Some(parent) = output.parent().and_then(|p| p.canonicalize().ok()) { |
| 1788 | require( |
| 1789 | !parent.starts_with(&package.root), |
| 1790 | "Output must be outside the selected bundle.", |
| 1791 | )?; |
| 1792 | } |
| 1793 | let loaded = load_bundle(&package)?; |
| 1794 | let mut output_files = OutputFiles::default(); |
| 1795 | let native_rows = native_composition_files( |
| 1796 | &package, |
| 1797 | &loaded.entries, |
| 1798 | loaded.composition, |
| 1799 | &mut output_files, |
| 1800 | )?; |
| 1801 | let requires_native = !native_rows.is_empty(); |
| 1802 | let mut notes = loaded.notes; |
| 1803 | let mut components = Components::default(); |
| 1804 | if !requires_native { |
| 1805 | components.walk(&package, &loaded.entries, None)?; |
| 1806 | } |
| 1807 | require( |
| 1808 | components.servers.len() <= MAX_SERVERS, |
| 1809 | "At most 64 MCP servers can be converted at once.", |
| 1810 | )?; |
| 1811 | let mut outcomes = loaded.outcomes; |
| 1812 | outcomes.append(&mut components.outcomes); |
| 1813 | notes.append(&mut components.notes); |
| 1814 | |
| 1815 | let package_name = loaded |
| 1816 | .manifest |
| 1817 | .get("name") |
| 1818 | .and_then(Value::str) |
| 1819 | .map(str::to_string); |
| 1820 | let package_version = loaded |
| 1821 | .manifest |
| 1822 | .get("version") |
| 1823 | .and_then(Value::str) |
| 1824 | .filter(|v| !v.trim().is_empty()) |
| 1825 | .map(str::to_string); |
| 1826 | let Some(plugin_name) = package_name.as_deref().and_then(derived_plugin_name) else { |
| 1827 | return refuse( |
| 1828 | "The package name cannot form a native plugin name (1–64 lowercase letters/digits with single dots or hyphens).", |
| 1829 | ); |
| 1830 | }; |
| 1831 | |
| 1832 | let mut skill_sources = Vec::new(); |
| 1833 | for directory in &components.skill_dirs { |
| 1834 | let mut children: Vec<_> = fs::read_dir(directory)?.collect::<std::io::Result<_>>()?; |
| 1835 | children.sort_by_key(fs::DirEntry::file_name); |
| 1836 | for child in children { |
| 1837 | let path = child.path(); |
| 1838 | if child.file_name().to_string_lossy().starts_with('.') { |
| 1839 | continue; |
| 1840 | } |
| 1841 | if path.join("SKILL.md").is_file() || path.extension().is_some_and(|ext| ext == "md") { |
| 1842 | skill_sources.push(path); |
| 1843 | } |
| 1844 | } |
| 1845 | } |
| 1846 | let mut skills = Vec::new(); |
| 1847 | for source in skill_sources.iter().filter(|_| !requires_native) { |
| 1848 | let name = skill_files(source, &mut output_files)?; |
| 1849 | require( |
| 1850 | !skills.contains(&name), |
| 1851 | "Duplicate skill name; no files were written.", |
| 1852 | )?; |
| 1853 | skills.push(name); |
| 1854 | } |
| 1855 | if !requires_native { |
| 1856 | for (name, root) in &components.roots { |
| 1857 | stdio_files(root, name, &mut output_files)?; |
| 1858 | } |
| 1859 | } else { |
| 1860 | // Live rows register only under their selected Native entry. The |
| 1861 | // declarative copy would otherwise leak across all caller presets. |
| 1862 | components.servers.clear(); |
| 1863 | } |
| 1864 | |
| 1865 | if let Some(bytes) = output_files.files.get("native/presets.json") { |
| 1866 | let catalog: Json = serde_json::from_slice(bytes)?; |
| 1867 | for row in catalog |
| 1868 | .get("presets") |
| 1869 | .and_then(Json::as_array) |
| 1870 | .into_iter() |
| 1871 | .flatten() |
| 1872 | { |
| 1873 | if let Some(reason) = row.get("broken").and_then(Json::as_str) { |
| 1874 | outcomes.push(DshOutcome { |
| 1875 | row: row.get("id").and_then(Json::as_str).map(str::to_string), |
| 1876 | package: Some(presets::AGENT_PRESETS.into()), |
| 1877 | kind: "native-preset".into(), |
| 1878 | outcome: "skipped".into(), |
| 1879 | reason: reason.into(), |
| 1880 | layer: None, |
| 1881 | patch: None, |
| 1882 | }); |
| 1883 | } |
| 1884 | } |
| 1885 | } |
| 1886 | |
| 1887 | for outcome in &mut outcomes { |
| 1888 | if outcome.kind == "foreign" |
| 1889 | && outcome |
| 1890 | .package |
| 1891 | .as_ref() |
| 1892 | .is_some_and(|name| native_rows.contains(name)) |
| 1893 | { |
| 1894 | outcome.kind = "native".to_string(); |
| 1895 | outcome.outcome = "converted".to_string(); |
| 1896 | outcome.reason = |
| 1897 | "reviewed Native composition; core authority and experimental host required" |
| 1898 | .to_string(); |
| 1899 | } |
| 1900 | } |
| 1901 | require( |
| 1902 | !output_files.files.is_empty() || !components.servers.is_empty(), |
| 1903 | "No portable components in this package: nothing to import.", |
| 1904 | )?; |
| 1905 | |
| 1906 | let mut hosts = components.hosts.clone(); |
| 1907 | hosts.sort(); |
| 1908 | hosts.dedup(); |
| 1909 | // Only portable stdio roots copied for a Node command require Node. Native |
| 1910 | // composition runs on the selected host runtime and must remain usable on |
| 1911 | // a reviewed Bun-only installation; individual MCP launches keep their own |
| 1912 | // exact command/dependency admission. |
| 1913 | let requires_node = !components.roots.is_empty(); |
| 1914 | let mut manifest = json!({ |
| 1915 | "$schema": "https://agent-plugins.org/schemas/plugin.json", |
| 1916 | "name": plugin_name, |
| 1917 | }); |
| 1918 | for field in ["version", "description"] { |
| 1919 | if let Some(value) = loaded |
| 1920 | .manifest |
| 1921 | .get(field) |
| 1922 | .and_then(Value::str) |
| 1923 | .filter(|v| !v.trim().is_empty()) |
| 1924 | { |
| 1925 | manifest[field] = json!(value); |
| 1926 | } |
| 1927 | } |
| 1928 | let mut extension = serde_json::Map::new(); |
| 1929 | if requires_native { |
| 1930 | let paths: Vec<_> = output_files |
| 1931 | .files |
| 1932 | .keys() |
| 1933 | .filter(|p| p.starts_with("native/presets/") && p.ends_with(".mjs")) |
| 1934 | .cloned() |
| 1935 | .collect(); |
| 1936 | extension.insert( |
| 1937 | "native".into(), |
| 1938 | if paths.is_empty() { |
| 1939 | json!({"path":"native/index.mjs"}) |
| 1940 | } else { |
| 1941 | json!({"paths":paths}) |
| 1942 | }, |
| 1943 | ); |
| 1944 | } |
| 1945 | if !hosts.is_empty() { |
| 1946 | extension.insert("capabilities".into(), json!({"network_hosts": hosts})); |
| 1947 | } |
| 1948 | if requires_node { |
| 1949 | extension.insert("when".into(), json!({"binaries": ["node"]})); |
| 1950 | } |
| 1951 | if !extension.is_empty() { |
| 1952 | manifest["extensions"] = json!({"net.codewhale": extension}); |
| 1953 | } |
| 1954 | notes.insert( |
| 1955 | 0, |
| 1956 | format!( |
| 1957 | "source package: {}{}", |
| 1958 | package_name.as_deref().unwrap_or("unnamed"), |
| 1959 | package_version |
| 1960 | .as_deref() |
| 1961 | .map(|v| format!("@{v}")) |
| 1962 | .unwrap_or_default() |
| 1963 | ), |
| 1964 | ); |
| 1965 | output_files.add( |
| 1966 | "plugin.json".into(), |
| 1967 | format!("{}\n", serde_json::to_string_pretty(&manifest)?).into_bytes(), |
| 1968 | )?; |
| 1969 | let (local, remote): (Vec<_>, Vec<_>) = components |
| 1970 | .servers |
| 1971 | .iter() |
| 1972 | .partition(|(_, server)| server["type"] == "stdio"); |
| 1973 | if !components.servers.is_empty() { |
| 1974 | let servers: serde_json::Map<String, Json> = components.servers.iter().cloned().collect(); |
| 1975 | output_files.add( |
| 1976 | "mcp.json".into(), |
| 1977 | format!( |
| 1978 | "{}\n", |
| 1979 | serde_json::to_string_pretty(&json!({"mcpServers": servers}))? |
| 1980 | ) |
| 1981 | .into_bytes(), |
| 1982 | )?; |
| 1983 | } |
| 1984 | |
| 1985 | let conversion = DshConversion { |
| 1986 | plugin_name, |
| 1987 | source_package: package_name, |
| 1988 | source_version: package_version, |
| 1989 | manifest_sha256: loaded.manifest_sha256, |
| 1990 | layers: loaded.layers, |
| 1991 | outcomes, |
| 1992 | diagnostics: notes, |
| 1993 | skills, |
| 1994 | remote_servers: remote.iter().map(|(name, _)| name.clone()).collect(), |
| 1995 | local_servers: local.iter().map(|(name, _)| name.clone()).collect(), |
| 1996 | network_hosts: hosts, |
| 1997 | requires_node, |
| 1998 | requires_native, |
| 1999 | native_rows, |
| 2000 | }; |
| 2001 | output_files.add( |
| 2002 | "CONVERSION.md".into(), |
| 2003 | conversion_markdown(&conversion).into_bytes(), |
| 2004 | )?; |
| 2005 | let receipt = json!({ |
| 2006 | "schema": "codewhale.plugin-conversion.v1", |
| 2007 | "converter_version": CONVERTER_VERSION, |
| 2008 | "converter": "codewhale-native", |
| 2009 | "source": { |
| 2010 | "dialect": "dsh", |
| 2011 | "package": conversion.source_package, |
| 2012 | "version": conversion.source_version, |
| 2013 | "manifest_sha256": conversion.manifest_sha256, |
| 2014 | "patch_layers": conversion.layers, |
| 2015 | }, |
| 2016 | "counts": {"skills": conversion.skills.len(), "mcp_servers": components.servers.len(), "native_rows": conversion.native_rows.len()}, |
| 2017 | "requires_native": conversion.requires_native, |
| 2018 | "outcomes": conversion.outcomes, |
| 2019 | "diagnostics": conversion.diagnostics, |
| 2020 | "required_manual_ports": conversion.outcomes.iter().filter(|o| o.needs_manual_port()).collect::<Vec<_>>(), |
| 2021 | }); |
| 2022 | output_files.add( |
| 2023 | "CONVERSION.json".into(), |
| 2024 | format!("{}\n", serde_json::to_string_pretty(&receipt)?).into_bytes(), |
| 2025 | )?; |
| 2026 | |
| 2027 | write_output(output, &output_files)?; |
| 2028 | Ok(conversion) |
| 2029 | } |
| 2030 | |
| 2031 | fn conversion_markdown(conversion: &DshConversion) -> String { |
| 2032 | let mut lines = vec![ |
| 2033 | "# Conversion receipt".to_string(), |
| 2034 | String::new(), |
| 2035 | format!( |
| 2036 | "Converter version {CONVERTER_VERSION} (native Codewhale import). Source dialect: dsh." |
| 2037 | ), |
| 2038 | format!( |
| 2039 | "Source package: {}{}", |
| 2040 | conversion.source_package.as_deref().unwrap_or("unnamed"), |
| 2041 | conversion |
| 2042 | .source_version |
| 2043 | .as_deref() |
| 2044 | .map(|v| format!("@{v}")) |
| 2045 | .unwrap_or_default() |
| 2046 | ), |
| 2047 | format!("Source manifest sha256: {}", conversion.manifest_sha256), |
| 2048 | "Selected patch layers, applied in declaration order:".to_string(), |
| 2049 | ]; |
| 2050 | lines.extend(conversion.layers.iter().map(|layer| { |
| 2051 | format!( |
| 2052 | "- {} (sha256 {}, {} bytes)", |
| 2053 | layer.path, layer.sha256, layer.bytes |
| 2054 | ) |
| 2055 | })); |
| 2056 | lines.push(String::new()); |
| 2057 | lines.push(format!( |
| 2058 | "Converted {} Skills, {} remote and {} local MCP declarations; {} reviewed Native composition rows.", |
| 2059 | conversion.skills.len(), |
| 2060 | conversion.remote_servers.len(), |
| 2061 | conversion.local_servers.len(),conversion.native_rows.len() |
| 2062 | )); |
| 2063 | lines.push(String::new()); |
| 2064 | if !conversion.outcomes.is_empty() { |
| 2065 | lines.push("## Component outcomes".to_string()); |
| 2066 | lines.push(String::new()); |
| 2067 | lines.push( |
| 2068 | "Unsupported rows need a manual port; disabled rows remain intentionally inactive." |
| 2069 | .to_string(), |
| 2070 | ); |
| 2071 | lines.push(String::new()); |
| 2072 | for outcome in &conversion.outcomes { |
| 2073 | lines.push(format!( |
| 2074 | "- {} ({}) {}: {}{}", |
| 2075 | outcome.row.as_deref().unwrap_or("unlabeled"), |
| 2076 | outcome.package.as_deref().unwrap_or("unlabeled"), |
| 2077 | outcome.kind, |
| 2078 | outcome.outcome, |
| 2079 | if outcome.reason.is_empty() { |
| 2080 | String::new() |
| 2081 | } else { |
| 2082 | format!(" — {}", outcome.reason) |
| 2083 | } |
| 2084 | )); |
| 2085 | } |
| 2086 | lines.push(String::new()); |
| 2087 | } |
| 2088 | if !conversion.diagnostics.is_empty() { |
| 2089 | lines.push("Bundle diagnostics:".to_string()); |
| 2090 | lines.extend( |
| 2091 | conversion |
| 2092 | .diagnostics |
| 2093 | .iter() |
| 2094 | .map(|note| format!("- {note}")), |
| 2095 | ); |
| 2096 | lines.push(String::new()); |
| 2097 | } |
| 2098 | lines.extend( |
| 2099 | [ |
| 2100 | "No selected-package code, package manager, install hook, network request or credential lookup ran; the pinned pure review evaluator parsed configuration.", |
| 2101 | "No environment variable values were resolved. Only files inside the selected package were read;", |
| 2102 | "host paths are never inferred from expressions or copied.", |
| 2103 | "Companion skill files and packaged Node source were copied as data.", |
| 2104 | "Local MCP runs with host-user process authority, not an OS sandbox; stdio does not confine its network or files.", |
| 2105 | "The bundle is installed disabled and untrusted: review it with /plugin validate <name> and the exact trust token before enabling.", |
| 2106 | "Remote MCP output uses Streamable HTTP only. Conversion does not prove server connectivity or foreign runtime compatibility.", |
| 2107 | ] |
| 2108 | .map(str::to_string), |
| 2109 | ); |
| 2110 | format!("{}\n", lines.join("\n")) |
| 2111 | } |
| 2112 | |
| 2113 | fn write_output(output: &Path, files: &OutputFiles) -> Result<()> { |
| 2114 | #[cfg(unix)] |
| 2115 | { |
| 2116 | use std::os::unix::fs::DirBuilderExt as _; |
| 2117 | fs::DirBuilder::new().mode(0o700).create(output)?; |
| 2118 | } |
| 2119 | #[cfg(not(unix))] |
| 2120 | fs::create_dir(output)?; |
| 2121 | let result = (|| -> Result<()> { |
| 2122 | for (relative, content) in &files.files { |
| 2123 | let destination = output.join(relative); |
| 2124 | if let Some(parent) = destination.parent() { |
| 2125 | fs::create_dir_all(parent)?; |
| 2126 | } |
| 2127 | let mut file = fs::OpenOptions::new() |
| 2128 | .write(true) |
| 2129 | .create_new(true) |
| 2130 | .open(&destination)?; |
| 2131 | std::io::Write::write_all(&mut file, content)?; |
| 2132 | } |
| 2133 | Ok(()) |
| 2134 | })(); |
| 2135 | if result.is_err() { |
| 2136 | // The directory was created by this call, so removing it removes |
| 2137 | // only what this conversion wrote. |
| 2138 | let _ = fs::remove_dir_all(output); |
| 2139 | } |
| 2140 | result.map_err(|_| { |
| 2141 | anyhow::Error::msg("Writing the converted bundle failed; nothing was installed.") |
| 2142 | }) |
| 2143 | } |
| 2144 | |
| 2145 | /// Convert into a private scratch directory and return it with the receipt. |
| 2146 | /// The directory is removed when the returned guard drops. |
| 2147 | pub(crate) fn convert_to_scratch( |
| 2148 | package: &Path, |
| 2149 | ) -> Result<(tempfile::TempDir, PathBuf, DshConversion)> { |
| 2150 | let scratch = tempfile::Builder::new() |
| 2151 | .prefix("codewhale-dsh-import-") |
| 2152 | .tempdir()?; |
| 2153 | let bundle = scratch.path().join("bundle"); |
| 2154 | let conversion = convert_package(package, &bundle)?; |
| 2155 | Ok((scratch, bundle, conversion)) |
| 2156 | } |
| 2157 | |
| 2158 | /// Whether `path` looks like a DSH bundle package (for routing a plain local |
| 2159 | /// install to the importer instead of the native bundle reader). |
| 2160 | pub(crate) fn is_dsh_package(path: &Path) -> bool { |
| 2161 | let Ok(package) = Package::open(path) else { |
| 2162 | return false; |
| 2163 | }; |
| 2164 | let Ok(path) = package.contained(Path::new("package.json")) else { |
| 2165 | return false; |
| 2166 | }; |
| 2167 | read_file(&path, MAX_DOCUMENT) |
| 2168 | .ok() |
| 2169 | .and_then(|bytes| utf8(bytes).ok()) |
| 2170 | .and_then(|text| parse_json(&text).ok()) |
| 2171 | .is_some_and(|manifest| { |
| 2172 | manifest |
| 2173 | .get("dsh") |
| 2174 | .and_then(|dsh| dsh.get("bundle")) |
| 2175 | .is_some() |
| 2176 | }) |
| 2177 | } |
| 2178 | |
| 2179 | #[cfg(test)] |
| 2180 | mod shell_hook_import_tests { |
| 2181 | use super::*; |
| 2182 | #[test] |
| 2183 | fn raw_mixed_preset_import_keeps_mcp_and_skills_selected_without_global_duplicates() { |
| 2184 | let temp = tempfile::tempdir().unwrap(); |
| 2185 | let source = temp.path().join("source"); |
| 2186 | fs::create_dir_all(source.join("presets/a")).unwrap(); |
| 2187 | fs::create_dir_all(source.join("skills/check")).unwrap(); |
| 2188 | fs::write(source.join("package.json"), r#"{"name":"@demo/mixed-preset","version":"1.0.0","dsh":{"bundle":{"patch":"./cordis.patch.json"}}}"#).unwrap(); |
| 2189 | fs::write(source.join("cordis.patch.json"), json!([{"insert":[{"id":"roster","name":"@deepseek-ai/dsh-agent-presets","config":{"default":"a","roots":[{"path":"presets","trust":"user"}],"includeShippedRoot":false,"includeUserRoot":false}}]}]).to_string()).unwrap(); |
| 2190 | fs::write( |
| 2191 | source.join("presets/a/preset.yml"), |
| 2192 | "name: Mixed\ndescription: Five selected component categories\n", |
| 2193 | ) |
| 2194 | .unwrap(); |
| 2195 | fs::write(source.join("presets/a/agent.cordis.yml"), "- name: '@deepseek-ai/dsh-mcp-client'\n config: {serverName: scoped, transport: stdio, command: node, args: [peer.mjs]}\n- name: '@deepseek-ai/dsh-skill-filesystem'\n config: {includeDefaultRoots: false, watch: false, customSkillDirs: [skills]}\n- name: ../../authored.mjs\n").unwrap(); |
| 2196 | fs::write(source.join("authored.mjs"), "export const inject=['tools','commands'];export function apply(ctx){ctx.tools.register({name:'echo',description:'mixed',parameters:{type:'object'},execute:()=>''});ctx.commands.register({name:'echo-mod',description:'mixed',handler:()=>''});ctx.on('tools/pre-execute',()=>undefined)}").unwrap(); |
| 2197 | fs::write( |
| 2198 | source.join("peer.mjs"), |
| 2199 | "throw new Error('import must not execute this MCP process')", |
| 2200 | ) |
| 2201 | .unwrap(); |
| 2202 | fs::write( |
| 2203 | source.join("skills/check/SKILL.md"), |
| 2204 | "---\nname: check\ndescription: mixed skill\n---\nInspect the selected source.\n", |
| 2205 | ) |
| 2206 | .unwrap(); |
| 2207 | let output = temp.path().join("out"); |
| 2208 | let converted = convert_package(&source, &output).unwrap(); |
| 2209 | assert!(converted.requires_native); |
| 2210 | assert!( |
| 2211 | !converted.requires_node, |
| 2212 | "Native composition uses the selected host runtime" |
| 2213 | ); |
| 2214 | assert!( |
| 2215 | !output.join("mcp.json").exists(), |
| 2216 | "no global duplicate MCP adapter" |
| 2217 | ); |
| 2218 | assert!( |
| 2219 | !output.join("skills").exists(), |
| 2220 | "no global duplicate Skill adapter" |
| 2221 | ); |
| 2222 | assert!(output.join("source/skills/check/SKILL.md").is_file()); |
| 2223 | assert!(output.join("source/peer.mjs").is_file()); |
| 2224 | assert!(output.join("native/presets/a.mjs").is_file()); |
| 2225 | let manifest: Json = |
| 2226 | serde_json::from_slice(&fs::read(output.join("plugin.json")).unwrap()).unwrap(); |
| 2227 | assert_eq!( |
| 2228 | manifest["extensions"]["net.codewhale"]["native"]["paths"], |
| 2229 | json!(["native/presets/a.mjs"]) |
| 2230 | ); |
| 2231 | assert!(manifest.get("skills").is_none()); |
| 2232 | let admitted = |
| 2233 | crate::plugins::manifest::PluginManifest::from_path(&output.join("plugin.json")) |
| 2234 | .unwrap(); |
| 2235 | assert!( |
| 2236 | admitted.when.is_none(), |
| 2237 | "no fabricated Node binary condition" |
| 2238 | ); |
| 2239 | assert!( |
| 2240 | admitted.check_when(), |
| 2241 | "Native-only applicability is runtime-neutral" |
| 2242 | ); |
| 2243 | } |
| 2244 | |
| 2245 | #[test] |
| 2246 | fn native_shell_bridge_import_seals_assets_without_executing_commands() { |
| 2247 | let temp = tempfile::tempdir().unwrap(); |
| 2248 | let bundle = temp.path().join("bundle"); |
| 2249 | fs::create_dir(&bundle).unwrap(); |
| 2250 | fs::write(bundle.join("package.json"), r#"{"name":"@demo/hook-bundle","version":"1.0.0","dsh":{"bundle":{"patch":"./cordis.patch.yml"}}}"#).unwrap(); |
| 2251 | fs::write(bundle.join("cordis.patch.yml"), "- insert:\n - id: hooks\n name: '@deepseek-ai/dsh-hooks-claude-code'\n config: {configPath: hooks.json}\n").unwrap(); |
| 2252 | let sentinel = temp.path().join("must-not-execute"); |
| 2253 | let config = json!({"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":format!("touch {}",sentinel.display())}]}]}}).to_string(); |
| 2254 | fs::write(bundle.join("hooks.json"), &config).unwrap(); |
| 2255 | let output = temp.path().join("output"); |
| 2256 | let converted = convert_package(&bundle, &output).unwrap(); |
| 2257 | assert!(converted.requires_native); |
| 2258 | assert!( |
| 2259 | !converted.requires_node, |
| 2260 | "Native composition uses the selected host runtime" |
| 2261 | ); |
| 2262 | assert_eq!( |
| 2263 | converted.native_rows, |
| 2264 | ["@deepseek-ai/dsh-hooks-claude-code"] |
| 2265 | ); |
| 2266 | let spec: Json = |
| 2267 | serde_json::from_slice(&fs::read(output.join("native/composition.json")).unwrap()) |
| 2268 | .unwrap(); |
| 2269 | assert_eq!(spec["modules"], json!([])); |
| 2270 | assert_eq!(spec["files"]["hooks.json"], sha256_hex(config.as_bytes())); |
| 2271 | assert_eq!( |
| 2272 | fs::read(output.join("source/hooks.json")).unwrap(), |
| 2273 | config.as_bytes() |
| 2274 | ); |
| 2275 | let manifest: Json = |
| 2276 | serde_json::from_slice(&fs::read(output.join("plugin.json")).unwrap()).unwrap(); |
| 2277 | assert_eq!( |
| 2278 | manifest["extensions"]["net.codewhale"]["native"]["path"], |
| 2279 | "native/index.mjs" |
| 2280 | ); |
| 2281 | let admitted = |
| 2282 | crate::plugins::manifest::PluginManifest::from_path(&output.join("plugin.json")) |
| 2283 | .unwrap(); |
| 2284 | assert!( |
| 2285 | admitted.when.is_none(), |
| 2286 | "no fabricated Node binary condition" |
| 2287 | ); |
| 2288 | assert!( |
| 2289 | admitted.check_when(), |
| 2290 | "Native-only applicability is runtime-neutral" |
| 2291 | ); |
| 2292 | assert!( |
| 2293 | !sentinel.exists(), |
| 2294 | "import and preview must never run a shell command" |
| 2295 | ); |
| 2296 | } |
| 2297 | } |
| 2298 | |
| 2299 | #[cfg(test)] |
| 2300 | #[path = "dsh_tests.rs"] |
| 2301 | mod tests; |
| 2302 |