Files
Skald-Circle/crates/skald-core/src/mcp/verify.rs
T
dguiducciandClaude Opus 5 8bcf09a67e
Nightly Build / build (push) Successful in 7m13s
chore: delete scripts/ and cut requirements.txt down to its real consumers
scripts/ held the pre-marketplace MCP servers (gmail, gcal, gmaps, ssh, weather,
google_trends, whatsapp, serpapi_flights). Nothing referenced them any more:
connectors are admin-curated and installed into connectors/ from the
marketplace, and ci/package.sh never shipped scripts/ in the first place — so on
every installed box requirements.txt was pulling google-auth, googlemaps,
paramiko, trendspyg and friends for files that did not exist there.

requirements.txt now states what it is actually for: the two TTS plugins, which
spawn a bare `python3` on an embedded server script and so have no dependency
reconciler of their own. A connector's deps stay with the connector —
`ensure_installed` puts them in .pydeps/node_modules inside the user's
container, `ensure_installed_host` beside the files for a global one.

The venv itself stays load-bearing for those two plugins and for the host pip
that installs a global connector's deps, so the run/install/update scripts keep
creating it — but their "Python MCP servers will be unavailable" warning was
naming the one thing that no longer depends on it, and now says what really
breaks.

CONNECTOR_MANIFEST_GUIDE.md moves to the repo root: it was the one thing in
scripts/ still referenced (CLAUDE.md), and being under a gitignored directory it
had never been committed at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 22:19:01 +01:00

418 lines
14 KiB
Rust

