diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 172114ce6..d54fd3162 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -18,11 +18,13 @@ const expectedOmpStatusExtension = posix.join( 'extensions', 'orca-agent-status.ts' ) -const expectedAttributionShimDir = join( - '/tmp/orca-user-data', - 'orca-terminal-attribution', - isWindowsHost ? 'win32' : 'posix' -) +function expectedAttributionShimDir(): string { + return join( + '/tmp/orca-user-data', + 'orca-terminal-attribution', + process.platform === 'win32' ? 'win32' : 'posix' + ) +} const { handleMock, @@ -1152,7 +1154,7 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_GIT_COMMIT_TRAILER).toBe('Co-authored-by: Orca ') expect(env.ORCA_GH_PR_FOOTER).toBe('Made with [Orca](https://github.com/stablyai/orca) 🐋') expect(env.ORCA_GH_ISSUE_FOOTER).toBe('Made with [Orca](https://github.com/stablyai/orca) 🐋') - expect(env.PATH).toContain(expectedAttributionShimDir) + expect(env.PATH).toContain(expectedAttributionShimDir()) }) it('skips git/gh attribution shims when attribution is disabled', async () => { @@ -1164,7 +1166,7 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_GIT_COMMIT_TRAILER).toBeUndefined() expect(env.ORCA_GH_PR_FOOTER).toBeUndefined() expect(env.ORCA_GH_ISSUE_FOOTER).toBeUndefined() - expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir) + expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir()) }) it('prepends git/gh attribution shims for daemon-backed local PTYs', async () => { @@ -1193,7 +1195,7 @@ describe('registerPtyHandlers', () => { const env = daemonSpawn.mock.calls.at(-1)![0].env expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1') - expect(env.PATH).toContain(expectedAttributionShimDir) + expect(env.PATH).toContain(expectedAttributionShimDir()) }) it('overrides ambient CODEX_HOME with the Orca-managed home for system default', async () => { @@ -1799,7 +1801,7 @@ describe('registerPtyHandlers', () => { const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin') - expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir) + expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir()) expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined() expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() expect(spawnOptions.envToDelete).toEqual( @@ -1829,7 +1831,7 @@ describe('registerPtyHandlers', () => { enableGitHubAttribution: true })) expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBe('1') - expect(env.PATH).toContain(expectedAttributionShimDir) + expect(env.PATH).toContain(expectedAttributionShimDir()) }) it('keeps the Agent Teams tmux shim ahead of host PATH shims on daemon pty:spawn', async () => { @@ -1850,7 +1852,7 @@ describe('registerPtyHandlers', () => { ) expect(spawnOptions.env.PATH.split(delimiter)[0]).toBe('/tmp/orca-agent-teams-bin') - expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir) + expect(spawnOptions.env.PATH).toContain(expectedAttributionShimDir()) expect(spawnOptions.env.TERM_PROGRAM).toBeUndefined() expect(spawnOptions.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() expect(spawnOptions.envToDelete).toEqual( @@ -1967,7 +1969,7 @@ describe('registerPtyHandlers', () => { enableGitHubAttribution: false })) expect(env.ORCA_ENABLE_GIT_ATTRIBUTION).toBeUndefined() - expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir) + expect(env.PATH ?? '').not.toContain(expectedAttributionShimDir()) }) it('does not mutate the caller-provided args.env on the daemon path', async () => { @@ -5810,9 +5812,11 @@ describe('registerPtyHandlers', () => { const env = spawnCall[2].env as Record expect(spawnCall[0]).toBe('wsl.exe') expect(env.ORCA_TERMINAL_HANDLE).toBe('term_wsl') + expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data') expect(env.WSLENV?.split(':')).toEqual( expect.arrayContaining([ 'ORCA_TERMINAL_HANDLE/u', + 'ORCA_USER_DATA_PATH/p', 'ORCA_AGENT_HOOK_PORT/u', 'ORCA_AGENT_HOOK_TOKEN/u', 'ORCA_OMP_SOURCE_AGENT_DIR/p', @@ -5822,6 +5826,42 @@ describe('registerPtyHandlers', () => { ) }) + it('forces managed ORCA_USER_DATA_PATH for WSL spawns even when the caller provides a stale root', async () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32' + }) + const runtime = { + setPtyController: vi.fn(), + preAllocateHandleForPty: vi.fn(() => 'term_wsl'), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + + try { + registerPtyHandlers(mainWindow as never, runtime as never) + await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + shellOverride: 'wsl.exe', + env: { + ORCA_USER_DATA_PATH: '/tmp/stale-orca-user-data' + } + }) + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + + const spawnCall = spawnMock.mock.calls.at(-1)! + const env = spawnCall[2].env as Record + expect(spawnCall[0]).toBe('wsl.exe') + expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data') + }) + describe('Windows UTF-8 code page', () => { let originalPlatform: string let originalComspec: string | undefined diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index f63b43719..bdd4cc13b 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -909,15 +909,14 @@ export function buildPtyHostEnv( baseEnv.ORCA_CODEX_HOME = opts.selectedCodexHomePath } - // Why: in dev mode the `orca` CLI defaults to the production userData - // path, which routes status updates to the packaged Orca instead of this - // dev instance. Injecting ORCA_USER_DATA_PATH ensures CLI calls from - // agents running inside dev terminals reach the correct runtime. We also - // prepend the dev CLI launcher directory to PATH so `orca` resolves to - // the dev build (which supports ORCA_USER_DATA_PATH) instead of the - // production binary at /usr/local/bin/orca. - if (!opts.isPackaged) { + // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. + if (opts.isWsl) { + baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath + } else if (!opts.isPackaged) { baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath + } + // Why: dev mode needs the launcher PATH override so `orca` resolves to the dev build instead of the production binary at /usr/local/bin/orca. + if (!opts.isPackaged) { const devCliBin = join(opts.userDataPath, 'cli', 'bin') const inheritedPath = readInheritedPath(baseEnv) // Why: avoid a trailing delimiter when PATH is empty — some shells diff --git a/src/main/providers/local-pty-shell-ready.ts b/src/main/providers/local-pty-shell-ready.ts index 978d3e94b..99d52002d 100644 --- a/src/main/providers/local-pty-shell-ready.ts +++ b/src/main/providers/local-pty-shell-ready.ts @@ -50,6 +50,10 @@ export type ShellReadySignal = { // ── Shell wrapper files ───────────────────────────────────────────── function getShellReadyWrapperRoot(): string { + // Why: this instance's userData must win over an inherited + // ORCA_USER_DATA_PATH (Orca launched from another Orca's terminal), so the + // wrapper writer root always matches the root buildPtyHostEnv hands to + // WSL children. const userDataPath = app?.getPath?.('userData') ?? process.env.ORCA_USER_DATA_PATH ?? tmpdir() return `${userDataPath}/shell-ready` } @@ -64,8 +68,8 @@ function getRequiredShellReadyWrapperPaths(root = getShellReadyWrapperRoot()): s ] } -function shellReadyWrappersExist(): boolean { - return getRequiredShellReadyWrapperPaths().every((path) => existsSync(path)) +function shellReadyWrappersExist(root = getShellReadyWrapperRoot()): boolean { + return getRequiredShellReadyWrapperPaths(root).every((path) => existsSync(path)) } // Why: if our own process inherited ZDOTDIR from a parent shell that was @@ -288,16 +292,12 @@ fi ` } -function ensureShellReadyWrappers(): void { - if (process.platform === 'win32') { - return - } - if (didEnsureShellReadyWrappers && shellReadyWrappersExist()) { +export function ensureShellReadyWrappersAt(root = getShellReadyWrapperRoot()): void { + if (didEnsureShellReadyWrappers && shellReadyWrappersExist(root)) { return } didEnsureShellReadyWrappers = true - const root = getShellReadyWrapperRoot() const zshDir = `${root}/zsh` const bashDir = `${root}/bash` @@ -365,6 +365,13 @@ ${getZshFinalZdotdirRestoreBlock()} } } +function ensureShellReadyWrappers(): void { + if (process.platform === 'win32') { + return + } + ensureShellReadyWrappersAt() +} + // ── Shell launch config ───────────────────────────────────────────── export type ShellReadyLaunchConfig = { diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index d68f4014b..7d0eeb5b4 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { encodePowerShellCommand, getPowerShellOsc133Bootstrap @@ -17,6 +20,24 @@ function expectedWslArgs(linuxCwd: string, distro?: string): string[] { } describe('resolveWindowsShellLaunchArgs', () => { + let previousUserDataPath: string | undefined + let userDataPath: string + + beforeEach(() => { + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + userDataPath = mkdtempSync(join(tmpdir(), 'windows-shell-args-test-')) + process.env.ORCA_USER_DATA_PATH = userDataPath + }) + + afterEach(() => { + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + rmSync(userDataPath, { recursive: true, force: true }) + }) + it('returns cmd.exe args with chcp 65001 for UTF-8 output', () => { const result = resolveWindowsShellLaunchArgs('cmd.exe', 'C:\\Users\\alice', 'C:\\Users\\alice') expect(result.shellArgs).toEqual(['/K', 'chcp 65001 > nul']) @@ -201,6 +222,27 @@ describe('resolveWindowsShellLaunchArgs', () => { expect(result.validationCwd).toBe('C:\\Users\\alice\\code') }) + it('materializes shell-ready wrappers before building WSL shell args', () => { + const result = resolveWindowsShellLaunchArgs( + 'wsl.exe', + 'C:\\Users\\alice\\code', + 'C:\\Users\\alice' + ) + + expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/code')) + expect(existsSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'))).toBe(true) + expect(existsSync(join(userDataPath, 'shell-ready', 'zsh', '.zshenv'))).toBe(true) + + // Why: the point of materializing wrappers for WSL is that a typed `omp` + // picks up Orca's status extension; pin that shim end to end. + const bashRcfile = readFileSync(join(userDataPath, 'shell-ready', 'bash', 'rcfile'), 'utf8') + const zshLogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8') + for (const wrapperFile of [bashRcfile, zshLogin]) { + expect(wrapperFile).toContain('command omp --extension "${ORCA_OMP_STATUS_EXTENSION}" "$@"') + expect(wrapperFile).toContain('omp() { __orca_omp "$@"; }') + } + }) + it('translates MSYS drive cwd to /mnt//... for wsl.exe', () => { const result = resolveWindowsShellLaunchArgs( 'wsl.exe', diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 98b119d85..3889edfc5 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -6,6 +6,7 @@ import { escapeWslShCommandForWindows, quotePosixShell } from '../../shared/wsl-login-shell-command' +import { ensureShellReadyWrappersAt } from './local-pty-shell-ready' import { encodePowerShellCommand, getPowerShellOsc133Bootstrap @@ -94,6 +95,7 @@ function getPowerShellEncodedCommand(startupCommand?: string): { * Builds wsl.exe arguments that enter the target directory through the distro shell. */ function buildWslShellArgs(linuxCwd: string, distro?: string): string[] { + ensureShellReadyWrappersAt() const setupCommand = [ `cd ${quotePosixShell(linuxCwd)}`, 'export PATH="$HOME/.local/bin:$PATH"', diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index ec210e5db..9d7c36c6c 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -23,6 +23,7 @@ describe('addOrcaWslInteropEnv', () => { it('marks OMP status and hook env for Windows to WSL import', () => { const env: Record = { ORCA_TERMINAL_HANDLE: 'term_wsl', + ORCA_USER_DATA_PATH: 'C:\\Users\\jin\\AppData\\Roaming\\Orca', ORCA_OMP_STATUS_EXTENSION: 'C:\\Users\\jin\\.omp\\agent\\extensions\\orca-agent-status.ts', ORCA_PANE_KEY: 'tab-1:leaf-1', ORCA_TAB_ID: 'tab-1', @@ -36,6 +37,7 @@ describe('addOrcaWslInteropEnv', () => { addOrcaWslInteropEnv(env) expect(env.WSLENV).toContain('ORCA_TERMINAL_HANDLE/u') + expect(env.WSLENV).toContain('ORCA_USER_DATA_PATH/p') expect(env.WSLENV).toContain('ORCA_OMP_STATUS_EXTENSION/p') expect(env.WSLENV).toContain('ORCA_PANE_KEY/u') expect(env.WSLENV).toContain('ORCA_TAB_ID/u') diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 2e4a878f5..40a694a87 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -16,10 +16,10 @@ function upsertWslenvEntry(entries: string[], entry: string): void { export function addOrcaWslInteropEnv(env: Record): void { const entries = parseWslenvEntries(env.WSLENV) - // Why: wsl.exe only imports selected Windows env vars. Agent status in WSL - // needs both the pane identity and the hook/OMP coordinates at process start. + // Why: wsl.exe only imports selected Windows env vars, so WSL needs the wrapper root, pane identity, and hook/OMP coordinates at start. const passthroughEntries = [ 'ORCA_TERMINAL_HANDLE/u', + 'ORCA_USER_DATA_PATH/p', 'ORCA_PANE_KEY/u', 'ORCA_TAB_ID/u', 'ORCA_WORKTREE_ID/u', diff --git a/src/shared/wsl-login-shell-command.test.ts b/src/shared/wsl-login-shell-command.test.ts index 925937c88..7cc304771 100644 --- a/src/shared/wsl-login-shell-command.test.ts +++ b/src/shared/wsl-login-shell-command.test.ts @@ -117,6 +117,14 @@ describe('wsl login shell command helpers', () => { expect(command).toContain('getent passwd') expect(command).toContain('if [ -z "$_orca_wsl_shell" ] || [ ! -x "$_orca_wsl_shell" ]; then') + expect(command).toContain('_orca_shell_ready_root=""') + expect(command).toContain('if [ -n "${ORCA_USER_DATA_PATH:-}" ]; then') + expect(command).toContain('_orca_wsl_shell_name=$(basename "$_orca_wsl_shell"') + expect(command).toContain('bash)') + expect(command).toContain('--rcfile "${_orca_shell_ready_root}/bash/rcfile"') + expect(command).toContain('zsh)') + expect(command).toContain('export ZDOTDIR="${_orca_shell_ready_root}/zsh"') expect(command).toContain('exec "$_orca_wsl_shell" -l') + expectValidShSyntax(command) }) }) diff --git a/src/shared/wsl-login-shell-command.ts b/src/shared/wsl-login-shell-command.ts index 45356f402..d95939eaa 100644 --- a/src/shared/wsl-login-shell-command.ts +++ b/src/shared/wsl-login-shell-command.ts @@ -45,6 +45,23 @@ export function buildWslInteractiveLoginShellCommand(): string { 'if [ -z "$_orca_wsl_shell" ] || [ ! -x "$_orca_wsl_shell" ]; then', ' _orca_wsl_shell=/bin/sh', 'fi', + '_orca_shell_ready_root=""', + 'if [ -n "${ORCA_USER_DATA_PATH:-}" ]; then', + ' _orca_shell_ready_root="${ORCA_USER_DATA_PATH%/}/shell-ready"', + 'fi', + '_orca_wsl_shell_name=$(basename "$_orca_wsl_shell" | tr "[:upper:]" "[:lower:]")', + 'case "$_orca_wsl_shell_name" in', + ' bash)', + ' if [ -n "${_orca_shell_ready_root:-}" ] && [ -f "${_orca_shell_ready_root}/bash/rcfile" ]; then', + ' exec "$_orca_wsl_shell" --rcfile "${_orca_shell_ready_root}/bash/rcfile"', + ' fi', + ' ;;', + ' zsh)', + ' if [ -n "${_orca_shell_ready_root:-}" ] && [ -d "${_orca_shell_ready_root}/zsh" ]; then', + ' export ZDOTDIR="${_orca_shell_ready_root}/zsh"', + ' fi', + ' ;;', + 'esac', 'exec "$_orca_wsl_shell" -l' ].join('\n') }