Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions docs/architecture/remote-workspace-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# Remote workspace transport

This document defines how BitFun turns SSH hosts and Docker containers into one
workspace runtime without leaking transport-specific behavior into Agent,
search, terminal, or file-service callers.

## Goals

One saved target must have the same workspace semantics across:

- direct SSH and an arbitrary `ProxyJump` chain;
- a container reached through its own sshd;
- `docker exec` on a remote Docker host; and
- `docker exec` on the local machine.

Once connected, all workspace commands, terminal sessions, Agent subprocesses,
ACP processes, search helpers, and file operations target the effective
workspace. Docker-host commands are never an implicit fallback.

The transport adapters run on macOS, Windows, and Linux clients. Remote paths
remain POSIX paths on every client, and host `std::path` semantics must never be
used to split or join them. Docker execution targets a POSIX-compatible
container shell.

## Issue capability matrix

| Issue tier | Capability | Design owner |
|---|---|---|
| P0 | Arbitrary ProxyJump chain, per-hop host/user/key, staged errors | SSH session establishment |
| P0 | Local/remote Docker, direct container sshd, `docker exec` | Effective target resolution |
| P0 | Terminal, Agent, files, ACP, and search stay inside the container | Workspace stdio and file adapters |
| P1 | SSH config import and Docker container discovery | Existing remote connection dialog |
| P1 | sshd probe with `docker-exec` fallback | `container.access: auto` |
| P1 | Jump/target/container test stages | Connection test report |
| P2 | ssh-agent, OpenSSH certificates, keyboard-interactive challenges | SSH authentication adapter |
| P2 | Bounded connect/auth timeouts, retries, and challenge rounds | `SSHConnectionOptions` |
| P2 | TTY, stdin, long-running completion, interrupt/kill | Terminal adapter and `WorkspaceStdio` |
| Optional | Arbitrary host-side diagnosis from a container workspace | Deliberately excluded; requires a future typed read-only security surface |

## Configuration and runtime resolution

`SSHConnectionConfig` is the user-authored, persisted target. An
`ActiveConnection` retains that configuration for reconnect and drift
detection, and separately stores an `effective_config`.

For `container.access: auto`, connection establishment probes the container's
published `22/tcp` endpoint:

1. local Docker opens a normal SSH session to the published loopback port;
2. remote Docker opens a second SSH session through a `direct-tcpip` channel
owned by the Docker-host session;
3. a successful handshake and authentication resolves the effective access to
`sshd`;
4. an unavailable or rejected endpoint falls back to `docker-exec` and probes
the configured container shell.

Runtime code only reads `effective_config`. The original `auto` value remains
persisted so a later reconnect can discover that sshd has become available.

## ProxyJump

The comma-separated jump chain is resolved left to right. Each token can be a
`~/.ssh/config` alias or `[user@]host[:port]`. Every hop has an independent
resolved host, port, user, identity file, certificate, and host-key check.
`direct-tcpip` channels carry the next SSH handshake; handles for all preceding
hops remain owned by the active connection.

Errors carry the stage name (`Jump N`, final target, or container sshd) and
separate reachability, handshake, and authentication failures. Connection and
authentication timeouts, whole-chain connection retries, and maximum
keyboard-interactive challenge rounds are bounded by `SSHConnectionOptions`.

Agent and OpenSSH certificate authentication are available on the target and
through SSH-configured jumps. Explicit password or keyboard-interactive
responses can be reused by a jump when that jump has no independent identity
configuration. Challenge responses and passphrases are runtime-only.

## Workspace stdio

`WorkspaceStdio` is the process-level port shared by SSH and local Docker:

```text
caller
├── stdin ───────────────▶ workspace process
├── stdout ◀─────────────── workspace process
├── stderr ◀─────────────── workspace process
├── interrupt / kill ─────▶ supervisor
└── completion / exit code ◀ supervisor
```

The SSH adapter pumps a `russh` channel. The local adapter supervises a piped
child process. Dropping all public IO streams cancels the owner, and explicit
interrupts escalate through the existing remote-exec grace period. This port is
used by:

- non-TTY remote execution, including stdin writes;
- local and remote Docker file streams;
- remote ACP subprocesses; and
- remote Flashgrep search helpers.

For non-TTY Docker processes, the command shell records the child PID inside
the container and uses `setsid` when available. Interrupt and kill requests
open a separate local or remote `docker exec` control path, signal the
in-container process group, and then close the owning Docker CLI transport.
This prevents cancelling the client-side CLI while leaving the workspace
process running. PID tracking is an enhancement, not a new execution
prerequisite: containers with a read-only temporary directory keep the legacy
Docker execution path and fall back to transport-level cancellation.

TTY execution remains a terminal-specific adapter: SSH requests a PTY, while
local Docker uses the existing local PTY service with `docker exec -it`.

## Files

SSH workspaces continue to use SFTP. Docker workspaces use binary stdio streams,
not text or base64 envelopes.

Reads stream chunks and report real byte progress. Writes stream to a unique
temporary file in the destination directory and rename it only after the input
has completed successfully. Cancellation kills the process and the shell trap
removes the temporary file, so an interrupted upload does not replace a valid
destination with partial content.

