Skip to content

fix(server): device-host SSH tunnels exit when the server dies - #17448

Merged
juliusmarminge merged 4 commits into
pingdotgg:mainfrom
kcazin:fix/device-host-tunnel-orphan
Oct 19, 2026
Merged

juliusmarminge merged 4 commits into
pingdotgg:mainfrom
kcazin:fix/device-host-tunnel-orphan

Conversation

@kcazin

@kcazin kcazin commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Detached device-host SSH tunnels can survive an abrupt server exit and keep their ports open. This runs a remote stdin reader with piped stdin, so server death sends EOF; SSH exits after existing forwarded connections close. Graceful teardown and the existing ControlMaster opt-out remain in place. Fixes #17438.

The change keeps @kcazin's implementation from #17448. Main was merged into the contributor branch. The standalone local cat test was removed because it largely tested the shell and relied on a removed shared export. The existing host test now checks the SSH command and stdin contract: 2 tests → 1, failing against main and passing with the fix. Targeted lint and server typecheck passed; the current head's CI is green.

Live verification on Linux with OpenSSH 9.6 and local sshd exercised a forward and killed its Node parent: the old -N tunnel survived; the new tunnel exited within 10 ms and released its listener. Captured output. An independent live check held a forwarded connection open: exit waits for that connection to close. This change does not promise immediate termination while an external client holds a forward open. The existing ControlMaster=no, ControlPath=none, and ControlPersist=no prevent handing the forwards to a persistent master.

Not verified: a complete service-launcher self-update; macOS or Windows server hosts; Electron desktop, iOS or Android clients; non-POSIX remote login shells. No visual UI changes.

Initial takeover: Claude Fable 5.1 through Claude Code. Resumed by GPT-6.1-Sol through the Codex harness in T3 Code; adversarial reviews by Claude Opus 5.5 and GPT-6-Sol.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:S 10-29 changed lines (additions + deletions). labels Oct 9, 2026
@macroscopeapp

macroscopeapp Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Approved at 33459db

Macroscope's review found this PR approvable — This is a narrowly scoped SSH tunnel lifecycle bug fix: tunnels now terminate when the server dies, while forwarding and graceful cleanup behavior remain unchanged. The accompanying test verifies the new command and stdin contract, with no schema, deployment, default, or static-analysis changes.

You can add or adjust custom eligibility rules. Learn more.

macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Oct 9, 2026
@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Path: .coderabbit.config.ts
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: a42b8dab-6613-448f-be56-59a71fad72ae
📥 Commits

Reviewing files that changed from the base of the PR and between 43f8a8d and 66b12ec.

📒 Files selected for processing (2)
  • apps/server/src/device/SshDeviceHost.test.ts
  • apps/server/src/device/SshDeviceHost.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.

📝 Walkthrough
📝 Walkthrough

Walkthrough

The SSH forwarding process now runs a remote command that reads from piped stdin. The test suite checks that the command exits after receiving EOF.

Changes

SSH Tunnel Lifetime

Layer / File(s) Summary
Pipe-driven tunnel exit
apps/server/src/device/SshDeviceHost.ts, apps/server/src/device/SshDeviceHost.test.ts
The forwarding process runs a remote command that reads stdin, with piped stdin replacing ignored stdin. The hub and optional daemon forwarding arguments remain unchanged. A live test checks that the command exits successfully after receiving 1 MiB and then stopping the host. The mock identifies tunnel commands by -L.

Priority: ➖ Normal

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant SshDeviceHost
  participant SSH
  participant RemoteShell
  participant Cat
  SshDeviceHost->>SSH: Start forwarding process with piped stdin
  SSH->>RemoteShell: Run remote shell command
  RemoteShell->>Cat: Execute cat reading stdin
  SshDeviceHost->>SSH: Close stdin when the server exits
  SSH->>Cat: Forward EOF
  Cat-->>SSH: Exit after EOF
Loading

Suggested reviewers: juliusmarminge

Merge Risk: ⚪ Minimal · up to 66b12

The tunnel exited in the reported abrupt-shutdown check, and no concrete merge-blocking issue remains.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 66b12

