Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` 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 [<path>] [copilot args...]`

Exec the GitHub Copilot CLI (`copilot`) inside the project's running devcontainer.
Expand Down
64 changes: 64 additions & 0 deletions project/plans/2026-06-30-browser-passthrough/plan.md
Original file line number Diff line number Diff line change
@@ -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).
107 changes: 107 additions & 0 deletions project/plans/2026-06-30-browser-passthrough/research-findings.md
Original file line number Diff line number Diff line change
@@ -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=<server>/bin/helpers/browser-linux.sh`
- `VSCODE_IPC_HOOK_CLI=/tmp/vscode-ipc-<uuid>.sock`
- `PATH` prepended with `<server>/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" <app> <ver> <commit> <exec> --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:<port>` to the host, so redirect-style OAuth
(browser → `http://localhost:<port>/callback` → the CLI's in-container
listener) completes. Both halves — browser-open and port-forward — are handled
for free **when VS Code is connected**.

`<server>` 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 <url>`; Linux build → bare `xdg-open <url>`.
- **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=<sock>` and `-e BROWSER=<helper>` (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:
`<shell> -c 'export PATH="<remote-cli>:$PATH"; exec <shell> <args>'`
(`$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.
16 changes: 12 additions & 4 deletions src/indevcontainer/copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading