mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* fix(cli): check the runtime source before sending A terminal Orca launches now carries which Orca launched it (ORCA_RUNTIME_SOURCE_ID = the profile's installation id, ORCA_RUNTIME_SOURCE_INCARNATION = the launching runtime id). Before a request is sent, the CLI compares that with the runtime the profile's runtime record names. Same runtime: sent as before. Different runtime: a read-only status.get asks which profile it serves; the same profile (a restart or update) is accepted, anything else is refused before the request reaches it. The vars cross into WSL through WSLENV. The SSH bridge stamps the bridging runtime itself rather than trusting the remote env. The existing orchestration compatibility stamp is untouched. * fix(orcad): drop an inherited runtime source at startup * fix(cli): an explicit switch to another profile is not fenced The stamp also carries the launching runtime's userData path. A CLI that dials a different profile than the one that launched its terminal, and not the platform default an unpinned terminal falls back to, was pointed there on purpose (orca-dev from a packaged Orca's terminal), so the fence does not apply. * refactor(cli): reuse the shared userData path comparison --------- Co-authored-by: m4air <m4air@Mac.localdomain>
350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
// 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 { 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,
|
|
ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV,
|
|
ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV
|
|
} from '../../shared/orchestration-compatibility-evidence'
|
|
import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env'
|
|
import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker'
|
|
import { ORCA_SSH_BRIDGE_CREDENTIAL_ENV } from '../../shared/ssh-bridge-credential-env'
|
|
import { stampRuntimeSourceEnv, type RuntimeSourceStamp } from '../../shared/runtime-source-env'
|
|
import {
|
|
sshBridgeCredentials,
|
|
type SshBridgeCallerScope,
|
|
type SshBridgeCredentialRegistry
|
|
} from '../runtime/rpc/ssh-bridge-credentials'
|
|
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
|
|
// Why: required so no bridged invocation can run with owner authority over this Orca.
|
|
callerScope: SshBridgeCallerScope
|
|
}
|
|
|
|
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
|
|
credentials?: SshBridgeCredentialRegistry
|
|
/** The runtime this bridge belongs to; the host CLI must reach exactly that one. */
|
|
runtimeSource?: RuntimeSourceStamp | null
|
|
}
|
|
|
|
/** 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 {}
|
|
|
|
// Only terminal identity may cross hosts; remote paths and Node options cannot.
|
|
const REMOTE_CONTEXT_ENV_VARS = [
|
|
'ORCA_TERMINAL_HANDLE',
|
|
'ORCA_WORKTREE_ID',
|
|
'ORCA_PANE_KEY',
|
|
'ORCA_AGENT_LAUNCH_TOKEN',
|
|
'ORCA_WORKSPACE_ID'
|
|
] as const
|
|
|
|
// Bound output retained for the relay response.
|
|
const MAX_CAPTURED_OUTPUT_BYTES = 8 * 1024 * 1024
|
|
|
|
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')
|
|
}
|
|
|
|
export function buildHostCliEnv(args: {
|
|
hostEnv: NodeJS.ProcessEnv
|
|
remoteEnv: Record<string, string>
|
|
userDataPath: string
|
|
remoteCwd: string
|
|
bridgeCredential: string
|
|
runtimeAuthority?: SshCliRuntimeAuthority
|
|
artifactInput?: RemoteArtifactInput
|
|
runtimeSource?: RuntimeSourceStamp | null
|
|
}): 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: recovery commands run on the SSH execution host through its relay shim.
|
|
env.ORCA_CLI_COMMAND = 'orca'
|
|
// 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]
|
|
// Why: a remote command must never claim a local agent session. The host's env carries one only
|
|
// when Orca was launched inside a session, and identity by session id is same-host only.
|
|
delete env[ORCA_AGENT_SESSION_ID_ENV]
|
|
delete env[ORCA_STRUCTURED_SESSION_ENV]
|
|
// Why: this machine's paired-server selection would route the remote command with its credentials.
|
|
delete env.ORCA_PAIRING_CODE
|
|
delete env.ORCA_REMOTE_PAIRING
|
|
delete env.ORCA_ENVIRONMENT
|
|
env[ORCA_SSH_BRIDGE_CREDENTIAL_ENV] = args.bridgeCredential
|
|
// Why: stamped from this process, never from the remote env, so remote input cannot pick the runtime.
|
|
// The host CLI dials exactly this profile, so the stamp names the same one.
|
|
stampRuntimeSourceEnv(
|
|
env,
|
|
args.runtimeSource ? { ...args.runtimeSource, profilePath: args.userDataPath } : null
|
|
)
|
|
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 credential = (options.credentials ?? sshBridgeCredentials).mint(request.callerScope)
|
|
const env = buildHostCliEnv({
|
|
hostEnv,
|
|
remoteEnv: request.env,
|
|
userDataPath,
|
|
remoteCwd: request.cwd,
|
|
bridgeCredential: credential.token,
|
|
runtimeAuthority: request.runtimeAuthority,
|
|
artifactInput: request.artifactInput,
|
|
runtimeSource: options.runtimeSource
|
|
})
|
|
|
|
try {
|
|
return await spawnHostCli(spawn, execPath, cliEntryPath, request, env, killTimeoutMs)
|
|
} finally {
|
|
credential.revoke()
|
|
}
|
|
}
|
|
|
|
function spawnHostCli(
|
|
spawn: typeof nodeSpawn,
|
|
execPath: string,
|
|
cliEntryPath: string,
|
|
request: RemoteOrcaCliRequest,
|
|
env: NodeJS.ProcessEnv,
|
|
killTimeoutMs: number
|
|
): Promise<RemoteOrcaCliResult> {
|
|
return 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
|
|
}
|
|
}
|