fix(rag): emit an sbx kit v2 mixin and declare RAG credentials to the proxy

The RAG attach sidecar was written against the sbx kit v1 spec and still emitted schemaVersion "1" with network.allowedDomains, network.serviceDomains, network.serviceAuth, credentials.sources.<n>.env and environment.proxyManaged. Every one of those keys was removed in kit v2. Coyote does not validate mixins, it copies them byte-for-byte into spec.yaml, so the invalid document surfaced only as an opaque sbx failure with no indication of which mixin caused it.

generate_rag_sbx_mixin now builds the document from the shared serializer structs instead of a format! string, which is how the envelope drifted unnoticed in the first place. render_mixin_yaml and the RAG sidecar both go through a new render_mixin_document, giving one definition of the envelope and one enforcement point for the rule that every inject domain must also appear in permissions.network.allow.

Fix an auth bug the port exposed: inject_rag_secrets bound the API key with sbx secret set, but nothing ever emitted a matching credentials entry, so the proxy held a value with no inject rule and never rewrote the auth header. An attached RAG credential silently did not work inside the sandbox. The sidecar now declares that credential; a RAG with no API key declares none while still receiving egress.

Fix the service id: the bind passed the raw file stem instead of routing it through secret_service_id, so a RAG named My_Docs produced an illegal id. The bind and the generated credentials service now share that derivation and cannot disagree.

Retire sbx_domain_forms in favour of allow_entry_for_url, now pub(crate). It emitted both a bare host and host:port because v1 serviceDomains needed a bare key; v2 has no such need, so the extra entry is simply wrong. It also defaulted a schemeless host to port 6333 while normalize_base_url resolves it to http and port 80, meaning the allow entry named a port the client never dialled.
This commit is contained in:
2026-08-10 15:58:40 -06:00
parent f68937611e
commit 3abc30d633
5 changed files with 308 additions and 185 deletions
+64 -38
View File
@@ -387,7 +387,11 @@ pub(crate) fn collect_server_allow_entries(
out.into_iter().collect()
}
fn allow_entry_for_url(raw: &str) -> Option<String> {
/// The single definition of the sbx kit v2 allow-list entry grammar: https on
/// the default port yields a bare host, anything else is spelled `host:port`.
/// Bracketed IPv6 hosts and non-http(s) schemes have no representation in the
/// grammar and yield `None`.
pub(crate) fn allow_entry_for_url(raw: &str) -> Option<String> {
let url = Url::parse(raw).ok()?;
let scheme = url.scheme();
if scheme != "https" && scheme != "http" {
@@ -426,8 +430,8 @@ fn placeholders(text: &str) -> Result<Vec<PlaceholderMatch>> {
struct CredentialsMixin {
schema_version: &'static str,
kind: &'static str,
name: &'static str,
description: &'static str,
name: String,
description: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
credentials: Vec<CredentialEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -435,20 +439,20 @@ struct CredentialsMixin {
}
#[derive(Serialize)]
struct CredentialEntry {
service: String,
description: String,
pub(crate) struct CredentialEntry {
pub service: String,
pub description: String,
#[serde(rename = "apiKey")]
api_key: ApiKey,
pub api_key: ApiKey,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ApiKey {
name: String,
proxy_managed: bool,
pub(crate) struct ApiKey {
pub name: String,
pub proxy_managed: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
inject: Vec<InjectRule>,
pub inject: Vec<InjectRule>,
}
#[derive(Serialize)]
@@ -461,40 +465,30 @@ struct Network {
allow: Vec<String>,
}
pub(crate) fn render_mixin_yaml(
credentials: &[CredentialSpec],
server_allow_entries: &[String],
/// Serializes one sbx kit v2 mixin document.
///
/// Every `inject[].domain` is unioned into `permissions.network.allow`: sbx
/// does not derive allow entries from inject rules, so a rule whose domain is
/// not allowed would be dead. Enforcing it here keeps the invariant in one
/// place for every mixin Coyote generates.
pub(crate) fn render_mixin_document(
name: &str,
description: &str,
credentials: Vec<CredentialEntry>,
extra_allow_entries: &[String],
) -> Result<String> {
let mut allow: BTreeSet<String> = credentials
.iter()
.flat_map(|c| c.inject.iter().map(|r| r.domain.clone()))
.flat_map(|c| c.api_key.inject.iter().map(|r| r.domain.clone()))
.collect();
allow.extend(server_allow_entries.iter().cloned());
allow.extend(extra_allow_entries.iter().cloned());
let mixin = CredentialsMixin {
schema_version: "2",
kind: "mixin",
name: MCP_MIXIN_NAME,
description: "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 approved on first interactive run). Values are \
pre-seeded from Coyote's vault via `sbx secret set`.",
credentials: credentials
.iter()
.map(|c| CredentialEntry {
service: c.service_id.clone(),
description: format!(
"Coyote vault secret '{}', used by MCP server(s) {}",
c.secret_name,
quoted_list(&c.servers)
),
api_key: ApiKey {
name: c.env_var.clone(),
proxy_managed: c.proxy_managed,
inject: c.inject.clone(),
},
})
.collect(),
name: name.to_string(),
description: description.to_string(),
credentials,
permissions: (!allow.is_empty()).then(|| Permissions {
network: Network {
allow: allow.into_iter().collect(),
@@ -502,7 +496,39 @@ pub(crate) fn render_mixin_yaml(
}),
};
serde_yaml::to_string(&mixin).context("Failed to serialize generated MCP credentials mixin")
serde_yaml::to_string(&mixin).context("Failed to serialize generated sandbox mixin")
}
pub(crate) fn render_mixin_yaml(
credentials: &[CredentialSpec],
server_allow_entries: &[String],
) -> Result<String> {
let entries = credentials
.iter()
.map(|c| CredentialEntry {
service: c.service_id.clone(),
description: format!(
"Coyote vault secret '{}', used by MCP server(s) {}",
c.secret_name,
quoted_list(&c.servers)
),
api_key: ApiKey {
name: c.env_var.clone(),
proxy_managed: c.proxy_managed,
inject: c.inject.clone(),
},
})
.collect();
render_mixin_document(
MCP_MIXIN_NAME,
"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 \
approved on first interactive run). Values are pre-seeded from Coyote's vault via \
`sbx secret set`.",
entries,
server_allow_entries,
)
}
#[cfg(test)]
+8 -3
View File
@@ -10,7 +10,7 @@ use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use which::which;
mod mcp_credentials;
pub(crate) mod mcp_credentials;
mod mixins;
pub(crate) use mcp_credentials::sandbox_secret_env_var;
@@ -346,7 +346,12 @@ fn inject_rag_secrets(vault: &Vault, registered: &HashSet<String>) -> Result<()>
let Some(placeholder) = data.driver_config.get("api_key") else {
continue;
};
if registered.contains(&stem) {
// The sidecar mixin declares `credentials[].service` under the same
// derivation, so the bound value and the inject rule that consumes it
// always name the same service. Passing the raw stem here would produce
// an id sbx rejects for any RAG whose name is not already a valid id.
let service_id = mcp_credentials::secret_service_id(&stem);
if service_id.is_empty() || registered.contains(&service_id) {
continue;
}
let secret_name = placeholder
@@ -358,7 +363,7 @@ fn inject_rag_secrets(vault: &Vault, registered: &HashSet<String>) -> Result<()>
// is recoverable without a restart.
match vault.get_secret(secret_name, false) {
Ok(secret_value) => {
sbx_secret_set(&stem, &secret_value)
sbx_secret_set(&service_id, &secret_value)
.context("Failed to register RAG secret with sbx")?;
}
Err(e) => {