feat(rag): add attach-only Qdrant provider, attach wizard and sandbox wiring

Adds QdrantProvider as a read-only driver for pre-existing remote Qdrant
collections, an interactive '.rag attach' wizard, and the sandbox credential
and domain-whitelisting wiring that lets an attached RAG work inside a sandbox.

Attach-only by design: rebuild_indexes bails for both the attached and the
unattached case rather than silently succeeding. Coyote never writes to Qdrant
in this change.

Vectors are never hydrated back from Qdrant. Cosine collections L2-normalize
stored vectors on write, so reading them back returns unit-length copies of the
originals; the YAML vector copy is authoritative and the serialization guard
stays scoped to the duckdb driver alone.

Collections keyed by string or UUID point IDs are rejected at attach time. The
read path parses point ids as u64 inside a filter_map, so such a collection
would otherwise yield zero results with no error.

The three response-shape parsers are pure functions over an already-parsed JSON
body, unit-tested against captured fixtures, with the async wrappers delegating
to them rather than duplicating the logic.

'.rag' now splits its first argument, so '.rag attach <name>' no longer tries to
load a RAG literally named 'attach <name>'.
This commit is contained in:
2026-08-10 13:40:22 -06:00
parent 98d3ba4a83
commit 7a732436aa
8 changed files with 1295 additions and 8 deletions
+82
View File
@@ -10,6 +10,7 @@ use sha2::{Digest, Sha256};
use crate::config::paths;
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
const SBX_MIXIN_FILE_SUFFIX: &str = ".sbx-mixin.yaml";
const KIT_SPEC_FILE_NAME: &str = "spec.yaml";
#[derive(Debug, Clone)]
@@ -72,6 +73,13 @@ pub fn discover() -> Result<Vec<DiscoveredMixin>> {
for path in collect_subdir_mixins(&paths::agents_data_dir()) {
out.push(read_mixin(path)?);
}
// RAG sidecars are FLAT files named `<rag>.sbx-mixin.yaml` inside rags/, not
// the `<subdir>/sbx-mixin.yaml` shape the two scans above walk. Loaded
// unconditionally, mirroring agents/*: a RAG mixin only adds an outbound
// allowlist entry for that RAG's host and opens no inbound rules.
for path in collect_flat_mixins(&paths::rags_dir()) {
out.push(read_mixin(path)?);
}
if let Ok(cwd) = env::current_dir()
&& let Some(path) = paths::find_workspace_sbx_mixin(&cwd)
@@ -173,6 +181,30 @@ fn collect_subdir_mixins(dir: &Path) -> Vec<PathBuf> {
result
}
/// Mixins stored as flat `<name>.sbx-mixin.yaml` files directly inside `dir`,
/// matched by suffix rather than by exact filename.
fn collect_flat_mixins(dir: &Path) -> Vec<PathBuf> {
let mut result = Vec::new();
let Ok(rd) = read_dir(dir) else { return result };
let mut entries: Vec<_> = rd
.flatten()
.filter(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false))
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.ends_with(SBX_MIXIN_FILE_SUFFIX))
})
.collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
result.push(entry.path());
}
result
}
#[cfg(test)]
mod tests {
use super::*;
@@ -439,4 +471,54 @@ network:
);
}
}
/// RAG sidecars are flat `<name>.sbx-mixin.yaml` files, matched by SUFFIX.
#[test]
fn collect_flat_mixins_matches_rag_sidecars_by_suffix() {
let root = unique_root("flat-mixins");
fs::write(root.join("company-docs.sbx-mixin.yaml"), "kind: mixin\n").unwrap();
fs::write(root.join("alpha.sbx-mixin.yaml"), "kind: mixin\n").unwrap();
// The RAGs themselves must not be picked up, only their sidecars.
fs::write(root.join("company-docs.yaml"), "driver: qdrant\n").unwrap();
fs::write(root.join("notes.yaml"), "driver: yaml\n").unwrap();
// A directory whose name ends in the suffix is not a mixin file.
fs::create_dir_all(root.join("decoy.sbx-mixin.yaml")).unwrap();
let found = collect_flat_mixins(&root);
let names: Vec<_> = found
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap())
.collect();
// Sorted by file name, so the order is deterministic.
assert_eq!(
names,
vec!["alpha.sbx-mixin.yaml", "company-docs.sbx-mixin.yaml"]
);
let _ = fs::remove_dir_all(&root);
}
/// Why `collect_flat_mixins` had to be written: the existing collector walks
/// SUBDIRECTORIES for a file named exactly `sbx-mixin.yaml`, so it cannot see
/// a flat sidecar. If this ever starts finding them, the new collector is
/// redundant — but until then, removing it silently drops every RAG mixin.
#[test]
fn collect_subdir_mixins_cannot_see_flat_rag_sidecars() {
let root = unique_root("flat-vs-subdir");
fs::write(root.join("company-docs.sbx-mixin.yaml"), "kind: mixin\n").unwrap();
assert!(collect_subdir_mixins(&root).is_empty());
assert_eq!(collect_flat_mixins(&root).len(), 1);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn collect_flat_mixins_tolerates_a_missing_directory() {
let root = unique_root("flat-missing");
let absent = root.join("nope");
assert!(collect_flat_mixins(&absent).is_empty());
let _ = fs::remove_dir_all(&root);
}
}
+61
View File
@@ -16,6 +16,7 @@ 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::mixins::DiscoveredMixin;
use crate::utils::run_command_with_output;
use crate::vault::SECRET_RE;
@@ -51,6 +52,7 @@ pub fn launch(name: Option<String>, fresh: bool) -> Result<()> {
inject_llm_secret(&config_content, &vault, &registered)?;
if !fresh {
inject_mcp_secrets(&vault, &registered)?;
inject_rag_secrets(&vault, &registered)?;
}
let discovered = mixins::discover()?;
@@ -301,6 +303,65 @@ fn inject_mcp_secrets(vault: &Vault, registered: &HashSet<String>) -> Result<()>
Ok(())
}
/// Registers the API key of every attached RAG with the sbx proxy.
///
/// `launch()` has no notion of an active RAG — that is runtime state set by
/// `--rag` / `.rag` and never persisted — so every attached RAG is scanned
/// unconditionally, exactly as `inject_mcp_secrets` does for MCP servers.
fn inject_rag_secrets(vault: &Vault, registered: &HashSet<String>) -> 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()) {
// Skip sidecars ("myrag.sbx-mixin.yaml" has stem "myrag.sbx-mixin").
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 Some(placeholder) = data.driver_config.get("api_key") else {
continue;
};
if registered.contains(&stem) {
continue;
}
let secret_name = placeholder
.trim_start_matches("{{")
.trim_end_matches("}}")
.trim();
// Degrade rather than abort: one stale RAG key must not block the whole
// sandbox launch. Queries to that RAG fail with a 401 at runtime, which
// is recoverable without a restart.
match vault.get_secret(secret_name, false) {
Ok(secret_value) => {
sbx_secret_set(&stem, &secret_value)
.context("Failed to register RAG secret with sbx")?;
}
Err(e) => {
eprintln!(
"Warning: could not load secret '{secret_name}' for RAG '{stem}': {e}. \
Queries to this RAG will fail inside the sandbox. \
Run `coyote --add-secret {secret_name}` to fix."
);
}
}
}
Ok(())
}
fn provider_to_sbx_service(provider_type: &str, client_name: Option<&str>) -> String {
match provider_type {
"claude" => "anthropic".to_string(),