From 60d7dcdca0e89bcce5029be3b45281d502be54a8 Mon Sep 17 00:00:00 2001 From: Andrea Basile Date: Sat, 7 Nov 2026 18:43:03 +0100 Subject: [PATCH] feat(remote): reopen a local project in its dev container (#1843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #1083 (step B). A local project with a devcontainer.json can be reopened in its dev container (File › Reopen in Container, Find Action): the Dev Containers CLI brings the container up and the window connects to openidea-server inside it, as for WSL, so terminal, language servers, run, debug and git work in the container. The project's identity stays the local folder, so reopening from Recent Projects finds the same container. Missing CLI gives install guidance; trust of the local project carries over. Pure CLI output parsing lives in ide-remote; a failed connection no longer closes the current backend early, saved locations keep the container configuration, and Docker context precedence follows the CLI. Co-authored-by: andreabasile <7715018+andreabasile@users.noreply.github.com> --- crates/ide-docker/src/lib.rs | 56 ++- crates/ide-host/src/bin/server.rs | 5 + crates/ide-host/src/environment.rs | 6 +- crates/ide-host/src/trust.rs | 22 +- crates/ide-host/tests/trust.rs | 18 + crates/ide-remote/src/devcontainer.rs | 102 ++++++ crates/ide-remote/src/lib.rs | 1 + crates/ide-tools/src/registry.rs | 19 +- src-tauri/src/lib.rs | 1 + src-tauri/src/menu.rs | 1 + src-tauri/src/remote_container.rs | 387 +++++++++++++++++++++ src-tauri/src/remote_dev.rs | 41 ++- src-tauri/src/remote_wsl.rs | 66 ++-- src-tauri/src/windows.rs | 6 +- src/bindings.ts | 22 +- src/features/remote-dev/RemoteStatus.tsx | 8 +- src/features/remote-dev/container.test.ts | 51 +++ src/features/remote-dev/container.ts | 27 ++ src/features/remote-dev/index.ts | 18 +- src/features/remote-dev/model.ts | 2 + src/features/remote-dev/ports.test.tsx | 19 +- src/features/remote-dev/ports.ts | 8 +- src/features/remote-dev/remoteDev.test.ts | 29 ++ src/features/tools/LanguageServersPage.tsx | 1 + src/platform/locales/en.json | 1 + src/platform/locales/text/en.json | 9 +- src/platform/locales/text/zh.json | 9 +- src/platform/locales/zh.json | 1 + src/platform/remote.ts | 41 ++- 29 files changed, 908 insertions(+), 69 deletions(-) create mode 100644 crates/ide-remote/src/devcontainer.rs create mode 100644 src-tauri/src/remote_container.rs create mode 100644 src/features/remote-dev/container.test.ts create mode 100644 src/features/remote-dev/container.ts diff --git a/crates/ide-docker/src/lib.rs b/crates/ide-docker/src/lib.rs index 8eb638e24..6a254aa88 100644 --- a/crates/ide-docker/src/lib.rs +++ b/crates/ide-docker/src/lib.rs @@ -443,7 +443,7 @@ pub fn compose_op( /// Whether `ep` is a daemon on this machine, where a bind mount of the project /// is this machine's folder. The CLI's default daemon and a context are looked -/// up (`DOCKER_HOST`, else the context's endpoint). +/// up (`DOCKER_CONTEXT`, then `DOCKER_HOST`, else the current context). pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult { let url = match ep { DockerEndpoint::Socket { path } => path.trim().to_owned(), @@ -451,17 +451,31 @@ pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult { let host = host.trim(); if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") } } - DockerEndpoint::Default => { - match ide_core::process::host_var_os("DOCKER_HOST").map(|v| v.to_string_lossy().trim().to_owned()) { - Some(url) if !url.is_empty() => url, - _ => context_host(ep, None)?, - } - } + DockerEndpoint::Default => default_daemon_url( + ide_core::process::host_var_os("DOCKER_CONTEXT").as_deref().and_then(|v| v.to_str()), + ide_core::process::host_var_os("DOCKER_HOST").as_deref().and_then(|v| v.to_str()), + |name| context_host(&DockerEndpoint::Default, name), + )?, DockerEndpoint::Context { name } => context_host(&DockerEndpoint::Default, Some(name.trim()))?, }; Ok(daemon_url_is_local(&url)) } +fn default_daemon_url( + context: Option<&str>, + host: Option<&str>, + lookup: impl FnOnce(Option<&str>) -> IdeResult, +) -> IdeResult { + let context = context.filter(|v| !v.is_empty()); + if context.is_some() { + return lookup(context); + } + match host.filter(|v| !v.is_empty()) { + Some(host) => Ok(host.to_owned()), + None => lookup(None), + } +} + /// The daemon URL of context `name` (the current one with `None`). fn context_host(ep: &DockerEndpoint, name: Option<&str>) -> IdeResult { let mut args = vec!["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"]; @@ -506,6 +520,34 @@ pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeR mod local_tests { use super::*; + #[test] + fn the_selected_context_overrides_docker_host() { + let local = "unix:///var/run/docker.sock"; + let remote = "ssh://me@build"; + for host in [None, Some(""), Some(local)] { + let url = default_daemon_url(Some("build"), host, |name| { + assert_eq!(name, Some("build")); + Ok(remote.into()) + }) + .unwrap(); + assert!(!daemon_url_is_local(&url)); + } + for context in [None, Some("")] { + assert_eq!( + default_daemon_url(context, Some(local), |_| panic!("host overrides current context")).unwrap(), + local + ); + assert_eq!( + default_daemon_url(context, None, |name| { + assert_eq!(name, None); + Ok(remote.into()) + }) + .unwrap(), + remote + ); + } + } + #[test] fn local_daemon_urls() { for url in [ diff --git a/crates/ide-host/src/bin/server.rs b/crates/ide-host/src/bin/server.rs index 6a1f25785..f5c140f2a 100644 --- a/crates/ide-host/src/bin/server.rs +++ b/crates/ide-host/src/bin/server.rs @@ -78,6 +78,11 @@ async fn run(base: PathBuf) -> i32 { None } }; + if let Some(root) = std::env::var_os(ide_host::trust::TRUSTED_ROOT_ENV) + && let Err(e) = host.trust.inherit(std::path::Path::new(&root)) + { + tracing::warn!("cannot trust {}: {e}", root.to_string_lossy()); + } tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION")); let mut dispatcher = Dispatcher::standard(); if let Some(plugins) = &plugins { diff --git a/crates/ide-host/src/environment.rs b/crates/ide-host/src/environment.rs index 7180b5abd..eb34cfd89 100644 --- a/crates/ide-host/src/environment.rs +++ b/crates/ide-host/src/environment.rs @@ -22,6 +22,10 @@ use std::time::Duration; type Changes = Vec<(OsString, Option)>; +/// Set by the desktop when the Dev Containers CLI already ran the lifecycle commands +/// (`devcontainer up`): the server then doesn't run `postStartCommand` again. +pub const LIFECYCLE_DONE_ENV: &str = "OPENIDEA_LIFECYCLE_DONE"; + /// What Reload Environment changed. #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -72,7 +76,7 @@ impl Environment { return None; } let config = self.load_project(root, sink); - let post_start = config.and_then(|c| c.post_start); + let post_start = config.and_then(|c| c.post_start).filter(|_| std::env::var_os(LIFECYCLE_DONE_ENV).is_none()); let first = self.state.lock().post_started.insert(root.to_path_buf()); post_start.filter(|_| first) } diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 2144c9d15..483a9b2bc 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -16,8 +16,14 @@ pub struct TrustDecisions { pub struct TrustStore { file: PathBuf, state: Mutex>, + /// Folders trusted for this process only, by the client that started it ([`TRUSTED_ROOT_ENV`]). + inherited: Mutex>, } +/// Set by the desktop when it starts the server in a dev container of a folder the user trusts +/// there: the project folder in the container, trusted without asking again. Never persisted. +pub const TRUSTED_ROOT_ENV: &str = "OPENIDEA_TRUSTED_ROOT"; + impl TrustStore { pub fn load(config: &Path, data: &Path) -> Self { let file = data.join("project-trust.json"); @@ -34,7 +40,7 @@ impl TrustStore { } Err(e) => Err(e.into()), }; - Self { file, state: Mutex::new(state) } + Self { file, state: Mutex::new(state), inherited: Mutex::default() } } pub fn decisions(&self) -> IdeResult { @@ -45,7 +51,19 @@ impl TrustStore { let root = dunce::canonicalize(path)?; let decisions = self.decisions()?; Ok(!decisions.restricted.contains(&root) - && (decisions.projects.contains(&root) || decisions.locations.iter().any(|p| root.starts_with(p)))) + && (decisions.projects.contains(&root) + || decisions.locations.iter().any(|p| root.starts_with(p)) + || self.inherited.lock().contains(&root))) + } + + /// Trusts `path` for this process without recording a decision (see [`TRUSTED_ROOT_ENV`]). + pub fn inherit(&self, path: &Path) -> IdeResult<()> { + let root = dunce::canonicalize(path)?; + let mut inherited = self.inherited.lock(); + if !inherited.contains(&root) { + inherited.push(root); + } + Ok(()) } pub fn decide(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> { diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index 74766decd..baa798680 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -211,6 +211,24 @@ fn persists_user_decisions_locations_revocation_and_one_time_migration() { assert!(TrustStore::load(&config, &data).trusted(&a).is_err(), "corruption fails closed"); } +#[test] +fn an_inherited_folder_is_trusted_for_this_process_only() { + let tmp = tempfile::tempdir().unwrap(); + let (config, data) = (tmp.path().join("config"), tmp.path().join("data")); + let root = tmp.path().join("workspaces/app"); + std::fs::create_dir_all(&root).unwrap(); + let trust = TrustStore::load(&config, &data); + assert!(!trust.trusted(&root).unwrap()); + trust.inherit(&root).unwrap(); + assert!(trust.trusted(&root).unwrap()); + assert!(!trust.trusted(root.parent().unwrap()).unwrap(), "only that folder"); + assert!(trust.status(&root).unwrap().decided, "the trust prompt doesn't ask again"); + assert!(trust.decisions().unwrap().projects.is_empty(), "no decision is recorded"); + assert!(!TrustStore::load(&config, &data).trusted(&root).unwrap(), "not persisted"); + trust.decide(&root, false, false).unwrap(); + assert!(!trust.trusted(&root).unwrap(), "a decision to restrict it still wins"); +} + #[cfg(unix)] fn script(root: &Path, name: &str, marker: &str) { use std::os::unix::fs::PermissionsExt; diff --git a/crates/ide-remote/src/devcontainer.rs b/crates/ide-remote/src/devcontainer.rs new file mode 100644 index 000000000..cb99f22d9 --- /dev/null +++ b/crates/ide-remote/src/devcontainer.rs @@ -0,0 +1,102 @@ +//! Validated results of the Dev Containers CLI. + +use ide_core::{IdeError, IdeResult}; +use serde::Deserialize; + +/// The container `devcontainer up` started, as it reported it. +#[derive(Debug, PartialEq, Eq)] +pub struct Up { + pub container: String, + /// The user the IDE works as (`remoteUser`). + pub user: String, + /// The project folder in the container (`remoteWorkspaceFolder`). + pub folder: String, +} + +/// The JSON line `devcontainer up` ends with. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct UpResult { + outcome: String, + container_id: Option, + remote_user: Option, + remote_workspace_folder: Option, + message: Option, + description: Option, +} + +fn is_container_id(id: &str) -> bool { + (12..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// A user name or `uid[:gid]`, never something `docker exec` could read as an option. +fn is_user(user: &str) -> bool { + !user.is_empty() + && !user.starts_with('-') + && user.len() <= 64 + && user.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':')) +} + +fn is_container_path(path: &str) -> bool { + path.starts_with('/') && !path.chars().any(char::is_control) +} + +/// Reads the result of `devcontainer up` from its stdout (the last JSON line). +pub fn parse_up(stdout: &str) -> IdeResult { + let line = stdout.lines().rev().map(str::trim).find(|l| l.starts_with('{')); + let result: UpResult = line + .and_then(|l| serde_json::from_str(l).ok()) + .ok_or_else(|| IdeError::unavailable("devcontainer up did not report a result"))?; + if result.outcome != "success" { + let why = [result.message, result.description].into_iter().flatten().collect::>().join(": "); + return Err(IdeError::unavailable(format!("devcontainer up failed: {why}"))); + } + let container = result.container_id.filter(|id| is_container_id(id)); + let user = result.remote_user.filter(|u| is_user(u)); + let folder = result.remote_workspace_folder.filter(|f| is_container_path(f)); + match (container, user, folder) { + (Some(container), Some(user), Some(folder)) => Ok(Up { container, user, folder }), + _ => Err(IdeError::unavailable("devcontainer up reported an unexpected container, user or folder")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ID: &str = "3f1c2b9a7d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8"; + + fn result(fields: &str) -> String { + format!("[1 ms] Start: Run: docker build\n{{\"outcome\":\"success\",{fields}}}\n") + } + + #[test] + fn reads_the_container_from_the_last_json_line() { + let out = + result(&format!(r#""containerId":"{ID}","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/app""#)); + let up = parse_up(&out).unwrap(); + assert_eq!(up, Up { container: ID.into(), user: "vscode".into(), folder: "/workspaces/app".into() }); + } + + #[test] + fn a_failure_reports_the_cli_message() { + let out = r#"{"outcome":"error","message":"Command failed: docker build","description":"An error occurred"}"#; + let err = parse_up(out).unwrap_err(); + assert!(err.message.contains("Command failed: docker build: An error occurred"), "{}", err.message); + assert!(parse_up("no json at all").is_err()); + } + + #[test] + fn unexpected_values_never_reach_docker_exec() { + for fields in [ + r#""containerId":"--privileged","remoteUser":"vscode","remoteWorkspaceFolder":"/w""#.to_owned(), + format!(r#""containerId":"{ID}","remoteUser":"-u0","remoteWorkspaceFolder":"/w""#), + format!(r#""containerId":"{ID}","remoteUser":"a b","remoteWorkspaceFolder":"/w""#), + format!(r#""containerId":"{ID}","remoteUser":"vscode","remoteWorkspaceFolder":"relative""#), + format!(r#""containerId":"{ID}","remoteUser":"vscode""#), + ] { + assert!(parse_up(&result(&fields)).is_err(), "{fields}"); + } + assert!(is_user("1000:1000") && is_user("node") && is_user("dev.user")); + } +} diff --git a/crates/ide-remote/src/lib.rs b/crates/ide-remote/src/lib.rs index e39e0b2f7..91a2c27cc 100644 --- a/crates/ide-remote/src/lib.rs +++ b/crates/ide-remote/src/lib.rs @@ -2,6 +2,7 @@ //! configurations on run targets ([`target`]; Docker and Compose ones in [`docker`]). pub mod deploy; +pub mod devcontainer; pub mod docker; pub mod import; pub mod mapping; diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index bbb9b5a36..73dbfb181 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -13,6 +13,8 @@ pub enum ToolKind { /// Loaded into another tool's process, e.g. a jdtls agent. Plugin, Formatter, + /// A command the IDE itself runs, e.g. the Dev Containers CLI. + Cli, } /// Where release archives come from. @@ -112,7 +114,7 @@ impl ToolSpec { use Arch::{Arm64, X64}; use Os::{Linux, Mac, Windows}; -use ToolKind::{Debugger, Formatter, LanguageServer, Plugin}; +use ToolKind::{Cli, Debugger, Formatter, LanguageServer, Plugin}; const V: Option<(&str, &[&str])> = None; const JAVA: &[(&str, &str)] = &[("java", "Java is required (https://adoptium.net)")]; @@ -140,6 +142,17 @@ pub static TOOLS: &[ToolSpec] = &[ needs: NODE, homepage: "https://prettier.io", }, + ToolSpec { + name: "devcontainer", + display: "Dev Containers CLI", + kind: Cli, + commands: &["devcontainer"], + method: Method::Npm { packages: &["@devcontainers/cli"] }, + fallback: None, + version: ver("devcontainer"), + needs: NODE, + homepage: "https://github.com/devcontainers/cli", + }, ToolSpec { name: "oxfmt", display: "oxfmt", @@ -1044,8 +1057,8 @@ mod tests { known.insert((d.adapter.clone(), d.command.clone())); } } - // The host selects the formatter of each file (Prettier or oxfmt). - for t in TOOLS.iter().filter(|t| !matches!(t.kind, Plugin | Formatter)) { + // The host selects the formatter of each file (Prettier or oxfmt) and runs its own CLIs. + for t in TOOLS.iter().filter(|t| !matches!(t.kind, Plugin | Formatter | Cli)) { assert!( known.contains(&(t.name.to_string(), t.commands[0].to_string())), "{} / {} is not referenced by languages/*.toml", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1da67b2d2..04c1f40db 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -22,6 +22,7 @@ mod page_swipe; mod plugin_web; #[cfg(target_os = "macos")] mod relaunch; +mod remote_container; mod remote_dev; mod remote_wsl; diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index afa5182c9..240946692 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -223,6 +223,7 @@ pub fn spec(os: Os) -> Vec { act("file.renameProject", "Rename Project…"), SEP, act("remote.connect", "Remote Development…"), + act("remote.reopenInContainer", "Reopen in Container"), act("remote.disconnect", "Disconnect from Remote Host"), act("remote.reloadEnvironment", "Reload Environment"), SEP, diff --git a/src-tauri/src/remote_container.rs b/src-tauri/src/remote_container.rs new file mode 100644 index 000000000..b34646396 --- /dev/null +++ b/src-tauri/src/remote_container.rs @@ -0,0 +1,387 @@ +//! Dev container transport of remote development: the Dev Containers CLI (`devcontainer up`) +//! builds or starts the container of a local folder, then `openidea-server --stdio` runs in it +//! through `docker exec`, installed there the way it is in a WSL distribution. +//! +//! The container id, user and folder come from the CLI's output, are checked, and only ever +//! reach `docker exec` as separate arguments; only fixed scripts reach `sh -c`. + +use crate::remote_dev::{Progress, RemoteStage, SERVER_BIN}; +use crate::remote_wsl::{Probe, Tail, install_script, parse_probe, probe_script, upload, version_dir}; +use ide_core::{IdeError, IdeResult}; +use ide_jsonrpc::{Handler, Peer}; +use ide_remote::devcontainer::{Up, parse_up}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::AsyncBufReadExt; + +/// Building an image (and its features) can take a while on a first open. +const UP_TIMEOUT: Duration = Duration::from_secs(30 * 60); +const PROBE_TIMEOUT: Duration = Duration::from_secs(60); + +/// The Dev Containers CLI: the IDE-managed copy (Settings › Tools) or one on `PATH`. +fn cli(data_dir: &Path) -> IdeResult { + ide_tools::Tools::new(data_dir) + .info("devcontainer", &[]) + .ok() + .and_then(|t| t.path) + .map(ide_core::IpcPath::into_path_buf) + .ok_or_else(|| { + IdeError::unavailable( + "The Dev Containers CLI is not installed. Install it in Settings › Tools › Dev Containers CLI.", + ) + }) +} + +/// `devcontainer up` for `folder` (with configuration `config`, relative to it; empty = the +/// default one). Its log goes to `tail` and the IDE log. +async fn up(cli: &Path, docker: &Path, folder: &Path, config: &str, tail: &Tail) -> IdeResult { + let mut cmd = tokio::process::Command::new(cli); + ide_core::process::prepare(cmd.as_std_mut()); + cmd.arg("up").arg("--workspace-folder").arg(folder); + if !config.trim().is_empty() { + cmd.arg("--config").arg(folder.join(config.trim())); + } + cmd.arg("--docker-path") + .arg(docker) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("cannot start devcontainer: {e}")))?; + let stderr = child.stderr.take(); + let log = tail.clone(); + let reader = tokio::spawn(async move { + let Some(stderr) = stderr else { return }; + let mut lines = tokio::io::BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + tracing::info!(target: "devcontainer", "{line}"); + log.push(line); + } + }); + let out = tokio::time::timeout(UP_TIMEOUT, child.wait_with_output()) + .await + .map_err(|_| IdeError::unavailable("devcontainer up did not finish in time"))? + .map_err(|e| IdeError::unavailable(format!("devcontainer up failed: {e}")))?; + let _ = reader.await; + let result = parse_up(&String::from_utf8_lossy(&out.stdout)); + if out.status.success() { + return result; + } + let error = result + .err() + .unwrap_or_else(|| IdeError::unavailable(format!("devcontainer up exited with {status}", status = out.status))); + Err(match tail.text() { + t if t.is_empty() => error, + t => IdeError::unavailable(format!("{}\n{t}", error.message)), + }) +} + +/// `docker exec [-i] -u sh -c