Skip to content

feat(bridge): opt-in remote daemon attach for cross-machine sessions#53

Draft
doug-w wants to merge 2 commits into
puritysb:masterfrom
doug-w:feat/remote-daemon-attach
Draft

feat(bridge): opt-in remote daemon attach for cross-machine sessions#53
doug-w wants to merge 2 commits into
puritysb:masterfrom
doug-w:feat/remote-daemon-attach

Conversation

@doug-w

@doug-w doug-w commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Opt-in remote daemon attach: let a session bridge (agentdeck claude / codex / opencode) attach to a daemon on another machine and be driven from that machine's single Stream Deck — for running agents on several boxes (often over SSH) with one deck on a main node. Default behavior is byte-for-byte unchanged (local-only).

Closes #24.

Reverse control — same-socket is PRIMARY (as decided in the issue)

The worker's outbound push socket (DaemonWsClient → daemon) is now bidirectional. When the daemon focuses a remote session it sends command frames down the socket the worker already opened — the daemon never dials back. A NAT'd / SSH-only worker needs only a single outbound connection and no inbound reachability.

  • New internal frames: session_focus_down / session_unfocus_down (daemon → worker), session_command_down (daemon → worker, wraps a PluginCommand), session_event_up (worker → daemon, relayed BridgeEvent).
  • The daemon stores the live push socket (remote-sessions.ts sender / getRemoteSender); the focus relay prefers it (setSameSocketResolver), routes commands to it, and drops focus when it closes (handleSenderClosed).
  • On the worker, inbound commands run through the same applyPluginCommand handler as the local WS server — local and remote control never diverge — and RELAYED_EVENTS are forwarded back up only while focused.

Dial-back — kept as opt-in FALLBACK. For a session with no live push socket the relay dials ws://<host>:<port>?token=<ephemeral callbackToken>. The per-session token is registered via WsServer.addAcceptedToken, never shares the machine token, is never advertised in mDNS, and dies with the socket. (Requires the daemon to reach the worker's port — LAN/VPN or ssh -R.)

Discovery / precedence (matches the Q3 direction)

resolveDaemonTarget() = local daemon → explicit --daemon-host → mDNS (--remote-daemon). mDNS browse (mdns-discover.ts) mirrors the iOS/Android BridgeDiscovery: browse _agentdeck._tcp, reject link-local/loopback, confirm each candidate via /health. mDNS only runs when the user opts in — no silent auto-attach.

Auth (Q2 note)

Upward attach presents the daemon's /health pairingToken (auto-fetched) — the load-bearing token stays in daemon-server.ts, untouched by #31. The ephemeral callback token is only for the dial-back hop.

Naming / opt-in invariant

Split --remote-daemon (opt-in switch) + --daemon-host <host[:port]> (explicit endpoint) retained; env AGENTDECK_REMOTE_DAEMON=1 + AGENTDECK_DAEMON_HOST (with AGENTDECK_REMOTE_DAEMON_HOST alias). With no flag/env the path is local-only and unchanged.

Tests

mDNS parser (link-local filtering, /health token precedence), target-resolution precedence, host-hint parsing, DaemonWsClient URL/token building, WsServer ephemeral-token auth, the focus-relay same-socket transport (focus/route/relay/unfocus + same-socket-wins precedence), and an end-to-end worker↔daemon same-socket reverse-control proof over real sockets. Full bridge suite green (1198 passed / 2 skipped).

Docs

docs/daemon.md (reverse-control section — same-socket primary), docs/protocol.md (new push-channel frame table), README.md (remote-attach usage + SSH note), CLAUDE.md.

Verification

Validated end-to-end on a two-machine setup (Windows main-node daemon + Mac worker): the worker attaches with --daemon-host, appears in the main node's sessions list, and is fully controllable from the deck with reverse control riding the worker's outbound socket (no reverse forward needed).

🤖 Generated with Claude Code

