feat: upgraded to sbx kit v2 spec for improved integration
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+55
-11
@@ -33,8 +33,12 @@ impl DiscoveredMixin {
|
||||
pub fn wrap_mixin_as_kit(mixin_path: &Path) -> Result<PathBuf> {
|
||||
let bytes = fs::read(mixin_path)
|
||||
.with_context(|| format!("Failed to read sbx mixin {}", mixin_path.display()))?;
|
||||
wrap_mixin_bytes_as_kit(&bytes, &mixin_path.display().to_string())
|
||||
}
|
||||
|
||||
pub fn wrap_mixin_bytes_as_kit(bytes: &[u8], label: &str) -> Result<PathBuf> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&bytes);
|
||||
hasher.update(bytes);
|
||||
let hash = format!("{:x}", hasher.finalize());
|
||||
|
||||
let kit_dir = paths::sbx_mixin_kits_dir().join(&hash);
|
||||
@@ -48,14 +52,10 @@ pub fn wrap_mixin_as_kit(mixin_path: &Path) -> Result<PathBuf> {
|
||||
|
||||
fs::create_dir_all(&kit_dir)
|
||||
.with_context(|| format!("Failed to create mixin kit dir {}", kit_dir.display()))?;
|
||||
fs::write(&spec_path, &bytes)
|
||||
fs::write(&spec_path, bytes)
|
||||
.with_context(|| format!("Failed to write {}", spec_path.display()))?;
|
||||
|
||||
debug!(
|
||||
"Wrapped mixin {} as kit at {}",
|
||||
mixin_path.display(),
|
||||
kit_dir.display()
|
||||
);
|
||||
debug!("Wrapped mixin {label} as kit at {}", kit_dir.display());
|
||||
|
||||
Ok(kit_dir)
|
||||
}
|
||||
@@ -89,15 +89,18 @@ pub fn summarize(path: &Path) -> Result<(usize, usize)> {
|
||||
.with_context(|| format!("Failed to parse sbx mixin {}", path.display()))?;
|
||||
|
||||
let installs = value
|
||||
.get("commands")
|
||||
.and_then(|c| c.get("install"))
|
||||
.get("setup")
|
||||
.and_then(|s| s.get("install"))
|
||||
.or_else(|| value.get("commands").and_then(|c| c.get("install")))
|
||||
.and_then(|i| i.as_sequence())
|
||||
.map(|s| s.len())
|
||||
.unwrap_or(0);
|
||||
|
||||
let domains = value
|
||||
.get("network")
|
||||
.and_then(|n| n.get("allowedDomains"))
|
||||
.get("permissions")
|
||||
.and_then(|p| p.get("network"))
|
||||
.and_then(|n| n.get("allow"))
|
||||
.or_else(|| value.get("network").and_then(|n| n.get("allowedDomains")))
|
||||
.and_then(|d| d.as_sequence())
|
||||
.map(|s| s.len())
|
||||
.unwrap_or(0);
|
||||
@@ -196,6 +199,34 @@ mod tests {
|
||||
fs::write(
|
||||
&path,
|
||||
r#"
|
||||
schemaVersion: "2"
|
||||
kind: mixin
|
||||
setup:
|
||||
install:
|
||||
- command: "echo hi"
|
||||
- command: "echo bye"
|
||||
permissions:
|
||||
network:
|
||||
allow:
|
||||
- "a.example.com:443"
|
||||
- "b.example.com:443"
|
||||
- "c.example.com:443"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(summarize(&path).unwrap(), (2, 3));
|
||||
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_falls_back_to_v1_field_paths() {
|
||||
let root = unique_root("sbx-mixin-counts-v1");
|
||||
let path = root.join("sbx-mixin.yaml");
|
||||
fs::write(
|
||||
&path,
|
||||
r#"
|
||||
schemaVersion: "1"
|
||||
kind: mixin
|
||||
commands:
|
||||
@@ -343,6 +374,19 @@ network:
|
||||
assert_eq!(fs::read_to_string(&spec).unwrap(), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn wrap_mixin_bytes_as_kit_writes_spec_yaml() {
|
||||
let _guard = TestCacheDirGuard::new();
|
||||
let content = b"schemaVersion: '2'\nkind: mixin\nname: generated\n";
|
||||
|
||||
let kit_dir = wrap_mixin_bytes_as_kit(content, "generated").unwrap();
|
||||
let spec = kit_dir.join("spec.yaml");
|
||||
|
||||
assert!(spec.exists(), "spec.yaml must exist in wrapped kit dir");
|
||||
assert_eq!(fs::read(&spec).unwrap(), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn wrap_mixin_as_kit_is_deterministic_for_identical_content() {
|
||||
|
||||
+156
-48
@@ -10,12 +10,16 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use which::which;
|
||||
|
||||
mod mcp_credentials;
|
||||
mod mixins;
|
||||
|
||||
pub(crate) use mcp_credentials::sandbox_secret_env_var;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::config::Config;
|
||||
use crate::config::VAULT_DATA_FILE_NAME;
|
||||
use crate::config::paths;
|
||||
use crate::sandbox::mcp_credentials::MCP_MIXIN_NAME;
|
||||
use crate::sandbox::mixins::DiscoveredMixin;
|
||||
use crate::utils::run_command_with_output;
|
||||
use crate::vault::SECRET_RE;
|
||||
@@ -49,9 +53,11 @@ pub fn launch(name: Option<String>, fresh: bool) -> Result<()> {
|
||||
let vault = Vault::init(&bootstrap)?;
|
||||
let registered = sbx_registered_services()?;
|
||||
inject_llm_secret(&config_content, &vault, ®istered)?;
|
||||
if !fresh {
|
||||
inject_mcp_secrets(&vault, ®istered)?;
|
||||
}
|
||||
let credentials_mixin = if fresh {
|
||||
None
|
||||
} else {
|
||||
inject_mcp_secrets(&vault, ®istered)?
|
||||
};
|
||||
|
||||
let discovered = mixins::discover()?;
|
||||
|
||||
@@ -59,7 +65,7 @@ pub fn launch(name: Option<String>, fresh: bool) -> Result<()> {
|
||||
info!("Re-attaching to existing sandbox '{name}'");
|
||||
} else {
|
||||
mixins::log_discovery(&discovered, false);
|
||||
create_sandbox(&name, &kit_path, &discovered)?;
|
||||
create_sandbox(&name, &kit_path, &discovered, credentials_mixin.as_deref())?;
|
||||
if !fresh {
|
||||
copy_host_files(&name)?;
|
||||
}
|
||||
@@ -232,7 +238,7 @@ fn inject_llm_secret(
|
||||
if registered.contains(&service) {
|
||||
eprintln!(
|
||||
"Secret for '{service}' already registered with sbx. \
|
||||
To update it, run: sbx secret set -g --force {service}"
|
||||
To update it, run: sbx secret set --force {service}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -247,23 +253,14 @@ fn inject_llm_secret(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_secret_placeholder(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(s) => SECRET_RE
|
||||
.captures(s)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|caps| caps[1].to_string()),
|
||||
Value::Object(map) => map.values().find_map(find_secret_placeholder),
|
||||
Value::Array(arr) => arr.iter().find_map(find_secret_placeholder),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn inject_mcp_secrets(vault: &Vault, registered: &HashSet<String>) -> Result<()> {
|
||||
/// Registers one sbx secret per distinct `{{placeholder}}` in the MCP config
|
||||
/// and returns the generated schema-v2 `coyote-mcp` mixin (network egress for
|
||||
/// every remote MCP server + credential declarations), or `None` when the MCP
|
||||
/// config references no remote servers and no secrets.
|
||||
fn inject_mcp_secrets(vault: &Vault, registered: &HashSet<String>) -> Result<Option<String>> {
|
||||
let mcp_path = paths::mcp_config_file();
|
||||
if !mcp_path.exists() {
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&mcp_path)
|
||||
@@ -272,40 +269,51 @@ fn inject_mcp_secrets(vault: &Vault, registered: &HashSet<String>) -> Result<()>
|
||||
.with_context(|| format!("Failed to parse {}", mcp_path.display()))?;
|
||||
|
||||
let Some(servers) = mcp.get("mcpServers").and_then(|v| v.as_object()) else {
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
for (server_name, server_config) in servers {
|
||||
let Some(secret_name) = find_secret_placeholder(server_config) else {
|
||||
continue;
|
||||
};
|
||||
let credentials = mcp_credentials::collect_credentials(servers)?;
|
||||
let allow_entries = mcp_credentials::collect_server_allow_entries(servers);
|
||||
if credentials.is_empty() && allow_entries.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if registered.contains(server_name.as_str()) {
|
||||
for credential in &credentials {
|
||||
if registered.contains(credential.service_id.as_str()) {
|
||||
eprintln!(
|
||||
"Secret for '{server_name}' already registered with sbx. \
|
||||
To update it, run: sbx secret set -g --force {server_name}"
|
||||
"Secret for '{}' already registered with sbx. \
|
||||
To update it, run: sbx secret set --force {}",
|
||||
credential.service_id, credential.service_id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let secret_value = vault.get_secret(&secret_name, false).with_context(|| {
|
||||
format!(
|
||||
"Secret '{secret_name}' referenced by MCP server '{server_name}' not found \
|
||||
in vault. Add it with: coyote --add-secret {secret_name}"
|
||||
)
|
||||
})?;
|
||||
let secret_value = vault
|
||||
.get_secret(&credential.secret_name, false)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Secret '{}' referenced by MCP server(s) {} not found \
|
||||
in vault. Add it with: coyote --add-secret {}",
|
||||
credential.secret_name,
|
||||
mcp_credentials::quoted_list(&credential.servers),
|
||||
credential.secret_name
|
||||
)
|
||||
})?;
|
||||
|
||||
sbx_secret_set(server_name, &secret_value)?;
|
||||
sbx_secret_set(&credential.service_id, &secret_value)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(Some(mcp_credentials::render_mixin_yaml(
|
||||
&credentials,
|
||||
&allow_entries,
|
||||
)?))
|
||||
}
|
||||
|
||||
fn provider_to_sbx_service(provider_type: &str, client_name: Option<&str>) -> String {
|
||||
match provider_type {
|
||||
"claude" => "anthropic".to_string(),
|
||||
"openai" => "openai".to_string(),
|
||||
"gemini" | "vertexai" => "google".to_string(),
|
||||
"gemini" | "vertexai" => "gemini".to_string(),
|
||||
"openai-compatible" => client_name.unwrap_or("openai-compatible").to_string(),
|
||||
other => client_name.unwrap_or(other).to_string(),
|
||||
}
|
||||
@@ -338,25 +346,29 @@ fn sbx_registered_services() -> Result<HashSet<String>> {
|
||||
|
||||
fn sbx_secret_set(service: &str, secret_value: &str) -> Result<()> {
|
||||
let mut child = Command::new(SBX_BINARY)
|
||||
.args(["secret", "set", "-g", service])
|
||||
.args(["secret", "set", service])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.context("Failed to spawn `sbx secret set -g`")?;
|
||||
.context("Failed to spawn `sbx secret set`")?;
|
||||
|
||||
if let Some(mut stdin_handle) = child.stdin.take() {
|
||||
stdin_handle
|
||||
.write_all(secret_value.as_bytes())
|
||||
.context("Failed to write secret to `sbx secret set -g` stdin")?;
|
||||
.context("Failed to write secret to `sbx secret set` stdin")?;
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.context("Failed to wait for `sbx secret set -g`")?;
|
||||
.context("Failed to wait for `sbx secret set`")?;
|
||||
|
||||
if !status.success() {
|
||||
bail!("`sbx secret set -g {service}` exited with {status}");
|
||||
eprintln!(
|
||||
"Warning: failed to register sbx secret '{service}' \
|
||||
(`sbx secret set {service}` exited with {status}). \
|
||||
Set it manually with: echo '<value>' | sbx secret set {service}"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -375,9 +387,17 @@ fn sandbox_exists(name: &str) -> Result<bool> {
|
||||
.any(|line| line.split_whitespace().next() == Some(name)))
|
||||
}
|
||||
|
||||
fn create_sandbox(name: &str, kit_path: &Path, mixins: &[DiscoveredMixin]) -> Result<()> {
|
||||
fn create_sandbox(
|
||||
name: &str,
|
||||
kit_path: &Path,
|
||||
mixins: &[DiscoveredMixin],
|
||||
credentials_mixin: Option<&str>,
|
||||
) -> Result<()> {
|
||||
info!("Creating sandbox '{name}'");
|
||||
let args = build_create_args(name, kit_path, mixins)?;
|
||||
let credentials_kit = credentials_mixin
|
||||
.map(|yaml| mixins::wrap_mixin_bytes_as_kit(yaml.as_bytes(), MCP_MIXIN_NAME))
|
||||
.transpose()?;
|
||||
let args = build_create_args(name, kit_path, mixins, credentials_kit.as_deref())?;
|
||||
debug!("sbx {}", args.join(" "));
|
||||
let status = Command::new(SBX_BINARY)
|
||||
.args(&args)
|
||||
@@ -398,6 +418,7 @@ fn build_create_args(
|
||||
name: &str,
|
||||
kit_path: &Path,
|
||||
mixins: &[DiscoveredMixin],
|
||||
credentials_kit: Option<&Path>,
|
||||
) -> Result<Vec<String>> {
|
||||
let kit_str = kit_path
|
||||
.to_str()
|
||||
@@ -421,6 +442,15 @@ fn build_create_args(
|
||||
args.push(mixin_str);
|
||||
}
|
||||
|
||||
if let Some(kit) = credentials_kit {
|
||||
let cred_str = kit
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("Credentials kit path is not valid UTF-8: {}", kit.display()))?
|
||||
.to_string();
|
||||
args.push("--kit".to_string());
|
||||
args.push(cred_str);
|
||||
}
|
||||
|
||||
args.push(SANDBOX_AGENT.to_string());
|
||||
args.push(".".to_string());
|
||||
|
||||
@@ -558,6 +588,7 @@ fn chown_agent_recursive(sandbox: &str, path: &str) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[test]
|
||||
fn sanitize_name_lowercases() {
|
||||
@@ -626,8 +657,8 @@ mod tests {
|
||||
#[test]
|
||||
fn build_create_args_emits_base_kit_before_mixins() {
|
||||
let kit = PathBuf::from("/cache/sbx-kit");
|
||||
let unique = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let dir_a = env::temp_dir().join(format!("coyote-mixin-a-{unique}"));
|
||||
@@ -650,7 +681,7 @@ mod tests {
|
||||
},
|
||||
];
|
||||
|
||||
let args = build_create_args("my-box", &kit, &mixins).unwrap();
|
||||
let args = build_create_args("my-box", &kit, &mixins, None).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
@@ -676,7 +707,9 @@ mod tests {
|
||||
#[test]
|
||||
fn build_create_args_with_no_mixins_omits_mixin_kits() {
|
||||
let kit = PathBuf::from("/cache/sbx-kit");
|
||||
let args = build_create_args("box", &kit, &[]).unwrap();
|
||||
|
||||
let args = build_create_args("box", &kit, &[], None).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
@@ -690,4 +723,79 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_create_args_appends_credentials_kit_after_mixins() {
|
||||
let kit = PathBuf::from("/cache/sbx-kit");
|
||||
let credentials_kit = PathBuf::from("/cache/sbx-mixin-kits/abc123");
|
||||
|
||||
let args = build_create_args("box", &kit, &[], Some(&credentials_kit)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"create".to_string(),
|
||||
"--name".to_string(),
|
||||
"box".to_string(),
|
||||
"--kit".to_string(),
|
||||
"/cache/sbx-kit".to_string(),
|
||||
"--kit".to_string(),
|
||||
"/cache/sbx-mixin-kits/abc123".to_string(),
|
||||
"coyote".to_string(),
|
||||
".".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_create_args_orders_base_kit_then_mixins_then_credentials_kit() {
|
||||
let kit = PathBuf::from("/cache/sbx-kit");
|
||||
let credentials_kit = PathBuf::from("/cache/sbx-mixin-kits/abc123");
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let dir = env::temp_dir().join(format!("coyote-mixin-cred-{unique}"));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let mixins = vec![DiscoveredMixin {
|
||||
path: dir.clone(),
|
||||
label: "user".into(),
|
||||
install_count: 0,
|
||||
domain_count: 0,
|
||||
}];
|
||||
|
||||
let args = build_create_args("box", &kit, &mixins, Some(&credentials_kit)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"create".to_string(),
|
||||
"--name".to_string(),
|
||||
"box".to_string(),
|
||||
"--kit".to_string(),
|
||||
"/cache/sbx-kit".to_string(),
|
||||
"--kit".to_string(),
|
||||
dir.display().to_string(),
|
||||
"--kit".to_string(),
|
||||
"/cache/sbx-mixin-kits/abc123".to_string(),
|
||||
"coyote".to_string(),
|
||||
".".to_string(),
|
||||
]
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_to_sbx_service_maps_gemini_family_to_gemini() {
|
||||
assert_eq!(provider_to_sbx_service("gemini", None), "gemini");
|
||||
assert_eq!(provider_to_sbx_service("vertexai", None), "gemini");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_to_sbx_service_maps_known_providers() {
|
||||
assert_eq!(provider_to_sbx_service("claude", None), "anthropic");
|
||||
assert_eq!(provider_to_sbx_service("openai", None), "openai");
|
||||
}
|
||||
}
|
||||
|
||||
+27
-2
@@ -1,5 +1,5 @@
|
||||
use crate::config::ensure_parent_exists;
|
||||
use crate::sandbox::SANDBOX_ENV_FLAG;
|
||||
use crate::sandbox::{SANDBOX_ENV_FLAG, sandbox_secret_env_var};
|
||||
use crate::vault::{SECRET_RE, Vault};
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
@@ -358,7 +358,32 @@ fn required_cli_preflight(label: &str, cli: &str, install_url: &str) {
|
||||
|
||||
pub fn interpolate_secrets(content: &str, vault: &Vault) -> Result<(String, Vec<String>)> {
|
||||
if env::var_os(SANDBOX_ENV_FLAG).is_some() {
|
||||
return Ok((content.to_string(), vec![]));
|
||||
let (parsed, missing) = interpolate_secrets_with(content, None, |name| {
|
||||
env::var(sandbox_secret_env_var(name)).map_err(|_| {
|
||||
anyhow!(SecretError::NotFound {
|
||||
key: name.to_string(),
|
||||
provider: "sandbox environment",
|
||||
})
|
||||
})
|
||||
})?;
|
||||
|
||||
if !missing.is_empty() {
|
||||
let mut env_vars: Vec<String> = missing
|
||||
.iter()
|
||||
.map(|name| sandbox_secret_env_var(name))
|
||||
.collect();
|
||||
env_vars.sort();
|
||||
env_vars.dedup();
|
||||
eprintln!(
|
||||
"Config references secrets that are not available inside this sandbox \
|
||||
(expected env vars: {}). Sandbox secrets are provisioned at creation \
|
||||
from the host; add the missing secrets on the host, then re-create \
|
||||
the sandbox.",
|
||||
env_vars.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
return Ok((parsed, missing));
|
||||
}
|
||||
interpolate_secrets_with(content, vault.auth_hint(), |name| {
|
||||
vault.get_secret(name, false)
|
||||
|
||||
Reference in New Issue
Block a user