diff --git a/docs/how-to/dev-env/browser-forwarding.md b/docs/how-to/dev-env/browser-forwarding.md new file mode 100644 index 00000000..b370ea7a --- /dev/null +++ b/docs/how-to/dev-env/browser-forwarding.md @@ -0,0 +1,35 @@ +# Browser forwarding + +When a command inside a dev container opens a URL (for example `ddtool auth gitlab login`), +`dda` automatically forwards it to the host's default browser — including handling OAuth callback +redirects that must reach a service running inside the container. + +## How it works + +A **browser proxy daemon** runs on the host and listens on a shared port. Each container has a +small **`xdg-open` script** mounted at `/usr/local/bin/xdg-open` that forwards open requests to +the daemon over HTTP via `host.docker.internal`. + +``` +Container Host +────────────────────────────── ────────────────────────────────────── +tool calls xdg-open + └─ xdg-open (dda script) + └─ HTTP → proxy daemon ──────► 1. detect OAuth redirect_uri → localhost:{port} + 2. set up SSH tunnel for the callback port + 3. open URL in host browser + │ +OAuth provider redirects to │ SSH tunnel + localhost:{callback_port} ◄─────────────┘ + (forwarded to container) +``` + +For OAuth flows, the proxy parses the URL for a `redirect_uri` pointing at `localhost` and +establishes an SSH local port forward **before** opening the browser, so the callback from the +provider reaches the service inside the container. + +## Lifecycle + +The daemon is started on `dda env dev start` and is intentionally kept running across container +restarts — it is shared by all running containers. All containers share the same daemon instance, +each identified by their own SSH port embedded in the `xdg-open` script at container start time. diff --git a/mkdocs.yml b/mkdocs.yml index 190d3361..620e3ad8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -56,6 +56,8 @@ nav: - Extend: - Local commands: how-to/extend/local.md - Plugins: how-to/extend/plugin.md + - Developer environments: + - Browser forwarding: how-to/dev-env/browser-forwarding.md - Feature flags: - CI: how-to/feature-flags/ci.md - Tutorials: @@ -149,7 +151,7 @@ plugins: - https://docs.python.org/3/objects.inv - https://click.palletsprojects.com/en/8.1.x/objects.inv - https://rich.readthedocs.io/en/stable/objects.inv - - https://jcristharif.com/msgspec/objects.inv + # - https://jcristharif.com/msgspec/objects.inv Currently unavailable markdown_extensions: # Built-in diff --git a/src/dda/env/dev/browser_proxy.py b/src/dda/env/dev/browser_proxy.py new file mode 100644 index 00000000..73b663f6 --- /dev/null +++ b/src/dda/env/dev/browser_proxy.py @@ -0,0 +1,327 @@ +# SPDX-FileCopyrightText: 2026-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +"""Browser proxy daemon — shared across all dev containers. + +Serves a minimal HTTP endpoint that opens URLs in the host's default browser. +Started once on the host; all containers share the same instance. + +* Binds to ``0.0.0.0`` so Docker containers can reach it via + ``host.docker.internal``. +* Accepts ``GET /open?url=&ssh_port=`` and opens only + ``http``/``https`` URLs. +* When the URL contains a ``redirect_uri`` pointing at ``localhost:{port}``, + an SSH local-port-forward is established *before* the browser opens so that + the auth callback from the browser reaches the container service. +* ``ssh_port`` is supplied per-request (embedded in each container's + xdg-open script) so the single daemon can serve multiple containers. +""" + +from __future__ import annotations + +import contextlib +import logging +import shutil +import socket +import subprocess +import threading +import time +import urllib.parse +from http.server import BaseHTTPRequestHandler, HTTPServer + +log = logging.getLogger(__name__) + +# Maximum depth when recursing into nested redirect parameters. +_MAX_REDIRECT_DEPTH = 5 + +# How long to keep an SSH tunnel alive after it is established (seconds). +_TUNNEL_LIFETIME = 300 + +# How long to wait for SSH to bind the callback port (seconds). +_TUNNEL_BIND_TIMEOUT = 5.0 + +# Maps (ssh_port, callback_port) → live SSH Popen for that tunnel. +# Keyed by both ports so tunnels from different containers to the same +# callback port are tracked independently. +_active_tunnels: dict[tuple[int, int], subprocess.Popen] = {} +_tunnel_lock = threading.Lock() + + +class _Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + parsed = urllib.parse.urlparse(self.path) + if parsed.path == "/open": + params = urllib.parse.parse_qs(parsed.query) + urls = params.get("url", []) + if urls: + url = urls[0] + if url.startswith(("http://", "https://")): + ssh_ports = params.get("ssh_port", []) + try: + ssh_port: int | None = int(ssh_ports[0]) if ssh_ports else None + except ValueError: + ssh_port = None + log.info("open request: url=%s ssh_port=%s", url, ssh_port) + _handle_open(url, ssh_port) + else: + log.warning("rejected non-http(s) url: %s", url) + else: + log.warning("open request missing url parameter") + self.send_response(200) + self.end_headers() + + def log_message(self, fmt: str, *args: object) -> None: # noqa: PLR6301 + log.debug(fmt, *args) + + +# --------------------------------------------------------------------------- +# Core open logic +# --------------------------------------------------------------------------- + + +def _handle_open(url: str, ssh_port: int | None) -> None: + parsed = urllib.parse.urlparse(url) + redirect = _find_redirect_url(urllib.parse.parse_qs(parsed.query)) + + if redirect is not None and _is_localhost(redirect.hostname or ""): + port = redirect.port or (443 if redirect.scheme == "https" else 80) + log.info("detected OAuth callback redirect to localhost:%d", port) + if ssh_port is not None: + _setup_port_forward(ssh_port, port) + else: + log.warning("no ssh_port provided — skipping port forward for callback port %d", port) + + _open_browser(url) + + +def _find_redirect_url( + params: dict[str, list[str]], + depth: int = 0, +) -> urllib.parse.ParseResult | None: + """Return the first localhost redirect URL found in *params*, or None.""" + if depth > _MAX_REDIRECT_DEPTH: + return None + for key in ("redirect_uri", "redirect_url", "redirect"): + values = params.get(key) + value = values[0] if values else None + if value: + with contextlib.suppress(Exception): + return urllib.parse.urlparse(value) + # Recurse into nested URL-valued query parameters. + for values in params.values(): + for v in values: + with contextlib.suppress(Exception): + nested = urllib.parse.urlparse(v) + if nested.query: + found = _find_redirect_url(urllib.parse.parse_qs(nested.query), depth + 1) + if found is not None: + return found + return None + + +def _is_localhost(host: str) -> bool: + return host in {"localhost", "127.0.0.1", "::1", "0.0.0.0"} # noqa: S104 + + +# --------------------------------------------------------------------------- +# SSH port-forward helpers +# --------------------------------------------------------------------------- + +# Cmdline markers that identify an SSH tunnel spawned by this daemon. +_TUNNEL_MARKERS = ("dd@localhost", "-L") + + +def _is_our_tunnel(cmdline: list[str], ssh_port: int, callback_port: int) -> bool: + """Return True if *cmdline* belongs to a tunnel we would have spawned.""" + joined = " ".join(cmdline) + return ( + "dd@localhost" in joined + and f"-p\x00{ssh_port}" in "\x00".join(cmdline) + and f"127.0.0.1:{callback_port}:localhost:{callback_port}" in joined + ) + + +def _kill_tunnel_process(proc: subprocess.Popen | None) -> None: + """Terminate *proc*, escalating to SIGKILL if it does not exit within 1 s.""" + if proc is None: + return + with contextlib.suppress(Exception): + proc.terminate() + for _ in range(20): + if proc.poll() is not None: + return + time.sleep(0.05) + with contextlib.suppress(Exception): + proc.kill() + + +def _kill_orphaned_tunnels(ssh_port: int | None = None, callback_port: int | None = None) -> None: + """Kill SSH tunnel processes left over from a previous daemon instance. + + When called at startup (both args None) it sweeps all processes that match + our tunnel markers. When called before setting up a specific tunnel it + targets only processes matching that exact ``(ssh_port, callback_port)`` pair. + """ + try: + import psutil + except ImportError: + return + + for proc in psutil.process_iter(["pid", "name", "cmdline"]): + try: + name = proc.info["name"] or "" + cmdline: list[str] = proc.info["cmdline"] or [] + if "ssh" not in name.lower() and not any("ssh" in c for c in cmdline[:2]): + continue + joined = " ".join(cmdline) + if not all(m in joined for m in _TUNNEL_MARKERS): + continue + if ( + ssh_port is not None + and callback_port is not None + and not _is_our_tunnel(cmdline, ssh_port, callback_port) + ): + continue + log.info("killing orphaned tunnel pid=%d cmdline=%s", proc.pid, joined) + proc.kill() + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + + +def _setup_port_forward(ssh_port: int, callback_port: int) -> None: + """Bind ``127.0.0.1:{callback_port}`` on the host and forward it to the + container's ``localhost:{callback_port}`` via SSH local port forwarding. + + Serialised per ``(ssh_port, callback_port)`` pair. Any orphaned SSH tunnel + process for that pair is killed before a new one is started so that a daemon + restart never leaves a stale tunnel blocking the port. + """ + tunnel_key = (ssh_port, callback_port) + with _tunnel_lock: + existing = _active_tunnels.get(tunnel_key) + if existing is not None and existing.poll() is None: + log.info("reusing existing tunnel ssh_port=%d -> callback_port=%d", ssh_port, callback_port) + return + + # Kill any orphaned SSH process holding this port from a previous daemon. + _kill_orphaned_tunnels(ssh_port, callback_port) + + log.info("establishing SSH tunnel ssh_port=%d -> callback_port=%d", ssh_port, callback_port) + ssh = shutil.which("ssh") or "ssh" + proc = subprocess.Popen( + [ + ssh, + "-N", + "-q", + "-F", + "/dev/null", + "-o", + "ExitOnForwardFailure=yes", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-p", + str(ssh_port), + "-L", + f"127.0.0.1:{callback_port}:localhost:{callback_port}", + "dd@localhost", + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + + if not _wait_for_port_bound(proc, callback_port): + stderr_output = proc.stderr.read().decode(errors="replace").strip() if proc.stderr else "" + log.warning( + "failed to bind callback_port=%d (ssh_port=%d)%s", + callback_port, + ssh_port, + f": {stderr_output}" if stderr_output else "", + ) + _kill_tunnel_process(proc) + return + + log.info("tunnel established ssh_port=%d -> callback_port=%d", ssh_port, callback_port) + _active_tunnels[tunnel_key] = proc + + def _cleanup() -> None: + time.sleep(_TUNNEL_LIFETIME) + with _tunnel_lock: + _kill_tunnel_process(proc) + _active_tunnels.pop(tunnel_key, None) + log.info("tunnel expired ssh_port=%d -> callback_port=%d", ssh_port, callback_port) + + threading.Thread(target=_cleanup, daemon=True).start() + + +def _wait_for_port_bound(proc: subprocess.Popen, port: int) -> bool: + """Return True once *our* ssh process has bound *port* on 127.0.0.1. + + Detection strategy: try to bind the port ourselves — if that raises + EADDRINUSE we know *something* holds it. We then confirm it is our SSH + process and not a pre-existing listener by waiting up to 500 ms for SSH to + exit. With ``ExitOnForwardFailure=yes``, SSH exits within ~100-300 ms of + starting if it could not bind the port (either pre-occupied or auth failure). + If SSH is still alive after that window, it is the port owner. + """ + deadline = time.monotonic() + _TUNNEL_BIND_TIMEOUT + while time.monotonic() < deadline: + if proc.poll() is not None: + return False # ssh exited — auth failure or pre-occupied port + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", port)) + # Still bindable — ssh not ready yet + time.sleep(0.05) + except OSError: + # Port is taken by something. SSH may have just started and not + # yet had time to fail. Poll for up to 500 ms: if SSH exits it + # did not own the port; if it stays alive it bound the port itself. + for _ in range(10): + if proc.poll() is not None: + return False + time.sleep(0.05) + return proc.poll() is None + return False + + +# --------------------------------------------------------------------------- +# Browser open +# --------------------------------------------------------------------------- + + +def _open_browser(url: str) -> None: + import webbrowser + + log.info("opening browser: %s", url) + webbrowser.open(url) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def serve(port: int, log_file: str | None = None) -> None: + logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s %(levelname)s %(message)s", + handlers=[ + logging.FileHandler(log_file) if log_file else logging.StreamHandler(), + ], + ) + log.info("browser proxy starting on port %d", port) + _kill_orphaned_tunnels() + HTTPServer(("0.0.0.0", port), _Handler).serve_forever() # noqa: S104 + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("port", type=int) + parser.add_argument("--log-file", default=None) + args = parser.parse_args() + serve(args.port, log_file=args.log_file) diff --git a/src/dda/env/dev/interface.py b/src/dda/env/dev/interface.py index 5047950c..e9aebaf7 100644 --- a/src/dda/env/dev/interface.py +++ b/src/dda/env/dev/interface.py @@ -199,6 +199,44 @@ def launch_gui(self) -> NoReturn: """ raise NotImplementedError + def ensure_browser_proxy_started(self) -> None: + """ + Start the shared browser proxy daemon on the host if it is not already running. + + The daemon forwards browser open requests from inside the environment to the host's + default browser, including setting up SSH port forwards for OAuth callbacks. + Subclasses that do not support browser forwarding may leave this as a no-op. + """ + import sys + + try: + import psutil + except ImportError: + return + + storage = self.app.config.storage.join("browser-proxy") + storage.data.ensure_dir() + pid_file = storage.data / "server.pid" + log_file = storage.data / "browser-proxy.log" + if pid_file.is_file(): + try: + pid = int(pid_file.read_text().strip()) + proc = psutil.Process(pid) + if proc.is_running() and "dda.env.dev.browser_proxy" in " ".join(proc.cmdline()): + return + except (ValueError, psutil.NoSuchProcess, psutil.AccessDenied): + pass + pid_file.unlink() + pid = self.app.subprocess.spawn_daemon([ + sys.executable, + "-m", + "dda.env.dev.browser_proxy", + str(self.browser_proxy_port), + "--log-file", + str(log_file), + ]) + pid_file.write_text(str(pid), encoding="utf-8") + def remove_cache(self) -> None: """ This method removes the developer environment's cache that is persisted between lifecycles. @@ -277,6 +315,16 @@ def global_shared_dir(self) -> Path: """ return self.storage_dirs.data.parent.joinpath(".shared") + @cached_property + def browser_proxy_port(self) -> int: + """ + The port used by the shared browser proxy daemon on the host. + All environment types that support browser forwarding share this port. + """ + from dda.utils.network.protocols import derive_service_port + + return derive_service_port("dda-browser-proxy") + @cached_property def default_repo(self) -> str: """ diff --git a/src/dda/env/dev/scripts/__init__.py b/src/dda/env/dev/scripts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/dda/env/dev/scripts/xdg_open_template.py.template b/src/dda/env/dev/scripts/xdg_open_template.py.template new file mode 100644 index 00000000..065fb0be --- /dev/null +++ b/src/dda/env/dev/scripts/xdg_open_template.py.template @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +# +# This script is mounted into dev containers as /usr/local/bin/xdg-open. +# It forwards open requests to the browser proxy daemon running on the host. +# +# {proxy_port} and {ssh_port} are substituted at container start time so the +# script works inside SSH sessions (which do not inherit Docker -e variables) +# and so the daemon knows which container to tunnel back to for OAuth callbacks. +import sys +import urllib.parse +import urllib.request + + +def main() -> None: + if len(sys.argv) < 2: + sys.exit(1) + url = sys.argv[1] + encoded = urllib.parse.quote(url, safe="") + try: + urllib.request.urlopen( + f"http://host.docker.internal:{proxy_port}/open?url={{encoded}}&ssh_port={ssh_port}", + timeout=5, + ) + except Exception as exc: + print(f"browser-proxy: {{exc}}", file=sys.stderr) + sys.exit(1) + + +main() diff --git a/src/dda/env/dev/types/linux_container.py b/src/dda/env/dev/types/linux_container.py index 07be3d5e..cde627b8 100644 --- a/src/dda/env/dev/types/linux_container.py +++ b/src/dda/env/dev/types/linux_container.py @@ -14,6 +14,25 @@ from dda.utils.fs import cp_r, temp_directory from dda.utils.git.constants import GitEnvVars + +def _make_xdg_open_script(proxy_port: int, ssh_port: int) -> str: + """Return the xdg-open script with both ports substituted. + + Both the shared proxy port and the container's own SSH port are baked in + so the script works in SSH sessions (which do not inherit Docker ``-e`` + variables) and so the single shared daemon knows which container to tunnel + back to for OAuth callbacks. + """ + import importlib.resources + + template = ( + importlib.resources.files("dda.env.dev.scripts") + .joinpath("xdg_open_template.py.template") + .read_text(encoding="utf-8") + ) + return template.format(proxy_port=proxy_port, ssh_port=ssh_port) + + if TYPE_CHECKING: from dda.env.models import EnvironmentStatus from dda.env.shells.interface import Shell @@ -128,6 +147,7 @@ def start(self) -> None: status = self.__latest_status if self.__latest_status is not None else self.status() if status.state == EnvironmentState.STOPPED: self.docker.wait(["start", self.container_name], message=f"Starting container: {self.container_name}") + self.ensure_browser_proxy_started() else: from dda.config.constants import AppEnvVars from dda.utils.process import EnvVars @@ -140,6 +160,7 @@ def start(self) -> None: self.docker.wait(pull_command, message=f"Pulling image: {self.config.image}") self.shared_dir.ensure_dir() + self._write_xdg_open_script() command = [ "run", "--pull", @@ -156,10 +177,16 @@ def start(self) -> None: ] if sys.platform != "win32": command.extend(( + "--add-host", + "host.docker.internal:host-gateway", "-e", f"HOST_UID={os.getuid()}", "-e", f"HOST_GID={os.getgid()}", + "-e", + "BROWSER", + "-v", + f"{self._xdg_open_script_path}:/usr/local/bin/xdg-open:ro", )) command.extend(( @@ -211,6 +238,7 @@ def start(self) -> None: env = EnvVars() env["DD_SHELL"] = self.config.shell + env["BROWSER"] = "xdg-open" env[AppEnvVars.TELEMETRY_USER_MACHINE_ID] = self.app.telemetry.user.machine_id if self.app.telemetry.api_key is not None: env[AppEnvVars.TELEMETRY_API_KEY] = self.app.telemetry.api_key @@ -228,6 +256,7 @@ def start(self) -> None: with self.app.status(f"Waiting for container: {self.container_name}"): wait_for(self.check_readiness, timeout=30, interval=0.3) + self.ensure_browser_proxy_started() self.ensure_ssh_config() if self.config.clone: @@ -286,6 +315,7 @@ def status(self) -> EnvironmentStatus: def launch_shell(self, *, repo: str | None = None) -> NoReturn: self.ensure_ssh_config() + self.ensure_browser_proxy_started() ssh_command = self.ssh_base_command() ssh_command.append(self.shell.get_login_command(cwd=self.repo_path(repo))) process = self.app.subprocess.attach(ssh_command, check=False) @@ -296,6 +326,7 @@ def code(self, *, editor: EditorInterface, repo: str | None = None) -> None: self.app.abort(f"Unsupported editor: {editor.name}") self.ensure_ssh_config() + self.ensure_browser_proxy_started() repo_path = self.repo_path(repo) # TODO: Currently, we do not support aggregating local commands from multiple repositories as a single tool @@ -315,6 +346,7 @@ def code(self, *, editor: EditorInterface, repo: str | None = None) -> None: def run_command(self, command: list[str], *, repo: str | None = None) -> None: self.ensure_ssh_config() + self.ensure_browser_proxy_started() self.app.subprocess.run(self.construct_command(command, cwd=self.repo_path(repo))) def remove_cache(self) -> None: @@ -371,6 +403,10 @@ def mcp_port(self) -> int: return derive_service_port(f"{self.container_name}-mcp") + @cached_property + def _xdg_open_script_path(self) -> Any: + return self.storage_dirs.data / "bin" / "xdg-open" + @cached_property def home_dir(self) -> str: return "/home/dd" @@ -412,6 +448,13 @@ def get_volume_name(self, key: str) -> str: name += f"-{self.config.arch}" return name + def _write_xdg_open_script(self) -> None: + self._xdg_open_script_path.parent.ensure_dir() + self._xdg_open_script_path.write_text( + _make_xdg_open_script(self.browser_proxy_port, self.ssh_port), encoding="utf-8" + ) + os.chmod(self._xdg_open_script_path, 0o755) # noqa: S103 + def construct_command(self, command: list[str], *, cwd: str | None = None) -> list[str]: if cwd is None: cwd = self.home_dir diff --git a/tests/env/dev/types/test_linux_container.py b/tests/env/dev/types/test_linux_container.py index 07316c05..c29c3c4e 100644 --- a/tests/env/dev/types/test_linux_container.py +++ b/tests/env/dev/types/test_linux_container.py @@ -30,6 +30,15 @@ def updated_config(config_file): config_file.save() +@pytest.fixture(autouse=True) +def mock_spawn_daemon(mocker): + # Prevent spawn_daemon from launching real processes during tests. On Windows + # a real Popen inherits the test CWD (a temp dir), holding a directory handle + # that blocks pytest cleanup (WinError 32). Mocking here keeps all platforms + # consistent without skipping browser-proxy logic in production code. + mocker.patch("dda.utils.process.SubprocessRunner.spawn_daemon", return_value=0) + + @pytest.fixture(scope="module") def host_user_args(): return [] if sys.platform == "win32" else ["-e", f"HOST_UID={os.getuid()}", "-e", f"HOST_GID={os.getgid()}"] @@ -191,6 +200,7 @@ def test_default(self, dda, helpers, mocker, temp_dir, host_user_args): shared_dir = temp_dir / "data" / "env" / "dev" / "linux-container" / "default" / ".shared" global_shared_dir = shared_dir.parent.parent / ".shared" + xdg_open_script_path = shared_dir.parent / "bin" / "xdg-open" starship_mount = get_starship_mount(global_shared_dir) volumes = get_volumes() assert calls == [ @@ -214,7 +224,13 @@ def test_default(self, dda, helpers, mocker, temp_dir, host_user_args): "31381:9000", "-v", "/var/run/docker.sock:/var/run/docker.sock", + *([] if sys.platform == "win32" else ["--add-host", "host.docker.internal:host-gateway"]), *host_user_args, + *( + [] + if sys.platform == "win32" + else ["-e", "BROWSER", "-v", f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro"] + ), "-e", "DD_SHELL", "-e", @@ -274,6 +290,7 @@ def test_clone(self, dda, helpers, mocker, temp_dir, host_user_args): shared_dir = temp_dir / "data" / "env" / "dev" / "linux-container" / "default" / ".shared" global_shared_dir = shared_dir.parent.parent / ".shared" + xdg_open_script_path = shared_dir.parent / "bin" / "xdg-open" starship_mount = get_starship_mount(global_shared_dir) volumes = get_volumes() assert calls == [ @@ -297,7 +314,13 @@ def test_clone(self, dda, helpers, mocker, temp_dir, host_user_args): "31381:9000", "-v", "/var/run/docker.sock:/var/run/docker.sock", + *([] if sys.platform == "win32" else ["--add-host", "host.docker.internal:host-gateway"]), *host_user_args, + *( + [] + if sys.platform == "win32" + else ["-e", "BROWSER", "-v", f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro"] + ), "-e", "DD_SHELL", "-e", @@ -375,6 +398,7 @@ def test_no_pull(self, dda, helpers, mocker, temp_dir, host_user_args): shared_dir = temp_dir / "data" / "env" / "dev" / "linux-container" / "default" / ".shared" global_shared_dir = shared_dir.parent.parent / ".shared" + xdg_open_script_path = shared_dir.parent / "bin" / "xdg-open" starship_mount = get_starship_mount(global_shared_dir) volumes = get_volumes() assert calls == [ @@ -394,7 +418,13 @@ def test_no_pull(self, dda, helpers, mocker, temp_dir, host_user_args): "31381:9000", "-v", "/var/run/docker.sock:/var/run/docker.sock", + *([] if sys.platform == "win32" else ["--add-host", "host.docker.internal:host-gateway"]), *host_user_args, + *( + [] + if sys.platform == "win32" + else ["-e", "BROWSER", "-v", f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro"] + ), "-e", "DD_SHELL", "-e", @@ -462,6 +492,7 @@ def test_multiple(self, dda, helpers, mocker, temp_dir, host_user_args): shared_dir = temp_dir / "data" / "env" / "dev" / "linux-container" / "default" / ".shared" global_shared_dir = shared_dir.parent.parent / ".shared" + xdg_open_script_path = shared_dir.parent / "bin" / "xdg-open" starship_mount = get_starship_mount(global_shared_dir) volumes = get_volumes() assert calls == [ @@ -485,7 +516,13 @@ def test_multiple(self, dda, helpers, mocker, temp_dir, host_user_args): "31381:9000", "-v", "/var/run/docker.sock:/var/run/docker.sock", + *([] if sys.platform == "win32" else ["--add-host", "host.docker.internal:host-gateway"]), *host_user_args, + *( + [] + if sys.platform == "win32" + else ["-e", "BROWSER", "-v", f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro"] + ), "-e", "DD_SHELL", "-e", @@ -548,6 +585,7 @@ def test_multiple_clones(self, dda, helpers, mocker, temp_dir, host_user_args): shared_dir = temp_dir / "data" / "env" / "dev" / "linux-container" / "default" / ".shared" global_shared_dir = shared_dir.parent.parent / ".shared" + xdg_open_script_path = shared_dir.parent / "bin" / "xdg-open" starship_mount = get_starship_mount(global_shared_dir) volumes = get_volumes() assert calls == [ @@ -571,7 +609,13 @@ def test_multiple_clones(self, dda, helpers, mocker, temp_dir, host_user_args): "31381:9000", "-v", "/var/run/docker.sock:/var/run/docker.sock", + *([] if sys.platform == "win32" else ["--add-host", "host.docker.internal:host-gateway"]), *host_user_args, + *( + [] + if sys.platform == "win32" + else ["-e", "BROWSER", "-v", f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro"] + ), "-e", "DD_SHELL", "-e", @@ -643,6 +687,7 @@ def test_extra_volume_specs(self, dda, helpers, mocker, temp_dir, host_user_args shared_dir = temp_dir / "data" / "env" / "dev" / "linux-container" / "default" / ".shared" global_shared_dir = shared_dir.parent.parent / ".shared" + xdg_open_script_path = shared_dir.parent / "bin" / "xdg-open" starship_mount = get_starship_mount(global_shared_dir) volumes = get_volumes() @@ -692,7 +737,13 @@ def test_extra_volume_specs(self, dda, helpers, mocker, temp_dir, host_user_args "31381:9000", "-v", "/var/run/docker.sock:/var/run/docker.sock", + *([] if sys.platform == "win32" else ["--add-host", "host.docker.internal:host-gateway"]), *host_user_args, + *( + [] + if sys.platform == "win32" + else ["-e", "BROWSER", "-v", f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro"] + ), "-e", "DD_SHELL", "-e", @@ -747,6 +798,7 @@ def test_extra_mounts(self, dda, helpers, mocker, temp_dir, host_user_args, moun shared_dir = temp_dir / "data" / "env" / "dev" / "linux-container" / "default" / ".shared" global_shared_dir = shared_dir.parent.parent / ".shared" + xdg_open_script_path = shared_dir.parent / "bin" / "xdg-open" starship_mount = get_starship_mount(global_shared_dir) volumes = get_volumes() @@ -797,7 +849,13 @@ def test_extra_mounts(self, dda, helpers, mocker, temp_dir, host_user_args, moun "31381:9000", "-v", "/var/run/docker.sock:/var/run/docker.sock", + *([] if sys.platform == "win32" else ["--add-host", "host.docker.internal:host-gateway"]), *host_user_args, + *( + [] + if sys.platform == "win32" + else ["-e", "BROWSER", "-v", f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro"] + ), "-e", "DD_SHELL", "-e",