From 13f677eed23e416ce798a79671a19bd2dafe176c Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 5 Sep 2026 17:20:34 +0800 Subject: [PATCH 1/3] feat(remote): add native Windows WSL workspaces --- .../remote-workspace-transport.md | 26 +- docs/features/remote-workspaces.md | 12 +- docs/interactive-capabilities/README.md | 16 +- .../capabilities.json | 66 ++- .../capabilities/feature.remote-workspaces.md | 2 + .../technical/product-control-open-audit.json | 22 +- .../technical/tauri-command-map.json | 24 +- src/apps/desktop/Cargo.toml | 2 +- src/apps/desktop/README.md | 22 + src/apps/desktop/src/api/ssh_api.rs | 11 +- src/apps/desktop/src/lib.rs | 1 + .../implementations/port_forward_tool.rs | 1 + .../generated/product-control-catalog.json | 66 ++- .../generated/remote-surface-registry.json | 16 +- .../src/remote_surface/capabilities.rs | 8 +- .../src/remote_surface/table.rs | 1 + .../services/services-integrations/AGENTS.md | 6 + .../src/remote_ssh/manager.rs | 159 ++++++-- .../src/remote_ssh/mod.rs | 2 + .../src/remote_ssh/port_forward.rs | 6 +- .../src/remote_ssh/remote_exec.rs | 24 +- .../src/remote_ssh/remote_fs.rs | 32 +- .../src/remote_ssh/remote_terminal.rs | 31 +- .../src/remote_ssh/types.rs | 31 ++ .../src/remote_ssh/wsl.rs | 382 ++++++++++++++++++ .../remote_ssh_contracts.rs | 38 ++ .../remote_ssh_disabled_contracts.rs | 1 + .../interactive-capabilities/catalog.json | 14 + .../generated/interactive-capabilities.json | 66 ++- .../ssh-remote/SSHConnectionDialog.test.tsx | 88 +++- .../ssh-remote/SSHConnectionDialog.tsx | 160 ++++++-- .../features/ssh-remote/SSHRemoteProvider.tsx | 1 + src/web-ui/src/features/ssh-remote/sshApi.ts | 5 + src/web-ui/src/features/ssh-remote/types.ts | 12 + .../api/adapters/peer-device-adapter.test.ts | 19 + .../api/adapters/peer-device-adapter.ts | 12 + .../api/generated/productControl.ts | 2 +- .../api/generated/remoteSurface.ts | 4 +- .../peer-device/PeerConnectionManager.test.ts | 3 + .../peer-device/PeerConnectionManager.ts | 6 + .../src/infrastructure/peer-device/README.md | 6 + src/web-ui/src/locales/en-US/common.json | 11 + src/web-ui/src/locales/zh-CN/common.json | 11 + src/web-ui/src/locales/zh-TW/common.json | 11 + 44 files changed, 1297 insertions(+), 142 deletions(-) create mode 100644 src/crates/services/services-integrations/src/remote_ssh/wsl.rs diff --git a/docs/architecture/remote-workspace-transport.md b/docs/architecture/remote-workspace-transport.md index f99349f2c9..6472491c08 100644 --- a/docs/architecture/remote-workspace-transport.md +++ b/docs/architecture/remote-workspace-transport.md @@ -1,6 +1,6 @@ # Remote workspace transport -This document defines the transport boundary for SSH and Docker workspaces. +This document defines the transport boundary for SSH, Docker, and WSL workspaces. The Agent Runtime stays on the OpenBitFun host. Local and remote workspaces share file-tool algorithms through Session-bound IO providers; search retains native acceleration with shared matching and reduction. The convergence section below @@ -59,6 +59,30 @@ published `22/tcp` endpoint: Runtime code only reads `effective_config`. The original `auto` value remains persisted so a later reconnect can discover that sshd has become available. +## Native WSL + +WSL is an independent workspace target. The additive `wsl` profile field stores +an explicit distribution and optional Linux user. Missing `wsl` preserves legacy +SSH/Docker behavior. Reserved `wsl.invalid:0` legacy endpoint fields keep older +readers from treating a WSL profile as a usable SSH endpoint. Connection drift +and saved-profile deduplication include the WSL target. + +The Services integration launches `wsl.exe --distribution … --cd ~ --exec +/bin/sh -lc …` on the owning Windows host through the managed command factory. +Each argument stays separate; command stdout and file streams remain binary. +Only Windows-side WSL listing and diagnostic output is decoded as UTF-16LE when +needed. Interactive terminals use the existing local PTY adapter and WSL's +configured default shell, with a POSIX cwd. Non-TTY commands share Docker's +in-target PID/process-group supervision and separate signal channel. + +WSL uses shell filesystem operations, never SFTP or Windows-local path IO. +The host advertises `wsl_workspaces_v1`; controllers reject WSL discovery and +profile mutations before RPC if the peer lacks that capability. Discovery then +reports the executing host's platform support. CLI peer setup is explicitly +unsupported by the Product Operation Registry. Existing Remote Control sessions +reuse the bound workspace providers, and Detached Dispatch does not create WSL +profiles. Native WSL has no SSH transport for port forwarding. + ## ProxyJump The comma-separated jump chain is resolved left to right. Each token can be a diff --git a/docs/features/remote-workspaces.md b/docs/features/remote-workspaces.md index 37e6e9085e..7ae7312650 100644 --- a/docs/features/remote-workspaces.md +++ b/docs/features/remote-workspaces.md @@ -1,4 +1,4 @@ -# Remote SSH and container workspaces +# Remote SSH, container, and WSL workspaces OpenBitFun remote workspaces use one saved target for the file explorer, terminal, Agent commands, and workspace tools. The target can be: @@ -7,7 +7,8 @@ Agent commands, and workspace tools. The target can be: - an SSH host reached through one or more jump hosts; - a Docker container on an SSH host; - a Docker container on the local machine; or -- an sshd endpoint running inside a container. +- an sshd endpoint running inside a container; or +- a WSL Linux distribution on the Windows OpenBitFun host. The local client behavior is supported on macOS, Windows, and Linux. Remote workspace paths are always interpreted with POSIX `/` separators, independent @@ -15,6 +16,13 @@ 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. +## Windows WSL + +Choose **Windows WSL** as its own workspace target. OpenBitFun discovers installed +distributions on the executing Windows host and connects through `wsl.exe`. +See [Desktop WSL setup](../../src/apps/desktop/README.md#windows-wsl-workspaces) +for prerequisites, user selection, and remote-surface support. + ## Jump hosts `ProxyJump` accepts a comma-separated chain such as `jump1,jump2` or diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index ce45d2818d..09eac55a30 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -1,9 +1,9 @@ # OpenBitFun 功能与设置目录 / OpenBitFun Features & Settings -OpenBitFun Playbook 当前包含 **22 个功能**和 **21 个设置页**,共 **43 个**用户可理解的条目、**320 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` Agent 工具。 +OpenBitFun Playbook 当前包含 **22 个功能**和 **21 个设置页**,共 **43 个**用户可理解的条目、**321 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` Agent 工具。 -OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, and **320** source-backed sub-capabilities across **43** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. +OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, and **321** source-backed sub-capabilities across **43** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. ## 唯一事实源 / Single source of truth @@ -27,20 +27,20 @@ OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, a - Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json` - Generated low-level audit map: `docs/interactive-capabilities/technical/tauri-command-map.json` -说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **665** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 +说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **666** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 -Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **665** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. +Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **666** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. ## 控制边界 / Control boundary -- 每个子能力都明确标记为直接控制、委托给专用 Agent 工具、需交互打开或不支持;“打开页面”不会再被统计成“Agent 已控制”。当前覆盖:直接 **48**、委托 **61**、需交互 **211**、不支持 **0**。 +- 每个子能力都明确标记为直接控制、委托给专用 Agent 工具、需交互打开或不支持;“打开页面”不会再被统计成“Agent 已控制”。当前覆盖:直接 **48**、委托 **61**、需交互 **212**、不支持 **0**。 - 稳定行为声明为带 JSON 输入契约的 `operations` 或 `options`,并绑定原生产品控制 Provider;Agent 不接触原始 Tauri Command。 -- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 320 项子能力都不会写入 system prompt。 +- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 321 项子能力都不会写入 system prompt。 - 目录发现与契约读取不依赖 React 或可见窗口。普通配置型 option 统一由 Product Assembly 的共享 ConfigService 执行器读、写并回读,因此 Desktop、CLI 与 Headless 表面走同一份实现;只有宿主原生 operation/provider option 和界面导航按表面注册适配器,缺失时必须明确返回不可用,禁止静默回退本机。只读 Agent 只能发现和读取目录。 -- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **48 direct**, **61 delegated**, **211 interactive**, and **0 unsupported**. +- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **48 direct**, **61 delegated**, **212 interactive**, and **0 unsupported**. - Stable behavior becomes a typed `operation` or `option` with a JSON input contract and a native product-control provider. Agents never receive raw Tauri commands. -- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 320 documented items enters the system prompt. +- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 321 documented items enters the system prompt. - Discovery and contract lookup do not depend on React or a visible window. Ordinary config-backed options are read, written, and read back by one Product Assembly ConfigService executor shared by Desktop, CLI, and headless surfaces. Only host-native operations/provider options and presentation routes install surface adapters; missing adapters return explicit unavailability without local fallback. Read-only agents may only discover and inspect entries. ## 防腐化门禁 / Anti-drift gates diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index a635ba4c23..2e91ce5971 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "9973af8307e72206bb5c4af5dfb3249fef2def6a27925a13aca9a25ac62ef8a3", + "digest": "9a3d11cb4a198369ceb62ae82e1d0e1e6bddb658f4a790ec84e9eb4123803e5a", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -138,11 +138,11 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 320, + "documentedItems": 321, "controlCoverage": { "direct": 48, "delegated": 61, - "interactive": 211, + "interactive": 212, "unsupported": 0 } }, @@ -8145,6 +8145,7 @@ "ssh-profiles", "ssh-auth", "docker", + "wsl", "remote-open", "remote-files", "transfer", @@ -8329,6 +8330,52 @@ "actionId": "project.new" } }, + { + "id": "feature.remote-workspaces:open:wsl", + "capabilityId": "feature.remote-workspaces", + "itemIds": [ + "wsl" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "action", + "actionId": "project.new" + } + }, { "id": "feature.remote-workspaces:open:remote-open", "capabilityId": "feature.remote-workspaces", @@ -23508,6 +23555,17 @@ "reasonEn": "“Discover remote Docker containers and use a container as the work environment” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, + { + "id": "wsl", + "titleZh": "选择 Windows 主机上的 WSL 发行版作为工作区", + "titleEn": "Choose a WSL distribution on the Windows host as a workspace", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "打开远程连接对话框,由用户选择发行版、Linux 用户和工作区目录。", + "reasonEn": "Open the remote connection dialog for the user to choose a distribution, Linux user, and workspace directory." + } + }, { "id": "remote-open", "titleZh": "打开、关闭和移除远程工作区,并读取服务器信息", @@ -23660,6 +23718,8 @@ "Connect with passwords, private keys, certificates, SSH config hosts, and jump hosts", "发现远程 Docker 容器并把容器作为工作环境", "Discover remote Docker containers and use a container as the work environment", + "选择 Windows 主机上的 WSL 发行版作为工作区", + "Choose a WSL distribution on the Windows host as a workspace", "打开、关闭和移除远程工作区,并读取服务器信息", "Open, close, and remove remote workspaces, and inspect server information", "浏览、读取、写入、创建、重命名和删除远程文件与目录", diff --git a/docs/interactive-capabilities/capabilities/feature.remote-workspaces.md b/docs/interactive-capabilities/capabilities/feature.remote-workspaces.md index b533665245..59567db215 100644 --- a/docs/interactive-capabilities/capabilities/feature.remote-workspaces.md +++ b/docs/interactive-capabilities/capabilities/feature.remote-workspaces.md @@ -23,6 +23,8 @@ Open projects over SSH or in containers so files, search, terminal, and agents a - Connect with passwords, private keys, certificates, SSH config hosts, and jump hosts - **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 发现远程 Docker 容器并把容器作为工作环境 - Discover remote Docker containers and use a container as the work environment +- **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 选择 Windows 主机上的 WSL 发行版作为工作区 + - Choose a WSL distribution on the Windows host as a workspace - **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 打开、关闭和移除远程工作区,并读取服务器信息 - Open, close, and remove remote workspaces, and inspect server information - **由专用 Agent 工具控制 / Delegated Agent tool** · `LS` / `Read` / `Write` / `Edit` / `Delete` / `Glob` / `Grep` / `ExecCommand` · 浏览、读取、写入、创建、重命名和删除远程文件与目录 diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index ff540e156f..6b822a1a9f 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,12 +1,12 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "9973af8307e72206bb5c4af5dfb3249fef2def6a27925a13aca9a25ac62ef8a3", - "count": 211, + "catalogDigest": "9a3d11cb4a198369ceb62ae82e1d0e1e6bddb658f4a790ec84e9eb4123803e5a", + "count": 212, "reasonCounts": { "externalAuth": 4, "secretEntry": 5, - "unstructuredInteraction": 184, + "unstructuredInteraction": 185, "visualSelection": 18 }, "entries": [ @@ -1771,6 +1771,22 @@ "command:ssh_list_docker_containers" ] }, + { + "capabilityId": "feature.remote-workspaces", + "itemId": "wsl", + "titleZh": "选择 Windows 主机上的 WSL 发行版作为工作区", + "titleEn": "Choose a WSL distribution on the Windows host as a workspace", + "reasonCode": "unstructuredInteraction", + "reasonZh": "打开远程连接对话框,由用户选择发行版、Linux 用户和工作区目录。", + "reasonEn": "Open the remote connection dialog for the user to choose a distribution, Linux user, and workspace directory.", + "presentationTarget": { + "kind": "action", + "actionId": "project.new" + }, + "evidence": [ + "command:ssh_list_wsl_distributions" + ] + }, { "capabilityId": "feature.remote-workspaces", "itemId": "remote-open", diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 106e854522..51b120a71e 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,11 +1,11 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "9973af8307e72206bb5c4af5dfb3249fef2def6a27925a13aca9a25ac62ef8a3", - "commandCount": 665, + "catalogDigest": "9a3d11cb4a198369ceb62ae82e1d0e1e6bddb658f4a790ec84e9eb4123803e5a", + "commandCount": 666, "coverage": { - "commandCount": 665, - "documentedCommandCount": 632, + "commandCount": 666, + "documentedCommandCount": 633, "implementationCommandCount": 33, "implementationDigest": "35539d9c1510287cb47f4a68fe35859b78f93bb06cd66b86e58d878a48d8c509" }, @@ -9546,6 +9546,22 @@ "signature": "fn ssh_list_saved_connections( state: State<'_, AppState>, ) -> Result, String>", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, + { + "id": "ssh_list_wsl_distributions", + "moduleId": "ssh", + "capabilityId": "feature.remote-workspaces", + "capabilityIds": [ + "feature.remote-workspaces" + ], + "documentedItemIds": [ + "feature.remote-workspaces:wsl" + ], + "visibility": "documented", + "rustPath": "api::ssh_api::ssh_list_wsl_distributions", + "sourceFile": "src/apps/desktop/src/api/ssh_api.rs", + "signature": "fn ssh_list_wsl_distributions( ) -> Result", + "remoteWorkspacePolicy": "WorkspaceAgnostic" + }, { "id": "ssh_save_connection", "moduleId": "ssh", diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 0ed3b6b8f7..634c939780 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -24,7 +24,7 @@ openbitfun-relay-service = { path = "../../crates/services/relay-service" } openbitfun-agent-runtime = { path = "../../crates/execution/agent-runtime", features = ["agent-runtime"] } openbitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports", features = ["agent-api", "permission", "workspace-ports"] } openbitfun-product-domains = { path = "../../crates/contracts/product-domains", features = ["appearance-market"] } -openbitfun-services-integrations = { path = "../../crates/services/services-integrations", features = ["canvas-runtime", "miniapp-market", "speech-realtime"] } +openbitfun-services-integrations = { path = "../../crates/services/services-integrations", features = ["canvas-runtime", "miniapp-market", "remote-ssh-concrete", "speech-realtime"] } openbitfun-core-types = { path = "../../crates/contracts/core-types" } openbitfun-agent-tools = { path = "../../crates/execution/tool-contracts", features = ["element-token"] } openbitfun-transport = { path = "../../crates/adapters/transport", features = ["tauri-adapter"] } diff --git a/src/apps/desktop/README.md b/src/apps/desktop/README.md index 00cc8f7c3e..7ae0278027 100644 --- a/src/apps/desktop/README.md +++ b/src/apps/desktop/README.md @@ -25,3 +25,25 @@ Application updates always belong to the local desktop, including while viewing a peer device or a remote workspace. They do not install software on the peer or cancel independently running detached jobs on another host. Connections through the restarting desktop are interrupted. + +## Windows WSL workspaces + +In the remote connection dialog, choose **Workspace target → Windows WSL**. +Select an installed Linux distribution, optionally enter a Linux user, then +connect and choose a folder inside that distribution. **Refresh distributions** +reloads the list after you install another distribution. No SSH server or SSH +credentials are needed. WSL must already be installed and the distribution must +have completed its first-run setup on the Windows host. + +Files, search, Git, Agent commands, and terminal sessions use the selected Linux +filesystem and processes. Paths use POSIX separators. Saved connections retain +the distribution and optional user for reconnect; omitting the user uses the +distribution's configured default user. + +In Peer Device Mode, the selected Windows Desktop host owns WSL discovery and +execution. Older peers and CLI peers explicitly refuse native WSL setup; +non-Windows hosts show an unsupported state. Existing mobile and bot session +controls can continue driving a host session, but do not expose WSL connection +setup. Detached Dispatch does not provision WSL connections. SSH port forwarding +is unavailable for native WSL targets; use Windows WSL networking to reach a +Linux service. diff --git a/src/apps/desktop/src/api/ssh_api.rs b/src/apps/desktop/src/api/ssh_api.rs index 6401bb4049..d89012c158 100644 --- a/src/apps/desktop/src/api/ssh_api.rs +++ b/src/apps/desktop/src/api/ssh_api.rs @@ -32,7 +32,7 @@ async fn hydrate_stored_password( // Local Docker Exec/Auto profiles do not require SSH credentials. Older // saved profiles may therefore legitimately contain an empty Password // auth placeholder with no vault entry. - if config.uses_local_docker() { + if config.uses_local_process() { return Ok(()); } if let SSHAuthMethod::Password { ref password } = config.auth { @@ -54,6 +54,14 @@ async fn hydrate_stored_password( Ok(()) } +#[tauri::command] +pub async fn ssh_list_wsl_distributions( +) -> Result { + openbitfun_services_integrations::remote_ssh::wsl::list_distributions() + .await + .map_err(|error| error.to_string()) +} + #[tauri::command] pub async fn ssh_list_saved_connections( state: State<'_, AppState>, @@ -1242,6 +1250,7 @@ mod tests { user: None, interactive: true, }), + wsl: None, options: Default::default(), }; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 86be8e24fd..d113f3e0d9 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -2020,6 +2020,7 @@ pub async fn run() { api::ssh_api::ssh_connect, api::ssh_api::ssh_test_connection, api::ssh_api::ssh_list_docker_containers, + api::ssh_api::ssh_list_wsl_distributions, api::ssh_api::ssh_disconnect, api::ssh_api::ssh_disconnect_all, api::ssh_api::ssh_is_connected, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/port_forward_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/port_forward_tool.rs index fd80e9ad76..f61f7457f5 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/port_forward_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/port_forward_tool.rs @@ -236,6 +236,7 @@ impl PortForwardTool { default_workspace: None, proxy_jump: config_entry.and_then(|entry| entry.proxy_jump), container: None, + wsl: None, options: SSHConnectionOptions::default(), }) .await diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index 0a0ceff153..07892b36eb 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "9973af8307e72206bb5c4af5dfb3249fef2def6a27925a13aca9a25ac62ef8a3", + "digest": "9a3d11cb4a198369ceb62ae82e1d0e1e6bddb658f4a790ec84e9eb4123803e5a", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -138,11 +138,11 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 320, + "documentedItems": 321, "controlCoverage": { "direct": 48, "delegated": 61, - "interactive": 211, + "interactive": 212, "unsupported": 0 } }, @@ -8145,6 +8145,7 @@ "ssh-profiles", "ssh-auth", "docker", + "wsl", "remote-open", "remote-files", "transfer", @@ -8329,6 +8330,52 @@ "actionId": "project.new" } }, + { + "id": "feature.remote-workspaces:open:wsl", + "capabilityId": "feature.remote-workspaces", + "itemIds": [ + "wsl" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "action", + "actionId": "project.new" + } + }, { "id": "feature.remote-workspaces:open:remote-open", "capabilityId": "feature.remote-workspaces", @@ -23549,6 +23596,17 @@ "reasonEn": "“Discover remote Docker containers and use a container as the work environment” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, + { + "id": "wsl", + "titleZh": "选择 Windows 主机上的 WSL 发行版作为工作区", + "titleEn": "Choose a WSL distribution on the Windows host as a workspace", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "打开远程连接对话框,由用户选择发行版、Linux 用户和工作区目录。", + "reasonEn": "Open the remote connection dialog for the user to choose a distribution, Linux user, and workspace directory." + } + }, { "id": "remote-open", "titleZh": "打开、关闭和移除远程工作区,并读取服务器信息", @@ -23701,6 +23759,8 @@ "Connect with passwords, private keys, certificates, SSH config hosts, and jump hosts", "发现远程 Docker 容器并把容器作为工作环境", "Discover remote Docker containers and use a container as the work environment", + "选择 Windows 主机上的 WSL 发行版作为工作区", + "Choose a WSL distribution on the Windows host as a workspace", "打开、关闭和移除远程工作区,并读取服务器信息", "Open, close, and remove remote workspaces, and inspect server information", "浏览、读取、写入、创建、重命名和删除远程文件与目录", diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index 814d75153a..f44eaa3dc1 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:90117ec8e98d6d48", + "digest": "fnv1a64:66d9fd8002bf016a", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -13,6 +13,7 @@ "targeted_session_rollback", "token_usage_statistics", "miniapp_agent_context_files_v1", + "wsl_workspaces_v1", "product_control_v1", "product_control_native_v1", "product_control_presentation_v1", @@ -25,6 +26,7 @@ "targeted_session_rollback", "token_usage_statistics", "miniapp_agent_context_files_v1", + "wsl_workspaces_v1", "product_control_v1", "product_control_native_v1", "product_control_presentation_v1", @@ -7320,6 +7322,18 @@ "reason": "the CLI peer host does not run this desktop IDE surface" } }, + { + "id": "ssh_list_wsl_distributions", + "surface": "tauri_command", + "remoteWorkspace": "WorkspaceAgnostic", + "peer": { + "kind": "proxied" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the CLI peer host does not run this desktop IDE surface" + } + }, { "id": "ssh_save_connection", "surface": "tauri_command", diff --git a/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs b/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs index 1a8c2ba8c0..74aa2f0d33 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs @@ -31,6 +31,8 @@ pub enum PeerHostCapability { TokenUsageStatistics, /// `miniapp_agent_run` accepts non-empty `contextFiles`. MiniappAgentContextFilesV1, + /// WSL profiles and discovery are understood; availability is probed on the host. + WslWorkspacesV1, /// `product_control_invoke` is implemented (shared config contract). ProductControlV1, /// ProductControl definitions that need a host-native provider run here. @@ -52,6 +54,7 @@ impl PeerHostCapability { Self::TargetedSessionRollback, Self::TokenUsageStatistics, Self::MiniappAgentContextFilesV1, + Self::WslWorkspacesV1, Self::ProductControlV1, Self::ProductControlNativeV1, Self::ProductControlPresentationV1, @@ -67,6 +70,7 @@ impl PeerHostCapability { Self::TargetedSessionRollback => "targeted_session_rollback", Self::TokenUsageStatistics => "token_usage_statistics", Self::MiniappAgentContextFilesV1 => "miniapp_agent_context_files_v1", + Self::WslWorkspacesV1 => "wsl_workspaces_v1", Self::ProductControlV1 => "product_control_v1", Self::ProductControlNativeV1 => "product_control_native_v1", Self::ProductControlPresentationV1 => "product_control_presentation_v1", @@ -84,8 +88,8 @@ impl PeerHostCapability { const DESKTOP_CAPABILITIES: &[PeerHostCapability] = PeerHostCapability::ALL; -/// The CLI peer host has no MiniApp runtime, no host-native ProductControl -/// providers, and no presentation surface; everything else it shares. +/// The CLI peer host has no MiniApp runtime, WSL connection setup, host-native +/// ProductControl providers, or presentation surface. const CLI_CAPABILITIES: &[PeerHostCapability] = &[ PeerHostCapability::IdempotentDialogSubmit, PeerHostCapability::TargetedSessionRollback, diff --git a/src/crates/contracts/product-domains/src/remote_surface/table.rs b/src/crates/contracts/product-domains/src/remote_surface/table.rs index 8c99e932fc..f8736c3162 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -698,6 +698,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("ssh_list_port_forwards", Agnostic, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), op("ssh_list_remote_listening_ports", Agnostic, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), op("ssh_list_saved_connections", Agnostic, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), + op("ssh_list_wsl_distributions", Agnostic, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), op("ssh_save_connection", Agnostic, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), op("ssh_start_port_forward", Agnostic, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), op("ssh_stop_port_forward", Agnostic, Proxied, CLI_NO_DESKTOP_IDE_SURFACE), diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index 4c12713e86..61d6ef1948 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -111,6 +111,7 @@ cargo check -p openbitfun-services-integrations --no-default-features cargo test -p openbitfun-services-integrations --no-default-features --features mcp --test mcp_contracts cargo test -p openbitfun-services-integrations --no-default-features --features remote-ssh --test remote_ssh_contracts remote_ssh_disabled_contracts:: cargo test -p openbitfun-services-integrations --no-default-features --features remote-ssh-concrete --lib remote_ssh::manager::tests::workspace_ +cargo test -p openbitfun-services-integrations --no-default-features --features remote-ssh-concrete --lib remote_ssh::wsl::tests:: cargo test -p openbitfun-services-integrations --no-default-features --features file-watch --test file_watch_contracts cargo test --locked -p openbitfun-services-integrations --no-default-features --features deep-research --lib deep_research::tests:: pnpm run check:core-boundaries @@ -118,3 +119,8 @@ pnpm run check:core-boundaries Other family-specific targets remain in `Cargo.toml`; add a guide command only for a recurring workflow, not to mirror every test target. + +On Windows with an initialized WSL distribution, set `OPENBITFUN_TEST_WSL_DISTRO` +and run `cargo test -p openbitfun-services-integrations --no-default-features +--features remote-ssh-concrete --lib wsl_windows_workspace_transport -- --ignored` +for binary filesystem/stdio, exit status, cancellation, and saved reconnect. diff --git a/src/crates/services/services-integrations/src/remote_ssh/manager.rs b/src/crates/services/services-integrations/src/remote_ssh/manager.rs index a09112fd1b..db94c8cb56 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/manager.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/manager.rs @@ -545,7 +545,7 @@ pub struct KnownHostEntry { /// Active SSH connection struct ActiveConnection { - /// Absent only for a local Docker container workspace. + /// Absent for local process transports (Docker and WSL). handle: Option>>, /// Keep every preceding hop alive while the final transport is using its /// direct-tcpip channel as the underlying stream. @@ -1616,12 +1616,16 @@ fn supervised_container_command( container: &ContainerWorkspaceConfig, command: &str, ) -> (String, String) { + supervised_workspace_command(&container.shell, command) +} + +fn supervised_workspace_command(shell: &str, command: &str) -> (String, String) { let pid_file = format!( "/tmp/{}-exec-{}.pid", hidden_data_directory(), uuid::Uuid::new_v4() ); - let wrapped = supervised_container_command_with_pid_file(container, command, &pid_file); + let wrapped = supervised_workspace_command_with_pid_file(shell, command, &pid_file); (wrapped, pid_file) } @@ -1637,14 +1641,23 @@ fn supervised_container_command( /// The supervisor must also duplicate stdin before starting the asynchronous /// child. POSIX non-interactive shells attach `/dev/null` to an asynchronous /// list's fd 0, so `<&0` inside that list cannot preserve streamed input. +#[cfg(test)] fn supervised_container_command_with_pid_file( container: &ContainerWorkspaceConfig, command: &str, pid_file: &str, +) -> String { + supervised_workspace_command_with_pid_file(&container.shell, command, pid_file) +} + +fn supervised_workspace_command_with_pid_file( + shell: &str, + command: &str, + pid_file: &str, ) -> String { let quoted_pid_file = crate::remote_ssh::shell::quote_arg(pid_file); let quoted_command = crate::remote_ssh::shell::quote_arg(command); - let quoted_shell = crate::remote_ssh::shell::quote_arg(&container.shell); + let quoted_shell = crate::remote_ssh::shell::quote_arg(shell); let sweep = stale_pid_file_sweep(); format!( "{sweep}\ @@ -2554,7 +2567,14 @@ impl SSHConnectionManager { /// suitable for the connection dialog. pub async fn test_connection(&self, config: &SSHConnectionConfig) -> ConnectionTestReport { let mut stages = Vec::new(); - if config.uses_local_docker() { + if let Some(wsl) = &config.wsl { + stages.push(ConnectionTestStage { + id: "wsl".into(), + label: wsl.distribution.clone(), + success: false, + error: None, + }); + } else if config.uses_local_docker() { stages.push(ConnectionTestStage { id: "docker-host".to_string(), label: "local".to_string(), @@ -2742,10 +2762,11 @@ impl SSHConnectionManager { if !matches!( conn.auth_type, crate::remote_ssh::types::SavedAuthType::Password - ) || conn - .container - .as_ref() - .is_some_and(|container| container.local) + ) || conn.wsl.is_some() + || conn + .container + .as_ref() + .is_some_and(|container| container.local) { continue; } @@ -2782,7 +2803,7 @@ impl SSHConnectionManager { /// Save a connection configuration pub async fn save_connection(&self, config: &SSHConnectionConfig) -> anyhow::Result<()> { - match (&config.auth, config.uses_local_docker()) { + match (&config.auth, config.uses_local_process()) { (_, true) => { self.password_vault.remove(&config.id).await?; } @@ -2816,7 +2837,8 @@ impl SSHConnectionManager { c.id != config.id && !(c.host == config.host && c.username == config.username - && c.container == config.container) + && c.container == config.container + && c.wsl == config.wsl) }); // Add new entry @@ -2851,6 +2873,7 @@ impl SSHConnectionManager { last_connected: Some(chrono::Utc::now().timestamp() as u64), proxy_jump: config.proxy_jump.clone(), container: config.container.clone(), + wsl: config.wsl.clone(), options: config.options.clone(), }); @@ -3004,6 +3027,25 @@ impl SSHConnectionManager { config: &SSHConnectionConfig, timeout_secs: u64, ) -> anyhow::Result { + if let Some(wsl) = &config.wsl { + if config.container.is_some() + || config + .proxy_jump + .as_deref() + .is_some_and(|jump| !jump.trim().is_empty()) + { + anyhow::bail!("WSL cannot be combined with Docker or SSH jump hosts"); + } + let server_info = super::wsl::probe(wsl, Duration::from_secs(timeout_secs)).await?; + return Ok(EstablishedSession { + handle: None, + jump_handles: Vec::new(), + alive: Arc::new(AtomicBool::new(true)), + server_info: Some(server_info), + effective_config: config.clone(), + unpublished: UnpublishedSshSession::default(), + }); + } if config.uses_local_docker() { if config .container @@ -3461,6 +3503,7 @@ impl SSHConnectionManager { default_workspace: None, proxy_jump: None, container: None, + wsl: None, options: config.options.clone(), }); } @@ -4194,6 +4237,9 @@ impl SSHConnectionManager { if !alive { return false; } + if let Some(wsl) = &config.wsl { + return super::wsl::is_running(wsl).await; + } if !config.uses_local_docker() { return true; } @@ -4226,13 +4272,14 @@ impl SSHConnectionManager { return Ok(None); }; - let local_docker = saved - .container - .as_ref() - .is_some_and(|container| container.local); + let local_process = saved.wsl.is_some() + || saved + .container + .as_ref() + .is_some_and(|container| container.local); let auth = match saved.auth_type { crate::remote_ssh::types::SavedAuthType::Password => { - let password = if local_docker { + let password = if local_process { String::new() } else { self.password_vault.load(connection_id).await?.ok_or_else(|| { @@ -4276,6 +4323,7 @@ impl SSHConnectionManager { default_workspace: saved.default_workspace, proxy_jump: saved.proxy_jump, container: saved.container, + wsl: saved.wsl, options: saved.options, })) } @@ -4388,7 +4436,7 @@ impl SSHConnectionManager { // Refresh the password from the encrypted vault if password auth was // configured but the in-memory copy is empty (defensive — covers cases // where callers cleared it intentionally). - if !config.uses_local_docker() { + if !config.uses_local_process() { if let SSHAuthMethod::Password { ref password } = config.auth { if password.is_empty() { match self.password_vault.load(connection_id).await { @@ -4544,7 +4592,7 @@ impl SSHConnectionManager { ) }; - if config.uses_docker_exec() { + if config.uses_shell_filesystem() { let mut setup_options = self.workspace_setup_options(connection_id).await; setup_options.timeout_ms = match (setup_options.timeout_ms, options.timeout_ms) { (Some(setup), Some(command)) => Some(setup.min(command)), @@ -4586,8 +4634,8 @@ impl SSHConnectionManager { connection.effective_config.clone(), ) }; - if config.uses_local_docker() { - anyhow::bail!("Local Docker execution does not use an SSH channel"); + if config.uses_local_process() { + anyhow::bail!("Local Docker and WSL execution do not use an SSH channel"); } let handle = handle.ok_or_else(|| anyhow!("SSH handle is unavailable for {}", connection_id))?; @@ -4646,8 +4694,8 @@ impl SSHConnectionManager { connection.effective_config.clone(), ) }; - if config.uses_local_docker() { - anyhow::bail!("A local Docker workspace has no SSH transport to forward over"); + if config.uses_local_process() { + anyhow::bail!("Local Docker and WSL workspaces have no SSH transport to forward over"); } let handle = handle.ok_or_else(|| anyhow!("SSH handle is unavailable for {}", connection_id))?; @@ -4746,6 +4794,23 @@ impl SSHConnectionManager { connection.effective_config.clone(), ) }; + if let Some(wsl) = config.wsl { + super::wsl::ensure_supported()?; + super::wsl::validate(&wsl)?; + let (supervised_command, pid_file) = supervised_workspace_command("/bin/sh", command); + let signal_config = wsl.clone(); + let signal_hook: crate::remote_ssh::transport::WorkspaceSignalHook = + Arc::new(move |signal| { + let config = signal_config.clone(); + let command = container_signal_command(&pid_file, signal); + Box::pin(async move { super::wsl::signal(&config, &command).await }) + }); + return crate::remote_ssh::WorkspaceStdio::spawn_local_process_with_signal_hook( + super::wsl::EXECUTABLE, + &super::wsl::exec_args(&wsl, &supervised_command), + Some(signal_hook), + ); + } if config.uses_docker_exec() { let container = config .container @@ -4811,8 +4876,8 @@ impl SSHConnectionManager { connection.effective_config.clone(), ) }; - if config.uses_local_docker() { - anyhow::bail!("Local Docker execution does not use an SSH channel"); + if config.uses_local_process() { + anyhow::bail!("Local Docker and WSL execution do not use an SSH channel"); } let handle = handle.ok_or_else(|| anyhow!("SSH handle is unavailable for {}", connection_id))?; @@ -4899,27 +4964,36 @@ impl SSHConnectionManager { .map(|connection| connection.effective_config.clone()) } - pub async fn is_local_container_connection(&self, connection_id: &str) -> bool { + pub async fn is_local_process_connection(&self, connection_id: &str) -> bool { self.get_effective_connection_config(connection_id) .await - .is_some_and(|config| config.uses_local_docker()) + .is_some_and(|config| config.uses_local_process()) } - pub async fn is_container_workspace(&self, connection_id: &str) -> bool { + pub async fn is_shell_workspace(&self, connection_id: &str) -> bool { self.get_effective_connection_config(connection_id) .await - .is_some_and(|config| config.uses_docker_exec()) + .is_some_and(|config| config.uses_shell_filesystem()) } - pub async fn local_container_exec_spec( + pub async fn local_process_exec_spec( &self, connection_id: &str, command: &str, tty: bool, ) -> anyhow::Result)>> { + self.ensure_alive_or_reconnect(connection_id).await?; let Some(config) = self.get_effective_connection_config(connection_id).await else { anyhow::bail!("Connection {} not found", connection_id); }; + if let Some(wsl) = &config.wsl { + super::wsl::ensure_supported()?; + super::wsl::validate(wsl)?; + return Ok(Some(( + super::wsl::EXECUTABLE.into(), + super::wsl::exec_args(wsl, command), + ))); + } if !config.uses_local_docker() { return Ok(None); } @@ -4934,14 +5008,24 @@ impl SSHConnectionManager { ))) } - pub async fn local_container_shell_spec( + pub async fn local_process_shell_spec( &self, connection_id: &str, cwd: Option<&str>, ) -> anyhow::Result)>> { + self.ensure_alive_or_reconnect(connection_id).await?; let Some(config) = self.get_effective_connection_config(connection_id).await else { anyhow::bail!("Connection {} not found", connection_id); }; + if let Some(wsl) = &config.wsl { + super::wsl::ensure_supported()?; + super::wsl::validate(wsl)?; + super::wsl::validate_cwd(cwd)?; + return Ok(Some(( + super::wsl::EXECUTABLE.into(), + super::wsl::shell_args(wsl, cwd), + ))); + } if !config.uses_local_docker() { return Ok(None); } @@ -5012,7 +5096,7 @@ impl SSHConnectionManager { path: &str, ) -> anyhow::Result { let path = self.resolve_sftp_path(connection_id, path).await?; - if self.is_container_workspace(connection_id).await { + if self.is_shell_workspace(connection_id).await { let command = format!( "[ -f {0} ] || {{ echo 'Workspace path is not a readable regular file' >&2; exit 1; }}; cat -- {0}", crate::remote_ssh::shell::quote_arg(&path) @@ -5039,7 +5123,7 @@ impl SSHConnectionManager { permissions: u32, ) -> anyhow::Result<()> { let path = self.resolve_sftp_path(connection_id, path).await?; - if self.is_container_workspace(connection_id).await { + if self.is_shell_workspace(connection_id).await { let command = format!( "chmod {:o} -- {}", permissions & 0o7777, @@ -5071,7 +5155,7 @@ impl SSHConnectionManager { .context("Remote file modification time is before the Unix epoch")? .as_secs(); let path = self.resolve_sftp_path(connection_id, path).await?; - if self.is_container_workspace(connection_id).await { + if self.is_shell_workspace(connection_id).await { let command = format!( "touch -m -d @{} -- {}", seconds, @@ -5176,7 +5260,7 @@ impl SSHConnectionManager { content: &[u8], ) -> anyhow::Result<()> { use tokio::io::AsyncWriteExt; - if !self.is_container_workspace(connection_id).await { + if !self.is_shell_workspace(connection_id).await { return self.sftp_write(connection_id, path, content).await; } let path = self.resolve_sftp_path(connection_id, path).await?; @@ -6627,6 +6711,7 @@ mod tests { default_workspace: None, proxy_jump: None, container: None, + wsl: None, options: Default::default(), }, requested, @@ -7134,6 +7219,7 @@ mod tests { user: None, interactive: true, }), + wsl: None, options: Default::default(), }; manager.connections.write().await.insert( @@ -7381,6 +7467,7 @@ mod tests { user: None, interactive: true, }), + wsl: None, options: Default::default(), }; let manager = SSHConnectionManager::new(root.join("manager-data")); @@ -7766,6 +7853,7 @@ mod tests { default_workspace: Some("/srv/project".to_string()), proxy_jump: None, container: None, + wsl: None, options: Default::default(), }; let alive = Arc::new(AtomicBool::new(true)); @@ -8149,6 +8237,7 @@ mod tests { user: None, interactive: true, }), + wsl: None, options: Default::default(), }) .await @@ -8397,6 +8486,7 @@ mod tests { default_workspace: None, proxy_jump: None, container: None, + wsl: None, options: Default::default(), }) .await; @@ -8425,6 +8515,7 @@ mod tests { default_workspace: Some("/root/project".to_string()), proxy_jump: Some("jump-a,jump-b".to_string()), container: None, + wsl: None, options: Default::default(), }) .await @@ -8514,6 +8605,7 @@ mod tests { default_workspace: Some("/workspace".to_string()), proxy_jump: Some("jump.example.com".to_string()), container: None, + wsl: None, options: Default::default(), }) .await @@ -8558,6 +8650,7 @@ mod tests { default_workspace: None, proxy_jump: None, container: None, + wsl: None, options: Default::default(), }) .await diff --git a/src/crates/services/services-integrations/src/remote_ssh/mod.rs b/src/crates/services/services-integrations/src/remote_ssh/mod.rs index 84f9a93c9a..a86907384e 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/mod.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/mod.rs @@ -15,6 +15,8 @@ pub mod workspace_registry; #[cfg(feature = "workspace-search")] pub mod workspace_search; mod workspace_services; +#[cfg(feature = "remote-ssh-concrete")] +pub mod wsl; #[cfg(not(feature = "remote-ssh-concrete"))] mod disabled; diff --git a/src/crates/services/services-integrations/src/remote_ssh/port_forward.rs b/src/crates/services/services-integrations/src/remote_ssh/port_forward.rs index 2cf848389e..5b76fd6898 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/port_forward.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/port_forward.rs @@ -255,10 +255,10 @@ impl PortForwardManager { // first request: a local Docker workspace is reached by `docker exec` // and has no SSH session to carry a channel. Discovering that after // opening a browser would be a worse way to learn it. - if manager.is_local_container_connection(connection_id).await { + if manager.is_local_process_connection(connection_id).await { anyhow::bail!( - "A local Docker workspace has no SSH transport to forward over. \ - Publish the container port with `docker run -p` instead." + "Local Docker and WSL workspaces do not support SSH port forwarding. \ + Use the target's published ports or Windows WSL networking instead." ); } diff --git a/src/crates/services/services-integrations/src/remote_ssh/remote_exec.rs b/src/crates/services/services-integrations/src/remote_ssh/remote_exec.rs index 5e6467673e..1230524293 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/remote_exec.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/remote_exec.rs @@ -705,20 +705,28 @@ async fn spawn_remote_process( if request.tty { if let Some(spec) = request .ssh_manager - .local_container_exec_spec(&request.connection_id, &request.command, true) + .local_process_exec_spec(&request.connection_id, &request.command, true) .await? { - return spawn_local_container_pty_process(request, spec).await; + return spawn_local_workspace_pty_process(request, spec).await; } return spawn_remote_pty_process(request).await; } spawn_remote_pipe_process(request).await } -async fn spawn_local_container_pty_process( +async fn spawn_local_workspace_pty_process( request: RemoteExecCommandRequest, (executable, args): (String, Vec), ) -> anyhow::Result { + let shell_type = ShellType::Custom( + if executable == super::wsl::EXECUTABLE { + "WSL" + } else { + "Docker" + } + .to_string(), + ); let shell_config = ShellConfig { executable, args, @@ -731,14 +739,8 @@ async fn spawn_local_container_pty_process( .try_into() .expect("UUID prefix is four bytes"), ); - let spawned = spawn_pty( - process_id, - &shell_config, - ShellType::Custom("Docker".to_string()), - 80, - 24, - ) - .map_err(|error| anyhow!("Failed to start local Docker PTY: {}", error))?; + let spawned = spawn_pty(process_id, &shell_config, shell_type, 80, 24) + .map_err(|error| anyhow!("Failed to start local workspace PTY: {}", error))?; let output = Arc::new(OutputState::new(request.output_capture_tx.clone())); let (command_tx, mut command_rx) = mpsc::channel::(64); let owner_output = output.clone(); diff --git a/src/crates/services/services-integrations/src/remote_ssh/remote_fs.rs b/src/crates/services/services-integrations/src/remote_ssh/remote_fs.rs index 0d4990b34a..2f40864d3b 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/remote_fs.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/remote_fs.rs @@ -72,7 +72,7 @@ impl RemoteFileService { follow_symlinks: bool, ) -> anyhow::Result> { let manager = self.get_manager(connection_id).await?; - if manager.is_container_workspace(connection_id).await { + if manager.is_shell_workspace(connection_id).await { return manager .container_workspace_metadata(connection_id, path, follow_symlinks) .await; @@ -146,7 +146,7 @@ impl RemoteFileService { /// Read a file from the remote server via SFTP pub async fn read_file(&self, connection_id: &str, path: &str) -> anyhow::Result> { let manager = self.get_manager(connection_id).await?; - if manager.is_container_workspace(connection_id).await { + if manager.is_shell_workspace(connection_id).await { return manager.container_read_file(connection_id, path).await; } manager.sftp_read(connection_id, path).await @@ -162,7 +162,7 @@ impl RemoteFileService { on_progress: &mut impl FnMut(u64, u64) -> bool, ) -> anyhow::Result> { let manager = self.get_manager(connection_id).await?; - if manager.is_container_workspace(connection_id).await { + if manager.is_shell_workspace(connection_id).await { return manager .container_read_file_with_progress(connection_id, path, on_progress) .await; @@ -180,7 +180,7 @@ impl RemoteFileService { content: &[u8], ) -> anyhow::Result<()> { let manager = self.get_manager(connection_id).await?; - if manager.is_container_workspace(connection_id).await { + if manager.is_shell_workspace(connection_id).await { return manager .container_write_file(connection_id, path, content) .await; @@ -213,7 +213,7 @@ impl RemoteFileService { on_progress: &mut impl FnMut(u64, u64) -> bool, ) -> anyhow::Result<()> { let manager = self.get_manager(connection_id).await?; - if manager.is_container_workspace(connection_id).await { + if manager.is_shell_workspace(connection_id).await { return manager .container_write_file_with_progress(connection_id, path, content, on_progress) .await; @@ -226,7 +226,7 @@ impl RemoteFileService { /// Check if a remote path exists pub async fn exists(&self, connection_id: &str, path: &str) -> anyhow::Result { let manager = self.get_manager(connection_id).await?; - if manager.is_container_workspace(connection_id).await { + if manager.is_shell_workspace(connection_id).await { return manager.container_exists(connection_id, path).await; } manager.sftp_exists(connection_id, path).await @@ -274,7 +274,7 @@ impl RemoteFileService { max_entries: Option, ) -> anyhow::Result> { let manager = self.get_manager(connection_id).await?; - if manager.is_container_workspace(connection_id).await { + if manager.is_shell_workspace(connection_id).await { return match max_entries { Some(max_entries) => { manager @@ -447,7 +447,7 @@ impl RemoteFileService { /// Create a directory on the remote server via SFTP pub async fn create_dir(&self, connection_id: &str, path: &str) -> anyhow::Result<()> { let manager = self.get_manager(connection_id).await?; - if manager.is_container_workspace(connection_id).await { + if manager.is_shell_workspace(connection_id).await { return manager.container_mkdir(connection_id, path, false).await; } manager.sftp_mkdir(connection_id, path).await @@ -456,7 +456,7 @@ impl RemoteFileService { /// Create directory and all parent directories via SFTP pub async fn create_dir_all(&self, connection_id: &str, path: &str) -> anyhow::Result<()> { let manager = self.get_manager(connection_id).await?; - if manager.is_container_workspace(connection_id).await { + if manager.is_shell_workspace(connection_id).await { return manager.container_mkdir(connection_id, path, true).await; } manager.sftp_mkdir_all(connection_id, path).await @@ -465,7 +465,7 @@ impl RemoteFileService { /// Remove a file from the remote server via SFTP pub async fn remove_file(&self, connection_id: &str, path: &str) -> anyhow::Result<()> { let manager = self.get_manager(connection_id).await?; - if manager.is_container_workspace(connection_id).await { + if manager.is_shell_workspace(connection_id).await { return manager.container_remove(connection_id, path, false).await; } manager.sftp_remove(connection_id, path).await @@ -496,7 +496,7 @@ impl RemoteFileService { Box::pin(self.remove_dir_all(connection_id, &entry_path)).await?; } else { let manager = self.get_manager(connection_id).await?; - if manager.is_container_workspace(connection_id).await { + if manager.is_shell_workspace(connection_id).await { manager .container_remove(connection_id, &entry_path, false) .await?; @@ -509,7 +509,7 @@ impl RemoteFileService { // Then remove the directory itself let manager = self.get_manager(connection_id).await?; - if manager.is_container_workspace(connection_id).await { + if manager.is_shell_workspace(connection_id).await { manager.container_remove(connection_id, path, true).await } else { manager.sftp_rmdir(connection_id, path).await @@ -519,7 +519,7 @@ impl RemoteFileService { /// Remove an empty directory via SFTP (non-recursive; fails if not empty) pub async fn remove_dir(&self, connection_id: &str, path: &str) -> anyhow::Result<()> { let manager = self.get_manager(connection_id).await?; - if manager.is_container_workspace(connection_id).await { + if manager.is_shell_workspace(connection_id).await { manager.container_remove(connection_id, path, true).await } else { manager.sftp_rmdir(connection_id, path).await @@ -534,7 +534,7 @@ impl RemoteFileService { new_path: &str, ) -> anyhow::Result<()> { let manager = self.get_manager(connection_id).await?; - if manager.is_container_workspace(connection_id).await { + if manager.is_shell_workspace(connection_id).await { return manager .container_rename(connection_id, old_path, new_path) .await; @@ -549,7 +549,7 @@ impl RemoteFileService { path: &str, ) -> anyhow::Result> { let manager = self.get_manager(connection_id).await?; - if manager.is_container_workspace(connection_id).await { + if manager.is_shell_workspace(connection_id).await { return manager.container_stat(connection_id, path).await; } @@ -563,7 +563,7 @@ impl RemoteFileService { path: &str, ) -> anyhow::Result> { let manager = self.get_manager(connection_id).await?; - if manager.is_container_workspace(connection_id).await { + if manager.is_shell_workspace(connection_id).await { return manager.container_stat(connection_id, path).await; } remote_file_entry_from_stat_result(path, manager.sftp_lstat(connection_id, path).await) diff --git a/src/crates/services/services-integrations/src/remote_ssh/remote_terminal.rs b/src/crates/services/services-integrations/src/remote_ssh/remote_terminal.rs index 1dacf6289e..060b185f0b 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/remote_terminal.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/remote_terminal.rs @@ -96,7 +96,8 @@ impl RemoteTerminalManager { let session_id = session_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); let name = name.unwrap_or_else(|| format!("Remote Terminal {}", &session_id[..8])); - if manager.is_local_container_connection(connection_id).await { + manager.ensure_connected(connection_id).await?; + if manager.is_local_process_connection(connection_id).await { let cwd = if let Some(dir) = initial_cwd { dir.to_string() } else { @@ -113,12 +114,12 @@ impl RemoteTerminalManager { } }; let spec = manager - .local_container_shell_spec(connection_id, (cwd != "~").then_some(cwd.as_str())) + .local_process_shell_spec(connection_id, (cwd != "~").then_some(cwd.as_str())) .await? - .ok_or_else(|| anyhow::anyhow!("Local container shell is unavailable"))?; + .ok_or_else(|| anyhow::anyhow!("Local workspace shell is unavailable"))?; drop(ssh_guard); return self - .create_local_container_session( + .create_local_workspace_session( session_id, name, connection_id, @@ -311,7 +312,7 @@ impl RemoteTerminalManager { } #[allow(clippy::too_many_arguments)] - async fn create_local_container_session( + async fn create_local_workspace_session( &self, session_id: String, name: String, @@ -322,6 +323,14 @@ impl RemoteTerminalManager { source: SessionSource, (executable, args): (String, Vec), ) -> anyhow::Result { + let shell_type = ShellType::Custom( + if executable == super::wsl::EXECUTABLE { + "WSL" + } else { + "Docker" + } + .to_string(), + ); let shell_config = ShellConfig { executable, args, @@ -334,14 +343,10 @@ impl RemoteTerminalManager { .try_into() .expect("UUID prefix is four bytes"), ); - let spawned = spawn_pty( - process_id, - &shell_config, - ShellType::Custom("Docker".to_string()), - cols, - rows, - ) - .map_err(|error| anyhow::anyhow!("Failed to start local Docker terminal: {}", error))?; + let spawned = + spawn_pty(process_id, &shell_config, shell_type, cols, rows).map_err(|error| { + anyhow::anyhow!("Failed to start local workspace terminal: {}", error) + })?; let (output_tx, output_rx) = broadcast::channel::>(1000); let (cmd_tx, mut cmd_rx) = mpsc::channel::(100); diff --git a/src/crates/services/services-integrations/src/remote_ssh/types.rs b/src/crates/services/services-integrations/src/remote_ssh/types.rs index 31640c4736..db4a1b9c29 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/types.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/types.rs @@ -55,6 +55,9 @@ pub struct SSHConnectionConfig { /// Optional Docker container that becomes the effective workspace target. #[serde(default)] pub container: Option, + /// A Linux distribution owned by the Windows host running OpenBitFun. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub wsl: Option, /// Connection and authentication timeout/retry policy. #[serde(default)] pub options: SSHConnectionOptions, @@ -72,6 +75,7 @@ impl SSHConnectionConfig { && self.auth.connection_params_equal(&other.auth) && self.proxy_jump == other.proxy_jump && self.container == other.container + && self.wsl == other.wsl && self.options == other.options } @@ -81,6 +85,14 @@ impl SSHConnectionConfig { .is_some_and(|container| container.local) } + pub fn uses_local_process(&self) -> bool { + self.wsl.is_some() || self.uses_local_docker() + } + + pub fn uses_shell_filesystem(&self) -> bool { + self.wsl.is_some() || self.uses_docker_exec() + } + pub fn uses_docker_exec(&self) -> bool { self.container.as_ref().is_some_and(|container| { matches!( @@ -170,6 +182,23 @@ fn default_docker_path() -> String { "docker".to_string() } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WslWorkspaceConfig { + pub distribution: String, + /// Omitted means the distribution's configured default Linux user. + #[serde(default)] + pub user: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WslDistributions { + /// Describes the executing host, never the controller's operating system. + pub supported: bool, + pub distributions: Vec, +} + fn default_container_shell() -> String { "/bin/sh".to_string() } @@ -334,6 +363,8 @@ pub struct SavedConnection { pub proxy_jump: Option, #[serde(default)] pub container: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub wsl: Option, #[serde(default)] pub options: SSHConnectionOptions, } diff --git a/src/crates/services/services-integrations/src/remote_ssh/wsl.rs b/src/crates/services/services-integrations/src/remote_ssh/wsl.rs new file mode 100644 index 0000000000..554faace80 --- /dev/null +++ b/src/crates/services/services-integrations/src/remote_ssh/wsl.rs @@ -0,0 +1,382 @@ +//! Native WSL transport. Linux paths and commands are interpreted in the named +//! distribution; the Windows host is only responsible for launching wsl.exe. + +use super::types::{ServerInfo, WslDistributions, WslWorkspaceConfig}; +use anyhow::{anyhow, Context}; +use openbitfun_services_core::process_manager; +use std::process::{Output, Stdio}; +use std::time::Duration; + +pub(crate) const EXECUTABLE: &str = "wsl.exe"; + +pub(crate) fn validate(config: &WslWorkspaceConfig) -> anyhow::Result<()> { + if config.distribution.trim().is_empty() + || config.distribution.contains(['\0', '\r', '\n']) + || config + .user + .as_deref() + .is_some_and(|user| user.contains(['\0', '\r', '\n'])) + { + anyhow::bail!("WSL requires a valid distribution name and Linux user"); + } + Ok(()) +} + +pub(crate) fn ensure_supported() -> anyhow::Result<()> { + if !cfg!(windows) { + anyhow::bail!("Native WSL workspaces require a Windows OpenBitFun host"); + } + Ok(()) +} + +pub(crate) fn validate_cwd(cwd: Option<&str>) -> anyhow::Result<()> { + if let Some(cwd) = cwd { + if cwd != "~" && !cwd.starts_with('/') || cwd.contains('\0') { + anyhow::bail!( + "WSL workspace directories must be absolute POSIX paths inside the distribution" + ); + } + } + Ok(()) +} + +pub(crate) fn shell_args(config: &WslWorkspaceConfig, cwd: Option<&str>) -> Vec { + let mut args = vec!["--distribution".into(), config.distribution.clone()]; + if let Some(user) = config + .user + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + args.extend(["--user".into(), user.into()]); + } + // Never inherit a Windows cwd (including controller paths) in Linux. + args.extend(["--cd".into(), cwd.unwrap_or("~").into()]); + args +} + +pub(crate) fn exec_args(config: &WslWorkspaceConfig, command: &str) -> Vec { + let mut args = shell_args(config, None); + args.extend([ + "--exec".into(), + "/bin/sh".into(), + "-lc".into(), + command.into(), + ]); + args +} + +async fn output(args: &[String], timeout: Duration) -> anyhow::Result { + ensure_supported()?; + tokio::time::timeout( + timeout, + process_manager::create_tokio_command(EXECUTABLE) + .args(args) + .stdin(Stdio::null()) + .kill_on_drop(true) + .output(), + ) + .await + .map_err(|_| anyhow!("WSL command timed out"))? + .context("Failed to start wsl.exe; install WSL and a Linux distribution on the Windows host") +} + +// WSL's Windows-side listing/errors can be UTF-16LE. Linux process stdout +// remains binary and must never pass through this decoder. +fn decode_cli_output(bytes: &[u8]) -> String { + if bytes.starts_with(&[0xff, 0xfe]) || bytes.iter().skip(1).step_by(2).any(|b| *b == 0) { + let bytes = bytes.strip_prefix(&[0xff, 0xfe]).unwrap_or(bytes); + String::from_utf16_lossy( + &bytes + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect::>(), + ) + } else { + String::from_utf8_lossy(bytes) + .trim_start_matches('\u{feff}') + .to_string() + } +} + +fn distribution_names(bytes: &[u8]) -> Vec { + decode_cli_output(bytes) + .lines() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} + +fn check_output(output: &Output) -> anyhow::Result<()> { + if !output.status.success() { + let mut detail = decode_cli_output(&output.stderr); + if detail.trim().is_empty() { + detail = decode_cli_output(&output.stdout); + } + anyhow::bail!("WSL command failed ({}): {}", output.status, detail.trim()); + } + Ok(()) +} + +pub async fn list_distributions() -> anyhow::Result { + if !cfg!(windows) { + return Ok(WslDistributions { + supported: false, + distributions: Vec::new(), + }); + } + let result = output( + &["--list".into(), "--quiet".into()], + Duration::from_secs(15), + ) + .await?; + check_output(&result)?; + Ok(WslDistributions { + supported: true, + distributions: distribution_names(&result.stdout), + }) +} + +pub(crate) async fn is_running(config: &WslWorkspaceConfig) -> bool { + let Ok(result) = output( + &["--list".into(), "--running".into(), "--quiet".into()], + Duration::from_secs(3), + ) + .await + else { + return false; + }; + result.status.success() && distribution_names(&result.stdout).contains(&config.distribution) +} + +pub(crate) async fn probe( + config: &WslWorkspaceConfig, + timeout: Duration, +) -> anyhow::Result { + validate(config)?; + let result = output( + &exec_args(config, "uname -s && hostname && printf '%s\\n' \"$HOME\""), + timeout, + ) + .await?; + check_output(&result)?; + let text = String::from_utf8(result.stdout) + .context("WSL returned invalid UTF-8 system information")?; + let lines: Vec<_> = text.lines().collect(); + if lines.len() != 3 || lines[0] != "Linux" || lines[1].is_empty() || !lines[2].starts_with('/') + { + anyhow::bail!( + "WSL distribution '{}' did not return valid Linux system information", + config.distribution + ); + } + Ok(ServerInfo { + os_type: lines[0].into(), + hostname: lines[1].into(), + home_dir: lines[2].into(), + }) +} + +pub(crate) async fn signal(config: &WslWorkspaceConfig, command: &str) -> anyhow::Result<()> { + let result = output(&exec_args(config, command), Duration::from_secs(3)).await?; + check_output(&result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wsl_cli_decodes_utf16_and_utf8_distribution_lists() { + let names = "Ubuntu-24.04\r\n自定义 Linux\r\n"; + let utf16: Vec<_> = names.encode_utf16().flat_map(u16::to_le_bytes).collect(); + assert_eq!( + distribution_names(&utf16), + vec!["Ubuntu-24.04", "自定义 Linux"] + ); + assert_eq!( + distribution_names(names.as_bytes()), + distribution_names(&utf16) + ); + assert_eq!( + distribution_names(&[&[0xff, 0xfe][..], &utf16].concat()), + distribution_names(&utf16) + ); + } + + #[test] + fn wsl_args_preserve_target_user_paths_and_command_as_separate_arguments() { + let config = WslWorkspaceConfig { + distribution: "My Linux".into(), + user: Some("dev".into()), + }; + let command = "printf '%s' \"a b; $(whoami)\""; + assert_eq!( + exec_args(&config, command), + vec![ + "--distribution", + "My Linux", + "--user", + "dev", + "--cd", + "~", + "--exec", + "/bin/sh", + "-lc", + command + ] + ); + assert_eq!( + shell_args(&config, Some("/home/dev/a b")), + vec![ + "--distribution", + "My Linux", + "--user", + "dev", + "--cd", + "/home/dev/a b" + ] + ); + } + + #[cfg(not(windows))] + #[tokio::test] + async fn wsl_non_windows_host_refuses_without_spawning_a_process() { + assert!(!list_distributions().await.unwrap().supported); + let config = WslWorkspaceConfig { + distribution: "Ubuntu".into(), + user: None, + }; + assert!(probe(&config, Duration::from_secs(1)) + .await + .unwrap_err() + .to_string() + .contains("Windows OpenBitFun host")); + } + #[test] + fn wsl_rejects_windows_and_relative_workspace_paths() { + assert!(validate_cwd(Some(r"C:\Users\dev\project")).is_err()); + assert!(validate_cwd(Some("relative/project")).is_err()); + assert!(validate_cwd(Some("/home/dev/project")).is_ok()); + assert!(validate_cwd(Some("~")).is_ok()); + assert!(validate_cwd(None).is_ok()); + } + + fn workspace_config() -> super::super::SSHConnectionConfig { + serde_json::from_value(serde_json::json!({ + "id": "wsl-test", "name": "WSL test", "host": "wsl.invalid", "port": 0, + "username": "", "auth": { "type": "PrivateKey", "keyPath": "" }, + "wsl": { "distribution": "Ubuntu" } + })) + .unwrap() + } + + #[tokio::test] + async fn wsl_saved_profiles_survive_reload_without_credentials() { + let root = tempfile::tempdir().unwrap(); + let manager = super::super::SSHConnectionManager::new(root.path().into()); + let mut config = workspace_config(); + manager.save_connection(&config).await.unwrap(); + config.id = "wsl-debian".into(); + config.wsl.as_mut().unwrap().distribution = "Debian".into(); + manager.save_connection(&config).await.unwrap(); + let restored = super::super::SSHConnectionManager::new(root.path().into()); + restored.load_saved_connections().await.unwrap(); + let saved = restored.get_saved_connections().await; + assert_eq!(saved.len(), 2); + assert_eq!(saved[0].wsl.as_ref().unwrap().distribution, "Ubuntu"); + assert_eq!(saved[1].wsl.as_ref().unwrap().distribution, "Debian"); + assert!(restored + .load_stored_password("wsl-test") + .await + .unwrap() + .is_none()); + } + + #[cfg(not(windows))] + #[tokio::test] + async fn wsl_connection_report_identifies_unsupported_target_and_keeps_profile() { + let root = tempfile::tempdir().unwrap(); + let manager = super::super::SSHConnectionManager::new(root.path().into()); + let config = workspace_config(); + manager.save_connection(&config).await.unwrap(); + let report = manager.test_connection(&config).await; + assert!(!report.success); + assert_eq!(report.stages.len(), 1); + assert_eq!(report.stages[0].id, "wsl"); + assert!(report.stages[0] + .error + .as_ref() + .unwrap() + .contains("Windows OpenBitFun host")); + assert_eq!(manager.get_saved_connections().await.len(), 1); + assert!(!manager.is_connected(&config.id).await); + // Terminal transport selection must restore the saved target before + // deciding between local WSL and SSH, including after a host restart. + let error = manager + .local_process_exec_spec(&config.id, "pwd", true) + .await + .unwrap_err(); + assert!(error.to_string().contains("Windows OpenBitFun host")); + } + + /// Opt-in real Windows/WSL transport check. Set OPENBITFUN_TEST_WSL_DISTRO + /// to an initialized distribution and run this test with --ignored. + #[tokio::test] + #[ignore = "requires Windows and an initialized WSL distribution"] + async fn wsl_windows_workspace_transport() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + ensure_supported().unwrap(); + let distribution = + std::env::var("OPENBITFUN_TEST_WSL_DISTRO").expect("Set OPENBITFUN_TEST_WSL_DISTRO"); + let root = tempfile::tempdir().unwrap(); + let manager = super::super::SSHConnectionManager::new(root.path().into()); + let mut config = workspace_config(); + config.wsl.as_mut().unwrap().distribution = distribution; + let result = manager.connect(config.clone()).await.unwrap(); + assert!(result.success); + assert_eq!(result.server_info.unwrap().os_type, "Linux"); + manager.save_connection(&config).await.unwrap(); + let remote_path = format!("/tmp/openbitfun-wsl-{}", uuid::Uuid::new_v4()); + let bytes = b"binary\0payload\n\xff\xfe"; + manager + .container_write_file(&config.id, &remote_path, bytes) + .await + .unwrap(); + let read_result = manager.container_read_file(&config.id, &remote_path).await; + manager + .execute_command(&config.id, &format!("rm -- '{}'", remote_path)) + .await + .unwrap(); + assert_eq!(read_result.unwrap(), bytes); + let transport = manager + .open_workspace_stdio(&config.id, "cat; printf diagnostic >&2; exit 7") + .await + .unwrap(); + let (mut stdin, mut stdout, mut stderr, _, completion) = transport.into_parts(); + stdin.write_all(bytes).await.unwrap(); + stdin.shutdown().await.unwrap(); + let mut read = Vec::new(); + stdout.read_to_end(&mut read).await.unwrap(); + let mut diagnostic = String::new(); + stderr.read_to_string(&mut diagnostic).await.unwrap(); + assert_eq!(read, bytes); + assert_eq!(diagnostic, "diagnostic"); + assert_eq!(completion.wait().await.exit_code, Some(7)); + let transport = manager + .open_workspace_stdio(&config.id, "sleep 60") + .await + .unwrap(); + let (_stdin, _stdout, _stderr, control, completion) = transport.into_parts(); + control.kill().await.unwrap(); + let exit = tokio::time::timeout(Duration::from_secs(5), completion.wait()) + .await + .unwrap(); + assert_ne!(exit.exit_code, Some(0)); + manager.disconnect(&config.id).await.unwrap(); + manager.ensure_connected(&config.id).await.unwrap(); + assert!(manager.is_shell_workspace(&config.id).await); + manager.disconnect(&config.id).await.unwrap(); + } +} diff --git a/src/crates/services/services-integrations/tests/remote_ssh_contracts/remote_ssh_contracts.rs b/src/crates/services/services-integrations/tests/remote_ssh_contracts/remote_ssh_contracts.rs index 93fc418fb9..afe5c7b17b 100644 --- a/src/crates/services/services-integrations/tests/remote_ssh_contracts/remote_ssh_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_ssh_contracts/remote_ssh_contracts.rs @@ -36,6 +36,7 @@ fn remote_ssh_legacy_agent_auth_keeps_default_private_key_fallback() { } assert_eq!(config.proxy_jump, None); assert_eq!(config.container, None); + assert_eq!(config.wsl, None); assert_eq!(config.options.connect_timeout_secs, 30); assert_eq!(config.options.auth_timeout_secs, 60); assert_eq!(config.options.auth_attempts, 3); @@ -62,6 +63,7 @@ fn remote_ssh_legacy_agent_auth_keeps_default_private_key_fallback() { )); assert_eq!(saved.proxy_jump, None); assert_eq!(saved.container, None); + assert_eq!(saved.wsl, None); assert_eq!(saved.options.connect_timeout_secs, 30); assert_eq!(saved.options.auth_timeout_secs, 60); assert_eq!(saved.options.auth_attempts, 3); @@ -92,6 +94,7 @@ fn remote_target_contract_uses_proxy_jump_and_kebab_case_container_access() { user: Some("trainer".to_string()), interactive: true, }), + wsl: None, options: Default::default(), }; @@ -454,3 +457,38 @@ fn remote_listening_port_wire_shape_stays_camel_case() { assert_eq!(value["process"], "vite"); assert_eq!(value["pid"], 4242); } + +#[test] +fn wsl_target_and_legacy_ssh_profiles_round_trip_without_requiring_new_fields() { + let legacy = serde_json::json!({ + "id": "legacy", "name": "legacy", "host": "example.test", "port": 22, + "username": "dev", "auth": { "type": "Password", "password": "" } + }); + let config: SSHConnectionConfig = serde_json::from_value(legacy).unwrap(); + let wire = serde_json::to_value(&config).unwrap(); + assert!(wire.get("wsl").is_none()); + let round_trip: SSHConnectionConfig = serde_json::from_value(wire).unwrap(); + assert!(config.connection_params_equal(&round_trip)); + assert!(!round_trip.uses_local_process()); + + let wsl_wire = serde_json::json!({ + "id": "wsl-Ubuntu-", "name": "WSL", "host": "wsl.invalid", "port": 0, + "username": "", "auth": { "type": "PrivateKey", "keyPath": "" }, + "wsl": { "distribution": "Ubuntu" } + }); + let config: SSHConnectionConfig = serde_json::from_value(wsl_wire).unwrap(); + assert!(config.uses_local_process()); + assert!(config.uses_shell_filesystem()); + assert!(!config.uses_local_docker()); + assert!(!config.uses_docker_exec()); + assert_eq!(config.wsl.as_ref().unwrap().user, None); + let round_trip: SSHConnectionConfig = + serde_json::from_value(serde_json::to_value(&config).unwrap()).unwrap(); + assert!(config.connection_params_equal(&round_trip)); + let mut other = round_trip; + other.wsl.as_mut().unwrap().distribution = "Debian".into(); + assert!(!config.connection_params_equal(&other)); + other.wsl.as_mut().unwrap().distribution = "Ubuntu".into(); + other.wsl.as_mut().unwrap().user = Some("root".into()); + assert!(!config.connection_params_equal(&other)); +} diff --git a/src/crates/services/services-integrations/tests/remote_ssh_contracts/remote_ssh_disabled_contracts.rs b/src/crates/services/services-integrations/tests/remote_ssh_contracts/remote_ssh_disabled_contracts.rs index 2288d52c43..80300b009f 100644 --- a/src/crates/services/services-integrations/tests/remote_ssh_contracts/remote_ssh_disabled_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_ssh_contracts/remote_ssh_disabled_contracts.rs @@ -22,6 +22,7 @@ fn test_config() -> SSHConnectionConfig { default_workspace: Some("/repo".to_string()), proxy_jump: None, container: None, + wsl: None, options: Default::default(), } } diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 185eed8ca2..82328004e9 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -4037,6 +4037,20 @@ "command:ssh_list_docker_containers" ] }, + { + "id": "wsl", + "titleZh": "选择 Windows 主机上的 WSL 发行版作为工作区", + "titleEn": "Choose a WSL distribution on the Windows host as a workspace", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "打开远程连接对话框,由用户选择发行版、Linux 用户和工作区目录。", + "reasonEn": "Open the remote connection dialog for the user to choose a distribution, Linux user, and workspace directory." + }, + "evidence": [ + "command:ssh_list_wsl_distributions" + ] + }, { "id": "remote-open", "titleZh": "打开、关闭和移除远程工作区,并读取服务器信息", diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index a635ba4c23..2e91ce5971 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "9973af8307e72206bb5c4af5dfb3249fef2def6a27925a13aca9a25ac62ef8a3", + "digest": "9a3d11cb4a198369ceb62ae82e1d0e1e6bddb658f4a790ec84e9eb4123803e5a", "ownerDigest": "c0e5c187cf62bc6ed06196ce8520b3eb427bf268cf24659b72d2552fb1d99c54", "searchAcceptance": [ { @@ -138,11 +138,11 @@ "features": 22, "settings": 21, "userFacing": 43, - "documentedItems": 320, + "documentedItems": 321, "controlCoverage": { "direct": 48, "delegated": 61, - "interactive": 211, + "interactive": 212, "unsupported": 0 } }, @@ -8145,6 +8145,7 @@ "ssh-profiles", "ssh-auth", "docker", + "wsl", "remote-open", "remote-files", "transfer", @@ -8329,6 +8330,52 @@ "actionId": "project.new" } }, + { + "id": "feature.remote-workspaces:open:wsl", + "capabilityId": "feature.remote-workspaces", + "itemIds": [ + "wsl" + ], + "kind": "open", + "risk": "ui", + "executionHost": "presentationSurface", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1", + "product_control_presentation_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": false, + "reason": "This delivery profile has no live presentation surface" + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "openReason": "unstructuredInteraction", + "presentationTarget": { + "kind": "action", + "actionId": "project.new" + } + }, { "id": "feature.remote-workspaces:open:remote-open", "capabilityId": "feature.remote-workspaces", @@ -23508,6 +23555,17 @@ "reasonEn": "“Discover remote Docker containers and use a container as the work environment” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, + { + "id": "wsl", + "titleZh": "选择 Windows 主机上的 WSL 发行版作为工作区", + "titleEn": "Choose a WSL distribution on the Windows host as a workspace", + "control": { + "kind": "open", + "reasonCode": "unstructuredInteraction", + "reasonZh": "打开远程连接对话框,由用户选择发行版、Linux 用户和工作区目录。", + "reasonEn": "Open the remote connection dialog for the user to choose a distribution, Linux user, and workspace directory." + } + }, { "id": "remote-open", "titleZh": "打开、关闭和移除远程工作区,并读取服务器信息", @@ -23660,6 +23718,8 @@ "Connect with passwords, private keys, certificates, SSH config hosts, and jump hosts", "发现远程 Docker 容器并把容器作为工作环境", "Discover remote Docker containers and use a container as the work environment", + "选择 Windows 主机上的 WSL 发行版作为工作区", + "Choose a WSL distribution on the Windows host as a workspace", "打开、关闭和移除远程工作区,并读取服务器信息", "Open, close, and remove remote workspaces, and inspect server information", "浏览、读取、写入、创建、重命名和删除远程文件与目录", diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx index 386ee40aab..e2cf717a32 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx @@ -12,6 +12,7 @@ const sshApiMock = vi.hoisted(() => ({ listSavedConnections: vi.fn(), listSSHConfigHosts: vi.fn(), getSSHConfig: vi.fn(), + listWslDistributions: vi.fn(), })); const remoteContextMock = vi.hoisted(() => ({ @@ -24,11 +25,10 @@ const authFilePickerMock = vi.hoisted(() => ({ pickSshCertificatePath: vi.fn(), })); -vi.mock('@/infrastructure/i18n', () => ({ - useI18n: () => ({ - t: (key: string) => key, - }), -})); +vi.mock('@/infrastructure/i18n', () => { + const t = (key: string) => key; + return { useI18n: () => ({ t }) }; +}); vi.mock('./SSHRemoteContext', () => ({ useSSHRemoteContext: () => ({ @@ -113,7 +113,7 @@ vi.mock('@openbitfun/ui', () => ({ ), Tooltip: ({ children }: React.PropsWithChildren) => <>{children}, - ScrollArea: ({ children, ...props }: React.HTMLAttributes) =>
{children}
, + ScrollArea: ({ children, scrollbarVisibility, ...props }: React.HTMLAttributes & { scrollbarVisibility?: string }) =>
{children}
, FormSection: ({ children, title, @@ -164,6 +164,7 @@ describe('SSHConnectionDialog', () => { sshApiMock.listSavedConnections.mockResolvedValue([]); sshApiMock.listSSHConfigHosts.mockResolvedValue([]); sshApiMock.getSSHConfig.mockResolvedValue({ found: false }); + sshApiMock.listWslDistributions.mockResolvedValue({ supported: true, distributions: ['Ubuntu', 'Debian'] }); authFilePickerMock.pickSshPrivateKeyPath.mockResolvedValue(null); authFilePickerMock.pickSshCertificatePath.mockResolvedValue(null); }); @@ -402,4 +403,79 @@ describe('SSHConnectionDialog', () => { ); }, ); + async function selectWsl(): Promise { + setSelectValue(findTargetSelect(), 'wsl'); + await act(async () => { await Promise.resolve(); }); + } + + it('connects to an installed WSL distribution without SSH or Docker credentials', async () => { + remoteContextMock.connect.mockResolvedValue(undefined); + await renderDialog(); + await selectWsl(); + expect(container.querySelector('input[aria-label="ssh.remote.host"]')).toBeNull(); + expect(container.querySelector('input[aria-label="ssh.remote.password"]')).toBeNull(); + expect(container.querySelector('input[placeholder="ssh.remote.containerNamePlaceholder"]')).toBeNull(); + setInputValue('ssh.remote.wslUser', 'dev'); + await act(async () => { findConnectButton()?.click(); }); + expect(remoteContextMock.connect).toHaveBeenCalledWith( + 'wsl-Ubuntu@dev', + expect.objectContaining({ wsl: { distribution: 'Ubuntu', user: 'dev' }, host: 'wsl.invalid', port: 0 }), + { browseAfterConnect: true }, + ); + expect(remoteContextMock.connect.mock.calls[0]?.[1].container).toBeUndefined(); + }); + + it('keeps distinct distribution and user pairs from sharing a connection identity', async () => { + sshApiMock.listWslDistributions.mockResolvedValue({ supported: true, distributions: ['Ubuntu-dev', 'Ubuntu'] }); + remoteContextMock.connect.mockResolvedValue(undefined); + await renderDialog(); + await selectWsl(); + setInputValue('ssh.remote.wslUser', 'ops'); + await act(async () => { findConnectButton()?.click(); }); + const distribution = Array.from(container.querySelectorAll('select')) + .find(select => select.querySelector('option[value="Ubuntu"]')) ?? null; + setSelectValue(distribution, 'Ubuntu'); + setInputValue('ssh.remote.wslUser', 'dev-ops'); + await act(async () => { findConnectButton()?.click(); }); + expect(remoteContextMock.connect.mock.calls[0]?.[0]).not.toBe(remoteContextMock.connect.mock.calls[1]?.[0]); + }); + + it.each([ + [{ supported: false, distributions: [] }, 'ssh.remote.wslUnsupported'], + [{ supported: true, distributions: [] }, 'ssh.remote.wslNoDistributions'], + ])('gates WSL when discovery returns %j', async (result, hint) => { + sshApiMock.listWslDistributions.mockResolvedValue(result); + await renderDialog(); + await selectWsl(); + expect(container.textContent).toContain(hint); + expect(findConnectButton()?.disabled).toBe(true); + expect(remoteContextMock.connect).not.toHaveBeenCalled(); + }); + + it('shows a failed discovery and lets the user retry', async () => { + sshApiMock.listWslDistributions.mockRejectedValueOnce(new Error('wsl.exe unavailable')); + await renderDialog(); + await selectWsl(); + expect(container.textContent).toContain('wsl.exe unavailable'); + expect(findConnectButton()?.disabled).toBe(true); + const refresh = Array.from(container.querySelectorAll('button')).find(button => button.textContent?.includes('ssh.remote.wslRefresh')); + await act(async () => { refresh?.click(); }); + expect(findConnectButton()?.disabled).toBe(false); + }); + + it('retains WSL target and Linux user when editing and quick connecting a saved profile', async () => { + const wsl = { distribution: 'Debian', user: 'dev' }; + sshApiMock.listSavedConnections.mockResolvedValue([{ + id: 'wsl-Debian@dev', name: 'WSL · Debian · dev', host: 'wsl.invalid', port: 0, + username: 'dev', authType: { type: 'PrivateKey', keyPath: '' }, wsl, + }]); + remoteContextMock.connect.mockResolvedValue(undefined); + await renderDialog(); + await act(async () => { container.querySelector('button[title="actions.edit"]')?.click(); }); + expect(findTargetSelect()?.value).toBe('wsl'); + expect(container.querySelector('input[aria-label="ssh.remote.wslUser"]')?.value).toBe('dev'); + await act(async () => { container.querySelector('button[title="ssh.remote.connect"]')?.click(); }); + expect(remoteContextMock.connect).toHaveBeenCalledWith('wsl-Debian@dev', expect.objectContaining({ wsl }), { browseAfterConnect: true }); + }); + }); diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx index 4cbe701b25..173ba74dd5 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx @@ -33,6 +33,7 @@ import type { ConnectionTestStage, DockerContainerInfo, SSHConnectionConfig, + WslDistributions, SSHAuthMethod, SavedConnection, SSHConfigEntry, @@ -68,11 +69,16 @@ export const SSHConnectionDialog: React.FC = ({ const [connectionTest, setConnectionTest] = useState(null); const [dockerContainers, setDockerContainers] = useState([]); + const [wslDistributions, setWslDistributions] = useState(null); + const [wslError, setWslError] = useState(null); + const [isListingWsl, setIsListingWsl] = useState(false); + const [wslRefresh, setWslRefresh] = useState(0); + const error = localError || connectionError; // Form state const [formData, setFormData] = useState({ - targetType: 'ssh' as 'ssh' | 'remoteDocker' | 'localDocker' | 'containerSshd', + targetType: 'ssh' as 'ssh' | 'remoteDocker' | 'localDocker' | 'containerSshd' | 'wsl', name: '', host: '', port: '22', @@ -86,6 +92,8 @@ export const SSHConnectionDialog: React.FC = ({ fallbackKeyPath: '~/.ssh/id_rsa', verificationCode: '', proxyJump: '', + wslDistribution: '', + wslUser: '', containerName: '', containerAccess: 'auto' as 'auto' | 'docker-exec', dockerPath: 'docker', @@ -164,9 +172,30 @@ export const SSHConnectionDialog: React.FC = ({ } }, [open, clearError]); + useEffect(() => { + if (!open || formData.targetType !== 'wsl') return; + let cancelled = false; + setIsListingWsl(true); + setWslDistributions(null); + setWslError(null); + sshApi.listWslDistributions().then((result) => { + if (cancelled) return; + setWslDistributions(result); + setFormData((prev) => ({ + ...prev, + wslDistribution: prev.wslDistribution || result.distributions[0] || '', + })); + }).catch((error: unknown) => { + if (!cancelled) setWslError(error instanceof Error ? error.message : t('ssh.remote.wslDiscoveryFailed')); + }).finally(() => { + if (!cancelled) setIsListingWsl(false); + }); + return () => { cancelled = true; }; + }, [open, formData.targetType, wslRefresh, t]); + // Load SSH config from ~/.ssh/config when host changes useEffect(() => { - if (!formData.host.trim()) return; + if (!formData.host.trim() || formData.targetType === 'wsl' || formData.targetType === 'localDocker') return; const loadSSHConfig = async () => { try { @@ -200,7 +229,7 @@ export const SSHConnectionDialog: React.FC = ({ // Debounce the SSH config lookup const timeout = setTimeout(loadSSHConfig, 300); return () => clearTimeout(timeout); - }, [formData.host]); + }, [formData.host, formData.targetType]); const handleInputChange = (field: string, value: string) => { setFormData((prev) => ({ ...prev, [field]: value })); @@ -260,6 +289,29 @@ export const SSHConnectionDialog: React.FC = ({ const buildConnectionConfig = async ( requireContainer: boolean, ): Promise => { + if (formData.targetType === 'wsl') { + if (!wslDistributions?.supported) { + setLocalError(wslError || t('ssh.remote.wslUnsupported')); + return null; + } + const distribution = formData.wslDistribution.trim(); + if (!distribution || !wslDistributions.distributions.includes(distribution)) { + setLocalError(t('ssh.remote.wslDistributionRequired')); + return null; + } + const user = formData.wslUser.trim(); + return { + id: `wsl-${encodeURIComponent(distribution)}@${encodeURIComponent(user)}`, + name: formData.name.trim() || `WSL · ${distribution}${user ? ` · ${user}` : ''}`, + // Reserved, non-routable legacy SSH fields prevent older installs from + // treating this additive profile as a usable SSH endpoint. + host: 'wsl.invalid', + port: 0, + username: user, + auth: { type: 'PrivateKey', keyPath: '' }, + wsl: { distribution, user: user || undefined }, + }; + } const isLocalDocker = formData.targetType === 'localDocker'; const usesContainer = formData.targetType !== 'ssh'; if (!isLocalDocker && !formData.host.trim()) { @@ -442,6 +494,7 @@ export const SSHConnectionDialog: React.FC = ({ defaultWorkspace: conn.defaultWorkspace, proxyJump: conn.proxyJump, container: conn.container, + wsl: conn.wsl, options: conn.options, }, { browseAfterConnect: true } @@ -478,6 +531,7 @@ export const SSHConnectionDialog: React.FC = ({ defaultWorkspace: conn.defaultWorkspace, proxyJump: conn.proxyJump, container: conn.container, + wsl: conn.wsl, options: conn.options, }, { browseAfterConnect: true } @@ -515,6 +569,8 @@ export const SSHConnectionDialog: React.FC = ({ verificationCode: '', proxyJump: configHost.proxyJump || '', targetType: 'ssh', + wslDistribution: '', + wslUser: '', containerName: '', containerAccess: 'auto', dockerPath: 'docker', @@ -548,6 +604,7 @@ export const SSHConnectionDialog: React.FC = ({ defaultWorkspace: conn.defaultWorkspace, proxyJump: conn.proxyJump, container: conn.container, + wsl: conn.wsl, options: conn.options, }; await connect(conn.id, full, { browseAfterConnect: true }); @@ -568,9 +625,11 @@ export const SSHConnectionDialog: React.FC = ({ const handleEditConnection = (e: React.MouseEvent, conn: SavedConnection) => { e.stopPropagation(); const keyPath = conn.authType.type === 'PrivateKey' ? conn.authType.keyPath : '~/.ssh/id_rsa'; - const defaultConnectionName = conn.container?.local - ? conn.container.name - : `${conn.username}@${conn.host}`; + const defaultConnectionName = conn.wsl + ? `WSL · ${conn.wsl.distribution}${conn.wsl.user ? ` · ${conn.wsl.user}` : ''}` + : conn.container?.local + ? conn.container.name + : `${conn.username}@${conn.host}`; const authType = conn.authType.type === 'Password' ? 'password' : conn.authType.type === 'PrivateKey' @@ -578,13 +637,15 @@ export const SSHConnectionDialog: React.FC = ({ : conn.authType.type === 'Agent' ? 'agent' : 'keyboardInteractive'; - const targetType = conn.container?.local - ? 'localDocker' - : conn.container?.access === 'docker-exec' || conn.container?.access === 'auto' - ? 'remoteDocker' - : conn.container?.access === 'sshd' - ? 'containerSshd' - : 'ssh'; + const targetType = conn.wsl + ? 'wsl' + : conn.container?.local + ? 'localDocker' + : conn.container?.access === 'docker-exec' || conn.container?.access === 'auto' + ? 'remoteDocker' + : conn.container?.access === 'sshd' + ? 'containerSshd' + : 'ssh'; setFormData({ targetType, name: conn.name, @@ -606,6 +667,8 @@ export const SSHConnectionDialog: React.FC = ({ : '~/.ssh/id_rsa', verificationCode: '', proxyJump: conn.proxyJump || '', + wslDistribution: conn.wsl?.distribution || '', + wslUser: conn.wsl?.user || '', containerName: conn.container?.name || '', containerAccess: conn.container?.access === 'docker-exec' ? 'docker-exec' : 'auto', dockerPath: conn.container?.dockerPath || 'docker', @@ -661,6 +724,7 @@ export const SSHConnectionDialog: React.FC = ({ if (stage.id === 'container') { return `${t('ssh.remote.testStageContainer')} · ${stage.label}`; } + if (stage.id === 'wsl') return `WSL · ${stage.label}`; if (stage.id === 'docker-host') { return t('ssh.remote.testStageLocalDocker'); } @@ -671,9 +735,14 @@ export const SSHConnectionDialog: React.FC = ({ { label: t('ssh.remote.targetRemoteDocker'), value: 'remoteDocker' }, { label: t('ssh.remote.targetLocalDocker'), value: 'localDocker' }, { label: t('ssh.remote.targetContainerSshd'), value: 'containerSshd' }, + { label: t('ssh.remote.targetWsl'), value: 'wsl' }, ]; - const isLocalDockerTarget = formData.targetType === 'localDocker'; - const usesContainerTarget = formData.targetType !== 'ssh'; + const isWslTarget = formData.targetType === 'wsl'; + const isLocalProcessTarget = formData.targetType === 'localDocker' || isWslTarget; + const usesContainerTarget = formData.targetType !== 'ssh' && !isWslTarget; + const wslUnavailable = isListingWsl + || !wslDistributions?.supported + || !wslDistributions.distributions.includes(formData.wslDistribution); const filteredSavedConnections = savedConnections.filter((conn) => { if (!savedSearch.trim()) return true; @@ -735,7 +804,7 @@ export const SSHConnectionDialog: React.FC = ({ )} - + {/* Saved connections section */} {savedConnections.length > 0 && ( = ({ /> )} > - + {filteredSavedConnections.map((conn) => (
= ({
{conn.name} - {conn.container?.local + {conn.wsl + ? `WSL · ${conn.wsl.distribution}${conn.wsl.user ? ` · ${conn.wsl.user}` : ''}` + : conn.container?.local ? `Docker · ${conn.container.name}` : `${conn.username}@${conn.host}:${conn.port}${conn.container ? ` · ${conn.container.name}` : ''}${conn.proxyJump ? ` · ${t('ssh.remote.via')} ${conn.proxyJump}` : ''}`} @@ -833,7 +904,7 @@ export const SSHConnectionDialog: React.FC = ({ /> )} > - + {filteredSSHConfigHosts.map((configHost) => (
= ({ /> - {!isLocalDockerTarget && ( + {isWslTarget && ( + <> + +