Skip to content

fix(hub): route interactive SSH prompts by owner identity, not bare host (#43)#49

Merged
oorabona merged 6 commits into
mainfrom
fix/coalesce-session-acquisition
Jun 7, 2026
Merged

fix(hub): route interactive SSH prompts by owner identity, not bare host (#43)#49
oorabona merged 6 commits into
mainfrom
fix/coalesce-session-acquisition

Conversation

@oorabona

@oorabona oorabona commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

Closes #43.

What & why

The coalesced multi-client SSH connect issues interactive prompts (passphrase, host-key TOFU,
agent-binary verify, elevation). The previous handling conflated each prompt's identity, owner, route,
and lifecycle onto (hostId → {resolve, clientId}), producing a long tail of cross-client edges that
never converged (prompts bound to a disconnected leader, TEST_CONNECT clobbering a session prompt,
prompts surviving session close, stale owners answering after re-target).

This PR makes the acquisition the prompt owner and the client a mutable route: a PromptContext { id, kind, hostId, routeClientId, prompts }, keyed by a unique promptId, with lifecycle cleared by
owner-id. Every interactive prompt path (passphrase, host-key, agent-verify, elevation, test-connect,
reconnect) routes by the current live route and retargets — or fails cleanly — on owner disconnect; the
hub emits PROMPT_CANCEL so a stale dialog dismisses, and stale/rogue responses (wrong route, old
delivery epoch) are rejected.

Cross-package

  • shared: optional promptId + deliveryEpoch on AUTH_PROMPT/AUTH_PROMPT_RESPONSE, new
    PROMPT_CANCEL message. PROTOCOL_VERSION unchanged — additive/optional, back-compat with cached web
    clients (the version equality check guards only the Rust-agent HELLO handshake).
  • hub: PromptContext ops (open/prompt/respond/retarget/clearContext/clientDisconnect) with
    store-before-send, terminal-CLOSED, deterministic route chooser, delivery-epoch, owner-id lifecycle,
    and KIND discipline (test/elevation contexts never retarget to / cache for / cross-answer an unrelated
    client). Reconnect routes to clients engaged with the session's channels. The three legacy prompt maps
    are removed; removeClient/closeSession collapse onto clientDisconnect/clearContext.
  • web: prompt stores queue by promptId, echo promptId+epoch, bind every response to the
    rendered promptId (no-op if not the queue head), scope rememberSession per-prompt, and handle
    PROMPT_CANCEL.

Original per-prompt timeouts (host-key / agent verify 30 s, passphrase 120 s, elevation 60 s) and the
15-minute passphrase cache are preserved. The validated session-acquisition state machine is untouched.

Verification

  • Full monorepo suite: 2484 passed / 10 skipped / 0 failed. Typecheck (hub/shared/web) and biome
    clean. No new skips.
  • Hardened against the parallel orthogonal gate (codex xhigh + copilot) across several rounds — the gate
    caught 11 real defects the green suite missed because it exercised functions, not the production entry
    points (WS handler dropping promptId; elevation secrets cached as SSH passphrase; non-session
    contexts retargeting to unrelated clients; acquisition wedging on a pre-commit exception with joined
    followers; queued-prompt response races; rememberSession leaking across prompts). All fixed with
    regression tests at the production entry point.

Gate status (degraded GATE_OK — documented)

The final gate on the merged HEAD returned codex = CLEAN; copilot was exogenously unavailable
(transient outage) that run. Per the gate's set-union semantics a single CLEAN satisfies when the other
engine is exogenously down. copilot returned CLEAN on the immediately-prior HEAD, which differs from
the final HEAD only by a 3-file, web-only rememberSession per-prompt scoping fix that codex reviewed
CLEAN.

Deferred follow-ups (tracked locally, low priority)

  • Centralize pre-commit acquisition-failure handling into a failConnectingAcq(acq, err) helper.
  • Extract a shared usePromptQueue<TEntry> composable for the three web prompt stores.

Both surfaced by codex as architecture signals; deferred to keep this PR scoped (no current correctness
gap — the specific defects are fixed).

oorabona added 6 commits June 7, 2026 03:15
…on state machine (#43)

Replace ad-hoc per-host acquire/waiter bookkeeping with a single SessionAcquisition
owner (session-acquisition.ts) that serializes the lifecycle:

- One acquisition per host, refcounted by per-spawn leases; the shared connect is
  aborted only when the last lease leaves (no leader-disconnect cancelling followers).
- Single authority: the acquisition exists only while CONNECTING; it is deleted on a
  guarded commit (map-identity + state + not-aborted + session-current), so a late
  connect cannot revive a closed session and fast-path never coexists with an acq.
- AbortSignal threaded through connect AND reconnect; closeSession/shutdown abort
  in-flight attempts; reconnect re-checks session currency before wiring (no revive).
- Idempotent lease release on every exit path (no underflow, no orphaned session leak).
- All map/prompt removals are identity-guarded; auth/host-key/agent-verify prompts are
  promptId-keyed with an owner, and re-target to a live lease-holder when the prompt
  owner disconnects instead of failing the shared connect.
- SshAgent: reject (no unverified-binary fallback) on deploy failure; reject fast when
  the agent channel closes before HELLO instead of hanging.

Design: docs/plans/session-acquisition-redesign.md.
…nt-verify response path (#43)

Thread client.id through the full WS → session-manager → SshConnectionManager
call chain for HOST_VERIFY_RESPONSE and AGENT_BINARY_VERIFY_RESPONSE. Make
clientId a required (non-optional) parameter in both handleHostVerifyResponse
and handleAgentVerifyResponse at every layer, and replace the optional guard
(`clientId !== undefined && ...`) with an unconditional ownership check
(`pending.clientId !== clientId`) that mirrors the existing SEC-003 pattern
used for handleAuthPromptResponse. Stale or re-targeted clients are silently
rejected; the pending entry and its trust decision remain intact until the
correct owner responds.

Tests: 6 new deterministic cases covering correct-owner accept, wrong-owner
reject (pending entry intact, resolveSpy not called), and post-re-target
old-owner reject / new-owner accept — for both host-key and agent-binary
verify paths.
… reviewed (codex+copilot), multi-package scope
…ost (#43)

Concurrent SSH connects, reconnects, and per-client test-connects now deliver
their interactive prompts (passphrase, host-key TOFU, agent-binary verify,
elevation) to the correct live client and dismiss stale dialogs — even when a
client connects or disconnects in the middle of a multi-prompt sequence.

Replaces the bare-hostId prompt maps with a PromptContext model: each prompt is
owned by its acquisition / test / elevation / reconnect context and keyed by a
unique promptId; the client is a mutable route. On owner-client disconnect the
whole remaining prompt sequence retargets to a live lease-holder (or fails
cleanly so followers retry), and the hub sends PROMPT_CANCEL so the dead
client's dialog dismisses. Stale or rogue responses (wrong route, old delivery
epoch) are rejected.

Coordinated across packages:
- shared: optional promptId+deliveryEpoch on AUTH_PROMPT/RESPONSE and a new
  PROMPT_CANCEL message. PROTOCOL_VERSION unchanged — additive/optional, keeps
  back-compat with cached web clients (the version equality check guards only
  the Rust agent HELLO handshake).
- hub: PromptContext ops (open/prompt/respond/retarget/clearContext/
  clientDisconnect) with store-before-send, terminal-CLOSED, deterministic
  route chooser, delivery-epoch and owner-id lifecycle guards; host-verify,
  agent-verify, passphrase, elevation, test-connect and reconnect all migrated
  onto them; the three legacy prompt maps removed; removeClient/closeSession
  collapsed onto clientDisconnect/clearContext.
- web: prompt stores queue by promptId, echo promptId+epoch, handle
  PROMPT_CANCEL to drop a stale dialog.

Preserves the original per-prompt timeouts (host-key / agent verify 30s,
passphrase 120s, elevation 60s) and the 15-minute passphrase cache.
…ailConnectingAcq

Every leader pre-commit failure path (connect catch, commit-guard failure,
generic catch) now routes through one caller-local helper that fails the
acquisition (Acq.fail -> rejects waiters + removes the acq so followers never
hang), clears its PromptContext, and releases the leader lease. Closes the
class the #43 gate surfaced (a catch that forgot to fail the acq, wedging
followers). Behavior-preserving; existing acquisition tests unchanged.
…mpt stores

auth-prompt / host-verify / agent-verify duplicated queue logic, which produced
two #43 gate findings (response applied to the wrong prompt after the queue
advanced; rememberSession leaking across prompts). The queue mechanics —
FIFO + dedup-by-promptId, head computed, head-guarded actions (withHeadPrompt),
removeByPromptId, PROMPT_CANCEL — now live in one composable. Store-local bits
(WS payloads, rememberSession, deliveryEpoch echo, accessors) stay per-store;
public store surface unchanged (no component/session.ts edits). Behavior-preserving.
@oorabona oorabona merged commit bd2cc04 into main Jun 7, 2026
8 checks passed
@oorabona oorabona deleted the fix/coalesce-session-acquisition branch June 7, 2026 22:52
oorabona added a commit that referenced this pull request Jun 8, 2026
prompt-routing-redesign and session-acquisition-redesign shipped in PR #49
(squash bd2cc04); rust-agent shipped 2026-03-21. Flip doc-meta status so
/next stops surfacing completed plans as actionable.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Concurrent SSH SPAWN coalescing: cancel in-flight connects on session close/shutdown

1 participant