fix(pty): load omp status extension in wsl shells (#7642)

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
Rod Boev 2026-07-08 00:24:08 -04:00 committed by GitHub
parent 2ce77d9098
commit a09a00f066
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 148 additions and 31 deletions

View File

@ -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 <help@stably.ai>')
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<string, string>
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<string, string>
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

View File

@ -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

View File

@ -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 = {

View File

@ -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/<drive>/... for wsl.exe', () => {
const result = resolveWindowsShellLaunchArgs(
'wsl.exe',

View File

@ -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"',

View File

@ -23,6 +23,7 @@ describe('addOrcaWslInteropEnv', () => {
it('marks OMP status and hook env for Windows to WSL import', () => {
const env: Record<string, string> = {
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')

View File

@ -16,10 +16,10 @@ function upsertWslenvEntry(entries: string[], entry: string): void {
export function addOrcaWslInteropEnv(env: Record<string, string>): 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',

View File

@ -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)
})
})

View File

@ -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')
}