Let a session bridge attach to a daemon on ANOTHER machine and be
controlled from that machine's Stream Deck — for running Claude Code on
several boxes (often over SSH) with one Stream Deck on a main node.

Opt-in only (default behavior unchanged): `--remote-daemon` (mDNS browse)
and/or `--daemon-host <host[:port]>` (explicit endpoint for cross-subnet /
SSH); env `AGENTDECK_REMOTE_DAEMON=1` / `AGENTDECK_DAEMON_HOST` (alias
`AGENTDECK_REMOTE_DAEMON_HOST`).

Discovery + upward attach:
- mdns-discover.ts: consume side of mdns.ts — browse `_agentdeck._tcp`,
  read TXT, reject link-local/loopback, confirm via `/health`. Mirrors the
  iOS/Android BridgeDiscovery.
- session-registry.ts: resolveDaemonTarget() (local -> explicit host -> mDNS),
  probeHealthAt(), parseHostHint/isLoopbackHost/formatHostForUrl helpers.
- daemon-ws-client.ts: target-based ({host,port,token}); remote URL carries
  the daemon's advertised token; sends session host + ephemeral callbackToken
  in session_push_register.

Reverse control (daemon -> session):
- Same-socket (PRIMARY): the worker's outbound push socket is bidirectional.
  The daemon drives a focused remote session by sending session_command_down /
  session_focus_down down the socket the worker already opened; the worker
  applies commands through the SAME applyPluginCommand handler as the local WS
  server and forwards RELAYED_EVENTS back up as session_event_up. No inbound
  reachability required — NAT'd / SSH-only workers work with one outbound
  connection. remote-sessions.ts stores the live push socket (getRemoteSender);
  session-focus-relay.ts prefers it (setSameSocketResolver) and drops focus when
  it closes (handleSenderClosed).
- Dial-back (opt-in FALLBACK): for a session with no live push socket, the focus
  relay dials ws://host:port?token=<ephemeral callbackToken>. ws-server.ts
  addAcceptedToken/removeAcceptedToken accepts the per-session token without
  sharing the machine token; it is never advertised in mDNS and dies with the
  push socket. Requires the daemon to reach the worker's port (LAN/VPN or ssh -R).

