@@ -23,7 +23,6 @@ use crate::{
docker::{
Docker, DockerClient, DockerComposeConfig, DockerComposeService, DockerComposeServiceBuild,
DockerComposeServicePort, DockerComposeVolume, DockerInspect, DockerPs,
- get_remote_dir_from_config,
},
features::{DevContainerFeatureJson, FeatureManifest, parse_oci_feature_ref},
get_oci_token,
@@ -57,7 +56,7 @@ struct DevContainerManifest {
features_build_info: Option<FeaturesBuildInfo>,
features: Vec<FeatureManifest>,
}
-const DEFAULT_REMOTE_PROJECT_DIR: &str = "/workspaces/";
+const DEFAULT_REMOTE_PROJECT_DIR: &str = "/workspaces";
impl DevContainerManifest {
async fn new(
context: &DevContainerContext,
@@ -772,17 +771,14 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${{PATH:-\3}}/g' /etc/profile || true
};
let remote_user = get_remote_user_from_config(&running_container, self)?;
- let remote_workspace_folder = get_remote_dir_from_config(
- &running_container,
- (&self.local_project_directory.display()).to_string(),
- )?;
+ let remote_workspace_folder = self.remote_workspace_folder()?;
let remote_env = self.runtime_remote_env(&running_container.config.env_as_map()?)?;
Ok(DevContainerUp {
container_id: running_container.id,
remote_user,
- remote_workspace_folder,
+ remote_workspace_folder: remote_workspace_folder.display().to_string(),
extension_ids: self.extension_ids(),
remote_env,
})
@@ -1716,7 +1712,14 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
.as_ref()
.map(|folder| PathBuf::from(folder))
.or(Some(
- PathBuf::from(DEFAULT_REMOTE_PROJECT_DIR).join(self.local_workspace_base_name()?),
+ // We explicitly use "/" here, instead of PathBuf::join
+ // because we want remote targets to use unix-style filepaths,
+ // even on a Windows host
+ PathBuf::from(format!(
+ "{}/{}",
+ DEFAULT_REMOTE_PROJECT_DIR,
+ self.local_workspace_base_name()?
+ )),
))
.ok_or(DevContainerError::DevContainerParseFailed)
}
@@ -1738,7 +1741,14 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
Ok(MountDefinition {
source: Some(self.local_workspace_folder()),
- target: format!("/workspaces/{}", project_directory_name.display()),
+ // We explicitly use "/" here, instead of PathBuf::join
+ // because we want the remote target to use unix-style filepaths,
+ // even on a Windows host
+ target: format!(
+ "{}/{}",
+ PathBuf::from(DEFAULT_REMOTE_PROJECT_DIR).display(),
+ project_directory_name.display()
+ ),
mount_type: None,
})
}
@@ -1847,6 +1857,8 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
}
async fn build_and_run(&mut self) -> Result<DevContainerUp, DevContainerError> {
+ self.dev_container().validate_devcontainer_contents()?;
+
self.run_initialize_commands().await?;
self.download_feature_and_dockerfile_resources().await?;
@@ -1980,17 +1992,14 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
let remote_user = get_remote_user_from_config(&docker_inspect, self)?;
- let remote_folder = get_remote_dir_from_config(
- &docker_inspect,
- (&self.local_project_directory.display()).to_string(),
- )?;
+ let remote_folder = self.remote_workspace_folder()?;
let remote_env = self.runtime_remote_env(&docker_inspect.config.env_as_map()?)?;
let dev_container_up = DevContainerUp {
container_id: docker_ps.id,
remote_user: remote_user,
- remote_workspace_folder: remote_folder,
+ remote_workspace_folder: remote_folder.display().to_string(),
extension_ids: self.extension_ids(),
remote_env,
};
@@ -2494,7 +2503,10 @@ mod test {
},
oci::TokenResponse,
};
+ #[cfg(not(target_os = "windows"))]
const TEST_PROJECT_PATH: &str = "/path/to/local/project";
+ #[cfg(target_os = "windows")]
+ const TEST_PROJECT_PATH: &str = r#"C:\\path\to\local\project"#;
async fn build_tarball(content: Vec<(&str, &str)>) -> Vec<u8> {
let buffer = futures::io::Cursor::new(Vec::new());
@@ -2755,11 +2767,11 @@ mod test {
OsStr::new("--sig-proxy=false"),
OsStr::new("-d"),
OsStr::new("--mount"),
- OsStr::new(
- "type=bind,source=/path/to/local/project,target=/workspaces/project,consistency=cached"
- ),
+ OsStr::new(&format!(
+ "type=bind,source={TEST_PROJECT_PATH},target=/workspaces/project,consistency=cached"
+ )),
OsStr::new("-l"),
- OsStr::new("devcontainer.local_folder=/path/to/local/project"),
+ OsStr::new(&format!("devcontainer.local_folder={TEST_PROJECT_PATH}")),
OsStr::new("-l"),
OsStr::new(&format!(
"devcontainer.config_file={expected_config_file_label}"
@@ -2935,7 +2947,8 @@ mod test {
.remote_env
.as_ref()
.and_then(|env| env.get("LOCAL_WORKSPACE_FOLDER")),
- Some(&TEST_PROJECT_PATH.to_string())
+ // We replace backslashes with forward slashes during variable replacement for JSON safety
+ Some(&TEST_PROJECT_PATH.replace("\\", "/"))
);
// ${localEnv:VARIABLE_NAME}
@@ -3038,7 +3051,8 @@ mod test {
.remote_env
.as_ref()
.and_then(|env| env.get("LOCAL_WORKSPACE_FOLDER")),
- Some(&TEST_PROJECT_PATH.to_string())
+ // We replace backslashes with forward slashes during variable replacement for JSON safety
+ Some(&TEST_PROJECT_PATH.replace("\\", "/"))
);
}
@@ -4569,6 +4583,33 @@ RUN sed -i -E 's/((^|\s)PATH=)([^\$]*)$/\1\${PATH:-\3}/g' /etc/profile || true
);
}
+ #[cfg(target_os = "windows")]
+ #[gpui::test]
+ async fn test_spawns_devcontainer_with_plain_image(cx: &mut TestAppContext) {
+ cx.executor().allow_parking();
+ env_logger::try_init().ok();
+ let given_devcontainer_contents = r#"
+ {
+ "name": "cli-${devcontainerId}",
+ "image": "test_image:latest",
+ }
+ "#;
+
+ let (_, mut devcontainer_manifest) =
+ init_default_devcontainer_manifest(cx, given_devcontainer_contents)
+ .await
+ .unwrap();
+
+ devcontainer_manifest.parse_nonremote_vars().unwrap();
+
+ let devcontainer_up = devcontainer_manifest.build_and_run().await.unwrap();
+
+ assert_eq!(
+ devcontainer_up.remote_workspace_folder,
+ "/workspaces/project"
+ );
+ }
+
#[cfg(not(target_os = "windows"))]
#[gpui::test]
async fn test_spawns_devcontainer_with_docker_compose_and_plain_image(cx: &mut TestAppContext) {
@@ -5017,11 +5058,14 @@ FROM docker.io/hexpm/elixir:1.21-erlang-28.4.1-debian-trixie-20260316-slim AS de
&self,
config_files: &Vec<PathBuf>,
) -> Result<Option<DockerComposeConfig>, DevContainerError> {
+ let project_path = PathBuf::from(TEST_PROJECT_PATH);
if config_files.len() == 1
&& config_files.get(0)
- == Some(&PathBuf::from(
- "/path/to/local/project/.devcontainer/docker-compose.yml",
- ))
+ == Some(
+ &project_path
+ .join(".devcontainer")
+ .join("docker-compose.yml"),
+ )
{
return Ok(Some(DockerComposeConfig {
name: None,
@@ -522,36 +522,6 @@ where
}
}
-pub(crate) fn get_remote_dir_from_config(
- config: &DockerInspect,
- local_dir: String,
-) -> Result<String, DevContainerError> {
- let local_path = PathBuf::from(&local_dir);
-
- let Some(mounts) = &config.mounts else {
- log::error!("No mounts defined for container");
- return Err(DevContainerError::ContainerNotValid(config.id.clone()));
- };
-
- for mount in mounts {
- // Sometimes docker will mount the local filesystem on host_mnt for system isolation
- let mount_source = PathBuf::from(&mount.source.trim_start_matches("/host_mnt"));
- if let Ok(relative_path_to_project) = local_path.strip_prefix(&mount_source) {
- let remote_dir = format!(
- "{}/{}",
- &mount.destination,
- relative_path_to_project.display()
- );
- return Ok(remote_dir);
- }
- if mount.source == local_dir {
- return Ok(mount.destination.clone());
- }
- }
- log::error!("No mounts to local folder");
- Err(DevContainerError::ContainerNotValid(config.id.clone()))
-}
-
#[cfg(test)]
mod test {
use std::{
@@ -565,7 +535,7 @@ mod test {
devcontainer_json::MountDefinition,
docker::{
Docker, DockerComposeConfig, DockerComposeService, DockerComposeServicePort,
- DockerComposeVolume, DockerInspect, DockerPs, get_remote_dir_from_config,
+ DockerComposeVolume, DockerInspect, DockerPs,
},
};
@@ -710,259 +680,6 @@ mod test {
assert_eq!(result.id, "abdb6ab59573".to_string());
}
- #[test]
- fn should_get_target_dir_from_docker_inspect() {
- let given_config = r#"
- {
- "Id": "abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85",
- "Created": "2026-02-04T23:44:21.802688084Z",
- "Path": "/bin/sh",
- "Args": [
- "-c",
- "echo Container started\ntrap \"exit 0\" 15\n\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done",
- "-"
- ],
- "State": {
- "Status": "running",
- "Running": true,
- "Paused": false,
- "Restarting": false,
- "OOMKilled": false,
- "Dead": false,
- "Pid": 23087,
- "ExitCode": 0,
- "Error": "",
- "StartedAt": "2026-02-04T23:44:21.954875084Z",
- "FinishedAt": "0001-01-01T00:00:00Z"
- },
- "Image": "sha256:3dcb059253b2ebb44de3936620e1cff3dadcd2c1c982d579081ca8128c1eb319",
- "ResolvConfPath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/resolv.conf",
- "HostnamePath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/hostname",
- "HostsPath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/hosts",
- "LogPath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85-json.log",
- "Name": "/objective_haslett",
- "RestartCount": 0,
- "Driver": "overlayfs",
- "Platform": "linux",
- "MountLabel": "",
- "ProcessLabel": "",
- "AppArmorProfile": "",
- "ExecIDs": [
- "008019d93df4107fcbba78bcc6e1ed7e121844f36c26aca1a56284655a6adb53"
- ],
- "HostConfig": {
- "Binds": null,
- "ContainerIDFile": "",
- "LogConfig": {
- "Type": "json-file",
- "Config": {}
- },
- "NetworkMode": "bridge",
- "PortBindings": {},
- "RestartPolicy": {
- "Name": "no",
- "MaximumRetryCount": 0
- },
- "AutoRemove": false,
- "VolumeDriver": "",
- "VolumesFrom": null,
- "ConsoleSize": [
- 0,
- 0
- ],
- "CapAdd": null,
- "CapDrop": null,
- "CgroupnsMode": "private",
- "Dns": [],
- "DnsOptions": [],
- "DnsSearch": [],
- "ExtraHosts": null,
- "GroupAdd": null,
- "IpcMode": "private",
- "Cgroup": "",
- "Links": null,
- "OomScoreAdj": 0,
- "PidMode": "",
- "Privileged": false,
- "PublishAllPorts": false,
- "ReadonlyRootfs": false,
- "SecurityOpt": null,
- "UTSMode": "",
- "UsernsMode": "",
- "ShmSize": 67108864,
- "Runtime": "runc",
- "Isolation": "",
- "CpuShares": 0,
- "Memory": 0,
- "NanoCpus": 0,
- "CgroupParent": "",
- "BlkioWeight": 0,
- "BlkioWeightDevice": [],
- "BlkioDeviceReadBps": [],
- "BlkioDeviceWriteBps": [],
- "BlkioDeviceReadIOps": [],
- "BlkioDeviceWriteIOps": [],
- "CpuPeriod": 0,
- "CpuQuota": 0,
- "CpuRealtimePeriod": 0,
- "CpuRealtimeRuntime": 0,
- "CpusetCpus": "",
- "CpusetMems": "",
- "Devices": [],
- "DeviceCgroupRules": null,
- "DeviceRequests": null,
- "MemoryReservation": 0,
- "MemorySwap": 0,
- "MemorySwappiness": null,
- "OomKillDisable": null,
- "PidsLimit": null,
- "Ulimits": [],
- "CpuCount": 0,
- "CpuPercent": 0,
- "IOMaximumIOps": 0,
- "IOMaximumBandwidth": 0,
- "Mounts": [
- {
- "Type": "bind",
- "Source": "/somepath/cli",
- "Target": "/workspaces/cli",
- "Consistency": "cached"
- }
- ],
- "MaskedPaths": [
- "/proc/asound",
- "/proc/acpi",
- "/proc/interrupts",
- "/proc/kcore",
- "/proc/keys",
- "/proc/latency_stats",
- "/proc/timer_list",
- "/proc/timer_stats",
- "/proc/sched_debug",
- "/proc/scsi",
- "/sys/firmware",
- "/sys/devices/virtual/powercap"
- ],
- "ReadonlyPaths": [
- "/proc/bus",
- "/proc/fs",
- "/proc/irq",
- "/proc/sys",
- "/proc/sysrq-trigger"
- ]
- },
- "GraphDriver": {
- "Data": null,
- "Name": "overlayfs"
- },
- "Mounts": [
- {
- "Type": "bind",
- "Source": "/somepath/cli",
- "Destination": "/workspaces/cli",
- "Mode": "",
- "RW": true,
- "Propagation": "rprivate"
- }
- ],
- "Config": {
- "Hostname": "abdb6ab59573",
- "Domainname": "",
- "User": "root",
- "AttachStdin": false,
- "AttachStdout": true,
- "AttachStderr": true,
- "Tty": false,
- "OpenStdin": false,
- "StdinOnce": false,
- "Env": [
- "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
- ],
- "Cmd": [
- "-c",
- "echo Container started\ntrap \"exit 0\" 15\n\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done",
- "-"
- ],
- "Image": "mcr.microsoft.com/devcontainers/base:ubuntu",
- "Volumes": null,
- "WorkingDir": "",
- "Entrypoint": [
- "/bin/sh"
- ],
- "OnBuild": null,
- "Labels": {
- "dev.containers.features": "common",
- "dev.containers.id": "base-ubuntu",
- "dev.containers.release": "v0.4.24",
- "dev.containers.source": "https://github.com/devcontainers/images",
- "dev.containers.timestamp": "Fri, 30 Jan 2026 16:52:34 GMT",
- "dev.containers.variant": "noble",
- "devcontainer.config_file": "/somepath/cli/.devcontainer/dev_container_2/devcontainer.json",
- "devcontainer.local_folder": "/somepath/cli",
- "devcontainer.metadata": "[{\"id\":\"ghcr.io/devcontainers/features/common-utils:2\"},{\"id\":\"ghcr.io/devcontainers/features/git:1\",\"customizations\":{\"vscode\":{\"settings\":{\"github.copilot.chat.codeGeneration.instructions\":[{\"text\":\"This dev container includes an up-to-date version of Git, built from source as needed, pre-installed and available on the `PATH`.\"}]}}}},{\"remoteUser\":\"vscode\"}]",
- "org.opencontainers.image.ref.name": "ubuntu",
- "org.opencontainers.image.version": "24.04",
- "version": "2.1.6"
- },
- "StopTimeout": 1
- },
- "NetworkSettings": {
- "Bridge": "",
- "SandboxID": "2a94990d542fe532deb75f1cc67f761df2d669e3b41161f914079e88516cc54b",
- "SandboxKey": "/var/run/docker/netns/2a94990d542f",
- "Ports": {},
- "HairpinMode": false,
- "LinkLocalIPv6Address": "",
- "LinkLocalIPv6PrefixLen": 0,
- "SecondaryIPAddresses": null,
- "SecondaryIPv6Addresses": null,
- "EndpointID": "ef5b35a8fbb145565853e1a1d960e737fcc18c20920e96494e4c0cfc55683570",
- "Gateway": "172.17.0.1",
- "GlobalIPv6Address": "",
- "GlobalIPv6PrefixLen": 0,
- "IPAddress": "172.17.0.3",
- "IPPrefixLen": 16,
- "IPv6Gateway": "",
- "MacAddress": "",
- "Networks": {
- "bridge": {
- "IPAMConfig": null,
- "Links": null,
- "Aliases": null,
- "MacAddress": "9a:ec:af:8a:ac:81",
- "DriverOpts": null,
- "GwPriority": 0,
- "NetworkID": "51bb8ccc4d1281db44f16d915963fc728619d4a68e2f90e5ea8f1cb94885063e",
- "EndpointID": "ef5b35a8fbb145565853e1a1d960e737fcc18c20920e96494e4c0cfc55683570",
- "Gateway": "172.17.0.1",
- "IPAddress": "172.17.0.3",
- "IPPrefixLen": 16,
- "IPv6Gateway": "",
- "GlobalIPv6Address": "",
- "GlobalIPv6PrefixLen": 0,
- "DNSNames": null
- }
- }
- },
- "ImageManifestDescriptor": {
- "mediaType": "application/vnd.oci.image.manifest.v1+json",
- "digest": "sha256:39c3436527190561948236894c55b59fa58aa08d68d8867e703c8d5ab72a3593",
- "size": 2195,
- "platform": {
- "architecture": "arm64",
- "os": "linux"
- }
- }
- }
- "#;
- let config = serde_json_lenient::from_str::<DockerInspect>(given_config).unwrap();
-
- let target_dir = get_remote_dir_from_config(&config, "/somepath/cli".to_string());
-
- assert!(target_dir.is_ok());
- assert_eq!(target_dir.unwrap(), "/workspaces/cli/".to_string());
- }
-
#[test]
fn should_deserialize_object_metadata_from_docker_compose_container() {
// The devcontainer CLI writes metadata as a bare JSON object (not an array)