Skip to content

feat(auth): open the login URL in a Windows browser from WSL - #206

Merged
bojanz merged 9 commits into
mainfrom
wsl-browser-login
Oct 13, 2026
Merged

bojanz merged 9 commits into
mainfrom
wsl-browser-login

Conversation

@pjcdawkins

@pjcdawkins pjcdawkins commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

On WSL there is usually no DISPLAY and no xdg-open, so the "Log in via a browser?" prompt was not offered, and login printed the URL instead of opening it. Under WSL (detected from WSL_DISTRO_NAME/WSL_INTEROP or the kernel release), if Windows interop is enabled (WSL's WSLInterop binfmt_misc entry, and not disabled in /etc/wsl.conf, which WSL2 needs), the CLI now counts as having a display, and opens URLs with Windows' rundll32.exe url.dll,FileProtocolHandler (as on Windows), from <root>/c/Windows/System32 (root is [automount] root in wsl.conf, default /mnt/) or else PATH (searching PATH is slow in WSL2). The local login server's 127.0.0.1 address works from Windows, as WSL forwards localhost.

The legacy CLI finds the opener in the same way, only when it needs one, so legacy commands also offer the prompt in WSL (the legacy CLI decides whether to offer it, with or without Go auth), and the PHP login opens the URL too.

Checked manually on Windows 11 with WSL1 and WSL2 (Ubuntu 24.04), as CI has no WSL. This was before the legacy CLI found the opener itself (Go passed it in an env var).

  • login opens the URL in Edge, which reaches the login page, with rundll32.exe from /mnt/c/Windows/System32 and from PATH.
  • A legacy command (project:list) offers "Log in via a browser?" and opens the URL, with and without Go auth.
  • With interop disabled in wsl.conf, there is no prompt and login prints the URL at once.

🤖 Generated with Claude Code

@upsun-dispatch upsun-dispatch Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Changes suggested — 🟡 2 warnings

🔍 Full review · 2 files reviewed

Verification
  • browserCommand keeps the explicit --browser option and the "0" opt-out ahead of the new WSL case, so users can still override the WSL choice.
  • isWSL returns false right away on non-Linux GOOS, so Windows and macOS behaviour is unchanged.
  • The rundll32 loop's inner p shadows the loop variable, but it returns the resolved absolute path, which TestBrowserCommand_WSL asserts.
  • TestBrowserCommand_WSL sets PATH to the temp dir only, so a real wslview on the host cannot change its result.

The new unit tests TestBrowserCommand_WSL (Linux only, stubbed rundll32.exe on PATH) and TestIsWSL_NotWSL cover detection and command selection, and run through make test. Nothing covers the case where no Windows opener is found, or opening a URL from real WSL; the PR says this needs a manual check.

Review details
  • Commit: 80b7a16
  • Model: claude-opus-5-5

Review 1 of 10 for this pull request · View the full run

Comment thread commands/auth.go Outdated
Comment thread commands/auth.go

@upsun-dispatch upsun-dispatch Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Changes suggested — 🟡 1 warning

🔁 Incremental · 2 files reviewed

Verification
  • hasDisplay() now returns true for WSL only when wslBrowserCommand() finds wslview or rundll32.exe, so the xdg-open fallback is never reached with DISPLAY unset.
  • A Docker Desktop container with the microsoft kernel, no /mnt/c and no wslview now gets hasDisplay() false and is not offered a browser login.
  • If openURL fails, runBrowserLogin (auth_login.go:166) falls back to printing the URL, so a failed opener does not block login.

The new TestBrowserCommand_WSLWithoutOpener covers WSL with no opener file on PATH or under /mnt/c, and TestBrowserCommand_WSL covers the case where an opener is found. Nothing covers interop being disabled, or real WSL hosts, which the PR leaves to a manual check.

Review details

Review 2 of 10 for this pull request · View the full run

Comment thread commands/auth.go
@upsun-dispatch

upsun-dispatch Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

📋 PR Summary

When running under WSL with Windows interop enabled, the CLI now treats itself as having a display and opens login URLs in a Windows browser using rundll32.exe url.dll,FileProtocolHandler. It looks for that program first under the automount root set in wsl.conf (default /mnt/), at c/Windows/System32, and falls back to searching PATH. The PR description says the Go wrapper hands its opener to the legacy CLI in a WSL_BROWSER variable, but this update removes that: the PHP Url service now detects WSL, checks interop and finds rundll32.exe itself, using the same logic as the Go side. Both sides now also strip surrounding quotes from wsl.conf values.

Changes
Layer / File(s) Summary
Go WSL browser detection
commands/auth.go wslConfValue strips matching surrounding quotes. wslBrowserCommand tries the System32 path under the mount root first, then falls back to PATH. Removes the wslBrowser helper that fed the legacy CLI.
commands/auth_test.go Adds a wslOpener test helper and new cases for quoted wsl.conf values and the PATH fallback.
Legacy CLI handoff removed
commands/root.go No longer sets WSLBrowser on the legacy CLI wrapper.
internal/legacy/legacy.go Removes the WSLBrowser field and no longer sets the WSL_BROWSER environment variable for the legacy CLI.
PHP WSL browser detection
legacy/src/Service/Url.php Detects WSL (from environment variables or the kernel release), checks interop (the binfmt_misc entry plus wsl.conf) and finds rundll32.exe itself. File paths can be overridden in tests, and the Config dependency is dropped.
legacy/tests/Service/UrlTest.php Adds data-driven tests using a temporary directory, covering mount roots, the PATH fallback, interop states, detection from the kernel release, and wslConfValue parsing.

@upsun-dispatch upsun-dispatch Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Changes suggested — 🟡 1 warning · 1 still open

🔁 Incremental · 4 files reviewed

Outstanding from earlier reviews:

  • 🟡 #4225570767 — commands/auth.go:280: The user is asked a browser prompt that cannot open a browser. — wslBrowserCommand() still only calls exec.LookPath, so with interop disabled and /mnt/c mounted the browser is still offered. The new PHP getWslBrowser() has the same gap.
Verification
  • Url::hasDisplay() on WSL now needs getWslBrowser() to find an opener. A Docker Desktop container, with no /mnt/c and no Windows PATH, therefore gets no display.
  • getDefaultBrowser() checks for the WSL opener before falling back to xdg-open/gnome-open, and only when isWsl() is true, so plain Linux behaves as before.
  • openUrl() adds url.dll,FileProtocolHandler both when rundll32.exe is found on PATH and when the /mnt/c absolute path is used, because str_ends_with matches both.
  • Go hasDisplay() now ANDs isWSL() with wslBrowserCommand() != nil, which matches the PHP condition.

The diff adds legacy/tests/Service/UrlTest.php, which mocks Shell::commandExists/execute to cover the rundll32, /mnt/c fallback, wslview and no-opener cases. It also adds OsUtilTest::testIsWsl, which tests env-var detection. No test covers a found opener that cannot execute (interop disabled), and real WSL behaviour still needs the manual check.

Review details

Review 3 of 10 for this pull request · View the full run

Comment thread legacy/src/Service/Url.php Outdated

@upsun-dispatch upsun-dispatch Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Reviewed — No blocking findings · 🔵 1 minor point

🔁 Incremental · 8 files reviewed

🔵 Minor point

  • commands/root.go:348 — makeLegacyCLIWrapper now calls wslBrowser() for every legacy invocation. That includes _complete, which runs on every Tab press, plus list, completion, init and the session-export migrator. Under WSL, wslBrowser() globs binfmt_misc and reads /etc/wsl.conf. It then calls exec.LookPath("wslview") and exec.LookPath("rundll32.exe"), which stat each PATH entry. With the default appendWindowsPath=true, PATH holds many /mnt/c/... directories. On WSL2 those are on the drvfs/9p mount, where each stat is slow. When wslview is not installed, the whole PATH is scanned once for it and again for rundll32. Every legacy command and every completion request on WSL now pays this cost, even though only browser-opening commands need WSL_BROWSER. Computing it lazily, or caching it once per process, would avoid the cost.
Verification
  • The Go wrapper appends WSL_BROWSER after os.Environ(), and Go's exec keeps the last duplicate. So a user-set value is replaced, as the comment says.
  • Url's new Config constructor argument is autowired by legacy/config/services.yaml (autowire: true), and nothing in legacy/src builds Url by hand.
  • The PHP side reads application.env_prefix, which is the same prefix the wrapper uses (cnf.Application.EnvPrefix) to set WSL_BROWSER. An empty value maps to false through ?:.
  • wslBrowser() returns only c[0]. Url::openUrl adds url.dll,FileProtocolHandler back for any browser ending in rundll32.exe, so the rundll32 path still gets its argument.

New Go tests cover the wsl.conf parsing (TestWSLInteropEnabled) and wslBrowser() with fake binfmt and PATH stubs. The rewritten legacy/tests/Service/UrlTest.php covers the env-var handoff. The CI jobs make test and scripts/test/unit.sh in ci.yml run these tests. Nothing tests real WSL, and no test covers the WSL_BROWSER env injection in CLIWrapper.Exec.

Review details

Review 5 of 10 for this pull request · View the full run

@upsun-dispatch upsun-dispatch Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Reviewed — No new issues found · 1 still open

🔁 Incremental · 3 files reviewed

Outstanding from earlier reviews:

  • 🔵 commands/root.go:348: Shell completion and every legacy command get slower on WSL2. — makeLegacyCLIWrapper still calls wslBrowser() on every legacy run, including _complete. Trying System32 first skips the PATH scan only when /mnt/c holds rundll32.exe; without it, the binfmt glob, the wsl.conf read and the full PATH scan still happen on every run. (first raised)
Verification
  • wslBrowserCommand returns before any LookPath when wslInteropEnabled() is false, so turning interop off now skips the System32 stat as well as the PATH scan.
  • Every test that calls setWSLInterop now points wslRundll32Path at a temp file and restores it in t.Cleanup, so a real /mnt/c on the host can no longer change the test results.
  • Removing the wslview branch leaves nothing broken: legacy Url::openUrl adds url.dll,FileProtocolHandler for any browser path ending in rundll32.exe, which is the only kind the Go side now passes.
  • In TestBrowserCommand_WSL, rundll32.exe exists both in System32 and on PATH, and the test asserts the System32 one wins, which pins the new lookup order.

The Go behaviour is covered by tests in this change: the TestBrowserCommand_WSL* tests, a new PATH-fallback test, and TestWSLInteropEnabled, all with faked binfmt, wsl.conf and System32 paths. On the PHP side the change only removes the wslview case from UrlTest. The project runs these tests with make test (Go) and PHPUnit (legacy). As the PR description says, nothing in CI runs on real WSL.

Review details

Review 6 of 10 for this pull request · View the full run

@pjcdawkins

Copy link
Copy Markdown
Contributor Author

Re the minor point on commands/root.go:348 (WSL opener lookup on every legacy run): leaving this as is. Measured on WSL2 (Windows 11, default PATH with 7 Windows directories):

  • Interop check (binfmt_misc glob and /etc/wsl.conf read): 0.05ms.
  • /mnt/c/Windows/System32/rundll32.exe check, which succeeds with Windows on C: and the default automount: about 6ms.
  • The PATH scan only runs when that file is missing (Windows on another drive, or another automount root). Then PATH usually holds the Windows directories at their real location, and finding rundll32.exe took about 22ms. With interop off, it returns before any lookup.

_complete runs the legacy PHP CLI anyway, which costs hundreds of milliseconds, so this is small next to it. A special case to skip the lookup for completion isn't worth keeping in sync.

🤖 Replied by Claude Code

@bojanz
bojanz force-pushed the cli-189-go-auth-feature-flag branch from 4b2b344 to 95786fa Compare October 13, 2026 11:59
@pjcdawkins
pjcdawkins changed the base branch from cli-189-go-auth-feature-flag to main October 13, 2026 12:55

@upsun-dispatch upsun-dispatch Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Reviewed — No blocking findings · 🔵 1 minor point

🔍 Full review · 6 files reviewed

🔵 Minor point

  • commands/root.go:348 — makeLegacyCLIWrapper now always calls wslBrowser(), so every legacy command does the WSL lookup before it starts, even though only the login prompt and openUrl use the result. This is also true of the auth:export-sessions migration wrapper. On WSL each command globs binfmt_misc, reads the WSLInterop entry and /etc/wsl.conf, then calls exec.LookPath. The exec.LookPath call is the costly part. When rundll32.exe is not at /mnt/c/Windows/System32 (for example, [automount] root is changed in wsl.conf), it falls back to searching PATH. The PR itself says that search takes tens of milliseconds under WSL2, and now every legacy invocation pays it. On non-WSL Linux it still reads /proc/sys/kernel/osrelease on every command. Doing the lookup lazily, or only once per process, avoids that cost.
Verification
  • browserCommand's new isWSL() case falls through to the xdg-open/gnome-open search when wslBrowserCommand() returns nil, so non-interop WSL keeps its previous behaviour.
  • The legacy wrapper always sets &lt;PREFIX>WSL_BROWSER, even when empty. The PHP getWslBrowser() treats an empty value as false, so a value inherited from the user's environment cannot enable a browser.
  • PHP Url is autowired through config/services.yaml (the Platformsh\Cli\Service\ resource), so its new Config constructor argument needs no manual wiring. The mocked Url in BrowserLoginCommandTest is unaffected.
  • The PHP openUrl adds the url.dll,FileProtocolHandler argument only for a browser path ending in rundll32.exe. On Windows the 'start' branch is still checked first.

New Go unit tests in commands/auth_test.go fake binfmt_misc, wsl.conf and the rundll32.exe stubs. The new legacy/tests/Service/UrlTest.php covers the PHP side. These run in the ci.yml make test and legacy-php jobs. Nothing in CI runs on a real WSL system; the PR says it was checked by hand.

Review details
  • Commit: f483f6a
  • Model: claude-opus-5-5

Review 7 of 10 for this pull request · View the full run

@upsun-dispatch upsun-dispatch Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Changes suggested — 🟡 2 warnings

🔁 Incremental · 2 files reviewed

Verification
  • wslBrowserCommand reads wsl.conf once and passes its contents to both wslInteropEnabled and the automount root lookup, so the two checks see the same file.
  • filepath.Join normalises a root with or without a trailing slash, as the 'custom root without slash' test case covers.
  • exec.LookPath with an absolute path only stats that file, so the expensive WSL2 search of PATH is gone from every legacy command start.
  • wslConfValue compares section and key names case-insensitively and ignores keys from other sections, matching the old interop-only parser.

This push changes the unit tests in commands/auth_test.go: TestWSLBrowser_MountRoot covers the default and custom roots and the removed PATH fallback, TestWSLConfValue covers the parser, and TestWSLInteropEnabled now passes wsl.conf contents directly. They run in make test. Quoted wsl.conf values and non-C: system drives have no tests, and CI has no real WSL.

Review details

Review 8 of 10 for this pull request · View the full run

Comment thread commands/auth.go
Comment thread commands/auth.go Outdated
pjcdawkins and others added 9 commits October 13, 2026 17:35
On WSL there is usually no DISPLAY and no xdg-open, so the login prompt
was not offered, and login printed the URL instead of opening it. Under
WSL, URLs are now opened with wslview if it is installed, or else with
Windows' rundll32.exe (as on Windows), found in PATH or in its default
location. The local server's 127.0.0.1 address works from Windows, as
WSL forwards localhost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… found

WSL counted as having a display even without a Windows opener, e.g.
with interop disabled, or in a Docker Desktop container, which shares
the WSL kernel. The CLI then offered a browser login with no display,
and xdg-open could start a terminal browser or fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On WSL, the legacy CLI found no display or xdg-open, so legacy commands
did not offer "Log in via a browser?" (with or without Go auth), and the
PHP login printed the URL instead of opening it.

Match the Go detection: under WSL (from WSL_DISTRO_NAME/WSL_INTEROP or
the kernel release), count as having a display if a Windows opener is
found, and open URLs with wslview, or else rundll32.exe from PATH or
/mnt/c/Windows/System32. The lookup only runs on WSL, when a URL is to
be opened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With WSL interop off, rundll32.exe still exists under /mnt/c, so the CLI
offered "Log in via a browser?", but running it fails (exec format
error). Require WSL's binfmt_misc interop entry (WSLInterop or
WSLInterop-late) to be enabled before using wslview or rundll32.exe,
in Go and in the legacy CLI. Checked on WSL1: the entry exists and is
"enabled" with interop on, and is missing with interop off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WSL2 keeps the WSLInterop binfmt_misc entry enabled when wsl.conf sets
[interop] enabled=false, so the CLI offered a browser login and then
waited about 10 seconds for rundll32.exe to fail. Also check wsl.conf.

