mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
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:
+3
-26
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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 } = {}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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])(\/.*)?$/)
|
||||
|
||||
Reference in New Issue
Block a user