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:
parent
2ce77d9098
commit
a09a00f066
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue