diff --git a/src/main/wsl.ts b/src/main/wsl.ts index 31c1c9a8a5c..e3b783cc4b8 100644 --- a/src/main/wsl.ts +++ b/src/main/wsl.ts @@ -8,7 +8,9 @@ import { dropStaleWslAvailabilityFailure } from './wsl-availability' -export { toWindowsWslPath } from '../shared/wsl-paths' +// Why re-exported rather than defined here: the relay bundle needs the path +// conversion without this module's distro-probing subprocess graph. +export { toLinuxPath, toWindowsWslPath } from '../shared/wsl-paths' export { getCachedWslAvailability, hasCachedWslAvailability, @@ -117,31 +119,6 @@ export function wslUncDirectoryExistsAsync(uncPath: string): Promise() diff --git a/src/relay/pty-handler-spawn-environment.test.ts b/src/relay/pty-handler-spawn-environment.test.ts index 096531c6b80..45dfb9c5f8d 100644 --- a/src/relay/pty-handler-spawn-environment.test.ts +++ b/src/relay/pty-handler-spawn-environment.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { resolveSetupAgentSequenceLaunchCommand, @@ -200,6 +200,69 @@ describe('PtyHandler', () => { }) }) + describe('history isolation for a Windows relay launching WSL', () => { + const wslWorktreeId = 'r::/remote/wsl-worktree' + const wslHistoryFile = join( + homedir(), + '.orca-remote', + 'terminal-history', + `${hashWorktreeId(wslWorktreeId)}-bash_history` + ) + let previousPlatform: PropertyDescriptor | undefined + + beforeEach(() => { + previousPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + }) + + afterEach(() => { + if (previousPlatform) { + Object.defineProperty(process, 'platform', previousPlatform) + } + rmSync(wslHistoryFile, { force: true }) + }) + + const spawnWslPane = (): Promise => + dispatcher.callRequest('pty.spawn', { + cols: 80, + rows: 24, + shellOverride: 'wsl.exe', + terminalWindowsWslDistro: 'Ubuntu', + worktreeId: wslWorktreeId, + historyIsolationEnabled: true + }) + + it('scopes HISTFILE past the wsl.exe wrapper and carries it over WSLENV', async () => { + await spawnWslPane() + + const spawnEnv = mockPtySpawn.mock.calls.at(-1)?.[2]?.env as Record + expect(spawnEnv.HISTFILE?.endsWith(`${hashWorktreeId(wslWorktreeId)}-bash_history`)).toBe( + true + ) + expect(spawnEnv.WSLENV?.split(':')).toContain('HISTFILE') + }) + + // The injected file lives on the relay host, so the existing host-side + // unlink is the deletion counterpart — no distro-scoped root is involved. + it('deletes the injected file through the ordinary relay history deletion', async () => { + await spawnWslPane() + expect(existsSync(wslHistoryFile)).toBe(true) + + await dispatcher.callRequest('pty.deleteWorktreeHistory', { worktreeId: wslWorktreeId }) + + expect(existsSync(wslHistoryFile)).toBe(false) + }) + + // Guest fish keeps its history inside the distro, where relay deletion cannot + // reach it, so wsl.exe panes intentionally stay on shared fish history. + it('does not mint a fish session for a WSL pane', async () => { + await spawnWslPane() + + const spawnEnv = mockPtySpawn.mock.calls.at(-1)?.[2]?.env as Record + expect(spawnEnv.fish_history).toBeUndefined() + }) + }) + it('guards SSH agent terminals after merging the relay inherited Git config', async () => { const gitConfigKeys = [ 'GIT_CONFIG_COUNT', diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index a4d9770013b..dffa7f31327 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -16,7 +16,8 @@ import { isProcessAlive, listShellProfiles } from './pty-shell-utils' -import { getRelayShellLaunchConfig } from './pty-shell-launch' +import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch' +import { addWslEnvKeys } from '../shared/wsl-env' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../shared/ssh-types' import { shouldUseShellReadyStartupDelivery } from '../shared/codex-startup-delivery' import { buildStartupCommandSubmission } from '../shared/startup-command-submission' @@ -1554,11 +1555,18 @@ export class PtyHandler { const worktreeId = typeof params.worktreeId === 'string' ? params.worktreeId : env?.ORCA_WORKTREE_ID const historyIsolationEnabled = params.historyIsolationEnabled === true + // Deliberately not reached by wsl.exe: a guest fish writes its history file + // inside the distro, where relay deletion cannot reach it (STA-4682). if (historyIsolationEnabled && worktreeId && basename(shell).toLowerCase().startsWith('fish')) { injectRelayFishHistoryEnv(spawnEnv, worktreeId) } + const wslShell = isRelayWslShell(shell) if (historyIsolationEnabled && worktreeId) { - injectRelayHistoryEnv(spawnEnv, worktreeId, shell) + const historyRoot = injectRelayHistoryEnv(spawnEnv, worktreeId, shell, { wsl: wslShell }) + if (wslShell && historyRoot) { + // WSLENV is the only channel that carries a host env var into the guest. + addWslEnvKeys(spawnEnv, ['HISTFILE']) + } } const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(spawnEnv, command) // Why: SSH PTYs bypass main's host-env builder, so apply the guard after the relay merges its authoritative env. @@ -2162,6 +2170,8 @@ export class PtyHandler { ) { injectRelayFishHistoryEnv(spawnEnv, entry.worktreeId) } + // No WSL branch on purpose: revive re-spawns the host default shell rather + // than the entry's stored override, so this env matches the shell it launches. if (historyIsolationEnabled && entry.worktreeId) { injectRelayHistoryEnv(spawnEnv, entry.worktreeId, shell) } diff --git a/src/relay/pty-shell-launch.test.ts b/src/relay/pty-shell-launch.test.ts index d8e5f24baee..b006bacc7bc 100644 --- a/src/relay/pty-shell-launch.test.ts +++ b/src/relay/pty-shell-launch.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { getRelayShellLaunchConfig } from './pty-shell-launch' +import { getRelayShellLaunchConfig, isRelayWslShell } from './pty-shell-launch' const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0 const itWithBash = hasBash ? it : it.skip @@ -69,6 +69,24 @@ function expectFinalZdotdirRestoreContext(content: string) { expect(content).toContain('export ZDOTDIR="$_orca_home"') } +describe('isRelayWslShell', () => { + it.each(['wsl.exe', 'WSL.EXE', 'C:\\Windows\\System32\\wsl.exe', 'wsl'])( + 'recognizes %s on a Windows relay', + (shell) => { + expect(isRelayWslShell(shell, 'win32')).toBe(true) + } + ) + + it.each([ + ['C:\\Program Files\\Git\\bin\\bash.exe', 'win32' as const], + ['/bin/bash', 'win32' as const], + // A POSIX remote reaches no distro, so a like-named binary is an ordinary shell. + ['wsl.exe', 'linux' as const] + ])('does not treat %s on %s as a WSL launch', (shell, platform) => { + expect(isRelayWslShell(shell, platform)).toBe(false) + }) +}) + describe('getRelayShellLaunchConfig', () => { let homeDir: string diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index 1af77e87b7c..5a919c3e333 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -13,6 +13,19 @@ function shellBasename(shellPath: string): string { return shellPath.replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? '' } +/** The outer exe of a WSL launch; the shell the user actually types into lives + * inside the distro, so history/env handling must look past this name. */ +export function isRelayWslShell( + shellPath: string, + platform: NodeJS.Platform = process.platform +): boolean { + if (platform !== 'win32') { + return false + } + const name = shellBasename(shellPath) + return name === 'wsl.exe' || name === 'wsl' +} + function windowsShellArgs( shellName: string, options: { terminalWindowsWslDistro?: string | null } = {} diff --git a/src/relay/terminal-history-wsl.test.ts b/src/relay/terminal-history-wsl.test.ts new file mode 100644 index 00000000000..acb58f8bd69 --- /dev/null +++ b/src/relay/terminal-history-wsl.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' +import type * as NodeOs from 'node:os' +import { hashWorktreeId } from '../main/terminal-history-id' + +// Why mocked rather than a real temp dir: the assertion is the Windows -> Linux +// path conversion, which only happens for a drive-letter root that cannot exist +// on the macOS/Linux runners this suite executes on. +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), + homedir: () => 'C:\\Users\\relay' +})) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const dirStat = { isSymbolicLink: () => false, isDirectory: () => true, isFile: () => false } + const fileStat = { + isSymbolicLink: () => false, + isDirectory: () => false, + isFile: () => true, + dev: 1n, + ino: 2n + } + return { + ...actual, + constants: actual.constants, + mkdirSync: vi.fn(), + lstatSync: vi.fn((path: string) => (path.endsWith('terminal-history') ? dirStat : fileStat)), + openSync: vi.fn(() => 42), + fstatSync: vi.fn(() => fileStat), + closeSync: vi.fn(), + unlinkSync: vi.fn() + } +}) + +const worktreeId = 'relay-wsl::/remote/worktree' + +describe('relay WSL shell history', () => { + it('hands the guest a drvfs path for the host history file', async () => { + const { injectRelayHistoryEnv } = await import('./terminal-history') + const env: Record = {} + + const root = injectRelayHistoryEnv(env, worktreeId, 'C:\\Windows\\System32\\wsl.exe', { + wsl: true + }) + + expect(root).toBe('C:\\Users\\relay/.orca-remote/terminal-history') + expect(env.HISTFILE).toBe( + `/mnt/c/Users/relay/.orca-remote/terminal-history/${hashWorktreeId(worktreeId)}-bash_history` + ) + expect(env.ORCA_HISTFILE).toBe(env.HISTFILE) + }) + + it('leaves a host shell on the untranslated host path', async () => { + const { injectRelayHistoryEnv } = await import('./terminal-history') + const env: Record = {} + + injectRelayHistoryEnv(env, worktreeId, '/bin/bash') + + expect(env.HISTFILE).toBe( + `C:\\Users\\relay/.orca-remote/terminal-history/${hashWorktreeId(worktreeId)}-bash_history` + ) + }) + + it('scopes nothing for wsl.exe when the caller does not flag it as WSL', async () => { + const { injectRelayHistoryEnv } = await import('./terminal-history') + const env: Record = {} + + expect(injectRelayHistoryEnv(env, worktreeId, 'wsl.exe')).toBeNull() + expect(env.HISTFILE).toBeUndefined() + }) +}) diff --git a/src/relay/terminal-history.ts b/src/relay/terminal-history.ts index 774f1e2959f..254b3c235e7 100644 --- a/src/relay/terminal-history.ts +++ b/src/relay/terminal-history.ts @@ -9,6 +9,7 @@ import { } from 'node:fs' import { homedir } from 'node:os' import { basename, join } from 'node:path' +import { toLinuxPath } from '../shared/wsl-paths' import { hashWorktreeId } from '../main/terminal-history-id' import { deleteFishHistoryFile, @@ -33,7 +34,8 @@ function historyFilename(shell: string): string | null { export function injectRelayHistoryEnv( env: Record, worktreeId: string, - shell: string + shell: string, + options: { wsl?: boolean } = {} ): string | null { // Why first: same reason as the desktop path — an inherited ORCA_HISTFILE // would otherwise survive every early return below and let the remote wrapper @@ -42,7 +44,9 @@ export function injectRelayHistoryEnv( if (env.HISTFILE) { return null } - const filename = historyFilename(shell) + // WSL's outer exe is wsl.exe, which matches no shell name; the guest login + // shell reads HISTFILE regardless, so pick the file the desktop path picks. + const filename = historyFilename(options.wsl ? 'bash' : shell) if (!filename) { return null } @@ -81,12 +85,15 @@ export function injectRelayHistoryEnv( return null } closeSync(fd) - env.HISTFILE = path + // Why the same host file rather than a distro-scoped one: the guest reaches + // it over drvfs, so `deleteRelayHistory` still reclaims it by host path. + env.HISTFILE = options.wsl ? toLinuxPath(path) : path // Why a second variable: a remote macOS `/etc/zshrc` assigns HISTFILE // unconditionally before the wrapper runs, so the injected value is gone by // the first prompt. The wrapper restores it from here (#11044) — the same - // contract the desktop PTY path uses. - env.ORCA_HISTFILE = path + // contract the desktop PTY path uses. Under WSL it holds the guest-visible + // path and stays out of WSLENV, matching the desktop; no wrapper reads it there. + env.ORCA_HISTFILE = env.HISTFILE return HISTORY_ROOT } catch { return null diff --git a/src/shared/wsl-paths.ts b/src/shared/wsl-paths.ts index f8acb9d69d6..f779bc2d2ff 100644 --- a/src/shared/wsl-paths.ts +++ b/src/shared/wsl-paths.ts @@ -20,6 +20,33 @@ export function isWslUncPath(path: string): boolean { return parseWslUncPath(path) !== null } +/** + * Convert a Windows path to a Linux path for commands that will execute inside WSL. + * Returns the path unchanged if it is already POSIX-style. + * + * Why: WSL hook/setup environments may need both the worktree UNC path + * (\\wsl.localhost\...) and regular Windows install paths (C:\Users\...) + * translated before passing them to bash. Leaving drive paths untouched + * breaks scripts that read ORCA_ROOT_PATH or similar env vars inside WSL. + */ +export function toLinuxPath(windowsPath: string): string { + // Why the platform guard: on a POSIX host a literal `//wsl$/x` path is an + // ordinary directory, not a distro mount, so it must survive unchanged. + const info = process.platform === 'win32' ? parseWslUncPath(windowsPath) : null + if (info) { + return info.linuxPath + } + + const driveMatch = windowsPath.match(/^([A-Za-z]):[/\\](.*)$/) + if (!driveMatch) { + return windowsPath + } + + const driveLetter = driveMatch[1].toLowerCase() + const rest = driveMatch[2].replace(/\\/g, '/') + return `/mnt/${driveLetter}/${rest}` +} + /** Convert an absolute Linux path in a known WSL distro to its Windows form. */ export function toWindowsWslPath(linuxPath: string, distro: string): string { const mntMatch = linuxPath.match(/^\/mnt\/([a-z])(\/.*)?$/)