fix(relay): scope shell history for Windows -> WSL panes (STA-4682)

`injectRelayHistoryEnv` matched only bash*/zsh*, so a relay pane launched
through `wsl.exe` got no HISTFILE at all and every WSL worktree shared one
global history.

The history file stays on the relay host under the existing flat root, so
`deleteRelayHistory` remains the deletion counterpart unchanged; only the
exported path is translated to drvfs for the guest, and WSLENV carries it
across the boundary.

Guest fish stays out of scope on purpose: its history file lives inside the
distro, where the relay has no deletion path.
This commit is contained in:
Neil
2026-08-17 21:44:16 -07:00
parent 598ba5d276
commit ebe728da54
8 changed files with 222 additions and 35 deletions
+3 -26
View File
@@ -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<boolean | n
})
}
/**
* 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 {
const info = parseWslPath(windowsPath)
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}`
}
// ─── WSL home directory resolution ──────────────────────────────────
const wslHomeCache = new Map<string, string>()
@@ -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<unknown> =>
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<string, string>
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<string, string>
expect(spawnEnv.fish_history).toBeUndefined()
})
})
it('guards SSH agent terminals after merging the relay inherited Git config', async () => {
const gitConfigKeys = [
'GIT_CONFIG_COUNT',
+12 -2
View File
@@ -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)
}
+19 -1
View File
@@ -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
+13
View File
@@ -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 } = {}
+72
View File
@@ -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<typeof NodeOs>()),
homedir: () => 'C:\\Users\\relay'
}))
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFs>()
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<string, string> = {}
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<string, string> = {}
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<string, string> = {}
expect(injectRelayHistoryEnv(env, worktreeId, 'wsl.exe')).toBeNull()
expect(env.HISTFILE).toBeUndefined()
})
})
+12 -5
View File
@@ -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<string, string>,
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
+27
View File
@@ -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])(\/.*)?$/)