Node-placed agent terminal: container PTY on the agent's node + WebRTC, shared node-local drives

Completes "agent on a node" (single-node): when an agent's placement points at a
fleet node, its terminal container runs there and the browser reaches it over a
direct WebRTC DataChannel (LAN speed), sharing a node-local volume with the
sandbox. gw-04-local agents are byte-identical to before.

- cm-sandbox/docker.rs: empty drive subpath → mount the whole volume at the target
  (volume_options None), so a per-agent node-local volume auto-creates at ~/drives.
- cm-api/fleet.rs: NodeHub.open_pty/webrtc_offer carry optional container+session
  (injected only when Some); node-terminal caller passes None (host shell unchanged).
- cm-runtime/terminals.rs: TerminalManager gains node_provider + placement
  (mirrors SandboxManager, draining-aware); node_local_drive_mount(agent) =
  clawmates_agent_<id> at ~/drives; placement_for() ensures + locates the container;
  attach uses driver_for(node) (local byte-identical).
- cm-runtime/sandboxes.rs: a node-placed agent sandbox mounts the same per-agent
  volume → shares files with the terminal on that node.
- cm-api/routes/terminal.rs: ticket response gains `node`; ws() bridges node-placed
  agents through the NodeHub relay (WebRTC + fallback) execing into the container;
  local path unchanged. server main wires with_node_provider.
- frontend: agentTerminalConnector mints the ticket then picks WebRTC (node-placed,
   direct / relayed badge) vs WS (local); webrtcConnector generalized to be
  endpoint-agnostic (node terminal reuses it).

