340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
// 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'
|
|
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 {
|
|
ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV,
|
|
ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV,
|
|
ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV,
|
|
ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV
|
|
} from '../../shared/orchestration-compatibility-evidence'
|
|
import {
|
|
REMOTE_ARTIFACT_INPUT_ENV,
|
|
sshArtifactSourceKey,
|
|
type RemoteArtifactInput
|
|
} from '../../shared/artifact-cli-bridge'
|
|
|
|
export type SshCliRuntimeAuthority = {
|
|
kind: 'ssh'
|
|
targetId: string
|
|
connectionIncarnation: string
|
|
attachmentId: string
|
|
}
|
|
|
|
export type RemoteOrcaCliRequest = {
|
|
argv: string[]
|
|
cwd: string
|
|
env: Record<string, string>
|
|
stdin?: string
|
|
artifactInput?: RemoteArtifactInput
|
|
runtimeAuthority?: SshCliRuntimeAuthority
|
|
}
|
|
|
|
export type RemoteOrcaCliResult = {
|
|
stdout: string
|
|
stderr: string
|
|
exitCode: number
|
|
postOutput?: RemoteOrcaCliPostOutput
|
|
}
|
|
|
|
export type RemoteOrcaCliPostOutput =
|
|
| {
|
|
kind: 'legacy_check_ack'
|
|
terminal: string
|
|
messageIds: string[]
|
|
types?: string[]
|
|
}
|
|
| {
|
|
kind: 'legacy_question_ack'
|
|
terminal: string
|
|
questionId: string
|
|
answerMessageId: string
|
|
}
|
|
|
|
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_AGENT_LAUNCH_TOKEN',
|
|
'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 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>
|
|
userDataPath: string
|
|
remoteCwd: string
|
|
runtimeAuthority?: SshCliRuntimeAuthority
|
|
artifactInput?: RemoteArtifactInput
|
|
}): 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
|
|
delete env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV]
|
|
delete env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV]
|
|
delete env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV]
|
|
delete env[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV]
|
|
delete env[REMOTE_ARTIFACT_INPUT_ENV]
|
|
if (args.runtimeAuthority) {
|
|
env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV] = 'ssh'
|
|
env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV] = args.runtimeAuthority.targetId
|
|
env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV] =
|
|
args.runtimeAuthority.connectionIncarnation
|
|
env[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV] = args.runtimeAuthority.attachmentId
|
|
}
|
|
if (args.artifactInput) {
|
|
const sourceKey = args.runtimeAuthority
|
|
? sshArtifactSourceKey(args.runtimeAuthority.targetId, args.artifactInput.sourceKey)
|
|
: args.artifactInput.sourceKey
|
|
env[REMOTE_ARTIFACT_INPUT_ENV] = JSON.stringify({ ...args.artifactInput, sourceKey })
|
|
}
|
|
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 (!isSafeTimerDelayMs(killTimeoutMs)) {
|
|
throw new RangeError(
|
|
`Host CLI kill timeout must be an integer between 0 and ${MAX_TIMER_DELAY_MS}ms.`
|
|
)
|
|
}
|
|
|
|
if (!entryExists(cliEntryPath)) {
|
|
throw new HostCliUnavailableError(`Orca CLI entry not found at ${cliEntryPath}`)
|
|
}
|
|
|
|
const env = buildHostCliEnv({
|
|
hostEnv,
|
|
remoteEnv: request.env,
|
|
userDataPath,
|
|
remoteCwd: request.cwd,
|
|
runtimeAuthority: request.runtimeAuthority,
|
|
artifactInput: request.artifactInput
|
|
})
|
|
|
|
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
|
|
}
|
|
}
|