So that the detection lives in one place, the Go wrapper passes the WSL
opener to the legacy CLI in <PREFIX>WSL_BROWSER (always set, empty when
there is none), and the PHP Url service uses it instead of its own WSL
checks.

Checked on Windows 11 with WSL1 (VirtualBox) and WSL2 (QEMU/KVM): with
interop on, legacy commands offer the prompt and open Edge; with it off,
there's no prompt and login prints the URL at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The wrapper looks up the WSL opener for every legacy command. In WSL2,
PATH includes Windows directories on a slow mount: searching it for
wslview (usually missing) and rundll32.exe took about 70ms, against 6ms
for checking /mnt/c/Windows/System32/rundll32.exe. Check that path first,
fall back to PATH, and drop wslview, which rundll32.exe makes unneeded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…earching PATH

The legacy CLI wrapper looks for a Windows browser on every legacy command.
When rundll32.exe was not at /mnt/c/Windows/System32 (e.g. wsl.conf sets
[automount] root), it fell back to searching PATH, which is slow under WSL2
as PATH includes Windows directories.

Read the automount root from wsl.conf (default /mnt/) and only check
<root>c/Windows/System32/rundll32.exe. wsl.conf is now read once per lookup,
with a shared helper for the [interop] and [automount] keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Go wrapper looked for a Windows browser on every legacy command, to
pass it in <PREFIX>WSL_BROWSER, although few commands open URLs. The
legacy Url service now finds it itself, only when it needs one, with the
same checks as Go: WSL, interop enabled (binfmt_misc and wsl.conf), then
rundll32.exe under the wsl.conf automount root, or else in PATH.

Since the lookup now only runs before opening a browser, the PATH
fallback is restored in Go too, for setups where the Windows drive is
not at <root>/c (e.g. mounted via fstab). wsl.conf values may be quoted,
so one pair of surrounding quotes is stripped in both Go and PHP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bojanz
bojanz force-pushed the wsl-browser-login branch from d523d7d to 70b17d7 Compare October 13, 2026 15:35
@bojanz
bojanz self-requested a review October 13, 2026 15:35
@bojanz
bojanz merged commit 54986c0 into main Oct 13, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants