Add client-side ControlMaster (connection multiplexing) support#863
Add client-side ControlMaster (connection multiplexing) support#863duncm wants to merge 10 commits into
Conversation
Implements ssh ControlMaster/ControlPath/ControlSlave multiplexing on Windows, which has historically been out of scope for this port. The POSIX mux design depends on two primitives Windows lacks: Unix domain socket servers and SCM_RIGHTS file descriptor passing. This change provides Windows-native equivalents in the compat layer and compiles mux.c into ssh.exe: - AF_UNIX server emulation over named pipes (fileio.c, w32fd.c): bind()/listen()/accept() on AF_UNIX stream sockets are implemented with CreateNamedPipe + overlapped ConnectNamedPipe, integrated with the w32_select event loop the same way TCP listeners are. ControlPath values are mapped deterministically onto pipe names (\\.\pipe\openssh-uds-<mangled path>); explicit \\.\pipe\ paths are used verbatim. The listener pipe is created with a DACL restricted to SYSTEM and the current user, with PIPE_REJECT_REMOTE_CLIENTS. - File descriptor passing (w32fd.c, monitor_fdpass.c): mm_send_fd() transmits the sender's pid and raw handle value in-band; mm_receive_fd() verifies the claimed pid against GetNamedPipeClientProcessId, verifies the peer process token belongs to the same Windows user, and then duplicates the handle with OpenProcess(PROCESS_DUP_HANDLE) + DuplicateHandle. Received handles default to the synchronous io path, matching how inherited stdio handles are classified. - getpeereid() (misc.c): now succeeds iff the pipe peer process runs as the same Windows user, so the mux listener's peer check in channels.c behaves as intended (defense in depth on top of the pipe DACL). - mux.c is compiled into ssh.exe; the temp-path/link/unlink/umask socket setup and tcgetattr are #ifdef'd for Windows. sun_path is raised to 260 since ControlPath commonly lives under deep profile paths. Limitations (documented, fail safe): - tty sessions are not multiplexed; ssh transparently falls back to a separate connection (the master would have to drive the client's console - raw mode, VT input, resize - which is future work). - ControlPersist is disabled on Windows (requires fork()); the master must remain in the foreground, e.g. ssh -M -N. - Passing socket-type fds (OPENSSH_STDIO_MODE=sock) is not supported. - Client and master must run un-elevated or both elevated; DuplicateHandle across an elevation boundary fails (by design). Tested on Windows 11 x64 against sshd from this branch: master (-M -N -S), -O check, multiple concurrent exec sessions with stdout/ stderr/exit-status propagation, stdin round-trip, -O forward with a live tunnel, -O exit, and single shared TCP connection verified throughout. Stale/foreign ControlPath and same-user enforcement exercised manually. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers: master startup and -O check, remote command output and exit code propagation through the master, stdin pass-through, single shared TCP connection, -O forward with a live tunnel, tty fallback to a separate connection, second-master graceful degradation on a busy ControlPath, -O exit shutdown, and direct-connection fallback when no master is present (explicit ControlPath and ControlMaster=auto). Verified against Pester 3.4.0 (the version the E2E framework uses): 11/11 passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the master-side half of client-side console relay support for
multiplexed tty sessions on Windows (client half follows). Because
Windows console handles are not usable across processes, the master
cannot query a mux client's terminal size, so the client will send it
explicitly; and old peers must not be disrupted.
- Hello extension "tty-relay@win32.openssh.com" (provisional): the
Windows master advertises it in its hello; a Windows client detects
it. Unknown extensions are already ignored with a debug log on both
sides, so this is non-breaking in every direction (verified against
old/new client/master pairings).
- MUX_C_WINSIZE (0x1000000e): client->master, {col,row,xpix,ypix},
no reply. mux_master_process_winsize() stashes the size in
struct mux_master_state and, if a tty session channel is already
open, forwards it as a "window-change" channel request. An unknown
type on an old master yields MUX_S_FAILURE rather than a disconnect,
and the client only sends this once the extension is negotiated.
- client_session2_setup() gains a winsize parameter (NULL preserves the
existing ioctl() behavior; POSIX callers pass NULL). On Windows the
mux master passes the client-reported size, or zeros when none was
received -- never falling back to ioctl(), which on Windows would
return the master's own console size (w32_ioctl ignores the fd).
- channel_send_window_changes() skips mux-owned channels (ctl_chan
!= -1) on Windows for the same reason: their rfd is a relay pipe and
ioctl() would stamp the master's console size onto them. Their
resizes arrive via MUX_C_WINSIZE instead.
All additions are #ifdef WINDOWS or NULL-preserving; POSIX behavior is
unchanged. Existing 11 mux Pester scenarios still pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ndows) Implements the client half of multiplexed tty sessions on Windows. Because a Windows console handle is bound to its owning process's console and cannot be driven by the master across the process boundary, the mux client no longer passes console handles to the master. Instead, for each std fd that is a console, it substitutes a pipe (which the master drives exactly like redirected stdio) and pumps bytes between its own console and that pipe itself, reusing the same in-process terminal emulation a non-mux ssh already uses. - mux_relay_prepare/pump/close: for each console std fd, create a pipe, hand the master-facing end to mm_send_fd() in place of the real fd, and run a poll() loop (finite timeout, since SIGWINCH does not wake poll on Windows) moving console<->pipe bytes while still handling the control-channel packets (MUX_S_TTY_ALLOC_FAIL / MUX_S_EXIT_MESSAGE) and draining remote output before exit. Applies to both session and stdio-forward requests, and to non-tty sessions whose stdio is a console (the master's cross-process console I/O is equally broken there). With redirected stdio (e.g. CI) no fd is a console, the relay stays inactive, and behavior is byte-identical to before. - The passed pipe ends are held open until MUX_S_SESSION_OPENED, after which the master's duplicated handles keep them alive; failure paths close everything and leave the real std fds untouched so the caller can still fall back to a direct connection. - SIGWINCH is caught locally (never relayed via kill(), which w32_kill() would turn into TerminateProcess of a tracked child) and sent to the master as MUX_C_WINSIZE; an initial size is sent before the session request to seed the pty-req. - The pre-connect Windows tty fallback moves to after the hello exchange: tty sessions multiplex when the master advertised the tty-relay extension, and fall back to a separate connection otherwise, so old masters keep working. Verified on Windows 11 x64 (nested conpty): keystroke round-trip, initial window size matching the outer pty, Ctrl+C interrupting a remote command, and clean drain + "Shared connection closed" when the master is killed mid-session. POSIX and the existing 11 mux Pester scenarios are unaffected (relay is #ifdef WINDOWS and inactive without a console). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mux client now runs tty sessions over the master via the console relay, so the Pester scenario that asserted a fallback to a separate connection is updated to assert multiplexing: the session gets a master session id and no fallback message is emitted. Exit-code propagation through a tty is a pre-existing Windows conpty limitation (a direct -tt connection behaves the same), so it stays covered by the existing non-tty exit-code scenario. Console-relay keystroke/resize behavior is verified manually (nested conpty) and documented in the PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Windows has no fork(), so ssh cannot background an already-authenticated connection the way POSIX ControlPersist does (control_persist_detach()). A live SSH transport also cannot be handed to another process. So on Windows ControlPersist is implemented by auto-starting a *separate* master process: - When the user wants a persistent master (ControlMaster auto + ControlPersist) and none is running, ssh_controlpersist_spawn() launches a fresh ssh process from saved_av plus overrides (-oControlMaster=yes -oSessionType=none) marked with the environment variable SSH_CONTROLPERSIST_MASTER. The child shares this process's console so it can prompt for authentication. The foreground process then waits for the child's control socket to appear and connects to it as an ordinary mux client (interactive tty sessions included, via the console relay). If the child cannot be established, ssh falls back to a direct, non-persistent connection. - The spawned master authenticates itself, sets up the control socket in ssh_session2(), then detaches from the shared console (w32_detach_console -> FreeConsole) so it survives the terminal and does not contend with the foreground client. Its lifetime is governed by the existing ControlPersist idle timeout (set_control_persist_exit_ time), which needs no fork and works unchanged. - The POSIX fork-based backgrounding block in ssh_session2() is compiled out on Windows; the blanket "ControlPersist is not supported" disable is removed. New Windows compat helpers (misc.c): w32_spawn_control_master (spawn sharing the console, not tracked as a child so it outlives us), w32_process_alive, w32_close_handle, w32_is_controlpersist_master, w32_detach_console. Verified on Windows 11 x64 (key auth): first connection auto-starts a master and returns promptly while the master persists; later connections reuse it over a single TCP connection; -O check / -O exit work; interactive tty multiplexes through the persistent master; the master self-exits after the ControlPersist idle timeout; and an unreachable target falls back cleanly to a direct connection without hanging. POSIX is unaffected (all additions are #ifdef WINDOWS). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a Pester context covering Windows ControlPersist: a first connection auto-starts a persistent master and returns while it persists, a subsequent connection reuses it, and -O exit stops it. Uses Start-Process for the auto-spawning invocations so the spawned master's console does not block the test runner. Interactive-auth prompting and the console-relay data path remain manual-only (CI has pipe stdio); the existing 11 scenarios are unchanged (14 total). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tty scenario forced a pty (-tt) and asserted the remote command's output in the redirected stdout. Capturing a forced pty's stdout under redirection is unreliable in the headless CI agent (the interactive Terminal test is skipped there for the same reason), and the assertion flaked: it passed on the tty commit's build and failed on the next. Assert multiplexing from the client's own debug output instead -- a master session id is assigned and no fallback message is emitted -- which does not depend on pty output flushing. This still fails if a tty session wrongly falls back to a separate connection, so coverage is preserved. Console-relay keystroke/output behavior remains manually verified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
No functional change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
|
@microsoft-github-policy-service agree |
There was a problem hiding this comment.
Pull request overview
This PR adds Windows client support for SSH connection multiplexing (ControlMaster/ControlPath/ControlPersist) by emulating AF_UNIX server-side semantics over Windows named pipes, implementing Windows handle “fd passing” for mux stdio, and adding a client-side console relay to support multiplexed interactive tty sessions.
Changes:
- Implement AF_UNIX listener semantics over named pipes (bind/listen/accept) and deterministic ControlPath → pipe name mapping for Windows.
- Add Windows fd-passing for mux stdio using in-band
{pid, handle}+DuplicateHandle, plusgetpeereid()emulation for same-user verification. - Enable
mux.cin the Windows build, add tty relay +MUX_C_WINSIZE, and add new Pester E2E tests for multiplexing and ControlPersist.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| ssh.c | Adds Windows ControlPersist auto-spawn logic and detaches the spawned master from the console after mux socket setup. |
| regress/pesterTests/Multiplex.Tests.ps1 | Adds new Pester E2E tests covering mux lifecycle, sessions, tty muxing, and ControlPersist. |
| mux.c | Enables Windows mux support, adds hello extension + MUX_C_WINSIZE, and implements client-side tty console relay for multiplexed sessions. |
| monitor_fdpass.c | Switches mm_send_fd/mm_receive_fd to Windows implementations using DuplicateHandle. |
| contrib/win32/win32compat/w32fd.h | Declares AF_UNIX bind/listen/accept emulation APIs. |
| contrib/win32/win32compat/w32fd.c | Adds AF_UNIX accept/listen/bind support and implements Windows fd passing send/recv routines. |
| contrib/win32/win32compat/no-ops.c | Removes Windows mux stubs now that mux.c is compiled. |
| contrib/win32/win32compat/misc.c | Adds ControlPersist spawn/detach helpers and implements getpeereid() via named-pipe peer validation. |
| contrib/win32/win32compat/misc_internal.h | Exposes w32_is_pid_same_user() for cross-module use. |
| contrib/win32/win32compat/inc/sys/un.h | Increases sockaddr_un.sun_path capacity for Windows path depth. |
| contrib/win32/win32compat/fileio.c | Implements AF_UNIX server emulation over named pipes and updates connect to map ControlPath to pipe names. |
| contrib/win32/openssh/ssh.vcxproj | Builds mux.c into the Windows ssh.exe project. |
| clientloop.h | Extends client_session2_setup signature to optionally accept a winsize override. |
| clientloop.c | Uses provided winsize (when present) instead of always ioctl’ing in_fd for pty-req. |
| channels.c | Skips ioctl-based resize handling for mux-owned sessions on Windows (resized via MUX_C_WINSIZE). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Copilot flagged two issues on the upstream PR: - mux_client_send_winsize() only queried TIOCGWINSZ on stdout, so a tty session with stdout redirected (stdin the only tty) would skip sending MUX_C_WINSIZE and seed the pty-req with a 0x0 size. Fall back to stdin when stdout is not a tty. - Fix "becase" -> "because" in a Multiplex.Tests.ps1 comment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lhecker
left a comment
There was a problem hiding this comment.
Some notes from testing this PR. I really hope we can get support for this sometime soon.
(You may notice that I'm a MSFT employee, but I'm not affiliated with this project. I'm testing it in a personal capacity.)
| debug_f("persistent master exited before it was ready"); | ||
| break; | ||
| } | ||
| usleep(200000); |
There was a problem hiding this comment.
This sleep is a bit hacky. You can pass an event HANDLE via PROC_THREAD_ATTRIBUTE_HANDLE_LIST to the child process, tell the master about the HANDLE value via your W32_CONTROLPERSIST_ENV and make it such that this can wait on the child process immediately with WaitForMultipleObjects.
| memset(&pi, 0, sizeof(pi)); | ||
|
|
||
| /* the child reads this marker to learn it is the persistent master */ | ||
| SetEnvironmentVariableW(L"" W32_CONTROLPERSIST_ENV, L"1"); |
There was a problem hiding this comment.
Ideally you should modify the child process environment block here. But if I search for setenv(, there are lots of other places, so this is probably fine.
| debug4("fileclose - pio:%p", pio); | ||
|
|
||
| /* bound/listening AF_UNIX socket emulated over named pipes */ | ||
| if (pio->internal.context) { |
There was a problem hiding this comment.
Testing for non-null here is also a bit hacky since that just so happens to work today. A proper tag would be cleaner, but idk if modifying the struct is a good idea.
Preface
Except for this preface (which I'm writing myself, hi everyone!) this pull request and diff was written entirely by Claude. I don't expect it to be merged without proper review (if at all) and I recommend appropriate caution for anyone wanting to test it.
Why did I do this? I was starting to get impatient at the need to reauthenticate. But it's working well enough that it seemed appropriate to submit a PR. The text below is a tad verbose but I figured this was preferable given that the implementation differs from POSIX in multiple ways.
Summary
This PR implements
ControlMaster/ControlPathconnection multiplexing inssh.exe, which the project scope has historically excluded. With it, the following works today on Windows:Interactive tty sessions multiplex too (via a client-side console relay — see the dedicated section below), so a shared master gives you real interactive shells, not just command execution.
ControlPersistworks as well — an auto-started background master keeps the connection warm:ControlMaster autoinssh_configalso works forscp/sftpinvocations that shell out tossh.exe.Why this was hard, and the approach
Upstream mux relies on two POSIX primitives with no direct Windows equivalent:
AF_UNIX(aCreateFileWon a pipe name, used for ssh-agent);bind()/listen()/accept()returnedENOTSUP.SCM_RIGHTSfd passing. The mux client hands its stdin/stdout/stderr to the master over the control socket. WindowsAF_UNIX(afunix.sys) has no ancillary data at all, so even a "real" Unix-socket implementation would not solve this.Because SCM_RIGHTS is unavailable either way, this PR uses named pipes as the control-channel transport rather than
afunix.sys— consistent with how this port already handles ssh-agent, and it brings two things Unix sockets on Windows don't have: security descriptors on the listener andGetNamedPipeClientProcessId()for peer verification.What the change consists of
AF_UNIXserver emulationfileio.c,w32fd.cbind()creates the first pipe instance (FILE_FLAG_FIRST_PIPE_INSTANCEgivesEADDRINUSEsemantics),listen()arms an overlappedConnectNamedPipe,accept()hands out the connected instance and re-arms. The listener integrates withw32_select()through the same event mechanism as TCP listeners.fileio.c\\.\pipe\openssh-uds-<mangled path>(both sides derive it independently); paths already of the form\\.\pipe\...are used verbatim.w32fd.c,monitor_fdpass.cmm_send_fd()sends{magic, pid, handle value, io type}in-band.mm_receive_fd()validates the message, cross-checks the claimed pid againstGetNamedPipeClientProcessId(), requires the peer process token to be the same Windows user, thenOpenProcess(PROCESS_DUP_HANDLE)+DuplicateHandle(). Received handles enter the fd table on the synchronous-io path (same classification as inherited stdio).getpeereid()misc.cENOTSUPstub: succeeds iff the pipe peer runs as the same Windows user, sochannel_post_mux_listener()'s uid check works unchanged.mux.c,no-ops.c,ssh.vcxproj#ifdefs: no temp-path/link()/unlink()/umask()dance inmuxserver_listen()(pipe creation is atomic), notcgetattr().mux.c,channels.c,clientloop.c,ssh.cMUX_C_WINSIZEmux message negotiated via a hello extension. See the tty section below.ssh.c,misc.cfork()), which authenticates itself and detaches. See the ControlPersist section below.inc/sys/un.h,ssh.csun_pathraised to 260 (profile paths are deep).Security model
The control pipe grants full use of the authenticated SSH connection, so access control matters:
D:P(A;;GA;;;SY)(A;;GA;;;<owner SID>)— only SYSTEM and the creating user can connect.PIPE_REJECT_REMOTE_CLIENTSis set.getpeereid()independently verifies the connecting process token's user SID (defense in depth, mirrors the Unix uid check in channels.c).DuplicateHandle, the receiver requires (a) the fd message's claimed pid to match the pipe peer pid, and (b) that process to be the same Windows user. An elevated master will not accept handles from processes it can't verify, andDuplicateHandleacross an elevation boundary fails closed.Points I'd particularly welcome maintainer scrutiny on: the DACL string, the pid/SID verification ordering in
w32_fdpass_recv(), and whetherSECURITY_IDENTIFICATIONon the client'sCreateFileWis sufficiently restrictive.tty sessions (client-side console relay)
Interactive (
ssh -t) sessions do multiplex. This was the hard part, because Windows console handles cannot be driven across a process boundary — the master cannot read aDuplicateHandle'dCONIN(ERROR_OPERATION_ABORTED), which is why passing console handles to the master (as POSIX does viaSCM_RIGHTS) fundamentally can't work.The solution keeps the console on the client, where it already works exactly as a non-mux
ssh -tdrives it, and never hands it to the master:mm_send_fd()in place of the real fd. The master drives that pipe identically to redirected stdio — its data path is completely unchanged and can't tell the difference.mux_relay_pump) that moves bytes between its own console and the pipe ends using the port's existing in-process terminal emulation (raw mode, VT translation), while still servicing the control channel (MUX_S_TTY_ALLOC_FAIL,MUX_S_EXIT_MESSAGE) and draining remote output before exit.MUX_C_WINSIZEmessage — an initial size before the session request (seeding the pty-req) and one per local resize.SIGWINCHis handled locally rather than relayed withkill()(whichw32_kill()would turn intoTerminateProcessof a tracked child).This is negotiated by a hello extension (
tty-relay@win32.openssh.com, provisional): a client that wants a tty only multiplexes if the master advertised it, otherwise it falls back to a separate direct connection. Unrecognized extensions are ignored on both sides perPROTOCOL.mux, so every old/new client/master pairing interoperates (verified — see below). The protocol additions are documented in a block comment atop the relay code inmux.c.Known tty caveats (parity with a direct Windows connection, not relay-specific): exit-status of a
-tsession is not reliably propagated through Windows conpty (a directssh -t exit 42behaves the same); resize latency is bounded by the pump's poll interval (~200 ms).ControlPersist (auto-spawned master)
ControlPersistworks. POSIX implements it byfork()-ing an already-authenticated connection into the background — impossible on Windows (nofork(), and a live SSH transport can't be handed to another process). Instead, when the user wants a persistent master and none is running, ssh auto-starts a separate master process:ssh_controlpersist_spawn()launches a freshssh(fromsaved_avplus-oControlMaster=yes -oSessionType=none, marked by an env var) that shares the current console so it can prompt for authentication. The foreground process waits for the new master's control socket, then connects to it as an ordinary mux client — interactive tty included.FreeConsole) so it survives the terminal and doesn't contend with the foreground client. Its lifetime is governed by the existingControlPersistidle timeout (set_control_persist_exit_time), which needs nofork().This means the console-sharing model handles interactive auth (password/passphrase/2FA prompts appear on your terminal before the master detaches), not just agent/key auth. The POSIX fork-based backgrounding path is compiled out on Windows.
Limitations (all fail safe, documented in code)
OPENSSH_STDIO_MODE=sock) is rejected with a clear error.ssh -f(background-after-auth) doesn't return control to the invoking shell — with nofork(),daemon()keeps the master in the same process. The master itself functions (verified via-O check/-O forward/ exec through anssh -f -Mmaster); it's a backgrounding-semantics gap, andControlPersist(above) is the better way to get a persistent shared master anyway.ssh -O stop,-O cancel,-O proxycompile and follow upstream code paths but have had lighter testing than the flows above.Testing done
Windows 11 x64, binaries from this branch, against
sshd.exe(also from this branch) on127.0.0.1:2222:-M -N -S <path>master establishes; control pipe appears with the expected name and DACL.-O checkreturns the master pid.'data' | ssh -S ... "$input").-O forward -L 12345:127.0.0.1:2222adds a live forwarding to the running master; the tunnel serves traffic (verified by reading the SSH banner through it).-O exitshuts the master down and the pipe name disappears.sun_pathlimit) works; over-long paths fail withENAMETOOLONGrather than truncating.tty sessions over mux (manual, nested-conpty harness)
-vshows a master session id (not a fallback); keystroke round-trip throughRead-Hostreturns the typed value.mode coninside the mux session matches the outer pty (120×30) — provesMUX_C_WINSIZE→ pty-req.Ctrl+Cthrough the relay interrupts a remoteping -twithout killing the client; the session continues.ControlPersist
ControlMaster=auto+ControlPersistconnection auto-starts a master; the foreground returns promptly while the master persists.-O checkreports it; an interactive tty session multiplexes through it.ControlPersist=5), and-O exittears it down immediately.Interop matrix (with a stashed pre-change
ssh.exe)All four cells verified; masters stay healthy after a peer falls back.
Automated tests
regress/pesterTests/Multiplex.Tests.ps1(new, taggedCIlike its siblings) covers the matrix end-to-end against the standardtest_targetenvironment: master startup +-O check, command output and exit-code propagation, stdin pass-through, the single-shared-TCP-connection property,-O forwardwith a live tunnel (banner read through the forwarded port), tty session multiplexing (a-ttsession gets a master session id and does not fall back), second-master graceful degradation on a busy ControlPath,-O exit, direct-connection fallback when no master exists (explicit ControlPath andControlMaster=auto), and a ControlPersist context (auto-start a persistent master, reuse it,-O exit). 14/14 passing under Pester 3.4.0 — the versionRun-OpenSSHE2ETestuses. CI runs with pipe stdio, so the console-relay data path and interactive-auth prompting are exercised by the manual tests above; happy to wire up upstream'sregress/multiplex.shtoo if maintainers prefer.Follow-up work (out of scope here)
WSADuplicateSocket(needed for-O stdio-forwardfrom socket stdio).tty-relay@win32.openssh.comextension name with maintainers.Appendix: why the console relay lives on the client
The obvious approach — pass the console handles to the master and let it drive the terminal, as POSIX does — was prototyped and rejected. Isolation pinned down why: a direct
ssh -tworks and a mux non-tty session works, but mux + tty hung, because pipes/files are real cross-process kernel objects while console buffers are not (aReadConsoleInputWon aDuplicateHandle'dCONINin the un-attached master returns 0 withERROR_OPERATION_ABORTED).AttachConsoleon the master is also a dead end — one console per process, so it can't serve concurrent tty sessions. Hence the client keeps its console and relays over pipes.🤖 Generated with Claude Code