The change aims to reduce orphaned SSH tunnels without broadening network access or remote-command authority. Normal cleanup remains unchanged. Confidence is limited because the added test exercises a local shell rather than real SSH forwarding during abrupt server termination.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The directly affected capability is each configured device-host SSH session and its local hub or daemon listeners. The inspected change does not enlarge destination selection, listener bindings, or credential authority; it attempts to shorten capability lifetime after server death.

Trust Boundaries and Controls

  • observed — The forwarding process retains batch-mode authentication, configured identity selection, ExitOnForwardFailure, and server-alive settings. The added EOF reader executes under the same configured remote SSH account already used for bootstrap.

Resilience and Maintainability Implications

  • observed — The added non-Windows test uses a real local shell to verify stdin consumption and successful exit after EOF. It substitutes that shell for SSH, so it does not establish listener release after SIGKILL, behavior with surviving forwarded connections, or interruption and recovery races.

Hardening Proposals

  • proposed — Validate the capability-lifetime guarantee with real SSH and abrupt parent termination, including an independently held forwarded connection. Check listener release and subsequent server recovery rather than treating remote-command exit alone as complete tunnel cleanup.
Pre-merge checks: Passed 4
Check name Status Explanation
Linked Issues check Passed For directly linked issue #17438, SshDeviceHost.ts changes the device-host tunnel from -N to -T, adds the remote sh -c 'exec cat >/dev/null' command, and sets stdin: "pipe". This couples SSH…
Out of Scope Changes check Passed The changed files are limited to the device-host tunnel implementation and its focused test. The implementation preserves the existing forwards, SSH keepalive options, forward-failure option, graceful…
Title check Passed The title clearly and concisely describes the main fix: device-host SSH tunnels now exit when the server dies.
Description check Passed The description covers the problem, implementation, scope, linked issue, verification results, limitations, and agent usage. It is mostly complete and directly related to the change.
Full details: Linked Issues check

Explanation

For directly linked issue #17438, SshDeviceHost.ts changes the device-host tunnel from -N to -T, adds the remote sh -c 'exec cat >/dev/null' command, and sets stdin: "pipe". This couples SSH lifetime to the server stdin pipe. The focused live test in SshDeviceHost.test.ts verifies the pipe, writes input, closes stdin, and verifies exit code 0. The issue lists this lifetime-coupling approach as a valid fix. The separate ensureReady interruption path is not a required completion condition for this approach.

Full details: Out of Scope Changes check

Explanation

The changed files are limited to the device-host tunnel implementation and its focused test. The implementation preserves the existing forwards, SSH keepalive options, forward-failure option, graceful stop path, and detached process behavior. The test directly verifies the new stdin lifetime behavior. No unrelated change is demonstrated.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@kcazin
kcazin force-pushed the fix/device-host-tunnel-orphan branch from 66b12ec to 8816e80 Compare October 10, 2026 05:36
@juliusmarminge juliusmarminge added the macroscope-review Opt PRs made by unvouched contributors in for Macroscope review. Vouched contributors auto-reviews label Oct 11, 2026 — with ChatGPT Codex Connector
The device-host tunnel ran `ssh -N` with stdin ignored in its own
session, so only the host's stop finalizer could end it. When the
server died before that finalizer ran (the service launcher SIGKILLs
it 5 s into a self-update; also OOM or a crash), the tunnel was
orphaned with its forwards and SSH session. Give the tunnel a stdin
pipe and a remote command that exits on EOF: the server holds the only
write end, so once it is gone ssh exits as soon as its forwarded
connections close.

Fixes pingdotgg#17438

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@kcazin
kcazin force-pushed the fix/device-host-tunnel-orphan branch from 8816e80 to d55d392 Compare October 11, 2026 09:35
@juliusmarminge

Copy link
Copy Markdown
Member

Taking over this one. I'll merge main into the branch, trim the tests to the minimum, rebuild the implementation to repo conventions where needed, run adversarial reviews, and push here. Thanks @kcazin for the diagnosis and the live evidence.

