Skip to content

feat(remote-ssh): forward remote ports to this machine - #2408

Merged
bobleer merged 5 commits into
GCWing:1.0.0-explorefrom
bobleer:bob/bitfun-remote-port-mapping-explore
Aug 21, 2026
Merged

bobleer merged 5 commits into
GCWing:1.0.0-explorefrom
bobleer:bob/bitfun-remote-port-mapping-explore

Conversation

@bobleer

@bobleer bobleer commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Starting a server on an SSH host left it unreachable from the machine running BitFun. PortForwardManager existed but was a placeholder: it recorded forwards in a HashMap and logged "Port forwarding is not fully implemented - connections will not be forwarded".

This implements local (-L) forwarding for real, gives it a UI on the remote workspace it belongs to, and exposes it to Agents as a tool.

Each forward binds a listener and splices accepted connections onto a direct-tcpip channel, so the remote sshd dials the service and both directions are copied. That primitive was already in production use for ProxyJump chains and container sshd probing; this puts it behind a managed listener.

Type and Areas

Type: Feature

Areas: Rust core (services-integrations/remote_ssh), Agent tools (assembly/core), desktop/Tauri commands, web UI, locales

Motivation / Impact

The remote endpoint is a forward's identity; the local port is only an allocation. A requested local port that is already taken is replaced rather than refused, and the port originally asked for is returned with the mapping so the UI can say "local 3000 was in use, bound 3001 instead" rather than silently handing over a different address. The table keeps "remote port" and "local address" in separate columns for the same reason. Privileged ports (<1024) fall back the same way instead of surfacing a bare permission error.

Where the UI lives. On the ··· menu of a remote workspace item — a forward is carried by that workspace's SSH session, so it belongs to the workspace that owns the connection rather than to a global menu.

Detection leads. It runs when the dialog opens, and a port the remote is listening on is one click from being forwarded instead of a number the user has to go look up and type. Nothing is forwarded automatically — a click is still a request — and a manual row stays for a specific local port or a host detection cannot see. Discovery tries ss, then lsof, then netstat (that order matters: macOS netstat -p means "protocol"), with a marker line naming the dialect so the parser does not have to guess.

The Agent tool is not confined to remote-SSH workspaces. Users frequently stay in a local workspace and tell an Agent to ssh somewhere and start a server; that Agent needs the same capability. target accepts whatever name the caller already has for the host — a saved connection (id or name), a ~/.ssh/config alias, or [user@]host[:port] — and is optional in a remote workspace, where it defaults to that workspace's connection. A targets operation lists what is reachable. Ad-hoc targets authenticate with an SSH agent or a config-declared key, since no one can type a passphrase into a tool call.

Both surfaces share one registry through a process-global manager, so a forward made by hand and one made by an Agent are the same object and either side can list or stop the other's.

Three failure modes worth calling out for review:

  1. Reconnect. Channels are opened through SSHConnectionManager per connection rather than against a cached Arc<Handle>, so a forward survives an automatic reconnect (reconnect installs a new handle). That same path would let a forward nobody stopped dial back out to a host the user explicitly disconnected from — so each connection checks liveness first, and ssh_disconnect / ssh_disconnect_all tear down the forwards they carried.
  2. Local Docker. A local Docker workspace is reached by docker exec and has no SSH session to carry a channel. It is rejected when the mapping is added, not on the first request.
  3. Exposure. Forwards bind loopback unless the user opts into exposing them on the local network.

Reverse (-R) forwarding keeps its entry point but now fails loudly instead of silently succeeding; it needs tcpip-forward global requests and a forwarded-tcpip channel handler that the session handler does not carry.

Verification

AI-assisted. Testing level: lightly tested — unit coverage is thorough and the dialog was exercised by hand against a live remote host, but the Agent tool's ad-hoc target paths (~/.ssh/config alias and [user@]host[:port]) have not been run against a real host. Reviewers should weigh that.

