mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user