juliusmarminge and others added 2 commits October 19, 2026 01:30
The live sh -c test only proved that cat reads until EOF and imported a shared subpath main no longer exports. The existing host test now asserts the tunnel is spawned without -N, with a piped stdin, and with the quoted remote cat command.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@macroscopeapp
macroscopeapp Bot dismissed their stale review October 19, 2026 01:33

Dismissing prior approval to re-evaluate 33459db

@juliusmarminge juliusmarminge self-assigned this Oct 19, 2026
@juliusmarminge
juliusmarminge merged commit 4130507 into pingdotgg:main Oct 19, 2026
30 checks passed
github-actions Bot added a commit to omarcresp/t3code-flake that referenced this pull request Oct 19, 2026
## What's Changed
* fix(mobile): sheets close with an X and confirm with a checkmark by @juliusmarminge in pingdotgg/t3code#21319
* fix(tailscale): a slow tailscale status no longer hides Tailscale in Settings by @SunkenInTime in pingdotgg/t3code#21057
* chore(deps): web and mobile share React 19.3 from the catalog by @juliusmarminge in pingdotgg/t3code#21376
* fix(server): migrate legacy thread pull request links by @Yash-Singh1 in pingdotgg/t3code#19129
* fix(server): a failed Codex turn no longer traps the queue behind its subagents by @juliusmarminge in pingdotgg/t3code#21366
* feat(server): count which product features people use by @juliusmarminge in pingdotgg/t3code#15921
* feat(server): count which T3 MCP tools agents use by @juliusmarminge in pingdotgg/t3code#15913
* perf(web): HTML renders drop the loading skeleton by @maria-rcks in pingdotgg/t3code#21480
* fix(mobile): sent steer and queued messages are marked like desktop by @flamboh in pingdotgg/t3code#21417
* fix(server): keep queued subagent status in sync by @Yash-Singh1 in pingdotgg/t3code#21487
* perf(web): virtualize large changed-file trees by @Yash-Singh1 in pingdotgg/t3code#21482
* fix(pull-requests): distinguish skipped checks with a slash icon by @Yash-Singh1 in pingdotgg/t3code#21498
* fix(mobile): crash report shows the store build number by @mohamed-akef in pingdotgg/t3code#16052
* fix(files): load PDFs in browser previews by @Yash-Singh1 in pingdotgg/t3code#21095
* fix(server): device-host SSH tunnels exit when the server dies by @kcazin in pingdotgg/t3code#17448
* fix(web): Open in → Finder in the file preview opened the file in its default app instead of revealing it by @jamesvillarrubia in pingdotgg/t3code#16955
* fix(web): project actions no longer reuse the right-panel terminal by @TonybynMp4 in pingdotgg/t3code#18340
* fix(web): the device screen keeps focus when typing or returning to the window by @haydenkz in pingdotgg/t3code#14534
* fix(server): keep browser input responsive during slow navigation by @josephv123 in pingdotgg/t3code#16691
* fix(grok): a Homebrew-installed Grok updates through brew and is compared against the cask by @Mnigos in pingdotgg/t3code#18772
* fix(antigravity): read usage from each instance data directory by @fixfon in pingdotgg/t3code#17769
* fix(server): reconnecting no longer reopens a closed terminal by @Gigioxx in pingdotgg/t3code#18444
* fix(shared): preview addresses containing another URL open instead of searching by @maxwellyoung in pingdotgg/t3code#17943
* fix(server): ACP Registry loads on networks with slow IPv4 connects by @Gigioxx in pingdotgg/t3code#15054
* fix(preview): keep desktop-rendered tabs out of idle cleanup by @TDanks2000 in pingdotgg/t3code#18147
* fix(server): auto-delivered messages queue between provider turns by @Gigioxx in pingdotgg/t3code#15691
* fix(server): OpenCode 2 threads keep T3's tools after an idle project is evicted by @voltcrash in pingdotgg/t3code#16796
* fix(server): install the advertised version for pnpm-global provider updates by @RaphaelFakhri in pingdotgg/t3code#14295
* fix(web): ignore right-clicks in the flat Device viewport by @samaluk in pingdotgg/t3code#17020
* fix(web): "No project" picker shows its full label on the new-thread screen by @flamboh in pingdotgg/t3code#21504
* fix(web): up-arrow recall skips automation and agent messages by @maria-rcks in pingdotgg/t3code#21510
* fix(desktop): reattach a browser tab whose debugger Chromium dropped by @tobymarks in pingdotgg/t3code#17525
* fix(web): Integrations settings show one Browser section, not two by @RakshithBhat03 in pingdotgg/t3code#18264
* fix(web): hide the empty reset badge on unstarted limit windows by @akash3444 in pingdotgg/t3code#18579
* fix(cli): browser setup installs missing fonts by @adrilasar in pingdotgg/t3code#17570
* fix(web,mobile): queued messages no longer say "From queue" once sent by @flamboh in pingdotgg/t3code#21474
* fix(web): refresh embedded assets in markdown file previews by @Saikrishna1876 in pingdotgg/t3code#18647
* fix(web,mobile): keep reporting client activity after an interrupted report by @skyhong2002 in pingdotgg/t3code#15738
* fix(web): paste terminal text once and clear the shortcut on release by @t3dotgg in pingdotgg/t3code#19366
* fix(server): fall back when checkpoint fsync is unsupported by @mdshzb04 in pingdotgg/t3code#14411
* fix(preview): browser tabs no longer go blank when the tab list reorders by @mkiera in pingdotgg/t3code#13799
* fix(server): terminals no longer inherit the launching tmux session by @UzEE in pingdotgg/t3code#13359