Directory and stat records use NUL-separated fields. File names containing
newlines or the delimiters used by older implementations remain round-trippable.
The records are decoded only after the full byte stream is assembled; invalid
UTF-8 names return an explicit unsupported-path error. Likewise, streamed text
output keeps incomplete UTF-8 suffixes between transport chunks instead of
inserting replacement characters at arbitrary chunk boundaries.

Remote names are validated before recursive download. Traversal components and
local-platform-invalid names are rejected, and case-colliding sibling names are
rejected on Windows and macOS before either entry can overwrite the other.
Recursive local uploads reject non-UTF-8 names and symbolic links explicitly;
recursive downloads reject remote symbolic links. Transfers never silently
omit an entry or follow a link outside the selected tree.

Host bind mounts are not path-translated. A host path is visible only at the
path mounted inside the container.

## Authentication and secrets

Supported target methods are password, private key, private key plus OpenSSH
certificate, OpenSSH agent, and keyboard-interactive responses.

- Passwords use the existing encrypted password vault.
- Private-key paths, certificate paths, and auth method metadata may be saved.
- Private-key passphrases, keyboard-interactive responses, and OTP values are
never copied into `SavedConnection`.
- A saved interactive profile is retained but requires credential entry on the
next manual connection.
- A legacy serialized `Agent` profile keeps its old
`~/.ssh/id_rsa` compatibility fallback when the agent is unavailable.

## Product surface

The existing remote connection dialog owns all target types. It can import SSH
config hosts, discover local or remote Docker containers, choose `auto` or
`docker-exec`, and test the resolved jump/target/container stages before
connecting.

BitFun intentionally does not expose an arbitrary “run on Docker host” action
from a container workspace. That would bypass the selected workspace and its
security boundary. Host diagnosis, if added later, must be a typed, read-only
capability with a distinct confirmation and audit surface.

## Upgrade compatibility

New configuration fields have Serde defaults. Profiles written before this
design remain direct SSH targets with the same IDs, credentials, paths, and
workspace restore entries. Legacy port-bearing IDs are migrated together with
password-vault and workspace references.

Local Docker profiles may legitimately retain an empty legacy password
placeholder. Connection, testing, and local container discovery do not require
a password-vault entry for those profiles.

Startup recovery never deletes a profile or workspace merely because a
credential is unavailable, a connection times out, or a remote host is
temporarily offline. Destructive removal remains an explicit user action.

Contract tests cover legacy Agent/profile deserialization, defaulted connection
options, remote-workspace retention, stdio round trips, cancellation, and
delimiter-safe Docker metadata parsing. A Docker-backed ignored integration test
is available through `BITFUN_TEST_DOCKER_CONTAINER`.
57 changes: 50 additions & 7 deletions docs/features/remote-workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ Agent commands, and workspace tools. The target can be:
- a Docker container on the local machine; or
- an sshd endpoint running inside a container.

The local client behavior is supported on macOS, Windows, and Linux. Remote
workspace paths are always interpreted with POSIX `/` separators, independent
of the client OS. Docker workspace commands require a POSIX-compatible
container shell; selecting a Windows container does not silently reinterpret
paths or commands with Windows semantics.

## Jump hosts

`ProxyJump` accepts a comma-separated chain such as `jump1,jump2` or
Expand All @@ -20,8 +26,10 @@ BitFun opens each hop in order and carries the next SSH handshake over a
`direct-tcpip` channel. Connection errors identify the failed jump number or
the final target, and distinguish reachability from SSH authentication.

Password authentication remains available for the final target. Jump hosts in
the P0 flow use identity files from SSH config.
Each SSH-configured jump may use its own identity, OpenSSH certificate, or
ssh-agent identity. Explicit password and keyboard-interactive challenge
responses are also supported, with configurable connection/authentication
timeouts, whole-chain retries, and challenge-round limits.

## Docker targets

Expand All @@ -40,6 +48,12 @@ For **Container sshd**, the normal host, port, user, and authentication fields
must point directly to the container's sshd endpoint. Optional jump hosts use
the same SSH path described above.

`Auto` probes the container's published `22/tcp` endpoint and completes an SSH
handshake. If sshd is unavailable or rejects authentication, BitFun falls back
to `docker exec`. The connection dialog shows the resolved access mode and can
test jumps, the target, and the container before connecting. It can also list
containers from local Docker or from the configured SSH Docker host.

## Filesystem semantics

When a Docker target is selected:
Expand All @@ -52,8 +66,26 @@ When a Docker target is selected:

A host bind mount is visible only through the path at which it is mounted in
the container. BitFun does not silently translate host paths to container
paths. File transfer uses container commands and base64 for binary-safe file
content; ordinary SSH workspaces continue to use SFTP.
paths. File transfer uses binary stdin/stdout streams. Uploads write to a
same-directory temporary file and rename atomically after success; cancellation
leaves the previous destination intact. Ordinary SSH workspaces continue to use
SFTP.

