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:
co-authored by
Claude Opus 4.8
parent
12212c72ac
commit
10c89f5157
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user