## New Contributors
* @mohamed-akef made their first contribution in pingdotgg/t3code#16052
* @haydenkz made their first contribution in pingdotgg/t3code#14534
* @RaphaelFakhri made their first contribution in pingdotgg/t3code#14295
* @samaluk made their first contribution in pingdotgg/t3code#17020
* @tobymarks made their first contribution in pingdotgg/t3code#17525
* @adrilasar made their first contribution in pingdotgg/t3code#17570
* @skyhong2002 made their first contribution in pingdotgg/t3code#15738
* @mkiera made their first contribution in pingdotgg/t3code#13799

**Full Changelog**: pingdotgg/t3code@v0.1.1-nightly.20261018.3335...v0.1.1-nightly.20261019.3348

Upstream release: https://github.com/pingdotgg/t3code/releases/tag/v0.1.1-nightly.20261019.3348
github-actions Bot added a commit to davidvanderklay/t3code-flake that referenced this pull request Oct 19, 2026
## What's Changed
* fix(mobile): sheets close with an X and confirm with a checkmark by @juliusmarminge in pingdotgg/t3code#21319
* fix(tailscale): a slow tailscale status no longer hides Tailscale in Settings by @SunkenInTime in pingdotgg/t3code#21057
* chore(deps): web and mobile share React 19.3 from the catalog by @juliusmarminge in pingdotgg/t3code#21376
* fix(server): migrate legacy thread pull request links by @Yash-Singh1 in pingdotgg/t3code#19129
* fix(server): a failed Codex turn no longer traps the queue behind its subagents by @juliusmarminge in pingdotgg/t3code#21366
* feat(server): count which product features people use by @juliusmarminge in pingdotgg/t3code#15921
* feat(server): count which T3 MCP tools agents use by @juliusmarminge in pingdotgg/t3code#15913
* perf(web): HTML renders drop the loading skeleton by @maria-rcks in pingdotgg/t3code#21480
* fix(mobile): sent steer and queued messages are marked like desktop by @flamboh in pingdotgg/t3code#21417
* fix(server): keep queued subagent status in sync by @Yash-Singh1 in pingdotgg/t3code#21487
* perf(web): virtualize large changed-file trees by @Yash-Singh1 in pingdotgg/t3code#21482
* fix(pull-requests): distinguish skipped checks with a slash icon by @Yash-Singh1 in pingdotgg/t3code#21498
* fix(mobile): crash report shows the store build number by @mohamed-akef in pingdotgg/t3code#16052
* fix(files): load PDFs in browser previews by @Yash-Singh1 in pingdotgg/t3code#21095
* fix(server): device-host SSH tunnels exit when the server dies by @kcazin in pingdotgg/t3code#17448
* fix(web): Open in → Finder in the file preview opened the file in its default app instead of revealing it by @jamesvillarrubia in pingdotgg/t3code#16955
* fix(web): project actions no longer reuse the right-panel terminal by @TonybynMp4 in pingdotgg/t3code#18340
* fix(web): the device screen keeps focus when typing or returning to the window by @haydenkz in pingdotgg/t3code#14534
* fix(server): keep browser input responsive during slow navigation by @josephv123 in pingdotgg/t3code#16691
* fix(grok): a Homebrew-installed Grok updates through brew and is compared against the cask by @Mnigos in pingdotgg/t3code#18772
* fix(antigravity): read usage from each instance data directory by @fixfon in pingdotgg/t3code#17769
* fix(server): reconnecting no longer reopens a closed terminal by @Gigioxx in pingdotgg/t3code#18444
* fix(shared): preview addresses containing another URL open instead of searching by @maxwellyoung in pingdotgg/t3code#17943
* fix(server): ACP Registry loads on networks with slow IPv4 connects by @Gigioxx in pingdotgg/t3code#15054
* fix(preview): keep desktop-rendered tabs out of idle cleanup by @TDanks2000 in pingdotgg/t3code#18147
* fix(server): auto-delivered messages queue between provider turns by @Gigioxx in pingdotgg/t3code#15691
* fix(server): OpenCode 2 threads keep T3's tools after an idle project is evicted by @voltcrash in pingdotgg/t3code#16796
* fix(server): install the advertised version for pnpm-global provider updates by @RaphaelFakhri in pingdotgg/t3code#14295
* fix(web): ignore right-clicks in the flat Device viewport by @samaluk in pingdotgg/t3code#17020
* fix(web): "No project" picker shows its full label on the new-thread screen by @flamboh in pingdotgg/t3code#21504
* fix(web): up-arrow recall skips automation and agent messages by @maria-rcks in pingdotgg/t3code#21510
* fix(desktop): reattach a browser tab whose debugger Chromium dropped by @tobymarks in pingdotgg/t3code#17525
* fix(web): Integrations settings show one Browser section, not two by @RakshithBhat03 in pingdotgg/t3code#18264
* fix(web): hide the empty reset badge on unstarted limit windows by @akash3444 in pingdotgg/t3code#18579
* fix(cli): browser setup installs missing fonts by @adrilasar in pingdotgg/t3code#17570
* fix(web,mobile): queued messages no longer say "From queue" once sent by @flamboh in pingdotgg/t3code#21474
* fix(web): refresh embedded assets in markdown file previews by @Saikrishna1876 in pingdotgg/t3code#18647
* fix(web,mobile): keep reporting client activity after an interrupted report by @skyhong2002 in pingdotgg/t3code#15738
* fix(web): paste terminal text once and clear the shortcut on release by @t3dotgg in pingdotgg/t3code#19366
* fix(server): fall back when checkpoint fsync is unsupported by @mdshzb04 in pingdotgg/t3code#14411
* fix(preview): browser tabs no longer go blank when the tab list reorders by @mkiera in pingdotgg/t3code#13799
* fix(server): terminals no longer inherit the launching tmux session by @UzEE in pingdotgg/t3code#13359

## New Contributors
* @mohamed-akef made their first contribution in pingdotgg/t3code#16052
* @haydenkz made their first contribution in pingdotgg/t3code#14534
* @RaphaelFakhri made their first contribution in pingdotgg/t3code#14295
* @samaluk made their first contribution in pingdotgg/t3code#17020
* @tobymarks made their first contribution in pingdotgg/t3code#17525
* @adrilasar made their first contribution in pingdotgg/t3code#17570
* @skyhong2002 made their first contribution in pingdotgg/t3code#15738
* @mkiera made their first contribution in pingdotgg/t3code#13799

**Full Changelog**: pingdotgg/t3code@v0.1.1-nightly.20261018.3335...v0.1.1-nightly.20261019.3348

Upstream release: https://github.com/pingdotgg/t3code/releases/tag/v0.1.1-nightly.20261019.3348
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

macroscope-review Opt PRs made by unvouched contributors in for Macroscope review. Vouched contributors auto-reviews size:S 10-29 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Server self-update leaks the old server's SSH device-host tunnel

2 participants