返回 CodeWhale
dsh.rs
根目录 / crates / tui / src / plugins / install / dsh.rs
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(&current) {
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
2302 lines RUST