1689 lines
53 KiB
Rust
1689 lines
53 KiB
Rust
use anyhow::{Context, Result, anyhow, bail};
|
|
use rust_embed::RustEmbed;
|
|
use serde_json::Value;
|
|
use sha2::{Digest, Sha256};
|
|
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
|
use std::fs;
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Stdio};
|
|
use std::{env, io};
|
|
use which::which;
|
|
|
|
pub(crate) 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::rag::RagData;
|
|
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;
|
|
use crate::vault::Vault;
|
|
|
|
const SBX_BINARY: &str = "sbx";
|
|
pub(crate) const SANDBOX_ENV_FLAG: &str = "IS_SANDBOX";
|
|
const SANDBOX_AGENT: &str = "coyote";
|
|
|
|
#[derive(RustEmbed)]
|
|
#[folder = "assets/sbx-kit/"]
|
|
struct EmbeddedKit;
|
|
|
|
pub fn launch(name: Option<String>, fresh: bool) -> Result<()> {
|
|
ensure_sbx_installed()?;
|
|
bail_if_nested()?;
|
|
|
|
let name = resolve_name(name)?;
|
|
let kit_path = resolve_kit_path()?;
|
|
|
|
let config_path = paths::config_file();
|
|
if !config_path.exists() {
|
|
bail!("No Coyote config found. Run `coyote` on your host to complete setup first.");
|
|
}
|
|
let (config, config_content) = Config::load_from_file(&config_path)?;
|
|
let bootstrap = AppConfig {
|
|
vault_password_file: config.vault_password_file.clone(),
|
|
secrets_provider: config.secrets_provider.clone(),
|
|
..AppConfig::default()
|
|
};
|
|
let vault = Vault::init(&bootstrap)?;
|
|
let registered = sbx_registered_secrets()?;
|
|
inject_llm_secret(&config_content, &vault, ®istered.services)?;
|
|
let mut custom_plans: BTreeMap<String, CustomSecretPlan> = BTreeMap::new();
|
|
if !fresh {
|
|
collect_rag_custom_secrets(&mut custom_plans)?;
|
|
}
|
|
|
|
let credentials_mixin = if fresh {
|
|
None
|
|
} else {
|
|
inject_mcp_secrets(&vault, ®istered, &mut custom_plans)?
|
|
};
|
|
let new_custom_envs = provision_custom_secrets(&vault, ®istered, custom_plans)?;
|
|
|
|
let discovered = mixins::discover()?;
|
|
|
|
if sandbox_exists(&name)? {
|
|
info!("Re-attaching to existing sandbox '{name}'");
|
|
if !fresh {
|
|
warn_if_mixin_drifted(&name, credentials_mixin.as_deref());
|
|
if !new_custom_envs.is_empty() {
|
|
eprintln!(
|
|
"Custom secret env var(s) {} were just registered; restart sandbox \
|
|
'{name}' for them to appear in its environment.",
|
|
mcp_credentials::quoted_list(&new_custom_envs)
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
mixins::log_discovery(&discovered, false);
|
|
create_sandbox(&name, &kit_path, &discovered, credentials_mixin.as_deref())?;
|
|
persist_mixin_hash(&name, credentials_mixin.as_deref());
|
|
if !fresh {
|
|
copy_host_files(&name)?;
|
|
}
|
|
}
|
|
|
|
exec_run(&name, &kit_path)
|
|
}
|
|
|
|
fn ensure_sbx_installed() -> Result<()> {
|
|
which(SBX_BINARY).map_err(|_| {
|
|
anyhow!(
|
|
"`sbx` binary not found in PATH.\n\n\
|
|
Install Docker Sandboxes:\n https://docs.docker.com/ai/sandboxes/get-started/"
|
|
)
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn bail_if_nested() -> Result<()> {
|
|
if env::var_os(SANDBOX_ENV_FLAG).is_some() {
|
|
bail!("Refusing to nest sandboxes: ${SANDBOX_ENV_FLAG} is set, already inside one");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn resolve_name(name: Option<String>) -> Result<String> {
|
|
if let Some(n) = name {
|
|
let trimmed = n.trim();
|
|
if !trimmed.is_empty() {
|
|
let sanitized = sanitize_name(trimmed);
|
|
if sanitized.is_empty() {
|
|
bail!("Sandbox name '{trimmed}' sanitizes to an empty string");
|
|
}
|
|
|
|
return Ok(sanitized);
|
|
}
|
|
}
|
|
|
|
let cwd = env::current_dir().context("Failed to determine current directory")?;
|
|
let basename = cwd
|
|
.file_name()
|
|
.and_then(|s| s.to_str())
|
|
.ok_or_else(|| anyhow!("Could not derive sandbox name from current directory"))?;
|
|
let sanitized = sanitize_name(basename);
|
|
if sanitized.is_empty() {
|
|
bail!("Could not derive a valid sandbox name from '{basename}'; pass --sandbox <NAME>");
|
|
}
|
|
|
|
Ok(sanitized)
|
|
}
|
|
|
|
fn sanitize_name(input: &str) -> String {
|
|
let mut out = String::with_capacity(input.len());
|
|
let mut last_was_dash = false;
|
|
for ch in input.chars() {
|
|
let lower = ch.to_ascii_lowercase();
|
|
if lower.is_ascii_alphanumeric() {
|
|
out.push(lower);
|
|
last_was_dash = false;
|
|
} else if !last_was_dash {
|
|
out.push('-');
|
|
last_was_dash = true;
|
|
}
|
|
}
|
|
|
|
out.trim_matches('-').to_string()
|
|
}
|
|
|
|
fn resolve_kit_path() -> Result<PathBuf> {
|
|
if let Some(path) = paths::sandbox_kit_override() {
|
|
if !path.exists() {
|
|
bail!(
|
|
"$COYOTE_SANDBOX_KIT is set but path does not exist: {}",
|
|
path.display()
|
|
);
|
|
}
|
|
|
|
debug!(
|
|
"Using kit override from $COYOTE_SANDBOX_KIT: {}",
|
|
path.display()
|
|
);
|
|
|
|
return Ok(path);
|
|
}
|
|
|
|
extract_embedded_kit()
|
|
}
|
|
|
|
fn extract_embedded_kit() -> Result<PathBuf> {
|
|
let cache_root = paths::sbx_kit_dir();
|
|
let new_hash = compute_kit_hash()?;
|
|
let hash_file = paths::sbx_kit_hash_file();
|
|
if let Ok(existing) = fs::read_to_string(&hash_file)
|
|
&& existing == new_hash
|
|
{
|
|
return Ok(cache_root);
|
|
}
|
|
|
|
if cache_root.exists() {
|
|
fs::remove_dir_all(&cache_root)
|
|
.with_context(|| format!("Failed to clear stale kit at {}", cache_root.display()))?;
|
|
}
|
|
fs::create_dir_all(&cache_root)
|
|
.with_context(|| format!("Failed to create {}", cache_root.display()))?;
|
|
|
|
for entry in EmbeddedKit::iter() {
|
|
let file = EmbeddedKit::get(&entry)
|
|
.ok_or_else(|| anyhow!("Embedded kit file missing during extraction: {entry}"))?;
|
|
let dest = cache_root.join(entry.as_ref());
|
|
if let Some(parent) = dest.parent() {
|
|
fs::create_dir_all(parent)
|
|
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
|
}
|
|
|
|
fs::write(&dest, &file.data)
|
|
.with_context(|| format!("Failed to write {}", dest.display()))?;
|
|
}
|
|
|
|
fs::write(&hash_file, &new_hash)
|
|
.with_context(|| format!("Failed to write {}", hash_file.display()))?;
|
|
debug!("Extracted embedded sbx-kit to {}", cache_root.display());
|
|
|
|
Ok(cache_root)
|
|
}
|
|
|
|
fn compute_kit_hash() -> Result<String> {
|
|
let mut hasher = Sha256::new();
|
|
let mut entries: Vec<_> = EmbeddedKit::iter().collect();
|
|
entries.sort();
|
|
|
|
for entry in &entries {
|
|
let file = EmbeddedKit::get(entry)
|
|
.ok_or_else(|| anyhow!("Embedded kit file missing during hash: {entry}"))?;
|
|
hasher.update(entry.as_bytes());
|
|
hasher.update(b"\0");
|
|
hasher.update(&file.data);
|
|
}
|
|
|
|
Ok(format!("{:x}", hasher.finalize()))
|
|
}
|
|
|
|
/// The generated `coyote-mcp` mixin is baked into a sandbox at create time and
|
|
/// never re-applied on re-attach, so its hash is persisted per sandbox to
|
|
/// detect when the MCP config drifts from the rules the sandbox runs with.
|
|
/// An absent mixin hashes as the empty string, keeping the comparison total.
|
|
fn credentials_mixin_hash(mixin: Option<&str>) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(mixin.unwrap_or("").as_bytes());
|
|
format!("{:x}", hasher.finalize())
|
|
}
|
|
|
|
fn persist_mixin_hash(name: &str, mixin: Option<&str>) {
|
|
let path = paths::sandbox_mixin_hash_file(name);
|
|
let write = |path: &Path| -> io::Result<()> {
|
|
if let Some(parent) = path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
|
|
fs::write(path, credentials_mixin_hash(mixin))
|
|
};
|
|
|
|
if let Err(e) = write(&path) {
|
|
eprintln!(
|
|
"Warning: failed to record the sandbox mixin hash at {} ({e}); \
|
|
stale-rule detection is disabled for sandbox '{name}'.",
|
|
path.display()
|
|
);
|
|
}
|
|
}
|
|
|
|
fn warn_if_mixin_drifted(name: &str, mixin: Option<&str>) {
|
|
let path = paths::sandbox_mixin_hash_file(name);
|
|
let Ok(stored) = fs::read_to_string(&path) else {
|
|
return;
|
|
};
|
|
|
|
if stored.trim() != credentials_mixin_hash(mixin) {
|
|
eprintln!(
|
|
"Warning: the MCP config changed since sandbox '{name}' was created; its \
|
|
baked-in network and credential rules are stale. Remove and re-create \
|
|
the sandbox to apply the new rules: sbx rm {name}"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn inject_llm_secret(
|
|
config_content: &str,
|
|
vault: &Vault,
|
|
registered: &HashSet<String>,
|
|
) -> Result<()> {
|
|
let value: serde_yaml::Value = serde_yaml::from_str(config_content)
|
|
.context("Failed to parse config for LLM secret injection")?;
|
|
|
|
let Some(clients) = value.get("clients").and_then(|v| v.as_sequence()) else {
|
|
return Ok(());
|
|
};
|
|
|
|
for client in clients {
|
|
let Some(api_key) = client.get("api_key").and_then(|v| v.as_str()) else {
|
|
continue;
|
|
};
|
|
|
|
let Some(caps) = SECRET_RE.captures(api_key)? else {
|
|
continue;
|
|
};
|
|
let secret_name = caps[1].to_string();
|
|
|
|
let client_type = client.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
|
let client_name = client.get("name").and_then(|v| v.as_str());
|
|
let service = provider_to_sbx_service(client_type, client_name);
|
|
|
|
if registered.contains(&service) {
|
|
eprintln!(
|
|
"Secret for '{service}' already registered with sbx. \
|
|
To update it, run: sbx secret set --force {service}"
|
|
);
|
|
continue;
|
|
}
|
|
|
|
let secret_value = vault
|
|
.get_secret(&secret_name, false)
|
|
.with_context(|| format!("Failed to decrypt LLM api_key secret '{secret_name}'"))?;
|
|
|
|
sbx_secret_set(&service, &secret_value)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// Proxy-managed credentials go through `sbx secret set` and are declared in
|
|
/// the mixin. The rest (slot-conflicted or non-header secrets) go through
|
|
/// `sbx secret set-custom`; they are only accumulated into `custom_plans`
|
|
/// here. `provision_custom_secrets` registers each env var once with the
|
|
/// union of targets from every source (MCP and RAG) that needs it.
|
|
fn inject_mcp_secrets(
|
|
vault: &Vault,
|
|
registered: &SbxSecrets,
|
|
custom_plans: &mut BTreeMap<String, CustomSecretPlan>,
|
|
) -> Result<Option<String>> {
|
|
let mcp_path = paths::mcp_config_file();
|
|
if !mcp_path.exists() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let content = fs::read_to_string(&mcp_path)
|
|
.with_context(|| format!("Failed to read {}", mcp_path.display()))?;
|
|
let mcp: Value = serde_json::from_str(&content)
|
|
.with_context(|| format!("Failed to parse {}", mcp_path.display()))?;
|
|
|
|
let Some(servers) = mcp.get("mcpServers").and_then(|v| v.as_object()) else {
|
|
return Ok(None);
|
|
};
|
|
|
|
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);
|
|
}
|
|
|
|
for credential in &credentials {
|
|
if credential.proxy_managed {
|
|
if registered.services.contains(credential.service_id.as_str()) {
|
|
eprintln!(
|
|
"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(&credential.secret_name, false)
|
|
.with_context(|| mcp_secret_missing_hint(credential))?;
|
|
|
|
sbx_secret_set(&credential.service_id, &secret_value)?;
|
|
continue;
|
|
}
|
|
|
|
add_custom_secret_plan(
|
|
custom_plans,
|
|
&credential.secret_name,
|
|
credential.custom_hosts.iter().cloned(),
|
|
format!(
|
|
"MCP server(s) {}",
|
|
mcp_credentials::quoted_list(&credential.servers)
|
|
),
|
|
true,
|
|
);
|
|
}
|
|
|
|
Ok(Some(mcp_credentials::render_mixin_yaml(
|
|
&credentials,
|
|
&allow_entries,
|
|
)?))
|
|
}
|
|
|
|
fn mcp_secret_missing_hint(credential: &mcp_credentials::CredentialSpec) -> String {
|
|
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
|
|
)
|
|
}
|
|
|
|
/// One custom secret to provision, accumulated across every source (RAG
|
|
/// driver configs, demoted MCP credentials) before anything is registered,
|
|
/// so an env var shared by several sources gets exactly one registration
|
|
/// with the union of their target hosts.
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
struct CustomSecretPlan {
|
|
secret_name: String,
|
|
hosts: BTreeSet<String>,
|
|
/// Human labels ("RAG 'docs'", "MCP server(s) 'kong'") for notices.
|
|
sources: BTreeSet<String>,
|
|
/// When false a missing vault secret only warns (RAG behavior); any
|
|
/// strict source (MCP) upgrades the whole plan to a hard error.
|
|
strict: bool,
|
|
}
|
|
|
|
fn add_custom_secret_plan(
|
|
plans: &mut BTreeMap<String, CustomSecretPlan>,
|
|
secret_name: &str,
|
|
hosts: impl IntoIterator<Item = String>,
|
|
source: String,
|
|
strict: bool,
|
|
) {
|
|
let plan = plans
|
|
.entry(sandbox_secret_env_var(secret_name))
|
|
.or_insert_with(|| CustomSecretPlan {
|
|
secret_name: secret_name.to_string(),
|
|
hosts: BTreeSet::new(),
|
|
sources: BTreeSet::new(),
|
|
strict: false,
|
|
});
|
|
plan.hosts.extend(hosts);
|
|
plan.sources.insert(source);
|
|
plan.strict |= strict;
|
|
}
|
|
|
|
/// Target hosts for a custom secret; falls back to the `'**'` wildcard (match
|
|
/// any host) when none could be derived, so the secret is still provisioned.
|
|
fn custom_secret_targets(plan: &CustomSecretPlan) -> Vec<String> {
|
|
if plan.hosts.is_empty() {
|
|
eprintln!(
|
|
"Warning: no target host could be derived for secret '{}'; \
|
|
registering its sandbox custom secret with the wildcard target '**', \
|
|
so the proxy replaces its placeholder in headers sent to ANY host.",
|
|
plan.secret_name
|
|
);
|
|
return vec!["**".to_string()];
|
|
}
|
|
|
|
plan.hosts.iter().cloned().collect()
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum CustomSecretAction {
|
|
/// No custom secret is registered for this env var yet.
|
|
Register {
|
|
targets: Vec<String>,
|
|
},
|
|
Covered,
|
|
/// Targets drifted. sbx cannot update targets in place, so the existing
|
|
/// registration is removed (by placeholder) and re-registered with the
|
|
/// union of old and new targets. A union so that scope widened outside
|
|
/// Coyote is never narrowed. Values are re-seeded from the vault, so
|
|
/// this is an update, not a deletion.
|
|
Replace {
|
|
placeholder: String,
|
|
targets: Vec<String>,
|
|
},
|
|
}
|
|
|
|
fn plan_custom_secret_action(
|
|
existing: Option<&CustomSecret>,
|
|
wanted: &[String],
|
|
) -> CustomSecretAction {
|
|
let Some(existing) = existing else {
|
|
return CustomSecretAction::Register {
|
|
targets: wanted.to_vec(),
|
|
};
|
|
};
|
|
|
|
let covered =
|
|
existing.targets.contains("**") || wanted.iter().all(|t| existing.targets.contains(t));
|
|
if covered {
|
|
return CustomSecretAction::Covered;
|
|
}
|
|
|
|
let targets: Vec<String> = existing
|
|
.targets
|
|
.iter()
|
|
.chain(wanted.iter())
|
|
.cloned()
|
|
.collect::<BTreeSet<_>>()
|
|
.into_iter()
|
|
.collect();
|
|
CustomSecretAction::Replace {
|
|
placeholder: existing.placeholder.clone(),
|
|
targets,
|
|
}
|
|
}
|
|
|
|
/// Registers every accumulated custom secret with sbx and returns the env
|
|
/// vars that were newly (re-)registered. Never re-registers on a value
|
|
/// change (values are write-once here) only on target drift.
|
|
fn provision_custom_secrets(
|
|
vault: &Vault,
|
|
registered: &SbxSecrets,
|
|
plans: BTreeMap<String, CustomSecretPlan>,
|
|
) -> Result<Vec<String>> {
|
|
let mut new_envs = Vec::new();
|
|
for (env_var, plan) in plans {
|
|
let targets = custom_secret_targets(&plan);
|
|
let sources = plan.sources.iter().cloned().collect::<Vec<_>>().join(", ");
|
|
eprintln!(
|
|
"Secret '{}' (used by {sources}) resolves to a proxy placeholder inside \
|
|
the sandbox (env var {env_var}); the real value is only injected into \
|
|
HTTP(S) request headers sent to: {}.",
|
|
plan.secret_name,
|
|
targets.join(", ")
|
|
);
|
|
|
|
let action = plan_custom_secret_action(registered.custom.get(&env_var), &targets);
|
|
if let CustomSecretAction::Covered = action {
|
|
eprintln!("Custom secret '{env_var}' already registered with sbx.");
|
|
let existing = ®istered.custom[&env_var];
|
|
if existing.targets.contains("**") && targets != ["**"] {
|
|
eprintln!(
|
|
"Note: the existing registration targets the wildcard '**', wider \
|
|
than the derived host(s) {}. To re-scope it, remove it with \
|
|
`sbx secret rm --placeholder {} -f` and re-launch.",
|
|
mcp_credentials::quoted_list(&targets),
|
|
existing.placeholder
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Resolve the value BEFORE any removal so a missing vault secret
|
|
// never destroys an existing registration.
|
|
let secret_value = match vault.get_secret(&plan.secret_name, false) {
|
|
Ok(value) => value,
|
|
Err(e) if !plan.strict => {
|
|
eprintln!(
|
|
"Warning: could not load secret '{}' (used by {sources}): {e}. \
|
|
Requests that need it will fail inside the sandbox. \
|
|
Run `coyote --add-secret {}` to fix.",
|
|
plan.secret_name, plan.secret_name
|
|
);
|
|
continue;
|
|
}
|
|
Err(e) => {
|
|
return Err(e).with_context(|| {
|
|
format!(
|
|
"Secret '{}' (used by {sources}) not found in vault. \
|
|
Add it with: coyote --add-secret {}",
|
|
plan.secret_name, plan.secret_name
|
|
)
|
|
});
|
|
}
|
|
};
|
|
|
|
let (targets, replaced) = match action {
|
|
CustomSecretAction::Register { targets } => (targets, false),
|
|
CustomSecretAction::Replace {
|
|
placeholder,
|
|
targets,
|
|
} => {
|
|
eprintln!(
|
|
"Updating the sbx custom secret for '{env_var}' to cover target \
|
|
host(s) {}.",
|
|
mcp_credentials::quoted_list(&targets)
|
|
);
|
|
|
|
if !sbx_secret_rm_custom(&placeholder)? {
|
|
continue;
|
|
}
|
|
|
|
(targets, true)
|
|
}
|
|
CustomSecretAction::Covered => unreachable!("handled above"),
|
|
};
|
|
|
|
if sbx_secret_set_custom(&env_var, &targets, &secret_value)? {
|
|
new_envs.push(env_var);
|
|
} else if replaced {
|
|
eprintln!(
|
|
"Warning: the old registration for '{env_var}' was removed but \
|
|
re-registering it failed; it will be re-registered from the vault \
|
|
on the next launch."
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(new_envs)
|
|
}
|
|
|
|
/// Accumulates every attached RAG's driver_config secrets into `custom_plans`
|
|
/// (bound to `COYOTE_SECRET_<NAME>`, the env var `interpolate_secrets`
|
|
/// resolves inside the sandbox). Non-strict: a missing vault secret warns at
|
|
/// provisioning time instead of failing the launch, meaning only that RAG's
|
|
/// queries would fail.
|
|
fn collect_rag_custom_secrets(custom_plans: &mut BTreeMap<String, CustomSecretPlan>) -> Result<()> {
|
|
let rags_dir = paths::rags_dir();
|
|
if !rags_dir.exists() {
|
|
return Ok(());
|
|
}
|
|
for entry in fs::read_dir(&rags_dir)?.flatten() {
|
|
let path = entry.path();
|
|
if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
|
|
continue;
|
|
}
|
|
let stem = match path.file_stem().and_then(|s| s.to_str()) {
|
|
Some(s) if !paths::is_rag_sidecar_name(s) => s.to_string(),
|
|
_ => continue,
|
|
};
|
|
let Ok(raw) = fs::read_to_string(&path) else {
|
|
continue;
|
|
};
|
|
let Ok(data) = serde_yaml::from_str::<RagData>(&raw) else {
|
|
continue;
|
|
};
|
|
if !data.attached {
|
|
continue;
|
|
}
|
|
let secret_names = driver_config_secret_names(&data);
|
|
if secret_names.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let hosts = rag_driver_hosts(&data);
|
|
for secret_name in &secret_names {
|
|
add_custom_secret_plan(
|
|
custom_plans,
|
|
secret_name,
|
|
hosts.iter().cloned(),
|
|
format!("RAG '{stem}'"),
|
|
false,
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn driver_config_secret_names(data: &RagData) -> Vec<String> {
|
|
let mut names: Vec<String> = Vec::new();
|
|
for value in data.driver_config.values() {
|
|
let trimmed = value.trim();
|
|
let Ok(Some(caps)) = SECRET_RE.captures(trimmed) else {
|
|
continue;
|
|
};
|
|
if caps.get(0).map(|m| m.as_str()) != Some(trimmed) {
|
|
continue;
|
|
}
|
|
let Some(name) = caps.get(1).map(|m| m.as_str().trim()) else {
|
|
continue;
|
|
};
|
|
if !name.is_empty() && !names.iter().any(|n| n == name) {
|
|
names.push(name.to_string());
|
|
}
|
|
}
|
|
names
|
|
}
|
|
|
|
/// Derives custom-secret target hosts from a RAG's driver_config: any http(s)
|
|
/// URL in a value contributes its host, and the `host`/`url` keys also accept
|
|
/// a bare `host[:port]` value (e.g. `qdrant.example.com:6333`). Ports are
|
|
/// stripped (sbx custom-secret targets are host-only).
|
|
fn rag_driver_hosts(data: &RagData) -> Vec<String> {
|
|
let mut hosts: BTreeSet<String> = BTreeSet::new();
|
|
for (key, value) in &data.driver_config {
|
|
let trimmed = value.trim();
|
|
hosts.extend(mcp_credentials::hosts_in_text(trimmed));
|
|
if (key == "host" || key == "url")
|
|
&& let Some(host) = bare_host(trimmed)
|
|
{
|
|
hosts.insert(host);
|
|
}
|
|
}
|
|
|
|
hosts.into_iter().collect()
|
|
}
|
|
|
|
fn bare_host(value: &str) -> Option<String> {
|
|
if value.is_empty()
|
|
|| value.contains("://")
|
|
|| value.contains("{{")
|
|
|| value.contains('/')
|
|
|| value.contains(char::is_whitespace)
|
|
{
|
|
return None;
|
|
}
|
|
|
|
let host = match value.rsplit_once(':') {
|
|
Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host,
|
|
Some(_) => value,
|
|
None => value,
|
|
};
|
|
|
|
if host.is_empty() || host.contains(':') || host.starts_with('[') {
|
|
return None;
|
|
}
|
|
|
|
Some(host.to_string())
|
|
}
|
|
|
|
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" => "gemini".to_string(),
|
|
"openai-compatible" => client_name.unwrap_or("openai-compatible").to_string(),
|
|
other => client_name.unwrap_or(other).to_string(),
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct SbxSecrets {
|
|
services: HashSet<String>,
|
|
custom: HashMap<String, CustomSecret>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct CustomSecret {
|
|
targets: BTreeSet<String>,
|
|
placeholder: String,
|
|
}
|
|
|
|
fn sbx_registered_secrets() -> Result<SbxSecrets> {
|
|
let (success, stdout, stderr) = run_command_with_output(SBX_BINARY, &["secret", "ls"], None)
|
|
.context("Failed to run `sbx secret ls`")?;
|
|
|
|
if !success {
|
|
eprintln!(
|
|
"Warning: `sbx secret ls` failed ({}); Coyote cannot tell which secrets \
|
|
are already registered and may attempt to re-register existing ones.",
|
|
stderr.trim()
|
|
);
|
|
return Ok(SbxSecrets::default());
|
|
}
|
|
|
|
Ok(parse_sbx_secret_ls(&stdout))
|
|
}
|
|
|
|
fn parse_sbx_secret_ls(stdout: &str) -> SbxSecrets {
|
|
let mut secrets = SbxSecrets::default();
|
|
let mut in_custom = false;
|
|
let mut in_header = true;
|
|
let mut custom_body_lines = 0usize;
|
|
let mut custom_rows = 0usize;
|
|
for line in stdout.lines() {
|
|
let trimmed = line.trim();
|
|
if trimmed.is_empty() {
|
|
continue;
|
|
}
|
|
if trimmed == "CUSTOM SECRETS" {
|
|
in_custom = true;
|
|
in_header = true;
|
|
continue;
|
|
}
|
|
if in_header {
|
|
in_header = false;
|
|
continue;
|
|
}
|
|
|
|
if in_custom {
|
|
custom_body_lines += 1;
|
|
let cols = split_columns(line);
|
|
let [scope, targets, env, placeholder, ..] = cols.as_slice() else {
|
|
continue;
|
|
};
|
|
custom_rows += 1;
|
|
if *scope != "(global)" {
|
|
continue;
|
|
}
|
|
secrets.custom.insert(
|
|
(*env).to_string(),
|
|
CustomSecret {
|
|
targets: targets.split(',').map(|t| t.trim().to_string()).collect(),
|
|
placeholder: (*placeholder).to_string(),
|
|
},
|
|
);
|
|
} else {
|
|
let mut parts = line.split_whitespace();
|
|
let (Some(scope), Some(_kind), Some(name)) = (parts.next(), parts.next(), parts.next())
|
|
else {
|
|
continue;
|
|
};
|
|
if scope == "(global)" {
|
|
secrets.services.insert(name.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
if custom_body_lines > 0 && custom_rows == 0 {
|
|
eprintln!(
|
|
"Warning: no rows could be parsed from the CUSTOM SECRETS section of \
|
|
`sbx secret ls`; its output format may have changed. Custom-secret \
|
|
idempotency checks are disabled for this launch."
|
|
);
|
|
}
|
|
|
|
secrets
|
|
}
|
|
|
|
fn split_columns(line: &str) -> Vec<&str> {
|
|
let mut out = Vec::new();
|
|
let mut rest = line.trim();
|
|
while !rest.is_empty() {
|
|
match rest.find(" ") {
|
|
Some(idx) => {
|
|
out.push(&rest[..idx]);
|
|
rest = rest[idx..].trim_start();
|
|
}
|
|
None => {
|
|
out.push(rest);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
out
|
|
}
|
|
|
|
fn sbx_secret_set(service: &str, secret_value: &str) -> Result<()> {
|
|
let mut child = Command::new(SBX_BINARY)
|
|
.args(["secret", "set", service])
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::inherit())
|
|
.stderr(Stdio::inherit())
|
|
.spawn()
|
|
.context("Failed to spawn `sbx secret set`")?;
|
|
|
|
if let Some(mut stdin_handle) = child.stdin.take()
|
|
&& let Err(e) = stdin_handle.write_all(secret_value.as_bytes())
|
|
&& e.kind() != io::ErrorKind::BrokenPipe
|
|
{
|
|
return Err(anyhow!(e).context("Failed to write secret to `sbx secret set` stdin"));
|
|
}
|
|
|
|
let status = child
|
|
.wait()
|
|
.context("Failed to wait for `sbx secret set`")?;
|
|
|
|
if !status.success() {
|
|
eprintln!(
|
|
"Warning: failed to register sbx secret '{service}' \
|
|
(`sbx secret set {service}` exited with {status}). \
|
|
Set it manually with: sbx secret set {service} \
|
|
(the value is read from the prompt)"
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn sbx_secret_set_custom(env_var: &str, targets: &[String], secret_value: &str) -> Result<bool> {
|
|
let mut args: Vec<&str> = vec!["secret", "set-custom", "--env", env_var];
|
|
for target in targets {
|
|
args.push("--host");
|
|
args.push(target);
|
|
}
|
|
|
|
debug!(
|
|
"sbx secret set-custom --env {env_var} (targets: {})",
|
|
targets.join(", ")
|
|
);
|
|
|
|
let mut child = Command::new(SBX_BINARY)
|
|
.args(&args)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::inherit())
|
|
.stderr(Stdio::inherit())
|
|
.spawn()
|
|
.context("Failed to spawn `sbx secret set-custom`")?;
|
|
|
|
if let Some(mut stdin_handle) = child.stdin.take()
|
|
&& let Err(e) = stdin_handle.write_all(secret_value.as_bytes())
|
|
&& e.kind() != io::ErrorKind::BrokenPipe
|
|
{
|
|
return Err(anyhow!(e).context("Failed to write secret to `sbx secret set-custom` stdin"));
|
|
}
|
|
|
|
let status = child
|
|
.wait()
|
|
.context("Failed to wait for `sbx secret set-custom`")?;
|
|
|
|
if !status.success() {
|
|
let host_flags: String = targets.iter().map(|t| format!(" --host '{t}'")).collect();
|
|
eprintln!(
|
|
"Warning: failed to register sbx custom secret '{env_var}' \
|
|
(`sbx secret set-custom` exited with {status}). Set it manually with: \
|
|
sbx secret set-custom --env {env_var}{host_flags} \
|
|
(the value is read from the prompt)"
|
|
);
|
|
}
|
|
|
|
Ok(status.success())
|
|
}
|
|
|
|
fn sbx_secret_rm_custom(placeholder: &str) -> Result<bool> {
|
|
debug!("sbx secret rm --placeholder {placeholder} -f");
|
|
let status = Command::new(SBX_BINARY)
|
|
.args(["secret", "rm", "--placeholder", placeholder, "-f"])
|
|
.stdin(Stdio::inherit())
|
|
.stdout(Stdio::inherit())
|
|
.stderr(Stdio::inherit())
|
|
.status()
|
|
.context("Failed to spawn `sbx secret rm`")?;
|
|
|
|
if !status.success() {
|
|
eprintln!(
|
|
"Warning: failed to remove the outdated sbx custom secret \
|
|
(`sbx secret rm --placeholder {placeholder} -f` exited with {status}); \
|
|
its targets were left unchanged."
|
|
);
|
|
}
|
|
|
|
Ok(status.success())
|
|
}
|
|
|
|
fn sandbox_exists(name: &str) -> Result<bool> {
|
|
let (success, stdout, stderr) =
|
|
run_command_with_output(SBX_BINARY, &["ls"], None).context("Failed to run `sbx ls`")?;
|
|
if !success {
|
|
bail!("`sbx ls` failed: {stderr}");
|
|
}
|
|
|
|
Ok(stdout
|
|
.lines()
|
|
.skip(1)
|
|
.any(|line| line.split_whitespace().next() == Some(name)))
|
|
}
|
|
|
|
fn create_sandbox(
|
|
name: &str,
|
|
kit_path: &Path,
|
|
mixins: &[DiscoveredMixin],
|
|
credentials_mixin: Option<&str>,
|
|
) -> Result<()> {
|
|
info!("Creating sandbox '{name}'");
|
|
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)
|
|
.stdin(Stdio::inherit())
|
|
.stdout(Stdio::inherit())
|
|
.stderr(Stdio::inherit())
|
|
.status()
|
|
.context("Failed to spawn `sbx create`")?;
|
|
|
|
if !status.success() {
|
|
bail!("`sbx create` exited with {status}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
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()
|
|
.ok_or_else(|| anyhow!("Kit path is not valid UTF-8: {}", kit_path.display()))?;
|
|
|
|
let mut args = vec![
|
|
"create".to_string(),
|
|
"--name".to_string(),
|
|
name.to_string(),
|
|
"--kit".to_string(),
|
|
kit_str.to_string(),
|
|
];
|
|
|
|
for mixin in mixins {
|
|
let mixin_kit = mixin.kit_path()?;
|
|
let mixin_str = mixin_kit
|
|
.to_str()
|
|
.ok_or_else(|| anyhow!("Mixin kit path is not valid UTF-8: {}", mixin_kit.display()))?
|
|
.to_string();
|
|
args.push("--kit".to_string());
|
|
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());
|
|
|
|
Ok(args)
|
|
}
|
|
|
|
fn copy_host_files(name: &str) -> Result<()> {
|
|
let config_dir = paths::config_dir();
|
|
|
|
if config_dir.exists() {
|
|
let sandbox_config_dir = "/home/agent/.config/coyote";
|
|
ensure_sandbox_dir(name, sandbox_config_dir)?;
|
|
let dest = format!("{name}:{sandbox_config_dir}/");
|
|
for entry in fs::read_dir(&config_dir)
|
|
.with_context(|| format!("Failed to read {}", config_dir.display()))?
|
|
{
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
if path.file_name().is_some_and(|n| n == VAULT_DATA_FILE_NAME) {
|
|
continue;
|
|
}
|
|
sbx_cp(&path.display().to_string(), &dest)?;
|
|
}
|
|
chown_agent_recursive(name, sandbox_config_dir)?;
|
|
} else {
|
|
debug!(
|
|
"Skipping config copy: {} does not exist",
|
|
config_dir.display()
|
|
);
|
|
}
|
|
|
|
let oauth_tokens_dir = paths::oauth_tokens_dir();
|
|
if oauth_tokens_dir.exists() {
|
|
let sandbox_cache_dir = "/home/agent/.cache";
|
|
let sandbox_oauth_dir = "/home/agent/.cache/coyote/oauth";
|
|
ensure_sandbox_dir(name, sandbox_oauth_dir)?;
|
|
let dest = format!("{name}:{sandbox_oauth_dir}/");
|
|
for entry in fs::read_dir(&oauth_tokens_dir)
|
|
.with_context(|| format!("Failed to read {}", oauth_tokens_dir.display()))?
|
|
{
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
sbx_cp(&path.display().to_string(), &dest)?;
|
|
}
|
|
chown_agent_recursive(name, sandbox_cache_dir)?;
|
|
} else {
|
|
debug!(
|
|
"Skipping OAuth token copy: {} does not exist",
|
|
oauth_tokens_dir.display()
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_sandbox_dir(sandbox: &str, dir: &str) -> Result<()> {
|
|
let dir_q = shell_words::quote(dir);
|
|
let cmd = format!("sudo mkdir -p {dir_q} && sudo chown agent:agent {dir_q}");
|
|
|
|
debug!("sbx exec {sandbox}: {cmd}");
|
|
|
|
let status = Command::new(SBX_BINARY)
|
|
.args(["exec", sandbox, "sh", "-c", &cmd])
|
|
.stdin(Stdio::inherit())
|
|
.stdout(Stdio::inherit())
|
|
.stderr(Stdio::inherit())
|
|
.status()
|
|
.context("Failed to spawn `sbx exec` to prepare destination directory")?;
|
|
|
|
if !status.success() {
|
|
bail!("Preparing sandbox directory '{dir}' failed: sbx exec exited with {status}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn sbx_cp(src: &str, dest: &str) -> Result<()> {
|
|
debug!("sbx cp {src} {dest}");
|
|
let status = Command::new(SBX_BINARY)
|
|
.args(["cp", src, dest])
|
|
.stdin(Stdio::inherit())
|
|
.stdout(Stdio::inherit())
|
|
.stderr(Stdio::inherit())
|
|
.status()
|
|
.context("Failed to spawn `sbx cp`")?;
|
|
|
|
if !status.success() {
|
|
bail!("`sbx cp {src} {dest}` exited with {status}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn exec_run(name: &str, kit_path: &Path) -> Result<()> {
|
|
let kit_str = kit_path
|
|
.to_str()
|
|
.ok_or_else(|| anyhow!("Kit path is not valid UTF-8: {}", kit_path.display()))?;
|
|
debug!("sbx run --name {name} --kit {kit_str}");
|
|
let status = Command::new(SBX_BINARY)
|
|
.args(["run", "--name", name, "--kit", kit_str])
|
|
.stdin(Stdio::inherit())
|
|
.stdout(Stdio::inherit())
|
|
.stderr(Stdio::inherit())
|
|
.status()
|
|
.context("Failed to spawn `sbx run`")?;
|
|
|
|
if !status.success() {
|
|
bail!("`sbx run` exited with {status}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn chown_agent_recursive(sandbox: &str, path: &str) -> Result<()> {
|
|
let path_q = shell_words::quote(path);
|
|
let cmd = format!("sudo chown -R agent:agent {path_q}");
|
|
|
|
debug!("sbx exec {sandbox}: {cmd}");
|
|
|
|
let status = Command::new(SBX_BINARY)
|
|
.args(["exec", sandbox, "sh", "-c", &cmd])
|
|
.stdin(Stdio::inherit())
|
|
.stdout(Stdio::inherit())
|
|
.stderr(Stdio::inherit())
|
|
.status()
|
|
.context("Failed to spawn `sbx exec` to chown copied files")?;
|
|
|
|
if !status.success() {
|
|
bail!("Chowning '{path}' in sandbox failed: sbx exec exited with {status}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn rag_with(driver_config: &[(&str, &str)]) -> RagData {
|
|
let mut data = RagData::new("m".into(), 1024, 50, None, 5, None, Default::default());
|
|
data.driver = "qdrant".to_string();
|
|
data.attached = true;
|
|
for (k, v) in driver_config {
|
|
data.driver_config.insert(k.to_string(), v.to_string());
|
|
}
|
|
data
|
|
}
|
|
|
|
#[test]
|
|
fn secret_names_are_found_whatever_the_field_is_called() {
|
|
let data = rag_with(&[
|
|
("host", "qdrant.example.com:6333"),
|
|
("collection", "docs"),
|
|
("token", "{{SOME_TOKEN}}"),
|
|
]);
|
|
|
|
assert_eq!(driver_config_secret_names(&data), vec!["SOME_TOKEN"]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_literal_credential_is_not_treated_as_a_secret_name() {
|
|
let data = rag_with(&[("api_key", "sk-a-real-looking-key")]);
|
|
|
|
assert!(driver_config_secret_names(&data).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn plain_values_are_never_mistaken_for_secrets() {
|
|
let data = rag_with(&[("host", "localhost:6333"), ("collection", "docs")]);
|
|
|
|
assert!(driver_config_secret_names(&data).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_partial_placeholder_is_not_a_credential() {
|
|
let data = rag_with(&[("api_key", "Bearer {{KEY}}")]);
|
|
|
|
assert!(driver_config_secret_names(&data).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn several_secrets_are_all_found_and_deduped() {
|
|
let data = rag_with(&[
|
|
("api_key", "{{QDRANT_KEY}}"),
|
|
("host", "localhost:6333"),
|
|
("token", "{{ OTHER_TOKEN }}"),
|
|
("fallback_key", "{{QDRANT_KEY}}"),
|
|
]);
|
|
|
|
assert_eq!(
|
|
driver_config_secret_names(&data),
|
|
vec!["QDRANT_KEY", "OTHER_TOKEN"],
|
|
"order follows driver_config, and a repeat is not registered twice"
|
|
);
|
|
}
|
|
|
|
/// Pinned to the `sbx secret ls` output shape of sbx v0.38.0.
|
|
const SECRET_LS_SAMPLE: &str = "\
|
|
SCOPE TYPE NAME SECRET
|
|
(global) service github (stored)
|
|
(global) service kong-prod-pat (stored)
|
|
(global) service anthropic (oauth configured)
|
|
|
|
CUSTOM SECRETS
|
|
SCOPE TARGETS ENV PLACEHOLDER SECRET
|
|
(global) api.stripe.com STRIPE_API_KEY sbx-cs-97BPiO11AS93Tlo5 rk_liv******...******JT6n
|
|
";
|
|
|
|
#[test]
|
|
fn parse_sbx_secret_ls_reads_both_sections() {
|
|
let secrets = parse_sbx_secret_ls(SECRET_LS_SAMPLE);
|
|
|
|
assert_eq!(
|
|
secrets.services,
|
|
HashSet::from([
|
|
"github".to_string(),
|
|
"kong-prod-pat".to_string(),
|
|
"anthropic".to_string()
|
|
])
|
|
);
|
|
let custom = secrets.custom.get("STRIPE_API_KEY").unwrap();
|
|
assert_eq!(
|
|
custom.targets,
|
|
BTreeSet::from(["api.stripe.com".to_string()])
|
|
);
|
|
assert_eq!(custom.placeholder, "sbx-cs-97BPiO11AS93Tlo5");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_sbx_secret_ls_splits_comma_separated_targets() {
|
|
// Pinned to a live capture: multiple --host targets render as one
|
|
// comma+space-separated TARGETS field, columns padded to 2+ spaces.
|
|
let output = "\
|
|
SCOPE TYPE NAME SECRET
|
|
(global) service github (stored)
|
|
|
|
CUSTOM SECRETS
|
|
SCOPE TARGETS ENV PLACEHOLDER SECRET
|
|
(global) probe.invalid, other.probe.invalid, a-quite-long-hostname.subdomain.probe.invalid COYOTE_TEST_PROBE sbx-cs-TdaC76ZA3MYAfNAt probe-*******
|
|
";
|
|
|
|
let secrets = parse_sbx_secret_ls(output);
|
|
|
|
let custom = secrets.custom.get("COYOTE_TEST_PROBE").unwrap();
|
|
assert_eq!(
|
|
custom.targets,
|
|
BTreeSet::from([
|
|
"probe.invalid".to_string(),
|
|
"other.probe.invalid".to_string(),
|
|
"a-quite-long-hostname.subdomain.probe.invalid".to_string()
|
|
])
|
|
);
|
|
assert_eq!(custom.placeholder, "sbx-cs-TdaC76ZA3MYAfNAt");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_sbx_secret_ls_without_custom_section() {
|
|
let output = "\
|
|
SCOPE TYPE NAME SECRET
|
|
(global) service github (stored)
|
|
";
|
|
|
|
let secrets = parse_sbx_secret_ls(output);
|
|
|
|
assert_eq!(secrets.services, HashSet::from(["github".to_string()]));
|
|
assert!(secrets.custom.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_sbx_secret_ls_ignores_non_global_rows() {
|
|
let output = "\
|
|
SCOPE TYPE NAME SECRET
|
|
my-box service github (stored)
|
|
|
|
CUSTOM SECRETS
|
|
SCOPE TARGETS ENV PLACEHOLDER SECRET
|
|
my-box api.stripe.com API_KEY sbx-cs-abc12 sk-***
|
|
";
|
|
|
|
let secrets = parse_sbx_secret_ls(output);
|
|
|
|
assert!(secrets.services.is_empty());
|
|
assert!(secrets.custom.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_sbx_secret_ls_handles_empty_output() {
|
|
let secrets = parse_sbx_secret_ls("");
|
|
|
|
assert!(secrets.services.is_empty());
|
|
assert!(secrets.custom.is_empty());
|
|
}
|
|
|
|
fn plan_for(secret_name: &str, hosts: &[&str]) -> CustomSecretPlan {
|
|
CustomSecretPlan {
|
|
secret_name: secret_name.to_string(),
|
|
hosts: hosts.iter().map(|h| h.to_string()).collect(),
|
|
sources: BTreeSet::from(["test".to_string()]),
|
|
strict: true,
|
|
}
|
|
}
|
|
|
|
fn strs(items: &[&str]) -> Vec<String> {
|
|
items.iter().map(|s| s.to_string()).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn custom_secret_targets_falls_back_to_wildcard() {
|
|
assert_eq!(
|
|
custom_secret_targets(&plan_for("KEY", &[])),
|
|
vec!["**".to_string()]
|
|
);
|
|
assert_eq!(
|
|
custom_secret_targets(&plan_for("KEY", &["api.example.com"])),
|
|
vec!["api.example.com".to_string()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plan_action_registers_when_no_secret_exists() {
|
|
assert_eq!(
|
|
plan_custom_secret_action(None, &strs(&["api.example.com"])),
|
|
CustomSecretAction::Register {
|
|
targets: strs(&["api.example.com"])
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plan_action_skips_a_covering_registration() {
|
|
let existing = CustomSecret {
|
|
targets: BTreeSet::from(["a.example.com".to_string(), "b.example.com".to_string()]),
|
|
placeholder: "sbx-cs-x".to_string(),
|
|
};
|
|
|
|
assert_eq!(
|
|
plan_custom_secret_action(Some(&existing), &strs(&["a.example.com"])),
|
|
CustomSecretAction::Covered
|
|
);
|
|
assert_eq!(
|
|
plan_custom_secret_action(Some(&existing), &strs(&["a.example.com", "b.example.com"])),
|
|
CustomSecretAction::Covered
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plan_action_treats_wildcard_as_covering_everything() {
|
|
let existing = CustomSecret {
|
|
targets: BTreeSet::from(["**".to_string()]),
|
|
placeholder: "sbx-cs-x".to_string(),
|
|
};
|
|
|
|
assert_eq!(
|
|
plan_custom_secret_action(Some(&existing), &strs(&["any.example.com"])),
|
|
CustomSecretAction::Covered,
|
|
"a wildcard registration is never narrowed, only noted"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plan_action_replaces_on_target_drift_with_the_union() {
|
|
let existing = CustomSecret {
|
|
targets: BTreeSet::from(["a.example.com".to_string()]),
|
|
placeholder: "sbx-cs-x".to_string(),
|
|
};
|
|
|
|
assert_eq!(
|
|
plan_custom_secret_action(Some(&existing), &strs(&["b.example.com"])),
|
|
CustomSecretAction::Replace {
|
|
placeholder: "sbx-cs-x".to_string(),
|
|
targets: strs(&["a.example.com", "b.example.com"]),
|
|
},
|
|
"drift removes the old registration (by placeholder) and re-registers \
|
|
with the union so scope widened outside Coyote is never narrowed"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shared_rag_and_mcp_secret_accumulates_one_plan_with_unioned_hosts() {
|
|
let mut plans: BTreeMap<String, CustomSecretPlan> = BTreeMap::new();
|
|
|
|
add_custom_secret_plan(
|
|
&mut plans,
|
|
"OPENAI_KEY",
|
|
strs(&["qdrant.example.com"]),
|
|
"RAG 'docs'".to_string(),
|
|
false,
|
|
);
|
|
add_custom_secret_plan(
|
|
&mut plans,
|
|
"OPENAI_KEY",
|
|
strs(&["api.openai.com"]),
|
|
"MCP server(s) 'assistant'".to_string(),
|
|
true,
|
|
);
|
|
|
|
assert_eq!(plans.len(), 1, "one env var must yield one registration");
|
|
let plan = &plans["COYOTE_SECRET_OPENAI_KEY"];
|
|
assert_eq!(
|
|
plan.hosts,
|
|
BTreeSet::from([
|
|
"api.openai.com".to_string(),
|
|
"qdrant.example.com".to_string()
|
|
]),
|
|
"targets from every source are unioned, not last-writer-wins"
|
|
);
|
|
assert_eq!(
|
|
plan.sources,
|
|
BTreeSet::from([
|
|
"MCP server(s) 'assistant'".to_string(),
|
|
"RAG 'docs'".to_string()
|
|
])
|
|
);
|
|
assert!(
|
|
plan.strict,
|
|
"any strict source upgrades the whole plan to a hard error on a missing secret"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rag_driver_hosts_strips_port_from_bare_host() {
|
|
let data = rag_with(&[("host", "qdrant.example.com:6333"), ("collection", "docs")]);
|
|
|
|
assert_eq!(rag_driver_hosts(&data), vec!["qdrant.example.com"]);
|
|
}
|
|
|
|
#[test]
|
|
fn rag_driver_hosts_scrapes_urls_and_bare_url_key() {
|
|
let data = rag_with(&[
|
|
("url", "https://qdrant.example.com:6333"),
|
|
("proxy", "endpoint http://edge.example.com/v1"),
|
|
]);
|
|
|
|
assert_eq!(
|
|
rag_driver_hosts(&data),
|
|
vec!["edge.example.com", "qdrant.example.com"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rag_driver_hosts_ignores_placeholders_and_non_host_values() {
|
|
let data = rag_with(&[
|
|
("host", "{{QDRANT_HOST}}"),
|
|
("api_key", "{{QDRANT_KEY}}"),
|
|
("collection", "docs"),
|
|
]);
|
|
|
|
assert!(rag_driver_hosts(&data).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn bare_host_accepts_host_and_host_port_only() {
|
|
assert_eq!(
|
|
bare_host("qdrant.example.com"),
|
|
Some("qdrant.example.com".to_string())
|
|
);
|
|
assert_eq!(bare_host("localhost:6333"), Some("localhost".to_string()));
|
|
assert_eq!(bare_host("127.0.0.1:6333"), Some("127.0.0.1".to_string()));
|
|
assert_eq!(bare_host("https://a.example.com"), None);
|
|
assert_eq!(bare_host("{{HOST}}"), None);
|
|
assert_eq!(bare_host("host/path"), None);
|
|
assert_eq!(bare_host("two words"), None);
|
|
assert_eq!(bare_host("::1"), None);
|
|
assert_eq!(bare_host("[::1]:6333"), None);
|
|
assert_eq!(bare_host(""), None);
|
|
}
|
|
|
|
#[test]
|
|
fn credentials_mixin_hash_distinguishes_content_and_absence() {
|
|
assert_eq!(
|
|
credentials_mixin_hash(Some("kind: mixin\n")),
|
|
credentials_mixin_hash(Some("kind: mixin\n"))
|
|
);
|
|
assert_eq!(
|
|
credentials_mixin_hash(None),
|
|
credentials_mixin_hash(Some(""))
|
|
);
|
|
assert_ne!(
|
|
credentials_mixin_hash(None),
|
|
credentials_mixin_hash(Some("kind: mixin\n"))
|
|
);
|
|
}
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
#[test]
|
|
fn sanitize_name_lowercases() {
|
|
assert_eq!(sanitize_name("Foo"), "foo");
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_name_replaces_non_alphanumeric() {
|
|
assert_eq!(sanitize_name("hello world!"), "hello-world");
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_name_collapses_dash_runs() {
|
|
assert_eq!(sanitize_name("a___b"), "a-b");
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_name_trims_dashes() {
|
|
assert_eq!(sanitize_name("---hi---"), "hi");
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_name_handles_mixed_input() {
|
|
assert_eq!(sanitize_name("My Project (v2)"), "my-project-v2");
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_name_all_invalid_yields_empty() {
|
|
assert_eq!(sanitize_name("///"), "");
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_name_uses_explicit_arg() {
|
|
let n = resolve_name(Some("explicit-name".to_string())).unwrap();
|
|
assert_eq!(n, "explicit-name");
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_name_sanitizes_explicit_arg() {
|
|
let n = resolve_name(Some("My Sandbox!".to_string())).unwrap();
|
|
assert_eq!(n, "my-sandbox");
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_name_rejects_empty_after_sanitize() {
|
|
let err = resolve_name(Some("///".to_string()));
|
|
assert!(err.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn resolve_name_falls_back_to_cwd_when_none() {
|
|
let n = resolve_name(None).unwrap();
|
|
assert!(!n.is_empty());
|
|
assert!(n.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'));
|
|
}
|
|
|
|
#[test]
|
|
fn compute_kit_hash_is_deterministic() {
|
|
let h1 = compute_kit_hash().unwrap();
|
|
let h2 = compute_kit_hash().unwrap();
|
|
|
|
assert_eq!(h1, h2);
|
|
assert_eq!(h1.len(), 64);
|
|
}
|
|
|
|
#[test]
|
|
fn build_create_args_emits_base_kit_before_mixins() {
|
|
let kit = PathBuf::from("/cache/sbx-kit");
|
|
let unique = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
let dir_a = env::temp_dir().join(format!("coyote-mixin-a-{unique}"));
|
|
let dir_b = env::temp_dir().join(format!("coyote-mixin-b-{unique}"));
|
|
fs::create_dir_all(&dir_a).unwrap();
|
|
fs::create_dir_all(&dir_b).unwrap();
|
|
|
|
let mixins = vec![
|
|
DiscoveredMixin {
|
|
path: dir_a.clone(),
|
|
label: "user".into(),
|
|
install_count: 0,
|
|
domain_count: 0,
|
|
},
|
|
DiscoveredMixin {
|
|
path: dir_b.clone(),
|
|
label: "sql".into(),
|
|
install_count: 0,
|
|
domain_count: 0,
|
|
},
|
|
];
|
|
|
|
let args = build_create_args("my-box", &kit, &mixins, None).unwrap();
|
|
|
|
assert_eq!(
|
|
args,
|
|
vec![
|
|
"create".to_string(),
|
|
"--name".to_string(),
|
|
"my-box".to_string(),
|
|
"--kit".to_string(),
|
|
"/cache/sbx-kit".to_string(),
|
|
"--kit".to_string(),
|
|
dir_a.display().to_string(),
|
|
"--kit".to_string(),
|
|
dir_b.display().to_string(),
|
|
"coyote".to_string(),
|
|
".".to_string(),
|
|
]
|
|
);
|
|
|
|
let _ = fs::remove_dir_all(&dir_a);
|
|
let _ = fs::remove_dir_all(&dir_b);
|
|
}
|
|
|
|
#[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, &[], None).unwrap();
|
|
|
|
assert_eq!(
|
|
args,
|
|
vec![
|
|
"create".to_string(),
|
|
"--name".to_string(),
|
|
"box".to_string(),
|
|
"--kit".to_string(),
|
|
"/cache/sbx-kit".to_string(),
|
|
"coyote".to_string(),
|
|
".".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
}
|