Commit Graph
206 Commits
Author SHA1 Message Date
naps62-yoloandClaude Opus 4.8 b6833ede43 refactor(maestro): drop allow_inbound policy gate, keep JWT session binding (NAP-339)
The peer (T2) attach path had a per-pair access-control layer
(`[daemon.policy].allow_inbound`): a target-side allow-list of daemon ids
permitted to broker-attach, enforced inside JWT verification, hot-reloadable via
SIGHUP / `POST /api/policy/reload`, with a `maestro policy` CLI. On a flat-trust
perimeter (the operator's VPN/firewall is the boundary for which hosts connect)
that granularity is unused, and deny-by-default made it config friction —
cross-daemon attach silently failed until every peer was listed.

Remove the allow-list; keep the registry-minted pair-attach JWT and ALL its
binding checks (iss=registry, aud=target, sid=session, exp). The firewall gates
host->port reachability; it does not stop a reachable host from attaching to a
session it wasn't handed or replaying a token elsewhere — that's the JWT's job
and it stays. Only the `sub in allow_inbound` check is dropped.

Removed: DaemonPolicySection / [daemon.policy].allow_inbound (config + daemon),
policy.rs (SIGHUP listener + GET/POST /api/policy), the `maestro policy` CLI,
PairAttachVerifier's allow-list field/reload/snapshot + JwtError::NotAllowed,
and the e2e broker_rejects_when_target_doesnt_allow_inbound test. The NAP-265
pre-upgrade-502 path stays covered by broker_attach_returns_502_on_perimeter_mismatch.

Also dropped the unit's ExecReload=kill -HUP: with no SIGHUP handler left,
default SIGHUP disposition is terminate, so wiring `systemctl reload` to it would
kill the daemon. Re-introducing a per-pair gate later is a verify-time change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 07:07:55 +00:00
naps62-yolo d1f32f6580 Merge branch 'updates'
ci / rust (push) Successful in 3m50s
2026-06-18 15:04:28 +00:00
naps62-yoloandClaude Opus 4.8 02475191df fix(maestro): registry writes each session change once; drop dual-write (NAP-268)
A registry-driven CreateSession/KillSession was reported to the registry twice:
the RPC reply (applied eagerly in create_session/kill_session) AND the async
SessionAdded/SessionRemoved the daemon emits on its own SessionManager stream.
Both landed, so the registry's catalog write had to be idempotent to absorb the
duplicate — masking bugs where the async event never arrives and setting a trap
for any future non-idempotent event (counters, partial updates).

Drop the duplicate at the source: the daemon parks the session id of an
RPC-initiated create/kill in a per-connection RpcCovered set and swallows the
matching async event (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, since the registry has
no other way to learn about them. A lag resync clears the set (the snapshot is
full truth). The registry's eager apply is now the authoritative single write,
not a race-bandaid; idempotency is no longer a correctness invariant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:04:24 +00:00
naps62-yolo 8e0ce7423e Merge remote-tracking branch 'origin/main' into linear/nap-273-post-nap-245-cleanup-consolidate-duplicated-helpers-across
ci / rust (push) Successful in 4m26s
2026-06-18 14:35:56 +00:00
naps62-yoloandClaude Opus 4.8 75d162dea3 refactor(maestro): share cookie name + extraction across CLI and registry (NAP-273 F)
ci / rust (push) Successful in 6m45s
Promote `COOKIE_NAME` and a robust `extract_cookie` (iterates every `;`-pair,
so it works on both `Cookie:` and `Set-Cookie:` headers regardless of position)
into `maestro-protocol`. The registry re-exports them; the CLI drops its weaker
`extract_cookie_value` (which only inspected the first pair) and its duplicate
`COOKIE_NAME`. Both ends of the wire now agree on one definition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:35:30 +00:00
naps62-yoloandClaude Opus 4.8 71a45db0a4 refactor(maestro): single-source broadcast capacities + extract cleanup_record (NAP-273 D/E)
D: the four independent `= 256` broadcast-channel constants (session events,
local event bus, PTY fan-out, registry events) now derive from one shared
`maestro_config::BroadcastTuning::DEFAULT`, so the coincidence is explicit and
there's a home for per-deployment tuning later.

E: `kill()` and `reap_dead_session()` hand-rolled the same FIFO/sidecar/
worktree/broadcast teardown, differing only in whether tmux still had a session
to kill. Extracted into `cleanup_record(inner, id, record, kill_tmux)`. kill's
sidecar-delete is now best-effort (the session is already out of the
authoritative map), matching the reaper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:35:30 +00:00
naps62-yoloandClaude Opus 4.8 881113d121 refactor(maestro): consolidate bind_addr + state_dir config helpers (NAP-273 B/C)
Add `parse_bind_addr`, `default_state_dir(subdir)`, and an `InvalidBindAddr`
variant to `maestro-config`, and route the daemon, registry, and web config
loaders through them. Each crate's hand-rolled `InvalidBindAddr` variant and
duplicated XDG state-dir resolution are gone; their loaders now `?` the shared
helper, which converts into their existing transparent `Inner` variant.

Also adds the shared `BroadcastTuning` struct (used by the next commit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:35:30 +00:00
naps62-yoloandClaude Opus 4.8 9d7f5456ef docs(maestro): design spike for in-app update notifications + opt-in auto-update (NAP-331)
ci / rust (push) Successful in 2m51s
Answers the six NAP-331 questions and proposes an 8-ticket impl breakdown.
Grounds decisions in existing infra: DaemonInfo.version is already fanned out
by the registry, [autoupdate] fits the sectioned config, and NAP-330's
KillMode=process already gives restart-without-session-loss.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:27:14 +00:00
naps62-yoloandClaude Opus 4.8 4c49abfdf2 feat(maestro): web UI persistent sidebar + content split w/ background connection pool (NAP-338)
ci / rust (push) Successful in 5m24s
Replace the full-screen route-swap SPA with a persistent two-pane layout:

- Global top bar (brand + Spawn + theme) above a sidebar (search + status +
  session tree, highlights the active session) and a content pane (welcome or
  the attached terminal). Selecting a row swaps only the content pane; the
  sidebar and its store subscription persist.
- New `connections.ts` ConnectionPool: attach connections (terminal + WS) are
  decoupled from the UI — navigating away parks a session (keeps it streaming)
  rather than tearing it down, so switching back is instant with scrollback
  intact. LRU-bounded (MAX_LIVE=6); explicit `⏻` disconnect distinct from nav.
- `attach.ts` → mountable `createAttachConnection`; `catalog.ts` exposes
  `renderTopbar` + `renderSidebar`; `main.ts` is the shell + hash router.
- Responsive: desktop side-by-side; mobile single-pane (list ↔ terminal).
  Single session viewed at a time (split panes deferred).

Typecheck + production build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 12:49:16 +00:00
naps62-yoloandClaude Opus 4.8 4247b5706e fix(maestro): installer unit sets KillMode=process so restarts keep sessions (NAP-330)
ci / rust (push) Successful in 5m47s
The generated `maestro.service` used systemd's default
`KillMode=control-group`, so `systemctl restart` (any update/auto-update)
SIGKILLed the daemon's whole cgroup — including the tmux server and every
`maestro-<uuid>` session. recover() then found nothing, silently destroying
all live sessions and their processes on every restart.

Emit `KillMode=process` in the daemon unit so only the daemon's MainPID is
signalled; the tmux server survives and recover() re-adopts the sessions on
the next start. (The web unit spawns no tmux, so it's left unchanged.)

Tests assert the daemon unit carries KillMode=process and the web unit does
not. INSTALL.md notes that updating keeps sessions, and that units written
before this default need a refresh.

Verified live on a host: with KillMode=process a restart leaves the session's
PID unchanged (recovered=1); without it, recovered=0 and the process is gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:19:12 +00:00
naps62-yolo ba73a713db chore(maestro): gitignore local worktrees dir (accidentally staged)
ci / rust (push) Successful in 2m54s
2026-06-16 06:42:03 +00:00
naps62-yolo 473aa948a8 feat(maestro): exclude hidden dirs from spawn-form autocomplete (NAP-329)
ci / rust (push) Successful in 5m6s
2026-06-16 06:41:39 +00:00
naps62-yolo 977800951d test(maestro): skip sidecar-rollback test under root (pre-existing CI red)
ci / rust (push) Successful in 2m37s
CI runs as root, where the 0o500 read-only sidecar dir doesn't block the
write the test relies on failing — so it reported a spurious failure and
has reddened main since commit 149. Probe writability and skip when root;
non-root runs still assert the rollback.
2026-06-15 21:28:47 +00:00
naps62-yolo 3aedd7a0a5 feat(maestro): live directory autocomplete in spawn form (NAP-329)
ci / rust (push) Failing after 5m3s
Adds api.listDir + a reusable dirAutocomplete widget (live dropdown,
Tab/Enter completion, /-drill-in, per-(daemon,dir) cache) wired onto the
cwd and worktree base-repo fields, with matching dropdown styling.
2026-06-15 21:20:36 +00:00
naps62-yolo 498cb471b7 feat(maestro): daemon+registry ListDir RPC + broker relay + HTTP dirs endpoint (NAP-329)
Also fixes two pre-existing test call sites that NAP-328 left passing the
old arity to register()/state_for() after adding home_dir.
2026-06-15 21:18:09 +00:00
naps62-yolo 1f20d70a2e feat(maestro): add ListDir/ForwardListDir wire protocol + DirListing (NAP-329) 2026-06-15 21:11:43 +00:00
naps62-yolo 8842147164 docs(maestro): design for spawn-form dir autocomplete + home prefill (NAP-329) 2026-06-15 21:10:57 +00:00
naps62-yolo 23594a5f00 Merge pull request 'feat(maestro): redesign web UI — token system, dark/light, a11y status' (#4) from feat/web-redesign into main
ci / rust (push) Failing after 3m27s
2026-06-15 19:42:07 +01:00
naps62-yoloandClaude Opus 4.8 815ad695df feat(maestro): redesign web UI — token system, dark/light, a11y status
ci / rust (pull_request) Failing after 3m32s
Rewrite styles.css as an OKLCH design-system: cool-slate neutrals with a
single committed signal-green for "alive" (running status, primary action,
focus, selection). Monospace becomes the UI typeface; dark is default with a
full light theme via tokens, OS-following with a persisted header toggle.

- catalog: pulsing wordmark, status-bar tally (N sessions / M live) + daemon
  chips, color-independent session status (pulsing dot for running, hollow
  ring + "exited" badge for dead), reveal-on-hover kill, three teaching
  empty states.
- attach: state pill (live/closed/error), framed dark xterm w/ green cursor.
- spawn: bottom-sheet/dialog form, mono inputs with green focus rings.
- a11y: AA contrast both themes, visible focus, prefers-reduced-motion paths,
  status never color-only.
- theme.ts: dark/light toggle + persistence; mock.ts: DEV-only review fixture
  (?mock / ?mock&spawn / ?theme=), tree-shaken from production builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:40:42 +00:00
naps62-yoloandClaude Opus 4.8 e909790b56 docs(maestro): add PRODUCT.md + DESIGN.md design context
Capture the web UI's strategic register (product) and the visual design
system (OKLCH token palette, monospace-first type, dark+light theming) for
the impeccable workflow. Supersedes the transient .impeccable.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:40:42 +00:00
naps62-yoloandClaude Opus 4.8 c2f32779a3 style(maestro): cargo fmt — clear pre-existing drift blocking CI fmt check (NAP-328)
ci / rust (push) Failing after 1m58s
`cargo fmt --all -- --check` was already failing on `main` (CI run 149) due to
unformatted code in broker.rs and http.rs; e2e.rs carried the same drift. This
applies rustfmt to those three so the branch passes the fmt gate. No behavior
change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:31:42 +00:00
naps62-yoloandClaude Opus 4.8 6d7cd0b4c8 feat(maestro): spawn form defaults command to claude + prefills cwd to daemon home dir (NAP-328)
Two web spawn QoL improvements plus the wire support they need:

- Command field now prefills `claude` instead of an empty box.
- Working directory prefills the selected daemon's home dir, updating on
  daemon change unless the user has typed their own value.

The daemon resolves its `$HOME` via `dirs::home_dir()` at startup and
advertises it end-to-end: a new `home_dir` field on `DaemonOutbound::Hello`
flows through the registry's `register` into `DaemonInfo` (served on
`/api/daemons` and `/api/events`). The field is `#[serde(default)]`, so older
daemons that omit it deserialize to an empty string — backward compatible, and
the UI simply skips prefill when it's empty.

Hierarchical `/`-separated labels in the web sidebar already worked
(web/src/catalog.ts); no change there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:07:53 +00:00
naps62-yoloandClaude Opus 4.8 906538c776 docs(maestro): refresh local_api/broker module docs for NAP-327
ci / rust (push) Failing after 57s
The 501 path is gone; update the stale "rejects cross-daemon create" / "broker
is attach-only" module docs to describe the brokered create/kill RPCs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:30:40 +00:00
naps62-yoloandClaude Opus 4.8 1cdb126977 feat(maestro): drop CLI registry fallback for cross-daemon create/kill + e2e (NAP-327)
The home daemon now brokers cross-daemon create/kill itself, so the CLI only
falls back to the legacy registry HTTP path when the home daemon is
*unreachable* — not on 501/404. Removes the now-dead `is_remote_op_unsupported`
/ `is_not_found` / `has_status` predicates.

Adds an e2e (real tmux, two daemons + registry): POST to alpha with
daemon_id=beta brokers the create to beta and returns beta's session; a kill
via alpha reaches beta. Both verified against the registry catalog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:27:31 +00:00
naps62-yoloandClaude Opus 4.8 1e4b65c73c feat(maestro): daemon brokers cross-daemon create/kill instead of 501/404 (NAP-327)
The home daemon's `POST /api/sessions` (peer `daemon_id`) and
`DELETE /api/sessions/:id` (peer-owned) now broker the op to the owning daemon
over the registry control link rather than returning 501/404. This fixes
cross-daemon spawn/kill for *all* local clients — notably maestro-web, which
talks only to the local daemon and had no registry fallback.

- broker: `BrokerHandle::{forward_create,forward_kill}` + completion handlers,
  mirroring `resolve_attach` (fail-fast while disconnected, RAII pending
  cleanup, RESOLVE_TIMEOUT).
- registry_client: route `Forward{Create,Kill}Result` back to the broker.
- local_api: create forwards on peer `daemon_id`; kill looks up the owner in
  the catalog mirror and forwards (only a truly-unknown session is 404).
  `broker_error_response` maps BrokerError → HTTP (503/504/502/404/...).
- cli: thread the broker into `LocalApiState`.

Tests: broker round-trip / fail-fast / registry-err propagation; updated the
local_api integration test (peer create now 503s without a registry link
instead of 501, proving it brokers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:24:48 +00:00
naps62-yoloandClaude Opus 4.8 daeacc750f feat(maestro): registry relays Forward{Create,Kill} RPC for cross-daemon ops (NAP-327)
Adds the home-daemon → registry control-link RPC that brokers a cross-daemon
session create/kill without putting any session bytes on the registry: the
owning daemon still does the tmux spawn, the registry just relays request and
reply.

- protocol: `DaemonOutbound::{ForwardCreate,ForwardKill}` +
  `DaemonInbound::{ForwardCreateResult,ForwardKillResult}` (+ round-trip tests).
- registry: `Registry::{forward_create,forward_kill}` reuse the existing
  `create_session`/`kill_session` R→D machinery and route the reply back to the
  home daemon; transport faults map to stable `ErrorInfo` codes. Wired into the
  `WS /daemon` dispatch.

Next: daemon-side broker handle + local_api wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:21:11 +00:00
naps62-yoloandClaude Opus 4.8 f11c75a4fd fix(maestro): build registry image from the unified maestro binary (NAP-245 fallout)
ci / rust (push) Failing after 3m1s
The Dockerfile still built `-p maestro-registry` and copied
`target/release/maestro-registry`, but NAP-245 retired the per-binary crates —
`maestro-registry` is now a library, so the build produced no such binary and
the `cp` failed, breaking every Dokploy deploy.

Build the umbrella `maestro` binary with only the `registry` feature
(`-p maestro --no-default-features --features registry`), copy
`target/release/maestro`, and run its `registry` subcommand via ENTRYPOINT.
Also drops the now-vestigial web-builder stage (the registry no longer embeds
the web bundle — that moved to the separate maestro-web binary).

Validated locally: docker build succeeds and the image boots, binds
0.0.0.0:8080, and serves HTTP 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 14:05:44 +00:00
naps62-yoloandClaude Opus 4.8 4a008684f6 refactor(maestro): dedup CLI JWT percent-encoder onto urlencoding crate (NAP-273 part A)
ci / rust (push) Failing after 5m42s
`maestro-cli` carried a hand-rolled `urlencoding()` for the attach-token query
param while `maestro-daemon` already used the `urlencoding` crate for the same
wire field. Two encoders for one field is exactly the drift that only surfaces
in production — collapse to the crate (`urlencoding = "2"`), deleting the local
copy. The unreserved set is identical, so the encoded output is unchanged
(existing `attach_url` tests pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 01:27:31 +00:00
naps62-yoloandClaude Opus 4.8 5ea8d0fa59 perf(maestro): run tmux subprocess calls off the async runtime (NAP-256)
ci / rust (push) Failing after 6m50s
Every `SessionManager` tmux call shelled out via blocking
`std::process::Command::output()` on the tokio worker — including `send_keys`
on the per-keystroke hot path. Under concurrent typing the small worker pool
stalls on `tmux` fork+waits (tens of ms on a loaded host), starving HTTP
handlers, the registry WS, the liveness watcher, and PTY readers.

Wrap each call site (create/kill/recover/liveness/send_keys/resize/pipe_pane)
in `tokio::task::spawn_blocking` via a small `tmux_blocking` helper, so the
blocking fork+wait runs on the blocking pool and never parks an async worker.
The `Tmux` API stays synchronous (lower blast radius than an async migration;
its tests are unchanged). Full daemon suite + send_keys/broker e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 01:01:18 +00:00
naps62-yoloandClaude Opus 4.8 eb6aeb4241 refactor(maestro): typed perimeter advertisement replaces Option pair (NAP-272)
ci / rust (push) Failing after 5m58s
`(perimeter_id, private_addr)` was carried as two `Option<String>`s with an
unwritten "both Some or both None" invariant, enforced ad-hoc at every reader
and silently violable (`with_perimeter(Some, None)`).

Collapse it to `Option<PerimeterCandidate>` (reusing the existing protocol
type, so it flows unconverted from Heartbeat → registry → AttachCandidates):

- protocol: `Heartbeat { perimeter: Option<PerimeterCandidate> }` (empty still
  serializes to the legacy `{"type":"Heartbeat"}` shape).
- registry: `DaemonEntry.perimeter`, `update_perimeter`, `perimeter_for` lose
  the defensive AND-check; `build_attach_candidates` uses it directly.
- daemon: `RegistryClient.with_perimeter(Option<PerimeterCandidate>)`; the
  both-or-neither rule now lives once, in `DaemonConfig::perimeter()`, which
  warns and drops a half-configured pair instead of advertising it.

Unblocked by NAP-270's coverage; broker T2 + perimeter-mismatch e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 00:32:31 +00:00
naps62-yoloandClaude Opus 4.8 f3f696790c test(maestro): cover perimeter-mismatch boundary + broker error paths (NAP-270)
ci / rust (push) Failing after 5m25s
The asymmetric-VLAN security boundary and the broker's RPC error branches had
no tests, so a regression that leaked a JWT across perimeters or dialed across
them would ship undetected. Adds the required coverage:

registry (state.rs unit):
- perimeter_for_requires_both_id_and_addr
- resolve_attach_refuses_self_target
- resolve_attach_returns_unknown_session_for_orphan_id

daemon broker (broker.rs unit):
- broker_resolve_attach_propagates_registry_err
- broker_resolve_attach_times_out_when_no_reply (paused-clock, instant)

daemon e2e:
- broker_attach_returns_502_on_perimeter_mismatch (two daemons, different
  perimeters → structured 502, never dials across)

Enables tokio `test-util` in maestro-daemon dev-deps for the paused-clock test.
Unblocks NAP-272.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 00:04:16 +00:00
naps62-yoloandClaude Opus 4.8 ae588b76d5 fix(maestro): broker dials peer before WS upgrade so failures surface (NAP-265)
ci / rust (push) Failing after 5m59s
`broker_attach_ws` upgraded the client WS first and only then dialed the peer
inside `pump_broker_ws`; a dial failure (peer down, perimeter rejection, peer
verifier refusing the pair JWT — all reported pre-upgrade) just logged and
closed the socket, indistinguishable from a clean session end.

Move the `connect_async` ahead of `on_upgrade`: on failure return a structured
`502 peer_unreachable` (matching the other pre-upgrade error paths); on success
hand the connected upstream to `pump_broker_ws`.

Updated the allow_inbound-reject e2e to assert the handshake now fails with 502
instead of an opaque post-upgrade close.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:35:03 +00:00
naps62-yoloandClaude Opus 4.8 ae3775946d fix(maestro): broker fails fast when the registry link is down (NAP-263)
ci / rust (push) Failing after 6m5s
`BrokerHandle::resolve_attach` queued ResolveAttach frames with
`out_tx.send().await`. While the registry was disconnected nothing drained the
bounded channel, so after 64 queued frames every caller blocked on the send —
HTTP `/api/attach/:id` requests for peer sessions hung instead of erroring.

Track link state with an `AtomicBool` the registry-client flips on connect /
disconnect: `resolve_attach` returns `NoControlLink` immediately while down, and
uses a non-blocking `try_send` so a back-pressured-but-connected channel can't
block either.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 23:10:43 +00:00
naps62-yoloandClaude Opus 4.8 939d67615a fix(maestro): bind registry JWTs to issuer/audience to stop token confusion (NAP-266)
ci / rust (push) Failing after 6m0s
Cookie and pair-attach JWTs are signed with the same shared secret. Neither
verifier bound the token's purpose, so a token minted for one could in principle
validate as the other.

- Add a canonical `REGISTRY_ISSUER` to maestro-protocol; registry re-exports it.
- Auth cookies now carry `iss = maestro-registry` + a dedicated `aud =
  maestro-cookie`; `verify_cookie` binds both, so a pair-attach JWT (daemon-id
  audience) is rejected.
- `PairAttachVerifier::verify` now binds `iss = REGISTRY_ISSUER`, rejecting any
  token not minted by the registry.

Tests: pair-verifier rejects a foreign issuer; verify_cookie rejects a
pair-attach JWT. Existing cookie + broker e2e flows unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:44:25 +00:00
naps62-yoloandClaude Opus 4.8 2f4dde85ac fix(maestro): roll back partial SessionManager::create on failure (NAP-260)
ci / rust (push) Failing after 5m47s
create() ran worktree-add → tmux spawn → sidecar write with no unwinding. A
failure in a later step leaked earlier ones — worst case a sidecar write
failure (disk full / EIO / permission) left a ghost `maestro-<uuid>` tmux
session running with no in-memory record, silently adopted with empty metadata
on the next daemon restart.

Each step now unwinds the ones before it: bridge/tmux failures remove the
worktree (+ FIFO), and a sidecar-write failure kills the just-spawned tmux
session before removing the FIFO + worktree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:15:02 +00:00
naps62-yolo 3167364505 Merge branch 'linear/nap-257-web-proxy-body-caps'
ci / rust (push) Successful in 5m54s
2026-05-31 21:12:23 +00:00
naps62-yoloandClaude Opus 4.8 b0774934e8 fix(maestro): bound web proxy request bodies + stream responses (NAP-257)
ci / rust (push) Successful in 6m56s
`proxy_http` buffered request bodies with `to_bytes(.., usize::MAX)` and
materialized full upstream responses with `.bytes()`. Anyone reachable on the
bind address could POST a multi-GB body (the cookie middleware admits any
signed cookie) and OOM the process; a large upstream reply did the same.

Cap buffered request bodies at 8 MiB and return 413 on overflow; stream the
upstream response straight through via `bytes_stream()` → `Body::from_stream`
so it's never fully held in RAM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:12:17 +00:00
naps62-yoloandClaude Opus 4.8 287afa26ea fix(maestro): remove pending-RPC entries on caller cancellation (NAP-259)
ci / rust (push) Successful in 7m22s
`BrokerHandle::resolve_attach` (daemon) and `Registry::create_session` /
`kill_session` (registry) insert a pending oneshot into a map, then await
the reply. If the awaiting future is cancelled — e.g. an axum handler
dropped because the browser closed the tab or aborted the XHR — the entry
was never removed, leaking one map slot per cancelled request and growing
unboundedly under UI-driven retry traffic.

Introduce a small RAII `PendingGuard` that removes the entry on drop, so
cleanup happens on every exit path including cancellation. Because a sync
`Drop` can't lock a tokio mutex, move the pending maps to
`std::sync::Mutex` (only ever locked for a tiny insert/remove, never held
across an await): broker `pending`, and the registry's `pending_create` /
`pending_kill` lifted out of the async-locked `State`. Removal is
idempotent, so the happy path (reply already taken by `complete_*`) and
the guard's drop don't conflict; `await_rpc` no longer needs its manual
timeout cleanup.

Tested: cancelling an in-flight `resolve_attach` (broker) and
`kill_session` (registry) leaves the pending map empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:10:10 +00:00
naps62-yoloandClaude Opus 4.8 599f3ed178 fix(maestro): fence registry deregister with a per-connection token (NAP-258)
ci / rust (push) Successful in 7m41s
`Registry::deregister` removed the catalog entry unconditionally. If a daemon's
old WS handler hit heartbeat timeout *after* the daemon had reconnected on a
new socket, the stale handler's deregister wiped the freshly-installed live
entry and broadcast DaemonRemoved + SessionRemoved for all its sessions.

`register` now mints a per-connection fencing token (uuid) stored on the
DaemonEntry and returned to the handler, which holds it for the connection's
life. `deregister(daemon_id, token)` only tears down the entry when the stored
token matches, so a superseded handler's late deregister is a no-op.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:02:54 +00:00
naps62-yoloandClaude Opus 4.8 3517e4e145 fix(maestro): recover() re-advertises sessions under current daemon_id (NAP-264)
ci / rust (push) Successful in 6m46s
`SessionManager::recover()` re-inserted each sidecar's `Session` verbatim,
keeping the persisted `daemon_id`. If the operator renamed the daemon between
starts, recovered sessions were re-advertised under the old id and became
unroutable. Overwrite `daemon_id` with the daemon's current id on recover,
warning when the sidecar's id differs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:51:18 +00:00
naps62-yolo 421e36c59f Merge branch 'linear/nap-269-registry-ws-drops-on-any-unknown-daemonoutbound-variant'
ci / rust (push) Successful in 3m58s
2026-05-31 19:30:44 +00:00
naps62-yoloandClaude Opus 4.8 8b4cdfd650 fix(maestro): tolerate unknown WS frame types instead of dropping the connection (NAP-269)
ci / rust (push) Successful in 14m9s
`dispatch_text` (registry) and `handle_inbound` (daemon) treated any
frame that didn't deserialize into a known `DaemonOutbound`/`DaemonInbound`
variant as fatal: the registry returned `false` (breaking the handler
loop and deregistering the daemon, wiping its catalog), and the daemon
propagated the serde error out of the read loop. A newer peer emitting a
not-yet-merged variant would therefore get stuck in a tight
disconnect/reconnect loop.

- Add a `#[serde(other)] Unknown` catch-all to both enums so unknown
  `type` tags decode instead of erroring.
- Log-and-skip the `Unknown` variant on both sides.
- Also log-and-skip genuinely malformed frames rather than tearing down
  the connection, so one bad frame can't wipe a daemon's catalog.

Tested: unknown/malformed frames keep the connection alive (registry
handler), and unknown `type` decodes to `Unknown` (protocol round-trip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:30:36 +00:00
naps62-yolo 4a20bc168f Merge remote-tracking branch 'origin/main' into linear/nap-244-cli-rewire-maestro-attachlistspawnkill-through-home-daemon
ci / rust (push) Successful in 14m12s
2026-05-31 19:27:23 +00:00
naps62-yoloandClaude Opus 4.8 643da30f63 test(maestro): e2e for CLI broker path + registry compat fallback (NAP-244)
ci / rust (push) Successful in 10m42s
Drives the real `maestro` binary: one test points `MAESTRO_HOME_DAEMON_URL` at
a live daemon with no registry configured (proves the catalog read comes from
the home daemon), the other points it at a dead port and asserts the CLI warns
and falls back to the registry while still listing the session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:25:28 +00:00
naps62-yoloandClaude Opus 4.8 0df1d61f07 feat(maestro): rewire CLI through home-daemon broker with registry fallback (NAP-244)
`maestro list/spawn/kill/attach` now target the home daemon's local API
(`[cli].home_daemon_url`, default http://127.0.0.1:7070) instead of the
registry directly. The daemon brokers transport selection and `allow_inbound`
enforcement.

- list reads the daemon's catalog mirror (own ∪ peer sessions)
- spawn/kill hit the daemon's local session endpoints
- attach opens the daemon's `WS /api/attach/:id` broker (shared pump loop
  via the new `run_attach_url` / `broker_attach_url`)

Compat fallback to the legacy registry path (HTTP + direct WS) with a stderr
warning when the home daemon is unreachable. Cross-daemon spawn (501) and kill
(404) — not yet brokered locally — also fall back so they keep working.

Adds `[cli].home_daemon_url` config + `--home-daemon-url`/`MAESTRO_HOME_DAEMON_URL`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:23:17 +00:00
naps62-yoloandClaude Opus 4.8 3b60f67332 fix(maestro): give CLI reqwest clients connect + request timeouts (NAP-262)
ci / rust (push) Successful in 9m49s
The `RegistryClient` and the bare `reqwest::Client::new()` calls in
`policy.rs` built clients with no timeouts, so `maestro list` / `maestro
policy {show,reload}` would hang forever against a registry/daemon that
is TCP-alive but not making forward progress (SIGSTOP, swap thrash,
deadlock behind slow disk).

Centralize construction in `http::build_client()`, which applies a 5s
connect timeout and a 30s request timeout, and route every call site
through it. Covered by a test that points the client at a peer which
accepts the connection but never responds and asserts the request times
out instead of hanging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:22:58 +00:00
naps62-yoloandClaude Opus 4.8 2bfab13b58 fix(maestro): heal daemon→registry catalog desync that left dead sessions listed (NAP-291)
ci / rust (push) Successful in 9m41s
The daemon→registry uplink could leave a session in the registry's catalog
after the daemon had already dropped it from its authoritative map, so
`maestro list` / the web UI kept showing it while DELETE returned 404
("no such session on this daemon"). It only healed on the next reconnect.

Two defects in `registry_client::connect_once`, both fixed here:

A. Subscribe-after-snapshot race: the uplink took `manager.snapshot()` and
   then subscribed to `manager.events()`. A reap/kill firing in that window
   was in neither — the registry kept the dead session. Now subscribe before
   snapshotting, matching `local_api::pump_events` (which documents the same
   ordering rule for the browser/CLI event WS).

B. Lagged events were silently dropped (`continue`). If the 256-deep event
   broadcast overflowed, a dropped `SessionRemoved` was never relayed and the
   registry stayed stale until reconnect. The events arm is now extracted into
   `forward_session_event`, which on `Lagged` re-sends a full `SessionsSnapshot`
   — the registry applies it by replacing the daemon's set, healing immediately.

Adds deterministic unit tests for the lag-resync, the Removed mapping, and the
closed-stream break. Existing integration tests still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:36:19 +00:00
naps62-yolo 39dddb17da feat(maestro-tui): trim chrome + walk sidebar in visual order (NAP-289)
ci / rust (push) Successful in 8m18s
Dogfood-driven layout fixes:

- `j`/`k` now walk the same daemon-then-sessions order the sidebar emits
  (`visible_sidebar_order`), so the highlight follows what the user sees
  instead of jumping by Uuid order across daemons.
- Tab bar collapses from 3 rows of box border to 1 row of plain text +
  1 row of horizontal rule. The rule doubles as the pane's focus indicator
  (cyan top border when `focus = Pane`, default otherwise). Saves ~4 rows
  above the pane before any content shows.
- Sidebar drops the full box border in favour of a single right-edge
  vertical rule (also cyan when focused). The "daemons" header now lives
  inline as a bold first line instead of in the block title.

Visible content area grows by ~2 cols + ~4 rows on the typical layout
without losing focus / panel-boundary affordances.

Workspace clippy + 85 unit tests passing.
2026-05-29 10:10:56 +00:00
naps62-yolo f208aa441d feat(maestro-tui): attach lands in Insert + real help + blinking cursors (NAP-288)
ci / rust (push) Successful in 8m45s
Three dogfood-driven UX fixes:

1. `AppState::attach_to_focused` now sets `mode = Mode::Insert` along with
   `focus = Focus::Pane`, so pressing Enter on a sidebar row drops the user
   straight into typing the remote shell. The leader (default `ctrl-a`)
   returns to Normal — same surface as tmux's prefix key.

2. `Keymap::entries(mode)` exposes the live binding table; `App::dispatch
   (Action::ShowHelp)` (and `App::new`) rebuild `HelpOverlay::lines` from
   it. Users see the actual Normal + Insert bindings + the leader hint,
   not just "? to close".

3. Visible blinking cursor in both surfaces that accept text:
   - `Pane::render` calls `Frame::set_cursor_position` at the vt100 cursor
     position when the pane is focused (and the screen hasn't hidden the
     cursor itself). Terminals do the blink.
   - `SpawnModal::render` places the cursor at the end of whichever text
     field is currently focused (cwd / command / label). Daemon dropdown
     and the Submit button still rely on the REVERSED style.

Workspace clippy + 85 unit tests passing.
2026-05-29 09:19:30 +00:00
naps62-yolo f27adf5aba feat(maestro-tui): status bar + focus highlight + j/k sidebar nav (NAP-287)
ci / rust (push) Successful in 15m39s
First-use UX fix: the TUI rendered the daemon list but felt frozen because
mode and focus were invisible, and the sidebar had no nav keys, so
`Enter`/`AttachHighlighted` had nothing to target.

- `render::draw` now reserves the bottom row for a status bar that always
  shows `[NORMAL]`/`[INSERT]` (cyan/yellow chip), the focused panel
  (sidebar / pane), and the most useful key hints (`j/k: nav  •  enter:
  attach  •  s: spawn  •  ?: help  •  ^Wq: quit`). Status bar is never
  eclipsed by overlays.
- `Sidebar::render_with_focus` and the pane block now draw their border
  in cyan when focused, in the default colour otherwise.
- New `Action::SidebarNext` / `SidebarPrev`, bound to `j`/`k` and
  `down`/`up` in the shipped Normal-mode defaults. Dispatch walks the
  catalog's session list, calling `AppState::move_sidebar_highlight`
  (wraps both ends).

Workspace clippy + 85 unit tests + 3 integration tests green.
2026-05-29 09:01:36 +00:00