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.
This commit is contained in:
Neil
2026-10-28 19:31:54 -07:00
committed by GitHub
parent 9dd6f26a6e
commit 005da54a15
3 changed files with 106 additions and 7 deletions
@@ -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://<host>/…) 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
)
+7 -1
View File
@@ -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',
@@ -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'])
})
})