Fleet P2a: in-dashboard remote terminal (PTY over the WSS channel)
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

You can now open a real shell on any connected node from the dashboard — the
daemon spawns a host PTY and streams it over the existing outbound control
channel (no inbound port, no Tailscale brokering needed).

Daemon:
- portable-pty host shell sessions: pty_open/pty_in/pty_resize/pty_close ops; a
  reader thread streams base64 pty_out frames. Outbound frames now funnel through
  one mpsc channel so PTY output and heartbeats interleave.

cm-api NodeHub:
- per-connection pty_sinks + sid multiplexing; open_terminal/terminal_input/
  terminal_resize/terminal_close; in-memory single-use terminal tickets (the
  browser WS can't carry a bearer, and the session is instance-local anyway).
- routes/nodes.rs: POST /api/nodes/{id}/terminal/ticket + GET .../terminal/ws
  (bridges browser xterm <-> node PTY: binary = keystrokes, text = resize).

Frontend:
- NodeTerminal xterm modal (reuses the agent Terminal's xterm setup); a Terminal
  button on each online node card opens a shell.

This proves the bidirectional streaming-over-channel mechanism the RemoteDriver
will reuse. Remaining P2: RemoteDriver + placement (run agent workloads on nodes).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-24 12:13:24 -07:00
co-authored by Claude Opus 4.8
parent 7332d69f8a
commit f5f96508eb
9 changed files with 548 additions and 35 deletions
+81 -2
View File
@@ -3,20 +3,22 @@
//! daemon's outbound control channel.
use std::convert::Infallible;
use std::sync::Arc;
use std::time::Duration;
use axum::extract::ws::WebSocketUpgrade;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::{Path, Query, State};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::{IntoResponse, Response};
use axum::Json;
use cm_db::repo::nodes;
use cm_domain::NodeId;
use futures::{SinkExt, StreamExt};
use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::fleet::run_channel;
use crate::fleet::{run_channel, NodeHub};
use crate::{ApiError, AppState, Authed};
/// An unguessable control-channel token (two time-ordered UUIDs).
@@ -146,3 +148,80 @@ pub async fn agent_ws(
let hub = state.node_hub.clone();
upgrade.on_upgrade(move |socket| run_channel(pool, hub, node_id, socket))
}
/// `POST /api/nodes/{id}/terminal/ticket` — mint a single-use terminal ticket
/// (the browser WS handshake can't carry a bearer header).
pub async fn terminal_ticket(
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)?;
if !state.node_hub.is_online(node_id).await {
return Ok(Json(json!({ "error": "node is offline" })));
}
Ok(Json(json!({ "ticket": state.node_hub.mint_ticket(node_id).await })))
}
/// `GET /api/nodes/{id}/terminal/ws?ticket=…` — bridge a browser xterm to a host
/// shell on the node (PTY proxied over the daemon's control channel).
pub async fn terminal_ws(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Query(q): Query<AgentQuery>,
upgrade: WebSocketUpgrade,
) -> Response {
let node_id = NodeId::from(id);
match state.node_hub.redeem_ticket(&q.token).await {
Some(t) if t == node_id => {}
_ => return ApiError::Unauthorized.into_response(),
}
let hub = state.node_hub.clone();
upgrade.on_upgrade(move |socket| bridge_terminal(hub, node_id, socket))
}
#[derive(Deserialize)]
struct TermCtrl {
#[serde(rename = "type")]
kind: String,
cols: u16,
rows: u16,
}
async fn bridge_terminal(hub: Arc<NodeHub>, node_id: NodeId, socket: WebSocket) {
let Some((sid, mut rx)) = hub.open_terminal(node_id, 80, 24).await else {
return;
};
let (mut ws_tx, mut ws_rx) = socket.split();
let to_browser = async {
while let Some(bytes) = rx.recv().await {
if ws_tx.send(Message::Binary(bytes.into())).await.is_err() {
break;
}
}
};
let to_node = async {
while let Some(Ok(msg)) = ws_rx.next().await {
match msg {
Message::Binary(b) => hub.terminal_input(node_id, sid, b.as_ref()).await,
Message::Text(t) => {
if let Ok(c) = serde_json::from_str::<TermCtrl>(t.as_str()) {
if c.kind == "resize" {
hub.terminal_resize(node_id, sid, c.cols, c.rows).await;
}
}
}
Message::Close(_) => break,
_ => {}
}
}
};
tokio::select! {
_ = to_browser => {},
_ = to_node => {},
}
hub.terminal_close(node_id, sid).await;
}