fix(ssh): preserve terminal prompt observation deadlines

This commit is contained in:
Jinwoo-H
2026-09-05 00:42:37 -04:00
parent f06b1a3f3b
commit 3979c5a159
5 changed files with 91 additions and 90 deletions
+43
View File
@@ -0,0 +1,43 @@
import { parseRemoteCliArgs } from './ssh-remote-cli-args'
import { clampOrchestrationAskTimeoutMs } from '../../shared/orchestration-ask-timeout'
import {
isSafeTimerDelayMs,
parsePositiveSafeIntegerNumericText,
parsePositiveSafeIntegerText
} from '../../shared/timer-delay'
const DEFAULT_KILL_TIMEOUT_MS = 10 * 60_000
const KILL_TIMEOUT_GRACE_MS = 2 * 60_000
/** 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 parsed = parseRemoteCliArgs(argv)
const rawTimeout = parsed.flags.get('timeout-ms')
if (parsed.commandPath[0] === 'terminal' && parsed.commandPath[1] === 'send') {
const rawWait = parsed.flags.get('wait-submit')
const seconds =
typeof rawWait === 'string' ? parsePositiveSafeIntegerNumericText(rawWait) : null
if (seconds !== null && seconds <= 3600) {
return Math.max(DEFAULT_KILL_TIMEOUT_MS, seconds * 1000 + KILL_TIMEOUT_GRACE_MS)
}
}
if (parsed.commandPath[0] === 'orchestration' && parsed.commandPath[1] === 'ask') {
const explicit =
typeof rawTimeout === 'string' ? parsePositiveSafeIntegerText(rawTimeout) : null
return Math.max(
DEFAULT_KILL_TIMEOUT_MS,
clampOrchestrationAskTimeoutMs(explicit ?? undefined) + KILL_TIMEOUT_GRACE_MS
)
}
const explicit =
typeof rawTimeout === 'string' ? parsePositiveSafeIntegerNumericText(rawTimeout) : null
// Why: this feeds the kill timer directly, so a post-grace budget outside the
// timer range degrades to the default instead of throwing at spawn time.
const extended = explicit === null ? null : explicit + KILL_TIMEOUT_GRACE_MS
if (extended !== null && isSafeTimerDelayMs(extended)) {
return Math.max(DEFAULT_KILL_TIMEOUT_MS, extended)
}
return DEFAULT_KILL_TIMEOUT_MS
}
+2 -19
View File
@@ -1,3 +1,4 @@
import { CLI_BOOLEAN_FLAGS } from '../../shared/cli-argument-boundary'
import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-cli-argument-error'
import {
isOrchestrationRetryRequestId,
@@ -5,24 +6,6 @@ import {
VALUELESS_RETRY_REQUEST_GUIDANCE
} from '../../shared/orchestration-retry-request-id'
const REMOTE_BOOLEAN_FLAGS = new Set([
'all',
'attachments',
'children',
'comments',
'current',
'full',
'help',
'inject',
'include-archived',
'include-visual-layouts',
'json',
'me',
'relations',
'parent-current',
'unread',
'wait'
])
const REPEATED_FLAG_SEPARATOR = '\u0000'
const REPEATABLE_REMOTE_STRING_FLAGS = new Set(['label'])
@@ -121,7 +104,7 @@ export function optionalRemoteCliNumber(
function isRemoteBooleanFlag(flag: string, commandPath: string[]): boolean {
// Why: Android launch already uses --activity <name>; only Linear issue reads use it as a boolean.
return (
REMOTE_BOOLEAN_FLAGS.has(flag) ||
CLI_BOOLEAN_FLAGS.has(flag) ||
(flag === 'activity' && commandPath[0] === 'linear' && commandPath[1] === 'issue')
)
}
@@ -160,6 +160,27 @@ describe('buildHostCliEnv', () => {
})
describe('resolveHostCliKillTimeoutMs', () => {
it.each([
['--wait-submit', '3600'],
['--wait-submit=3600'],
['--wait-submit=3600.000000000000001'],
['--wait-submit', '1', '--wait-submit=3600']
])('keeps SSH prompt observation inside both outer deadlines: %j', (...waitFlags) => {
const argv = ['terminal', 'send', '--text', 'review', '--enter', ...waitFlags]
const innerTimeout = 3_600_000 + 10_000
const hostTimeout = resolveHostCliKillTimeoutMs(argv)
const relayTimeout = remoteCliRequestTimeoutMs({ argv })!
expect(hostTimeout).toBeGreaterThan(innerTimeout)
expect(relayTimeout).toBeGreaterThan(hostTimeout)
})
it('keeps pre-command Enter flags inside the prompt observation deadline', () => {
const argv = ['--enter', 'terminal', 'send', '--text', 'review', '--wait-submit', '3600']
const hostTimeout = resolveHostCliKillTimeoutMs(argv)
expect(hostTimeout).toBeGreaterThan(3_610_000)
expect(remoteCliRequestTimeoutMs({ argv })).toBeGreaterThan(hostTimeout)
})
it('extends the kill timer past an explicit --timeout-ms budget', () => {
expect(resolveHostCliKillTimeoutMs(['terminal', 'wait', '--timeout-ms', '1800000'])).toBe(
1_920_000
@@ -1,22 +1,12 @@
// 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.
// The SSH shim runs the bundled CLI so remote shells get the full command surface.
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'
import { parseRemoteCliArgs } from './ssh-remote-cli-args'
import { clampOrchestrationAskTimeoutMs } from '../../shared/orchestration-ask-timeout'
import {
MAX_TIMER_DELAY_MS,
isSafeTimerDelayMs,
parsePositiveSafeIntegerNumericText,
parsePositiveSafeIntegerText
} from '../../shared/timer-delay'
import { resolveHostCliKillTimeoutMs } from './ssh-host-cli-deadline'
export { resolveHostCliKillTimeoutMs } from './ssh-host-cli-deadline'
import { MAX_TIMER_DELAY_MS, isSafeTimerDelayMs } from '../../shared/timer-delay'
import {
ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV,
ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV,
@@ -81,10 +71,7 @@ export type HostCliPassthroughOptions = {
* 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.
// Only terminal identity may cross hosts; remote paths and Node options cannot.
const REMOTE_CONTEXT_ENV_VARS = [
'ORCA_TERMINAL_HANDLE',
'ORCA_WORKTREE_ID',
@@ -93,11 +80,8 @@ const REMOTE_CONTEXT_ENV_VARS = [
'ORCA_WORKSPACE_ID'
] as const
// Why: bound captured output so a runaway command cannot balloon the relay
// JSON-RPC response or main-process memory.
// Bound output retained for the relay response.
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
@@ -112,31 +96,6 @@ export function resolveHostCliEntryPath(app: {
: 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 parsed = parseRemoteCliArgs(argv)
const rawTimeout = parsed.flags.get('timeout-ms')
if (parsed.commandPath[0] === 'orchestration' && parsed.commandPath[1] === 'ask') {
const explicit =
typeof rawTimeout === 'string' ? parsePositiveSafeIntegerText(rawTimeout) : null
return Math.max(
DEFAULT_KILL_TIMEOUT_MS,
clampOrchestrationAskTimeoutMs(explicit ?? undefined) + KILL_TIMEOUT_GRACE_MS
)
}
const explicit =
typeof rawTimeout === 'string' ? parsePositiveSafeIntegerNumericText(rawTimeout) : null
// Why: this feeds the kill timer directly, so a post-grace budget outside the
// timer range degrades to the default instead of throwing at spawn time.
const extended = explicit === null ? null : explicit + KILL_TIMEOUT_GRACE_MS
if (extended !== null && isSafeTimerDelayMs(extended)) {
return Math.max(DEFAULT_KILL_TIMEOUT_MS, extended)
}
return DEFAULT_KILL_TIMEOUT_MS
}
export function buildHostCliEnv(args: {
hostEnv: NodeJS.ProcessEnv
remoteEnv: Record<string, string>
+19 -24
View File
@@ -1,3 +1,4 @@
import { CLI_BOOLEAN_FLAGS } from '../shared/cli-argument-boundary'
import { clampOrchestrationAskTimeoutMs } from '../shared/orchestration-ask-timeout'
import {
isSafeTimerDelayMs,
@@ -14,23 +15,8 @@ import {
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 ORCHESTRATION_ASK_RELAY_GRACE_MS = 3 * 60_000
const ORCHESTRATION_ASK_RELAY_BASE_MS = 11 * 60_000
const REMOTE_TIMEOUT_BOOLEAN_FLAGS = new Set([
'all',
'attachments',
'children',
'comments',
'current',
'full',
'help',
'inject',
'json',
'relations',
'unread',
'wait'
])
const REMOTE_CLI_LONG_WAIT_GRACE_MS = 3 * 60_000
const REMOTE_CLI_LONG_WAIT_BASE_MS = 11 * 60_000
export function remoteCliRequestTimeoutMs(params: Record<string, unknown>): number | undefined {
const argv = getStringArgv(params)
@@ -38,12 +24,20 @@ export function remoteCliRequestTimeoutMs(params: Record<string, unknown>): numb
return undefined
}
const commandPath = parseRemoteCommandPath(argv)
const timeoutFlag = findLastTimeoutMsFlag(argv)
const timeoutFlag = findLastTimeoutFlag(argv, 'timeout-ms')
if (commandPath[0] === 'terminal' && commandPath[1] === 'send') {
const waitFlag = findLastTimeoutFlag(argv, 'wait-submit')
const seconds =
waitFlag?.raw === undefined ? null : parsePositiveSafeIntegerNumericText(waitFlag.raw)
if (seconds !== null && seconds <= 3600) {
return Math.max(REMOTE_CLI_LONG_WAIT_BASE_MS, seconds * 1000 + REMOTE_CLI_LONG_WAIT_GRACE_MS)
}
}
if (commandPath[0] === 'orchestration' && commandPath[1] === 'ask') {
const parsed =
timeoutFlag?.raw === undefined ? null : parsePositiveSafeIntegerText(timeoutFlag.raw)
const effective = clampOrchestrationAskTimeoutMs(parsed ?? undefined)
return Math.max(ORCHESTRATION_ASK_RELAY_BASE_MS, effective + ORCHESTRATION_ASK_RELAY_GRACE_MS)
return Math.max(REMOTE_CLI_LONG_WAIT_BASE_MS, effective + REMOTE_CLI_LONG_WAIT_GRACE_MS)
}
const base = isWaitStyleCliRequest(argv, commandPath)
? REMOTE_CLI_WAIT_TIMEOUT_MS
@@ -69,15 +63,16 @@ function isWaitStyleCliRequest(argv: string[], commandPath: string[]): boolean {
)
}
function findLastTimeoutMsFlag(argv: string[]): { raw: string | undefined } | null {
function findLastTimeoutFlag(argv: string[], name: string): { raw: string | undefined } | null {
const flag = `--${name}`
let result: { raw: string | undefined } | null = null
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index]
if (token === '--timeout-ms') {
if (token === flag) {
const next = argv[index + 1]
result = { raw: next?.startsWith('--') ? undefined : next }
} else if (token.startsWith('--timeout-ms=')) {
result = { raw: token.slice('--timeout-ms='.length) }
} else if (token.startsWith(`${flag}=`)) {
result = { raw: token.slice(flag.length + 1) }
}
}
return result
@@ -106,7 +101,7 @@ function parseRemoteCommandPath(argv: string[]): string[] {
}
const next = argv[index + 1]
if (!REMOTE_TIMEOUT_BOOLEAN_FLAGS.has(assignment) && next && !next.startsWith('--')) {
if (!CLI_BOOLEAN_FLAGS.has(assignment) && next && !next.startsWith('--')) {
index += 1
}
}