diff --git a/src/anolisa/Cargo.lock b/src/anolisa/Cargo.lock index 2a1d4c318a..311e0688f9 100644 --- a/src/anolisa/Cargo.lock +++ b/src/anolisa/Cargo.lock @@ -68,6 +68,7 @@ dependencies = [ "semver", "serde", "serde_json", + "serde_yaml_ng", "sha2", "tar", "tempfile", @@ -926,6 +927,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "semver" version = "1.0.28" @@ -984,6 +991,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1178,6 +1198,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/src/anolisa/Cargo.toml b/src/anolisa/Cargo.toml index 7710424773..b2ec82ea4a 100644 --- a/src/anolisa/Cargo.toml +++ b/src/anolisa/Cargo.toml @@ -22,6 +22,7 @@ clap = { version = "4", features = ["derive", "env", "color"] } # Serialization serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serde_yaml_ng = "0.10" toml = "0.8" # AST-level TOML editing — used by sandbox_install.rs to patch # /etc/containerd/config.toml without losing user comments / ordering and diff --git a/src/anolisa/crates/anolisa-cli/src/commands/adapter.rs b/src/anolisa/crates/anolisa-cli/src/commands/adapter.rs index 5e3cea858a..efc5eb520f 100644 --- a/src/anolisa/crates/anolisa-cli/src/commands/adapter.rs +++ b/src/anolisa/crates/anolisa-cli/src/commands/adapter.rs @@ -32,7 +32,7 @@ //! conditions behind it. Verification that cannot run reports `unknown` //! rather than a faked healthy/absent verdict. -use clap::{Parser, Subcommand}; +use clap::{ArgAction, Parser, Subcommand}; use serde::Serialize; use anolisa_core::adapter::AdapterError; @@ -81,6 +81,10 @@ pub enum AdapterCommands { /// default; a normal install never bypasses OpenClaw's checks. #[arg(long)] allow_unsafe_plugin_install: bool, + /// dsh profile to enable. Repeat for multiple profiles; required + /// when the selected framework is profile-scoped. + #[arg(long = "profile", action = ArgAction::Append)] + profiles: Vec, }, /// Disable a previously enabled adapter. Disable { @@ -217,11 +221,13 @@ pub fn handle(args: AdapterArgs, ctx: &CliContext) -> Result<(), CliError> { component, framework, allow_unsafe_plugin_install, + profiles, } => handle_enable( ctx, &component, framework.as_deref(), allow_unsafe_plugin_install, + profiles, ), AdapterCommands::Disable { component, @@ -337,6 +343,7 @@ fn handle_enable( component: &str, framework: Option<&str>, allow_unsafe_plugin_install: bool, + profiles: Vec, ) -> Result<(), CliError> { const COMMAND: &str = "adapter enable"; let (component, view) = common::resolve_adapter_target(component, ctx, COMMAND)?; @@ -348,6 +355,7 @@ fn handle_enable( ctx.dry_run, EnableOptions { allow_unsafe_plugin_install, + profiles, }, ) .map_err(|e| map_err(COMMAND, e))?; @@ -678,6 +686,7 @@ mod tests { component, framework, allow_unsafe_plugin_install, + profiles, } => { assert_eq!(component, "tokenless"); assert!(framework.is_none()); @@ -685,6 +694,7 @@ mod tests { !allow_unsafe_plugin_install, "unsafe install must default to false" ); + assert!(profiles.is_empty()); } _ => panic!("expected enable"), } @@ -713,6 +723,7 @@ mod tests { component, framework, allow_unsafe_plugin_install, + profiles, } => { assert_eq!(component, "tokenless"); assert_eq!(framework.as_deref(), Some("openclaw")); @@ -720,6 +731,28 @@ mod tests { allow_unsafe_plugin_install, "flag must be captured when passed" ); + assert!(profiles.is_empty()); + } + _ => panic!("expected enable"), + } + } + + #[test] + fn enable_parses_repeatable_profiles() { + let cli = TestCli::try_parse_from([ + "x", + "enable", + "tokenless", + "dsh", + "--profile", + "web", + "--profile", + "headless", + ]) + .expect("parse"); + match cli.command { + AdapterCommands::Enable { profiles, .. } => { + assert_eq!(profiles, vec!["web", "headless"]); } _ => panic!("expected enable"), } diff --git a/src/anolisa/crates/anolisa-core/Cargo.toml b/src/anolisa/crates/anolisa-core/Cargo.toml index 469e0f2b71..0cb54150ce 100644 --- a/src/anolisa/crates/anolisa-core/Cargo.toml +++ b/src/anolisa/crates/anolisa-core/Cargo.toml @@ -11,6 +11,7 @@ anolisa-env.workspace = true anolisa-platform.workspace = true serde.workspace = true serde_json.workspace = true +serde_yaml_ng.workspace = true toml.workspace = true toml_edit.workspace = true thiserror.workspace = true diff --git a/src/anolisa/crates/anolisa-core/src/adapter.rs b/src/anolisa/crates/anolisa-core/src/adapter.rs index 52d6de1c67..afe056f760 100644 --- a/src/anolisa/crates/anolisa-core/src/adapter.rs +++ b/src/anolisa/crates/anolisa-core/src/adapter.rs @@ -26,6 +26,7 @@ pub mod codex; pub mod contract; pub mod cosh; pub mod driver; +pub mod dsh; pub mod hermes; pub mod managed_files; pub mod manager; diff --git a/src/anolisa/crates/anolisa-core/src/adapter/claim.rs b/src/anolisa/crates/anolisa-core/src/adapter/claim.rs index f41e15e351..756b4f66d2 100644 --- a/src/anolisa/crates/anolisa-core/src/adapter/claim.rs +++ b/src/anolisa/crates/anolisa-core/src/adapter/claim.rs @@ -208,7 +208,11 @@ impl AdapterClaim { exact_symlink_targets: &[PathBuf], ) -> Result<(), ClaimValidationError> { if let Some(pid) = &self.plugin_id { - validate_plugin_id(pid)?; + if self.framework == "dsh" { + validate_dsh_package_name(pid)?; + } else { + validate_plugin_id(pid)?; + } } for resource in &self.resources { resource.validate_with_owned_roots( @@ -441,6 +445,10 @@ impl ClaimResource { } }) } + ClaimResourceKind::FrameworkPlugin { + framework, + plugin_id, + } if framework == "dsh" => validate_dsh_package_name(plugin_id), ClaimResourceKind::FrameworkPlugin { plugin_id, .. } => validate_plugin_id(plugin_id), ClaimResourceKind::FrameworkMarketplace { marketplace, .. } => { validate_marketplace_name(marketplace).map_err(|_| { @@ -581,6 +589,9 @@ pub enum DriverPayload { /// Qwen Code driver payload. #[serde(rename = "qwencode")] QwenCode(QwenCodeClaim), + /// DeepSeek Harness (`dsh`) native plugin payload. + #[serde(rename = "dsh")] + Dsh(DshClaim), } /// OpenClaw driver payload. Holds only [`ClaimResource::id`] references — @@ -724,6 +735,32 @@ pub struct QwenCodeClaim { pub plugin_resource: String, } +/// DeepSeek Harness native-plugin receipt. A single ANOLISA receipt owns the +/// same package across every explicitly selected dsh profile; each profile +/// keeps its own validated framework-plugin resource reference so disable can +/// release exactly the registrations that enable created. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DshClaim { + /// Package name read from the bundle's `package.json`. + pub package_name: String, + /// Resource id of the enable-time dsh home + /// ([`ClaimResourceKind::ExternalPath`]). Persisting the resolved root + /// keeps later lifecycle operations independent of process environment + /// and working-directory drift. + pub home_resource: String, + /// Profiles in which ANOLISA registered the package. + pub profiles: Vec, +} + +/// One profile entry in a [`DshClaim`]. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DshProfileClaim { + /// dsh profile identifier passed to the native plugin CLI. + pub name: String, + /// Resource id of the profile's registered package. + pub plugin_resource: String, +} + // --------------------------------------------------------------------------- // Validation // --------------------------------------------------------------------------- @@ -1156,6 +1193,55 @@ pub fn validate_plugin_id(plugin_id: &str) -> Result<(), ClaimValidationError> { Ok(()) } +/// Validate an npm-compatible dsh package name before it enters a CLI argv. +/// Scoped names (`@scope/name`) are accepted because native dsh bundles use +/// package-manager names, while traversal, flags, empty segments, and shell +/// metacharacters remain rejected. +pub fn validate_dsh_package_name(package_name: &str) -> Result<(), ClaimValidationError> { + let reject = |reason: &str| { + Err(ClaimValidationError::PluginId { + plugin_id: package_name.to_string(), + reason: reason.to_string(), + }) + }; + if package_name.is_empty() { + return reject("must not be empty"); + } + if package_name.starts_with('-') || package_name == "." || package_name == ".." { + return reject("must not be '.'/'..' or start with '-'"); + } + let mut segments = package_name.split('/'); + let first = segments.next().unwrap_or_default(); + let scoped = first.starts_with('@'); + if scoped { + if first.len() <= 1 || segments.clone().count() != 1 { + return reject("scoped names must have exactly one non-empty package segment"); + } + if first[1..].starts_with('.') || first[1..].starts_with('-') { + return reject("scope must not start with '.' or '-'"); + } + if !first[1..] + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + { + return reject("scope contains disallowed characters"); + } + } else if segments.clone().next().is_some() { + return reject("unscoped names must not contain '/'"); + } + let name = segments.next().unwrap_or(first); + if name.is_empty() || name == "." || name == ".." || name.starts_with('-') { + return reject("package segment is empty, traversal, or starts with '-'"); + } + if !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + { + return reject("contains disallowed character"); + } + Ok(()) +} + /// Reject a marketplace name unless it is a non-empty string of argv-safe /// characters (`[A-Za-z0-9._-]`) that is neither `.`/`..` nor leading with /// `-`. Codex/Claude Code marketplace names are passed to the framework @@ -1395,6 +1481,26 @@ mod tests { validate_plugin_id("a.b_c-1").expect("mixed"); } + #[test] + fn validate_dsh_package_name_accepts_scoped_name() { + validate_dsh_package_name("@anolisa/dsh-tokenless").expect("scoped package"); + validate_dsh_package_name("dsh-tokenless").expect("unscoped package"); + } + + #[test] + fn validate_dsh_package_name_rejects_traversal_and_shell_text() { + for value in [ + "", + "@anolisa", + "@anolisa/", + "@anolisa/a/b", + "../escape", + "a b", + ] { + assert!(validate_dsh_package_name(value).is_err(), "{value}"); + } + } + #[test] fn validate_plugin_id_rejects_unsafe_ids() { assert!(validate_plugin_id("").is_err(), "empty"); diff --git a/src/anolisa/crates/anolisa-core/src/adapter/claude_code.rs b/src/anolisa/crates/anolisa-core/src/adapter/claude_code.rs index 5832add380..1be65a615d 100644 --- a/src/anolisa/crates/anolisa-core/src/adapter/claude_code.rs +++ b/src/anolisa/crates/anolisa-core/src/adapter/claude_code.rs @@ -768,6 +768,7 @@ mod tests { resource_root: root.to_path_buf(), user_home: Some(PathBuf::from("/tmp/cc-home")), declared_plugin_id: Some("tokenless".to_string()), + requested_profiles: Vec::new(), adapter_type: Some("plugin".to_string()), declared_skills: Vec::new(), declared_config: Vec::new(), @@ -841,6 +842,7 @@ mod tests { resource_root: root.clone(), user_home: Some(PathBuf::from("/tmp/cc-home2")), declared_plugin_id: Some("tokenless".to_string()), + requested_profiles: Vec::new(), adapter_type: Some("plugin".to_string()), declared_skills: Vec::new(), declared_config: Vec::new(), diff --git a/src/anolisa/crates/anolisa-core/src/adapter/cosh.rs b/src/anolisa/crates/anolisa-core/src/adapter/cosh.rs index 4fccb2b2e7..9ee8d308b5 100644 --- a/src/anolisa/crates/anolisa-core/src/adapter/cosh.rs +++ b/src/anolisa/crates/anolisa-core/src/adapter/cosh.rs @@ -656,6 +656,7 @@ mod tests { resource_root: resource_root.to_path_buf(), user_home: Some(user_home.to_path_buf()), declared_plugin_id: Some("tokenless".to_string()), + requested_profiles: Vec::new(), adapter_type: Some("extension".to_string()), declared_skills: Vec::new(), declared_config: Vec::new(), diff --git a/src/anolisa/crates/anolisa-core/src/adapter/driver.rs b/src/anolisa/crates/anolisa-core/src/adapter/driver.rs index 95da3fe164..fd6efa3b35 100644 --- a/src/anolisa/crates/anolisa-core/src/adapter/driver.rs +++ b/src/anolisa/crates/anolisa-core/src/adapter/driver.rs @@ -64,6 +64,9 @@ pub struct DriverCtx<'a> { /// Plugin id declared in the component's adapter manifest, if any. /// A driver may fall back to it when the bundle does not name one. pub declared_plugin_id: Option, + /// Explicit framework profiles selected by the caller for profile-scoped + /// adapters. Drivers that are not profile-scoped ignore this list. + pub requested_profiles: Vec, /// Adapter type declared in the component manifest. Absent means the /// legacy plugin adapter model. pub adapter_type: Option, diff --git a/src/anolisa/crates/anolisa-core/src/adapter/dsh.rs b/src/anolisa/crates/anolisa-core/src/adapter/dsh.rs new file mode 100644 index 0000000000..a316d321ca --- /dev/null +++ b/src/anolisa/crates/anolisa-core/src/adapter/dsh.rs @@ -0,0 +1,1510 @@ +//! DeepSeek Harness (`dsh`) native plugin driver. +//! +//! dsh owns profile configuration and plugin registration. ANOLISA therefore +//! treats a bundle as immutable package data (`package.json` plus the +//! `dsh.bundle.patch` file) and delegates every profile mutation to the dsh +//! plugin CLI. A single receipt records all explicitly selected profiles so +//! disable and status never guess an implicit profile. + +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::path::{Component, Path, PathBuf}; +use std::time::Duration; + +use serde::Deserialize; + +use super::AdapterError; +use super::claim::{ + AdapterClaim, CLAIM_SCHEMA_VERSION, ClaimResource, ClaimResourceKind, ClaimStatus, + DRIVER_SCHEMA_VERSION, DriverPayload, DshClaim, DshProfileClaim, validate_dsh_package_name, +}; +use super::driver::{ + AdapterBundle, AdapterCondition, AdapterConditionKind, AdapterStatusReport, AdapterSummary, + ClaimResourceRef, ConditionStatus, DetectResult, DisableReport, DriverCtx, DriverPlan, + FrameworkCommand, FrameworkDriver, HostEnv, PreparedEnable, find_binary_in_path, +}; +use super::util::{bool_status, cli_failure_reason, display_command, now_iso8601}; + +const CLI_TIMEOUT: Duration = Duration::from_secs(60); +const PACKAGE_JSON: &str = "package.json"; +const HOME_RESOURCE: &str = "dsh_home"; +const RES_PREFIX: &str = "dsh_plugin_"; + +/// dsh native bundle metadata (`package.json` → `dsh.bundle`). +#[derive(Debug, Deserialize)] +struct PackageJson { + name: String, + dsh: DshMetadata, +} + +#[derive(Debug, Deserialize)] +struct DshMetadata { + bundle: DshBundleMeta, +} + +#[derive(Debug, Deserialize)] +struct DshBundleMeta { + patch: String, +} + +#[derive(Debug, Deserialize)] +struct ProfilePackageJson { + #[serde(default)] + dependencies: BTreeMap, + dsh: Option, +} + +#[derive(Debug, Deserialize)] +struct ProfileDshMetadata { + profile: Option, +} + +#[derive(Debug, Deserialize)] +struct DshProfileMeta { + #[serde(default)] + bundles: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProfilePackageState { + Registered, + DependencyOnly, + BundleOnly, + Absent, +} + +/// Parsed dsh bundle with the package and patch identities required by the +/// driver. The patch entry itself stays package-owned and is never persisted +/// as executable receipt data. +#[derive(Debug, Clone)] +struct DshBundle { + package_name: String, + patch: serde_yaml_ng::Value, +} + +/// dsh framework driver. Per-operation state is carried by [`DriverCtx`] and +/// the typed [`DshClaim`] receipt. +pub struct DshDriver; + +impl DshDriver { + /// Construct a dsh driver. + pub fn new() -> Self { + Self + } +} + +impl Default for DshDriver { + fn default() -> Self { + Self::new() + } +} + +impl FrameworkDriver for DshDriver { + fn name(&self) -> &'static str { + "dsh" + } + + fn probe_bundle(&self, resource_root: &Path, _declared_entry: Option<&str>) -> bool { + read_dsh_bundle(resource_root).is_ok() + } + + fn detect(&self, _env: &HostEnv) -> DetectResult { + match dsh_program_path() { + Some(path) => DetectResult { + detected: true, + reason: format!("dsh CLI found at {}", path.display()), + }, + None => DetectResult { + detected: false, + reason: "dsh CLI not found on PATH".to_string(), + }, + } + } + + fn allowed_external_roots(&self, ctx: &DriverCtx) -> Vec { + // Profile manifests are read for status and idempotent cleanup, while + // all profile mutations remain delegated to the dsh CLI. + dsh_home(ctx.user_home.as_deref()).into_iter().collect() + } + + fn read_bundle(&self, ctx: &DriverCtx) -> Result { + let bundle = read_dsh_bundle(&ctx.resource_root)?; + if let Some(declared) = ctx.declared_plugin_id.as_deref().filter(|v| !v.is_empty()) { + super::claim::validate_plugin_id(declared).map_err(AdapterError::ClaimValidation)?; + if !patch_contains_id(&bundle.patch, declared) { + return Err(AdapterError::BundleInvalid { + root: ctx.resource_root.clone(), + reason: format!( + "dsh patch has no plugin id '{declared}' matching manifest declaration" + ), + }); + } + } + Ok(AdapterBundle { + resource_root: ctx.resource_root.clone(), + plugin_id: Some(bundle.package_name), + }) + } + + fn plan_enable( + &self, + bundle: &AdapterBundle, + ctx: &DriverCtx, + ) -> Result { + let package = bundle_package_name(bundle)?; + let profiles = requested_profiles(ctx)?; + let home = required_dsh_home(ctx)?; + let actions = profiles + .iter() + .map(|profile| { + format!( + "register dsh plugin '{package}' in profile '{profile}' from {}", + bundle.resource_root.display() + ) + }) + .collect::>(); + let register_command = match profiles.as_slice() { + [profile] => Some(display_command(&dsh_command( + [ + "plugin", + "--profile", + profile, + "add", + &format!("link:{}", bundle.resource_root.display()), + ], + &home, + ))), + _ => None, + }; + Ok(DriverPlan { + framework: self.name().to_string(), + component: ctx.component.clone(), + actions, + register_command, + }) + } + + fn prepare_enable( + &self, + bundle: &AdapterBundle, + ctx: &DriverCtx, + ) -> Result<(AdapterClaim, PreparedEnable), AdapterError> { + let package = bundle_package_name(bundle)?; + let home = required_dsh_home(ctx)?; + let profiles = requested_profiles(ctx)?; + let profiles = profiles + .into_iter() + .enumerate() + .map(|(index, name)| DshProfileClaim { + name, + plugin_resource: format!("{RES_PREFIX}{index}"), + }) + .collect::>(); + let mut resources = profiles + .iter() + .map(|profile| ClaimResource { + id: profile.plugin_resource.clone(), + purpose: format!("dsh_plugin_profile_{}", profile.name), + kind: ClaimResourceKind::FrameworkPlugin { + framework: self.name().to_string(), + plugin_id: package.clone(), + }, + }) + .collect::>(); + resources.push(ClaimResource { + id: HOME_RESOURCE.to_string(), + purpose: "dsh_home".to_string(), + kind: ClaimResourceKind::ExternalPath { path: home }, + }); + let claim = AdapterClaim { + claim_schema: CLAIM_SCHEMA_VERSION, + component: ctx.component.clone(), + framework: self.name().to_string(), + plugin_id: Some(package.clone()), + adapter_type: ctx.adapter_type.clone(), + enabled_at: now_iso8601(), + resource_root: bundle.resource_root.clone(), + bundle_digest: None, + source_revision: None, + materialized_files: Vec::new(), + driver_schema: DRIVER_SCHEMA_VERSION, + status: ClaimStatus::Enabled, + notices: Vec::new(), + resources, + driver_payload: DriverPayload::Dsh(DshClaim { + package_name: package, + home_resource: HOME_RESOURCE.to_string(), + profiles, + }), + }; + Ok((claim, PreparedEnable::None)) + } + + fn plan_reenable_cleanup( + &self, + prior: &AdapterClaim, + ctx: &DriverCtx, + ) -> Result, AdapterError> { + let prior_payload = dsh_claim(prior)?; + validate_dsh_claim(prior, prior_payload)?; + let current = read_dsh_bundle(&ctx.resource_root)?; + let package_changed = current.package_name != prior_payload.package_name; + let source_changed = prior.resource_root != ctx.resource_root; + let next_profiles = requested_profiles(ctx)?; + let mut actions = Vec::new(); + for profile in &prior_payload.profiles { + let retained = next_profiles.iter().any(|name| name == &profile.name); + if package_changed || source_changed || !retained { + actions.push(format!( + "remove prior dsh plugin '{}' from profile '{}'", + prior_payload.package_name, profile.name + )); + } + } + Ok(actions) + } + + fn cleanup_replaced_claim( + &self, + prior: &AdapterClaim, + next: &AdapterClaim, + ctx: &DriverCtx, + ) -> Result { + let prior_payload = dsh_claim(prior)?; + let next_payload = dsh_claim(next)?; + validate_dsh_claim(prior, prior_payload)?; + validate_dsh_claim(next, next_payload)?; + let prior_home = dsh_claim_home(prior, prior_payload)?; + let source_changed = prior.resource_root != next.resource_root; + let package_changed = prior_payload.package_name != next_payload.package_name; + let mut cleanup_complete = true; + let mut messages = Vec::new(); + for profile in &prior_payload.profiles { + let retained = next_payload + .profiles + .iter() + .any(|candidate| candidate.name == profile.name); + if retained && !source_changed && !package_changed { + continue; + } + if profile_package_state(ctx, prior_home, &profile.name, &prior_payload.package_name)? + == Some(ProfilePackageState::Absent) + { + messages.push(format!( + "prior dsh plugin '{}' is already absent from profile '{}'", + prior_payload.package_name, profile.name + )); + continue; + } + let output = ctx.ops.run_framework_cli(dsh_command( + [ + "plugin", + "--profile", + profile.name.as_str(), + "remove", + prior_payload.package_name.as_str(), + ], + prior_home, + ))?; + if output.success() { + messages.push(format!( + "removed prior dsh plugin '{}' from profile '{}'", + prior_payload.package_name, profile.name + )); + } else { + cleanup_complete = false; + messages.push(format!( + "failed to remove prior dsh plugin '{}' from profile '{}': {}", + prior_payload.package_name, + profile.name, + cli_failure_reason("plugin remove", &output) + )); + } + } + Ok(DisableReport { + cleanup_complete, + messages, + }) + } + + fn apply_enable( + &self, + claim: &mut AdapterClaim, + prepared: &PreparedEnable, + ctx: &DriverCtx, + _progress: &mut dyn super::driver::EnableProgress, + ) -> Result<(), AdapterError> { + if !matches!(prepared, PreparedEnable::None) { + return Err(AdapterError::FrameworkCli { + program: dsh_program(), + reason: "dsh enable received unexpected prepared state".to_string(), + }); + } + let payload = dsh_claim(claim)?; + validate_dsh_claim(claim, payload)?; + let home = dsh_claim_home(claim, payload)?; + for profile in &payload.profiles { + let root = format!("link:{}", claim.resource_root.display()); + let output = ctx.ops.run_framework_cli(dsh_command( + [ + "plugin", + "--profile", + profile.name.as_str(), + "add", + root.as_str(), + ], + home, + ))?; + if !output.success() { + return Err(AdapterError::FrameworkCli { + program: dsh_program(), + reason: cli_failure_reason("plugin add", &output), + }); + } + } + Ok(()) + } + + fn status( + &self, + claim: &AdapterClaim, + ctx: &DriverCtx, + ) -> Result { + let payload = dsh_claim(claim)?; + validate_dsh_claim(claim, payload)?; + let home = dsh_claim_home(claim, payload)?; + let detect = self.detect(&HostEnv { + user_home: ctx.user_home.clone(), + }); + let mut conditions = vec![AdapterCondition { + kind: AdapterConditionKind::FrameworkDetected, + status: bool_status(detect.detected), + reason: Some(detect.reason), + resource: None, + }]; + let mut all_registered = true; + let mut any_unknown = false; + let mut verification = ConditionStatus::True; + for profile in &payload.profiles { + let resource = Some(ClaimResourceRef { + id: profile.plugin_resource.clone(), + }); + let (status, reason) = + match profile_package_state(ctx, home, &profile.name, &payload.package_name)? { + Some(ProfilePackageState::Registered) => (ConditionStatus::True, None), + Some(_) => { + all_registered = false; + ( + ConditionStatus::False, + Some(format!( + "package '{}' is not registered in profile '{}'", + payload.package_name, profile.name + )), + ) + } + None => { + verification = ConditionStatus::Unknown; + any_unknown = true; + ( + ConditionStatus::Unknown, + Some(format!( + "dsh could not read plugin registration in profile '{}'", + profile.name + )), + ) + } + }; + all_registered &= status == ConditionStatus::True; + conditions.push(AdapterCondition { + kind: AdapterConditionKind::PluginRegistered, + status, + reason, + resource, + }); + } + conditions.push(AdapterCondition { + kind: AdapterConditionKind::VerificationSupported, + status: verification, + reason: (verification != ConditionStatus::True) + .then(|| "dsh profile registration could not be verified".to_string()), + resource: None, + }); + let summary = if claim.status == ClaimStatus::CleanupFailed { + AdapterSummary::CleanupFailed + } else if !detect.detected || !all_registered { + if verification == ConditionStatus::Unknown + && any_unknown + && !conditions.iter().any(|condition| { + condition.kind == AdapterConditionKind::PluginRegistered + && condition.status == ConditionStatus::False + }) + { + AdapterSummary::Unknown + } else { + AdapterSummary::Degraded + } + } else { + AdapterSummary::Healthy + }; + Ok(AdapterStatusReport { + summary, + conditions, + }) + } + + fn disable( + &self, + claim: &AdapterClaim, + ctx: &DriverCtx, + ) -> Result { + let payload = dsh_claim(claim)?; + validate_dsh_claim(claim, payload)?; + let home = dsh_claim_home(claim, payload)?; + if dsh_program_path().is_none() { + return Ok(DisableReport { + cleanup_complete: false, + messages: vec![ + "dsh CLI not found on PATH; receipt kept for cleanup retry".to_string(), + ], + }); + } + let mut messages = Vec::new(); + let mut cleanup_complete = true; + for profile in &payload.profiles { + if profile_package_state(ctx, home, &profile.name, &payload.package_name)? + == Some(ProfilePackageState::Absent) + { + messages.push(format!( + "dsh plugin '{}' is already absent from profile '{}'", + payload.package_name, profile.name + )); + continue; + } + let output = ctx.ops.run_framework_cli(dsh_command( + [ + "plugin", + "--profile", + profile.name.as_str(), + "remove", + payload.package_name.as_str(), + ], + home, + ))?; + if output.success() { + messages.push(format!( + "removed dsh plugin '{}' from profile '{}'", + payload.package_name, profile.name + )); + } else { + cleanup_complete = false; + messages.push(format!( + "failed to remove dsh plugin '{}' from profile '{}': {}", + payload.package_name, + profile.name, + cli_failure_reason("plugin remove", &output) + )); + } + } + Ok(DisableReport { + cleanup_complete, + messages, + }) + } +} + +fn read_dsh_bundle(root: &Path) -> Result { + if !root.is_dir() { + return Err(bundle_error( + root, + "resource root does not exist or is not a directory", + )); + } + let canonical_root = std::fs::canonicalize(root).map_err(|source| { + bundle_error( + root, + format!("cannot resolve bundle root '{}': {source}", root.display()), + ) + })?; + let package_path = resolve_bundle_file( + root, + &canonical_root, + Path::new(PACKAGE_JSON), + "package manifest", + )?; + let bytes = std::fs::read(&package_path).map_err(|source| { + bundle_error( + root, + format!("cannot read '{}': {source}", package_path.display()), + ) + })?; + let package: PackageJson = serde_json::from_slice(&bytes).map_err(|source| { + bundle_error( + root, + format!("invalid '{}': {source}", package_path.display()), + ) + })?; + validate_dsh_package_name(&package.name).map_err(AdapterError::ClaimValidation)?; + let patch_rel = validate_relative_bundle_path(root, &package.dsh.bundle.patch, "patch")?; + let patch = resolve_bundle_file(root, &canonical_root, &patch_rel, "patch")?; + let patch_text = std::fs::read_to_string(&patch).map_err(|source| { + bundle_error( + root, + format!( + "cannot read dsh bundle patch '{}': {source}", + patch.display() + ), + ) + })?; + let patch = parse_patch_list(root, &patch_text)?; + validate_patch_entries(root, &patch)?; + Ok(DshBundle { + package_name: package.name, + patch, + }) +} + +fn parse_patch_list(root: &Path, patch: &str) -> Result { + let parsed = serde_yaml_ng::from_str::(patch) + .map_err(|source| bundle_error(root, format!("invalid dsh bundle patch YAML: {source}")))?; + let Some(entries) = parsed.as_sequence() else { + return Err(bundle_error( + root, + "dsh.bundle.patch must be a top-level YAML array", + )); + }; + if entries.iter().any(|entry| entry.as_mapping().is_none()) { + return Err(bundle_error( + root, + "every dsh bundle patch entry must be a YAML mapping", + )); + } + Ok(parsed) +} + +fn patch_contains_id(patch: &serde_yaml_ng::Value, expected: &str) -> bool { + patch.as_sequence().is_some_and(|entries| { + entries.iter().any(|entry| { + let Some(mapping) = entry.as_mapping() else { + return false; + }; + yaml_string(mapping, "id") == Some(expected) + || yaml_sequence(mapping, "insert").is_some_and(|inserted| { + inserted.iter().any(|row| { + row.as_mapping() + .and_then(|mapping| yaml_string(mapping, "id")) + == Some(expected) + }) + }) + }) + }) +} + +fn validate_patch_entries(root: &Path, patch: &serde_yaml_ng::Value) -> Result<(), AdapterError> { + for entry in patch.as_sequence().into_iter().flatten() { + let Some(mapping) = entry.as_mapping() else { + continue; + }; + let Some(insert) = yaml_value(mapping, "insert") else { + continue; + }; + let Some(rows) = insert.as_sequence() else { + return Err(bundle_error( + root, + "dsh patch 'insert' must be a YAML array", + )); + }; + for row in rows { + let Some(row) = row.as_mapping() else { + return Err(bundle_error( + root, + "every dsh patch 'insert' row must be a YAML mapping", + )); + }; + let Some(name) = yaml_value(row, "name") else { + continue; + }; + let Some(name) = name.as_str() else { + return Err(bundle_error( + root, + "dsh patch plugin 'name' must be a string", + )); + }; + if Path::new(name).is_absolute() + || name.starts_with("./") + || name.starts_with("../") + || name.starts_with(".\\") + || name.starts_with("..\\") + { + return Err(bundle_error( + root, + format!("dsh patch plugin name '{name}' must use its installed package name"), + )); + } + } + } + Ok(()) +} + +fn yaml_value<'a>( + mapping: &'a serde_yaml_ng::Mapping, + key: &str, +) -> Option<&'a serde_yaml_ng::Value> { + mapping.get(serde_yaml_ng::Value::String(key.to_string())) +} + +fn yaml_string<'a>(mapping: &'a serde_yaml_ng::Mapping, key: &str) -> Option<&'a str> { + yaml_value(mapping, key).and_then(serde_yaml_ng::Value::as_str) +} + +fn yaml_sequence<'a>( + mapping: &'a serde_yaml_ng::Mapping, + key: &str, +) -> Option<&'a Vec> { + yaml_value(mapping, key).and_then(serde_yaml_ng::Value::as_sequence) +} + +fn resolve_bundle_file( + root: &Path, + canonical_root: &Path, + relative: &Path, + role: &str, +) -> Result { + let path = root.join(relative); + let resolved = std::fs::canonicalize(&path).map_err(|source| { + bundle_error( + root, + format!("cannot resolve dsh {role} '{}': {source}", path.display()), + ) + })?; + if !resolved.starts_with(canonical_root) { + return Err(bundle_error( + root, + format!( + "dsh {role} '{}' resolves outside the bundle root", + path.display() + ), + )); + } + let metadata = std::fs::metadata(&resolved).map_err(|source| { + bundle_error( + root, + format!("cannot inspect dsh {role} '{}': {source}", path.display()), + ) + })?; + if !metadata.is_file() { + return Err(bundle_error( + root, + format!("dsh {role} '{}' is not a regular file", path.display()), + )); + } + Ok(resolved) +} + +fn validate_relative_bundle_path( + root: &Path, + value: &str, + role: &str, +) -> Result { + let path = PathBuf::from(value); + if path.as_os_str().is_empty() || path.is_absolute() { + return Err(bundle_error( + root, + format!("dsh {role} must be a non-empty relative path"), + )); + } + if path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return Err(bundle_error( + root, + format!("dsh {role} '{}' escapes the bundle root", path.display()), + )); + } + Ok(path) +} + +fn bundle_error(root: &Path, reason: impl Into) -> AdapterError { + AdapterError::BundleInvalid { + root: root.to_path_buf(), + reason: reason.into(), + } +} + +fn bundle_package_name(bundle: &AdapterBundle) -> Result { + let package = bundle + .plugin_id + .clone() + .ok_or_else(|| bundle_error(&bundle.resource_root, "dsh bundle has no package name"))?; + validate_dsh_package_name(&package).map_err(AdapterError::ClaimValidation)?; + Ok(package) +} + +fn requested_profiles(ctx: &DriverCtx) -> Result, AdapterError> { + if ctx.requested_profiles.is_empty() { + return Err(AdapterError::InvalidAdapterInput { + component: ctx.component.clone(), + framework: "dsh".to_string(), + reason: "dsh adapter enable requires at least one explicit --profile".to_string(), + }); + } + let mut profiles = ctx.requested_profiles.clone(); + profiles.sort(); + profiles.dedup(); + for profile in &profiles { + validate_profile_name(profile).map_err(|reason| AdapterError::InvalidAdapterInput { + component: ctx.component.clone(), + framework: "dsh".to_string(), + reason, + })?; + } + Ok(profiles) +} + +fn validate_profile_name(profile: &str) -> Result<(), String> { + if profile.is_empty() + || matches!(profile, "." | ".." | "node_modules") + || profile.starts_with('-') + { + return Err(format!("invalid dsh profile '{profile}'")); + } + if !profile + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + { + return Err(format!( + "invalid dsh profile '{profile}': use only letters, digits, '.', '_' and '-'" + )); + } + Ok(()) +} + +fn dsh_claim(claim: &AdapterClaim) -> Result<&DshClaim, AdapterError> { + match &claim.driver_payload { + DriverPayload::Dsh(payload) => Ok(payload), + _ => Err(bundle_error( + &claim.resource_root, + "receipt payload is not a dsh claim", + )), + } +} + +fn validate_dsh_claim<'a>( + claim: &AdapterClaim, + payload: &'a DshClaim, +) -> Result<&'a DshClaim, AdapterError> { + validate_dsh_package_name(&payload.package_name).map_err(AdapterError::ClaimValidation)?; + if claim.plugin_id.as_deref() != Some(payload.package_name.as_str()) { + return Err(AdapterError::BundleInvalid { + root: claim.resource_root.clone(), + reason: "dsh receipt package name disagrees with plugin_id".to_string(), + }); + } + if payload.profiles.is_empty() { + return Err(AdapterError::BundleInvalid { + root: claim.resource_root.clone(), + reason: "dsh receipt contains no profiles".to_string(), + }); + } + let _ = dsh_claim_home(claim, payload)?; + let mut names = BTreeMap::new(); + let mut resources = BTreeMap::new(); + for profile in &payload.profiles { + validate_profile_name(&profile.name).map_err(|reason| AdapterError::BundleInvalid { + root: claim.resource_root.clone(), + reason, + })?; + if names.insert(profile.name.clone(), ()).is_some() { + return Err(AdapterError::BundleInvalid { + root: claim.resource_root.clone(), + reason: format!("dsh receipt contains duplicate profile '{}'", profile.name), + }); + } + if resources + .insert(profile.plugin_resource.clone(), ()) + .is_some() + { + return Err(AdapterError::BundleInvalid { + root: claim.resource_root.clone(), + reason: format!( + "dsh receipt reuses plugin resource '{}'", + profile.plugin_resource + ), + }); + } + let Some(resource) = claim.resource(&profile.plugin_resource) else { + return Err(AdapterError::BundleInvalid { + root: claim.resource_root.clone(), + reason: format!( + "dsh receipt references unknown resource '{}'", + profile.plugin_resource + ), + }); + }; + match &resource.kind { + ClaimResourceKind::FrameworkPlugin { + framework, + plugin_id, + } if framework == "dsh" && plugin_id == &payload.package_name => {} + _ => { + return Err(AdapterError::BundleInvalid { + root: claim.resource_root.clone(), + reason: format!( + "dsh resource '{}' is not its package plugin", + profile.plugin_resource + ), + }); + } + } + } + Ok(payload) +} + +fn dsh_claim_home<'a>( + claim: &'a AdapterClaim, + payload: &DshClaim, +) -> Result<&'a Path, AdapterError> { + let Some(resource) = claim.resource(&payload.home_resource) else { + return Err(AdapterError::BundleInvalid { + root: claim.resource_root.clone(), + reason: format!( + "dsh receipt references unknown home resource '{}'", + payload.home_resource + ), + }); + }; + match &resource.kind { + ClaimResourceKind::ExternalPath { path } + if path.is_absolute() + && path.to_str().is_some() + && !path.components().any(|component| { + matches!(component, Component::CurDir | Component::ParentDir) + }) => + { + Ok(path) + } + _ => Err(AdapterError::BundleInvalid { + root: claim.resource_root.clone(), + reason: format!( + "dsh home resource '{}' is not a normalized absolute external path", + payload.home_resource + ), + }), + } +} + +fn dsh_program() -> String { + std::env::var("DSH_BIN").unwrap_or_else(|_| "dsh".to_string()) +} + +fn dsh_program_path() -> Option { + let program = dsh_program(); + let candidate = PathBuf::from(&program); + if candidate.components().count() > 1 { + return candidate.is_file().then_some(candidate); + } + find_binary_in_path(&program) +} + +fn dsh_home(user_home: Option<&Path>) -> Option { + let configured = std::env::var_os("DSH_HOME"); + let cwd = std::env::current_dir().ok(); + resolve_dsh_home(configured.as_deref(), user_home, cwd.as_deref()) +} + +fn resolve_dsh_home( + configured: Option<&OsStr>, + user_home: Option<&Path>, + cwd: Option<&Path>, +) -> Option { + let configured = configured.filter(|value| !value.to_string_lossy().trim().is_empty()); + let path = match configured { + Some(value) => { + let display = value.to_string_lossy(); + if display == "~" { + user_home?.to_path_buf() + } else if let Some(rest) = display + .strip_prefix("~/") + .or_else(|| display.strip_prefix("~\\")) + { + user_home?.join(rest) + } else { + PathBuf::from(value) + } + } + None => user_home?.join(".dsh"), + }; + let absolute = if path.is_absolute() { + path + } else { + cwd?.join(path) + }; + Some(normalize_lexically(&absolute)) +} + +fn required_dsh_home(ctx: &DriverCtx) -> Result { + let home = + dsh_home(ctx.user_home.as_deref()).ok_or_else(|| AdapterError::InvalidAdapterInput { + component: ctx.component.clone(), + framework: "dsh".to_string(), + reason: "cannot resolve an absolute dsh home from DSH_HOME or the user home" + .to_string(), + })?; + if home.to_str().is_none() { + return Err(AdapterError::InvalidAdapterInput { + component: ctx.component.clone(), + framework: "dsh".to_string(), + reason: "resolved dsh home is not valid UTF-8 and cannot be passed as DSH_HOME" + .to_string(), + }); + } + Ok(home) +} + +fn normalize_lexically(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + _ => normalized.push(component.as_os_str()), + } + } + normalized +} + +fn dsh_command(args: [&str; N], home: &Path) -> FrameworkCommand { + // Both receipt validation and enable-time resolution reject non-UTF-8 + // homes before command construction, so this branch is unreachable. + let Some(home) = home.to_str() else { + unreachable!("validated dsh home must be UTF-8") + }; + FrameworkCommand { + program: dsh_program(), + args: args.into_iter().map(str::to_string).collect(), + stdin: None, + env_set: vec![("DSH_HOME".to_string(), home.to_string())], + env_remove: Vec::new(), + path_prepend: Vec::new(), + timeout: CLI_TIMEOUT, + } +} + +fn profile_package_state( + ctx: &DriverCtx, + home: &Path, + profile: &str, + package: &str, +) -> Result, AdapterError> { + let manifest_path = home.join("profiles").join(profile).join(PACKAGE_JSON); + let Some(bytes) = ctx.ops.read_file(&manifest_path)? else { + return Ok(Some(ProfilePackageState::Absent)); + }; + Ok(profile_package_state_from_manifest(&bytes, package)) +} + +fn profile_package_state_from_manifest(bytes: &[u8], package: &str) -> Option { + let manifest = serde_json::from_slice::(bytes).ok()?; + let dependency = manifest.dependencies.contains_key(package); + let bundle = manifest + .dsh + .and_then(|dsh| dsh.profile) + .is_some_and(|profile| profile.bundles.iter().any(|candidate| candidate == package)); + Some(match (dependency, bundle) { + (true, true) => ProfilePackageState::Registered, + (true, false) => ProfilePackageState::DependencyOnly, + (false, true) => ProfilePackageState::BundleOnly, + (false, false) => ProfilePackageState::Absent, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::adapter::driver::{AdapterOps, CliOutput}; + use anolisa_platform::fs_layout::FsLayout; + use std::sync::{Arc, Mutex}; + + struct RecordingOps { + commands: Arc>>, + reads: Arc>>, + output: CliOutput, + } + + impl AdapterOps for RecordingOps { + fn run_framework_cli(&self, command: FrameworkCommand) -> Result { + self.commands.lock().unwrap().push(command); + Ok(self.output.clone()) + } + fn copy_tree(&self, _: &Path, _: &Path) -> Result<(), AdapterError> { + unreachable!("dsh never copies files") + } + fn copy_file(&self, _: &Path, _: &Path) -> Result<(), AdapterError> { + unreachable!("dsh never copies files") + } + fn remove_tree(&self, _: &Path) -> Result { + unreachable!("dsh never removes files") + } + fn write_file(&self, _: &Path, _: &[u8]) -> Result<(), AdapterError> { + unreachable!("dsh never writes files") + } + fn create_symlink(&self, _: &Path, _: &Path) -> Result<(), AdapterError> { + unreachable!("dsh never creates symlinks") + } + fn read_file(&self, path: &Path) -> Result>, AdapterError> { + self.reads.lock().unwrap().push(path.to_path_buf()); + Ok(Some( + br#"{"dependencies":{"@anolisa/dsh-tokenless":"link:/bundle"},"dsh":{"profile":{"bundles":["@anolisa/dsh-tokenless"]}}}"# + .to_vec(), + )) + } + } + + fn ctx<'a>(root: &'a Path, ops: &'a RecordingOps, profiles: Vec) -> DriverCtx<'a> { + ctx_with_user_home(root, root, ops, profiles) + } + + fn ctx_with_user_home<'a>( + root: &'a Path, + user_home: &Path, + ops: &'a RecordingOps, + profiles: Vec, + ) -> DriverCtx<'a> { + let layout = Box::leak(Box::new(FsLayout::user(root.to_path_buf()))); + DriverCtx { + component: "tokenless".to_string(), + framework: "dsh".to_string(), + layout, + resource_root: root.to_path_buf(), + user_home: Some(user_home.to_path_buf()), + declared_plugin_id: Some("anolisa-tokenless".to_string()), + requested_profiles: profiles, + adapter_type: Some("plugin".to_string()), + declared_skills: Vec::new(), + declared_config: Vec::new(), + declared_bundle_entry: None, + framework_version_req: None, + allow_unsafe_plugin_install: false, + dry_run: false, + ops, + } + } + + fn write_bundle(root: &Path, entry: &str) { + std::fs::create_dir_all(root.join("dist")).unwrap(); + std::fs::write( + root.join(PACKAGE_JSON), + r#"{"name":"@anolisa/dsh-tokenless","dsh":{"bundle":{"patch":"./cordis.patch.yml"}}}"#, + ) + .unwrap(); + std::fs::write( + root.join("cordis.patch.yml"), + format!("- insert:\n - id: anolisa-tokenless\n name: {entry}\n"), + ) + .unwrap(); + std::fs::write(root.join("dist/index.js"), "export {}\n").unwrap(); + } + + #[test] + fn bundle_requires_package_patch_and_package_entry() { + let dir = tempfile::tempdir().unwrap(); + write_bundle(dir.path(), "'@anolisa/dsh-tokenless'"); + let bundle = read_dsh_bundle(dir.path()).unwrap(); + assert_eq!(bundle.package_name, "@anolisa/dsh-tokenless"); + assert!(read_dsh_bundle(dir.path()).is_ok()); + std::fs::write( + dir.path().join("cordis.patch.yml"), + "- insert:\n - id: anolisa-tokenless\n name: ../escape.js\n", + ) + .unwrap(); + assert!(read_dsh_bundle(dir.path()).is_err()); + } + + #[test] + fn bundle_rejects_malformed_patch_before_registration() { + let dir = tempfile::tempdir().unwrap(); + write_bundle(dir.path(), "'@anolisa/dsh-tokenless'"); + std::fs::write( + dir.path().join("cordis.patch.yml"), + "- insert:\n - id: anolisa-tokenless\n name: '@anolisa/dsh-tokenless'\n broken\n", + ) + .unwrap(); + + assert!(read_dsh_bundle(dir.path()).is_err()); + } + + #[test] + fn bundle_patch_accepts_dsh_js_values() { + let dir = tempfile::tempdir().unwrap(); + write_bundle(dir.path(), "'@anolisa/dsh-tokenless'"); + std::fs::write( + dir.path().join("cordis.patch.yml"), + "- insert:\n - id: anolisa-tokenless\n name: '@anolisa/dsh-tokenless'\n config:\n mode: !!js process.env.DSH_TOOLS_MODE\n", + ) + .unwrap(); + + assert!(read_dsh_bundle(dir.path()).is_ok()); + } + + #[cfg(unix)] + #[test] + fn bundle_rejects_patch_symlink_that_resolves_outside_root() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + write_bundle(dir.path(), "'@anolisa/dsh-tokenless'"); + let outside_patch = outside.path().join("cordis.patch.yml"); + std::fs::write(&outside_patch, "[]\n").unwrap(); + std::fs::remove_file(dir.path().join("cordis.patch.yml")).unwrap(); + symlink(&outside_patch, dir.path().join("cordis.patch.yml")).unwrap(); + + let err = read_dsh_bundle(dir.path()).expect_err("external patch must be rejected"); + assert!(err.to_string().contains("outside the bundle root")); + } + + #[test] + fn enable_records_all_profiles_and_uses_native_add() { + let dir = tempfile::tempdir().unwrap(); + write_bundle(dir.path(), "'@anolisa/dsh-tokenless'"); + let ops = RecordingOps { + commands: Arc::new(Mutex::new(Vec::new())), + reads: Arc::new(Mutex::new(Vec::new())), + output: CliOutput { + status: Some(0), + timed_out: false, + stdout: String::new(), + stderr: String::new(), + }, + }; + let driver = DshDriver::new(); + let context = ctx( + dir.path(), + &ops, + vec!["headless".to_string(), "web".to_string()], + ); + let bundle = driver.read_bundle(&context).unwrap(); + let plan = driver.plan_enable(&bundle, &context).unwrap(); + assert_eq!(plan.actions.len(), 2); + assert!( + plan.register_command.is_none(), + "a singular command must not hide additional profile mutations" + ); + let (mut claim, prepared) = driver.prepare_enable(&bundle, &context).unwrap(); + driver + .apply_enable(&mut claim, &prepared, &context, &mut ()) + .unwrap(); + let DriverPayload::Dsh(payload) = claim.driver_payload else { + panic!("expected dsh payload") + }; + assert_eq!(payload.package_name, "@anolisa/dsh-tokenless"); + assert_eq!( + payload + .profiles + .iter() + .map(|p| p.name.as_str()) + .collect::>(), + vec!["headless", "web"] + ); + let commands = ops.commands.lock().unwrap(); + assert_eq!( + commands[0].args, + vec![ + "plugin", + "--profile", + "headless", + "add", + &format!("link:{}", dir.path().display()) + ] + ); + assert_eq!( + commands[0].env_set, + [( + "DSH_HOME".to_string(), + dir.path().join(".dsh").display().to_string() + )] + ); + } + + #[test] + fn single_profile_plan_exposes_its_native_command() { + let dir = tempfile::tempdir().unwrap(); + write_bundle(dir.path(), "'@anolisa/dsh-tokenless'"); + let ops = RecordingOps { + commands: Arc::new(Mutex::new(Vec::new())), + reads: Arc::new(Mutex::new(Vec::new())), + output: CliOutput { + status: Some(0), + timed_out: false, + stdout: String::new(), + stderr: String::new(), + }, + }; + let driver = DshDriver::new(); + let context = ctx(dir.path(), &ops, vec!["web".to_string()]); + let bundle = driver.read_bundle(&context).unwrap(); + + let plan = driver.plan_enable(&bundle, &context).unwrap(); + + assert!(plan.register_command.is_some()); + } + + #[test] + fn no_profile_is_rejected_instead_of_defaulting() { + let dir = tempfile::tempdir().unwrap(); + write_bundle(dir.path(), "'@anolisa/dsh-tokenless'"); + let ops = RecordingOps { + commands: Arc::new(Mutex::new(Vec::new())), + reads: Arc::new(Mutex::new(Vec::new())), + output: CliOutput { + status: Some(0), + timed_out: false, + stdout: String::new(), + stderr: String::new(), + }, + }; + let context = ctx(dir.path(), &ops, Vec::new()); + let err = DshDriver::new() + .prepare_enable(&DshDriver::new().read_bundle(&context).unwrap(), &context) + .expect_err("profile selection is mandatory"); + assert!(err.to_string().contains("--profile")); + } + + #[test] + fn profile_validation_matches_dsh_reserved_name() { + assert!(validate_profile_name("node_modules").is_err()); + assert!(validate_profile_name("--flag").is_err()); + assert!(validate_profile_name("custom-profile").is_ok()); + } + + #[test] + fn relative_dsh_home_resolution_is_anchored_to_enable_cwd() { + let first_cwd = Path::new("/work/first"); + let second_cwd = Path::new("/work/second"); + let configured = OsStr::new("state/dsh"); + + assert_eq!( + resolve_dsh_home( + Some(configured), + Some(Path::new("/home/user")), + Some(first_cwd), + ), + Some(first_cwd.join("state/dsh")) + ); + assert_eq!( + resolve_dsh_home( + Some(configured), + Some(Path::new("/home/user")), + Some(second_cwd), + ), + Some(second_cwd.join("state/dsh")) + ); + assert_eq!( + resolve_dsh_home(Some(OsStr::new("/var/lib/dsh")), None, None), + Some(PathBuf::from("/var/lib/dsh")), + "an absolute DSH_HOME must not depend on a readable cwd" + ); + } + + #[test] + fn receipt_rejects_unvalidated_dsh_home_resource() { + let dir = tempfile::tempdir().unwrap(); + write_bundle(dir.path(), "'@anolisa/dsh-tokenless'"); + let ops = RecordingOps { + commands: Arc::new(Mutex::new(Vec::new())), + reads: Arc::new(Mutex::new(Vec::new())), + output: CliOutput { + status: Some(0), + timed_out: false, + stdout: String::new(), + stderr: String::new(), + }, + }; + let driver = DshDriver::new(); + let context = ctx(dir.path(), &ops, vec!["web".to_string()]); + let bundle = driver.read_bundle(&context).unwrap(); + let (mut claim, _) = driver.prepare_enable(&bundle, &context).unwrap(); + let allowed_home = dir.path().join(".dsh"); + claim + .validate(context.layout, std::slice::from_ref(&allowed_home)) + .expect("enable-time dsh home must validate against its resolved boundary"); + let payload = dsh_claim(&claim).unwrap().clone(); + let resource = claim + .resources + .iter_mut() + .find(|resource| resource.id == payload.home_resource) + .unwrap(); + resource.kind = ClaimResourceKind::ExternalPath { + path: PathBuf::from("relative/dsh"), + }; + + assert!( + claim + .validate(context.layout, std::slice::from_ref(&allowed_home)) + .is_err(), + "Manager validation must reject a receipt-derived relative home" + ); + let err = validate_dsh_claim(&claim, &payload).expect_err("relative home must fail"); + assert!( + err.to_string() + .contains("normalized absolute external path") + ); + } + + #[test] + fn reenable_plan_fails_closed_when_bundle_cannot_be_read() { + let dir = tempfile::tempdir().unwrap(); + write_bundle(dir.path(), "'@anolisa/dsh-tokenless'"); + let ops = RecordingOps { + commands: Arc::new(Mutex::new(Vec::new())), + reads: Arc::new(Mutex::new(Vec::new())), + output: CliOutput { + status: Some(0), + timed_out: false, + stdout: String::new(), + stderr: String::new(), + }, + }; + let driver = DshDriver::new(); + let context = ctx(dir.path(), &ops, vec!["web".to_string()]); + let bundle = driver.read_bundle(&context).unwrap(); + let (prior, _) = driver.prepare_enable(&bundle, &context).unwrap(); + std::fs::write(dir.path().join(PACKAGE_JSON), "not json\n").unwrap(); + + assert!(driver.plan_reenable_cleanup(&prior, &context).is_err()); + assert!(ops.commands.lock().unwrap().is_empty()); + } + + #[test] + fn reenable_removes_only_profiles_no_longer_claimed() { + let dir = tempfile::tempdir().unwrap(); + let first_home = tempfile::tempdir().unwrap(); + let later_home = tempfile::tempdir().unwrap(); + write_bundle(dir.path(), "'@anolisa/dsh-tokenless'"); + let ops = RecordingOps { + commands: Arc::new(Mutex::new(Vec::new())), + reads: Arc::new(Mutex::new(Vec::new())), + output: CliOutput { + status: Some(0), + timed_out: false, + stdout: String::new(), + stderr: String::new(), + }, + }; + let driver = DshDriver::new(); + let prior_ctx = ctx_with_user_home( + dir.path(), + first_home.path(), + &ops, + vec!["retained".to_string(), "stale".to_string()], + ); + let bundle = driver.read_bundle(&prior_ctx).unwrap(); + let (prior, _) = driver.prepare_enable(&bundle, &prior_ctx).unwrap(); + let next_ctx = ctx_with_user_home( + dir.path(), + later_home.path(), + &ops, + vec!["retained".to_string()], + ); + let (next, _) = driver.prepare_enable(&bundle, &next_ctx).unwrap(); + + let report = driver + .cleanup_replaced_claim(&prior, &next, &next_ctx) + .unwrap(); + + assert!(report.cleanup_complete); + assert_eq!( + ops.commands.lock().unwrap()[0].args, + [ + "plugin", + "--profile", + "stale", + "remove", + "@anolisa/dsh-tokenless", + ] + ); + assert_eq!( + ops.commands.lock().unwrap()[0].env_set, + [( + "DSH_HOME".to_string(), + first_home.path().join(".dsh").display().to_string(), + )] + ); + assert_eq!( + ops.reads.lock().unwrap()[0], + first_home.path().join(".dsh/profiles/stale/package.json") + ); + } + + #[test] + fn cleanup_retains_receipt_on_pnpm_infrastructure_failure() { + let dir = tempfile::tempdir().unwrap(); + write_bundle(dir.path(), "'@anolisa/dsh-tokenless'"); + let ops = RecordingOps { + commands: Arc::new(Mutex::new(Vec::new())), + reads: Arc::new(Mutex::new(Vec::new())), + output: CliOutput { + status: Some(127), + timed_out: false, + stdout: String::new(), + stderr: "dsh: pnpm not found on PATH".to_string(), + }, + }; + let driver = DshDriver::new(); + let prior_ctx = ctx(dir.path(), &ops, vec!["stale".to_string()]); + let bundle = driver.read_bundle(&prior_ctx).unwrap(); + let (prior, _) = driver.prepare_enable(&bundle, &prior_ctx).unwrap(); + let next_ctx = ctx(dir.path(), &ops, vec!["retained".to_string()]); + let (next, _) = driver.prepare_enable(&bundle, &next_ctx).unwrap(); + + let report = driver + .cleanup_replaced_claim(&prior, &next, &next_ctx) + .unwrap(); + + assert!(!report.cleanup_complete); + assert!(report.messages[0].contains("pnpm not found")); + } + + #[test] + fn status_reads_profile_manifest_without_invoking_dsh_plugin() { + let dir = tempfile::tempdir().unwrap(); + write_bundle(dir.path(), "'@anolisa/dsh-tokenless'"); + let ops = RecordingOps { + commands: Arc::new(Mutex::new(Vec::new())), + reads: Arc::new(Mutex::new(Vec::new())), + output: CliOutput { + status: Some(0), + timed_out: false, + stdout: String::new(), + stderr: String::new(), + }, + }; + let driver = DshDriver::new(); + let context = ctx(dir.path(), &ops, vec!["web".to_string()]); + let bundle = driver.read_bundle(&context).unwrap(); + let (claim, _) = driver.prepare_enable(&bundle, &context).unwrap(); + + driver.status(&claim, &context).unwrap(); + + assert!(ops.commands.lock().unwrap().is_empty()); + } + + #[test] + fn profile_manifest_package_match_is_exact() { + let package = "@anolisa/dsh-tokenless"; + let missing = br#"{"dependencies":{"@anolisa/dsh-tokenless-extra":"1"},"dsh":{"profile":{"bundles":["@anolisa/dsh-tokenless-extra"]}}}"#; + assert_eq!( + profile_package_state_from_manifest(missing, package), + Some(ProfilePackageState::Absent) + ); + let present = br#"{"dependencies":{"@anolisa/dsh-tokenless":"1"},"dsh":{"profile":{"bundles":["@anolisa/dsh-tokenless"]}}}"#; + assert_eq!( + profile_package_state_from_manifest(present, package), + Some(ProfilePackageState::Registered) + ); + } +} diff --git a/src/anolisa/crates/anolisa-core/src/adapter/hermes.rs b/src/anolisa/crates/anolisa-core/src/adapter/hermes.rs index cf0822e6b4..d938be9835 100644 --- a/src/anolisa/crates/anolisa-core/src/adapter/hermes.rs +++ b/src/anolisa/crates/anolisa-core/src/adapter/hermes.rs @@ -991,6 +991,7 @@ mod tests { resource_root: dir.path().to_path_buf(), user_home: Some(PathBuf::from("/tmp/test-home-a")), declared_plugin_id: Some("agent-sec".to_string()), + requested_profiles: Vec::new(), adapter_type: None, declared_skills: Vec::new(), declared_config: Vec::new(), @@ -1019,6 +1020,7 @@ mod tests { resource_root: dir.path().join("bundle"), user_home: Some(dir.path().join("home")), declared_plugin_id: Some("agent-sec".into()), + requested_profiles: Vec::new(), adapter_type: Some("plugin".into()), declared_skills: vec![super::super::driver::DeclaredSkill { name: "sec-audit".into(), @@ -1082,6 +1084,7 @@ mod tests { resource_root: root.to_path_buf(), user_home: Some(PathBuf::from("/tmp/test-home-c")), declared_plugin_id: Some("test-plugin".to_string()), + requested_profiles: Vec::new(), adapter_type: None, declared_skills: vec![DeclaredSkill { name: "sec-audit".to_string(), @@ -1149,6 +1152,7 @@ mod tests { resource_root: dir.path().to_path_buf(), user_home: Some(PathBuf::from("/tmp/test-home-d")), declared_plugin_id: None, + requested_profiles: Vec::new(), adapter_type: Some("skill_bundle".to_string()), declared_skills: vec![DeclaredSkill { name: "install-hermes".to_string(), diff --git a/src/anolisa/crates/anolisa-core/src/adapter/manager.rs b/src/anolisa/crates/anolisa-core/src/adapter/manager.rs index b854196bc7..3e8bc8f246 100644 --- a/src/anolisa/crates/anolisa-core/src/adapter/manager.rs +++ b/src/anolisa/crates/anolisa-core/src/adapter/manager.rs @@ -36,7 +36,9 @@ use anolisa_platform::pkg_files::PackageFileQuery; use anolisa_platform::rpm_query::RpmPackageQuery; use super::AdapterError; -use super::claim::{AdapterClaim, AdapterSourceRevision, ClaimStatus}; +use super::claim::{ + AdapterClaim, AdapterSourceRevision, ClaimResourceKind, ClaimStatus, DriverPayload, +}; use super::driver::{ AdapterCondition, AdapterConditionKind, AdapterOps, AdapterStatusReport, AdapterSummary, CliOutput, ConditionStatus, DisableReport, DriverCtx, DriverPlan, EnableProgress, @@ -100,6 +102,10 @@ pub struct EnableOptions { /// adapter. Even when set, the driver adds the framework's unsafe flag /// only if the host's install help exposes it. pub allow_unsafe_plugin_install: bool, + /// Explicit profiles for profile-scoped framework adapters such as dsh. + /// An empty list means no profiles were selected; profile-scoped drivers + /// reject that input rather than silently mutating an implicit profile. + pub profiles: Vec, } /// Outcome of [`AdapterManager::disable`]. @@ -355,8 +361,8 @@ impl AdapterDecl { } } -/// Trust decision for the receipt symlink *targets* of one -/// `(component, framework)`: the roots targets may resolve under, plus +/// Trust decision for the external resources of one `(component, framework)`: +/// the roots symlink targets may resolve under, plus /// whether the two-source condition — RPM provenance recorded in state /// **and** a contract-declared `[adapters.backends.rpm].resource_root` /// — currently grants external-root trust. This is the single decision @@ -391,6 +397,19 @@ impl ExternalRootTrust { self.anchor.as_slice() } + /// Restore a Manager-written dsh home anchor as an allowed external + /// root. Unlike ordinary receipt data, this value was captured only + /// after the enable-time `DSH_HOME` boundary validated, so environment + /// drift cannot redirect later reads or cleanup commands. + fn extend_allowed_roots(&self, framework: &str, roots: &mut Vec) { + if framework == "dsh" + && let Some(anchor) = &self.anchor + && !roots.contains(anchor) + { + roots.push(anchor.clone()); + } + } + /// Persist or clear the enable-time anchor under the same /// eligibility that governs anchor consumption — by construction the /// write condition and the read condition can never diverge. The @@ -414,6 +433,18 @@ impl ExternalRootTrust { claim: &AdapterClaim, trusted_owned_roots: &[PathBuf], ) { + if claim.framework == "dsh" { + if let Some(root) = dsh_home_anchor(claim) { + state.upsert_adapter_trust_root( + &claim.component, + &claim.framework, + root.to_path_buf(), + ); + } else { + state.remove_adapter_trust_root(&claim.component, &claim.framework); + } + return; + } if self.anchor_eligible && claim.requires_external_symlink_trust(layout, trusted_owned_roots) { @@ -428,6 +459,19 @@ impl ExternalRootTrust { } } +/// Return the already-validated dsh home resource for anchor persistence. +/// The driver's claim validation establishes the exact payload/resource +/// relationship before [`ExternalRootTrust::sync_anchor`] is called. +fn dsh_home_anchor(claim: &AdapterClaim) -> Option<&Path> { + let DriverPayload::Dsh(payload) = &claim.driver_payload else { + return None; + }; + match &claim.resource(&payload.home_resource)?.kind { + ClaimResourceKind::ExternalPath { path } => Some(path), + _ => None, + } +} + /// A state root paired with the datadir roots it may use for component /// contract resolution. Contract lookup for a component found in this /// state root searches only the paired datadir roots — not datadirs @@ -826,6 +870,13 @@ impl AdapterManager { adapter_type: adapter_type.clone(), }); } + if !options.profiles.is_empty() && framework != "dsh" { + return Err(AdapterError::InvalidAdapterInput { + component: component.to_string(), + framework: framework.clone(), + reason: "--profile is only valid for the dsh framework".to_string(), + }); + } let declared_plugin_id = declared_plugin_id(&manifest, &framework); let skill_specs = declared_skills(&manifest, &framework); @@ -926,6 +977,7 @@ impl AdapterManager { resource_root: resource_root.clone(), user_home: self.user_home.clone(), declared_plugin_id: declared_plugin_id.clone(), + requested_profiles: options.profiles.clone(), adapter_type: adapter_type.clone(), declared_skills: Vec::new(), declared_config: Vec::new(), @@ -936,6 +988,7 @@ impl AdapterManager { ops: &probe_ops, }; let mut allowed_roots = driver.allowed_external_roots(&probe_ctx); + trust.extend_allowed_roots(&framework, &mut allowed_roots); allowed_roots.push(resource_root.clone()); // Skill sources that live outside the resource root (e.g. // `{datadir}/skills//`) must also be readable by the @@ -965,6 +1018,7 @@ impl AdapterManager { resource_root: resource_root.clone(), user_home: self.user_home.clone(), declared_plugin_id, + requested_profiles: options.profiles.clone(), adapter_type, declared_skills: skills, declared_config: config, @@ -979,7 +1033,8 @@ impl AdapterManager { let bundle = driver.read_bundle(&ctx)?; let mut plan = driver.plan_enable(&bundle, &ctx)?; if let Some(prior) = state.find_adapter_claim(component, &framework) { - let claim_allowed_roots = driver.allowed_external_roots(&ctx); + let mut claim_allowed_roots = driver.allowed_external_roots(&ctx); + trust.extend_allowed_roots(&framework, &mut claim_allowed_roots); prior.validate_with_trust( &self.layout, &claim_allowed_roots, @@ -1042,7 +1097,8 @@ impl AdapterManager { // disable can show `post_disable` notices from the receipt alone. // Inert text — never expanded or executed. claim.notices = all_notices; - let claim_allowed_roots = driver.allowed_external_roots(&ctx); + let mut claim_allowed_roots = driver.allowed_external_roots(&ctx); + trust.extend_allowed_roots(&framework, &mut claim_allowed_roots); let prior = state.find_adapter_claim(component, &framework).cloned(); if let Some(prior) = &prior { // A forged prior receipt must not gain authority merely because a @@ -1277,6 +1333,7 @@ impl AdapterManager { .discover_resource_root(component, &framework) .map(|(path, _)| path) .unwrap_or_else(|| claim.resource_root.clone()); + let trust = self.external_root_trust_from_state(component, &framework, &state); let label = format!("adapter disable {component} {framework}"); let probe_ops = ManagerOps::new( @@ -1294,6 +1351,7 @@ impl AdapterManager { resource_root: resource_root.clone(), user_home: self.user_home.clone(), declared_plugin_id: None, + requested_profiles: Vec::new(), adapter_type: claim.adapter_type.clone(), declared_skills: Vec::new(), declared_config: Vec::new(), @@ -1304,6 +1362,7 @@ impl AdapterManager { ops: &probe_ops, }; let mut allowed_roots = driver.allowed_external_roots(&probe_ctx); + trust.extend_allowed_roots(&framework, &mut allowed_roots); allowed_roots.push(resource_root.clone()); drop(probe_ctx); drop(probe_ops); @@ -1323,6 +1382,7 @@ impl AdapterManager { resource_root, user_home: self.user_home.clone(), declared_plugin_id: None, + requested_profiles: Vec::new(), adapter_type: claim.adapter_type.clone(), declared_skills: Vec::new(), declared_config: Vec::new(), @@ -1334,10 +1394,11 @@ impl AdapterManager { }; // Re-validate the receipt before acting on it (forged-state guard). - let trust = self.external_root_trust_from_state(component, &framework, &state); + let mut claim_allowed_roots = driver.allowed_external_roots(&ctx); + trust.extend_allowed_roots(&framework, &mut claim_allowed_roots); claim.validate_with_trust( &self.layout, - &driver.allowed_external_roots(&ctx), + &claim_allowed_roots, &trust.target_roots, trust.exact_targets(), )?; @@ -1444,6 +1505,7 @@ impl AdapterManager { .map(|(path, _)| path) }) .unwrap_or_else(|| claim.resource_root.clone()); + let trust = self.external_root_trust_from_state(&claim.component, &framework, &state); let label = format!("adapter status {} {framework}", claim.component); // Two-phase ops mirroring enable/disable: probe to learn the // driver's external roots, then rebuild so a driver that verifies @@ -1465,6 +1527,7 @@ impl AdapterManager { resource_root: resource_root.clone(), user_home: self.user_home.clone(), declared_plugin_id: None, + requested_profiles: Vec::new(), adapter_type: claim.adapter_type.clone(), declared_skills: Vec::new(), declared_config: Vec::new(), @@ -1475,6 +1538,7 @@ impl AdapterManager { ops: &probe_ops, }; let mut allowed_roots = driver.allowed_external_roots(&probe_ctx); + trust.extend_allowed_roots(&framework, &mut allowed_roots); allowed_roots.push(resource_root.clone()); drop(probe_ctx); drop(probe_ops); @@ -1494,6 +1558,7 @@ impl AdapterManager { resource_root, user_home: self.user_home.clone(), declared_plugin_id: None, + requested_profiles: Vec::new(), adapter_type: claim.adapter_type.clone(), declared_skills: Vec::new(), declared_config: Vec::new(), @@ -1504,10 +1569,11 @@ impl AdapterManager { ops: &ops, }; - let trust = self.external_root_trust_from_state(&claim.component, &framework, &state); + let mut claim_allowed_roots = driver.allowed_external_roots(&ctx); + trust.extend_allowed_roots(&framework, &mut claim_allowed_roots); claim.validate_with_trust( &self.layout, - &driver.allowed_external_roots(&ctx), + &claim_allowed_roots, &trust.target_roots, trust.exact_targets(), )?; @@ -3574,6 +3640,8 @@ fn allowed_adapter_types(framework: &str) -> Option<&'static [&'static str]> { // Qoder installs a directory-named plugin and activates it via // settings.json entries: plugin only (no extension / skill_bundle). "qoder" => Some(&["plugin"]), + // dsh bundles are native plugins registered per explicit profile. + "dsh" => Some(&["plugin"]), // Extension frameworks require an explicit type. Qwen Code delegates // artifact and activation mutations to its native CLI. "cosh" | "qwencode" => Some(&["extension"]), @@ -3986,17 +4054,22 @@ fn plan_disable_report(claim: &AdapterClaim) -> DisableReport { .push("would remove the Qwen Code activation policy via the qwen CLI".to_string()); None } + DriverPayload::Dsh(dsh) => { + for profile in &dsh.profiles { + cleanup_ids.push(&profile.plugin_resource); + } + None + } }; // Whether disable uninstalls (Claude Code / Qoder semantics) rather than // unregisters (registry-only). Purely cosmetic for the plan text. - let plugin_verb = if matches!( - claim.driver_payload, - DriverPayload::ClaudeCode(_) | DriverPayload::Qoder(_) | DriverPayload::QwenCode(_) - ) { - "uninstall" - } else { - "unregister" + let plugin_verb = match claim.driver_payload { + DriverPayload::ClaudeCode(_) | DriverPayload::Qoder(_) | DriverPayload::QwenCode(_) => { + "uninstall" + } + DriverPayload::Dsh(_) => "remove", + _ => "unregister", }; for resource in &claim.resources { @@ -4201,6 +4274,79 @@ mod tests { (layout, home) } + #[test] + fn dsh_home_anchor_survives_environment_root_drift() { + use crate::adapter::claim::{ + CLAIM_SCHEMA_VERSION, ClaimResource, DRIVER_SCHEMA_VERSION, DshClaim, DshProfileClaim, + }; + + let tmp = tempfile::tempdir().expect("tempdir"); + let (layout, _) = test_user_layout(tmp.path()); + let enabled_home = tmp.path().join("first-dsh-home"); + let claim = AdapterClaim { + claim_schema: CLAIM_SCHEMA_VERSION, + component: "tokenless".to_string(), + framework: "dsh".to_string(), + plugin_id: Some("@anolisa/dsh-tokenless".to_string()), + adapter_type: Some("plugin".to_string()), + enabled_at: "2026-08-16T00:00:00Z".to_string(), + resource_root: tmp.path().join("bundle"), + bundle_digest: None, + source_revision: None, + materialized_files: Vec::new(), + driver_schema: DRIVER_SCHEMA_VERSION, + status: ClaimStatus::Enabled, + notices: Vec::new(), + resources: vec![ + ClaimResource { + id: "dsh_home".to_string(), + purpose: "dsh_home".to_string(), + kind: ClaimResourceKind::ExternalPath { + path: enabled_home.clone(), + }, + }, + ClaimResource { + id: "dsh_plugin_0".to_string(), + purpose: "dsh_plugin_profile_web".to_string(), + kind: ClaimResourceKind::FrameworkPlugin { + framework: "dsh".to_string(), + plugin_id: "@anolisa/dsh-tokenless".to_string(), + }, + }, + ], + driver_payload: DriverPayload::Dsh(DshClaim { + package_name: "@anolisa/dsh-tokenless".to_string(), + home_resource: "dsh_home".to_string(), + profiles: vec![DshProfileClaim { + name: "web".to_string(), + plugin_resource: "dsh_plugin_0".to_string(), + }], + }), + }; + let mut state = StateStore::empty(); + let initial = ExternalRootTrust { + target_roots: Vec::new(), + anchor: None, + anchor_eligible: false, + }; + + initial.sync_anchor(&mut state, &layout, &claim, &[]); + + let anchored = ExternalRootTrust { + target_roots: Vec::new(), + anchor: state + .find_adapter_trust_root("tokenless", "dsh") + .map(Path::to_path_buf), + anchor_eligible: false, + }; + let mut later_roots = vec![tmp.path().join("second-dsh-home")]; + anchored.extend_allowed_roots("dsh", &mut later_roots); + assert_eq!( + later_roots, + [tmp.path().join("second-dsh-home"), enabled_home] + ); + } + /// The framework-agnostic Manager resolves the requirement by precedence /// only and never validates it — a present-but-empty value is passed /// through verbatim (the owning driver decides validity), and it never @@ -4782,6 +4928,8 @@ mod tests { assert!(ok("cosh", Some("extension"))); assert!(ok("qoder", Some("plugin"))); assert!(ok("qoder", None), "qoder defaults to plugin"); + assert!(ok("dsh", Some("plugin"))); + assert!(ok("dsh", None), "dsh defaults to plugin"); assert!(ok("qwencode", Some("extension"))); } diff --git a/src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs b/src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs index 0cd8cf19fe..d7ae1dbd2d 100644 --- a/src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs +++ b/src/anolisa/crates/anolisa-core/src/adapter/openclaw.rs @@ -3662,6 +3662,7 @@ mod tests { resource_root: dir.path().to_path_buf(), user_home: Some(PathBuf::from("/tmp/test-home")), declared_plugin_id: None, + requested_profiles: Vec::new(), adapter_type: Some("skill_bundle".to_string()), declared_skills: vec![DeclaredSkill { name: "install-openclaw".to_string(), @@ -3756,6 +3757,7 @@ mod tests { resource_root: PathBuf::from("/tmp/test-home/resource"), user_home: Some(PathBuf::from("/tmp/test-home")), declared_plugin_id: None, + requested_profiles: Vec::new(), adapter_type: adapter_type.map(str::to_string), declared_skills: Vec::new(), declared_config: Vec::new(), diff --git a/src/anolisa/crates/anolisa-core/src/adapter/qoder.rs b/src/anolisa/crates/anolisa-core/src/adapter/qoder.rs index f65936bf6b..857a85406e 100644 --- a/src/anolisa/crates/anolisa-core/src/adapter/qoder.rs +++ b/src/anolisa/crates/anolisa-core/src/adapter/qoder.rs @@ -1916,6 +1916,7 @@ mod tests { resource_root: root.to_path_buf(), user_home: Some(PathBuf::from("/tmp/qoder-home")), declared_plugin_id: Some("tokenless".to_string()), + requested_profiles: Vec::new(), adapter_type: Some("plugin".to_string()), declared_skills: Vec::new(), declared_config: Vec::new(), diff --git a/src/anolisa/crates/anolisa-core/src/adapter/qwencode.rs b/src/anolisa/crates/anolisa-core/src/adapter/qwencode.rs index 34fc82a5be..658290b4de 100644 --- a/src/anolisa/crates/anolisa-core/src/adapter/qwencode.rs +++ b/src/anolisa/crates/anolisa-core/src/adapter/qwencode.rs @@ -1456,6 +1456,7 @@ mod tests { resource_root: resource_root.to_path_buf(), user_home: Some(user_home.to_path_buf()), declared_plugin_id: Some("tokenless".to_string()), + requested_profiles: Vec::new(), adapter_type: Some("extension".to_string()), declared_skills: Vec::new(), declared_config: Vec::new(), diff --git a/src/anolisa/crates/anolisa-core/src/adapter/registry.rs b/src/anolisa/crates/anolisa-core/src/adapter/registry.rs index d5550f258a..dcdf0c9db5 100644 --- a/src/anolisa/crates/anolisa-core/src/adapter/registry.rs +++ b/src/anolisa/crates/anolisa-core/src/adapter/registry.rs @@ -8,6 +8,7 @@ use super::claude_code::ClaudeCodeDriver; use super::codex::CodexDriver; use super::cosh::CoshDriver; use super::driver::FrameworkDriver; +use super::dsh::DshDriver; use super::hermes::HermesDriver; use super::openclaw::OpenClawDriver; use super::qoder::QoderDriver; @@ -26,6 +27,7 @@ impl DriverRegistry { Box::new(OpenClawDriver::new()), Box::new(HermesDriver::new()), Box::new(CoshDriver::new()), + Box::new(DshDriver::new()), Box::new(CodexDriver::new()), Box::new(ClaudeCodeDriver::new()), Box::new(QoderDriver::new()), @@ -69,6 +71,7 @@ mod tests { assert!(reg.contains("openclaw")); assert!(reg.contains("hermes")); assert!(reg.contains("cosh")); + assert!(reg.contains("dsh")); assert!(reg.contains("codex")); assert!(reg.contains("claude-code")); assert!(reg.contains("qoder")); @@ -79,6 +82,7 @@ mod tests { "openclaw", "hermes", "cosh", + "dsh", "codex", "claude-code", "qoder", diff --git a/src/anolisa/crates/anolisa-core/src/state_store.rs b/src/anolisa/crates/anolisa-core/src/state_store.rs index 322282e38c..724566d00e 100644 --- a/src/anolisa/crates/anolisa-core/src/state_store.rs +++ b/src/anolisa/crates/anolisa-core/src/state_store.rs @@ -67,21 +67,18 @@ struct StateFileV5 { adapter_trust_roots: Vec, } -/// Manager-written record of the external adapter resource root that was -/// contract-validated at the receipt's last successful enable. +/// Manager-written record of an external adapter root validated at enable. /// -/// Receipt symlink targets are never trusted from the receipt itself (a -/// forged receipt must not self-authorize). But a legitimate RPM update -/// may move the resource root and refresh the contract snapshot while an -/// enabled receipt still points at the old root — without this anchor, -/// that receipt could no longer be validated for status/disable/re-enable -/// and became permanently stuck. Only the Manager writes this record -/// (enable upserts, disable removes); drivers never see it. +/// This anchors either a contract-validated symlink target or a framework +/// home resolved and validated from the enable-time process environment. +/// Without it, an RPM update or later environment drift could make the +/// authoritative receipt impossible to verify or clean up. Only the Manager +/// writes this record (enable upserts, disable removes); drivers never see it. /// -/// Despite the type name, validation consumes the anchor as an -/// **exact-equality** symlink-target allowance, never as a root: a -/// state-resident path (forgeable in user mode) authorizes only itself, -/// nothing beneath it, and no write outside anolisa's own layout. +/// Symlink validation consumes the anchor as an **exact-equality** target +/// allowance. Drivers whose native CLI owns a persisted framework home may +/// consume that home as an external IO root; the initial value must first +/// validate against the driver's environment-derived boundary. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AdapterTrustRoot { /// Component the anchored receipt belongs to. diff --git a/src/anolisa/crates/anolisa-core/tests/adapter_manager.rs b/src/anolisa/crates/anolisa-core/tests/adapter_manager.rs index 02db34f7b0..2b929a660f 100644 --- a/src/anolisa/crates/anolisa-core/tests/adapter_manager.rs +++ b/src/anolisa/crates/anolisa-core/tests/adapter_manager.rs @@ -2149,6 +2149,7 @@ fn authorized_unsafe_supported_includes_flag_once() { false, EnableOptions { allow_unsafe_plugin_install: true, + profiles: Vec::new(), }, ) .expect("authorized unsafe enable"); @@ -2188,6 +2189,7 @@ fn authorized_unsafe_unsupported_blocks() { false, EnableOptions { allow_unsafe_plugin_install: true, + profiles: Vec::new(), }, ) .expect_err("authorized-but-unsupported unsafe must block"); @@ -2219,6 +2221,7 @@ fn authorized_unsafe_deprecated_noop_blocks() { false, EnableOptions { allow_unsafe_plugin_install: true, + profiles: Vec::new(), }, ) .expect_err("a deprecated no-op cannot satisfy unsafe authorization"); @@ -2879,6 +2882,7 @@ dest = "{{datadir}}/adapters/{{component}}/openclaw/" false, EnableOptions { allow_unsafe_plugin_install: true, + profiles: Vec::new(), }, ) .expect_err("unsafe authorization must be rejected for skill_bundle"); @@ -3477,6 +3481,7 @@ fn authorized_unsafe_dry_run_shows_flag_without_mutation() { true, EnableOptions { allow_unsafe_plugin_install: true, + profiles: Vec::new(), }, ) .expect("dry-run enable"); @@ -3518,6 +3523,7 @@ fn central_log_records_authorized_unsafe_install_argv() { false, EnableOptions { allow_unsafe_plugin_install: true, + profiles: Vec::new(), }, ) .expect("authorized unsafe enable"); @@ -3553,6 +3559,7 @@ dest = "{{datadir}}/adapters/{{component}}/hermes/" false, EnableOptions { allow_unsafe_plugin_install: true, + profiles: Vec::new(), }, ) .expect_err("unsafe authorization must be rejected for a non-OpenClaw framework");