From acdd4bc547f7c9c6770140cc5378c8e15227b338 Mon Sep 17 00:00:00 2001 From: rosstaco Date: Tue, 30 Jun 2026 15:52:46 +1000 Subject: [PATCH] feat(shell): forward host-browser passthrough, xdg-open shim, and code bridge from connected VS Code Discover a connected VS Code's IPC socket and browser helper and inject BROWSER + VSCODE_IPC_HOOK_CLI on docker exec, so browser logins (az, gh, glab, snowflake) reach the host. Also drop an xdg-open shim (+ aliases) that forwards to the helper, fixing tools that ignore $BROWSER (e.g. Atlassian acli) which VS Code itself can't forward. idc shell additionally prepends VS Code's remote-cli dir so `code` opens in the connected window. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 17 ++ .../2026-06-30-browser-passthrough/plan.md | 64 +++++++ .../research-findings.md | 107 +++++++++++ src/indevcontainer/copilot.py | 16 +- src/indevcontainer/shell.py | 162 ++++++++++++++++- tests/test_copilot.py | 68 +++++++ tests/test_shell.py | 168 ++++++++++++++++++ 7 files changed, 596 insertions(+), 6 deletions(-) create mode 100644 project/plans/2026-06-30-browser-passthrough/plan.md create mode 100644 project/plans/2026-06-30-browser-passthrough/research-findings.md diff --git a/README.md b/README.md index 9152548..a970e72 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,23 @@ the devcontainer. `idc shell` detects the VS Code relay socket at `/tmp/vscode-ssh-auth-*.sock` and sets `SSH_AUTH_SOCK` on `docker exec`. If no socket is found, it prints a hint to open the project in VS Code first. +Host-browser passthrough and the `code` command piggyback on the same connected +VS Code session. When VS Code is attached, `idc shell` and `idc copilot` detect +its CLI IPC socket (`/tmp/vscode-ipc-*.sock`) and browser helper, then set +`VSCODE_IPC_HOOK_CLI` and `BROWSER` on `docker exec`. CLIs that open a browser +to sign in (`az login`, `gh auth login`, `glab auth login`, `snowflake`, …) then +launch your **host** browser, and the OAuth `localhost` callback completes via +VS Code's automatic port forwarding — exactly like an integrated terminal. + +Some CLIs (notably Atlassian `acli`) call `xdg-open` directly and ignore +`$BROWSER`, so they don't work even in a plain VS Code terminal. `idc` also +drops an `xdg-open` shim (plus `x-www-browser`/`gnome-open`/`sensible-browser` +aliases) ahead of `PATH` that forwards to the same VS Code browser helper, so +those tools reach the host browser too. `idc shell` additionally prepends +VS Code's `remote-cli` directory to `PATH` so `code ` opens in the +connected window. All of this needs VS Code connected; otherwise `idc` prints a +one-line hint and continues without it. + ### `idc copilot [] [copilot args...]` Exec the GitHub Copilot CLI (`copilot`) inside the project's running devcontainer. diff --git a/project/plans/2026-06-30-browser-passthrough/plan.md b/project/plans/2026-06-30-browser-passthrough/plan.md new file mode 100644 index 0000000..4a94af4 --- /dev/null +++ b/project/plans/2026-06-30-browser-passthrough/plan.md @@ -0,0 +1,64 @@ +# Plan — host-browser passthrough + `code` shim + +See `research-findings.md` for the verified VS Code mechanism. Tier-1 +(piggyback) only; the VS-Code-independent relay is explicitly out of scope. + +## Goal + +When a VS Code window is connected to the dev container, `idc shell` and +`idc copilot` reproduce VS Code's terminal env so host-browser OAuth works, and +`idc shell` additionally exposes the `code` CLI shim. + +## Changes (all in `src/indevcontainer/`) + +1. **`shell.py` — `find_vscode_bridges(container_id, exec_user)`** + Returns `VSCodeBridges(ipc_sock, browser_helper, remote_cli_dir)` (each + `str | None`). One combined `docker exec [-u user] sh -c '…'` probe that + resolves `$HOME`, then `ls -t … | head -1` + `test`-validates each of the + three paths, printing exactly three (possibly empty) lines. Mirrors + `find_ssh_socket`'s structure and test style. + +2. **`shell.py` — `ContainerExec`** gains `vscode_ipc`, `browser_helper`, + `remote_cli_dir`. `prepare_container_exec` calls `find_vscode_bridges` and + populates them. When the IPC socket isn't found, print a single concise + stderr hint (passthrough/`code` need VS Code connected), styled like the + existing SSH hint. + +3. **`shell.py` — `run_shell`** injects `-e VSCODE_IPC_HOOK_CLI=…` and + `-e BROWSER=…` when present, then wraps the shell via the shared + `_path_wrapper_argv()` (POSIX `sh -c`) which prepends, in-container: an + `xdg-open` shim (+ aliases) that `exec`s the browser helper — for tools that + ignore `$BROWSER` like `acli` — and VS Code's `remote-cli` dir so `code` + resolves. No bridges ⇒ argv identical to today. + +4. **`copilot.py` — `run_copilot`** injects the same browser env and uses + `_path_wrapper_argv(..., remote_cli_dir=None)` — it gets the `xdg-open` shim + (browser logins from tools copilot runs reach the host) but not the + shell-only `code` shim. No bridges ⇒ exec's `copilot` directly, preserving + verbatim arg forwarding. + +5. **`README.md`** — extend the SSH-forwarding section: host-browser + passthrough + `code` shim, same "VS Code connected" caveat. + +## Tests (`tests/test_shell.py`, `tests/test_copilot.py`) + +- `TestFindVscodeBridges` mirroring `TestFindSshSocket`: all-found, + partial (e.g. ipc only), none-found, `$HOME`-unset path. Mock + `subprocess.run` with `_completed(0, "ipc\nbrowser\nremotecli\n", "")`. +- `run_shell`: assert `BROWSER=…` and `VSCODE_IPC_HOOK_CLI=…` in argv; assert + the PATH wrapper appears when remote-cli found and is absent otherwise; + assert argv unchanged when no bridges. +- `run_copilot`: assert browser env injected; assert no PATH wrapper. +- Hint-on-miss assertion via `capsys`. + +## Acceptance criteria + +- `uv run ruff check` and `uv run pytest` green. +- With VS Code connected: `BROWSER` + `VSCODE_IPC_HOOK_CLI` set in both + subcommands; `code` resolves in `idc shell`. +- Without VS Code: no new env, argv unchanged, one concise hint printed. + +## Out of scope + +- VS-Code-independent host relay / port-callback proxy (future, flag-gated). +- `xdg-open` shimming for tools that ignore `$BROWSER` (VS Code doesn't either). diff --git a/project/plans/2026-06-30-browser-passthrough/research-findings.md b/project/plans/2026-06-30-browser-passthrough/research-findings.md new file mode 100644 index 0000000..3c2e4a9 --- /dev/null +++ b/project/plans/2026-06-30-browser-passthrough/research-findings.md @@ -0,0 +1,107 @@ +# Research Findings — host-browser passthrough for `idc shell` / `idc copilot` + +Scope: make CLIs that "spawn the host browser" for OAuth (`az`, `gh`, `glab`, +`snowflake`, …) work inside `idc shell` / `idc copilot` the way they do in a +VS Code integrated terminal. + +This is the same architectural pattern as the 2026-05-27 `dcode-shell-host-bridges` +research: **piggyback on VS Code's live remote infrastructure, don't replace +it.** Browser passthrough and the `code` CLI shim both fall out of the same +discovery. + +## How VS Code does it (verified against `microsoft/vscode`) + +When a VS Code window is connected to the dev container, its terminal env +contains: + +- `BROWSER=/bin/helpers/browser-linux.sh` +- `VSCODE_IPC_HOOK_CLI=/tmp/vscode-ipc-.sock` +- `PATH` prepended with `/bin/remote-cli/` (the `code` / `code-insiders` + shims) + +`browser-linux.sh` (from `resources/server/bin/helpers/`) is just: + +```sh +ROOT="$(dirname "$(dirname "$(dirname "$(readlink -f "$0")")")")" +"$ROOT/node" "$ROOT/out/server-cli.js" --openExternal "$@" +``` + +`server-cli.js` connects to `VSCODE_IPC_HOOK_CLI` and asks the connected VS +Code (on the host) to open the URL. VS Code **also** auto-forwards container +`localhost:` to the host, so redirect-style OAuth +(browser → `http://localhost:/callback` → the CLI's in-container +listener) completes. Both halves — browser-open and port-forward — are handled +for free **when VS Code is connected**. + +`` is `~/.vscode-server` (stable) or `~/.vscode-server-insiders`. +Older builds named the helper `browser.sh`; newer ones use +`browser-{linux,darwin}.sh` + `browser.cmd`. + +## CLI compatibility + +Most relevant CLIs honor `$BROWSER`, so matching VS Code's `$BROWSER` reaches +parity: + +- `az`, `snowflake` — Python `webbrowser`, honors `$BROWSER`. +- `gh`, `glab` — `cli/browser` / `pkg/browser`, checks `$BROWSER` first. + +### Tools that ignore `$BROWSER` (e.g. Atlassian `acli`) — verified from binaries + +Some CLIs call `xdg-open` directly and never consult `$BROWSER`. Proven by +inspecting both `acli` binaries (macOS + `acli_linux_amd64`): + +- `acli` is Go; opens via its own `atlassian.com/cli/pkg/utils.OpenBrowser` + (no standard browser lib). +- macOS build → `open `; Linux build → bare `xdg-open `. +- **Zero** `$BROWSER` references; no fallback chain (no gio/x-www-browser/…). + +VS Code only sets `$BROWSER` and does **not** shim `xdg-open`, so these tools +fail even in a plain VS Code terminal — and our `$BROWSER`-only Tier-1 wouldn't +catch them either. Symptom: works on the Mac host, silent no-op in the +container. + +**Fix (implemented):** because `acli` calls *bare* `xdg-open` (PATH-resolved), +`idc` materializes an `xdg-open` shim (plus `x-www-browser` / `gnome-open` / +`gnome-www-browser` / `sensible-browser` / `www-browser` aliases) in a per-user +temp dir at process start and prepends it to `PATH`; each shim `exec`s the VS +Code browser helper. This makes `idc` strictly better than a plain VS Code +terminal for such tools. Verified end-to-end by executing the generated wrapper +with a real shell. See `_path_wrapper_argv` in `src/indevcontainer/shell.py`. + +## What `idc` must do — same parasitic trick as `find_ssh_socket` + +`find_ssh_socket` (`src/indevcontainer/shell.py`) already discovers VS Code's +relay socket and injects it via `docker exec -e SSH_AUTH_SOCK=...`. Mirror it: + +1. **IPC socket** — newest `/tmp/vscode-ipc-*.sock`, confirmed with `test -S`. +2. **Browser helper** — newest + `$HOME/.vscode-server*/bin/*/bin/helpers/browser-linux.sh` (fallback + `browser.sh`), confirmed `-x`. +3. **remote-cli dir** — newest `$HOME/.vscode-server*/bin/*/bin/remote-cli`, + confirmed `-d`. + +`$HOME` may be unset under `docker exec -u`, so resolve it inside the probe: +`h="${HOME:-$(getent passwd "$(id -u)" | cut -d: -f6)}"`. + +Inject: + +- `-e VSCODE_IPC_HOOK_CLI=` and `-e BROWSER=` (both subcommands). +- For `idc shell` only, prepend remote-cli to PATH so `code` resolves. A fresh + `docker exec` does **not** inherit VS Code's terminal PATH, and the shell runs + interactive-non-login (no `/etc/profile`), so wrap the exec: + ` -c 'export PATH=":$PATH"; exec '` + (`$PATH` expands in-container; `shlex.quote` the parts). `idc copilot` + forwards args verbatim, so it gets the browser env only — no wrapper. + +## Caveats / non-goals + +- **Requires VS Code connected.** No connected window → no IPC socket → no + passthrough and no `code`. Same UX as today's SSH forwarding; emit a one-line + stderr hint. +- **No VS-Code-independent relay in this change.** A host opener server + + `host.docker.internal` shim + dynamic callback-port proxy is feasible but a + real engineering project (host process lifecycle, cross-platform reachability + — Docker Desktop reaches host `127.0.0.1`, Linux needs the gateway IP and a + bridge-bound, token-gated listener — plus host→container port forwarding for + redirect OAuth). Deferred, exactly as the 2026-05-27 doc deferred the + equivalent SSH relay. Device-code flows would need only the opener. diff --git a/src/indevcontainer/copilot.py b/src/indevcontainer/copilot.py index da90adf..15582e5 100644 --- a/src/indevcontainer/copilot.py +++ b/src/indevcontainer/copilot.py @@ -12,7 +12,7 @@ import subprocess import sys -from indevcontainer.shell import prepare_container_exec +from indevcontainer.shell import _path_wrapper_argv, prepare_container_exec def _copilot_installed(container_id: str, exec_user: str | None) -> bool: @@ -65,10 +65,18 @@ def run_copilot(path: str, *, extra_args: list[str] | None = None) -> int: argv.extend(["-w", ctx.workdir]) if ctx.ssh_sock: argv.extend(["-e", f"SSH_AUTH_SOCK={ctx.ssh_sock}"]) + if ctx.vscode_ipc: + argv.extend(["-e", f"VSCODE_IPC_HOOK_CLI={ctx.vscode_ipc}"]) + if ctx.browser_helper: + argv.extend(["-e", f"BROWSER={ctx.browser_helper}"]) argv.append(ctx.container_id) - argv.append("copilot") - if extra_args: - argv.extend(extra_args) + # `code` (remote_cli_dir) is shell-only, but the xdg-open shim applies here + # too so browser logins from tools copilot runs reach the host browser. + command = ["copilot", *(extra_args or [])] + wrapper = _path_wrapper_argv( + command, browser_helper=ctx.browser_helper, remote_cli_dir=None + ) + argv.extend(wrapper if wrapper is not None else command) try: os.execvp("docker", argv) diff --git a/src/indevcontainer/shell.py b/src/indevcontainer/shell.py index 783b4b2..ed66a70 100644 --- a/src/indevcontainer/shell.py +++ b/src/indevcontainer/shell.py @@ -11,6 +11,7 @@ import json import os import platform +import shlex import subprocess import sys from dataclasses import dataclass @@ -431,6 +432,77 @@ def find_ssh_socket(container_id: str) -> str | None: return None +# --------------------------------------------------------------------------- +# VS Code remote bridge discovery (host-browser passthrough + `code` shim) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class VSCodeBridges: + """VS Code remote-server bridges discovered inside a running container. + + These are the artefacts a *connected* VS Code window leaves in the + container, mirroring how ``find_ssh_socket`` finds the SSH relay: they all + exist only while VS Code is attached and disappear on disconnect. + + * ``ipc_sock`` → injected as ``VSCODE_IPC_HOOK_CLI``; the unix socket the + ``code`` shim and the ``BROWSER`` helper talk to. + * ``browser_helper`` → injected as ``BROWSER``; the ``helpers/browser-*.sh`` + script that forwards ``--openExternal `` to the host browser. + * ``remote_cli_dir`` → prepended to ``PATH`` so ``code`` resolves. + """ + + ipc_sock: str | None = None + browser_helper: str | None = None + remote_cli_dir: str | None = None + + +# One probe, three lines out. Resolves $HOME first (often unset under +# `docker exec -u`), then prints the newest *validated* IPC socket, browser +# helper, and remote-cli dir — an empty line for any bridge that's absent, so +# the three slots always parse positionally. +_VSCODE_BRIDGE_PROBE = ( + 'h="${HOME:-$(getent passwd "$(id -u)" 2>/dev/null | cut -d: -f6)}"\n' + 's=$(ls -t /tmp/vscode-ipc-*.sock 2>/dev/null | head -1); ' + '[ -S "$s" ] && echo "$s" || echo\n' + 'b=$(ls -t "$h"/.vscode-server*/bin/*/bin/helpers/browser-linux.sh ' + '"$h"/.vscode-server*/bin/*/bin/helpers/browser.sh 2>/dev/null | head -1); ' + '[ -x "$b" ] && echo "$b" || echo\n' + 'c=$(ls -td "$h"/.vscode-server*/bin/*/bin/remote-cli 2>/dev/null | head -1); ' + '[ -d "$c" ] && echo "$c" || echo\n' +) + + +def find_vscode_bridges(container_id: str, exec_user: str | None) -> VSCodeBridges: + """Discover VS Code remote-server bridges inside a running container. + + Runs a single ``docker exec [-u ] sh -c`` probe (as the same user the + real exec will use, so ``$HOME`` and the server install path resolve + correctly). Every field is ``None`` unless a VS Code window is currently + connected. Never raises — returns an empty :class:`VSCodeBridges` on any + docker/parse failure. + """ + argv = ["docker", "exec"] + if exec_user: + argv.extend(["-u", exec_user]) + argv.extend([container_id, "sh", "-c", _VSCODE_BRIDGE_PROBE]) + try: + proc = subprocess.run(argv, capture_output=True, text=True, check=False) + except (FileNotFoundError, OSError): + return VSCodeBridges() + if proc.returncode != 0: + return VSCodeBridges() + + lines = proc.stdout.splitlines() + lines += [""] * (3 - len(lines)) # pad so missing trailing slots parse as "" + ipc, browser, remote_cli = lines[0].strip(), lines[1].strip(), lines[2].strip() + return VSCodeBridges( + ipc_sock=ipc or None, + browser_helper=browser or None, + remote_cli_dir=remote_cli or None, + ) + + # --------------------------------------------------------------------------- # Working-directory probe # --------------------------------------------------------------------------- @@ -661,6 +733,10 @@ class ContainerExec: agent socket to forward (or ``None``), and the parsed ``devcontainer.json`` so callers can layer their own logic on top (e.g. terminal-profile resolution). + + ``vscode_ipc`` / ``browser_helper`` / ``remote_cli_dir`` carry the VS Code + remote bridges (see :class:`VSCodeBridges`) used for host-browser + passthrough and the ``code`` shim; all ``None`` unless VS Code is connected. """ container_id: str @@ -671,6 +747,9 @@ class ContainerExec: devcontainer_cfg: dict main_repo: Path rel_path: Path | None + vscode_ipc: str | None = None + browser_helper: str | None = None + remote_cli_dir: str | None = None def prepare_container_exec(path: str) -> ContainerExec | None: @@ -800,6 +879,15 @@ def prepare_container_exec(path: str) -> ContainerExec | None: file=sys.stderr, ) + bridges = find_vscode_bridges(container_id, exec_user) + if bridges.ipc_sock is None: + print( + "idc: VS Code not connected — host-browser passthrough and the " + "`code` command are unavailable (open the project in VS Code to " + "enable)", + file=sys.stderr, + ) + if rel_path is not None: candidate_workdir = f"{workspace_folder}/{rel_path.as_posix()}" else: @@ -815,9 +903,70 @@ def prepare_container_exec(path: str) -> ContainerExec | None: devcontainer_cfg=devcontainer_cfg, main_repo=main_repo, rel_path=rel_path, + vscode_ipc=bridges.ipc_sock, + browser_helper=bridges.browser_helper, + remote_cli_dir=bridges.remote_cli_dir, ) +# Browser-opener commands (besides `xdg-open`) that take a URL as their first +# argument; we alias them all to the same shim so tools that call any of them +# directly still reach the host browser. +_BROWSER_SHIM_ALIASES = ( + "x-www-browser gnome-open gnome-www-browser sensible-browser www-browser" +) + + +def _path_wrapper_argv( + command: list[str], + *, + browser_helper: str | None, + remote_cli_dir: str | None, +) -> list[str] | None: + """Wrap *command* in ``sh -c`` to inject VS Code bridges onto ``PATH``. + + A fresh ``docker exec`` doesn't inherit VS Code's terminal ``PATH``, so we + prepend, in the container, at process start: + + * an ``xdg-open`` shim (plus common aliases) that ``exec``s the VS Code + browser helper — this rescues CLIs that call ``xdg-open`` directly and + ignore ``$BROWSER`` (e.g. Atlassian ``acli``), which neither VS Code nor + a plain ``$BROWSER`` export can forward; and + * VS Code's ``remote-cli`` dir so ``code`` resolves (callers that don't + want it — e.g. ``idc copilot`` — pass ``remote_cli_dir=None``). + + Returns the ``["sh", "-c",