Fix orchestration mail from WSL and SSH terminals (#3918)
This commit is contained in:
parent
ce552c4a26
commit
4cc0147005
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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" "$@"
|
||||
`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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("'", `'\\''`)}'`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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' }
|
||||
}
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'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't ask again
|
||||
</button>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isDeleting}>
|
||||
|
|
|
|||
Loading…
Reference in New Issue