Fleet P2b: node sandbox-readiness check (hardened workload on a node)
ci / gates (push) Failing after 5s
ci / rust (push) Has been skipped
ci / sandbox-k8s (push) Has been skipped
ci / frontend (push) Has been skipped
ci / e2e (push) Has been skipped

Proves a connected node can host hardened agent workloads end-to-end, without
touching the agent run loop (zero blast radius on existing agents).

- Daemon: typed `sb_check` op — pulls a tiny image and runs it fully locked down
  (cap-drop ALL, no-new-privileges, no network, read-only rootfs, non-root,
  memory/pids caps), then tears it down. Fixed command; nothing caller-supplied
  runs (preserves the exec-hardening invariant).
- cm-api: NodeHub.sandbox_check + POST /api/nodes/{id}/sandbox-check.
- UI: a shield "sandbox check" button on each online node card streams the
  result (✓ SANDBOX READY + container id/uname).

This validates the full provision→run→destroy mechanism on nodes. The remaining
P2 work — wiring real agent deploys to auto-place onto nodes — is its own
subsystem (a RemoteDriver reusing the local DockerDriver for security parity,
agent-image distribution to nodes, and node-routing in SandboxManager) and is
best done as a focused pass; it is intentionally NOT bundled here to keep the
core agent path untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-24 12:31:50 -07:00
co-authored by Claude Opus 4.8
parent f5f96508eb
commit 33aa9c0693
5 changed files with 87 additions and 2 deletions
+36
View File
@@ -202,6 +202,12 @@ async fn handle_frame(text: &str, out: &mpsc::UnboundedSender<String>, ptys: &Pt
let _ = out.send(json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string()); let _ = out.send(json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string());
} }
} }
"sb_check" => {
if let Some(id) = v.get("id").and_then(Value::as_u64) {
let (ok, output) = sandbox_check().await;
let _ = out.send(json!({ "t": "result", "id": id, "ok": ok, "output": output }).to_string());
}
}
"pty_open" => { "pty_open" => {
let sid = v.get("sid").and_then(Value::as_u64).unwrap_or(0); let sid = v.get("sid").and_then(Value::as_u64).unwrap_or(0);
let cols = v.get("cols").and_then(Value::as_u64).unwrap_or(80) as u16; let cols = v.get("cols").and_then(Value::as_u64).unwrap_or(80) as u16;
@@ -295,6 +301,36 @@ async fn verify() -> (bool, String) {
} }
} }
/// Readiness check: provision a fully locked-down throwaway container (the same
/// hardening agent sandboxes use — cap-drop ALL, no-new-privileges, no network,
/// read-only rootfs, non-root, resource caps), run it, and tear it down. Proves
/// the node can host hardened agent workloads. Fixed command — nothing
/// caller-supplied runs.
async fn sandbox_check() -> (bool, String) {
let _ = tokio::process::Command::new("docker")
.args(["pull", "-q", "alpine:latest"])
.output()
.await;
let out = tokio::process::Command::new("docker")
.args([
"run", "--rm", "--cap-drop=ALL", "--security-opt", "no-new-privileges",
"--network", "none", "--read-only", "--tmpfs", "/tmp", "--user", "10001:10001",
"--memory", "256m", "--pids-limit", "128", "alpine:latest",
"sh", "-c", "echo sandbox-ok; id; uname -sm",
])
.output()
.await;
match out {
Ok(o) if o.status.success() => (true, String::from_utf8_lossy(&o.stdout).trim().to_owned()),
Ok(o) => {
let mut s = String::from_utf8_lossy(&o.stdout).into_owned();
s.push_str(&String::from_utf8_lossy(&o.stderr));
(false, s.trim().to_owned())
}
Err(e) => (false, format!("docker error: {e} — is Docker installed?")),
}
}
/// Best-effort: join the user's tailnet (BYO Tailscale) and enable Tailscale SSH /// Best-effort: join the user's tailnet (BYO Tailscale) and enable Tailscale SSH
/// so they can reach this node keylessly. Failures are non-fatal — the WSS /// so they can reach this node keylessly. Failures are non-fatal — the WSS
/// control channel works regardless. /// control channel works regardless.
+7
View File
@@ -64,6 +64,13 @@ impl NodeHub {
.await .await
} }
/// Provision + run + tear down a fully hardened throwaway container on the
/// node (readiness check that it can host agent workloads).
pub async fn sandbox_check(&self, id: NodeId) -> Result<ExecOutput, String> {
self.request(id, |req_id| json!({ "t": "sb_check", "id": req_id }).to_string())
.await
}
/// Send a typed request frame and await the node's matching result. /// Send a typed request frame and await the node's matching result.
async fn request( async fn request(
&self, &self,
+1
View File
@@ -113,6 +113,7 @@ pub fn router(state: AppState) -> Router {
.route("/api/nodes/live", get(routes::nodes::live)) .route("/api/nodes/live", get(routes::nodes::live))
.route("/api/nodes/agent", get(routes::nodes::agent_ws)) .route("/api/nodes/agent", get(routes::nodes::agent_ws))
.route("/api/nodes/{id}/exec-test", post(routes::nodes::exec_test)) .route("/api/nodes/{id}/exec-test", post(routes::nodes::exec_test))
.route("/api/nodes/{id}/sandbox-check", post(routes::nodes::sandbox_check))
.route("/api/nodes/{id}/terminal/ticket", post(routes::nodes::terminal_ticket)) .route("/api/nodes/{id}/terminal/ticket", post(routes::nodes::terminal_ticket))
.route("/api/nodes/{id}/terminal/ws", get(routes::nodes::terminal_ws)) .route("/api/nodes/{id}/terminal/ws", get(routes::nodes::terminal_ws))
.route("/api/nodes/{id}", delete(routes::nodes::remove)) .route("/api/nodes/{id}", delete(routes::nodes::remove))
+17
View File
@@ -117,6 +117,23 @@ pub async fn exec_test(
} }
} }
/// `POST /api/nodes/{id}/sandbox-check` — run a hardened throwaway container on
/// the node to confirm it can host agent workloads.
pub async fn sandbox_check(
State(state): State<AppState>,
Authed(user): Authed,
Path(id): Path<Uuid>,
) -> Result<Json<Value>, ApiError> {
let node_id = NodeId::from(id);
nodes::get(&state.pool, node_id, user.workspace_id)
.await?
.ok_or(ApiError::NotFound)?;
match state.node_hub.sandbox_check(node_id).await {
Ok(out) => Ok(Json(json!({ "ok": out.ok, "output": out.output }))),
Err(e) => Ok(Json(json!({ "ok": false, "output": e }))),
}
}
/// `DELETE /api/nodes/{id}` — deregister a node (workspace-scoped). /// `DELETE /api/nodes/{id}` — deregister a node (workspace-scoped).
pub async fn remove( pub async fn remove(
State(state): State<AppState>, State(state): State<AppState>,
@@ -4,7 +4,7 @@
// comes from the nodes registry (`GET /api/nodes`), polled every 3s. // comes from the nodes registry (`GET /api/nodes`), polled every 3s.
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Cpu, HardDrive, MemoryStick, Network, Plus, Server, Terminal, Trash2 } from "lucide-react"; import { Cpu, HardDrive, MemoryStick, Network, Plus, Server, ShieldCheck, Terminal, Trash2 } from "lucide-react";
import { useFetchJson } from "@/lib/api/use-fetch"; import { useFetchJson } from "@/lib/api/use-fetch";
@@ -79,6 +79,17 @@ function Bar({ label, pct, detail, color }: { label: string; pct: number; detail
export function NodeCard({ node, onRemoved }: { node: FleetNode; onRemoved: () => void }) { export function NodeCard({ node, onRemoved }: { node: FleetNode; onRemoved: () => void }) {
const h = node.health; const h = node.health;
const [term, setTerm] = useState(false); const [term, setTerm] = useState(false);
const [check, setCheck] = useState<{ ok: boolean; output: string } | null>(null);
const [checking, setChecking] = useState(false);
const runCheck = useCallback(() => {
setChecking(true);
setCheck(null);
fetch(`/api/nodes/${node.id}/sandbox-check`, { method: "POST" })
.then((r) => r.json())
.then((d: { ok: boolean; output: string }) => setCheck(d))
.catch((e: Error) => setCheck({ ok: false, output: e.message }))
.finally(() => setChecking(false));
}, [node.id]);
const memPct = h && h.memTotal > 0 ? (h.memUsed / h.memTotal) * 100 : 0; const memPct = h && h.memTotal > 0 ? (h.memUsed / h.memTotal) * 100 : 0;
const diskUsedPct = h && h.diskTotal > 0 ? ((h.diskTotal - h.diskFree) / h.diskTotal) * 100 : 0; const diskUsedPct = h && h.diskTotal > 0 ? ((h.diskTotal - h.diskFree) / h.diskTotal) * 100 : 0;
const remove = useCallback(() => { const remove = useCallback(() => {
@@ -99,7 +110,10 @@ export function NodeCard({ node, onRemoved }: { node: FleetNode; onRemoved: () =
</div> </div>
</div> </div>
{node.status === "online" ? ( {node.status === "online" ? (
<button type="button" onClick={() => setTerm(true)} title="Open terminal" aria-label="Open terminal" style={{ width: 30, height: 30, borderRadius: 8, border: "1px solid rgba(94,200,216,.3)", background: "rgba(94,200,216,.08)", color: "#5ec8d8", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><Terminal size={14} /></button> <>
<button type="button" onClick={runCheck} disabled={checking} title="Run sandbox readiness check" aria-label="Sandbox check" style={{ width: 30, height: 30, borderRadius: 8, border: "1px solid rgba(95,208,138,.3)", background: "rgba(95,208,138,.08)", color: "#5fd08a", cursor: checking ? "default" : "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><ShieldCheck size={14} /></button>
<button type="button" onClick={() => setTerm(true)} title="Open terminal" aria-label="Open terminal" style={{ width: 30, height: 30, borderRadius: 8, border: "1px solid rgba(94,200,216,.3)", background: "rgba(94,200,216,.08)", color: "#5ec8d8", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><Terminal size={14} /></button>
</>
) : null} ) : null}
<button type="button" onClick={remove} title="Remove node" aria-label="Remove node" style={{ width: 30, height: 30, borderRadius: 8, border: "1px solid rgba(255,255,255,.1)", background: "transparent", color: "#7a7a82", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><Trash2 size={14} /></button> <button type="button" onClick={remove} title="Remove node" aria-label="Remove node" style={{ width: 30, height: 30, borderRadius: 8, border: "1px solid rgba(255,255,255,.1)", background: "transparent", color: "#7a7a82", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><Trash2 size={14} /></button>
</div> </div>
@@ -124,6 +138,16 @@ export function NodeCard({ node, onRemoved }: { node: FleetNode; onRemoved: () =
{node.status === "pending" ? "waiting for the daemon to connect…" : "no health reported yet"} {node.status === "pending" ? "waiting for the daemon to connect…" : "no health reported yet"}
</div> </div>
)} )}
{checking || check ? (
<div style={{ borderRadius: 9, background: "#08080a", border: `1px solid ${check && !check.ok ? "rgba(255,111,97,.3)" : "rgba(95,208,138,.3)"}`, padding: 10 }}>
<div style={{ fontFamily: mono, fontSize: 9.5, letterSpacing: ".08em", color: check && !check.ok ? "#ff8a7a" : "#5fd08a", marginBottom: check ? 6 : 0 }}>
{checking ? "RUNNING SANDBOX CHECK…" : check?.ok ? "✓ SANDBOX READY" : "✗ CHECK FAILED"}
</div>
{check ? (
<pre style={{ fontFamily: mono, fontSize: 10.5, color: "#bfe9d4", whiteSpace: "pre-wrap", wordBreak: "break-word", margin: 0, maxHeight: 120, overflow: "auto" }}>{check.output}</pre>
) : null}
</div>
) : null}
{term ? <NodeTerminal nodeId={node.id} nodeName={node.name} onClose={() => setTerm(false)} /> : null} {term ? <NodeTerminal nodeId={node.id} nodeName={node.name} onClose={() => setTerm(false)} /> : null}
</div> </div>
); );