feat(maestro): wire daemon to registry + per-session WS stream

Completes the headless daemon stack for NAP-214.

- `config`: daemon TOML config (daemon_id, registry_url, bind_addr,
  token, state_dir, worktree_root) with sensible XDG defaults.
- `jwt`: HS256 verifier; the daemon validates per-attach JWTs locally
  with no callback to the registry. Also exposes `issue()` for tests.
- `pty::PtyBridge`: per-session FIFO + tokio broadcast pump. The FIFO is
  opened r+w so it never sees zero writers — stays alive across
  `pipe-pane` re-issues. Helper `pipe_pane_command()` builds the
  `cat >> <fifo>` shell line.
- `session::SessionManager`: authoritative live-session store. Creates
  worktrees (when requested), spawns tmux, mkfifo + pipe-pane, writes
  sidecar; tears it all down on kill. Emits diff events on a
  broadcast for the registry client. `recover()` adopts surviving
  `maestro-*` tmux sessions on startup and re-pipes them.
- `registry_client::RegistryClient`: persistent outbound WS to the
  registry with exponential backoff. Sends Hello → snapshot, drives a
  select loop that pumps inbound CreateSession/KillSession RPCs into
  the manager (replying with results), emits SessionAdded/Removed/
  Updated diffs from the manager, and heartbeats every 10s.
- `attach`: axum router mounting `WS /sessions/:id/stream?token=<jwt>`.
  Binary frames carry PTY bytes either way (subscribe to broadcast →
  send to client; receive from client → tmux send-keys -l). Text
  frames carry `ClientControl::{Resize,Ping,Pong}`.
- `bin/maestrod`: ties everything together, loads config from
  `MAESTROD_CONFIG` env or `~/.config/maestrod/config.toml`,
  recovers, spawns the registry client, binds the WS server.

Also adds `JwtClaims` to `maestro-protocol` so the daemon and the
future registry JWT issuer (NAP-215) agree on the wire shape.

Integration tests cover session manager round-trip + recovery + PTY
broadcast; attach endpoint JWT (bad/wrong-session/wrong-daemon),
PTY round-trip through real tmux, multi-attach broadcast, ping/pong;
registry client registration + snapshot + diff forwarding +
CreateSession RPC round-trip.

Closes NAP-214.
This commit is contained in:
2026-05-24 15:22:36 +00:00
parent c721b5ec62
commit ab9a5bf017
16 changed files with 2041 additions and 20 deletions
+37
View File
@@ -150,6 +150,24 @@ 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.
#[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<i64>,
}
#[cfg(test)]
mod tests {
use super::*;
@@ -353,4 +371,23 @@ mod tests {
let err = serde_json::from_value::<DaemonOutbound>(bad).unwrap_err();
assert!(err.to_string().contains("Bogus"), "{}", err);
}
#[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());
}
}