Check Result
cargo check -p bitfun-services-integrations --features remote-ssh-concrete pass
cargo check -p bitfun-services-integrations --features remote-ssh (stub build) pass
cargo test -p bitfun-services-integrations --features remote-ssh-concrete 147 lib + 14 contract tests pass
cargo test -p bitfun-tool-packs 11 pass
cargo test -p bitfun-core --features product-full --lib port_forward_tool 4 pass
cargo clippy -p bitfun-services-integrations --features remote-ssh-concrete --all-targets no warnings in new code
cargo check -p bitfun-desktop pass (warning count unchanged from baseline)
pnpm --dir src/web-ui run type-check pass
pnpm --dir src/web-ui exec eslint (changed dirs) pass
pnpm --dir src/web-ui exec vitest run src/features/ssh-remote src/app/components/NavPanel 143 pass, 2 pre-existing failures (below)
pnpm run i18n:audit pass
pnpm run theme:color-audit pass
pnpm run check:repo-hygiene pass
pnpm run fmt:rs applied

Pre-existing on 1.0.0-explore, each verified by stashing this branch's changes and reproducing:

  • WorkspaceSessionFilterMenu.test.tsx and WorkspaceSessionGroupingToggle.test.tsx — 2 failures in files this PR does not touch.
  • cargo test -p bitfun-desktop — blocked by a compile error in src/apps/desktop/src/logging.rs (format_args! temporary dropped while borrowed) in an existing #[cfg(test)] block. The bitfun-desktop lib target compiles clean.

Reviewer Notes

New unit coverage worth a look: the bind-fallback path, the three listening-port dialect parsers (ss / netstat / lsof, including shell-banner tolerance and dual-stack row merging), the endpoint parser (IPv6 literals are not mistaken for host:port), and wire-shape contract tests for the new camelCase DTOs.

The duplicate check, bind, and registry insert are held under one write lock, so two concurrent requests for the same remote endpoint cannot both bind. Concurrency per forward is capped at 256 via a semaphore that queues rather than drops, and the accept loop's permit wait is under the same cancellation as accept so a stopped forward does not linger.

last_error is the forward's health signal and self-heals: it clears after any connection completes normally, so creating a mapping before starting the remote service does not leave a permanent error badge.

The Agent tool schema is flat — 8 optional-except-operation properties across 5 operations — with a test that fails if it grows past that.

Checklist

  • This PR is focused and does not include secrets, temporary prompts, generated scratch files, or unrelated artifacts.
  • Relevant verification is recorded above, or skipped checks are explained.
  • User-facing strings, docs, and locales are updated where applicable (ssh.portForward.* in zh-CN / zh-TW / en-US).

Starting a server on an SSH host left it unreachable from here.
`PortForwardManager` existed but was a placeholder: it recorded forwards
in a HashMap and logged "not fully implemented - connections will not be
forwarded".

Implement local (`-L`) forwarding for real. Each forward binds a listener
and splices accepted connections onto a `direct-tcpip` channel, so the
remote sshd dials the service and both directions are copied.

The remote endpoint is a forward's identity; the local port is only an
allocation. A requested local port that is taken is replaced rather than
refused, and the port originally asked for comes back with the mapping so
the UI can say the address moved instead of silently handing over a
different one. Privileged ports fall back the same way.

Three failure modes worth naming:

- Channels are opened through SSHConnectionManager per connection instead
  of against a cached handle, so a forward survives an automatic
  reconnect. That same path would let a forward nobody stopped dial back
  out to a host the user explicitly disconnected from, so each connection
  checks liveness first, and disconnect tears down the forwards it
  carried.
- A local Docker workspace has no SSH session to carry a channel. It is
  rejected when the mapping is added rather than on the first request.
- Forwards bind loopback unless the user opts into exposing them on the
  local network.

Add remote listening-port discovery (ss, then lsof, then netstat, with a
marker line naming the dialect) so a port can be picked rather than
guessed. Discovery forwards nothing on its own.

The UI lives on the `···` menu of a remote workspace item, because a
forward is carried by that workspace's SSH session. Detection runs when
the dialog opens and its results lead: a port the remote is listening on
is one click from being forwarded, instead of a number to go look up and
type. Nothing is forwarded automatically — a click is still a request —
and a manual row stays for a specific local port or a host detection
cannot see.