//! Verify-before-save for MCP connectors (blueprint §15 verify step).
//!
//! When a user fills the activation form, Skald can run the connector's declared
//! `verify` command to confirm the credentials actually work *before* persisting
//! the activation. This module owns:
//!
//! - [`apply_placeholders`] — the single substitution engine for `{ENV:NAME}`
//! and `{SECRET:NAME}` tokens (used here for the verify command, and by the
//! MCP transport for URLs / env values).
//! - [`run_verify`] — launches the resolved command either on the host (for a
//! global `mcp_remote` connector) or inside the caller's container (for a
//! per-user `mcp_local` connector), parses the JSON result, and returns a
//! [`VerifyReport`].
//!
//! Output contract: the verify command must print one JSON object on stdout,
//! `{"ok": bool, "message": string, "details"?: object}`, and exit 0 on success.
//! If the JSON parse fails, [`run_verify`] falls back to the exit code. Secrets
//! are never logged.
use std::collections::HashMap;
use std::path::Path;
use std::process::Stdio;
use std::time::{Duration, Instant};
use serde::Serialize;
use serde_json::Value;
use tokio::io::AsyncReadExt;
use tracing::debug;
/// Default timeout for a verify command (seconds). Overridable per-connector via
/// the manifest's `verify.timeout_secs`.
pub const DEFAULT_VERIFY_TIMEOUT_SECS: u64 = 15;
/// The outcome of a verify run, surfaced to the UI verbatim.
#[derive(Debug, Clone, Serialize)]
pub struct VerifyReport {
/// `true` when the credentials check out.
pub ok: bool,
/// Human-readable result line (shown next to the Test button).
pub message: String,
/// Optional structured details (shown in a `<pre>` block). Never holds
/// secrets — the verify script is responsible for not echoing them.
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<Value>,
/// Wall-clock time the command took.
#[serde(skip)]
pub elapsed: Duration,
/// `true` when the connector declares no `verify` step, so "no test" must
/// be distinguishable from "test passed" in the UI.
#[serde(skip)]
pub skipped: bool,
}
impl VerifyReport {
/// Synthesized when the connector has no `verify` step — the UI shows
/// "no test available" rather than a pass/fail.
pub fn skipped() -> Self {
Self {
ok: true,
message: "This connector has no verification step.".into(),
details: None,
elapsed: Duration::ZERO,
skipped: true,
}
}
}
/// Where [`run_verify`] executes the command. Mirrors `McpServerSpec.launch_in`:
/// `None` runs on the host (a global `mcp_remote` connector), `Some(container)`
/// runs inside the user's container via `docker exec`.
pub enum VerifyTarget<'a> {
/// Run on the Skald host process. `workdir` is an absolute host path
/// (typically `connectors/<folder>/`).
Host { workdir: &'a Path },
/// Run inside the user's sandbox container. `workdir` is an absolute path
/// *inside* the container (e.g. `/root/.skald/mcp/<name>`).
Container {
container: &'a str,
workdir: &'a Path,
},
}
/// Substitutes `{ENV:NAME}` and `{SECRET:NAME}` tokens in `text`.
///
/// - `{ENV:NAME}` → `env[NAME]`, or empty string if absent.
/// - `{SECRET:NAME}` → `secret[NAME]`, or empty string if absent.
/// - Any other `{...}` token is left untouched — `{key}` belongs to the remote
/// transport's URL substitution (see `mcp::apply_key`), and anything else is a
/// misconfiguration that should stay visible rather than be silently erased.
pub fn apply_placeholders(
text: &str,
env: &HashMap<String, String>,
secret: &HashMap<String, String>,
) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(open) = rest.find('{') {
// Append everything up to the '{'.
out.push_str(&rest[..open]);
let after = &rest[open..];
if let Some(close) = after.find('}') {
let token = &after[..=close]; // includes both braces
let inner = token.strip_prefix('{').unwrap().strip_suffix('}').unwrap();
// ENV:/SECRET: tokens are always consumed (missing key → empty);
// any other `{...}` is left untouched so a misconfiguration stays
// visible rather than being silently erased.
if let Some(name) = inner.strip_prefix("ENV:") {
out.push_str(env.get(name).map(|s| s.as_str()).unwrap_or(""));
} else if let Some(name) = inner.strip_prefix("SECRET:") {
out.push_str(secret.get(name).map(|s| s.as_str()).unwrap_or(""));
} else {
out.push_str(token);
}
rest = &after[close + 1..];
} else {
// No closing brace — emit the rest literally and stop.
out.push_str(after);
return out;
}
}
out.push_str(rest);
out
}
/// Runs the verify `command` (after placeholder substitution) in the given
/// target, injects the env/secret values as environment variables, captures
/// stdout/stderr under a timeout, and parses the JSON result.
///
/// The command and resolved env are NOT logged (secrets may be inline). Only
/// the final `ok`/`message` are traced at debug level.
pub async fn run_verify(
command: &str,
env_values: &HashMap<String, String>,
secret_values: &HashMap<String, String>,
target: VerifyTarget<'_>,
timeout_secs: u64,
) -> VerifyReport {
let resolved = apply_placeholders(command, env_values, secret_values);
let timeout = Duration::from_secs(timeout_secs.max(1));
let started = Instant::now();
// Build the process: `docker exec … sh -c "<cmd>"` or host `sh -c "<cmd>"`.
let mut cmd = match target {
VerifyTarget::Container { container, workdir } => {
let mut c = tokio::process::Command::new("docker");
c.arg("exec")
.arg("-w").arg(workdir)
.arg(container);
inject_env_flags(&mut c, env_values, secret_values);
c.arg("sh").arg("-c").arg(&resolved);
c
}
VerifyTarget::Host { workdir } => {
let mut c = tokio::process::Command::new("sh");
c.arg("-c").arg(&resolved).current_dir(workdir);
inject_env_vars(&mut c, env_values, secret_values);
c
}
};
cmd.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null())
.kill_on_drop(true);
let child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
return VerifyReport {
ok: false,
message: format!("Could not start the verify command: {e}"),
details: None,
elapsed: started.elapsed(),
skipped: false,
};
}
};
let outcome = run_with_timeout(child, timeout).await;
let elapsed = started.elapsed();
let report = parse_verify_output(&outcome, elapsed);
debug!(ok = report.ok, elapsed_ms = elapsed.as_millis() as u64, "verify");
report
}
/// Collects the child's stdout/stderr under a single timeout, returning the
/// captured buffers and the exit code (None if killed by timeout).
async fn run_with_timeout(
mut child: tokio::process::Child,
timeout: Duration,
) -> VerifyOutcome {
let mut stdout = child.stdout.take().expect("stdout piped");
let mut stderr = child.stderr.take().expect("stderr piped");
let collect = async {
let mut out = Vec::new();
let mut err = Vec::new();
// Read concurrently — the pipes are independent.
let r1 = stdout.read_to_end(&mut out);
let r2 = stderr.read_to_end(&mut err);
let (ro, re, status) = tokio::join!(r1, r2, child.wait());
ro.map_err(anyhow::Error::from)?;
re.map_err(anyhow::Error::from)?;
let code = status.ok().and_then(|s| s.code());
Ok::<_, anyhow::Error>((out, err, code))
};
match tokio::time::timeout(timeout, collect).await {
Ok(Ok((out, err, code))) => VerifyOutcome { stdout: out, stderr: err, code, timed_out: false },
// Inner error (spawn/io).
Ok(Err(e)) => VerifyOutcome {
stdout: Vec::new(),
stderr: e.to_string().into_bytes(),
code: None,
timed_out: false,
},
// Timeout: kill_on_drop takes care of the child.
Err(_) => VerifyOutcome {
stdout: Vec::new(),
stderr: format!("verify timed out after {}s", timeout.as_secs()).into_bytes(),
code: None,
timed_out: true,
},
}
}
struct VerifyOutcome {
stdout: Vec<u8>,
stderr: Vec<u8>,
code: Option<i32>,
timed_out: bool,
}
/// Parses the verify command's output into a [`VerifyReport`].
///
/// Contract: the command prints one JSON object on stdout:
/// `{"ok": bool, "message": string, "details"?: object}`. If the parse fails,
/// falls back to the exit code (0 = ok, anything else = fail) and uses stderr
/// (or stdout) as the message.
fn parse_verify_output(outcome: &VerifyOutcome, elapsed: Duration) -> VerifyReport {
let stdout = String::from_utf8_lossy(&outcome.stdout);
let stderr = String::from_utf8_lossy(&outcome.stderr);
if outcome.timed_out {
return VerifyReport {
ok: false,
message: stderr.trim().to_string(),
details: None,
elapsed,
skipped: false,
};
}
// Try JSON parse first (prefer the last line, in case the script emitted a
// trailing newline or a preamble).
let trimmed = stdout.trim();
if !trimmed.is_empty() {
if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
let ok = v.get("ok").and_then(|o| o.as_bool()).unwrap_or_else(|| outcome.code == Some(0));
let message = v
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("")
.to_string();
let details = v.get("details").cloned();
return VerifyReport { ok, message, details, elapsed, skipped: false };
}
}
// Fallback: exit-code semantics. Empty stdout → fall back to stderr.
let ok = outcome.code == Some(0);
let message = if !trimmed.is_empty() {
trimmed.to_string()
} else if !stderr.trim().is_empty() {
stderr.trim().to_string()
} else if ok {
"Verification succeeded.".into()
} else {
format!("Verify failed (exit code {}).", outcome.code.unwrap_or(-1))
};
VerifyReport { ok, message, details: None, elapsed, skipped: false }
}
/// Adds `-e KEY=VALUE` flags for `docker exec`, for both env and secret values.
fn inject_env_flags(
cmd: &mut tokio::process::Command,
env: &HashMap<String, String>,
secret: &HashMap<String, String>,
) {
for (k, v) in env.iter().chain(secret.iter()) {
cmd.arg("-e").arg(format!("{k}={v}"));
}
}
/// Sets environment variables for a host `sh -c` process.
fn inject_env_vars(
cmd: &mut tokio::process::Command,
env: &HashMap<String, String>,
secret: &HashMap<String, String>,
) {
for (k, v) in env.iter().chain(secret.iter()) {
cmd.env(k, v);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn m(items: &[(&str, &str)]) -> HashMap<String, String> {
items.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
#[test]
fn placeholders_env_and_secret() {
let env = m(&[("HOST", "imap.example.com"), ("PORT", "993")]);
let secret = m(&[("PASS", "hunter2")]);
let s = apply_placeholders("h={ENV:HOST} p={ENV:PORT} s={SECRET:PASS}", &env, &secret);
assert_eq!(s, "h=imap.example.com p=993 s=hunter2");
}
#[test]
fn placeholders_missing_become_empty() {
let env = m(&[("HOST", "x")]);
let secret = HashMap::new();
let s = apply_placeholders("[{ENV:HOST}][{ENV:MISSING}][{SECRET:X}]", &env, &secret);
assert_eq!(s, "[x][][]");
}
#[test]
fn placeholders_unknown_left_untouched() {
let env = HashMap::new();
let secret = HashMap::new();
let s = apply_placeholders("{key} {ENV:A} {0}", &env, &secret);
assert_eq!(s, "{key} {0}");
}
#[test]
fn placeholders_no_braces() {
let env = HashMap::new();
let secret = HashMap::new();
assert_eq!(apply_placeholders("plain text", &env, &secret), "plain text");
}
#[test]
fn placeholders_unclosed_brace_kept() {
let env = HashMap::new();
let secret = HashMap::new();
assert_eq!(apply_placeholders("a {ENV:B c", &env, &secret), "a {ENV:B c");
}
#[test]
fn parse_json_ok() {
let o = VerifyOutcome {
stdout: br#"{"ok": true, "message": "all good"}"#.to_vec(),
stderr: vec![],
code: Some(0),
timed_out: false,
};
let r = parse_verify_output(&o, Duration::from_millis(10));
assert!(r.ok);
assert_eq!(r.message, "all good");
}
#[test]
fn parse_json_fail_with_details() {
let o = VerifyOutcome {
stdout: br#"{"ok": false, "message": "bad creds", "details": {"imap": "ok", "smtp": "no"}}"#.to_vec(),
stderr: vec![],
code: Some(1),
timed_out: false,
};
let r = parse_verify_output(&o, Duration::from_millis(10));
assert!(!r.ok);
assert_eq!(r.message, "bad creds");
assert_eq!(r.details.unwrap()["smtp"], "no");
}
#[test]
fn parse_fallback_exit_code() {
let o = VerifyOutcome {
stdout: b"some plain output".to_vec(),
stderr: vec![],
code: Some(0),
timed_out: false,
};
let r = parse_verify_output(&o, Duration::from_millis(10));
assert!(r.ok);
assert_eq!(r.message, "some plain output");
}
#[test]
fn parse_fallback_stderr_on_fail() {
let o = VerifyOutcome {
stdout: vec![],
stderr: b"connection refused".to_vec(),
code: Some(2),
timed_out: false,
};
let r = parse_verify_output(&o, Duration::from_millis(10));
assert!(!r.ok);
assert_eq!(r.message, "connection refused");
}
#[test]
fn parse_timeout_is_fail() {
let o = VerifyOutcome {
stdout: vec![],
stderr: b"verify timed out after 15s".to_vec(),
code: None,
timed_out: true,
};
let r = parse_verify_output(&o, Duration::from_secs(15));
assert!(!r.ok);
assert!(r.message.contains("timed out"));
}
}