From e17a4c7fed3d7ddeeafce786fcd31595318cd05c Mon Sep 17 00:00:00 2001 From: Kevin Fairise Date: Fri, 5 Jun 2026 14:02:20 +0200 Subject: [PATCH 01/20] Add browser proxy to automatically forward browser connections to local laptop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When running `dda env dev start`, a small HTTP daemon is now started on the host that listens on a deterministic port (derived from the container name). Inside the container, /usr/local/bin/xdg-open is replaced with a Python script that forwards any xdg-open call to the host daemon via host.docker.internal. For OAuth/auth flows that redirect back to localhost:{port}/callback, the daemon detects the redirect_uri in the URL query parameters and sets up an SSH local port forward (host 127.0.0.1:{port} → container localhost:{port}) before opening the browser, so the auth callback reaches the service running inside the container. Co-Authored-By: Claude Sonnet 4.6 --- src/dda/env/dev/browser_proxy.py | 205 +++++++++++++++++++++++ src/dda/env/dev/types/linux_container.py | 91 ++++++++++ 2 files changed, 296 insertions(+) create mode 100644 src/dda/env/dev/browser_proxy.py diff --git a/src/dda/env/dev/browser_proxy.py b/src/dda/env/dev/browser_proxy.py new file mode 100644 index 00000000..397b3759 --- /dev/null +++ b/src/dda/env/dev/browser_proxy.py @@ -0,0 +1,205 @@ +# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# +# SPDX-License-Identifier: MIT +"""Browser proxy daemon. + +Serves a minimal HTTP endpoint that opens URLs in the host's default browser. +Designed to be started as a detached subprocess by ``LinuxContainer``. + +* Binds to ``0.0.0.0`` so Docker containers can reach it via + ``host.docker.internal``. +* Accepts ``GET /open?url=`` 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. +""" +from __future__ import annotations + +import socket +import subprocess +import sys +import threading +import time +import urllib.parse +from http.server import BaseHTTPRequestHandler, HTTPServer + +# 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 = 600 + +# How long to wait for SSH to bind the callback port (seconds). +_TUNNEL_BIND_TIMEOUT = 5.0 + +_ssh_port: int | None = None + + +class _Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + 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://")): + _handle_open(url) + self.send_response(200) + self.end_headers() + + def log_message(self, *args: object) -> None: + pass + + +# --------------------------------------------------------------------------- +# Core open logic +# --------------------------------------------------------------------------- + + +def _handle_open(url: str) -> 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) + if _ssh_port is not None: + _setup_port_forward(_ssh_port, 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"): + value = (params.get(key) or [None])[0] + if value: + try: + return urllib.parse.urlparse(value) + except Exception: # noqa: BLE001 + pass + # Recurse into nested URL-valued query parameters. + for values in params.values(): + for v in values: + try: + 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 + except Exception: # noqa: BLE001 + pass + return None + + +def _is_localhost(host: str) -> bool: + return host in ("localhost", "127.0.0.1", "::1", "0.0.0.0") + + +# --------------------------------------------------------------------------- +# SSH port-forward helpers +# --------------------------------------------------------------------------- + + +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. + + Blocks until the port is bound (or the attempt times out / fails) so the + caller can safely open the browser immediately after returning. + """ + proc = subprocess.Popen( + [ + "ssh", + "-N", + "-q", + "-p", + str(ssh_port), + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-L", + f"127.0.0.1:{callback_port}:localhost:{callback_port}", + "dd@localhost", + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + if not _wait_for_port_bound(proc, callback_port): + proc.terminate() + return + + def _cleanup() -> None: + time.sleep(_TUNNEL_LIFETIME) + try: + proc.terminate() + except Exception: # noqa: BLE001 + pass + + threading.Thread(target=_cleanup, daemon=True).start() + + +def _wait_for_port_bound(proc: subprocess.Popen, port: int) -> bool: + """Return True once ssh has bound *port* on 127.0.0.1, False on timeout or + early ssh exit. + + Detection strategy: try to bind the port ourselves — if that raises + EADDRINUSE it means ssh already owns it. + """ + deadline = time.monotonic() + _TUNNEL_BIND_TIMEOUT + while time.monotonic() < deadline: + if proc.poll() is not None: + return False # ssh exited early (e.g. ExitOnForwardFailure) + 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: + return True # EADDRINUSE: ssh has claimed the port + return False + + +# --------------------------------------------------------------------------- +# Browser open +# --------------------------------------------------------------------------- + + +def _open_browser(url: str) -> None: + if sys.platform == "darwin": + subprocess.run(["open", url], check=False) # noqa: S603, S607 + elif sys.platform == "linux": + subprocess.run(["xdg-open", url], check=False) # noqa: S603, S607 + else: + import webbrowser + + webbrowser.open(url) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def serve(port: int, ssh_port: int | None = None) -> None: + global _ssh_port # noqa: PLW0603 + _ssh_port = ssh_port + 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("--ssh-port", type=int, default=None) + args = parser.parse_args() + serve(args.port, args.ssh_port) diff --git a/src/dda/env/dev/types/linux_container.py b/src/dda/env/dev/types/linux_container.py index 07be3d5e..abe3fe73 100644 --- a/src/dda/env/dev/types/linux_container.py +++ b/src/dda/env/dev/types/linux_container.py @@ -8,6 +8,32 @@ from functools import cached_property from typing import TYPE_CHECKING, Annotated, Any, Literal, NoReturn +# Script installed inside the container as /usr/local/bin/xdg-open. It +# forwards browser-open requests to the host daemon via host.docker.internal. +_XDG_OPEN_SCRIPT = """\ +#!/usr/bin/env python3 +import os, sys, urllib.parse, urllib.request + +def main(): + if len(sys.argv) < 2: + sys.exit(1) + url = sys.argv[1] + port = os.environ.get("DDA_BROWSER_PROXY_PORT", "") + if not port: + sys.exit(0) + encoded = urllib.parse.quote(url, safe="") + try: + urllib.request.urlopen( + f"http://host.docker.internal:{port}/open?url={encoded}", + timeout=5, + ) + except Exception as exc: + print(f"browser-proxy: {exc}", file=sys.stderr) + sys.exit(1) + +main() +""" + import msgspec from dda.env.dev.interface import DeveloperEnvironmentConfig, DeveloperEnvironmentInterface @@ -128,6 +154,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._start_browser_proxy() else: from dda.config.constants import AppEnvVars from dda.utils.process import EnvVars @@ -140,6 +167,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", @@ -147,6 +175,8 @@ def start(self) -> None: "-d", "--name", self.container_name, + "--add-host", + "host.docker.internal:host-gateway", "-p", f"{self.ssh_port}:22", "-p", @@ -173,6 +203,12 @@ def start(self) -> None: GitEnvVars.AUTHOR_NAME, "-e", GitEnvVars.AUTHOR_EMAIL, + "-e", + "DDA_BROWSER_PROXY_PORT", + "-e", + "BROWSER", + "-v", + f"{self._xdg_open_script_path}:/usr/local/bin/xdg-open:ro", )) if self.config.arch is not None: command.extend(("--platform", f"linux/{self.config.arch}")) @@ -211,6 +247,8 @@ def start(self) -> None: env = EnvVars() env["DD_SHELL"] = self.config.shell + env["DDA_BROWSER_PROXY_PORT"] = str(self.browser_proxy_port) + 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 +266,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._start_browser_proxy() self.ensure_ssh_config() if self.config.clone: @@ -247,6 +286,7 @@ def stop(self) -> None: ["stop", "-t", "0", self.container_name], message=f"Stopping container: {self.container_name}", ) + self._stop_browser_proxy() def remove(self) -> None: self.docker.wait(["rm", "-f", self.container_name], message=f"Removing container: {self.container_name}") @@ -371,6 +411,16 @@ def mcp_port(self) -> int: return derive_service_port(f"{self.container_name}-mcp") + @cached_property + def browser_proxy_port(self) -> int: + from dda.utils.network.protocols import derive_service_port + + return derive_service_port(f"{self.container_name}-browser-proxy") + + @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 +462,47 @@ 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(_XDG_OPEN_SCRIPT, encoding="utf-8") + os.chmod(self._xdg_open_script_path, 0o755) + + def _start_browser_proxy(self) -> None: + import psutil + + pid_file = self.storage_dirs.data / "browser-proxy.pid" + if pid_file.is_file(): + try: + pid = int(pid_file.read_text().strip()) + if psutil.Process(pid).is_running(): + return + except (ValueError, psutil.NoSuchProcess): + pass + pid_file.unlink() + + pid = self.app.subprocess.spawn_daemon([ + sys.executable, + "-m", + "dda.env.dev.browser_proxy", + str(self.browser_proxy_port), + "--ssh-port", + str(self.ssh_port), + ]) + pid_file.write_text(str(pid), encoding="utf-8") + + def _stop_browser_proxy(self) -> None: + import psutil + + pid_file = self.storage_dirs.data / "browser-proxy.pid" + if not pid_file.is_file(): + return + try: + pid = int(pid_file.read_text().strip()) + psutil.Process(pid).kill() + except (ValueError, psutil.NoSuchProcess): + pass + pid_file.unlink() + def construct_command(self, command: list[str], *, cwd: str | None = None) -> list[str]: if cwd is None: cwd = self.home_dir From 0d9805c29f309e1e825a99eed4ee171177859df9 Mon Sep 17 00:00:00 2001 From: Kevin Fairise Date: Fri, 5 Jun 2026 14:15:17 +0200 Subject: [PATCH 02/20] Fix _wait_for_port_bound to correctly handle port already taken by another tunnel When two containers run OAuth simultaneously with the same callback port, the second SSH tunnel can't bind the port. Previously _wait_for_port_bound would incorrectly return True (EADDRINUSE detected but from the wrong process). Now it also verifies that our ssh process is still alive after detecting the port is taken, returning False if ssh exited (meaning the port belongs to someone else). Co-Authored-By: Claude Sonnet 4.6 --- src/dda/env/dev/browser_proxy.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/dda/env/dev/browser_proxy.py b/src/dda/env/dev/browser_proxy.py index 397b3759..3e39f2e6 100644 --- a/src/dda/env/dev/browser_proxy.py +++ b/src/dda/env/dev/browser_proxy.py @@ -148,23 +148,26 @@ def _cleanup() -> None: def _wait_for_port_bound(proc: subprocess.Popen, port: int) -> bool: - """Return True once ssh has bound *port* on 127.0.0.1, False on timeout or - early ssh exit. + """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 it means ssh already owns it. + EADDRINUSE we know *something* holds it. We then confirm it is our ssh + process (not a pre-existing listener or another container's tunnel) by + checking that ``proc`` is still alive. If ssh exited, the port was already + taken by someone else and the forward was never established. """ deadline = time.monotonic() + _TUNNEL_BIND_TIMEOUT while time.monotonic() < deadline: if proc.poll() is not None: - return False # ssh exited early (e.g. ExitOnForwardFailure) + return False # ssh exited early 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: - return True # EADDRINUSE: ssh has claimed the port + # Port is taken — verify it is our ssh process still running + return proc.poll() is None return False From 745a93886d9aa549678db96b7e685f9904b7f528 Mon Sep 17 00:00:00 2001 From: Kevin Fairise Date: Fri, 5 Jun 2026 14:23:37 +0200 Subject: [PATCH 03/20] Fix hatch fmt linting issues in browser proxy Co-Authored-By: Claude Sonnet 4.6 --- src/dda/env/dev/browser_proxy.py | 26 +++++++++++------------- src/dda/env/dev/types/linux_container.py | 14 ++++++------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/dda/env/dev/browser_proxy.py b/src/dda/env/dev/browser_proxy.py index 3e39f2e6..663ff399 100644 --- a/src/dda/env/dev/browser_proxy.py +++ b/src/dda/env/dev/browser_proxy.py @@ -13,8 +13,11 @@ an SSH local-port-forward is established *before* the browser opens so that the auth callback from the browser reaches the container service. """ + from __future__ import annotations +import contextlib +import shutil import socket import subprocess import sys @@ -36,7 +39,7 @@ class _Handler(BaseHTTPRequestHandler): - def do_GET(self) -> None: + def do_GET(self) -> None: # noqa: N802 parsed = urllib.parse.urlparse(self.path) if parsed.path == "/open": params = urllib.parse.parse_qs(parsed.query) @@ -79,26 +82,22 @@ def _find_redirect_url( for key in ("redirect_uri", "redirect_url", "redirect"): value = (params.get(key) or [None])[0] if value: - try: + with contextlib.suppress(Exception): return urllib.parse.urlparse(value) - except Exception: # noqa: BLE001 - pass # Recurse into nested URL-valued query parameters. for values in params.values(): for v in values: - try: + 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 - except Exception: # noqa: BLE001 - pass return None def _is_localhost(host: str) -> bool: - return host in ("localhost", "127.0.0.1", "::1", "0.0.0.0") + return host in {"localhost", "127.0.0.1", "::1", "0.0.0.0"} # noqa: S104 # --------------------------------------------------------------------------- @@ -113,9 +112,10 @@ def _setup_port_forward(ssh_port: int, callback_port: int) -> None: Blocks until the port is bound (or the attempt times out / fails) so the caller can safely open the browser immediately after returning. """ + ssh = shutil.which("ssh") or "ssh" proc = subprocess.Popen( [ - "ssh", + ssh, "-N", "-q", "-p", @@ -139,10 +139,8 @@ def _setup_port_forward(ssh_port: int, callback_port: int) -> None: def _cleanup() -> None: time.sleep(_TUNNEL_LIFETIME) - try: + with contextlib.suppress(Exception): proc.terminate() - except Exception: # noqa: BLE001 - pass threading.Thread(target=_cleanup, daemon=True).start() @@ -178,9 +176,9 @@ def _wait_for_port_bound(proc: subprocess.Popen, port: int) -> bool: def _open_browser(url: str) -> None: if sys.platform == "darwin": - subprocess.run(["open", url], check=False) # noqa: S603, S607 + subprocess.run(["open", url], check=False) # noqa: S607 elif sys.platform == "linux": - subprocess.run(["xdg-open", url], check=False) # noqa: S603, S607 + subprocess.run(["xdg-open", url], check=False) # noqa: S607 else: import webbrowser diff --git a/src/dda/env/dev/types/linux_container.py b/src/dda/env/dev/types/linux_container.py index abe3fe73..bd7c61a0 100644 --- a/src/dda/env/dev/types/linux_container.py +++ b/src/dda/env/dev/types/linux_container.py @@ -8,6 +8,12 @@ from functools import cached_property from typing import TYPE_CHECKING, Annotated, Any, Literal, NoReturn +import msgspec + +from dda.env.dev.interface import DeveloperEnvironmentConfig, DeveloperEnvironmentInterface +from dda.utils.fs import cp_r, temp_directory +from dda.utils.git.constants import GitEnvVars + # Script installed inside the container as /usr/local/bin/xdg-open. It # forwards browser-open requests to the host daemon via host.docker.internal. _XDG_OPEN_SCRIPT = """\ @@ -34,12 +40,6 @@ def main(): main() """ -import msgspec - -from dda.env.dev.interface import DeveloperEnvironmentConfig, DeveloperEnvironmentInterface -from dda.utils.fs import cp_r, temp_directory -from dda.utils.git.constants import GitEnvVars - if TYPE_CHECKING: from dda.env.models import EnvironmentStatus from dda.env.shells.interface import Shell @@ -465,7 +465,7 @@ def get_volume_name(self, key: str) -> str: def _write_xdg_open_script(self) -> None: self._xdg_open_script_path.parent.ensure_dir() self._xdg_open_script_path.write_text(_XDG_OPEN_SCRIPT, encoding="utf-8") - os.chmod(self._xdg_open_script_path, 0o755) + os.chmod(self._xdg_open_script_path, 0o755) # noqa: S103 def _start_browser_proxy(self) -> None: import psutil From 37bc28526179829b6642c875d09567f1b0d479f5 Mon Sep 17 00:00:00 2001 From: Kevin Fairise Date: Fri, 5 Jun 2026 14:25:45 +0200 Subject: [PATCH 04/20] Fix mypy type error in _find_redirect_url Co-Authored-By: Claude Sonnet 4.6 --- src/dda/env/dev/browser_proxy.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/dda/env/dev/browser_proxy.py b/src/dda/env/dev/browser_proxy.py index 663ff399..d317d5e3 100644 --- a/src/dda/env/dev/browser_proxy.py +++ b/src/dda/env/dev/browser_proxy.py @@ -80,7 +80,8 @@ def _find_redirect_url( if depth > _MAX_REDIRECT_DEPTH: return None for key in ("redirect_uri", "redirect_url", "redirect"): - value = (params.get(key) or [None])[0] + values = params.get(key) + value = values[0] if values else None if value: with contextlib.suppress(Exception): return urllib.parse.urlparse(value) From 1de37f0824e3bd26982d175a673d2277a23ccf77 Mon Sep 17 00:00:00 2001 From: Kevin Fairise Date: Fri, 5 Jun 2026 14:39:21 +0200 Subject: [PATCH 05/20] Fix tests to include browser proxy docker run args Co-Authored-By: Claude Sonnet 4.6 --- tests/env/dev/types/test_linux_container.py | 63 +++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/env/dev/types/test_linux_container.py b/tests/env/dev/types/test_linux_container.py index 07316c05..a6b3734e 100644 --- a/tests/env/dev/types/test_linux_container.py +++ b/tests/env/dev/types/test_linux_container.py @@ -191,6 +191,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 == [ @@ -208,6 +209,8 @@ def test_default(self, dda, helpers, mocker, temp_dir, host_user_args): "-d", "--name", "dda-linux-container-default", + "--add-host", + "host.docker.internal:host-gateway", "-p", "26090:22", "-p", @@ -225,6 +228,12 @@ def test_default(self, dda, helpers, mocker, temp_dir, host_user_args): GitEnvVars.AUTHOR_NAME, "-e", GitEnvVars.AUTHOR_EMAIL, + "-e", + "DDA_BROWSER_PROXY_PORT", + "-e", + "BROWSER", + "-v", + f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro", "-v", f"{shared_dir}:/.shared", *starship_mount, @@ -274,6 +283,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 == [ @@ -291,6 +301,8 @@ def test_clone(self, dda, helpers, mocker, temp_dir, host_user_args): "-d", "--name", "dda-linux-container-default", + "--add-host", + "host.docker.internal:host-gateway", "-p", "26090:22", "-p", @@ -308,6 +320,12 @@ def test_clone(self, dda, helpers, mocker, temp_dir, host_user_args): GitEnvVars.AUTHOR_NAME, "-e", GitEnvVars.AUTHOR_EMAIL, + "-e", + "DDA_BROWSER_PROXY_PORT", + "-e", + "BROWSER", + "-v", + f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro", "-v", f"{shared_dir}:/.shared", *starship_mount, @@ -375,6 +393,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 == [ @@ -388,6 +407,8 @@ def test_no_pull(self, dda, helpers, mocker, temp_dir, host_user_args): "-d", "--name", "dda-linux-container-default", + "--add-host", + "host.docker.internal:host-gateway", "-p", "26090:22", "-p", @@ -405,6 +426,12 @@ def test_no_pull(self, dda, helpers, mocker, temp_dir, host_user_args): GitEnvVars.AUTHOR_NAME, "-e", GitEnvVars.AUTHOR_EMAIL, + "-e", + "DDA_BROWSER_PROXY_PORT", + "-e", + "BROWSER", + "-v", + f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro", "-v", f"{shared_dir}:/.shared", *starship_mount, @@ -462,6 +489,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 == [ @@ -479,6 +507,8 @@ def test_multiple(self, dda, helpers, mocker, temp_dir, host_user_args): "-d", "--name", "dda-linux-container-default", + "--add-host", + "host.docker.internal:host-gateway", "-p", "26090:22", "-p", @@ -496,6 +526,12 @@ def test_multiple(self, dda, helpers, mocker, temp_dir, host_user_args): GitEnvVars.AUTHOR_NAME, "-e", GitEnvVars.AUTHOR_EMAIL, + "-e", + "DDA_BROWSER_PROXY_PORT", + "-e", + "BROWSER", + "-v", + f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro", "-v", f"{shared_dir}:/.shared", *starship_mount, @@ -548,6 +584,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 == [ @@ -565,6 +602,8 @@ def test_multiple_clones(self, dda, helpers, mocker, temp_dir, host_user_args): "-d", "--name", "dda-linux-container-default", + "--add-host", + "host.docker.internal:host-gateway", "-p", "26090:22", "-p", @@ -582,6 +621,12 @@ def test_multiple_clones(self, dda, helpers, mocker, temp_dir, host_user_args): GitEnvVars.AUTHOR_NAME, "-e", GitEnvVars.AUTHOR_EMAIL, + "-e", + "DDA_BROWSER_PROXY_PORT", + "-e", + "BROWSER", + "-v", + f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro", "-v", f"{shared_dir}:/.shared", *starship_mount, @@ -643,6 +688,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() @@ -686,6 +732,8 @@ def test_extra_volume_specs(self, dda, helpers, mocker, temp_dir, host_user_args "-d", "--name", "dda-linux-container-default", + "--add-host", + "host.docker.internal:host-gateway", "-p", "26090:22", "-p", @@ -703,6 +751,12 @@ def test_extra_volume_specs(self, dda, helpers, mocker, temp_dir, host_user_args GitEnvVars.AUTHOR_NAME, "-e", GitEnvVars.AUTHOR_EMAIL, + "-e", + "DDA_BROWSER_PROXY_PORT", + "-e", + "BROWSER", + "-v", + f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro", "-v", f"{shared_dir}:/.shared", *starship_mount, @@ -747,6 +801,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() @@ -791,6 +846,8 @@ def test_extra_mounts(self, dda, helpers, mocker, temp_dir, host_user_args, moun "-d", "--name", "dda-linux-container-default", + "--add-host", + "host.docker.internal:host-gateway", "-p", "26090:22", "-p", @@ -808,6 +865,12 @@ def test_extra_mounts(self, dda, helpers, mocker, temp_dir, host_user_args, moun GitEnvVars.AUTHOR_NAME, "-e", GitEnvVars.AUTHOR_EMAIL, + "-e", + "DDA_BROWSER_PROXY_PORT", + "-e", + "BROWSER", + "-v", + f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro", "-v", f"{shared_dir}:/.shared", *starship_mount, From 0b4c214c34dee1f176dd017d4804fe4d9777771a Mon Sep 17 00:00:00 2001 From: Kevin Fairise Date: Fri, 5 Jun 2026 14:50:52 +0200 Subject: [PATCH 06/20] Skip browser proxy on Windows to prevent temp dir locking spawn_daemon uses subprocess.Popen which is not mocked in tests. On Windows, the spawned process inherits the test CWD (inside a temp dir), holding a directory handle that blocks pytest cleanup and causes WinError 32 in subsequent tests. Co-Authored-By: Claude Sonnet 4.6 --- src/dda/env/dev/types/linux_container.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/dda/env/dev/types/linux_container.py b/src/dda/env/dev/types/linux_container.py index bd7c61a0..64eed6ac 100644 --- a/src/dda/env/dev/types/linux_container.py +++ b/src/dda/env/dev/types/linux_container.py @@ -468,6 +468,8 @@ def _write_xdg_open_script(self) -> None: os.chmod(self._xdg_open_script_path, 0o755) # noqa: S103 def _start_browser_proxy(self) -> None: + if sys.platform == "win32": + return import psutil pid_file = self.storage_dirs.data / "browser-proxy.pid" @@ -491,6 +493,8 @@ def _start_browser_proxy(self) -> None: pid_file.write_text(str(pid), encoding="utf-8") def _stop_browser_proxy(self) -> None: + if sys.platform == "win32": + return import psutil pid_file = self.storage_dirs.data / "browser-proxy.pid" From 27a8f1ce9a74c32da192705bf1e9161d7d55645c Mon Sep 17 00:00:00 2001 From: Kevin Fairise Date: Fri, 5 Jun 2026 15:17:21 +0200 Subject: [PATCH 07/20] Fix port-forward false positive when callback port is pre-occupied Without ExitOnForwardFailure=yes, ssh -N stays alive even if -L fails to bind, so _wait_for_port_bound (which checks proc.poll() is None after seeing EADDRINUSE) incorrectly treated a pre-existing listener as our own tunnel, opening the browser and sending the OAuth callback to the wrong local service. Fix: use -F /dev/null to suppress user SSH configs (the earlier reason for removing ExitOnForwardFailure was that ~/.ssh/workspaces config adds a failing reverse-forward that kills SSH), then re-add ExitOnForwardFailure=yes so SSH exits immediately on bind failure. Also add _active_tunnels dict + lock so concurrent /open requests for the same callback port share the existing tunnel instead of racing to start a second SSH process. Co-Authored-By: Claude Sonnet 4.6 --- src/dda/env/dev/browser_proxy.py | 77 ++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 28 deletions(-) diff --git a/src/dda/env/dev/browser_proxy.py b/src/dda/env/dev/browser_proxy.py index d317d5e3..f2d76c6f 100644 --- a/src/dda/env/dev/browser_proxy.py +++ b/src/dda/env/dev/browser_proxy.py @@ -37,6 +37,10 @@ _ssh_port: int | None = None +# Maps callback_port → live SSH Popen for that tunnel. +_active_tunnels: dict[int, subprocess.Popen] = {} +_tunnel_lock = threading.Lock() + class _Handler(BaseHTTPRequestHandler): def do_GET(self) -> None: # noqa: N802 @@ -110,38 +114,55 @@ 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. - Blocks until the port is bound (or the attempt times out / fails) so the - caller can safely open the browser immediately after returning. + Serialised per ``callback_port``: if a live tunnel already exists for that + port, this is a no-op. Uses ``ExitOnForwardFailure=yes`` (safe here + because ``-F /dev/null`` suppresses user SSH configs that may add failing + reverse-forwards) so SSH exits immediately when the port is pre-occupied, + making ``_wait_for_port_bound`` return False rather than treating a + pre-existing listener as our own tunnel. """ - ssh = shutil.which("ssh") or "ssh" - proc = subprocess.Popen( - [ - ssh, - "-N", - "-q", - "-p", - str(ssh_port), - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/dev/null", - "-L", - f"127.0.0.1:{callback_port}:localhost:{callback_port}", - "dd@localhost", - ], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - if not _wait_for_port_bound(proc, callback_port): - proc.terminate() - return + with _tunnel_lock: + existing = _active_tunnels.get(callback_port) + if existing is not None and existing.poll() is None: + return # live tunnel already installed for this 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.DEVNULL, + ) + + if not _wait_for_port_bound(proc, callback_port): + proc.terminate() + return + + _active_tunnels[callback_port] = proc def _cleanup() -> None: time.sleep(_TUNNEL_LIFETIME) - with contextlib.suppress(Exception): - proc.terminate() + with _tunnel_lock: + with contextlib.suppress(Exception): + proc.terminate() + _active_tunnels.pop(callback_port, None) threading.Thread(target=_cleanup, daemon=True).start() From 7bddfc8542c0fdf96087d42e812542f6f0176daa Mon Sep 17 00:00:00 2001 From: Kevin Fairise Date: Fri, 5 Jun 2026 15:20:35 +0200 Subject: [PATCH 08/20] Embed proxy port in xdg-open script instead of reading from env Docker -e variables are not propagated into SSH sessions (sshd only passes TERM per the SSH config), so DDA_BROWSER_PROXY_PORT was always empty in shells/commands run via `dda env dev shell/run`, causing xdg-open to silently exit without contacting the proxy. Fix: bake the port directly into the generated script at container start time. The script is already per-container, so the port is always correct and available regardless of how the session was opened. Co-Authored-By: Claude Sonnet 4.6 --- src/dda/env/dev/types/linux_container.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/dda/env/dev/types/linux_container.py b/src/dda/env/dev/types/linux_container.py index 64eed6ac..6d36fff5 100644 --- a/src/dda/env/dev/types/linux_container.py +++ b/src/dda/env/dev/types/linux_container.py @@ -14,32 +14,35 @@ from dda.utils.fs import cp_r, temp_directory from dda.utils.git.constants import GitEnvVars -# Script installed inside the container as /usr/local/bin/xdg-open. It -# forwards browser-open requests to the host daemon via host.docker.internal. -_XDG_OPEN_SCRIPT = """\ + +def _make_xdg_open_script(port: int) -> str: + """Return the xdg-open script with the proxy port embedded at write time. + + The port is baked in rather than read from an environment variable so the + script works in SSH sessions, which do not inherit Docker ``-e`` variables. + """ + return f"""\ #!/usr/bin/env python3 -import os, sys, urllib.parse, urllib.request +import sys, urllib.parse, urllib.request def main(): if len(sys.argv) < 2: sys.exit(1) url = sys.argv[1] - port = os.environ.get("DDA_BROWSER_PROXY_PORT", "") - if not port: - sys.exit(0) encoded = urllib.parse.quote(url, safe="") try: urllib.request.urlopen( - f"http://host.docker.internal:{port}/open?url={encoded}", + f"http://host.docker.internal:{port}/open?url={{encoded}}", timeout=5, ) except Exception as exc: - print(f"browser-proxy: {exc}", file=sys.stderr) + print(f"browser-proxy: {{exc}}", file=sys.stderr) sys.exit(1) main() """ + if TYPE_CHECKING: from dda.env.models import EnvironmentStatus from dda.env.shells.interface import Shell @@ -464,7 +467,7 @@ def get_volume_name(self, key: str) -> str: def _write_xdg_open_script(self) -> None: self._xdg_open_script_path.parent.ensure_dir() - self._xdg_open_script_path.write_text(_XDG_OPEN_SCRIPT, encoding="utf-8") + self._xdg_open_script_path.write_text(_make_xdg_open_script(self.browser_proxy_port), encoding="utf-8") os.chmod(self._xdg_open_script_path, 0o755) # noqa: S103 def _start_browser_proxy(self) -> None: From d008c356b6512ff94a8894afa3f0b08243368818 Mon Sep 17 00:00:00 2001 From: Kevin Fairise Date: Mon, 8 Jun 2026 11:05:42 +0200 Subject: [PATCH 09/20] Switch to a single shared browser proxy daemon for all containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-container daemons meant a crash affected only one container but required a per-container restart to recover. With a shared daemon, one restart fixes all containers simultaneously. Changes: - browser_proxy.py: remove _ssh_port global; each request now carries its container's ssh_port as a query param (?ssh_port=), so the stateless daemon can set up the right SSH tunnel per request. _active_tunnels is now keyed by (ssh_port, callback_port) so tunnels from different containers to the same callback port are tracked independently. - linux_container.py: browser_proxy_port derives from the fixed key "dda-browser-proxy" (same port for all containers). _make_xdg_open_script now embeds both the shared proxy port and the container's ssh_port. _start_browser_proxy uses a shared PID file at config.storage.join("browser-proxy"). _stop_browser_proxy removed — the daemon is not owned by any single container. _start_browser_proxy is also called in launch_shell, run_command, and code so a crashed daemon is automatically recovered on the next user interaction. Co-Authored-By: Claude Sonnet 4.6 --- src/dda/env/dev/browser_proxy.py | 58 ++++++++++++--------- src/dda/env/dev/types/linux_container.py | 46 ++++++---------- tests/env/dev/types/test_linux_container.py | 14 ----- 3 files changed, 49 insertions(+), 69 deletions(-) diff --git a/src/dda/env/dev/browser_proxy.py b/src/dda/env/dev/browser_proxy.py index f2d76c6f..c376757c 100644 --- a/src/dda/env/dev/browser_proxy.py +++ b/src/dda/env/dev/browser_proxy.py @@ -1,17 +1,20 @@ # SPDX-FileCopyrightText: 2025-present Datadog, Inc. # # SPDX-License-Identifier: MIT -"""Browser proxy daemon. +"""Browser proxy daemon — shared across all dev containers. Serves a minimal HTTP endpoint that opens URLs in the host's default browser. -Designed to be started as a detached subprocess by ``LinuxContainer``. +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=`` and opens only ``http``/``https`` URLs. +* 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 @@ -35,10 +38,10 @@ # How long to wait for SSH to bind the callback port (seconds). _TUNNEL_BIND_TIMEOUT = 5.0 -_ssh_port: int | None = None - -# Maps callback_port → live SSH Popen for that tunnel. -_active_tunnels: dict[int, subprocess.Popen] = {} +# 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() @@ -51,7 +54,12 @@ def do_GET(self) -> None: # noqa: N802 if urls: url = urls[0] if url.startswith(("http://", "https://")): - _handle_open(url) + 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 + _handle_open(url, ssh_port) self.send_response(200) self.end_headers() @@ -64,14 +72,14 @@ def log_message(self, *args: object) -> None: # --------------------------------------------------------------------------- -def _handle_open(url: str) -> None: +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) - if _ssh_port is not None: - _setup_port_forward(_ssh_port, port) + if ssh_port is not None: + _setup_port_forward(ssh_port, port) _open_browser(url) @@ -114,17 +122,18 @@ 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 ``callback_port``: if a live tunnel already exists for that - port, this is a no-op. Uses ``ExitOnForwardFailure=yes`` (safe here - because ``-F /dev/null`` suppresses user SSH configs that may add failing - reverse-forwards) so SSH exits immediately when the port is pre-occupied, - making ``_wait_for_port_bound`` return False rather than treating a - pre-existing listener as our own tunnel. + Serialised per ``(ssh_port, callback_port)`` pair: if a live tunnel already + exists for that container+port combination, this is a no-op. Uses + ``ExitOnForwardFailure=yes`` (safe here because ``-F /dev/null`` suppresses + user SSH configs that may add failing reverse-forwards) so SSH exits + immediately when the port is pre-occupied, making ``_wait_for_port_bound`` + return False rather than treating a pre-existing listener as our own tunnel. """ + tunnel_key = (ssh_port, callback_port) with _tunnel_lock: - existing = _active_tunnels.get(callback_port) + existing = _active_tunnels.get(tunnel_key) if existing is not None and existing.poll() is None: - return # live tunnel already installed for this port + return # live tunnel already installed for this container+port ssh = shutil.which("ssh") or "ssh" proc = subprocess.Popen( @@ -155,14 +164,14 @@ def _setup_port_forward(ssh_port: int, callback_port: int) -> None: proc.terminate() return - _active_tunnels[callback_port] = proc + _active_tunnels[tunnel_key] = proc def _cleanup() -> None: time.sleep(_TUNNEL_LIFETIME) with _tunnel_lock: with contextlib.suppress(Exception): proc.terminate() - _active_tunnels.pop(callback_port, None) + _active_tunnels.pop(tunnel_key, None) threading.Thread(target=_cleanup, daemon=True).start() @@ -212,9 +221,7 @@ def _open_browser(url: str) -> None: # --------------------------------------------------------------------------- -def serve(port: int, ssh_port: int | None = None) -> None: - global _ssh_port # noqa: PLW0603 - _ssh_port = ssh_port +def serve(port: int) -> None: HTTPServer(("0.0.0.0", port), _Handler).serve_forever() # noqa: S104 @@ -223,6 +230,5 @@ def serve(port: int, ssh_port: int | None = None) -> None: parser = argparse.ArgumentParser() parser.add_argument("port", type=int) - parser.add_argument("--ssh-port", type=int, default=None) args = parser.parse_args() - serve(args.port, args.ssh_port) + serve(args.port) diff --git a/src/dda/env/dev/types/linux_container.py b/src/dda/env/dev/types/linux_container.py index 6d36fff5..f5d25a65 100644 --- a/src/dda/env/dev/types/linux_container.py +++ b/src/dda/env/dev/types/linux_container.py @@ -15,11 +15,13 @@ from dda.utils.git.constants import GitEnvVars -def _make_xdg_open_script(port: int) -> str: - """Return the xdg-open script with the proxy port embedded at write time. +def _make_xdg_open_script(proxy_port: int, ssh_port: int) -> str: + """Return the xdg-open script with both ports embedded at write time. - The port is baked in rather than read from an environment variable so the - script works in SSH sessions, which do not inherit Docker ``-e`` variables. + 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. """ return f"""\ #!/usr/bin/env python3 @@ -32,7 +34,7 @@ def main(): encoded = urllib.parse.quote(url, safe="") try: urllib.request.urlopen( - f"http://host.docker.internal:{port}/open?url={{encoded}}", + f"http://host.docker.internal:{proxy_port}/open?url={{encoded}}&ssh_port={ssh_port}", timeout=5, ) except Exception as exc: @@ -207,8 +209,6 @@ def start(self) -> None: "-e", GitEnvVars.AUTHOR_EMAIL, "-e", - "DDA_BROWSER_PROXY_PORT", - "-e", "BROWSER", "-v", f"{self._xdg_open_script_path}:/usr/local/bin/xdg-open:ro", @@ -250,7 +250,6 @@ def start(self) -> None: env = EnvVars() env["DD_SHELL"] = self.config.shell - env["DDA_BROWSER_PROXY_PORT"] = str(self.browser_proxy_port) 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: @@ -289,7 +288,6 @@ def stop(self) -> None: ["stop", "-t", "0", self.container_name], message=f"Stopping container: {self.container_name}", ) - self._stop_browser_proxy() def remove(self) -> None: self.docker.wait(["rm", "-f", self.container_name], message=f"Removing container: {self.container_name}") @@ -329,6 +327,7 @@ def status(self) -> EnvironmentStatus: def launch_shell(self, *, repo: str | None = None) -> NoReturn: self.ensure_ssh_config() + self._start_browser_proxy() 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) @@ -339,6 +338,7 @@ def code(self, *, editor: EditorInterface, repo: str | None = None) -> None: self.app.abort(f"Unsupported editor: {editor.name}") self.ensure_ssh_config() + self._start_browser_proxy() repo_path = self.repo_path(repo) # TODO: Currently, we do not support aggregating local commands from multiple repositories as a single tool @@ -358,6 +358,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._start_browser_proxy() self.app.subprocess.run(self.construct_command(command, cwd=self.repo_path(repo))) def remove_cache(self) -> None: @@ -418,7 +419,7 @@ def mcp_port(self) -> int: def browser_proxy_port(self) -> int: from dda.utils.network.protocols import derive_service_port - return derive_service_port(f"{self.container_name}-browser-proxy") + return derive_service_port("dda-browser-proxy") @cached_property def _xdg_open_script_path(self) -> Any: @@ -467,7 +468,9 @@ def get_volume_name(self, key: str) -> str: 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), encoding="utf-8") + 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 _start_browser_proxy(self) -> None: @@ -475,7 +478,9 @@ def _start_browser_proxy(self) -> None: return import psutil - pid_file = self.storage_dirs.data / "browser-proxy.pid" + storage = self.app.config.storage.join("browser-proxy") + storage.data.ensure_dir() + pid_file = storage.data / "server.pid" if pid_file.is_file(): try: pid = int(pid_file.read_text().strip()) @@ -490,26 +495,9 @@ def _start_browser_proxy(self) -> None: "-m", "dda.env.dev.browser_proxy", str(self.browser_proxy_port), - "--ssh-port", - str(self.ssh_port), ]) pid_file.write_text(str(pid), encoding="utf-8") - def _stop_browser_proxy(self) -> None: - if sys.platform == "win32": - return - import psutil - - pid_file = self.storage_dirs.data / "browser-proxy.pid" - if not pid_file.is_file(): - return - try: - pid = int(pid_file.read_text().strip()) - psutil.Process(pid).kill() - except (ValueError, psutil.NoSuchProcess): - pass - pid_file.unlink() - 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 a6b3734e..1736dbdb 100644 --- a/tests/env/dev/types/test_linux_container.py +++ b/tests/env/dev/types/test_linux_container.py @@ -229,8 +229,6 @@ def test_default(self, dda, helpers, mocker, temp_dir, host_user_args): "-e", GitEnvVars.AUTHOR_EMAIL, "-e", - "DDA_BROWSER_PROXY_PORT", - "-e", "BROWSER", "-v", f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro", @@ -321,8 +319,6 @@ def test_clone(self, dda, helpers, mocker, temp_dir, host_user_args): "-e", GitEnvVars.AUTHOR_EMAIL, "-e", - "DDA_BROWSER_PROXY_PORT", - "-e", "BROWSER", "-v", f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro", @@ -427,8 +423,6 @@ def test_no_pull(self, dda, helpers, mocker, temp_dir, host_user_args): "-e", GitEnvVars.AUTHOR_EMAIL, "-e", - "DDA_BROWSER_PROXY_PORT", - "-e", "BROWSER", "-v", f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro", @@ -527,8 +521,6 @@ def test_multiple(self, dda, helpers, mocker, temp_dir, host_user_args): "-e", GitEnvVars.AUTHOR_EMAIL, "-e", - "DDA_BROWSER_PROXY_PORT", - "-e", "BROWSER", "-v", f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro", @@ -622,8 +614,6 @@ def test_multiple_clones(self, dda, helpers, mocker, temp_dir, host_user_args): "-e", GitEnvVars.AUTHOR_EMAIL, "-e", - "DDA_BROWSER_PROXY_PORT", - "-e", "BROWSER", "-v", f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro", @@ -752,8 +742,6 @@ def test_extra_volume_specs(self, dda, helpers, mocker, temp_dir, host_user_args "-e", GitEnvVars.AUTHOR_EMAIL, "-e", - "DDA_BROWSER_PROXY_PORT", - "-e", "BROWSER", "-v", f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro", @@ -866,8 +854,6 @@ def test_extra_mounts(self, dda, helpers, mocker, temp_dir, host_user_args, moun "-e", GitEnvVars.AUTHOR_EMAIL, "-e", - "DDA_BROWSER_PROXY_PORT", - "-e", "BROWSER", "-v", f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro", From 1043d4000d14a1f1d638a4c547f7ea2f721a719f Mon Sep 17 00:00:00 2001 From: Kevin Fairise Date: Mon, 8 Jun 2026 11:22:19 +0200 Subject: [PATCH 10/20] Skip --add-host, BROWSER env, and xdg-open mount on Windows The browser proxy daemon is a no-op on Windows, so the container-side plumbing that supports it (--add-host host.docker.internal:host-gateway, -e BROWSER, and the xdg-open volume mount) is also pointless there. Guard all three under the existing sys.platform != "win32" block. Co-Authored-By: Claude Sonnet 4.6 --- src/dda/env/dev/types/linux_container.py | 12 ++--- tests/env/dev/types/test_linux_container.py | 56 ++++++--------------- 2 files changed, 20 insertions(+), 48 deletions(-) diff --git a/src/dda/env/dev/types/linux_container.py b/src/dda/env/dev/types/linux_container.py index f5d25a65..e04c5b51 100644 --- a/src/dda/env/dev/types/linux_container.py +++ b/src/dda/env/dev/types/linux_container.py @@ -180,8 +180,6 @@ def start(self) -> None: "-d", "--name", self.container_name, - "--add-host", - "host.docker.internal:host-gateway", "-p", f"{self.ssh_port}:22", "-p", @@ -191,10 +189,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(( @@ -208,10 +212,6 @@ def start(self) -> None: GitEnvVars.AUTHOR_NAME, "-e", GitEnvVars.AUTHOR_EMAIL, - "-e", - "BROWSER", - "-v", - f"{self._xdg_open_script_path}:/usr/local/bin/xdg-open:ro", )) if self.config.arch is not None: command.extend(("--platform", f"linux/{self.config.arch}")) diff --git a/tests/env/dev/types/test_linux_container.py b/tests/env/dev/types/test_linux_container.py index 1736dbdb..52f47a76 100644 --- a/tests/env/dev/types/test_linux_container.py +++ b/tests/env/dev/types/test_linux_container.py @@ -209,15 +209,15 @@ def test_default(self, dda, helpers, mocker, temp_dir, host_user_args): "-d", "--name", "dda-linux-container-default", - "--add-host", - "host.docker.internal:host-gateway", "-p", "26090:22", "-p", "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", @@ -228,10 +228,6 @@ def test_default(self, dda, helpers, mocker, temp_dir, host_user_args): GitEnvVars.AUTHOR_NAME, "-e", GitEnvVars.AUTHOR_EMAIL, - "-e", - "BROWSER", - "-v", - f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro", "-v", f"{shared_dir}:/.shared", *starship_mount, @@ -299,15 +295,15 @@ def test_clone(self, dda, helpers, mocker, temp_dir, host_user_args): "-d", "--name", "dda-linux-container-default", - "--add-host", - "host.docker.internal:host-gateway", "-p", "26090:22", "-p", "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", @@ -318,10 +314,6 @@ def test_clone(self, dda, helpers, mocker, temp_dir, host_user_args): GitEnvVars.AUTHOR_NAME, "-e", GitEnvVars.AUTHOR_EMAIL, - "-e", - "BROWSER", - "-v", - f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro", "-v", f"{shared_dir}:/.shared", *starship_mount, @@ -403,15 +395,15 @@ def test_no_pull(self, dda, helpers, mocker, temp_dir, host_user_args): "-d", "--name", "dda-linux-container-default", - "--add-host", - "host.docker.internal:host-gateway", "-p", "26090:22", "-p", "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", @@ -422,10 +414,6 @@ def test_no_pull(self, dda, helpers, mocker, temp_dir, host_user_args): GitEnvVars.AUTHOR_NAME, "-e", GitEnvVars.AUTHOR_EMAIL, - "-e", - "BROWSER", - "-v", - f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro", "-v", f"{shared_dir}:/.shared", *starship_mount, @@ -501,15 +489,15 @@ def test_multiple(self, dda, helpers, mocker, temp_dir, host_user_args): "-d", "--name", "dda-linux-container-default", - "--add-host", - "host.docker.internal:host-gateway", "-p", "26090:22", "-p", "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", @@ -520,10 +508,6 @@ def test_multiple(self, dda, helpers, mocker, temp_dir, host_user_args): GitEnvVars.AUTHOR_NAME, "-e", GitEnvVars.AUTHOR_EMAIL, - "-e", - "BROWSER", - "-v", - f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro", "-v", f"{shared_dir}:/.shared", *starship_mount, @@ -594,15 +578,15 @@ def test_multiple_clones(self, dda, helpers, mocker, temp_dir, host_user_args): "-d", "--name", "dda-linux-container-default", - "--add-host", - "host.docker.internal:host-gateway", "-p", "26090:22", "-p", "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", @@ -613,10 +597,6 @@ def test_multiple_clones(self, dda, helpers, mocker, temp_dir, host_user_args): GitEnvVars.AUTHOR_NAME, "-e", GitEnvVars.AUTHOR_EMAIL, - "-e", - "BROWSER", - "-v", - f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro", "-v", f"{shared_dir}:/.shared", *starship_mount, @@ -722,15 +702,15 @@ def test_extra_volume_specs(self, dda, helpers, mocker, temp_dir, host_user_args "-d", "--name", "dda-linux-container-default", - "--add-host", - "host.docker.internal:host-gateway", "-p", "26090:22", "-p", "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", @@ -741,10 +721,6 @@ def test_extra_volume_specs(self, dda, helpers, mocker, temp_dir, host_user_args GitEnvVars.AUTHOR_NAME, "-e", GitEnvVars.AUTHOR_EMAIL, - "-e", - "BROWSER", - "-v", - f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro", "-v", f"{shared_dir}:/.shared", *starship_mount, @@ -834,15 +810,15 @@ def test_extra_mounts(self, dda, helpers, mocker, temp_dir, host_user_args, moun "-d", "--name", "dda-linux-container-default", - "--add-host", - "host.docker.internal:host-gateway", "-p", "26090:22", "-p", "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", @@ -853,10 +829,6 @@ def test_extra_mounts(self, dda, helpers, mocker, temp_dir, host_user_args, moun GitEnvVars.AUTHOR_NAME, "-e", GitEnvVars.AUTHOR_EMAIL, - "-e", - "BROWSER", - "-v", - f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro", "-v", f"{shared_dir}:/.shared", *starship_mount, From 408b41f0aee657caa83499a9504eee1e96b3e9da Mon Sep 17 00:00:00 2001 From: Kevin Fairise Date: Mon, 8 Jun 2026 11:34:26 +0200 Subject: [PATCH 11/20] Fix stray character and fmt issues Co-Authored-By: Claude Sonnet 4.6 --- tests/env/dev/types/test_linux_container.py | 42 +++++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/tests/env/dev/types/test_linux_container.py b/tests/env/dev/types/test_linux_container.py index 52f47a76..bf689332 100644 --- a/tests/env/dev/types/test_linux_container.py +++ b/tests/env/dev/types/test_linux_container.py @@ -217,7 +217,11 @@ def test_default(self, dda, helpers, mocker, temp_dir, host_user_args): "/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"]), + *( + [] + if sys.platform == "win32" + else ["-e", "BROWSER", "-v", f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro"] + ), "-e", "DD_SHELL", "-e", @@ -303,7 +307,11 @@ def test_clone(self, dda, helpers, mocker, temp_dir, host_user_args): "/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"]), + *( + [] + if sys.platform == "win32" + else ["-e", "BROWSER", "-v", f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro"] + ), "-e", "DD_SHELL", "-e", @@ -403,7 +411,11 @@ def test_no_pull(self, dda, helpers, mocker, temp_dir, host_user_args): "/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"]), + *( + [] + if sys.platform == "win32" + else ["-e", "BROWSER", "-v", f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro"] + ), "-e", "DD_SHELL", "-e", @@ -497,7 +509,11 @@ def test_multiple(self, dda, helpers, mocker, temp_dir, host_user_args): "/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"]), + *( + [] + if sys.platform == "win32" + else ["-e", "BROWSER", "-v", f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro"] + ), "-e", "DD_SHELL", "-e", @@ -586,7 +602,11 @@ def test_multiple_clones(self, dda, helpers, mocker, temp_dir, host_user_args): "/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"]), + *( + [] + if sys.platform == "win32" + else ["-e", "BROWSER", "-v", f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro"] + ), "-e", "DD_SHELL", "-e", @@ -710,7 +730,11 @@ def test_extra_volume_specs(self, dda, helpers, mocker, temp_dir, host_user_args "/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"]), + *( + [] + if sys.platform == "win32" + else ["-e", "BROWSER", "-v", f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro"] + ), "-e", "DD_SHELL", "-e", @@ -818,7 +842,11 @@ def test_extra_mounts(self, dda, helpers, mocker, temp_dir, host_user_args, moun "/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"]), + *( + [] + if sys.platform == "win32" + else ["-e", "BROWSER", "-v", f"{xdg_open_script_path}:/usr/local/bin/xdg-open:ro"] + ), "-e", "DD_SHELL", "-e", From eadd02b617ae11c67e52779747cb9c5cd730e6b6 Mon Sep 17 00:00:00 2001 From: Kevin Fairise Date: Mon, 8 Jun 2026 11:38:30 +0200 Subject: [PATCH 12/20] Comment unavailable mkdoc inventory --- mkdocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index 190d3361..6a1884b1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -149,7 +149,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 From 1affbe4da41d134261e3713d744e4b76e40bbdfe Mon Sep 17 00:00:00 2001 From: Kevin Fairise Date: Mon, 8 Jun 2026 12:55:13 +0200 Subject: [PATCH 13/20] Set to 300s tunnel alive time --- src/dda/env/dev/browser_proxy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dda/env/dev/browser_proxy.py b/src/dda/env/dev/browser_proxy.py index c376757c..bbb10cee 100644 --- a/src/dda/env/dev/browser_proxy.py +++ b/src/dda/env/dev/browser_proxy.py @@ -33,7 +33,7 @@ _MAX_REDIRECT_DEPTH = 5 # How long to keep an SSH tunnel alive after it is established (seconds). -_TUNNEL_LIFETIME = 600 +_TUNNEL_LIFETIME = 300 # How long to wait for SSH to bind the callback port (seconds). _TUNNEL_BIND_TIMEOUT = 5.0 From 9b9642fdfcb3905006813aad9156efb9194881ce Mon Sep 17 00:00:00 2001 From: Kevin Fairise Date: Fri, 12 Jun 2026 14:54:57 +0200 Subject: [PATCH 14/20] Address first 3 minor comments --- src/dda/env/dev/browser_proxy.py | 13 ++++--------- src/dda/env/dev/types/linux_container.py | 13 +++++++------ 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/dda/env/dev/browser_proxy.py b/src/dda/env/dev/browser_proxy.py index bbb10cee..1a92e457 100644 --- a/src/dda/env/dev/browser_proxy.py +++ b/src/dda/env/dev/browser_proxy.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2025-present Datadog, Inc. +# SPDX-FileCopyrightText: 2026-present Datadog, Inc. # # SPDX-License-Identifier: MIT """Browser proxy daemon — shared across all dev containers. @@ -206,14 +206,9 @@ def _wait_for_port_bound(proc: subprocess.Popen, port: int) -> bool: def _open_browser(url: str) -> None: - if sys.platform == "darwin": - subprocess.run(["open", url], check=False) # noqa: S607 - elif sys.platform == "linux": - subprocess.run(["xdg-open", url], check=False) # noqa: S607 - else: - import webbrowser - - webbrowser.open(url) + import webbrowser + + webbrowser.open(url) # --------------------------------------------------------------------------- diff --git a/src/dda/env/dev/types/linux_container.py b/src/dda/env/dev/types/linux_container.py index e04c5b51..47f7d392 100644 --- a/src/dda/env/dev/types/linux_container.py +++ b/src/dda/env/dev/types/linux_container.py @@ -159,7 +159,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._start_browser_proxy() + self._ensure_browser_proxy_started() else: from dda.config.constants import AppEnvVars from dda.utils.process import EnvVars @@ -268,7 +268,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._start_browser_proxy() + self._ensure_browser_proxy_started() self.ensure_ssh_config() if self.config.clone: @@ -327,7 +327,7 @@ def status(self) -> EnvironmentStatus: def launch_shell(self, *, repo: str | None = None) -> NoReturn: self.ensure_ssh_config() - self._start_browser_proxy() + 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) @@ -338,7 +338,7 @@ def code(self, *, editor: EditorInterface, repo: str | None = None) -> None: self.app.abort(f"Unsupported editor: {editor.name}") self.ensure_ssh_config() - self._start_browser_proxy() + 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 @@ -358,7 +358,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._start_browser_proxy() + self._ensure_browser_proxy_started() self.app.subprocess.run(self.construct_command(command, cwd=self.repo_path(repo))) def remove_cache(self) -> None: @@ -473,7 +473,8 @@ def _write_xdg_open_script(self) -> None: ) os.chmod(self._xdg_open_script_path, 0o755) # noqa: S103 - def _start_browser_proxy(self) -> None: + def _ensure_browser_proxy_started(self) -> None: + """Start the shared browser proxy daemon if it is not already running.""" if sys.platform == "win32": return import psutil From 2117a421ed3639e410a28f5b4bfbd241e23f1cc6 Mon Sep 17 00:00:00 2001 From: Kevin Fairise Date: Fri, 12 Jun 2026 15:17:45 +0200 Subject: [PATCH 15/20] Remove windows guard and fix test --- src/dda/env/dev/types/linux_container.py | 8 +++++--- tests/env/dev/types/test_linux_container.py | 9 +++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/dda/env/dev/types/linux_container.py b/src/dda/env/dev/types/linux_container.py index 47f7d392..21d7d693 100644 --- a/src/dda/env/dev/types/linux_container.py +++ b/src/dda/env/dev/types/linux_container.py @@ -474,9 +474,11 @@ def _write_xdg_open_script(self) -> None: os.chmod(self._xdg_open_script_path, 0o755) # noqa: S103 def _ensure_browser_proxy_started(self) -> None: - """Start the shared browser proxy daemon if it is not already running.""" - if sys.platform == "win32": - return + """ + Start the shared browser proxy daemon if it is not already running. + Useful to open browser on the host machine from the container, to handle authentication + callbacks. + """ import psutil storage = self.app.config.storage.join("browser-proxy") diff --git a/tests/env/dev/types/test_linux_container.py b/tests/env/dev/types/test_linux_container.py index bf689332..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()}"] From 8f7e9ef9204fa3667cd2cd6cb7dd34bcbb2fe371 Mon Sep 17 00:00:00 2001 From: Kevin Fairise Date: Fri, 12 Jun 2026 16:30:53 +0200 Subject: [PATCH 16/20] Better logging for browser-proxy + safeguard to avoid orphaned processes --- src/dda/env/dev/browser_proxy.py | 142 +++++++++++++++++++---- src/dda/env/dev/types/linux_container.py | 9 +- 2 files changed, 126 insertions(+), 25 deletions(-) diff --git a/src/dda/env/dev/browser_proxy.py b/src/dda/env/dev/browser_proxy.py index 1a92e457..73b663f6 100644 --- a/src/dda/env/dev/browser_proxy.py +++ b/src/dda/env/dev/browser_proxy.py @@ -20,15 +20,17 @@ from __future__ import annotations import contextlib +import logging import shutil import socket import subprocess -import sys 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 @@ -59,12 +61,17 @@ def do_GET(self) -> None: # noqa: N802 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, *args: object) -> None: - pass + def log_message(self, fmt: str, *args: object) -> None: # noqa: PLR6301 + log.debug(fmt, *args) # --------------------------------------------------------------------------- @@ -78,8 +85,11 @@ def _handle_open(url: str, ssh_port: int | None) -> None: 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) @@ -117,24 +127,86 @@ def _is_localhost(host: str) -> bool: # 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: if a live tunnel already - exists for that container+port combination, this is a no-op. Uses - ``ExitOnForwardFailure=yes`` (safe here because ``-F /dev/null`` suppresses - user SSH configs that may add failing reverse-forwards) so SSH exits - immediately when the port is pre-occupied, making ``_wait_for_port_bound`` - return False rather than treating a pre-existing listener as our own tunnel. + 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: - return # live tunnel already installed for this container+port + 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( [ @@ -157,21 +229,29 @@ def _setup_port_forward(ssh_port: int, callback_port: int) -> None: ], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stderr=subprocess.PIPE, ) if not _wait_for_port_bound(proc, callback_port): - proc.terminate() + 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: - with contextlib.suppress(Exception): - proc.terminate() + _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() @@ -180,22 +260,29 @@ 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 (not a pre-existing listener or another container's tunnel) by - checking that ``proc`` is still alive. If ssh exited, the port was already - taken by someone else and the forward was never established. + 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 early + 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 — verify it is our ssh process still running + # 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 @@ -208,6 +295,7 @@ def _wait_for_port_bound(proc: subprocess.Popen, port: int) -> bool: def _open_browser(url: str) -> None: import webbrowser + log.info("opening browser: %s", url) webbrowser.open(url) @@ -216,7 +304,16 @@ def _open_browser(url: str) -> None: # --------------------------------------------------------------------------- -def serve(port: int) -> None: +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 @@ -225,5 +322,6 @@ def serve(port: int) -> None: parser = argparse.ArgumentParser() parser.add_argument("port", type=int) + parser.add_argument("--log-file", default=None) args = parser.parse_args() - serve(args.port) + serve(args.port, log_file=args.log_file) diff --git a/src/dda/env/dev/types/linux_container.py b/src/dda/env/dev/types/linux_container.py index 21d7d693..2af40a54 100644 --- a/src/dda/env/dev/types/linux_container.py +++ b/src/dda/env/dev/types/linux_container.py @@ -484,20 +484,23 @@ def _ensure_browser_proxy_started(self) -> None: 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()) - if psutil.Process(pid).is_running(): + proc = psutil.Process(pid) + if proc.is_running() and "dda.env.dev.browser_proxy" in " ".join(proc.cmdline()): return - except (ValueError, psutil.NoSuchProcess): + 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") From bccd8e82e403fd8f39667af5662987d42febd96c Mon Sep 17 00:00:00 2001 From: Kevin Fairise Date: Fri, 12 Jun 2026 17:00:58 +0200 Subject: [PATCH 17/20] Move script template to a dedicated file --- src/dda/env/dev/interface.py | 10 ++++++ src/dda/env/dev/scripts/__init__.py | 0 .../dev/scripts/xdg_open_template.py.template | 32 +++++++++++++++++ src/dda/env/dev/types/linux_container.py | 34 +++++-------------- 4 files changed, 50 insertions(+), 26 deletions(-) create mode 100644 src/dda/env/dev/scripts/__init__.py create mode 100644 src/dda/env/dev/scripts/xdg_open_template.py.template diff --git a/src/dda/env/dev/interface.py b/src/dda/env/dev/interface.py index 5047950c..0c8852fb 100644 --- a/src/dda/env/dev/interface.py +++ b/src/dda/env/dev/interface.py @@ -277,6 +277,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..980dded4 --- /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 2af40a54..81469f24 100644 --- a/src/dda/env/dev/types/linux_container.py +++ b/src/dda/env/dev/types/linux_container.py @@ -16,33 +16,21 @@ def _make_xdg_open_script(proxy_port: int, ssh_port: int) -> str: - """Return the xdg-open script with both ports embedded at write time. + """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. """ - return f"""\ -#!/usr/bin/env python3 -import sys, urllib.parse, urllib.request - -def main(): - 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) + import importlib.resources -main() -""" + 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: @@ -415,12 +403,6 @@ def mcp_port(self) -> int: return derive_service_port(f"{self.container_name}-mcp") - @cached_property - def browser_proxy_port(self) -> int: - from dda.utils.network.protocols import derive_service_port - - return derive_service_port("dda-browser-proxy") - @cached_property def _xdg_open_script_path(self) -> Any: return self.storage_dirs.data / "bin" / "xdg-open" From 7c20287a8f09b63b059fc0835dae2712d58cc0a5 Mon Sep 17 00:00:00 2001 From: Kevin Fairise Date: Fri, 12 Jun 2026 17:12:06 +0200 Subject: [PATCH 18/20] Move ensure_browser_proxy_started to parent class --- src/dda/env/dev/interface.py | 38 ++++++++++++++++++++++ src/dda/env/dev/types/linux_container.py | 41 +++--------------------- 2 files changed, 43 insertions(+), 36 deletions(-) diff --git a/src/dda/env/dev/interface.py b/src/dda/env/dev/interface.py index 0c8852fb..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. diff --git a/src/dda/env/dev/types/linux_container.py b/src/dda/env/dev/types/linux_container.py index 81469f24..cde627b8 100644 --- a/src/dda/env/dev/types/linux_container.py +++ b/src/dda/env/dev/types/linux_container.py @@ -147,7 +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() + self.ensure_browser_proxy_started() else: from dda.config.constants import AppEnvVars from dda.utils.process import EnvVars @@ -256,7 +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_browser_proxy_started() self.ensure_ssh_config() if self.config.clone: @@ -315,7 +315,7 @@ def status(self) -> EnvironmentStatus: def launch_shell(self, *, repo: str | None = None) -> NoReturn: self.ensure_ssh_config() - self._ensure_browser_proxy_started() + 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) @@ -326,7 +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() + 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 @@ -346,7 +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.ensure_browser_proxy_started() self.app.subprocess.run(self.construct_command(command, cwd=self.repo_path(repo))) def remove_cache(self) -> None: @@ -455,37 +455,6 @@ def _write_xdg_open_script(self) -> None: ) os.chmod(self._xdg_open_script_path, 0o755) # noqa: S103 - def _ensure_browser_proxy_started(self) -> None: - """ - Start the shared browser proxy daemon if it is not already running. - Useful to open browser on the host machine from the container, to handle authentication - callbacks. - """ - import psutil - - 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 construct_command(self, command: list[str], *, cwd: str | None = None) -> list[str]: if cwd is None: cwd = self.home_dir From 3f32f8ef163922f3b0018b7c3bb7439bd7505dfb Mon Sep 17 00:00:00 2001 From: Kevin Fairise Date: Fri, 12 Jun 2026 17:17:33 +0200 Subject: [PATCH 19/20] Fix template extrapolating --- src/dda/env/dev/scripts/xdg_open_template.py.template | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dda/env/dev/scripts/xdg_open_template.py.template b/src/dda/env/dev/scripts/xdg_open_template.py.template index 980dded4..065fb0be 100644 --- a/src/dda/env/dev/scripts/xdg_open_template.py.template +++ b/src/dda/env/dev/scripts/xdg_open_template.py.template @@ -21,11 +21,11 @@ def main() -> None: encoded = urllib.parse.quote(url, safe="") try: urllib.request.urlopen( - f"http://host.docker.internal:{proxy_port}/open?url={encoded}&ssh_port={ssh_port}", + 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) + print(f"browser-proxy: {{exc}}", file=sys.stderr) sys.exit(1) From 66f26fd01046c8b1c35e2e53b2876a46a0b702e3 Mon Sep 17 00:00:00 2001 From: Kevin Fairise Date: Fri, 12 Jun 2026 18:19:39 +0200 Subject: [PATCH 20/20] Add some doc --- docs/how-to/dev-env/browser-forwarding.md | 35 +++++++++++++++++++++++ mkdocs.yml | 2 ++ 2 files changed, 37 insertions(+) create mode 100644 docs/how-to/dev-env/browser-forwarding.md 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 6a1884b1..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: