diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index 4ef0499..3d91685 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -188,12 +188,14 @@ mod tests { 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: "vm1".into(), hostname: "vm1.lan".into(), version: "0.1.0".into(), daemon_url: "ws://10.0.0.2:7070".into(), + home_dir: String::new(), }, ]; let mut buf = Vec::new(); @@ -222,6 +224,7 @@ mod tests { hostname: "naps".into(), version: "0.1.0".into(), daemon_url: "ws://x".into(), + home_dir: String::new(), }]; let mut buf = Vec::new(); super::print_session_table(&mut buf, &[], &daemons); diff --git a/crates/maestro-daemon/src/catalog_mirror.rs b/crates/maestro-daemon/src/catalog_mirror.rs index 9cd360a..3abe001 100644 --- a/crates/maestro-daemon/src/catalog_mirror.rs +++ b/crates/maestro-daemon/src/catalog_mirror.rs @@ -131,6 +131,7 @@ mod tests { hostname: format!("{daemon_id}-host"), version: "0.1.0".into(), daemon_url: format!("ws://{daemon_id}:7070"), + home_dir: String::new(), } } diff --git a/crates/maestro-daemon/src/cli.rs b/crates/maestro-daemon/src/cli.rs index 08719c2..924f27c 100644 --- a/crates/maestro-daemon/src/cli.rs +++ b/crates/maestro-daemon/src/cli.rs @@ -127,6 +127,7 @@ async fn run(config_override: Option) -> Result<(), Box> { config.daemon_id.clone(), config.hostname.clone(), config.daemon_url.clone(), + config.home_dir.clone(), config.token.clone(), manager.clone(), Some(events.clone()), @@ -138,6 +139,7 @@ async fn run(config_override: Option) -> Result<(), Box> { manager.clone(), config.hostname.clone(), config.daemon_url.clone(), + config.home_dir.clone(), catalog.clone(), events, client.broker(), diff --git a/crates/maestro-daemon/src/config.rs b/crates/maestro-daemon/src/config.rs index 31406a1..9123242 100644 --- a/crates/maestro-daemon/src/config.rs +++ b/crates/maestro-daemon/src/config.rs @@ -72,6 +72,10 @@ pub enum ConfigError { pub struct DaemonConfig { pub daemon_id: String, 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, @@ -189,6 +193,9 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result Result, 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, /// Peer-only mirror of the global catalog. Empty until the daemon's /// registry client connects and receives the snapshot. pub catalog: CatalogMirror, @@ -87,6 +90,7 @@ async fn list_daemons(State(s): State) -> Json> { 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); @@ -206,6 +210,7 @@ pub fn state_for( manager: SessionManager, hostname: impl Into, daemon_url: impl Into, + home_dir: impl Into, catalog: CatalogMirror, events: LocalEventBus, broker: BrokerHandle, @@ -214,6 +219,7 @@ pub fn state_for( manager, hostname: Arc::new(hostname.into()), daemon_url: Arc::new(daemon_url.into()), + home_dir: Arc::new(home_dir.into()), catalog, events, broker, @@ -254,6 +260,7 @@ async fn pump_events( hostname: (*s.hostname).clone(), version: PROTOCOL_VERSION.into(), daemon_url: (*s.daemon_url).clone(), + home_dir: (*s.home_dir).clone(), }; let mut daemons = vec![self_info]; daemons.extend(s.catalog.daemons().await); diff --git a/crates/maestro-daemon/src/registry_client.rs b/crates/maestro-daemon/src/registry_client.rs index 88fa2a6..b4ef6a4 100644 --- a/crates/maestro-daemon/src/registry_client.rs +++ b/crates/maestro-daemon/src/registry_client.rs @@ -48,6 +48,7 @@ pub struct RegistryClient { 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 @@ -68,11 +69,13 @@ pub struct RegistryClient { } 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 { @@ -81,6 +84,7 @@ impl RegistryClient { daemon_id, hostname, daemon_url, + home_dir, token, manager, None, @@ -89,11 +93,13 @@ impl RegistryClient { /// 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, @@ -108,6 +114,7 @@ impl RegistryClient { daemon_id: daemon_id.into(), hostname: hostname.into(), daemon_url: daemon_url.into(), + home_dir: home_dir.into(), token: token.into(), perimeter: None, manager, @@ -171,6 +178,7 @@ impl RegistryClient { 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?; diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index 7637986..3edb910 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -117,6 +117,7 @@ async fn list_daemons_includes_self_and_mirrored_peers() { hostname: "konishi-host".into(), version: "0.1.0".into(), daemon_url: "ws://10.0.0.2:7070".into(), + home_dir: String::new(), }, }) .await; diff --git a/crates/maestro-daemon/tests/registry_client.rs b/crates/maestro-daemon/tests/registry_client.rs index 1b5f569..84e7392 100644 --- a/crates/maestro-daemon/tests/registry_client.rs +++ b/crates/maestro-daemon/tests/registry_client.rs @@ -58,6 +58,7 @@ async fn daemon_registers_and_streams_snapshot() { "laptop", "naps-laptop", "ws://127.0.0.1:8081", + "/home/naps62", TOKEN, fx.manager.clone(), ); @@ -115,6 +116,7 @@ async fn daemon_reconnects_after_registry_drop() { "laptop", "naps-laptop", "ws://127.0.0.1:8081", + "/home/naps62", TOKEN, fx.manager.clone(), ); @@ -156,6 +158,7 @@ async fn registry_create_session_round_trips() { "laptop", "naps-laptop", "ws://127.0.0.1:8081", + "/home/naps62", TOKEN, fx.manager.clone(), ); diff --git a/crates/maestro-e2e/tests/e2e.rs b/crates/maestro-e2e/tests/e2e.rs index 70abc92..7dcd21d 100644 --- a/crates/maestro-e2e/tests/e2e.rs +++ b/crates/maestro-e2e/tests/e2e.rs @@ -159,6 +159,15 @@ async fn daemon_local_api_mirrors_peer_catalog() { assert!(ids.contains(&"alpha")); assert!(ids.contains(&"beta")); + // NAP-328: home_dir is advertised end-to-end — for a peer relayed through + // the registry (beta, via Hello → register → catalog mirror), so the web + // spawn form can prefill the working directory. + let beta = list.iter().find(|d| d.daemon_id == "beta").unwrap(); + assert!( + !beta.home_dir.is_empty(), + "peer home_dir should propagate through the registry: {beta:?}" + ); + client.kill_session(b.id).await.expect("kill b"); } diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 106e135..9dcfd4d 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -100,6 +100,11 @@ pub enum DaemonOutbound { /// 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, @@ -234,6 +239,11 @@ pub struct DaemonInfo { 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`. @@ -528,6 +538,7 @@ mod tests { 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"); @@ -656,10 +667,25 @@ mod tests { 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 { @@ -676,6 +702,7 @@ mod tests { 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![ @@ -882,12 +909,14 @@ mod tests { 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()], @@ -929,6 +958,7 @@ mod tests { hostname: "x".into(), version: "y".into(), daemon_url: "z".into(), + home_dir: String::new(), } } .for_peer("laptop") @@ -966,6 +996,7 @@ mod tests { hostname: "peer-host".into(), version: "0.1.0".into(), daemon_url: "ws://peer:7070".into(), + home_dir: String::new(), }], sessions: vec![sample_session()], }, diff --git a/crates/maestro-registry/src/handler.rs b/crates/maestro-registry/src/handler.rs index ff1cf42..742e061 100644 --- a/crates/maestro-registry/src/handler.rs +++ b/crates/maestro-registry/src/handler.rs @@ -38,14 +38,15 @@ pub async fn handle_socket( None => return None, }; - let (daemon_id, hostname, version, token, daemon_url) = match hello { + let (daemon_id, hostname, version, token, daemon_url, home_dir) = match hello { DaemonOutbound::Hello { daemon_id, hostname, version, token, daemon_url, - } => (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; @@ -66,6 +67,7 @@ pub async fn handle_socket( hostname, version, daemon_url, + home_dir, out_tx.clone(), ) .await; diff --git a/crates/maestro-registry/src/state.rs b/crates/maestro-registry/src/state.rs index 3116e15..8ba3428 100644 --- a/crates/maestro-registry/src/state.rs +++ b/crates/maestro-registry/src/state.rs @@ -83,6 +83,7 @@ pub struct DaemonView { pub hostname: String, pub version: String, pub daemon_url: String, + pub home_dir: String, } impl From for DaemonInfo { @@ -92,6 +93,7 @@ impl From for DaemonInfo { hostname: v.hostname, version: v.version, daemon_url: v.daemon_url, + home_dir: v.home_dir, } } } @@ -120,6 +122,7 @@ 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 @@ -139,6 +142,7 @@ impl DaemonEntry { hostname: self.hostname.clone(), version: self.version.clone(), daemon_url: self.daemon_url.clone(), + home_dir: self.home_dir.clone(), } } } @@ -193,6 +197,7 @@ impl Registry { hostname: String, version: String, daemon_url: String, + home_dir: String, outbound: mpsc::Sender, ) -> Uuid { let connection_token = Uuid::new_v4(); @@ -204,6 +209,7 @@ impl Registry { hostname: hostname.clone(), version: version.clone(), daemon_url: daemon_url.clone(), + home_dir: home_dir.clone(), outbound, perimeter: None, connection_token, @@ -222,6 +228,7 @@ impl Registry { hostname, version, daemon_url, + home_dir, }, }); connection_token @@ -874,7 +881,10 @@ mod tests { // The home daemon gets the relayed result. match home_rx.recv().await.expect("home got ForwardCreateResult") { - DaemonInbound::ForwardCreateResult { reply_to: got, result } => { + DaemonInbound::ForwardCreateResult { + reply_to: got, + result, + } => { assert_eq!(got, reply_to); match result { RpcResult::Ok { value } => { @@ -924,7 +934,10 @@ mod tests { 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 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 } => { diff --git a/crates/maestro-registry/tests/daemon_ws.rs b/crates/maestro-registry/tests/daemon_ws.rs index f2e35fe..70e48a4 100644 --- a/crates/maestro-registry/tests/daemon_ws.rs +++ b/crates/maestro-registry/tests/daemon_ws.rs @@ -89,6 +89,7 @@ fn hello(token: &str) -> DaemonOutbound { version: "0.1.0".into(), token: token.into(), daemon_url: "ws://127.0.0.1:8081".into(), + home_dir: String::new(), } } diff --git a/crates/maestro-registry/tests/http_api.rs b/crates/maestro-registry/tests/http_api.rs index b231969..87f9df7 100644 --- a/crates/maestro-registry/tests/http_api.rs +++ b/crates/maestro-registry/tests/http_api.rs @@ -118,6 +118,7 @@ impl MockDaemon { 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 diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 323eefe..b76038e 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -514,6 +514,7 @@ mod tests { hostname: "konishi.lan".into(), version: "test".into(), daemon_url: "ws://k:7070".into(), + home_dir: String::new(), }; app.catalog.apply_event(RegistryEvent::Snapshot { daemons: vec![d.clone()], @@ -537,6 +538,7 @@ mod tests { hostname: "k".into(), version: "t".into(), daemon_url: "ws://k".into(), + home_dir: String::new(), }], sessions: vec![Session { id: sid, diff --git a/crates/maestro-tui/src/catalog.rs b/crates/maestro-tui/src/catalog.rs index c8a17d1..55f0a77 100644 --- a/crates/maestro-tui/src/catalog.rs +++ b/crates/maestro-tui/src/catalog.rs @@ -205,6 +205,7 @@ mod tests { hostname: format!("{id}.lan"), version: "test".into(), daemon_url: format!("ws://{id}.lan:7070"), + home_dir: String::new(), } } diff --git a/crates/maestro-tui/src/overlays.rs b/crates/maestro-tui/src/overlays.rs index a12a843..40aeec2 100644 --- a/crates/maestro-tui/src/overlays.rs +++ b/crates/maestro-tui/src/overlays.rs @@ -312,6 +312,7 @@ mod tests { hostname: "konishi".into(), version: "0".into(), daemon_url: "ws://x".into(), + home_dir: String::new(), }], daemon_idx: 0, cwd: "/x".into(), diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index f0bcf09..b1117f5 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -93,6 +93,7 @@ mod tests { hostname: id.into(), version: "test".into(), daemon_url: format!("ws://{id}:7070"), + home_dir: String::new(), } } diff --git a/crates/maestro-tui/tests/catalog_ws.rs b/crates/maestro-tui/tests/catalog_ws.rs index 7805f53..8c5e27b 100644 --- a/crates/maestro-tui/tests/catalog_ws.rs +++ b/crates/maestro-tui/tests/catalog_ws.rs @@ -19,6 +19,7 @@ fn daemon(id: &str) -> DaemonInfo { hostname: format!("{id}.lan"), version: "test".into(), daemon_url: format!("ws://{id}.lan:7070"), + home_dir: String::new(), } } diff --git a/web/src/spawn.ts b/web/src/spawn.ts index 1494db4..030aa7a 100644 --- a/web/src/spawn.ts +++ b/web/src/spawn.ts @@ -25,7 +25,7 @@ export function openSpawnModal(daemons: Map): void {