1use super::*;
3use agent_secret_contract::SecretRef;
4use std::collections::BTreeMap;
5
6pub const SANDBOX_SKILLS_PATH: &str = "/internal/v1/workspace/sandboxes/{sandboxId}/skills";
8
9pub const SPACE_SKILLS_PATH: &str = "/internal/v1/workspace/spaces/{spaceId}/skills";
11
12pub const SKILLS_SEARCH_PATH: &str = "/internal/v1/workspace/skills:search";
14
15pub const SANDBOX_SKILL_INSTALL_ROOT: &str = "skills";
17
18pub const SPACE_SKILL_INSTALL_ROOT: &str = "skills";
20
21pub const SPACE_CLI_INSTALL_ROOT: &str = ".local/bin";
22pub const SKILL_CLI_MAX_BYTES: usize = 128 * 1024 * 1024;
25
26pub const SKILL_ENV_FILE: &str = "skills/.env";
29
30pub const SANDBOX_SKILL_DEFAULT_VERSION: &str = "latest";
32
33pub const SANDBOX_SKILL_DEFAULT_PROVIDER: &str = "local";
35
36pub const SKILL_ENV_MAX_ENTRIES: usize = 64;
38
39pub 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 #[serde(default = "default_sandbox_skill_provider")]
55 pub provider: String,
56 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
60 pub env: BTreeMap<String, String>,
61 #[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 #[serde(default = "default_sandbox_skill_provider")]
92 pub provider: String,
93 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
97 pub env: BTreeMap<String, String>,
98 #[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#[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
229pub 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
256pub 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
264pub 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
274pub fn sandbox_skill_env_abs_path() -> String {
276 format!("/{SKILL_ENV_FILE}")
277}
278
279pub 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
286pub 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
307pub 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
313pub 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
371pub 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#[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 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";