Tests: mDNS parser (link-local filtering, /health token precedence), the
remote-session registry, host-hint parsing, WsServer ephemeral-token auth, the
focus-relay same-socket transport (focus/route/relay/unfocus + precedence), and
an end-to-end worker<->daemon same-socket reverse-control proof over real
sockets. Full bridge suite green (1073 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@puritysb puritysb left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same-socket design and test coverage look solid, but the documented opt-in invariant is not currently enforced. startSession() always passes hostHint to resolveDaemonTarget(), and that function processes an explicit host before checking opts.remote. As a result, --daemon-host / AGENTDECK_DAEMON_HOST alone enables a remote connection even when --remote-daemon is absent. This conflicts with the issue decision and PR claim that all remote behavior is gated behind the explicit opt-in switch. Please either gate explicit-host resolution on remote === true and add a regression test, or deliberately change the policy/docs/issue contract so --daemon-host itself is the opt-in. Given the security boundary, this should be unambiguous and tested.

Address PR puritysb#53 review: the documented opt-in invariant was correct in
behavior but not self-evident or regression-tested. `resolveDaemonTarget`
processed an explicit host before the `remote` switch, which read as
"host alone silently enables remote."

Clarify the contract in code and docs: the two remote paths each require
a distinct, user-typed opt-in and neither runs on ambient state —
an explicit `--daemon-host` *is itself* the opt-in (a named node, never a
silent auto-attach), while mDNS auto-discovery is gated solely behind the
`--remote-daemon` switch. With neither and no local daemon, resolution
returns null (local-only, unchanged).

- Inject findLocal/probeHealth/discover deps into resolveDaemonTarget so
  the precedence + opt-in gate are unit-testable without the network.
- Add `resolveDaemonTarget precedence + opt-in gate` regression tests: local
  wins and skips remote; explicit host opts in without touching mDNS;
  unreachable named host returns null (no LAN fallthrough); mDNS runs ONLY
  when remote===true; no opt-in never leaves the machine.
- Document the security-boundary gate in docs/daemon.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FeDdptNxWmPfGK4Vph6VUk
@doug-w

doug-w commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in dbef6b4. Made the opt-in gate explicit, self-documenting, and regression-tested rather than changing runtime behavior — because the shipping docs already treat an explicit host as its own opt-in (README lists --daemon-host mainnode.lan as the standalone recommended invocation; docs/daemon.md says --remote-daemon and/or --daemon-host). The mismatch was only with the PR-body/issue-§4 phrasing "gated behind --remote-daemon".

The contract is now stated unambiguously — two distinct, user-typed opt-ins, neither firing on ambient state:

  • Explicit host (--daemon-host / AGENTDECK_DAEMON_HOST) is itself the opt-in — the user named a specific node, so it's never a silent auto-attach. An unreachable named host returns null instead of falling through to mDNS.
  • mDNS auto-discovery is the only path that could reach "whatever hub is on the LAN", so it's gated solely behind --remote-daemon (remote === true) and can't run on any other signal.
  • With neither opt-in and no local daemon, resolveDaemonTarget returns null → local-only, byte-for-byte unchanged.

resolveDaemonTarget now takes injectable findLocal/probeHealth/discover deps so the precedence + gate are unit-tested without the network. New resolveDaemonTarget precedence + opt-in gate block asserts: local wins and skips both remote paths; explicit host opts in without touching mDNS; unreachable named host → null with no LAN fallthrough; mDNS runs ONLY when remote === true; no opt-in never leaves the machine. Gate documented in docs/daemon.md. Full suite green (1726 passed).

If you'd rather go the other way — require --remote-daemon even alongside --daemon-host — say so and I'll flip the gate + update the README examples; but that would break the currently-recommended standalone --daemon-host invocation, so I kept the explicit-host-is-opt-in reading.

@puritysb puritysb left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying the intended gate and for adding the regression tests. I re-reviewed the full PR at dbef6b4, including the real-socket tests. The same-socket direction remains sound, but there are several merge-blocking issues that are not covered by the current suite:

  1. The agreed opt-in contract is still not enforced. In issue #24 I explicitly agreed to keep --remote-daemon as the opt-in switch and --daemon-host as the explicit endpoint, and later reiterated the --remote-daemon + local-default invariant. resolveDaemonTarget() still processes hostHint when remote !== true. The standalone --daemon-host README example is new in this PR, so it cannot be used as the pre-existing contract. Please require remote === true for both remote paths and document the explicit form as --remote-daemon --daemon-host mainnode.lan; explicit-host failure should still return null without mDNS fallback.

  2. The documented SSH local-forward topology is not registered as remote. session_push_register only calls registerRemoteSession when !isLocalConnection(senderIp). With ssh -L 9120:localhost:9120 mainnode and --daemon-host 127.0.0.1, the main-node daemon sees the push socket as loopback, skips remote registration, and the worker is not in that machine's sessions.json, so it never becomes listable/focusable. The E2E test uses a raw WsServer harness and does not exercise this daemon-side classification. Please add an integration test that goes through the real registration handler for the SSH-forward/loopback case.

  3. A stale socket can delete a newer registration. Re-registering the same sessionId replaces sender, but every socket installs an unconditional close handler that removes by sessionId alone and calls handleSenderClosed(sessionId). If the new socket registers before the old one finishes closing, the old close removes/unfocuses the new connection. Removal and focus teardown need sender identity checks, plus an overlapping old/new socket regression test. The same ownership check should be applied to session_event_up, and down-frames should be ignored by a worker when msg.sessionId !== this.sessionId.

  4. The daemon pairing token is written to debug logs. DaemonWsClient logs the complete URL after appending ?token=.... Please log only the redacted host/port.

  5. Dial-back is described as opt-in but is enabled for every session. startSession() unconditionally creates and registers an extra accepted callback token, including the default local-only path. In addition, the remote registry is removed when the push socket closes, so the advertised no-live-sender fallback is effectively unavailable after disconnect. Please either remove this fallback for now or put it behind an explicit, tested opt-in and make its lifecycle reachable.

  6. Remote targets are rejected by port number alone. resolved.port !== core.port drops a valid daemon on another host whenever it happens to use the same numeric port as the worker session. The self-port guard must also require a loopback/local host.

This does not require implementing the feature in the Swift daemon if the intended main node is the Node CLI daemon. However, the docs currently say only "daemon", and discovery accepts any /health response with mode === 'daemon'; a worker can therefore attach to the App Store Swift daemon, register successfully, and then silently lack the new same-socket control frames. Please scope the docs to the Node CLI daemon and preferably require an advertised /health capability such as sameSocketControl so unsupported daemons are not selected.

Verification on the PR branch: TypeScript --noEmit passed and the full Vitest suite passed (100 files, 1726 tests). GitHub currently has no check runs for this head. The branch also conflicts with current master in bridge/src/daemon-server.ts and bridge/src/ws-server.ts; please rebase carefully because both files have gained recent steering/topology behavior.

@puritysb puritysb added changes-requested Review feedback must be addressed before merge enhancement New feature or request platform:bridge Node bridge, daemon, hooks, and CLI labels Jul 18, 2026 — with ChatGPT Codex Connector

Copy link
Copy Markdown
Owner

Moving this PR back to Draft to match its actual state. The same-socket design and two-machine validation remain the right foundation, but the current head is 133 master commits behind, conflicts in bridge/src/daemon-server.ts and bridge/src/ws-server.ts, has no GitHub checks, and still has unresolved merge blockers:

  1. enforce the agreed --remote-daemon opt-in contract;
  2. register SSH local-forward/loopback workers correctly;
  3. guard registration/event/close handling by sender identity;
  4. redact the daemon pairing token from logs;
  5. remove or explicitly gate and make dial-back lifecycle-reachable;
  6. make the self-port guard host-aware;
  7. negotiate/document daemon same-socket capability, including Swift-daemon behavior.

Please rebase on current master, add the requested integration regressions, and rerun the real Windows-main/Mac-worker scenario before marking ready for review again. Issue #24 remains open and assigned to you.

@puritysb
puritysb marked this pull request as draft July 18, 2026 06:13
@puritysb

Copy link
Copy Markdown
Owner

Friendly ping — no new commits on feat/remote-daemon-attach since 2026-07-12, and the 2026-07-18 draft review still stands. The branch is now even further behind master and conflicts in bridge/src/daemon-server.ts and bridge/src/ws-server.ts.

The seven blockers from that review remain open:

  1. enforce the agreed --remote-daemon opt-in contract;
  2. register SSH local-forward/loopback workers correctly;
  3. guard registration/event/close handling by sender identity;
  4. redact the daemon pairing token from logs;
  5. remove — or explicitly gate and make dial-back lifecycle-reachable;
  6. make the self-port guard host-aware;
  7. negotiate/document daemon same-socket capability, including Swift-daemon behavior.

If you can rebase onto current master, add the requested integration regressions, and rerun the real Windows-main/Mac-worker scenario, I'll re-review. Otherwise I'll close this PR as stale by 2026-08-01 to keep the PR queue accurate. Issue #24 stays open and assigned to you so the work isn't lost, and you're welcome to resubmit whenever it's ready.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changes-requested Review feedback must be addressed before merge enhancement New feature or request platform:bridge Node bridge, daemon, hooks, and CLI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Attach remote sessions to a central daemon and control them from one Stream Deck (mDNS + explicit host, opt-in)

2 participants