use super::*;
use agent_secret_contract::SecretRef;
use std::collections::BTreeMap;
pub const SANDBOX_SKILLS_PATH: &str = "/internal/v1/workspace/sandboxes/{sandboxId}/skills";
pub const SPACE_SKILLS_PATH: &str = "/internal/v1/workspace/spaces/{spaceId}/skills";
pub const SKILLS_SEARCH_PATH: &str = "/internal/v1/workspace/skills:search";
pub const SANDBOX_SKILL_INSTALL_ROOT: &str = "skills";
pub const SPACE_SKILL_INSTALL_ROOT: &str = "skills";
pub const SPACE_CLI_INSTALL_ROOT: &str = ".local/bin";
pub const SKILL_CLI_MAX_BYTES: usize = 128 * 1024 * 1024;
pub const SKILL_ENV_FILE: &str = "skills/.env";
pub const SANDBOX_SKILL_DEFAULT_VERSION: &str = "latest";
pub const SANDBOX_SKILL_DEFAULT_PROVIDER: &str = "local";
pub const SKILL_ENV_MAX_ENTRIES: usize = 64;
pub const SKILL_ENV_MAX_VALUE_BYTES: usize = 8 * 1024;
fn default_sandbox_skill_provider() -> String {
SANDBOX_SKILL_DEFAULT_PROVIDER.to_string()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct InstallSandboxSkillRequest {
pub group: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default = "default_sandbox_skill_provider")]
pub provider: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env_refs: BTreeMap<String, SecretRef>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct InstallSandboxSkillResource {
pub sandbox_id: String,
pub provider: String,
pub group: String,
pub name: String,
pub version: String,
pub install_path: String,
pub files: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct InstallSpaceSkillRequest {
pub group: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default = "default_sandbox_skill_provider")]
pub provider: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env_refs: BTreeMap<String, SecretRef>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct InstallSpaceSkillResource {
pub space_id: String,
pub computer_id: String,
pub provider: String,
pub group: String,
pub name: String,
pub version: String,
pub install_path: String,
pub files: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub clis: Vec<InstalledSpaceCli>,
pub revision: u64,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct InstalledSpaceCli {
pub full_name: String,
pub version: String,
pub entrypoint: String,
pub install_path: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillPackageFile {
pub path: String,
pub content: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillPackage {
pub group: String,
pub name: String,
pub version: String,
pub files: Vec<SkillPackageFile>,
pub cli: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillCliPackage {
pub full_name: String,
pub version: String,
pub entrypoint: String,
pub bytes: Vec<u8>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillSearchQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub q: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub page: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillSearchItem {
pub group: String,
pub name: String,
pub full_name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub latest_version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skill_type: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillSearchPage {
pub items: Vec<SkillSearchItem>,
#[serde(default)]
pub page: u32,
#[serde(default)]
pub limit: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub has_more: Option<bool>,
}
#[async_trait]
pub trait SkillHubClient: Send + Sync + Debug {
async fn download_skill(
&self,
group: &str,
name: &str,
version: &str,
user_authorization: &str,
) -> Result<SkillPackage>;
async fn download_cli(
&self,
full_name: &str,
version: &str,
os: &str,
arch: &str,
user_authorization: &str,
) -> Result<SkillCliPackage> {
let _ = (full_name, version, os, arch, user_authorization);
Err(EnvironmentDomainError::new(
EnvironmentErrorCode::CapabilityUnsupported,
"Skills Hub client does not support CLI distributions",
)
.into())
}
async fn search_skills(
&self,
query: &SkillSearchQuery,
user_authorization: &str,
) -> Result<SkillSearchPage>;
}
pub fn validate_skill_env(env: &BTreeMap<String, String>) -> Result<()> {
if env.len() > SKILL_ENV_MAX_ENTRIES {
anyhow::bail!("skill env cannot contain more than {SKILL_ENV_MAX_ENTRIES} entries");
}
let mut total = 0_usize;
for (name, value) in env {
if !valid_skill_env_name(name) {
anyhow::bail!("skill env name {name:?} is invalid");
}
if value.contains('\0') {
anyhow::bail!("skill env value for {name} contains a NUL byte");
}
if value.len() > SKILL_ENV_MAX_VALUE_BYTES {
anyhow::bail!("skill env value for {name} exceeds size policy");
}
total = total
.checked_add(name.len())
.and_then(|bytes| bytes.checked_add(value.len()))
.ok_or_else(|| anyhow::anyhow!("skill env exceeds size policy"))?;
if total > SKILL_ENV_MAX_ENTRIES * SKILL_ENV_MAX_VALUE_BYTES {
anyhow::bail!("skill env exceeds size policy");
}
}
Ok(())
}
pub fn valid_skill_env_name(name: &str) -> bool {
let mut bytes = name.bytes();
matches!(bytes.next(), Some(byte) if byte == b'_' || byte.is_ascii_alphabetic())
&& bytes.all(|byte| byte == b'_' || byte.is_ascii_alphanumeric())
&& name.len() <= 191
}
pub fn space_skill_env_abs_path(space_root: &str) -> String {
let root = space_root.trim_end_matches('/');
if root.is_empty() {
format!("/{SKILL_ENV_FILE}")
} else {
format!("{root}/{SKILL_ENV_FILE}")
}
}
pub fn sandbox_skill_env_abs_path() -> String {
format!("/{SKILL_ENV_FILE}")
}
pub fn encode_skill_env_file(env: &BTreeMap<String, String>) -> String {
env.iter()
.map(|(name, value)| format!("export {name}={}\n", posix_single_quote(value)))
.collect()
}
pub fn parse_skill_env_file(content: &str) -> BTreeMap<String, String> {
let mut env = BTreeMap::new();
for line in content.lines() {
let line = line.trim();
let Some(rest) = line.strip_prefix("export ") else {
continue;
};
let Some((name, quoted)) = rest.split_once('=') else {
continue;
};
if !valid_skill_env_name(name) {
continue;
}
if let Some(value) = unquote_posix_single(quoted) {
env.insert(name.to_string(), value);
}
}
env
}
pub fn skill_env_source_prefix(env_file: &str) -> String {
let file = posix_single_quote(env_file);
format!("if [ -f {file} ]; then set -a; . {file}; set +a; fi; ")
}
pub fn command_env_export_prefix(env: &BTreeMap<String, String>) -> String {
env.iter()
.filter(|(name, _)| valid_skill_env_name(name))
.map(|(name, value)| format!("export {name}={}; ", posix_single_quote(value)))
.collect()
}
pub fn posix_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
fn unquote_posix_single(value: &str) -> Option<String> {
let mut chars = value.chars().peekable();
let mut out = String::new();
let mut in_quote = false;
while let Some(ch) = chars.next() {
match ch {
'\'' => in_quote = !in_quote,
'\\' if !in_quote => out.push(chars.next()?),
other => out.push(other),
}
}
(!in_quote).then_some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skill_env_round_trips_quotes_and_rejects_invalid_names() {
let mut env = BTreeMap::new();
env.insert("SKYWORKS_AGENT_PLUGIN_ROUTER".into(), "agent-xxx".into());
env.insert("NOTE".into(), "it's a value".into());
let encoded = encode_skill_env_file(&env);
assert_eq!(parse_skill_env_file(&encoded), env);
assert!(validate_skill_env(&env).is_ok());
assert!(!valid_skill_env_name("1BAD"));
assert!(!valid_skill_env_name("HAS-DASH"));
assert_eq!(
command_env_export_prefix(&env),
"export NOTE='it'\\''s a value'; export SKYWORKS_AGENT_PLUGIN_ROUTER='agent-xxx'; "
);
assert!(validate_skill_env(&BTreeMap::from([("HAS-DASH".into(), "x".into())])).is_err());
assert_eq!(
skill_env_source_prefix("/skills/.env"),
"if [ -f '/skills/.env' ]; then set -a; . '/skills/.env'; set +a; fi; "
);
assert_eq!(
space_skill_env_abs_path("/home/user/spaces/spc_1"),
"/home/user/spaces/spc_1/skills/.env"
);
assert_eq!(sandbox_skill_env_abs_path(), "/skills/.env");
}
}
pub const COMPUTER_SKILLS_PATH: &str = "/internal/v1/workspace/computers/{computerId}/skills";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct InstallComputerSkillRequest {
#[serde(default)]
pub space_id: Option<String>,
pub agent_id: String,
pub group: String,
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct InstallComputerSkillResource {
pub computer_id: String,
pub agent_id: String,
pub group: String,
pub name: String,
pub version: String,
pub install_path: String,
pub files: Vec<String>,
pub clis: Vec<InstalledSpaceCli>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SpaceSkillInstallationState {
Preparing,
Ready,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct SpaceSkillInstallation {
pub group: String,
pub name: String,
pub version: String,
pub state: SpaceSkillInstallationState,
pub files: Vec<String>,
pub clis: Vec<InstalledSpaceCli>,
pub file_digests: BTreeMap<String, Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct UninstallSpaceSkillRequest {
pub group: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct UninstallSpaceSkillResource {
pub space_id: String,
pub group: String,
pub name: String,
pub removed_paths: Vec<String>,
pub retained_paths: Vec<String>,
pub revision: u64,
}
pub const SPACE_SKILL_UNINSTALL_PATH: &str =
"/internal/v1/workspace/spaces/{spaceId}/skills/uninstall";