Compare commits

...
3 Commits
Author SHA1 Message Date
Dark-Alex-17 7cb7d66575 fix: improved handling of non service-specific secrets using sbx custom-secrets
CI / All (ubuntu-latest) (push) Failing after 30s
CI / All (macos-latest) (push) Canceled after 0s
CI / All (windows-latest) (push) Canceled after 0s
2026-08-17 18:01:21 -06:00
Dark-Alex-17 400b50fbd0 feat: Support auto confirmation for gatekeeper agents 2026-08-17 15:48:36 -06:00
Dark-Alex-17 7eeff2a226 fix: include tool output to LLM_OUTPUT in errors as well as stderr 2026-08-17 11:38:21 -06:00
5 changed files with 1156 additions and 88 deletions
+3
View File
@@ -14,6 +14,9 @@ variables:
- name: project_dir - name: project_dir
description: Absolute path to the project the plan targets - the ground truth for pointer verification description: Absolute path to the project the plan targets - the ground truth for pointer verification
default: '.' default: '.'
- name: auto_confirm
description: Auto-confirm command execution
default: '1'
global_tools: global_tools:
- ast_grep.sh - ast_grep.sh
+10
View File
@@ -148,6 +148,16 @@ pub fn sbx_kit_hash_file() -> PathBuf {
sbx_kit_dir().join(SBX_KIT_HASH_FILE) sbx_kit_dir().join(SBX_KIT_HASH_FILE)
} }
pub fn sandbox_mixin_hashes_dir() -> PathBuf {
cache_dir().join("sandbox-mixin-hashes")
}
pub fn sandbox_mixin_hash_file(sandbox_name: &str) -> PathBuf {
// Sandbox names are sanitized by the caller, but never trust a path
// component: a stray separator must not escape the hash directory.
sandbox_mixin_hashes_dir().join(format!("{}.hash", sandbox_name.replace('/', "_")))
}
pub fn sbx_mixin_kits_dir() -> PathBuf { pub fn sbx_mixin_kits_dir() -> PathBuf {
cache_dir().join(SBX_MIXIN_KITS_DIR_NAME) cache_dir().join(SBX_MIXIN_KITS_DIR_NAME)
} }
+31
View File
@@ -1648,6 +1648,11 @@ pub fn run_llm_function(
if !stdout.is_empty() { if !stdout.is_empty() {
error_json["stdout"] = json!(stdout); error_json["stdout"] = json!(stdout);
} }
if let Ok(contents) = fs::read_to_string(&tmp_file)
&& !contents.trim().is_empty()
{
error_json["output"] = json!(contents);
}
debug!("Tool call error: {error_json:?}"); debug!("Tool call error: {error_json:?}");
return Ok(Some(error_json.to_string())); return Ok(Some(error_json.to_string()));
} }
@@ -2423,6 +2428,32 @@ mod tests {
fs::remove_dir_all(&dir).unwrap(); fs::remove_dir_all(&dir).unwrap();
} }
#[cfg(unix)]
#[test]
fn run_llm_function_includes_llm_output_on_nonzero_exit() {
let result = run_llm_function(
"bash".into(),
vec![
"-c".into(),
"echo partial-output >> \"$LLM_OUTPUT\"; echo err-text >&2; exit 3".into(),
],
HashMap::new(),
None,
)
.unwrap()
.expect("nonzero exit must return an error payload");
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(
json["tool_call_error"]
.as_str()
.unwrap()
.contains("exited with code 3")
);
assert_eq!(json["stderr"], "err-text");
assert_eq!(json["output"], "partial-output\n");
}
#[test] #[test]
fn bin_entry_stem_strips_run_prefix_and_extension() { fn bin_entry_stem_strips_run_prefix_and_extension() {
assert_eq!(bin_entry_stem("fs_grep"), "fs_grep"); assert_eq!(bin_entry_stem("fs_grep"), "fs_grep");
+296 -15
View File
@@ -80,6 +80,8 @@ pub(crate) struct CredentialSpec {
pub env_var: String, pub env_var: String,
pub proxy_managed: bool, pub proxy_managed: bool,
pub inject: Vec<InjectRule>, pub inject: Vec<InjectRule>,
pub custom_hosts: Vec<String>,
pub custom_allow_entries: Vec<String>,
pub servers: Vec<String>, pub servers: Vec<String>,
} }
@@ -110,8 +112,13 @@ pub(crate) fn collect_credentials(
} }
let mut by_secret: BTreeMap<String, Aggregate> = BTreeMap::new(); let mut by_secret: BTreeMap<String, Aggregate> = BTreeMap::new();
let mut hosts_by_server: BTreeMap<String, ServerSecretHosts> = BTreeMap::new();
for (server_name, config) in servers { for (server_name, config) in servers {
for occurrence in collect_server_occurrences(config)? { let occurrences = collect_server_occurrences(config)?;
if !occurrences.is_empty() {
hosts_by_server.insert(server_name.clone(), server_secret_hosts(config));
}
for occurrence in occurrences {
let agg = by_secret let agg = by_secret
.entry(occurrence.secret_name) .entry(occurrence.secret_name)
.or_insert_with(|| Aggregate { .or_insert_with(|| Aggregate {
@@ -152,8 +159,9 @@ pub(crate) fn collect_credentials(
eprintln!( eprintln!(
"MCP secrets {} all target the '{header}' header for '{domain}'. The \ "MCP secrets {} all target the '{header}' header for '{domain}'. The \
sandbox proxy cannot tell which one a given request needs, so these \ sandbox proxy cannot tell which one a given request needs, so these \
secrets will be resolved from environment variables inside the \ secrets will be provisioned as placeholder-based custom secrets \
sandbox instead.", instead: each env var holds a unique placeholder that the proxy \
swaps for the real value in outbound request headers.",
quoted_list(secrets) quoted_list(secrets)
); );
slot_conflicted.extend(secrets.iter().cloned()); slot_conflicted.extend(secrets.iter().cloned());
@@ -191,6 +199,21 @@ pub(crate) fn collect_credentials(
} }
let proxy_managed = agg.all_injectable && !slot_conflicted.contains(&secret_name); let proxy_managed = agg.all_injectable && !slot_conflicted.contains(&secret_name);
let (custom_hosts, custom_allow_entries) = if proxy_managed {
(Vec::new(), Vec::new())
} else {
let mut targets: BTreeSet<String> = BTreeSet::new();
let mut allow: BTreeSet<String> = BTreeSet::new();
for hosts in agg
.servers
.iter()
.filter_map(|server| hosts_by_server.get(server))
{
targets.extend(hosts.targets.iter().cloned());
allow.extend(hosts.allow_entries.iter().cloned());
}
(targets.into_iter().collect(), allow.into_iter().collect())
};
credentials.push(CredentialSpec { credentials.push(CredentialSpec {
secret_name, secret_name,
service_id, service_id,
@@ -201,6 +224,8 @@ pub(crate) fn collect_credentials(
} else { } else {
Vec::new() Vec::new()
}, },
custom_hosts,
custom_allow_entries,
servers: agg.servers.into_iter().collect(), servers: agg.servers.into_iter().collect(),
}); });
} }
@@ -365,6 +390,98 @@ fn parse_https_domain(raw: &str) -> Option<String> {
} }
} }
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct ServerSecretHosts {
pub targets: BTreeSet<String>,
pub allow_entries: BTreeSet<String>,
}
pub(crate) fn server_secret_hosts(server: &Value) -> ServerSecretHosts {
let mut hosts = ServerSecretHosts::default();
scrape_hosts_value(server, &mut hosts);
if let Some(host) = server
.get("url")
.and_then(Value::as_str)
.and_then(|raw| Url::parse(raw).ok())
.and_then(|url| url.host_str().map(str::to_string))
.filter(|host| is_usable_host(host))
{
hosts.targets.insert(host);
}
hosts
}
fn scrape_hosts_value(value: &Value, out: &mut ServerSecretHosts) {
match value {
Value::String(s) => {
for url in urls_in_text(s) {
let Some(host) = url.host_str().filter(|h| is_usable_host(h)) else {
continue;
};
out.targets.insert(host.to_string());
if let Some(entry) = allow_entry_for_parsed(&url) {
out.allow_entries.insert(entry);
}
}
}
Value::Object(map) => {
for v in map.values() {
scrape_hosts_value(v, out);
}
}
Value::Array(arr) => {
for v in arr {
scrape_hosts_value(v, out);
}
}
_ => {}
}
}
/// A host usable in the sbx target and allow grammars: non-empty, not a
/// bracketed IPv6 literal (no spelling in either grammar), and not an
/// unresolved `{{placeholder}}` fragment (would register garbage targets
/// and could fail kit validation at create).
fn is_usable_host(host: &str) -> bool {
!host.is_empty() && !host.starts_with('[') && !host.contains('{') && !host.contains('}')
}
/// Every http(s) URL embedded in `text`. Tokens end at whitespace, quotes,
/// or URL-hostile punctuation so comma- or bracket-separated lists don't
/// bleed into one another.
fn urls_in_text(text: &str) -> Vec<Url> {
const TERMINATORS: &[char] = &['"', '\'', ',', ';', '(', ')', '[', ']', '{', '}', '<', '>'];
let mut out = Vec::new();
for (idx, _) in text.match_indices("http") {
let candidate = &text[idx..];
if !candidate.starts_with("http://") && !candidate.starts_with("https://") {
continue;
}
let end = candidate
.find(|c: char| c.is_whitespace() || TERMINATORS.contains(&c))
.unwrap_or(candidate.len());
if let Ok(url) = Url::parse(&candidate[..end]) {
out.push(url);
}
}
out
}
/// Extracts the host of every http(s) URL embedded in `text`, ports stripped.
/// Bracketed IPv6 hosts and placeholder fragments are skipped.
pub(crate) fn hosts_in_text(text: &str) -> BTreeSet<String> {
urls_in_text(text)
.iter()
.filter_map(|url| url.host_str())
.filter(|host| is_usable_host(host))
.map(str::to_string)
.collect()
}
/// Collects a network allow-list entry for every remote MCP server `url` /// Collects a network allow-list entry for every remote MCP server `url`
/// (http/https), so user-configured servers are reachable regardless of how, /// (http/https), so user-configured servers are reachable regardless of how,
/// or whether, their credentials are provisioned. Https on the default port /// or whether, their credentials are provisioned. Https on the default port
@@ -394,13 +511,14 @@ pub(crate) fn allow_entry_for_url(raw: &str) -> Option<String> {
return None; return None;
} }
let host = url.host_str()?; allow_entry_for_parsed(&url)
if host.is_empty() || host.starts_with('[') { }
return None;
} fn allow_entry_for_parsed(url: &Url) -> Option<String> {
let host = url.host_str().filter(|h| is_usable_host(h))?;
match url.port_or_known_default() { match url.port_or_known_default() {
Some(443) if scheme == "https" => Some(host.to_string()), Some(443) if url.scheme() == "https" => Some(host.to_string()),
Some(port) => Some(format!("{host}:{port}")), Some(port) => Some(format!("{host}:{port}")),
None => None, None => None,
} }
@@ -489,12 +607,17 @@ pub(crate) fn render_mixin_document(
serde_yaml::to_string(&mixin).context("Failed to serialize generated sandbox mixin") serde_yaml::to_string(&mixin).context("Failed to serialize generated sandbox mixin")
} }
/// Renders the generated `coyote-mcp` mixin. Only proxy-managed credentials
/// are declared. sbx does not materialize env vars for `proxyManaged: false`
/// mixin credentials, so the rest are provisioned as custom secrets outside
/// the mixin, and only their target hosts join the network allow list here.
pub(crate) fn render_mixin_yaml( pub(crate) fn render_mixin_yaml(
credentials: &[CredentialSpec], credentials: &[CredentialSpec],
server_allow_entries: &[String], server_allow_entries: &[String],
) -> Result<String> { ) -> Result<String> {
let entries = credentials let entries = credentials
.iter() .iter()
.filter(|c| c.proxy_managed)
.map(|c| CredentialEntry { .map(|c| CredentialEntry {
service: c.service_id.clone(), service: c.service_id.clone(),
description: format!( description: format!(
@@ -510,14 +633,20 @@ pub(crate) fn render_mixin_yaml(
}) })
.collect(); .collect();
let mut allow_entries: Vec<String> = server_allow_entries.to_vec();
for credential in credentials.iter().filter(|c| !c.proxy_managed) {
allow_entries.extend(credential.custom_allow_entries.iter().cloned());
}
render_mixin_document( render_mixin_document(
MCP_MIXIN_NAME, MCP_MIXIN_NAME,
"Auto-generated by Coyote at launch: allows network egress to the user's remote MCP \ "Auto-generated by Coyote at launch: allows network egress to the user's remote MCP \
servers and declares their credentials so Docker Sandboxes binds them (bindings are \ servers and declares their credentials so Docker Sandboxes binds them (bindings are \
approved on first interactive run). Values are pre-seeded from Coyote's vault via \ approved on first interactive run). Proxy-injectable values are pre-seeded from \
`sbx secret set`.", Coyote's vault via `sbx secret set`; the remaining secrets are provisioned as \
placeholder-based custom secrets via `sbx secret set-custom`.",
entries, entries,
server_allow_entries, &allow_entries,
) )
} }
@@ -603,6 +732,11 @@ mod tests {
assert_eq!(cred.service_id, "github-pat"); assert_eq!(cred.service_id, "github-pat");
assert_eq!(cred.env_var, "COYOTE_SECRET_GITHUB_PAT"); assert_eq!(cred.env_var, "COYOTE_SECRET_GITHUB_PAT");
assert!(cred.proxy_managed); assert!(cred.proxy_managed);
assert!(
cred.custom_hosts.is_empty(),
"proxy-managed credentials are provisioned via `sbx secret set`, \
not set-custom, so they carry no custom hosts"
);
assert_eq!( assert_eq!(
cred.inject, cred.inject,
vec![InjectRule { vec![InjectRule {
@@ -839,6 +973,12 @@ mod tests {
"secrets sharing an inject domain must both fall back to env" "secrets sharing an inject domain must both fall back to env"
); );
assert!(creds.iter().all(|c| c.inject.is_empty())); assert!(creds.iter().all(|c| c.inject.is_empty()));
assert!(
creds
.iter()
.all(|c| c.custom_hosts == vec!["api.githubcopilot.com".to_string()]),
"demoted secrets must derive their set-custom targets from the server url"
);
} }
#[test] #[test]
@@ -1018,21 +1158,162 @@ mod tests {
} }
#[test] #[test]
fn rendered_mixin_omits_inject_and_permissions_for_env_based_secrets() { fn rendered_mixin_drops_env_based_credentials() {
let servers = servers(json!({ let servers = servers(json!({
"local": { "command": "run", "env": { "KEY": "{{NOTION_TOKEN}}" } } "local": { "command": "run", "env": { "KEY": "{{NOTION_TOKEN}}" } }
})); }));
let creds = collect_credentials(&servers).unwrap(); let creds = collect_credentials(&servers).unwrap();
assert_eq!(creds.len(), 1);
assert!(!creds[0].proxy_managed, "precondition");
assert!(
creds[0].custom_hosts.is_empty(),
"a stdio server with no URLs anywhere yields no derivable hosts"
);
let yaml = render_mixin_yaml(&creds, &[]).unwrap(); let yaml = render_mixin_yaml(&creds, &[]).unwrap();
let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap(); let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
let cred = &value["credentials"][0]; assert!(
assert_eq!(cred["apiKey"]["proxyManaged"].as_bool(), Some(false)); value.get("credentials").is_none(),
assert!(cred["apiKey"].get("inject").is_none()); "sbx does not materialize env vars for proxyManaged:false mixin \
credentials; declaring them would be dead config"
);
assert!(value.get("permissions").is_none()); assert!(value.get("permissions").is_none());
} }
#[test]
fn hosts_in_text_extracts_hosts_and_strips_ports() {
assert_eq!(
hosts_in_text("https://api.example.com:8443/v1"),
BTreeSet::from(["api.example.com".to_string()])
);
assert_eq!(
hosts_in_text("see http://a.example.com/x and https://b.example.com/y"),
BTreeSet::from(["a.example.com".to_string(), "b.example.com".to_string()])
);
assert_eq!(
hosts_in_text("https://api.example.com/mcp?key={{KEY}}"),
BTreeSet::from(["api.example.com".to_string()])
);
assert!(hosts_in_text("ws://sock.example.com/mcp").is_empty());
assert!(hosts_in_text("qdrant.example.com:6333").is_empty());
assert!(hosts_in_text("https://[::1]:8443/mcp").is_empty());
assert!(hosts_in_text("httpserver is not a scheme").is_empty());
}
#[test]
fn hosts_in_text_terminates_urls_at_punctuation() {
assert_eq!(
hosts_in_text("https://a.example.com,https://b.example.com;https://c.example.com"),
BTreeSet::from([
"a.example.com".to_string(),
"b.example.com".to_string(),
"c.example.com".to_string()
])
);
assert_eq!(
hosts_in_text("(see https://docs.example.com)"),
BTreeSet::from(["docs.example.com".to_string()])
);
}
#[test]
fn server_secret_hosts_unions_url_host_and_scraped_urls() {
let server = json!({
"command": "run",
"args": ["--endpoint", "https://api.vendor.example/v2"],
"env": { "BASE_URL": "http://internal.example.com:8080/api" }
});
let hosts = server_secret_hosts(&server);
assert_eq!(
hosts.targets,
BTreeSet::from([
"api.vendor.example".to_string(),
"internal.example.com".to_string()
]),
"set-custom targets are port-stripped"
);
assert_eq!(
hosts.allow_entries,
BTreeSet::from([
"api.vendor.example".to_string(),
"internal.example.com:8080".to_string()
]),
"allow entries keep non-default ports so the hosts stay reachable"
);
}
#[test]
fn server_secret_hosts_includes_non_http_url_host() {
let server = json!({ "url": "ws://sock.example.com/mcp" });
let hosts = server_secret_hosts(&server);
assert_eq!(
hosts.targets,
BTreeSet::from(["sock.example.com".to_string()])
);
assert!(
hosts.allow_entries.is_empty(),
"non-http(s) urls have no spelling in the allow grammar"
);
}
#[test]
fn server_secret_hosts_skips_placeholder_hosts() {
let server = json!({
"url": "https://{{TENANT}}.example.com/mcp",
"env": { "API_URL": "https://{{REGION}}.api.example.com/v1" }
});
let hosts = server_secret_hosts(&server);
assert!(
hosts.targets.is_empty() && hosts.allow_entries.is_empty(),
"a host containing an unresolved placeholder must never reach \
set-custom argv or the mixin allow list: {hosts:?}"
);
}
#[test]
fn demoted_credential_hosts_are_unioned_into_allow() {
let servers = servers(json!({
"local": {
"command": "run",
"env": {
"KEY": "{{TOKEN}}",
"API_URL": "https://api.internal.example.com:8443/v1"
}
}
}));
let creds = collect_credentials(&servers).unwrap();
assert_eq!(creds.len(), 1);
assert!(!creds[0].proxy_managed, "precondition");
assert_eq!(
creds[0].custom_hosts,
vec!["api.internal.example.com".to_string()]
);
assert_eq!(
creds[0].custom_allow_entries,
vec!["api.internal.example.com:8443".to_string()],
"allow entries keep the port that set-custom targets must strip"
);
let yaml = render_mixin_yaml(&creds, &[]).unwrap();
let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
assert!(value.get("credentials").is_none());
assert_eq!(
value["permissions"]["network"]["allow"][0].as_str(),
Some("api.internal.example.com:8443"),
"a demoted credential's derived hosts must still be reachable"
);
}
#[test] #[test]
fn rendered_mixin_is_deterministic() { fn rendered_mixin_is_deterministic() {
let servers = servers(json!({ let servers = servers(json!({
+807 -64
View File
@@ -2,12 +2,12 @@ use anyhow::{Context, Result, anyhow, bail};
use rust_embed::RustEmbed; use rust_embed::RustEmbed;
use serde_json::Value; use serde_json::Value;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::HashSet; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::env;
use std::fs; use std::fs;
use std::io::Write; use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::{env, io};
use which::which; use which::which;
pub(crate) mod mcp_credentials; pub(crate) mod mcp_credentials;
@@ -52,25 +52,38 @@ pub fn launch(name: Option<String>, fresh: bool) -> Result<()> {
..AppConfig::default() ..AppConfig::default()
}; };
let vault = Vault::init(&bootstrap)?; let vault = Vault::init(&bootstrap)?;
let registered = sbx_registered_services()?; let registered = sbx_registered_secrets()?;
inject_llm_secret(&config_content, &vault, &registered)?; inject_llm_secret(&config_content, &vault, &registered.services)?;
let mut custom_plans: BTreeMap<String, CustomSecretPlan> = BTreeMap::new();
if !fresh { if !fresh {
inject_rag_secrets(&vault, &registered)?; collect_rag_custom_secrets(&mut custom_plans)?;
} }
let credentials_mixin = if fresh { let credentials_mixin = if fresh {
None None
} else { } else {
inject_mcp_secrets(&vault, &registered)? inject_mcp_secrets(&vault, &registered, &mut custom_plans)?
}; };
let new_custom_envs = provision_custom_secrets(&vault, &registered, custom_plans)?;
let discovered = mixins::discover()?; let discovered = mixins::discover()?;
if sandbox_exists(&name)? { if sandbox_exists(&name)? {
info!("Re-attaching to existing sandbox '{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 { } else {
mixins::log_discovery(&discovered, false); mixins::log_discovery(&discovered, false);
create_sandbox(&name, &kit_path, &discovered, credentials_mixin.as_deref())?; create_sandbox(&name, &kit_path, &discovered, credentials_mixin.as_deref())?;
persist_mixin_hash(&name, credentials_mixin.as_deref());
if !fresh { if !fresh {
copy_host_files(&name)?; copy_host_files(&name)?;
} }
@@ -214,6 +227,50 @@ fn compute_kit_hash() -> Result<String> {
Ok(format!("{:x}", hasher.finalize())) 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( fn inject_llm_secret(
config_content: &str, config_content: &str,
vault: &Vault, vault: &Vault,
@@ -262,7 +319,17 @@ fn inject_llm_secret(
/// and returns the generated schema-v2 `coyote-mcp` mixin (network egress for /// and returns the generated schema-v2 `coyote-mcp` mixin (network egress for
/// every remote MCP server + credential declarations), or `None` when the MCP /// every remote MCP server + credential declarations), or `None` when the MCP
/// config references no remote servers and no secrets. /// config references no remote servers and no secrets.
fn inject_mcp_secrets(vault: &Vault, registered: &HashSet<String>) -> Result<Option<String>> { ///
/// 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(); let mcp_path = paths::mcp_config_file();
if !mcp_path.exists() { if !mcp_path.exists() {
return Ok(None); return Ok(None);
@@ -284,7 +351,8 @@ fn inject_mcp_secrets(vault: &Vault, registered: &HashSet<String>) -> Result<Opt
} }
for credential in &credentials { for credential in &credentials {
if registered.contains(credential.service_id.as_str()) { if credential.proxy_managed {
if registered.services.contains(credential.service_id.as_str()) {
eprintln!( eprintln!(
"Secret for '{}' already registered with sbx. \ "Secret for '{}' already registered with sbx. \
To update it, run: sbx secret set --force {}", To update it, run: sbx secret set --force {}",
@@ -295,17 +363,22 @@ fn inject_mcp_secrets(vault: &Vault, registered: &HashSet<String>) -> Result<Opt
let secret_value = vault let secret_value = vault
.get_secret(&credential.secret_name, false) .get_secret(&credential.secret_name, false)
.with_context(|| { .with_context(|| mcp_secret_missing_hint(credential))?;
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(&credential.service_id, &secret_value)?; 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( Ok(Some(mcp_credentials::render_mixin_yaml(
@@ -314,7 +387,216 @@ fn inject_mcp_secrets(vault: &Vault, registered: &HashSet<String>) -> Result<Opt
)?)) )?))
} }
fn inject_rag_secrets(vault: &Vault, registered: &HashSet<String>) -> Result<()> { 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 = &registered.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(); let rags_dir = paths::rags_dir();
if !rags_dir.exists() { if !rags_dir.exists() {
return Ok(()); return Ok(());
@@ -338,20 +620,19 @@ fn inject_rag_secrets(vault: &Vault, registered: &HashSet<String>) -> Result<()>
continue; continue;
} }
let secret_names = driver_config_secret_names(&data); let secret_names = driver_config_secret_names(&data);
let Some((primary, extra)) = secret_names.split_first() else { if secret_names.is_empty() {
continue; continue;
};
let service_id = mcp_credentials::secret_service_id(&stem);
if !service_id.is_empty() && !registered.contains(&service_id) {
bind_rag_secret(vault, &service_id, primary, &stem)?;
} }
for name in extra { let hosts = rag_driver_hosts(&data);
let id = mcp_credentials::secret_service_id(name); for secret_name in &secret_names {
if !id.is_empty() && !registered.contains(&id) { add_custom_secret_plan(
bind_rag_secret(vault, &id, name, &stem)?; custom_plans,
} secret_name,
hosts.iter().cloned(),
format!("RAG '{stem}'"),
false,
);
} }
} }
@@ -378,21 +659,46 @@ fn driver_config_secret_names(data: &RagData) -> Vec<String> {
names names
} }
fn bind_rag_secret(vault: &Vault, service_id: &str, secret_name: &str, stem: &str) -> Result<()> { /// Derives custom-secret target hosts from a RAG's driver_config: any http(s)
match vault.get_secret(secret_name, false) { /// URL in a value contributes its host, and the `host`/`url` keys also accept
Ok(secret_value) => { /// a bare `host[:port]` value (e.g. `qdrant.example.com:6333`). Ports are
sbx_secret_set(service_id, &secret_value) /// stripped (sbx custom-secret targets are host-only).
.context("Failed to register RAG secret with sbx")?; fn rag_driver_hosts(data: &RagData) -> Vec<String> {
} let mut hosts: BTreeSet<String> = BTreeSet::new();
Err(e) => { for (key, value) in &data.driver_config {
eprintln!( let trimmed = value.trim();
"Warning: could not load secret '{secret_name}' for RAG '{stem}': {e}. \ hosts.extend(mcp_credentials::hosts_in_text(trimmed));
Queries to this RAG will fail inside the sandbox. \ if (key == "host" || key == "url")
Run `coyote --add-secret {secret_name}` to fix." && let Some(host) = bare_host(trimmed)
); {
hosts.insert(host);
} }
} }
Ok(())
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 { fn provider_to_sbx_service(provider_type: &str, client_name: Option<&str>) -> String {
@@ -405,29 +711,112 @@ fn provider_to_sbx_service(provider_type: &str, client_name: Option<&str>) -> St
} }
} }
fn sbx_registered_services() -> Result<HashSet<String>> { #[derive(Debug, Default)]
let (success, stdout, _) = run_command_with_output(SBX_BINARY, &["secret", "ls"], None) 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`")?; .context("Failed to run `sbx secret ls`")?;
if !success { if !success {
return Ok(HashSet::new()); 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(stdout Ok(parse_sbx_secret_ls(&stdout))
.lines() }
.skip(1)
.filter_map(|line| { fn parse_sbx_secret_ls(stdout: &str) -> SbxSecrets {
let mut parts = line.split_whitespace(); let mut secrets = SbxSecrets::default();
let scope = parts.next()?; let mut in_custom = false;
let _kind = parts.next()?; let mut in_header = true;
let name = parts.next()?; let mut custom_body_lines = 0usize;
if scope == "(global)" { let mut custom_rows = 0usize;
Some(name.to_string()) for line in stdout.lines() {
} else { let trimmed = line.trim();
None if trimmed.is_empty() {
continue;
} }
}) if trimmed == "CUSTOM SECRETS" {
.collect()) 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<()> { fn sbx_secret_set(service: &str, secret_value: &str) -> Result<()> {
@@ -439,10 +828,11 @@ fn sbx_secret_set(service: &str, secret_value: &str) -> Result<()> {
.spawn() .spawn()
.context("Failed to spawn `sbx secret set`")?; .context("Failed to spawn `sbx secret set`")?;
if let Some(mut stdin_handle) = child.stdin.take() { if let Some(mut stdin_handle) = child.stdin.take()
stdin_handle && let Err(e) = stdin_handle.write_all(secret_value.as_bytes())
.write_all(secret_value.as_bytes()) && e.kind() != io::ErrorKind::BrokenPipe
.context("Failed to write secret to `sbx secret set` stdin")?; {
return Err(anyhow!(e).context("Failed to write secret to `sbx secret set` stdin"));
} }
let status = child let status = child
@@ -453,13 +843,79 @@ fn sbx_secret_set(service: &str, secret_value: &str) -> Result<()> {
eprintln!( eprintln!(
"Warning: failed to register sbx secret '{service}' \ "Warning: failed to register sbx secret '{service}' \
(`sbx secret set {service}` exited with {status}). \ (`sbx secret set {service}` exited with {status}). \
Set it manually with: echo '<value>' | sbx secret set {service}" Set it manually with: sbx secret set {service} \
(the value is read from the prompt)"
); );
} }
Ok(()) 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> { fn sandbox_exists(name: &str) -> Result<bool> {
let (success, stdout, stderr) = let (success, stdout, stderr) =
run_command_with_output(SBX_BINARY, &["ls"], None).context("Failed to run `sbx ls`")?; run_command_with_output(SBX_BINARY, &["ls"], None).context("Failed to run `sbx ls`")?;
@@ -732,6 +1188,293 @@ mod tests {
"order follows driver_config, and a repeat is not registered twice" "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}; use std::time::{SystemTime, UNIX_EPOCH};
#[test] #[test]