fix(ssh): bridge the full Orca CLI over the SSH relay instead of a hardcoded command switch (#7771)

The relay CLI shim on SSH remotes rejected every orchestration/mutation
command with 'Unsupported SSH Orca CLI command' because the host handled
relay CLI requests with a hand-rolled allowlist of five read-only-ish
commands. The host now runs the real bundled orca CLI entry (same entry
as the local shell command, via ELECTRON_RUN_AS_NODE) as a captured
subprocess, so remote invocations get the full command surface by
construction. Remote cwd is carried via ORCA_CLI_CWD so cwd-based
selectors (--worktree active) resolve against the caller's remote
directory; only Orca terminal-context env vars cross the bridge.

Host-interactive commands (serve, claude-teams, agent-teams-tmux) get a
targeted error, and the legacy in-process switch remains as a fallback
when the host CLI entry cannot be launched. Relay-side request timeouts
are raised to fit mutation and long-poll (--wait/--timeout-ms) commands,
and stdin forwarding now covers *-stdin payload flags.

Fixes #7716

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-07-08 13:59:42 -07:00
committed by GitHub
co-authored by Orca
parent e3eec5c39f
commit 0784a7ea37
12 changed files with 906 additions and 85 deletions
+32
View File
@@ -434,6 +434,38 @@ describe('orca cli worktree awareness', () => {
expect(logSpy).toHaveBeenCalledTimes(1)
})
it('resolves the invocation cwd from ORCA_CLI_CWD when no cwd is passed', async () => {
// Why: the SSH relay bridge runs the CLI on the Orca host with the remote
// shell's cwd carried in ORCA_CLI_CWD (#7716); cwd-based selectors must
// resolve against it, not the host process cwd.
process.env.ORCA_CLI_CWD = '/tmp/repo/feature/src'
try {
queueFixtures(
callMock,
worktreeListFixture([
buildWorktree('/tmp/repo', 'main'),
buildWorktree('/tmp/repo/feature', 'feature/foo')
]),
okFixture('req_1', {
worktree: {
id: 'repo::/tmp/repo/feature',
branch: 'feature/foo',
path: '/tmp/repo/feature'
}
})
)
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['worktree', 'current', '--json'])
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', {
worktree: 'id:repo::/tmp/repo/feature'
})
} finally {
delete process.env.ORCA_CLI_CWD
}
})
it.skipIf(process.platform === 'win32')(
'prepares and starts Claude Agent Teams in the current Orca terminal',
async () => {
+13 -1
View File
@@ -25,7 +25,19 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
)
}
export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise<void> {
// Why: the SSH relay bridge executes this CLI on the Orca host while the
// caller's shell cwd lives on the remote machine (which cannot be chdir'd
// into). ORCA_CLI_CWD carries that remote cwd so cwd-based selectors like
// `--worktree active` resolve against the caller's directory.
function resolveInvocationCwd(): string {
const override = process.env.ORCA_CLI_CWD
return typeof override === 'string' && override.length > 0 ? override : process.cwd()
}
export async function main(
argv = process.argv.slice(2),
cwd = resolveInvocationCwd()
): Promise<void> {
if (argv[0] === 'agent-teams-tmux') {
await runAgentTeamsTmuxShim(argv.slice(1))
return
@@ -0,0 +1,250 @@
import { EventEmitter } from 'node:events'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: {
isPackaged: false,
getAppPath: () => '/host/app'
}
}))
vi.mock('../persistence', () => ({
getCanonicalUserDataPath: () => '/host/user-data'
}))
import {
HostCliUnavailableError,
buildHostCliEnv,
resolveHostCliEntryPath,
resolveHostCliKillTimeoutMs,
runHostOrcaCliPassthrough
} from './ssh-remote-cli-host-passthrough'
type FakeChild = EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
stdin: { end: ReturnType<typeof vi.fn>; on: ReturnType<typeof vi.fn> }
kill: ReturnType<typeof vi.fn>
}
function createFakeChild(): FakeChild {
const child = new EventEmitter() as FakeChild
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.stdin = { end: vi.fn(), on: vi.fn() }
child.kill = vi.fn()
return child
}
const BASE_OPTIONS = {
execPath: '/host/electron',
cliEntryPath: '/host/app/out/cli/index.js',
userDataPath: '/host/user-data',
entryExists: () => true
}
describe('resolveHostCliEntryPath', () => {
it('uses the in-repo entry for dev builds and the unpacked asar entry when packaged', () => {
expect(
resolveHostCliEntryPath({ isPackaged: false, resourcesPath: '/r', appPath: '/host/app' })
).toBe(join('/host/app', 'out', 'cli', 'index.js'))
expect(
resolveHostCliEntryPath({ isPackaged: true, resourcesPath: '/r', appPath: '/host/app' })
).toBe(join('/r', 'app.asar.unpacked', 'out', 'cli', 'index.js'))
})
})
describe('buildHostCliEnv', () => {
it('forwards only Orca terminal-context vars from the remote env', () => {
const env = buildHostCliEnv({
hostEnv: { PATH: '/host/bin', NODE_OPTIONS: '--inspect' },
remoteEnv: {
ORCA_TERMINAL_HANDLE: 'term_remote',
ORCA_WORKTREE_ID: 'repo::/home/alice/wt',
ORCA_PANE_KEY: 'pane-9',
ORCA_WORKSPACE_ID: 'ws-1',
// Why: these are remote-machine paths and must not leak into the host
// subprocess (PATH would break host binary lookup; user-data would
// retarget the CLI at a different local instance).
PATH: '/remote/bin',
ORCA_USER_DATA_PATH: '/remote/user-data'
},
userDataPath: '/host/user-data',
remoteCwd: '/home/alice/wt/sub'
})
expect(env.ORCA_TERMINAL_HANDLE).toBe('term_remote')
expect(env.ORCA_WORKTREE_ID).toBe('repo::/home/alice/wt')
expect(env.ORCA_PANE_KEY).toBe('pane-9')
expect(env.ORCA_WORKSPACE_ID).toBe('ws-1')
expect(env.PATH).toBe('/host/bin')
expect(env.ORCA_USER_DATA_PATH).toBe('/host/user-data')
expect(env.ORCA_CLI_CWD).toBe('/home/alice/wt/sub')
expect(env.ELECTRON_RUN_AS_NODE).toBe('1')
expect(env.NODE_OPTIONS).toBeUndefined()
expect(env.ORCA_NODE_OPTIONS).toBe('--inspect')
})
})
describe('resolveHostCliKillTimeoutMs', () => {
it('extends the kill timer past an explicit --timeout-ms budget', () => {
expect(resolveHostCliKillTimeoutMs(['terminal', 'wait', '--timeout-ms', '1800000'])).toBe(
1_920_000
)
expect(resolveHostCliKillTimeoutMs(['orchestration', 'check', '--timeout-ms=5000'])).toBe(
600_000
)
expect(resolveHostCliKillTimeoutMs(['worktree', 'list'])).toBe(600_000)
})
})
describe('runHostOrcaCliPassthrough', () => {
it('spawns the bundled CLI entry with the remote argv and returns captured output', async () => {
const child = createFakeChild()
const spawn = vi.fn(() => child)
const resultPromise = runHostOrcaCliPassthrough(
{
argv: ['orchestration', 'task-create', '--spec', 'do the thing', '--json'],
cwd: '/home/alice/wt',
env: { ORCA_TERMINAL_HANDLE: 'term_remote' }
},
{ ...BASE_OPTIONS, spawn: spawn as never }
)
await Promise.resolve()
child.stdout.emit('data', Buffer.from('{"ok":true}\n'))
child.stderr.emit('data', Buffer.from('warn\n'))
child.emit('close', 0)
const result = await resultPromise
expect(result).toEqual({ stdout: '{"ok":true}\n', stderr: 'warn\n', exitCode: 0 })
expect(spawn).toHaveBeenCalledTimes(1)
const [execPath, args, options] = spawn.mock.calls[0] as unknown as [
string,
string[],
{ env: NodeJS.ProcessEnv }
]
expect(execPath).toBe('/host/electron')
expect(args).toEqual([
'/host/app/out/cli/index.js',
'orchestration',
'task-create',
'--spec',
'do the thing',
'--json'
])
expect(options.env.ELECTRON_RUN_AS_NODE).toBe('1')
expect(options.env.ORCA_CLI_CWD).toBe('/home/alice/wt')
expect(options.env.ORCA_TERMINAL_HANDLE).toBe('term_remote')
// Why: stdin must be closed even without a payload so CLI handlers that
// stream stdin see EOF instead of hanging forever.
expect(child.stdin.end).toHaveBeenCalledWith()
})
it('pipes a stdin payload to the CLI subprocess', async () => {
const child = createFakeChild()
const spawn = vi.fn(() => child)
const resultPromise = runHostOrcaCliPassthrough(
{
argv: ['linear', 'comment', 'add', 'ENG-1', '--body-file', '-'],
cwd: '/home/alice/wt',
env: {},
stdin: 'comment body'
},
{ ...BASE_OPTIONS, spawn: spawn as never }
)
await Promise.resolve()
child.emit('close', 0)
await resultPromise
expect(child.stdin.end).toHaveBeenCalledWith('comment body')
})
it('propagates non-zero exit codes', async () => {
const child = createFakeChild()
const spawn = vi.fn(() => child)
const resultPromise = runHostOrcaCliPassthrough(
{ argv: ['worktree', 'show'], cwd: '/', env: {} },
{ ...BASE_OPTIONS, spawn: spawn as never }
)
await Promise.resolve()
child.stderr.emit('data', Buffer.from('boom\n'))
child.emit('close', 3)
await expect(resultPromise).resolves.toEqual({ stdout: '', stderr: 'boom\n', exitCode: 3 })
})
it('throws HostCliUnavailableError when the CLI entry is missing', async () => {
const spawn = vi.fn()
await expect(
runHostOrcaCliPassthrough(
{ argv: ['status'], cwd: '/', env: {} },
{ ...BASE_OPTIONS, entryExists: () => false, spawn: spawn as never }
)
).rejects.toBeInstanceOf(HostCliUnavailableError)
expect(spawn).not.toHaveBeenCalled()
})
it('throws HostCliUnavailableError when the subprocess fails to launch', async () => {
const child = createFakeChild()
const spawn = vi.fn(() => child)
const resultPromise = runHostOrcaCliPassthrough(
{ argv: ['status'], cwd: '/', env: {} },
{ ...BASE_OPTIONS, spawn: spawn as never }
)
await Promise.resolve()
child.emit('error', new Error('spawn ENOENT'))
await expect(resultPromise).rejects.toBeInstanceOf(HostCliUnavailableError)
})
it('kills the subprocess and reports an error when the kill timeout elapses', async () => {
vi.useFakeTimers()
try {
const child = createFakeChild()
const spawn = vi.fn(() => child)
const resultPromise = runHostOrcaCliPassthrough(
{ argv: ['terminal', 'wait', '--for', 'exit'], cwd: '/', env: {} },
{ ...BASE_OPTIONS, spawn: spawn as never, killTimeoutMs: 1000 }
)
await vi.advanceTimersByTimeAsync(1001)
const result = await resultPromise
expect(child.kill).toHaveBeenCalledWith('SIGKILL')
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain('timed out')
} finally {
vi.useRealTimers()
}
})
it('caps runaway output instead of buffering it unbounded', async () => {
const child = createFakeChild()
const spawn = vi.fn(() => child)
const resultPromise = runHostOrcaCliPassthrough(
{ argv: ['terminal', 'read'], cwd: '/', env: {} },
{ ...BASE_OPTIONS, spawn: spawn as never }
)
await Promise.resolve()
const chunk = Buffer.alloc(3 * 1024 * 1024, 97)
for (let i = 0; i < 4; i += 1) {
child.stdout.emit('data', chunk)
}
child.emit('close', 0)
const result = await resultPromise
expect(result.stdout.length).toBeLessThanOrEqual(8 * 1024 * 1024 + 64)
expect(result.stdout).toContain('output truncated')
})
})
@@ -0,0 +1,270 @@
// Why: the SSH relay shim (`~/.orca-relay/bin/orca`) forwards CLI invocations
// to the host app. Instead of re-implementing every command in a hand-rolled
// switch (the cause of "Unsupported SSH Orca CLI command", #7716), the host
// runs the real bundled `orca` CLI entry in Electron node mode — the same
// entry the local shell command uses — so remote invocations get the full
// command surface (orchestration, worktree, terminal, ...) by construction.
import { app } from 'electron'
import { spawn as nodeSpawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { getCanonicalUserDataPath } from '../persistence'
export type RemoteOrcaCliRequest = {
argv: string[]
cwd: string
env: Record<string, string>
stdin?: string
}
export type RemoteOrcaCliResult = {
stdout: string
stderr: string
exitCode: number
}
export type HostCliPassthroughOptions = {
execPath?: string
cliEntryPath?: string
userDataPath?: string
hostEnv?: NodeJS.ProcessEnv
spawn?: typeof nodeSpawn
entryExists?: (path: string) => boolean
killTimeoutMs?: number
}
/** Thrown when the host CLI entry cannot be launched at all; callers fall back
* to the legacy in-process command switch so previously-working commands keep
* working even on broken installs. */
export class HostCliUnavailableError extends Error {}
// Why: only Orca terminal-context vars may cross from the remote shell into
// the host CLI process. Remote PATH / ORCA_USER_DATA_PATH are paths on the
// remote machine (meaningless or instance-hijacking on the host), and
// NODE_OPTIONS-style vars could alter host execution.
const REMOTE_CONTEXT_ENV_VARS = [
'ORCA_TERMINAL_HANDLE',
'ORCA_WORKTREE_ID',
'ORCA_PANE_KEY',
'ORCA_WORKSPACE_ID'
] as const
// Why: bound captured output so a runaway command cannot balloon the relay
// JSON-RPC response or main-process memory.
const MAX_CAPTURED_OUTPUT_BYTES = 8 * 1024 * 1024
const DEFAULT_KILL_TIMEOUT_MS = 10 * 60_000
const KILL_TIMEOUT_GRACE_MS = 2 * 60_000
export function resolveHostCliEntryPath(app: {
isPackaged: boolean
resourcesPath: string
appPath: string
}): string {
// Why: mirrors the packaged launcher scripts (resources/*/bin) and the dev
// launcher in cli-installer.ts — packaged builds ship the CLI entry outside
// app.asar so Electron node mode can execute it directly.
return app.isPackaged
? join(app.resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js')
: join(app.appPath, 'out', 'cli', 'index.js')
}
/** Kill timer for the host CLI subprocess. Long-poll commands carry their wait
* budget in `--timeout-ms`; extend past it so the CLI's own timeout fires
* first and produces a proper error message. */
export function resolveHostCliKillTimeoutMs(argv: string[]): number {
const explicit = parseTimeoutMsFlag(argv)
if (explicit !== null && Number.isFinite(explicit) && explicit > 0) {
return Math.max(DEFAULT_KILL_TIMEOUT_MS, explicit + KILL_TIMEOUT_GRACE_MS)
}
return DEFAULT_KILL_TIMEOUT_MS
}
export function buildHostCliEnv(args: {
hostEnv: NodeJS.ProcessEnv
remoteEnv: Record<string, string>
userDataPath: string
remoteCwd: string
}): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...args.hostEnv }
for (const key of REMOTE_CONTEXT_ENV_VARS) {
const value = args.remoteEnv[key]
if (typeof value === 'string' && value.length > 0) {
env[key] = value
}
}
// Why: bind the subprocess to this app instance's runtime metadata (dev and
// parallel instances use non-default userData dirs).
env.ORCA_USER_DATA_PATH = args.userDataPath
// Why: the caller's working directory lives on the remote machine, so the
// subprocess cwd cannot be chdir'd there; ORCA_CLI_CWD carries it for
// cwd-based selectors like `--worktree active`.
env.ORCA_CLI_CWD = args.remoteCwd
// Why: same node-mode hygiene as the shipped CLI launchers — stash and clear
// NODE_OPTIONS so Electron's node bootstrap does not inherit them.
env.ORCA_NODE_OPTIONS = args.hostEnv.NODE_OPTIONS ?? ''
env.ORCA_NODE_REPL_EXTERNAL_MODULE = args.hostEnv.NODE_REPL_EXTERNAL_MODULE ?? ''
delete env.NODE_OPTIONS
delete env.NODE_REPL_EXTERNAL_MODULE
env.ELECTRON_RUN_AS_NODE = '1'
return env
}
export async function runHostOrcaCliPassthrough(
request: RemoteOrcaCliRequest,
options: HostCliPassthroughOptions = {}
): Promise<RemoteOrcaCliResult> {
// Why: per-field lazy defaults keep the module testable — tests inject all
// three, so no Electron API is touched outside the production path.
const execPath = options.execPath ?? process.execPath
let cliEntryPath: string
let userDataPath: string
try {
cliEntryPath =
options.cliEntryPath ??
resolveHostCliEntryPath({
isPackaged: app.isPackaged,
resourcesPath: process.resourcesPath,
appPath: app.getAppPath()
})
// Why: must match the userData dir the runtime RPC server writes metadata
// to (see index.ts OrcaRuntimeRpcServer wiring), or the CLI subprocess
// reports "Orca is not running" against a healthy app.
userDataPath = options.userDataPath ?? getCanonicalUserDataPath()
} catch (err) {
// Why: no Electron app context (or broken install paths) — degrade to the
// caller's legacy in-process fallback instead of failing the command.
throw new HostCliUnavailableError(
`Host CLI environment unavailable: ${err instanceof Error ? err.message : String(err)}`
)
}
const hostEnv = options.hostEnv ?? process.env
const spawn = options.spawn ?? nodeSpawn
const entryExists = options.entryExists ?? existsSync
const killTimeoutMs = options.killTimeoutMs ?? resolveHostCliKillTimeoutMs(request.argv)
if (!entryExists(cliEntryPath)) {
throw new HostCliUnavailableError(`Orca CLI entry not found at ${cliEntryPath}`)
}
const env = buildHostCliEnv({
hostEnv,
remoteEnv: request.env,
userDataPath,
remoteCwd: request.cwd
})
return await new Promise<RemoteOrcaCliResult>((resolve, reject) => {
let settled = false
const child = spawn(execPath, [cliEntryPath, ...request.argv], {
env,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
})
const stdout = new CappedOutputCollector(MAX_CAPTURED_OUTPUT_BYTES)
const stderr = new CappedOutputCollector(MAX_CAPTURED_OUTPUT_BYTES)
const killTimer = setTimeout(() => {
if (settled) {
return
}
settled = true
try {
child.kill('SIGKILL')
} catch {
// best effort — process may already be gone
}
resolve({
stdout: stdout.toString(),
stderr: `${stderr.toString()}Orca CLI bridge timed out after ${killTimeoutMs}ms on the host.\n`,
exitCode: 1
})
}, killTimeoutMs)
killTimer.unref?.()
child.on('error', (err) => {
if (settled) {
return
}
settled = true
clearTimeout(killTimer)
// Why: failure to launch (ENOENT, EACCES) means the host CLI is not
// runnable at all — signal the caller to use the legacy fallback rather
// than reporting a confusing per-command failure.
reject(
new HostCliUnavailableError(`Failed to launch the Orca CLI on the host: ${err.message}`)
)
})
child.stdout?.on('data', (chunk: Buffer) => stdout.push(chunk))
child.stderr?.on('data', (chunk: Buffer) => stderr.push(chunk))
child.on('close', (code) => {
if (settled) {
return
}
settled = true
clearTimeout(killTimer)
resolve({
stdout: stdout.toString(),
stderr: stderr.toString(),
exitCode: typeof code === 'number' ? code : 1
})
})
if (child.stdin) {
child.stdin.on('error', () => {
// Why: the CLI may exit without draining stdin; EPIPE here is routine.
})
if (request.stdin !== undefined) {
child.stdin.end(request.stdin)
} else {
child.stdin.end()
}
}
})
}
class CappedOutputCollector {
private readonly chunks: Buffer[] = []
private bytes = 0
private truncated = false
constructor(private readonly maxBytes: number) {}
push(chunk: Buffer): void {
if (this.truncated) {
return
}
const remaining = this.maxBytes - this.bytes
if (chunk.length >= remaining) {
this.chunks.push(chunk.subarray(0, remaining))
this.bytes = this.maxBytes
this.truncated = true
return
}
this.chunks.push(chunk)
this.bytes += chunk.length
}
toString(): string {
const text = Buffer.concat(this.chunks).toString('utf8')
return this.truncated ? `${text}\n[orca ssh cli] output truncated\n` : text
}
}
function parseTimeoutMsFlag(argv: string[]): number | null {
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i]
if (token === '--timeout-ms') {
const next = argv[i + 1]
const parsed = next === undefined ? Number.NaN : Number(next)
return Number.isFinite(parsed) ? parsed : null
}
if (token.startsWith('--timeout-ms=')) {
const parsed = Number(token.slice('--timeout-ms='.length))
return Number.isFinite(parsed) ? parsed : null
}
}
return null
}
+160 -22
View File
@@ -1,7 +1,45 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: {
isPackaged: false,
getAppPath: () => '/host/app'
}
}))
vi.mock('../persistence', () => ({
getCanonicalUserDataPath: () => '/host/user-data'
}))
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { HostCliPassthroughOptions } from './ssh-remote-cli-host-passthrough'
import { runRemoteOrcaCli } from './ssh-remote-orca-cli'
// Why: pointing the passthrough at a missing CLI entry forces the legacy
// in-process fallback, which is what these dispatch tests exercise.
const LEGACY_FALLBACK_OPTIONS: HostCliPassthroughOptions = {
execPath: '/host/electron',
cliEntryPath: '/host/app/out/cli/index.js',
userDataPath: '/host/user-data',
entryExists: () => false
}
type FakeChild = EventEmitter & {
stdout: EventEmitter
stderr: EventEmitter
stdin: { end: ReturnType<typeof vi.fn>; on: ReturnType<typeof vi.fn> }
kill: ReturnType<typeof vi.fn>
}
function createFakeChild(): FakeChild {
const child = new EventEmitter() as FakeChild
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
child.stdin = { end: vi.fn(), on: vi.fn() }
child.kill = vi.fn()
return child
}
describe('runRemoteOrcaCli', () => {
function createRuntime() {
const messages: {
@@ -93,11 +131,15 @@ describe('runRemoteOrcaCli', () => {
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' }
})
const result = await runRemoteOrcaCli(
runtime,
{
argv: ['orchestration', 'send', '--to', 'term_windows', '--subject', 'ping', '--json'],
cwd: '/home/alice/repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
},
LEGACY_FALLBACK_OPTIONS
)
expect(result.exitCode).toBe(0)
const payload = JSON.parse(result.stdout) as { ok: boolean }
@@ -108,18 +150,22 @@ describe('runRemoteOrcaCli', () => {
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' }
})
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' }
},
LEGACY_FALLBACK_OPTIONS
)
expect(result.exitCode).toBe(0)
const payload = JSON.parse(result.stdout) as { ok: boolean }
@@ -138,11 +184,15 @@ describe('runRemoteOrcaCli', () => {
body: 'hello'
})
const result = await runRemoteOrcaCli(runtime, {
argv: ['orchestration', 'check', '--all', '--json'],
cwd: '/home/alice/repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
const result = await runRemoteOrcaCli(
runtime,
{
argv: ['orchestration', 'check', '--all', '--json'],
cwd: '/home/alice/repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
},
LEGACY_FALLBACK_OPTIONS
)
expect(result.exitCode).toBe(0)
const payload = JSON.parse(result.stdout) as {
@@ -153,4 +203,92 @@ describe('runRemoteOrcaCli', () => {
expect(payload.result.count).toBe(1)
expect(payload.result.messages[0]?.subject).toBe('pong')
})
it('routes previously-unsupported commands through the full host CLI', async () => {
const { runtime } = createRuntime()
const child = createFakeChild()
const spawn = vi.fn(() => child)
const resultPromise = runRemoteOrcaCli(
runtime,
{
argv: ['worktree', 'create', '--repo', 'orca', '--branch', 'fix/x', '--json'],
cwd: '/home/alice/repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
},
{
execPath: '/host/electron',
cliEntryPath: '/host/app/out/cli/index.js',
userDataPath: '/host/user-data',
entryExists: () => true,
spawn: spawn as never
}
)
await Promise.resolve()
child.stdout.emit('data', Buffer.from('{"ok":true}\n'))
child.emit('close', 0)
const result = await resultPromise
expect(result).toEqual({ stdout: '{"ok":true}\n', stderr: '', exitCode: 0 })
const [, args] = spawn.mock.calls[0] as unknown as [string, string[]]
expect(args).toEqual([
'/host/app/out/cli/index.js',
'worktree',
'create',
'--repo',
'orca',
'--branch',
'fix/x',
'--json'
])
})
it('rejects host-interactive commands with a targeted error instead of bridging them', async () => {
const { runtime } = createRuntime()
const spawn = vi.fn()
const result = await runRemoteOrcaCli(
runtime,
{ argv: ['serve'], cwd: '/home/alice', env: {} },
{ ...LEGACY_FALLBACK_OPTIONS, spawn: spawn as never }
)
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain('orca serve')
expect(result.stderr).toContain('SSH relay bridge')
expect(spawn).not.toHaveBeenCalled()
})
it('reports host-interactive command errors as JSON envelopes with --json', async () => {
const { runtime } = createRuntime()
const result = await runRemoteOrcaCli(
runtime,
{ argv: ['serve', '--json'], cwd: '/home/alice', env: {} },
LEGACY_FALLBACK_OPTIONS
)
expect(result.exitCode).toBe(1)
const payload = JSON.parse(result.stdout) as {
ok: boolean
error: { code: string }
}
expect(payload.ok).toBe(false)
expect(payload.error.code).toBe('unsupported_over_ssh')
})
it('explains the root cause when falling back and the command is not in the legacy switch', async () => {
const { runtime } = createRuntime()
const result = await runRemoteOrcaCli(
runtime,
{ argv: ['worktree', 'list'], cwd: '/home/alice', env: {} },
LEGACY_FALLBACK_OPTIONS
)
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain('Unsupported SSH Orca CLI command: worktree list')
expect(result.stderr).toContain('full Orca CLI bridge unavailable')
})
})
+73 -17
View File
@@ -3,30 +3,38 @@ import { RpcDispatcher } from '../runtime/rpc/dispatcher'
import type { RpcResponse } from '../runtime/rpc/core'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { formatRemoteCli } from './ssh-remote-cli-format'
import {
HostCliUnavailableError,
runHostOrcaCliPassthrough,
type HostCliPassthroughOptions,
type RemoteOrcaCliRequest,
type RemoteOrcaCliResult
} from './ssh-remote-cli-host-passthrough'
import {
RemoteCliArgumentError,
getRemoteLinearHelp,
tryDispatchRemoteLinearCli
} from './ssh-remote-linear-cli'
export type RemoteOrcaCliRequest = {
argv: string[]
cwd: string
env: Record<string, string>
stdin?: string
}
export type RemoteOrcaCliResult = {
stdout: string
stderr: string
exitCode: number
}
export type { RemoteOrcaCliRequest, RemoteOrcaCliResult } from './ssh-remote-cli-host-passthrough'
type ParsedRemoteCli = {
commandPath: string[]
flags: Map<string, string | boolean>
}
// Why: these commands run a foreground/interactive process attached to the
// caller's TTY (or a local tmux pane), which a buffered one-shot relay bridge
// cannot host. Everything else routes through the full host CLI.
const HOST_INTERACTIVE_COMMANDS: Record<string, string> = {
serve:
'orca serve starts a foreground headless Orca server and cannot run through the SSH relay bridge. Run it directly on the machine that should host Orca.',
'claude-teams':
'orca claude-teams starts an interactive Claude Code session and cannot run through the SSH relay bridge. Run it in a terminal on the Orca host machine.',
'agent-teams-tmux':
'orca agent-teams-tmux is a tmux pane shim for the Orca host machine and cannot run through the SSH relay bridge.'
}
const REMOTE_BOOLEAN_FLAGS = new Set([
'all',
'attachments',
@@ -48,18 +56,60 @@ const REPEATABLE_REMOTE_STRING_FLAGS = new Set(['label'])
export async function runRemoteOrcaCli(
runtime: OrcaRuntimeService,
request: RemoteOrcaCliRequest
request: RemoteOrcaCliRequest,
passthroughOptions?: HostCliPassthroughOptions
): Promise<RemoteOrcaCliResult> {
const dispatcher = new RpcDispatcher({ runtime })
const parsed = parseRemoteCliArgs(request.argv)
const json = parsed.flags.has('json')
const interactiveMessage = HOST_INTERACTIVE_COMMANDS[parsed.commandPath[0] ?? '']
if (interactiveMessage) {
if (json) {
return {
stdout: `${JSON.stringify(buildLocalError(interactiveMessage, 'unsupported_over_ssh'), null, 2)}\n`,
stderr: '',
exitCode: 1
}
}
return { stdout: '', stderr: `${interactiveMessage}\n`, exitCode: 1 }
}
let passthroughFailure: HostCliUnavailableError | null = null
try {
return await runHostOrcaCliPassthrough(request, passthroughOptions)
} catch (err) {
if (!(err instanceof HostCliUnavailableError)) {
throw err
}
// Why: fall back to the legacy in-process command switch below so the
// historical read-only/orchestration surface keeps working even when the
// bundled CLI entry cannot be launched on this install.
passthroughFailure = err
}
return await runLegacyRemoteOrcaCli(runtime, request, parsed, json, passthroughFailure)
}
async function runLegacyRemoteOrcaCli(
runtime: OrcaRuntimeService,
request: RemoteOrcaCliRequest,
parsed: ParsedRemoteCli,
json: boolean,
passthroughFailure: HostCliUnavailableError
): Promise<RemoteOrcaCliResult> {
const dispatcher = new RpcDispatcher({ runtime })
const help = getRemoteLinearHelp(parsed)
if (help) {
return { stdout: `${help}\n`, stderr: '', exitCode: 0 }
}
try {
const response = await dispatchRemoteCli(dispatcher, parsed, request.env, request.stdin)
const response = await dispatchRemoteCli(
dispatcher,
parsed,
request.env,
request.stdin,
passthroughFailure.message
)
const formatted = json
? { stdout: `${JSON.stringify(response, null, 2)}\n`, stderr: '' }
: formatRemoteCli(response)
@@ -93,7 +143,8 @@ async function dispatchRemoteCli(
dispatcher: RpcDispatcher,
parsed: ParsedRemoteCli,
env: Record<string, string>,
stdin?: string
stdin: string | undefined,
passthroughFailureReason: string
): Promise<RpcResponse> {
const command = parsed.commandPath.join(' ')
const linearResponse = await tryDispatchRemoteLinearCli(dispatcher, parsed, env, stdin)
@@ -156,7 +207,12 @@ async function dispatchRemoteCli(
terminal: optionalString(parsed.flags, 'terminal')
})
default:
throw new Error(`Unsupported SSH Orca CLI command: ${command}`)
// Why: only reachable when the full host CLI could not be launched;
// include that root cause so users can fix the install instead of
// assuming the command family is unsupported over SSH.
throw new Error(
`Unsupported SSH Orca CLI command: ${command} (full Orca CLI bridge unavailable: ${passthroughFailureReason})`
)
}
}
+4
View File
@@ -7,6 +7,8 @@ describe('pickRemoteCliEnv', () => {
pickRemoteCliEnv({
ORCA_TERMINAL_HANDLE: 'term_ssh',
ORCA_WORKTREE_ID: 'repo::remote',
ORCA_PANE_KEY: 'pane-1',
ORCA_WORKSPACE_ID: 'workspace-1',
ORCA_USER_DATA_PATH: '/tmp/orca',
PATH: '/usr/bin',
SECRET_TOKEN: 'nope'
@@ -14,6 +16,8 @@ describe('pickRemoteCliEnv', () => {
).toEqual({
ORCA_TERMINAL_HANDLE: 'term_ssh',
ORCA_WORKTREE_ID: 'repo::remote',
ORCA_PANE_KEY: 'pane-1',
ORCA_WORKSPACE_ID: 'workspace-1',
ORCA_USER_DATA_PATH: '/tmp/orca',
PATH: '/usr/bin'
})
+2
View File
@@ -3,6 +3,8 @@ export function pickRemoteCliEnv(env: NodeJS.ProcessEnv): Record<string, string>
for (const key of [
'ORCA_TERMINAL_HANDLE',
'ORCA_WORKTREE_ID',
'ORCA_PANE_KEY',
'ORCA_WORKSPACE_ID',
'ORCA_USER_DATA_PATH',
'PATH',
'Path'
+12
View File
@@ -15,4 +15,16 @@ describe('shouldReadRemoteCliStdin', () => {
false
)
})
it('reads stdin for *-stdin payload flags bridged to the full host CLI', () => {
expect(shouldReadRemoteCliStdin(['computer', 'action', '--app', 'Notes', '--text-stdin'])).toBe(
true
)
expect(shouldReadRemoteCliStdin(['computer', 'action', '--app', 'Notes', '--text', 'hi'])).toBe(
false
)
expect(
shouldReadRemoteCliStdin(['computer', 'action', '--app', 'Notes', '--text-stdin', '--help'])
).toBe(false)
})
})
+6
View File
@@ -2,6 +2,12 @@ export function shouldReadRemoteCliStdin(argv: string[]): boolean {
if (argv.includes('--help') || argv.includes('-h')) {
return false
}
// Why: computer-use style flags (`--text-stdin`, ...) declare a stdin
// payload directly in the flag name; the full-CLI bridge (#7716) must
// forward stdin for them the same way local shells provide it.
if (argv.some((part) => /^--[a-z0-9][a-z0-9-]*-stdin(?:=|$)/.test(part))) {
return true
}
const commandPath = parseRemoteCliCommandPath(argv)
if (!isLinearBodyWriteCommand(commandPath)) {
return false
+35 -29
View File
@@ -2,58 +2,64 @@ import { describe, expect, it } from 'vitest'
import { remoteCliRequestTimeoutMs } from './remote-cli-timeout'
describe('remoteCliRequestTimeoutMs', () => {
it('extends SSH remote CLI timeout for Linear issue context reads', () => {
it('gives Linear issue context reads the general CLI budget', () => {
expect(
remoteCliRequestTimeoutMs({
argv: ['linear', 'issue', 'ENG-123', '--json']
})
).toBe(120_000)
).toBe(300_000)
})
it('extends the timeout when global flags appear before the Linear command', () => {
it('gives ordinary remote CLI requests the general CLI budget instead of the 30s relay default', () => {
// Why: mutation commands bridged through the full host CLI (worktree
// create, orchestration dispatch, ...) can legitimately exceed 30s.
expect(remoteCliRequestTimeoutMs({ argv: ['status'] })).toBe(300_000)
expect(remoteCliRequestTimeoutMs({ argv: ['worktree', 'create', '--repo', 'r'] })).toBe(300_000)
})
it('extends the timeout for wait-style commands', () => {
expect(remoteCliRequestTimeoutMs({ argv: ['terminal', 'wait', '--for', 'exit'] })).toBe(600_000)
expect(
remoteCliRequestTimeoutMs({
argv: ['--json', 'linear', 'issue', 'ENG-123', '--workspace', 'workspace-1', '--full']
argv: ['orchestration', 'check', '--wait', '--json']
})
).toBe(120_000)
})
it('extends SSH remote CLI timeout for Linear search', () => {
).toBe(600_000)
expect(
remoteCliRequestTimeoutMs({
argv: ['linear', 'search', 'auth', '--limit', '1']
argv: ['orchestration', 'ask', '--to', 'term_x', '--question', 'ok?']
})
).toBe(120_000)
).toBe(600_000)
})
it('extends the timeout when boolean flags appear between Linear and search', () => {
it('extends past an explicit --timeout-ms waiter budget', () => {
expect(
remoteCliRequestTimeoutMs({
argv: ['linear', '--json', 'search', 'auth', '--limit', '1']
argv: ['terminal', 'wait', '--for', 'exit', '--timeout-ms', '1800000']
})
).toBe(120_000)
})
it('extends the timeout when boolean flags appear between Linear and issue', () => {
).toBe(1_860_000)
expect(
remoteCliRequestTimeoutMs({
argv: ['linear', '--json', 'issue', 'ENG-123', '--full']
argv: ['orchestration', 'check', '--wait', '--timeout-ms=1800000']
})
).toBe(120_000)
).toBe(1_860_000)
})
it('extends SSH remote CLI timeout for Linear writes', () => {
for (const argv of [
['linear', 'status', 'set', 'ENG-123', '--to', 'Done'],
['linear', 'comment', 'add', 'ENG-123', '--body', 'Done'],
['linear', 'attach', 'ENG-123', '--url', 'https://example.invalid/review'],
['linear', 'create', '--team', 'ENG', '--title', 'Follow up']
]) {
expect(remoteCliRequestTimeoutMs({ argv })).toBe(120_000)
}
it('keeps the wait base budget when --timeout-ms is small', () => {
expect(
remoteCliRequestTimeoutMs({
argv: ['terminal', 'wait', '--for', 'exit', '--timeout-ms', '5000']
})
).toBe(600_000)
})
it('keeps ordinary remote CLI requests on the relay default timeout', () => {
expect(remoteCliRequestTimeoutMs({ argv: ['status'] })).toBeUndefined()
it('does not treat a flag value named wait as a command path element', () => {
expect(remoteCliRequestTimeoutMs({ argv: ['terminal', 'read', '--terminal', 'wait'] })).toBe(
300_000
)
})
it('falls back to the relay default for malformed argv', () => {
expect(remoteCliRequestTimeoutMs({ argv: 'status' })).toBeUndefined()
expect(remoteCliRequestTimeoutMs({})).toBeUndefined()
})
})
+49 -16
View File
@@ -1,4 +1,13 @@
const LINEAR_ISSUE_CONTEXT_TIMEOUT_MS = 120_000
// Why: the host bridges the full Orca CLI over the relay (#7716), so mutation
// commands (worktree create, orchestration dispatch, Linear writes, ...) can
// legitimately outlive the relay's 30 s default request timeout. Long-poll
// commands carry their waiter budget in --timeout-ms; extend past it so the
// host-side CLI produces its proper timeout error instead of the relay
// cutting the request short.
const REMOTE_CLI_DEFAULT_TIMEOUT_MS = 5 * 60_000
const REMOTE_CLI_WAIT_TIMEOUT_MS = 10 * 60_000
const REMOTE_CLI_TIMEOUT_GRACE_MS = 60_000
const REMOTE_TIMEOUT_BOOLEAN_FLAGS = new Set([
'all',
'attachments',
@@ -15,30 +24,54 @@ const REMOTE_TIMEOUT_BOOLEAN_FLAGS = new Set([
])
export function remoteCliRequestTimeoutMs(params: Record<string, unknown>): number | undefined {
return isLinearCliRequest(params) ? LINEAR_ISSUE_CONTEXT_TIMEOUT_MS : undefined
const argv = getStringArgv(params)
if (!argv) {
return undefined
}
const base = isWaitStyleCliRequest(argv)
? REMOTE_CLI_WAIT_TIMEOUT_MS
: REMOTE_CLI_DEFAULT_TIMEOUT_MS
const explicit = parseTimeoutMsFlag(argv)
if (explicit !== null && explicit > 0) {
return Math.max(base, explicit + REMOTE_CLI_TIMEOUT_GRACE_MS)
}
return base
}
function isLinearCliRequest(params: Record<string, unknown>): boolean {
const argv = params.argv
if (!Array.isArray(argv) || !argv.every((part) => typeof part === 'string')) {
return false
function isWaitStyleCliRequest(argv: string[]): boolean {
if (argv.includes('--wait')) {
return true
}
const commandPath = parseRemoteCommandPath(argv)
return commandPath.some(
(part, index) =>
part === 'linear' && isExtendedLinearCliCommand(commandPath.slice(index + 1, index + 4))
return (
(commandPath[0] === 'terminal' && commandPath[1] === 'wait') ||
(commandPath[0] === 'orchestration' && commandPath[1] === 'ask')
)
}
function isExtendedLinearCliCommand(command: string[]): boolean {
const [first, second] = command
if (first === 'issue' || first === 'search' || first === 'attach' || first === 'create') {
return true
function parseTimeoutMsFlag(argv: string[]): number | null {
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index]
let raw: string | undefined
if (token === '--timeout-ms') {
raw = argv[index + 1]
} else if (token.startsWith('--timeout-ms=')) {
raw = token.slice('--timeout-ms='.length)
} else {
continue
}
const parsed = raw === undefined ? Number.NaN : Number(raw)
return Number.isFinite(parsed) ? parsed : null
}
if (first === 'status' && second === 'set') {
return true
return null
}
function getStringArgv(params: Record<string, unknown>): string[] | null {
const argv = params.argv
if (!Array.isArray(argv) || !argv.every((part) => typeof part === 'string')) {
return null
}
return first === 'comment' && second === 'add'
return argv
}
function parseRemoteCommandPath(argv: string[]): string[] {