Add a PortForward Agent tool alongside it. It is not confined to
remote-SSH workspaces: users often stay in a local workspace and tell an
Agent to ssh somewhere and start a server, and that Agent needs the same
capability. `target` accepts whatever name the caller already has for the
host — a saved connection, a `~/.ssh/config` alias, or
`[user@]host[:port]` — and is optional in a remote workspace, where it
defaults to that workspace's connection. A `targets` operation lists what
is reachable. Ad-hoc targets authenticate with an agent or a
config-declared key, since no one can type a passphrase into a tool call.

Both surfaces share one registry through a process-global manager, so a
forward made by hand and one made by an Agent are the same object and
either side can list or stop the other's.

Reverse forwarding keeps its entry point but now fails loudly; it needs
`tcpip-forward` global requests and a `forwarded-tcpip` handler that the
session handler does not carry.
…its dialog

Two problems with the previous commit.

The tool module compiled unconditionally but its imports only exist under
`remote-workspace`. `bitfun-sdk-host` enables `tools-agent-control`
without it, so the SDK host build broke — a workspace-wide `cargo check`
missed it because feature unification hands `bitfun-core` the union of
every consumer's features. Gate the SSH-dependent parts and report the
tool as unavailable when the build has no SSH transport, rather than
gating the provider plan: registry construction treats an unmaterialized
tool name as a hard error, so the name has to stay resolvable in every
build. A tool that could only fail is hidden instead of offered.

The dialog also ignored the modal conventions every other workspace
dialog follows, most visibly `contentInset` — without it the content sat
flush against the modal edge with no padding at all. Adopt the same
structure as the sibling dialogs on this menu: an intro callout, bordered
section cards with tinted headers, library `IconButton`s for row actions,
rows separated by a rule instead of a bare table, and font sizes from the
appearance tokens rather than hardcoded pixels.
The tool was registered but no Agent could see it. `GetToolSpec`'s
deferred catalog is built from each mode's allowed-tool list, and
`PortForward` was in none of them — so an Agent asked to expose a remote
server had no idea the capability existed and answered with hand-written
`ssh -L` instructions for the user to run.

Add it to the modes that already carry the environment-control set. Cron
is the marker rather than ExecCommand, which even the deliberately narrow
modes have: `minimal` is a fixed six-tool manifest and `Ultra` is a
planner whose workers do the running, and neither carries Git or Cron
either. A registry test now enforces the pairing.

Also close the gaps a filtered test run had been hiding:

- Both tool-manifest stability snapshots needed the new name. They exist
  so a manifest change is deliberate; this is that deliberate change.
- Add a materialization test that every planned tool name resolves.
  Registry construction treats an unknown name as fatal, so this is the
  guard for the class of break that took out the SDK host build.
- Mirror `ensure_connected` into the disabled SSH stub, which had fallen
  out of parity with the concrete manager.
- Drop five dead `column*` locale keys left over from the table layout
  the row layout replaced, in all three locales.
- Pin the shared-registry invariant: the UI and the tool must hold one
  registry, or a forward made on one surface is invisible to the other.
…forward nodes

`appearance:contract-audit` requires `data-bf-component` and
`data-bf-part` to be declared together on the same node, and two nodes
carried only the part. The audit runs inside `build:web`, which is why a
passing type-check, lint, and test run still left the Frontend Build job
red.
…d commands

`every_registered_command_declares_a_remote_workspace_policy` requires
every command in `generate_handler!` to state how it behaves for a remote
workspace, and the four port-forwarding commands stated nothing.

They are `WorkspaceAgnostic`, matching every other `ssh_*` command: each
takes an explicit connection or forward id and does not depend on where
the workspace filesystem lives.

This is a `bitfun-desktop --lib` test, which is the suite a pre-existing
`format_args!` borrow error in `logging.rs` prevents from compiling on
newer stable toolchains — so it never ran locally. Verified by patching
that error out locally: core 2070 passed, desktop 323 passed.
@bobleer
bobleer merged commit d9203db into GCWing:1.0.0-explore Aug 21, 2026
15 of 16 checks passed
@bobleer
bobleer deleted the bob/bitfun-remote-port-mapping-explore branch September 24, 2026 08:19
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