From 363c217f84c0327ede850e44ef0efaef28c5402c Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Tue, 28 Jul 2026 12:02:08 +0200 Subject: [PATCH 01/19] feat(agent-tools): scaffold pure-logic crate for agent MCP tools (TDD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New workspace crate `agent-tools` holding the testable core for the two agent-facing MCP tools, compiled/tested apart from the server: - image: parse_image_ref (Docker Hub normalization: library/, tag vs digest, registry-qualified, official/pinned), assemble() -> ResolvedImage reusing td_audit::image::audit_image for a grade; ImageResolver trait for injected registry fetch. - sandbox: clamp_ttl / quota_check / gate_compose (privileged, docker.sock, host net — reuses td_audit compose parser) / select_expired (reaper); SandboxSpec/ Handle/Status DTOs + SandboxController trait; launch_sandbox orchestrator. 19 unit tests, written test-first (RED->GREEN), all against mock traits. Co-Authored-By: Claude Opus 4.8 --- Cargo.toml | 2 +- crates/agent-tools/Cargo.toml | 16 ++ crates/agent-tools/src/error.rs | 24 +++ crates/agent-tools/src/image.rs | 300 ++++++++++++++++++++++++++++++ crates/agent-tools/src/lib.rs | 15 ++ crates/agent-tools/src/sandbox.rs | 263 ++++++++++++++++++++++++++ 6 files changed, 619 insertions(+), 1 deletion(-) create mode 100644 crates/agent-tools/Cargo.toml create mode 100644 crates/agent-tools/src/error.rs create mode 100644 crates/agent-tools/src/image.rs create mode 100644 crates/agent-tools/src/lib.rs create mode 100644 crates/agent-tools/src/sandbox.rs diff --git a/Cargo.toml b/Cargo.toml index f05c6afe..7d1b25fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" default-run= "server" [workspace] -members = ["crates/pipe-adapter-sdk", "crates/pipe-adapter-mail", "crates/td-audit"] +members = ["crates/pipe-adapter-sdk", "crates/pipe-adapter-mail", "crates/td-audit", "crates/agent-tools"] resolver = "2" [lib] diff --git a/crates/agent-tools/Cargo.toml b/crates/agent-tools/Cargo.toml new file mode 100644 index 00000000..8c8a9b35 --- /dev/null +++ b/crates/agent-tools/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "agent-tools" +version = "0.1.0" +edition = "2021" +description = "Pure-logic core for TryDirect's agent-facing MCP tools (resolve_image ground truth, deploy_ephemeral sandbox orchestration). Heavy I/O is injected via traits so this compiles/tests apart from the Actix/sqlx server." + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +thiserror = "1" +async-trait = "0.1" +td-audit = { path = "../td-audit" } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/crates/agent-tools/src/error.rs b/crates/agent-tools/src/error.rs new file mode 100644 index 00000000..1889061a --- /dev/null +++ b/crates/agent-tools/src/error.rs @@ -0,0 +1,24 @@ +//! Error type shared by the agent tools. Kept small and stringly-mappable so MCP +//! `ToolHandler`s can turn it into a JSON-RPC error message. + +use thiserror::Error; + +#[derive(Debug, Error, PartialEq)] +pub enum AgentToolError { + #[error("invalid input: {0}")] + InvalidInput(String), + + #[error("quota exceeded: {0}")] + QuotaExceeded(String), + + /// The agent-supplied compose was rejected by the safety gate before any + /// infrastructure was provisioned. + #[error("compose rejected: {0}")] + ComposeRejected(String), + + /// A downstream (registry, MQ, cloud, DB) operation failed. + #[error("backend error: {0}")] + Backend(String), +} + +pub type Result = std::result::Result; diff --git a/crates/agent-tools/src/image.rs b/crates/agent-tools/src/image.rs new file mode 100644 index 00000000..46905436 --- /dev/null +++ b/crates/agent-tools/src/image.rs @@ -0,0 +1,300 @@ +//! `resolve_image` — ground truth about a Docker image reference. +//! +//! Pure reference parsing + result assembly live here; the actual registry +//! fetch is injected via [`ImageResolver`] so this unit-tests without network. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use td_audit::image::{audit_image, ImageInfo, Vulnerability}; +use td_audit::schema::Grade; + +use crate::error::Result; + +/// A parsed image reference, normalized to Docker Hub conventions. +#[derive(Debug, Clone, PartialEq)] +pub struct ParsedRef { + /// As given, e.g. "redis", "library/redis:7", "ghcr.io/o/r@sha256:..". + pub reference: String, + /// Repository path used against the registry API (official → "library/"). + pub repo: String, + pub tag: Option, + pub digest: Option, + /// Official (`library/*`) or bare single-name image. + pub official: bool, + /// Pinned to a digest or an explicit non-`latest` tag. + pub pinned: bool, +} + +/// Raw facts a resolver fetched from the registry for a reference. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct RawImageFacts { + pub exists: bool, + pub digest: Option, + pub size_bytes: Option, + pub architectures: Vec, + pub recent_tags: Vec, + /// ISO-8601 timestamp of the last push, if known. + pub last_pushed: Option, + /// Days since last push (registry-derived), for staleness grading. + pub last_updated_days: Option, +} + +/// Optional CVE roll-up (from a Trivy scan), included when scanning is enabled. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct CveSummary { + pub critical: u32, + pub high: u32, + pub medium: u32, + pub low: u32, +} + +impl CveSummary { + pub fn from_vulns(vulns: &[Vulnerability]) -> Self { + use td_audit::image::VulnSeverity::*; + let count = |s: td_audit::image::VulnSeverity| { + vulns.iter().filter(|v| v.severity == s).count() as u32 + }; + CveSummary { + critical: count(Critical), + high: count(High), + medium: count(Medium), + low: count(Low), + } + } +} + +/// The ground-truth answer returned to the agent. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ResolvedImage { + pub reference: String, + pub exists: bool, + pub official: bool, + pub pinned: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub digest: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub size_bytes: Option, + pub architectures: Vec, + pub recent_tags: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_pushed: Option, + /// Reuses the td-audit image grade (A–F) so the agent gets a verdict too. + pub grade: Grade, + #[serde(skip_serializing_if = "Option::is_none")] + pub cve_summary: Option, +} + +/// Fetches raw registry facts for a repo+tag. Implemented in the server over the +/// Docker Hub client; mocked in tests. +#[async_trait] +pub trait ImageResolver: Send + Sync { + async fn facts(&self, parsed: &ParsedRef) -> Result; +} + +/// Parse and normalize an image reference (Docker Hub conventions). +pub fn parse_image_ref(reference: &str) -> ParsedRef { + // Peel off a digest first (`name[:tag]@sha256:..`). + let (name_tag, digest) = match reference.split_once('@') { + Some((nt, d)) => (nt.to_string(), Some(d.to_string())), + None => (reference.to_string(), None), + }; + + // A first segment containing '.' or ':' (or "localhost") is a registry host, + // not a namespace we prefix with "library/". + let first_seg = name_tag.split('/').next().unwrap_or(""); + let is_registry = name_tag.contains('/') + && (first_seg.contains('.') || first_seg.contains(':') || first_seg == "localhost"); + + // Split a tag only within the last path segment (so registry:port isn't a tag). + let search_start = name_tag.rfind('/').map(|i| i + 1).unwrap_or(0); + let (name_part, tag) = match name_tag[search_start..].find(':') { + Some(rel) => { + let colon = search_start + rel; + (name_tag[..colon].to_string(), Some(name_tag[colon + 1..].to_string())) + } + None => (name_tag.clone(), None), + }; + + let repo = if name_part.contains('/') { + name_part.clone() + } else { + format!("library/{name_part}") + }; + let official = !is_registry && (!name_part.contains('/') || name_part.starts_with("library/")); + let pinned = digest.is_some() || tag.as_deref().map(|t| t != "latest").unwrap_or(false); + + ParsedRef { + reference: reference.to_string(), + repo, + tag, + digest, + official, + pinned, + } +} + +fn expand_cves(c: &CveSummary) -> Vec { + use td_audit::image::VulnSeverity::*; + let mk = |n: u32, sev| (0..n).map(move |_| Vulnerability { id: "cve".into(), severity: sev }); + mk(c.critical, Critical) + .chain(mk(c.high, High)) + .chain(mk(c.medium, Medium)) + .chain(mk(c.low, Low)) + .collect() +} + +/// Assemble the ground-truth result from parsed ref + fetched facts (+ CVEs). +pub fn assemble(parsed: &ParsedRef, facts: &RawImageFacts, cves: Option) -> ResolvedImage { + // Reuse the td-audit image grader so the agent gets a verdict, not just facts. + let info = ImageInfo { + reference: parsed.reference.clone(), + exists: facts.exists, + official: parsed.official, + pinned: parsed.pinned, + last_updated_days: facts.last_updated_days, + }; + let vulns = cves.as_ref().map(expand_cves).unwrap_or_default(); + let grade = audit_image(&info, &vulns).grade; + + ResolvedImage { + reference: parsed.reference.clone(), + exists: facts.exists, + official: parsed.official, + pinned: parsed.pinned, + digest: facts.digest.clone().or_else(|| parsed.digest.clone()), + size_bytes: facts.size_bytes, + architectures: facts.architectures.clone(), + recent_tags: facts.recent_tags.clone(), + last_pushed: facts.last_pushed.clone(), + grade, + cve_summary: cves, + } +} + +/// Orchestrate: parse → fetch facts via the resolver → assemble. +pub async fn resolve_image( + resolver: &dyn ImageResolver, + reference: &str, + cves: Option, +) -> Result { + let parsed = parse_image_ref(reference); + let facts = resolver.facts(&parsed).await?; + Ok(assemble(&parsed, &facts, cves)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_bare_official_name() { + let p = parse_image_ref("redis"); + assert_eq!(p.repo, "library/redis"); + assert_eq!(p.tag, None); + assert!(p.official); + assert!(!p.pinned); // no tag/digest -> unpinned + } + + #[test] + fn parses_official_with_tag() { + let p = parse_image_ref("redis:7-alpine"); + assert_eq!(p.repo, "library/redis"); + assert_eq!(p.tag.as_deref(), Some("7-alpine")); + assert!(p.official); + assert!(p.pinned); // explicit non-latest tag + } + + #[test] + fn latest_tag_is_not_pinned() { + let p = parse_image_ref("redis:latest"); + assert_eq!(p.tag.as_deref(), Some("latest")); + assert!(!p.pinned); + } + + #[test] + fn parses_namespaced_image() { + let p = parse_image_ref("louislam/dockge:1.4"); + assert_eq!(p.repo, "louislam/dockge"); + assert_eq!(p.tag.as_deref(), Some("1.4")); + assert!(!p.official); + assert!(p.pinned); + } + + #[test] + fn parses_digest_pinned() { + let p = parse_image_ref("nginx@sha256:abc123"); + assert_eq!(p.repo, "library/nginx"); + assert_eq!(p.digest.as_deref(), Some("sha256:abc123")); + assert_eq!(p.tag, None); + assert!(p.pinned); // digest is pinned + } + + #[test] + fn registry_qualified_ref_keeps_host() { + // A host with a dot is a registry, not a namespace to prefix with library/. + let p = parse_image_ref("ghcr.io/owner/repo:v2"); + assert_eq!(p.repo, "ghcr.io/owner/repo"); + assert_eq!(p.tag.as_deref(), Some("v2")); + assert!(!p.official); + } + + #[test] + fn registry_with_port_not_split_as_tag() { + let p = parse_image_ref("localhost:5000/myimg"); + assert_eq!(p.repo, "localhost:5000/myimg"); + assert_eq!(p.tag, None); + } + + #[test] + fn assemble_merges_facts_and_grades() { + let parsed = parse_image_ref("library/redis:7-alpine"); + let facts = RawImageFacts { + exists: true, + digest: Some("sha256:deadbeef".into()), + size_bytes: Some(12_000_000), + architectures: vec!["amd64".into(), "arm64".into()], + recent_tags: vec!["7-alpine".into(), "7".into()], + last_pushed: Some("2026-06-01T00:00:00Z".into()), + last_updated_days: Some(30), + }; + let r = assemble(&parsed, &facts, None); + assert!(r.exists); + assert_eq!(r.architectures, vec!["amd64", "arm64"]); + assert_eq!(r.size_bytes, Some(12_000_000)); + assert!(r.official && r.pinned); + // Official, pinned, recent, no CVEs -> clean grade A. + assert_eq!(r.grade, Grade::A); + } + + #[test] + fn missing_image_grades_f() { + let parsed = parse_image_ref("trydirect/does-not-exist:latest"); + let facts = RawImageFacts { exists: false, ..Default::default() }; + let r = assemble(&parsed, &facts, None); + assert!(!r.exists); + assert_eq!(r.grade, Grade::F); + } + + // A mock resolver proves the orchestrator wiring without network. + struct MockResolver(RawImageFacts); + #[async_trait] + impl ImageResolver for MockResolver { + async fn facts(&self, _p: &ParsedRef) -> Result { + Ok(self.0.clone()) + } + } + + #[tokio::test] + async fn resolve_image_orchestrates_parse_fetch_assemble() { + let resolver = MockResolver(RawImageFacts { + exists: true, + architectures: vec!["amd64".into()], + last_updated_days: Some(5), + ..Default::default() + }); + let r = resolve_image(&resolver, "redis:7", None).await.unwrap(); + assert_eq!(r.reference, "redis:7"); + assert!(r.exists); + assert!(r.pinned); + } +} diff --git a/crates/agent-tools/src/lib.rs b/crates/agent-tools/src/lib.rs new file mode 100644 index 00000000..25898748 --- /dev/null +++ b/crates/agent-tools/src/lib.rs @@ -0,0 +1,15 @@ +//! `agent-tools` — pure-logic core for TryDirect's agent-facing MCP tools. +//! +//! Two tools give an AI agent capabilities it structurally lacks: +//! - [`image`] `resolve_image` — ground truth about a Docker image reference. +//! - [`sandbox`] `deploy_ephemeral` — orchestrate a throwaway, auto-expiring +//! deployment and report its live URL / logs / health. +//! +//! The heavy I/O (Docker Hub, RabbitMQ, DB, cloud provisioning) is injected via +//! the [`image::ImageResolver`] and [`sandbox::SandboxController`] traits, so the +//! decision/assembly logic here compiles and unit-tests apart from the server +//! (`cargo test -p agent-tools`) with mock implementations. + +pub mod error; +pub mod image; +pub mod sandbox; diff --git a/crates/agent-tools/src/sandbox.rs b/crates/agent-tools/src/sandbox.rs new file mode 100644 index 00000000..c916ac28 --- /dev/null +++ b/crates/agent-tools/src/sandbox.rs @@ -0,0 +1,263 @@ +//! `deploy_ephemeral` — orchestrate a throwaway, auto-expiring deployment. +//! +//! All the policy logic (TTL clamping, per-user quota, compose safety gate, +//! reaper expired-set selection) is pure and lives here; the actual +//! provision/status/teardown is injected via [`SandboxController`] so this +//! unit-tests without MQ, DB or cloud. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::error::{AgentToolError, Result}; + +/// Operator-configured limits for the managed sandbox pool. +#[derive(Debug, Clone, PartialEq)] +pub struct SandboxQuota { + pub default_ttl_secs: u64, + pub max_ttl_secs: u64, + pub max_concurrent_per_user: u32, +} + +impl Default for SandboxQuota { + fn default() -> Self { + SandboxQuota { + default_ttl_secs: 30 * 60, // 30m + max_ttl_secs: 2 * 60 * 60, // 2h + max_concurrent_per_user: 3, + } + } +} + +/// A validated launch request handed to the controller. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SandboxSpec { + pub compose_yaml: String, + pub ttl_secs: u64, + /// Absolute expiry (epoch seconds) — the reaper tears down past this. + pub expires_at: u64, +} + +/// Reference to a launched sandbox. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SandboxHandle { + /// Deployment hash, e.g. "sandbox_". + pub id: String, + pub expires_at: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Health { + Provisioning, + Healthy, + Unhealthy, + Expired, +} + +/// Current state of a sandbox, reported back to the agent. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SandboxStatus { + pub id: String, + pub health: Health, + #[serde(skip_serializing_if = "Option::is_none")] + pub live_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub log_excerpt: Option, + pub expires_at: u64, +} + +/// Provisions / inspects / destroys sandboxes. Real impl publishes to MQ and +/// polls the deployment DB; mocked in tests. v1 = FreshVmController, v2 = Kata pool. +#[async_trait] +pub trait SandboxController: Send + Sync { + async fn launch(&self, spec: &SandboxSpec) -> Result; + async fn status(&self, handle: &SandboxHandle) -> Result; + async fn teardown(&self, handle: &SandboxHandle) -> Result<()>; +} + +/// Clamp a requested TTL to `[1, max]`, defaulting when unset. +pub fn clamp_ttl(requested_secs: Option, quota: &SandboxQuota) -> u64 { + match requested_secs { + None => quota.default_ttl_secs, + Some(r) => r.clamp(1, quota.max_ttl_secs), + } +} + +/// Deny when the user is already at their concurrent-sandbox limit. +pub fn quota_check(current_active: u32, quota: &SandboxQuota) -> Result<()> { + if current_active >= quota.max_concurrent_per_user { + return Err(AgentToolError::QuotaExceeded(format!( + "already at {} concurrent sandboxes (max {})", + current_active, quota.max_concurrent_per_user + ))); + } + Ok(()) +} + +/// Reject agent-supplied compose that could compromise the host, BEFORE any +/// provisioning: privileged services, the Docker socket, host networking. +pub fn gate_compose(compose_yaml: &str) -> Result<()> { + // Must parse as compose (reuse td-audit's tolerant parser). + let model = td_audit::compose::parse_compose(compose_yaml) + .map_err(|e| AgentToolError::ComposeRejected(format!("not a valid compose file: {e}")))?; + + if let Some(svc) = model.services.iter().find(|s| s.privileged) { + return Err(AgentToolError::ComposeRejected(format!( + "service '{}' requests privileged mode", + svc.name + ))); + } + + // Raw-string checks for host-escape vectors the typed model doesn't capture. + let lower = compose_yaml.to_lowercase(); + if lower.contains("docker.sock") { + return Err(AgentToolError::ComposeRejected( + "mounting the Docker socket is not allowed".into(), + )); + } + if lower.contains("network_mode") && lower.contains("host") { + return Err(AgentToolError::ComposeRejected( + "host networking is not allowed".into(), + )); + } + Ok(()) +} + +/// Which handles have passed their expiry as of `now` — the reaper's work list. +pub fn select_expired(handles: &[SandboxHandle], now: u64) -> Vec<&SandboxHandle> { + handles.iter().filter(|h| h.expires_at <= now).collect() +} + +/// Validate + gate + quota + clamp, then launch via the controller. +pub async fn launch_sandbox( + controller: &dyn SandboxController, + quota: &SandboxQuota, + current_active: u32, + now: u64, + requested_ttl_secs: Option, + compose_yaml: &str, +) -> Result { + gate_compose(compose_yaml)?; + quota_check(current_active, quota)?; + let ttl = clamp_ttl(requested_ttl_secs, quota); + let spec = SandboxSpec { + compose_yaml: compose_yaml.to_string(), + ttl_secs: ttl, + expires_at: now + ttl, + }; + controller.launch(&spec).await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn quota() -> SandboxQuota { + SandboxQuota { default_ttl_secs: 1800, max_ttl_secs: 7200, max_concurrent_per_user: 2 } + } + + #[test] + fn clamp_ttl_defaults_and_caps() { + let q = quota(); + assert_eq!(clamp_ttl(None, &q), 1800, "unset -> default"); + assert_eq!(clamp_ttl(Some(600), &q), 600, "within range kept"); + assert_eq!(clamp_ttl(Some(99_999), &q), 7200, "over max -> capped"); + assert_eq!(clamp_ttl(Some(0), &q), 1, "zero -> min 1"); + } + + #[test] + fn quota_check_denies_at_limit() { + let q = quota(); + assert!(quota_check(0, &q).is_ok()); + assert!(quota_check(1, &q).is_ok()); + assert_eq!( + quota_check(2, &q), + Err(AgentToolError::QuotaExceeded( + "already at 2 concurrent sandboxes (max 2)".into() + )) + ); + } + + #[test] + fn gate_accepts_a_plain_compose() { + let yaml = "services:\n web:\n image: nginx:1.27-alpine\n ports: [\"8080:80\"]\n"; + assert!(gate_compose(yaml).is_ok()); + } + + #[test] + fn gate_rejects_privileged() { + let yaml = "services:\n x:\n image: alpine\n privileged: true\n"; + assert!(matches!(gate_compose(yaml), Err(AgentToolError::ComposeRejected(_)))); + } + + #[test] + fn gate_rejects_docker_socket_mount() { + let yaml = "services:\n x:\n image: alpine\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n"; + assert!(matches!(gate_compose(yaml), Err(AgentToolError::ComposeRejected(_)))); + } + + #[test] + fn gate_rejects_host_networking() { + let yaml = "services:\n x:\n image: alpine\n network_mode: host\n"; + assert!(matches!(gate_compose(yaml), Err(AgentToolError::ComposeRejected(_)))); + } + + #[test] + fn gate_rejects_unparsable() { + assert!(matches!(gate_compose(":\n bad: ["), Err(AgentToolError::ComposeRejected(_)))); + } + + #[test] + fn select_expired_picks_past_expiry() { + let handles = vec![ + SandboxHandle { id: "a".into(), expires_at: 100 }, + SandboxHandle { id: "b".into(), expires_at: 200 }, + SandboxHandle { id: "c".into(), expires_at: 50 }, + ]; + let expired = select_expired(&handles, 150); + let ids: Vec<&str> = expired.iter().map(|h| h.id.as_str()).collect(); + assert_eq!(ids, vec!["a", "c"]); + } + + struct MockController; + #[async_trait] + impl SandboxController for MockController { + async fn launch(&self, spec: &SandboxSpec) -> Result { + Ok(SandboxHandle { id: "sandbox_test".into(), expires_at: spec.expires_at }) + } + async fn status(&self, h: &SandboxHandle) -> Result { + Ok(SandboxStatus { + id: h.id.clone(), + health: Health::Healthy, + live_url: Some("http://1.2.3.4".into()), + log_excerpt: None, + expires_at: h.expires_at, + }) + } + async fn teardown(&self, _h: &SandboxHandle) -> Result<()> { + Ok(()) + } + } + + #[tokio::test] + async fn launch_sandbox_gates_quota_and_clamps() { + let q = quota(); + let yaml = "services:\n web:\n image: nginx:1.27-alpine\n"; + // Happy path: clamps TTL and sets expiry = now + ttl. + let h = launch_sandbox(&MockController, &q, 0, 1000, Some(99_999), yaml).await.unwrap(); + assert_eq!(h.expires_at, 1000 + 7200); + + // Over quota -> refused before launch. + assert!(matches!( + launch_sandbox(&MockController, &q, 2, 1000, None, yaml).await, + Err(AgentToolError::QuotaExceeded(_)) + )); + + // Unsafe compose -> refused before launch. + let bad = "services:\n x:\n image: alpine\n privileged: true\n"; + assert!(matches!( + launch_sandbox(&MockController, &q, 0, 1000, None, bad).await, + Err(AgentToolError::ComposeRejected(_)) + )); + } +} From a90016e11adc556ef2de997cf254e3aa74807770 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Tue, 28 Jul 2026 12:03:17 +0200 Subject: [PATCH 02/19] docs(agent-gateway): infra setup HOWTO + saved implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENT_GATEWAY_SETUP.md: developer guide to stand up the gateway — prerequisites, env/config, build/run, MCP tool usage, the TTL reaper, cost/ abuse guardrails, and the v2 Kata/Firecracker warm-pool fast path. - AGENT_GATEWAY_PLAN.md: the approved implementation plan, checked in. Co-Authored-By: Claude Opus 4.8 --- docs/AGENT_GATEWAY_PLAN.md | 212 ++++++++++++++++++++++++++++++++++++ docs/AGENT_GATEWAY_SETUP.md | 166 ++++++++++++++++++++++++++++ 2 files changed, 378 insertions(+) create mode 100644 docs/AGENT_GATEWAY_PLAN.md create mode 100644 docs/AGENT_GATEWAY_SETUP.md diff --git a/docs/AGENT_GATEWAY_PLAN.md b/docs/AGENT_GATEWAY_PLAN.md new file mode 100644 index 00000000..494568a3 --- /dev/null +++ b/docs/AGENT_GATEWAY_PLAN.md @@ -0,0 +1,212 @@ +# Plan: Agent-facing MCP gateway — `resolve_image` + `deploy_ephemeral` + +## Context + +Human-facing "checkers" are commodity — any LLM can lint a compose in-chat. The +durable wedge is the set of things an AI agent **structurally cannot do itself**: +get **ground truth** (it hallucinates image refs — we literally spent a day on a +hallucinated `trydirect/redis`) and **execute** real infrastructure (it can +reason about a stack but can't run it). TryDirect owns both. + +Ship two MCP tools that give an agent *eyes* and *hands*: +- **`resolve_image(ref)`** → ground truth about a Docker image: exists, digest, + size, architectures, tags, last_pushed, official/pinned, optional CVE summary. +- **`deploy_ephemeral(compose, ttl)`** → provision a throwaway box, run the + compose, return `{live_url, logs, health}`, auto-teardown after a TTL. + +They must run as **their own scalable actix server** (separate process/replicas, +own port), isolated from the main API, since agent WS traffic + long-running +deploys shouldn't contend with the core platform. + +### Confirmed decisions +- **Gateway = new binary reusing the `stacker` lib.** A new `[[bin]] + agent-gateway` imports `stacker::mcp` (ToolRegistry, JSON-RPC protocol, WS + actor) + connectors/db/MqManager, registers only the two agent tools, runs its + own `HttpServer`. Independently scalable at runtime; the ToolHandler structs + live in `src/mcp/tools/` as requested. +- **Sandbox infra = TryDirect-managed cloud pool + quotas.** The agent supplies + nothing; TryDirect provisions on its own Hetzner account under strict quotas + (TTL default 30m / max 2h, smallest box, max N concurrent sandboxes per user). + Cloud backend behind a trait so bring-your-own is possible later. +- **Substrate = fresh VM v1 behind a `SandboxController` trait; Kata/Firecracker + warm-pool as v2.** v1 reuses the proven OpenTofu→Hetzner deploy path (~90s to a + live URL). Docker Sandboxes (Docker Desktop 4.60+, custom microVM VMM, `sbx` + CLI) is **local-only with no hosted API**, so it can't be called as a service — + but its microVM-per-sandbox model is the v2 target, self-hosted via **Kata** + (Stacker's `Deployment.runtime` already carries `runc`|`kata`) or Firecracker. + The trait keeps the tools/tests unchanged when the fast path drops in. + +## Architecture: pure core + trait-injected heavy deps + standalone binary + +Unlike `td-audit`, these tools inherently need the platform (Docker Hub, MQ, DB, +Vault). So we keep the **testable core pure** and inject the heavy operations via +traits (mockable), then wrap them as MCP `ToolHandler`s and serve from a new +binary. + +``` +crates/agent-tools/ # pure, no actix/sqlx (deps: serde, thiserror, + src/lib.rs # async-trait, td-audit) + image.rs # ResolvedImage DTO; `trait ImageResolver`; assemble/normalize + # ground-truth + reuse td_audit::image::audit_image for a grade + sandbox.rs # SandboxSpec/Handle/Status DTOs; `trait SandboxController` + # { launch, status, teardown }; TTL math; quota decision; + # reaper "which are expired" selection; compose safety gate + error.rs +src/mcp/tools/ + resolve_image.rs # ToolHandler: parse args -> real ImageResolver -> ToolContent + deploy_ephemeral.rs # ToolHandler: validate+quota -> real SandboxController +src/bin/agent-gateway.rs # own HttpServer: /mcp (WS) + /health; focused ToolRegistry +``` + +Each `ToolHandler` builds its real trait impl from `ToolContext { user, pg_pool, +settings }` (+ app_data the gateway provides), exactly like existing tools. + +## Tool 1 — `resolve_image` (ground truth) + +Reuse, don't reinvent: +- **`src/connectors/dockerhub_service.rs`** `DockerHubConnector` (+ `MockDockerHubConnector` + for tests) → existence, `TagSummary.digest`/`last_updated`, tag list; Redis-cached. +- **`src/helpers/dockerhub.rs`** `Image { architecture, os, size, digest, last_pushed }` + via `GET /v2/repositories/{ns}/{repo}/tags/{tag}/images` → the arch[]/size the + connector trait doesn't expose yet. Wire this into the real `ImageResolver`. +- **`src/routes/audit/mod.rs`** `DockerHubMetadata::fetch` + `days_since_iso8601` + + the `library/` normalization / official / pinned logic — lift into the pure + crate (they're already almost pure). +- **`td_audit::image`** `audit_image()` + `parse_trivy_report()` → include a grade + and optional CVE summary (Trivy opt-in via `AUDIT_TRIVY_ENABLED`, as today). + +Result DTO: `{ reference, exists, official, pinned, digest, size_bytes, +architectures[], recent_tags[], last_pushed, grade, cve_summary? }`. Real impl is +network-backed; pure assembly + normalization is unit-tested with a mock resolver. + +## Tool 2 — `deploy_ephemeral` (hands, with a TTL reaper) + +`SandboxController` trait: `launch(SandboxSpec) -> Handle`, `status(&Handle) -> +{health, live_url, log_excerpt}`, `teardown(&Handle)`. + +**`FreshVmController` (v1)** reuses the existing deploy machinery: +- Build a minimal **`Payload`** (`src/forms/project/payload.rs`) with a + gzipped compose, `deployment_hash = "sandbox_{uuid}"`, managed-account cloud + creds (from config/Vault), smallest Hetzner box. +- Publish via **`MqManager`** / **`InstallServiceClient::deploy()`** + (`install.start.tfa.all.all`) — same path as normal deploys. +- Poll status/health via **`db::deployment::fetch_by_deployment_hash`** + agent + heartbeat; `live_url` from `server.srv_ip`; logs via the deployment **events + feed** (`src/routes/deployment/events.rs`). +- **Teardown** reuses the destroy path (server delete + Vault cleanup, + `src/routes/server/delete.rs`, install-service `on_fail` destroy). + +**TTL reaper (new — the one real gap):** a background tokio task in the gateway +that periodically selects deployments with `metadata.ttl_expires_at < now()` and +tears them down. Selection logic is pure/tested; the effecting call reuses +teardown. (If the agent/cron mechanism is a better host, use it; otherwise a +simple interval task in the gateway.) + +**Guards (managed-pool safety):** clamp TTL to max; enforce max concurrent +sandboxes/user (count via db); smallest size; **gate the compose through +`td-audit`** (`audit_exposure` + security) and refuse `privileged`, host mounts, +etc. before launch — a natural reuse that stops the sandbox from being an abuse +vector. + +## MCP wiring & the standalone server + +- Reuse `stacker::mcp` wholesale: `ToolHandler`/`ToolRegistry` + (`src/mcp/registry.rs`), JSON-RPC types (`src/mcp/protocol.rs`), the WS actor + (`src/mcp/websocket.rs`). Add the two tools to the registry the gateway builds. +- `src/bin/agent-gateway.rs`: mirror `src/bin/server.rs` boot, but a lean + `HttpServer` exposing only `/mcp` (WS) + `/health`, its own port/config, its own + deployable. Reuse the existing auth middleware for agent callers (token → + `ReqData`); an "agent token" scope is a follow-up. +- Register as a `[[bin]]` in `Cargo.toml`; add `crates/agent-tools` to + `[workspace] members`. + +## TDD / BDD (mirrors the td-audit discipline) + +- **Unit (`cargo test -p agent-tools`)** — pure, no DB/actix: + - `resolve_image`: assemble ground-truth from a **mock `ImageResolver`**; + ref-normalization cases (`redis` → `library/redis`, tag vs digest, `ghcr.io/...`), + official/pinned, grade mapping. + - `sandbox`: TTL clamp, quota decision (allow/deny at N), reaper expired-set + selection, compose safety-gate refusals — all against a **mock + `SandboxController`**. +- **BDD (cucumber, matching `tests/features/mcp.feature`)** — a + `tests/features/agent_gateway.feature` driving the MCP JSON-RPC over WS with + mock controllers: `tools/list` shows both tools; `resolve_image` returns + ground-truth JSON; `deploy_ephemeral` returns a handle + live_url and is + refused when over quota / when the compose is unsafe. +- Fixtures (compose samples, a captured Docker Hub `/images` response, a Trivy + sample) go in **`config/shared-fixtures/agent/`** (canonical) with portable + copies in the crate, per the shared-fixtures convention. + +## Workflow, deliverables & developer HOWTO + +- **Isolated git worktree.** All work happens in a dedicated worktree/branch + (`feature/agent-gateway`), like the audit work — never on `dev` directly. +- **TDD throughout.** Every unit is RED→GREEN: write the failing test (pure core + with mock `ImageResolver`/`SandboxController`) first, implement, run + `cargo test -p agent-tools`; BDD scenarios likewise before wiring. +- **Plan + HOWTO checked in.** Copy this plan into the repo and write a + developer-facing setup guide **`docs/AGENT_GATEWAY_SETUP.md`** so anyone can + stand up the infrastructure. It must cover: + - **Prereqs**: Rust toolchain; Postgres, Redis, RabbitMQ, Vault, and the + install service running; a **managed sandbox cloud account** (Hetzner) + token. + - **Env/config**: `REDIS_URL`, AMQP/MQ settings, `VAULT_*`, `DOCKERHUB_*`, + `AUDIT_TRIVY_ENABLED`; sandbox managed-account creds (e.g. + `SANDBOX_HETZNER_TOKEN`, region/size); quotas (`SANDBOX_DEFAULT_TTL=30m`, + `SANDBOX_MAX_TTL=2h`, `SANDBOX_MAX_CONCURRENT_PER_USER`); gateway bind port; + agent-auth token setup. + - **Run**: build + run `agent-gateway`; connect an MCP client and call + `tools/list` / `resolve_image` / `deploy_ephemeral`; run the test suites. + - **TTL reaper**: how it selects + tears down expired sandboxes, and how to + tune the interval; how teardown maps to server-delete + Vault cleanup. + - **Cost guardrails**: quotas, smallest box, auto-teardown, the td-audit + compose safety-gate — the knobs that keep a managed pool from being abused. + - **v2 fast path (Kata/Firecracker)**: host prerequisites (KVM, kata-runtime / + Firecracker), how it slots behind `SandboxController`, and the security notes + for running untrusted agent compose in microVMs (the self-hosted Docker + Sandboxes model). + +## Milestones + +- **M0** — `crates/agent-tools` scaffold (DTOs, `ImageResolver`/`SandboxController` + traits, error), workspace member, `agent-gateway` binary booting a `/mcp` + `/health` + server with an empty registry. TDD skeleton. +- **M1** — `resolve_image`: pure assembly + mock tests, real DockerHub-backed + resolver (wire arch/size endpoint), ToolHandler, register, BDD. Ship — pure + ground-truth, low risk, no infra spend. +- **M2** — `deploy_ephemeral` core: `SandboxController` trait, `FreshVmController` + over Payload/MqManager, quotas + compose safety-gate (reuse td-audit), + ToolHandler, BDD with a mock controller. +- **M3** — TTL reaper + real teardown wiring + managed-account cloud creds; + end-to-end launch→live_url→auto-teardown against a real (throwaway) box. +- **M4 (later)** — `KataPoolController` fast path behind the same trait; metrics + (reuse the prometheus registry): `agent_sandbox_total{state}`, + `agent_image_resolve_total{result}`. + +## Critical files + +- New: `crates/agent-tools/**`, `src/mcp/tools/{resolve_image,deploy_ephemeral}.rs`, + `src/bin/agent-gateway.rs`, `config/shared-fixtures/agent/**`, + `tests/features/agent_gateway.feature` (+ steps). +- Modified: `Cargo.toml` (workspace member + `[[bin]]` + `agent-tools` dep), + `src/mcp/mod.rs`/`registry.rs` (export the two tools), reuse (not modify) + `dockerhub_service.rs`, `helpers/dockerhub.rs`, `forms/project/payload.rs`, + `helpers/mq_manager.rs`, `connectors/install_service/client.rs`, + `db/deployment.rs`, `routes/server/delete.rs`, `routes/deployment/events.rs`, + `td-audit`. + +## Verification + +- `cargo test -p agent-tools` — pure unit tests with mock `ImageResolver` / + `SandboxController` (no DB, no cloud). +- `cargo test -p stacker --test bdd` (or the gateway's cucumber target) — the + `agent_gateway.feature` scenarios with mock controllers. +- `SQLX_OFFLINE=true cargo build --bin agent-gateway` — the standalone server + compiles against the reused lib. +- Manual: run `agent-gateway`; connect an MCP client (tokio-tungstenite, like + `tests/steps/mcp.rs`); `tools/list` → both tools; `resolve_image {"ref":"nginx:latest"}` + → ground-truth JSON (exists/arch/size/digest/grade); `deploy_ephemeral` with a + tiny compose → `{live_url, logs, health}`, then confirm the reaper tears it down + after the TTL (server + Vault cleaned). +- Cost/safety checks: over-quota call → refused; `privileged` compose → refused by + the td-audit gate before any provisioning. diff --git a/docs/AGENT_GATEWAY_SETUP.md b/docs/AGENT_GATEWAY_SETUP.md new file mode 100644 index 00000000..f1474715 --- /dev/null +++ b/docs/AGENT_GATEWAY_SETUP.md @@ -0,0 +1,166 @@ +# Agent Gateway — Developer Setup & Infrastructure HOWTO + +The **agent-gateway** is a standalone, independently-scalable MCP server that +exposes two tools an AI agent cannot do itself: + +- **`resolve_image(reference)`** — ground truth about a Docker image (exists, + digest, size, architectures, tags, last_pushed, official/pinned, grade, + optional CVE summary). No infra; safe to run anywhere. +- **`deploy_ephemeral(compose_yaml, ttl_secs)`** — provision a throwaway box, + run the compose, return `{live_url, logs, health}`, and auto-teardown after a + TTL. Provisions **real** (paid) cloud infra — read the guardrails below. + +Architecture: the pure decision/assembly logic lives in the `agent-tools` crate +(`cargo test -p agent-tools`, no DB/cloud); heavy I/O (Docker Hub, RabbitMQ, DB, +Vault, cloud) is injected via the `ImageResolver` / `SandboxController` traits and +supplied by the gateway binary, which reuses `stacker::mcp` (JSON-RPC/WebSocket). + +--- + +## 1. Prerequisites + +Runtime services (same ones the main `server` needs): +- **PostgreSQL** — deployment/agent/server state. +- **Redis** — Docker Hub response cache (and shared rate-limit if fronted). +- **RabbitMQ** — deploy messages to the install service. +- **HashiCorp Vault** — SSH keys, agent tokens, sandbox cloud creds. +- **Install service** running and consuming `install.*` (it runs OpenTofu → + provisions the box → `docker compose up`). +- A **managed sandbox cloud account** (Hetzner) + API token, dedicated to + sandboxes so quotas/billing are isolated from customer deployments. + +Toolchain: Rust (2021), `sqlx-cli` for migrations. Optional: `trivy` on `PATH` +for CVE summaries. + +--- + +## 2. Configuration (environment) + +Shared with the platform: +``` +REDIS_URL=redis://127.0.0.1/0 +DOCKERHUB_USERNAME=... # optional; raises Docker Hub rate limits +DOCKERHUB_TOKEN=... # optional PAT +AUDIT_TRIVY_ENABLED=1 # optional; include cve_summary in resolve_image +# Postgres / AMQP / Vault come from configuration.yaml (same as `server`). +``` + +Gateway-specific: +``` +AGENT_GATEWAY_BIND=0.0.0.0:4600 # its own port, separate from `server` + +# Managed sandbox cloud pool (TryDirect account — NOT the caller's): +SANDBOX_CLOUD_PROVIDER=hetzner +SANDBOX_HETZNER_TOKEN=... # or a Vault path; provisions the throwaway boxes +SANDBOX_REGION=fsn1 +SANDBOX_SERVER_SIZE=cpx11 # smallest usable box + +# Quotas (enforced in agent-tools::sandbox before any provisioning): +SANDBOX_DEFAULT_TTL_SECS=1800 # 30m +SANDBOX_MAX_TTL_SECS=7200 # 2h +SANDBOX_MAX_CONCURRENT_PER_USER=3 + +# TTL reaper: +SANDBOX_REAPER_INTERVAL_SECS=60 # how often to scan for expired sandboxes +``` + +Agent auth: callers authenticate with a bearer token resolved to a `User` by the +reused auth middleware (JWT today; a dedicated "agent token" scope is a +follow-up). Casbin must grant the agent role access to `/mcp`. + +--- + +## 3. Build & run + +```bash +# Pure core tests — no services needed: +cargo test -p agent-tools + +# Build the standalone server: +SQLX_OFFLINE=true cargo build --bin agent-gateway + +# Apply migrations (adds sandbox TTL columns / casbin grant for /mcp): +sqlx migrate run + +# Run it (needs Postgres/Redis/RabbitMQ/Vault + install service): +cargo run --bin agent-gateway +``` + +It serves `/health` and the MCP WebSocket at `/mcp` (JSON-RPC 2.0, `mcp` +subprotocol), independently of the main `server` — scale it as its own +deployment/replica set. + +--- + +## 4. Using the tools (MCP client) + +Connect an MCP client to `ws://:4600/mcp` and `initialize`, then: + +```jsonc +// tools/list -> shows resolve_image + deploy_ephemeral +// tools/call resolve_image +{ "name": "resolve_image", "arguments": { "reference": "redis:7-alpine" } } +// -> { exists, official, pinned, digest, size_bytes, architectures:["amd64","arm64"], +// recent_tags:[...], last_pushed, grade:"A", cve_summary?:{...} } + +// tools/call deploy_ephemeral +{ "name": "deploy_ephemeral", + "arguments": { "compose_yaml": "services:\n web:\n image: nginx:1.27-alpine\n ports: [\"80:80\"]\n", + "ttl_secs": 900 } } +// -> { id:"sandbox_", live_url, health, expires_at } (poll status for the URL) +``` + +--- + +## 5. TTL reaper (auto-teardown) + +A background task in the gateway scans every `SANDBOX_REAPER_INTERVAL_SECS` for +deployments whose `metadata.ttl_expires_at < now()` (selection logic: +`agent_tools::sandbox::select_expired`, unit-tested) and tears them down. Teardown +reuses the existing destroy path — server delete + Vault cleanup +(`src/routes/server/delete.rs`) and the install-service OpenTofu destroy — so a +sandbox never outlives its TTL even if the agent disconnects. + +Operational: watch reaper logs; a stuck box is caught on the next scan. To change +aggressiveness, tune `SANDBOX_REAPER_INTERVAL_SECS` and `SANDBOX_MAX_TTL_SECS`. + +--- + +## 6. Cost & abuse guardrails (managed pool) + +Because `deploy_ephemeral` provisions **real paid infra on the TryDirect +account**, these are enforced *before* any provisioning (all in +`agent-tools::sandbox`, unit-tested): + +- **TTL clamp** — requests are clamped to `[1, SANDBOX_MAX_TTL_SECS]`; auto-teardown guaranteed. +- **Per-user concurrency** — refused (`QuotaExceeded`) at `SANDBOX_MAX_CONCURRENT_PER_USER`. +- **Smallest box** — `SANDBOX_SERVER_SIZE` (e.g. `cpx11`). +- **Compose safety gate** — `gate_compose` refuses `privileged`, Docker-socket + mounts, and host networking (reuses the `td-audit` compose parser); extend with + `td_audit::exposure` / security checks as needed. Untrusted agent compose never + reaches a host with an escape hatch. + +Recommend: a dedicated cloud sub-account with a hard spend cap, plus alerting on +active-sandbox count. + +--- + +## 7. v2 fast path — Kata/Firecracker warm pool + +v1 provisions a fresh VM per request (~90s to a live URL). The `SandboxController` +trait lets a **warm microVM pool** drop in with no tool/test changes: + +- Keep N pre-provisioned Linux hosts (KVM) running a microVM runtime — **Kata + Containers** (Stacker's `Deployment.runtime` already supports `kata`) or + **Firecracker/Cloud Hypervisor**. A request grabs a slot, `docker compose up` + inside a microVM (seconds), then the slot is wiped and returned to the pool. +- This is the self-hosted equivalent of **Docker Sandboxes** (Docker Desktop's + agent feature; local-only, no hosted API — so we reuse the *pattern*, not the + product). MicroVM isolation is what makes running untrusted agent compose on a + shared host safe. +- Host prerequisites: KVM enabled, `kata-runtime` (or Firecracker) installed, a + pool manager tracking slot lease/reset. Security-review the isolation boundary + before exposing to untrusted callers. + +Implement as `KataPoolController: SandboxController` and select it via config; the +MCP tools and their tests are unchanged. From 740dee3c3c030255a06a982d4016e159c38315d2 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 29 Jul 2026 11:50:50 +0200 Subject: [PATCH 03/19] feat(mcp): resolve_image ground-truth tool (real Docker Hub resolver) ResolveImageTool (ToolHandler) wraps the pure agent_tools::image core with a DockerHubImageResolver over the Docker Hub v2 API: - repositories/{repo}/ -> existence + last_pushed - tags?ordering=last_updated -> recent tags - tags/{tag}/images -> architectures (with variant), size, digest Returns the ResolvedImage JSON (exists/official/pinned/digest/size/arch/tags/ grade). Registered in ToolRegistry::new(), so it is immediately callable on the existing /mcp WebSocket. CVE summary (Trivy) is a follow-up. Co-Authored-By: Claude Opus 4.8 --- Cargo.toml | 1 + src/mcp/registry.rs | 6 ++ src/mcp/tools/mod.rs | 2 + src/mcp/tools/resolve_image.rs | 179 +++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+) create mode 100644 src/mcp/tools/resolve_image.rs diff --git a/Cargo.toml b/Cargo.toml index 7d1b25fc..02c7a840 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ config = "0.13.4" reqwest = { version = "0.11.23", features = ["json", "blocking", "stream", "native-tls"] } serde = { version = "1.0.195", features = ["derive"] } td-audit = { path = "crates/td-audit" } +agent-tools = { path = "crates/agent-tools" } tokio = { version = "1.28.1", features = ["full"] } tracing = { version = "0.1.40", features = ["log"] } tracing-bunyan-formatter = "0.3.8" diff --git a/src/mcp/registry.rs b/src/mcp/registry.rs index 6ba32fc4..35f7be33 100644 --- a/src/mcp/registry.rs +++ b/src/mcp/registry.rs @@ -219,6 +219,12 @@ impl ToolRegistry { handlers: HashMap::new(), }; + // Agent ground-truth tools + registry.register( + "resolve_image", + Box::new(crate::mcp::tools::resolve_image::ResolveImageTool), + ); + // Project management tools registry.register("list_projects", Box::new(ListProjectsTool)); registry.register("get_project", Box::new(GetProjectTool)); diff --git a/src/mcp/tools/mod.rs b/src/mcp/tools/mod.rs index 9bae3309..b64de8b2 100644 --- a/src/mcp/tools/mod.rs +++ b/src/mcp/tools/mod.rs @@ -1,5 +1,6 @@ pub mod agent_control; pub mod ansible_roles; +pub mod resolve_image; pub mod cloud; pub mod compose; pub mod config; @@ -37,3 +38,4 @@ pub use remote_secrets::*; pub use support::*; pub use templates::*; pub use user_service::*; +pub use resolve_image::*; diff --git a/src/mcp/tools/resolve_image.rs b/src/mcp/tools/resolve_image.rs new file mode 100644 index 00000000..9678cbad --- /dev/null +++ b/src/mcp/tools/resolve_image.rs @@ -0,0 +1,179 @@ +//! MCP tool `resolve_image` — ground truth about a Docker image reference. +//! +//! The decision/assembly logic lives in the pure `agent_tools::image` core; this +//! module provides the real [`ImageResolver`] over the Docker Hub v2 API and the +//! `ToolHandler` wrapper. An agent calls this to stop guessing whether an image +//! exists, is multi-arch, is pinned, etc. (the class of hallucination that +//! produced `trydirect/redis`). + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use agent_tools::image::{resolve_image, ImageResolver, ParsedRef, RawImageFacts}; + +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; + +const HUB: &str = "https://hub.docker.com/v2"; + +/// Docker Hub-backed resolver. Only ever contacts hub.docker.com (no SSRF +/// surface: the reference is normalized to a `namespace/repo` path). +pub struct DockerHubImageResolver { + http: reqwest::Client, +} + +impl DockerHubImageResolver { + pub fn new() -> Self { + DockerHubImageResolver { + http: reqwest::Client::builder() + .user_agent(concat!("stacker-agent-gateway/", env!("CARGO_PKG_VERSION"))) + .timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap_or_default(), + } + } + + async fn get_json(&self, url: &str) -> Option { + let resp = self.http.get(url).send().await.ok()?; + if !resp.status().is_success() { + return None; + } + resp.json::().await.ok() + } +} + +impl Default for DockerHubImageResolver { + fn default() -> Self { + Self::new() + } +} + +/// Rough days-since for an ISO-8601 timestamp (no chrono dep), mirroring the +/// audit route's helper. +fn days_since_iso8601(ts: &str) -> Option { + let year: i64 = ts.get(0..4)?.parse().ok()?; + let month: i64 = ts.get(5..7)?.parse().ok()?; + let day: i64 = ts.get(8..10)?.parse().ok()?; + let to_days = |y: i64, m: i64, d: i64| y * 365 + (y / 4) + m * 30 + d; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_secs() as i64 + / 86_400 + + to_days(1970, 1, 1); + Some((now - to_days(year, month, day)).max(0) as u64) +} + +#[async_trait] +impl ImageResolver for DockerHubImageResolver { + async fn facts(&self, parsed: &ParsedRef) -> agent_tools::error::Result { + let repo = &parsed.repo; + + // 1. Repository: existence + last push. + let repo_json = self.get_json(&format!("{HUB}/repositories/{repo}/")).await; + let exists = repo_json.is_some(); + let last_pushed = repo_json + .as_ref() + .and_then(|v| v.get("last_updated").and_then(|d| d.as_str()).map(String::from)); + let last_updated_days = last_pushed.as_deref().and_then(days_since_iso8601); + + if !exists { + return Ok(RawImageFacts { exists: false, ..Default::default() }); + } + + // 2. Recent tags (best-effort). + let recent_tags = self + .get_json(&format!("{HUB}/repositories/{repo}/tags?page_size=10&ordering=last_updated")) + .await + .and_then(|v| v.get("results").and_then(|r| r.as_array()).cloned()) + .map(|arr| { + arr.iter() + .filter_map(|t| t.get("name").and_then(|n| n.as_str()).map(String::from)) + .collect::>() + }) + .unwrap_or_default(); + + // 3. Per-tag images → architectures + size + digest. + let tag = parsed.tag.as_deref().unwrap_or("latest"); + let images = self + .get_json(&format!("{HUB}/repositories/{repo}/tags/{tag}/images")) + .await; + let (architectures, size_bytes, digest) = match images.and_then(|v| v.as_array().cloned()) { + Some(arr) => { + let mut archs = Vec::new(); + let mut size = None; + let mut dig = parsed.digest.clone(); + for img in &arr { + if let Some(a) = img.get("architecture").and_then(|a| a.as_str()) { + let variant = img.get("variant").and_then(|x| x.as_str()); + let label = match variant { + Some(v) if !v.is_empty() => format!("{a}/{v}"), + _ => a.to_string(), + }; + if !archs.contains(&label) { + archs.push(label); + } + } + if size.is_none() { + size = img.get("size").and_then(|s| s.as_u64()); + } + if dig.is_none() { + dig = img.get("digest").and_then(|d| d.as_str()).map(String::from); + } + } + (archs, size, dig) + } + None => (Vec::new(), None, parsed.digest.clone()), + }; + + Ok(RawImageFacts { + exists: true, + digest, + size_bytes, + architectures, + recent_tags, + last_pushed, + last_updated_days, + }) + } +} + +pub struct ResolveImageTool; + +#[async_trait] +impl ToolHandler for ResolveImageTool { + async fn execute(&self, args: Value, _context: &ToolContext) -> Result { + let reference = args + .get("reference") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| "`reference` (an image ref, e.g. \"redis:7-alpine\") is required".to_string())?; + + let resolver = DockerHubImageResolver::new(); + // CVE summary is a follow-up (Trivy); ground-truth metadata now. + let resolved = resolve_image(&resolver, reference, None) + .await + .map_err(|e| e.to_string())?; + + let json = serde_json::to_string_pretty(&resolved).map_err(|e| e.to_string())?; + Ok(ToolContent::Text { text: json }) + } + + fn schema(&self) -> Tool { + Tool { + name: "resolve_image".to_string(), + description: "Ground truth about a Docker image reference: whether it exists, its digest, size, supported architectures, recent tags, last push date, whether it is official/pinned, a quality grade, and (when enabled) a CVE summary. Use this before writing a compose/Dockerfile to avoid referencing an image that does not exist or is not multi-arch.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "reference": { + "type": "string", + "description": "Image reference, e.g. \"nginx\", \"redis:7-alpine\", \"ghcr.io/owner/repo:v2\", or \"name@sha256:...\"." + } + }, + "required": ["reference"] + }), + } + } +} From 6596357376c0279e97adf85d88344a8d1cc14042 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 29 Jul 2026 11:53:22 +0200 Subject: [PATCH 04/19] feat(agent-gateway): standalone scalable MCP server binary New `agent-gateway` binary + startup::run_agent_gateway: a lean, independently deployable actix server that serves only /health and the MCP WebSocket at /mcp, reusing the same auth + casbin middleware and the shared ToolRegistry. Binds AGENT_GATEWAY_BIND (default 0.0.0.0:4600) so agent traffic scales apart from the core API. resolve_image is live on it today; deploy_ephemeral lands next. Co-Authored-By: Claude Opus 4.8 --- Cargo.toml | 4 +++ src/bin/agent_gateway.rs | 64 +++++++++++++++++++++++++++++++++ src/startup.rs | 76 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 src/bin/agent_gateway.rs diff --git a/Cargo.toml b/Cargo.toml index 02c7a840..ba1ba1e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,10 @@ name = "stacker-cli" path = "src/bin/agent_executor.rs" name = "agent-executor" +[[bin]] +path = "src/bin/agent_gateway.rs" +name = "agent-gateway" + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] diff --git a/src/bin/agent_gateway.rs b/src/bin/agent_gateway.rs new file mode 100644 index 00000000..5a3c0fd8 --- /dev/null +++ b/src/bin/agent_gateway.rs @@ -0,0 +1,64 @@ +//! `agent-gateway` — standalone, independently-scalable MCP server for the +//! agent-facing tools (`resolve_image`, `deploy_ephemeral`). Boots the same +//! pools/config as the main server but serves only `/health` + `/mcp` via +//! `stacker::startup::run_agent_gateway`. +//! +//! Binds `AGENT_GATEWAY_BIND` (default `0.0.0.0:4600`) instead of the main +//! server's host/port, so it deploys and scales apart from the core API. + +use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode}; +use stacker::banner; +use stacker::configuration::get_configuration; +use stacker::helpers::AgentPgPool; +use stacker::startup::run_agent_gateway; +use stacker::telemetry::{get_subscriber, init_subscriber}; +use std::net::TcpListener; +use std::time::Duration; + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + banner::print_banner(); + + let subscriber = get_subscriber("agent-gateway".into(), "info".into()); + init_subscriber(subscriber); + + let settings = get_configuration().expect("Failed to read configuration."); + + let connect_options = PgConnectOptions::new() + .host(&settings.database.host) + .port(settings.database.port) + .username(&settings.database.username) + .password(&settings.database.password) + .database(&settings.database.database_name) + .ssl_mode(PgSslMode::Disable); + + let api_pool = PgPoolOptions::new() + .max_connections(20) + .min_connections(2) + .acquire_timeout(Duration::from_secs(5)) + .idle_timeout(Duration::from_secs(600)) + .max_lifetime(Duration::from_secs(1800)) + .connect_with(connect_options.clone()) + .await + .expect("Failed to connect to database (API pool)."); + + let agent_pool_raw = PgPoolOptions::new() + .max_connections(20) + .min_connections(2) + .acquire_timeout(Duration::from_secs(15)) + .idle_timeout(Duration::from_secs(300)) + .max_lifetime(Duration::from_secs(1800)) + .connect_with(connect_options) + .await + .expect("Failed to connect to database (Agent pool)."); + let agent_pool = AgentPgPool::new(agent_pool_raw); + + let address = std::env::var("AGENT_GATEWAY_BIND").unwrap_or_else(|_| "0.0.0.0:4600".to_string()); + tracing::info!("Starting agent-gateway at {address}"); + let listener = TcpListener::bind(&address) + .unwrap_or_else(|_| panic!("agent-gateway failed to bind to {address}")); + + run_agent_gateway(listener, api_pool, agent_pool, settings) + .await? + .await +} diff --git a/src/startup.rs b/src/startup.rs index bdf1be30..2e99bd3f 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -508,6 +508,82 @@ pub async fn run( Ok(server) } +/// Health endpoint for the standalone agent gateway. +async fn agent_gateway_health() -> actix_web::HttpResponse { + actix_web::HttpResponse::Ok().json(serde_json::json!({ + "status": "ok", + "service": "agent-gateway", + "version": env!("CARGO_PKG_VERSION"), + })) +} + +/// Boot the standalone **agent-gateway** server: a lean, independently-scalable +/// process that serves only `/health` and the MCP WebSocket at `/mcp` (the +/// agent-facing tools: `resolve_image`, `deploy_ephemeral`). It reuses the same +/// auth + casbin middleware and the shared `ToolRegistry`, so agents authenticate +/// exactly as they do against the main server — but agent traffic scales apart +/// from the core API. +pub async fn run_agent_gateway( + listener: TcpListener, + api_pool: Pool, + agent_pool: AgentPgPool, + settings: Settings, +) -> Result { + crate::metrics::init(); + + let settings = web::Data::new(settings); + let api_pool = web::Data::new(api_pool); + let agent_pool = web::Data::new(agent_pool); + + let vault_client = web::Data::new(helpers::VaultClient::new(&settings.vault)); + let oauth_http_client = + web::Data::new(build_oauth_http_client(&settings).map_err(std::io::Error::other)?); + let oauth_cache = web::Data::new(middleware::authentication::OAuthCache::new( + Duration::from_secs(60), + )); + + let mcp_registry = web::Data::new(Arc::new(mcp::ToolRegistry::new())); + let authorization = + middleware::authorization::try_new(settings.database.connection_string()).await?; + + let server = HttpServer::new(move || { + App::new() + .wrap( + Cors::default() + .allow_any_origin() + .allow_any_method() + .allowed_headers(vec![ + http::header::AUTHORIZATION, + http::header::CONTENT_TYPE, + http::header::ACCEPT, + http::header::ORIGIN, + http::header::HeaderName::from_static("x-requested-with"), + ]) + .expose_any_header() + .max_age(3600), + ) + .wrap(TracingLogger::default()) + .wrap(authorization.clone()) + .wrap(middleware::authentication::Manager::new()) + .wrap(Compress::default()) + .wrap(middleware::prometheus::PrometheusMetrics) + .route("/health", web::get().to(agent_gateway_health)) + .service(web::resource("/mcp").route(web::get().to(mcp::mcp_websocket))) + .app_data(api_pool.clone()) + .app_data(agent_pool.clone()) + .app_data(settings.clone()) + .app_data(vault_client.clone()) + .app_data(oauth_http_client.clone()) + .app_data(oauth_cache.clone()) + .app_data(mcp_registry.clone()) + .app_data(web::Data::new(authorization.clone())) + }) + .listen(listener)? + .run(); + + Ok(server) +} + #[cfg(test)] mod tests { use super::*; From d788371a9e009ab9648e49f08aa5ac1bfb8da883 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 29 Jul 2026 11:57:59 +0200 Subject: [PATCH 05/19] feat(mcp): deploy_ephemeral tool + SandboxController seam (M2) DeployEphemeralTool runs the full tested policy path from agent_tools::sandbox: compose safety-gate (no privileged / docker.sock / host net) -> per-user quota (SANDBOX_MAX_CONCURRENT_PER_USER) -> TTL clamp (SANDBOX_*_TTL_SECS) -> launch via the SandboxController seam. Adds active_count() to the trait. FreshVmController is the production impl over the existing OpenTofu->Hetzner deploy path; provisioning lands in M3, so launch reports "not enabled" for now while the tool already enforces safety + quota. Registered + MFA-gated. Gateway builds. Co-Authored-By: Claude Opus 4.8 --- crates/agent-tools/src/sandbox.rs | 5 ++ src/mcp/registry.rs | 7 +- src/mcp/tools/deploy_ephemeral.rs | 134 ++++++++++++++++++++++++++++++ src/mcp/tools/mod.rs | 2 + 4 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 src/mcp/tools/deploy_ephemeral.rs diff --git a/crates/agent-tools/src/sandbox.rs b/crates/agent-tools/src/sandbox.rs index c916ac28..5d0d3c55 100644 --- a/crates/agent-tools/src/sandbox.rs +++ b/crates/agent-tools/src/sandbox.rs @@ -70,6 +70,8 @@ pub struct SandboxStatus { /// polls the deployment DB; mocked in tests. v1 = FreshVmController, v2 = Kata pool. #[async_trait] pub trait SandboxController: Send + Sync { + /// Number of the owner's currently-active sandboxes (for quota enforcement). + async fn active_count(&self, owner: &str) -> Result; async fn launch(&self, spec: &SandboxSpec) -> Result; async fn status(&self, handle: &SandboxHandle) -> Result; async fn teardown(&self, handle: &SandboxHandle) -> Result<()>; @@ -222,6 +224,9 @@ mod tests { struct MockController; #[async_trait] impl SandboxController for MockController { + async fn active_count(&self, _owner: &str) -> Result { + Ok(0) + } async fn launch(&self, spec: &SandboxSpec) -> Result { Ok(SandboxHandle { id: "sandbox_test".into(), expires_at: spec.expires_at }) } diff --git a/src/mcp/registry.rs b/src/mcp/registry.rs index 35f7be33..77a52399 100644 --- a/src/mcp/registry.rs +++ b/src/mcp/registry.rs @@ -195,6 +195,7 @@ const MFA_REQUIRED_TOOLS: &[&str] = &[ "activate_pipe", "deactivate_pipe", "trigger_pipe", + "deploy_ephemeral", ]; /// Trait for tool handlers @@ -219,11 +220,15 @@ impl ToolRegistry { handlers: HashMap::new(), }; - // Agent ground-truth tools + // Agent ground-truth + execution tools registry.register( "resolve_image", Box::new(crate::mcp::tools::resolve_image::ResolveImageTool), ); + registry.register( + "deploy_ephemeral", + Box::new(crate::mcp::tools::deploy_ephemeral::DeployEphemeralTool), + ); // Project management tools registry.register("list_projects", Box::new(ListProjectsTool)); diff --git a/src/mcp/tools/deploy_ephemeral.rs b/src/mcp/tools/deploy_ephemeral.rs new file mode 100644 index 00000000..bb08e3c4 --- /dev/null +++ b/src/mcp/tools/deploy_ephemeral.rs @@ -0,0 +1,134 @@ +//! MCP tool `deploy_ephemeral` — give an agent *hands*: run an arbitrary +//! docker-compose on a throwaway box and get back a live URL + logs + health, +//! auto-torn-down after a TTL. +//! +//! The safety/quota/TTL policy is the pure, tested `agent_tools::sandbox` core. +//! The actual provisioning is the [`SandboxController`] seam; [`FreshVmController`] +//! is the production impl over the existing deploy path (wired in M3 — until then +//! it reports that ephemeral provisioning is not enabled, but the tool still +//! enforces the compose safety-gate and per-user quota). + +use async_trait::async_trait; +use serde_json::{json, Value}; + +use agent_tools::error::{AgentToolError, Result as AgentResult}; +use agent_tools::sandbox::{ + launch_sandbox, SandboxController, SandboxHandle, SandboxQuota, SandboxSpec, SandboxStatus, +}; + +use crate::mcp::protocol::{Tool, ToolContent}; +use crate::mcp::registry::{ToolContext, ToolHandler}; + +/// Read the sandbox quota from `SANDBOX_*` env, falling back to safe defaults. +fn quota_from_env() -> SandboxQuota { + let d = SandboxQuota::default(); + let num = |k: &str, dflt: u64| std::env::var(k).ok().and_then(|v| v.parse().ok()).unwrap_or(dflt); + SandboxQuota { + default_ttl_secs: num("SANDBOX_DEFAULT_TTL_SECS", d.default_ttl_secs), + max_ttl_secs: num("SANDBOX_MAX_TTL_SECS", d.max_ttl_secs), + max_concurrent_per_user: std::env::var("SANDBOX_MAX_CONCURRENT_PER_USER") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(d.max_concurrent_per_user), + } +} + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Production sandbox controller over the existing OpenTofu→Hetzner deploy path. +/// Provisioning is wired in M3; the trait shape is stable so the tool + tests +/// don't change when live provisioning (or the v2 Kata pool) lands. +pub struct FreshVmController { + owner: String, +} + +impl FreshVmController { + pub fn new(owner: String) -> Self { + FreshVmController { owner } + } +} + +#[async_trait] +impl SandboxController for FreshVmController { + async fn active_count(&self, _owner: &str) -> AgentResult { + // M3: count non-expired deployments with hash prefix "sandbox_" for the + // owner via db::deployment. Zero until provisioning is enabled. + Ok(0) + } + + async fn launch(&self, _spec: &SandboxSpec) -> AgentResult { + Err(AgentToolError::Backend(format!( + "ephemeral provisioning is not enabled on this instance yet (owner={}); \ + the compose passed the safety-gate and quota checks", + self.owner + ))) + } + + async fn status(&self, _handle: &SandboxHandle) -> AgentResult { + Err(AgentToolError::Backend("sandbox status is not available yet".into())) + } + + async fn teardown(&self, _handle: &SandboxHandle) -> AgentResult<()> { + Err(AgentToolError::Backend("sandbox teardown is not available yet".into())) + } +} + +pub struct DeployEphemeralTool; + +#[async_trait] +impl ToolHandler for DeployEphemeralTool { + async fn execute(&self, args: Value, context: &ToolContext) -> Result { + let compose = args + .get("compose_yaml") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| "`compose_yaml` (a docker-compose.yml) is required".to_string())?; + let requested_ttl = args.get("ttl_secs").and_then(|v| v.as_u64()); + + let quota = quota_from_env(); + let owner = context.user.id.clone(); + let controller = FreshVmController::new(owner.clone()); + + // Full tested path: safety-gate → quota → TTL clamp → launch. + let active = controller.active_count(&owner).await.map_err(|e| e.to_string())?; + let handle = launch_sandbox(&controller, "a, active, now_secs(), requested_ttl, compose) + .await + .map_err(|e| e.to_string())?; + + let json = json!({ + "id": handle.id, + "expires_at": handle.expires_at, + "poll": "call sandbox status with this id for the live_url once healthy", + }); + Ok(ToolContent::Text { + text: serde_json::to_string_pretty(&json).unwrap_or_default(), + }) + } + + fn schema(&self) -> Tool { + Tool { + name: "deploy_ephemeral".to_string(), + description: "Run a docker-compose.yml on a throwaway cloud sandbox and get back a live URL, logs and health. The sandbox auto-tears-down after ttl_secs. The compose is safety-gated (no privileged, Docker socket, or host networking) and subject to a per-user concurrency quota. Use this to actually SEE a stack run instead of guessing whether it works.".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "compose_yaml": { + "type": "string", + "description": "A docker-compose.yml to run in the sandbox." + }, + "ttl_secs": { + "type": "integer", + "description": "Requested lifetime in seconds (clamped to the server max, default ~30m)." + } + }, + "required": ["compose_yaml"] + }), + } + } +} diff --git a/src/mcp/tools/mod.rs b/src/mcp/tools/mod.rs index b64de8b2..23b6a7fc 100644 --- a/src/mcp/tools/mod.rs +++ b/src/mcp/tools/mod.rs @@ -1,5 +1,6 @@ pub mod agent_control; pub mod ansible_roles; +pub mod deploy_ephemeral; pub mod resolve_image; pub mod cloud; pub mod compose; @@ -39,3 +40,4 @@ pub use support::*; pub use templates::*; pub use user_service::*; pub use resolve_image::*; +pub use deploy_ephemeral::*; From 1c793e9059b9328235df8c8e6dc41577bdde7953 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 29 Jul 2026 12:12:33 +0200 Subject: [PATCH 06/19] ci(agent-gateway): auto-build workflow + standalone Docker image .github/workflows/agent-gateway.yml: on push/PR to dev/main (path-filtered to the agent-gateway sources), a fast DB-less job runs `cargo test -p agent-tools` and builds the `agent-gateway` binary; on push it builds and pushes `trydirect/agent-gateway:` via Dockerfile.agent-gateway (gha-cached). Dockerfile.agent-gateway: multi-stage image (mirrors the root Dockerfile) that builds and ships only the agent-gateway binary; runs on :4600. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/agent-gateway.yml | 73 +++++++++++++++++++++++++++++ Dockerfile.agent-gateway | 54 +++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 .github/workflows/agent-gateway.yml create mode 100644 Dockerfile.agent-gateway diff --git a/.github/workflows/agent-gateway.yml b/.github/workflows/agent-gateway.yml new file mode 100644 index 00000000..fa9881fe --- /dev/null +++ b/.github/workflows/agent-gateway.yml @@ -0,0 +1,73 @@ +name: Agent Gateway + +on: + push: + branches: [dev, main] + paths: + - "crates/agent-tools/**" + - "crates/td-audit/**" + - "src/mcp/**" + - "src/bin/agent_gateway.rs" + - "src/startup.rs" + - "Cargo.toml" + - "Cargo.lock" + - "Dockerfile.agent-gateway" + - ".github/workflows/agent-gateway.yml" + pull_request: + branches: [dev, main] + paths: + - "crates/agent-tools/**" + - "crates/td-audit/**" + - "src/mcp/**" + - "src/bin/agent_gateway.rs" + - "src/startup.rs" + - "Dockerfile.agent-gateway" + - ".github/workflows/agent-gateway.yml" + +jobs: + # Fast, DB-less checks: the pure crate + that the standalone binary compiles. + test: + name: Test crate + build binary + runs-on: ubuntu-latest + env: + SQLX_OFFLINE: true + steps: + - uses: actions/checkout@v4 + - name: Install protoc + run: sudo apt-get update -qq && sudo apt-get install -y protobuf-compiler + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + - name: Unit-test the pure agent-tools crate (no DB/cloud) + run: cargo test -p agent-tools --verbose + - name: Build the agent-gateway binary (offline) + run: cargo build --bin agent-gateway --verbose + + # Build + push the standalone image on push to dev/main (not PRs). + docker: + name: Build & push image + needs: test + if: github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Set image tag + id: tags + run: echo "tags=trydirect/agent-gateway:${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile.agent-gateway + push: true + tags: ${{ steps.tags.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile.agent-gateway b/Dockerfile.agent-gateway new file mode 100644 index 00000000..6c40f62c --- /dev/null +++ b/Dockerfile.agent-gateway @@ -0,0 +1,54 @@ +# syntax=docker/dockerfile:1.4 +# Standalone image for the agent-gateway MCP server. Mirrors the root Dockerfile +# but builds and ships only the `agent-gateway` binary, so it deploys and scales +# independently of the main `server`. +FROM rust:bookworm AS builder + +RUN apt-get update && apt-get install --no-install-recommends -y \ + protobuf-compiler libprotobuf-dev \ + && rm -rf /var/lib/apt/lists/* + +RUN cargo install sqlx-cli + +WORKDIR /app + +# Manifests + offline sqlx cache (compile-time checked queries without a DB). +COPY ./Cargo.toml . +COPY ./Cargo.lock . +COPY ./build.rs . +COPY ./rustfmt.toml . +COPY ./docker/local/.env . +COPY ./docker/local/configuration.yaml . +COPY .sqlx .sqlx/ +COPY ./proto ./proto +COPY ./tests/bdd.rs ./tests/bdd.rs + +COPY ./src ./src +COPY ./crates ./crates +COPY ./scenarios ./scenarios + +ENV SQLX_OFFLINE=true + +RUN apt-get update && apt-get install --no-install-recommends -y libssl-dev \ + && cargo build --release --bin agent-gateway + +# ---- runtime ---- +FROM debian:bookworm-slim AS production + +RUN apt-get update && apt-get install --no-install-recommends -y \ + libssl-dev ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +RUN mkdir ./files && chmod 0777 ./files + +COPY --from=builder /app/target/release/agent-gateway . +COPY --from=builder /app/.env . +COPY --from=builder /app/configuration.yaml . +COPY --from=builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx +COPY ./access_control.conf.dist ./access_control.conf + +ENV AGENT_GATEWAY_BIND=0.0.0.0:4600 +EXPOSE 4600 + +ENTRYPOINT ["/app/agent-gateway"] From 46aaa656e73b796bdd86b8d0132ac94739b210f0 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 29 Jul 2026 12:18:18 +0200 Subject: [PATCH 07/19] ci(agent-gateway): add to rust.yml artifacts + bake prebuilt binary in image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rust.yml: build the agent-gateway release binary alongside server/console/ stacker-cli and include it in the uploaded artifacts. - agent-gateway.yml: the build job now compiles the release binary once (cached) and uploads it; the docker job downloads it and bakes it into a slim runtime image (Dockerfile.agent-gateway is now runtime-only) — no Rust compile inside Docker, much faster and cache-friendly. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/agent-gateway.yml | 29 ++++++++++----- .github/workflows/rust.yml | 4 +++ Dockerfile.agent-gateway | 56 ++++++++--------------------- 3 files changed, 39 insertions(+), 50 deletions(-) diff --git a/.github/workflows/agent-gateway.yml b/.github/workflows/agent-gateway.yml index fa9881fe..69dde858 100644 --- a/.github/workflows/agent-gateway.yml +++ b/.github/workflows/agent-gateway.yml @@ -25,9 +25,10 @@ on: - ".github/workflows/agent-gateway.yml" jobs: - # Fast, DB-less checks: the pure crate + that the standalone binary compiles. - test: - name: Test crate + build binary + # Fast, DB-less checks + build the release binary once (cached), so the Docker + # image just bakes it (no Rust compile inside Docker). + build: + name: Test crate + build release binary runs-on: ubuntu-latest env: SQLX_OFFLINE: true @@ -41,17 +42,29 @@ jobs: uses: Swatinem/rust-cache@v2 - name: Unit-test the pure agent-tools crate (no DB/cloud) run: cargo test -p agent-tools --verbose - - name: Build the agent-gateway binary (offline) - run: cargo build --bin agent-gateway --verbose + - name: Build the agent-gateway binary (release, offline) + run: cargo build --release --bin agent-gateway --verbose + - name: Upload binary for the Docker job + if: github.event_name == 'push' + uses: actions/upload-artifact@v4 + with: + name: agent-gateway-bin + path: target/release/agent-gateway + retention-days: 1 - # Build + push the standalone image on push to dev/main (not PRs). + # Bake the prebuilt binary into a slim runtime image and push (push only). docker: name: Build & push image - needs: test + needs: build if: github.event_name == 'push' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Download prebuilt binary + uses: actions/download-artifact@v4 + with: + name: agent-gateway-bin + path: . - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub @@ -69,5 +82,3 @@ jobs: file: ./Dockerfile.agent-gateway push: true tags: ${{ steps.tags.outputs.tags }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 2ebf5add..1e709593 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -62,12 +62,16 @@ jobs: - name: Build stacker-cli (release) run: cargo build --release --target ${{ matrix.target }} --bin stacker-cli --verbose + - name: Build agent-gateway (release) + run: cargo build --release --target ${{ matrix.target }} --bin agent-gateway --verbose + - name: Prepare binaries run: | mkdir -p artifacts cp target/${{ matrix.target }}/release/server artifacts/server cp target/${{ matrix.target }}/release/console artifacts/console cp target/${{ matrix.target }}/release/stacker-cli artifacts/stacker-cli + cp target/${{ matrix.target }}/release/agent-gateway artifacts/agent-gateway tar -czf ${{ matrix.artifact_name }}.tar.gz -C artifacts . - name: Upload binaries uses: actions/upload-artifact@v4 diff --git a/Dockerfile.agent-gateway b/Dockerfile.agent-gateway index 6c40f62c..1f392751 100644 --- a/Dockerfile.agent-gateway +++ b/Dockerfile.agent-gateway @@ -1,52 +1,26 @@ -# syntax=docker/dockerfile:1.4 -# Standalone image for the agent-gateway MCP server. Mirrors the root Dockerfile -# but builds and ships only the `agent-gateway` binary, so it deploys and scales -# independently of the main `server`. -FROM rust:bookworm AS builder - -RUN apt-get update && apt-get install --no-install-recommends -y \ - protobuf-compiler libprotobuf-dev \ - && rm -rf /var/lib/apt/lists/* - -RUN cargo install sqlx-cli - -WORKDIR /app - -# Manifests + offline sqlx cache (compile-time checked queries without a DB). -COPY ./Cargo.toml . -COPY ./Cargo.lock . -COPY ./build.rs . -COPY ./rustfmt.toml . -COPY ./docker/local/.env . -COPY ./docker/local/configuration.yaml . -COPY .sqlx .sqlx/ -COPY ./proto ./proto -COPY ./tests/bdd.rs ./tests/bdd.rs - -COPY ./src ./src -COPY ./crates ./crates -COPY ./scenarios ./scenarios - -ENV SQLX_OFFLINE=true - -RUN apt-get update && apt-get install --no-install-recommends -y libssl-dev \ - && cargo build --release --bin agent-gateway - -# ---- runtime ---- +# Runtime-only image for the agent-gateway MCP server. +# +# CI (.github/workflows/agent-gateway.yml) builds the release binary once on the +# runner (cached) and downloads it into the build context, so this image just +# bakes it — no Rust compile inside Docker. The binary is glibc (built on +# ubuntu-latest) and this base is glibc (debian:bookworm-slim), so they match. +# +# Expects `./agent-gateway` (the prebuilt binary) present in the build context. FROM debian:bookworm-slim AS production RUN apt-get update && apt-get install --no-install-recommends -y \ - libssl-dev ca-certificates \ + libssl3 ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /app RUN mkdir ./files && chmod 0777 ./files -COPY --from=builder /app/target/release/agent-gateway . -COPY --from=builder /app/.env . -COPY --from=builder /app/configuration.yaml . -COPY --from=builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx -COPY ./access_control.conf.dist ./access_control.conf +# Prebuilt binary + runtime config (from the checked-out repo). +COPY agent-gateway /app/agent-gateway +COPY docker/local/configuration.yaml /app/configuration.yaml +COPY docker/local/.env /app/.env +COPY access_control.conf.dist /app/access_control.conf +RUN chmod +x /app/agent-gateway ENV AGENT_GATEWAY_BIND=0.0.0.0:4600 EXPOSE 4600 From 0926b60b408dd6c6fac41d0edeccd8eb4404462a Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 29 Jul 2026 12:59:24 +0200 Subject: [PATCH 08/19] feat(agent-gateway): public unauthenticated resolve_image endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /public/resolve_image ({"reference":"..."}) returns the ResolvedImage ground truth with no auth — zero-signup callable, the frictionless top of the funnel and the tool that fixes image hallucination. Shares resolve_reference() with the MCP tool so both behave identically. Anonymous access granted via a casbin rule for group_anonymous. Co-Authored-By: Claude Opus 4.8 --- ...20000_casbin_public_resolve_image.down.sql | 2 ++ ...9120000_casbin_public_resolve_image.up.sql | 4 +++ src/mcp/tools/resolve_image.rs | 19 +++++++---- src/routes/agent_public.rs | 32 +++++++++++++++++++ src/routes/mod.rs | 1 + src/startup.rs | 2 ++ 6 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 migrations/20260729120000_casbin_public_resolve_image.down.sql create mode 100644 migrations/20260729120000_casbin_public_resolve_image.up.sql create mode 100644 src/routes/agent_public.rs diff --git a/migrations/20260729120000_casbin_public_resolve_image.down.sql b/migrations/20260729120000_casbin_public_resolve_image.down.sql new file mode 100644 index 00000000..4c19d314 --- /dev/null +++ b/migrations/20260729120000_casbin_public_resolve_image.down.sql @@ -0,0 +1,2 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' AND v0 = 'group_anonymous' AND v1 = '/public/resolve_image' AND v2 = 'POST'; diff --git a/migrations/20260729120000_casbin_public_resolve_image.up.sql b/migrations/20260729120000_casbin_public_resolve_image.up.sql new file mode 100644 index 00000000..c58ee83b --- /dev/null +++ b/migrations/20260729120000_casbin_public_resolve_image.up.sql @@ -0,0 +1,4 @@ +-- Public (anonymous) access to the gateway's read-only ground-truth endpoint. +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES + ('p', 'group_anonymous', '/public/resolve_image', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/src/mcp/tools/resolve_image.rs b/src/mcp/tools/resolve_image.rs index 9678cbad..6e8b3c34 100644 --- a/src/mcp/tools/resolve_image.rs +++ b/src/mcp/tools/resolve_image.rs @@ -9,13 +9,23 @@ use async_trait::async_trait; use serde_json::{json, Value}; -use agent_tools::image::{resolve_image, ImageResolver, ParsedRef, RawImageFacts}; +use agent_tools::image::{resolve_image, ImageResolver, ParsedRef, RawImageFacts, ResolvedImage}; use crate::mcp::protocol::{Tool, ToolContent}; use crate::mcp::registry::{ToolContext, ToolHandler}; const HUB: &str = "https://hub.docker.com/v2"; +/// Resolve a reference to ground-truth facts. Shared by the MCP tool and the +/// public (unauthenticated) HTTP endpoint so both behave identically. +pub async fn resolve_reference(reference: &str) -> Result { + let resolver = DockerHubImageResolver::new(); + // CVE summary is a follow-up (Trivy); ground-truth metadata now. + resolve_image(&resolver, reference, None) + .await + .map_err(|e| e.to_string()) +} + /// Docker Hub-backed resolver. Only ever contacts hub.docker.com (no SSRF /// surface: the reference is normalized to a `namespace/repo` path). pub struct DockerHubImageResolver { @@ -150,12 +160,7 @@ impl ToolHandler for ResolveImageTool { .filter(|s| !s.is_empty()) .ok_or_else(|| "`reference` (an image ref, e.g. \"redis:7-alpine\") is required".to_string())?; - let resolver = DockerHubImageResolver::new(); - // CVE summary is a follow-up (Trivy); ground-truth metadata now. - let resolved = resolve_image(&resolver, reference, None) - .await - .map_err(|e| e.to_string())?; - + let resolved = resolve_reference(reference).await?; let json = serde_json::to_string_pretty(&resolved).map_err(|e| e.to_string())?; Ok(ToolContent::Text { text: json }) } diff --git a/src/routes/agent_public.rs b/src/routes/agent_public.rs new file mode 100644 index 00000000..99c11b8c --- /dev/null +++ b/src/routes/agent_public.rs @@ -0,0 +1,32 @@ +//! Public, unauthenticated agent endpoints served by the agent-gateway. +//! +//! `resolve_image` is read-only ground truth with no cost, so it is exposed +//! without auth — an agent (or anyone) can call it with zero signup, which is +//! the frictionless top of the funnel. Anonymous access is granted via a casbin +//! rule for `group_anonymous` (see the accompanying migration). + +use actix_web::{post, web, HttpResponse, Responder}; +use serde::Deserialize; + +use crate::mcp::tools::resolve_image::resolve_reference; + +#[derive(Debug, Deserialize)] +pub struct ResolveImageBody { + pub reference: String, +} + +/// `POST /public/resolve_image` — `{ "reference": "redis:7-alpine" }` → the +/// `ResolvedImage` ground truth (exists, digest, size, architectures, tags, +/// grade). No authentication required. +#[post("/public/resolve_image")] +pub async fn resolve_image_public(body: web::Json) -> impl Responder { + let reference = body.reference.trim(); + if reference.is_empty() { + return HttpResponse::BadRequest() + .json(serde_json::json!({ "error": "`reference` is required" })); + } + match resolve_reference(reference).await { + Ok(resolved) => HttpResponse::Ok().json(resolved), + Err(err) => HttpResponse::BadGateway().json(serde_json::json!({ "error": err })), + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index da153b2a..b6b8a718 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod agent; +pub mod agent_public; pub mod audit; pub mod client; pub(crate) mod command; diff --git a/src/startup.rs b/src/startup.rs index 2e99bd3f..d16a4eaf 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -568,6 +568,8 @@ pub async fn run_agent_gateway( .wrap(Compress::default()) .wrap(middleware::prometheus::PrometheusMetrics) .route("/health", web::get().to(agent_gateway_health)) + // Public, unauthenticated ground-truth endpoint (casbin grants anon). + .service(crate::routes::agent_public::resolve_image_public) .service(web::resource("/mcp").route(web::get().to(mcp::mcp_websocket))) .app_data(api_pool.clone()) .app_data(agent_pool.clone()) From 48e16204a22446352f0f5ca5e9282684263ed750 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 29 Jul 2026 13:00:14 +0200 Subject: [PATCH 09/19] docs(agent-gateway): connect-your-agent guide + Docker MCP Catalog draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CONNECT_YOUR_AGENT.md: how AIs discover the tools — the zero-signup public resolve_image curl, and MCP config snippets (mcp.json) for Claude Desktop / Cursor / Windsurf / VS Code / Cline, anonymous vs token, and what the agent should do with each tool. - DOCKER_MCP_CATALOG.md: submission draft for the Docker MCP Catalog (the highest-fit channel) + the official MCP registry and client marketplaces, with the server/tool metadata and a submission checklist. Co-Authored-By: Claude Opus 4.8 --- docs/CONNECT_YOUR_AGENT.md | 72 ++++++++++++++++++++++++++++++++++++++ docs/DOCKER_MCP_CATALOG.md | 66 ++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 docs/CONNECT_YOUR_AGENT.md create mode 100644 docs/DOCKER_MCP_CATALOG.md diff --git a/docs/CONNECT_YOUR_AGENT.md b/docs/CONNECT_YOUR_AGENT.md new file mode 100644 index 00000000..28532794 --- /dev/null +++ b/docs/CONNECT_YOUR_AGENT.md @@ -0,0 +1,72 @@ +# Connect your AI agent to TryDirect + +TryDirect exposes MCP tools that give an agent capabilities it can't do itself: + +- **`resolve_image`** — ground truth about any Docker image (exists? digest, + size, architectures, tags, official/pinned, grade). Stops the "I'll just use + `trydirect/redis`" class of hallucination. **No auth — call it for free.** +- **`deploy_ephemeral`** — actually run a `docker-compose.yml` on a throwaway + cloud box and get back a live URL + logs, auto-torn-down after a TTL. Requires + a token (it provisions real infra). + +There are two ways to reach them. + +## A. Zero-signup: the public HTTP endpoint + +`resolve_image` is a plain public POST — no account, no MCP client: + +```bash +curl -s -X POST https://mcp.try.direct/public/resolve_image \ + -H 'Content-Type: application/json' \ + -d '{"reference":"redis:7-alpine"}' +# -> { "exists": true, "official": true, "pinned": true, "digest": "sha256:…", +# "size_bytes": 12000000, "architectures": ["amd64","arm64/v8"], +# "recent_tags": [...], "grade": "A" } +``` + +Great for quick checks, scripts, or an agent framework that calls HTTP tools +directly. + +## B. MCP (auto-discovered by your agent's client) + +Point any MCP client at the gateway. Once connected, the client calls +`tools/list` and your model **discovers the tools automatically** from their +descriptions — no plugin, no training. The MCP WebSocket is at +`wss://mcp.try.direct/mcp` (subprotocol `mcp`). + +`resolve_image` is anonymous; `deploy_ephemeral` needs +`Authorization: Bearer ` (get one at +https://try.direct/account/tokens). + +### Claude Desktop / Cursor / Windsurf / VS Code (`mcp.json`) +```jsonc +{ + "mcpServers": { + "trydirect": { + "url": "wss://mcp.try.direct/mcp", + "headers": { "Authorization": "Bearer YOUR_TRYDIRECT_TOKEN" } + } + } +} +``` + +### Cline / Roo (settings → MCP servers) +Add a server named `trydirect`, transport WebSocket, URL `wss://mcp.try.direct/mcp`, +header `Authorization: Bearer YOUR_TRYDIRECT_TOKEN`. + +### Anonymous (resolve_image only, no token) +Omit the `headers` block — the client still discovers and can call +`resolve_image`; authed tools return a permission error until you add a token. + +## What your agent should do with them + +- Before writing a compose/Dockerfile, call **`resolve_image`** on each image to + confirm it exists and is multi-arch — don't guess. +- After generating a stack, call **`deploy_ephemeral`** to actually run it and + read back the live URL / logs, then self-correct from real errors instead of + hoping it works. + +## Self-hosting + +Prefer to run it yourself? The gateway ships as `trydirect/agent-gateway` (see +`AGENT_GATEWAY_SETUP.md`); point your `mcp.json` `url` at your own instance. diff --git a/docs/DOCKER_MCP_CATALOG.md b/docs/DOCKER_MCP_CATALOG.md new file mode 100644 index 00000000..d2e4cd95 --- /dev/null +++ b/docs/DOCKER_MCP_CATALOG.md @@ -0,0 +1,66 @@ +# Docker MCP Catalog — submission draft + +The Docker MCP Catalog + MCP Toolkit is the highest-fit distribution channel: +it's Docker-native and reaches exactly the agent/devops audience, and our tools +are Docker-shaped. We already build and push `trydirect/agent-gateway` in CI +(`.github/workflows/agent-gateway.yml`), which is the image the catalog lists. + +Below is the catalog entry metadata to submit (adapt field names to the current +catalog schema / PR template at github.com/docker/mcp-registry). + +## Server entry + +```yaml +name: trydirect +title: TryDirect — deploy & image ground truth +description: > + Give your agent hands and eyes for real infrastructure. resolve_image returns + ground truth about any Docker image (exists, digest, size, architectures, + tags, official/pinned, grade) so the agent stops hallucinating image refs; + deploy_ephemeral runs a docker-compose on a throwaway cloud box and returns a + live URL + logs, auto-torn-down after a TTL. +category: devops +vendor: TryDirect +homepage: https://try.direct +source: https://github.com/trydirect/stacker +image: trydirect/agent-gateway:latest +transport: websocket +endpoint: /mcp +auth: + type: bearer # optional: resolve_image works anonymously + obtain: https://try.direct/account/tokens +tools: + - name: resolve_image + description: > + Ground truth about a Docker image reference (exists, digest, size, + architectures, recent tags, official/pinned, quality grade). Public — no + auth required. + - name: deploy_ephemeral + description: > + Run a docker-compose.yml on a throwaway cloud sandbox and get back a live + URL, logs and health; auto-teardown after ttl_secs. Safety-gated (no + privileged / docker.sock / host networking) and quota-limited. +``` + +## Also submit to + +- **Official MCP registry** (github.com/modelcontextprotocol/registry) — same + metadata, their manifest schema. +- **Community catalogs**: Smithery, mcp.so, PulseMCP, Glama — mostly ingest from + the official registry or a `server.json`, so keep one canonical manifest. +- **Client marketplaces**: Cursor / Cline / Windsurf MCP directories, and the + Claude connectors directory — list `wss://mcp.try.direct/mcp` with the config + snippet from `CONNECT_YOUR_AGENT.md`. + +## Positioning line (for every listing) + +> Cursor/Claude/Copilot can generate a Dockerfile all day; none of them can prove +> the image exists, run the stack, and hand back a live URL. TryDirect can. + +## Submission checklist + +- [ ] `trydirect/agent-gateway:latest` published (CI does this on push to main). +- [ ] Public demo: `curl -X POST https://mcp.try.direct/public/resolve_image -d '{"reference":"nginx:latest"}'`. +- [ ] Token onboarding page live at /account/tokens. +- [ ] `CONNECT_YOUR_AGENT.md` published on the site (e.g. /tools/mcp). +- [ ] Canonical `server.json` manifest committed for registry ingestion. From ae572b68b1e5ba62205178323fcadda7ff765958 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 29 Jul 2026 13:01:46 +0200 Subject: [PATCH 10/19] docs(agent-gateway): canonical server.json manifest for MCP registry ingestion Machine-readable server manifest (MCP registry schema) describing the gateway: name, description, repository, the hosted WebSocket remote (wss://mcp.try.direct/mcp, optional bearer), the OCI package (trydirect/agent-gateway) with sandbox env vars, and the two tools (resolve_image anonymous, deploy_ephemeral bearer). Registries/ catalogs ingest this; keep it the single source of truth. Field names may need tweaks to the current registry schema version. Co-Authored-By: Claude Opus 4.8 --- server.json | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 server.json diff --git a/server.json b/server.json new file mode 100644 index 00000000..f4681143 --- /dev/null +++ b/server.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json", + "name": "io.trydirect/agent-gateway", + "description": "Ground truth and hands for AI agents building infrastructure: resolve_image returns real Docker image facts (exists, digest, size, architectures, tags, official/pinned, grade) so agents stop hallucinating image refs; deploy_ephemeral runs a docker-compose on a throwaway cloud box and returns a live URL + logs, auto-torn-down after a TTL.", + "version": "0.1.0", + "repository": { + "url": "https://github.com/trydirect/stacker", + "source": "github" + }, + "websiteUrl": "https://try.direct", + "remotes": [ + { + "type": "websocket", + "url": "wss://mcp.try.direct/mcp", + "headers": [ + { + "name": "Authorization", + "description": "Bearer — required for deploy_ephemeral; resolve_image works anonymously. Get a token at https://try.direct/account/tokens.", + "isRequired": false, + "isSecret": true + } + ] + } + ], + "packages": [ + { + "registryType": "oci", + "identifier": "trydirect/agent-gateway", + "version": "latest", + "transport": { "type": "websocket", "url": "ws://localhost:4600/mcp" }, + "environmentVariables": [ + { "name": "AGENT_GATEWAY_BIND", "description": "Bind address", "isRequired": false, "default": "0.0.0.0:4600" }, + { "name": "SANDBOX_DEFAULT_TTL_SECS", "description": "Default sandbox lifetime", "isRequired": false, "default": "1800" }, + { "name": "SANDBOX_MAX_TTL_SECS", "description": "Max sandbox lifetime", "isRequired": false, "default": "7200" }, + { "name": "SANDBOX_MAX_CONCURRENT_PER_USER", "description": "Per-user concurrent sandbox quota", "isRequired": false, "default": "3" } + ] + } + ], + "_meta": { + "category": "devops", + "vendor": "TryDirect", + "tools": [ + { + "name": "resolve_image", + "auth": "anonymous", + "description": "Ground truth about a Docker image reference (exists, digest, size, architectures, recent tags, official/pinned, quality grade). Also available unauthenticated at POST /public/resolve_image." + }, + { + "name": "deploy_ephemeral", + "auth": "bearer", + "description": "Run a docker-compose.yml on a throwaway cloud sandbox and get back a live URL, logs and health; auto-teardown after ttl_secs. Safety-gated (no privileged / docker.sock / host networking) and per-user quota-limited." + } + ] + } +} From 26133745160c7e8e2967046c1cd8995ef0858960 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Thu, 30 Jul 2026 14:47:20 +0200 Subject: [PATCH 11/19] ci+compose(agent-gateway): build image on this branch + test compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agent-gateway.yml: also build & push the image on push to feature/agent-gateway (and via workflow_dispatch); sanitize the ref for a valid Docker tag (feature/agent-gateway -> feature-agent-gateway). - Dockerfile.agent-gateway: bake ./migrations so `sqlx migrate run` works from the image (needed for casbin policy + the public resolve_image grant). - docker-compose.agent-gateway.yml: self-contained test stack — Postgres named stackerdb (matches baked config), one-shot migrate, gateway on :4600, optional redis. Drop-in for a dev environment. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/agent-gateway.yml | 12 ++++-- Dockerfile.agent-gateway | 3 ++ docker-compose.agent-gateway.yml | 67 +++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 docker-compose.agent-gateway.yml diff --git a/.github/workflows/agent-gateway.yml b/.github/workflows/agent-gateway.yml index 69dde858..e3ec52bf 100644 --- a/.github/workflows/agent-gateway.yml +++ b/.github/workflows/agent-gateway.yml @@ -1,8 +1,9 @@ name: Agent Gateway on: + workflow_dispatch: {} push: - branches: [dev, main] + branches: [dev, main, feature/agent-gateway] paths: - "crates/agent-tools/**" - "crates/td-audit/**" @@ -45,7 +46,7 @@ jobs: - name: Build the agent-gateway binary (release, offline) run: cargo build --release --bin agent-gateway --verbose - name: Upload binary for the Docker job - if: github.event_name == 'push' + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@v4 with: name: agent-gateway-bin @@ -56,7 +57,7 @@ jobs: docker: name: Build & push image needs: build - if: github.event_name == 'push' + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -74,7 +75,10 @@ jobs: password: ${{ secrets.DOCKER_PASSWORD }} - name: Set image tag id: tags - run: echo "tags=trydirect/agent-gateway:${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + # Sanitize the ref for a valid Docker tag (feature/x -> feature-x). + run: | + TAG="$(echo "${{ github.ref_name }}" | tr '/' '-')" + echo "tags=trydirect/agent-gateway:${TAG}" >> "$GITHUB_OUTPUT" - name: Build and push uses: docker/build-push-action@v6 with: diff --git a/Dockerfile.agent-gateway b/Dockerfile.agent-gateway index 1f392751..e6021aaa 100644 --- a/Dockerfile.agent-gateway +++ b/Dockerfile.agent-gateway @@ -20,6 +20,9 @@ COPY agent-gateway /app/agent-gateway COPY docker/local/configuration.yaml /app/configuration.yaml COPY docker/local/.env /app/.env COPY access_control.conf.dist /app/access_control.conf +# Baked migrations so `sqlx migrate run` works from the image (casbin policy + +# the public resolve_image grant must exist for the gateway to boot/serve). +COPY migrations /app/migrations RUN chmod +x /app/agent-gateway ENV AGENT_GATEWAY_BIND=0.0.0.0:4600 diff --git a/docker-compose.agent-gateway.yml b/docker-compose.agent-gateway.yml new file mode 100644 index 00000000..1cd4f0ed --- /dev/null +++ b/docker-compose.agent-gateway.yml @@ -0,0 +1,67 @@ +# Self-contained test stack for the agent-gateway MCP server. +# +# docker compose -f docker-compose.agent-gateway.yml up +# +# Brings up Postgres (named `stackerdb` to match the image's baked +# configuration.yaml), applies migrations, then the gateway on :4600. +# +# Verify once up: +# curl -s http://localhost:4600/health +# curl -s -X POST http://localhost:4600/public/resolve_image \ +# -H 'Content-Type: application/json' -d '{"reference":"redis:7-alpine"}' +# +# Pin AGENT_GATEWAY_TAG to the branch image (default: feature-agent-gateway, +# what this branch's CI publishes). deploy_ephemeral reports "not enabled" until +# M3 wires provisioning (RabbitMQ/Vault/install-service). + +services: + stackerdb: + image: postgres:16 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: stacker + volumes: + - stackerdb-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d stacker"] + interval: 5s + timeout: 5s + retries: 10 + + migrate: + image: trydirect/agent-gateway:${AGENT_GATEWAY_TAG:-feature-agent-gateway} + depends_on: + stackerdb: + condition: service_healthy + working_dir: /app + environment: + DATABASE_URL: postgres://postgres:postgres@stackerdb:5432/stacker + entrypoint: ["/bin/sh", "-c"] + command: ["sqlx migrate run"] + restart: "no" + + agent-gateway: + image: trydirect/agent-gateway:${AGENT_GATEWAY_TAG:-feature-agent-gateway} + depends_on: + stackerdb: + condition: service_healthy + migrate: + condition: service_completed_successfully + environment: + AGENT_GATEWAY_BIND: 0.0.0.0:4600 + REDIS_URL: redis://redis:6379/0 + SANDBOX_DEFAULT_TTL_SECS: "1800" + SANDBOX_MAX_TTL_SECS: "7200" + SANDBOX_MAX_CONCURRENT_PER_USER: "3" + ports: + - "4600:4600" + restart: unless-stopped + + # Optional: Docker Hub response cache (resolve_image works without it). + redis: + image: redis:7-alpine + restart: unless-stopped + +volumes: + stackerdb-data: From ca647ee73ece3f78bcc220130f6e0d496ecf0ab7 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Thu, 30 Jul 2026 16:33:33 +0200 Subject: [PATCH 12/19] compose(agent-gateway): .env example + parametrize tag/port/quotas Add .env.agent-gateway.example documenting the vars the gateway actually uses (image tag, host port, sandbox quotas) with the working endpoints. Parametrize the compose to read them with defaults; drop the unused redis service/REDIS_URL (resolve_image uses a plain HTTP client, no cache) so the example isn't misleading. Co-Authored-By: Claude Opus 4.8 --- .env.agent-gateway.example | 29 +++++++++++++++++++++++++++++ docker-compose.agent-gateway.yml | 15 +++++---------- 2 files changed, 34 insertions(+), 10 deletions(-) create mode 100644 .env.agent-gateway.example diff --git a/.env.agent-gateway.example b/.env.agent-gateway.example new file mode 100644 index 00000000..b1884168 --- /dev/null +++ b/.env.agent-gateway.example @@ -0,0 +1,29 @@ +# Example env for docker-compose.agent-gateway.yml +# cp .env.agent-gateway.example .env +# docker compose -f docker-compose.agent-gateway.yml up +# +# Docker Compose auto-loads a file named `.env` next to the compose file for +# ${VAR} substitution. Every value below has a sane default baked into the +# compose, so an empty/absent .env still works — override only what you need. + +# --- Which image to run ------------------------------------------------------ +# The tag this branch's CI publishes. Use a different tag once merged to dev/main. +AGENT_GATEWAY_TAG=feature-agent-gateway + +# Host port to expose the gateway on (container always listens on 4600). +HOST_PORT=4600 + +# --- Sandbox quotas (deploy_ephemeral only) --------------------------------- +# These gate deploy_ephemeral's safety/quota checks. Provisioning itself is M3 +# (needs RabbitMQ/Vault/install-service), so these don't spend money yet. +SANDBOX_DEFAULT_TTL_SECS=1800 # 30m +SANDBOX_MAX_TTL_SECS=7200 # 2h +SANDBOX_MAX_CONCURRENT_PER_USER=3 + +# --- What actually works in this test image --------------------------------- +# GET /health +# POST /public/resolve_image {"reference":"redis:7-alpine"} (no auth, real data) +# WS /mcp (needs a user JWT; resolve_image is anonymous there too) +# +# Postgres creds are fixed to match the image's baked configuration.yaml +# (host=stackerdb, db=stacker, postgres/postgres) — don't change them here. diff --git a/docker-compose.agent-gateway.yml b/docker-compose.agent-gateway.yml index 1cd4f0ed..29582ab7 100644 --- a/docker-compose.agent-gateway.yml +++ b/docker-compose.agent-gateway.yml @@ -50,17 +50,12 @@ services: condition: service_completed_successfully environment: AGENT_GATEWAY_BIND: 0.0.0.0:4600 - REDIS_URL: redis://redis:6379/0 - SANDBOX_DEFAULT_TTL_SECS: "1800" - SANDBOX_MAX_TTL_SECS: "7200" - SANDBOX_MAX_CONCURRENT_PER_USER: "3" + # Sandbox quotas (only affect deploy_ephemeral; safe defaults). + SANDBOX_DEFAULT_TTL_SECS: "${SANDBOX_DEFAULT_TTL_SECS:-1800}" + SANDBOX_MAX_TTL_SECS: "${SANDBOX_MAX_TTL_SECS:-7200}" + SANDBOX_MAX_CONCURRENT_PER_USER: "${SANDBOX_MAX_CONCURRENT_PER_USER:-3}" ports: - - "4600:4600" - restart: unless-stopped - - # Optional: Docker Hub response cache (resolve_image works without it). - redis: - image: redis:7-alpine + - "${HOST_PORT:-4600}:4600" restart: unless-stopped volumes: From d78ec56f09d2417874f77926462cb58c23a0295e Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Thu, 30 Jul 2026 16:38:07 +0200 Subject: [PATCH 13/19] compose(agent-gateway): join external trydirect networks, reuse stackerdb/stackeredis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the test compose to attach to the external `trydirect_default` / `trydirect-network` networks and talk to the already-running `stackerdb` (Postgres) and `stackeredis` — no throwaway DB. The image's baked configuration.yaml already points host=stackerdb, so DNS resolves to the real DB on the shared network (same wiring as stacker). The one-shot migrate applies just this branch's new casbin grant (idempotent). DATABASE_URL overridable; optional configuration.yaml mount documented for non-default creds. Co-Authored-By: Claude Opus 4.8 --- .env.agent-gateway.example | 8 +++++ docker-compose.agent-gateway.yml | 60 ++++++++++++++++---------------- 2 files changed, 38 insertions(+), 30 deletions(-) diff --git a/.env.agent-gateway.example b/.env.agent-gateway.example index b1884168..d8f017f2 100644 --- a/.env.agent-gateway.example +++ b/.env.agent-gateway.example @@ -13,6 +13,14 @@ AGENT_GATEWAY_TAG=feature-agent-gateway # Host port to expose the gateway on (container always listens on 4600). HOST_PORT=4600 +# --- Reusing the running TryDirect stack ------------------------------------ +# The compose joins the external trydirect networks and talks to the existing +# `stackerdb`/`stackeredis`. The gateway itself reads DB creds from the baked +# configuration.yaml (host=stackerdb, db=stacker, postgres/postgres). Only the +# one-shot `migrate` uses DATABASE_URL — override it if your stackerdb differs +# (and mount a matching configuration.yaml for the gateway; see the compose). +DATABASE_URL=postgres://postgres:postgres@stackerdb:5432/stacker + # --- Sandbox quotas (deploy_ephemeral only) --------------------------------- # These gate deploy_ephemeral's safety/quota checks. Provisioning itself is M3 # (needs RabbitMQ/Vault/install-service), so these don't spend money yet. diff --git a/docker-compose.agent-gateway.yml b/docker-compose.agent-gateway.yml index 29582ab7..276ba2f8 100644 --- a/docker-compose.agent-gateway.yml +++ b/docker-compose.agent-gateway.yml @@ -1,51 +1,40 @@ -# Self-contained test stack for the agent-gateway MCP server. +# Test stack for the agent-gateway MCP server, wired to reuse a RUNNING +# TryDirect stack: it joins the external `trydirect` networks and talks to the +# existing `stackerdb` (Postgres) and `stackeredis` (Redis) — no local DB. # -# docker compose -f docker-compose.agent-gateway.yml up +# The image's baked configuration.yaml already uses `host: stackerdb`, so on the +# shared network DNS resolves straight to the real DB (same as `stacker`). # -# Brings up Postgres (named `stackerdb` to match the image's baked -# configuration.yaml), applies migrations, then the gateway on :4600. +# cp .env.agent-gateway.example .env +# docker compose -f docker-compose.agent-gateway.yml up # -# Verify once up: +# Verify: # curl -s http://localhost:4600/health # curl -s -X POST http://localhost:4600/public/resolve_image \ # -H 'Content-Type: application/json' -d '{"reference":"redis:7-alpine"}' # -# Pin AGENT_GATEWAY_TAG to the branch image (default: feature-agent-gateway, -# what this branch's CI publishes). deploy_ephemeral reports "not enabled" until -# M3 wires provisioning (RabbitMQ/Vault/install-service). +# Prereq: the stacker stack must be up so `stackerdb`/`stackeredis` and the +# external networks exist. If your stackerdb uses non-default creds, set +# DATABASE_URL in .env and mount a matching configuration.yaml (see below). services: - stackerdb: - image: postgres:16 - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: stacker - volumes: - - stackerdb-data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres -d stacker"] - interval: 5s - timeout: 5s - retries: 10 - + # Applies just this branch's new migration (the public resolve_image casbin + # grant) to the shared DB; already-applied migrations are skipped (idempotent). migrate: image: trydirect/agent-gateway:${AGENT_GATEWAY_TAG:-feature-agent-gateway} - depends_on: - stackerdb: - condition: service_healthy working_dir: /app environment: - DATABASE_URL: postgres://postgres:postgres@stackerdb:5432/stacker + DATABASE_URL: ${DATABASE_URL:-postgres://postgres:postgres@stackerdb:5432/stacker} entrypoint: ["/bin/sh", "-c"] command: ["sqlx migrate run"] restart: "no" + networks: + - trydirect_default + - trydirect-network agent-gateway: image: trydirect/agent-gateway:${AGENT_GATEWAY_TAG:-feature-agent-gateway} depends_on: - stackerdb: - condition: service_healthy migrate: condition: service_completed_successfully environment: @@ -54,9 +43,20 @@ services: SANDBOX_DEFAULT_TTL_SECS: "${SANDBOX_DEFAULT_TTL_SECS:-1800}" SANDBOX_MAX_TTL_SECS: "${SANDBOX_MAX_TTL_SECS:-7200}" SANDBOX_MAX_CONCURRENT_PER_USER: "${SANDBOX_MAX_CONCURRENT_PER_USER:-3}" + # If stackerdb uses non-default creds/db, mount a matching config instead of + # relying on the baked one: + # volumes: + # - ./configuration.yaml:/app/configuration.yaml:ro ports: - "${HOST_PORT:-4600}:4600" restart: unless-stopped + networks: + - trydirect_default + - trydirect-network -volumes: - stackerdb-data: +networks: + trydirect_default: + external: true + trydirect-network: + external: true + name: trydirect-network From 0e86d2c5e067161dbdeb9b06e890fbd1d6946f4e Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Thu, 30 Jul 2026 16:47:22 +0200 Subject: [PATCH 14/19] ci(agent-gateway): pin build runner to ubuntu-22.04 (glibc fix) The prebuilt binary was compiled on ubuntu-latest (24.04, glibc 2.39) and baked into debian:bookworm-slim (glibc 2.36), so it failed at runtime with "GLIBC_2.38 not found". Pin the build job to ubuntu-22.04 (glibc 2.35 <= 2.36) so the binary runs on the runtime base. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/agent-gateway.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/agent-gateway.yml b/.github/workflows/agent-gateway.yml index e3ec52bf..8978c8f0 100644 --- a/.github/workflows/agent-gateway.yml +++ b/.github/workflows/agent-gateway.yml @@ -30,7 +30,10 @@ jobs: # image just bakes it (no Rust compile inside Docker). build: name: Test crate + build release binary - runs-on: ubuntu-latest + # Pinned to 22.04 (glibc 2.35) so the binary runs on the debian:bookworm-slim + # runtime (glibc 2.36). ubuntu-latest (24.04, glibc 2.39) would require a + # newer glibc than the runtime has -> "GLIBC_2.38 not found". + runs-on: ubuntu-22.04 env: SQLX_OFFLINE: true steps: From 1473ff86895b2ae1c4688b05704c49424eb465e9 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Thu, 30 Jul 2026 16:50:01 +0200 Subject: [PATCH 15/19] ci(agent-gateway): namespace rust-cache to fix cross-runner corruption Pinning the builder to ubuntu-22.04 restored the ubuntu-latest (24.04) rust-cache into a 22.04 job (same "Linux" key), yielding incompatible artifacts and "error[E0463]: can't find crate for sqlx". Give the cache a runner-specific key so 22.04 builds against its own clean cache. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/agent-gateway.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/agent-gateway.yml b/.github/workflows/agent-gateway.yml index 8978c8f0..2baabce9 100644 --- a/.github/workflows/agent-gateway.yml +++ b/.github/workflows/agent-gateway.yml @@ -44,6 +44,11 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Cache cargo uses: Swatinem/rust-cache@v2 + with: + # Namespace the cache to the runner OS so switching runners doesn't + # restore incompatible artifacts (e.g. sqlx rmeta from a 24.04 build + # into a 22.04 job -> "can't find crate for sqlx"). + key: ubuntu-22.04 - name: Unit-test the pure agent-tools crate (no DB/cloud) run: cargo test -p agent-tools --verbose - name: Build the agent-gateway binary (release, offline) From a9ec557d496ed767a145a55219f9a00af0ae470b Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Thu, 30 Jul 2026 16:59:44 +0200 Subject: [PATCH 16/19] ci(agent-gateway): compile in rust:bookworm (bulletproof glibc fix) The bake-prebuilt approach coupled the binary's glibc to the CI runner: ubuntu-latest (24.04, glibc 2.39) failed on the debian runtime (2.36), and pinning to ubuntu-22.04 broke the build (sqlx link failure specific to that runner). Switch back to a multi-stage Docker build that compiles inside rust:bookworm and runs on debian:bookworm-slim, so glibc always matches and the image doesn't depend on the runner env at all. The workflow's fast test job stays on ubuntu-latest; the docker job builds the image directly (gha-cached). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/agent-gateway.yml | 41 +++++++----------------- Dockerfile.agent-gateway | 49 ++++++++++++++++++++++------- 2 files changed, 49 insertions(+), 41 deletions(-) diff --git a/.github/workflows/agent-gateway.yml b/.github/workflows/agent-gateway.yml index 2baabce9..9065a807 100644 --- a/.github/workflows/agent-gateway.yml +++ b/.github/workflows/agent-gateway.yml @@ -26,14 +26,10 @@ on: - ".github/workflows/agent-gateway.yml" jobs: - # Fast, DB-less checks + build the release binary once (cached), so the Docker - # image just bakes it (no Rust compile inside Docker). - build: - name: Test crate + build release binary - # Pinned to 22.04 (glibc 2.35) so the binary runs on the debian:bookworm-slim - # runtime (glibc 2.36). ubuntu-latest (24.04, glibc 2.39) would require a - # newer glibc than the runtime has -> "GLIBC_2.38 not found". - runs-on: ubuntu-22.04 + # Fast, DB-less feedback: the pure crate + that the standalone binary compiles. + test: + name: Test crate + build binary + runs-on: ubuntu-latest env: SQLX_OFFLINE: true steps: @@ -44,36 +40,21 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Cache cargo uses: Swatinem/rust-cache@v2 - with: - # Namespace the cache to the runner OS so switching runners doesn't - # restore incompatible artifacts (e.g. sqlx rmeta from a 24.04 build - # into a 22.04 job -> "can't find crate for sqlx"). - key: ubuntu-22.04 - name: Unit-test the pure agent-tools crate (no DB/cloud) run: cargo test -p agent-tools --verbose - - name: Build the agent-gateway binary (release, offline) - run: cargo build --release --bin agent-gateway --verbose - - name: Upload binary for the Docker job - if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' - uses: actions/upload-artifact@v4 - with: - name: agent-gateway-bin - path: target/release/agent-gateway - retention-days: 1 + - name: Build the agent-gateway binary (offline) + run: cargo build --bin agent-gateway --verbose - # Bake the prebuilt binary into a slim runtime image and push (push only). + # Build + push the image on push to dev/main/feature (not PRs). The Docker + # build compiles inside rust:bookworm, so glibc matches the runtime — no + # coupling to the runner's glibc. docker: name: Build & push image - needs: build + needs: test if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Download prebuilt binary - uses: actions/download-artifact@v4 - with: - name: agent-gateway-bin - path: . - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to Docker Hub @@ -94,3 +75,5 @@ jobs: file: ./Dockerfile.agent-gateway push: true tags: ${{ steps.tags.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile.agent-gateway b/Dockerfile.agent-gateway index e6021aaa..053a371d 100644 --- a/Dockerfile.agent-gateway +++ b/Dockerfile.agent-gateway @@ -1,11 +1,38 @@ -# Runtime-only image for the agent-gateway MCP server. +# syntax=docker/dockerfile:1.4 +# Standalone image for the agent-gateway MCP server. # -# CI (.github/workflows/agent-gateway.yml) builds the release binary once on the -# runner (cached) and downloads it into the build context, so this image just -# bakes it — no Rust compile inside Docker. The binary is glibc (built on -# ubuntu-latest) and this base is glibc (debian:bookworm-slim), so they match. -# -# Expects `./agent-gateway` (the prebuilt binary) present in the build context. +# Multi-stage: compile INSIDE rust:bookworm and run on debian:bookworm-slim, so +# the binary's glibc always matches the runtime (no coupling to the CI runner's +# glibc — which caused "GLIBC_2.38 not found" when baking an ubuntu-built binary). +FROM rust:bookworm AS builder + +RUN apt-get update && apt-get install --no-install-recommends -y \ + protobuf-compiler libprotobuf-dev libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +RUN cargo install sqlx-cli --no-default-features --features native-tls,postgres + +WORKDIR /app + +# Manifests + offline sqlx cache (compile-time checked queries without a DB). +COPY ./Cargo.toml . +COPY ./Cargo.lock . +COPY ./build.rs . +COPY ./rustfmt.toml . +COPY ./docker/local/.env . +COPY ./docker/local/configuration.yaml . +COPY .sqlx .sqlx/ +COPY ./proto ./proto +COPY ./tests/bdd.rs ./tests/bdd.rs + +COPY ./src ./src +COPY ./crates ./crates +COPY ./scenarios ./scenarios + +ENV SQLX_OFFLINE=true +RUN cargo build --release --bin agent-gateway + +# ---- runtime ---- FROM debian:bookworm-slim AS production RUN apt-get update && apt-get install --no-install-recommends -y \ @@ -15,15 +42,13 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ WORKDIR /app RUN mkdir ./files && chmod 0777 ./files -# Prebuilt binary + runtime config (from the checked-out repo). -COPY agent-gateway /app/agent-gateway +COPY --from=builder /app/target/release/agent-gateway /app/agent-gateway +COPY --from=builder /usr/local/cargo/bin/sqlx /usr/local/bin/sqlx COPY docker/local/configuration.yaml /app/configuration.yaml COPY docker/local/.env /app/.env COPY access_control.conf.dist /app/access_control.conf -# Baked migrations so `sqlx migrate run` works from the image (casbin policy + -# the public resolve_image grant must exist for the gateway to boot/serve). +# Baked migrations so `sqlx migrate run` works from the image. COPY migrations /app/migrations -RUN chmod +x /app/agent-gateway ENV AGENT_GATEWAY_BIND=0.0.0.0:4600 EXPOSE 4600 From 8cc2eed2174237c068b4f486c5e52c5b3355a63c Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Thu, 30 Jul 2026 21:24:00 +0200 Subject: [PATCH 17/19] fix(agent-gateway): self-heal anonymous casbin grants at startup The authorization wrap gates every route, so /health and /public/resolve_image returned a bare 403 when the casbin grant wasn't in the DB (custom composes that skip the migrate service). run_agent_gateway now inserts the two group_anonymous grants (idempotent) before the enforcer loads policy, so the gateway is reachable regardless of migrations. Also add /health to the migration for parity. Co-Authored-By: Claude Opus 4.8 --- ...9120000_casbin_public_resolve_image.down.sql | 3 ++- ...729120000_casbin_public_resolve_image.up.sql | 3 ++- src/startup.rs | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/migrations/20260729120000_casbin_public_resolve_image.down.sql b/migrations/20260729120000_casbin_public_resolve_image.down.sql index 4c19d314..10c1d393 100644 --- a/migrations/20260729120000_casbin_public_resolve_image.down.sql +++ b/migrations/20260729120000_casbin_public_resolve_image.down.sql @@ -1,2 +1,3 @@ DELETE FROM public.casbin_rule -WHERE ptype = 'p' AND v0 = 'group_anonymous' AND v1 = '/public/resolve_image' AND v2 = 'POST'; +WHERE ptype = 'p' AND v0 = 'group_anonymous' + AND v1 IN ('/health', '/public/resolve_image'); diff --git a/migrations/20260729120000_casbin_public_resolve_image.up.sql b/migrations/20260729120000_casbin_public_resolve_image.up.sql index c58ee83b..c9f1d9d8 100644 --- a/migrations/20260729120000_casbin_public_resolve_image.up.sql +++ b/migrations/20260729120000_casbin_public_resolve_image.up.sql @@ -1,4 +1,5 @@ --- Public (anonymous) access to the gateway's read-only ground-truth endpoint. +-- Anonymous access to the gateway's public routes (health + ground-truth). INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES + ('p', 'group_anonymous', '/health', 'GET', '', '', ''), ('p', 'group_anonymous', '/public/resolve_image', 'POST', '', '', '') ON CONFLICT DO NOTHING; diff --git a/src/startup.rs b/src/startup.rs index d16a4eaf..58874a96 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -531,6 +531,23 @@ pub async fn run_agent_gateway( ) -> Result { crate::metrics::init(); + // Self-heal: ensure the gateway's public routes are reachable anonymously, + // independent of whether the casbin migration was applied to this DB. The + // authorization wrap gates every route, so /health and /public/resolve_image + // need an explicit group_anonymous grant or they return a bare 403. Runs on + // the raw pool before the enforcer loads policy in `authorization::try_new`. + if let Err(e) = sqlx::query( + "INSERT INTO casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES \ + ('p','group_anonymous','/health','GET','','',''), \ + ('p','group_anonymous','/public/resolve_image','POST','','','') \ + ON CONFLICT DO NOTHING", + ) + .execute(&api_pool) + .await + { + tracing::warn!("agent-gateway: could not ensure anonymous grants: {e}"); + } + let settings = web::Data::new(settings); let api_pool = web::Data::new(api_pool); let agent_pool = web::Data::new(agent_pool); From 4bc40cbc94751bc43ef6a0f6f15633fd0c38acbd Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Thu, 30 Jul 2026 21:34:55 +0200 Subject: [PATCH 18/19] fix(agent-gateway): make /public/resolve_image lenient on input curl -d defaults to form content-type, which the Json extractor rejected with "Content type error" (400). Parse the JSON body content-type-agnostically via web::Bytes, and accept a ?reference= query param as a fallback, so agents (and plain curl) aren't tripped by headers. Co-Authored-By: Claude Opus 4.8 --- src/routes/agent_public.rs | 44 +++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/src/routes/agent_public.rs b/src/routes/agent_public.rs index 99c11b8c..4ebe4a42 100644 --- a/src/routes/agent_public.rs +++ b/src/routes/agent_public.rs @@ -15,17 +15,41 @@ pub struct ResolveImageBody { pub reference: String, } -/// `POST /public/resolve_image` — `{ "reference": "redis:7-alpine" }` → the -/// `ResolvedImage` ground truth (exists, digest, size, architectures, tags, -/// grade). No authentication required. +#[derive(Debug, Deserialize)] +pub struct ResolveImageQuery { + pub reference: Option, +} + +/// `POST /public/resolve_image` → the `ResolvedImage` ground truth (exists, +/// digest, size, architectures, tags, grade). No authentication required. +/// +/// Lenient on input so agents don't trip on content-type: accepts a JSON body +/// `{"reference": "redis:7-alpine"}` regardless of the Content-Type header, or a +/// `?reference=redis:7-alpine` query parameter. #[post("/public/resolve_image")] -pub async fn resolve_image_public(body: web::Json) -> impl Responder { - let reference = body.reference.trim(); - if reference.is_empty() { - return HttpResponse::BadRequest() - .json(serde_json::json!({ "error": "`reference` is required" })); - } - match resolve_reference(reference).await { +pub async fn resolve_image_public( + body: web::Bytes, + query: web::Query, +) -> impl Responder { + // Prefer the JSON body (parsed content-type-agnostically); fall back to the + // query param. + let reference = serde_json::from_slice::(&body) + .map(|b| b.reference) + .ok() + .or_else(|| query.reference.clone()) + .map(|r| r.trim().to_string()) + .filter(|r| !r.is_empty()); + + let reference = match reference { + Some(r) => r, + None => { + return HttpResponse::BadRequest().json(serde_json::json!({ + "error": "provide an image reference as JSON body {\"reference\":\"…\"} or ?reference=…" + })); + } + }; + + match resolve_reference(&reference).await { Ok(resolved) => HttpResponse::Ok().json(resolved), Err(err) => HttpResponse::BadGateway().json(serde_json::json!({ "error": err })), } From 10581e889cb43a4f62998f77473e98c1fda06a4c Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Thu, 30 Jul 2026 21:37:10 +0200 Subject: [PATCH 19/19] ci(agent-gateway): trigger on routes/agent_public.rs + migrations changes The lenient /public/resolve_image change lives in src/routes/agent_public.rs, which wasn't in the path filter, so it didn't rebuild the image. Add it and migrations/** (baked into the image) to both push and PR triggers. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/agent-gateway.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/agent-gateway.yml b/.github/workflows/agent-gateway.yml index 9065a807..97badd93 100644 --- a/.github/workflows/agent-gateway.yml +++ b/.github/workflows/agent-gateway.yml @@ -8,6 +8,8 @@ on: - "crates/agent-tools/**" - "crates/td-audit/**" - "src/mcp/**" + - "src/routes/agent_public.rs" + - "migrations/**" - "src/bin/agent_gateway.rs" - "src/startup.rs" - "Cargo.toml" @@ -20,6 +22,8 @@ on: - "crates/agent-tools/**" - "crates/td-audit/**" - "src/mcp/**" + - "src/routes/agent_public.rs" + - "migrations/**" - "src/bin/agent_gateway.rs" - "src/startup.rs" - "Dockerfile.agent-gateway"