Skip to main content

agent_workspace_contract/environment/
skills.rs

1//! Skill Hub installation contracts include executable dependencies and owned file lifecycle.
2use super::*;
3use agent_secret_contract::SecretRef;
4use std::collections::BTreeMap;
5
6/// Product path for installing a Skills Hub package into a running Sandbox.
7pub const SANDBOX_SKILLS_PATH: &str = "/internal/v1/workspace/sandboxes/{sandboxId}/skills";
8
9/// Product path for installing a Skills Hub package into a Runtime-bound Space.
10pub const SPACE_SKILLS_PATH: &str = "/internal/v1/workspace/spaces/{spaceId}/skills";
11
12/// Product path for searching published Skills Hub packages.
13pub const SKILLS_SEARCH_PATH: &str = "/internal/v1/workspace/skills:search";
14
15/// Sandbox-root directory that receives unpacked skill packages.
16pub const SANDBOX_SKILL_INSTALL_ROOT: &str = "skills";
17
18/// Space-relative directory that Runtime workspace tools expose to the agent.
19pub const SPACE_SKILL_INSTALL_ROOT: &str = "skills";
20
21pub const SPACE_CLI_INSTALL_ROOT: &str = ".local/bin";
22/// One shared budget bounds both a downloaded CLI and all CLI dependencies of a Skill.
23/// Standalone runtimes (for example Ignis) can exceed 64 MiB.
24pub const SKILL_CLI_MAX_BYTES: usize = 128 * 1024 * 1024;
25
26/// POSIX export file written next to installed skill packages so later execs
27/// can inject `os.environ` values.
28pub const SKILL_ENV_FILE: &str = "skills/.env";
29
30/// Default Skills Hub version selector when the caller omits `version`.
31pub const SANDBOX_SKILL_DEFAULT_VERSION: &str = "latest";
32
33/// Default sandbox provider when the caller omits `provider`.
34pub const SANDBOX_SKILL_DEFAULT_PROVIDER: &str = "local";
35
36/// Maximum number of environment variables accepted on one skill install.
37pub const SKILL_ENV_MAX_ENTRIES: usize = 64;
38
39/// Maximum UTF-8 bytes allowed for one skill environment value.
40pub const SKILL_ENV_MAX_VALUE_BYTES: usize = 8 * 1024;
41
42fn default_sandbox_skill_provider() -> String {
43    SANDBOX_SKILL_DEFAULT_PROVIDER.to_string()
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(deny_unknown_fields, rename_all = "camelCase")]
48pub struct InstallSandboxSkillRequest {
49    pub group: String,
50    pub name: String,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub version: Option<String>,
53    /// Sandbox provider to write into: `local`, `e2b`, or `moulin`.
54    #[serde(default = "default_sandbox_skill_provider")]
55    pub provider: String,
56    /// Plaintext process environment injected for later skill script execution.
57    /// Prefer `env_refs` for secrets; both are merged, with `env_refs` resolved
58    /// server-side.
59    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
60    pub env: BTreeMap<String, String>,
61    /// Secret references (`secret://…`) resolved by the workspace secret store at
62    /// install time and merged into the injected environment. Values are never
63    /// plaintext on the wire; the resolved plaintext is written only into the
64    /// sandbox `.env` file.
65    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
66    pub env_refs: BTreeMap<String, SecretRef>,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(deny_unknown_fields, rename_all = "camelCase")]
71pub struct InstallSandboxSkillResource {
72    pub sandbox_id: String,
73    pub provider: String,
74    pub group: String,
75    pub name: String,
76    pub version: String,
77    pub install_path: String,
78    pub files: Vec<String>,
79    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
80    pub env: BTreeMap<String, String>,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields, rename_all = "camelCase")]
85pub struct InstallSpaceSkillRequest {
86    pub group: String,
87    pub name: String,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub version: Option<String>,
90    /// Computer provider that owns the Space: `local`, `e2b`, or `moulin`.
91    #[serde(default = "default_sandbox_skill_provider")]
92    pub provider: String,
93    /// Plaintext process environment injected for later skill script execution.
94    /// Prefer `env_refs` for secrets; both are merged, with `env_refs` resolved
95    /// server-side.
96    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
97    pub env: BTreeMap<String, String>,
98    /// Secret references (`secret://…`) resolved by the workspace secret store at
99    /// install time and merged into the injected environment. Values are never
100    /// plaintext on the wire; the resolved plaintext is written only into the
101    /// Space `.env` file.
102    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
103    pub env_refs: BTreeMap<String, SecretRef>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(deny_unknown_fields, rename_all = "camelCase")]
108pub struct InstallSpaceSkillResource {
109    pub space_id: String,
110    pub computer_id: String,
111    pub provider: String,
112    pub group: String,
113    pub name: String,
114    pub version: String,
115    pub install_path: String,
116    pub files: Vec<String>,
117    #[serde(default, skip_serializing_if = "Vec::is_empty")]
118    pub clis: Vec<InstalledSpaceCli>,
119    pub revision: u64,
120    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
121    pub env: BTreeMap<String, String>,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(deny_unknown_fields, rename_all = "camelCase")]
126pub struct InstalledSpaceCli {
127    pub full_name: String,
128    pub version: String,
129    pub entrypoint: String,
130    pub install_path: String,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct SkillPackageFile {
135    pub path: String,
136    pub content: Vec<u8>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct SkillPackage {
141    pub group: String,
142    pub name: String,
143    pub version: String,
144    pub files: Vec<SkillPackageFile>,
145    pub cli: BTreeMap<String, String>,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct SkillCliPackage {
150    pub full_name: String,
151    pub version: String,
152    pub entrypoint: String,
153    pub bytes: Vec<u8>,
154}
155
156#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "camelCase")]
158pub struct SkillSearchQuery {
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub q: Option<String>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub page: Option<u32>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub limit: Option<u32>,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169pub struct SkillSearchItem {
170    pub group: String,
171    pub name: String,
172    pub full_name: String,
173    #[serde(default)]
174    pub description: String,
175    #[serde(default)]
176    pub latest_version: String,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub skill_type: Option<String>,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase")]
183pub struct SkillSearchPage {
184    pub items: Vec<SkillSearchItem>,
185    #[serde(default)]
186    pub page: u32,
187    #[serde(default)]
188    pub limit: u32,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub total: Option<u64>,
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub has_more: Option<bool>,
193}
194
195/// Downloads a published skill archive. Implementations live in adapter crates.
196#[async_trait]
197pub trait SkillHubClient: Send + Sync + Debug {
198    async fn download_skill(
199        &self,
200        group: &str,
201        name: &str,
202        version: &str,
203        user_authorization: &str,
204    ) -> Result<SkillPackage>;
205
206    async fn download_cli(
207        &self,
208        full_name: &str,
209        version: &str,
210        os: &str,
211        arch: &str,
212        user_authorization: &str,
213    ) -> Result<SkillCliPackage> {
214        let _ = (full_name, version, os, arch, user_authorization);
215        Err(EnvironmentDomainError::new(
216            EnvironmentErrorCode::CapabilityUnsupported,
217            "Skills Hub client does not support CLI distributions",
218        )
219        .into())
220    }
221
222    async fn search_skills(
223        &self,
224        query: &SkillSearchQuery,
225        user_authorization: &str,
226    ) -> Result<SkillSearchPage>;
227}
228
229/// Validate skill-install environment names and values.
230pub fn validate_skill_env(env: &BTreeMap<String, String>) -> Result<()> {
231    if env.len() > SKILL_ENV_MAX_ENTRIES {
232        anyhow::bail!("skill env cannot contain more than {SKILL_ENV_MAX_ENTRIES} entries");
233    }
234    let mut total = 0_usize;
235    for (name, value) in env {
236        if !valid_skill_env_name(name) {
237            anyhow::bail!("skill env name {name:?} is invalid");
238        }
239        if value.contains('\0') {
240            anyhow::bail!("skill env value for {name} contains a NUL byte");
241        }
242        if value.len() > SKILL_ENV_MAX_VALUE_BYTES {
243            anyhow::bail!("skill env value for {name} exceeds size policy");
244        }
245        total = total
246            .checked_add(name.len())
247            .and_then(|bytes| bytes.checked_add(value.len()))
248            .ok_or_else(|| anyhow::anyhow!("skill env exceeds size policy"))?;
249        if total > SKILL_ENV_MAX_ENTRIES * SKILL_ENV_MAX_VALUE_BYTES {
250            anyhow::bail!("skill env exceeds size policy");
251        }
252    }
253    Ok(())
254}
255
256/// POSIX identifier: `[A-Za-z_][A-Za-z0-9_]*`.
257pub fn valid_skill_env_name(name: &str) -> bool {
258    let mut bytes = name.bytes();
259    matches!(bytes.next(), Some(byte) if byte == b'_' || byte.is_ascii_alphabetic())
260        && bytes.all(|byte| byte == b'_' || byte.is_ascii_alphanumeric())
261        && name.len() <= 191
262}
263
264/// Absolute path providers source before executing a command in a Space.
265pub fn space_skill_env_abs_path(space_root: &str) -> String {
266    let root = space_root.trim_end_matches('/');
267    if root.is_empty() {
268        format!("/{SKILL_ENV_FILE}")
269    } else {
270        format!("{root}/{SKILL_ENV_FILE}")
271    }
272}
273
274/// Absolute path providers source before executing a command in a Sandbox.
275pub fn sandbox_skill_env_abs_path() -> String {
276    format!("/{SKILL_ENV_FILE}")
277}
278
279/// `export NAME='value'` file that login/non-login shells can source.
280pub fn encode_skill_env_file(env: &BTreeMap<String, String>) -> String {
281    env.iter()
282        .map(|(name, value)| format!("export {name}={}\n", posix_single_quote(value)))
283        .collect()
284}
285
286/// Parse the file produced by [`encode_skill_env_file`].
287pub fn parse_skill_env_file(content: &str) -> BTreeMap<String, String> {
288    let mut env = BTreeMap::new();
289    for line in content.lines() {
290        let line = line.trim();
291        let Some(rest) = line.strip_prefix("export ") else {
292            continue;
293        };
294        let Some((name, quoted)) = rest.split_once('=') else {
295            continue;
296        };
297        if !valid_skill_env_name(name) {
298            continue;
299        }
300        if let Some(value) = unquote_posix_single(quoted) {
301            env.insert(name.to_string(), value);
302        }
303    }
304    env
305}
306
307/// Prefix that no-ops when the skill env file is absent.
308pub fn skill_env_source_prefix(env_file: &str) -> String {
309    let file = posix_single_quote(env_file);
310    format!("if [ -f {file} ]; then set -a; . {file}; set +a; fi; ")
311}
312
313/// `export NAME='value'; ` prefix so a later command can override a sourced
314/// skill `.env` file. Invalid names are skipped; callers should validate first.
315pub fn command_env_export_prefix(env: &BTreeMap<String, String>) -> String {
316    env.iter()
317        .filter(|(name, _)| valid_skill_env_name(name))
318        .map(|(name, value)| format!("export {name}={}; ", posix_single_quote(value)))
319        .collect()
320}
321
322pub fn posix_single_quote(value: &str) -> String {
323    format!("'{}'", value.replace('\'', "'\\''"))
324}
325
326fn unquote_posix_single(value: &str) -> Option<String> {
327    let mut chars = value.chars().peekable();
328    let mut out = String::new();
329    let mut in_quote = false;
330    while let Some(ch) = chars.next() {
331        match ch {
332            '\'' => in_quote = !in_quote,
333            '\\' if !in_quote => out.push(chars.next()?),
334            other => out.push(other),
335        }
336    }
337    (!in_quote).then_some(out)
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn skill_env_round_trips_quotes_and_rejects_invalid_names() {
346        let mut env = BTreeMap::new();
347        env.insert("SKYWORKS_AGENT_PLUGIN_ROUTER".into(), "agent-xxx".into());
348        env.insert("NOTE".into(), "it's a value".into());
349        let encoded = encode_skill_env_file(&env);
350        assert_eq!(parse_skill_env_file(&encoded), env);
351        assert!(validate_skill_env(&env).is_ok());
352        assert!(!valid_skill_env_name("1BAD"));
353        assert!(!valid_skill_env_name("HAS-DASH"));
354        assert_eq!(
355            command_env_export_prefix(&env),
356            "export NOTE='it'\\''s a value'; export SKYWORKS_AGENT_PLUGIN_ROUTER='agent-xxx'; "
357        );
358        assert!(validate_skill_env(&BTreeMap::from([("HAS-DASH".into(), "x".into())])).is_err());
359        assert_eq!(
360            skill_env_source_prefix("/skills/.env"),
361            "if [ -f '/skills/.env' ]; then set -a; . '/skills/.env'; set +a; fi; "
362        );
363        assert_eq!(
364            space_skill_env_abs_path("/home/user/spaces/spc_1"),
365            "/home/user/spaces/spc_1/skills/.env"
366        );
367        assert_eq!(sandbox_skill_env_abs_path(), "/skills/.env");
368    }
369}
370
371/// Install code only at a Computer-owned, Agent/version-isolated location.
372pub const COMPUTER_SKILLS_PATH: &str = "/internal/v1/workspace/computers/{computerId}/skills";
373#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
374#[serde(rename_all = "camelCase", deny_unknown_fields)]
375pub struct InstallComputerSkillRequest {
376    #[serde(default)]
377    pub space_id: Option<String>,
378    pub agent_id: String,
379    pub group: String,
380    pub name: String,
381    pub version: String,
382}
383#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384#[serde(rename_all = "camelCase", deny_unknown_fields)]
385pub struct InstallComputerSkillResource {
386    pub computer_id: String,
387    pub agent_id: String,
388    pub group: String,
389    pub name: String,
390    pub version: String,
391    pub install_path: String,
392    pub files: Vec<String>,
393    pub clis: Vec<InstalledSpaceCli>,
394}
395
396/// Installation metadata contains no environment values or credentials.
397#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
398#[serde(rename_all = "snake_case")]
399pub enum SpaceSkillInstallationState {
400    Preparing,
401    Ready,
402}
403
404#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
405#[serde(deny_unknown_fields, rename_all = "camelCase")]
406pub struct SpaceSkillInstallation {
407    pub group: String,
408    pub name: String,
409    pub version: String,
410    pub state: SpaceSkillInstallationState,
411    pub files: Vec<String>,
412    pub clis: Vec<InstalledSpaceCli>,
413    /// Accepted previous/current contents permit interrupted upgrades to resume.
414    pub file_digests: BTreeMap<String, Vec<String>>,
415}
416
417#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
418#[serde(deny_unknown_fields, rename_all = "camelCase")]
419pub struct UninstallSpaceSkillRequest {
420    pub group: String,
421    pub name: String,
422}
423
424#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
425#[serde(deny_unknown_fields, rename_all = "camelCase")]
426pub struct UninstallSpaceSkillResource {
427    pub space_id: String,
428    pub group: String,
429    pub name: String,
430    pub removed_paths: Vec<String>,
431    pub retained_paths: Vec<String>,
432    pub revision: u64,
433}
434
435pub const SPACE_SKILL_UNINSTALL_PATH: &str =
436    "/internal/v1/workspace/spaces/{spaceId}/skills/uninstall";