diff --git a/Cargo.lock b/Cargo.lock index 82c61a5..c9547c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1015,19 +1015,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonwebtoken" -version = "9.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" -dependencies = [ - "base64", - "js-sys", - "ring", - "serde", - "serde_json", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -1116,7 +1103,6 @@ dependencies = [ "clap", "maestro-cli", "maestro-daemon", - "maestro-registry", "maestro-tui", "tokio", "tracing-subscriber", @@ -1168,11 +1154,9 @@ dependencies = [ "clap", "dirs", "futures-util", - "jsonwebtoken", "libc", "maestro-config", "maestro-protocol", - "maestro-registry", "reqwest", "serde", "serde_json", @@ -1180,7 +1164,6 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tokio-tungstenite 0.21.0", - "toml", "tracing", "tracing-subscriber", "urlencoding", @@ -1234,31 +1217,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "maestro-registry" -version = "0.1.0" -dependencies = [ - "axum", - "chrono", - "dirs", - "futures-util", - "jsonwebtoken", - "maestro-config", - "maestro-protocol", - "rand 0.8.6", - "reqwest", - "serde", - "serde_json", - "tempfile", - "thiserror 1.0.69", - "tokio", - "tokio-tungstenite 0.21.0", - "toml", - "tracing", - "tracing-subscriber", - "uuid", -] - [[package]] name = "maestro-tui" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9a0dd2a..86465f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,6 @@ clap = { version = "4.5", features = ["derive", "env"] } crossterm = { version = "0.28", features = ["event-stream"] } dirs = "5" futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] } -jsonwebtoken = { version = "9", default-features = false } libc = "0.2" maestro-config = { path = "crates/maestro-config" } maestro-names = { path = "crates/maestro-names" } diff --git a/crates/maestro-config/src/lib.rs b/crates/maestro-config/src/lib.rs index e3c26dd..b42af94 100644 --- a/crates/maestro-config/src/lib.rs +++ b/crates/maestro-config/src/lib.rs @@ -4,28 +4,19 @@ //! `$MAESTRO_CONFIG`) holds settings for every Maestro binary: //! //! ```toml -//! [shared] -//! registry_url = "https://maestro.example.com" -//! token = "" -//! //! [cli] -//! cookie = "" +//! daemon_url = "http://127.0.0.1:7070" //! //! [daemon] -//! daemon_url = "ws://laptop.lan:7070" -//! bind_addr = "127.0.0.1:7070" +//! bind_addr = "127.0.0.1:7070" //! -//! [registry] +//! [web] //! bind_addr = "127.0.0.1:8080" //! ``` //! -//! The CLI consumes `[shared].registry_url` as its HTTP base; the daemon -//! derives its WS endpoint with [`derive_daemon_ws_url`] (scheme swap + -//! `/daemon` suffix). -//! -//! This crate owns the file schema, path resolution, atomic save, and the -//! URL derivation helper. Each binary applies its own env-var precedence and -//! defaults on top of [`MaestroConfigFile`]. +//! This crate owns the file schema, path resolution, and atomic save. Each +//! binary applies its own env-var precedence and defaults on top of +//! [`MaestroConfigFile`]. use std::collections::BTreeMap; use std::net::SocketAddr; @@ -52,14 +43,10 @@ pub enum ConfigError { NoConfigDir, #[error( "maestro config not found at {}\n\ - Run `maestrod configure` to create it. \ - (Legacy ~/.config/maestrod/config.toml and ~/.config/maestro/{{cli,registry}}.toml \ - are no longer read.)", + Run `maestro daemon configure` to create it.", path.display() )] MissingFile { path: PathBuf }, - #[error("invalid registry_url {input:?}: {reason}")] - InvalidRegistryUrl { input: String, reason: String }, #[error("invalid {env} ({input:?}): {source}")] InvalidBindAddr { env: &'static str, @@ -69,18 +56,13 @@ pub enum ConfigError { } /// On-disk representation of the unified config. Every section is optional -/// so a partial file (e.g. just `[shared]` + `[registry]` for a registry -/// host) is valid. +/// so a partial file (e.g. just `[daemon]`) is valid. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct MaestroConfigFile { - #[serde(default, skip_serializing_if = "SharedSection::is_empty")] - pub shared: SharedSection, #[serde(default, skip_serializing_if = "CliSection::is_empty")] pub cli: CliSection, #[serde(default, skip_serializing_if = "DaemonSection::is_empty")] pub daemon: DaemonSection, - #[serde(default, skip_serializing_if = "RegistrySection::is_empty")] - pub registry: RegistrySection, #[serde(default, skip_serializing_if = "WebSection::is_empty")] pub web: WebSection, #[serde(default, skip_serializing_if = "TuiSection::is_empty")] @@ -89,57 +71,28 @@ pub struct MaestroConfigFile { pub spawn: SpawnSection, } -/// Fields consumed by more than one binary (CLI + daemon, daemon + registry). -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct SharedSection { - /// Registry base URL, e.g. `https://maestro.example.com`. CLI uses it - /// as-is for HTTP; the daemon derives its WS endpoint via - /// [`derive_daemon_ws_url`]. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub registry_url: Option, - /// Shared HS256 secret. Registry signs JWTs with it, daemons verify - /// (and authenticate registry calls) with it. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub token: Option, -} - -impl SharedSection { - fn is_empty(&self) -> bool { - self.registry_url.is_none() && self.token.is_none() - } -} - #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct CliSection { - /// Persisted `maestro_session` cookie value (a JWT). Opaque to the CLI. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cookie: Option, - /// HTTP base URL of the home daemon the CLI brokers through (NAP-244), - /// e.g. `http://127.0.0.1:7070`. When unset, the CLI derives it from + /// HTTP base URL of the daemon the CLI talks to, e.g. + /// `http://127.0.0.1:7070`. When unset, the CLI derives it from /// `[daemon].bind_addr` if a daemon is configured locally, else falls - /// back to `http://127.0.0.1:7070`. The legacy registry path stays - /// available as a compat fallback via `[shared].registry_url`. + /// back to `http://127.0.0.1:7070`. #[serde(default, skip_serializing_if = "Option::is_none")] - pub home_daemon_url: Option, + pub daemon_url: Option, } impl CliSection { fn is_empty(&self) -> bool { - self.cookie.is_none() && self.home_daemon_url.is_none() + self.daemon_url.is_none() } } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct DaemonSection { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub daemon_id: Option, + /// Hostname advertised to clients, purely for display. Defaults to + /// `gethostname(2)`. #[serde(default, skip_serializing_if = "Option::is_none")] pub hostname: Option, - /// Reachable URL clients dial for the daemon's per-session WS endpoint. - /// Distinct from `bind_addr` because the bind may be `0.0.0.0` while - /// the publicly reachable address is a Wireguard / VPN interface. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub daemon_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub bind_addr: Option, /// Root for everything daemon-local: sidecars, FIFOs, etc. @@ -149,16 +102,6 @@ pub struct DaemonSection { /// with a `worktree` spec. #[serde(default, skip_serializing_if = "Option::is_none")] pub worktree_root: Option, - /// Operator-set perimeter id (e.g. `"wg-naps62"`). When two daemons share - /// a perimeter, peer-to-peer attach can use the T2 transport. Advertised - /// to the registry in heartbeats. See connection-orchestration spec. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub perimeter_id: Option, - /// Daemon's reachable address on the declared perimeter (e.g. - /// `"10.0.0.7:7070"`). Advertised in heartbeats; the registry hands it - /// back to peer daemons as a T2 dial target. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub private_addr: Option, /// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach /// receives this much of the prior PTY output before the live stream. /// `None` ⇒ daemon default (~64 KB); `Some(0)` disables replay. @@ -181,32 +124,16 @@ pub struct DaemonSection { impl DaemonSection { fn is_empty(&self) -> bool { - self.daemon_id.is_none() - && self.hostname.is_none() - && self.daemon_url.is_none() + self.hostname.is_none() && self.bind_addr.is_none() && self.state_dir.is_none() && self.worktree_root.is_none() - && self.perimeter_id.is_none() - && self.private_addr.is_none() && self.pty_replay_bytes.is_none() && self.tmux_tmpdir.is_none() && self.spawn_history_days.is_none() } } -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct RegistrySection { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bind_addr: Option, -} - -impl RegistrySection { - fn is_empty(&self) -> bool { - self.bind_addr.is_none() - } -} - /// `[web]` — `maestro-web` binary settings (NAP-241). #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct WebSection { @@ -400,8 +327,7 @@ impl MaestroConfigFile { } /// Load if present, else return an empty config. Use for callers that - /// want a default-on-missing read (e.g. CLI's `set-registry` bootstrap, - /// registry's first-run token generation). + /// want a default-on-missing read. pub fn load_or_default(path: &Path) -> Result { if !path.exists() { return Ok(Self::default()); @@ -436,34 +362,10 @@ pub fn default_config_path() -> Result { Ok(base.join(DIR_NAME).join(FILE_NAME)) } -/// Derive the daemon's WS endpoint from the shared `registry_url`. -/// -/// | input | output | -/// |--------------------------|---------------------------------| -/// | `https://r.example` | `wss://r.example/daemon` | -/// | `http://r.example:8080` | `ws://r.example:8080/daemon` | -/// | `http://r/some/prefix/` | `ws://r/some/prefix/daemon` | -/// -/// Trailing slashes are normalised so the result never has `//daemon`. -/// Any non-`http(s)` scheme returns [`ConfigError::InvalidRegistryUrl`]. -pub fn derive_daemon_ws_url(registry_url: &str) -> Result { - let trimmed = registry_url.trim_end_matches('/'); - if let Some(rest) = trimmed.strip_prefix("https://") { - Ok(format!("wss://{rest}/daemon")) - } else if let Some(rest) = trimmed.strip_prefix("http://") { - Ok(format!("ws://{rest}/daemon")) - } else { - Err(ConfigError::InvalidRegistryUrl { - input: registry_url.to_string(), - reason: "must start with http:// or https://".into(), - }) - } -} - /// Parse a `SocketAddr` from a raw (usually env-sourced) string, tagging the /// failure with the originating env-var name so every binary surfaces the same -/// `invalid (...)` diagnostic. Shared by the daemon, registry, and web -/// `bind_addr` resolution paths (NAP-273). +/// `invalid (...)` diagnostic. Shared by the daemon and web `bind_addr` +/// resolution paths (NAP-273). pub fn parse_bind_addr(raw: &str, env: &'static str) -> Result { raw.parse().map_err(|source| ConfigError::InvalidBindAddr { env, @@ -483,13 +385,13 @@ pub fn default_state_dir(subdir: &str) -> Result { Ok(base.join(subdir)) } -/// Capacities for the daemon/registry broadcast channels (session events and -/// PTY byte fan-out). These were four independent `= 256` constants scattered -/// across the daemon and registry; centralizing them here makes the coincidence -/// explicit and gives a single home for per-deployment tuning later (NAP-273). +/// Capacities for the daemon's broadcast channels (session events and PTY byte +/// fan-out). These were independent `= 256` constants scattered across the +/// daemon; centralizing them here makes the coincidence explicit and gives a +/// single home for per-deployment tuning later (NAP-273). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct BroadcastTuning { - /// Session/registry event channels (`SessionEvent`, `RegistryEvent`). + /// Session event channels (`SessionEvent`, `CatalogEvent`). pub events_capacity: usize, /// Per-session PTY byte broadcast channel. pub pty_capacity: usize, @@ -551,8 +453,7 @@ mod tests { let err = MaestroConfigFile::load(&path).unwrap_err(); let msg = err.to_string(); assert!(matches!(err, ConfigError::MissingFile { .. })); - assert!(msg.contains("maestrod configure"), "msg was: {msg}"); - assert!(msg.contains("Legacy"), "msg was: {msg}"); + assert!(msg.contains("maestro daemon configure"), "msg was: {msg}"); } #[test] @@ -568,30 +469,18 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); let cfg = MaestroConfigFile { - shared: SharedSection { - registry_url: Some("https://r.example".into()), - token: Some("secret".into()), - }, cli: CliSection { - cookie: Some("eyJ...".into()), - home_daemon_url: Some("http://127.0.0.1:7070".into()), + daemon_url: Some("http://127.0.0.1:7070".into()), }, daemon: DaemonSection { - daemon_id: Some("laptop".into()), hostname: Some("laptop.lan".into()), - daemon_url: Some("ws://laptop.lan:7070".into()), bind_addr: Some("127.0.0.1:7070".parse().unwrap()), state_dir: Some(PathBuf::from("/var/lib/maestrod")), worktree_root: Some(PathBuf::from("/var/lib/maestrod/wt")), - perimeter_id: Some("wg-naps62".into()), - private_addr: Some("10.0.0.7:7070".into()), pty_replay_bytes: Some(131_072), tmux_tmpdir: Some(PathBuf::from("/run/user/1000")), spawn_history_days: Some(14), }, - registry: RegistrySection { - bind_addr: Some("0.0.0.0:8080".parse().unwrap()), - }, web: WebSection { bind_addr: Some("127.0.0.1:8080".parse().unwrap()), daemon_url: Some("http://127.0.0.1:7070".into()), @@ -751,8 +640,8 @@ presets = [ let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); MaestroConfigFile { - shared: SharedSection { - token: Some("t".into()), + daemon: DaemonSection { + hostname: Some("laptop".into()), ..Default::default() }, ..Default::default() @@ -765,27 +654,22 @@ presets = [ #[test] fn partial_file_loads_with_section_defaults() { - // Only [shared] + [registry] present (typical registry host). + // Only [daemon] present (typical single-host install). let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); std::fs::write( &path, r#" -[shared] -token = "abc" - -[registry] +[daemon] bind_addr = "127.0.0.1:9090" "#, ) .unwrap(); let cfg = MaestroConfigFile::load(&path).unwrap(); - assert_eq!(cfg.shared.token.as_deref(), Some("abc")); - assert!(cfg.shared.registry_url.is_none()); assert_eq!(cfg.cli, CliSection::default()); - assert_eq!(cfg.daemon, DaemonSection::default()); + assert_eq!(cfg.web, WebSection::default()); assert_eq!( - cfg.registry.bind_addr, + cfg.daemon.bind_addr, Some("127.0.0.1:9090".parse().unwrap()) ); } @@ -797,18 +681,17 @@ bind_addr = "127.0.0.1:9090" let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); let cfg = MaestroConfigFile { - shared: SharedSection { - token: Some("t".into()), + daemon: DaemonSection { + hostname: Some("laptop".into()), ..Default::default() }, ..Default::default() }; cfg.save(&path).unwrap(); let body = std::fs::read_to_string(&path).unwrap(); - assert!(body.contains("[shared]"), "body: {body}"); + assert!(body.contains("[daemon]"), "body: {body}"); assert!(!body.contains("[cli]"), "body: {body}"); - assert!(!body.contains("[daemon]"), "body: {body}"); - assert!(!body.contains("[registry]"), "body: {body}"); + assert!(!body.contains("[web]"), "body: {body}"); } #[test] @@ -827,47 +710,4 @@ bind_addr = "127.0.0.1:9090" let p = default_config_path().unwrap(); assert!(p.ends_with(format!("{DIR_NAME}/{FILE_NAME}"))); } - - #[test] - fn derive_daemon_ws_url_swaps_http_to_ws() { - assert_eq!( - derive_daemon_ws_url("http://r.example").unwrap(), - "ws://r.example/daemon" - ); - assert_eq!( - derive_daemon_ws_url("http://r.example:8080").unwrap(), - "ws://r.example:8080/daemon" - ); - } - - #[test] - fn derive_daemon_ws_url_swaps_https_to_wss() { - assert_eq!( - derive_daemon_ws_url("https://r.example").unwrap(), - "wss://r.example/daemon" - ); - } - - #[test] - fn derive_daemon_ws_url_normalises_trailing_slash() { - assert_eq!( - derive_daemon_ws_url("https://r.example/").unwrap(), - "wss://r.example/daemon" - ); - assert_eq!( - derive_daemon_ws_url("https://r.example/path/").unwrap(), - "wss://r.example/path/daemon" - ); - } - - #[test] - fn derive_daemon_ws_url_rejects_non_http_schemes() { - let err = derive_daemon_ws_url("ws://r.example/daemon").unwrap_err(); - match err { - ConfigError::InvalidRegistryUrl { input, .. } => { - assert_eq!(input, "ws://r.example/daemon"); - } - other => panic!("expected InvalidRegistryUrl, got {other:?}"), - } - } } diff --git a/crates/maestro-daemon/Cargo.toml b/crates/maestro-daemon/Cargo.toml index 35f21a2..112f736 100644 --- a/crates/maestro-daemon/Cargo.toml +++ b/crates/maestro-daemon/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true license.workspace = true repository.workspace = true publish.workspace = true -description = "Per-host tmux wrapper + session orchestrator that talks to the registry" +description = "Per-host tmux wrapper + session orchestrator" [lib] path = "src/lib.rs" @@ -16,7 +16,6 @@ chrono.workspace = true clap.workspace = true dirs.workspace = true futures-util.workspace = true -jsonwebtoken.workspace = true libc.workspace = true maestro-config.workspace = true maestro-protocol.workspace = true @@ -24,16 +23,14 @@ serde.workspace = true serde_json.workspace = true thiserror.workspace = true tokio.workspace = true -tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] } -toml.workspace = true tracing.workspace = true tracing-subscriber.workspace = true -urlencoding = "2" uuid.workspace = true [dev-dependencies] reqwest.workspace = true +tokio-tungstenite.workspace = true +urlencoding = "2" tempfile.workspace = true -maestro-registry = { path = "../maestro-registry" } # `test-util` enables `start_paused` for instant timeout tests (NAP-270). tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/maestro-daemon/src/attach.rs b/crates/maestro-daemon/src/attach.rs index c65c2e9..84a83dc 100644 --- a/crates/maestro-daemon/src/attach.rs +++ b/crates/maestro-daemon/src/attach.rs @@ -1,108 +1,18 @@ -//! Per-session attach endpoint: `WS /sessions/:id/stream?token=`. +//! PTY pump shared by the attach endpoints. //! //! - Binary frames carry raw PTY bytes both directions //! - Text frames carry [`maestro_protocol::ClientControl`] (Resize/Ping/Pong) -//! - JWT is HS256, validated locally with no callback to the registry -use std::sync::Arc; - -use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; -use axum::extract::{Path as AxumPath, Query, State}; -use axum::http::StatusCode; -use axum::response::Response; -use axum::routing::any; -use axum::Router; +use axum::extract::ws::{Message, WebSocket}; use futures_util::{SinkExt, StreamExt}; use maestro_protocol::ClientControl; -use serde::Deserialize; use tokio::sync::broadcast::error::RecvError; use uuid::Uuid; -use crate::jwt::{JwtVerifier, PairAttachVerifier}; use crate::session::SessionManager; -#[derive(Debug, Clone)] -pub struct AttachState { - pub manager: SessionManager, - pub verifier: Arc, - /// Pair-scoped JWT verifier (NAP-247). When the legacy verifier fails - /// (e.g. the token was minted as a pair-attach JWT for the broker path), - /// we fall back to this one before rejecting the client outright. Local - /// attaches always go through the legacy verifier. - pub pair_verifier: Arc, -} - -#[derive(Debug, Deserialize)] -struct AttachParams { - token: String, -} - -pub fn router( - manager: SessionManager, - verifier: JwtVerifier, - pair_verifier: PairAttachVerifier, -) -> Router { - Router::new() - .route("/sessions/:id/stream", any(attach_ws)) - .with_state(AttachState { - manager, - verifier: Arc::new(verifier), - pair_verifier: Arc::new(pair_verifier), - }) -} - -async fn attach_ws( - AxumPath(id): AxumPath, - Query(params): Query, - State(state): State, - ws: WebSocketUpgrade, -) -> Result { - // Try the legacy `JwtClaims` shape first (browser/CLI direct attach). On - // any failure, try the pair-scoped shape used by the home-daemon broker - // (NAP-247). One — and only one — must accept. - let legacy_err = match state.verifier.verify(¶ms.token) { - Ok(claims) => { - if claims.session_id != id { - return Err(( - StatusCode::FORBIDDEN, - format!( - "token is for session {} but path requested {}", - claims.session_id, id - ), - )); - } - None - } - Err(e) => Some(e), - }; - if let Some(legacy_err) = legacy_err { - match state.pair_verifier.verify(¶ms.token, id) { - Ok(sub) => { - tracing::debug!(home_daemon = %sub, %id, "pair-scoped attach accepted"); - } - Err(pair_err) => { - // Both rejected — return the legacy error since most clients - // still mint that shape today. - return Err(( - StatusCode::UNAUTHORIZED, - format!("legacy: {legacy_err}; pair: {pair_err}"), - )); - } - } - } - if !state.manager.has(id).await { - return Err((StatusCode::NOT_FOUND, format!("no session {id}"))); - } - let manager = state.manager.clone(); - Ok(ws.on_upgrade(move |socket| async move { - let _ = serve_local(socket, manager, id).await; - })) -} - -/// Pump a WebSocket against a local session's PTY. Reused by both the legacy -/// `/sessions/:id/stream` endpoint (after JWT auth) and the new broker -/// `WS /api/attach/:id` endpoint (when the session is local-owned — NAP-243). -pub(crate) async fn serve_local( +/// Pump a WebSocket against a session's PTY, behind `WS /api/attach/:id`. +pub(crate) async fn serve( socket: WebSocket, manager: SessionManager, id: Uuid, @@ -112,7 +22,7 @@ pub(crate) async fn serve_local( let (replay, mut rx) = match manager.subscribe_pty_with_replay(id).await { Some(pair) => pair, None => { - // session was killed between the auth check and now — let the + // session was killed between the existence check and now — let the // client see a clean close instead of a dropped socket. let _ = sink.send(Message::Close(None)).await; return Ok(()); diff --git a/crates/maestro-daemon/src/broker.rs b/crates/maestro-daemon/src/broker.rs deleted file mode 100644 index da9a287..0000000 --- a/crates/maestro-daemon/src/broker.rs +++ /dev/null @@ -1,822 +0,0 @@ -//! Home-daemon broker handle (NAP-247, extended in NAP-327). -//! -//! The HTTP handlers use this to issue correlated request/reply RPCs over the -//! daemon's single registry control link, keeping themselves thin: -//! -//! - `WS /api/attach/:id` → `resolve_attach`: resolve a peer-owned session into -//! an [`AttachCandidates`] (pair-scoped JWT + T2 perimeter info) (NAP-247). -//! - `POST /api/sessions` / `DELETE /api/sessions/:id` for a peer target → -//! `forward_create` / `forward_kill`: relay the op to the owning daemon and -//! await its result (NAP-327). -//! -//! Each call correlates its reply by `reply_to`, fails fast while the link is -//! down (NAP-263), and cleans up its pending entry on every exit path (NAP-259). - -use std::collections::HashMap; -use std::hash::Hash; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -use maestro_protocol::{ - AttachCandidates, CreateSessionRequest, DaemonOutbound, DirListing, GitRoot, RecentDirs, - ReplyTo, RpcResult, Session, -}; -use tokio::sync::{mpsc, oneshot}; -use uuid::Uuid; - -/// RAII cleanup for a pending-RPC entry. Removing the entry on drop guarantees -/// it is gone on *every* exit path of the awaiting call — success, error, -/// timeout, and crucially future cancellation (the axum handler is dropped when -/// the client hangs up) — so the map can't grow without bound (NAP-259). -/// -/// The map is a `std::sync::Mutex` (never held across an await) precisely so the -/// sync `Drop` can lock it. Removal is idempotent: on the happy path the reply -/// handler has already taken the entry and this is a harmless no-op. -struct PendingGuard<'a, K: Eq + Hash, V> { - map: &'a Mutex>, - key: K, -} - -impl Drop for PendingGuard<'_, K, V> { - fn drop(&mut self) { - self.map.lock().unwrap().remove(&self.key); - } -} - -/// How long to wait for a `ResolveAttachResult` after the request goes out. -/// Matches the registry's RPC timeout — a healthy registry replies in ms. -pub const RESOLVE_TIMEOUT: Duration = Duration::from_secs(10); - -#[derive(Debug, thiserror::Error)] -pub enum BrokerError { - #[error("registry control link unavailable")] - NoControlLink, - #[error("registry reply channel closed before response")] - ReplyClosed, - #[error("registry did not reply within {0:?}")] - Timeout(Duration), - #[error("registry rejected resolve: {0}: {1}")] - Registry(String, String), -} - -/// Shared between the registry-client task (producer of `ResolveAttachResult`) -/// and the broker HTTP handler (consumer). Clone freely — all fields are -/// behind `Arc`s. -#[derive(Debug, Clone)] -pub struct BrokerHandle { - out_tx: mpsc::Sender, - pending: Arc>>>>, - /// Pending `ForwardCreate` replies, keyed by `reply_to` (NAP-327). Same - /// fail-fast + RAII-cleanup discipline as `pending`. - pending_create: Arc>>>>, - /// Pending `ForwardKill` replies, keyed by `reply_to` (NAP-327). - pending_kill: Arc>>>>, - /// Pending `ForwardListDir` replies, keyed by `reply_to` (NAP-329). - pending_list_dir: Arc>>>>, - /// Pending `ForwardRecentDirs` replies, keyed by `reply_to` (#26). - pending_recent_dirs: Arc>>>>, - /// Pending `ForwardGitRoot` replies, keyed by `reply_to` (#26). - pending_git_root: Arc>>>>, - /// Whether the registry control link is currently up. Flipped by the - /// registry-client task via [`BrokerHandle::set_connected`]. When down, - /// `resolve_attach` fails fast instead of queueing onto an undrained - /// channel that would otherwise fill and block every caller (NAP-263). - connected: Arc, -} - -impl BrokerHandle { - /// `(handle, receiver)` — the registry-client task owns the receiver and - /// drains it into the control-link WS sink. The handle is shared with - /// HTTP handlers and is what they use to issue requests. - /// - /// Starts **disconnected**: the registry-client task calls - /// [`set_connected(true)`](Self::set_connected) once the handshake lands. - pub fn new() -> (Self, mpsc::Receiver) { - let (out_tx, out_rx) = mpsc::channel(64); - let handle = Self { - out_tx, - pending: Arc::new(Mutex::new(HashMap::new())), - pending_create: Arc::new(Mutex::new(HashMap::new())), - pending_kill: Arc::new(Mutex::new(HashMap::new())), - pending_list_dir: Arc::new(Mutex::new(HashMap::new())), - pending_recent_dirs: Arc::new(Mutex::new(HashMap::new())), - pending_git_root: Arc::new(Mutex::new(HashMap::new())), - connected: Arc::new(AtomicBool::new(false)), - }; - (handle, out_rx) - } - - /// Mark the control link up/down. Called by the registry-client task on - /// every connect (`true`) and disconnect (`false`). - pub fn set_connected(&self, connected: bool) { - self.connected.store(connected, Ordering::Release); - } - - /// Resolve a peer session: ask the registry for an [`AttachCandidates`]. - pub async fn resolve_attach(&self, session_id: Uuid) -> Result { - // Fail fast when the registry link is down — otherwise the bounded - // outbound channel (nothing draining it while disconnected) fills up - // and every subsequent caller blocks on `send().await` (NAP-263). - if !self.connected.load(Ordering::Acquire) { - return Err(BrokerError::NoControlLink); - } - - let reply_to = Uuid::new_v4(); - let (tx, rx) = oneshot::channel(); - self.pending.lock().unwrap().insert(reply_to, tx); - // From here on, every exit path (including cancellation of this future) - // removes the pending entry when `_guard` drops (NAP-259). - let _guard = PendingGuard { - map: &*self.pending, - key: reply_to, - }; - - // Non-blocking send: a full channel (registry up but back-pressured) - // is treated as "link unavailable" rather than parking the caller. - if self - .out_tx - .try_send(DaemonOutbound::ResolveAttach { - reply_to, - session_id, - }) - .is_err() - { - return Err(BrokerError::NoControlLink); - } - - match tokio::time::timeout(RESOLVE_TIMEOUT, rx).await { - Ok(Ok(RpcResult::Ok { value })) => Ok(value), - Ok(Ok(RpcResult::Err { error })) => { - Err(BrokerError::Registry(error.code, error.message)) - } - Ok(Err(_)) => Err(BrokerError::ReplyClosed), - Err(_) => Err(BrokerError::Timeout(RESOLVE_TIMEOUT)), - } - } - - /// Called by the registry-client task when a `ResolveAttachResult` frame - /// comes back from the registry. Resolves the waiting `resolve_attach` - /// future if one's still alive. - pub async fn complete_resolve( - &self, - reply_to: ReplyTo, - result: RpcResult, - ) -> bool { - let entry = self.pending.lock().unwrap().remove(&reply_to); - match entry { - Some(tx) => tx.send(result).is_ok(), - None => { - tracing::debug!(?reply_to, "no waiter for ResolveAttachResult"); - false - } - } - } - - /// Broker a cross-daemon create: ask the registry to relay the request to - /// the owning daemon (`request.daemon_id`) and return the created session. - /// Same fail-fast-while-disconnected + RAII-cleanup discipline as - /// [`resolve_attach`](Self::resolve_attach) (NAP-263 / NAP-259, NAP-327). - pub async fn forward_create( - &self, - request: CreateSessionRequest, - ) -> Result { - if !self.connected.load(Ordering::Acquire) { - return Err(BrokerError::NoControlLink); - } - let reply_to = Uuid::new_v4(); - let (tx, rx) = oneshot::channel(); - self.pending_create.lock().unwrap().insert(reply_to, tx); - let _guard = PendingGuard { - map: &*self.pending_create, - key: reply_to, - }; - if self - .out_tx - .try_send(DaemonOutbound::ForwardCreate { reply_to, request }) - .is_err() - { - return Err(BrokerError::NoControlLink); - } - match tokio::time::timeout(RESOLVE_TIMEOUT, rx).await { - Ok(Ok(RpcResult::Ok { value })) => Ok(value), - Ok(Ok(RpcResult::Err { error })) => { - Err(BrokerError::Registry(error.code, error.message)) - } - Ok(Err(_)) => Err(BrokerError::ReplyClosed), - Err(_) => Err(BrokerError::Timeout(RESOLVE_TIMEOUT)), - } - } - - /// Broker a cross-daemon kill: ask the registry to relay the kill to the - /// daemon that owns `id` (NAP-327). - pub async fn forward_kill(&self, daemon_id: String, id: Uuid) -> Result<(), BrokerError> { - if !self.connected.load(Ordering::Acquire) { - return Err(BrokerError::NoControlLink); - } - let reply_to = Uuid::new_v4(); - let (tx, rx) = oneshot::channel(); - self.pending_kill.lock().unwrap().insert(reply_to, tx); - let _guard = PendingGuard { - map: &*self.pending_kill, - key: reply_to, - }; - if self - .out_tx - .try_send(DaemonOutbound::ForwardKill { - reply_to, - daemon_id, - id, - }) - .is_err() - { - return Err(BrokerError::NoControlLink); - } - match tokio::time::timeout(RESOLVE_TIMEOUT, rx).await { - Ok(Ok(RpcResult::Ok { .. })) => Ok(()), - Ok(Ok(RpcResult::Err { error })) => { - Err(BrokerError::Registry(error.code, error.message)) - } - Ok(Err(_)) => Err(BrokerError::ReplyClosed), - Err(_) => Err(BrokerError::Timeout(RESOLVE_TIMEOUT)), - } - } - - /// Broker a cross-daemon directory listing: ask the registry to relay the - /// `ListDir` to the daemon named by `daemon_id` (which may be a peer) and - /// return its listing. Same fail-fast-while-disconnected + RAII-cleanup - /// discipline as the other broker calls (NAP-329). - pub async fn forward_list_dir( - &self, - daemon_id: String, - path: String, - ) -> Result { - if !self.connected.load(Ordering::Acquire) { - return Err(BrokerError::NoControlLink); - } - let reply_to = Uuid::new_v4(); - let (tx, rx) = oneshot::channel(); - self.pending_list_dir.lock().unwrap().insert(reply_to, tx); - let _guard = PendingGuard { - map: &*self.pending_list_dir, - key: reply_to, - }; - if self - .out_tx - .try_send(DaemonOutbound::ForwardListDir { - reply_to, - daemon_id, - path, - }) - .is_err() - { - return Err(BrokerError::NoControlLink); - } - match tokio::time::timeout(RESOLVE_TIMEOUT, rx).await { - Ok(Ok(RpcResult::Ok { value })) => Ok(value), - Ok(Ok(RpcResult::Err { error })) => { - Err(BrokerError::Registry(error.code, error.message)) - } - Ok(Err(_)) => Err(BrokerError::ReplyClosed), - Err(_) => Err(BrokerError::Timeout(RESOLVE_TIMEOUT)), - } - } - - /// Resolve a waiting [`forward_list_dir`](Self::forward_list_dir); called by - /// the registry-client task on a `ForwardListDirResult` frame. - pub async fn complete_forward_list_dir( - &self, - reply_to: ReplyTo, - result: RpcResult, - ) -> bool { - let entry = self.pending_list_dir.lock().unwrap().remove(&reply_to); - match entry { - Some(tx) => tx.send(result).is_ok(), - None => { - tracing::debug!(?reply_to, "no waiter for ForwardListDirResult"); - false - } - } - } - - /// Broker a cross-daemon recent-dirs query: ask the registry to relay a - /// `RecentDirs` request to the daemon named by `daemon_id` (which may be a - /// peer) and return its recents. Same discipline as the other broker calls - /// (#26). - pub async fn forward_recent_dirs(&self, daemon_id: String) -> Result { - if !self.connected.load(Ordering::Acquire) { - return Err(BrokerError::NoControlLink); - } - let reply_to = Uuid::new_v4(); - let (tx, rx) = oneshot::channel(); - self.pending_recent_dirs - .lock() - .unwrap() - .insert(reply_to, tx); - let _guard = PendingGuard { - map: &*self.pending_recent_dirs, - key: reply_to, - }; - if self - .out_tx - .try_send(DaemonOutbound::ForwardRecentDirs { - reply_to, - daemon_id, - }) - .is_err() - { - return Err(BrokerError::NoControlLink); - } - match tokio::time::timeout(RESOLVE_TIMEOUT, rx).await { - Ok(Ok(RpcResult::Ok { value })) => Ok(value), - Ok(Ok(RpcResult::Err { error })) => { - Err(BrokerError::Registry(error.code, error.message)) - } - Ok(Err(_)) => Err(BrokerError::ReplyClosed), - Err(_) => Err(BrokerError::Timeout(RESOLVE_TIMEOUT)), - } - } - - /// Resolve a waiting [`forward_recent_dirs`](Self::forward_recent_dirs); - /// called by the registry-client task on a `ForwardRecentDirsResult` frame. - pub async fn complete_forward_recent_dirs( - &self, - reply_to: ReplyTo, - result: RpcResult, - ) -> bool { - let entry = self.pending_recent_dirs.lock().unwrap().remove(&reply_to); - match entry { - Some(tx) => tx.send(result).is_ok(), - None => { - tracing::debug!(?reply_to, "no waiter for ForwardRecentDirsResult"); - false - } - } - } - - /// Broker a cross-daemon gitroot probe: ask the registry to relay a - /// `GitRoot` request for `path` to the daemon named by `daemon_id` (which - /// may be a peer) and return its result (#26). - pub async fn forward_git_root( - &self, - daemon_id: String, - path: String, - ) -> Result { - if !self.connected.load(Ordering::Acquire) { - return Err(BrokerError::NoControlLink); - } - let reply_to = Uuid::new_v4(); - let (tx, rx) = oneshot::channel(); - self.pending_git_root.lock().unwrap().insert(reply_to, tx); - let _guard = PendingGuard { - map: &*self.pending_git_root, - key: reply_to, - }; - if self - .out_tx - .try_send(DaemonOutbound::ForwardGitRoot { - reply_to, - daemon_id, - path, - }) - .is_err() - { - return Err(BrokerError::NoControlLink); - } - match tokio::time::timeout(RESOLVE_TIMEOUT, rx).await { - Ok(Ok(RpcResult::Ok { value })) => Ok(value), - Ok(Ok(RpcResult::Err { error })) => { - Err(BrokerError::Registry(error.code, error.message)) - } - Ok(Err(_)) => Err(BrokerError::ReplyClosed), - Err(_) => Err(BrokerError::Timeout(RESOLVE_TIMEOUT)), - } - } - - /// Resolve a waiting [`forward_git_root`](Self::forward_git_root); called by - /// the registry-client task on a `ForwardGitRootResult` frame. - pub async fn complete_forward_git_root( - &self, - reply_to: ReplyTo, - result: RpcResult, - ) -> bool { - let entry = self.pending_git_root.lock().unwrap().remove(&reply_to); - match entry { - Some(tx) => tx.send(result).is_ok(), - None => { - tracing::debug!(?reply_to, "no waiter for ForwardGitRootResult"); - false - } - } - } - - /// Resolve a waiting [`forward_create`](Self::forward_create); called by the - /// registry-client task on a `ForwardCreateResult` frame. - pub async fn complete_forward_create( - &self, - reply_to: ReplyTo, - result: RpcResult, - ) -> bool { - let entry = self.pending_create.lock().unwrap().remove(&reply_to); - match entry { - Some(tx) => tx.send(result).is_ok(), - None => { - tracing::debug!(?reply_to, "no waiter for ForwardCreateResult"); - false - } - } - } - - /// Resolve a waiting [`forward_kill`](Self::forward_kill); called by the - /// registry-client task on a `ForwardKillResult` frame. - pub async fn complete_forward_kill(&self, reply_to: ReplyTo, result: RpcResult<()>) -> bool { - let entry = self.pending_kill.lock().unwrap().remove(&reply_to); - match entry { - Some(tx) => tx.send(result).is_ok(), - None => { - tracing::debug!(?reply_to, "no waiter for ForwardKillResult"); - false - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Cancelling (dropping) an in-flight `resolve_attach` must not leak its - /// pending-reply entry — otherwise a browser hanging up mid-attach grows - /// the map without bound (NAP-259). - #[tokio::test] - async fn cancelled_resolve_attach_removes_pending_entry() { - // Keep the receiver alive so `out_tx.send()` succeeds and the call - // parks on the reply oneshot rather than failing fast. - let (handle, _out_rx) = BrokerHandle::new(); - handle.set_connected(true); - - { - let fut = handle.resolve_attach(Uuid::new_v4()); - tokio::pin!(fut); - tokio::select! { - biased; - _ = &mut fut => panic!("resolve must not complete without a reply"), - _ = tokio::time::sleep(Duration::from_millis(20)) => {} - } - assert_eq!( - handle.pending.lock().unwrap().len(), - 1, - "entry should be in-flight before cancellation" - ); - } - - assert_eq!( - handle.pending.lock().unwrap().len(), - 0, - "pending entry must be removed on cancellation (NAP-259)" - ); - } - - /// NAP-263: while the registry link is down, `resolve_attach` must return - /// promptly instead of queueing onto the undrained outbound channel (which - /// would fill and deadlock callers). The receiver is alive with free - /// capacity here, so a `NoControlLink` proves the disconnected gate — not a - /// closed/full channel — short-circuited the call. - #[tokio::test] - async fn resolve_attach_fails_fast_while_disconnected() { - let (handle, _out_rx) = BrokerHandle::new(); // starts disconnected - - let res = tokio::time::timeout( - Duration::from_secs(1), - handle.resolve_attach(Uuid::new_v4()), - ) - .await - .expect("resolve_attach must return promptly, not block on a full channel"); - - assert!(matches!(res, Err(BrokerError::NoControlLink)), "{res:?}"); - // It must not leave a pending entry behind either. - assert_eq!(handle.pending.lock().unwrap().len(), 0); - } - - /// NAP-270: a registry-reported error in the `ResolveAttachResult` reply is - /// surfaced as `BrokerError::Registry` carrying the registry's error code, - /// so the broker handler can map it to the right HTTP status. - #[tokio::test] - async fn broker_resolve_attach_propagates_registry_err() { - let (handle, mut out_rx) = BrokerHandle::new(); - handle.set_connected(true); - - let h = handle.clone(); - let task = tokio::spawn(async move { h.resolve_attach(Uuid::new_v4()).await }); - - // Learn the reply_to from the outbound frame, then deliver an error. - let reply_to = match out_rx.recv().await.expect("ResolveAttach went out") { - DaemonOutbound::ResolveAttach { reply_to, .. } => reply_to, - other => panic!("expected ResolveAttach, got {other:?}"), - }; - handle - .complete_resolve( - reply_to, - RpcResult::Err { - error: maestro_protocol::ErrorInfo { - code: "unknown_session".into(), - message: "no daemon owns it".into(), - }, - }, - ) - .await; - - match task.await.unwrap() { - Err(BrokerError::Registry(code, _)) => assert_eq!(code, "unknown_session"), - other => panic!("expected Registry error, got {other:?}"), - } - } - - // ----- NAP-327: forward_create / forward_kill ----- - - fn sample_request() -> CreateSessionRequest { - CreateSessionRequest { - daemon_id: "konishi".into(), - cwd: std::path::PathBuf::from("/tmp"), - command: "bash".into(), - env: Default::default(), - metadata: Default::default(), - worktree: None, - } - } - - /// A `forward_create` issues a `ForwardCreate` frame and resolves with the - /// session once the registry's `ForwardCreateResult` is delivered. - #[tokio::test] - async fn forward_create_round_trips_session() { - let (handle, mut out_rx) = BrokerHandle::new(); - handle.set_connected(true); - - let h = handle.clone(); - let task = tokio::spawn(async move { h.forward_create(sample_request()).await }); - - let reply_to = match out_rx.recv().await.expect("ForwardCreate went out") { - DaemonOutbound::ForwardCreate { reply_to, request } => { - assert_eq!(request.daemon_id, "konishi"); - reply_to - } - other => panic!("expected ForwardCreate, got {other:?}"), - }; - let session = Session { - id: Uuid::new_v4(), - daemon_id: "konishi".into(), - cwd: std::path::PathBuf::from("/tmp"), - command: "bash".into(), - env: Default::default(), - metadata: Default::default(), - status: maestro_protocol::SessionStatus::Running, - created_at: chrono::Utc::now(), - foreground: None, - activity: Default::default(), - last_activity_at: chrono::Utc::now(), - - current_cwd: None, - }; - handle - .complete_forward_create( - reply_to, - RpcResult::Ok { - value: session.clone(), - }, - ) - .await; - - let got = task.await.unwrap().expect("create ok"); - assert_eq!(got.id, session.id); - } - - /// While disconnected, `forward_create` / `forward_kill` fail fast with - /// `NoControlLink` and leave no pending entry (NAP-263 / NAP-259). - #[tokio::test] - async fn forward_ops_fail_fast_while_disconnected() { - let (handle, _out_rx) = BrokerHandle::new(); // starts disconnected - assert!(matches!( - handle.forward_create(sample_request()).await, - Err(BrokerError::NoControlLink) - )); - assert!(matches!( - handle.forward_kill("konishi".into(), Uuid::new_v4()).await, - Err(BrokerError::NoControlLink) - )); - assert_eq!(handle.pending_create.lock().unwrap().len(), 0); - assert_eq!(handle.pending_kill.lock().unwrap().len(), 0); - } - - /// A registry-reported error in the reply surfaces as `BrokerError::Registry` - /// carrying the code, so the HTTP handler can map it to a status. - #[tokio::test] - async fn forward_kill_propagates_registry_err() { - let (handle, mut out_rx) = BrokerHandle::new(); - handle.set_connected(true); - - let h = handle.clone(); - let task = - tokio::spawn(async move { h.forward_kill("konishi".into(), Uuid::new_v4()).await }); - - let reply_to = match out_rx.recv().await.expect("ForwardKill went out") { - DaemonOutbound::ForwardKill { reply_to, .. } => reply_to, - other => panic!("expected ForwardKill, got {other:?}"), - }; - handle - .complete_forward_kill( - reply_to, - RpcResult::Err { - error: maestro_protocol::ErrorInfo { - code: "daemon_not_connected".into(), - message: "owner offline".into(), - }, - }, - ) - .await; - - match task.await.unwrap() { - Err(BrokerError::Registry(code, _)) => assert_eq!(code, "daemon_not_connected"), - other => panic!("expected Registry error, got {other:?}"), - } - } - - // ----- NAP-329: forward_list_dir ----- - - /// A `forward_list_dir` issues a `ForwardListDir` frame and resolves with - /// the listing once the registry's `ForwardListDirResult` is delivered. - #[tokio::test] - async fn forward_list_dir_round_trips_listing() { - let (handle, mut out_rx) = BrokerHandle::new(); - handle.set_connected(true); - - let h = handle.clone(); - let task = - tokio::spawn(async move { h.forward_list_dir("konishi".into(), "/work".into()).await }); - - let reply_to = match out_rx.recv().await.expect("ForwardListDir went out") { - DaemonOutbound::ForwardListDir { - reply_to, - daemon_id, - path, - } => { - assert_eq!(daemon_id, "konishi"); - assert_eq!(path, "/work"); - reply_to - } - other => panic!("expected ForwardListDir, got {other:?}"), - }; - handle - .complete_forward_list_dir( - reply_to, - RpcResult::Ok { - value: DirListing { - path: std::path::PathBuf::from("/work"), - entries: vec!["repo".into()], - truncated: false, - }, - }, - ) - .await; - - let got = task.await.unwrap().expect("list ok"); - assert_eq!(got.entries, vec!["repo".to_string()]); - } - - /// While disconnected, `forward_list_dir` fails fast and leaves no pending - /// entry (NAP-263 / NAP-259). - #[tokio::test] - async fn forward_list_dir_fails_fast_while_disconnected() { - let (handle, _out_rx) = BrokerHandle::new(); // starts disconnected - assert!(matches!( - handle - .forward_list_dir("konishi".into(), "/work".into()) - .await, - Err(BrokerError::NoControlLink) - )); - assert_eq!(handle.pending_list_dir.lock().unwrap().len(), 0); - } - - // ----- #26: forward_recent_dirs / forward_git_root ----- - - /// `forward_recent_dirs` issues a `ForwardRecentDirs` frame and resolves - /// with the recents once the registry's `ForwardRecentDirsResult` lands. - #[tokio::test] - async fn forward_recent_dirs_round_trips() { - use maestro_protocol::{RecentDir, RecentDirs}; - let (handle, mut out_rx) = BrokerHandle::new(); - handle.set_connected(true); - - let h = handle.clone(); - let task = tokio::spawn(async move { h.forward_recent_dirs("konishi".into()).await }); - - let reply_to = match out_rx.recv().await.expect("ForwardRecentDirs went out") { - DaemonOutbound::ForwardRecentDirs { - reply_to, - daemon_id, - } => { - assert_eq!(daemon_id, "konishi"); - reply_to - } - other => panic!("expected ForwardRecentDirs, got {other:?}"), - }; - handle - .complete_forward_recent_dirs( - reply_to, - RpcResult::Ok { - value: RecentDirs { - dirs: vec![RecentDir { - cwd: std::path::PathBuf::from("/work"), - last_command: "claude".into(), - last_used: chrono::Utc::now(), - }], - truncated: false, - }, - }, - ) - .await; - - let got = task.await.unwrap().expect("recents ok"); - assert_eq!(got.dirs.len(), 1); - assert_eq!(got.dirs[0].cwd, std::path::PathBuf::from("/work")); - } - - /// `forward_git_root` issues a `ForwardGitRoot` frame carrying the path and - /// resolves with the probe result. - #[tokio::test] - async fn forward_git_root_round_trips() { - use maestro_protocol::GitRoot; - let (handle, mut out_rx) = BrokerHandle::new(); - handle.set_connected(true); - - let h = handle.clone(); - let task = tokio::spawn(async move { - h.forward_git_root("konishi".into(), "/work/sub".into()) - .await - }); - - let reply_to = match out_rx.recv().await.expect("ForwardGitRoot went out") { - DaemonOutbound::ForwardGitRoot { - reply_to, - daemon_id, - path, - } => { - assert_eq!(daemon_id, "konishi"); - assert_eq!(path, "/work/sub"); - reply_to - } - other => panic!("expected ForwardGitRoot, got {other:?}"), - }; - handle - .complete_forward_git_root( - reply_to, - RpcResult::Ok { - value: GitRoot { - root: Some(std::path::PathBuf::from("/work")), - }, - }, - ) - .await; - - let got = task.await.unwrap().expect("gitroot ok"); - assert_eq!(got.root, Some(std::path::PathBuf::from("/work"))); - } - - /// While disconnected, both new broker calls fail fast and leave no pending - /// entry (NAP-263 / NAP-259). - #[tokio::test] - async fn forward_recents_and_gitroot_fail_fast_while_disconnected() { - let (handle, _out_rx) = BrokerHandle::new(); // starts disconnected - assert!(matches!( - handle.forward_recent_dirs("konishi".into()).await, - Err(BrokerError::NoControlLink) - )); - assert!(matches!( - handle.forward_git_root("konishi".into(), "/x".into()).await, - Err(BrokerError::NoControlLink) - )); - assert_eq!(handle.pending_recent_dirs.lock().unwrap().len(), 0); - assert_eq!(handle.pending_git_root.lock().unwrap().len(), 0); - } - - /// NAP-270: if the registry never replies, `resolve_attach` resolves to - /// `BrokerError::Timeout` after `RESOLVE_TIMEOUT` (driven here with a paused - /// clock so the test is instant). - #[tokio::test(start_paused = true)] - async fn broker_resolve_attach_times_out_when_no_reply() { - let (handle, mut out_rx) = BrokerHandle::new(); - handle.set_connected(true); - - let h = handle.clone(); - let task = tokio::spawn(async move { h.resolve_attach(Uuid::new_v4()).await }); - - // The request is out and the call is parked on the reply oneshot. - let _ = out_rx.recv().await.expect("ResolveAttach went out"); - // Never reply; jump past the RPC timeout. - tokio::time::advance(RESOLVE_TIMEOUT + Duration::from_secs(1)).await; - - match task.await.unwrap() { - Err(BrokerError::Timeout(_)) => {} - other => panic!("expected Timeout, got {other:?}"), - } - } -} diff --git a/crates/maestro-daemon/src/catalog_mirror.rs b/crates/maestro-daemon/src/catalog_mirror.rs deleted file mode 100644 index 62a9d06..0000000 --- a/crates/maestro-daemon/src/catalog_mirror.rs +++ /dev/null @@ -1,201 +0,0 @@ -//! Daemon-local mirror of the registry catalog (NAP-246). -//! -//! The registry fans out a stream of `RegistryEvent`s to each connected -//! daemon, filtered so each one only sees *peer* state. This module owns the -//! resulting in-memory copy and exposes read snapshots to the daemon's HTTP -//! handlers (`GET /api/sessions`, `GET /api/daemons`). -//! -//! The mirror does *not* include the daemon's own sessions — those come from -//! `SessionManager`. Combining the two for HTTP responses is the caller's -//! job (see `local_api.rs`). - -use std::collections::{BTreeMap, HashMap}; -use std::sync::Arc; - -use maestro_protocol::{DaemonInfo, RegistryEvent, Session}; -use tokio::sync::Mutex; -use uuid::Uuid; - -#[derive(Debug, Default)] -struct State { - daemons: HashMap, - /// `(daemon_id, session_id) -> Session` — mirrors the registry's catalog - /// keying so removals (which arrive as `(daemon_id, id)`) are O(1). - sessions: BTreeMap<(String, Uuid), Session>, -} - -#[derive(Debug, Clone, Default)] -pub struct CatalogMirror { - inner: Arc>, - /// Optional fan-out (NAP-248). When attached, every applied event is - /// re-published here so `WS /api/events` subscribers see peer state - /// changes the same way the registry's clients do. - bus: Option, -} - -impl CatalogMirror { - pub fn new() -> Self { - Self::default() - } - - /// Attach a [`LocalEventBus`](crate::local_events::LocalEventBus) so peer - /// catalog deltas reach the daemon's `/api/events` subscribers. - pub fn with_bus(mut self, bus: crate::local_events::LocalEventBus) -> Self { - self.bus = Some(bus); - self - } - - /// Apply one delta. Snapshot events replace the mirror wholesale; - /// `Lagged` resets it on the assumption that the next full snapshot will - /// arrive later (today the registry doesn't re-send one — see follow-up - /// notes in `handler.rs`). - pub async fn apply(&self, event: RegistryEvent) { - { - let mut s = self.inner.lock().await; - match &event { - RegistryEvent::Snapshot { daemons, sessions } => { - s.daemons.clear(); - s.sessions.clear(); - for d in daemons { - s.daemons.insert(d.daemon_id.clone(), d.clone()); - } - for sess in sessions { - s.sessions - .insert((sess.daemon_id.clone(), sess.id), sess.clone()); - } - } - RegistryEvent::DaemonAdded { daemon } => { - s.daemons.insert(daemon.daemon_id.clone(), daemon.clone()); - } - RegistryEvent::DaemonRemoved { daemon_id } => { - s.daemons.remove(daemon_id); - s.sessions.retain(|(d, _), _| d != daemon_id); - } - RegistryEvent::SessionAdded { session } - | RegistryEvent::SessionUpdated { session } => { - s.sessions - .insert((session.daemon_id.clone(), session.id), session.clone()); - } - RegistryEvent::SessionRemoved { daemon_id, id } => { - s.sessions.remove(&(daemon_id.clone(), *id)); - } - RegistryEvent::Lagged { skipped } => { - tracing::warn!( - skipped = *skipped, - "catalog mirror lagged — local view may be stale" - ); - } - } - } - // Re-publish AFTER releasing the lock so subscribers reading `daemons()` - // / `sessions()` in response observe the new state. - if let Some(bus) = &self.bus { - bus.publish(event); - } - } - - /// Snapshot the peer daemons currently mirrored. - pub async fn daemons(&self) -> Vec { - self.inner.lock().await.daemons.values().cloned().collect() - } - - /// Snapshot every peer session currently mirrored. - pub async fn sessions(&self) -> Vec { - self.inner.lock().await.sessions.values().cloned().collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::Utc; - use maestro_protocol::SessionStatus; - use std::collections::HashMap as StdHashMap; - - fn peer_session(daemon: &str, id_lo: u128) -> Session { - Session { - id: Uuid::from_u128(id_lo), - daemon_id: daemon.into(), - cwd: "/tmp".into(), - command: "bash".into(), - env: StdHashMap::new(), - metadata: StdHashMap::new(), - status: SessionStatus::Running, - created_at: Utc::now(), - foreground: None, - activity: Default::default(), - last_activity_at: chrono::Utc::now(), - - current_cwd: None, - } - } - - fn peer_daemon(daemon_id: &str) -> DaemonInfo { - DaemonInfo { - daemon_id: daemon_id.into(), - hostname: format!("{daemon_id}-host"), - version: "0.1.0".into(), - daemon_url: format!("ws://{daemon_id}:7070"), - home_dir: String::new(), - } - } - - #[tokio::test] - async fn snapshot_replaces_state() { - let m = CatalogMirror::new(); - m.apply(RegistryEvent::DaemonAdded { - daemon: peer_daemon("old"), - }) - .await; - m.apply(RegistryEvent::Snapshot { - daemons: vec![peer_daemon("new")], - sessions: vec![peer_session("new", 1)], - }) - .await; - let ds = m.daemons().await; - assert_eq!(ds.len(), 1); - assert_eq!(ds[0].daemon_id, "new"); - assert_eq!(m.sessions().await.len(), 1); - } - - #[tokio::test] - async fn daemon_removed_cascades_to_sessions() { - let m = CatalogMirror::new(); - m.apply(RegistryEvent::DaemonAdded { - daemon: peer_daemon("konishi"), - }) - .await; - m.apply(RegistryEvent::SessionAdded { - session: peer_session("konishi", 1), - }) - .await; - m.apply(RegistryEvent::SessionAdded { - session: peer_session("konishi", 2), - }) - .await; - assert_eq!(m.sessions().await.len(), 2); - - m.apply(RegistryEvent::DaemonRemoved { - daemon_id: "konishi".into(), - }) - .await; - assert!(m.daemons().await.is_empty()); - assert!(m.sessions().await.is_empty()); - } - - #[tokio::test] - async fn session_update_replaces_in_place() { - let m = CatalogMirror::new(); - m.apply(RegistryEvent::SessionAdded { - session: peer_session("konishi", 1), - }) - .await; - let mut updated = peer_session("konishi", 1); - updated.status = SessionStatus::Dead; - m.apply(RegistryEvent::SessionUpdated { session: updated }) - .await; - let after = m.sessions().await; - assert_eq!(after.len(), 1); - assert_eq!(after[0].status, SessionStatus::Dead); - } -} diff --git a/crates/maestro-daemon/src/cli.rs b/crates/maestro-daemon/src/cli.rs index 7fc6592..e537fd9 100644 --- a/crates/maestro-daemon/src/cli.rs +++ b/crates/maestro-daemon/src/cli.rs @@ -1,8 +1,5 @@ -//! Clap-derive subcommand for the daemon, plus a runner that wires together -//! everything the binary used to do in `main.rs`. -//! -//! Lifted out of the per-binary `main.rs` (NAP-245) so the umbrella `maestro` -//! binary can compose this subcommand alongside the CLI and registry. +//! Clap-derive subcommand for the daemon, plus a runner that wires everything +//! together. use std::error::Error; use std::path::PathBuf; @@ -15,52 +12,14 @@ use crate::sidecar::SidecarStore; use crate::tmux::Tmux; use crate::worktree::WorktreeManager; use crate::{ - attach, default_config_path, default_state_dir, load_config, local_api, run_configure, - spawn_session_publisher, ConfigureInputs, EnvOverrides, JwtVerifier, LocalEventBus, - PairAttachVerifier, RegistryClient, SessionManager, + default_config_path, default_state_dir, load_config, local_api, spawn_session_publisher, + EnvOverrides, LocalEventBus, SessionManager, }; #[derive(Debug, Subcommand)] pub enum DaemonCmd { /// Start the daemon. Default when no subcommand is given. Run, - /// Write `~/.config/maestro/config.toml` interactively or from flags. - Configure(ConfigureArgs), -} - -#[derive(Debug, clap::Args)] -pub struct ConfigureArgs { - /// Registry base URL (e.g. `https://maestro.example.com`). The daemon - /// derives its WS endpoint by scheme-swapping and appending `/daemon`. - #[arg(long)] - pub registry_url: Option, - /// Reachable URL clients dial for this daemon's attach WS. - #[arg(long)] - pub daemon_url: Option, - /// Shared token (MAESTRO_TOKEN). Tip: pipe from `pass` / `op` / `secret-tool`. - #[arg(long)] - pub token: Option, - /// Stable daemon ID. Defaults to the system hostname. - #[arg(long)] - pub daemon_id: Option, - /// Local bind address for the per-session WS endpoint. - #[arg(long)] - pub bind_addr: Option, - /// Override the daemon's state directory. - #[arg(long)] - pub state_dir: Option, - /// Override the worktree root. - #[arg(long)] - pub worktree_root: Option, - /// Override the recorded hostname (defaults to `gethostname(2)`). - #[arg(long)] - pub hostname: Option, - /// Overwrite an existing config file. - #[arg(long)] - pub force: bool, - /// Disable interactive prompts (errors if any required field is missing). - #[arg(long)] - pub non_interactive: bool, } /// Dispatch a parsed daemon subcommand. `config_override` is the unified @@ -71,7 +30,6 @@ pub async fn dispatch( ) -> Result<(), Box> { match cmd.unwrap_or(DaemonCmd::Run) { DaemonCmd::Run => run(config_override).await, - DaemonCmd::Configure(args) => configure(config_override, args), } } @@ -82,8 +40,7 @@ async fn run(config_override: Option) -> Result<(), Box> { }; let config = load_config(&config_path, EnvOverrides::from_env())?; tracing::info!( - daemon_id = %config.daemon_id, - registry = %config.registry_url, + hostname = %config.hostname, bind = %config.bind_addr, "loaded config" ); @@ -100,7 +57,6 @@ async fn run(config_override: Option) -> Result<(), Box> { None => Tmux::new(), }; let manager = SessionManager::new_with_replay( - config.daemon_id.clone(), tmux, sidecar, worktree_mgr, @@ -116,65 +72,20 @@ async fn run(config_override: Option) -> Result<(), Box> { tracing::info!(recovered, "adopted existing tmux sessions"); manager.start_liveness_watcher(DEFAULT_LIVENESS_INTERVAL); - let verifier = JwtVerifier::new(&config.token, &config.daemon_id); - let pair_verifier = PairAttachVerifier::new(&config.token, &config.daemon_id); - let events = LocalEventBus::new(); spawn_session_publisher(manager.clone(), events.clone()); - // Cross-host attach (T2) only works when both daemons advertise a matching - // perimeter. With none set, peers resolving this daemon's sessions get - // `no_perimeter_candidate` and the attach fails — warn loudly at startup so - // operators aren't left guessing (#39). - let perimeter = config.perimeter(); - if perimeter.is_none() { - tracing::warn!( - "no [daemon].perimeter_id/private_addr configured: cross-host attach (T2) is \ - unavailable — peers will get `no_perimeter_candidate`. Set a matching \ - perimeter_id on every host plus this host's reachable private_addr. \ - See docs/INSTALL.md § Cross-host attach." - ); - } - - let client = RegistryClient::new_with_bus( - config.registry_url.clone(), - config.daemon_id.clone(), + let router = local_api::router(local_api::state_for( + manager, config.hostname.clone(), - config.daemon_url.clone(), - config.home_dir.clone(), - config.token.clone(), - manager.clone(), - Some(events.clone()), - ) - .with_perimeter(perimeter); - let catalog = client.catalog(); - - let local_state = local_api::state_for( - manager.clone(), - config.hostname.clone(), - config.daemon_url.clone(), config.home_dir.clone(), config.spawn_presets.clone(), - catalog.clone(), events, - client.broker(), - ); - let broker_state = local_api::BrokerState { - manager: manager.clone(), - catalog, - broker: client.broker(), - self_daemon_id: std::sync::Arc::new(config.daemon_id.clone()), - self_perimeter_id: config.perimeter_id.clone().map(std::sync::Arc::new), - }; - let router = attach::router(manager.clone(), verifier, pair_verifier) - .merge(local_api::router(local_state)) - .merge(local_api::broker_router(broker_state)); + )); let listener = TcpListener::bind(config.bind_addr).await?; let bound = listener.local_addr()?; - tracing::info!(addr = %bound, "attach WS listening"); - - tokio::spawn(async move { client.run().await }); + tracing::info!(addr = %bound, "API listening"); let shutdown = async { let _ = tokio::signal::ctrl_c().await; @@ -186,23 +97,3 @@ async fn run(config_override: Option) -> Result<(), Box> { .await?; Ok(()) } - -fn configure(config_override: Option, args: ConfigureArgs) -> Result<(), Box> { - let path: PathBuf = match config_override { - Some(p) => p, - None => default_config_path()?, - }; - let inputs = ConfigureInputs { - registry_url: args.registry_url, - daemon_url: args.daemon_url, - token: args.token, - daemon_id: args.daemon_id, - bind_addr: args.bind_addr, - state_dir: args.state_dir, - worktree_root: args.worktree_root, - hostname: args.hostname, - }; - let written = run_configure(&path, inputs, !args.non_interactive, args.force)?; - eprintln!("wrote {}", written.display()); - Ok(()) -} diff --git a/crates/maestro-daemon/src/config.rs b/crates/maestro-daemon/src/config.rs index e753e63..a0068ad 100644 --- a/crates/maestro-daemon/src/config.rs +++ b/crates/maestro-daemon/src/config.rs @@ -1,26 +1,21 @@ //! Daemon view over the unified Maestro config. //! -//! Reads `[shared]` (registry_url, token) and `[daemon]` (daemon_id, -//! hostname, daemon_url, bind_addr, state_dir, worktree_root) from -//! `~/.config/maestro/config.toml`. +//! Reads `[daemon]` (hostname, bind_addr, state_dir, worktree_root, …) and +//! `[spawn]` from `~/.config/maestro/config.toml`. //! -//! Precedence per field: **env > file > default**. The daemon refuses -//! to start if `registry_url`, `daemon_url`, or `token` are absent -//! from both env and file. +//! Precedence per field: **env > file > default**. Every field has a default, +//! so a missing config file is fine — the daemon starts with defaults. //! //! Default config path: `~/.config/maestro/config.toml` (override via -//! `$MAESTRO_CONFIG`). If the file is missing entirely the daemon -//! errors with a pointer at `maestrod configure` — legacy -//! `~/.config/maestrod/config.toml` is no longer read. +//! `$MAESTRO_CONFIG`). use std::net::SocketAddr; use std::path::{Path, PathBuf}; -use maestro_config::{derive_daemon_ws_url, MaestroConfigFile}; -use maestro_protocol::PerimeterCandidate; +use maestro_config::MaestroConfigFile; pub use maestro_config::{ - default_config_path, resolve_config_path, DaemonSection, SharedSection, CONFIG_PATH_ENV, + default_config_path, resolve_config_path, DaemonSection, CONFIG_PATH_ENV, }; /// Default bind address when neither env nor file specify one. @@ -30,20 +25,8 @@ pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:7070"; /// `[daemon].spawn_history_days` is unset. `0` in config disables history. pub const DEFAULT_SPAWN_HISTORY_DAYS: u32 = 30; -/// Env var that overrides `[shared].registry_url`. -pub const ENV_REGISTRY_URL: &str = "MAESTRO_REGISTRY_URL"; -/// Env var that overrides `[shared].token`. -pub const ENV_TOKEN: &str = "MAESTRO_TOKEN"; -/// Env var that overrides `[daemon].daemon_id`. -pub const ENV_DAEMON_ID: &str = "MAESTRO_DAEMON_ID"; -/// Env var that overrides `[daemon].daemon_url`. -pub const ENV_DAEMON_URL: &str = "MAESTRO_DAEMON_URL"; /// Env var that overrides `[daemon].bind_addr`. pub const ENV_DAEMON_BIND_ADDR: &str = "MAESTRO_DAEMON_BIND_ADDR"; -/// Env var that overrides `[daemon].perimeter_id`. -pub const ENV_DAEMON_PERIMETER_ID: &str = "MAESTRO_DAEMON_PERIMETER_ID"; -/// Env var that overrides `[daemon].private_addr`. -pub const ENV_DAEMON_PRIVATE_ADDR: &str = "MAESTRO_DAEMON_PRIVATE_ADDR"; /// Env var that overrides `[daemon].tmux_tmpdir`. pub const ENV_DAEMON_TMUX_TMPDIR: &str = "MAESTRO_DAEMON_TMUX_TMPDIR"; @@ -57,12 +40,6 @@ pub enum ConfigError { Inner(#[from] maestro_config::ConfigError), #[error("io: {0}")] Io(#[from] std::io::Error), - #[error("missing required config field `{field}` — set it in {path:?} or via env var `{env}`")] - Missing { - field: &'static str, - env: &'static str, - path: PathBuf, - }, #[error("invalid [spawn].presets: {0}")] SpawnPresets(#[from] maestro_config::SpawnConfigError), } @@ -70,26 +47,15 @@ pub enum ConfigError { /// Resolved, validated daemon configuration ready to drive the binary. #[derive(Debug, Clone)] pub struct DaemonConfig { - pub daemon_id: String, + /// Hostname advertised to clients, for display only. pub hostname: String, /// Home directory of the OS user the daemon runs as, advertised to clients /// so they can prefill a spawn form's working directory. Empty when `$HOME` /// can't be resolved (NAP-328). pub home_dir: String, - /// Registry WS URL the daemon dials. Derived from `[shared].registry_url` - /// via [`maestro_config::derive_daemon_ws_url`]. - pub registry_url: String, pub bind_addr: SocketAddr, - pub daemon_url: String, - pub token: String, pub state_dir: PathBuf, pub worktree_root: PathBuf, - /// Operator-set perimeter identifier. Advertised to the registry in - /// heartbeats so peers can dial this daemon over T2 when they share it. - pub perimeter_id: Option, - /// Daemon's reachable address on the declared perimeter. Only meaningful - /// when `perimeter_id` is set. - pub private_addr: Option, /// Per-session PTY replay buffer cap in bytes (NAP-229). Each new attach /// receives this much of the prior PTY output before the live stream. /// Default `pty::DEFAULT_REPLAY_BYTES`; `0` disables. @@ -104,8 +70,8 @@ pub struct DaemonConfig { /// [`DEFAULT_SPAWN_HISTORY_DAYS`]. pub spawn_history_days: u32, /// Resolved spawn-form command presets (#22/#26): `[spawn].presets` (or the - /// built-ins) with the implicit `custom` tile appended. Advertised to the - /// web in `GET /api/web/info`. + /// built-ins) with the implicit `custom` tile appended. Advertised to + /// clients in `GET /api/info`. pub spawn_presets: Vec, } @@ -122,43 +88,13 @@ impl DaemonConfig { pub fn spawn_history_path(&self) -> PathBuf { self.state_dir.join("spawn-history.jsonl") } - - /// Combine the two perimeter config keys into the typed advertisement the - /// daemon broadcasts. A perimeter is only advertised when BOTH the id and - /// the private address are set — this is the single place the "both or - /// neither" invariant lives (NAP-272). A half-configured pair is dropped - /// with a warning rather than silently advertising a useless value. - pub fn perimeter(&self) -> Option { - match (self.perimeter_id.clone(), self.private_addr.clone()) { - (Some(perimeter_id), Some(private_addr)) => Some(PerimeterCandidate { - perimeter_id, - private_addr, - }), - (None, None) => None, - (id, addr) => { - tracing::warn!( - perimeter_id = ?id, - private_addr = ?addr, - "ignoring half-configured perimeter — set both [daemon].perimeter_id and \ - [daemon].private_addr, or neither" - ); - None - } - } - } } /// Env-var overrides applied by [`load_config`]. Tests pass explicit /// `Option`s; production reads from real env vars in `main`. #[derive(Debug, Clone, Default)] pub struct EnvOverrides { - pub daemon_id: Option, - pub registry_url: Option, pub bind_addr: Option, - pub daemon_url: Option, - pub token: Option, - pub perimeter_id: Option, - pub private_addr: Option, pub tmux_tmpdir: Option, } @@ -166,40 +102,17 @@ impl EnvOverrides { /// Read every supported env var from the process environment. pub fn from_env() -> Self { Self { - daemon_id: std::env::var(ENV_DAEMON_ID).ok(), - registry_url: std::env::var(ENV_REGISTRY_URL).ok(), bind_addr: std::env::var(ENV_DAEMON_BIND_ADDR).ok(), - daemon_url: std::env::var(ENV_DAEMON_URL).ok(), - token: std::env::var(ENV_TOKEN).ok(), - perimeter_id: std::env::var(ENV_DAEMON_PERIMETER_ID).ok(), - private_addr: std::env::var(ENV_DAEMON_PRIVATE_ADDR).ok(), tmux_tmpdir: std::env::var(ENV_DAEMON_TMUX_TMPDIR).ok(), } } } -/// Resolve the runtime configuration. Applies env overrides on top of the -/// unified file, fills defaults, validates the three required fields, and -/// derives the daemon's WS endpoint from the shared registry URL. -/// -/// Returns [`maestro_config::ConfigError::MissingFile`] (wrapped) when the -/// file is absent and not all required fields come from env vars — this is -/// the hard-cutover error that points the operator at `maestrod configure`. +/// Resolve the runtime configuration: env overrides on top of the unified +/// file, then defaults. A missing file is not an error. pub fn load_config(path: &Path, env: EnvOverrides) -> Result { - let file = if path.exists() { - MaestroConfigFile::load(path)? - } else if env_has_all_required(&env) { - // Container / systemd path: every required field comes from env, - // so the on-disk file is genuinely optional. - MaestroConfigFile::default() - } else { - return Err(maestro_config::ConfigError::MissingFile { - path: path.to_path_buf(), - } - .into()); - }; + let file = MaestroConfigFile::load_or_default(path)?; - let shared = file.shared; let daemon = file.daemon; // Resolve spawn presets up front so a bad `[spawn].presets` (empty/dup id, // empty command) refuses startup with a clear error, like other config @@ -210,32 +123,6 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result maestro_config::parse_bind_addr(&raw, ENV_DAEMON_BIND_ADDR)?, @@ -250,8 +137,6 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result Result Result bool { - env.registry_url.is_some() && env.daemon_url.is_some() && env.token.is_some() -} - pub fn default_state_dir() -> Result { Ok(maestro_config::default_state_dir("maestro")?) } @@ -307,60 +182,27 @@ pub fn system_hostname() -> String { mod tests { use super::*; - fn minimal_file() -> &'static str { - r#" -[shared] -registry_url = "https://r.example" -token = "abc" - -[daemon] -daemon_url = "ws://localhost:7070" -"# - } - #[test] - fn minimal_file_defaults_the_rest() { + fn empty_file_defaults_everything() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); - std::fs::write(&path, minimal_file()).unwrap(); + std::fs::write(&path, "").unwrap(); let cfg = load_config(&path, EnvOverrides::default()).unwrap(); - assert_eq!(cfg.registry_url, "wss://r.example/daemon"); - assert_eq!(cfg.daemon_url, "ws://localhost:7070"); - assert_eq!(cfg.token, "abc"); assert_eq!(cfg.bind_addr, default_bind_addr()); assert!(cfg.state_dir.ends_with("maestro")); assert!(cfg.worktree_root.ends_with("worktrees")); - assert_eq!(cfg.daemon_id, cfg.hostname); assert!(!cfg.hostname.is_empty()); } #[test] - fn missing_file_with_full_env_works() { - // systemd / container scenario: no config file, everything in env. + fn missing_file_is_not_an_error() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("does-not-exist.toml"); - let env = EnvOverrides { - registry_url: Some("http://r.example".into()), - daemon_url: Some("ws://x:7070".into()), - token: Some("t".into()), - ..EnvOverrides::default() - }; - let cfg = load_config(&path, env).unwrap(); - assert_eq!(cfg.registry_url, "ws://r.example/daemon"); - assert_eq!(cfg.token, "t"); + let cfg = load_config(&path, EnvOverrides::default()).unwrap(); + assert_eq!(cfg.bind_addr, default_bind_addr()); assert!(!path.exists(), "load_config must not create the file"); } - #[test] - fn missing_file_without_full_env_errors_with_hard_cutover_message() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("does-not-exist.toml"); - let err = load_config(&path, EnvOverrides::default()).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("maestrod configure"), "msg: {msg}"); - assert!(msg.contains("Legacy"), "msg: {msg}"); - } - #[test] fn env_overrides_file() { let dir = tempfile::tempdir().unwrap(); @@ -368,68 +210,24 @@ daemon_url = "ws://localhost:7070" std::fs::write( &path, r#" -[shared] -registry_url = "http://from-file" -token = "from-file" - [daemon] -daemon_id = "from-file" -daemon_url = "ws://from-file:1" bind_addr = "127.0.0.1:1" "#, ) .unwrap(); let env = EnvOverrides { - daemon_id: Some("from-env".into()), - registry_url: Some("https://from-env".into()), bind_addr: Some("0.0.0.0:9999".into()), - daemon_url: Some("ws://from-env:1".into()), - token: Some("from-env".into()), ..EnvOverrides::default() }; let cfg = load_config(&path, env).unwrap(); - assert_eq!(cfg.daemon_id, "from-env"); - assert_eq!(cfg.registry_url, "wss://from-env/daemon"); - assert_eq!(cfg.daemon_url, "ws://from-env:1"); - assert_eq!(cfg.token, "from-env"); assert_eq!(cfg.bind_addr, "0.0.0.0:9999".parse().unwrap()); } - #[test] - fn missing_required_field_reports_path_and_env_name() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write( - &path, - r#" -[shared] -registry_url = "http://r" - -[daemon] -daemon_url = "ws://x" -"#, - ) - .unwrap(); - let err = load_config(&path, EnvOverrides::default()).unwrap_err(); - match err { - ConfigError::Missing { - field, - env, - path: p, - } => { - assert_eq!(field, "shared.token"); - assert_eq!(env, ENV_TOKEN); - assert_eq!(p, path); - } - other => panic!("expected Missing(shared.token), got {other:?}"), - } - } - #[test] fn invalid_env_bind_addr_returns_diagnostic() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); - std::fs::write(&path, minimal_file()).unwrap(); + std::fs::write(&path, "").unwrap(); let env = EnvOverrides { bind_addr: Some("not-an-addr".into()), ..EnvOverrides::default() @@ -447,97 +245,27 @@ daemon_url = "ws://x" } #[test] - fn daemon_id_defaults_to_hostname() { + fn hostname_from_file_wins_over_gethostname() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); std::fs::write( &path, r#" -[shared] -registry_url = "http://r" -token = "t" - [daemon] hostname = "vm-42" -daemon_url = "ws://x" "#, ) .unwrap(); let cfg = load_config(&path, EnvOverrides::default()).unwrap(); - assert_eq!(cfg.daemon_id, "vm-42"); assert_eq!(cfg.hostname, "vm-42"); } - #[test] - fn registry_url_must_be_http_or_https() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write( - &path, - r#" -[shared] -registry_url = "ws://r/daemon" -token = "t" - -[daemon] -daemon_url = "ws://x" -"#, - ) - .unwrap(); - let err = load_config(&path, EnvOverrides::default()).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("registry_url"), "msg: {msg}"); - } - - #[test] - fn perimeter_fields_round_trip_through_file_and_env() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write( - &path, - r#" -[shared] -registry_url = "http://r" -token = "t" - -[daemon] -daemon_url = "ws://x" -perimeter_id = "wg-naps62" -private_addr = "10.0.0.7:7070" -"#, - ) - .unwrap(); - let cfg = load_config(&path, EnvOverrides::default()).unwrap(); - assert_eq!(cfg.perimeter_id.as_deref(), Some("wg-naps62")); - assert_eq!(cfg.private_addr.as_deref(), Some("10.0.0.7:7070")); - - // Env beats file. - let env = EnvOverrides { - perimeter_id: Some("lan-home".into()), - private_addr: Some("192.168.1.10:7070".into()), - ..EnvOverrides::default() - }; - let cfg = load_config(&path, env).unwrap(); - assert_eq!(cfg.perimeter_id.as_deref(), Some("lan-home")); - assert_eq!(cfg.private_addr.as_deref(), Some("192.168.1.10:7070")); - } - - #[test] - fn perimeter_fields_default_to_none() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write(&path, minimal_file()).unwrap(); - let cfg = load_config(&path, EnvOverrides::default()).unwrap(); - assert!(cfg.perimeter_id.is_none()); - assert!(cfg.private_addr.is_none()); - } - #[test] fn spawn_history_days_defaults_and_overrides() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); - std::fs::write(&path, minimal_file()).unwrap(); + std::fs::write(&path, "").unwrap(); let cfg = load_config(&path, EnvOverrides::default()).unwrap(); assert_eq!(cfg.spawn_history_days, DEFAULT_SPAWN_HISTORY_DAYS); assert!(cfg.spawn_history_path().ends_with("spawn-history.jsonl")); @@ -545,12 +273,7 @@ private_addr = "10.0.0.7:7070" std::fs::write( &path, r#" -[shared] -registry_url = "http://r" -token = "t" - [daemon] -daemon_url = "ws://x" spawn_history_days = 0 "#, ) @@ -576,12 +299,7 @@ spawn_history_days = 0 std::fs::write( &path, r#" -[shared] -registry_url = "http://r" -token = "t" - [daemon] -daemon_url = "ws://x" tmux_tmpdir = "/from-file" "#, ) @@ -601,12 +319,7 @@ tmux_tmpdir = "/from-file" std::fs::write( &path, r#" -[shared] -registry_url = "http://r" -token = "t" - [daemon] -daemon_url = "ws://x" tmux_tmpdir = "/from-file" "#, ) @@ -619,7 +332,7 @@ tmux_tmpdir = "/from-file" fn tmux_tmpdir_defaults_to_xdg_runtime_dir_when_unset() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); - std::fs::write(&path, minimal_file()).unwrap(); + std::fs::write(&path, "").unwrap(); let cfg = load_config(&path, EnvOverrides::default()).unwrap(); // dirs::runtime_dir() is `Some(_)` on any Linux host with // $XDG_RUNTIME_DIR set (CI included). When it's `None`, the daemon diff --git a/crates/maestro-daemon/src/configure.rs b/crates/maestro-daemon/src/configure.rs deleted file mode 100644 index 5230577..0000000 --- a/crates/maestro-daemon/src/configure.rs +++ /dev/null @@ -1,300 +0,0 @@ -//! `maestrod configure` — interactive + flag-driven config bootstrap. -//! -//! Writes the unified Maestro config file at `~/.config/maestro/config.toml` -//! (or `$MAESTRO_CONFIG`) from a mix of CLI flags and (when stdin is a TTY) -//! prompts. Refuses to overwrite an existing file unless `--force` is passed. -//! -//! With `--force`, the daemon-relevant sections (`[shared]` and `[daemon]`) -//! are rewritten, but any `[cli]` / `[registry]` sections already in the file -//! are preserved. - -use std::io::{self, BufRead, IsTerminal, Write}; -use std::net::SocketAddr; -use std::path::{Path, PathBuf}; - -use maestro_config::{DaemonSection, MaestroConfigFile, SharedSection}; - -use crate::config::{system_hostname, ConfigError, DEFAULT_BIND_ADDR}; - -#[derive(Debug, thiserror::Error)] -pub enum ConfigureError { - #[error("config: {0}")] - Config(#[from] ConfigError), - #[error("io: {0}")] - Io(#[from] io::Error), - #[error("invalid bind_addr {input:?}: {source}")] - InvalidBindAddr { - input: String, - source: std::net::AddrParseError, - }, - #[error("{path:?} already exists — pass --force to overwrite")] - AlreadyExists { path: PathBuf }, - #[error( - "missing required field(s) in non-interactive mode: {missing} \ - (pass via flags or run interactively)" - )] - MissingRequired { missing: String }, -} - -/// User-supplied inputs from flags. `None` means "not given on the CLI"; -/// the configurator will prompt or default depending on context. -#[derive(Debug, Default, Clone)] -pub struct ConfigureInputs { - pub registry_url: Option, - pub daemon_url: Option, - pub token: Option, - pub daemon_id: Option, - pub bind_addr: Option, - pub state_dir: Option, - pub worktree_root: Option, - pub hostname: Option, -} - -/// Run `maestrod configure`. Writes the file at `path` (creating parents). -/// -/// `interactive` controls prompt behaviour: -/// - `true` → prompt for any required field that's missing from `inputs`. -/// - `false` → error out instead (used in scripts / containers). -/// -/// Returns the path that was written. -pub fn run( - path: &Path, - inputs: ConfigureInputs, - interactive: bool, - force: bool, -) -> Result { - if path.exists() && !force { - return Err(ConfigureError::AlreadyExists { - path: path.to_path_buf(), - }); - } - - let stdin = io::stdin(); - let stdout = io::stdout(); - let mut handle = stdout.lock(); - let interactive_ok = interactive && stdin.is_terminal(); - - let registry_url = resolve_required( - "registry_url", - "Registry base URL (e.g. https://maestro.example.com)", - inputs.registry_url, - &mut handle, - &stdin, - interactive_ok, - )?; - let daemon_url = resolve_required( - "daemon_url", - "Reachable URL for this daemon (e.g. ws://laptop.tail-abc.ts.net:7070)", - inputs.daemon_url, - &mut handle, - &stdin, - interactive_ok, - )?; - let token = resolve_required( - "token", - "Shared token (MAESTRO_TOKEN; paste from the registry)", - inputs.token, - &mut handle, - &stdin, - interactive_ok, - )?; - - let host = inputs.hostname.unwrap_or_else(system_hostname); - let daemon_id = inputs.daemon_id.unwrap_or_else(|| host.clone()); - let bind_addr = match inputs.bind_addr.as_deref() { - Some(raw) => Some(parse_bind_addr(raw)?), - None => None, - }; - - // Read-modify-write: preserve `[cli]` / `[registry]` sections that may - // already exist (e.g. on a host that runs both the registry and the - // daemon, or one that's been used as a CLI client). - let mut file = MaestroConfigFile::load_or_default(path).map_err(ConfigError::from)?; - file.shared = SharedSection { - registry_url: Some(registry_url), - token: Some(token), - }; - file.daemon = DaemonSection { - daemon_id: Some(daemon_id), - hostname: Some(host), - daemon_url: Some(daemon_url), - bind_addr, - state_dir: inputs.state_dir, - worktree_root: inputs.worktree_root, - // `maestrod configure` doesn't prompt for perimeter fields yet — those - // are operator-edited in config.toml directly. Leave None on bootstrap. - perimeter_id: None, - private_addr: None, - // None ⇒ daemon falls back to pty::DEFAULT_REPLAY_BYTES. Override in - // config.toml as `[daemon] pty_replay_bytes = ...` (NAP-229). - pty_replay_bytes: None, - // None ⇒ daemon resolves to $XDG_RUNTIME_DIR at startup. Override in - // config.toml as `[daemon] tmux_tmpdir = "/tmp"` (NAP-234). - tmux_tmpdir: None, - // None ⇒ daemon defaults to DEFAULT_SPAWN_HISTORY_DAYS (30). Override - // in config.toml as `[daemon] spawn_history_days = 0` to disable (#24). - spawn_history_days: None, - }; - file.save(path).map_err(ConfigError::from)?; - let _ = handle; // stdout handle only used during prompts; binary prints "wrote ..." - Ok(path.to_path_buf()) -} - -fn resolve_required( - field: &'static str, - prompt: &str, - provided: Option, - out: &mut impl Write, - stdin: &io::Stdin, - interactive_ok: bool, -) -> Result { - if let Some(v) = provided.filter(|v| !v.is_empty()) { - return Ok(v); - } - if !interactive_ok { - return Err(ConfigureError::MissingRequired { - missing: field.to_string(), - }); - } - write!(out, "{prompt}: ")?; - out.flush()?; - let mut line = String::new(); - stdin.lock().read_line(&mut line)?; - let trimmed = line.trim().to_string(); - if trimmed.is_empty() { - return Err(ConfigureError::MissingRequired { - missing: field.to_string(), - }); - } - Ok(trimmed) -} - -fn parse_bind_addr(raw: &str) -> Result { - raw.parse() - .map_err(|source| ConfigureError::InvalidBindAddr { - input: raw.to_string(), - source, - }) -} - -/// Suggest a sensible default bind address. Currently `DEFAULT_BIND_ADDR` -/// from `config.rs`; here so callers don't import both modules. -pub fn default_bind_addr_hint() -> &'static str { - DEFAULT_BIND_ADDR -} - -#[cfg(test)] -mod tests { - use super::*; - - fn full_inputs() -> ConfigureInputs { - ConfigureInputs { - registry_url: Some("https://reg.example".into()), - daemon_url: Some("ws://localhost:7070".into()), - token: Some("secret".into()), - daemon_id: Some("laptop".into()), - bind_addr: Some("0.0.0.0:7070".into()), - state_dir: None, - worktree_root: None, - hostname: Some("laptop.lan".into()), - } - } - - #[test] - fn non_interactive_with_full_flags_writes_unified_file() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - run(&path, full_inputs(), false, false).unwrap(); - let written = MaestroConfigFile::load(&path).unwrap(); - assert_eq!( - written.shared.registry_url.as_deref(), - Some("https://reg.example") - ); - assert_eq!(written.shared.token.as_deref(), Some("secret")); - assert_eq!(written.daemon.daemon_id.as_deref(), Some("laptop")); - assert_eq!( - written.daemon.bind_addr.unwrap().to_string(), - "0.0.0.0:7070" - ); - } - - #[test] - fn non_interactive_missing_required_errors() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - let mut inputs = full_inputs(); - inputs.token = None; - let err = run(&path, inputs, false, false).unwrap_err(); - match err { - ConfigureError::MissingRequired { missing } => assert_eq!(missing, "token"), - other => panic!("expected MissingRequired(token), got {other:?}"), - } - assert!(!path.exists()); - } - - #[test] - fn refuses_to_overwrite_without_force() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write(&path, "existing").unwrap(); - let err = run(&path, full_inputs(), false, false).unwrap_err(); - assert!(matches!(err, ConfigureError::AlreadyExists { .. })); - // file untouched - assert_eq!(std::fs::read_to_string(&path).unwrap(), "existing"); - } - - #[test] - fn force_preserves_unrelated_sections() { - // Existing file has [cli] (set by the user) and [registry] (set by - // a co-located registry on the same host). `configure --force` - // should overwrite [shared] + [daemon] but leave those alone. - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write( - &path, - r#" -[cli] -cookie = "keep-me" - -[registry] -bind_addr = "0.0.0.0:8080" -"#, - ) - .unwrap(); - run(&path, full_inputs(), false, true).unwrap(); - let written = MaestroConfigFile::load(&path).unwrap(); - assert_eq!(written.shared.token.as_deref(), Some("secret")); - assert_eq!(written.cli.cookie.as_deref(), Some("keep-me")); - assert_eq!( - written.registry.bind_addr, - Some("0.0.0.0:8080".parse().unwrap()) - ); - } - - #[test] - fn invalid_bind_addr_flag_errors() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - let mut inputs = full_inputs(); - inputs.bind_addr = Some("nope".into()); - let err = run(&path, inputs, false, false).unwrap_err(); - assert!(matches!(err, ConfigureError::InvalidBindAddr { .. })); - } - - #[test] - fn daemon_id_defaults_to_hostname_when_neither_given() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - let inputs = ConfigureInputs { - registry_url: Some("http://r".into()), - daemon_url: Some("ws://x".into()), - token: Some("t".into()), - hostname: Some("override-host".into()), - ..ConfigureInputs::default() - }; - run(&path, inputs, false, false).unwrap(); - let written = MaestroConfigFile::load(&path).unwrap(); - assert_eq!(written.daemon.daemon_id.as_deref(), Some("override-host")); - assert_eq!(written.daemon.hostname.as_deref(), Some("override-host")); - } -} diff --git a/crates/maestro-daemon/src/jwt.rs b/crates/maestro-daemon/src/jwt.rs deleted file mode 100644 index 1075a33..0000000 --- a/crates/maestro-daemon/src/jwt.rs +++ /dev/null @@ -1,265 +0,0 @@ -//! HS256 JWT validation. Tokens are minted by the registry (NAP-215) and -//! validated locally by each daemon using the shared secret — no callback to -//! the registry on the attach hot path. Spec §Auth. - -use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; -use maestro_protocol::{JwtClaims, PairAttachClaims, REGISTRY_ISSUER}; -use uuid::Uuid; - -#[derive(Debug, thiserror::Error)] -pub enum JwtError { - #[error("token decode failed: {0}")] - Decode(#[from] jsonwebtoken::errors::Error), - #[error("daemon_id mismatch (token wants `{wanted}`, daemon is `{ours}`)")] - WrongDaemon { wanted: String, ours: String }, - #[error("session mismatch (token wants `{wanted}`, path is `{path}`)")] - WrongSession { wanted: Uuid, path: Uuid }, -} - -#[derive(Clone)] -pub struct JwtVerifier { - decoding: DecodingKey, - daemon_id: String, -} - -impl std::fmt::Debug for JwtVerifier { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("JwtVerifier") - .field("daemon_id", &self.daemon_id) - .field("decoding", &"") - .finish() - } -} - -impl JwtVerifier { - pub fn new(secret: &str, daemon_id: impl Into) -> Self { - Self { - decoding: DecodingKey::from_secret(secret.as_bytes()), - daemon_id: daemon_id.into(), - } - } - - /// Decodes and validates the token. Returns the claims on success. - /// `exp` is enforced by `jsonwebtoken` with default 60s leeway. - pub fn verify(&self, token: &str) -> Result { - let validation = Validation::new(Algorithm::HS256); - let data = decode::(token, &self.decoding, &validation)?; - if data.claims.daemon_id != self.daemon_id { - return Err(JwtError::WrongDaemon { - wanted: data.claims.daemon_id, - ours: self.daemon_id.clone(), - }); - } - Ok(data.claims) - } -} - -/// Mint a token. The daemon doesn't issue JWTs in production — this is here -/// for tests and for the future registry crate's convenience. -pub fn issue(secret: &str, claims: &JwtClaims) -> Result { - let header = Header::new(Algorithm::HS256); - let key = EncodingKey::from_secret(secret.as_bytes()); - encode(&header, claims, &key) -} - -/// Verifier for pair-scoped attach JWTs (NAP-247). Checks signature, `exp`, -/// `iss == REGISTRY_ISSUER`, `aud == self.daemon_id`, and `sid == session_id` -/// — i.e. the token was minted by the registry for *this* daemon and *this* -/// session, and hasn't expired. This is the session-binding guarantee the -/// network perimeter can't provide: a host the firewall admits still can't -/// attach to a session it wasn't handed, or replay a token to another -/// session/daemon. -/// -/// It does **not** gate on the home daemon's identity (no `allow_inbound` -/// allow-list, NAP-339): the operator's perimeter/firewall is the trust -/// boundary for *which hosts may connect*, so every daemon on the perimeter is -/// equally trusted to attach. Re-introducing a per-pair gate later is a -/// verify-time check, not an architecture change. -#[derive(Clone)] -pub struct PairAttachVerifier { - decoding: DecodingKey, - daemon_id: String, -} - -impl std::fmt::Debug for PairAttachVerifier { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("PairAttachVerifier") - .field("daemon_id", &self.daemon_id) - .field("decoding", &"") - .finish() - } -} - -impl PairAttachVerifier { - pub fn new(secret: &str, daemon_id: impl Into) -> Self { - Self { - decoding: DecodingKey::from_secret(secret.as_bytes()), - daemon_id: daemon_id.into(), - } - } - - /// Validate the token for the path-bound `session_id`. On success returns - /// the home daemon's id (`sub`) for logging/tracing. - pub fn verify(&self, token: &str, session_id: Uuid) -> Result { - let mut validation = Validation::new(Algorithm::HS256); - // Require explicit audience match — the jsonwebtoken crate validates - // `aud` against this set on decode. - validation.set_audience(&[self.daemon_id.as_str()]); - // Bind to the registry issuer so a differently-purposed token signed - // with the same shared secret (e.g. an auth cookie) can't validate as a - // pair-attach credential (NAP-266). - validation.set_issuer(&[REGISTRY_ISSUER]); - let data = decode::(token, &self.decoding, &validation)?; - let claims = data.claims; - if claims.aud != self.daemon_id { - return Err(JwtError::WrongDaemon { - wanted: claims.aud, - ours: self.daemon_id.clone(), - }); - } - if claims.sid != session_id { - return Err(JwtError::WrongSession { - wanted: claims.sid, - path: session_id, - }); - } - Ok(claims.sub) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::{Duration, Utc}; - use uuid::Uuid; - - fn claims_for(daemon_id: &str, valid_seconds: i64) -> JwtClaims { - JwtClaims { - session_id: Uuid::new_v4(), - daemon_id: daemon_id.into(), - exp: (Utc::now() + Duration::seconds(valid_seconds)).timestamp(), - iat: Some(Utc::now().timestamp()), - } - } - - #[test] - fn good_token_round_trip() { - let secret = "super-secret"; - let v = JwtVerifier::new(secret, "laptop"); - let c = claims_for("laptop", 300); - let token = issue(secret, &c).unwrap(); - let back = v.verify(&token).unwrap(); - assert_eq!(back.daemon_id, "laptop"); - assert_eq!(back.session_id, c.session_id); - } - - #[test] - fn rejects_wrong_daemon() { - let secret = "s"; - let v = JwtVerifier::new(secret, "laptop"); - let c = claims_for("dev-vm", 300); - let token = issue(secret, &c).unwrap(); - let err = v.verify(&token).unwrap_err(); - assert!(matches!(err, JwtError::WrongDaemon { .. })); - } - - #[test] - fn rejects_expired() { - let secret = "s"; - let v = JwtVerifier::new(secret, "laptop"); - let c = claims_for("laptop", -120); // expired 2 minutes ago, beyond default 60s leeway - let token = issue(secret, &c).unwrap(); - assert!(v.verify(&token).is_err()); - } - - #[test] - fn rejects_wrong_secret() { - let v = JwtVerifier::new("right", "laptop"); - let token = issue("wrong", &claims_for("laptop", 300)).unwrap(); - assert!(v.verify(&token).is_err()); - } - - // ----- PairAttachVerifier ----- - - fn pair_claims(home: &str, target: &str, sid: Uuid, valid_seconds: i64) -> PairAttachClaims { - PairAttachClaims { - iss: REGISTRY_ISSUER.into(), - sub: home.into(), - aud: target.into(), - sid, - exp: (Utc::now() + Duration::seconds(valid_seconds)).timestamp(), - } - } - - fn issue_pair(secret: &str, claims: &PairAttachClaims) -> String { - let header = Header::new(Algorithm::HS256); - let key = EncodingKey::from_secret(secret.as_bytes()); - encode(&header, claims, &key).unwrap() - } - - #[test] - fn pair_verifier_accepts_registry_minted_token() { - let secret = "s"; - let v = PairAttachVerifier::new(secret, "konishi"); - let sid = Uuid::new_v4(); - // The home daemon's identity is no longer gated (NAP-339) — any - // registry-minted, correctly-bound token for this daemon+session passes. - let token = issue_pair(secret, &pair_claims("laptop", "konishi", sid, 60)); - let sub = v.verify(&token, sid).unwrap(); - assert_eq!(sub, "laptop"); - } - - #[test] - fn pair_verifier_rejects_wrong_audience() { - let secret = "s"; - let v = PairAttachVerifier::new(secret, "konishi"); - let sid = Uuid::new_v4(); - // Token addressed to a different target — must not pass our checks. - let token = issue_pair(secret, &pair_claims("laptop", "elsewhere", sid, 60)); - assert!(v.verify(&token, sid).is_err()); - } - - #[test] - fn pair_verifier_rejects_foreign_issuer() { - // NAP-266: a token signed with the right secret but a non-registry - // `iss` (e.g. minted for some other purpose) must not validate as a - // pair-attach credential. - let secret = "s"; - let v = PairAttachVerifier::new(secret, "konishi"); - let sid = Uuid::new_v4(); - let mut claims = pair_claims("laptop", "konishi", sid, 60); - claims.iss = "somebody-else".into(); - let token = issue_pair(secret, &claims); - assert!(v.verify(&token, sid).is_err()); - } - - #[test] - fn pair_verifier_rejects_session_id_mismatch() { - let secret = "s"; - let v = PairAttachVerifier::new(secret, "konishi"); - let sid = Uuid::new_v4(); - let path = Uuid::new_v4(); - let token = issue_pair(secret, &pair_claims("laptop", "konishi", sid, 60)); - assert!(matches!( - v.verify(&token, path).unwrap_err(), - JwtError::WrongSession { .. } - )); - } - - #[test] - fn pair_verifier_rejects_expired() { - let secret = "s"; - let v = PairAttachVerifier::new(secret, "konishi"); - let sid = Uuid::new_v4(); - let token = issue_pair(secret, &pair_claims("laptop", "konishi", sid, -120)); - assert!(v.verify(&token, sid).is_err()); - } - - #[test] - fn pair_verifier_rejects_wrong_secret() { - let v = PairAttachVerifier::new("right", "konishi"); - let sid = Uuid::new_v4(); - let token = issue_pair("wrong", &pair_claims("laptop", "konishi", sid, 60)); - assert!(v.verify(&token, sid).is_err()); - } -} diff --git a/crates/maestro-daemon/src/lib.rs b/crates/maestro-daemon/src/lib.rs index 18618b0..95c0be4 100644 --- a/crates/maestro-daemon/src/lib.rs +++ b/crates/maestro-daemon/src/lib.rs @@ -1,37 +1,28 @@ -//! `maestrod` — the per-host Maestro daemon. +//! `maestro daemon` — the per-host Maestro daemon. //! -//! See `docs/superpowers/specs/2026-05-24-maestro-design.md` §Daemon for the -//! component's responsibilities. This library exposes the pieces; the binary -//! entry point in `src/main.rs` wires them together. +//! Wraps tmux, owns every session on the machine, and serves them to clients +//! (CLI, TUI, web) over a plain HTTP + WebSocket API on a local bind address. +//! No registry, no peers, no auth layer: reaching the bind address is the +//! authorization. #![deny(missing_debug_implementations)] pub mod activity; pub mod attach; -pub mod broker; -pub mod catalog_mirror; pub mod cli; pub mod config; -pub mod configure; pub mod dirlist; pub mod gitroot; -pub mod jwt; pub mod local_api; pub mod local_events; pub mod proc; pub mod pty; -pub mod registry_client; pub mod session; pub mod sidecar; pub mod spawn_history; pub mod tmux; pub mod worktree; -pub use broker::{BrokerError, BrokerHandle}; -pub use catalog_mirror::CatalogMirror; pub use config::{default_config_path, default_state_dir, load_config, DaemonConfig, EnvOverrides}; -pub use configure::{run as run_configure, ConfigureError, ConfigureInputs}; -pub use jwt::{issue as issue_jwt, JwtError, JwtVerifier, PairAttachVerifier}; pub use local_events::{spawn_session_publisher, LocalEventBus}; -pub use registry_client::{ClientError, RegistryClient, HEARTBEAT_INTERVAL}; pub use session::{SessionError, SessionEvent, SessionManager}; diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index 221528d..970d64e 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -1,27 +1,14 @@ -//! Daemon-side HTTP API for local clients (NAP-243). +//! Daemon-side HTTP API for clients (NAP-243). //! -//! These routes live on the daemon's existing `bind_addr` (default -//! `127.0.0.1:7070`) alongside the legacy `/sessions/:id/stream` WS endpoint. -//! They're the contract `maestro-web` (NAP-241) and the rewired CLI (NAP-244) -//! call against — a same-origin, same-trust surface that doesn't need a -//! registry round-trip for catalog reads. +//! These routes live on the daemon's `bind_addr` (default `127.0.0.1:7070`). +//! They're the contract `maestro-web`, the TUI, and the CLI call against. //! -//! ## Trust model (v1) +//! ## Trust model //! //! Whoever can reach `bind_addr` can use these routes. No JWT, no cookie. The //! default `127.0.0.1` bind makes this same-trust as tmux (one-user-wide). -//! Operators who bind a perimeter address (LAN/WG IP) opt into one-segment-wide -//! trust — same model `maestro-web` documents per the connection-orchestration -//! spec. Cookie auth can be added later if a real use case demands it. -//! -//! ## Scope -//! -//! The daemon surfaces its own sessions plus the peer-catalog mirror (NAP-246). -//! Cross-daemon operations are brokered over the registry control link: -//! `WS /api/attach/:id` dials a peer over T2 (NAP-247), and `POST /api/sessions` -//! / `DELETE /api/sessions/:id` for a peer-owned target relay a create/kill RPC -//! to the owning daemon (NAP-327) — control-plane only, no session bytes -//! traverse the registry. +//! Operators who bind a LAN/VPN address opt into one-segment-wide trust — the +//! perimeter is theirs to choose, Maestro does not police it. use std::sync::Arc; @@ -32,27 +19,26 @@ use axum::routing::{any, delete, get}; use axum::{Json, Router}; use maestro_config::SpawnPreset; use maestro_protocol::{ - CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, RecentDirs, Session, - PROTOCOL_VERSION, + CatalogEvent, CreateSessionRequest, ErrorInfo, GitRoot, Session, PROTOCOL_VERSION, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::catalog_mirror::CatalogMirror; use crate::local_events::LocalEventBus; use crate::session::SessionManager; -/// Daemon identity returned by `GET /api/web/info`. Browsers render this in the -/// header ("you're connected to konishi"); the field set mirrors `DaemonInfo` -/// plus the running daemon's protocol version and its spawn presets (#26). +/// Daemon identity returned by `GET /api/info`. Clients render this in the +/// header ("you're connected to konishi"), plus the daemon's protocol version +/// and its spawn presets (#26). #[derive(Debug, Clone, Serialize)] -pub struct WebInfo { - pub daemon_id: String, +pub struct DaemonInfo { pub hostname: String, pub version: String, - pub daemon_url: String, + /// Home directory of the OS user this daemon runs as, so clients can + /// prefill a spawn form's working directory. Empty if unresolved. + pub home_dir: String, /// Spawn-form command presets resolved from `[spawn].presets` (#22), with - /// the implicit `custom` tile appended. The web renders these as one-click + /// the implicit `custom` tile appended. Clients render these as one-click /// tiles; it rides this existing payload so opening the form needs no extra /// round-trip (#26). pub presets: Vec, @@ -62,82 +48,48 @@ pub struct WebInfo { pub struct LocalApiState { pub manager: SessionManager, pub hostname: Arc, - pub daemon_url: Arc, /// Home directory of the OS user this daemon runs as, advertised so clients /// can prefill a spawn form's working directory. Empty if unresolved. pub home_dir: Arc, - /// Resolved spawn presets (#22/#26) advertised in `GET /api/web/info`. + /// Resolved spawn presets (#22/#26) advertised in `GET /api/info`. pub presets: Arc>, - /// Peer-only mirror of the global catalog. Empty until the daemon's - /// registry client connects and receives the snapshot. - pub catalog: CatalogMirror, - /// Daemon-local fan-out of own + peer catalog deltas (NAP-248). The - /// `/api/events` WS subscribes here; everything else just reads REST. + /// Fan-out of session deltas (NAP-248). The `/api/events` WS subscribes + /// here; everything else just reads REST. pub events: LocalEventBus, - /// Control link to the registry, used to broker cross-daemon create/kill - /// (NAP-327). When the link is down, brokered ops fail fast. - pub broker: BrokerHandle, } pub fn router(state: LocalApiState) -> Router { Router::new() .route("/api/sessions", get(list_sessions).post(create_session)) .route("/api/sessions/:id", delete(kill_session)) - .route("/api/daemons", get(list_daemons)) - .route("/api/daemons/:id/dirs", get(list_dir)) - .route("/api/daemons/:id/recent-dirs", get(recent_dirs)) - .route("/api/daemons/:id/gitroot", get(git_root)) - .route("/api/web/info", get(web_info)) + .route("/api/dirs", get(list_dir)) + .route("/api/recent-dirs", get(recent_dirs)) + .route("/api/gitroot", get(git_root)) + .route("/api/info", get(daemon_info)) .route("/api/events", any(events_ws)) + .route("/api/attach/:id", any(attach_ws)) .with_state(state) } async fn list_sessions(State(s): State) -> Json> { - let mut out = s.manager.snapshot().await; - out.extend(s.catalog.sessions().await); - Json(out) + Json(s.manager.snapshot().await) } -async fn list_daemons(State(s): State) -> Json> { - let self_info = DaemonInfo { - daemon_id: s.manager.daemon_id().to_string(), - hostname: (*s.hostname).clone(), - version: PROTOCOL_VERSION.into(), - daemon_url: (*s.daemon_url).clone(), - home_dir: (*s.home_dir).clone(), - }; - let mut out = vec![self_info]; - out.extend(s.catalog.daemons().await); - Json(out) -} - -/// Query string for `GET /api/daemons/:id/dirs`. `path` is the directory to -/// list (the parent of what the user is typing); empty/`~`/relative are -/// resolved against the target daemon's home dir. +/// Query string for `GET /api/dirs`. `path` is the directory to list (the +/// parent of what the user is typing); empty/`~`/relative are resolved against +/// the daemon's home dir. #[derive(Debug, Deserialize)] struct DirQuery { #[serde(default)] path: String, } -/// List one directory's immediate subdirectories on `daemon_id`'s host, for the -/// spawn form's working-directory autocomplete (NAP-329). Lists locally when -/// the target is this daemon; otherwise brokers a `ListDir` to the owning -/// daemon over the registry control link. -async fn list_dir( - State(s): State, - Path(daemon_id): Path, - Query(q): Query, -) -> Response { - if daemon_id == s.manager.daemon_id() { - return match crate::dirlist::list_dir(&s.home_dir, &q.path) { - Ok(listing) => Json(listing).into_response(), - Err(info) => error_response(dir_error_status(&info.code), &info.code, &info.message), - }; - } - match s.broker.forward_list_dir(daemon_id, q.path).await { - Ok(listing) => Json::(listing).into_response(), - Err(e) => broker_error_response(&e), +/// List one directory's immediate subdirectories, for the spawn form's +/// working-directory autocomplete (NAP-329). +async fn list_dir(State(s): State, Query(q): Query) -> Response { + match crate::dirlist::list_dir(&s.home_dir, &q.path) { + Ok(listing) => Json(listing).into_response(), + Err(info) => error_response(dir_error_status(&info.code), &info.code, &info.message), } } @@ -151,48 +103,27 @@ fn dir_error_status(code: &str) -> StatusCode { } } -/// Recent working dirs on `daemon_id`'s host for the spawn form's recents -/// picker (#26). Reads locally when the target is this daemon; otherwise brokers -/// a `RecentDirs` query to the owning daemon over the registry control link. -async fn recent_dirs(State(s): State, Path(daemon_id): Path) -> Response { - if daemon_id == s.manager.daemon_id() { - let value = crate::spawn_history::to_wire(s.manager.recent_dirs()); - return Json(value).into_response(); - } - match s.broker.forward_recent_dirs(daemon_id).await { - Ok(recents) => Json::(recents).into_response(), - Err(e) => broker_error_response(&e), - } +/// Recent working dirs for the spawn form's recents picker (#26). +async fn recent_dirs(State(s): State) -> Response { + Json(crate::spawn_history::to_wire(s.manager.recent_dirs())).into_response() } -/// Probe the enclosing git repo of `path` on `daemon_id`'s host, to infer the -/// worktree `base_repo` for the spawn form (#26). Local when the target is this -/// daemon; otherwise brokered to the owning daemon. `path` is resolved against -/// the target daemon's home dir (empty/`~`/relative), like [`list_dir`]. -async fn git_root( - State(s): State, - Path(daemon_id): Path, - Query(q): Query, -) -> Response { - if daemon_id == s.manager.daemon_id() { - let dir = crate::dirlist::resolve(&s.home_dir, &q.path); - let value = GitRoot { - root: crate::gitroot::find_root(&dir), - }; - return Json(value).into_response(); - } - match s.broker.forward_git_root(daemon_id, q.path).await { - Ok(root) => Json::(root).into_response(), - Err(e) => broker_error_response(&e), - } +/// Probe the enclosing git repo of `path`, to infer the worktree `base_repo` +/// for the spawn form (#26). `path` is resolved against the daemon's home dir +/// (empty/`~`/relative), like [`list_dir`]. +async fn git_root(State(s): State, Query(q): Query) -> Response { + let dir = crate::dirlist::resolve(&s.home_dir, &q.path); + Json(GitRoot { + root: crate::gitroot::find_root(&dir), + }) + .into_response() } -async fn web_info(State(s): State) -> Json { - Json(WebInfo { - daemon_id: s.manager.daemon_id().to_string(), +async fn daemon_info(State(s): State) -> Json { + Json(DaemonInfo { hostname: (*s.hostname).clone(), version: PROTOCOL_VERSION.into(), - daemon_url: (*s.daemon_url).clone(), + home_dir: (*s.home_dir).clone(), presets: (*s.presets).clone(), }) } @@ -201,14 +132,6 @@ async fn create_session( State(s): State, Json(body): Json, ) -> Response { - if body.daemon_id != s.manager.daemon_id() { - // Cross-daemon create: broker it to the owning daemon over the registry - // control link (NAP-327). Control-plane only — the owner does the spawn. - return match s.broker.forward_create(body).await { - Ok(session) => (StatusCode::CREATED, Json(session)).into_response(), - Err(e) => broker_error_response(&e), - }; - } match s.manager.create(body).await { Ok(session) => (StatusCode::CREATED, Json(session)).into_response(), Err(e) => { @@ -224,27 +147,11 @@ async fn create_session( async fn kill_session(State(s): State, Path(id): Path) -> Response { if !s.manager.has(id).await { - // Not ours — if a peer owns it, broker the kill to that daemon over the - // registry control link (NAP-327). Only a session no daemon knows about - // is a real 404. - let owner = s - .catalog - .sessions() - .await - .into_iter() - .find(|sess| sess.id == id) - .map(|sess| sess.daemon_id); - return match owner { - Some(daemon_id) => match s.broker.forward_kill(daemon_id, id).await { - Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(e) => broker_error_response(&e), - }, - None => error_response( - StatusCode::NOT_FOUND, - "unknown_session", - "no such session on this daemon or any known peer", - ), - }; + return error_response( + StatusCode::NOT_FOUND, + "unknown_session", + "no such session on this daemon", + ); } match s.manager.kill(id).await { Ok(()) => StatusCode::NO_CONTENT.into_response(), @@ -259,29 +166,6 @@ async fn kill_session(State(s): State, Path(id): Path) -> R } } -/// Map a [`BrokerError`] from a forwarded create/kill (NAP-327) to a structured -/// HTTP error. Mirrors `broker_error_code`'s code points but also picks the -/// status the CLI/web client should react to. -fn broker_error_response(e: &BrokerError) -> Response { - let (code, message) = broker_error_code(e); - let status = match e { - // Registry link down or back-pressured: the home daemon can't broker - // right now — a retryable 503. - BrokerError::NoControlLink => StatusCode::SERVICE_UNAVAILABLE, - BrokerError::Timeout(_) => StatusCode::GATEWAY_TIMEOUT, - BrokerError::ReplyClosed => StatusCode::BAD_GATEWAY, - // Registry- or owner-reported faults. Distinguish "nothing owns this" - // (404) from spawn/transport failures (502). - BrokerError::Registry(c, _) => match c.as_str() { - "unknown_session" => StatusCode::NOT_FOUND, - "daemon_not_connected" | "daemon_gone" => StatusCode::BAD_GATEWAY, - "rpc_timeout" => StatusCode::GATEWAY_TIMEOUT, - _ => StatusCode::BAD_GATEWAY, - }, - }; - error_response(status, code, &message) -} - fn error_response(status: StatusCode, code: &str, message: &str) -> Response { ( status, @@ -297,31 +181,46 @@ fn error_response(status: StatusCode, code: &str, message: &str) -> Response { /// Convenience constructor — pulls the bits `LocalApiState` needs from /// `DaemonConfig` so callers don't have to assemble it by hand. -#[allow(clippy::too_many_arguments)] pub fn state_for( manager: SessionManager, hostname: impl Into, - daemon_url: impl Into, home_dir: impl Into, presets: Vec, - catalog: CatalogMirror, events: LocalEventBus, - broker: BrokerHandle, ) -> LocalApiState { LocalApiState { manager, hostname: Arc::new(hostname.into()), - daemon_url: Arc::new(daemon_url.into()), home_dir: Arc::new(home_dir.into()), presets: Arc::new(presets), - catalog, events, - broker, } } // --------------------------------------------------------------------------- -// WS /api/events — live catalog deltas for browser/CLI subscribers (NAP-248) +// WS /api/attach/:id — raw PTY bytes for one session +// --------------------------------------------------------------------------- + +async fn attach_ws( + Path(id): Path, + State(s): State, + ws: axum::extract::WebSocketUpgrade, +) -> Result { + if !s.manager.has(id).await { + return Err(error_response( + StatusCode::NOT_FOUND, + "unknown_session", + "no such session on this daemon", + )); + } + let manager = s.manager.clone(); + Ok(ws.on_upgrade(move |socket| async move { + let _ = crate::attach::serve(socket, manager, id).await; + })) +} + +// --------------------------------------------------------------------------- +// WS /api/events — live catalog deltas for browser/TUI subscribers (NAP-248) // --------------------------------------------------------------------------- async fn events_ws( @@ -339,7 +238,6 @@ async fn pump_events( ) -> Result<(), axum::Error> { use axum::extract::ws::Message; use futures_util::{SinkExt, StreamExt}; - use maestro_protocol::RegistryEvent; use tokio::sync::broadcast::error::RecvError; let (mut sink, mut stream) = socket.split(); @@ -349,18 +247,9 @@ async fn pump_events( // idempotent for repeated *Added events, so any overlap is harmless. let mut rx = s.events.subscribe(); - let self_info = DaemonInfo { - daemon_id: s.manager.daemon_id().to_string(), - hostname: (*s.hostname).clone(), - version: PROTOCOL_VERSION.into(), - daemon_url: (*s.daemon_url).clone(), - home_dir: (*s.home_dir).clone(), + let snapshot = CatalogEvent::Snapshot { + sessions: s.manager.snapshot().await, }; - let mut daemons = vec![self_info]; - daemons.extend(s.catalog.daemons().await); - let mut sessions = s.manager.snapshot().await; - sessions.extend(s.catalog.sessions().await); - let snapshot = RegistryEvent::Snapshot { daemons, sessions }; let text = serde_json::to_string(&snapshot).expect("snapshot encode"); if sink.send(Message::Text(text)).await.is_err() { return Ok(()); @@ -371,7 +260,7 @@ async fn pump_events( ev = rx.recv() => { let event = match ev { Ok(e) => e, - Err(RecvError::Lagged(n)) => RegistryEvent::Lagged { skipped: n }, + Err(RecvError::Lagged(n)) => CatalogEvent::Lagged { skipped: n }, Err(RecvError::Closed) => break, }; let text = serde_json::to_string(&event).expect("event encode"); @@ -393,205 +282,3 @@ async fn pump_events( } Ok(()) } - -// --------------------------------------------------------------------------- -// Broker WS — `WS /api/attach/:session_id` -// --------------------------------------------------------------------------- -// -// Home-daemon broker (NAP-243 + NAP-247): -// -// 1. Session owned by self → delegate to local PTY subscribe. -// 2. Session owned by a peer daemon → ResolveAttach via the registry, dial -// the peer over T2, proxy frames. -// 3. Unknown session → 404. - -use crate::broker::{BrokerError, BrokerHandle}; - -#[derive(Debug, Clone)] -pub struct BrokerState { - pub manager: SessionManager, - pub catalog: CatalogMirror, - pub broker: BrokerHandle, - pub self_daemon_id: Arc, - pub self_perimeter_id: Option>, -} - -pub fn broker_router(state: BrokerState) -> Router { - Router::new() - .route("/api/attach/:id", any(broker_attach_ws)) - .with_state(state) -} - -async fn broker_attach_ws( - Path(id): Path, - State(state): State, - ws: axum::extract::WebSocketUpgrade, -) -> Result { - if state.manager.has(id).await { - // Local short-circuit. - let manager = state.manager.clone(); - return Ok(ws.on_upgrade(move |socket| async move { - let _ = crate::attach::serve_local(socket, manager, id).await; - })); - } - - // Look up which peer owns the session via the catalog mirror. - let peer = state - .catalog - .sessions() - .await - .into_iter() - .find(|s| s.id == id) - .map(|s| s.daemon_id); - let Some(peer_daemon_id) = peer else { - return Err(error_response( - StatusCode::NOT_FOUND, - "unknown_session", - "no such session on this daemon or any known peer", - )); - }; - - // ResolveAttach via the control link. - let candidates = match state.broker.resolve_attach(id).await { - Ok(c) => c, - Err(e) => { - tracing::warn!(%id, %peer_daemon_id, error = %e, "ResolveAttach failed"); - let (code, msg) = broker_error_code(&e); - return Err(error_response(StatusCode::BAD_GATEWAY, code, &msg)); - } - }; - - // Pick a transport. T1 is canceled (NAP-235); T3 is gated (NAP-238). - // For T2 we need both daemons to declare the same perimeter id, and the - // candidate must include a private address. - let perimeter = candidates.perimeter.clone(); - let our_perimeter = state.self_perimeter_id.as_deref().map(|s| s.as_str()); - let dial_url = match (perimeter, our_perimeter) { - (Some(p), Some(ours)) if p.perimeter_id == ours => { - format!( - "ws://{}/sessions/{}/stream?token={}", - p.private_addr, - id, - urlencoding::encode(&candidates.jwt), - ) - } - (Some(_), Some(_)) => { - return Err(error_response( - StatusCode::BAD_GATEWAY, - "perimeter_mismatch", - "peer is on a different perimeter; T2 unavailable, T1/T3 not implemented", - )); - } - (Some(_), None) => { - return Err(error_response( - StatusCode::BAD_GATEWAY, - "no_local_perimeter", - "this daemon has no perimeter_id set; can't use T2", - )); - } - (None, _) => { - return Err(error_response( - StatusCode::BAD_GATEWAY, - "no_perimeter_candidate", - "peer hasn't advertised a perimeter; T2 unavailable", - )); - } - }; - - // Dial the peer BEFORE completing the upgrade. A failure here — peer down, - // perimeter-side rejection, or the peer's verifier refusing the pair JWT - // (all of which the peer reports pre-upgrade) — then surfaces as a - // structured `502` the client can act on, instead of an opaque - // post-upgrade close indistinguishable from a clean session end (NAP-265). - let upstream = match tokio_tungstenite::connect_async(&dial_url).await { - Ok((up, _)) => up, - Err(e) => { - tracing::warn!(%id, url = %dial_url, error = %e, "T2 dial to peer failed"); - return Err(error_response( - StatusCode::BAD_GATEWAY, - "peer_unreachable", - &format!("could not open the data-plane connection to the session's daemon: {e}"), - )); - } - }; - - Ok(ws.on_upgrade(move |socket| async move { - let _ = pump_broker_ws(socket, upstream).await; - })) -} - -/// Bidirectional pump between the client WS and an already-connected peer WS. -async fn pump_broker_ws( - client: axum::extract::ws::WebSocket, - upstream: tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, -) -> Result<(), axum::Error> { - use axum::extract::ws::Message as AxMessage; - use futures_util::{SinkExt, StreamExt}; - use tokio_tungstenite::tungstenite::Message as WsMessage; - - let (mut client_sink, mut client_stream) = client.split(); - let (mut up_sink, mut up_stream) = upstream.split(); - - let client_to_up = async { - while let Some(Ok(msg)) = client_stream.next().await { - let frame = match msg { - AxMessage::Text(t) => WsMessage::Text(t), - AxMessage::Binary(b) => WsMessage::Binary(b), - AxMessage::Ping(p) => WsMessage::Ping(p), - AxMessage::Pong(p) => WsMessage::Pong(p), - AxMessage::Close(_) => break, - }; - if up_sink.send(frame).await.is_err() { - break; - } - } - }; - let up_to_client = async { - while let Some(Ok(msg)) = up_stream.next().await { - let frame = match msg { - WsMessage::Text(t) => AxMessage::Text(t), - WsMessage::Binary(b) => AxMessage::Binary(b), - WsMessage::Ping(p) => AxMessage::Ping(p), - WsMessage::Pong(p) => AxMessage::Pong(p), - WsMessage::Close(_) | WsMessage::Frame(_) => break, - }; - if client_sink.send(frame).await.is_err() { - break; - } - } - }; - - tokio::select! { - _ = client_to_up => {} - _ = up_to_client => {} - } - Ok(()) -} - -fn broker_error_code(e: &BrokerError) -> (&'static str, String) { - match e { - BrokerError::NoControlLink => ( - "no_registry_link", - "this daemon is not connected to the registry".into(), - ), - BrokerError::ReplyClosed => ("reply_closed", e.to_string()), - BrokerError::Timeout(_) => ("resolve_timeout", e.to_string()), - BrokerError::Registry(code, msg) => ( - // Code points are stable across the protocol so the client can - // distinguish "unknown_session" from "self_target" etc. - match code.as_str() { - "unknown_session" => "unknown_session", - "self_target" => "self_target", - "jwt_failed" => "jwt_failed", - // Forwarded create/kill transport faults (NAP-327). - "daemon_not_connected" => "daemon_not_connected", - "daemon_gone" => "daemon_gone", - "rpc_timeout" => "rpc_timeout", - _ => "registry_error", - }, - msg.clone(), - ), - } -} diff --git a/crates/maestro-daemon/src/local_events.rs b/crates/maestro-daemon/src/local_events.rs index 399fd08..bac1aca 100644 --- a/crates/maestro-daemon/src/local_events.rs +++ b/crates/maestro-daemon/src/local_events.rs @@ -1,37 +1,26 @@ -//! Daemon-local fan-out of `RegistryEvent`s for the `WS /api/events` -//! endpoint (NAP-248). +//! Fan-out of session deltas for the `WS /api/events` endpoint (NAP-248). //! -//! Two sources feed this bus: -//! -//! 1. **Own-session events** — the daemon's `SessionManager::events()` stream, -//! translated from `SessionEvent` into the matching `RegistryEvent` variant -//! by [`spawn_session_publisher`]. -//! 2. **Peer-catalog events** — applied to the daemon's [`CatalogMirror`] when -//! the registry fans them out (NAP-246). `CatalogMirror::apply` publishes -//! each event here after mutating its local view. -//! -//! Subscribers get the merged stream. The `WS /api/events` handler builds a -//! one-shot `Snapshot` of own ∪ peer state on connect, then forwards live -//! deltas until the client disconnects. `Lagged` follows the registry's -//! existing convention — clients resync via REST. +//! [`spawn_session_publisher`] translates the `SessionManager::events()` stream +//! from `SessionEvent` into the matching [`CatalogEvent`] variant and publishes +//! it here. The `WS /api/events` handler sends a one-shot `Snapshot` on connect, +//! then forwards live deltas until the client disconnects. `Lagged` tells +//! clients to resync via REST. use std::sync::Arc; -use maestro_protocol::RegistryEvent; +use maestro_protocol::CatalogEvent; use tokio::sync::broadcast; use crate::session::{SessionEvent, SessionManager}; /// Capacity of the per-bus broadcast channel. Sourced from the shared -/// [`maestro_config::BroadcastTuning`], which the registry's -/// `EVENT_CHANNEL_CAPACITY` also tracks, so a slow client behaves the same on -/// either side (NAP-273). +/// [`maestro_config::BroadcastTuning`] (NAP-273). const BUS_CAPACITY: usize = maestro_config::BroadcastTuning::DEFAULT.events_capacity; -/// Cheap-to-clone handle around a `broadcast::Sender`. +/// Cheap-to-clone handle around a `broadcast::Sender`. #[derive(Debug, Clone)] pub struct LocalEventBus { - sender: Arc>, + sender: Arc>, } impl LocalEventBus { @@ -43,11 +32,11 @@ impl LocalEventBus { } /// Push an event onto the bus. No-op if there are no subscribers. - pub fn publish(&self, event: RegistryEvent) { + pub fn publish(&self, event: CatalogEvent) { let _ = self.sender.send(event); } - pub fn subscribe(&self) -> broadcast::Receiver { + pub fn subscribe(&self) -> broadcast::Receiver { self.sender.subscribe() } } @@ -59,28 +48,24 @@ impl Default for LocalEventBus { } /// Spawn a task that converts each `SessionEvent` from `manager` into the -/// matching `RegistryEvent` and publishes it on `bus`. Exits when the +/// matching `CatalogEvent` and publishes it on `bus`. Exits when the /// `SessionManager` is dropped (the events channel closes). pub fn spawn_session_publisher(manager: SessionManager, bus: LocalEventBus) { - let daemon_id = manager.daemon_id().to_string(); tokio::spawn(async move { let mut rx = manager.events(); loop { match rx.recv().await { Ok(SessionEvent::Added(session)) => { - bus.publish(RegistryEvent::SessionAdded { session }); + bus.publish(CatalogEvent::SessionAdded { session }); } Ok(SessionEvent::Updated(session)) => { - bus.publish(RegistryEvent::SessionUpdated { session }); + bus.publish(CatalogEvent::SessionUpdated { session }); } Ok(SessionEvent::Removed(id)) => { - bus.publish(RegistryEvent::SessionRemoved { - daemon_id: daemon_id.clone(), - id, - }); + bus.publish(CatalogEvent::SessionRemoved { id }); } Err(broadcast::error::RecvError::Lagged(skipped)) => { - bus.publish(RegistryEvent::Lagged { skipped }); + bus.publish(CatalogEvent::Lagged { skipped }); } Err(broadcast::error::RecvError::Closed) => return, } @@ -96,9 +81,9 @@ mod tests { async fn publish_visible_to_subscriber() { let bus = LocalEventBus::new(); let mut rx = bus.subscribe(); - bus.publish(RegistryEvent::Lagged { skipped: 7 }); + bus.publish(CatalogEvent::Lagged { skipped: 7 }); match rx.recv().await { - Ok(RegistryEvent::Lagged { skipped }) => assert_eq!(skipped, 7), + Ok(CatalogEvent::Lagged { skipped }) => assert_eq!(skipped, 7), other => panic!("unexpected: {other:?}"), } } @@ -107,9 +92,7 @@ mod tests { async fn no_subscribers_swallows_event() { let bus = LocalEventBus::new(); // No one's listening — `publish` must not panic and must not block. - bus.publish(RegistryEvent::DaemonRemoved { - daemon_id: "ghost".into(), - }); + bus.publish(CatalogEvent::Lagged { skipped: 1 }); } #[tokio::test] @@ -117,9 +100,9 @@ mod tests { let a = LocalEventBus::new(); let b = a.clone(); let mut rx = a.subscribe(); - b.publish(RegistryEvent::Lagged { skipped: 3 }); + b.publish(CatalogEvent::Lagged { skipped: 3 }); match rx.recv().await { - Ok(RegistryEvent::Lagged { skipped }) => assert_eq!(skipped, 3), + Ok(CatalogEvent::Lagged { skipped }) => assert_eq!(skipped, 3), other => panic!("unexpected: {other:?}"), } } diff --git a/crates/maestro-daemon/src/registry_client.rs b/crates/maestro-daemon/src/registry_client.rs deleted file mode 100644 index 1f24f6d..0000000 --- a/crates/maestro-daemon/src/registry_client.rs +++ /dev/null @@ -1,773 +0,0 @@ -//! Outbound WebSocket client to the always-on registry. -//! -//! Holds a single persistent connection. On disconnect, reconnects with -//! exponential backoff. Per connection: -//! -//! 1. Send `Hello { daemon_id, hostname, version, token }`. -//! 2. Send `SessionsSnapshot { sessions: snapshot() }`. -//! 3. Drive a `select!` loop that pumps: -//! - inbound `CreateSession` / `KillSession` → `SessionManager` → reply -//! - outbound `SessionAdded/Removed/Updated` from the local event bus, -//! except the event that an in-flight RPC reply already conveys, which is -//! swallowed so the registry writes each change once (see [`RpcCovered`]). -//! - a 10s `Heartbeat` ticker. - -use std::collections::HashSet; -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use maestro_protocol::{ - DaemonInbound, DaemonOutbound, PerimeterCandidate, RpcResult, PROTOCOL_VERSION, -}; -use tokio::sync::broadcast::error::RecvError; -use tokio_tungstenite::tungstenite::Message; -use uuid::Uuid; - -use tokio::sync::mpsc; - -use crate::broker::BrokerHandle; -use crate::catalog_mirror::CatalogMirror; -use crate::session::{SessionEvent, SessionManager}; - -/// Per-message heartbeat interval. Less than the registry's 30s timeout, with -/// headroom for jitter. Spec §"Wire protocol (D↔R)". -pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10); -/// Bounds on the reconnect backoff. -const BACKOFF_MIN: Duration = Duration::from_millis(250); -const BACKOFF_MAX: Duration = Duration::from_secs(30); - -/// Session ids whose next lifecycle event the registry already learns about via -/// an RPC reply, so forwarding the matching async `SessionAdded` / -/// `SessionRemoved` would be a redundant *second* write to the registry catalog -/// (NAP-268). -/// -/// A registry-driven `CreateSession` / `KillSession` is reported back to the -/// registry in two ways: the `CreateSessionResult` / `KillSessionResult` reply -/// (which the registry applies eagerly), *and* the async lifecycle event this -/// daemon emits on its own `SessionManager` stream. Both used to land, so the -/// registry's catalog write had to be idempotent to absorb the duplicate. We -/// drop the duplicate at the source instead: when an RPC creates or kills a -/// session, its id is parked here and the matching async event is swallowed -/// (consumed exactly once) before it reaches the registry. Spontaneous changes -/// — sessions created via the daemon's local HTTP API, or reaped on their own — -/// are not parked and still forward normally, since the registry has no other -/// way to learn about them. -/// -/// Tracked per-connection: a reconnect starts fresh (the post-handshake -/// `SessionsSnapshot` reconciles the registry's full set anyway). -#[derive(Debug, Default)] -struct RpcCovered { - added: HashSet, - removed: HashSet, -} - -#[derive(Debug, thiserror::Error)] -pub enum ClientError { - #[error("ws error: {0}")] - Ws(#[from] tokio_tungstenite::tungstenite::Error), - #[error("json: {0}")] - Json(#[from] serde_json::Error), - #[error("registry handshake failed: {0}")] - Handshake(String), -} - -#[derive(Debug)] -pub struct RegistryClient { - registry_url: String, - daemon_id: String, - hostname: String, - daemon_url: String, - home_dir: String, - token: String, - /// Operator-set perimeter (id + reachable address) advertised in heartbeats - /// so peer daemons can dial this one over T2 when they share the same - /// perimeter. Typed so "both or neither" holds by construction (NAP-272). - perimeter: Option, - manager: SessionManager, - /// Local mirror of the global catalog (peer daemons + peer sessions), - /// populated from `DaemonInbound::CatalogEvent` frames the registry - /// fans out. - catalog: CatalogMirror, - /// Shared with HTTP handlers (NAP-247): when one calls `resolve_attach`, - /// we route the request through this client's control link and route the - /// reply back via the handle's pending map. - broker: BrokerHandle, - /// Outbound channel from `BrokerHandle::resolve_attach` (and future broker - /// requests). Drained inside `connect_once` and forwarded into the WS. - broker_rx: mpsc::Receiver, -} - -impl RegistryClient { - #[allow(clippy::too_many_arguments)] - pub fn new( - registry_url: impl Into, - daemon_id: impl Into, - hostname: impl Into, - daemon_url: impl Into, - home_dir: impl Into, - token: impl Into, - manager: SessionManager, - ) -> Self { - Self::new_with_bus( - registry_url, - daemon_id, - hostname, - daemon_url, - home_dir, - token, - manager, - None, - ) - } - - /// Variant that wires a [`LocalEventBus`](crate::LocalEventBus) into the - /// catalog mirror so peer deltas reach `WS /api/events` (NAP-248). - #[allow(clippy::too_many_arguments)] - pub fn new_with_bus( - registry_url: impl Into, - daemon_id: impl Into, - hostname: impl Into, - daemon_url: impl Into, - home_dir: impl Into, - token: impl Into, - manager: SessionManager, - bus: Option, - ) -> Self { - let (broker, broker_rx) = BrokerHandle::new(); - let catalog = match &bus { - Some(b) => CatalogMirror::new().with_bus(b.clone()), - None => CatalogMirror::new(), - }; - Self { - registry_url: registry_url.into(), - daemon_id: daemon_id.into(), - hostname: hostname.into(), - daemon_url: daemon_url.into(), - home_dir: home_dir.into(), - token: token.into(), - perimeter: None, - manager, - catalog, - broker, - broker_rx, - } - } - - /// Cheap clone of the broker handle for HTTP handlers (NAP-247). Sends - /// here are pumped through this client's control link. - pub fn broker(&self) -> BrokerHandle { - self.broker.clone() - } - - /// Cheap handle to the daemon's peer-catalog mirror. Clone freely; - /// internal state is `Arc>`. - pub fn catalog(&self) -> CatalogMirror { - self.catalog.clone() - } - - /// Set the perimeter advertised in heartbeats. The typed argument encodes - /// "both id and addr, or neither" — a private address without a perimeter - /// id is no longer representable (NAP-272). - pub fn with_perimeter(mut self, perimeter: Option) -> Self { - self.perimeter = perimeter; - self - } - - /// Run forever, reconnecting with exponential backoff between attempts. - /// Returns only if cancelled by the caller dropping the task. - pub async fn run(mut self) { - let mut backoff = BACKOFF_MIN; - loop { - let outcome = self.connect_once().await; - // Whatever happened, the link is down now — make the broker fail - // fast until the next successful handshake re-marks it up (NAP-263). - self.broker.set_connected(false); - match outcome { - Ok(()) => { - tracing::info!("registry connection ended cleanly"); - backoff = BACKOFF_MIN; - } - Err(e) => { - tracing::warn!(error = %e, ?backoff, "registry connection failed"); - } - } - tokio::time::sleep(backoff).await; - backoff = (backoff * 2).min(BACKOFF_MAX); - } - } - - async fn connect_once(&mut self) -> Result<(), ClientError> { - let (ws, _) = tokio_tungstenite::connect_async(&self.registry_url).await?; - let (mut sink, mut stream) = ws.split(); - - // Hello + wait for Ack - let hello = DaemonOutbound::Hello { - daemon_id: self.daemon_id.clone(), - hostname: self.hostname.clone(), - version: PROTOCOL_VERSION.into(), - token: self.token.clone(), - daemon_url: self.daemon_url.clone(), - home_dir: self.home_dir.clone(), - }; - sink.send(Message::Text(serde_json::to_string(&hello)?)) - .await?; - - match stream.next().await { - Some(Ok(Message::Text(t))) => match serde_json::from_str::(&t)? { - DaemonInbound::Ack => {} - DaemonInbound::Error(e) => { - return Err(ClientError::Handshake(format!("{}: {}", e.code, e.message))); - } - other => { - return Err(ClientError::Handshake(format!( - "unexpected first frame {other:?}" - ))) - } - }, - other => { - return Err(ClientError::Handshake(format!( - "no hello-ack frame: {other:?}" - ))) - } - } - - // Link is up — let the broker queue ResolveAttach frames again - // (NAP-263). Cleared in `run` once this connection ends. - self.broker.set_connected(true); - - // Subscribe to the session-event stream BEFORE taking the snapshot. A - // reap or kill that races the handshake would otherwise slip between - // the snapshot and the subscription — present in neither — leaving the - // registry advertising a session this daemon no longer has until the - // next reconnect. `local_api::pump_events` subscribes-then-snapshots - // for the browser/CLI event WS for exactly this reason. - let mut events = self.manager.events(); - let snapshot = DaemonOutbound::SessionsSnapshot { - sessions: self.manager.snapshot().await, - }; - sink.send(Message::Text(serde_json::to_string(&snapshot)?)) - .await?; - - // The first interval tick fires immediately, so the registry gets a - // perimeter advertisement (NAP-247) right after the handshake instead - // of waiting a full HEARTBEAT_INTERVAL for `update_perimeter` to land. - let mut hb = tokio::time::interval(HEARTBEAT_INTERVAL); - hb.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - - // Session ids whose async lifecycle event the in-flight RPC reply - // already conveys to the registry — swallowed here so the registry - // writes each change exactly once (NAP-268). - let mut covered = RpcCovered::default(); - - loop { - tokio::select! { - msg = stream.next() => { - match msg { - None => break, - Some(Err(e)) => return Err(e.into()), - Some(Ok(Message::Close(_))) => break, - Some(Ok(Message::Text(text))) => { - self.handle_inbound(&text, &mut sink, &mut covered).await?; - } - Some(Ok(_)) => {} - } - } - ev = events.recv() => { - if !forward_session_event(&self.manager, ev, &mut sink, &mut covered).await? { - break; - } - } - _ = hb.tick() => { - sink.send(Message::Text(serde_json::to_string( - &DaemonOutbound::Heartbeat { - perimeter: self.perimeter.clone(), - }, - )?)).await?; - } - Some(broker_msg) = self.broker_rx.recv() => { - // Outbound broker requests (NAP-247) — e.g. ResolveAttach. - sink.send(Message::Text(serde_json::to_string(&broker_msg)?)) - .await?; - } - } - } - Ok(()) - } - - async fn handle_inbound( - &self, - text: &str, - sink: &mut S, - covered: &mut RpcCovered, - ) -> Result<(), ClientError> - where - S: SinkExt + Unpin, - { - let parsed: DaemonInbound = match serde_json::from_str(text) { - Ok(p) => p, - Err(e) => { - // A single malformed frame must not drop the registry link — - // log and skip it (NAP-269). - tracing::warn!(error = %e, "malformed registry frame — skipping"); - return Ok(()); - } - }; - match parsed { - DaemonInbound::Ack => {} - DaemonInbound::Error(e) => { - tracing::warn!(code = %e.code, message = %e.message, "registry-reported error"); - } - DaemonInbound::CreateSession { reply_to, request } => { - let result = match self.manager.create(request).await { - Ok(s) => { - // The reply carries the session, so the registry applies - // it on receipt; swallow the async `SessionAdded` this - // create just queued on our event stream (NAP-268). - covered.added.insert(s.id); - RpcResult::Ok { value: s } - } - Err(e) => RpcResult::Err { - error: e.to_error_info(), - }, - }; - sink.send(Message::Text(serde_json::to_string( - &DaemonOutbound::CreateSessionResult { reply_to, result }, - )?)) - .await?; - } - DaemonInbound::KillSession { reply_to, id } => { - let result = match self.manager.kill(id).await { - Ok(()) => { - // The reply confirms the removal; swallow the async - // `SessionRemoved` it queued so the registry drops the - // session exactly once (NAP-268). - covered.removed.insert(id); - RpcResult::Ok { value: () } - } - Err(e) => RpcResult::Err { - error: e.to_error_info(), - }, - }; - sink.send(Message::Text(serde_json::to_string( - &DaemonOutbound::KillSessionResult { reply_to, result }, - )?)) - .await?; - } - DaemonInbound::ResolveAttachResult { reply_to, result } => { - self.broker.complete_resolve(reply_to, result).await; - } - DaemonInbound::ForwardCreateResult { reply_to, result } => { - self.broker.complete_forward_create(reply_to, result).await; - } - DaemonInbound::ForwardKillResult { reply_to, result } => { - self.broker.complete_forward_kill(reply_to, result).await; - } - DaemonInbound::ListDir { reply_to, path } => { - // List a directory on this host for a client's spawn-form - // autocomplete (NAP-329). Resolved against this daemon's home. - let result = match crate::dirlist::list_dir(&self.home_dir, &path) { - Ok(listing) => RpcResult::Ok { value: listing }, - Err(error) => RpcResult::Err { error }, - }; - sink.send(Message::Text(serde_json::to_string( - &DaemonOutbound::ListDirResult { reply_to, result }, - )?)) - .await?; - } - DaemonInbound::ForwardListDirResult { reply_to, result } => { - self.broker - .complete_forward_list_dir(reply_to, result) - .await; - } - DaemonInbound::RecentDirs { reply_to } => { - // Answer a peer's recent-dirs query from this host's spawn - // history (#26). Daemon-wide, so no path to resolve. - let value = crate::spawn_history::to_wire(self.manager.recent_dirs()); - sink.send(Message::Text(serde_json::to_string( - &DaemonOutbound::RecentDirsResult { - reply_to, - result: RpcResult::Ok { value }, - }, - )?)) - .await?; - } - DaemonInbound::ForwardRecentDirsResult { reply_to, result } => { - self.broker - .complete_forward_recent_dirs(reply_to, result) - .await; - } - DaemonInbound::GitRoot { reply_to, path } => { - // Probe the enclosing repo of `path` on this host (#26), - // resolving the abbreviated path against this daemon's home the - // same way `ListDir` does. - let dir = crate::dirlist::resolve(&self.home_dir, &path); - let value = maestro_protocol::GitRoot { - root: crate::gitroot::find_root(&dir), - }; - sink.send(Message::Text(serde_json::to_string( - &DaemonOutbound::GitRootResult { - reply_to, - result: RpcResult::Ok { value }, - }, - )?)) - .await?; - } - DaemonInbound::ForwardGitRootResult { reply_to, result } => { - self.broker - .complete_forward_git_root(reply_to, result) - .await; - } - DaemonInbound::RelaySetup { .. } => { - // T3 (relay) is gated on NAP-238. Lands here only so newer - // registries sending it don't crash this daemon on parse. - tracing::debug!("RelaySetup received but T3 not implemented (NAP-238 pending)"); - } - DaemonInbound::CatalogEvent { event } => { - // NAP-246: peer-filtered catalog delta from the registry. - self.catalog.apply(event).await; - } - DaemonInbound::Unknown => { - // A newer registry emitted a frame type this build doesn't know. - // Skip it rather than dropping the link (NAP-269). - tracing::debug!("unknown registry frame — skipping (forward-compat, NAP-269)"); - } - } - Ok(()) - } -} - -/// Translate one item from the session-event stream into an outbound frame on -/// `sink`. Returns `Ok(false)` once the stream has closed and the caller -/// should stop the loop. -/// -/// An `Added` / `Removed` whose id is parked in `covered` was driven by an RPC -/// whose reply already reports the change to the registry; it is consumed and -/// *not* forwarded, so the registry writes each change exactly once (NAP-268). -/// -/// On `Lagged` the daemon's own view of its sessions skipped frames — which -/// may include a `Removed`. Emitting nothing (the previous behaviour) left the -/// registry advertising a now-dead session until the next reconnect, since the -/// registry only reconciles a daemon's full session set on `apply_snapshot`. -/// So we re-send a full `SessionsSnapshot`, which the registry applies by -/// replacing this daemon's set — healing the missed removal immediately. The -/// snapshot is the full truth, so any still-parked `covered` ids are stale and -/// cleared. -async fn forward_session_event( - manager: &SessionManager, - ev: Result, - sink: &mut S, - covered: &mut RpcCovered, -) -> Result -where - S: SinkExt + Unpin, -{ - let frame = match ev { - Ok(SessionEvent::Added(session)) => { - if covered.added.remove(&session.id) { - return Ok(true); - } - DaemonOutbound::SessionAdded { session } - } - Ok(SessionEvent::Removed(id)) => { - if covered.removed.remove(&id) { - return Ok(true); - } - DaemonOutbound::SessionRemoved { id } - } - Ok(SessionEvent::Updated(session)) => DaemonOutbound::SessionUpdated { session }, - Err(RecvError::Lagged(skipped)) => { - tracing::warn!( - skipped, - "session-event stream lagged — resyncing registry with a full snapshot" - ); - covered.added.clear(); - covered.removed.clear(); - DaemonOutbound::SessionsSnapshot { - sessions: manager.snapshot().await, - } - } - Err(RecvError::Closed) => return Ok(false), - }; - sink.send(Message::Text(serde_json::to_string(&frame)?)) - .await?; - Ok(true) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::pin::Pin; - use std::task::{Context, Poll}; - - use uuid::Uuid; - - /// Minimal in-memory sink that records every frame sent to it, so we can - /// assert exactly what `forward_session_event` emits. - #[derive(Default)] - struct VecSink(Vec); - - impl futures_util::Sink for VecSink { - type Error = tokio_tungstenite::tungstenite::Error; - fn poll_ready(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - fn start_send(self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> { - self.get_mut().0.push(item); - Ok(()) - } - fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - fn poll_close(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } - } - - /// A `SessionManager` backed by temp dirs. It never spawns tmux here — the - /// tests only drive `forward_session_event`, which at most reads the - /// (empty) in-memory session map via `snapshot()`. - fn test_manager() -> (SessionManager, tempfile::TempDir) { - let dir = tempfile::tempdir().unwrap(); - let mgr = SessionManager::new( - "test-daemon", - crate::tmux::Tmux::new(), - crate::sidecar::SidecarStore::new(dir.path().join("sessions")), - crate::worktree::WorktreeManager::new(dir.path().join("worktrees")), - dir.path().join("fifos"), - ); - (mgr, dir) - } - - fn decode(sink: &VecSink) -> Vec { - sink.0 - .iter() - .map(|m| match m { - Message::Text(t) => serde_json::from_str(t).unwrap(), - other => panic!("unexpected frame {other:?}"), - }) - .collect() - } - - fn sample_session(id: Uuid) -> maestro_protocol::Session { - maestro_protocol::Session { - id, - daemon_id: "test-daemon".into(), - cwd: std::path::PathBuf::from("/tmp"), - command: "bash".into(), - env: Default::default(), - metadata: Default::default(), - status: maestro_protocol::SessionStatus::Running, - created_at: chrono::Utc::now(), - foreground: None, - activity: Default::default(), - last_activity_at: chrono::Utc::now(), - - current_cwd: None, - } - } - - // Regression test for NAP-291 (defect B): a lagged event stream may have - // dropped a `Removed`, so the daemon must resync the registry's full set - // rather than silently skip — otherwise a dead session lingers in the - // catalog until the next reconnect. The previous `continue` sent nothing. - #[tokio::test] - async fn lagged_stream_resyncs_with_full_snapshot() { - let (mgr, _dir) = test_manager(); - let mut sink = VecSink::default(); - let cont = forward_session_event( - &mgr, - Err(RecvError::Lagged(9)), - &mut sink, - &mut RpcCovered::default(), - ) - .await - .unwrap(); - assert!(cont, "lag is recoverable; the loop must continue"); - let frames = decode(&sink); - assert!( - matches!(frames.as_slice(), [DaemonOutbound::SessionsSnapshot { .. }]), - "a lagged stream must resync via SessionsSnapshot; got {frames:?}", - ); - } - - #[tokio::test] - async fn removed_event_forwards_session_removed() { - let (mgr, _dir) = test_manager(); - let mut sink = VecSink::default(); - let id = Uuid::new_v4(); - let cont = forward_session_event( - &mgr, - Ok(SessionEvent::Removed(id)), - &mut sink, - &mut RpcCovered::default(), - ) - .await - .unwrap(); - assert!(cont); - let frames = decode(&sink); - assert!( - matches!(frames.as_slice(), [DaemonOutbound::SessionRemoved { id: got }] if *got == id), - "got {frames:?}", - ); - } - - #[tokio::test] - async fn closed_stream_signals_break_without_sending() { - let (mgr, _dir) = test_manager(); - let mut sink = VecSink::default(); - let cont = forward_session_event( - &mgr, - Err(RecvError::Closed), - &mut sink, - &mut RpcCovered::default(), - ) - .await - .unwrap(); - assert!(!cont, "a closed stream must signal the loop to break"); - assert!(sink.0.is_empty(), "nothing should be sent on close"); - } - - // NAP-268: an RPC-driven create/kill reports the change to the registry via - // its reply, so the matching async lifecycle event must be swallowed — never - // forwarded — to keep the registry's catalog write single (not idempotent). - #[tokio::test] - async fn rpc_covered_added_is_swallowed_once() { - let (mgr, _dir) = test_manager(); - let mut sink = VecSink::default(); - let id = Uuid::new_v4(); - let mut covered = RpcCovered::default(); - covered.added.insert(id); - - let cont = forward_session_event( - &mgr, - Ok(SessionEvent::Added(sample_session(id))), - &mut sink, - &mut covered, - ) - .await - .unwrap(); - - assert!(cont, "swallowing an event must not stop the loop"); - assert!( - sink.0.is_empty(), - "an RPC-covered SessionAdded must not reach the registry; got {:?}", - decode(&sink), - ); - assert!( - !covered.added.contains(&id), - "the parked id must be consumed exactly once", - ); - } - - #[tokio::test] - async fn rpc_covered_removed_is_swallowed_once() { - let (mgr, _dir) = test_manager(); - let mut sink = VecSink::default(); - let id = Uuid::new_v4(); - let mut covered = RpcCovered::default(); - covered.removed.insert(id); - - let cont = - forward_session_event(&mgr, Ok(SessionEvent::Removed(id)), &mut sink, &mut covered) - .await - .unwrap(); - - assert!(cont); - assert!( - sink.0.is_empty(), - "an RPC-covered SessionRemoved must not reach the registry; got {:?}", - decode(&sink), - ); - assert!(!covered.removed.contains(&id)); - } - - // A spontaneous change (e.g. a session created via the daemon's local HTTP - // API, or reaped on its own) is the registry's only signal, so it must still - // forward even while *other* ids are parked. - #[tokio::test] - async fn uncovered_added_still_forwards() { - let (mgr, _dir) = test_manager(); - let mut sink = VecSink::default(); - let parked = Uuid::new_v4(); - let spontaneous = Uuid::new_v4(); - let mut covered = RpcCovered::default(); - covered.added.insert(parked); - - let cont = forward_session_event( - &mgr, - Ok(SessionEvent::Added(sample_session(spontaneous))), - &mut sink, - &mut covered, - ) - .await - .unwrap(); - - assert!(cont); - let frames = decode(&sink); - assert!( - matches!( - frames.as_slice(), - [DaemonOutbound::SessionAdded { session }] if session.id == spontaneous - ), - "an unparked create must forward; got {frames:?}", - ); - assert!( - covered.added.contains(&parked), - "an unrelated parked id must be left intact", - ); - } - - // A second event for the same id (e.g. the RPC was swallowed, then the - // session is reaped) is no longer covered and forwards normally. - #[tokio::test] - async fn covered_consumed_then_subsequent_event_forwards() { - let (mgr, _dir) = test_manager(); - let mut sink = VecSink::default(); - let id = Uuid::new_v4(); - let mut covered = RpcCovered::default(); - covered.added.insert(id); - - // First event: the RPC-covered create — swallowed. - forward_session_event( - &mgr, - Ok(SessionEvent::Added(sample_session(id))), - &mut sink, - &mut covered, - ) - .await - .unwrap(); - assert!(sink.0.is_empty()); - - // Second event: a spontaneous removal of that same session — forwarded, - // since only the create was parked. - forward_session_event(&mgr, Ok(SessionEvent::Removed(id)), &mut sink, &mut covered) - .await - .unwrap(); - let frames = decode(&sink); - assert!( - matches!(frames.as_slice(), [DaemonOutbound::SessionRemoved { id: got }] if *got == id), - "got {frames:?}", - ); - } - - // A lag resync is the registry's full truth, so any still-parked ids are - // stale and must be cleared (else a later unrelated event could be wrongly - // swallowed). - #[tokio::test] - async fn lag_resync_clears_parked_ids() { - let (mgr, _dir) = test_manager(); - let mut sink = VecSink::default(); - let mut covered = RpcCovered::default(); - covered.added.insert(Uuid::new_v4()); - covered.removed.insert(Uuid::new_v4()); - - forward_session_event(&mgr, Err(RecvError::Lagged(3)), &mut sink, &mut covered) - .await - .unwrap(); - - assert!( - covered.added.is_empty() && covered.removed.is_empty(), - "a full-snapshot resync must clear stale parked ids", - ); - } -} diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 69dfc54..a03f841 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -67,8 +67,6 @@ pub enum SessionError { Io(#[from] std::io::Error), #[error("session not found: {0}")] NotFound(Uuid), - #[error("request targets daemon `{wanted}`, this daemon is `{ours}`")] - WrongDaemon { wanted: String, ours: String }, } impl SessionError { @@ -79,7 +77,6 @@ impl SessionError { SessionError::Sidecar(_) => "sidecar_failed", SessionError::Io(_) => "io_failed", SessionError::NotFound(_) => "not_found", - SessionError::WrongDaemon { .. } => "wrong_daemon", }; ErrorInfo { code: code.into(), @@ -96,7 +93,6 @@ pub struct SessionManager { #[derive(Debug)] struct Inner { - daemon_id: String, tmux: Tmux, sidecar: SidecarStore, worktree_mgr: WorktreeManager, @@ -114,14 +110,12 @@ struct Inner { impl SessionManager { pub fn new( - daemon_id: impl Into, tmux: Tmux, sidecar: SidecarStore, worktree_mgr: WorktreeManager, fifo_dir: PathBuf, ) -> Self { Self::new_with_replay( - daemon_id, tmux, sidecar, worktree_mgr, @@ -133,7 +127,6 @@ impl SessionManager { /// Variant that lets the caller override the per-session replay buffer /// cap. Pass `0` to disable replay (subscribers see only live frames). pub fn new_with_replay( - daemon_id: impl Into, tmux: Tmux, sidecar: SidecarStore, worktree_mgr: WorktreeManager, @@ -143,7 +136,6 @@ impl SessionManager { let (events, _) = broadcast::channel(EVENTS_CAPACITY); Self { inner: Arc::new(Inner { - daemon_id: daemon_id.into(), tmux, sidecar, worktree_mgr, @@ -170,10 +162,6 @@ impl SessionManager { self } - pub fn daemon_id(&self) -> &str { - &self.inner.daemon_id - } - /// Recent working dirs for the spawn form's picker (#24), newest-first and /// deduped by dir. Empty when no history is wired or it's disabled. pub fn recent_dirs(&self) -> Vec { @@ -218,13 +206,6 @@ impl SessionManager { /// Spawn a new session. On success returns the freshly-created `Session`. pub async fn create(&self, req: CreateSessionRequest) -> Result { - if req.daemon_id != self.inner.daemon_id { - return Err(SessionError::WrongDaemon { - wanted: req.daemon_id, - ours: self.inner.daemon_id.clone(), - }); - } - let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree { Some(WorktreeSpec { base_repo, branch, .. @@ -285,7 +266,6 @@ impl SessionManager { let session = Session { id, - daemon_id: self.inner.daemon_id.clone(), cwd: effective_cwd, command: req.command, env: req.env, @@ -415,11 +395,10 @@ impl SessionManager { }; let mut adopted = 0usize; for id in live { - let mut session = match self.inner.sidecar.read(id) { + let session = match self.inner.sidecar.read(id) { Ok(Some(s)) => s, _ => Session { id, - daemon_id: self.inner.daemon_id.clone(), cwd: PathBuf::from("/"), command: String::new(), env: Default::default(), @@ -434,19 +413,6 @@ impl SessionManager { current_cwd: None, }, }; - // The sidecar persists whatever daemon_id was current when the - // session was created. If the operator renamed the daemon between - // starts, that id is stale — re-advertise under our own id so the - // session stays routable (NAP-264). - if session.daemon_id != self.inner.daemon_id { - tracing::warn!( - %id, - sidecar_daemon_id = %session.daemon_id, - current_daemon_id = %self.inner.daemon_id, - "recovered session had a stale daemon_id; re-advertising under the current id" - ); - session.daemon_id = self.inner.daemon_id.clone(); - } let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo")); let bridge = PtyBridge::start_with_replay(fifo_path.clone(), self.inner.replay_bytes).await?; diff --git a/crates/maestro-daemon/tests/attach.rs b/crates/maestro-daemon/tests/attach.rs index 1a26deb..10e3070 100644 --- a/crates/maestro-daemon/tests/attach.rs +++ b/crates/maestro-daemon/tests/attach.rs @@ -1,5 +1,5 @@ //! End-to-end tests for the per-session attach WS endpoint: -//! - bad / good JWT +//! - unknown session is rejected //! - PTY bytes round-trip through real tmux //! - multi-attach: two clients see the same output @@ -9,11 +9,10 @@ use std::collections::HashMap; use std::net::{Ipv4Addr, SocketAddr}; use std::time::Duration; -use chrono::{Duration as ChronoDuration, Utc}; use common::DaemonFixture; use futures_util::{SinkExt, StreamExt}; -use maestro_daemon::{attach, issue_jwt, JwtVerifier, PairAttachVerifier, SessionManager}; -use maestro_protocol::{CreateSessionRequest, JwtClaims}; +use maestro_daemon::{local_api, LocalEventBus, SessionManager}; +use maestro_protocol::CreateSessionRequest; use tempfile::TempDir; use tokio::net::TcpListener; use tokio::sync::oneshot; @@ -21,8 +20,6 @@ use tokio_tungstenite::tungstenite::Message as WsMessage; use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; use uuid::Uuid; -const TOKEN: &str = "shared-secret-for-tests"; - type WsClient = WebSocketStream>; struct Harness { @@ -32,12 +29,16 @@ struct Harness { _fx: DaemonFixture, } -async fn spawn_daemon_ws(daemon_id: &str) -> Harness { - let fx = DaemonFixture::new(daemon_id); +async fn spawn_daemon_ws() -> Harness { + let fx = DaemonFixture::new(); let manager = fx.manager.clone(); - let verifier = JwtVerifier::new(TOKEN, daemon_id); - let pair_verifier = PairAttachVerifier::new(TOKEN, daemon_id); - let router = attach::router(manager.clone(), verifier, pair_verifier); + let router = local_api::router(local_api::state_for( + manager.clone(), + "test-host", + "/home/test", + Vec::new(), + LocalEventBus::new(), + )); let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) .await .unwrap(); @@ -58,25 +59,11 @@ async fn spawn_daemon_ws(daemon_id: &str) -> Harness { } } -fn token_for(session_id: Uuid, daemon_id: &str, valid_seconds: i64) -> String { - issue_jwt( - TOKEN, - &JwtClaims { - session_id, - daemon_id: daemon_id.into(), - exp: (Utc::now() + ChronoDuration::seconds(valid_seconds)).timestamp(), - iat: Some(Utc::now().timestamp()), - }, - ) - .unwrap() -} - async fn connect_attach( addr: SocketAddr, session: Uuid, - token: &str, ) -> Result { - let url = format!("ws://{addr}/sessions/{session}/stream?token={token}"); + let url = format!("ws://{addr}/api/attach/{session}"); connect_async(url).await.map(|(ws, _)| ws) } @@ -86,7 +73,6 @@ async fn create_bash_session(h: &Harness) -> Uuid { let s = h .manager .create(CreateSessionRequest { - daemon_id: "laptop".into(), cwd: cwd_path, command: "bash --noprofile --norc".into(), env: HashMap::new(), @@ -100,42 +86,17 @@ async fn create_bash_session(h: &Harness) -> Uuid { } #[tokio::test] -async fn rejects_bad_token() { - let h = spawn_daemon_ws("laptop").await; - let id = create_bash_session(&h).await; - let err = connect_attach(h.addr, id, "not-a-jwt").await.unwrap_err(); - let msg = format!("{err}"); - assert!(msg.contains("401") || msg.contains("403"), "{msg}"); - let _ = h.manager.kill(id).await; -} - -#[tokio::test] -async fn rejects_token_for_different_session() { - let h = spawn_daemon_ws("laptop").await; - let real_id = create_bash_session(&h).await; - let other_id = Uuid::new_v4(); - let token = token_for(other_id, "laptop", 300); - let err = connect_attach(h.addr, real_id, &token).await.unwrap_err(); - assert!(format!("{err}").contains("403")); - let _ = h.manager.kill(real_id).await; -} - -#[tokio::test] -async fn rejects_token_for_different_daemon() { - let h = spawn_daemon_ws("laptop").await; - let id = create_bash_session(&h).await; - let token = token_for(id, "dev-vm", 300); - let err = connect_attach(h.addr, id, &token).await.unwrap_err(); - assert!(format!("{err}").contains("401")); - let _ = h.manager.kill(id).await; +async fn rejects_unknown_session() { + let h = spawn_daemon_ws().await; + let err = connect_attach(h.addr, Uuid::new_v4()).await.unwrap_err(); + assert!(format!("{err}").contains("404")); } #[tokio::test] async fn pty_round_trip_through_real_tmux() { - let h = spawn_daemon_ws("laptop").await; + let h = spawn_daemon_ws().await; let id = create_bash_session(&h).await; - let token = token_for(id, "laptop", 300); - let mut ws = connect_attach(h.addr, id, &token).await.unwrap(); + let mut ws = connect_attach(h.addr, id).await.unwrap(); ws.send(WsMessage::Binary(b"echo MAESTRO_RT_OK\n".to_vec())) .await @@ -164,12 +125,11 @@ async fn pty_round_trip_through_real_tmux() { #[tokio::test] async fn multi_attach_broadcasts_to_every_client() { - let h = spawn_daemon_ws("laptop").await; + let h = spawn_daemon_ws().await; let id = create_bash_session(&h).await; - let token = token_for(id, "laptop", 300); - let mut ws_a = connect_attach(h.addr, id, &token).await.unwrap(); - let mut ws_b = connect_attach(h.addr, id, &token).await.unwrap(); + let mut ws_a = connect_attach(h.addr, id).await.unwrap(); + let mut ws_b = connect_attach(h.addr, id).await.unwrap(); // give the broadcast subscribers a moment to register tokio::time::sleep(Duration::from_millis(100)).await; @@ -216,10 +176,9 @@ async fn sends_close_frame_when_tmux_session_dies_under_liveness_watcher() { // Bug NAP-226: when bash exits, tmux destroys the session, the liveness // watcher reaps it, and the broadcast channel closes — but the WS used to // be dropped silently, leaving the client hung in raw mode. - let h = spawn_daemon_ws("laptop").await; + let h = spawn_daemon_ws().await; let id = create_bash_session(&h).await; - let token = token_for(id, "laptop", 300); - let mut ws = connect_attach(h.addr, id, &token).await.unwrap(); + let mut ws = connect_attach(h.addr, id).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; @@ -251,10 +210,9 @@ async fn sends_close_frame_when_tmux_session_dies_under_liveness_watcher() { #[tokio::test] async fn sends_close_frame_when_session_is_killed() { - let h = spawn_daemon_ws("laptop").await; + let h = spawn_daemon_ws().await; let id = create_bash_session(&h).await; - let token = token_for(id, "laptop", 300); - let mut ws = connect_attach(h.addr, id, &token).await.unwrap(); + let mut ws = connect_attach(h.addr, id).await.unwrap(); // give the broadcast subscriber a moment to register before killing. tokio::time::sleep(Duration::from_millis(100)).await; @@ -287,10 +245,9 @@ async fn sends_close_frame_when_session_is_killed() { #[tokio::test] async fn ping_text_frame_gets_pong() { - let h = spawn_daemon_ws("laptop").await; + let h = spawn_daemon_ws().await; let id = create_bash_session(&h).await; - let token = token_for(id, "laptop", 300); - let mut ws = connect_attach(h.addr, id, &token).await.unwrap(); + let mut ws = connect_attach(h.addr, id).await.unwrap(); let ping = serde_json::to_string(&maestro_protocol::ClientControl::Ping).unwrap(); ws.send(WsMessage::Text(ping)).await.unwrap(); @@ -315,11 +272,10 @@ async fn ping_text_frame_gets_pong() { async fn second_attach_sees_replay_buffer_of_prior_output() { // NAP-229: a fresh attach must receive the buffered scrollback before // any live frames, so reconnecting to an idle prompt isn't blank. - let h = spawn_daemon_ws("laptop").await; + let h = spawn_daemon_ws().await; let session = create_bash_session(&h).await; - let token = token_for(session, "laptop", 60); - let mut a = connect_attach(h.addr, session, &token).await.unwrap(); + let mut a = connect_attach(h.addr, session).await.unwrap(); // Generate output the bash will echo back. a.send(WsMessage::Binary(b"echo MAESTRO_REPLAY_MARK\n".to_vec())) .await @@ -349,7 +305,7 @@ async fn second_attach_sees_replay_buffer_of_prior_output() { // Now attach a fresh client. It should see the prior MARK in the replay // even though no new output has happened since. - let mut b = connect_attach(h.addr, session, &token).await.unwrap(); + let mut b = connect_attach(h.addr, session).await.unwrap(); let saw_replay = tokio::time::timeout(std::time::Duration::from_secs(2), async { let mut buf = Vec::new(); while let Some(Ok(msg)) = b.next().await { diff --git a/crates/maestro-daemon/tests/common/mod.rs b/crates/maestro-daemon/tests/common/mod.rs index 4ce6a15..ba6732c 100644 --- a/crates/maestro-daemon/tests/common/mod.rs +++ b/crates/maestro-daemon/tests/common/mod.rs @@ -118,24 +118,23 @@ pub struct DaemonFixture { } impl DaemonFixture { - pub fn new(daemon_id: &str) -> Self { - Self::build(daemon_id, None) + pub fn new() -> Self { + Self::build(None) } /// Variant whose manager has a durable spawn history (#24) attached, with /// `retention_days` recency cap, logging to `/spawn-history.jsonl`. - pub fn new_with_history(daemon_id: &str, retention_days: u32) -> Self { - Self::build(daemon_id, Some(retention_days)) + pub fn new_with_history(retention_days: u32) -> Self { + Self::build(Some(retention_days)) } - fn build(daemon_id: &str, history_days: Option) -> Self { + fn build(history_days: Option) -> Self { let tmux = TmuxFixture::new(); let state_dir = TempDir::new().unwrap(); let worktree_root = TempDir::new().unwrap(); let sidecar = SidecarStore::new(state_dir.path().join("sessions")); let worktree_mgr = WorktreeManager::new(worktree_root.path().to_path_buf()); let mut manager = SessionManager::new( - daemon_id.to_string(), tmux.tmux.clone(), sidecar, worktree_mgr, diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index 30e2eff..7bdd2c0 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -1,7 +1,4 @@ -//! Integration tests for the daemon's local HTTP API + broker WS (NAP-243). -//! -//! The legacy `/sessions/:id/stream` endpoint is covered by `tests/attach.rs`; -//! this file exercises the new `/api/*` surface and the same-host broker WS. +//! Integration tests for the daemon's HTTP + WS API (NAP-243). mod common; @@ -11,8 +8,8 @@ use std::time::Duration; use common::DaemonFixture; use futures_util::{SinkExt, StreamExt}; -use maestro_daemon::{local_api, BrokerHandle, CatalogMirror, LocalEventBus}; -use maestro_protocol::{CreateSessionRequest, DaemonInfo, RegistryEvent, Session}; +use maestro_daemon::{local_api, LocalEventBus}; +use maestro_protocol::{CreateSessionRequest, Session}; use tokio::net::TcpListener; use tokio::sync::oneshot; use tokio_tungstenite::tungstenite::Message as WsMessage; @@ -24,42 +21,24 @@ type WsClient = WebSocketStream>; struct Harness { addr: SocketAddr, manager: maestro_daemon::SessionManager, - catalog: CatalogMirror, _shutdown: oneshot::Sender<()>, _fx: DaemonFixture, } -async fn spawn(daemon_id: &str, hostname: &str, daemon_url: &str) -> Harness { - let fx = DaemonFixture::new(daemon_id); +async fn spawn(hostname: &str) -> Harness { + let fx = DaemonFixture::new(); let manager = fx.manager.clone(); - let catalog = CatalogMirror::new(); let events = LocalEventBus::new(); - // NAP-247/NAP-327: tests don't exercise the cross-host broker path; hand the - // routers an isolated BrokerHandle whose receiver is dropped, so any - // resolve_attach / forward_create / forward_kill fails fast with - // NoControlLink (the handle starts disconnected). - let (broker, _broker_rx) = BrokerHandle::new(); - // Built-in presets (claude/opencode/zsh + custom) so web_info advertises a - // known set (#26). + // Built-in presets (claude/opencode/zsh + custom) so `/api/info` advertises + // a known set (#26). let presets = maestro_config::SpawnSection::default().resolve().unwrap(); - let state = local_api::state_for( + let router = local_api::router(local_api::state_for( manager.clone(), hostname, - daemon_url, "/home/test", presets, - catalog.clone(), events, - broker.clone(), - ); - let broker_state = local_api::BrokerState { - manager: manager.clone(), - catalog: catalog.clone(), - broker, - self_daemon_id: std::sync::Arc::new(daemon_id.into()), - self_perimeter_id: None, - }; - let router = local_api::router(state).merge(local_api::broker_router(broker_state)); + )); let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) .await .unwrap(); @@ -75,7 +54,6 @@ async fn spawn(daemon_id: &str, hostname: &str, daemon_url: &str) -> Harness { Harness { addr, manager, - catalog, _shutdown: tx, _fx: fx, } @@ -86,99 +64,22 @@ fn url(addr: SocketAddr, path: &str) -> String { } #[tokio::test] -async fn web_info_returns_daemon_identity() { - let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await; - let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/web/info")) +async fn info_returns_daemon_identity() { + let h = spawn("naps-laptop").await; + let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info")) .await .unwrap() .json() .await .unwrap(); - assert_eq!(resp["daemon_id"], "laptop"); assert_eq!(resp["hostname"], "naps-laptop"); - assert_eq!(resp["daemon_url"], "ws://10.0.0.1:7070"); + assert_eq!(resp["home_dir"], "/home/test"); assert!(resp["version"].is_string()); } -#[tokio::test] -async fn list_daemons_includes_self_and_mirrored_peers() { - let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await; - - // Before any catalog events arrive, the daemon only knows about itself. - let list: Vec = reqwest::get(url(h.addr, "/api/daemons")) - .await - .unwrap() - .json() - .await - .unwrap(); - assert_eq!(list.len(), 1); - assert_eq!(list[0].daemon_id, "laptop"); - - // Simulate the registry fanning out a peer addition. - h.catalog - .apply(RegistryEvent::DaemonAdded { - daemon: DaemonInfo { - daemon_id: "konishi".into(), - hostname: "konishi-host".into(), - version: "0.1.0".into(), - daemon_url: "ws://10.0.0.2:7070".into(), - home_dir: String::new(), - }, - }) - .await; - - let list: Vec = reqwest::get(url(h.addr, "/api/daemons")) - .await - .unwrap() - .json() - .await - .unwrap(); - assert_eq!(list.len(), 2); - let ids: Vec<&str> = list.iter().map(|d| d.daemon_id.as_str()).collect(); - assert!(ids.contains(&"laptop")); - assert!(ids.contains(&"konishi")); -} - -#[tokio::test] -async fn list_sessions_unions_own_and_peer() { - let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await; - // Create one local session. - let own = h - .manager - .create(CreateSessionRequest { - daemon_id: "laptop".into(), - cwd: "/tmp".into(), - command: "sleep 60".into(), - env: HashMap::new(), - metadata: HashMap::new(), - worktree: None, - }) - .await - .unwrap(); - // Inject a peer session via the mirror. - let mut peer = own.clone(); - peer.id = Uuid::new_v4(); - peer.daemon_id = "konishi".into(); - h.catalog - .apply(RegistryEvent::SessionAdded { - session: peer.clone(), - }) - .await; - - let list: Vec = reqwest::get(url(h.addr, "/api/sessions")) - .await - .unwrap() - .json() - .await - .unwrap(); - let ids: Vec = list.iter().map(|s| s.id).collect(); - assert!(ids.contains(&own.id), "own session missing: {ids:?}"); - assert!(ids.contains(&peer.id), "peer session missing: {ids:?}"); -} - #[tokio::test] async fn list_sessions_starts_empty_and_reflects_creates() { - let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await; + let h = spawn("naps-laptop").await; let empty: Vec = reqwest::get(url(h.addr, "/api/sessions")) .await @@ -189,8 +90,7 @@ async fn list_sessions_starts_empty_and_reflects_creates() { assert!(empty.is_empty()); let req = CreateSessionRequest { - daemon_id: "laptop".into(), - cwd: h.manager.daemon_id().into(), + cwd: "/tmp".into(), command: "sleep 60".into(), env: HashMap::new(), metadata: HashMap::new(), @@ -205,7 +105,6 @@ async fn list_sessions_starts_empty_and_reflects_creates() { .json() .await .unwrap(); - assert_eq!(created.daemon_id, "laptop"); let after: Vec = reqwest::get(url(h.addr, "/api/sessions")) .await @@ -218,37 +117,11 @@ async fn list_sessions_starts_empty_and_reflects_creates() { } #[tokio::test] -async fn post_to_peer_daemon_id_brokers_and_503s_without_registry_link() { - // Cross-daemon create now brokers to the owner over the registry control - // link (NAP-327). This harness has no registry link (the broker starts - // disconnected), so the daemon fails fast with a retryable 503 instead of - // the old 501 — proving it attempts to broker rather than refusing. - let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await; - let req = CreateSessionRequest { - daemon_id: "konishi".into(), - cwd: "/tmp".into(), - command: "sleep 60".into(), - env: HashMap::new(), - metadata: HashMap::new(), - worktree: None, - }; - let resp = reqwest::Client::new() - .post(url(h.addr, "/api/sessions")) - .json(&req) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), reqwest::StatusCode::SERVICE_UNAVAILABLE); - let body: serde_json::Value = resp.json().await.unwrap(); - assert_eq!(body["error"]["code"], "no_registry_link"); -} - -#[tokio::test] -async fn web_info_advertises_spawn_presets() { - // #26: the connected daemon's presets ride the web_info payload so the - // spawn form needs no extra round-trip. Built-ins + the implicit custom. - let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await; - let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/web/info")) +async fn info_advertises_spawn_presets() { + // #26: the daemon's presets ride the info payload so the spawn form needs + // no extra round-trip. Built-ins + the implicit custom. + let h = spawn("naps-laptop").await; + let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/info")) .await .unwrap() .json() @@ -264,11 +137,11 @@ async fn web_info_advertises_spawn_presets() { } #[tokio::test] -async fn recent_dirs_self_returns_empty_without_history() { - // #26: the self-path reads this daemon's spawn history. The test fixture - // wires no history, so the list is empty but the endpoint is well-formed. - let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await; - let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/daemons/laptop/recent-dirs")) +async fn recent_dirs_returns_empty_without_history() { + // #26: reads this daemon's spawn history. The test fixture wires no + // history, so the list is empty but the endpoint is well-formed. + let h = spawn("naps-laptop").await; + let resp: serde_json::Value = reqwest::get(url(h.addr, "/api/recent-dirs")) .await .unwrap() .json() @@ -279,15 +152,15 @@ async fn recent_dirs_self_returns_empty_without_history() { } #[tokio::test] -async fn gitroot_self_probes_path() { - // #26: the self-path resolves the query against the daemon home dir and - // walks up for a `.git`. A `/tmp`-rooted temp dir is not in a repo → null. - let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await; +async fn gitroot_probes_path() { + // #26: resolves the query against the daemon home dir and walks up for a + // `.git`. A `/tmp`-rooted temp dir is not in a repo, so the root is null. + let h = spawn("naps-laptop").await; let tmp = tempfile::tempdir().unwrap(); let resp: serde_json::Value = reqwest::get(url( h.addr, &format!( - "/api/daemons/laptop/gitroot?path={}", + "/api/gitroot?path={}", urlencoding::encode(&tmp.path().to_string_lossy()) ), )) @@ -299,22 +172,9 @@ async fn gitroot_self_probes_path() { assert!(resp.get("root").is_none() || resp["root"].is_null()); } -#[tokio::test] -async fn recent_dirs_for_peer_503s_without_registry_link() { - // #26: a peer target brokers over the (absent) registry link, failing fast - // with a retryable 503 — same shape as the cross-daemon create path. - let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await; - let resp = reqwest::get(url(h.addr, "/api/daemons/konishi/recent-dirs")) - .await - .unwrap(); - assert_eq!(resp.status(), reqwest::StatusCode::SERVICE_UNAVAILABLE); - let body: serde_json::Value = resp.json().await.unwrap(); - assert_eq!(body["error"]["code"], "no_registry_link"); -} - #[tokio::test] async fn delete_unknown_session_returns_404() { - let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await; + let h = spawn("naps-laptop").await; let resp = reqwest::Client::new() .delete(url(h.addr, &format!("/api/sessions/{}", Uuid::new_v4()))) .send() @@ -325,11 +185,10 @@ async fn delete_unknown_session_returns_404() { #[tokio::test] async fn delete_owned_session_kills_it() { - let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await; + let h = spawn("naps-laptop").await; let session = h .manager .create(CreateSessionRequest { - daemon_id: "laptop".into(), cwd: "/tmp".into(), command: "sleep 60".into(), env: HashMap::new(), @@ -348,12 +207,11 @@ async fn delete_owned_session_kills_it() { } #[tokio::test] -async fn broker_ws_attaches_to_local_session() { - let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await; +async fn attach_ws_streams_pty_bytes() { + let h = spawn("naps-laptop").await; let session = h .manager .create(CreateSessionRequest { - daemon_id: "laptop".into(), cwd: "/tmp".into(), command: "bash --noprofile --norc".into(), env: HashMap::new(), @@ -367,7 +225,7 @@ async fn broker_ws_attaches_to_local_session() { let (mut ws, _): (WsClient, _) = connect_async(ws_url).await.unwrap(); // Provoke output. The bash process should echo this back via the PTY. - ws.send(WsMessage::Binary(b"echo hello-broker\n".to_vec())) + ws.send(WsMessage::Binary(b"echo hello-attach\n".to_vec())) .await .unwrap(); @@ -375,7 +233,7 @@ async fn broker_ws_attaches_to_local_session() { loop { match ws.next().await { Some(Ok(WsMessage::Binary(bytes))) => { - if String::from_utf8_lossy(&bytes).contains("hello-broker") { + if String::from_utf8_lossy(&bytes).contains("hello-attach") { return true; } } @@ -386,12 +244,12 @@ async fn broker_ws_attaches_to_local_session() { }) .await .unwrap_or(false); - assert!(saw_hello, "PTY did not echo through the broker WS"); + assert!(saw_hello, "PTY did not echo through the attach WS"); } #[tokio::test] -async fn broker_ws_for_unknown_session_returns_404_not_502() { - let h = spawn("laptop", "naps-laptop", "ws://10.0.0.1:7070").await; +async fn attach_ws_for_unknown_session_returns_404() { + let h = spawn("naps-laptop").await; let ws_url = format!("ws://{}/api/attach/{}", h.addr, Uuid::new_v4()); let err = connect_async(ws_url) .await diff --git a/crates/maestro-daemon/tests/registry_client.rs b/crates/maestro-daemon/tests/registry_client.rs deleted file mode 100644 index 84e7392..0000000 --- a/crates/maestro-daemon/tests/registry_client.rs +++ /dev/null @@ -1,214 +0,0 @@ -//! End-to-end tests for the daemon's outbound registry client. -//! -//! Uses the `maestro-registry` crate as the real registry under test. Verifies -//! the daemon connects, sends Hello + snapshot, forwards diffs, replies to -//! inbound CreateSession/KillSession, and reconnects after a registry kill. - -mod common; - -use std::collections::HashMap; -use std::net::{Ipv4Addr, SocketAddr}; -use std::time::Duration; - -use common::{wait_for_async, DaemonFixture}; -use maestro_daemon::RegistryClient; -use maestro_protocol::CreateSessionRequest; -use maestro_registry::{router, Registry}; -use tempfile::TempDir; -use tokio::net::TcpListener; -use tokio::sync::oneshot; - -const TOKEN: &str = "shared-registry-token"; - -struct RegistryHarness { - url: String, - state: Registry, - shutdown: Option>, -} - -async fn spawn_registry() -> RegistryHarness { - let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let state = Registry::new(); - let app = router(state.clone(), TOKEN.to_string()); - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - tokio::spawn(async move { - let _ = axum::serve(listener, app) - .with_graceful_shutdown(async move { - let _ = shutdown_rx.await; - }) - .await; - }); - RegistryHarness { - url: format!("ws://{addr}/daemon"), - state, - shutdown: Some(shutdown_tx), - } -} - -#[tokio::test] -async fn daemon_registers_and_streams_snapshot() { - let reg = spawn_registry().await; - let fx = DaemonFixture::new("laptop"); - - let client = RegistryClient::new( - reg.url.clone(), - "laptop", - "naps-laptop", - "ws://127.0.0.1:8081", - "/home/naps62", - TOKEN, - fx.manager.clone(), - ); - tokio::spawn(async move { client.run().await }); - - // wait for the Hello + snapshot to land - let state = reg.state.clone(); - wait_for_async(Duration::from_secs(5), || { - let r = state.clone(); - async move { - let daemons = r.daemons().await; - daemons.len() == 1 && daemons[0].daemon_id == "laptop" - } - }) - .await; - - // create a session via the manager — should trigger a SessionAdded diff - let cwd = TempDir::new().unwrap(); - let session = fx - .manager - .create(CreateSessionRequest { - daemon_id: "laptop".into(), - cwd: cwd.path().to_path_buf(), - command: "sleep 30".into(), - env: HashMap::new(), - metadata: HashMap::new(), - worktree: None, - }) - .await - .unwrap(); - - let state = reg.state.clone(); - let want_id = session.id; - wait_for_async(Duration::from_secs(5), || { - let r = state.clone(); - async move { - let s = r.sessions().await; - s.len() == 1 && s[0].id == want_id - } - }) - .await; - - let _ = fx.manager.kill(session.id).await; -} - -#[tokio::test] -async fn daemon_reconnects_after_registry_drop() { - // first registry: accept connection, then drop - let mut reg1 = spawn_registry().await; - let url = reg1.url.clone(); - let fx = DaemonFixture::new("laptop"); - - let client = RegistryClient::new( - url.clone(), - "laptop", - "naps-laptop", - "ws://127.0.0.1:8081", - "/home/naps62", - TOKEN, - fx.manager.clone(), - ); - tokio::spawn(async move { client.run().await }); - - // wait for daemon to register on the first registry - let state1 = reg1.state.clone(); - wait_for_async(Duration::from_secs(5), || { - let r = state1.clone(); - async move { !r.daemons().await.is_empty() } - }) - .await; - - // drop registry1; daemon should reconnect once we bring another up on the - // same port. Easier: bring up a second registry on a *different* port - // requires changing url, which RegistryClient doesn't support — instead, - // just verify the daemon's task survives the drop and the original - // registry's state becomes empty (the daemon connection died). - let _ = reg1.shutdown.take().unwrap().send(()); - // give some time for the daemon to notice - tokio::time::sleep(Duration::from_millis(500)).await; - - // RegistryClient::run loops forever, so we can't easily assert - // reconnection without standing up the registry on the same port again. - // The hard part — graceful failure of the inner connection — is what we're - // really testing: the daemon must not panic. - // (A full reconnect-against-fresh-server test belongs in the e2e crate - // NAP-217.) - let _ = fx; -} - -#[tokio::test] -async fn registry_create_session_round_trips() { - let reg = spawn_registry().await; - let fx = DaemonFixture::new("laptop"); - - let client = RegistryClient::new( - reg.url.clone(), - "laptop", - "naps-laptop", - "ws://127.0.0.1:8081", - "/home/naps62", - TOKEN, - fx.manager.clone(), - ); - tokio::spawn(async move { client.run().await }); - - let state = reg.state.clone(); - wait_for_async(Duration::from_secs(5), || { - let r = state.clone(); - async move { !r.daemons().await.is_empty() } - }) - .await; - - let cwd = TempDir::new().unwrap(); - let request = CreateSessionRequest { - daemon_id: "laptop".into(), - cwd: cwd.path().to_path_buf(), - command: "sleep 30".into(), - env: HashMap::new(), - metadata: HashMap::new(), - worktree: None, - }; - - let result = reg - .state - .create_session("laptop", request) - .await - .expect("transport ok"); - let session = result.expect("rpc ok"); - assert_eq!(session.command, "sleep 30"); - - // the registry should also see the SessionAdded diff in its catalog - let state = reg.state.clone(); - wait_for_async(Duration::from_secs(5), || { - let r = state.clone(); - async move { r.sessions().await.len() == 1 } - }) - .await; - - // and KillSession should round-trip too - let kill = reg - .state - .kill_session("laptop", session.id) - .await - .expect("transport ok"); - kill.expect("rpc ok"); - - let state = reg.state.clone(); - wait_for_async(Duration::from_secs(5), || { - let r = state.clone(); - async move { r.sessions().await.is_empty() } - }) - .await; -} diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index 8ae0929..501b90e 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -13,13 +13,12 @@ use tempfile::TempDir; #[tokio::test] async fn create_then_kill_round_trip() { - let fx = DaemonFixture::new("laptop"); + let fx = DaemonFixture::new(); let cwd = TempDir::new().unwrap(); let session = fx .manager .create(CreateSessionRequest { - daemon_id: "laptop".into(), cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -55,13 +54,12 @@ async fn create_then_kill_round_trip() { // end against a real tmux + on-disk JSONL log. #[tokio::test] async fn create_appends_to_spawn_history() { - let fx = DaemonFixture::new_with_history("laptop", 30); + let fx = DaemonFixture::new_with_history(30); let cwd = TempDir::new().unwrap(); let session = fx .manager .create(CreateSessionRequest { - daemon_id: "laptop".into(), cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -87,11 +85,10 @@ async fn create_appends_to_spawn_history() { // recents query is empty — the spawn itself still succeeds. #[tokio::test] async fn create_without_history_logs_nothing() { - let fx = DaemonFixture::new("laptop"); + let fx = DaemonFixture::new(); let cwd = TempDir::new().unwrap(); fx.manager .create(CreateSessionRequest { - daemon_id: "laptop".into(), cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -104,25 +101,6 @@ async fn create_without_history_logs_nothing() { assert!(!fx.spawn_history_path().exists()); } -#[tokio::test] -async fn rejects_wrong_daemon_in_request() { - let fx = DaemonFixture::new("laptop"); - let cwd = TempDir::new().unwrap(); - let err = fx - .manager - .create(CreateSessionRequest { - daemon_id: "dev-vm".into(), - cwd: cwd.path().to_path_buf(), - command: "sleep 5".into(), - env: HashMap::new(), - metadata: HashMap::new(), - worktree: None, - }) - .await - .unwrap_err(); - assert!(format!("{err}").contains("targets daemon"), "{err}"); -} - /// NAP-260: when the sidecar write fails *after* tmux has spawned the session, /// `create` must roll back — no ghost tmux session, no in-memory record left /// behind. We force the failure by making the sidecar directory read-only (the @@ -131,7 +109,7 @@ async fn rejects_wrong_daemon_in_request() { async fn create_rolls_back_tmux_session_when_sidecar_write_fails() { use std::os::unix::fs::PermissionsExt; - let fx = DaemonFixture::new("laptop"); + let fx = DaemonFixture::new(); let cwd = TempDir::new().unwrap(); let sidecar_dir = fx.sidecar_dir(); @@ -154,7 +132,6 @@ async fn create_rolls_back_tmux_session_when_sidecar_write_fails() { let res = fx .manager .create(CreateSessionRequest { - daemon_id: "laptop".into(), cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -183,7 +160,7 @@ async fn create_rolls_back_tmux_session_when_sidecar_write_fails() { #[tokio::test] async fn recover_adopts_existing_tmux_sessions() { - let fx = DaemonFixture::new("laptop"); + let fx = DaemonFixture::new(); let cwd = TempDir::new().unwrap(); // create a session, then "throw away" the SessionManager (the tmux session @@ -191,7 +168,6 @@ async fn recover_adopts_existing_tmux_sessions() { let s = fx .manager .create(CreateSessionRequest { - daemon_id: "laptop".into(), cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -206,7 +182,6 @@ async fn recover_adopts_existing_tmux_sessions() { let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(fx.worktree_root.path().to_path_buf()); let revived = maestro_daemon::SessionManager::new( - "laptop".to_string(), fx.tmux.tmux.clone(), sidecar, worktree_mgr, @@ -220,49 +195,6 @@ async fn recover_adopts_existing_tmux_sessions() { assert_eq!(snap[0].command, "sleep 30"); } -/// NAP-264: if the operator renamed the daemon between starts (config edit), -/// `recover()` must re-advertise adopted sessions under the CURRENT daemon_id, -/// not the stale one baked into the sidecar — otherwise nothing routes to them. -#[tokio::test] -async fn recover_overwrites_stale_daemon_id_after_rename() { - let fx = DaemonFixture::new("laptop"); - let cwd = TempDir::new().unwrap(); - - let s = fx - .manager - .create(CreateSessionRequest { - daemon_id: "laptop".into(), - cwd: cwd.path().to_path_buf(), - command: "sleep 30".into(), - env: HashMap::new(), - metadata: HashMap::new(), - worktree: None, - }) - .await - .expect("create"); - - // Revive against the same dirs + tmux server but under a NEW daemon_id, - // as if the operator edited `daemon_id` and restarted. - let sidecar = maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir()); - let worktree_mgr = - maestro_daemon::worktree::WorktreeManager::new(fx.worktree_root.path().to_path_buf()); - let revived = maestro_daemon::SessionManager::new( - "laptop-v2".to_string(), - fx.tmux.tmux.clone(), - sidecar, - worktree_mgr, - fx.fifo_dir(), - ); - assert_eq!(revived.recover().await.expect("recover"), 1); - - let snap = revived.snapshot().await; - assert_eq!(snap[0].id, s.id); - assert_eq!( - snap[0].daemon_id, "laptop-v2", - "recovered session kept the stale daemon_id from the sidecar" - ); -} - /// Regression for NAP-253: the very first bytes a freshly-spawned command /// writes must reach the replay buffer. Before the fix, `pipe-pane` was a /// separate tmux invocation that ran after `new-session` + an async FIFO @@ -270,13 +202,12 @@ async fn recover_overwrites_stale_daemon_id_after_rename() { /// into the FIFO. #[tokio::test] async fn create_captures_initial_output_in_replay_buffer() { - let fx = DaemonFixture::new("laptop"); + let fx = DaemonFixture::new(); let cwd = TempDir::new().unwrap(); let s = fx .manager .create(CreateSessionRequest { - daemon_id: "laptop".into(), cwd: cwd.path().to_path_buf(), // The shell prints the marker before doing anything else and // then sleeps so the session stays alive. Before NAP-253 this @@ -318,13 +249,12 @@ async fn create_captures_initial_output_in_replay_buffer() { /// nothing. #[tokio::test] async fn recover_re_pipes_pane_so_output_reaches_subscribers() { - let fx = DaemonFixture::new("laptop"); + let fx = DaemonFixture::new(); let cwd = TempDir::new().unwrap(); let s = fx .manager .create(CreateSessionRequest { - daemon_id: "laptop".into(), cwd: cwd.path().to_path_buf(), command: "bash --noprofile --norc".into(), env: HashMap::new(), @@ -350,13 +280,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() { let sidecar = maestro_daemon::sidecar::SidecarStore::new(sidecar_dir); let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(worktree_root); - let revived = maestro_daemon::SessionManager::new( - "laptop".to_string(), - tmux, - sidecar, - worktree_mgr, - fifo_dir, - ); + let revived = maestro_daemon::SessionManager::new(tmux, sidecar, worktree_mgr, fifo_dir); assert_eq!(revived.recover().await.expect("recover"), 1); let mut rx = revived.subscribe_pty(s.id).await.expect("subscribe"); @@ -387,13 +311,12 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() { #[tokio::test] async fn pty_broadcast_delivers_bytes_to_subscribers() { - let fx = DaemonFixture::new("laptop"); + let fx = DaemonFixture::new(); let cwd = TempDir::new().unwrap(); let s = fx .manager .create(CreateSessionRequest { - daemon_id: "laptop".into(), cwd: cwd.path().to_path_buf(), command: "bash --noprofile --norc".into(), env: HashMap::new(), @@ -438,13 +361,12 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() { #[tokio::test] async fn liveness_watcher_reaps_dead_sessions_and_emits_removed() { - let fx = DaemonFixture::new("laptop"); + let fx = DaemonFixture::new(); let cwd = TempDir::new().unwrap(); let s = fx .manager .create(CreateSessionRequest { - daemon_id: "laptop".into(), cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), @@ -491,13 +413,12 @@ async fn liveness_watcher_reaps_dead_sessions_and_emits_removed() { #[tokio::test] async fn liveness_watcher_leaves_running_sessions_alone() { - let fx = DaemonFixture::new("laptop"); + let fx = DaemonFixture::new(); let cwd = TempDir::new().unwrap(); let s = fx .manager .create(CreateSessionRequest { - daemon_id: "laptop".into(), cwd: cwd.path().to_path_buf(), command: "sleep 30".into(), env: HashMap::new(), diff --git a/crates/maestro-daemon/tests/sidecar.rs b/crates/maestro-daemon/tests/sidecar.rs index ed92ed4..f270d1e 100644 --- a/crates/maestro-daemon/tests/sidecar.rs +++ b/crates/maestro-daemon/tests/sidecar.rs @@ -12,10 +12,9 @@ use serde_json::Value; use tempfile::TempDir; use uuid::Uuid; -fn sample(id: Uuid, daemon_id: &str) -> Session { +fn sample(id: Uuid) -> Session { Session { id, - daemon_id: daemon_id.to_string(), cwd: PathBuf::from("/tmp/work"), command: "bash".into(), env: HashMap::from([("FOO".into(), "bar".into())]), @@ -36,7 +35,7 @@ fn write_then_read_round_trip() { let dir = TempDir::new().unwrap(); let store = SidecarStore::new(dir.path()); let id = Uuid::new_v4(); - let s = sample(id, "laptop"); + let s = sample(id); store.write(&s).expect("write"); let loaded = store.read(id).expect("read"); @@ -59,7 +58,7 @@ fn delete_removes_file_and_is_idempotent() { let dir = TempDir::new().unwrap(); let store = SidecarStore::new(dir.path()); let id = Uuid::new_v4(); - let s = sample(id, "laptop"); + let s = sample(id); store.write(&s).unwrap(); assert!(store.path_for(id).exists()); @@ -76,8 +75,8 @@ fn load_all_recovers_every_sidecar_and_skips_garbage() { let dir = TempDir::new().unwrap(); let store = SidecarStore::new(dir.path()); - let s1 = sample(Uuid::new_v4(), "laptop"); - let s2 = sample(Uuid::new_v4(), "dev-vm"); + let s1 = sample(Uuid::new_v4()); + let s2 = sample(Uuid::new_v4()); store.write(&s1).unwrap(); store.write(&s2).unwrap(); @@ -86,9 +85,9 @@ fn load_all_recovers_every_sidecar_and_skips_garbage() { std::fs::write(dir.path().join("broken.json"), b"{not json}").unwrap(); let mut loaded = store.load_all().expect("load_all"); - loaded.sort_by_key(|s| s.daemon_id.clone()); + loaded.sort_by_key(|s| s.id); let mut expected = vec![s1, s2]; - expected.sort_by_key(|s| s.daemon_id.clone()); + expected.sort_by_key(|s| s.id); assert_eq!(loaded, expected); } @@ -104,7 +103,7 @@ fn write_creates_parent_directory() { let dir = TempDir::new().unwrap(); let nested = dir.path().join("a/b/c"); let store = SidecarStore::new(&nested); - let s = sample(Uuid::new_v4(), "laptop"); + let s = sample(Uuid::new_v4()); store.write(&s).expect("write"); assert!(nested.exists()); assert!(store.path_for(s.id).exists()); diff --git a/crates/maestro-e2e/Cargo.toml b/crates/maestro-e2e/Cargo.toml index 9edb70c..5077b17 100644 --- a/crates/maestro-e2e/Cargo.toml +++ b/crates/maestro-e2e/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true license.workspace = true repository.workspace = true publish.workspace = true -description = "Cross-process integration tests: real registry + daemons as subprocesses" +description = "Cross-process integration tests: a real daemon as a subprocess" [lib] path = "src/lib.rs" diff --git a/crates/maestro-e2e/src/registry.rs b/crates/maestro-e2e/src/registry.rs deleted file mode 100644 index c3fc154..0000000 --- a/crates/maestro-e2e/src/registry.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Subprocess wrapper around `maestro-registry`. - -use std::net::{Ipv4Addr, SocketAddr}; -use std::path::PathBuf; -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use tempfile::TempDir; - -use crate::build::workspace_target_bin; -use crate::util::{free_port, wait_for_async}; - -/// A `maestro-registry` running in a child process. The state directory is -/// kept as a `TempDir` so it survives the `Registry`'s lifetime and gets -/// cleaned up on drop. -#[derive(Debug)] -pub struct Registry { - pub addr: SocketAddr, - pub token: String, - child: Option, - _home: TempDir, -} - -impl Registry { - pub async fn spawn(token: &str) -> Self { - let port = free_port(); - let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); - let home = TempDir::new().expect("home tmpdir"); - - let bin = workspace_target_bin("maestro", "maestro"); - let child = Command::new(&bin) - .arg("registry") - .env_clear() - // PATH so tmux etc. resolve. We need a few essentials. - .env( - "PATH", - std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".into()), - ) - .env("HOME", home.path()) - .env("XDG_CONFIG_HOME", home.path().join(".config")) - .env("XDG_STATE_HOME", home.path().join(".local/state")) - .env("MAESTRO_TOKEN", token) - .env("MAESTRO_REGISTRY_BIND_ADDR", addr.to_string()) - .env("RUST_LOG", "warn") - .stdout(Stdio::null()) - .stderr(Stdio::inherit()) - .spawn() - .unwrap_or_else(|e| panic!("spawn {} failed: {e}", bin.display())); - - let url = format!("http://{addr}/api/daemons"); - let http = reqwest::Client::builder() - .timeout(Duration::from_secs(2)) - .build() - .expect("reqwest client"); - wait_for_async("registry to bind", Duration::from_secs(5), || { - let http = http.clone(); - let url = url.clone(); - async move { http.get(&url).send().await.is_ok() } - }) - .await; - - Self { - addr, - token: token.to_string(), - child: Some(child), - _home: home, - } - } - - pub fn http_base(&self) -> String { - format!("http://{}", self.addr) - } - - pub fn ws_base(&self) -> String { - format!("ws://{}", self.addr) - } - - /// URL daemons dial. - pub fn daemon_ws_url(&self) -> String { - format!("ws://{}/daemon", self.addr) - } - - pub fn state_dir(&self) -> PathBuf { - self._home.path().to_path_buf() - } -} - -impl Drop for Registry { - fn drop(&mut self) { - if let Some(mut c) = self.child.take() { - let _ = c.kill(); - let _ = c.wait(); - } - } -} diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index d236d85..048fa97 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -1,7 +1,9 @@ -//! Wire types shared between the Maestro daemon, registry, and clients. +//! Wire types shared between the Maestro daemon and its clients (CLI, TUI, web). //! -//! See `docs/superpowers/specs/2026-05-24-maestro-design.md` § "Wire protocol" -//! and § "Session model" for the source-of-truth schema. +//! Maestro is single-host: one daemon owns every session on the machine, and +//! clients talk to it directly over `/api/*`. There is no registry, no peer +//! federation, and no authentication layer — reaching the daemon's bind address +//! *is* the authorization, same trust model as tmux itself. #![deny(missing_debug_implementations)] @@ -13,19 +15,13 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; -/// A correlation token used to pair request/response messages over a single WS. -pub type ReplyTo = Uuid; - -/// The protocol version this crate implements. Daemons advertise it in `Hello`. +/// The protocol version this crate implements. Advertised in `GET /api/info`. pub const PROTOCOL_VERSION: &str = env!("CARGO_PKG_VERSION"); -/// A live or recently-dead tmux session managed by a daemon. -/// -/// The daemon is the source of truth; the registry caches snapshots. +/// A live or recently-dead tmux session managed by the daemon. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Session { pub id: Uuid, - pub daemon_id: String, pub cwd: PathBuf, pub command: String, #[serde(default)] @@ -94,11 +90,9 @@ pub struct WorktreeSpec { pub cleanup_on_kill: bool, } -/// HTTP `POST /api/sessions` body, also forwarded daemon-ward inside -/// `DaemonInbound::CreateSession`. +/// HTTP `POST /api/sessions` body. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CreateSessionRequest { - pub daemon_id: String, pub cwd: PathBuf, pub command: String, #[serde(default)] @@ -109,9 +103,8 @@ pub struct CreateSessionRequest { pub worktree: Option, } -/// Result of a `ListDir` query: the immediate subdirectories of one directory -/// on a daemon's host. Used to drive the web spawn form's working-directory -/// autocomplete (NAP-329). +/// Result of `GET /api/dirs`: the immediate subdirectories of one directory. +/// Drives the spawn form's working-directory autocomplete (NAP-329). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DirListing { /// The resolved absolute directory that was listed, after the daemon @@ -138,9 +131,9 @@ pub struct RecentDir { pub last_used: DateTime, } -/// Result of a `RecentDirs` query: a daemon's recently-used working dirs, -/// newest-first and deduped by `cwd` (#26). `truncated` mirrors [`DirListing`]: -/// true when the daemon capped the list. +/// Result of `GET /api/recent-dirs`: recently-used working dirs, newest-first +/// and deduped by `cwd` (#26). `truncated` mirrors [`DirListing`]: true when +/// the daemon capped the list. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RecentDirs { pub dirs: Vec, @@ -148,9 +141,9 @@ pub struct RecentDirs { pub truncated: bool, } -/// Result of a `GitRoot` probe: the enclosing git repository toplevel of a -/// path on a daemon's host, or `None` when the path isn't inside a repo (#26). -/// Drives the spawn form's worktree section — a present `root` is the inferred +/// Result of a `GET /api/gitroot` probe: the enclosing git repository toplevel +/// of a path, or `None` when the path isn't inside a repo (#26). Drives the +/// spawn form's worktree section — a present `root` is the inferred /// `base_repo`; `None` disables the worktree toggle. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct GitRoot { @@ -165,336 +158,25 @@ pub struct ErrorInfo { pub message: String, } -/// Generic request-reply result body. Used by `CreateSessionResult`, -/// `KillSessionResult`, and any future correlated reply. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "status", rename_all = "snake_case")] -pub enum RpcResult { - Ok { value: T }, - Err { error: ErrorInfo }, -} - // --------------------------------------------------------------------------- -// Daemon ↔ Registry messages +// Catalog events (`WS /api/events`) // --------------------------------------------------------------------------- -/// Messages a daemon sends to the registry over its outbound WS. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum DaemonOutbound { - Hello { - daemon_id: String, - hostname: String, - version: String, - token: String, - /// Reachable URL of this daemon's per-session WS endpoint, as the - /// operator wants web clients to dial it. The registry hands this back - /// in `GET /api/sessions/:id/connect-info` so the client can connect - /// directly. Example: `ws://10.0.0.1:8081`. - daemon_url: String, - /// Absolute home directory of the OS user this daemon runs as, used by - /// clients to prefill a spawn form's working directory. Empty when the - /// daemon could not resolve `$HOME`. (NAP-328) - #[serde(default)] - home_dir: String, - }, - SessionsSnapshot { - sessions: Vec, - }, - SessionAdded { - session: Session, - }, - SessionRemoved { - id: Uuid, - }, - SessionUpdated { - session: Session, - }, - /// Liveness ticker. The optional `perimeter` advertises the daemon's - /// reachability for the T2 transport (NAP-242); the typed object encodes the - /// "both id and addr, or neither" invariant once (NAP-272). Old daemons that - /// don't set it still produce the bare `{"type":"Heartbeat"}` shape. - Heartbeat { - #[serde(default, skip_serializing_if = "Option::is_none")] - perimeter: Option, - }, - CreateSessionResult { - reply_to: ReplyTo, - result: RpcResult, - }, - KillSessionResult { - reply_to: ReplyTo, - result: RpcResult<()>, - }, - /// Home daemon asks the registry to resolve a session it doesn't own: - /// "where is session X and how do I reach it?" The registry replies with - /// [`DaemonInbound::ResolveAttachResult`] containing [`AttachCandidates`]. - ResolveAttach { - reply_to: ReplyTo, - session_id: Uuid, - }, - /// Home daemon asks the registry to relay a session create to the daemon - /// that should own it (`request.daemon_id`). Control-plane only: the - /// registry forwards the request to the owner over its control link and - /// relays the owner's reply — no session bytes ever pass through the - /// registry, so the data-plane stays daemon-to-daemon. Reply: - /// [`DaemonInbound::ForwardCreateResult`]. (NAP-327) - ForwardCreate { - reply_to: ReplyTo, - request: CreateSessionRequest, - }, - /// Home daemon asks the registry to relay a kill to the daemon that owns - /// `id`. Reply: [`DaemonInbound::ForwardKillResult`]. (NAP-327) - ForwardKill { - reply_to: ReplyTo, - daemon_id: String, - id: Uuid, - }, - /// Owning daemon's reply to a [`DaemonInbound::ListDir`] — the requested - /// directory listing on its host. (NAP-329) - ListDirResult { - reply_to: ReplyTo, - result: RpcResult, - }, - /// Home daemon asks the registry to relay a directory listing to the daemon - /// named by `daemon_id` (which may be a peer). Control-plane only, like - /// [`ForwardCreate`](Self::ForwardCreate). Reply: - /// [`DaemonInbound::ForwardListDirResult`]. (NAP-329) - ForwardListDir { - reply_to: ReplyTo, - daemon_id: String, - path: String, - }, - /// Owning daemon's reply to a [`DaemonInbound::RecentDirs`] — its recent - /// working dirs. (#26) - RecentDirsResult { - reply_to: ReplyTo, - result: RpcResult, - }, - /// Home daemon asks the registry to relay a recent-dirs query to the daemon - /// named by `daemon_id` (which may be a peer). Recents are daemon-wide so - /// there's no path argument. Reply: - /// [`DaemonInbound::ForwardRecentDirsResult`]. (#26) - ForwardRecentDirs { - reply_to: ReplyTo, - daemon_id: String, - }, - /// Owning daemon's reply to a [`DaemonInbound::GitRoot`] — the enclosing - /// repo toplevel of the requested path on its host. (#26) - GitRootResult { - reply_to: ReplyTo, - result: RpcResult, - }, - /// Home daemon asks the registry to relay a gitroot probe to the daemon - /// named by `daemon_id` (which may be a peer). Reply: - /// [`DaemonInbound::ForwardGitRootResult`]. (#26) - ForwardGitRoot { - reply_to: ReplyTo, - daemon_id: String, - path: String, - }, - /// Target daemon answers a [`DaemonInbound::RelaySetup`]: whether it will - /// accept relay traffic for the requested session. Registry only allocates - /// relay capacity when `accepted == true`. - RelayAck { - reply_to: ReplyTo, - accepted: bool, - }, - /// Forward-compat catch-all: a frame whose `type` this build doesn't - /// recognize (e.g. a newer daemon emitting a not-yet-merged variant). - /// Readers log and skip it instead of tearing the connection down (NAP-269). - #[serde(other)] - Unknown, -} - -/// Messages the registry sends back to a daemon over the same WS. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum DaemonInbound { - Ack, - Error(ErrorInfo), - CreateSession { - reply_to: ReplyTo, - request: CreateSessionRequest, - }, - KillSession { - reply_to: ReplyTo, - id: Uuid, - }, - /// Registry's response to [`DaemonOutbound::ResolveAttach`]. `Err` covers - /// "session not found", "no daemon owns this session", etc. - ResolveAttachResult { - reply_to: ReplyTo, - result: RpcResult, - }, - /// Registry's reply to [`DaemonOutbound::ForwardCreate`] — the owning - /// daemon's create result, relayed back to the home daemon. `Err` covers - /// "owner offline", the owner's own spawn failure, etc. (NAP-327) - ForwardCreateResult { - reply_to: ReplyTo, - result: RpcResult, - }, - /// Registry's reply to [`DaemonOutbound::ForwardKill`]. (NAP-327) - ForwardKillResult { - reply_to: ReplyTo, - result: RpcResult<()>, - }, - /// Registry asks a daemon to list `path` on its host. The daemon expands - /// `~`/relative/empty against its own home dir, then replies with - /// [`DaemonOutbound::ListDirResult`]. (NAP-329) - ListDir { - reply_to: ReplyTo, - path: String, - }, - /// Registry's reply to [`DaemonOutbound::ForwardListDir`] — the owning - /// daemon's listing, relayed back to the home daemon. (NAP-329) - ForwardListDirResult { - reply_to: ReplyTo, - result: RpcResult, - }, - /// Registry asks a daemon for its recent working dirs (#26). Daemon-wide, - /// so no arguments beyond the correlation token; the daemon replies with - /// [`DaemonOutbound::RecentDirsResult`]. - RecentDirs { - reply_to: ReplyTo, - }, - /// Registry's reply to [`DaemonOutbound::ForwardRecentDirs`] — the owning - /// daemon's recents, relayed back to the home daemon. (#26) - ForwardRecentDirsResult { - reply_to: ReplyTo, - result: RpcResult, - }, - /// Registry asks a daemon to probe the enclosing git repo of `path` on its - /// host (#26). The daemon expands `~`/relative/empty against its own home - /// dir, then replies with [`DaemonOutbound::GitRootResult`]. - GitRoot { - reply_to: ReplyTo, - path: String, - }, - /// Registry's reply to [`DaemonOutbound::ForwardGitRoot`] — the owning - /// daemon's gitroot probe, relayed back to the home daemon. (#26) - ForwardGitRootResult { - reply_to: ReplyTo, - result: RpcResult, - }, - /// Registry signals a target daemon that a home daemon wants to attach via - /// the registry-mediated relay (T3). Target decides whether to accept the - /// relay and replies with [`DaemonOutbound::RelayAck`]. - RelaySetup { - reply_to: ReplyTo, - home_daemon: String, - session_id: Uuid, - }, - /// Catalog delta forwarded from the registry so each connected daemon can - /// maintain a local mirror of the global catalog (peer daemons + their - /// sessions). The registry filters out events originating at the - /// recipient daemon — a daemon only sees *peer* deltas. The first event - /// a freshly-connected daemon receives is a `RegistryEvent::Snapshot`. - CatalogEvent { - event: RegistryEvent, - }, - /// Forward-compat catch-all: a frame whose `type` this build doesn't - /// recognize (e.g. a newer registry emitting a not-yet-merged variant). - /// Readers log and skip it instead of tearing the connection down (NAP-269). - #[serde(other)] - Unknown, -} - -// --------------------------------------------------------------------------- -// Client ↔ Registry API types -// --------------------------------------------------------------------------- - -/// Public, read-only view of a connected daemon. Returned by `GET /api/daemons` -/// and included in `RegistryEvent::Snapshot` / `RegistryEvent::DaemonAdded`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct DaemonInfo { - pub daemon_id: String, - pub hostname: String, - pub version: String, - pub daemon_url: String, - /// Absolute home directory of the OS user the daemon runs as. Clients use - /// it to prefill the spawn form's working directory. Empty when unknown - /// (older daemon, or `$HOME` unresolved). (NAP-328) - #[serde(default)] - pub home_dir: String, -} - -/// Response body for `GET /api/sessions/:id/connect-info`. -/// -/// Superseded by the broker-mediated `ResolveAttach` / [`AttachCandidates`] -/// flow added for the connection-orchestration epic (NAP-236). Retained as a -/// compat path while clients migrate; new code should prefer the broker model. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ConnectInfo { - pub daemon_url: String, - pub jwt: String, -} - -// --------------------------------------------------------------------------- -// Broker-mediated attach (NAP-242) -// -// Wire types for the multi-transport attach flow defined in -// `docs/superpowers/specs/2026-05-26-connection-orchestration.md`. The home -// daemon asks the registry to `ResolveAttach`; the registry replies with -// `AttachCandidates` describing the transports the home daemon may try (T1 -// direct WSS, T2 direct WS over shared perimeter, T3 registry-mediated relay) -// plus a pair-scoped JWT. For T3, the registry signals the target daemon with -// `RelaySetup` and only allocates relay capacity after a `RelayAck`. -// --------------------------------------------------------------------------- - -/// T2 transport hint: target daemon's reachable address on a perimeter the -/// home daemon may share. The home daemon dials `ws:///...` only -/// if its own `perimeter_id` matches. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PerimeterCandidate { - pub perimeter_id: String, - pub private_addr: String, -} - -/// T3 transport marker. The concrete shape (relay endpoint, ticket, etc.) -/// depends on the relay backend selected by NAP-238 (Iroh vs libp2p -/// circuit-v2), so this stays a `#[non_exhaustive]` placeholder for now. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -#[non_exhaustive] -pub struct RelayCandidate {} - -/// Registry's reply to `ResolveAttach`. Each `Option` describes one viable -/// transport; the home daemon tries them in T1 → T2 → T3 order. `jwt` is the -/// pair-scoped credential ([`PairAttachClaims`]) target daemons validate. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AttachCandidates { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub direct_wss: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub perimeter: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub relay: Option, - pub jwt: String, -} - -/// Messages broadcast by the registry to web clients over `WS /api/events`. +/// Messages the daemon broadcasts to clients over `WS /api/events`. /// /// A subscriber receives a single `Snapshot` immediately on connect, then a -/// stream of deltas as daemons connect/disconnect and their session sets -/// change. Skipped events (broadcast channel lag) surface as `Lagged`. +/// stream of deltas as sessions are created, updated, and killed. Skipped +/// events (broadcast channel lag) surface as `Lagged`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type")] -pub enum RegistryEvent { +pub enum CatalogEvent { Snapshot { - daemons: Vec, sessions: Vec, }, - DaemonAdded { - daemon: DaemonInfo, - }, - DaemonRemoved { - daemon_id: String, - }, SessionAdded { session: Session, }, SessionRemoved { - daemon_id: String, id: Uuid, }, SessionUpdated { @@ -507,51 +189,11 @@ pub enum RegistryEvent { }, } -impl RegistryEvent { - /// Filter this event for a daemon recipient that should only see *peer* - /// state. Returns `None` if the entire event concerns the recipient, - /// otherwise a possibly-pruned event with self-references removed. - /// - /// Used by the registry's daemon fan-out (NAP-246) so each daemon's - /// local catalog mirror reflects every other daemon, not itself. - pub fn for_peer(self, self_daemon_id: &str) -> Option { - match self { - RegistryEvent::Snapshot { daemons, sessions } => { - let daemons = daemons - .into_iter() - .filter(|d| d.daemon_id != self_daemon_id) - .collect::>(); - let sessions = sessions - .into_iter() - .filter(|s| s.daemon_id != self_daemon_id) - .collect::>(); - Some(RegistryEvent::Snapshot { daemons, sessions }) - } - RegistryEvent::DaemonAdded { ref daemon } if daemon.daemon_id == self_daemon_id => None, - RegistryEvent::DaemonRemoved { ref daemon_id } if daemon_id == self_daemon_id => None, - RegistryEvent::SessionAdded { ref session } if session.daemon_id == self_daemon_id => { - None - } - RegistryEvent::SessionRemoved { ref daemon_id, .. } if daemon_id == self_daemon_id => { - None - } - RegistryEvent::SessionUpdated { ref session } - if session.daemon_id == self_daemon_id => - { - None - } - // Everything else (Lagged, peer adds/removes/updates) is forwarded - // as-is. - other => Some(other), - } - } -} - // --------------------------------------------------------------------------- // Client ↔ Daemon control messages (text frames on the attach WS) // --------------------------------------------------------------------------- -/// Text-frame control messages on a `WS /sessions/:id/stream` connection. +/// Text-frame control messages on a `WS /api/attach/:id` connection. /// PTY bytes travel in binary frames and are not modeled here. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type")] @@ -562,96 +204,10 @@ pub enum ClientControl { Error(ErrorInfo), } -// --------------------------------------------------------------------------- -// JWT claims used on the per-session attach WS -// --------------------------------------------------------------------------- - -/// Issued by the registry, validated locally by the daemon. Short-lived -/// (~5 min). The daemon checks `daemon_id` matches itself and `session_id` -/// is one of its live sessions. -/// -/// Superseded by [`PairAttachClaims`] for the broker-mediated attach flow -/// (NAP-242). Retained as a compat path while clients migrate. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct JwtClaims { - pub session_id: Uuid, - pub daemon_id: String, - /// Expiry (unix seconds). - pub exp: i64, - /// Issued at (unix seconds). Optional, mostly for diagnostics. - #[serde(default)] - pub iat: Option, -} - -/// `iss` claim baked into every registry-issued JWT (pair-attach credentials -/// and auth cookies alike). Verifiers bind to it so a token minted for one -/// purpose can't be replayed as another even though both share the signing -/// secret (NAP-266). -pub const REGISTRY_ISSUER: &str = "maestro-registry"; - -/// Name of the auth cookie the registry sets and the CLI persists. Defined -/// once here so both ends of the wire agree (NAP-273). -pub const COOKIE_NAME: &str = "maestro_session"; - -/// Find a named cookie's value inside a `Cookie:` / `Set-Cookie:` header value. -/// -/// Iterates every `;`-separated `name=value` pair, so it works whether the -/// target pair is first or buried among other cookies or `Set-Cookie` -/// attributes (`HttpOnly`, `Path=/`, …). Returns the first match, or `None`. -/// Shared by the registry's inbound-cookie check and the CLI's `Set-Cookie` -/// capture (NAP-273). -pub fn extract_cookie<'a>(header_value: &'a str, name: &str) -> Option<&'a str> { - for part in header_value.split(';') { - let part = part.trim(); - if let Some((k, v)) = part.split_once('=') { - if k.trim() == name { - return Some(v.trim()); - } - } - } - None -} - -/// Pair-scoped attach credential. Issued by the registry inside -/// [`AttachCandidates::jwt`]; verified by the target daemon for direct -/// transports (T1/T2). Binds the token to a target daemon and session; the -/// home daemon's identity (`sub`) is not gated against an allow-list (NAP-339). -/// -/// "Pair-scoped" means: `sub` (home daemon) may initiate one attach to -/// `aud` (target daemon) for session `sid`, valid until `exp`. A stolen -/// token still only works between the two daemons it was issued for. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PairAttachClaims { - /// Issuer — registry id. - pub iss: String, - /// Subject — home daemon-id (the daemon initiating attach). - pub sub: String, - /// Audience — target daemon-id (the daemon owning the session). - pub aud: String, - /// Session id this credential is scoped to. - pub sid: Uuid, - /// Expiry (unix seconds). Short-lived; v1 default 60s. - pub exp: i64, -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn extract_cookie_finds_named_value_anywhere() { - // Cookie: header — pair may be buried among others. - let h = "foo=1; maestro_session=abc.def.ghi; bar=2"; - assert_eq!(extract_cookie(h, COOKIE_NAME), Some("abc.def.ghi")); - assert_eq!(extract_cookie(h, "foo"), Some("1")); - assert_eq!(extract_cookie(h, "missing"), None); - // Set-Cookie: header — cookie first, attributes (no `=` or other keys) - // after it must be skipped, not matched. - let sc = "maestro_session=xyz; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000"; - assert_eq!(extract_cookie(sc, COOKIE_NAME), Some("xyz")); - assert_eq!(extract_cookie("other=1; HttpOnly", COOKIE_NAME), None); - } - fn round_trip(value: &T) -> T where T: Serialize + for<'de> Deserialize<'de>, @@ -663,7 +219,6 @@ mod tests { fn sample_session() -> Session { Session { id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef), - daemon_id: "laptop".into(), cwd: PathBuf::from("/home/naps62/projects/maestro"), command: "claude".into(), env: HashMap::from([("FOO".into(), "bar".into())]), @@ -687,7 +242,6 @@ mod tests { // to the forward-compat defaults rather than failing. let json = serde_json::json!({ "id": Uuid::from_u128(1), - "daemon_id": "laptop", "cwd": "/tmp", "command": "bash", "status": "Running", @@ -730,7 +284,6 @@ mod tests { #[test] fn create_session_request_with_worktree_round_trips() { let req = CreateSessionRequest { - daemon_id: "dev-vm".into(), cwd: PathBuf::from("/work"), command: "bash".into(), env: HashMap::new(), @@ -747,7 +300,6 @@ mod tests { #[test] fn create_session_request_omits_optional_worktree() { let json = serde_json::json!({ - "daemon_id": "dev-vm", "cwd": "/work", "command": "bash" }); @@ -758,106 +310,25 @@ mod tests { } #[test] - fn rpc_result_ok_and_err_round_trip() { - let ok: RpcResult = RpcResult::Ok { - value: sample_session(), - }; - let err: RpcResult = RpcResult::Err { - error: ErrorInfo { - code: "tmux_failed".into(), - message: "session name in use".into(), - }, - }; - assert_eq!(ok, round_trip(&ok)); - assert_eq!(err, round_trip(&err)); - } - - #[test] - fn daemon_outbound_hello_round_trip_and_tag() { - let hello = DaemonOutbound::Hello { - daemon_id: "laptop".into(), - hostname: "naps-laptop".into(), - version: PROTOCOL_VERSION.into(), - token: "shhh".into(), - daemon_url: "ws://10.0.0.1:8081".into(), - home_dir: String::new(), - }; - let v = serde_json::to_value(&hello).unwrap(); - assert_eq!(v["type"], "Hello"); - assert_eq!(v["daemon_id"], "laptop"); - assert_eq!(hello, round_trip(&hello)); - } - - #[test] - fn daemon_outbound_all_variants_round_trip() { + fn catalog_event_round_trips_and_tags() { let session = sample_session(); - let reply_to = Uuid::from_u128(1); let variants = vec![ - DaemonOutbound::SessionsSnapshot { + CatalogEvent::Snapshot { sessions: vec![session.clone()], }, - DaemonOutbound::SessionAdded { + CatalogEvent::SessionAdded { session: session.clone(), }, - DaemonOutbound::SessionRemoved { id: session.id }, - DaemonOutbound::SessionUpdated { - session: session.clone(), - }, - DaemonOutbound::Heartbeat { perimeter: None }, - DaemonOutbound::Heartbeat { - perimeter: Some(PerimeterCandidate { - perimeter_id: "wg-naps62".into(), - private_addr: "10.0.0.7:7070".into(), - }), - }, - DaemonOutbound::CreateSessionResult { - reply_to, - result: RpcResult::Ok { - value: session.clone(), - }, - }, - DaemonOutbound::KillSessionResult { - reply_to, - result: RpcResult::Ok { value: () }, - }, + CatalogEvent::SessionRemoved { id: session.id }, + CatalogEvent::SessionUpdated { session }, + CatalogEvent::Lagged { skipped: 3 }, ]; for v in variants { assert_eq!(v, round_trip(&v), "variant did not round-trip"); } - } - - #[test] - fn daemon_inbound_all_variants_round_trip() { - let reply_to = Uuid::from_u128(7); - let create = DaemonInbound::CreateSession { - reply_to, - request: CreateSessionRequest { - daemon_id: "laptop".into(), - cwd: PathBuf::from("/tmp"), - command: "bash".into(), - env: HashMap::new(), - metadata: HashMap::new(), - worktree: None, - }, - }; - let kill = DaemonInbound::KillSession { - reply_to, - id: Uuid::from_u128(42), - }; - let ack = DaemonInbound::Ack; - let err = DaemonInbound::Error(ErrorInfo { - code: "unknown_session".into(), - message: "no such id".into(), - }); - for v in [create, kill, ack, err] { - assert_eq!(v, round_trip(&v)); - } - } - - #[test] - fn daemon_inbound_ack_tag() { - let v = serde_json::to_value(DaemonInbound::Ack).unwrap(); - assert_eq!(v, serde_json::json!({ "type": "Ack" })); + let v = serde_json::to_value(CatalogEvent::Lagged { skipped: 3 }).unwrap(); + assert_eq!(v["type"], "Lagged"); + assert_eq!(v["skipped"], 3); } #[test] @@ -883,658 +354,4 @@ mod tests { assert_eq!(c, round_trip(&c)); } } - - #[test] - fn unknown_message_type_deserializes_to_unknown() { - // A newer peer can introduce a frame `type` this build has never heard - // of. It must decode to the forward-compat `Unknown` variant rather - // than erroring, so the reader skips it instead of dropping the WS - // (NAP-269). - let bad = serde_json::json!({ "type": "Bogus", "extra": 1 }); - assert_eq!( - serde_json::from_value::(bad).unwrap(), - DaemonOutbound::Unknown - ); - let bad_inbound = serde_json::json!({ "type": "AlsoBogus" }); - assert_eq!( - serde_json::from_value::(bad_inbound).unwrap(), - DaemonInbound::Unknown - ); - } - - #[test] - fn daemon_info_round_trips() { - let d = DaemonInfo { - daemon_id: "laptop".into(), - hostname: "naps-laptop".into(), - version: "0.1.0".into(), - daemon_url: "ws://10.0.0.1:8081".into(), - home_dir: "/home/naps62".into(), - }; - assert_eq!(d, round_trip(&d)); - } - - #[test] - fn daemon_info_home_dir_defaults_when_absent() { - // Older daemons / pre-NAP-328 payloads omit `home_dir`; it must - // deserialize to an empty string rather than failing. - let json = r#"{ - "daemon_id": "laptop", - "hostname": "naps-laptop", - "version": "0.1.0", - "daemon_url": "ws://10.0.0.1:8081" - }"#; - let d: DaemonInfo = serde_json::from_str(json).unwrap(); - assert_eq!(d.home_dir, ""); - } - - #[test] - fn connect_info_round_trips() { - let c = ConnectInfo { - daemon_url: "ws://10.0.0.1:8081".into(), - jwt: "eyJ...".into(), - }; - assert_eq!(c, round_trip(&c)); - } - - #[test] - fn registry_event_variants_round_trip_and_tag() { - let daemon = DaemonInfo { - daemon_id: "laptop".into(), - hostname: "naps-laptop".into(), - version: "0.1.0".into(), - daemon_url: "ws://10.0.0.1:8081".into(), - home_dir: String::new(), - }; - let session = sample_session(); - let variants = vec![ - RegistryEvent::Snapshot { - daemons: vec![daemon.clone()], - sessions: vec![session.clone()], - }, - RegistryEvent::DaemonAdded { - daemon: daemon.clone(), - }, - RegistryEvent::DaemonRemoved { - daemon_id: "laptop".into(), - }, - RegistryEvent::SessionAdded { - session: session.clone(), - }, - RegistryEvent::SessionRemoved { - daemon_id: "laptop".into(), - id: session.id, - }, - RegistryEvent::SessionUpdated { session }, - RegistryEvent::Lagged { skipped: 12 }, - ]; - for v in variants { - assert_eq!(v, round_trip(&v)); - } - - let json = serde_json::to_value(RegistryEvent::DaemonRemoved { - daemon_id: "x".into(), - }) - .unwrap(); - assert_eq!(json["type"], "DaemonRemoved"); - } - - #[test] - fn heartbeat_legacy_payload_parses_with_none_fields() { - // Daemons predating NAP-242 send the bare `{"type":"Heartbeat"}` shape. - // It must still deserialize, with both new fields defaulting to None. - let legacy = serde_json::json!({ "type": "Heartbeat" }); - let parsed: DaemonOutbound = serde_json::from_value(legacy).unwrap(); - match parsed { - DaemonOutbound::Heartbeat { perimeter } => { - assert!(perimeter.is_none()); - } - other => panic!("expected Heartbeat, got {other:?}"), - } - } - - #[test] - fn heartbeat_empty_serializes_to_legacy_shape() { - // A new daemon that doesn't set a perimeter must produce wire bytes - // identical to an old daemon — registries doing equality checks or - // logging payloads see no surprise. - let hb = DaemonOutbound::Heartbeat { perimeter: None }; - let v = serde_json::to_value(&hb).unwrap(); - assert_eq!(v, serde_json::json!({ "type": "Heartbeat" })); - } - - #[test] - fn heartbeat_with_fields_round_trips() { - let hb = DaemonOutbound::Heartbeat { - perimeter: Some(PerimeterCandidate { - perimeter_id: "wg-naps62".into(), - private_addr: "10.0.0.7:7070".into(), - }), - }; - let v = serde_json::to_value(&hb).unwrap(); - assert_eq!(v["type"], "Heartbeat"); - assert_eq!(v["perimeter"]["perimeter_id"], "wg-naps62"); - assert_eq!(v["perimeter"]["private_addr"], "10.0.0.7:7070"); - assert_eq!(hb, round_trip(&hb)); - } - - #[test] - fn daemon_outbound_resolve_attach_round_trip_and_tag() { - let msg = DaemonOutbound::ResolveAttach { - reply_to: Uuid::from_u128(11), - session_id: Uuid::from_u128(22), - }; - let v = serde_json::to_value(&msg).unwrap(); - assert_eq!(v["type"], "ResolveAttach"); - assert_eq!(msg, round_trip(&msg)); - } - - #[test] - fn daemon_outbound_forward_create_round_trip_and_tag() { - let msg = DaemonOutbound::ForwardCreate { - reply_to: Uuid::from_u128(51), - request: CreateSessionRequest { - daemon_id: "konishi".into(), - cwd: PathBuf::from("/tmp"), - command: "bash".into(), - env: HashMap::new(), - metadata: HashMap::new(), - worktree: None, - }, - }; - let v = serde_json::to_value(&msg).unwrap(); - assert_eq!(v["type"], "ForwardCreate"); - assert_eq!(v["request"]["daemon_id"], "konishi"); - assert_eq!(msg, round_trip(&msg)); - } - - #[test] - fn daemon_outbound_forward_kill_round_trip_and_tag() { - let msg = DaemonOutbound::ForwardKill { - reply_to: Uuid::from_u128(52), - daemon_id: "konishi".into(), - id: Uuid::from_u128(99), - }; - let v = serde_json::to_value(&msg).unwrap(); - assert_eq!(v["type"], "ForwardKill"); - assert_eq!(v["daemon_id"], "konishi"); - assert_eq!(msg, round_trip(&msg)); - } - - #[test] - fn daemon_inbound_forward_results_round_trip() { - let create_ok = DaemonInbound::ForwardCreateResult { - reply_to: Uuid::from_u128(51), - result: RpcResult::Ok { - value: sample_session(), - }, - }; - let create_err = DaemonInbound::ForwardCreateResult { - reply_to: Uuid::from_u128(51), - result: RpcResult::Err { - error: ErrorInfo { - code: "daemon_not_connected".into(), - message: "owner offline".into(), - }, - }, - }; - let kill_ok = DaemonInbound::ForwardKillResult { - reply_to: Uuid::from_u128(52), - result: RpcResult::Ok { value: () }, - }; - let v = serde_json::to_value(&create_ok).unwrap(); - assert_eq!(v["type"], "ForwardCreateResult"); - for m in [create_ok, create_err, kill_ok] { - assert_eq!(m, round_trip(&m)); - } - } - - #[test] - fn dir_listing_round_trips_and_truncated_defaults() { - let listing = DirListing { - path: PathBuf::from("/home/naps62"), - entries: vec!["projects".into(), ".config".into()], - truncated: true, - }; - assert_eq!(listing, round_trip(&listing)); - - // Older daemons omit `truncated`; it must default to false. - let json = serde_json::json!({ - "path": "/home/naps62", - "entries": ["projects"] - }); - let parsed: DirListing = serde_json::from_value(json).unwrap(); - assert!(!parsed.truncated); - } - - #[test] - fn daemon_list_dir_round_trips_and_tag() { - let req = DaemonInbound::ListDir { - reply_to: Uuid::from_u128(61), - path: "~/projects".into(), - }; - let v = serde_json::to_value(&req).unwrap(); - assert_eq!(v["type"], "ListDir"); - assert_eq!(v["path"], "~/projects"); - assert_eq!(req, round_trip(&req)); - - let res = DaemonOutbound::ListDirResult { - reply_to: Uuid::from_u128(61), - result: RpcResult::Ok { - value: DirListing { - path: PathBuf::from("/home/naps62/projects"), - entries: vec!["maestro".into()], - truncated: false, - }, - }, - }; - let v = serde_json::to_value(&res).unwrap(); - assert_eq!(v["type"], "ListDirResult"); - assert_eq!(res, round_trip(&res)); - } - - #[test] - fn daemon_forward_list_dir_round_trips_and_tag() { - let fwd = DaemonOutbound::ForwardListDir { - reply_to: Uuid::from_u128(62), - daemon_id: "konishi".into(), - path: "/work".into(), - }; - let v = serde_json::to_value(&fwd).unwrap(); - assert_eq!(v["type"], "ForwardListDir"); - assert_eq!(v["daemon_id"], "konishi"); - assert_eq!(fwd, round_trip(&fwd)); - - let res = DaemonInbound::ForwardListDirResult { - reply_to: Uuid::from_u128(62), - result: RpcResult::Err { - error: ErrorInfo { - code: "permission_denied".into(), - message: "EACCES".into(), - }, - }, - }; - let v = serde_json::to_value(&res).unwrap(); - assert_eq!(v["type"], "ForwardListDirResult"); - assert_eq!(res, round_trip(&res)); - } - - #[test] - fn daemon_recent_dirs_round_trips_and_tag() { - let req = DaemonInbound::RecentDirs { - reply_to: Uuid::from_u128(71), - }; - let v = serde_json::to_value(&req).unwrap(); - assert_eq!(v["type"], "RecentDirs"); - assert_eq!(req, round_trip(&req)); - - let res = DaemonOutbound::RecentDirsResult { - reply_to: Uuid::from_u128(71), - result: RpcResult::Ok { - value: RecentDirs { - dirs: vec![RecentDir { - cwd: PathBuf::from("/home/naps62/git/maestro"), - last_command: "claude".into(), - last_used: DateTime::parse_from_rfc3339("2026-06-20T10:00:00Z") - .unwrap() - .with_timezone(&Utc), - }], - truncated: false, - }, - }, - }; - let v = serde_json::to_value(&res).unwrap(); - assert_eq!(v["type"], "RecentDirsResult"); - assert_eq!(res, round_trip(&res)); - - let fwd = DaemonOutbound::ForwardRecentDirs { - reply_to: Uuid::from_u128(72), - daemon_id: "konishi".into(), - }; - let v = serde_json::to_value(&fwd).unwrap(); - assert_eq!(v["type"], "ForwardRecentDirs"); - assert_eq!(v["daemon_id"], "konishi"); - assert_eq!(fwd, round_trip(&fwd)); - - let fwd_res = DaemonInbound::ForwardRecentDirsResult { - reply_to: Uuid::from_u128(72), - result: RpcResult::Ok { - value: RecentDirs { - dirs: vec![], - truncated: false, - }, - }, - }; - let v = serde_json::to_value(&fwd_res).unwrap(); - assert_eq!(v["type"], "ForwardRecentDirsResult"); - assert_eq!(fwd_res, round_trip(&fwd_res)); - } - - #[test] - fn recent_dirs_truncated_defaults_when_absent() { - // Older daemons omit `truncated`; it must default to false. - let json = serde_json::json!({ "dirs": [] }); - let parsed: RecentDirs = serde_json::from_value(json).unwrap(); - assert!(!parsed.truncated); - } - - #[test] - fn daemon_git_root_round_trips_and_tag() { - let req = DaemonInbound::GitRoot { - reply_to: Uuid::from_u128(81), - path: "~/git/maestro/crates".into(), - }; - let v = serde_json::to_value(&req).unwrap(); - assert_eq!(v["type"], "GitRoot"); - assert_eq!(v["path"], "~/git/maestro/crates"); - assert_eq!(req, round_trip(&req)); - - let res = DaemonOutbound::GitRootResult { - reply_to: Uuid::from_u128(81), - result: RpcResult::Ok { - value: GitRoot { - root: Some(PathBuf::from("/home/naps62/git/maestro")), - }, - }, - }; - let v = serde_json::to_value(&res).unwrap(); - assert_eq!(v["type"], "GitRootResult"); - assert_eq!(res, round_trip(&res)); - - let fwd = DaemonOutbound::ForwardGitRoot { - reply_to: Uuid::from_u128(82), - daemon_id: "konishi".into(), - path: "/work".into(), - }; - let v = serde_json::to_value(&fwd).unwrap(); - assert_eq!(v["type"], "ForwardGitRoot"); - assert_eq!(fwd, round_trip(&fwd)); - - // A `None` root (path not inside a repo) drops the field from the wire. - let none = DaemonInbound::ForwardGitRootResult { - reply_to: Uuid::from_u128(82), - result: RpcResult::Ok { - value: GitRoot { root: None }, - }, - }; - let v = serde_json::to_value(&none).unwrap(); - assert_eq!(v["type"], "ForwardGitRootResult"); - assert_eq!(v["result"]["value"], serde_json::json!({})); - assert_eq!(none, round_trip(&none)); - } - - #[test] - fn daemon_outbound_relay_ack_round_trip_and_tag() { - let msg = DaemonOutbound::RelayAck { - reply_to: Uuid::from_u128(33), - accepted: true, - }; - let v = serde_json::to_value(&msg).unwrap(); - assert_eq!(v["type"], "RelayAck"); - assert_eq!(v["accepted"], true); - assert_eq!(msg, round_trip(&msg)); - - let denied = DaemonOutbound::RelayAck { - reply_to: Uuid::from_u128(34), - accepted: false, - }; - assert_eq!(denied, round_trip(&denied)); - } - - #[test] - fn daemon_inbound_resolve_attach_result_round_trip() { - let ok = DaemonInbound::ResolveAttachResult { - reply_to: Uuid::from_u128(11), - result: RpcResult::Ok { - value: AttachCandidates { - direct_wss: None, - perimeter: Some(PerimeterCandidate { - perimeter_id: "wg-naps62".into(), - private_addr: "10.0.0.7:7070".into(), - }), - relay: None, - jwt: "eyJ...".into(), - }, - }, - }; - let err = DaemonInbound::ResolveAttachResult { - reply_to: Uuid::from_u128(12), - result: RpcResult::Err { - error: ErrorInfo { - code: "unknown_session".into(), - message: "no daemon owns session".into(), - }, - }, - }; - for v in [ok, err] { - assert_eq!(v, round_trip(&v)); - } - } - - #[test] - fn registry_event_for_peer_filters_self() { - let mine = sample_session(); // daemon_id = "laptop" - let mut peer_session = sample_session(); - peer_session.daemon_id = "konishi".into(); - peer_session.id = Uuid::from_u128(99); - - let snapshot = RegistryEvent::Snapshot { - daemons: vec![ - DaemonInfo { - daemon_id: "laptop".into(), - hostname: "naps-laptop".into(), - version: "0.1.0".into(), - daemon_url: "ws://10.0.0.1:7070".into(), - home_dir: String::new(), - }, - DaemonInfo { - daemon_id: "konishi".into(), - hostname: "konishi".into(), - version: "0.1.0".into(), - daemon_url: "ws://10.0.0.2:7070".into(), - home_dir: String::new(), - }, - ], - sessions: vec![mine.clone(), peer_session.clone()], - }; - let filtered = snapshot.clone().for_peer("laptop").unwrap(); - match filtered { - RegistryEvent::Snapshot { daemons, sessions } => { - assert_eq!(daemons.len(), 1); - assert_eq!(daemons[0].daemon_id, "konishi"); - assert_eq!(sessions.len(), 1); - assert_eq!(sessions[0].daemon_id, "konishi"); - } - other => panic!("unexpected {other:?}"), - } - } - - #[test] - fn registry_event_for_peer_drops_self_deltas() { - let mine = sample_session(); // daemon_id "laptop" - assert!(RegistryEvent::SessionAdded { - session: mine.clone() - } - .for_peer("laptop") - .is_none()); - assert!(RegistryEvent::SessionUpdated { - session: mine.clone() - } - .for_peer("laptop") - .is_none()); - assert!(RegistryEvent::SessionRemoved { - daemon_id: "laptop".into(), - id: mine.id, - } - .for_peer("laptop") - .is_none()); - assert!(RegistryEvent::DaemonAdded { - daemon: DaemonInfo { - daemon_id: "laptop".into(), - hostname: "x".into(), - version: "y".into(), - daemon_url: "z".into(), - home_dir: String::new(), - } - } - .for_peer("laptop") - .is_none()); - assert!(RegistryEvent::DaemonRemoved { - daemon_id: "laptop".into() - } - .for_peer("laptop") - .is_none()); - } - - #[test] - fn registry_event_for_peer_forwards_peer_deltas_and_lagged() { - let mut peer = sample_session(); - peer.daemon_id = "konishi".into(); - assert!(matches!( - RegistryEvent::SessionAdded { - session: peer.clone() - } - .for_peer("laptop"), - Some(RegistryEvent::SessionAdded { .. }) - )); - assert!(matches!( - RegistryEvent::Lagged { skipped: 3 }.for_peer("laptop"), - Some(RegistryEvent::Lagged { skipped: 3 }) - )); - } - - #[test] - fn daemon_inbound_catalog_event_round_trip_and_tag() { - let snapshot = DaemonInbound::CatalogEvent { - event: RegistryEvent::Snapshot { - daemons: vec![DaemonInfo { - daemon_id: "peer".into(), - hostname: "peer-host".into(), - version: "0.1.0".into(), - daemon_url: "ws://peer:7070".into(), - home_dir: String::new(), - }], - sessions: vec![sample_session()], - }, - }; - let v = serde_json::to_value(&snapshot).unwrap(); - assert_eq!(v["type"], "CatalogEvent"); - assert_eq!(snapshot, round_trip(&snapshot)); - - let delta = DaemonInbound::CatalogEvent { - event: RegistryEvent::SessionAdded { - session: sample_session(), - }, - }; - assert_eq!(delta, round_trip(&delta)); - } - - #[test] - fn daemon_inbound_relay_setup_round_trip_and_tag() { - let msg = DaemonInbound::RelaySetup { - reply_to: Uuid::from_u128(33), - home_daemon: "laptop".into(), - session_id: Uuid::from_u128(44), - }; - let v = serde_json::to_value(&msg).unwrap(); - assert_eq!(v["type"], "RelaySetup"); - assert_eq!(v["home_daemon"], "laptop"); - assert_eq!(msg, round_trip(&msg)); - } - - #[test] - fn perimeter_candidate_round_trips() { - let p = PerimeterCandidate { - perimeter_id: "wg-naps62".into(), - private_addr: "10.0.0.7:7070".into(), - }; - assert_eq!(p, round_trip(&p)); - } - - #[test] - fn relay_candidate_round_trips_as_empty_object() { - let r = RelayCandidate::default(); - assert_eq!(r, round_trip(&r)); - assert_eq!(serde_json::to_value(&r).unwrap(), serde_json::json!({})); - } - - #[test] - fn attach_candidates_full_and_minimal_round_trip() { - let full = AttachCandidates { - direct_wss: Some("wss://konishi.naps62.maestro.example/".into()), - perimeter: Some(PerimeterCandidate { - perimeter_id: "wg-naps62".into(), - private_addr: "10.0.0.7:7070".into(), - }), - relay: Some(RelayCandidate::default()), - jwt: "eyJ...".into(), - }; - assert_eq!(full, round_trip(&full)); - - let minimal = AttachCandidates { - direct_wss: None, - perimeter: None, - relay: None, - jwt: "eyJ...".into(), - }; - assert_eq!(minimal, round_trip(&minimal)); - - // Optional transport fields are dropped from the wire when absent — - // keeps minimal payloads tight and avoids accidentally implying a - // transport is available with a `null` value. - let v = serde_json::to_value(&minimal).unwrap(); - assert_eq!(v, serde_json::json!({ "jwt": "eyJ..." })); - } - - #[test] - fn jwt_claims_round_trip_and_iat_optional() { - let c = JwtClaims { - session_id: Uuid::from_u128(7), - daemon_id: "laptop".into(), - exp: 1_700_000_000, - iat: Some(1_699_999_700), - }; - assert_eq!(c, round_trip(&c)); - - let no_iat = serde_json::json!({ - "session_id": Uuid::from_u128(8), - "daemon_id": "laptop", - "exp": 1_700_000_000_i64, - }); - let parsed: JwtClaims = serde_json::from_value(no_iat).unwrap(); - assert!(parsed.iat.is_none()); - } - - #[test] - fn pair_attach_claims_round_trip() { - let c = PairAttachClaims { - iss: "registry.naps62".into(), - sub: "laptop".into(), - aud: "konishi".into(), - sid: Uuid::from_u128(7), - exp: 1_700_000_060, - }; - assert_eq!(c, round_trip(&c)); - - // Standard JWT short claim names — keep them on the wire as-is so the - // existing `jsonwebtoken` validators don't need a custom name map. - let v = serde_json::to_value(&c).unwrap(); - assert_eq!(v["iss"], "registry.naps62"); - assert_eq!(v["sub"], "laptop"); - assert_eq!(v["aud"], "konishi"); - assert_eq!(v["exp"], 1_700_000_060_i64); - } - - #[test] - fn pair_attach_claims_reject_missing_audience() { - // aud is mandatory — without it a token isn't pair-scoped at all and - // the target daemon couldn't verify itself as the intended recipient. - let bad = serde_json::json!({ - "iss": "registry.naps62", - "sub": "laptop", - "sid": Uuid::from_u128(7), - "exp": 1_700_000_060_i64, - }); - assert!(serde_json::from_value::(bad).is_err()); - } } diff --git a/crates/maestro-registry/Cargo.toml b/crates/maestro-registry/Cargo.toml deleted file mode 100644 index 887e18b..0000000 --- a/crates/maestro-registry/Cargo.toml +++ /dev/null @@ -1,35 +0,0 @@ -[package] -name = "maestro-registry" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -publish.workspace = true -description = "Always-on registry — daemon presence index and session catalog" - -[lib] -path = "src/lib.rs" - -[dependencies] -axum.workspace = true -chrono.workspace = true -dirs.workspace = true -futures-util.workspace = true -jsonwebtoken.workspace = true -maestro-config.workspace = true -maestro-protocol.workspace = true -rand.workspace = true -serde.workspace = true -serde_json.workspace = true -thiserror.workspace = true -tokio.workspace = true -toml.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true -uuid.workspace = true - -[dev-dependencies] -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "cookies"] } -tempfile.workspace = true -tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "test-util"] } -tokio-tungstenite.workspace = true diff --git a/crates/maestro-registry/src/auth.rs b/crates/maestro-registry/src/auth.rs deleted file mode 100644 index dd770b6..0000000 --- a/crates/maestro-registry/src/auth.rs +++ /dev/null @@ -1,172 +0,0 @@ -//! Cookie-based auth for the client-facing HTTP/WS surface. -//! -//! On the first request the registry sets an httpOnly cookie whose value is a -//! JWT signed with the shared token (`MAESTRO_TOKEN`, HS256). Subsequent requests -//! must present a cookie whose signature verifies; missing or tampered -//! cookies get a fresh cookie issued (effectively re-bootstrapping a -//! first-time client) and are still allowed through. The cookie is the second -//! factor against anyone routing to the bind address; per spec §Auth the -//! perimeter is the operator's network. -//! -//! Implementation notes: -//! -//! - We reuse `jsonwebtoken` so we don't pull in a cookie/HMAC crate just for -//! this one signature. -//! - The middleware passes the inbound `Cookie` header through to the inner -//! handler unchanged, and adds a `Set-Cookie` response header if the inbound -//! cookie was missing or invalid. - -use std::sync::Arc; - -use axum::body::Body; -use axum::extract::State; -use axum::http::{header, HeaderValue, Request, StatusCode}; -use axum::middleware::Next; -use axum::response::{IntoResponse, Response}; -use chrono::{Duration, Utc}; -use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; -use maestro_protocol::REGISTRY_ISSUER; -use serde::{Deserialize, Serialize}; - -/// Name of the auth cookie surfaced to web clients. Defined in -/// `maestro-protocol` so the CLI agrees; re-exported here for local callers. -pub use maestro_protocol::{extract_cookie, COOKIE_NAME}; - -/// 30 days. Cookie is the second factor — long enough not to log users out; -/// short enough to bound the blast radius if the secret rotates. -const COOKIE_TTL_DAYS: i64 = 30; - -/// `aud` claim that distinguishes a registry auth cookie from every other -/// registry-signed JWT (pair-attach credentials carry a daemon-id audience). -/// Verifiers bind to it so a token minted for another purpose can't be replayed -/// as a cookie even though both share the signing secret (NAP-266). -const COOKIE_AUDIENCE: &str = "maestro-cookie"; - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct CookieClaims { - /// Issuer — always [`REGISTRY_ISSUER`]. - iss: String, - /// Audience — always [`COOKIE_AUDIENCE`]. - aud: String, - /// Subject — currently always `"client"` because this is single-user. - sub: String, - exp: i64, - iat: i64, -} - -/// Issue a fresh cookie value (a JWT) signed with `secret`. -pub fn issue_cookie(secret: &str) -> String { - let now = Utc::now(); - let claims = CookieClaims { - iss: REGISTRY_ISSUER.into(), - aud: COOKIE_AUDIENCE.into(), - sub: "client".into(), - iat: now.timestamp(), - exp: (now + Duration::days(COOKIE_TTL_DAYS)).timestamp(), - }; - let header = Header::new(Algorithm::HS256); - let key = EncodingKey::from_secret(secret.as_bytes()); - encode(&header, &claims, &key).expect("encode auth cookie") -} - -/// Returns true if `value` is a JWT validly signed by `secret` *and* bound to -/// this cookie's issuer + audience — so a pair-attach JWT (same secret, -/// different `aud`) is rejected here (NAP-266). -pub fn verify_cookie(secret: &str, value: &str) -> bool { - let key = DecodingKey::from_secret(secret.as_bytes()); - let mut validation = Validation::new(Algorithm::HS256); - validation.set_issuer(&[REGISTRY_ISSUER]); - validation.set_audience(&[COOKIE_AUDIENCE]); - decode::(value, &key, &validation).is_ok() -} - -/// Format the full `Set-Cookie` header value. -fn set_cookie_header(value: &str) -> String { - format!( - "{name}={value}; HttpOnly; SameSite=Strict; Path=/; Max-Age={age}", - name = COOKIE_NAME, - age = COOKIE_TTL_DAYS * 24 * 60 * 60, - ) -} - -/// Shared state for the auth middleware: just the signing secret. -#[derive(Debug, Clone)] -pub struct AuthState { - pub token: Arc, -} - -/// Axum middleware that enforces / bootstraps the client auth cookie. -/// -/// - Valid cookie present → pass through, no `Set-Cookie`. -/// - Cookie missing → issue a fresh one and pass through. -/// - Cookie present but tampered/expired → issue a fresh one and pass through. -/// (Spec is intentionally lenient — perimeter is the operator's network.) -pub async fn cookie_middleware( - State(state): State, - request: Request, - next: Next, -) -> Response { - let token = state.token.clone(); - let inbound_cookie = request - .headers() - .get(header::COOKIE) - .and_then(|h| h.to_str().ok()) - .and_then(|v| extract_cookie(v, COOKIE_NAME).map(|s| s.to_string())); - - let needs_new_cookie = match &inbound_cookie { - Some(value) => !verify_cookie(token.as_str(), value), - None => true, - }; - - let mut response = next.run(request).await; - - if needs_new_cookie { - let fresh = issue_cookie(token.as_str()); - match HeaderValue::from_str(&set_cookie_header(&fresh)) { - Ok(hv) => { - response.headers_mut().append(header::SET_COOKIE, hv); - } - Err(_) => { - return (StatusCode::INTERNAL_SERVER_ERROR, "cookie encode failed").into_response(); - } - } - } - - response -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn round_trip() { - let token = "shared-secret-aaaa"; - let value = issue_cookie(token); - assert!(verify_cookie(token, &value)); - assert!(!verify_cookie("different-secret", &value)); - } - - /// NAP-266: a pair-attach JWT is signed with the same shared secret but is - /// bound to a daemon-id audience, so it must NOT validate as an auth cookie. - #[test] - fn verify_cookie_rejects_pair_attach_jwt() { - let secret = "shared-secret-bbbb"; - let pair = - crate::jwt::issue_pair_attach_jwt(secret, "laptop", "konishi", uuid::Uuid::new_v4()) - .expect("issue pair jwt"); - assert!( - !verify_cookie(secret, &pair), - "a pair-attach JWT must not be accepted as an auth cookie" - ); - } - - #[test] - fn set_cookie_header_includes_attributes() { - let h = set_cookie_header("abc"); - assert!(h.contains("HttpOnly")); - assert!(h.contains("SameSite=Strict")); - assert!(h.contains("Path=/")); - assert!(h.starts_with("maestro_session=abc;")); - } -} diff --git a/crates/maestro-registry/src/client_api.rs b/crates/maestro-registry/src/client_api.rs deleted file mode 100644 index f0fc141..0000000 --- a/crates/maestro-registry/src/client_api.rs +++ /dev/null @@ -1,251 +0,0 @@ -//! Client-facing HTTP API routes (NAP-215). -//! -//! Mounts under the same router as the daemon-facing `WS /daemon`. The cookie -//! middleware in [`crate::auth`] gates everything except the WS upgrade for -//! `/daemon` (daemons authenticate via the shared token in `Hello`). - -use std::sync::Arc; -use std::time::Duration; - -use axum::extract::{ws::WebSocketUpgrade, Path, Query, State}; -use axum::http::StatusCode; -use axum::response::{IntoResponse, Response}; -use axum::routing::{any, get}; -use axum::{Json, Router}; -use maestro_protocol::{ConnectInfo, CreateSessionRequest, DaemonInfo, DirListing, Session}; -use serde::Deserialize; -use uuid::Uuid; - -use crate::events::events_ws; -use crate::jwt::issue_attach_jwt; -use crate::state::{Registry, RegistryError, RPC_TIMEOUT}; - -/// Shared state plumbed into client-facing handlers. -#[derive(Debug, Clone)] -pub struct ClientApiState { - pub registry: Registry, - pub token: Arc, - pub rpc_timeout: Duration, -} - -/// Mount client-facing routes on the given router. Caller is responsible for -/// applying the cookie middleware (we do that in [`crate::server::router`]). -pub fn routes(state: ClientApiState) -> Router { - // The registry serves no web UI — that's `maestro-web`'s job (see - // connection-orchestration spec §"UI hosting"). Only the JSON/WS API here. - Router::new() - .route("/api/daemons", get(list_daemons)) - .route("/api/daemons/:id/dirs", get(list_dir)) - .route("/api/sessions", get(list_sessions).post(create_session)) - .route("/api/sessions/:id", axum::routing::delete(delete_session)) - .route("/api/sessions/:id/connect-info", get(session_connect_info)) - .route("/api/events", any(events_ws_handler)) - .with_state(state) -} - -async fn list_daemons(State(s): State) -> Json> { - let infos: Vec = s - .registry - .daemons() - .await - .into_iter() - .map(Into::into) - .collect(); - Json(infos) -} - -async fn list_sessions(State(s): State) -> Json> { - Json(s.registry.sessions().await) -} - -/// Query string for `GET /api/daemons/:id/dirs`. `path` is the directory to -/// list; empty/`~`/relative are resolved against the target daemon's home dir. -#[derive(Debug, Deserialize)] -struct DirQuery { - #[serde(default)] - path: String, -} - -/// List one directory's immediate subdirectories on `daemon_id`'s host, for the -/// web spawn form's working-directory autocomplete (NAP-329). Drives a `ListDir` -/// RPC to the target daemon, same machinery + error mapping as `create_session`. -async fn list_dir( - State(s): State, - Path(daemon_id): Path, - Query(q): Query, -) -> Response { - let outcome = - tokio::time::timeout(s.rpc_timeout, s.registry.list_dir(&daemon_id, q.path)).await; - match outcome { - Err(_) => ( - StatusCode::GATEWAY_TIMEOUT, - Json(error_body("timeout", "daemon did not reply in time")), - ) - .into_response(), - Ok(Err(RegistryError::DaemonNotConnected(_))) => ( - StatusCode::NOT_FOUND, - Json(error_body("unknown_daemon", "daemon is not connected")), - ) - .into_response(), - Ok(Err(RegistryError::Timeout(_))) => ( - StatusCode::GATEWAY_TIMEOUT, - Json(error_body("timeout", "daemon did not reply in time")), - ) - .into_response(), - Ok(Err(e)) => ( - StatusCode::BAD_GATEWAY, - Json(error_body("transport", &e.to_string())), - ) - .into_response(), - Ok(Ok(Err(rpc_err))) => ( - dir_error_status(&rpc_err.0.code), - Json(error_body(&rpc_err.0.code, &rpc_err.0.message)), - ) - .into_response(), - Ok(Ok(Ok(listing))) => Json::(listing).into_response(), - } -} - -/// HTTP status for a daemon-reported directory-listing error code (NAP-329). -fn dir_error_status(code: &str) -> StatusCode { - match code { - "not_found" => StatusCode::NOT_FOUND, - "permission_denied" => StatusCode::FORBIDDEN, - "not_a_directory" => StatusCode::BAD_REQUEST, - _ => StatusCode::BAD_GATEWAY, - } -} - -async fn create_session( - State(s): State, - Json(body): Json, -) -> Response { - let daemon = body.daemon_id.clone(); - let outcome = - tokio::time::timeout(s.rpc_timeout, s.registry.create_session(&daemon, body)).await; - match outcome { - Err(_) => ( - StatusCode::GATEWAY_TIMEOUT, - Json(error_body("timeout", "daemon did not reply in time")), - ) - .into_response(), - Ok(Err(RegistryError::DaemonNotConnected(_))) => ( - StatusCode::NOT_FOUND, - Json(error_body("unknown_daemon", "daemon is not connected")), - ) - .into_response(), - Ok(Err(RegistryError::Timeout(_))) => ( - StatusCode::GATEWAY_TIMEOUT, - Json(error_body("timeout", "daemon did not reply in time")), - ) - .into_response(), - Ok(Err(e)) => ( - StatusCode::BAD_GATEWAY, - Json(error_body("transport", &e.to_string())), - ) - .into_response(), - Ok(Ok(Err(rpc_err))) => ( - StatusCode::BAD_GATEWAY, - Json(error_body(&rpc_err.0.code, &rpc_err.0.message)), - ) - .into_response(), - Ok(Ok(Ok(session))) => (StatusCode::CREATED, Json(session)).into_response(), - } -} - -async fn delete_session(State(s): State, Path(id): Path) -> Response { - let Some(daemon) = s.registry.daemon_for_session(id).await else { - return ( - StatusCode::NOT_FOUND, - Json(error_body("unknown_session", "no such session")), - ) - .into_response(); - }; - - let outcome = tokio::time::timeout(s.rpc_timeout, s.registry.kill_session(&daemon, id)).await; - match outcome { - Err(_) => ( - StatusCode::GATEWAY_TIMEOUT, - Json(error_body("timeout", "daemon did not reply in time")), - ) - .into_response(), - Ok(Err(RegistryError::DaemonNotConnected(_))) => ( - StatusCode::NOT_FOUND, - Json(error_body("unknown_daemon", "daemon is not connected")), - ) - .into_response(), - Ok(Err(RegistryError::Timeout(_))) => ( - StatusCode::GATEWAY_TIMEOUT, - Json(error_body("timeout", "daemon did not reply in time")), - ) - .into_response(), - Ok(Err(e)) => ( - StatusCode::BAD_GATEWAY, - Json(error_body("transport", &e.to_string())), - ) - .into_response(), - Ok(Ok(Err(rpc_err))) => ( - StatusCode::BAD_GATEWAY, - Json(error_body(&rpc_err.0.code, &rpc_err.0.message)), - ) - .into_response(), - Ok(Ok(Ok(()))) => StatusCode::NO_CONTENT.into_response(), - } -} - -async fn session_connect_info(State(s): State, Path(id): Path) -> Response { - let Some(daemon) = s.registry.daemon_for_session(id).await else { - return ( - StatusCode::NOT_FOUND, - Json(error_body("unknown_session", "no such session")), - ) - .into_response(); - }; - let Some(daemon_url) = s.registry.daemon_url(&daemon).await else { - return ( - StatusCode::NOT_FOUND, - Json(error_body("unknown_daemon", "owning daemon is offline")), - ) - .into_response(); - }; - let jwt = match issue_attach_jwt(s.token.as_str(), &daemon, id) { - Ok(t) => t, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(error_body("jwt_failed", &e.to_string())), - ) - .into_response(); - } - }; - Json(ConnectInfo { daemon_url, jwt }).into_response() -} - -async fn events_ws_handler( - State(s): State, - ws: WebSocketUpgrade, -) -> impl IntoResponse { - let registry = s.registry.clone(); - ws.on_upgrade(move |socket| async move { - let _ = events_ws(socket, registry).await; - }) -} - -fn error_body(code: &str, message: &str) -> serde_json::Value { - serde_json::json!({ - "error": { - "code": code, - "message": message, - }, - }) -} - -/// Convenience: produce a `ClientApiState` from a `Registry` and shared token. -/// Uses the production [`RPC_TIMEOUT`]. -pub fn state_for(registry: Registry, token: String) -> ClientApiState { - ClientApiState { - registry, - token: Arc::new(token), - rpc_timeout: RPC_TIMEOUT, - } -} diff --git a/crates/maestro-registry/src/events.rs b/crates/maestro-registry/src/events.rs deleted file mode 100644 index a4bea2e..0000000 --- a/crates/maestro-registry/src/events.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! `WS /api/events` — registry-side fan-out of catalog + presence deltas. -//! -//! On connect we immediately push a [`RegistryEvent::Snapshot`] of the current -//! state, then forward every broadcast event until the client disconnects. -//! Lagged subscribers receive a [`RegistryEvent::Lagged`] notice with the -//! count of dropped frames — the convention is for the client to re-fetch -//! `GET /api/sessions` on receipt. - -use axum::extract::ws::{Message, WebSocket}; -use futures_util::{SinkExt, StreamExt}; -use maestro_protocol::RegistryEvent; -use tokio::sync::broadcast::error::RecvError; - -use crate::state::Registry; - -pub async fn events_ws(socket: WebSocket, registry: Registry) -> Result<(), axum::Error> { - let (mut sink, mut stream) = socket.split(); - - let (snapshot, mut rx) = registry.subscribe().await; - sink.send(Message::Text( - serde_json::to_string(&snapshot).expect("snapshot"), - )) - .await?; - - loop { - tokio::select! { - ev = rx.recv() => { - match ev { - Ok(event) => { - let text = serde_json::to_string(&event).expect("event encode"); - if sink.send(Message::Text(text)).await.is_err() { - break; - } - } - Err(RecvError::Lagged(n)) => { - let text = serde_json::to_string(&RegistryEvent::Lagged { skipped: n }) - .expect("lagged encode"); - if sink.send(Message::Text(text)).await.is_err() { - break; - } - } - Err(RecvError::Closed) => break, - } - } - client_frame = stream.next() => { - // Treat anything from the client as either a close or a ping - // we don't care about; the events WS is server-push only. - match client_frame { - None => break, - Some(Err(_)) => break, - Some(Ok(Message::Close(_))) => break, - Some(Ok(_)) => {} - } - } - } - } - - Ok(()) -} diff --git a/crates/maestro-registry/src/handler.rs b/crates/maestro-registry/src/handler.rs deleted file mode 100644 index 580b0c5..0000000 --- a/crates/maestro-registry/src/handler.rs +++ /dev/null @@ -1,325 +0,0 @@ -//! Per-connection handler for the `WS /daemon` endpoint. -//! -//! The handler is split from the axum-specific glue so it can be exercised -//! directly with an in-process pair of channels in tests. The axum side just -//! adapts a `WebSocket` into the trait below. - -use std::time::Duration; - -use axum::extract::ws::{Message, WebSocket}; -use futures_util::{SinkExt, StreamExt}; -use maestro_protocol::{DaemonInbound, DaemonOutbound, ErrorInfo, RegistryEvent}; -use tokio::sync::broadcast::error::RecvError; -use tokio::sync::mpsc; - -use crate::state::Registry; - -/// Bound the registry's per-daemon outbound queue. 64 in-flight R→D requests -/// is plenty for a single daemon and bounds memory use under back-pressure. -const OUTBOUND_BUFFER: usize = 64; - -/// Run the handshake + message loop on a freshly-accepted WebSocket. Returns -/// the `daemon_id` once the connection terminates (for logging). -/// -/// `heartbeat_timeout` is the maximum gap allowed between inbound frames — -/// once exceeded, the connection is dropped and the daemon's sessions are -/// removed from the catalog. Production code passes -/// [`crate::state::HEARTBEAT_TIMEOUT`]; tests use a short value. -pub async fn handle_socket( - socket: WebSocket, - registry: Registry, - expected_token: &str, - heartbeat_timeout: Duration, -) -> Option { - let (mut sink, mut stream) = socket.split(); - - let hello = match read_hello(&mut stream).await { - Some(h) => h, - None => return None, - }; - - let (daemon_id, hostname, version, token, daemon_url, home_dir) = match hello { - DaemonOutbound::Hello { - daemon_id, - hostname, - version, - token, - daemon_url, - home_dir, - } => (daemon_id, hostname, version, token, daemon_url, home_dir), - _ => { - let _ = send_error(&mut sink, "expected_hello", "first message must be Hello").await; - return None; - } - }; - - if token != expected_token { - let _ = send_error(&mut sink, "auth_failed", "invalid token").await; - return None; - } - - let (out_tx, mut out_rx) = mpsc::channel::(OUTBOUND_BUFFER); - // Hold the fencing token for the life of this connection; only a deregister - // carrying it may tear down the entry we just installed (NAP-258). - let connection_token = registry - .register( - daemon_id.clone(), - hostname, - version, - daemon_url, - home_dir, - out_tx.clone(), - ) - .await; - - let _ = sink - .send(Message::Text( - serde_json::to_string(&DaemonInbound::Ack).expect("ack serialize"), - )) - .await; - - // NAP-246: forward peer-filtered catalog deltas to the daemon. Initial - // snapshot is pushed eagerly; live deltas flow through `catalog_task` → - // `out_tx` so they share the existing outbound writer in the select! loop - // (any extra sink writer would contend with the WS sink+stream coupling - // axum imposes and stall the inbound reader — see history of this file). - let (snapshot, mut catalog_rx) = registry.subscribe().await; - if let Some(peer_snapshot) = snapshot.for_peer(&daemon_id) { - if out_tx - .send(DaemonInbound::CatalogEvent { - event: peer_snapshot, - }) - .await - .is_err() - { - registry.deregister(&daemon_id, connection_token).await; - return Some(daemon_id); - } - } - let catalog_id = daemon_id.clone(); - let catalog_out = out_tx.clone(); - let catalog_task = tokio::spawn(async move { - loop { - let ev = match catalog_rx.recv().await { - Ok(e) => e.for_peer(&catalog_id), - Err(RecvError::Lagged(n)) => Some(RegistryEvent::Lagged { skipped: n }), - Err(RecvError::Closed) => return, - }; - let Some(event) = ev else { continue }; - if catalog_out - .send(DaemonInbound::CatalogEvent { event }) - .await - .is_err() - { - return; - } - } - }); - - loop { - tokio::select! { - inbound = tokio::time::timeout(heartbeat_timeout, stream.next()) => { - match inbound { - Err(_) => { - tracing::info!(%daemon_id, "heartbeat timeout — dropping"); - break; - } - Ok(None) => break, - Ok(Some(Err(e))) => { - tracing::info!(%daemon_id, ?e, "ws read error"); - break; - } - Ok(Some(Ok(Message::Close(_)))) => break, - Ok(Some(Ok(Message::Text(text)))) => { - if !dispatch_text(®istry, &daemon_id, expected_token, &text).await { - break; - } - } - Ok(Some(Ok(Message::Binary(_) | Message::Ping(_) | Message::Pong(_)))) => {} - } - } - Some(msg) = out_rx.recv() => { - let text = serde_json::to_string(&msg).expect("serialize daemon-inbound"); - if sink.send(Message::Text(text)).await.is_err() { - break; - } - } - } - } - - catalog_task.abort(); - registry.deregister(&daemon_id, connection_token).await; - Some(daemon_id) -} - -/// Reads the first text frame, parses it as a `DaemonOutbound`, returns it. -async fn read_hello( - stream: &mut futures_util::stream::SplitStream, -) -> Option { - match tokio::time::timeout(Duration::from_secs(5), stream.next()).await { - Ok(Some(Ok(Message::Text(t)))) => serde_json::from_str(&t).ok(), - _ => None, - } -} - -async fn dispatch_text( - registry: &Registry, - daemon_id: &str, - shared_secret: &str, - text: &str, -) -> bool { - let parsed: DaemonOutbound = match serde_json::from_str(text) { - Ok(p) => p, - Err(e) => { - // A single malformed frame must not tear down the WS (and wipe this - // daemon's catalog) — log and skip it (NAP-269). - tracing::warn!(%daemon_id, error = %e, "malformed message — skipping"); - return true; - } - }; - match parsed { - DaemonOutbound::Hello { .. } => { - tracing::warn!(%daemon_id, "second Hello on live connection — ignoring"); - } - DaemonOutbound::SessionsSnapshot { sessions } => { - registry.apply_snapshot(daemon_id, sessions).await; - } - DaemonOutbound::SessionAdded { session } => { - registry.add_session(daemon_id, session).await; - } - DaemonOutbound::SessionRemoved { id } => { - registry.remove_session(daemon_id, id).await; - } - DaemonOutbound::SessionUpdated { session } => { - registry.update_session(daemon_id, session).await; - } - DaemonOutbound::Heartbeat { perimeter } => { - // Per-read timeout above is the heartbeat watchdog; capture the - // perimeter advertisement so the broker can hand it back as a T2 - // candidate (NAP-247). - registry.update_perimeter(daemon_id, perimeter).await; - } - DaemonOutbound::CreateSessionResult { reply_to, result } => { - registry.complete_create(reply_to, result).await; - } - DaemonOutbound::KillSessionResult { reply_to, result } => { - registry.complete_kill(reply_to, result).await; - } - DaemonOutbound::ResolveAttach { - reply_to, - session_id, - } => { - registry - .resolve_attach(daemon_id, reply_to, session_id, shared_secret) - .await; - } - DaemonOutbound::ForwardCreate { reply_to, request } => { - // Relay the create to its owning daemon and route the reply back to - // this (home) daemon — control-plane only (NAP-327). - registry.forward_create(daemon_id, reply_to, request).await; - } - DaemonOutbound::ForwardKill { - reply_to, - daemon_id: target, - id, - } => { - registry - .forward_kill(daemon_id, reply_to, &target, id) - .await; - } - DaemonOutbound::ListDirResult { reply_to, result } => { - registry.complete_list_dir(reply_to, result).await; - } - DaemonOutbound::ForwardListDir { - reply_to, - daemon_id: target, - path, - } => { - // Relay a directory listing to its owning daemon and route the reply - // back to this (home) daemon — control-plane only (NAP-329). - registry - .forward_list_dir(daemon_id, reply_to, &target, path) - .await; - } - DaemonOutbound::RecentDirsResult { reply_to, result } => { - registry.complete_recent_dirs(reply_to, result).await; - } - DaemonOutbound::ForwardRecentDirs { - reply_to, - daemon_id: target, - } => { - // Relay a recent-dirs query to its owning daemon and route the reply - // back to this (home) daemon — control-plane only (#26). - registry - .forward_recent_dirs(daemon_id, reply_to, &target) - .await; - } - DaemonOutbound::GitRootResult { reply_to, result } => { - registry.complete_git_root(reply_to, result).await; - } - DaemonOutbound::ForwardGitRoot { - reply_to, - daemon_id: target, - path, - } => { - // Relay a gitroot probe to its owning daemon and route the reply - // back to this (home) daemon — control-plane only (#26). - registry - .forward_git_root(daemon_id, reply_to, &target, path) - .await; - } - DaemonOutbound::RelayAck { reply_to, .. } => { - // T3 (relay) is gated on NAP-238. Land the variant so newer daemons - // don't crash the connection; the registry doesn't act on it yet. - tracing::debug!(%daemon_id, ?reply_to, "RelayAck received but T3 not implemented (NAP-238 pending)"); - } - DaemonOutbound::Unknown => { - // A newer daemon emitted a frame type this build doesn't know. Skip - // it rather than dropping the connection (NAP-269). - tracing::debug!(%daemon_id, "unknown frame type — skipping (forward-compat, NAP-269)"); - } - } - true -} - -async fn send_error( - sink: &mut futures_util::stream::SplitSink, - code: &str, - message: &str, -) -> Result<(), axum::Error> { - let err = DaemonInbound::Error(ErrorInfo { - code: code.into(), - message: message.into(), - }); - let text = serde_json::to_string(&err).expect("serialize error"); - sink.send(Message::Text(text)).await -} - -#[cfg(test)] -mod tests { - use super::*; - - // `dispatch_text` returning `false` is what breaks the handler loop and - // triggers a deregister (catalog wipe). A frame the registry can't act on - // must NOT do that, or a newer daemon emitting an unknown variant gets - // stuck in a tight reconnect loop (NAP-269). - - #[tokio::test] - async fn unknown_frame_keeps_connection_alive() { - let registry = Registry::new(); - let frame = serde_json::json!({ "type": "FutureVariant", "x": 1 }).to_string(); - assert!( - dispatch_text(®istry, "laptop", "tok", &frame).await, - "unknown frame type must be skipped, not drop the WS" - ); - } - - #[tokio::test] - async fn malformed_frame_keeps_connection_alive() { - let registry = Registry::new(); - assert!( - dispatch_text(®istry, "laptop", "tok", "{ not valid json").await, - "malformed frame must be skipped, not drop the WS" - ); - } -} diff --git a/crates/maestro-registry/src/jwt.rs b/crates/maestro-registry/src/jwt.rs deleted file mode 100644 index 93447ca..0000000 --- a/crates/maestro-registry/src/jwt.rs +++ /dev/null @@ -1,102 +0,0 @@ -//! Registry-side JWT issuance for the per-session attach endpoint. -//! -//! The daemon validates these tokens locally with the same shared secret — -//! the registry doesn't see any callback on the attach hot path. -//! Spec §Auth, §"Wire protocol (Client↔Daemon)". - -use chrono::{Duration, Utc}; -use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; -use maestro_protocol::{JwtClaims, PairAttachClaims}; -use uuid::Uuid; - -/// Spec §Auth: ~5 minutes. -pub const ATTACH_JWT_TTL: Duration = Duration::minutes(5); -/// Pair-scoped attach JWT TTL — short-lived per the connection-orchestration -/// spec (60 s default; ample window for the home daemon to dial T2). -pub const PAIR_ATTACH_JWT_TTL: Duration = Duration::seconds(60); -/// `iss` claim baked into every registry-issued pair JWT. Canonical value -/// lives in `maestro-protocol` so issuers and verifiers can't drift (NAP-266). -pub use maestro_protocol::REGISTRY_ISSUER; - -/// Mint a short-lived attach JWT for `(daemon_id, session_id)`. -pub fn issue_attach_jwt( - secret: &str, - daemon_id: &str, - session_id: Uuid, -) -> Result { - let now = Utc::now(); - let claims = JwtClaims { - session_id, - daemon_id: daemon_id.to_string(), - exp: (now + ATTACH_JWT_TTL).timestamp(), - iat: Some(now.timestamp()), - }; - let header = Header::new(Algorithm::HS256); - let key = EncodingKey::from_secret(secret.as_bytes()); - encode(&header, &claims, &key) -} - -/// Mint a pair-scoped attach JWT used by the home-daemon broker (NAP-247). -/// `sub` is the home daemon-id; `aud` is the target daemon-id. Target -/// validates signature, `iss == REGISTRY_ISSUER`, `aud == self.daemon_id`, -/// `sid == session`, and `exp > now` before accepting the broker-attach. -/// `sub` is no longer gated against an allow-list (NAP-339) — it's retained -/// for logging/tracing. -pub fn issue_pair_attach_jwt( - secret: &str, - home_daemon_id: &str, - target_daemon_id: &str, - session_id: Uuid, -) -> Result { - let now = Utc::now(); - let claims = PairAttachClaims { - iss: REGISTRY_ISSUER.into(), - sub: home_daemon_id.to_string(), - aud: target_daemon_id.to_string(), - sid: session_id, - exp: (now + PAIR_ATTACH_JWT_TTL).timestamp(), - }; - let header = Header::new(Algorithm::HS256); - let key = EncodingKey::from_secret(secret.as_bytes()); - encode(&header, &claims, &key) -} - -#[cfg(test)] -mod tests { - use super::*; - use jsonwebtoken::{decode, DecodingKey, Validation}; - - #[test] - fn issued_token_verifies_with_shared_secret() { - let secret = "shared-secret"; - let daemon = "laptop"; - let session = Uuid::new_v4(); - let token = issue_attach_jwt(secret, daemon, session).unwrap(); - let decoded = decode::( - &token, - &DecodingKey::from_secret(secret.as_bytes()), - &Validation::new(Algorithm::HS256), - ) - .unwrap(); - assert_eq!(decoded.claims.daemon_id, daemon); - assert_eq!(decoded.claims.session_id, session); - assert!(decoded.claims.exp > Utc::now().timestamp()); - } - - #[test] - fn pair_jwt_carries_iss_sub_aud_sid() { - let secret = "shared-secret"; - let session = Uuid::new_v4(); - let token = issue_pair_attach_jwt(secret, "laptop", "konishi", session).unwrap(); - let mut v = Validation::new(Algorithm::HS256); - v.set_audience(&["konishi"]); - let decoded = - decode::(&token, &DecodingKey::from_secret(secret.as_bytes()), &v) - .unwrap(); - assert_eq!(decoded.claims.iss, REGISTRY_ISSUER); - assert_eq!(decoded.claims.sub, "laptop"); - assert_eq!(decoded.claims.aud, "konishi"); - assert_eq!(decoded.claims.sid, session); - assert!(decoded.claims.exp > Utc::now().timestamp()); - } -} diff --git a/crates/maestro-registry/src/lib.rs b/crates/maestro-registry/src/lib.rs deleted file mode 100644 index 6320239..0000000 --- a/crates/maestro-registry/src/lib.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! The always-on Maestro registry. -//! -//! Covers two surfaces: -//! -//! - Daemon-facing `WS /daemon` (NAP-213): presence + session catalog. -//! - Client-facing HTTP/WS API (NAP-215): list daemons/sessions, spawn/kill -//! sessions (forwarded to the owning daemon via WS), issue per-session -//! attach JWTs, and broadcast catalog deltas over `WS /api/events`. -//! -//! See `docs/superpowers/specs/2026-05-24-maestro-design.md` §Registry, -//! §"Wire protocol", and §Auth. - -#![deny(missing_debug_implementations)] - -pub mod auth; -pub mod client_api; -pub mod events; -pub mod handler; -pub mod jwt; -pub mod run; -pub mod server; -pub mod state; -pub mod token; - -pub use run::run; - -pub use auth::{issue_cookie, verify_cookie, COOKIE_NAME}; -pub use jwt::{issue_attach_jwt, ATTACH_JWT_TTL}; -pub use server::{router, router_full, router_with_timeout, RegistryServer}; -pub use state::{DaemonView, Registry, RegistryError, RpcError, HEARTBEAT_TIMEOUT, RPC_TIMEOUT}; -pub use token::{ - default_config_path, ensure_token, load_config, resolve_config_path, RegistryConfig, - TokenError, CONFIG_PATH_ENV, ENV_REGISTRY_BIND_ADDR, ENV_TOKEN, -}; diff --git a/crates/maestro-registry/src/run.rs b/crates/maestro-registry/src/run.rs deleted file mode 100644 index 111ba7a..0000000 --- a/crates/maestro-registry/src/run.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Public entry point: load config, bind, serve until SIGINT. -//! -//! Extracted from the per-binary `main.rs` (NAP-245) so the umbrella `maestro` -//! binary can dispatch `maestro registry` here. - -use std::error::Error; -use std::path::PathBuf; - -use tokio::net::TcpListener; - -use crate::{ - default_config_path, load_config, router, Registry, ENV_REGISTRY_BIND_ADDR, ENV_TOKEN, -}; - -/// Run the registry. `config_override` is the top-level `--config` flag from -/// the umbrella binary (also populated from `$MAESTRO_CONFIG` by clap). -pub async fn run(config_override: Option) -> Result<(), Box> { - let config_path: PathBuf = match config_override { - Some(p) => p, - None => default_config_path()?, - }; - let env_token = std::env::var(ENV_TOKEN).ok(); - let env_bind = std::env::var(ENV_REGISTRY_BIND_ADDR).ok(); - let token_from_env = env_token.is_some(); - let bind_from_env = env_bind.is_some(); - let config = load_config(&config_path, env_token, env_bind.as_deref())?; - tracing::info!( - config = %config_path.display(), - bind = %config.bind_addr, - token_from_env, - bind_from_env, - "loaded config" - ); - - let listener = TcpListener::bind(config.bind_addr).await?; - let bound = listener.local_addr()?; - tracing::info!(addr = %bound, "registry listening"); - - let registry = Registry::new(); - let app = router(registry, config.token); - - let shutdown = async { - let _ = tokio::signal::ctrl_c().await; - tracing::info!("ctrl-c received — shutting down"); - }; - - axum::serve(listener, app) - .with_graceful_shutdown(shutdown) - .await?; - Ok(()) -} diff --git a/crates/maestro-registry/src/server.rs b/crates/maestro-registry/src/server.rs deleted file mode 100644 index 6bfb744..0000000 --- a/crates/maestro-registry/src/server.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! axum router + listener for the registry, including both the daemon-facing -//! `WS /daemon` endpoint (NAP-213) and the client-facing HTTP API (NAP-215). - -use std::net::SocketAddr; -use std::sync::Arc; -use std::time::Duration; - -use axum::extract::ws::WebSocketUpgrade; -use axum::extract::State; -use axum::response::IntoResponse; -use axum::routing::any; -use axum::{middleware, Router}; -use tokio::net::TcpListener; - -use crate::auth::{cookie_middleware, AuthState}; -use crate::client_api::{routes as client_routes, ClientApiState}; -use crate::handler::handle_socket; -use crate::state::{Registry, HEARTBEAT_TIMEOUT, RPC_TIMEOUT}; - -#[derive(Debug, Clone)] -struct DaemonAppState { - registry: Registry, - token: Arc, - heartbeat_timeout: Duration, -} - -/// Build the production router (daemon WS + client HTTP API) with default -/// timeouts. -pub fn router(registry: Registry, token: String) -> Router { - router_full(registry, token, HEARTBEAT_TIMEOUT, RPC_TIMEOUT) -} - -/// Variant for integration tests that want a short heartbeat. Keeps the -/// existing 4-arg call sites in `daemon_ws.rs` working. -pub fn router_with_timeout( - registry: Registry, - token: String, - heartbeat_timeout: Duration, -) -> Router { - router_full(registry, token, heartbeat_timeout, RPC_TIMEOUT) -} - -/// Full builder; lets tests override every knob. -pub fn router_full( - registry: Registry, - token: String, - heartbeat_timeout: Duration, - rpc_timeout: Duration, -) -> Router { - let token_arc = Arc::new(token.clone()); - - let daemon_router = Router::new() - .route("/daemon", any(daemon_ws)) - .with_state(DaemonAppState { - registry: registry.clone(), - token: token_arc.clone(), - heartbeat_timeout, - }); - - let client_router = client_routes(ClientApiState { - registry, - token: token_arc.clone(), - rpc_timeout, - }) - .layer(middleware::from_fn_with_state( - AuthState { - token: token_arc.clone(), - }, - cookie_middleware, - )); - - Router::new().merge(daemon_router).merge(client_router) -} - -async fn daemon_ws(State(state): State, ws: WebSocketUpgrade) -> impl IntoResponse { - let registry = state.registry.clone(); - let token = state.token.clone(); - let timeout = state.heartbeat_timeout; - ws.on_upgrade(move |socket| async move { - let _ = handle_socket(socket, registry, token.as_str(), timeout).await; - }) -} - -/// Convenience: bind a TCP listener and serve the router on it. -#[derive(Debug)] -pub struct RegistryServer { - pub addr: SocketAddr, - pub registry: Registry, -} - -impl RegistryServer { - /// Bind to `bind_addr` and serve in-place. Returns the bound `SocketAddr` - /// (so callers passing `:0` can discover it) and a handle to the registry - /// state. The future runs until the listener is dropped. - pub async fn bind( - bind_addr: SocketAddr, - token: String, - ) -> std::io::Result<(Self, impl std::future::Future>)> { - let listener = TcpListener::bind(bind_addr).await?; - let addr = listener.local_addr()?; - let registry = Registry::new(); - let app = router(registry.clone(), token); - let server = Self { - addr, - registry: registry.clone(), - }; - let fut = async move { axum::serve(listener, app).await }; - Ok((server, fut)) - } -} diff --git a/crates/maestro-registry/src/state.rs b/crates/maestro-registry/src/state.rs deleted file mode 100644 index 385e78a..0000000 --- a/crates/maestro-registry/src/state.rs +++ /dev/null @@ -1,1249 +0,0 @@ -//! In-memory registry state shared across all daemon connections. -//! -//! The state holds: -//! -//! - one [`DaemonEntry`] per connected daemon, keyed by `daemon_id` -//! - one entry in the session catalog per `(daemon_id, session_id)` pair -//! - one pending-reply oneshot per outstanding R→D RPC -//! -//! Concurrent access is guarded by a single `Mutex` — the registry handles a -//! handful of daemons and these critical sections are tiny. - -use std::collections::{BTreeMap, HashMap}; -use std::hash::Hash; -use std::sync::{Arc, Mutex as StdMutex}; -use std::time::Duration; - -use maestro_protocol::{ - AttachCandidates, CreateSessionRequest, DaemonInbound, DaemonInfo, DirListing, ErrorInfo, - GitRoot, PerimeterCandidate, RecentDirs, RegistryEvent, ReplyTo, RpcResult, Session, -}; -use tokio::sync::{broadcast, mpsc, oneshot, Mutex}; -use uuid::Uuid; - -use crate::jwt::issue_pair_attach_jwt; - -/// RAII cleanup for a pending-RPC entry. Removing the entry on drop guarantees -/// it is gone on *every* exit path of the awaiting call — success, error, -/// timeout, and crucially future cancellation (an axum handler is dropped when -/// the HTTP client hangs up) — so the map can't grow without bound (NAP-259). -/// -/// The pending maps live behind a `std::sync::Mutex` (never held across an -/// await) precisely so this sync `Drop` can lock them. Removal is idempotent: -/// on the happy path the reply handler has already taken the entry, making the -/// drop a harmless no-op. -struct PendingGuard<'a, K: Eq + Hash, V> { - map: &'a StdMutex>, - key: K, -} - -impl Drop for PendingGuard<'_, K, V> { - fn drop(&mut self) { - self.map.lock().unwrap().remove(&self.key); - } -} - -/// Capacity of the registry-wide broadcast channel that feeds -/// `WS /api/events` subscribers. Subscribers that fall this far behind get a -/// `Lagged` event and are expected to resync via `GET /api/sessions`. Sourced -/// from the shared [`maestro_config::BroadcastTuning`] so the daemon-side buses -/// track the same value (NAP-273). -const EVENT_CHANNEL_CAPACITY: usize = maestro_config::BroadcastTuning::DEFAULT.events_capacity; - -/// How long a daemon can go without sending us *any* message before it is -/// considered offline and its sessions are dropped from the catalog. -pub const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(30); - -/// How long we wait for a `CreateSessionResult` / `KillSessionResult` after -/// sending the matching R→D request. Spec §"Wire protocol (D↔R)". -pub const RPC_TIMEOUT: Duration = Duration::from_secs(10); - -#[derive(Debug, thiserror::Error)] -pub enum RegistryError { - #[error("daemon `{0}` is not connected")] - DaemonNotConnected(String), - #[error("daemon outbound channel closed before send")] - DaemonGone, - #[error("rpc reply channel closed before reply arrived")] - ReplyChannelClosed, - #[error("rpc timed out after {0:?}")] - Timeout(Duration), -} - -/// Distinct error type for RPC failures bubbled up by the daemon. Separated -/// from [`RegistryError`] so callers can distinguish transport from daemon- -/// reported faults. -#[derive(Debug, thiserror::Error)] -#[error("daemon rpc failed: {0:?}")] -pub struct RpcError(pub ErrorInfo); - -/// Public, read-only view of a single connected daemon. Convertible to the -/// protocol-level `DaemonInfo` for inclusion in HTTP responses and events. -#[derive(Debug, Clone)] -pub struct DaemonView { - pub daemon_id: String, - pub hostname: String, - pub version: String, - pub daemon_url: String, - pub home_dir: String, -} - -impl From for DaemonInfo { - fn from(v: DaemonView) -> Self { - Self { - daemon_id: v.daemon_id, - hostname: v.hostname, - version: v.version, - daemon_url: v.daemon_url, - home_dir: v.home_dir, - } - } -} - -/// Shared, cloneable registry handle. -#[derive(Debug, Clone)] -pub struct Registry { - inner: Arc>, - events: broadcast::Sender, - /// Outstanding R->D RPC waiters. Held behind a `std::sync::Mutex` (separate - /// from `State`'s async lock) so a [`PendingGuard`] can clean them up on - /// drop — including when the awaiting HTTP handler is cancelled (NAP-259). - pending_create: Arc>>>>, - pending_kill: Arc>>>>, - /// Outstanding `ListDir` RPC waiters (NAP-329). Same `PendingGuard` - /// discipline as `pending_create` / `pending_kill`. - pending_list_dir: Arc>>>>, - /// Outstanding `RecentDirs` RPC waiters (#26). - pending_recent_dirs: Arc>>>>, - /// Outstanding `GitRoot` RPC waiters (#26). - pending_git_root: Arc>>>>, -} - -#[derive(Debug, Default)] -struct State { - daemons: HashMap, - /// `(daemon_id, session_id) -> Session`. - sessions: BTreeMap<(String, Uuid), Session>, -} - -#[derive(Debug)] -struct DaemonEntry { - hostname: String, - version: String, - daemon_url: String, - home_dir: String, - outbound: mpsc::Sender, - /// Latest perimeter advertised by this daemon in its heartbeats (id + addr - /// together, or absent). `None` until the daemon advertises one (NAP-247 T2 - /// dial target; typed to encode the both-or-neither invariant, NAP-272). - perimeter: Option, - /// Fencing token minted per WS connection in [`Registry::register`]. Only - /// the handler holding the matching token may [`Registry::deregister`] this - /// entry, so a stale handler (old socket timing out after the daemon - /// reconnected on a new one) can't wipe the live entry. (NAP-258) - connection_token: Uuid, -} - -impl DaemonEntry { - fn to_view(&self, daemon_id: &str) -> DaemonView { - DaemonView { - daemon_id: daemon_id.to_string(), - hostname: self.hostname.clone(), - version: self.version.clone(), - daemon_url: self.daemon_url.clone(), - home_dir: self.home_dir.clone(), - } - } -} - -impl Registry { - pub fn new() -> Self { - let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY); - Self { - inner: Arc::new(Mutex::new(State::default())), - events, - pending_create: Arc::new(StdMutex::new(HashMap::new())), - pending_kill: Arc::new(StdMutex::new(HashMap::new())), - pending_list_dir: Arc::new(StdMutex::new(HashMap::new())), - pending_recent_dirs: Arc::new(StdMutex::new(HashMap::new())), - pending_git_root: Arc::new(StdMutex::new(HashMap::new())), - } - } - - /// Subscribe to the registry's broadcast event stream. The subscriber gets - /// a one-shot `Snapshot` of the current catalog first, then live deltas. - pub async fn subscribe(&self) -> (RegistryEvent, broadcast::Receiver) { - let state = self.inner.lock().await; - let snapshot = RegistryEvent::Snapshot { - daemons: state - .daemons - .iter() - .map(|(id, e)| e.to_view(id).into()) - .collect(), - sessions: state.sessions.values().cloned().collect(), - }; - let rx = self.events.subscribe(); - (snapshot, rx) - } - - fn broadcast(&self, event: RegistryEvent) { - // Send returns Err only when there are no subscribers; that's not a - // bug, so swallow. - let _ = self.events.send(event); - } - - // ----------------------------------------------------------------------- - // Connection lifecycle - // ----------------------------------------------------------------------- - - /// Register a freshly-authenticated daemon. Replaces any previous entry - /// with the same `daemon_id` (and drops its sessions), matching the - /// spec's "re-registration restores catalog" expectation. - /// - /// Returns a per-connection fencing token the caller must pass back to - /// [`Registry::deregister`] when the connection ends, so a stale handler - /// can't wipe a newer live entry (NAP-258). - pub async fn register( - &self, - daemon_id: String, - hostname: String, - version: String, - daemon_url: String, - home_dir: String, - outbound: mpsc::Sender, - ) -> Uuid { - let connection_token = Uuid::new_v4(); - let mut state = self.inner.lock().await; - let removed_sessions = state.drop_daemon_sessions(&daemon_id); - state.daemons.insert( - daemon_id.clone(), - DaemonEntry { - hostname: hostname.clone(), - version: version.clone(), - daemon_url: daemon_url.clone(), - home_dir: home_dir.clone(), - outbound, - perimeter: None, - connection_token, - }, - ); - drop(state); - for id in removed_sessions { - self.broadcast(RegistryEvent::SessionRemoved { - daemon_id: daemon_id.clone(), - id, - }); - } - self.broadcast(RegistryEvent::DaemonAdded { - daemon: DaemonInfo { - daemon_id, - hostname, - version, - daemon_url, - home_dir, - }, - }); - connection_token - } - - /// Remove the daemon and any sessions it owned, but only if `token` matches - /// the entry's current [`DaemonEntry::connection_token`]. A stale handler - /// whose token no longer matches (the daemon reconnected on a new socket) - /// is a no-op, so it can't wipe the live entry or its sessions (NAP-258). - pub async fn deregister(&self, daemon_id: &str, token: Uuid) { - let removed_sessions = { - let mut state = self.inner.lock().await; - match state.daemons.get(daemon_id) { - Some(entry) if entry.connection_token == token => {} - // Either superseded by a newer connection or already gone — - // leave the live entry (and its sessions) untouched. - _ => return, - } - state.daemons.remove(daemon_id); - state.drop_daemon_sessions(daemon_id) - }; - for id in removed_sessions { - self.broadcast(RegistryEvent::SessionRemoved { - daemon_id: daemon_id.to_string(), - id, - }); - } - self.broadcast(RegistryEvent::DaemonRemoved { - daemon_id: daemon_id.to_string(), - }); - } - - // ----------------------------------------------------------------------- - // Catalog mutations driven by inbound D→R messages - // ----------------------------------------------------------------------- - - pub async fn apply_snapshot(&self, daemon_id: &str, sessions: Vec) { - let (removed, added) = { - let mut state = self.inner.lock().await; - let removed = state.drop_daemon_sessions(daemon_id); - let mut added = Vec::with_capacity(sessions.len()); - for s in sessions { - added.push(s.clone()); - state.sessions.insert((daemon_id.to_string(), s.id), s); - } - (removed, added) - }; - for id in removed { - self.broadcast(RegistryEvent::SessionRemoved { - daemon_id: daemon_id.to_string(), - id, - }); - } - for s in added { - self.broadcast(RegistryEvent::SessionAdded { session: s }); - } - } - - pub async fn add_session(&self, daemon_id: &str, session: Session) { - { - let mut state = self.inner.lock().await; - state - .sessions - .insert((daemon_id.to_string(), session.id), session.clone()); - } - self.broadcast(RegistryEvent::SessionAdded { session }); - } - - pub async fn remove_session(&self, daemon_id: &str, id: Uuid) { - let removed = { - let mut state = self.inner.lock().await; - state.sessions.remove(&(daemon_id.to_string(), id)) - }; - if removed.is_some() { - self.broadcast(RegistryEvent::SessionRemoved { - daemon_id: daemon_id.to_string(), - id, - }); - } - } - - pub async fn update_session(&self, daemon_id: &str, session: Session) { - { - let mut state = self.inner.lock().await; - state - .sessions - .insert((daemon_id.to_string(), session.id), session.clone()); - } - self.broadcast(RegistryEvent::SessionUpdated { session }); - } - - /// Look up the daemon that owns the given session id. - pub async fn daemon_for_session(&self, session_id: Uuid) -> Option { - let state = self.inner.lock().await; - state - .sessions - .iter() - .find(|((_, id), _)| *id == session_id) - .map(|((d, _), _)| d.clone()) - } - - /// Resolve a daemon's connect URL (the operator-advertised value from its - /// Hello frame). - pub async fn daemon_url(&self, daemon_id: &str) -> Option { - let state = self.inner.lock().await; - state.daemons.get(daemon_id).map(|e| e.daemon_url.clone()) - } - - /// Update a daemon's advertised perimeter from its heartbeat. Called by - /// the WS handler on every `Heartbeat` frame (NAP-247). - pub async fn update_perimeter(&self, daemon_id: &str, perimeter: Option) { - let mut state = self.inner.lock().await; - if let Some(entry) = state.daemons.get_mut(daemon_id) { - entry.perimeter = perimeter; - } - } - - /// The perimeter (`perimeter_id` + `private_addr`) last advertised by this - /// daemon, when set. The broker uses this to assemble `AttachCandidates` - /// for the T2 transport (NAP-247). The both-or-neither invariant is encoded - /// in the type, so no defensive AND-check is needed (NAP-272). - pub async fn perimeter_for(&self, daemon_id: &str) -> Option { - let state = self.inner.lock().await; - state - .daemons - .get(daemon_id) - .and_then(|e| e.perimeter.clone()) - } - - /// Handle a `ResolveAttach` from a home daemon (NAP-247). Look up which - /// daemon owns `session_id`, mint a pair-scoped JWT for the home → target - /// pair, and reply with `AttachCandidates`. Errors when the session is - /// unknown or its owning daemon has gone offline. - pub async fn resolve_attach( - &self, - home_daemon_id: &str, - reply_to: ReplyTo, - session_id: Uuid, - shared_secret: &str, - ) { - let result = self - .build_attach_candidates(home_daemon_id, session_id, shared_secret) - .await; - let frame = DaemonInbound::ResolveAttachResult { reply_to, result }; - let outbound = { - let state = self.inner.lock().await; - state - .daemons - .get(home_daemon_id) - .map(|e| e.outbound.clone()) - }; - // If the home daemon has gone away mid-flight, drop the response. - if let Some(outbound) = outbound { - let _ = outbound.send(frame).await; - } - } - - async fn build_attach_candidates( - &self, - home_daemon_id: &str, - session_id: Uuid, - shared_secret: &str, - ) -> RpcResult { - let Some(target) = self.daemon_for_session(session_id).await else { - return RpcResult::Err { - error: ErrorInfo { - code: "unknown_session".into(), - message: format!("no daemon owns session {session_id}"), - }, - }; - }; - // Self-attach: home daemon already short-circuits this in its own - // broker. But if it asks anyway, refuse — `aud != self` won't pass - // anyway and we'd just be wasting a JWT. - if target == home_daemon_id { - return RpcResult::Err { - error: ErrorInfo { - code: "self_target".into(), - message: "home daemon owns this session; short-circuit locally".into(), - }, - }; - } - let jwt = match issue_pair_attach_jwt(shared_secret, home_daemon_id, &target, session_id) { - Ok(t) => t, - Err(e) => { - return RpcResult::Err { - error: ErrorInfo { - code: "jwt_failed".into(), - message: e.to_string(), - }, - }; - } - }; - // T1 (direct WSS) stays None — NAP-235 was canceled. - // T2: hand back the target's perimeter so the home daemon can match. - // T3 (relay) stays None — NAP-238 spike pending. - let perimeter = self.perimeter_for(&target).await; - RpcResult::Ok { - value: AttachCandidates { - direct_wss: None, - perimeter, - relay: None, - jwt, - }, - } - } - - /// Relay a home daemon's `ForwardCreate` to the owning daemon and push the - /// result back to the home daemon as `ForwardCreateResult` (NAP-327). - /// - /// Control-plane only: the owning daemon does the actual tmux spawn; the - /// registry just relays request and reply, so no session bytes traverse it. - /// Reuses [`create_session`](Self::create_session) (the same R→D machinery - /// the legacy HTTP path uses), then routes the reply to `home_daemon_id`. - pub async fn forward_create( - &self, - home_daemon_id: &str, - reply_to: ReplyTo, - request: CreateSessionRequest, - ) { - let owner = request.daemon_id.clone(); - let result = match self.create_session(&owner, request).await { - Ok(Ok(session)) => RpcResult::Ok { value: session }, - Ok(Err(RpcError(error))) => RpcResult::Err { error }, - Err(e) => RpcResult::Err { - error: registry_error_info(&e), - }, - }; - self.reply_to_daemon( - home_daemon_id, - DaemonInbound::ForwardCreateResult { reply_to, result }, - ) - .await; - } - - /// Relay a home daemon's `ForwardKill` to the owning daemon and push the - /// `ForwardKillResult` back to the home daemon (NAP-327). - pub async fn forward_kill( - &self, - home_daemon_id: &str, - reply_to: ReplyTo, - target_daemon_id: &str, - id: Uuid, - ) { - let result = match self.kill_session(target_daemon_id, id).await { - Ok(Ok(())) => RpcResult::Ok { value: () }, - Ok(Err(RpcError(error))) => RpcResult::Err { error }, - Err(e) => RpcResult::Err { - error: registry_error_info(&e), - }, - }; - self.reply_to_daemon( - home_daemon_id, - DaemonInbound::ForwardKillResult { reply_to, result }, - ) - .await; - } - - /// Relay a home daemon's `ForwardListDir` to the target daemon and push the - /// `ForwardListDirResult` back to the home daemon (NAP-329). Reuses - /// [`list_dir`](Self::list_dir), then routes the reply to `home_daemon_id`. - pub async fn forward_list_dir( - &self, - home_daemon_id: &str, - reply_to: ReplyTo, - target_daemon_id: &str, - path: String, - ) { - let result = match self.list_dir(target_daemon_id, path).await { - Ok(Ok(listing)) => RpcResult::Ok { value: listing }, - Ok(Err(RpcError(error))) => RpcResult::Err { error }, - Err(e) => RpcResult::Err { - error: registry_error_info(&e), - }, - }; - self.reply_to_daemon( - home_daemon_id, - DaemonInbound::ForwardListDirResult { reply_to, result }, - ) - .await; - } - - /// Relay a home daemon's `ForwardRecentDirs` to the target daemon and push - /// the `ForwardRecentDirsResult` back to the home daemon (#26). - pub async fn forward_recent_dirs( - &self, - home_daemon_id: &str, - reply_to: ReplyTo, - target_daemon_id: &str, - ) { - let result = match self.recent_dirs(target_daemon_id).await { - Ok(Ok(recents)) => RpcResult::Ok { value: recents }, - Ok(Err(RpcError(error))) => RpcResult::Err { error }, - Err(e) => RpcResult::Err { - error: registry_error_info(&e), - }, - }; - self.reply_to_daemon( - home_daemon_id, - DaemonInbound::ForwardRecentDirsResult { reply_to, result }, - ) - .await; - } - - /// Relay a home daemon's `ForwardGitRoot` to the target daemon and push the - /// `ForwardGitRootResult` back to the home daemon (#26). - pub async fn forward_git_root( - &self, - home_daemon_id: &str, - reply_to: ReplyTo, - target_daemon_id: &str, - path: String, - ) { - let result = match self.git_root(target_daemon_id, path).await { - Ok(Ok(root)) => RpcResult::Ok { value: root }, - Ok(Err(RpcError(error))) => RpcResult::Err { error }, - Err(e) => RpcResult::Err { - error: registry_error_info(&e), - }, - }; - self.reply_to_daemon( - home_daemon_id, - DaemonInbound::ForwardGitRootResult { reply_to, result }, - ) - .await; - } - - /// Send a single frame to a connected daemon's outbound channel, dropping - /// it silently if the daemon has gone away mid-flight (the home daemon's - /// own RPC timeout will surface the failure). - async fn reply_to_daemon(&self, daemon_id: &str, frame: DaemonInbound) { - let outbound = { - let state = self.inner.lock().await; - state.daemons.get(daemon_id).map(|e| e.outbound.clone()) - }; - if let Some(outbound) = outbound { - let _ = outbound.send(frame).await; - } - } - - /// Resolve a CreateSession RPC reply. Returns true if a waiter was found. - pub async fn complete_create(&self, reply_to: ReplyTo, result: RpcResult) -> bool { - let entry = self.pending_create.lock().unwrap().remove(&reply_to); - match entry { - Some(tx) => tx.send(result).is_ok(), - None => false, - } - } - - /// Resolve a KillSession RPC reply. Returns true if a waiter was found. - pub async fn complete_kill(&self, reply_to: ReplyTo, result: RpcResult<()>) -> bool { - let entry = self.pending_kill.lock().unwrap().remove(&reply_to); - match entry { - Some(tx) => tx.send(result).is_ok(), - None => false, - } - } - - /// Resolve a ListDir RPC reply. Returns true if a waiter was found (NAP-329). - pub async fn complete_list_dir( - &self, - reply_to: ReplyTo, - result: RpcResult, - ) -> bool { - let entry = self.pending_list_dir.lock().unwrap().remove(&reply_to); - match entry { - Some(tx) => tx.send(result).is_ok(), - None => false, - } - } - - /// Resolve a RecentDirs RPC reply. Returns true if a waiter was found (#26). - pub async fn complete_recent_dirs( - &self, - reply_to: ReplyTo, - result: RpcResult, - ) -> bool { - let entry = self.pending_recent_dirs.lock().unwrap().remove(&reply_to); - match entry { - Some(tx) => tx.send(result).is_ok(), - None => false, - } - } - - /// Resolve a GitRoot RPC reply. Returns true if a waiter was found (#26). - pub async fn complete_git_root(&self, reply_to: ReplyTo, result: RpcResult) -> bool { - let entry = self.pending_git_root.lock().unwrap().remove(&reply_to); - match entry { - Some(tx) => tx.send(result).is_ok(), - None => false, - } - } - - // ----------------------------------------------------------------------- - // Outbound R→D requests (internal API for NAP-215) - // ----------------------------------------------------------------------- - - /// Forward a `CreateSession` to the named daemon and wait for the matching - /// `CreateSessionResult`. Returns the daemon-reported result on success. - pub async fn create_session( - &self, - daemon_id: &str, - request: CreateSessionRequest, - ) -> Result, RegistryError> { - let reply_to = Uuid::new_v4(); - let (tx, rx) = oneshot::channel(); - let outbound = { - let state = self.inner.lock().await; - state - .daemons - .get(daemon_id) - .ok_or_else(|| RegistryError::DaemonNotConnected(daemon_id.to_string()))? - .outbound - .clone() - }; - self.pending_create.lock().unwrap().insert(reply_to, tx); - // Every exit path past here — send failure, timeout, a successful reply, - // or cancellation of this future — removes the pending entry when - // `_guard` drops (NAP-259). - let _guard = PendingGuard { - map: &*self.pending_create, - key: reply_to, - }; - if outbound - .send(DaemonInbound::CreateSession { reply_to, request }) - .await - .is_err() - { - return Err(RegistryError::DaemonGone); - } - let rpc = await_rpc(rx).await.map(rpc_into_result)?; - // Apply the session to the catalog from the reply — the authoritative, - // single write for an RPC-initiated create. The daemon swallows the - // matching async `SessionAdded` precisely because we apply it here - // (NAP-268), so this is not racing a duplicate and `add_session` need - // not be idempotent for correctness. Without it the HTTP caller would - // see an empty `GET /api/sessions` immediately after a successful POST. - if let Ok(ref session) = rpc { - self.add_session(daemon_id, session.clone()).await; - } - Ok(rpc) - } - - /// Forward a `KillSession` to the named daemon and wait for the matching - /// `KillSessionResult`. - pub async fn kill_session( - &self, - daemon_id: &str, - id: Uuid, - ) -> Result, RegistryError> { - let reply_to = Uuid::new_v4(); - let (tx, rx) = oneshot::channel(); - let outbound = { - let state = self.inner.lock().await; - state - .daemons - .get(daemon_id) - .ok_or_else(|| RegistryError::DaemonNotConnected(daemon_id.to_string()))? - .outbound - .clone() - }; - self.pending_kill.lock().unwrap().insert(reply_to, tx); - // See `create_session`: `_guard` removes the pending entry on every exit - // path, including cancellation of this future (NAP-259). - let _guard = PendingGuard { - map: &*self.pending_kill, - key: reply_to, - }; - if outbound - .send(DaemonInbound::KillSession { reply_to, id }) - .await - .is_err() - { - return Err(RegistryError::DaemonGone); - } - let rpc = await_rpc(rx).await.map(rpc_into_result)?; - // Authoritative single write for an RPC-initiated kill (see - // `create_session`): drop the session from the catalog so subsequent - // reads are consistent. The daemon swallows the matching async - // `SessionRemoved` (NAP-268), so this is not racing a duplicate. - if rpc.is_ok() { - self.remove_session(daemon_id, id).await; - } - Ok(rpc) - } - - /// Forward a `ListDir` to the named daemon and wait for the matching - /// `ListDirResult` (NAP-329). No catalog side-effects — listings aren't - /// cached registry-side. Mirrors [`create_session`](Self::create_session). - pub async fn list_dir( - &self, - daemon_id: &str, - path: String, - ) -> Result, RegistryError> { - let reply_to = Uuid::new_v4(); - let (tx, rx) = oneshot::channel(); - let outbound = { - let state = self.inner.lock().await; - state - .daemons - .get(daemon_id) - .ok_or_else(|| RegistryError::DaemonNotConnected(daemon_id.to_string()))? - .outbound - .clone() - }; - self.pending_list_dir.lock().unwrap().insert(reply_to, tx); - let _guard = PendingGuard { - map: &*self.pending_list_dir, - key: reply_to, - }; - if outbound - .send(DaemonInbound::ListDir { reply_to, path }) - .await - .is_err() - { - return Err(RegistryError::DaemonGone); - } - await_rpc(rx).await.map(rpc_into_result) - } - - /// Forward a `RecentDirs` query to the named daemon and wait for its - /// `RecentDirsResult` (#26). No catalog side-effects. Mirrors - /// [`list_dir`](Self::list_dir). - pub async fn recent_dirs( - &self, - daemon_id: &str, - ) -> Result, RegistryError> { - let reply_to = Uuid::new_v4(); - let (tx, rx) = oneshot::channel(); - let outbound = { - let state = self.inner.lock().await; - state - .daemons - .get(daemon_id) - .ok_or_else(|| RegistryError::DaemonNotConnected(daemon_id.to_string()))? - .outbound - .clone() - }; - self.pending_recent_dirs - .lock() - .unwrap() - .insert(reply_to, tx); - let _guard = PendingGuard { - map: &*self.pending_recent_dirs, - key: reply_to, - }; - if outbound - .send(DaemonInbound::RecentDirs { reply_to }) - .await - .is_err() - { - return Err(RegistryError::DaemonGone); - } - await_rpc(rx).await.map(rpc_into_result) - } - - /// Forward a `GitRoot` probe to the named daemon and wait for its - /// `GitRootResult` (#26). Mirrors [`list_dir`](Self::list_dir). - pub async fn git_root( - &self, - daemon_id: &str, - path: String, - ) -> Result, RegistryError> { - let reply_to = Uuid::new_v4(); - let (tx, rx) = oneshot::channel(); - let outbound = { - let state = self.inner.lock().await; - state - .daemons - .get(daemon_id) - .ok_or_else(|| RegistryError::DaemonNotConnected(daemon_id.to_string()))? - .outbound - .clone() - }; - self.pending_git_root.lock().unwrap().insert(reply_to, tx); - let _guard = PendingGuard { - map: &*self.pending_git_root, - key: reply_to, - }; - if outbound - .send(DaemonInbound::GitRoot { reply_to, path }) - .await - .is_err() - { - return Err(RegistryError::DaemonGone); - } - await_rpc(rx).await.map(rpc_into_result) - } - - // ----------------------------------------------------------------------- - // Read-only views (mostly for tests) - // ----------------------------------------------------------------------- - - pub async fn daemons(&self) -> Vec { - let state = self.inner.lock().await; - state.daemons.iter().map(|(id, e)| e.to_view(id)).collect() - } - - pub async fn sessions(&self) -> Vec { - let state = self.inner.lock().await; - state.sessions.values().cloned().collect() - } - - pub async fn sessions_for(&self, daemon_id: &str) -> Vec { - let state = self.inner.lock().await; - state - .sessions - .iter() - .filter(|((d, _), _)| d == daemon_id) - .map(|(_, s)| s.clone()) - .collect() - } -} - -impl Default for Registry { - fn default() -> Self { - Self::new() - } -} - -impl State { - /// Removes every session belonging to `daemon_id`, returning their ids so - /// the caller can emit `SessionRemoved` events outside the lock. - fn drop_daemon_sessions(&mut self, daemon_id: &str) -> Vec { - let mut removed = Vec::new(); - self.sessions.retain(|(d, id), _| { - if d.as_str() == daemon_id { - removed.push(*id); - false - } else { - true - } - }); - removed - } -} - -/// Await `rx` with [`RPC_TIMEOUT`]. Cleanup of the pending map on every exit -/// path (timeout, channel close, cancellation) is handled by the caller's -/// [`PendingGuard`], so this only has to classify the outcome. -async fn await_rpc(rx: oneshot::Receiver>) -> Result, RegistryError> { - match tokio::time::timeout(RPC_TIMEOUT, rx).await { - Ok(Ok(result)) => Ok(result), - Ok(Err(_)) => Err(RegistryError::ReplyChannelClosed), - Err(_) => Err(RegistryError::Timeout(RPC_TIMEOUT)), - } -} - -/// Map a transport-level [`RegistryError`] to a wire [`ErrorInfo`] with a -/// stable `code` so the home daemon can translate it to an HTTP status -/// (NAP-327). Daemon-reported faults keep their own codes via [`RpcError`]. -fn registry_error_info(e: &RegistryError) -> ErrorInfo { - let code = match e { - RegistryError::DaemonNotConnected(_) => "daemon_not_connected", - RegistryError::DaemonGone => "daemon_gone", - RegistryError::ReplyChannelClosed => "reply_closed", - RegistryError::Timeout(_) => "rpc_timeout", - }; - ErrorInfo { - code: code.into(), - message: e.to_string(), - } -} - -fn rpc_into_result(rpc: RpcResult) -> Result { - match rpc { - RpcResult::Ok { value } => Ok(value), - RpcResult::Err { error } => Err(RpcError(error)), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use chrono::Utc; - use maestro_protocol::SessionStatus; - use std::path::PathBuf; - - fn sample_session(id: Uuid, daemon_id: &str) -> Session { - Session { - id, - daemon_id: daemon_id.to_string(), - cwd: PathBuf::from("/tmp"), - command: "bash".into(), - env: HashMap::new(), - metadata: HashMap::new(), - status: SessionStatus::Running, - created_at: Utc::now(), - foreground: None, - activity: Default::default(), - last_activity_at: chrono::Utc::now(), - - current_cwd: None, - } - } - - async fn register(reg: &Registry, daemon_id: &str) -> (Uuid, mpsc::Receiver) { - let (tx, rx) = mpsc::channel::(8); - let token = reg - .register( - daemon_id.to_string(), - "host".into(), - "0.1.0".into(), - format!("ws://{daemon_id}:7070"), - "/home/test".into(), - tx, - ) - .await; - (token, rx) - } - - /// NAP-258: a stale handler's deregister (old socket timing out after the - /// daemon reconnected on a new one) must NOT wipe the live entry or its - /// sessions. Only the current connection's token may tear it down. - #[tokio::test] - async fn stale_deregister_is_a_noop_after_reconnect() { - let reg = Registry::new(); - - // First connection. - let (token_old, _rx_old) = register(®, "X").await; - // Daemon reconnects on a new socket; register overwrites the entry and - // mints a fresh token. - let (token_new, _rx_new) = register(®, "X").await; - assert_ne!(token_old, token_new); - - // The live connection advertises a session. - let sid = Uuid::new_v4(); - reg.add_session("X", sample_session(sid, "X")).await; - - // The old handler finally times out and deregisters with its stale - // token — this must be a no-op. - reg.deregister("X", token_old).await; - assert!( - reg.daemons().await.iter().any(|d| d.daemon_id == "X"), - "stale deregister wiped the live daemon entry" - ); - assert_eq!( - reg.sessions_for("X").await.len(), - 1, - "stale deregister wiped the live daemon's sessions" - ); - - // The current handler's deregister (matching token) tears it down. - reg.deregister("X", token_new).await; - assert!( - !reg.daemons().await.iter().any(|d| d.daemon_id == "X"), - "matching-token deregister should remove the daemon" - ); - assert!(reg.sessions_for("X").await.is_empty()); - } - - /// Deregistering an unknown daemon (already gone) is a harmless no-op. - #[tokio::test] - async fn deregister_unknown_daemon_is_noop() { - let reg = Registry::new(); - reg.deregister("ghost", Uuid::new_v4()).await; - assert!(reg.daemons().await.is_empty()); - } - - // ----- NAP-270: perimeter / resolve_attach security boundary ----- - - /// Read the single `ResolveAttachResult` the registry pushes to the home - /// daemon's outbound channel in response to a `resolve_attach`. - async fn recv_resolve_result( - rx: &mut mpsc::Receiver, - ) -> RpcResult { - match rx - .recv() - .await - .expect("home daemon got a ResolveAttachResult") - { - DaemonInbound::ResolveAttachResult { result, .. } => result, - other => panic!("expected ResolveAttachResult, got {other:?}"), - } - } - - /// The `(perimeter_id, private_addr)` pair only yields a candidate when - /// The perimeter is advertised as one typed object, so "both id and addr, - /// or neither" is enforced by construction — `perimeter_for` round-trips - /// `None` and a full advertisement, and a half-pair is unrepresentable - /// (NAP-270 / NAP-272). - #[tokio::test] - async fn perimeter_for_round_trips_typed_advertisement() { - let reg = Registry::new(); - let (_t, _rx) = register(®, "d1").await; - - assert!( - reg.perimeter_for("d1").await.is_none(), - "nothing advertised" - ); - - let adv = PerimeterCandidate { - perimeter_id: "wg".into(), - private_addr: "10.0.0.1:7070".into(), - }; - reg.update_perimeter("d1", Some(adv.clone())).await; - assert_eq!(reg.perimeter_for("d1").await, Some(adv)); - - // Clearing it goes back to None. - reg.update_perimeter("d1", None).await; - assert!(reg.perimeter_for("d1").await.is_none()); - } - - /// A home daemon asking to attach to a session it already owns is refused - /// (`self_target`) rather than handed a useless pair JWT (NAP-270). - #[tokio::test] - async fn resolve_attach_refuses_self_target() { - let reg = Registry::new(); - let (_t, mut home_rx) = register(®, "laptop").await; - let sid = Uuid::new_v4(); - reg.add_session("laptop", sample_session(sid, "laptop")) - .await; - - reg.resolve_attach("laptop", Uuid::new_v4(), sid, "secret") - .await; - - match recv_resolve_result(&mut home_rx).await { - RpcResult::Err { error } => assert_eq!(error.code, "self_target"), - RpcResult::Ok { .. } => panic!("self-target attach must be refused"), - } - } - - // ----- NAP-327: forwarded create / kill ----- - - /// A `ForwardCreate` from the home daemon is relayed to the owning daemon as - /// a `CreateSession`, and the owner's success reply comes back to the home - /// daemon as a `ForwardCreateResult` carrying the new session. - #[tokio::test] - async fn forward_create_relays_to_owner_and_replies_to_home() { - let reg = Registry::new(); - let (_th, mut home_rx) = register(®, "laptop").await; - let (_tt, mut target_rx) = register(®, "konishi").await; - - let reply_to = Uuid::new_v4(); - let request = CreateSessionRequest { - daemon_id: "konishi".into(), - cwd: PathBuf::from("/tmp"), - command: "bash".into(), - env: HashMap::new(), - metadata: HashMap::new(), - worktree: None, - }; - - let r = reg.clone(); - let task = tokio::spawn(async move { r.forward_create("laptop", reply_to, request).await }); - - // The owner receives a CreateSession; learn its reply_to and answer. - let sid = Uuid::new_v4(); - let owner_reply_to = match target_rx.recv().await.expect("owner got CreateSession") { - DaemonInbound::CreateSession { reply_to, request } => { - assert_eq!(request.daemon_id, "konishi"); - reply_to - } - other => panic!("expected CreateSession, got {other:?}"), - }; - reg.complete_create( - owner_reply_to, - RpcResult::Ok { - value: sample_session(sid, "konishi"), - }, - ) - .await; - task.await.unwrap(); - - // The home daemon gets the relayed result. - match home_rx.recv().await.expect("home got ForwardCreateResult") { - DaemonInbound::ForwardCreateResult { - reply_to: got, - result, - } => { - assert_eq!(got, reply_to); - match result { - RpcResult::Ok { value } => { - assert_eq!(value.id, sid); - assert_eq!(value.daemon_id, "konishi"); - } - RpcResult::Err { error } => panic!("unexpected err {error:?}"), - } - } - other => panic!("expected ForwardCreateResult, got {other:?}"), - } - } - - /// Forwarding to an owner that isn't connected surfaces a structured - /// `daemon_not_connected` error back to the home daemon (NAP-327). - #[tokio::test] - async fn forward_create_owner_offline_replies_error_to_home() { - let reg = Registry::new(); - let (_th, mut home_rx) = register(®, "laptop").await; - - let request = CreateSessionRequest { - daemon_id: "ghost".into(), - cwd: PathBuf::from("/tmp"), - command: "bash".into(), - env: HashMap::new(), - metadata: HashMap::new(), - worktree: None, - }; - reg.forward_create("laptop", Uuid::new_v4(), request).await; - - match home_rx.recv().await.expect("home got ForwardCreateResult") { - DaemonInbound::ForwardCreateResult { result, .. } => match result { - RpcResult::Err { error } => assert_eq!(error.code, "daemon_not_connected"), - RpcResult::Ok { .. } => panic!("create to offline owner must fail"), - }, - other => panic!("expected ForwardCreateResult, got {other:?}"), - } - } - - /// A `ForwardKill` is relayed to the owner and the success reply returns to - /// the home daemon (NAP-327). - #[tokio::test] - async fn forward_kill_relays_to_owner_and_replies_to_home() { - let reg = Registry::new(); - let (_th, mut home_rx) = register(®, "laptop").await; - let (_tt, mut target_rx) = register(®, "konishi").await; - let id = Uuid::new_v4(); - - let r = reg.clone(); - let task = tokio::spawn(async move { - r.forward_kill("laptop", Uuid::new_v4(), "konishi", id) - .await - }); - - let owner_reply_to = match target_rx.recv().await.expect("owner got KillSession") { - DaemonInbound::KillSession { reply_to, id: got } => { - assert_eq!(got, id); - reply_to - } - other => panic!("expected KillSession, got {other:?}"), - }; - reg.complete_kill(owner_reply_to, RpcResult::Ok { value: () }) - .await; - task.await.unwrap(); - - match home_rx.recv().await.expect("home got ForwardKillResult") { - DaemonInbound::ForwardKillResult { result, .. } => { - assert!(matches!(result, RpcResult::Ok { .. })); - } - other => panic!("expected ForwardKillResult, got {other:?}"), - } - } - - /// Resolving an attach for a session no daemon owns yields - /// `unknown_session`, not a JWT for some default daemon (NAP-270). - #[tokio::test] - async fn resolve_attach_returns_unknown_session_for_orphan_id() { - let reg = Registry::new(); - let (_t, mut home_rx) = register(®, "laptop").await; - - reg.resolve_attach("laptop", Uuid::new_v4(), Uuid::new_v4(), "secret") - .await; - - match recv_resolve_result(&mut home_rx).await { - RpcResult::Err { error } => assert_eq!(error.code, "unknown_session"), - RpcResult::Ok { .. } => panic!("orphan session must not resolve"), - } - } -} - -#[cfg(test)] -mod nap259_tests { - use super::*; - - /// Cancelling an in-flight `kill_session` must not leak its pending-reply - /// entry — a client that hangs up mid-request would otherwise grow the map - /// without bound (NAP-259). - #[tokio::test] - async fn cancelled_kill_session_removes_pending_entry() { - let registry = Registry::new(); - // Register a daemon with a live outbound receiver so the R->D send - // succeeds and the call parks on the reply oneshot. - let (tx, _rx) = mpsc::channel::(8); - registry - .register( - "d1".into(), - "host".into(), - "0.1.0".into(), - "ws://127.0.0.1:1".into(), - "/home/test".into(), - tx, - ) - .await; - - { - let fut = registry.kill_session("d1", Uuid::new_v4()); - tokio::pin!(fut); - tokio::select! { - biased; - _ = &mut fut => panic!("kill must not complete without a reply"), - _ = tokio::time::sleep(Duration::from_millis(20)) => {} - } - assert_eq!( - registry.pending_kill.lock().unwrap().len(), - 1, - "entry should be in-flight before cancellation" - ); - } - - assert_eq!( - registry.pending_kill.lock().unwrap().len(), - 0, - "pending entry must be removed on cancellation (NAP-259)" - ); - } -} diff --git a/crates/maestro-registry/src/token.rs b/crates/maestro-registry/src/token.rs deleted file mode 100644 index 299b9dd..0000000 --- a/crates/maestro-registry/src/token.rs +++ /dev/null @@ -1,289 +0,0 @@ -//! Registry view over the unified Maestro config + first-run token bootstrap. -//! -//! On startup the registry looks for the unified file at -//! `~/.config/maestro/config.toml`: -//! -//! - If `[shared].token` is present, loads it. -//! - If absent, generates a 256-bit random token, prints it once to stderr, -//! and persists it back to the same file via read-modify-write (so any -//! `[cli]` / `[daemon]` sections from a co-located host stay intact). -//! Subsequent runs are silent. -//! -//! Env-var overrides (applied by [`load_config`]): -//! -//! - `MAESTRO_TOKEN` — when set, bypasses the file entirely (no read, -//! no write). Lets the binary run in a container with no writable volume. -//! - `MAESTRO_REGISTRY_BIND_ADDR` — when set, overrides `bind_addr`. -//! -//! Default config path: `~/.config/maestro/config.toml` (override via -//! `$MAESTRO_CONFIG`). - -use std::net::SocketAddr; -use std::path::Path; - -use maestro_config::MaestroConfigFile; -use rand::RngCore; - -pub use maestro_config::{default_config_path, resolve_config_path, CONFIG_PATH_ENV}; - -/// Default `bind_addr` when neither file nor env specify one. -pub const DEFAULT_BIND_ADDR: &str = "127.0.0.1:8080"; - -/// Env var that overrides `[shared].token`. -pub const ENV_TOKEN: &str = "MAESTRO_TOKEN"; -/// Env var that overrides `[registry].bind_addr`. -pub const ENV_REGISTRY_BIND_ADDR: &str = "MAESTRO_REGISTRY_BIND_ADDR"; - -fn default_bind_addr() -> SocketAddr { - DEFAULT_BIND_ADDR.parse().expect("valid default bind_addr") -} - -#[derive(Debug, thiserror::Error)] -pub enum TokenError { - #[error(transparent)] - Inner(#[from] maestro_config::ConfigError), - #[error("io: {0}")] - Io(#[from] std::io::Error), -} - -/// Resolved registry config: the token to sign JWTs with and the address -/// to bind on. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RegistryConfig { - pub token: String, - pub bind_addr: SocketAddr, -} - -/// Load `[shared].token` (or generate one and persist it). Returns the -/// resolved [`RegistryConfig`] populated with `[registry].bind_addr` when -/// present, else the default. -/// -/// On first run (no `[shared].token` in the file) this prints the freshly -/// generated token to stderr so the operator can copy it to every daemon. -pub fn ensure_token(config_path: &Path) -> Result { - let mut file = MaestroConfigFile::load_or_default(config_path)?; - let bind_addr = file.registry.bind_addr.unwrap_or_else(default_bind_addr); - - if let Some(token) = file.shared.token.clone() { - return Ok(RegistryConfig { token, bind_addr }); - } - - let token = generate_token(); - file.shared.token = Some(token.clone()); - file.save(config_path)?; - eprintln!( - "maestro-registry: generated new MAESTRO_TOKEN; deploy this to every daemon:\n {token}" - ); - Ok(RegistryConfig { token, bind_addr }) -} - -/// Resolve the runtime config, applying env-var overrides on top of (or -/// instead of) the on-disk config file. -/// -/// Precedence per field: env > file > default. -/// -/// `env_token` (`MAESTRO_TOKEN`): when `Some`, the file is neither -/// read nor created — useful in container deployments without a writable -/// volume. -/// `env_bind_addr` (`MAESTRO_REGISTRY_BIND_ADDR`): when `Some`, overrides -/// the bind address. -/// -/// Pass real env values from `main`; tests pass explicit `Option`s. -pub fn load_config( - config_path: &Path, - env_token: Option, - env_bind_addr: Option<&str>, -) -> Result { - let mut cfg = match env_token { - Some(token) => RegistryConfig { - token, - bind_addr: default_bind_addr(), - }, - None => ensure_token(config_path)?, - }; - - if let Some(raw) = env_bind_addr { - cfg.bind_addr = maestro_config::parse_bind_addr(raw, ENV_REGISTRY_BIND_ADDR)?; - } - - Ok(cfg) -} - -fn generate_token() -> String { - let mut bytes = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut bytes); - hex_encode(&bytes) -} - -fn hex_encode(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut out = String::with_capacity(bytes.len() * 2); - for &b in bytes { - out.push(HEX[(b >> 4) as usize] as char); - out.push(HEX[(b & 0xf) as usize] as char); - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn first_run_generates_persistent_token_in_shared_section() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - let cfg = ensure_token(&path).unwrap(); - assert_eq!(cfg.token.len(), 64); // 32 bytes hex-encoded - assert_eq!(cfg.bind_addr, default_bind_addr()); - - let again = ensure_token(&path).unwrap(); - assert_eq!(cfg, again, "second call must return same token"); - - let file = MaestroConfigFile::load(&path).unwrap(); - assert_eq!(file.shared.token.as_deref(), Some(cfg.token.as_str())); - } - - #[test] - fn first_run_preserves_other_sections() { - // A host running both daemon and registry already has the daemon's - // config in the file. Registry's first-run token gen must not lose - // [daemon] or [shared].registry_url. - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write( - &path, - r#" -[shared] -registry_url = "https://r.example" - -[daemon] -daemon_url = "ws://laptop:7070" -"#, - ) - .unwrap(); - let cfg = ensure_token(&path).unwrap(); - assert_eq!(cfg.token.len(), 64); - - let file = MaestroConfigFile::load(&path).unwrap(); - assert_eq!( - file.shared.registry_url.as_deref(), - Some("https://r.example") - ); - assert_eq!(file.shared.token.as_deref(), Some(cfg.token.as_str())); - assert_eq!(file.daemon.daemon_url.as_deref(), Some("ws://laptop:7070")); - } - - #[test] - fn creates_parent_directory() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("nested/here/config.toml"); - let cfg = ensure_token(&path).unwrap(); - assert!(path.exists()); - assert_eq!(cfg.token.len(), 64); - } - - #[test] - fn existing_token_is_loaded_as_is() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write( - &path, - r#" -[shared] -token = "abcd" -"#, - ) - .unwrap(); - let cfg = ensure_token(&path).unwrap(); - assert_eq!(cfg.token, "abcd"); - assert_eq!(cfg.bind_addr, default_bind_addr()); - } - - #[test] - fn custom_bind_addr_is_preserved() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write( - &path, - r#" -[shared] -token = "x" - -[registry] -bind_addr = "0.0.0.0:9999" -"#, - ) - .unwrap(); - let cfg = ensure_token(&path).unwrap(); - assert_eq!(cfg.bind_addr, "0.0.0.0:9999".parse().unwrap()); - } - - #[test] - fn env_token_bypasses_file_entirely() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - let cfg = load_config(&path, Some("envtoken".into()), None).unwrap(); - assert_eq!(cfg.token, "envtoken"); - assert_eq!(cfg.bind_addr, default_bind_addr()); - assert!( - !path.exists(), - "no file should be created when env token is set" - ); - } - - #[test] - fn env_bind_addr_overrides_file() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - std::fs::write( - &path, - r#" -[shared] -token = "file" - -[registry] -bind_addr = "127.0.0.1:1" -"#, - ) - .unwrap(); - let cfg = load_config(&path, None, Some("0.0.0.0:9090")).unwrap(); - assert_eq!(cfg.token, "file"); - assert_eq!(cfg.bind_addr, "0.0.0.0:9090".parse().unwrap()); - } - - #[test] - fn env_token_and_env_bind_addr_combined() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - let cfg = load_config(&path, Some("t".into()), Some("0.0.0.0:7777")).unwrap(); - assert_eq!(cfg.token, "t"); - assert_eq!(cfg.bind_addr, "0.0.0.0:7777".parse().unwrap()); - assert!(!path.exists()); - } - - #[test] - fn invalid_env_bind_addr_returns_error() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - let err = load_config(&path, Some("t".into()), Some("not-an-addr")).unwrap_err(); - match err { - TokenError::Inner(maestro_config::ConfigError::InvalidBindAddr { - input, env, .. - }) => { - assert_eq!(input, "not-an-addr"); - assert_eq!(env, ENV_REGISTRY_BIND_ADDR); - } - other => panic!("expected InvalidBindAddr, got {other:?}"), - } - } - - #[test] - fn no_env_falls_through_to_ensure_token() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("config.toml"); - let cfg = load_config(&path, None, None).unwrap(); - assert_eq!(cfg.token.len(), 64); - assert!(path.exists()); - } -} diff --git a/crates/maestro-registry/tests/daemon_ws.rs b/crates/maestro-registry/tests/daemon_ws.rs deleted file mode 100644 index 74d10d1..0000000 --- a/crates/maestro-registry/tests/daemon_ws.rs +++ /dev/null @@ -1,391 +0,0 @@ -//! End-to-end tests for the `WS /daemon` endpoint, driven by a real -//! `tokio-tungstenite` client against an axum server bound to `:0`. - -use std::collections::HashMap; -use std::future::Future; -use std::net::{Ipv4Addr, SocketAddr}; -use std::path::PathBuf; -use std::time::{Duration, Instant}; - -use chrono::{DateTime, Utc}; -use futures_util::{SinkExt, StreamExt}; -use maestro_protocol::{ - CreateSessionRequest, DaemonInbound, DaemonOutbound, RpcResult, Session, SessionStatus, -}; -use maestro_registry::{router_with_timeout, Registry}; -use tokio::net::TcpListener; -use tokio::sync::oneshot; -use tokio_tungstenite::tungstenite::Message as WsMessage; -use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; -use uuid::Uuid; - -const TOKEN: &str = "test-token-please-allow"; -const TEST_HEARTBEAT: Duration = Duration::from_millis(500); - -struct Harness { - addr: SocketAddr, - registry: Registry, - _shutdown_tx: oneshot::Sender<()>, -} - -async fn spawn_registry() -> Harness { - spawn_registry_with_timeout(TEST_HEARTBEAT).await -} - -async fn spawn_registry_with_timeout(heartbeat: Duration) -> Harness { - let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let registry = Registry::new(); - let app = router_with_timeout(registry.clone(), TOKEN.to_string(), heartbeat); - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - tokio::spawn(async move { - let _ = axum::serve(listener, app) - .with_graceful_shutdown(async move { - let _ = shutdown_rx.await; - }) - .await; - }); - Harness { - addr, - registry, - _shutdown_tx: shutdown_tx, - } -} - -type WsClient = WebSocketStream>; - -async fn connect(addr: SocketAddr) -> WsClient { - let url = format!("ws://{addr}/daemon"); - let (ws, _) = connect_async(url).await.expect("ws connect"); - ws -} - -async fn send_json(ws: &mut WsClient, msg: &DaemonOutbound) { - let text = serde_json::to_string(msg).unwrap(); - ws.send(WsMessage::Text(text)).await.unwrap(); -} - -async fn recv_inbound(ws: &mut WsClient) -> Option { - while let Some(Ok(msg)) = ws.next().await { - if let WsMessage::Text(t) = msg { - let parsed: DaemonInbound = serde_json::from_str(&t).unwrap(); - // NAP-246: skip catalog frames — these are informational and not - // what most tests are asserting on. - if matches!(parsed, DaemonInbound::CatalogEvent { .. }) { - continue; - } - return Some(parsed); - } - } - None -} - -fn hello(token: &str) -> DaemonOutbound { - DaemonOutbound::Hello { - daemon_id: "laptop".into(), - hostname: "naps-laptop".into(), - version: "0.1.0".into(), - token: token.into(), - daemon_url: "ws://127.0.0.1:8081".into(), - home_dir: String::new(), - } -} - -fn sample_session(id: Uuid, daemon_id: &str) -> Session { - Session { - id, - daemon_id: daemon_id.to_string(), - cwd: PathBuf::from("/tmp"), - command: "bash".into(), - env: HashMap::new(), - metadata: HashMap::new(), - status: SessionStatus::Running, - created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z") - .unwrap() - .with_timezone(&Utc), - foreground: None, - activity: Default::default(), - last_activity_at: chrono::Utc::now(), - - current_cwd: None, - } -} - -async fn wait_for(timeout: Duration, mut cond: F) -where - F: FnMut() -> Fut, - Fut: Future, -{ - let deadline = Instant::now() + timeout; - while Instant::now() < deadline { - if cond().await { - return; - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - panic!("timed out after {timeout:?} waiting for condition"); -} - -// -------------------------------------------------------------------------- -// Tests -// -------------------------------------------------------------------------- - -#[tokio::test] -async fn rejects_bad_token() { - let h = spawn_registry().await; - let mut ws = connect(h.addr).await; - - send_json(&mut ws, &hello("wrong")).await; - let reply = recv_inbound(&mut ws).await.expect("error frame"); - match reply { - DaemonInbound::Error(e) => assert_eq!(e.code, "auth_failed"), - other => panic!("expected Error, got {other:?}"), - } - let _ = ws.next().await; - assert!(h.registry.daemons().await.is_empty()); -} - -#[tokio::test] -async fn accepts_good_token_and_snapshot() { - let h = spawn_registry().await; - let mut ws = connect(h.addr).await; - - send_json(&mut ws, &hello(TOKEN)).await; - let ack = recv_inbound(&mut ws).await.expect("ack"); - assert!(matches!(ack, DaemonInbound::Ack)); - - let sessions = vec![ - sample_session(Uuid::new_v4(), "laptop"), - sample_session(Uuid::new_v4(), "laptop"), - ]; - send_json( - &mut ws, - &DaemonOutbound::SessionsSnapshot { - sessions: sessions.clone(), - }, - ) - .await; - - let expected = sessions.len(); - let registry = h.registry.clone(); - wait_for(Duration::from_secs(2), || { - let r = registry.clone(); - async move { r.sessions().await.len() == expected } - }) - .await; - - let daemons = h.registry.daemons().await; - assert_eq!(daemons.len(), 1); - assert_eq!(daemons[0].daemon_id, "laptop"); -} - -#[tokio::test] -async fn applies_individual_diffs() { - let h = spawn_registry().await; - let mut ws = connect(h.addr).await; - - send_json(&mut ws, &hello(TOKEN)).await; - let _ = recv_inbound(&mut ws).await; - - let s1 = sample_session(Uuid::new_v4(), "laptop"); - let s2 = sample_session(Uuid::new_v4(), "laptop"); - - send_json( - &mut ws, - &DaemonOutbound::SessionAdded { - session: s1.clone(), - }, - ) - .await; - send_json( - &mut ws, - &DaemonOutbound::SessionAdded { - session: s2.clone(), - }, - ) - .await; - - let registry = h.registry.clone(); - wait_for(Duration::from_secs(2), || { - let r = registry.clone(); - async move { r.sessions().await.len() == 2 } - }) - .await; - - let mut s1_updated = s1.clone(); - s1_updated.command = "fish".into(); - send_json( - &mut ws, - &DaemonOutbound::SessionUpdated { - session: s1_updated.clone(), - }, - ) - .await; - send_json(&mut ws, &DaemonOutbound::SessionRemoved { id: s2.id }).await; - - let registry = h.registry.clone(); - wait_for(Duration::from_secs(2), || { - let r = registry.clone(); - async move { - let s = r.sessions().await; - s.len() == 1 && s[0].command == "fish" - } - }) - .await; -} - -#[tokio::test] -async fn heartbeat_timeout_drops_daemon() { - let h = spawn_registry_with_timeout(Duration::from_millis(200)).await; - let mut ws = connect(h.addr).await; - - send_json(&mut ws, &hello(TOKEN)).await; - let _ = recv_inbound(&mut ws).await; - send_json( - &mut ws, - &DaemonOutbound::SessionAdded { - session: sample_session(Uuid::new_v4(), "laptop"), - }, - ) - .await; - - let registry = h.registry.clone(); - wait_for(Duration::from_secs(2), || { - let r = registry.clone(); - async move { r.sessions().await.len() == 1 } - }) - .await; - - // Hold the WS open but never send another frame. The server's per-read - // timeout should fire after 200ms and drop the daemon. - let registry = h.registry.clone(); - wait_for(Duration::from_secs(2), || { - let r = registry.clone(); - async move { r.daemons().await.is_empty() && r.sessions().await.is_empty() } - }) - .await; -} - -#[tokio::test] -async fn re_registration_replaces_catalog() { - let h = spawn_registry().await; - - let mut ws1 = connect(h.addr).await; - send_json(&mut ws1, &hello(TOKEN)).await; - let _ = recv_inbound(&mut ws1).await; - let s_old = sample_session(Uuid::new_v4(), "laptop"); - send_json( - &mut ws1, - &DaemonOutbound::SessionAdded { - session: s_old.clone(), - }, - ) - .await; - - let registry = h.registry.clone(); - wait_for(Duration::from_secs(2), || { - let r = registry.clone(); - async move { r.sessions().await.len() == 1 } - }) - .await; - - drop(ws1); - - let registry = h.registry.clone(); - wait_for(Duration::from_secs(2), || { - let r = registry.clone(); - async move { r.daemons().await.is_empty() } - }) - .await; - - let mut ws2 = connect(h.addr).await; - send_json(&mut ws2, &hello(TOKEN)).await; - let _ = recv_inbound(&mut ws2).await; - let s_new = sample_session(Uuid::new_v4(), "laptop"); - send_json( - &mut ws2, - &DaemonOutbound::SessionsSnapshot { - sessions: vec![s_new.clone()], - }, - ) - .await; - - let registry = h.registry.clone(); - let want_id = s_new.id; - wait_for(Duration::from_secs(2), || { - let r = registry.clone(); - async move { - let s = r.sessions().await; - s.len() == 1 && s[0].id == want_id - } - }) - .await; -} - -#[tokio::test] -async fn create_session_rpc_round_trip() { - let h = spawn_registry().await; - let mut ws = connect(h.addr).await; - - send_json(&mut ws, &hello(TOKEN)).await; - let _ = recv_inbound(&mut ws).await; - - let registry = h.registry.clone(); - let request = CreateSessionRequest { - daemon_id: "laptop".into(), - cwd: PathBuf::from("/tmp"), - command: "bash".into(), - env: HashMap::new(), - metadata: HashMap::new(), - worktree: None, - }; - let rpc = tokio::spawn(async move { registry.create_session("laptop", request).await }); - - let inbound = recv_inbound(&mut ws).await.expect("CreateSession"); - let (reply_to, _request) = match inbound { - DaemonInbound::CreateSession { reply_to, request } => (reply_to, request), - other => panic!("expected CreateSession, got {other:?}"), - }; - - let session = sample_session(Uuid::new_v4(), "laptop"); - send_json( - &mut ws, - &DaemonOutbound::CreateSessionResult { - reply_to, - result: RpcResult::Ok { - value: session.clone(), - }, - }, - ) - .await; - - let outcome = rpc.await.unwrap().expect("rpc transport ok"); - let returned = outcome.expect("rpc daemon ok"); - assert_eq!(returned.id, session.id); -} - -#[tokio::test] -async fn create_session_to_unknown_daemon_errors() { - let h = spawn_registry().await; - let err = h - .registry - .create_session( - "no-such-daemon", - CreateSessionRequest { - daemon_id: "no-such-daemon".into(), - cwd: PathBuf::from("/tmp"), - command: "bash".into(), - env: HashMap::new(), - metadata: HashMap::new(), - worktree: None, - }, - ) - .await - .unwrap_err(); - assert!(matches!( - err, - maestro_registry::RegistryError::DaemonNotConnected(_) - )); -} diff --git a/crates/maestro-registry/tests/http_api.rs b/crates/maestro-registry/tests/http_api.rs deleted file mode 100644 index fc3ca36..0000000 --- a/crates/maestro-registry/tests/http_api.rs +++ /dev/null @@ -1,728 +0,0 @@ -//! Integration tests for the client-facing HTTP API (NAP-215). -//! -//! Spins up a registry on `:0`, plus a tiny mock-daemon WS client that exists -//! purely to satisfy `CreateSession` / `KillSession` round-trips. All -//! HTTP/WS calls go over the wire via `reqwest` and `tokio-tungstenite`. - -use std::collections::HashMap; -use std::future::Future; -use std::net::{Ipv4Addr, SocketAddr}; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use chrono::Utc; -use futures_util::{SinkExt, StreamExt}; -use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; -use maestro_protocol::{ - ConnectInfo, CreateSessionRequest, DaemonInbound, DaemonInfo, DaemonOutbound, JwtClaims, - RegistryEvent, RpcResult, Session, SessionStatus, -}; -use maestro_registry::{router_full, Registry, COOKIE_NAME}; -use reqwest::Client; -use serde_json::Value; -use tokio::net::TcpListener; -use tokio::sync::{oneshot, Mutex}; -use tokio_tungstenite::tungstenite::Message as WsMessage; -use tokio_tungstenite::{ - connect_async, tungstenite::client::IntoClientRequest, MaybeTlsStream, WebSocketStream, -}; -use uuid::Uuid; - -const TOKEN: &str = "http-api-test-token"; -const TEST_HEARTBEAT: Duration = Duration::from_secs(30); - -// --------------------------------------------------------------------------- -// Harness -// --------------------------------------------------------------------------- - -struct Harness { - addr: SocketAddr, - registry: Registry, - _shutdown_tx: oneshot::Sender<()>, -} - -impl Harness { - fn base(&self) -> String { - format!("http://{}", self.addr) - } - - fn ws_base(&self) -> String { - format!("ws://{}", self.addr) - } -} - -async fn spawn(rpc_timeout: Duration) -> Harness { - let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let registry = Registry::new(); - let app = router_full( - registry.clone(), - TOKEN.to_string(), - TEST_HEARTBEAT, - rpc_timeout, - ); - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - tokio::spawn(async move { - let _ = axum::serve(listener, app) - .with_graceful_shutdown(async move { - let _ = shutdown_rx.await; - }) - .await; - }); - Harness { - addr, - registry, - _shutdown_tx: shutdown_tx, - } -} - -// --------------------------------------------------------------------------- -// Mock daemon -// --------------------------------------------------------------------------- - -type WsClient = WebSocketStream>; - -#[derive(Clone, Copy)] -enum DaemonBehavior { - /// Replies immediately with the session the request asked for. - ReplyOk, - /// Never replies — exercises the registry's 504 timeout path. - Hang, -} - -#[derive(Clone)] -struct MockDaemon { - daemon_id: String, - daemon_url: String, - captured: Arc>>, -} - -impl MockDaemon { - fn new(daemon_id: &str, daemon_url: &str) -> Self { - Self { - daemon_id: daemon_id.into(), - daemon_url: daemon_url.into(), - captured: Arc::new(Mutex::new(Vec::new())), - } - } - - async fn spawn(self, addr: SocketAddr, behavior: DaemonBehavior) { - let url = format!("ws://{addr}/daemon"); - let (mut ws, _) = connect_async(url).await.expect("ws connect"); - let hello = DaemonOutbound::Hello { - daemon_id: self.daemon_id.clone(), - hostname: "mock-host".into(), - version: "0.1.0".into(), - token: TOKEN.into(), - daemon_url: self.daemon_url.clone(), - home_dir: String::new(), - }; - ws.send(WsMessage::Text(serde_json::to_string(&hello).unwrap())) - .await - .unwrap(); - // consume the Ack - let _ = ws.next().await; - // send an empty initial snapshot so the registry's view is consistent - ws.send(WsMessage::Text( - serde_json::to_string(&DaemonOutbound::SessionsSnapshot { sessions: vec![] }).unwrap(), - )) - .await - .unwrap(); - - let captured = self.captured.clone(); - let daemon_id = self.daemon_id.clone(); - tokio::spawn(async move { - run_loop(ws, behavior, captured, daemon_id).await; - }); - } - - async fn captured(&self) -> Vec { - self.captured.lock().await.clone() - } -} - -async fn run_loop( - mut ws: WsClient, - behavior: DaemonBehavior, - captured: Arc>>, - daemon_id: String, -) { - while let Some(Ok(WsMessage::Text(text))) = ws.next().await { - let msg: DaemonInbound = match serde_json::from_str(&text) { - Ok(m) => m, - Err(_) => continue, - }; - captured.lock().await.push(msg.clone()); - - match (behavior, msg) { - (DaemonBehavior::ReplyOk, DaemonInbound::CreateSession { reply_to, request }) => { - let session = mock_session(&daemon_id, &request); - // Send SessionAdded BEFORE the RPC result so the registry's - // catalog is updated by the time the HTTP caller resumes. - // The registry processes inbound frames serially in order, - // so this ordering guarantee holds end-to-end. - let _ = ws - .send(WsMessage::Text( - serde_json::to_string(&DaemonOutbound::SessionAdded { - session: session.clone(), - }) - .unwrap(), - )) - .await; - let reply = DaemonOutbound::CreateSessionResult { - reply_to, - result: RpcResult::Ok { value: session }, - }; - let _ = ws - .send(WsMessage::Text(serde_json::to_string(&reply).unwrap())) - .await; - } - (DaemonBehavior::ReplyOk, DaemonInbound::KillSession { reply_to, id }) => { - let _ = ws - .send(WsMessage::Text( - serde_json::to_string(&DaemonOutbound::SessionRemoved { id }).unwrap(), - )) - .await; - let reply = DaemonOutbound::KillSessionResult { - reply_to, - result: RpcResult::Ok { value: () }, - }; - let _ = ws - .send(WsMessage::Text(serde_json::to_string(&reply).unwrap())) - .await; - } - (DaemonBehavior::Hang, _) => { - // intentionally do nothing - } - _ => {} - } - } -} - -fn mock_session(daemon_id: &str, request: &CreateSessionRequest) -> Session { - // If a worktree spec was attached, pretend the daemon "resolved" the cwd - // to /.worktrees/ so the test can assert on it. - let cwd = request - .worktree - .as_ref() - .map(|w| w.base_repo.join(".worktrees").join(&w.branch)) - .unwrap_or_else(|| request.cwd.clone()); - Session { - id: Uuid::new_v4(), - daemon_id: daemon_id.into(), - cwd, - command: request.command.clone(), - env: request.env.clone(), - metadata: request.metadata.clone(), - status: SessionStatus::Running, - created_at: Utc::now(), - foreground: None, - activity: Default::default(), - last_activity_at: chrono::Utc::now(), - - current_cwd: None, - } -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -async fn wait_for(timeout: Duration, mut cond: F) -where - F: FnMut() -> Fut, - Fut: Future, -{ - let deadline = Instant::now() + timeout; - while Instant::now() < deadline { - if cond().await { - return; - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - panic!("timed out after {timeout:?} waiting for condition"); -} - -fn client_with_cookies() -> Client { - reqwest::Client::builder() - .cookie_store(true) - .build() - .unwrap() -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn first_request_sets_cookie_and_subsequent_reuses_it() { - let h = spawn(Duration::from_secs(5)).await; - let client = client_with_cookies(); - - let r1 = client - .get(format!("{}/api/daemons", h.base())) - .send() - .await - .unwrap(); - assert!(r1.status().is_success()); - let cookies: Vec<_> = r1 - .headers() - .get_all(reqwest::header::SET_COOKIE) - .iter() - .collect(); - assert!( - !cookies.is_empty(), - "first request must include a Set-Cookie" - ); - let raw = cookies[0].to_str().unwrap(); - assert!(raw.contains(COOKIE_NAME)); - assert!(raw.contains("HttpOnly")); - assert!(raw.contains("SameSite=Strict")); - - let r2 = client - .get(format!("{}/api/daemons", h.base())) - .send() - .await - .unwrap(); - assert!(r2.status().is_success()); - let new_cookies: Vec<_> = r2 - .headers() - .get_all(reqwest::header::SET_COOKIE) - .iter() - .collect(); - assert!( - new_cookies.is_empty(), - "valid existing cookie should not be reissued, got {new_cookies:?}" - ); -} - -#[tokio::test] -async fn tampered_cookie_is_replaced_silently() { - let h = spawn(Duration::from_secs(5)).await; - let client = reqwest::Client::new(); - - let resp = client - .get(format!("{}/api/daemons", h.base())) - .header( - reqwest::header::COOKIE, - format!("{}=not-a-jwt", COOKIE_NAME), - ) - .send() - .await - .unwrap(); - assert!(resp.status().is_success()); - let cookies: Vec<_> = resp - .headers() - .get_all(reqwest::header::SET_COOKIE) - .iter() - .collect(); - assert!( - !cookies.is_empty(), - "tampered cookie must be replaced with a freshly-signed one" - ); -} - -#[tokio::test] -async fn list_daemons_includes_url() { - let h = spawn(Duration::from_secs(5)).await; - let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081"); - mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await; - - let registry = h.registry.clone(); - wait_for(Duration::from_secs(2), || { - let r = registry.clone(); - async move { !r.daemons().await.is_empty() } - }) - .await; - - let client = client_with_cookies(); - let resp = client - .get(format!("{}/api/daemons", h.base())) - .send() - .await - .unwrap(); - assert!(resp.status().is_success()); - let body: Vec = resp.json().await.unwrap(); - assert_eq!(body.len(), 1); - assert_eq!(body[0].daemon_id, "laptop"); - assert_eq!(body[0].daemon_url, "ws://10.0.0.1:8081"); -} - -#[tokio::test] -async fn post_sessions_round_trips_to_daemon() { - let h = spawn(Duration::from_secs(5)).await; - let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081"); - mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await; - - let registry = h.registry.clone(); - wait_for(Duration::from_secs(2), || { - let r = registry.clone(); - async move { !r.daemons().await.is_empty() } - }) - .await; - - let client = client_with_cookies(); - let request = CreateSessionRequest { - daemon_id: "laptop".into(), - cwd: PathBuf::from("/tmp"), - command: "bash".into(), - env: HashMap::new(), - metadata: HashMap::new(), - worktree: None, - }; - let resp = client - .post(format!("{}/api/sessions", h.base())) - .json(&request) - .send() - .await - .unwrap(); - assert_eq!(resp.status().as_u16(), 201); - let session: Session = resp.json().await.unwrap(); - assert_eq!(session.daemon_id, "laptop"); - assert_eq!(session.command, "bash"); - - // GET /api/sessions should now see it. - let resp = client - .get(format!("{}/api/sessions", h.base())) - .send() - .await - .unwrap(); - let listed: Vec = resp.json().await.unwrap(); - assert!(listed.iter().any(|s| s.id == session.id)); -} - -#[tokio::test] -async fn post_sessions_returns_504_on_daemon_timeout() { - // Short RPC timeout so the 504 path is fast. - let h = spawn(Duration::from_millis(200)).await; - let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081"); - mock.clone().spawn(h.addr, DaemonBehavior::Hang).await; - - let registry = h.registry.clone(); - wait_for(Duration::from_secs(2), || { - let r = registry.clone(); - async move { !r.daemons().await.is_empty() } - }) - .await; - - let client = client_with_cookies(); - let request = CreateSessionRequest { - daemon_id: "laptop".into(), - cwd: PathBuf::from("/tmp"), - command: "bash".into(), - env: HashMap::new(), - metadata: HashMap::new(), - worktree: None, - }; - let resp = client - .post(format!("{}/api/sessions", h.base())) - .json(&request) - .send() - .await - .unwrap(); - assert_eq!(resp.status().as_u16(), 504); - - // The daemon should have received the CreateSession even though it never - // replied. - let captured = mock.captured().await; - assert!( - captured - .iter() - .any(|m| matches!(m, DaemonInbound::CreateSession { .. })), - "daemon should have seen CreateSession, got {captured:?}" - ); -} - -#[tokio::test] -async fn post_sessions_with_worktree_forwards_spec_and_returns_resolved_cwd() { - let h = spawn(Duration::from_secs(5)).await; - let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081"); - mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await; - - let registry = h.registry.clone(); - wait_for(Duration::from_secs(2), || { - let r = registry.clone(); - async move { !r.daemons().await.is_empty() } - }) - .await; - - let client = client_with_cookies(); - let body = serde_json::json!({ - "daemon_id": "laptop", - "cwd": "/home/naps62/projects/maestro", - "command": "claude", - "worktree": { - "base_repo": "/home/naps62/projects/maestro", - "branch": "feat/test", - "cleanup_on_kill": true - } - }); - let resp = client - .post(format!("{}/api/sessions", h.base())) - .json(&body) - .send() - .await - .unwrap(); - assert_eq!(resp.status().as_u16(), 201); - let session: Session = resp.json().await.unwrap(); - assert_eq!( - session.cwd, - PathBuf::from("/home/naps62/projects/maestro/.worktrees/feat/test") - ); - - let captured = mock.captured().await; - let forwarded = captured - .iter() - .find_map(|m| match m { - DaemonInbound::CreateSession { request, .. } => Some(request.clone()), - _ => None, - }) - .expect("CreateSession was not forwarded"); - let spec = forwarded.worktree.expect("worktree spec dropped"); - assert_eq!(spec.branch, "feat/test"); - assert!(spec.cleanup_on_kill); -} - -#[tokio::test] -async fn delete_session_round_trips() { - let h = spawn(Duration::from_secs(5)).await; - let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081"); - mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await; - - let registry = h.registry.clone(); - wait_for(Duration::from_secs(2), || { - let r = registry.clone(); - async move { !r.daemons().await.is_empty() } - }) - .await; - - let client = client_with_cookies(); - let request = CreateSessionRequest { - daemon_id: "laptop".into(), - cwd: PathBuf::from("/tmp"), - command: "bash".into(), - env: HashMap::new(), - metadata: HashMap::new(), - worktree: None, - }; - let session: Session = client - .post(format!("{}/api/sessions", h.base())) - .json(&request) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - - // The mock daemon doesn't actually update its catalog after kill — but the - // registry's catalog reflects whatever the daemon broadcasts. We're - // asserting only on the HTTP response. - let resp = client - .delete(format!("{}/api/sessions/{}", h.base(), session.id)) - .send() - .await - .unwrap(); - assert_eq!(resp.status().as_u16(), 204); - - let captured = mock.captured().await; - assert!( - captured - .iter() - .any(|m| matches!(m, DaemonInbound::KillSession { id, .. } if *id == session.id)), - "daemon should have seen KillSession, got {captured:?}" - ); -} - -#[tokio::test] -async fn delete_session_returns_404_for_unknown_session() { - let h = spawn(Duration::from_secs(5)).await; - let client = client_with_cookies(); - let resp = client - .delete(format!("{}/api/sessions/{}", h.base(), Uuid::new_v4())) - .send() - .await - .unwrap(); - assert_eq!(resp.status().as_u16(), 404); -} - -#[tokio::test] -async fn connect_info_issues_valid_jwt() { - let h = spawn(Duration::from_secs(5)).await; - let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081"); - mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await; - - let registry = h.registry.clone(); - wait_for(Duration::from_secs(2), || { - let r = registry.clone(); - async move { !r.daemons().await.is_empty() } - }) - .await; - - let client = client_with_cookies(); - let session: Session = client - .post(format!("{}/api/sessions", h.base())) - .json(&CreateSessionRequest { - daemon_id: "laptop".into(), - cwd: PathBuf::from("/tmp"), - command: "bash".into(), - env: HashMap::new(), - metadata: HashMap::new(), - worktree: None, - }) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - - let resp = client - .get(format!( - "{}/api/sessions/{}/connect-info", - h.base(), - session.id - )) - .send() - .await - .unwrap(); - assert!(resp.status().is_success()); - let info: ConnectInfo = resp.json().await.unwrap(); - assert_eq!(info.daemon_url, "ws://10.0.0.1:8081"); - - // Verify the JWT against the same shared secret a daemon would use. - let decoded = decode::( - &info.jwt, - &DecodingKey::from_secret(TOKEN.as_bytes()), - &Validation::new(Algorithm::HS256), - ) - .expect("jwt decodes"); - assert_eq!(decoded.claims.daemon_id, "laptop"); - assert_eq!(decoded.claims.session_id, session.id); - assert!(decoded.claims.exp > Utc::now().timestamp()); -} - -#[tokio::test] -async fn events_ws_pushes_snapshot_then_deltas() { - let h = spawn(Duration::from_secs(5)).await; - - // First obtain a valid cookie via a plain HTTP call. - let client = client_with_cookies(); - let _ = client - .get(format!("{}/api/daemons", h.base())) - .send() - .await - .unwrap(); - let cookie_value = harvest_cookie(&client, &h.base()).await; - - // Now open the events WS with that cookie. - let url = format!("{}/api/events", h.ws_base()); - let mut req = url.into_client_request().unwrap(); - req.headers_mut().insert( - reqwest::header::COOKIE, - format!("{}={}", COOKIE_NAME, cookie_value).parse().unwrap(), - ); - let (mut ws, _) = connect_async(req).await.expect("connect events ws"); - - let snapshot = recv_event(&mut ws).await; - match snapshot { - RegistryEvent::Snapshot { daemons, sessions } => { - assert!(daemons.is_empty()); - assert!(sessions.is_empty()); - } - other => panic!("expected Snapshot first, got {other:?}"), - } - - // Now connect a daemon — subscriber should see DaemonAdded. - let mock = MockDaemon::new("laptop", "ws://10.0.0.1:8081"); - mock.clone().spawn(h.addr, DaemonBehavior::ReplyOk).await; - - let event = recv_event(&mut ws).await; - match event { - RegistryEvent::DaemonAdded { daemon } => assert_eq!(daemon.daemon_id, "laptop"), - other => panic!("expected DaemonAdded, got {other:?}"), - } - - // Spawn a session via HTTP; subscriber should see SessionAdded. - let _: Session = client - .post(format!("{}/api/sessions", h.base())) - .json(&CreateSessionRequest { - daemon_id: "laptop".into(), - cwd: PathBuf::from("/tmp"), - command: "bash".into(), - env: HashMap::new(), - metadata: HashMap::new(), - worktree: None, - }) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - - let event = recv_event(&mut ws).await; - assert!( - matches!(event, RegistryEvent::SessionAdded { .. }), - "expected SessionAdded, got {event:?}" - ); -} - -#[tokio::test] -async fn root_serves_no_ui() { - // The registry serves no web UI — that's `maestro-web`'s job (see - // connection-orchestration spec §"UI hosting"). `/` has no route. - let h = spawn(Duration::from_secs(5)).await; - let client = client_with_cookies(); - let resp = client.get(h.base()).send().await.unwrap(); - assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND); -} - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -async fn harvest_cookie(client: &Client, base: &str) -> String { - // Reqwest's cookie store keeps the cookie, but doesn't expose values - // directly. Easiest: re-issue a request without the store and read the - // Set-Cookie that comes back. - let bare = reqwest::Client::new(); - let resp = bare - .get(format!("{base}/api/daemons")) - .send() - .await - .unwrap(); - let raw = resp - .headers() - .get(reqwest::header::SET_COOKIE) - .expect("set-cookie") - .to_str() - .unwrap(); - // raw looks like "maestro_session=; HttpOnly; ..." - let prefix = format!("{COOKIE_NAME}="); - let body = raw.strip_prefix(&prefix).expect("cookie prefix"); - let value = body.split(';').next().unwrap(); - let _ = client; // unused — kept in signature for symmetry - value.to_string() -} - -async fn recv_event(ws: &mut WsClient) -> RegistryEvent { - let frame = tokio::time::timeout(Duration::from_secs(2), ws.next()) - .await - .expect("event arrived") - .expect("ws open") - .expect("ws frame"); - match frame { - WsMessage::Text(t) => match serde_json::from_str::(&t) { - Ok(e) => e, - Err(e) => { - let parsed: Value = serde_json::from_str(&t).unwrap(); - panic!("could not parse event {parsed:?}: {e}"); - } - }, - other => panic!("expected text frame, got {other:?}"), - } -} diff --git a/crates/maestro-web/src/proxy.rs b/crates/maestro-web/src/proxy.rs index 26d43b1..f5e5133 100644 --- a/crates/maestro-web/src/proxy.rs +++ b/crates/maestro-web/src/proxy.rs @@ -412,7 +412,10 @@ mod tests { #[test] fn upstream_close_reason_falls_back_for_non_http_errors() { use tokio_tungstenite::tungstenite::Error as WsError; - let e = WsError::Io(std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "nope")); + let e = WsError::Io(std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + "nope", + )); let (code, reason) = upstream_close_reason(&e); assert_eq!(code, CLOSE_UPSTREAM_REJECTED); assert!(!reason.is_empty()); diff --git a/crates/maestro/Cargo.toml b/crates/maestro/Cargo.toml index c941dfa..e7d2e5c 100644 --- a/crates/maestro/Cargo.toml +++ b/crates/maestro/Cargo.toml @@ -5,17 +5,16 @@ edition.workspace = true license.workspace = true repository.workspace = true publish.workspace = true -description = "Unified `maestro` binary: CLI + per-host daemon + always-on registry, feature-gated." +description = "Unified `maestro` binary: CLI + per-host daemon + TUI, feature-gated." [[bin]] name = "maestro" path = "src/main.rs" [features] -default = ["cli", "daemon", "registry", "tui"] +default = ["cli", "daemon", "tui"] cli = ["dep:maestro-cli"] daemon = ["dep:maestro-daemon"] -registry = ["dep:maestro-registry"] tui = ["dep:maestro-tui"] [dependencies] @@ -24,5 +23,4 @@ tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] } tracing-subscriber.workspace = true maestro-cli = { path = "../maestro-cli", optional = true } maestro-daemon = { path = "../maestro-daemon", optional = true } -maestro-registry = { path = "../maestro-registry", optional = true } maestro-tui = { path = "../maestro-tui", optional = true }