From d006f4a7bdca74424c3e354c3f4b75a19b16115f Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 10:00:24 +0100 Subject: [PATCH] refactor(maestro): drop registry, p2p, and auth from daemon core Maestro is now single-host: one daemon owns the machine's sessions and clients talk to it directly. Removes the registry crate, the daemon's registry client/broker/catalog mirror, JWT attach auth, cookie auth, and the perimeter (T2) transport config. The daemon API is now unauthenticated on its bind address, same trust model as tmux. Session and CreateSessionRequest lose daemon_id; RegistryEvent becomes CatalogEvent with no daemon variants; attach moves to WS /api/attach/:id with no token. --- Cargo.lock | 42 - Cargo.toml | 1 - crates/maestro-config/src/lib.rs | 232 +-- crates/maestro-daemon/Cargo.toml | 9 +- crates/maestro-daemon/src/attach.rs | 100 +- crates/maestro-daemon/src/broker.rs | 822 ----------- crates/maestro-daemon/src/catalog_mirror.rs | 201 --- crates/maestro-daemon/src/cli.rs | 127 +- crates/maestro-daemon/src/config.rs | 331 +---- crates/maestro-daemon/src/configure.rs | 300 ---- crates/maestro-daemon/src/jwt.rs | 265 ---- crates/maestro-daemon/src/lib.rs | 19 +- crates/maestro-daemon/src/local_api.rs | 471 ++----- crates/maestro-daemon/src/local_events.rs | 61 +- crates/maestro-daemon/src/registry_client.rs | 773 ---------- crates/maestro-daemon/src/session.rs | 36 +- crates/maestro-daemon/tests/attach.rs | 106 +- crates/maestro-daemon/tests/common/mod.rs | 11 +- crates/maestro-daemon/tests/local_api.rs | 220 +-- .../maestro-daemon/tests/registry_client.rs | 214 --- .../maestro-daemon/tests/session_manager.rs | 101 +- crates/maestro-daemon/tests/sidecar.rs | 17 +- crates/maestro-e2e/Cargo.toml | 2 +- crates/maestro-e2e/src/registry.rs | 95 -- crates/maestro-protocol/src/lib.rs | 1245 +--------------- crates/maestro-registry/Cargo.toml | 35 - crates/maestro-registry/src/auth.rs | 172 --- crates/maestro-registry/src/client_api.rs | 251 ---- crates/maestro-registry/src/events.rs | 59 - crates/maestro-registry/src/handler.rs | 325 ----- crates/maestro-registry/src/jwt.rs | 102 -- crates/maestro-registry/src/lib.rs | 34 - crates/maestro-registry/src/run.rs | 51 - crates/maestro-registry/src/server.rs | 110 -- crates/maestro-registry/src/state.rs | 1249 ----------------- crates/maestro-registry/src/token.rs | 289 ---- crates/maestro-registry/tests/daemon_ws.rs | 391 ------ crates/maestro-registry/tests/http_api.rs | 728 ---------- crates/maestro-web/src/proxy.rs | 5 +- crates/maestro/Cargo.toml | 6 +- 40 files changed, 314 insertions(+), 9294 deletions(-) delete mode 100644 crates/maestro-daemon/src/broker.rs delete mode 100644 crates/maestro-daemon/src/catalog_mirror.rs delete mode 100644 crates/maestro-daemon/src/configure.rs delete mode 100644 crates/maestro-daemon/src/jwt.rs delete mode 100644 crates/maestro-daemon/src/registry_client.rs delete mode 100644 crates/maestro-daemon/tests/registry_client.rs delete mode 100644 crates/maestro-e2e/src/registry.rs delete mode 100644 crates/maestro-registry/Cargo.toml delete mode 100644 crates/maestro-registry/src/auth.rs delete mode 100644 crates/maestro-registry/src/client_api.rs delete mode 100644 crates/maestro-registry/src/events.rs delete mode 100644 crates/maestro-registry/src/handler.rs delete mode 100644 crates/maestro-registry/src/jwt.rs delete mode 100644 crates/maestro-registry/src/lib.rs delete mode 100644 crates/maestro-registry/src/run.rs delete mode 100644 crates/maestro-registry/src/server.rs delete mode 100644 crates/maestro-registry/src/state.rs delete mode 100644 crates/maestro-registry/src/token.rs delete mode 100644 crates/maestro-registry/tests/daemon_ws.rs delete mode 100644 crates/maestro-registry/tests/http_api.rs 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 }