From 005da54a15e29fd94e601beeba2550830d806253 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:31:54 -0700 Subject: [PATCH] fix(wsl): start a bridge fish older than 2.7 without -C instead of failing the pane (#35579) * fix(wsl): start a bridge fish older than 2.7 without -C instead of failing the pane fish before 2.7 rejects -C, so the claude function and cwd hook made a WSL fish pane fail to start on distros such as Ubuntu 16.04. Read the version first and start those plainly. The cwd-report test now decodes reports through the daemon's parser and checks fish 4's own reports. * fix(wsl): read a fish 2.x version from stderr fish 2.x prints --version to stderr, so the probe read an empty string and old fish started with -C and failed. Read both streams; the stub now reports one old version on stderr so the test pins this. --- .../fish-wsl-cwd-report.node-pty.test.ts | 32 ++++++-- src/shared/wsl-login-shell-command.ts | 8 +- .../wsl-login-shell-fish-version.test.ts | 73 +++++++++++++++++++ 3 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 src/shared/wsl-login-shell-fish-version.test.ts diff --git a/src/shared/fish-wsl-cwd-report.node-pty.test.ts b/src/shared/fish-wsl-cwd-report.node-pty.test.ts index 7297fc67ff6..499ded3fc10 100644 --- a/src/shared/fish-wsl-cwd-report.node-pty.test.ts +++ b/src/shared/fish-wsl-cwd-report.node-pty.test.ts @@ -5,22 +5,36 @@ import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { fishRequirementViolation, resolveFishBinary } from './fish-binary-requirement' import { getWslFishInitCommand } from './wsl-login-shell-command' +import { toWindowsWslPath } from './wsl-paths' +import { parseFileUriPath } from '../main/daemon/osc7-file-uri' const FISH = resolveFishBinary(3) const FISH_PATH = FISH.available ? FISH.path : '' const itWithFish = FISH.available ? it : it.skip const PROMPT_MARK = 'ORCAFISH> ' -// Orca's reports and the foreign one carry no host; fish 4's own (file:///…) are left out. -function hostlessReports(output: string): string[] { +type Report = { hosted: boolean; folder: string } + +// Every OSC 7 the run printed, read the way the daemon reads a WSL pane's (host ignored). +function reports(output: string): Report[] { return output .split('\x1b]7;') .slice(1) .map((report) => report.slice(0, report.indexOf('\x07'))) - .filter((uri) => uri.startsWith('file:///')) - .map((uri) => decodeURIComponent(uri.slice('file://'.length))) + .map((uri) => ({ + hosted: !uri.startsWith('file:///'), + folder: parseFileUriPath(uri, { wslDistro: 'Ubuntu' }) ?? 'unparsed' + })) } +// Orca's reports and the foreign one carry no host; fish 4 also sends its own, with one. +const hostless = (output: string): string[] => + reports(output) + .filter((report) => !report.hosted) + .map((report) => report.folder) + +const unc = (folder: string): string => toWindowsWslPath(folder, 'Ubuntu') + describe('a WSL bridge fish pane reports its folder', () => { let home: string let deep: string @@ -103,7 +117,13 @@ describe('a WSL bridge fish pane reports its folder', () => { 'at every prompt, after the user config, keeping their prompt and hook', async () => { const output = await runFish({ WSL_DISTRO_NAME: 'Ubuntu' }) - expect(hostlessReports(output)).toEqual([home, deep, deep, '/elsewhere', deep]) + expect(hostless(output)).toEqual([home, deep, deep, '/elsewhere', deep].map(unc)) + // fish 4's own hosted reports name the same folders, so the two never disagree. + const hosted = reports(output).filter((report) => report.hosted) + expect(hosted.length > 0).toBe(FISH.majorVersion >= 4) + for (const report of hosted) { + expect([unc(home), unc(deep)]).toContain(report.folder) + } expect(output).toContain('USER_PROMPT_HOOK') }, 60_000 @@ -112,7 +132,7 @@ describe('a WSL bridge fish pane reports its folder', () => { itWithFish( 'only inside WSL', async () => { - expect(hostlessReports(await runFish({}))).toEqual(['/elsewhere']) + expect(hostless(await runFish({}))).toEqual([unc('/elsewhere')]) }, 60_000 ) diff --git a/src/shared/wsl-login-shell-command.ts b/src/shared/wsl-login-shell-command.ts index 2fb134c0041..ab2117f19f4 100644 --- a/src/shared/wsl-login-shell-command.ts +++ b/src/shared/wsl-login-shell-command.ts @@ -76,8 +76,14 @@ export function buildWslInteractiveLoginShellCommand(): string { ' fi', ' ;;', // Why: a WSL fish pane gets no shell-ready wrapper or XDG handoff, so this is its only `claude`. + // fish before 2.7 rejects -C and would fail the pane; it starts plain (no claude, no cwd hook). + // fish 2.x prints --version to stderr, so both streams are read. ' fish)', - ` exec "$_orca_wsl_shell" -l -C ${quotePosixShell(getWslFishInitCommand())}`, + ' _orca_fish_version=$("$_orca_wsl_shell" --version 2>&1)', + ' case "${_orca_fish_version##*version }" in', + ' [01].*|2.[0-6]|2.[0-6].*) ;;', + ` *) exec "$_orca_wsl_shell" -l -C ${quotePosixShell(getWslFishInitCommand())} ;;`, + ' esac', ' ;;', ' zsh)', ' if [ -n "${_orca_shell_ready_root:-}" ] && [ -d "${_orca_shell_ready_root}/zsh" ]; then', diff --git a/src/shared/wsl-login-shell-fish-version.test.ts b/src/shared/wsl-login-shell-fish-version.test.ts new file mode 100644 index 00000000000..1a0b72ef40c --- /dev/null +++ b/src/shared/wsl-login-shell-fish-version.test.ts @@ -0,0 +1,73 @@ +import { execFileSync } from 'node:child_process' +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it } from 'vitest' +import { buildWslInteractiveLoginShellCommand } from './wsl-login-shell-command' + +// A stub fish that reports a version and echoes the argv it was started with. +const root = mkdtempSync(join(tmpdir(), 'orca-wsl-fish-version-')) +const tools = join(root, 'tools') +const fish = join(root, 'bin', 'fish') +mkdirSync(tools) +mkdirSync(join(root, 'bin')) +writeFileSync( + join(tools, 'getent'), + `#!/bin/sh\nprintf '%s\\n' "user:x:1000:1000::/home/user:${fish}"\n` +) +writeFileSync( + fish, + [ + '#!/bin/sh', + 'if [ "$1" = --version ]; then', + ' [ -n "$ORCA_TEST_FISH_VERSION" ] || exit 1', + // fish 2.x writes its version to stderr. + ' if [ -n "$ORCA_TEST_FISH_VERSION_ON_STDERR" ]; then', + ' printf "%s\\n" "$ORCA_TEST_FISH_VERSION" >&2; exit 0', + ' fi', + ' printf "%s\\n" "$ORCA_TEST_FISH_VERSION"; exit 0', + 'fi', + 'printf "%s\\n" "$@"', + '' + ].join('\n') +) +chmodSync(join(tools, 'getent'), 0o755) +chmodSync(fish, 0o755) +afterAll(() => rmSync(root, { recursive: true, force: true })) + +function startFlags(version: string, onStderr = false): string[] { + const argv = execFileSync('/bin/sh', ['-c', buildWslInteractiveLoginShellCommand()], { + encoding: 'utf8', + env: { + PATH: `${tools}:/usr/bin:/bin`, + ORCA_TEST_FISH_VERSION: version, + ...(onStderr ? { ORCA_TEST_FISH_VERSION_ON_STDERR: '1' } : {}) + } + }) + return argv.split('\n').filter((arg) => arg === '-l' || arg === '-C') +} + +// fish before 2.7 rejects -C, which failed the whole bridge pane (Ubuntu 16.04, Debian 9). +describe.skipIf(process.platform === 'win32')('a WSL bridge fish pane by fish version', () => { + it.each(['fish, version 1.23.1', 'fish, version 2.2.0', 'fish, version 2.6.0'])( + '%s starts plain, without -C', + (version) => { + expect(startFlags(version)).toEqual(['-l']) + } + ) + + it('reads a fish 2.x version printed to stderr and starts plain', () => { + expect(startFlags('fish, version 2.3.1', true)).toEqual(['-l']) + }) + + it.each([ + 'fish, version 2.7.1', + 'fish, version 3.1.2', + 'fish, version 4.7.1', + 'fish, version 3.7.1-1234-gabcdef', + // An unreadable version keeps today's launch. + '' + ])('%s starts with the -C init command', (version) => { + expect(startFlags(version)).toEqual(['-l', '-C']) + }) +})