Skip to content

Add SSH remote Connections - #1408

Merged
currentbits merged 7 commits into
mainfrom
coder/native-remote-connections
Oct 3, 2026
Merged

currentbits merged 7 commits into
mainfrom
coder/native-remote-connections

Conversation

@currentbits

@currentbits currentbits commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

What changed

Settings → Connections opens a Solenta Web host (--serve-web) on another machine through an SSH tunnel, in an isolated desktop window. Closes the remaining scope of #245.

  • Connect: saved SSH host profiles. A loopback -L forward to the host's web port, a WebSocket token check before any window opens, then a sandboxed window with its own partition.
  • No token pasting: a blank token reads userData/web-token on the host over SSH (macOS, then Linux/XDG layout), then falls back to a copy encrypted with safeStorage in the OS keychain. Linux plaintext (basic_text) storage is refused. A rejected keychain token is deleted. Forget removes it.
  • Host identity: StrictHostKeyChecking=yes overrides a relaxed ~/.ssh/config, so the token never goes to an unknown or changed host key. Unknown host, changed key and password-only logins get instructions instead of raw ssh stderr.
  • Reconnect: an unexpected SSH exit respawns the tunnel on the same loopback port with backoff (about a minute). The page's wire client reconnects and resyncs by itself. A rejected token stops retrying. Giving up closes the window with the SSH error.
  • No auto-start (by decision): if nothing listens on the remote port, Connect fails fast with "Start Solenta there with --serve-web".

Verification

  • tsc --noEmit clean. Connections and Settings renderer tests 64/64. Full renderer suite 2641/2641 (on 32014f94).
  • electron/test/remoteConnections.test.js: wrong-token rejection, keychain-only storage, token read over SSH, SSH refusal before any tunnel, fail-fast when nothing listens, reconnect on the same port, give-up after 6 retries. A mutation check (retries disabled) fails the test.
  • The token-read shell command was checked against real sh/zsh/bash with fake macOS, Linux and empty homes. The ssh error strings were captured from OpenSSH.
  • Local full electron runs show only load-dependent waitFor/pid timeouts in untouched provider/MCP tests, which pass in isolation.

🤖 Generated with Claude Code

Willem and others added 5 commits October 2, 2026 18:20
Verified web tokens are saved per host:port with Electron safeStorage in
userData/remote-connections.json; the renderer only keeps a tokenSaved
hint. A blank token reuses the saved one, a rejected saved token is
deleted, Forget removes it, and Linux basic_text storage is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An unexpected SSH exit no longer closes the remote window. The tunnel is
respawned on the same loopback port with backoff (about a minute), so
the window origin is unchanged and the page's wire client reconnects and
resyncs by itself. The title shows Reconnecting… meanwhile. A rejected
token or a non-Solenta port stops retrying; giving up closes the window
with the last SSH error as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tunnel carries the web token, so force StrictHostKeyChecking=yes even
when ~/.ssh/config relaxes it for the host: an unknown or changed host key
now refuses to connect. Unknown host, changed key and password-only
logins get an instruction instead of raw ssh stderr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A blank token now reads userData/web-token on the host over SSH (macOS
then Linux/XDG layout, under sh -c so the login shell does not matter),
then falls back to the keychain copy. SSH refusals surface before a
tunnel is spawned. When the forward is up but nothing listens on the
remote port, Connect fails fast with "Start Solenta there with
--serve-web" instead of timing out; it never launches the host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@currentbits currentbits changed the title Add SSH remote Connections window Add SSH remote Connections Oct 3, 2026
@currentbits
currentbits marked this pull request as ready for review October 3, 2026 10:11
@currentbits

Copy link
Copy Markdown
Owner Author

End-to-end check against real OpenSSH (2026-10-03). No local sshd was running, so this used an unprivileged sshd -D on 127.0.0.1:2299 with throwaway host/client keys and its own known_hosts. Client config was injected through a PATH ssh shim adding -F. The remote side was a real startWebServer on port 4720 with loadOrCreateToken, and XDG_CONFIG_HOME was passed via SetEnv/AcceptEnv. Only BrowserWindow was stubbed.

  • PASS readRemoteToken over real ssh returns the host's web-token
  • PASS blank-token connect through the real -L tunnel (269 ms), token saved
  • PASS killing the real ssh process: tunnel respawned on the same loopback port and the server answers through it (1.3 s)
  • PASS nothing on remote port 4799: fails in 404 ms with "Start Solenta there with --serve-web=4799"
  • PASS host absent from known_hosts: refused before any tunnel, with instructions
  • PASS wrong typed token rejected by the real webBridge

CI is green on 844cda6. Marking ready for review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 844cda63b8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +159 to +162
server.listen(0, "127.0.0.1", () => {
const port = server.address().port;
server.close((err) => err ? reject(err) : resolve(port));
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reserve the loopback listener until SSH owns it

When another process on the same machine claims this port after server.close() and before ssh -L binds it, waitForHost connects to that process and probeToken sends it the typed or keychain token. The SSH forward then fails later, but the token has already been disclosed, allowing a local untrusted process to access the remote Web host. Keep an exclusive reservation or obtain the forwarding port from SSH before authenticating.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 1bb7d77. The window now uses a front loopback port that Solenta holds for the window's whole life, piped to ssh's current forward. ssh gets a fresh port per spawn. Nothing probes or routes to it until ssh -v logs "Entering interactive session": with ExitOnForwardFailure, ssh only gets there after every bind succeeded. ("Local forwarding listening" is logged before bind(), as verified against OpenSSH 10.3 with a squatted port, so it is not used.) This also closes the same race in the reconnect gap, where the page's own WebSocket would have resent its token. New test: a squatter wins ssh's port and receives zero messages; with the gate removed, that test fails. Re-verified end to end against a real sshd.

Codex review on #1408: freeLoopbackPort released the port before ssh -L
bound it, so a local process could take it and receive the probe's token;
the reconnect gap had the same race for the page's own WebSocket.

The window now talks to a front port this process holds for its whole
life, piped to ssh's current forward. ssh gets a fresh port per spawn and
is only probed or routed to after `ssh -v` reports "Entering interactive
session", which with ExitOnForwardFailure means every bind succeeded.
("Local forwarding listening" is logged before bind(), so it is not used.)
ssh's stderr is now read per line, debug lines are kept out of messages,
and the failure is taken on 'close' so the last stderr line is not lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@currentbits
currentbits merged commit 9939443 into main Oct 3, 2026
6 checks passed
@currentbits
currentbits deleted the coder/native-remote-connections branch October 3, 2026 11:43
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.

1 participant