Known follow-up: terminal (uid 65532) and sandbox (uid 10001) share the volume but
differ in uid — cross-container writes need an aligned uid/gid (group-writable).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-26 09:11:18 -07:00
co-authored by Claude Opus 4.8
parent 12212c72ac
commit 10c89f5157
10 changed files with 372 additions and 59 deletions
+4 -2
View File
@@ -258,9 +258,11 @@ async fn bridge_terminal(hub: Arc<NodeHub>, node_id: NodeId, socket: WebSocket)
let rows = c.rows.unwrap_or(24);
match c.kind.as_str() {
"resize" => hub.terminal_resize(node_id, sid, cols, rows).await,
"fallback" => hub.open_pty(node_id, sid, cols, rows).await,
// Host shell (no container) — the Infra node terminal.
"fallback" => hub.open_pty(node_id, sid, cols, rows, None, None).await,
"webrtc_offer" => {
hub.webrtc_offer(node_id, sid, c.sdp.as_deref().unwrap_or("")).await
hub.webrtc_offer(node_id, sid, c.sdp.as_deref().unwrap_or(""), None, None)
.await
}
"webrtc_ice" => {
hub.webrtc_ice(
+126 -2
View File
@@ -13,13 +13,14 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use cm_db::repo::audit::Actor;
use cm_domain::{AgentId, WorkspaceId};
use cm_domain::{AgentId, NodeId, WorkspaceId};
use futures::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::{PgPool, Row};
use tokio::io::AsyncWriteExt;
use crate::fleet::NodeHub;
use crate::routes::claws::workspace_agent;
use crate::{ApiError, AppState, Authed};
@@ -83,6 +84,9 @@ async fn redeem_ticket(
#[derive(Serialize)]
pub struct TicketResponse {
pub ticket: String,
/// The fleet-node UUID this agent's terminal is placed on, or null = gateway-
/// local. The browser uses the WebRTC transport for a node, plain WS for local.
pub node: Option<String>,
}
/// `POST /api/terminal/{id}/ticket` — owner-gated; mints a short-lived ticket.
@@ -115,6 +119,14 @@ pub async fn ticket(
Err(_) => "there".to_string(),
};
let ticket = issue_ticket(&state.pool, agent_id, user.workspace_id, &label).await?;
// Where the terminal is (or would be) placed — null = gateway-local.
let node = match state.runtime.terminals() {
Some(tm) => match tm.placement_node_for(agent_id).await {
n if n == "local" => None,
n => Some(n),
},
None => None,
};
cm_db::repo::audit::append(
&state.pool,
user.workspace_id,
@@ -125,7 +137,7 @@ pub async fn ticket(
json!({}),
)
.await?;
Ok(Json(TicketResponse { ticket }))
Ok(Json(TicketResponse { ticket, node }))
}
/// GET /api/terminal/{id}/tabs — the user's saved tab layout (or null).
@@ -233,6 +245,19 @@ pub async fn ws(
// MOTD greeting injected into the PTY session (read by the container .zshrc).
let env = vec![format!("CLAWMATES_USER={label}")];
let session = sanitize_session(q.session.as_deref());
// Node-placed agent → bridge to the node's daemon (WebRTC direct + WS relay),
// execing into the agent's terminal container there. Local stays unchanged.
if tm.placement_node_for(agent_id).await != "local" {
if let Ok((node_id, container)) = tm.placement_for(workspace_id, agent_id).await {
if let Ok(uuid) = node_id.parse::<uuid::Uuid>() {
let hub = state.node_hub.clone();
let nid = NodeId::from(uuid);
return upgrade
.on_upgrade(move |socket| bridge_node(socket, hub, nid, container, session));
}
}
// Couldn't place remotely (node dropped, etc.) — fall through to local.
}
upgrade.on_upgrade(move |socket| bridge(socket, tm, workspace_id, agent_id, env, session))
}
@@ -316,3 +341,102 @@ async fn bridge(
}
tm.detach(agent_id).await;
}
/// Browser→server control frames on the node-placed terminal WS: resize, the
/// `fallback` to open the WS-relay PTY, or WebRTC signaling (offer/ICE/close).
#[derive(Deserialize)]
struct NodeCtrl {
#[serde(rename = "type")]
kind: String,
cols: Option<u16>,
rows: Option<u16>,
sdp: Option<String>,
candidate: Option<String>,
sdp_mid: Option<String>,
sdp_mline_index: Option<u16>,
}
/// Bridge the browser terminal to a NODE-placed agent terminal container: relays
/// PTY bytes + WebRTC signaling to/from the node's daemon, which `docker exec`s a
/// tmux into `container`. Mirrors the Infra node terminal bridge, but targets the
/// agent's container (so the shell shares the agent's node-local ~/drives) and
/// carries the tmux `session` (one per tab). The same WS is the WebRTC signaling
/// channel and the WS-relay fallback path.
async fn bridge_node(
socket: WebSocket,
hub: Arc<NodeHub>,
node_id: NodeId,
container: String,
session: String,
) {
let Some((sid, mut pty_rx, mut sig_rx)) = hub.open_session(node_id).await else {
return;
};
let (mut ws_tx, mut ws_rx) = socket.split();
let to_browser = async {
loop {
tokio::select! {
bytes = pty_rx.recv() => match bytes {
Some(b) => { if ws_tx.send(Message::Binary(b.into())).await.is_err() { break; } }
None => break,
},
text = sig_rx.recv() => match text {
Some(t) => { if ws_tx.send(Message::Text(t.into())).await.is_err() { break; } }
None => break,
},
}
}
};
let to_node = async {
while let Some(Ok(msg)) = ws_rx.next().await {
match msg {
// Binary = keystrokes over the WS fallback (the DataChannel carries
// its own input when direct).
Message::Binary(b) => hub.terminal_input(node_id, sid, b.as_ref()).await,
Message::Text(t) => {
let Ok(c) = serde_json::from_str::<NodeCtrl>(t.as_str()) else {
continue;
};
let cols = c.cols.unwrap_or(80);
let rows = c.rows.unwrap_or(24);
match c.kind.as_str() {
"resize" => hub.terminal_resize(node_id, sid, cols, rows).await,
"fallback" => {
hub.open_pty(node_id, sid, cols, rows, Some(&container), Some(&session))
.await
}
"webrtc_offer" => {
hub.webrtc_offer(
node_id,
sid,
c.sdp.as_deref().unwrap_or(""),
Some(&container),
Some(&session),
)
.await
}
"webrtc_ice" => {
hub.webrtc_ice(
node_id,
sid,
c.candidate.as_deref().unwrap_or(""),
c.sdp_mid.as_deref(),
c.sdp_mline_index,
)
.await
}
"webrtc_close" => hub.webrtc_close(node_id, sid).await,
_ => {}
}
}
Message::Close(_) => break,
_ => {}
}
}
};
tokio::select! {
_ = to_browser => {},
_ = to_node => {},
}
hub.terminal_close(node_id, sid).await;
}