Text command output is decoded as one UTF-8 byte stream, so a multibyte
character split across SSH or Docker chunks is preserved. File bytes are never
decoded. Workspace metadata and paths must be valid UTF-8; names that cannot be
represented safely on the local filesystem (for example Windows reserved
names, traversal components, or case-colliding names on common Windows/macOS
filesystems) fail with an explicit error instead of being skipped or
overwritten. Recursive transfers reject symbolic links instead of following
them outside the selected tree.

Non-TTY Docker commands run under an in-container supervisor. Interrupt and
timeout handling signal the command's process group inside the container before
closing the local or SSH-hosted Docker CLI process. A read-only container
temporary directory does not make an existing Docker workspace unusable;
execution continues with transport-level cancellation as the compatibility
fallback.

The configured Docker CLI remains the security boundary. BitFun does not expose
the Docker daemon over the network or bypass the current user's Docker
Expand All @@ -62,11 +94,22 @@ permissions.
## Upgrade compatibility

Existing SSH profiles remain plain SSH targets because the new `proxyJump` and
`container` fields are optional. Existing remote-workspace records keep their
paths and connection metadata. Legacy connection IDs that included the SSH port
are migrated together with their workspace references.
`container` fields are optional and connection policy fields have defaults.
Existing remote-workspace records keep their paths and connection metadata.
Legacy connection IDs that included the SSH port are migrated together with
their password-vault and workspace references.

Legacy local-Docker profiles do not need an SSH password-vault entry, even if
their old serialized auth placeholder is an empty password.

If a saved password is unavailable after an upgrade or local keychain reset,
BitFun keeps the connection and workspace records and asks for the password on
the next manual reconnect. A startup timeout or temporary network failure marks
the workspace as unavailable but does not delete its restore metadata.

Private-key passphrases, keyboard-interactive responses, and one-time codes are
never persisted. Saved interactive profiles therefore remain visible after an
upgrade but require manual credential entry before reconnecting.

For the ownership and transport contracts behind these behaviors, see
[`remote-workspace-transport.md`](../architecture/remote-workspace-transport.md).
6 changes: 3 additions & 3 deletions scripts/core-boundaries/rules/source/required-rules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8116,7 +8116,7 @@ export const requiredContentRules = [
{
path: 'src/crates/assembly/core/src/service/search/remote.rs',
reason:
'core remote search runtime must remain a compatibility facade over services-integrations while retaining concrete SSH/russh bridge adapters',
'core remote search runtime must remain a compatibility facade over services-integrations while retaining the transport-neutral workspace stdio bridge adapter',
patterns: [
{
regex: /\bServiceRemoteWorkspaceSearchService\b/,
Expand All @@ -8131,8 +8131,8 @@ export const requiredContentRules = [
message: 'missing preferred remote connection lookup adapter',
},
{
regex: /\bopen_exec_channel\b/,
message: 'missing SSH stdio bridge adapter',
regex: /\bopen_workspace_stdio\b/,
message: 'missing transport-neutral workspace stdio bridge adapter',
},
{
regex: /\bRemoteWorkspaceSearchStdioProtocol\b/,
Expand Down
2 changes: 1 addition & 1 deletion scripts/core-boundaries/self-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3662,7 +3662,7 @@ export function runManifestParserSelfTest({
},
{
path: 'src/crates/assembly/core/src/service/search/remote.rs',
contracts: ['ServiceRemoteWorkspaceSearchService', 'impl RemoteWorkspaceSearchProvider for CoreRemoteWorkspaceSearchProvider', 'lookup_remote_connection_with_hint', 'open_exec_channel', 'RemoteWorkspaceSearchStdioProtocol'],
contracts: ['ServiceRemoteWorkspaceSearchService', 'impl RemoteWorkspaceSearchProvider for CoreRemoteWorkspaceSearchProvider', 'lookup_remote_connection_with_hint', 'open_workspace_stdio', 'RemoteWorkspaceSearchStdioProtocol'],
},
{
path: 'src/crates/services/services-integrations/src/remote_ssh/workspace_search/mod.rs',
Expand Down
13 changes: 9 additions & 4 deletions src/apps/desktop/src/api/remote_workspace_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1542,10 +1542,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
RemoteWorkspacePolicy::LocalOnly,
),
("speech_delete_model", RemoteWorkspacePolicy::LocalOnly),
(
"speech_download_model",
RemoteWorkspacePolicy::LocalOnly,
),
("speech_download_model", RemoteWorkspacePolicy::LocalOnly),
(
"speech_finish_input_session",
RemoteWorkspacePolicy::LocalOnly,
Expand Down Expand Up @@ -1580,6 +1577,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
"ssh_list_config_hosts",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"ssh_list_docker_containers",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"ssh_list_saved_connections",
RemoteWorkspacePolicy::WorkspaceAgnostic,
Expand All @@ -1588,6 +1589,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] =
"ssh_save_connection",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"ssh_test_connection",
RemoteWorkspacePolicy::WorkspaceAgnostic,
),
(
"start_acp_dialog_turn",
RemoteWorkspacePolicy::LegacyUnaudited,
Expand Down
Loading