Files
orca/src/relay/remote-cli-timeout.ts
T
Jinwoo HongandOrca 0784a7ea37 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>
2026-07-08 13:59:42 -07:00

98 lines
2.7 KiB
TypeScript

// 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',
'children',
'comments',
'current',
'full',
'help',
'inject',
'json',
'relations',
'unread',
'wait'
])
export function remoteCliRequestTimeoutMs(params: Record<string, unknown>): number | 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 isWaitStyleCliRequest(argv: string[]): boolean {
if (argv.includes('--wait')) {
return true
}
const commandPath = parseRemoteCommandPath(argv)
return (
(commandPath[0] === 'terminal' && commandPath[1] === 'wait') ||
(commandPath[0] === 'orchestration' && commandPath[1] === 'ask')
)
}
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
}
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 argv
}
function parseRemoteCommandPath(argv: string[]): string[] {
const commandPath: string[] = []
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index]
if (!token.startsWith('--')) {
commandPath.push(token)
continue
}
const assignment = token.slice(2)
if (assignment.includes('=')) {
continue
}
const next = argv[index + 1]
if (!REMOTE_TIMEOUT_BOOLEAN_FLAGS.has(assignment) && next && !next.startsWith('--')) {
index += 1
}
}
return commandPath
}