Fix orchestration mail from WSL and SSH terminals (#3918)

This commit is contained in:
Jinwoo Hong 2026-05-31 15:13:15 -04:00 committed by GitHub
parent ce552c4a26
commit 4cc0147005
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1205 additions and 77 deletions

View File

@ -9,7 +9,9 @@ vi.mock('node:child_process', () => ({
import { WslCliInstaller, _internals } from './wsl-cli-installer'
function makeHostStatus(launcherPath = 'C:\\Users\\me\\AppData\\Local\\Orca\\bin\\orca.cmd') {
function makeHostStatus(
launcherPath = 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd'
) {
return {
platform: 'win32',
commandName: 'orca',
@ -40,9 +42,6 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin
if (command.includes('printf %s "$HOME"')) {
return '/home/alice'
}
if (command.includes('command -v powershell.exe')) {
return 'yes'
}
if (command.includes('case ":$PATH:"')) {
return pathIncludesLocalBin ? 'yes' : 'no'
}
@ -57,6 +56,9 @@ function createWslRunner(initialFile: string | null = null, pathIncludesLocalBin
files.set(bridgePath, bridge)
return ''
}
if (command.includes('command -v powershell.exe')) {
return 'yes'
}
if (command.includes('rm -f')) {
if (
files.has(bridgePath) &&
@ -114,11 +116,11 @@ describe('WslCliInstaller', () => {
expect(installed).toMatchObject({
state: 'installed',
pathConfigured: true,
launcherPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\bin\\orca.cmd'
launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd'
})
expect(wsl.getFile()).toBe(
_internals.buildWslLauncher(
'C:\\Users\\me\\AppData\\Local\\Orca\\bin\\orca.cmd',
'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd',
'/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
)
)
@ -195,7 +197,12 @@ describe('WslCliInstaller', () => {
)
const bridge = _internals.buildWslBridgeScript()
expect(launcher).toContain('powershell.exe -NoProfile -ExecutionPolicy Bypass -File')
expect(launcher).toContain('command -v powershell.exe')
expect(launcher).toContain('/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe')
expect(launcher).toContain(
'Orca WSL CLI requires Windows interop and could not find powershell.exe.'
)
expect(launcher).toContain('"$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File')
expect(launcher).toContain('"$ORCA_WIN_LAUNCHER" "$@"')
expect(launcher).not.toContain('-Command')
expect(bridge).toContain('[Parameter(ValueFromRemainingArguments=$true)]')
@ -223,6 +230,52 @@ describe('WslCliInstaller', () => {
expect(Buffer.from(encoded as string, 'base64').toString('utf8')).toBe(command)
})
it('treats absolute Windows PowerShell as interop-ready when powershell.exe is missing from PATH', async () => {
const wsl = createWslRunner()
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus() },
wslRunner: async (distro, command) => {
if (command.includes('command -v powershell.exe') && !command.includes('cat >')) {
expect(command).toContain('/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe')
return 'yes'
}
return wsl.runner(distro, command)
}
})
await expect(installer.getStatus()).resolves.toMatchObject({
state: 'not_installed',
commandPath: '/home/alice/.local/bin/orca-ide'
})
})
it('marks stale managed launchers that point at the old app bin instead of packaged resources', async () => {
const oldLauncher = _internals.buildWslLauncher(
'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin\\orca.cmd',
'/home/alice/.local/share/orca/orca-wsl-bridge.ps1'
)
const wsl = createWslRunner(oldLauncher)
const installer = new WslCliInstaller({
platform: 'win32',
distro: 'Ubuntu',
hostInstaller: { getStatus: async () => makeHostStatus() },
wslRunner: wsl.runner
})
await expect(installer.getStatus()).resolves.toMatchObject({
state: 'stale',
currentTarget: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\bin\\orca.cmd',
launcherPath: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd'
})
await expect(installer.install()).resolves.toMatchObject({
state: 'installed',
currentTarget: 'C:\\Users\\me\\AppData\\Local\\Programs\\Orca\\resources\\bin\\orca.cmd'
})
})
it('settles when wsl.exe never reports completion', async () => {
vi.useFakeTimers()
const killMock = vi.fn()

View File

@ -234,7 +234,7 @@ export class WslCliInstaller {
(
await this.run(
this.distro,
'command -v powershell.exe >/dev/null 2>&1 && command -v wslpath >/dev/null 2>&1 && printf yes || printf no'
'{ command -v powershell.exe >/dev/null 2>&1 || [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; } && command -v wslpath >/dev/null 2>&1 && printf yes || printf no'
)
).trim() === 'yes'
if (!interopReady) {

View File

@ -12,8 +12,16 @@ ${MANAGED_MARKER}
# ORCA_WIN_LAUNCHER_B64=${encodedTarget}
ORCA_WIN_LAUNCHER=${quoteShell(windowsLauncherPath)}
ORCA_BRIDGE_PS1=${quoteShell(bridgePath)}
if command -v powershell.exe >/dev/null 2>&1; then
ORCA_POWERSHELL=powershell.exe
elif [ -x /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe ]; then
ORCA_POWERSHELL=/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe
else
echo "Orca WSL CLI requires Windows interop and could not find powershell.exe." >&2
exit 1
fi
ORCA_BRIDGE_PS1_WIN=$(wslpath -w "$ORCA_BRIDGE_PS1")
exec powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" "$@"
exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" "$@"
`
}

View File

@ -925,7 +925,12 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
['--', 'bash', '-c', `cd '${expectedLinuxCwd}' && exec bash -l`],
[
'--',
'bash',
'-c',
`cd '${expectedLinuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l`
],
expect.objectContaining({ cwd: expect.any(String) })
)
})
@ -962,7 +967,14 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Debian', '--', 'bash', '-c', `cd '${expectedLinuxCwd}' && exec bash -l`],
[
'-d',
'Debian',
'--',
'bash',
'-c',
`cd '${expectedLinuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l`
],
expect.objectContaining({ cwd: expect.any(String) })
)
})
@ -990,7 +1002,14 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/repo' && exec bash -l"],
[
'-d',
'Ubuntu',
'--',
'bash',
'-c',
'cd \'/home/jin/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
],
expect.objectContaining({ cwd: expect.any(String) })
)
})
@ -1018,7 +1037,14 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/repo' && exec bash -l"],
[
'-d',
'Ubuntu',
'--',
'bash',
'-c',
'cd \'/home/jin/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
],
expect.objectContaining({
env: expect.not.objectContaining({
CODEX_HOME: expect.anything(),
@ -1103,7 +1129,14 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--', 'bash', '-c', `cd '${expectedLinuxCwd}' && exec bash -l`],
[
'-d',
'Ubuntu',
'--',
'bash',
'-c',
`cd '${expectedLinuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l`
],
expect.objectContaining({
env: expect.objectContaining({
CODEX_HOME: '/home/jin/.local/share/orca/codex-accounts/a/home',
@ -1137,13 +1170,56 @@ describe('createPtySubprocess', () => {
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/repo' && exec bash -l"],
[
'-d',
'Ubuntu',
'--',
'bash',
'-c',
'cd \'/home/jin/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
],
expect.objectContaining({
env: expect.objectContaining({ CODEX_HOME: '/home/jin/.codex-alt' })
})
)
})
it('marks Orca terminal handles for WSL env import in daemon WSL terminals', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { value: 'win32' })
try {
createPtySubprocess({
sessionId: 'test',
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo',
env: {
ORCA_TERMINAL_HANDLE: 'term_wsl',
WSLENV: 'FOO/u'
}
})
} finally {
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
expect.any(Array),
expect.objectContaining({
env: expect.objectContaining({
ORCA_TERMINAL_HANDLE: 'term_wsl',
WSLENV: 'FOO/u:ORCA_TERMINAL_HANDLE/u'
})
})
)
})
it('keeps daemon WSL split panes in their distro when cwd is already POSIX', () => {
const proc = mockPtyProcess()
spawnMock.mockReturnValue(proc)
@ -1170,7 +1246,14 @@ describe('createPtySubprocess', () => {
)
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/repo/subdir' && exec bash -l"],
[
'-d',
'Ubuntu',
'--',
'bash',
'-c',
'cd \'/home/jin/repo/subdir\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
],
expect.objectContaining({ cwd: expect.any(String) })
)
})

View File

@ -24,6 +24,7 @@ import { removeInheritedNoColor } from '../pty/terminal-color-env'
import { parseWslPath } from '../wsl'
import { addWslEnvKeys } from '../wsl-env'
import { getWslContextFromSessionId } from './wsl-session-context'
import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../git-bash'
import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell'
@ -333,6 +334,9 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
delete env.CODEX_HOME
delete env.ORCA_CODEX_HOME
}
if (pathWin32.basename(shellPath).toLowerCase() === 'wsl.exe') {
addOrcaWslInteropEnv(env)
}
} else {
// Why: any Orca-injected overlay env that user rc files can clobber
// needs the wrapper so the post-rc restore line runs.

View File

@ -2389,6 +2389,40 @@ describe('registerPtyHandlers', () => {
expect(runtime.preAllocateHandleForPty).toHaveBeenCalledWith(expect.any(String))
})
it('forwards the trusted Orca terminal handle into managed WSL terminals', 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'
})
} 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_TERMINAL_HANDLE).toBe('term_wsl')
expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u')
})
describe('Windows UTF-8 code page', () => {
let originalPlatform: string
let originalComspec: string | undefined

View File

@ -65,6 +65,7 @@ import {
} from '../agent-hooks/migration-unsupported-pty-state'
import { parseWslPath } from '../wsl'
import { mergePersistedWindowsPath } from '../pty/windows-environment-path'
import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection'
import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env'
import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy'
@ -961,6 +962,9 @@ export function registerPtyHandlers(
if (preAllocatedHandle) {
env.ORCA_TERMINAL_HANDLE = preAllocatedHandle
}
if (ctx?.isWsl === true) {
addOrcaWslInteropEnv(env)
}
return env
},
onSpawned: (id) => runtime?.onPtySpawned(id),

View File

@ -47,6 +47,7 @@ const {
dispose: vi.fn(),
isDisposed: vi.fn().mockReturnValue(false),
onNotification: vi.fn(),
onRequest: vi.fn().mockReturnValue(() => {}),
onDispose: vi.fn().mockReturnValue(() => {}),
request: vi.fn().mockResolvedValue({}),
notify: vi.fn()

View File

@ -296,10 +296,34 @@ describe('LocalPtyProvider', () => {
'--',
'bash',
'-c',
"cd '/mnt/c/Users/jin/repo' && exec bash -l"
'cd \'/mnt/c/Users/jin/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
])
})
it('marks Orca terminal handle for WSL import when buildSpawnEnv opts in', async () => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
provider.configure({
buildSpawnEnv: (_id, env, ctx) => {
env.ORCA_TERMINAL_HANDLE = 'term_wsl'
if (ctx?.isWsl) {
env.WSLENV = 'ORCA_TERMINAL_HANDLE/u'
}
return env
}
})
await provider.spawn({
cols: 80,
rows: 24,
cwd: '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo'
})
const spawnCall = spawnMock.mock.calls.at(-1)!
expect(spawnCall[0]).toBe('wsl.exe')
expect(spawnCall[2].env.ORCA_TERMINAL_HANDLE).toBe('term_wsl')
expect(spawnCall[2].env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u')
})
it('does not inherit parent Orca pane identity when caller omits pane env', async () => {
const saved = {
ORCA_PANE_KEY: process.env.ORCA_PANE_KEY,
@ -425,7 +449,14 @@ describe('LocalPtyProvider', () => {
expect(spawnMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/repo/subdir' && exec bash -l"],
[
'-d',
'Ubuntu',
'--',
'bash',
'-c',
'cd \'/home/jin/repo/subdir\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
],
expect.objectContaining({ cwd: expect.any(String) })
)
})

View File

@ -213,11 +213,11 @@ export class LocalPtyProvider implements IPtyProvider {
| ((shell: string) => ReturnType<typeof getShellReadyLaunchConfig>)
| undefined
if (wslInfo) {
const escapedCwd = wslInfo.linuxPath.replace(/'/g, "'\\''")
shellPath = 'wsl.exe'
shellArgs = ['-d', wslInfo.distro, '--', 'bash', '-c', `cd '${escapedCwd}' && exec bash -l`]
effectiveCwd = getDefaultCwd()
validationCwd = cwd
const resolved = resolveWindowsShellLaunchArgs(shellPath, cwd, defaultCwd)
shellArgs = resolved.shellArgs
effectiveCwd = resolved.effectiveCwd
validationCwd = resolved.validationCwd
} else if (process.platform === 'win32') {
// Why: shellOverride lets a single tab open in a different shell than the
// persisted default (e.g. "New WSL terminal" from the "+" submenu) without

View File

@ -66,6 +66,65 @@ describe('SshPtyProvider', () => {
})
})
it('injects the relay-backed Orca CLI bridge into remote PTY env', async () => {
mux.request.mockResolvedValue({ id: 'pty-bridge' })
provider = new SshPtyProvider('conn-1', mux as never, {
binDir: '/home/user/.orca-relay/bin',
relayDir: '/home/user/.orca-relay/relay-v1',
nodePath: '/usr/bin/node',
sockPath: '/home/user/.orca-relay/relay.sock'
})
await provider.spawn({
cols: 120,
rows: 40,
env: { PATH: '/usr/bin', ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expect(mux.request).toHaveBeenCalledWith('pty.spawn', {
cols: 120,
rows: 40,
cwd: undefined,
env: {
PATH: '/home/user/.orca-relay/bin:/usr/bin',
ORCA_TERMINAL_HANDLE: 'term_ssh',
ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin',
ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1',
ORCA_RELAY_NODE_PATH: '/usr/bin/node',
ORCA_RELAY_SOCKET_PATH: '/home/user/.orca-relay/relay.sock'
}
})
})
it('does not clobber the remote relay PATH when caller env has no PATH', async () => {
mux.request.mockResolvedValue({ id: 'pty-bridge' })
provider = new SshPtyProvider('conn-1', mux as never, {
binDir: '/home/user/.orca-relay/bin',
relayDir: '/home/user/.orca-relay/relay-v1',
nodePath: '/usr/bin/node',
sockPath: '/home/user/.orca-relay/relay.sock'
})
await provider.spawn({
cols: 120,
rows: 40,
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expect(mux.request).toHaveBeenCalledWith('pty.spawn', {
cols: 120,
rows: 40,
cwd: undefined,
env: {
ORCA_TERMINAL_HANDLE: 'term_ssh',
ORCA_REMOTE_CLI_BIN_DIR: '/home/user/.orca-relay/bin',
ORCA_RELAY_DIR: '/home/user/.orca-relay/relay-v1',
ORCA_RELAY_NODE_PATH: '/usr/bin/node',
ORCA_RELAY_SOCKET_PATH: '/home/user/.orca-relay/relay.sock'
}
})
})
it('reattaches an existing session and returns attach replay separately from snapshot', async () => {
mux.request.mockResolvedValue({ replay: 'buffered-output' })

View File

@ -5,6 +5,12 @@ import { toAppSshPtyId, toRelaySshPtyId } from './ssh-pty-id'
type DataCallback = (payload: { id: string; data: string }) => void
type ReplayCallback = (payload: { id: string; data: string }) => void
type ExitCallback = (payload: { id: string; code: number }) => void
type RemoteCliBridgeEnv = {
binDir: string
relayDir: string
nodePath: string
sockPath: string
}
export const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
@ -29,7 +35,11 @@ export class SshPtyProvider implements IPtyProvider {
// the provider is torn down on disconnect, routing events to stale state.
private unsubscribeNotifications: (() => void) | null = null
constructor(connectionId: string, mux: SshChannelMultiplexer) {
constructor(
connectionId: string,
mux: SshChannelMultiplexer,
private readonly remoteCliBridgeEnv?: RemoteCliBridgeEnv
) {
this.connectionId = connectionId
this.mux = mux
@ -119,7 +129,7 @@ export class SshPtyProvider implements IPtyProvider {
cols: opts.cols,
rows: opts.rows,
cwd: opts.cwd,
env: opts.env,
env: this.withRemoteCliBridgeEnv(opts.env),
// Why: the relay's plugin-overlay env augmenter needs to know which
// Pi-compatible agent is being launched (`pi` vs `omp`) so it mirrors
// the right `~/.<kind>/agent` source dir on the remote disk. The
@ -135,6 +145,29 @@ export class SshPtyProvider implements IPtyProvider {
}
}
private withRemoteCliBridgeEnv(
env: Record<string, string> | undefined
): Record<string, string> | undefined {
if (!this.remoteCliBridgeEnv) {
return env
}
const merged = { ...env }
const pathKey = merged.PATH !== undefined ? 'PATH' : merged.Path !== undefined ? 'Path' : null
if (pathKey) {
const pathValue = merged[pathKey] ?? ''
merged[pathKey] = pathValue.split(':').includes(this.remoteCliBridgeEnv.binDir)
? pathValue
: pathValue
? `${this.remoteCliBridgeEnv.binDir}:${pathValue}`
: this.remoteCliBridgeEnv.binDir
}
merged.ORCA_REMOTE_CLI_BIN_DIR = this.remoteCliBridgeEnv.binDir
merged.ORCA_RELAY_DIR = this.remoteCliBridgeEnv.relayDir
merged.ORCA_RELAY_NODE_PATH = this.remoteCliBridgeEnv.nodePath
merged.ORCA_RELAY_SOCKET_PATH = this.remoteCliBridgeEnv.sockPath
return merged
}
async attach(id: string): Promise<void> {
await this.mux.request('pty.attach', { id: this.toRelayPtyId(id) })
}

View File

@ -99,7 +99,7 @@ describe('resolveWindowsShellLaunchArgs', () => {
'--',
'bash',
'-c',
"cd '/mnt/c/Users/alice/code' && exec bash -l"
'cd \'/mnt/c/Users/alice/code\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
])
// Why: WSL cannot cd into a Windows path, so node-pty must start from the
// user's Windows home and we inject the Linux cd into the shellArgs above.
@ -111,12 +111,16 @@ describe('resolveWindowsShellLaunchArgs', () => {
const result = resolveWindowsShellLaunchArgs('wsl.exe', "C:\\weird'path", 'C:\\Users\\alice')
// The injected bash cmd must not break out of the surrounding single
// quotes when the path contains a ' character.
expect(result.shellArgs[3]).toBe("cd '/mnt/c/weird'\\''path' && exec bash -l")
expect(result.shellArgs[3]).toBe(
"cd '/mnt/c/weird'\\''path' && export PATH=\"$HOME/.local/bin:$PATH\" && exec bash -l"
)
})
it('falls back to /mnt/c when cwd is not a drive-letter path', () => {
const result = resolveWindowsShellLaunchArgs('wsl.exe', '\\\\server\\share', 'C:\\Users\\alice')
expect(result.shellArgs[3]).toBe("cd '/mnt/c' && exec bash -l")
expect(result.shellArgs[3]).toBe(
'cd \'/mnt/c\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
)
})
it('keeps WSL UNC worktree cwd inside the matching distro', () => {
@ -138,7 +142,7 @@ describe('resolveWindowsShellLaunchArgs', () => {
'--',
'bash',
'-c',
"cd '/home/alice/repo' && exec bash -l"
'cd \'/home/alice/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
])
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
expect(result.validationCwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo')
@ -164,7 +168,7 @@ describe('resolveWindowsShellLaunchArgs', () => {
'--',
'bash',
'-c',
"cd '/home/alice/repo/subdir' && exec bash -l"
'cd \'/home/alice/repo/subdir\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
])
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
expect(result.validationCwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo\\subdir')

View File

@ -33,7 +33,14 @@ export type WindowsShellWslContext = {
function buildWslShellArgs(linuxCwd: string, distro?: string): string[] {
const escapedLinuxCwd = linuxCwd.replace(/'/g, "'\\''")
const shellArgs = ['--', 'bash', '-c', `cd '${escapedLinuxCwd}' && exec bash -l`]
// Why: Orca's WSL bridge is installed under ~/.local/bin, but distro login
// files do not consistently include that directory before agent commands run.
const shellArgs = [
'--',
'bash',
'-c',
`cd '${escapedLinuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l`
]
return distro ? ['-d', distro, ...shellArgs] : shellArgs
}

View File

@ -84,7 +84,7 @@ describe('mergePersistedWindowsPath', () => {
[
'',
'HKEY_CURRENT_USER\\Environment',
' Path REG_EXPAND_SZ C:\\Users\\me\\AppData\\Local\\agy\\bin;C:\\Existing',
' Path REG_EXPAND_SZ C:\\Users\\me\\AppData\\Local\\Orca\\bin;C:\\Existing',
''
].join('\r\n')
)
@ -93,7 +93,7 @@ describe('mergePersistedWindowsPath', () => {
mergePersistedWindowsPath(env, { platform: 'win32', execFileSync })
expect(env.Path).toBe(
'C:\\Existing;C:\\Windows\\System32;C:\\Users\\me\\AppData\\Local\\agy\\bin'
'C:\\Existing;C:\\Windows\\System32;C:\\Users\\me\\AppData\\Local\\Orca\\bin'
)
})

View File

@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { addOrcaWslInteropEnv } from './wsl-orca-env'
describe('addOrcaWslInteropEnv', () => {
it('marks the Orca terminal handle for Windows to WSL env import', () => {
const env: Record<string, string> = { ORCA_TERMINAL_HANDLE: 'term_wsl' }
addOrcaWslInteropEnv(env)
expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u')
})
it('preserves existing WSLENV entries and does not duplicate the handle entry', () => {
const env: Record<string, string> = {
WSLENV: 'FOO/u:ORCA_TERMINAL_HANDLE/u:BAR/p'
}
addOrcaWslInteropEnv(env)
expect(env.WSLENV).toBe('FOO/u:ORCA_TERMINAL_HANDLE/u:BAR/p')
})
})

View File

@ -0,0 +1,19 @@
const WSLENV_ENTRY_SEPARATOR = ':'
function parseWslenvEntries(value: string | undefined): string[] {
return value ? value.split(WSLENV_ENTRY_SEPARATOR).filter(Boolean) : []
}
function hasWslenvVariable(entries: readonly string[], variableName: string): boolean {
return entries.some((entry) => entry.split('/')[0] === variableName)
}
export function addOrcaWslInteropEnv(env: Record<string, string>): void {
const entries = parseWslenvEntries(env.WSLENV)
if (!hasWslenvVariable(entries, 'ORCA_TERMINAL_HANDLE')) {
// Why: WSL only imports selected Windows env vars. The terminal handle is
// the trusted orchestration identity, so managed WSL shells must opt it in.
entries.push('ORCA_TERMINAL_HANDLE/u')
}
env.WSLENV = entries.join(WSLENV_ENTRY_SEPARATOR)
}

View File

@ -31,6 +31,7 @@ type PendingRequest = {
export type NotificationHandler = (method: string, params: Record<string, unknown>) => void
export type MethodNotificationHandler = (params: Record<string, unknown>) => void
export type RequestHandler = (params: Record<string, unknown>) => Promise<unknown> | unknown
const REQUEST_TIMEOUT_MS = 30_000
@ -44,6 +45,7 @@ export class SshChannelMultiplexer {
private lastReceivedAt = Date.now()
private pendingRequests = new Map<number, PendingRequest>()
private notificationHandlers: NotificationHandler[] = []
private requestHandlers = new Map<string, RequestHandler>()
// Why: per-method dispatch map keeps streaming consumers (fs.streamChunk,
// fs.streamEnd, fs.streamError) from accreting string-match logic in the
// generic notification listener that already serves fs.changed.
@ -118,6 +120,15 @@ export class SshChannelMultiplexer {
}
}
onRequest(method: string, handler: RequestHandler): () => void {
this.requestHandlers.set(method, handler)
return () => {
if (this.requestHandlers.get(method) === handler) {
this.requestHandlers.delete(method)
}
}
}
// Why: the session needs to know when the relay channel dies so it can
// auto-reconnect. Without this, a relay channel close (e.g. --connect
// bridge exits) leaves the session in 'ready' state with a dead mux
@ -341,10 +352,41 @@ export class SshChannelMultiplexer {
private handleMessage(msg: JsonRpcMessage): void {
if ('id' in msg && ('result' in msg || 'error' in msg)) {
this.handleResponse(msg as JsonRpcResponse)
} else if ('id' in msg && 'method' in msg) {
void this.handleRequest(msg as JsonRpcRequest)
} else if ('method' in msg && !('id' in msg)) {
this.handleNotification(msg as JsonRpcNotification)
}
// Requests from relay to client are not expected in Phase 2
}
private async handleRequest(msg: JsonRpcRequest): Promise<void> {
const handler = this.requestHandlers.get(msg.method)
if (!handler) {
this.sendMessage({
jsonrpc: '2.0',
id: msg.id,
error: { code: -32601, message: `Method not found: ${msg.method}` }
})
return
}
try {
const result = await handler(msg.params ?? {})
this.sendMessage({
jsonrpc: '2.0',
id: msg.id,
result: result ?? null
})
} catch (err) {
this.sendMessage({
jsonrpc: '2.0',
id: msg.id,
error: {
code: (err as { code?: number }).code ?? -32000,
message: err instanceof Error ? err.message : String(err)
}
})
}
}
private handleResponse(msg: JsonRpcResponse): void {

View File

@ -34,6 +34,10 @@ import {
export type RelayDeployResult = {
transport: MultiplexerTransport
platform: RelayPlatform
remoteHome?: string
remoteRelayDir?: string
nodePath?: string
sockPath?: string
}
// Why: individual exec commands have 30s timeouts, but the full deploy
@ -161,7 +165,7 @@ async function deployAndLaunchRelayInner(
onProgress?.('Starting relay...')
console.log('[ssh-relay] Launching relay...')
const transport = await launchRelay(conn, remoteRelayDir, graceTimeSeconds, relayInstanceId)
const launched = await launchRelay(conn, remoteRelayDir, graceTimeSeconds, relayInstanceId)
console.log('[ssh-relay] Relay started successfully')
// Why: best-effort cleanup of unreferenced sibling version dirs. Errors
@ -169,7 +173,14 @@ async function deployAndLaunchRelayInner(
// can never block the user from connecting.
void gcOldRelayVersions(conn, remoteHome, remoteRelayDir).catch(() => {})
return { transport, platform }
return {
transport: launched.transport,
platform,
remoteHome,
remoteRelayDir,
nodePath: launched.nodePath,
sockPath: launched.sockPath
}
}
async function detectRemotePlatform(conn: SshConnection): Promise<RelayPlatform | null> {
@ -423,7 +434,7 @@ async function launchRelay(
remoteDir: string,
graceTimeSeconds?: number,
relayInstanceId?: string
): Promise<MultiplexerTransport> {
): Promise<{ transport: MultiplexerTransport; nodePath: string; sockPath: string }> {
// Why: Phase 1 of the plan requires Node.js on the remote. We use the
// system `node` rather than bundling a node binary, keeping the relay
// package small (~100KB JS vs ~60MB with embedded node).
@ -466,7 +477,7 @@ async function launchRelay(
)
const transport = await waitForSentinel(channel)
console.log('[ssh-relay] Reconnected to existing relay via socket')
return transport
return { transport, nodePath, sockPath: sockFile }
} catch (err) {
console.warn(
'[ssh-relay] Socket reconnect failed, launching fresh relay:',
@ -555,5 +566,5 @@ async function launchRelay(
const channel = await conn.exec(
`cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)}`
)
return waitForSentinel(channel)
return { transport: await waitForSentinel(channel), nodePath, sockPath: sockFile }
}

View File

@ -17,6 +17,7 @@ vi.mock('./ssh-channel-multiplexer', () => {
notify = vi.fn()
request = vi.fn().mockResolvedValue([])
onNotification = vi.fn().mockReturnValue(() => {})
onRequest = vi.fn().mockReturnValue(() => {})
onDispose = vi.fn().mockReturnValue(() => {})
dispose = vi.fn()
isDisposed = vi.fn().mockReturnValue(false)

View File

@ -24,6 +24,7 @@ vi.mock('./ssh-channel-multiplexer', () => {
notify = vi.fn()
request = muxRequestMock
onNotification = vi.fn().mockReturnValue(() => {})
onRequest = vi.fn().mockReturnValue(() => {})
onDispose = vi.fn().mockReturnValue(() => {})
dispose = vi.fn()
isDisposed = vi.fn().mockReturnValue(false)

View File

@ -10,6 +10,7 @@
import type { BrowserWindow } from 'electron'
import { deployAndLaunchRelay } from './ssh-relay-deploy'
import { execCommand } from './ssh-relay-deploy-helpers'
import { isRelayVersionMismatchError } from './ssh-relay-version-mismatch-error'
import type { RelayVersionMismatchError } from './ssh-relay-version-mismatch-error'
import { SshChannelMultiplexer } from './ssh-channel-multiplexer'
@ -49,6 +50,7 @@ import { notifyRemoteWorkspaceHandlers } from '../ipc/remote-workspace-events'
import { PortScanner } from './ssh-port-scanner'
import type { SshPortForwardManager } from './ssh-port-forward'
import type { SshConnection } from './ssh-connection'
import { shellEscape } from './ssh-connection-utils'
import {
DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS,
type DetectedPort,
@ -58,6 +60,7 @@ import {
} from '../../shared/ssh-types'
import type { Store } from '../persistence'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { runRemoteOrcaCli } from './ssh-remote-orca-cli'
export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed'
@ -97,6 +100,12 @@ export class SshRelaySession {
private _onReady: ((targetId: string) => void) | null = null
private portScanner: PortScanner | null = null
private currentConnection: SshConnection | null = null
private remoteCliBridgeEnv: {
binDir: string
relayDir: string
nodePath: string
sockPath: string
} | null = null
constructor(
readonly targetId: string,
@ -184,12 +193,17 @@ export class SshRelaySession {
this.currentConnection = conn
try {
const { transport } = await deployAndLaunchRelay(
conn,
undefined,
graceTimeSeconds,
this.targetId
)
const { transport, remoteHome, remoteRelayDir, nodePath, sockPath } =
await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId)
this.remoteCliBridgeEnv =
remoteHome && remoteRelayDir && nodePath && sockPath
? {
binDir: `${remoteHome}/.orca-relay/bin`,
relayDir: remoteRelayDir,
nodePath,
sockPath
}
: null
// Why: dispose() can fire during the await above (e.g. user clicks
// disconnect while relay is deploying). If so, the session is already
@ -303,12 +317,17 @@ export class SshRelaySession {
this.teardownProviders('connection_lost')
try {
const { transport } = await deployAndLaunchRelay(
conn,
undefined,
graceTimeSeconds,
this.targetId
)
const { transport, remoteHome, remoteRelayDir, nodePath, sockPath } =
await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId)
this.remoteCliBridgeEnv =
remoteHome && remoteRelayDir && nodePath && sockPath
? {
binDir: `${remoteHome}/.orca-relay/bin`,
relayDir: remoteRelayDir,
nodePath,
sockPath
}
: null
if (abortController.signal.aborted || this.isDisposed()) {
// Why: the relay is already running on the remote. Creating a temporary
@ -487,7 +506,14 @@ export class SshRelaySession {
return false
}
const ptyProvider = new SshPtyProvider(this.targetId, mux)
await this.installRemoteOrcaCliShim()
if (shouldContinue && !shouldContinue()) {
return false
}
this.wireUpRemoteOrcaCli(mux)
const ptyProvider = new SshPtyProvider(this.targetId, mux, this.remoteCliBridgeEnv ?? undefined)
registerSshPtyProvider(this.targetId, ptyProvider)
const fsProvider = new SshFilesystemProvider(this.targetId, mux, () =>
@ -559,6 +585,70 @@ export class SshRelaySession {
}
}
private async installRemoteOrcaCliShim(): Promise<void> {
if (!this.remoteCliBridgeEnv) {
return
}
const { binDir, relayDir, nodePath, sockPath } = this.remoteCliBridgeEnv
const shimPath = `${binDir}/orca`
const shim = [
'#!/usr/bin/env sh',
'set -eu',
`ORCA_RELAY_NODE_PATH=\${ORCA_RELAY_NODE_PATH:-${quoteSh(nodePath)}}`,
`ORCA_RELAY_DIR=\${ORCA_RELAY_DIR:-${quoteSh(relayDir)}}`,
`ORCA_RELAY_SOCKET_PATH=\${ORCA_RELAY_SOCKET_PATH:-${quoteSh(sockPath)}}`,
'if [ ! -S "$ORCA_RELAY_SOCKET_PATH" ]; then',
' echo "Orca SSH CLI bridge cannot find the relay socket: $ORCA_RELAY_SOCKET_PATH" >&2',
' exit 1',
'fi',
'exec "$ORCA_RELAY_NODE_PATH" "$ORCA_RELAY_DIR/relay.js" --sock-path "$ORCA_RELAY_SOCKET_PATH" --orca-cli "$@"',
''
].join('\n')
await execCommand(this.requireReadyConnection(), `mkdir -p ${shellEscape(binDir)}`)
const conn = this.requireReadyConnection()
if (typeof conn.writeFile === 'function') {
await conn.writeFile(shimPath, shim)
} else {
const sftp = await conn.sftp()
try {
await new Promise<void>((resolve, reject) => {
const ws = sftp.createWriteStream(shimPath)
sftp.once('error', reject)
ws.once('close', resolve)
ws.once('error', reject)
ws.end(shim)
})
} finally {
sftp.end()
}
}
await execCommand(conn, `chmod 755 ${shellEscape(shimPath)}`)
}
private wireUpRemoteOrcaCli(mux: SshChannelMultiplexer): void {
mux.onRequest('orca.cli', async (params) => {
if (!this.runtime) {
throw new Error('Orca runtime is unavailable')
}
const argv = Array.isArray(params.argv)
? params.argv.filter((item): item is string => typeof item === 'string')
: []
const cwd = typeof params.cwd === 'string' && params.cwd.length > 0 ? params.cwd : '/'
const rawEnv = params.env
const env =
rawEnv && typeof rawEnv === 'object' && !Array.isArray(rawEnv)
? Object.fromEntries(
Object.entries(rawEnv).filter(
(entry): entry is [string, string] =>
typeof entry[0] === 'string' && typeof entry[1] === 'string'
)
)
: {}
return await runRemoteOrcaCli(this.runtime, { argv, cwd, env })
})
}
// Why: ship the OpenCode plugin / Pi extension source bodies to the relay
// so it can materialize per-PTY overlay dirs and inject OPENCODE_CONFIG_DIR
// / PI_CODING_AGENT_DIR into spawn env. The strings change as we add agent
@ -897,3 +987,7 @@ export class SshRelaySession {
}
}
}
function quoteSh(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`
}

View File

@ -0,0 +1,125 @@
import { describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { runRemoteOrcaCli } from './ssh-remote-orca-cli'
describe('runRemoteOrcaCli', () => {
function createRuntime() {
const messages: {
id: string
from_handle: string
to_handle: string
subject: string
body?: string
read_at: string | null
}[] = []
let nextMessage = 1
const db = {
insertMessage: vi.fn(
(message: { from: string; to: string; subject: string; body?: string }) => {
const row = {
id: `msg_${nextMessage++}`,
from_handle: message.from,
to_handle: message.to,
subject: message.subject,
body: message.body,
read_at: null
}
messages.push(row)
return row
}
),
getUnreadMessages: vi.fn((handle: string) =>
messages.filter((message) => message.to_handle === handle && message.read_at === null)
),
getAllMessagesForHandle: vi.fn((handle: string) =>
messages.filter((message) => message.to_handle === handle)
),
markAsRead: vi.fn((ids: string[]) => {
for (const message of messages) {
if (ids.includes(message.id)) {
message.read_at = new Date(0).toISOString()
}
}
})
}
const runtime = {
getRuntimeId: () => 'runtime-test',
getStatus: () => ({
runtimeId: 'runtime-test',
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: 1,
liveTabCount: 1,
liveLeafCount: 1
}),
getOrchestrationDb: () => db,
deliverPendingMessagesForHandle: vi.fn(),
notifyMessageArrived: vi.fn()
} as unknown as OrcaRuntimeService
return { runtime, db }
}
it('uses the remote ORCA_TERMINAL_HANDLE as orchestration sender identity', async () => {
const { runtime, db } = createRuntime()
const result = await runRemoteOrcaCli(runtime, {
argv: ['orchestration', 'send', '--to', 'term_windows', '--subject', 'ping', '--json'],
cwd: '/home/alice/repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expect(result.exitCode).toBe(0)
const payload = JSON.parse(result.stdout) as { ok: boolean }
expect(payload.ok).toBe(true)
expect(db.getUnreadMessages('term_windows')[0]?.from_handle).toBe('term_ssh')
})
it('accepts equals-style orchestration flags in the remote shim', async () => {
const { runtime, db } = createRuntime()
const result = await runRemoteOrcaCli(runtime, {
argv: [
'orchestration',
'send',
'--to=term_windows',
'--subject=ping',
'--body=--literal-body',
'--json'
],
cwd: '/home/alice/repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expect(result.exitCode).toBe(0)
const payload = JSON.parse(result.stdout) as { ok: boolean }
expect(payload.ok).toBe(true)
const message = db.getUnreadMessages('term_windows')[0]
expect(message?.from_handle).toBe('term_ssh')
expect(message?.body).toBe('--literal-body')
})
it('uses the remote ORCA_TERMINAL_HANDLE as orchestration check identity', async () => {
const { runtime, db } = createRuntime()
db.insertMessage({
from: 'term_windows',
to: 'term_ssh',
subject: 'pong',
body: 'hello'
})
const result = await runRemoteOrcaCli(runtime, {
argv: ['orchestration', 'check', '--all', '--json'],
cwd: '/home/alice/repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expect(result.exitCode).toBe(0)
const payload = JSON.parse(result.stdout) as {
ok: boolean
result: { count: number; messages: { subject: string }[] }
}
expect(payload.ok).toBe(true)
expect(payload.result.count).toBe(1)
expect(payload.result.messages[0]?.subject).toBe('pong')
})
})

View File

@ -0,0 +1,216 @@
import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types'
import { RpcDispatcher } from '../runtime/rpc/dispatcher'
import type { RpcResponse } from '../runtime/rpc/core'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
export type RemoteOrcaCliRequest = {
argv: string[]
cwd: string
env: Record<string, string>
}
export type RemoteOrcaCliResult = {
stdout: string
stderr: string
exitCode: number
}
type ParsedRemoteCli = {
commandPath: string[]
flags: Map<string, string | boolean>
}
export async function runRemoteOrcaCli(
runtime: OrcaRuntimeService,
request: RemoteOrcaCliRequest
): Promise<RemoteOrcaCliResult> {
const dispatcher = new RpcDispatcher({ runtime })
const parsed = parseRemoteCliArgs(request.argv)
const json = parsed.flags.has('json')
try {
const response = await dispatchRemoteCli(dispatcher, parsed, request.env)
return {
stdout: json ? `${JSON.stringify(response, null, 2)}\n` : `${formatRemoteCli(response)}\n`,
stderr: '',
exitCode: response.ok ? 0 : 1
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (json) {
return {
stdout: `${JSON.stringify(buildLocalError(message), null, 2)}\n`,
stderr: '',
exitCode: 1
}
}
return { stdout: '', stderr: `${message}\n`, exitCode: 1 }
}
}
async function dispatchRemoteCli(
dispatcher: RpcDispatcher,
parsed: ParsedRemoteCli,
env: Record<string, string>
): Promise<RpcResponse> {
const command = parsed.commandPath.join(' ')
switch (command) {
case 'status': {
const response = await call(dispatcher, 'status.get')
if (!response.ok) {
return response
}
const status = response.result as RuntimeStatus
const cliStatus: CliStatusResult = {
app: { running: true, pid: null },
runtime: {
state: status.graphStatus === 'ready' ? 'ready' : 'graph_not_ready',
reachable: true,
runtimeId: status.runtimeId
},
graph: { state: status.graphStatus }
}
return { ...response, result: cliStatus }
}
case 'terminal list':
return await call(dispatcher, 'terminal.list', {
worktree: optionalString(parsed.flags, 'worktree'),
limit: optionalNumber(parsed.flags, 'limit')
})
case 'orchestration send':
return await call(dispatcher, 'orchestration.send', {
from: resolveHandle(parsed.flags, env, 'from'),
to: requiredString(parsed.flags, 'to'),
subject: requiredString(parsed.flags, 'subject'),
body: optionalString(parsed.flags, 'body'),
type: optionalString(parsed.flags, 'type'),
priority: optionalString(parsed.flags, 'priority'),
threadId: optionalString(parsed.flags, 'thread-id'),
payload: optionalString(parsed.flags, 'payload')
})
case 'orchestration check':
return await call(dispatcher, 'orchestration.check', {
terminal: resolveHandle(parsed.flags, env, 'terminal'),
unread: parsed.flags.has('unread') ? true : undefined,
all: parsed.flags.has('all') ? true : undefined,
types: optionalString(parsed.flags, 'types'),
inject: parsed.flags.has('inject') ? true : undefined,
wait: parsed.flags.has('wait') ? true : undefined,
timeoutMs: optionalNumber(parsed.flags, 'timeout-ms')
})
case 'orchestration reply':
return await call(dispatcher, 'orchestration.reply', {
id: requiredString(parsed.flags, 'id'),
body: requiredString(parsed.flags, 'body'),
from: resolveHandle(parsed.flags, env, 'from')
})
case 'orchestration inbox':
return await call(dispatcher, 'orchestration.inbox', {
limit: optionalNumber(parsed.flags, 'limit'),
terminal: optionalString(parsed.flags, 'terminal')
})
default:
throw new Error(`Unsupported SSH Orca CLI command: ${command}`)
}
}
async function call(
dispatcher: RpcDispatcher,
method: string,
params?: Record<string, unknown>
): Promise<RpcResponse> {
return await dispatcher.dispatch({
id: `remote-cli-${Date.now()}`,
authToken: 'remote-cli',
method,
params
})
}
function parseRemoteCliArgs(argv: string[]): ParsedRemoteCli {
const commandPath: string[] = []
const flags = new Map<string, string | boolean>()
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i]
if (!token.startsWith('--')) {
commandPath.push(token)
continue
}
const assignment = token.slice(2)
// Why: the SSH relay-backed shim should accept the same `--flag=value`
// form as the local CLI, including values that themselves start with `--`.
const equalsIndex = assignment.indexOf('=')
if (equalsIndex !== -1) {
flags.set(assignment.slice(0, equalsIndex), assignment.slice(equalsIndex + 1))
continue
}
const flag = assignment
const next = argv[i + 1]
if (next && !next.startsWith('--')) {
flags.set(flag, next)
i += 1
} else {
flags.set(flag, true)
}
}
return { commandPath, flags }
}
function resolveHandle(
flags: Map<string, string | boolean>,
env: Record<string, string>,
flagName: string
): string {
return optionalString(flags, flagName) ?? env.ORCA_TERMINAL_HANDLE ?? 'unknown'
}
function requiredString(flags: Map<string, string | boolean>, name: string): string {
const value = optionalString(flags, name)
if (!value) {
throw new Error(`Missing --${name}`)
}
return value
}
function optionalString(flags: Map<string, string | boolean>, name: string): string | undefined {
const value = flags.get(name)
return typeof value === 'string' && value.length > 0 ? value : undefined
}
function optionalNumber(flags: Map<string, string | boolean>, name: string): number | undefined {
const value = optionalString(flags, name)
if (value === undefined) {
return undefined
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : undefined
}
function formatRemoteCli(response: RpcResponse): string {
if (!response.ok) {
return response.error.message
}
const result = response.result as Record<string, unknown>
if ('app' in result && 'runtime' in result && 'graph' in result) {
const status = result as CliStatusResult
return [
`appRunning: ${status.app.running}`,
`pid: ${status.app.pid ?? 'none'}`,
`runtimeState: ${status.runtime.state}`,
`runtimeReachable: ${status.runtime.reachable}`,
`runtimeId: ${status.runtime.runtimeId ?? 'none'}`,
`graphState: ${status.graph.state}`
].join('\n')
}
return JSON.stringify(response.result)
}
function buildLocalError(message: string): RpcResponse {
return {
id: 'remote-cli-local',
ok: false,
error: { code: 'runtime_error', message },
_meta: { runtimeId: 'unknown' }
}
}

View File

@ -1,3 +1,6 @@
/* eslint-disable max-lines -- Why: dispatcher behavior is stateful across
primary, socket, timeout, and cancellation paths; keeping fixtures shared
makes regression tests easier to audit. */
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { RelayDispatcher } from './dispatcher'
import {
@ -201,6 +204,61 @@ describe('RelayDispatcher', () => {
expect(socketWritten).toHaveLength(1)
})
it('forwards relay-originated requests to an owning socket client instead of the caller', async () => {
dispatcher.invalidateClient()
const ownerWritten: Buffer[] = []
const ownerId = dispatcher.attachClient((data) => {
ownerWritten.push(Buffer.from(data))
})
const cliId = dispatcher.attachClient(() => {})
const pending = dispatcher.requestAnyClient(
'orca.cli',
{ argv: ['status'] },
{ excludeClientId: cliId }
)
expect(ownerWritten).toHaveLength(1)
const requestFrame = decodeFirstFrame(ownerWritten[0])
const request = JSON.parse(requestFrame.payload.toString('utf-8')) as JsonRpcRequest
expect(request.method).toBe('orca.cli')
expect(request.params).toEqual({ argv: ['status'] })
dispatcher.feedClient(
ownerId,
encodeJsonRpcFrame({ jsonrpc: '2.0', id: request.id, result: { exitCode: 0 } }, 1, 0)
)
await expect(pending).resolves.toEqual({ exitCode: 0 })
})
it('prefers an owning socket client over the synthetic primary client', async () => {
const ownerWritten: Buffer[] = []
const ownerId = dispatcher.attachClient((data) => {
ownerWritten.push(Buffer.from(data))
})
const cliId = dispatcher.attachClient(() => {})
const pending = dispatcher.requestAnyClient(
'orca.cli',
{ argv: ['status'] },
{ excludeClientId: cliId }
)
expect(written).toHaveLength(0)
expect(ownerWritten).toHaveLength(1)
const requestFrame = decodeFirstFrame(ownerWritten[0])
const request = JSON.parse(requestFrame.payload.toString('utf-8')) as JsonRpcRequest
expect(request.method).toBe('orca.cli')
dispatcher.feedClient(
ownerId,
encodeJsonRpcFrame({ jsonrpc: '2.0', id: request.id, result: { exitCode: 0 } }, 1, 0)
)
await expect(pending).resolves.toEqual({ exitCode: 0 })
})
it('isolates failed socket-client writes from other clients', () => {
const goodSocketWritten: Buffer[] = []
const failingClientId = dispatcher.attachClient(() => {

View File

@ -1,3 +1,5 @@
/* eslint-disable max-lines -- Why: the relay protocol dispatcher keeps client
routing, request cancellation, and framing state together. */
import {
FrameDecoder,
MessageType,
@ -35,16 +37,26 @@ type RelayClient = {
closed: boolean
}
type PendingRelayRequest = {
resolve: (result: unknown) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout>
}
const RELAY_TO_CLIENT_REQUEST_TIMEOUT_MS = 30_000
export class RelayDispatcher {
private readonly primaryClient: RelayClient
private readonly clients = new Map<number, RelayClient>()
private requestHandlers = new Map<string, MethodHandler>()
private notificationHandlers = new Map<string, NotificationHandler>()
private readonly requestAborts = new ClientRequestAborts()
private pendingRelayRequests = new Map<number, PendingRelayRequest>()
private clientDetachListeners = new Set<(clientId: number) => void>()
private keepaliveTimer: ReturnType<typeof setInterval> | null = null
private disposed = false
private nextClientId = 1
private nextRequestId = 1
constructor(write: (data: Buffer) => void) {
this.primaryClient = this.createClient(write)
@ -153,6 +165,60 @@ export class RelayDispatcher {
}
}
requestPrimary(
method: string,
params?: Record<string, unknown>,
options?: { timeoutMs?: number }
): Promise<unknown> {
return this.requestClient(this.primaryClient.id, method, params, options)
}
requestAnyClient(
method: string,
params?: Record<string, unknown>,
options?: { timeoutMs?: number; excludeClientId?: number }
): Promise<unknown> {
const candidates = Array.from(this.clients.values()).filter(
(client) => !client.closed && client.id !== options?.excludeClientId
)
// Why: detached relays keep the synthetic primary client object around even
// though the owning Orca is attached through a Unix-socket client. Prefer a
// real attached client so remote `orca` shims do not forward to dead stdout.
const target = candidates.find((client) => client !== this.primaryClient) ?? candidates[0]
if (!target) {
return Promise.reject(new Error('No owning Orca client is connected to the relay'))
}
return this.requestClient(target.id, method, params, options)
}
private requestClient(
clientId: number,
method: string,
params?: Record<string, unknown>,
options?: { timeoutMs?: number }
): Promise<unknown> {
const client = this.clients.get(clientId)
if (this.disposed || !client || client.closed) {
return Promise.reject(new Error('Relay client is not connected'))
}
const id = this.nextRequestId++
const msg: JsonRpcRequest = {
jsonrpc: '2.0',
id,
method,
...(params !== undefined ? { params } : {})
}
const timeoutMs = options?.timeoutMs ?? RELAY_TO_CLIENT_REQUEST_TIMEOUT_MS
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRelayRequests.delete(id)
reject(new Error(`Request "${method}" timed out after ${timeoutMs}ms`))
}, timeoutMs)
this.pendingRelayRequests.set(id, { resolve, reject, timer })
this.sendFrame(client, msg)
})
}
dispose(): void {
if (this.disposed) {
return
@ -162,6 +228,11 @@ export class RelayDispatcher {
clearInterval(this.keepaliveTimer)
this.keepaliveTimer = null
}
for (const [id, pending] of this.pendingRelayRequests) {
clearTimeout(pending.timer)
pending.reject(new Error('Relay dispatcher disposed'))
this.pendingRelayRequests.delete(id)
}
// Why: dispose means this relay instance cannot send responses anymore;
// abort in-flight request work so stale SSH-side scans/watchers release.
this.requestAborts.abortAll()
@ -216,11 +287,30 @@ export class RelayDispatcher {
): void {
if ('id' in msg && 'method' in msg) {
void this.handleRequest(client, msg as JsonRpcRequest)
} else if ('id' in msg && ('result' in msg || 'error' in msg)) {
this.handleResponse(msg as JsonRpcResponse)
} else if ('method' in msg && !('id' in msg)) {
this.handleNotification(client, msg as JsonRpcNotification)
}
}
private handleResponse(msg: JsonRpcResponse): void {
const pending = this.pendingRelayRequests.get(msg.id)
if (!pending) {
return
}
clearTimeout(pending.timer)
this.pendingRelayRequests.delete(msg.id)
if (msg.error) {
const error = new Error(msg.error.message) as Error & { code?: number; data?: unknown }
error.code = msg.error.code
error.data = msg.error.data
pending.reject(error)
return
}
pending.resolve(msg.result)
}
private async handleRequest(client: RelayClient, req: JsonRpcRequest): Promise<void> {
const handler = this.requestHandlers.get(req.method)
if (!handler) {

View File

@ -17,7 +17,10 @@ function quotePosixSingle(value: string): string {
function hasOverlayRestoreEnv(env: Record<string, string>): boolean {
return Boolean(
env.ORCA_OPENCODE_CONFIG_DIR || env.ORCA_PI_CODING_AGENT_DIR || env.ORCA_OMP_CODING_AGENT_DIR
env.ORCA_OPENCODE_CONFIG_DIR ||
env.ORCA_PI_CODING_AGENT_DIR ||
env.ORCA_OMP_CODING_AGENT_DIR ||
env.ORCA_REMOTE_CLI_BIN_DIR
)
}
@ -84,6 +87,7 @@ if [[ ! -o login ]]; then
if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then
export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}"
fi
[[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac
${getPosixOmpShellWrapper()}
fi
`
@ -101,6 +105,7 @@ fi
if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then
export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}"
fi
[[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac
${getPosixOmpShellWrapper()}
`
const bashRc = `# Orca relay bash overlay wrapper
@ -118,6 +123,7 @@ fi
if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}" ]]; then
export PI_CODING_AGENT_DIR="\${ORCA_OMP_CODING_AGENT_DIR}"
fi
[[ -n "\${ORCA_REMOTE_CLI_BIN_DIR:-}" ]] && case ":$PATH:" in *:"\${ORCA_REMOTE_CLI_BIN_DIR}":*) ;; *) export PATH="\${ORCA_REMOTE_CLI_BIN_DIR}:$PATH" ;; esac
${getPosixOmpShellWrapper()}
# Why: SSH bash sessions need the same command lifecycle markers as local
# bash so agent rows stop showing "working" when the foreground command exits.

View File

@ -23,7 +23,15 @@ import { createServer, createConnection, type Socket, type Server } from 'net'
import { homedir } from 'os'
import { resolve, join } from 'path'
import { unlinkSync, existsSync, statSync } from 'fs'
import { RELAY_SENTINEL } from './protocol'
import {
RELAY_SENTINEL,
FrameDecoder,
MessageType,
encodeJsonRpcFrame,
parseJsonRpcMessage,
type DecodedFrame,
type JsonRpcResponse
} from './protocol'
import { readLaunchVersion, runConnectHandshake, setupDaemonHandshake } from './relay-handshake'
import { RelayDispatcher } from './dispatcher'
import { RelayContext } from './context'
@ -91,11 +99,13 @@ function parseArgs(argv: string[]): {
graceTimeMs: number
connectMode: boolean
detached: boolean
cliMode: boolean
sockPath: string
} {
let graceTimeMs = DEFAULT_GRACE_MS
let connectMode = false
let detached = false
let cliMode = false
let sockPath = ''
for (let i = 2; i < argv.length; i++) {
if (argv[i] === '--grace-time' && argv[i + 1]) {
@ -109,6 +119,8 @@ function parseArgs(argv: string[]): {
i++
} else if (argv[i] === '--connect') {
connectMode = true
} else if (argv[i] === '--orca-cli') {
cliMode = true
} else if (argv[i] === '--detached') {
detached = true
} else if (argv[i] === '--sock-path' && argv[i + 1]) {
@ -119,7 +131,7 @@ function parseArgs(argv: string[]): {
if (!sockPath) {
sockPath = join(process.cwd(), SOCK_NAME)
}
return { graceTimeMs, connectMode, detached, sockPath }
return { graceTimeMs, connectMode, detached, cliMode, sockPath }
}
// ── Connect mode ─────────────────────────────────────────────────────
@ -187,15 +199,117 @@ function runConnectMode(sockPath: string): void {
})
}
function runOrcaCliMode(sockPath: string, argv: string[]): void {
const myVersion = readLaunchVersion()
const sock = createConnection({ path: sockPath })
let nextSeq = 1
let highestReceivedSeq = 0
const requestId = 1
const sendRequest = (): void => {
const env = pickRemoteCliEnv(process.env)
const frame = encodeJsonRpcFrame(
{
jsonrpc: '2.0',
id: requestId,
method: 'orca.cli',
params: {
argv,
cwd: process.cwd(),
env
}
},
nextSeq++,
highestReceivedSeq
)
sock.write(frame)
}
const decoder = new FrameDecoder((frame: DecodedFrame) => {
if (frame.id > highestReceivedSeq) {
highestReceivedSeq = frame.id
}
if (frame.type !== MessageType.Regular) {
return
}
const msg = parseJsonRpcMessage(frame.payload)
if (!('id' in msg) || msg.id !== requestId || !('result' in msg || 'error' in msg)) {
return
}
const response = msg as JsonRpcResponse
if (response.error) {
process.stderr.write(`${response.error.message}\n`)
sock.destroy()
process.exit(1)
}
const result = (response.result ?? {}) as {
stdout?: unknown
stderr?: unknown
exitCode?: unknown
}
if (typeof result.stdout === 'string' && result.stdout.length > 0) {
process.stdout.write(result.stdout)
}
if (typeof result.stderr === 'string' && result.stderr.length > 0) {
process.stderr.write(result.stderr)
}
sock.destroy()
process.exit(typeof result.exitCode === 'number' ? result.exitCode : 0)
})
const connectTimeout = setTimeout(() => {
process.stderr.write(`[orca-cli] Relay connection timed out after ${CONNECT_TIMEOUT_MS}ms\n`)
sock.destroy()
process.exit(1)
}, CONNECT_TIMEOUT_MS)
sock.on('connect', () => {
clearTimeout(connectTimeout)
runConnectHandshake(sock, myVersion, {
onAccepted: (leftover) => {
if (leftover.length > 0) {
decoder.feed(leftover)
}
sock.on('data', (chunk) =>
decoder.feed(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
)
sendRequest()
}
})
})
sock.on('error', (err) => {
clearTimeout(connectTimeout)
process.stderr.write(`[orca-cli] Relay socket error: ${err.message}\n`)
process.exit(1)
})
}
function pickRemoteCliEnv(env: NodeJS.ProcessEnv): Record<string, string> {
const picked: Record<string, string> = {}
for (const key of ['ORCA_TERMINAL_HANDLE', 'ORCA_USER_DATA_PATH', 'PATH', 'Path']) {
const value = env[key]
if (typeof value === 'string') {
picked[key] = value
}
}
return picked
}
// ── Normal mode ──────────────────────────────────────────────────────
async function main(): Promise<void> {
const { graceTimeMs, connectMode, detached, sockPath } = parseArgs(process.argv)
const { graceTimeMs, connectMode, detached, cliMode, sockPath } = parseArgs(process.argv)
if (connectMode) {
runConnectMode(sockPath)
return
}
if (cliMode) {
const marker = process.argv.indexOf('--orca-cli')
runOrcaCliMode(sockPath, marker >= 0 ? process.argv.slice(marker + 1) : [])
return
}
let ownsSocketPath = false
let ownedSocketIdentity: SocketIdentity | null = null
@ -306,6 +420,12 @@ async function main(): Promise<void> {
const _workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher)
void _workspaceSessionHandler
dispatcher.onRequest('orca.cli', async (params, context) => {
return await dispatcher.requestAnyClient('orca.cli', params, {
excludeClientId: context.clientId
})
})
function configureRelayGraceTime(params: Record<string, unknown>): { graceTimeMs: number } {
const seconds = Number(params.graceTimeSeconds)
if (Number.isFinite(seconds) && seconds >= 0) {

View File

@ -357,29 +357,31 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() {
</div>
)}
{!isMainWorktree && allowSkipConfirm && !canForceDelete && (
// Why: only show "Don't ask again" for the primary confirmation. The
// force-delete variant is a recovery path that shouldn't double as a
// preference checkpoint; see handleDelete for the matching guard.
<button
type="button"
role="checkbox"
aria-checked={dontAskAgain}
onClick={() => setDontAskAgain((prev) => !prev)}
className="flex items-center gap-2 rounded-sm px-1 py-1 text-xs text-foreground/80 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span
className={`flex size-4 items-center justify-center rounded-sm border transition-colors ${
dontAskAgain
? 'border-foreground bg-foreground text-background'
: 'border-muted-foreground bg-transparent'
}`}
{!isMainWorktree &&
allowSkipConfirm &&
!canForceDelete && (
// Why: only show "Don't ask again" for the primary confirmation. The
// force-delete variant is a recovery path that shouldn't double as a
// preference checkpoint; see handleDelete for the matching guard.
<button
type="button"
role="checkbox"
aria-checked={dontAskAgain}
onClick={() => setDontAskAgain((prev) => !prev)}
className="flex items-center gap-2 rounded-sm px-1 py-1 text-xs text-foreground/80 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{dontAskAgain ? <Check className="size-3" strokeWidth={3} /> : null}
</span>
Don&apos;t ask again
</button>
)}
<span
className={`flex size-4 items-center justify-center rounded-sm border transition-colors ${
dontAskAgain
? 'border-foreground bg-foreground text-background'
: 'border-muted-foreground bg-transparent'
}`}
>
{dontAskAgain ? <Check className="size-3" strokeWidth={3} /> : null}
</span>
Don&apos;t ask again
</button>
)}
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isDeleting}>