Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/anolisa/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/anolisa/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 34 additions & 1 deletion src/anolisa/crates/anolisa-cli/src/commands/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String>,
},
/// Disable a previously enabled adapter.
Disable {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -337,6 +343,7 @@ fn handle_enable(
component: &str,
framework: Option<&str>,
allow_unsafe_plugin_install: bool,
profiles: Vec<String>,
) -> Result<(), CliError> {
const COMMAND: &str = "adapter enable";
let (component, view) = common::resolve_adapter_target(component, ctx, COMMAND)?;
Expand All @@ -348,6 +355,7 @@ fn handle_enable(
ctx.dry_run,
EnableOptions {
allow_unsafe_plugin_install,
profiles,
},
)
.map_err(|e| map_err(COMMAND, e))?;
Expand Down Expand Up @@ -678,13 +686,15 @@ mod tests {
component,
framework,
allow_unsafe_plugin_install,
profiles,
} => {
assert_eq!(component, "tokenless");
assert!(framework.is_none());
assert!(
!allow_unsafe_plugin_install,
"unsafe install must default to false"
);
assert!(profiles.is_empty());
}
_ => panic!("expected enable"),
}
Expand Down Expand Up @@ -713,13 +723,36 @@ mod tests {
component,
framework,
allow_unsafe_plugin_install,
profiles,
} => {
assert_eq!(component, "tokenless");
assert_eq!(framework.as_deref(), Some("openclaw"));
assert!(
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"),
}
Expand Down
1 change: 1 addition & 0 deletions src/anolisa/crates/anolisa-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/anolisa/crates/anolisa-core/src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
108 changes: 107 additions & 1 deletion src/anolisa/crates/anolisa-core/src/adapter/claim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(|_| {
Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -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,
Comment thread
kongche-jbw marked this conversation as resolved.
/// 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<DshProfileClaim>,
}

/// 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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 2 additions & 0 deletions src/anolisa/crates/anolisa-core/src/adapter/claude_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions src/anolisa/crates/anolisa-core/src/adapter/cosh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
3 changes: 3 additions & 0 deletions src/anolisa/crates/anolisa-core/src/adapter/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Explicit framework profiles selected by the caller for profile-scoped
/// adapters. Drivers that are not profile-scoped ignore this list.
pub requested_profiles: Vec<String>,
/// Adapter type declared in the component manifest. Absent means the
/// legacy plugin adapter model.
pub adapter_type: Option<String>,
Expand Down
Loading
Loading