feat(desktop): forward SSH agents to remote environments#8190

closed1
main
GitHub fork: feat/ssh-agent-forwarding
openedAug 25, 2026byFfsargent
View on GitHubTwo-way sync enabledPR last imported 21d ago

Desktop-managed SSH launch sessions are short-lived, so an agent socket enabled only through ForwardAgent yes disappears before provider commands can use it. This is especially visible with the 1Password SSH agent: Git authentication and SSH commit signing fail in the managed remote environment even though ordinary SSH works.

Add an explicit Forward SSH agent option for desktop-managed SSH environments. When enabled, the desktop keeps a dedicated ssh -A channel alive, validates the remote agent socket, and starts its managed T3 server with that SSH_AUTH_SOCK. Connections without the option explicitly use ssh -a, keeping forwarding opt-in even when the host's SSH config enables it.

Testing

  • Focused SSH command, tunnel, persistence, and connection-catalog tests (77 passing)
  • Scoped typechecks for @t3tools/ssh, @t3tools/contracts, @t3tools/web, and @t3tools/desktop
  • Targeted lint
  • Generated remote shell scripts validated with sh -n

UI

Before: the SSH environment form had no agent-forwarding control.

After: the form exposes an opt-in Forward SSH agent toggle, including guidance for 1Password users.

SSH environment form with Forward SSH agent toggle

Built with GPT-5.6 Sol through the Codex harness.

Checks

1 failing · 8 successful · 5 skipped · 2 neutral
Some checks failed

Failing1

cb58d9b

Added 1 commit

  • feat(desktop): forward SSH agents to remote environmentscb58d9b
Synced from GitHub

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f7c11ee0-b180-4010-a8b2-99855539f30f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

