mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
295 lines
9.6 KiB
TypeScript
295 lines
9.6 KiB
TypeScript
import { spawn, type ChildProcess } from 'node:child_process'
|
|
import { existsSync } from 'node:fs'
|
|
import { delimiter, join } from 'node:path'
|
|
import type { RelayDispatcher, RequestContext } from './dispatcher'
|
|
import { applyTerminalGitCredentialPromptGuard } from '../shared/terminal-git-credential-guard'
|
|
import { mergeGitConfigEnvProtocol } from '../shared/git-credential-prompt-env'
|
|
import { terminateRelaySubprocessTree } from './subprocess-tree-termination'
|
|
|
|
const DEFAULT_TIMEOUT_MS = 60_000
|
|
const MAX_TIMEOUT_MS = 5 * 60 * 1000
|
|
const MAX_OUTPUT_BYTES = 4 * 1024 * 1024
|
|
const WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR = 'UNSAFE_WINDOWS_BATCH_ARGUMENTS'
|
|
|
|
function getCmdExePath(): string {
|
|
return process.env.ComSpec || `${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\cmd.exe`
|
|
}
|
|
|
|
function isWindowsBatchScript(commandPath: string): boolean {
|
|
return process.platform === 'win32' && /\.(cmd|bat)$/i.test(commandPath)
|
|
}
|
|
|
|
function hasUnsafeWindowsBatchSyntax(value: string): boolean {
|
|
return /[&|<>^"%!\r\n]/.test(value)
|
|
}
|
|
|
|
function quoteWindowsBatchToken(value: string): string {
|
|
if (hasUnsafeWindowsBatchSyntax(value)) {
|
|
throw new Error(WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR)
|
|
}
|
|
return `"${value}"`
|
|
}
|
|
|
|
function resolveWindowsCommand(binary: string, env: NodeJS.ProcessEnv): string {
|
|
if (process.platform !== 'win32') {
|
|
return binary
|
|
}
|
|
if (/[\\/]/.test(binary) || /\.[a-z0-9]+$/i.test(binary)) {
|
|
return binary
|
|
}
|
|
|
|
const pathEnv = env.PATH ?? env.Path
|
|
if (!pathEnv) {
|
|
return binary
|
|
}
|
|
const names = [`${binary}.cmd`, `${binary}.exe`, `${binary}.bat`, binary]
|
|
for (const directory of pathEnv.split(delimiter).filter(Boolean)) {
|
|
for (const name of names) {
|
|
const candidate = join(directory, name)
|
|
if (existsSync(candidate)) {
|
|
return candidate
|
|
}
|
|
}
|
|
}
|
|
return binary
|
|
}
|
|
|
|
function getWindowsSafeSpawn(
|
|
binary: string,
|
|
args: string[],
|
|
env: NodeJS.ProcessEnv
|
|
): { spawnCmd: string; spawnArgs: string[] } {
|
|
const resolvedBinary = resolveWindowsCommand(binary, env)
|
|
if (!isWindowsBatchScript(resolvedBinary)) {
|
|
return { spawnCmd: resolvedBinary, spawnArgs: args }
|
|
}
|
|
const commandLine = [resolvedBinary, ...args].map(quoteWindowsBatchToken).join(' ')
|
|
return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/s', '/c', commandLine] }
|
|
}
|
|
|
|
type ExecParams = {
|
|
binary: unknown
|
|
args: unknown
|
|
cwd: unknown
|
|
stdin: unknown
|
|
timeoutMs: unknown
|
|
env: unknown
|
|
operation: unknown
|
|
}
|
|
|
|
type CancelParams = {
|
|
cwd: unknown
|
|
operation: unknown
|
|
}
|
|
|
|
function laneKeyFor(cwd: string, operation: unknown): string {
|
|
const op = typeof operation === 'string' && operation ? operation : 'default'
|
|
return JSON.stringify([op, cwd])
|
|
}
|
|
|
|
type InFlightExec = { child: ChildProcess; cancel: () => void }
|
|
|
|
type ExecResult = {
|
|
stdout: string
|
|
stderr: string
|
|
exitCode: number | null
|
|
timedOut: boolean
|
|
/** Set when the user canceled the exec via `agent.cancelExec`. */
|
|
canceled?: boolean
|
|
/** Set when the binary could not be spawned (e.g. ENOENT). */
|
|
spawnError?: string
|
|
}
|
|
|
|
/**
|
|
* Non-interactive subprocess exec on the remote host. Used by the AI commit
|
|
* message generator to spawn agent CLIs (claude, codex, …) with the staged
|
|
* diff piped via stdin and the output captured to stdout. Distinct from
|
|
* `pty.spawn` because we want no terminal allocation, no escape sequences,
|
|
* and a clean exit code instead of an interactive session.
|
|
*/
|
|
export class AgentExecHandler {
|
|
// Why: commit-message and PR-field generation can run together for one cwd;
|
|
// operation lanes let cancel target only the user-visible job that stopped.
|
|
private inFlightByLane = new Map<string, InFlightExec>()
|
|
|
|
private laneKey(cwd: string, operation: unknown): string {
|
|
return laneKeyFor(cwd, operation)
|
|
}
|
|
|
|
constructor(dispatcher: RelayDispatcher) {
|
|
dispatcher.onRequest('agent.execNonInteractive', (p, context) =>
|
|
this.exec(p as ExecParams, context)
|
|
)
|
|
dispatcher.onRequest('agent.cancelExec', (p) => this.cancel(p as CancelParams))
|
|
}
|
|
|
|
private async cancel(params: CancelParams): Promise<{ canceled: boolean }> {
|
|
const cwd = typeof params.cwd === 'string' ? params.cwd : ''
|
|
const entry = this.inFlightByLane.get(this.laneKey(cwd, params.operation))
|
|
if (!entry) {
|
|
return { canceled: false }
|
|
}
|
|
entry.cancel()
|
|
return { canceled: true }
|
|
}
|
|
|
|
private async exec(params: ExecParams, context?: RequestContext): Promise<ExecResult> {
|
|
const binary = typeof params.binary === 'string' ? params.binary : ''
|
|
if (!binary) {
|
|
throw new Error('agent.execNonInteractive: binary is required')
|
|
}
|
|
const args = Array.isArray(params.args) ? params.args.map((a) => String(a)) : []
|
|
const cwd = typeof params.cwd === 'string' && params.cwd.length > 0 ? params.cwd : undefined
|
|
const stdinPayload = typeof params.stdin === 'string' ? params.stdin : null
|
|
const requestedTimeout =
|
|
typeof params.timeoutMs === 'number' ? params.timeoutMs : DEFAULT_TIMEOUT_MS
|
|
const timeoutMs = Math.max(1_000, Math.min(MAX_TIMEOUT_MS, requestedTimeout))
|
|
const extraEnv =
|
|
params.env && typeof params.env === 'object' && !Array.isArray(params.env)
|
|
? (params.env as Record<string, string>)
|
|
: null
|
|
const spawnEnv = mergeGitConfigEnvProtocol(process.env, extraEnv ?? undefined) as Record<
|
|
string,
|
|
string
|
|
>
|
|
// Why: this RPC has no interactive terminal, regardless of which wrapper
|
|
// launches the agent or hook command.
|
|
applyTerminalGitCredentialPromptGuard(spawnEnv, {
|
|
isUnattended: true,
|
|
platform: process.platform
|
|
})
|
|
|
|
return new Promise<ExecResult>((resolve) => {
|
|
let child
|
|
try {
|
|
const { spawnCmd, spawnArgs } = getWindowsSafeSpawn(binary, args, spawnEnv)
|
|
child = spawn(spawnCmd, spawnArgs, {
|
|
cwd,
|
|
env: spawnEnv,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
windowsHide: true
|
|
})
|
|
} catch (error) {
|
|
resolve({
|
|
stdout: '',
|
|
stderr: '',
|
|
exitCode: null,
|
|
timedOut: false,
|
|
spawnError: error instanceof Error ? error.message : String(error)
|
|
})
|
|
return
|
|
}
|
|
|
|
let stdout = ''
|
|
let stderr = ''
|
|
let stdoutBytes = 0
|
|
let stderrBytes = 0
|
|
let timedOut = false
|
|
let canceled = false
|
|
let settled = false
|
|
const laneKey = typeof cwd === 'string' ? this.laneKey(cwd, params.operation) : ''
|
|
let entry: InFlightExec | null = null
|
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
let detachChildListeners = (): void => {}
|
|
let detachRequestAbortListener = (): void => {}
|
|
const finish = (result: ExecResult): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
timer = null
|
|
}
|
|
detachRequestAbortListener()
|
|
detachChildListeners()
|
|
if (laneKey && entry && this.inFlightByLane.get(laneKey) === entry) {
|
|
this.inFlightByLane.delete(laneKey)
|
|
}
|
|
resolve(result)
|
|
}
|
|
const cancelCurrent = (): void => {
|
|
canceled = true
|
|
terminateRelaySubprocessTree(child)
|
|
}
|
|
if (laneKey) {
|
|
// Why: the relay owns one visible non-interactive job per cwd+operation.
|
|
// Replacing the lane without canceling the prior child would orphan
|
|
// that process until timeout because future cancelExec calls reach only
|
|
// the newest map entry.
|
|
this.inFlightByLane.get(laneKey)?.cancel()
|
|
entry = {
|
|
child,
|
|
cancel: cancelCurrent
|
|
}
|
|
this.inFlightByLane.set(laneKey, entry)
|
|
}
|
|
|
|
timer = setTimeout(() => {
|
|
timedOut = true
|
|
// Why: tree-kill because some CLIs trap SIGTERM and continue streaming;
|
|
// also Windows wraps `.cmd` shims in cmd.exe, so the immediate child
|
|
// is not the real node.exe process.
|
|
terminateRelaySubprocessTree(child)
|
|
finish({ stdout, stderr, exitCode: null, timedOut, canceled })
|
|
}, timeoutMs)
|
|
|
|
const onStdoutData = (chunk: Buffer): void => {
|
|
stdoutBytes += chunk.byteLength
|
|
if (stdoutBytes > MAX_OUTPUT_BYTES) {
|
|
terminateRelaySubprocessTree(child)
|
|
return
|
|
}
|
|
stdout += chunk.toString('utf-8')
|
|
}
|
|
const onStderrData = (chunk: Buffer): void => {
|
|
stderrBytes += chunk.byteLength
|
|
if (stderrBytes > MAX_OUTPUT_BYTES) {
|
|
terminateRelaySubprocessTree(child)
|
|
return
|
|
}
|
|
stderr += chunk.toString('utf-8')
|
|
}
|
|
const onError = (error: Error): void => {
|
|
finish({
|
|
stdout,
|
|
stderr,
|
|
exitCode: null,
|
|
timedOut,
|
|
spawnError: error.message
|
|
})
|
|
}
|
|
const onClose = (code: number | null): void => {
|
|
finish({ stdout, stderr, exitCode: code, timedOut, canceled })
|
|
}
|
|
child.stdout?.on('data', onStdoutData)
|
|
child.stderr?.on('data', onStderrData)
|
|
child.on('error', onError)
|
|
child.on('close', onClose)
|
|
detachChildListeners = () => {
|
|
child.stdout?.off('data', onStdoutData)
|
|
child.stderr?.off('data', onStderrData)
|
|
child.off('error', onError)
|
|
child.off('close', onClose)
|
|
}
|
|
|
|
if (context?.signal) {
|
|
if (context.signal.aborted) {
|
|
cancelCurrent()
|
|
} else {
|
|
context.signal.addEventListener('abort', cancelCurrent, { once: true })
|
|
detachRequestAbortListener = () => {
|
|
context.signal?.removeEventListener('abort', cancelCurrent)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (stdinPayload !== null) {
|
|
child.stdin?.end(stdinPayload)
|
|
} else {
|
|
child.stdin?.end()
|
|
}
|
|
})
|
|
}
|
|
}
|