From 56073676ac60d25ef2de232a55496954da748884 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 9 Oct 2026 01:23:14 +0100 Subject: [PATCH 1/9] feat(auth): open the login URL in a Windows browser from WSL 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 --- commands/auth.go | 34 ++++++++++++++++++++++++++++++++-- commands/auth_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/commands/auth.go b/commands/auth.go index 9c953a731..349717ce6 100644 --- a/commands/auth.go +++ b/commands/auth.go @@ -272,12 +272,38 @@ func withLogin(cmd *cobra.Command, cnf *config.Config, m *auth.Manager, fn func( return fn() } -// hasDisplay matches the legacy CLI's Url::hasDisplay(). +// hasDisplay matches the legacy CLI's Url::hasDisplay(), and also counts WSL, where a Windows browser can be used. func hasDisplay() bool { if d := os.Getenv("DISPLAY"); d != "" { return d != "none" } - return runtime.GOOS == "windows" || runtime.GOOS == "darwin" + return runtime.GOOS == "windows" || runtime.GOOS == "darwin" || isWSL() +} + +// isWSL reports whether this is Linux in the Windows Subsystem for Linux. +func isWSL() bool { + if runtime.GOOS != "linux" { + return false + } + if os.Getenv("WSL_DISTRO_NAME") != "" || os.Getenv("WSL_INTEROP") != "" { + return true + } + b, err := os.ReadFile("/proc/sys/kernel/osrelease") + return err == nil && strings.Contains(strings.ToLower(string(b)), "microsoft") +} + +// wslBrowserCommand returns a command to open URLs in a Windows browser from WSL, or nil if none is found. +func wslBrowserCommand() []string { + if p, err := exec.LookPath("wslview"); err == nil { + return []string{p} + } + // Windows paths can be left out of PATH (appendWindowsPath=false in wsl.conf). + for _, p := range []string{"rundll32.exe", "/mnt/c/Windows/System32/rundll32.exe"} { + if p, err := exec.LookPath(p); err == nil { + return []string{p, "url.dll,FileProtocolHandler"} + } + } + return nil } // browserCommand returns the command to open URLs, or nil if none should be used. @@ -295,6 +321,10 @@ func browserCommand(browserOption string) []string { return []string{"rundll32", "url.dll,FileProtocolHandler"} case runtime.GOOS == "darwin": return []string{"open"} + case isWSL(): + if c := wslBrowserCommand(); c != nil { + return c + } } for _, b := range []string{"xdg-open", "gnome-open"} { if _, err := exec.LookPath(b); err == nil { diff --git a/commands/auth_test.go b/commands/auth_test.go index ed7878146..7f59839af 100644 --- a/commands/auth_test.go +++ b/commands/auth_test.go @@ -1,6 +1,9 @@ package commands import ( + "os" + "path/filepath" + "runtime" "testing" "github.com/platformsh/platformify/vendorization" @@ -47,3 +50,30 @@ func TestBrowserCommand_Whitespace(t *testing.T) { assert.Nil(t, browserCommand("0")) assert.NotPanics(t, func() { browserCommand(" ") }) } + +func TestBrowserCommand_WSL(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("WSL is Linux") + } + dir := t.TempDir() + rundll := filepath.Join(dir, "rundll32.exe") + require.NoError(t, os.WriteFile(rundll, []byte("#!/bin/sh\n"), 0o700)) //nolint:gosec // an executable stub + t.Setenv("PATH", dir) + t.Setenv("DISPLAY", "") + t.Setenv("WSL_INTEROP", "") + t.Setenv("WSL_DISTRO_NAME", "Ubuntu") + + assert.True(t, isWSL()) + assert.True(t, hasDisplay(), "a Windows browser can be used without a display") + assert.Equal(t, []string{rundll, "url.dll,FileProtocolHandler"}, browserCommand("")) + assert.True(t, canOpenURLs("")) +} + +func TestIsWSL_NotWSL(t *testing.T) { + if _, err := os.Stat("/proc/sys/fs/binfmt_misc/WSLInterop"); err == nil { + t.Skip("running in WSL") + } + t.Setenv("WSL_DISTRO_NAME", "") + t.Setenv("WSL_INTEROP", "") + assert.False(t, isWSL()) +} From 0d33d64c4f40bd39f14990bd28e6122464c25115 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 9 Oct 2026 01:24:20 +0100 Subject: [PATCH 2/9] test: skip the non-WSL check on any WSL kernel Co-Authored-By: Claude Opus 5.5 --- commands/auth_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/commands/auth_test.go b/commands/auth_test.go index 7f59839af..ee4027123 100644 --- a/commands/auth_test.go +++ b/commands/auth_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "github.com/platformsh/platformify/vendorization" @@ -70,7 +71,7 @@ func TestBrowserCommand_WSL(t *testing.T) { } func TestIsWSL_NotWSL(t *testing.T) { - if _, err := os.Stat("/proc/sys/fs/binfmt_misc/WSLInterop"); err == nil { + if b, _ := os.ReadFile("/proc/sys/kernel/osrelease"); strings.Contains(strings.ToLower(string(b)), "microsoft") { t.Skip("running in WSL") } t.Setenv("WSL_DISTRO_NAME", "") From 9b675a892b94194421a721c42eccf5951a098f26 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 9 Oct 2026 01:43:18 +0100 Subject: [PATCH 3/9] fix(auth): only treat WSL as having a display if a Windows browser is 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 --- commands/auth.go | 7 ++++--- commands/auth_test.go | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/commands/auth.go b/commands/auth.go index 349717ce6..87d38a56f 100644 --- a/commands/auth.go +++ b/commands/auth.go @@ -272,15 +272,16 @@ func withLogin(cmd *cobra.Command, cnf *config.Config, m *auth.Manager, fn func( return fn() } -// hasDisplay matches the legacy CLI's Url::hasDisplay(), and also counts WSL, where a Windows browser can be used. +// hasDisplay matches the legacy CLI's Url::hasDisplay(). It also counts WSL, if a Windows browser can be opened. func hasDisplay() bool { if d := os.Getenv("DISPLAY"); d != "" { return d != "none" } - return runtime.GOOS == "windows" || runtime.GOOS == "darwin" || isWSL() + return runtime.GOOS == "windows" || runtime.GOOS == "darwin" || (isWSL() && wslBrowserCommand() != nil) } -// isWSL reports whether this is Linux in the Windows Subsystem for Linux. +// isWSL reports whether this is Linux in the Windows Subsystem for Linux. This includes Docker Desktop's containers, +// which share its kernel, but have no Windows browser: see wslBrowserCommand. func isWSL() bool { if runtime.GOOS != "linux" { return false diff --git a/commands/auth_test.go b/commands/auth_test.go index ee4027123..6296fa6b4 100644 --- a/commands/auth_test.go +++ b/commands/auth_test.go @@ -70,6 +70,23 @@ func TestBrowserCommand_WSL(t *testing.T) { assert.True(t, canOpenURLs("")) } +// TestBrowserCommand_WSLWithoutOpener checks WSL without a Windows opener, e.g. with interop disabled, or a Docker +// Desktop container (which shares the WSL kernel). +func TestBrowserCommand_WSLWithoutOpener(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("WSL is Linux") + } + if _, err := os.Stat("/mnt/c/Windows/System32/rundll32.exe"); err == nil { + t.Skip("a Windows opener exists") + } + t.Setenv("PATH", t.TempDir()) + t.Setenv("DISPLAY", "") + t.Setenv("WSL_DISTRO_NAME", "Ubuntu") + + assert.False(t, hasDisplay()) + assert.False(t, canOpenURLs("")) +} + func TestIsWSL_NotWSL(t *testing.T) { if b, _ := os.ReadFile("/proc/sys/kernel/osrelease"); strings.Contains(strings.ToLower(string(b)), "microsoft") { t.Skip("running in WSL") From bf1916b16f823c28c9488e43fa8a7da0900be77a Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 9 Oct 2026 07:59:29 +0100 Subject: [PATCH 4/9] feat(legacy): open URLs in a Windows browser from WSL 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 --- legacy/src/Service/Url.php | 24 +++++++- legacy/src/Util/OsUtil.php | 15 +++++ legacy/tests/Service/UrlTest.php | 96 ++++++++++++++++++++++++++++++++ legacy/tests/Util/OsUtilTest.php | 22 ++++++++ 4 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 legacy/tests/Service/UrlTest.php diff --git a/legacy/src/Service/Url.php b/legacy/src/Service/Url.php index d4deb738d..36e157a64 100644 --- a/legacy/src/Service/Url.php +++ b/legacy/src/Service/Url.php @@ -84,6 +84,8 @@ public function openUrl(string $url, bool $print = true): bool if (OsUtil::isWindows() && $browser === 'start') { // The start command needs an extra (title) argument. $args = [$browser, '', $url]; + } elseif (str_ends_with($browser, 'rundll32.exe')) { + $args = [$browser, 'url.dll,FileProtocolHandler', $url]; } else { $args = [$browser, $url]; } @@ -100,7 +102,7 @@ public function openUrl(string $url, bool $print = true): bool } /** - * Check for a display (if not on Windows or OS X). + * Check for a display (if not on Windows or OS X). WSL counts, if a Windows browser can be opened. * * @return bool */ @@ -110,7 +112,7 @@ public function hasDisplay(): bool return getenv('DISPLAY') !== 'none'; } - return OsUtil::isWindows() || OsUtil::isOsX(); + return OsUtil::isWindows() || OsUtil::isOsX() || (OsUtil::isWsl() && $this->getWslBrowser() !== false); } /** @@ -151,6 +153,9 @@ private function getDefaultBrowser(): string|false if (OsUtil::isOsX()) { return 'open'; } + if (OsUtil::isWsl() && ($browser = $this->getWslBrowser()) !== false) { + return $browser; + } $browsers = ['xdg-open', 'gnome-open']; foreach ($browsers as $browser) { if ($this->shell->commandExists($browser)) { @@ -160,4 +165,19 @@ private function getDefaultBrowser(): string|false return false; } + + /** + * Finds a command to open URLs in a Windows browser from WSL. + */ + private function getWslBrowser(): string|false + { + // Windows paths can be left out of PATH (appendWindowsPath=false in wsl.conf). + foreach (['wslview', 'rundll32.exe', '/mnt/c/Windows/System32/rundll32.exe'] as $browser) { + if ($this->shell->commandExists($browser)) { + return $browser; + } + } + + return false; + } } diff --git a/legacy/src/Util/OsUtil.php b/legacy/src/Util/OsUtil.php index cdb128e7c..853b06830 100644 --- a/legacy/src/Util/OsUtil.php +++ b/legacy/src/Util/OsUtil.php @@ -30,6 +30,21 @@ public static function isLinux(): bool return stripos(PHP_OS, 'Linux') !== false; } + /** + * Checks for Linux in the Windows Subsystem for Linux (or a Docker Desktop container, which shares its kernel). + */ + public static function isWsl(): bool + { + if (!self::isLinux()) { + return false; + } + if (getenv('WSL_DISTRO_NAME') || getenv('WSL_INTEROP')) { + return true; + } + + return stripos((string) @file_get_contents('/proc/sys/kernel/osrelease'), 'microsoft') !== false; + } + /** * Escapes a shell argument for POSIX shells, even when run on Windows. * diff --git a/legacy/tests/Service/UrlTest.php b/legacy/tests/Service/UrlTest.php new file mode 100644 index 000000000..66be911db --- /dev/null +++ b/legacy/tests/Service/UrlTest.php @@ -0,0 +1,96 @@ + */ + private array $env = []; + + private Shell&MockObject $shell; + + protected function setUp(): void + { + if (!OsUtil::isLinux()) { + $this->markTestSkipped('WSL is Linux'); + } + foreach (['DISPLAY', 'WSL_DISTRO_NAME', 'WSL_INTEROP'] as $name) { + $this->env[$name] = getenv($name); + } + putenv('DISPLAY'); + putenv('WSL_INTEROP'); + putenv('WSL_DISTRO_NAME=Ubuntu'); + } + + protected function tearDown(): void + { + foreach ($this->env as $name => $value) { + putenv($value === false ? $name : "$name=$value"); + } + } + + /** + * @param string[] $commands + */ + private function urlService(array $commands): Url + { + $this->shell = $this->createMock(Shell::class); + $this->shell->method('commandExists')->willReturnCallback(fn(string $c): bool => in_array($c, $commands, true)); + $definition = new InputDefinition(); + Url::configureInput($definition); + + return new Url($this->shell, new ArrayInput([], $definition), new BufferedOutput()); + } + + public function testWslOpensUrlsWithRundll32(): void + { + $url = $this->urlService(['rundll32.exe']); + $this->assertTrue($url->hasDisplay()); + $this->assertTrue($url->canOpenUrls()); + + $this->shell->expects($this->once()) + ->method('execute') + ->with(['rundll32.exe', 'url.dll,FileProtocolHandler', 'http://127.0.0.1:5000']) + ->willReturn(''); + $this->assertTrue($url->openUrl('http://127.0.0.1:5000', false)); + } + + public function testWslFindsRundll32OutsidePath(): void + { + $rundll32 = '/mnt/c/Windows/System32/rundll32.exe'; + $url = $this->urlService([$rundll32]); + $this->shell->expects($this->once()) + ->method('execute') + ->with([$rundll32, 'url.dll,FileProtocolHandler', 'http://127.0.0.1:5000']) + ->willReturn(''); + $this->assertTrue($url->openUrl('http://127.0.0.1:5000', false)); + } + + public function testWslPrefersWslview(): void + { + $url = $this->urlService(['wslview', 'rundll32.exe']); + $this->shell->expects($this->once()) + ->method('execute') + ->with(['wslview', 'http://127.0.0.1:5000']) + ->willReturn(''); + $this->assertTrue($url->openUrl('http://127.0.0.1:5000', false)); + } + + public function testWslWithoutOpener(): void + { + $url = $this->urlService([]); + $this->assertFalse($url->hasDisplay()); + $this->assertFalse($url->canOpenUrls()); + } +} diff --git a/legacy/tests/Util/OsUtilTest.php b/legacy/tests/Util/OsUtilTest.php index 39bf511e2..0a097262b 100644 --- a/legacy/tests/Util/OsUtilTest.php +++ b/legacy/tests/Util/OsUtilTest.php @@ -24,4 +24,26 @@ public function testEscapePosixShellArg(): void OsUtil::escapePosixShellArg("No it isn't"), ); } + + public function testIsWsl(): void + { + if (!OsUtil::isLinux()) { + $this->markTestSkipped('WSL is Linux'); + } + if (stripos((string) @file_get_contents('/proc/sys/kernel/osrelease'), 'microsoft') !== false) { + $this->markTestSkipped('running in WSL'); + } + $distro = getenv('WSL_DISTRO_NAME'); + $interop = getenv('WSL_INTEROP'); + try { + putenv('WSL_DISTRO_NAME'); + putenv('WSL_INTEROP'); + $this->assertFalse(OsUtil::isWsl()); + putenv('WSL_DISTRO_NAME=Ubuntu'); + $this->assertTrue(OsUtil::isWsl()); + } finally { + putenv($distro === false ? 'WSL_DISTRO_NAME' : "WSL_DISTRO_NAME=$distro"); + putenv($interop === false ? 'WSL_INTEROP' : "WSL_INTEROP=$interop"); + } + } } From c168b4cf4cd4892f18db96d0561c99d6b86c2a00 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 9 Oct 2026 08:36:57 +0100 Subject: [PATCH 5/9] fix(auth): only use a Windows opener from WSL when interop is enabled 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 --- commands/auth.go | 20 ++++++++++++++++++++ commands/auth_test.go | 31 +++++++++++++++++++++++++++++++ legacy/src/Service/Url.php | 9 +++++++++ legacy/src/Util/OsUtil.php | 15 +++++++++++++++ legacy/tests/Service/UrlTest.php | 23 +++++++++++++++++++++-- legacy/tests/Util/OsUtilTest.php | 18 ++++++++++++++++++ 6 files changed, 114 insertions(+), 2 deletions(-) diff --git a/commands/auth.go b/commands/auth.go index 87d38a56f..ffe2e7d8a 100644 --- a/commands/auth.go +++ b/commands/auth.go @@ -10,6 +10,7 @@ import ( "io" "os" "os/exec" + "path/filepath" "runtime" "slices" "strings" @@ -293,8 +294,27 @@ func isWSL() bool { return err == nil && strings.Contains(strings.ToLower(string(b)), "microsoft") } +// wslBinfmtDir is where WSL registers Windows interop (overridden in tests). +var wslBinfmtDir = "/proc/sys/fs/binfmt_misc" + +// wslInteropEnabled reports whether WSL can run Windows programs. Its binfmt_misc entry is named WSLInterop or +// WSLInterop-late, and is missing or disabled when interop is off. +func wslInteropEnabled() bool { + paths, _ := filepath.Glob(filepath.Join(wslBinfmtDir, "WSLInterop*")) + for _, p := range paths { + if b, err := os.ReadFile(p); err == nil && strings.HasPrefix(string(b), "enabled") { + return true + } + } + return false +} + // wslBrowserCommand returns a command to open URLs in a Windows browser from WSL, or nil if none is found. func wslBrowserCommand() []string { + // The openers run Windows programs. + if !wslInteropEnabled() { + return nil + } if p, err := exec.LookPath("wslview"); err == nil { return []string{p} } diff --git a/commands/auth_test.go b/commands/auth_test.go index 6296fa6b4..8e924c39d 100644 --- a/commands/auth_test.go +++ b/commands/auth_test.go @@ -52,10 +52,22 @@ func TestBrowserCommand_Whitespace(t *testing.T) { assert.NotPanics(t, func() { browserCommand(" ") }) } +// setWSLInterop fakes WSL's binfmt_misc entry for Windows interop; an empty state leaves it out. +func setWSLInterop(t *testing.T, state string) { + dir := t.TempDir() + if state != "" { + require.NoError(t, os.WriteFile(filepath.Join(dir, "WSLInterop"), []byte(state+"\ninterpreter /init\n"), 0o600)) + } + orig := wslBinfmtDir + wslBinfmtDir = dir + t.Cleanup(func() { wslBinfmtDir = orig }) +} + func TestBrowserCommand_WSL(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("WSL is Linux") } + setWSLInterop(t, "enabled") dir := t.TempDir() rundll := filepath.Join(dir, "rundll32.exe") require.NoError(t, os.WriteFile(rundll, []byte("#!/bin/sh\n"), 0o700)) //nolint:gosec // an executable stub @@ -87,6 +99,25 @@ func TestBrowserCommand_WSLWithoutOpener(t *testing.T) { assert.False(t, canOpenURLs("")) } +// TestBrowserCommand_WSLInteropOff checks that a Windows opener is not used when interop is off, as it cannot run. +func TestBrowserCommand_WSLInteropOff(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("WSL is Linux") + } + dir := t.TempDir() + rundll := filepath.Join(dir, "rundll32.exe") + require.NoError(t, os.WriteFile(rundll, []byte("#!/bin/sh\n"), 0o700)) //nolint:gosec // an executable stub + t.Setenv("PATH", dir) + t.Setenv("DISPLAY", "") + t.Setenv("WSL_DISTRO_NAME", "Ubuntu") + + for _, state := range []string{"", "disabled"} { + setWSLInterop(t, state) + assert.False(t, hasDisplay(), "interop state %q", state) + assert.False(t, canOpenURLs(""), "interop state %q", state) + } +} + func TestIsWSL_NotWSL(t *testing.T) { if b, _ := os.ReadFile("/proc/sys/kernel/osrelease"); strings.Contains(strings.ToLower(string(b)), "microsoft") { t.Skip("running in WSL") diff --git a/legacy/src/Service/Url.php b/legacy/src/Service/Url.php index 36e157a64..cd0b287d9 100644 --- a/legacy/src/Service/Url.php +++ b/legacy/src/Service/Url.php @@ -166,11 +166,20 @@ private function getDefaultBrowser(): string|false return false; } + protected function isWslInteropEnabled(): bool + { + return OsUtil::isWslInteropEnabled(); + } + /** * Finds a command to open URLs in a Windows browser from WSL. */ private function getWslBrowser(): string|false { + // The openers run Windows programs. + if (!$this->isWslInteropEnabled()) { + return false; + } // Windows paths can be left out of PATH (appendWindowsPath=false in wsl.conf). foreach (['wslview', 'rundll32.exe', '/mnt/c/Windows/System32/rundll32.exe'] as $browser) { if ($this->shell->commandExists($browser)) { diff --git a/legacy/src/Util/OsUtil.php b/legacy/src/Util/OsUtil.php index 853b06830..b745d8cb6 100644 --- a/legacy/src/Util/OsUtil.php +++ b/legacy/src/Util/OsUtil.php @@ -45,6 +45,21 @@ public static function isWsl(): bool return stripos((string) @file_get_contents('/proc/sys/kernel/osrelease'), 'microsoft') !== false; } + /** + * Checks whether WSL can run Windows programs. Its binfmt_misc entry is named WSLInterop or WSLInterop-late, and is + * missing or disabled when interop is off. + */ + public static function isWslInteropEnabled(string $binfmtDir = '/proc/sys/fs/binfmt_misc'): bool + { + foreach (glob($binfmtDir . '/WSLInterop*') ?: [] as $path) { + if (str_starts_with((string) @file_get_contents($path), 'enabled')) { + return true; + } + } + + return false; + } + /** * Escapes a shell argument for POSIX shells, even when run on Windows. * diff --git a/legacy/tests/Service/UrlTest.php b/legacy/tests/Service/UrlTest.php index 66be911db..f190717c0 100644 --- a/legacy/tests/Service/UrlTest.php +++ b/legacy/tests/Service/UrlTest.php @@ -11,7 +11,9 @@ use Platformsh\Cli\Util\OsUtil; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputDefinition; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\OutputInterface; class UrlTest extends TestCase { @@ -43,14 +45,24 @@ protected function tearDown(): void /** * @param string[] $commands */ - private function urlService(array $commands): Url + private function urlService(array $commands, bool $interop = true): Url { $this->shell = $this->createMock(Shell::class); $this->shell->method('commandExists')->willReturnCallback(fn(string $c): bool => in_array($c, $commands, true)); $definition = new InputDefinition(); Url::configureInput($definition); - return new Url($this->shell, new ArrayInput([], $definition), new BufferedOutput()); + return new class ($interop, $this->shell, new ArrayInput([], $definition), new BufferedOutput()) extends Url { + public function __construct(private readonly bool $interop, Shell $shell, InputInterface $input, OutputInterface $output) + { + parent::__construct($shell, $input, $output); + } + + protected function isWslInteropEnabled(): bool + { + return $this->interop; + } + }; } public function testWslOpensUrlsWithRundll32(): void @@ -93,4 +105,11 @@ public function testWslWithoutOpener(): void $this->assertFalse($url->hasDisplay()); $this->assertFalse($url->canOpenUrls()); } + + public function testWslInteropOff(): void + { + $url = $this->urlService(['wslview', 'rundll32.exe'], false); + $this->assertFalse($url->hasDisplay()); + $this->assertFalse($url->canOpenUrls()); + } } diff --git a/legacy/tests/Util/OsUtilTest.php b/legacy/tests/Util/OsUtilTest.php index 0a097262b..e78041726 100644 --- a/legacy/tests/Util/OsUtilTest.php +++ b/legacy/tests/Util/OsUtilTest.php @@ -46,4 +46,22 @@ public function testIsWsl(): void putenv($interop === false ? 'WSL_INTEROP' : "WSL_INTEROP=$interop"); } } + + public function testIsWslInteropEnabled(): void + { + $dir = sys_get_temp_dir() . '/binfmt-' . bin2hex(random_bytes(4)); + mkdir($dir); + try { + $this->assertFalse(OsUtil::isWslInteropEnabled($dir)); + file_put_contents($dir . '/WSLInterop', "disabled\ninterpreter /init\n"); + $this->assertFalse(OsUtil::isWslInteropEnabled($dir)); + file_put_contents($dir . '/WSLInterop-late', "enabled\ninterpreter /init\n"); + $this->assertTrue(OsUtil::isWslInteropEnabled($dir)); + } finally { + foreach (glob($dir . '/*') ?: [] as $file) { + unlink($file); + } + rmdir($dir); + } + } } From af6276e15153f8df3496a399e2391b9358a871e5 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 9 Oct 2026 09:46:10 +0100 Subject: [PATCH 6/9] fix(auth): detect WSL2 with interop disabled, and pass the opener to PHP 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 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 --- commands/auth.go | 44 +++++++++++++++++++-- commands/auth_test.go | 36 +++++++++++++++-- commands/root.go | 1 + internal/legacy/legacy.go | 4 ++ legacy/src/Service/Url.php | 26 +++---------- legacy/src/Util/OsUtil.php | 30 --------------- legacy/tests/Service/UrlTest.php | 66 ++++++++++---------------------- legacy/tests/Util/OsUtilTest.php | 40 ------------------- 8 files changed, 104 insertions(+), 143 deletions(-) diff --git a/commands/auth.go b/commands/auth.go index ffe2e7d8a..289c5a7dd 100644 --- a/commands/auth.go +++ b/commands/auth.go @@ -294,16 +294,41 @@ func isWSL() bool { return err == nil && strings.Contains(strings.ToLower(string(b)), "microsoft") } -// wslBinfmtDir is where WSL registers Windows interop (overridden in tests). -var wslBinfmtDir = "/proc/sys/fs/binfmt_misc" +// WSL's binfmt_misc directory and config file (overridden in tests). +var ( + wslBinfmtDir = "/proc/sys/fs/binfmt_misc" + wslConfPath = "/etc/wsl.conf" +) // wslInteropEnabled reports whether WSL can run Windows programs. Its binfmt_misc entry is named WSLInterop or -// WSLInterop-late, and is missing or disabled when interop is off. +// WSLInterop-late. WSL1 removes it when interop is off, but WSL2 keeps it enabled, so wsl.conf is checked too. func wslInteropEnabled() bool { paths, _ := filepath.Glob(filepath.Join(wslBinfmtDir, "WSLInterop*")) for _, p := range paths { if b, err := os.ReadFile(p); err == nil && strings.HasPrefix(string(b), "enabled") { - return true + return !wslConfDisablesInterop() + } + } + return false +} + +// wslConfDisablesInterop reports whether wsl.conf sets "enabled = false" in its [interop] section. +func wslConfDisablesInterop() bool { + b, err := os.ReadFile(wslConfPath) + if err != nil { + return false + } + var section string + for line := range strings.Lines(string(b)) { + line, _, _ = strings.Cut(line, "#") + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section = strings.ToLower(strings.TrimSpace(line[1 : len(line)-1])) + continue + } + key, value, ok := strings.Cut(line, "=") + if ok && section == "interop" && strings.EqualFold(strings.TrimSpace(key), "enabled") { + return strings.EqualFold(strings.TrimSpace(value), "false") } } return false @@ -327,6 +352,17 @@ func wslBrowserCommand() []string { return nil } +// wslBrowser returns the program to open URLs in a Windows browser, if this is WSL, for the legacy CLI. +func wslBrowser() string { + if !isWSL() { + return "" + } + if c := wslBrowserCommand(); c != nil { + return c[0] + } + return "" +} + // browserCommand returns the command to open URLs, or nil if none should be used. func browserCommand(browserOption string) []string { switch { diff --git a/commands/auth_test.go b/commands/auth_test.go index 8e924c39d..cb22bc769 100644 --- a/commands/auth_test.go +++ b/commands/auth_test.go @@ -58,9 +58,37 @@ func setWSLInterop(t *testing.T, state string) { if state != "" { require.NoError(t, os.WriteFile(filepath.Join(dir, "WSLInterop"), []byte(state+"\ninterpreter /init\n"), 0o600)) } - orig := wslBinfmtDir - wslBinfmtDir = dir - t.Cleanup(func() { wslBinfmtDir = orig }) + origDir, origConf := wslBinfmtDir, wslConfPath + wslBinfmtDir, wslConfPath = dir, filepath.Join(dir, "wsl.conf") + t.Cleanup(func() { wslBinfmtDir, wslConfPath = origDir, origConf }) +} + +// TestWSLInteropEnabled covers WSL2, which keeps the binfmt_misc entry enabled when wsl.conf disables interop. +func TestWSLInteropEnabled(t *testing.T) { + cases := []struct { + name string + binfmt string + wslConf string + want bool + }{ + {name: "enabled", binfmt: "enabled", want: true}, + {name: "no entry", want: false}, + {name: "entry disabled", binfmt: "disabled", want: false}, + {name: "wsl.conf disables", binfmt: "enabled", wslConf: "[boot]\nsystemd=true\n[interop]\nenabled=false\n"}, + {name: "wsl.conf spacing and case", binfmt: "enabled", wslConf: "[Interop]\n Enabled = False # off\n"}, + {name: "wsl.conf enables", binfmt: "enabled", wslConf: "[interop]\nenabled=true\n", want: true}, + {name: "other section", binfmt: "enabled", wslConf: "[automount]\nenabled=false\n", want: true}, + {name: "comment", binfmt: "enabled", wslConf: "[interop]\n# enabled=false\n", want: true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + setWSLInterop(t, c.binfmt) + if c.wslConf != "" { + require.NoError(t, os.WriteFile(wslConfPath, []byte(c.wslConf), 0o600)) + } + assert.Equal(t, c.want, wslInteropEnabled()) + }) + } } func TestBrowserCommand_WSL(t *testing.T) { @@ -77,6 +105,7 @@ func TestBrowserCommand_WSL(t *testing.T) { t.Setenv("WSL_DISTRO_NAME", "Ubuntu") assert.True(t, isWSL()) + assert.Equal(t, rundll, wslBrowser()) assert.True(t, hasDisplay(), "a Windows browser can be used without a display") assert.Equal(t, []string{rundll, "url.dll,FileProtocolHandler"}, browserCommand("")) assert.True(t, canOpenURLs("")) @@ -114,6 +143,7 @@ func TestBrowserCommand_WSLInteropOff(t *testing.T) { for _, state := range []string{"", "disabled"} { setWSLInterop(t, state) assert.False(t, hasDisplay(), "interop state %q", state) + assert.Empty(t, wslBrowser(), "interop state %q", state) assert.False(t, canOpenURLs(""), "interop state %q", state) } } diff --git a/commands/root.go b/commands/root.go index dc42e895c..f28b57639 100644 --- a/commands/root.go +++ b/commands/root.go @@ -360,6 +360,7 @@ func makeLegacyCLIWrapper(cnf *config.Config, stdout, stderr io.Writer, stdin io Version: config.Version, DebugLogFunc: debugLogf, DisableInteraction: viper.GetBool("no-interaction"), + WSLBrowser: wslBrowser(), Stdout: stdout, Stderr: stderr, Stdin: stdin, diff --git a/internal/legacy/legacy.go b/internal/legacy/legacy.go index 354eda692..680d248cb 100644 --- a/internal/legacy/legacy.go +++ b/internal/legacy/legacy.go @@ -38,6 +38,8 @@ type CLIWrapper struct { DisableInteraction bool ForceColor bool DebugLogFunc func(string, ...any) + // WSLBrowser is a command to open URLs in a Windows browser from WSL, or empty. + WSLBrowser string initOnce sync.Once _cacheDir string @@ -150,6 +152,8 @@ func (c *CLIWrapper) Exec(ctx context.Context, args ...string) error { if exe, err := os.Executable(); err == nil { cmd.Env = append(cmd.Env, envPrefix+"WRAPPER_EXECUTABLE="+exe) } + // The legacy CLI uses this to open URLs from WSL. It is always set, so the user's environment can't override it. + cmd.Env = append(cmd.Env, envPrefix+"WSL_BROWSER="+c.WSLBrowser) if c.DisableInteraction { cmd.Env = append(cmd.Env, envPrefix+"NO_INTERACTION=1") } diff --git a/legacy/src/Service/Url.php b/legacy/src/Service/Url.php index cd0b287d9..8a55bb9c6 100644 --- a/legacy/src/Service/Url.php +++ b/legacy/src/Service/Url.php @@ -16,7 +16,7 @@ class Url implements InputConfiguringInterface { protected OutputInterface $stdErr; - public function __construct(protected Shell $shell, protected InputInterface $input, protected OutputInterface $output) + public function __construct(protected Shell $shell, protected InputInterface $input, protected OutputInterface $output, protected Config $config) { $this->stdErr = $this->output instanceof ConsoleOutputInterface ? $this->output->getErrorOutput() @@ -112,7 +112,7 @@ public function hasDisplay(): bool return getenv('DISPLAY') !== 'none'; } - return OsUtil::isWindows() || OsUtil::isOsX() || (OsUtil::isWsl() && $this->getWslBrowser() !== false); + return OsUtil::isWindows() || OsUtil::isOsX() || $this->getWslBrowser() !== false; } /** @@ -153,7 +153,7 @@ private function getDefaultBrowser(): string|false if (OsUtil::isOsX()) { return 'open'; } - if (OsUtil::isWsl() && ($browser = $this->getWslBrowser()) !== false) { + if (($browser = $this->getWslBrowser()) !== false) { return $browser; } $browsers = ['xdg-open', 'gnome-open']; @@ -166,27 +166,11 @@ private function getDefaultBrowser(): string|false return false; } - protected function isWslInteropEnabled(): bool - { - return OsUtil::isWslInteropEnabled(); - } - /** - * Finds a command to open URLs in a Windows browser from WSL. + * Returns the command to open URLs in a Windows browser from WSL, which the Go wrapper finds, or false. */ private function getWslBrowser(): string|false { - // The openers run Windows programs. - if (!$this->isWslInteropEnabled()) { - return false; - } - // Windows paths can be left out of PATH (appendWindowsPath=false in wsl.conf). - foreach (['wslview', 'rundll32.exe', '/mnt/c/Windows/System32/rundll32.exe'] as $browser) { - if ($this->shell->commandExists($browser)) { - return $browser; - } - } - - return false; + return getenv($this->config->getStr('application.env_prefix') . 'WSL_BROWSER') ?: false; } } diff --git a/legacy/src/Util/OsUtil.php b/legacy/src/Util/OsUtil.php index b745d8cb6..cdb128e7c 100644 --- a/legacy/src/Util/OsUtil.php +++ b/legacy/src/Util/OsUtil.php @@ -30,36 +30,6 @@ public static function isLinux(): bool return stripos(PHP_OS, 'Linux') !== false; } - /** - * Checks for Linux in the Windows Subsystem for Linux (or a Docker Desktop container, which shares its kernel). - */ - public static function isWsl(): bool - { - if (!self::isLinux()) { - return false; - } - if (getenv('WSL_DISTRO_NAME') || getenv('WSL_INTEROP')) { - return true; - } - - return stripos((string) @file_get_contents('/proc/sys/kernel/osrelease'), 'microsoft') !== false; - } - - /** - * Checks whether WSL can run Windows programs. Its binfmt_misc entry is named WSLInterop or WSLInterop-late, and is - * missing or disabled when interop is off. - */ - public static function isWslInteropEnabled(string $binfmtDir = '/proc/sys/fs/binfmt_misc'): bool - { - foreach (glob($binfmtDir . '/WSLInterop*') ?: [] as $path) { - if (str_starts_with((string) @file_get_contents($path), 'enabled')) { - return true; - } - } - - return false; - } - /** * Escapes a shell argument for POSIX shells, even when run on Windows. * diff --git a/legacy/tests/Service/UrlTest.php b/legacy/tests/Service/UrlTest.php index f190717c0..9b9d43e0a 100644 --- a/legacy/tests/Service/UrlTest.php +++ b/legacy/tests/Service/UrlTest.php @@ -6,14 +6,13 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Platformsh\Cli\Service\Config; use Platformsh\Cli\Service\Shell; use Platformsh\Cli\Service\Url; use Platformsh\Cli\Util\OsUtil; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputDefinition; -use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\BufferedOutput; -use Symfony\Component\Console\Output\OutputInterface; class UrlTest extends TestCase { @@ -27,12 +26,11 @@ protected function setUp(): void if (!OsUtil::isLinux()) { $this->markTestSkipped('WSL is Linux'); } - foreach (['DISPLAY', 'WSL_DISTRO_NAME', 'WSL_INTEROP'] as $name) { + foreach (['DISPLAY', 'MOCK_CLI_WSL_BROWSER'] as $name) { $this->env[$name] = getenv($name); } putenv('DISPLAY'); - putenv('WSL_INTEROP'); - putenv('WSL_DISTRO_NAME=Ubuntu'); + putenv('MOCK_CLI_WSL_BROWSER'); } protected function tearDown(): void @@ -42,46 +40,29 @@ protected function tearDown(): void } } - /** - * @param string[] $commands - */ - private function urlService(array $commands, bool $interop = true): Url + private function urlService(): Url { $this->shell = $this->createMock(Shell::class); - $this->shell->method('commandExists')->willReturnCallback(fn(string $c): bool => in_array($c, $commands, true)); + $this->shell->method('commandExists')->willReturn(false); $definition = new InputDefinition(); Url::configureInput($definition); - return new class ($interop, $this->shell, new ArrayInput([], $definition), new BufferedOutput()) extends Url { - public function __construct(private readonly bool $interop, Shell $shell, InputInterface $input, OutputInterface $output) - { - parent::__construct($shell, $input, $output); - } - - protected function isWslInteropEnabled(): bool - { - return $this->interop; - } - }; + return new Url( + $this->shell, + new ArrayInput([], $definition), + new BufferedOutput(), + new Config([], __DIR__ . '/../data/mock-cli-config.yaml'), + ); } public function testWslOpensUrlsWithRundll32(): void { - $url = $this->urlService(['rundll32.exe']); + $rundll32 = '/mnt/c/Windows/System32/rundll32.exe'; + putenv('MOCK_CLI_WSL_BROWSER=' . $rundll32); + $url = $this->urlService(); $this->assertTrue($url->hasDisplay()); $this->assertTrue($url->canOpenUrls()); - $this->shell->expects($this->once()) - ->method('execute') - ->with(['rundll32.exe', 'url.dll,FileProtocolHandler', 'http://127.0.0.1:5000']) - ->willReturn(''); - $this->assertTrue($url->openUrl('http://127.0.0.1:5000', false)); - } - - public function testWslFindsRundll32OutsidePath(): void - { - $rundll32 = '/mnt/c/Windows/System32/rundll32.exe'; - $url = $this->urlService([$rundll32]); $this->shell->expects($this->once()) ->method('execute') ->with([$rundll32, 'url.dll,FileProtocolHandler', 'http://127.0.0.1:5000']) @@ -89,26 +70,21 @@ public function testWslFindsRundll32OutsidePath(): void $this->assertTrue($url->openUrl('http://127.0.0.1:5000', false)); } - public function testWslPrefersWslview(): void + public function testWslOpensUrlsWithWslview(): void { - $url = $this->urlService(['wslview', 'rundll32.exe']); + putenv('MOCK_CLI_WSL_BROWSER=/usr/bin/wslview'); + $url = $this->urlService(); $this->shell->expects($this->once()) ->method('execute') - ->with(['wslview', 'http://127.0.0.1:5000']) + ->with(['/usr/bin/wslview', 'http://127.0.0.1:5000']) ->willReturn(''); $this->assertTrue($url->openUrl('http://127.0.0.1:5000', false)); } - public function testWslWithoutOpener(): void - { - $url = $this->urlService([]); - $this->assertFalse($url->hasDisplay()); - $this->assertFalse($url->canOpenUrls()); - } - - public function testWslInteropOff(): void + public function testNoWslBrowser(): void { - $url = $this->urlService(['wslview', 'rundll32.exe'], false); + putenv('MOCK_CLI_WSL_BROWSER='); + $url = $this->urlService(); $this->assertFalse($url->hasDisplay()); $this->assertFalse($url->canOpenUrls()); } diff --git a/legacy/tests/Util/OsUtilTest.php b/legacy/tests/Util/OsUtilTest.php index e78041726..39bf511e2 100644 --- a/legacy/tests/Util/OsUtilTest.php +++ b/legacy/tests/Util/OsUtilTest.php @@ -24,44 +24,4 @@ public function testEscapePosixShellArg(): void OsUtil::escapePosixShellArg("No it isn't"), ); } - - public function testIsWsl(): void - { - if (!OsUtil::isLinux()) { - $this->markTestSkipped('WSL is Linux'); - } - if (stripos((string) @file_get_contents('/proc/sys/kernel/osrelease'), 'microsoft') !== false) { - $this->markTestSkipped('running in WSL'); - } - $distro = getenv('WSL_DISTRO_NAME'); - $interop = getenv('WSL_INTEROP'); - try { - putenv('WSL_DISTRO_NAME'); - putenv('WSL_INTEROP'); - $this->assertFalse(OsUtil::isWsl()); - putenv('WSL_DISTRO_NAME=Ubuntu'); - $this->assertTrue(OsUtil::isWsl()); - } finally { - putenv($distro === false ? 'WSL_DISTRO_NAME' : "WSL_DISTRO_NAME=$distro"); - putenv($interop === false ? 'WSL_INTEROP' : "WSL_INTEROP=$interop"); - } - } - - public function testIsWslInteropEnabled(): void - { - $dir = sys_get_temp_dir() . '/binfmt-' . bin2hex(random_bytes(4)); - mkdir($dir); - try { - $this->assertFalse(OsUtil::isWslInteropEnabled($dir)); - file_put_contents($dir . '/WSLInterop', "disabled\ninterpreter /init\n"); - $this->assertFalse(OsUtil::isWslInteropEnabled($dir)); - file_put_contents($dir . '/WSLInterop-late', "enabled\ninterpreter /init\n"); - $this->assertTrue(OsUtil::isWslInteropEnabled($dir)); - } finally { - foreach (glob($dir . '/*') ?: [] as $file) { - unlink($file); - } - rmdir($dir); - } - } } From 3aebf84314307ed12a1aa7e9f045f4f91381fc6c Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Fri, 9 Oct 2026 10:06:51 +0100 Subject: [PATCH 7/9] perf(auth): check System32 for rundll32.exe before searching PATH in WSL 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 --- commands/auth.go | 14 ++++---- commands/auth_test.go | 58 +++++++++++++++++++++----------- legacy/tests/Service/UrlTest.php | 11 ------ 3 files changed, 44 insertions(+), 39 deletions(-) diff --git a/commands/auth.go b/commands/auth.go index 289c5a7dd..a637cdcfe 100644 --- a/commands/auth.go +++ b/commands/auth.go @@ -294,10 +294,11 @@ func isWSL() bool { return err == nil && strings.Contains(strings.ToLower(string(b)), "microsoft") } -// WSL's binfmt_misc directory and config file (overridden in tests). +// WSL's binfmt_misc directory, config file, and the usual path to rundll32.exe (overridden in tests). var ( - wslBinfmtDir = "/proc/sys/fs/binfmt_misc" - wslConfPath = "/etc/wsl.conf" + wslBinfmtDir = "/proc/sys/fs/binfmt_misc" + wslConfPath = "/etc/wsl.conf" + wslRundll32Path = "/mnt/c/Windows/System32/rundll32.exe" ) // wslInteropEnabled reports whether WSL can run Windows programs. Its binfmt_misc entry is named WSLInterop or @@ -340,11 +341,8 @@ func wslBrowserCommand() []string { if !wslInteropEnabled() { return nil } - if p, err := exec.LookPath("wslview"); err == nil { - return []string{p} - } - // Windows paths can be left out of PATH (appendWindowsPath=false in wsl.conf). - for _, p := range []string{"rundll32.exe", "/mnt/c/Windows/System32/rundll32.exe"} { + // Searching PATH is slow under WSL2 (tens of ms, as it has Windows directories), so the usual path is tried first. + for _, p := range []string{wslRundll32Path, "rundll32.exe"} { if p, err := exec.LookPath(p); err == nil { return []string{p, "url.dll,FileProtocolHandler"} } diff --git a/commands/auth_test.go b/commands/auth_test.go index cb22bc769..2ac89a037 100644 --- a/commands/auth_test.go +++ b/commands/auth_test.go @@ -52,15 +52,22 @@ func TestBrowserCommand_Whitespace(t *testing.T) { assert.NotPanics(t, func() { browserCommand(" ") }) } -// setWSLInterop fakes WSL's binfmt_misc entry for Windows interop; an empty state leaves it out. -func setWSLInterop(t *testing.T, state string) { +// setWSLInterop fakes WSL's binfmt_misc entry for Windows interop (an empty state leaves it out), wsl.conf, and the +// System32 rundll32.exe (missing). It returns the paths for wsl.conf and rundll32.exe. +func setWSLInterop(t *testing.T, state string) (conf, rundll string) { dir := t.TempDir() if state != "" { require.NoError(t, os.WriteFile(filepath.Join(dir, "WSLInterop"), []byte(state+"\ninterpreter /init\n"), 0o600)) } - origDir, origConf := wslBinfmtDir, wslConfPath - wslBinfmtDir, wslConfPath = dir, filepath.Join(dir, "wsl.conf") - t.Cleanup(func() { wslBinfmtDir, wslConfPath = origDir, origConf }) + origDir, origConf, origRundll := wslBinfmtDir, wslConfPath, wslRundll32Path + wslBinfmtDir, wslConfPath, wslRundll32Path = dir, filepath.Join(dir, "wsl.conf"), filepath.Join(dir, "rundll32.exe") + t.Cleanup(func() { wslBinfmtDir, wslConfPath, wslRundll32Path = origDir, origConf, origRundll }) + return wslConfPath, wslRundll32Path +} + +// writeStub writes an executable stub. +func writeStub(t *testing.T, path string) { + require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\n"), 0o700)) //nolint:gosec // an executable stub } // TestWSLInteropEnabled covers WSL2, which keeps the binfmt_misc entry enabled when wsl.conf disables interop. @@ -82,9 +89,9 @@ func TestWSLInteropEnabled(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - setWSLInterop(t, c.binfmt) + conf, _ := setWSLInterop(t, c.binfmt) if c.wslConf != "" { - require.NoError(t, os.WriteFile(wslConfPath, []byte(c.wslConf), 0o600)) + require.NoError(t, os.WriteFile(conf, []byte(c.wslConf), 0o600)) } assert.Equal(t, c.want, wslInteropEnabled()) }) @@ -95,31 +102,45 @@ func TestBrowserCommand_WSL(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("WSL is Linux") } - setWSLInterop(t, "enabled") + _, system32 := setWSLInterop(t, "enabled") + writeStub(t, system32) dir := t.TempDir() - rundll := filepath.Join(dir, "rundll32.exe") - require.NoError(t, os.WriteFile(rundll, []byte("#!/bin/sh\n"), 0o700)) //nolint:gosec // an executable stub + writeStub(t, filepath.Join(dir, "rundll32.exe")) t.Setenv("PATH", dir) t.Setenv("DISPLAY", "") t.Setenv("WSL_INTEROP", "") t.Setenv("WSL_DISTRO_NAME", "Ubuntu") assert.True(t, isWSL()) - assert.Equal(t, rundll, wslBrowser()) + assert.Equal(t, system32, wslBrowser(), "System32 is checked before PATH") assert.True(t, hasDisplay(), "a Windows browser can be used without a display") - assert.Equal(t, []string{rundll, "url.dll,FileProtocolHandler"}, browserCommand("")) + assert.Equal(t, []string{system32, "url.dll,FileProtocolHandler"}, browserCommand("")) assert.True(t, canOpenURLs("")) } +// TestBrowserCommand_WSLRundll32InPath checks the PATH fallback, e.g. when the Windows drive is not mounted at /mnt/c. +func TestBrowserCommand_WSLRundll32InPath(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("WSL is Linux") + } + setWSLInterop(t, "enabled") + dir := t.TempDir() + rundll := filepath.Join(dir, "rundll32.exe") + writeStub(t, rundll) + t.Setenv("PATH", dir) + t.Setenv("DISPLAY", "") + t.Setenv("WSL_DISTRO_NAME", "Ubuntu") + + assert.Equal(t, rundll, wslBrowser()) +} + // TestBrowserCommand_WSLWithoutOpener checks WSL without a Windows opener, e.g. with interop disabled, or a Docker // Desktop container (which shares the WSL kernel). func TestBrowserCommand_WSLWithoutOpener(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("WSL is Linux") } - if _, err := os.Stat("/mnt/c/Windows/System32/rundll32.exe"); err == nil { - t.Skip("a Windows opener exists") - } + setWSLInterop(t, "enabled") t.Setenv("PATH", t.TempDir()) t.Setenv("DISPLAY", "") t.Setenv("WSL_DISTRO_NAME", "Ubuntu") @@ -133,15 +154,12 @@ func TestBrowserCommand_WSLInteropOff(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("WSL is Linux") } - dir := t.TempDir() - rundll := filepath.Join(dir, "rundll32.exe") - require.NoError(t, os.WriteFile(rundll, []byte("#!/bin/sh\n"), 0o700)) //nolint:gosec // an executable stub - t.Setenv("PATH", dir) t.Setenv("DISPLAY", "") t.Setenv("WSL_DISTRO_NAME", "Ubuntu") for _, state := range []string{"", "disabled"} { - setWSLInterop(t, state) + _, rundll := setWSLInterop(t, state) + writeStub(t, rundll) assert.False(t, hasDisplay(), "interop state %q", state) assert.Empty(t, wslBrowser(), "interop state %q", state) assert.False(t, canOpenURLs(""), "interop state %q", state) diff --git a/legacy/tests/Service/UrlTest.php b/legacy/tests/Service/UrlTest.php index 9b9d43e0a..98d2598e3 100644 --- a/legacy/tests/Service/UrlTest.php +++ b/legacy/tests/Service/UrlTest.php @@ -70,17 +70,6 @@ public function testWslOpensUrlsWithRundll32(): void $this->assertTrue($url->openUrl('http://127.0.0.1:5000', false)); } - public function testWslOpensUrlsWithWslview(): void - { - putenv('MOCK_CLI_WSL_BROWSER=/usr/bin/wslview'); - $url = $this->urlService(); - $this->shell->expects($this->once()) - ->method('execute') - ->with(['/usr/bin/wslview', 'http://127.0.0.1:5000']) - ->willReturn(''); - $this->assertTrue($url->openUrl('http://127.0.0.1:5000', false)); - } - public function testNoWslBrowser(): void { putenv('MOCK_CLI_WSL_BROWSER='); From e9eee6a035969e4732400e7316a286d2720f9b93 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 13 Oct 2026 14:06:44 +0100 Subject: [PATCH 8/9] perf(auth): find rundll32.exe via the WSL automount root instead of searching 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 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 --- commands/auth.go | 50 ++++++++++++------------ commands/auth_test.go | 91 ++++++++++++++++++++++++++++++------------- 2 files changed, 90 insertions(+), 51 deletions(-) diff --git a/commands/auth.go b/commands/auth.go index a637cdcfe..48e750690 100644 --- a/commands/auth.go +++ b/commands/auth.go @@ -294,58 +294,58 @@ func isWSL() bool { return err == nil && strings.Contains(strings.ToLower(string(b)), "microsoft") } -// WSL's binfmt_misc directory, config file, and the usual path to rundll32.exe (overridden in tests). +// WSL's binfmt_misc directory, config file, and default automount root (overridden in tests). var ( - wslBinfmtDir = "/proc/sys/fs/binfmt_misc" - wslConfPath = "/etc/wsl.conf" - wslRundll32Path = "/mnt/c/Windows/System32/rundll32.exe" + wslBinfmtDir = "/proc/sys/fs/binfmt_misc" + wslConfPath = "/etc/wsl.conf" + wslDefaultMountRoot = "/mnt/" ) // wslInteropEnabled reports whether WSL can run Windows programs. Its binfmt_misc entry is named WSLInterop or // WSLInterop-late. WSL1 removes it when interop is off, but WSL2 keeps it enabled, so wsl.conf is checked too. -func wslInteropEnabled() bool { +func wslInteropEnabled(wslConf string) bool { paths, _ := filepath.Glob(filepath.Join(wslBinfmtDir, "WSLInterop*")) for _, p := range paths { if b, err := os.ReadFile(p); err == nil && strings.HasPrefix(string(b), "enabled") { - return !wslConfDisablesInterop() + return !strings.EqualFold(wslConfValue(wslConf, "interop", "enabled"), "false") } } return false } -// wslConfDisablesInterop reports whether wsl.conf sets "enabled = false" in its [interop] section. -func wslConfDisablesInterop() bool { - b, err := os.ReadFile(wslConfPath) - if err != nil { - return false - } - var section string - for line := range strings.Lines(string(b)) { +// wslConfValue returns the value of a key in a section of wsl.conf's contents, or an empty string. +func wslConfValue(wslConf, section, key string) string { + var current string + for line := range strings.Lines(wslConf) { line, _, _ = strings.Cut(line, "#") line = strings.TrimSpace(line) if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { - section = strings.ToLower(strings.TrimSpace(line[1 : len(line)-1])) + current = strings.TrimSpace(line[1 : len(line)-1]) continue } - key, value, ok := strings.Cut(line, "=") - if ok && section == "interop" && strings.EqualFold(strings.TrimSpace(key), "enabled") { - return strings.EqualFold(strings.TrimSpace(value), "false") + k, v, ok := strings.Cut(line, "=") + if ok && strings.EqualFold(current, section) && strings.EqualFold(strings.TrimSpace(k), key) { + return strings.TrimSpace(v) } } - return false + return "" } // wslBrowserCommand returns a command to open URLs in a Windows browser from WSL, or nil if none is found. func wslBrowserCommand() []string { + b, _ := os.ReadFile(wslConfPath) + wslConf := string(b) // The openers run Windows programs. - if !wslInteropEnabled() { + if !wslInteropEnabled(wslConf) { return nil } - // Searching PATH is slow under WSL2 (tens of ms, as it has Windows directories), so the usual path is tried first. - for _, p := range []string{wslRundll32Path, "rundll32.exe"} { - if p, err := exec.LookPath(p); err == nil { - return []string{p, "url.dll,FileProtocolHandler"} - } + // PATH is not searched, as that is slow under WSL2 (tens of ms, as it has Windows directories). + root := wslConfValue(wslConf, "automount", "root") + if root == "" { + root = wslDefaultMountRoot + } + if p, err := exec.LookPath(filepath.Join(root, "c", "Windows", "System32", "rundll32.exe")); err == nil { + return []string{p, "url.dll,FileProtocolHandler"} } return nil } diff --git a/commands/auth_test.go b/commands/auth_test.go index 2ac89a037..da0390588 100644 --- a/commands/auth_test.go +++ b/commands/auth_test.go @@ -52,21 +52,22 @@ func TestBrowserCommand_Whitespace(t *testing.T) { assert.NotPanics(t, func() { browserCommand(" ") }) } -// setWSLInterop fakes WSL's binfmt_misc entry for Windows interop (an empty state leaves it out), wsl.conf, and the -// System32 rundll32.exe (missing). It returns the paths for wsl.conf and rundll32.exe. +// setWSLInterop fakes WSL's binfmt_misc entry for Windows interop (an empty state leaves it out), wsl.conf (missing), +// and the default automount root. It returns the paths for wsl.conf and System32's rundll32.exe (missing). func setWSLInterop(t *testing.T, state string) (conf, rundll string) { dir := t.TempDir() if state != "" { require.NoError(t, os.WriteFile(filepath.Join(dir, "WSLInterop"), []byte(state+"\ninterpreter /init\n"), 0o600)) } - origDir, origConf, origRundll := wslBinfmtDir, wslConfPath, wslRundll32Path - wslBinfmtDir, wslConfPath, wslRundll32Path = dir, filepath.Join(dir, "wsl.conf"), filepath.Join(dir, "rundll32.exe") - t.Cleanup(func() { wslBinfmtDir, wslConfPath, wslRundll32Path = origDir, origConf, origRundll }) - return wslConfPath, wslRundll32Path + origDir, origConf, origRoot := wslBinfmtDir, wslConfPath, wslDefaultMountRoot + wslBinfmtDir, wslConfPath, wslDefaultMountRoot = dir, filepath.Join(dir, "wsl.conf"), dir+"/" + t.Cleanup(func() { wslBinfmtDir, wslConfPath, wslDefaultMountRoot = origDir, origConf, origRoot }) + return wslConfPath, filepath.Join(dir, "c", "Windows", "System32", "rundll32.exe") } -// writeStub writes an executable stub. +// writeStub writes an executable stub, creating its directory. func writeStub(t *testing.T, path string) { + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\n"), 0o700)) //nolint:gosec // an executable stub } @@ -89,11 +90,8 @@ func TestWSLInteropEnabled(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - conf, _ := setWSLInterop(t, c.binfmt) - if c.wslConf != "" { - require.NoError(t, os.WriteFile(conf, []byte(c.wslConf), 0o600)) - } - assert.Equal(t, c.want, wslInteropEnabled()) + setWSLInterop(t, c.binfmt) + assert.Equal(t, c.want, wslInteropEnabled(c.wslConf)) }) } } @@ -104,34 +102,75 @@ func TestBrowserCommand_WSL(t *testing.T) { } _, system32 := setWSLInterop(t, "enabled") writeStub(t, system32) - dir := t.TempDir() - writeStub(t, filepath.Join(dir, "rundll32.exe")) - t.Setenv("PATH", dir) t.Setenv("DISPLAY", "") t.Setenv("WSL_INTEROP", "") t.Setenv("WSL_DISTRO_NAME", "Ubuntu") assert.True(t, isWSL()) - assert.Equal(t, system32, wslBrowser(), "System32 is checked before PATH") + assert.Equal(t, system32, wslBrowser()) assert.True(t, hasDisplay(), "a Windows browser can be used without a display") assert.Equal(t, []string{system32, "url.dll,FileProtocolHandler"}, browserCommand("")) assert.True(t, canOpenURLs("")) } -// TestBrowserCommand_WSLRundll32InPath checks the PATH fallback, e.g. when the Windows drive is not mounted at /mnt/c. -func TestBrowserCommand_WSLRundll32InPath(t *testing.T) { +// TestWSLBrowser_MountRoot checks that rundll32.exe is found via wsl.conf's automount root, and not via PATH. +func TestWSLBrowser_MountRoot(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("WSL is Linux") } - setWSLInterop(t, "enabled") - dir := t.TempDir() - rundll := filepath.Join(dir, "rundll32.exe") - writeStub(t, rundll) - t.Setenv("PATH", dir) - t.Setenv("DISPLAY", "") - t.Setenv("WSL_DISTRO_NAME", "Ubuntu") + custom := t.TempDir() + customRundll := filepath.Join(custom, "c", "Windows", "System32", "rundll32.exe") + cases := []struct { + name string + wslConf string + stub string // Where to write rundll32.exe: "default", "custom" (roots), "path", or nowhere. + want string // The expected root: "default", "custom", or none. + }{ + {name: "default root", stub: "default", want: "default"}, + {name: "custom root", wslConf: "[automount]\nroot = " + custom + "/\n", stub: "custom", want: "custom"}, + {name: "custom root without slash", wslConf: "[Automount]\nRoot=" + custom + "\n", stub: "custom", want: "custom"}, + {name: "custom root, rundll32 in default root", wslConf: "[automount]\nroot=" + custom + "/\n", stub: "default"}, + {name: "rundll32 only in PATH", stub: "path"}, + {name: "no rundll32"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + conf, defaultRundll := setWSLInterop(t, "enabled") + if c.wslConf != "" { + require.NoError(t, os.WriteFile(conf, []byte(c.wslConf), 0o600)) + } + require.NoError(t, os.RemoveAll(filepath.Join(custom, "c"))) + pathDir := t.TempDir() + t.Setenv("PATH", pathDir) + t.Setenv("WSL_DISTRO_NAME", "Ubuntu") + switch c.stub { + case "default": + writeStub(t, defaultRundll) + case "custom": + writeStub(t, customRundll) + case "path": + writeStub(t, filepath.Join(pathDir, "rundll32.exe")) + } + + switch c.want { + case "default": + assert.Equal(t, defaultRundll, wslBrowser()) + case "custom": + assert.Equal(t, customRundll, wslBrowser()) + default: + assert.Empty(t, wslBrowser()) + assert.Nil(t, wslBrowserCommand()) + } + }) + } +} - assert.Equal(t, rundll, wslBrowser()) +func TestWSLConfValue(t *testing.T) { + conf := "[boot]\nsystemd=true\nroot=/boot/\n\n[Automount] # mounts\n Root = /win/ # comment\n" + assert.Equal(t, "true", wslConfValue(conf, "boot", "systemd")) + assert.Equal(t, "/win/", wslConfValue(conf, "automount", "root")) + assert.Empty(t, wslConfValue(conf, "interop", "enabled")) + assert.Empty(t, wslConfValue("", "automount", "root")) } // TestBrowserCommand_WSLWithoutOpener checks WSL without a Windows opener, e.g. with interop disabled, or a Docker From 70b17d7ba35c7387443ae99306db2b134cea02e4 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Tue, 13 Oct 2026 15:58:37 +0100 Subject: [PATCH 9/9] refactor(legacy): find the WSL browser in PHP when needed The Go wrapper looked for a Windows browser on every legacy command, to pass it in 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 /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 --- commands/auth.go | 28 +++---- commands/auth_test.go | 34 ++++++-- commands/root.go | 1 - internal/legacy/legacy.go | 4 - legacy/src/Service/Url.php | 95 ++++++++++++++++++++- legacy/tests/Service/UrlTest.php | 136 ++++++++++++++++++++++++++----- 6 files changed, 247 insertions(+), 51 deletions(-) diff --git a/commands/auth.go b/commands/auth.go index 48e750690..298e80c25 100644 --- a/commands/auth.go +++ b/commands/auth.go @@ -313,7 +313,8 @@ func wslInteropEnabled(wslConf string) bool { return false } -// wslConfValue returns the value of a key in a section of wsl.conf's contents, or an empty string. +// wslConfValue returns the value of a key in a section of wsl.conf's contents, without surrounding quotes, or an +// empty string. func wslConfValue(wslConf, section, key string) string { var current string for line := range strings.Lines(wslConf) { @@ -325,7 +326,11 @@ func wslConfValue(wslConf, section, key string) string { } k, v, ok := strings.Cut(line, "=") if ok && strings.EqualFold(current, section) && strings.EqualFold(strings.TrimSpace(k), key) { - return strings.TrimSpace(v) + v = strings.TrimSpace(v) + if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') && v[len(v)-1] == v[0] { + v = v[1 : len(v)-1] + } + return v } } return "" @@ -339,28 +344,19 @@ func wslBrowserCommand() []string { if !wslInteropEnabled(wslConf) { return nil } - // PATH is not searched, as that is slow under WSL2 (tens of ms, as it has Windows directories). root := wslConfValue(wslConf, "automount", "root") if root == "" { root = wslDefaultMountRoot } - if p, err := exec.LookPath(filepath.Join(root, "c", "Windows", "System32", "rundll32.exe")); err == nil { - return []string{p, "url.dll,FileProtocolHandler"} + // Searching PATH is slow under WSL2 (tens of ms, as it has Windows directories), so it is the fallback. + for _, p := range []string{filepath.Join(root, "c", "Windows", "System32", "rundll32.exe"), "rundll32.exe"} { + if p, err := exec.LookPath(p); err == nil { + return []string{p, "url.dll,FileProtocolHandler"} + } } return nil } -// wslBrowser returns the program to open URLs in a Windows browser, if this is WSL, for the legacy CLI. -func wslBrowser() string { - if !isWSL() { - return "" - } - if c := wslBrowserCommand(); c != nil { - return c[0] - } - return "" -} - // browserCommand returns the command to open URLs, or nil if none should be used. func browserCommand(browserOption string) []string { switch { diff --git a/commands/auth_test.go b/commands/auth_test.go index da0390588..a5fa9b705 100644 --- a/commands/auth_test.go +++ b/commands/auth_test.go @@ -65,6 +65,14 @@ func setWSLInterop(t *testing.T, state string) (conf, rundll string) { return wslConfPath, filepath.Join(dir, "c", "Windows", "System32", "rundll32.exe") } +// wslOpener returns the program wslBrowserCommand finds, or an empty string. +func wslOpener() string { + if c := wslBrowserCommand(); c != nil { + return c[0] + } + return "" +} + // writeStub writes an executable stub, creating its directory. func writeStub(t *testing.T, path string) { require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700)) @@ -84,6 +92,7 @@ func TestWSLInteropEnabled(t *testing.T) { {name: "entry disabled", binfmt: "disabled", want: false}, {name: "wsl.conf disables", binfmt: "enabled", wslConf: "[boot]\nsystemd=true\n[interop]\nenabled=false\n"}, {name: "wsl.conf spacing and case", binfmt: "enabled", wslConf: "[Interop]\n Enabled = False # off\n"}, + {name: "wsl.conf quoted", binfmt: "enabled", wslConf: "[interop]\nenabled = \"false\"\n"}, {name: "wsl.conf enables", binfmt: "enabled", wslConf: "[interop]\nenabled=true\n", want: true}, {name: "other section", binfmt: "enabled", wslConf: "[automount]\nenabled=false\n", want: true}, {name: "comment", binfmt: "enabled", wslConf: "[interop]\n# enabled=false\n", want: true}, @@ -107,13 +116,13 @@ func TestBrowserCommand_WSL(t *testing.T) { t.Setenv("WSL_DISTRO_NAME", "Ubuntu") assert.True(t, isWSL()) - assert.Equal(t, system32, wslBrowser()) + assert.Equal(t, system32, wslOpener()) assert.True(t, hasDisplay(), "a Windows browser can be used without a display") assert.Equal(t, []string{system32, "url.dll,FileProtocolHandler"}, browserCommand("")) assert.True(t, canOpenURLs("")) } -// TestWSLBrowser_MountRoot checks that rundll32.exe is found via wsl.conf's automount root, and not via PATH. +// TestWSLBrowser_MountRoot checks that rundll32.exe is found via wsl.conf's automount root, or else via PATH. func TestWSLBrowser_MountRoot(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("WSL is Linux") @@ -124,13 +133,14 @@ func TestWSLBrowser_MountRoot(t *testing.T) { name string wslConf string stub string // Where to write rundll32.exe: "default", "custom" (roots), "path", or nowhere. - want string // The expected root: "default", "custom", or none. + want string // Where it is expected to be found, as above, or nowhere. }{ {name: "default root", stub: "default", want: "default"}, {name: "custom root", wslConf: "[automount]\nroot = " + custom + "/\n", stub: "custom", want: "custom"}, {name: "custom root without slash", wslConf: "[Automount]\nRoot=" + custom + "\n", stub: "custom", want: "custom"}, + {name: "custom root, quoted", wslConf: "[automount]\nroot = \"" + custom + "/\"\n", stub: "custom", want: "custom"}, {name: "custom root, rundll32 in default root", wslConf: "[automount]\nroot=" + custom + "/\n", stub: "default"}, - {name: "rundll32 only in PATH", stub: "path"}, + {name: "rundll32 in PATH", stub: "path", want: "path"}, {name: "no rundll32"}, } for _, c := range cases { @@ -154,11 +164,13 @@ func TestWSLBrowser_MountRoot(t *testing.T) { switch c.want { case "default": - assert.Equal(t, defaultRundll, wslBrowser()) + assert.Equal(t, defaultRundll, wslOpener()) case "custom": - assert.Equal(t, customRundll, wslBrowser()) + assert.Equal(t, customRundll, wslOpener()) + case "path": + assert.Equal(t, filepath.Join(pathDir, "rundll32.exe"), wslOpener()) default: - assert.Empty(t, wslBrowser()) + assert.Empty(t, wslOpener()) assert.Nil(t, wslBrowserCommand()) } }) @@ -171,6 +183,12 @@ func TestWSLConfValue(t *testing.T) { assert.Equal(t, "/win/", wslConfValue(conf, "automount", "root")) assert.Empty(t, wslConfValue(conf, "interop", "enabled")) assert.Empty(t, wslConfValue("", "automount", "root")) + + quoted := "[a]\nd=\"x\"\ns='y'\nm=\"z'\nq=\"\n" + assert.Equal(t, "x", wslConfValue(quoted, "a", "d")) + assert.Equal(t, "y", wslConfValue(quoted, "a", "s")) + assert.Equal(t, "\"z'", wslConfValue(quoted, "a", "m")) + assert.Equal(t, "\"", wslConfValue(quoted, "a", "q")) } // TestBrowserCommand_WSLWithoutOpener checks WSL without a Windows opener, e.g. with interop disabled, or a Docker @@ -200,7 +218,7 @@ func TestBrowserCommand_WSLInteropOff(t *testing.T) { _, rundll := setWSLInterop(t, state) writeStub(t, rundll) assert.False(t, hasDisplay(), "interop state %q", state) - assert.Empty(t, wslBrowser(), "interop state %q", state) + assert.Empty(t, wslOpener(), "interop state %q", state) assert.False(t, canOpenURLs(""), "interop state %q", state) } } diff --git a/commands/root.go b/commands/root.go index f28b57639..dc42e895c 100644 --- a/commands/root.go +++ b/commands/root.go @@ -360,7 +360,6 @@ func makeLegacyCLIWrapper(cnf *config.Config, stdout, stderr io.Writer, stdin io Version: config.Version, DebugLogFunc: debugLogf, DisableInteraction: viper.GetBool("no-interaction"), - WSLBrowser: wslBrowser(), Stdout: stdout, Stderr: stderr, Stdin: stdin, diff --git a/internal/legacy/legacy.go b/internal/legacy/legacy.go index 680d248cb..354eda692 100644 --- a/internal/legacy/legacy.go +++ b/internal/legacy/legacy.go @@ -38,8 +38,6 @@ type CLIWrapper struct { DisableInteraction bool ForceColor bool DebugLogFunc func(string, ...any) - // WSLBrowser is a command to open URLs in a Windows browser from WSL, or empty. - WSLBrowser string initOnce sync.Once _cacheDir string @@ -152,8 +150,6 @@ func (c *CLIWrapper) Exec(ctx context.Context, args ...string) error { if exe, err := os.Executable(); err == nil { cmd.Env = append(cmd.Env, envPrefix+"WRAPPER_EXECUTABLE="+exe) } - // The legacy CLI uses this to open URLs from WSL. It is always set, so the user's environment can't override it. - cmd.Env = append(cmd.Env, envPrefix+"WSL_BROWSER="+c.WSLBrowser) if c.DisableInteraction { cmd.Env = append(cmd.Env, envPrefix+"NO_INTERACTION=1") } diff --git a/legacy/src/Service/Url.php b/legacy/src/Service/Url.php index 8a55bb9c6..dbab9af51 100644 --- a/legacy/src/Service/Url.php +++ b/legacy/src/Service/Url.php @@ -16,7 +16,15 @@ class Url implements InputConfiguringInterface { protected OutputInterface $stdErr; - public function __construct(protected Shell $shell, protected InputInterface $input, protected OutputInterface $output, protected Config $config) + /** WSL's kernel release file, binfmt_misc directory, config file, and default automount root (overridden in tests). */ + public string $osReleasePath = '/proc/sys/kernel/osrelease'; + public string $wslBinfmtDir = '/proc/sys/fs/binfmt_misc'; + public string $wslConfPath = '/etc/wsl.conf'; + public string $wslDefaultMountRoot = '/mnt/'; + + private string|false|null $wslBrowser = null; + + public function __construct(protected Shell $shell, protected InputInterface $input, protected OutputInterface $output) { $this->stdErr = $this->output instanceof ConsoleOutputInterface ? $this->output->getErrorOutput() @@ -167,10 +175,91 @@ private function getDefaultBrowser(): string|false } /** - * Returns the command to open URLs in a Windows browser from WSL, which the Go wrapper finds, or false. + * Returns the command to open URLs in a Windows browser from WSL, or false. This matches the Go wrapper. */ private function getWslBrowser(): string|false { - return getenv($this->config->getStr('application.env_prefix') . 'WSL_BROWSER') ?: false; + return $this->wslBrowser ??= $this->findWslBrowser(); + } + + private function findWslBrowser(): string|false + { + if (!OsUtil::isLinux() || !$this->isWsl()) { + return false; + } + $wslConf = (string) @file_get_contents($this->wslConfPath); + // The opener runs a Windows program. + if (!$this->wslInteropEnabled($wslConf)) { + return false; + } + $root = self::wslConfValue($wslConf, 'automount', 'root'); + if ($root === '') { + $root = $this->wslDefaultMountRoot; + } + $path = rtrim($root, '/') . '/c/Windows/System32/rundll32.exe'; + if (is_file($path) && is_executable($path)) { + return $path; + } + // Searching PATH is slow under WSL2 (it has Windows directories), so it is the fallback. + if ($this->shell->commandExists('rundll32.exe')) { + return 'rundll32.exe'; + } + + return false; + } + + /** + * Tests for WSL. This includes Docker Desktop's containers, which share its kernel, but have no Windows browser. + */ + private function isWsl(): bool + { + if (getenv('WSL_DISTRO_NAME') || getenv('WSL_INTEROP')) { + return true; + } + $release = @file_get_contents($this->osReleasePath); + + return $release !== false && stripos($release, 'microsoft') !== false; + } + + /** + * Tests whether WSL can run Windows programs. Its binfmt_misc entry is named WSLInterop or WSLInterop-late. + * WSL1 removes it when interop is off, but WSL2 keeps it enabled, so wsl.conf is checked too. + */ + private function wslInteropEnabled(string $wslConf): bool + { + foreach (glob($this->wslBinfmtDir . '/WSLInterop*') ?: [] as $path) { + $contents = @file_get_contents($path); + if ($contents !== false && str_starts_with($contents, 'enabled')) { + return strcasecmp(self::wslConfValue($wslConf, 'interop', 'enabled'), 'false') !== 0; + } + } + + return false; + } + + /** + * Returns the value of a key in a section of wsl.conf's contents, without surrounding quotes, or an empty string. + */ + public static function wslConfValue(string $wslConf, string $section, string $key): string + { + $current = ''; + foreach (preg_split('/\r?\n/', $wslConf) ?: [] as $line) { + $line = trim(explode('#', $line, 2)[0]); + if (str_starts_with($line, '[') && str_ends_with($line, ']')) { + $current = trim(substr($line, 1, -1)); + continue; + } + $parts = explode('=', $line, 2); + if (count($parts) === 2 && strcasecmp($current, $section) === 0 && strcasecmp(trim($parts[0]), $key) === 0) { + $value = trim($parts[1]); + if (strlen($value) >= 2 && ($value[0] === '"' || $value[0] === "'") && $value[-1] === $value[0]) { + $value = substr($value, 1, -1); + } + + return $value; + } + } + + return ''; } } diff --git a/legacy/tests/Service/UrlTest.php b/legacy/tests/Service/UrlTest.php index 98d2598e3..76aef3eae 100644 --- a/legacy/tests/Service/UrlTest.php +++ b/legacy/tests/Service/UrlTest.php @@ -4,21 +4,26 @@ namespace Platformsh\Cli\Tests\Service; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Platformsh\Cli\Service\Config; use Platformsh\Cli\Service\Shell; use Platformsh\Cli\Service\Url; use Platformsh\Cli\Util\OsUtil; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Filesystem\Filesystem; class UrlTest extends TestCase { + private const ENV = ['DISPLAY', 'WSL_DISTRO_NAME', 'WSL_INTEROP']; + /** @var array */ private array $env = []; + private string $dir; + private Shell&MockObject $shell; protected function setUp(): void @@ -26,11 +31,13 @@ protected function setUp(): void if (!OsUtil::isLinux()) { $this->markTestSkipped('WSL is Linux'); } - foreach (['DISPLAY', 'MOCK_CLI_WSL_BROWSER'] as $name) { + foreach (self::ENV as $name) { $this->env[$name] = getenv($name); + putenv($name); } - putenv('DISPLAY'); - putenv('MOCK_CLI_WSL_BROWSER'); + putenv('WSL_DISTRO_NAME=Ubuntu'); + $this->dir = sys_get_temp_dir() . '/cli-url-test-' . bin2hex(random_bytes(6)); + mkdir($this->dir . '/binfmt', 0o700, true); } protected function tearDown(): void @@ -38,43 +45,134 @@ protected function tearDown(): void foreach ($this->env as $name => $value) { putenv($value === false ? $name : "$name=$value"); } + if (isset($this->dir)) { + (new Filesystem())->remove($this->dir); + } } - private function urlService(): Url + private function urlService(bool $rundll32InPath = false): Url { $this->shell = $this->createMock(Shell::class); - $this->shell->method('commandExists')->willReturn(false); + $this->shell->method('commandExists')->willReturnCallback( + fn(string $command): bool => $rundll32InPath && $command === 'rundll32.exe', + ); $definition = new InputDefinition(); Url::configureInput($definition); + $url = new Url($this->shell, new ArrayInput([], $definition), new BufferedOutput()); + $url->osReleasePath = $this->dir . '/osrelease'; + $url->wslBinfmtDir = $this->dir . '/binfmt'; + $url->wslConfPath = $this->dir . '/wsl.conf'; + $url->wslDefaultMountRoot = $this->dir . '/mnt/'; - return new Url( - $this->shell, - new ArrayInput([], $definition), - new BufferedOutput(), - new Config([], __DIR__ . '/../data/mock-cli-config.yaml'), - ); + return $url; + } + + private function setInterop(string $state): void + { + file_put_contents($this->dir . '/binfmt/WSLInterop', $state . "\ninterpreter /init\n"); + } + + private function writeStub(string $path): void + { + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0o700, true); + } + file_put_contents($path, "#!/bin/sh\n"); + chmod($path, 0o700); + } + + private function defaultRundll32(): string + { + return $this->dir . '/mnt/c/Windows/System32/rundll32.exe'; } public function testWslOpensUrlsWithRundll32(): void { - $rundll32 = '/mnt/c/Windows/System32/rundll32.exe'; - putenv('MOCK_CLI_WSL_BROWSER=' . $rundll32); + $this->setInterop('enabled'); + $this->writeStub($this->defaultRundll32()); $url = $this->urlService(); $this->assertTrue($url->hasDisplay()); $this->assertTrue($url->canOpenUrls()); $this->shell->expects($this->once()) ->method('execute') - ->with([$rundll32, 'url.dll,FileProtocolHandler', 'http://127.0.0.1:5000']) + ->with([$this->defaultRundll32(), 'url.dll,FileProtocolHandler', 'http://127.0.0.1:5000']) ->willReturn(''); $this->assertTrue($url->openUrl('http://127.0.0.1:5000', false)); } - public function testNoWslBrowser(): void + /** + * @return array + */ + public static function wslBrowserCases(): array + { + $custom = "[automount]\nroot = %s/custom/\n"; + + return [ + 'default root' => ['enabled', '', 'mnt', false, 'mnt'], + 'custom root' => ['enabled', $custom, 'custom', false, 'custom'], + 'custom root, quoted, no slash' => ['enabled', "[Automount]\nRoot = \"%s/custom\" # comment\n", 'custom', false, 'custom'], + 'custom root, rundll32 in default root' => ['enabled', $custom, 'mnt', false, false], + 'PATH fallback' => ['enabled', '', '', true, 'rundll32.exe'], + 'no rundll32' => ['enabled', '', '', false, false], + 'no interop entry' => ['', '', 'mnt', true, false], + 'interop entry disabled' => ['disabled', '', 'mnt', true, false], + 'wsl.conf disables interop' => ['enabled', "[boot]\nsystemd=true\n[Interop]\n Enabled = 'False'\n", 'mnt', true, false], + 'wsl.conf enables interop' => ['enabled', "[interop]\nenabled=true\n", 'mnt', false, 'mnt'], + 'commented out' => ['enabled', "[interop]\n# enabled=false\n", 'mnt', false, 'mnt'], + ]; + } + + #[DataProvider('wslBrowserCases')] + public function testWslBrowser(string $interop, string $wslConf, string $stubRoot, bool $rundll32InPath, string|false $want): void { - putenv('MOCK_CLI_WSL_BROWSER='); + if ($interop !== '') { + $this->setInterop($interop); + } + if ($wslConf !== '') { + file_put_contents($this->dir . '/wsl.conf', sprintf($wslConf, $this->dir)); + } + if ($stubRoot !== '') { + $this->writeStub($this->dir . '/' . $stubRoot . '/c/Windows/System32/rundll32.exe'); + } + $url = $this->urlService($rundll32InPath); + if ($want === false) { + $this->assertFalse($url->hasDisplay()); + $this->assertFalse($url->canOpenUrls()); + return; + } + $expected = $want === 'rundll32.exe' ? $want : $this->dir . '/' . $want . '/c/Windows/System32/rundll32.exe'; + $this->assertTrue($url->hasDisplay()); + $this->shell->expects($this->once()) + ->method('execute') + ->with([$expected, 'url.dll,FileProtocolHandler', 'https://example.com']) + ->willReturn(''); + $url->openUrl('https://example.com', false); + } + + public function testWslDetectedFromKernelRelease(): void + { + putenv('WSL_DISTRO_NAME'); + $this->setInterop('enabled'); + $this->writeStub($this->defaultRundll32()); $url = $this->urlService(); - $this->assertFalse($url->hasDisplay()); - $this->assertFalse($url->canOpenUrls()); + $this->assertFalse($url->hasDisplay(), 'not WSL'); + + file_put_contents($this->dir . '/osrelease', "5.15.167.4-Microsoft-standard-WSL2\n"); + $this->assertTrue($this->urlService()->hasDisplay()); + } + + public function testWslConfValue(): void + { + $conf = "[boot]\nsystemd=true\nroot=/boot/\n\n[Automount] # mounts\n Root = /win/ # comment\n" + . "[a]\nd=\"x\"\ns='y'\nm=\"z'\nq=\"\n"; + $this->assertSame('true', Url::wslConfValue($conf, 'boot', 'systemd')); + $this->assertSame('/win/', Url::wslConfValue($conf, 'automount', 'root')); + $this->assertSame('x', Url::wslConfValue($conf, 'a', 'd')); + $this->assertSame('y', Url::wslConfValue($conf, 'a', 's')); + $this->assertSame("\"z'", Url::wslConfValue($conf, 'a', 'm')); + $this->assertSame('"', Url::wslConfValue($conf, 'a', 'q')); + $this->assertSame('', Url::wslConfValue($conf, 'interop', 'enabled')); + $this->assertSame('', Url::wslConfValue('', 'automount', 'root')); } }