From 4cc01470059ea67a9bb5a686d565bb5633c78fe0 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 31 May 2026 15:13:15 -0400 Subject: [PATCH] Fix orchestration mail from WSL and SSH terminals (#3918) --- src/main/cli/wsl-cli-installer.test.ts | 67 +++++- src/main/cli/wsl-cli-installer.ts | 2 +- src/main/cli/wsl-cli-scripts.ts | 10 +- src/main/daemon/pty-subprocess.test.ts | 97 +++++++- src/main/daemon/pty-subprocess.ts | 4 + src/main/ipc/pty.test.ts | 34 +++ src/main/ipc/pty.ts | 4 + src/main/ipc/ssh.test.ts | 1 + src/main/providers/local-pty-provider.test.ts | 35 ++- src/main/providers/local-pty-provider.ts | 8 +- src/main/providers/ssh-pty-provider.test.ts | 59 +++++ src/main/providers/ssh-pty-provider.ts | 37 ++- src/main/providers/windows-shell-args.test.ts | 14 +- src/main/providers/windows-shell-args.ts | 9 +- src/main/pty/windows-environment-path.test.ts | 4 +- src/main/pty/wsl-orca-env.test.ts | 22 ++ src/main/pty/wsl-orca-env.ts | 19 ++ src/main/ssh/ssh-channel-multiplexer.ts | 44 +++- src/main/ssh/ssh-relay-deploy.ts | 21 +- .../ssh-relay-session-terminal-error.test.ts | 1 + src/main/ssh/ssh-relay-session.test.ts | 1 + src/main/ssh/ssh-relay-session.ts | 120 ++++++++-- src/main/ssh/ssh-remote-orca-cli.test.ts | 125 ++++++++++ src/main/ssh/ssh-remote-orca-cli.ts | 216 ++++++++++++++++++ src/relay/dispatcher.test.ts | 58 +++++ src/relay/dispatcher.ts | 90 ++++++++ src/relay/pty-shell-launch.ts | 8 +- src/relay/relay.ts | 126 +++++++++- .../sidebar/DeleteWorktreeDialog.tsx | 46 ++-- 29 files changed, 1205 insertions(+), 77 deletions(-) create mode 100644 src/main/pty/wsl-orca-env.test.ts create mode 100644 src/main/pty/wsl-orca-env.ts create mode 100644 src/main/ssh/ssh-remote-orca-cli.test.ts create mode 100644 src/main/ssh/ssh-remote-orca-cli.ts diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index 6b85686ce..b35c56587 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -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() diff --git a/src/main/cli/wsl-cli-installer.ts b/src/main/cli/wsl-cli-installer.ts index 21692899b..9e5d88772 100644 --- a/src/main/cli/wsl-cli-installer.ts +++ b/src/main/cli/wsl-cli-installer.ts @@ -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) { diff --git a/src/main/cli/wsl-cli-scripts.ts b/src/main/cli/wsl-cli-scripts.ts index 23f8f27ce..70e1b16fb 100644 --- a/src/main/cli/wsl-cli-scripts.ts +++ b/src/main/cli/wsl-cli-scripts.ts @@ -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" "$@" ` } diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index df64be7ac..0001f3330 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -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) }) ) }) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 5c1027a42..4b157bfcb 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -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. diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index f22b8e7c6..52a1482bc 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -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 + 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 diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 527b19a07..33148b0b5 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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), diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index baa5bd5ce..df0ff73f0 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -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() diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 93e010392..4f58591d8 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -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) }) ) }) diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index cf7739e09..d906f961e 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -213,11 +213,11 @@ export class LocalPtyProvider implements IPtyProvider { | ((shell: string) => ReturnType) | 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 diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 42c318568..a37b1fe55 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -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' }) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index 1c8c72288..db91c9cc4 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -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 `~/./agent` source dir on the remote disk. The @@ -135,6 +145,29 @@ export class SshPtyProvider implements IPtyProvider { } } + private withRemoteCliBridgeEnv( + env: Record | undefined + ): Record | 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 { await this.mux.request('pty.attach', { id: this.toRelayPtyId(id) }) } diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index d11cb6808..f44ad922e 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -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') diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 91724175c..0e41f9caa 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -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 } diff --git a/src/main/pty/windows-environment-path.test.ts b/src/main/pty/windows-environment-path.test.ts index a5bb21e8f..236b21d4b 100644 --- a/src/main/pty/windows-environment-path.test.ts +++ b/src/main/pty/windows-environment-path.test.ts @@ -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' ) }) diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts new file mode 100644 index 000000000..4615a1613 --- /dev/null +++ b/src/main/pty/wsl-orca-env.test.ts @@ -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 = { 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 = { + WSLENV: 'FOO/u:ORCA_TERMINAL_HANDLE/u:BAR/p' + } + + addOrcaWslInteropEnv(env) + + expect(env.WSLENV).toBe('FOO/u:ORCA_TERMINAL_HANDLE/u:BAR/p') + }) +}) diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts new file mode 100644 index 000000000..37f7e6c7b --- /dev/null +++ b/src/main/pty/wsl-orca-env.ts @@ -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): 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) +} diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts index b03588bed..09642387e 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -31,6 +31,7 @@ type PendingRequest = { export type NotificationHandler = (method: string, params: Record) => void export type MethodNotificationHandler = (params: Record) => void +export type RequestHandler = (params: Record) => Promise | unknown const REQUEST_TIMEOUT_MS = 30_000 @@ -44,6 +45,7 @@ export class SshChannelMultiplexer { private lastReceivedAt = Date.now() private pendingRequests = new Map() private notificationHandlers: NotificationHandler[] = [] + private requestHandlers = new Map() // 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 { + 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 { diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 51c710da4..d88ceddc6 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -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 { @@ -423,7 +434,7 @@ async function launchRelay( remoteDir: string, graceTimeSeconds?: number, relayInstanceId?: string -): Promise { +): 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 } } diff --git a/src/main/ssh/ssh-relay-session-terminal-error.test.ts b/src/main/ssh/ssh-relay-session-terminal-error.test.ts index 8f2f67248..34c67bb55 100644 --- a/src/main/ssh/ssh-relay-session-terminal-error.test.ts +++ b/src/main/ssh/ssh-relay-session-terminal-error.test.ts @@ -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) diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index b9975e05a..a45e356d0 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -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) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index d0d9638e7..c696be206 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -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 { + 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((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("'", `'\\''`)}'` +} diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts new file mode 100644 index 000000000..9d07d2aa7 --- /dev/null +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -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') + }) +}) diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts new file mode 100644 index 000000000..9b61d6b93 --- /dev/null +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -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 +} + +export type RemoteOrcaCliResult = { + stdout: string + stderr: string + exitCode: number +} + +type ParsedRemoteCli = { + commandPath: string[] + flags: Map +} + +export async function runRemoteOrcaCli( + runtime: OrcaRuntimeService, + request: RemoteOrcaCliRequest +): Promise { + 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 +): Promise { + 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 +): Promise { + 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() + 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, + env: Record, + flagName: string +): string { + return optionalString(flags, flagName) ?? env.ORCA_TERMINAL_HANDLE ?? 'unknown' +} + +function requiredString(flags: Map, name: string): string { + const value = optionalString(flags, name) + if (!value) { + throw new Error(`Missing --${name}`) + } + return value +} + +function optionalString(flags: Map, name: string): string | undefined { + const value = flags.get(name) + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function optionalNumber(flags: Map, 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 + 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' } + } +} diff --git a/src/relay/dispatcher.test.ts b/src/relay/dispatcher.test.ts index 5ca845be3..5598ae46e 100644 --- a/src/relay/dispatcher.test.ts +++ b/src/relay/dispatcher.test.ts @@ -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(() => { diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index e34928513..41809a4d7 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -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 +} + +const RELAY_TO_CLIENT_REQUEST_TIMEOUT_MS = 30_000 + export class RelayDispatcher { private readonly primaryClient: RelayClient private readonly clients = new Map() private requestHandlers = new Map() private notificationHandlers = new Map() private readonly requestAborts = new ClientRequestAborts() + private pendingRelayRequests = new Map() private clientDetachListeners = new Set<(clientId: number) => void>() private keepaliveTimer: ReturnType | 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, + options?: { timeoutMs?: number } + ): Promise { + return this.requestClient(this.primaryClient.id, method, params, options) + } + + requestAnyClient( + method: string, + params?: Record, + options?: { timeoutMs?: number; excludeClientId?: number } + ): Promise { + 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, + options?: { timeoutMs?: number } + ): Promise { + 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 { const handler = this.requestHandlers.get(req.method) if (!handler) { diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index 9eb69b396..8de35b5f5 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -17,7 +17,10 @@ function quotePosixSingle(value: string): string { function hasOverlayRestoreEnv(env: Record): 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. diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 213d71623..ae62a4fb7 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -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 { + const picked: Record = {} + 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 { - 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 { 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): { graceTimeMs: number } { const seconds = Number(params.graceTimeSeconds) if (Number.isFinite(seconds) && seconds >= 0) { diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx index 8f100408b..ac1826d8c 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.tsx @@ -357,29 +357,31 @@ const DeleteWorktreeDialog = React.memo(function DeleteWorktreeDialog() { )} - {!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. - - )} + + {dontAskAgain ? : null} + + Don't ask again + + )}