Repository navigation
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Starting a server on an SSH host left it unreachable from the machine running BitFun.
PortForwardManagerexisted but was a placeholder: it recorded forwards in aHashMapand 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-tcpipchannel, 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, localesMotivation / 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, thenlsof, thennetstat(that order matters: macOSnetstat -pmeans "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.
targetaccepts whatever name the caller already has for the host — a saved connection (id or name), a~/.ssh/configalias, or[user@]host[:port]— and is optional in a remote workspace, where it defaults to that workspace's connection. Atargetsoperation 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:
SSHConnectionManagerper connection rather than against a cachedArc<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, andssh_disconnect/ssh_disconnect_alltear down the forwards they carried.docker execand has no SSH session to carry a channel. It is rejected when the mapping is added, not on the first request.Reverse (
-R) forwarding keeps its entry point but now fails loudly instead of silently succeeding; it needstcpip-forwardglobal requests and aforwarded-tcpipchannel 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
targetpaths (~/.ssh/configalias and[user@]host[:port]) have not been run against a real host. Reviewers should weigh that.cargo check -p bitfun-services-integrations --features remote-ssh-concretecargo check -p bitfun-services-integrations --features remote-ssh(stub build)cargo test -p bitfun-services-integrations --features remote-ssh-concretecargo test -p bitfun-tool-packscargo test -p bitfun-core --features product-full --lib port_forward_toolcargo clippy -p bitfun-services-integrations --features remote-ssh-concrete --all-targetscargo check -p bitfun-desktoppnpm --dir src/web-ui run type-checkpnpm --dir src/web-ui exec eslint(changed dirs)pnpm --dir src/web-ui exec vitest run src/features/ssh-remote src/app/components/NavPanelpnpm run i18n:auditpnpm run theme:color-auditpnpm run check:repo-hygienepnpm run fmt:rsPre-existing on
1.0.0-explore, each verified by stashing this branch's changes and reproducing:WorkspaceSessionFilterMenu.test.tsxandWorkspaceSessionGroupingToggle.test.tsx— 2 failures in files this PR does not touch.cargo test -p bitfun-desktop— blocked by a compile error insrc/apps/desktop/src/logging.rs(format_args!temporary dropped while borrowed) in an existing#[cfg(test)]block. Thebitfun-desktoplib 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 forhost: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
acceptso a stopped forward does not linger.last_erroris 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-
operationproperties across 5 operations — with a test that fails if it grows past that.Checklist
ssh.portForward.*in zh-CN / zh-TW / en-US).