packages/ssh/src/tunnel.tsline 1506Open thread
……@@ -1339,20 +1500,68 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
1503+ const entryScope = yield* Scope.make("sequential");
1504+ const agentForwarderProcess =
1505+ input.resolvedTarget.forwardAgent === true
1506+ ? yield* runWithSshAuth({
MmacroscopeappBoton line 1506Aug 25, 2026

🟠 High src/tunnel.ts:1506

Password-authenticated hosts cannot use agent forwarding: when the SSH child exits with Permission denied before emitting AGENT_FORWARD_READY_MARKER, startSshAgentForwarder returns the generic readiness error without stderr, so isSshAuthFailure cannot detect the authentication failure and runWithSshAuth never prompts for a password. Make the readiness wait observe the child’s stderr/exit failure and propagate an authentication error that isSshAuthFailure can recognize.

🤖 Copy this AI Prompt to have your agent fix this:
text
1In file @packages/ssh/src/tunnel.ts around line 1506:
2
3Password-authenticated hosts cannot use agent forwarding: when the SSH child exits with `Permission denied` before emitting `AGENT_FORWARD_READY_MARKER`, `startSshAgentForwarder` returns the generic readiness error without stderr, so `isSshAuthFailure` cannot detect the authentication failure and `runWithSshAuth` never prompts for a password. Make the readiness wait observe the child’s stderr/exit failure and propagate an authentication error that `isSshAuthFailure` can recognize.
4
5Evidence trail:
6Reviewed commit cb58d9b0: packages/ssh/src/tunnel.ts:1068-1094, 1506-1515; packages/ssh/src/auth.ts:207-217. Git commands: `git show cb58d9b0 -- packages/ssh/src/tunnel.ts`; `git grep -n -C 5 'startSshAgentForwarder\|isSshAuthFailure\|runWithSshAuth' cb58d9b0 -- packages/ssh/src`
packages/ssh/src/tunnel.tslines 481–481Open thread
……@@ -452,6 +458,33 @@ printf 'Remote host is missing the t3 CLI and could not install @@T3_PACKAGE_SPE
478+ fi
479+ rm -f "$SOCKET_NEXT"
480+}
481+trap cleanup_forwarded_socket EXIT HUP INT TERM
MmacroscopeappBoton lines 481–481Aug 25, 2026

🟡 Medium src/tunnel.ts:481

The HUP, INT, and TERM traps clean up SOCKET_FILE but do not terminate the script, so the remote forwarder resumes its unconditional while loop after disconnect and leaves a shell/sleep process behind. Repeated desktop reconnects can accumulate these processes and consume the user's process or session quota; exit from the signal handler after cleanup while retaining the EXIT cleanup trap.

diff
1-trap cleanup_forwarded_socket EXIT HUP INT TERM
2+trap cleanup_forwarded_socket EXIT
3+trap 'cleanup_forwarded_socket; exit 0' HUP INT TERM
🤖 Copy this AI Prompt to have your agent fix this:
text
1In file @packages/ssh/src/tunnel.ts around line 481:
2
3The `HUP`, `INT`, and `TERM` traps clean up `SOCKET_FILE` but do not terminate the script, so the remote forwarder resumes its unconditional `while` loop after disconnect and leaves a shell/`sleep` process behind. Repeated desktop reconnects can accumulate these processes and consume the user's process or session quota; exit from the signal handler after cleanup while retaining the `EXIT` cleanup trap.
4
5Evidence trail:
6Repository commit cb58d9b: `packages/ssh/src/tunnel.ts:461-485` (forwarder traps and loop); `packages/ssh/src/tunnel.ts:1000-1100` (forwarder process launch); `packages/ssh/src/tunnel.ts:1342-1358` and `1616-1655` (scope/process cleanup). POSIX trap specification: https://pubs.opengroup.org/onlinepubs/009604399/utilities/trap.html. POSIX shell signal and loop semantics: https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html
packages/ssh/src/tunnel.tslines 622–641Open thread
……@@ -584,13 +619,29 @@ else
638+ printf 'SSH agent forwarding was requested, but its remote socket is unavailable.\n' >&2
639+ exit 1
640+ fi
641+ SSH_AUTH_SOCK="$FORWARDED_SSH_AUTH_SOCK" nohup env T3CODE_NO_BROWSER=1 "$RUNNER_FILE" serve --host 127.0.0.1 --port "$REMOTE_PORT" --base-dir "$DEFAULT_SERVER_HOME" >>"$LOG_FILE" 2>&1 < /dev/null &
MmacroscopeappBoton lines 622–641Aug 25, 2026

🟠 High src/tunnel.ts:622

When FORWARD_AGENT=1 and the existing server is managed, this block stops the working server before validating AGENT_SOCKET_FILE; if the socket is unavailable, the script exits and leaves T3 stopped. Validate the forwarded socket before killing REMOTE_PID so this failure preserves the existing server.

diff
1+if [ "$FORWARD_AGENT" = "1" ]; then
2+ FORWARDED_SSH_AUTH_SOCK="$(cat "$AGENT_SOCKET_FILE" 2>/dev/null || true)"
3+ if [ -z "$FORWARDED_SSH_AUTH_SOCK" ] || [ ! -S "$FORWARDED_SSH_AUTH_SOCK" ]; then
4+ printf 'SSH agent forwarding was requested, but its remote socket is unavailable.\n' >&2
5+ exit 1
6+ fi
7+fi
8 if [ "$FORWARD_AGENT" = "1" ] && [ "$REMOTE_MANAGED" = "managed" ] && [ -n "$REMOTE_PID" ]; then
9 kill "$REMOTE_PID" 2>/dev/null || true
10 wait_for_pid_exit "$REMOTE_PID"
11@@
12 if [ "$FORWARD_AGENT" = "1" ]; then
13- FORWARDED_SSH_AUTH_SOCK="$(cat "$AGENT_SOCKET_FILE" 2>/dev/null || true)"
14- if [ -z "$FORWARDED_SSH_AUTH_SOCK" ] || [ ! -S "$FORWARDED_SSH_AUTH_SOCK" ]; then
15- printf 'SSH agent forwarding was requested, but its remote socket is unavailable.\n' >&2
16- exit 1
17- fi
18 SSH_AUTH_SOCK="$FORWARDED_SSH_AUTH_SOCK" nohup env T3CODE_NO_BROWSER=1 "$RUNNER_FILE" serve --host 127.0.0.1 --port "$REMOTE_PORT" --base-dir "$DEFAULT_SERVER_HOME" >>"$LOG_FILE" 2>&1 < /dev/null &
🤖 Copy this AI Prompt to have your agent fix this:
text
1In file @packages/ssh/src/tunnel.ts around lines 622-641:
2
3When `FORWARD_AGENT=1` and the existing server is managed, this block stops the working server before validating `AGENT_SOCKET_FILE`; if the socket is unavailable, the script exits and leaves T3 stopped. Validate the forwarded socket before killing `REMOTE_PID` so this failure preserves the existing server.
4
5Evidence trail:
6packages/ssh/src/tunnel.ts:560-616, 622-640, commit cb58d9b0. The reuse path preserves a ready managed server, then the forwarding-specific block kills it before socket validation; socket failure exits afterward.
……@@ -1826,6 +1828,7 @@ export function ConnectionsSettings() {
18261828 const [savedBackendSshHost, setSavedBackendSshHost] = useState("");
18271829 const [savedBackendSshUsername, setSavedBackendSshUsername] = useState("");
18281830 const [savedBackendSshPort, setSavedBackendSshPort] = useState("");
1831+ const [savedBackendSshForwardAgent, setSavedBackendSshForwardAgent] = useState(false);
MmacroscopeappBoton line 1831Aug 25, 2026

🟠 High settings/ConnectionsSettings.tsx:1831

Closing the Add Environment dialog leaves savedBackendSshForwardAgent set, so reopening it for another SSH host silently forwards the local agent to that host. Because the remote host can use the forwarded agent, reset this state when the SSH form is cancelled or closed, or scope it to the pending connection.

🤖 Copy this AI Prompt to have your agent fix this:
text
1In file @apps/web/src/components/settings/ConnectionsSettings.tsx around line 1831:
2
3Closing the Add Environment dialog leaves `savedBackendSshForwardAgent` set, so reopening it for another SSH host silently forwards the local agent to that host. Because the remote host can use the forwarded agent, reset this state when the SSH form is cancelled or closed, or scope it to the pending connection.
4
5Evidence trail:
6Short commit: cb58d9b
7apps/web/src/components/settings/ConnectionsSettings.tsx:1824-1832, 2147-2180, 2479-2530, 2574-2578, 3413-3420
8packages/ssh/src/tunnel.ts:1503-1543
9packages/ssh/src/command.ts:104-114
packages/ssh/src/tunnel.tsline 622Open thread
……@@ -584,13 +619,29 @@ else
584619 REMOTE_PORT=""
585620 REMOTE_MANAGED=""
586621fi
622+if [ "$FORWARD_AGENT" = "1" ] && [ "$REMOTE_MANAGED" = "managed" ] && [ -n "$REMOTE_PID" ]; then
MmacroscopeappBoton line 622Aug 25, 2026

🟠 High src/tunnel.ts:622

Enabling agent forwarding can kill an unrelated process when the managed server's PID has been reused: this path validates only kill -0 and readiness on the recorded port, then sends kill to REMOTE_PID. Verify that the PID still belongs to the recorded T3 server (for example, by checking its command/owner) before terminating it.

🤖 Copy this AI Prompt to have your agent fix this:
text
1In file @packages/ssh/src/tunnel.ts around line 622:
2
3Enabling agent forwarding can kill an unrelated process when the managed server's PID has been reused: this path validates only `kill -0` and readiness on the recorded port, then sends `kill` to `REMOTE_PID`. Verify that the PID still belongs to the recorded T3 server (for example, by checking its command/owner) before terminating it.
4
5Evidence trail:
6cb58d9b packages/ssh/src/tunnel.ts:279-305, 488-648, 726-738, 1535-1542; cb58d9b apps/server/src/server.ts:495-524; cb58d9b packages/ssh/src/command.ts:76-82. Verification commands: git show cb58d9b -- packages/ssh/src/tunnel.ts; git show cb58d9b -- apps/server/src/server.ts; git show cb58d9b -- packages/ssh/src/command.ts

🟠 High

https://github.com/pingdotgg/t3code/blob/cb58d9b0d5ad835fc27c12f93dfd62166f4f63c1/packages/ssh/src/tunnel.ts#L78

remoteStateKey is identical for forwarded and non-forwarded connections, so their launch scripts share the same remote state and one connection can kill or stop the server used by the other. Include forwardAgent in the state key, for example by hashing targetConnectionKey(target).

🤖 Copy this AI Prompt to have your agent fix this:
text
1In file @packages/ssh/src/tunnel.ts around line 78:
2
3`remoteStateKey` is identical for forwarded and non-forwarded connections, so their launch scripts share the same remote state and one connection can kill or stop the server used by the other. Include `forwardAgent` in the state key, for example by hashing `targetConnectionKey(target)`.
4
5Evidence trail:
6Reviewed commit cb58d9b0: packages/ssh/src/command.ts:72-83; packages/ssh/src/tunnel.ts:488-663, 747-760, 1493-1543, 1757-1839. Verify with `git show cb58d9b0 -- packages/ssh/src/command.ts packages/ssh/src/tunnel.ts` and `git grep -n 'remoteStateKey\|targetConnectionKey' cb58d9b0 -- packages/ssh/src`.
packages/ssh/src/tunnel.tsline 1601Open thread
……@@ -1381,6 +1589,26 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
1598+ exitCode: Number(exitCode),
1599+ }).pipe(
1600+ Effect.andThen(
1601+ tunnelEntry.process.kill({
MmacroscopeappBoton line 1601Aug 25, 2026

🟡 Medium src/tunnel.ts:1601

When the agent-forwarding process exits after the tunnel is established, the observer only kills tunnelEntry.process; the managed remote T3 process and its pid/port/managed state are left running with an invalid forwarded socket. This leaves an orphaned remote server until a later ensure or disconnect cleans it up. Close entryScope here so its finalizers also stop the remote server and remove its state.

🤖 Copy this AI Prompt to have your agent fix this:
text
1In file @packages/ssh/src/tunnel.ts around line 1601:
2
3When the agent-forwarding process exits after the tunnel is established, the observer only kills `tunnelEntry.process`; the managed remote T3 process and its `pid`/`port`/`managed` state are left running with an invalid forwarded socket. This leaves an orphaned remote server until a later ensure or disconnect cleans it up. Close `entryScope` here so its finalizers also stop the remote server and remove its state.
4
5Evidence trail:
6Commit cb58d9b; packages/ssh/src/tunnel.ts:1503-1534, 1592-1610, 1616-1663, 1687-1707, 461-662, 678-695; git show cb58d9b -- packages/ssh/src/tunnel.ts
Synced from GitHub

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR adds a substantial SSH agent-forwarding capability that introduces a retained credential channel and changes remote server/process lifecycle, persistence, IPC, and UI behavior. Unresolved findings also identify risks in authentication handling, server termination, state isolation, cleanup, and accidental forwarding to another host.

Not approved because:

  • 7 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

Note

This comment is posted by Julius' dot

The forwarding lifetime bug is accepted in #11872, including the need for a retained agent owner. This PR changes the SSH environment form, but its UI evidence contains only the after screenshot with the new toggle. The before state is described in text. Closing under the verification requirement for before and after screenshots. Please attach the missing before screenshot and request reconsideration. I corrected this explanation to recognize the maintainer direction in #11872.