Files
orca/src/shared/ephemeral-vm-recipe-process.ts
Jinwoo Hong 500b72d8ef fix(vm): harden provisioned root ownership and cleanup (#14477)
* fix(vm): verify provisioned root ownership

* test(vm): retry transient removal menu

* test(vm): stabilize provisioned root teardown

* fix(vm): clarify recipe-owned cleanup

* fix(vm): pin provisioned root source commit

* fix(vm): make runtime cleanup user-cancellable
2026-08-14 19:04:55 -04:00

211 lines
6.2 KiB
TypeScript

import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import type { EphemeralVmRecipeContext } from './ephemeral-vm-recipe-runner'
const DEFAULT_MAX_CAPTURE_BYTES = 1024 * 1024
const CANCEL_FORCE_KILL_DELAY_MS = 5_000
export type ProcessRunResult = {
stdout: string
stderr: string
exitCode: number | null
signal: NodeJS.Signals | null
aborted?: true
}
export function quoteShellToken(value: string): string {
if (process.platform === 'win32') {
// Inside cmd.exe double quotes, `^` is literal; an embedded `"` is escaped
// by doubling it. This token is only displayed for manual cleanup, so it
// must be valid when pasted into cmd.exe.
return `"${value.replace(/"/g, '""')}"`
}
return `'${value.replace(/'/g, `'\\''`)}'`
}
export async function runRecipeCommand(args: {
command: string
repoPath: string
context: EphemeralVmRecipeContext
mode: 'create' | 'suspend' | 'resume' | 'destroy'
resultSchemaVersion: 1 | 2
stdin?: string
env?: NodeJS.ProcessEnv
maxCaptureBytes?: number
signal?: AbortSignal
onStdout?: (chunk: string) => void
onStderr?: (chunk: string) => void
spawnCommand?: typeof spawn
}): Promise<ProcessRunResult> {
const maxBytes = args.maxCaptureBytes ?? DEFAULT_MAX_CAPTURE_BYTES
const spawnCommand = args.spawnCommand ?? spawn
return new Promise((resolve, reject) => {
let child: ChildProcessWithoutNullStreams
try {
child = spawnCommand(args.command, {
cwd: args.repoPath,
detached: process.platform !== 'win32',
env: buildRecipeEnv(args.env, args.mode, args.context, args.resultSchemaVersion),
shell: true,
windowsHide: true
}) as ChildProcessWithoutNullStreams
} catch (error) {
reject(error)
return
}
let stdout = ''
let stderr = ''
let settled = false
let aborted = false
let forceKillTimer: ReturnType<typeof setTimeout> | undefined
const finish = (result: ProcessRunResult): void => {
if (settled) {
return
}
settled = true
if (forceKillTimer) {
clearTimeout(forceKillTimer)
}
args.signal?.removeEventListener('abort', abort)
resolve(result)
}
const fail = (error: Error): void => {
if (settled) {
return
}
settled = true
if (forceKillTimer) {
clearTimeout(forceKillTimer)
}
args.signal?.removeEventListener('abort', abort)
reject(error)
}
const abort = (): void => {
if (settled) {
return
}
aborted = true
forceKillTimer = setTimeout(() => {
if (settled) {
return
}
killRecipeProcess(child, true)
finish({ stdout, stderr, exitCode: null, signal: null, aborted: true })
child.stdin.destroy()
child.stdout.destroy()
child.stderr.destroy()
child.unref()
}, CANCEL_FORCE_KILL_DELAY_MS)
forceKillTimer.unref()
killRecipeProcess(child)
}
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => {
stdout = appendBounded(stdout, chunk, maxBytes)
args.onStdout?.(chunk)
})
child.stderr.on('data', (chunk: string) => {
stderr = appendBounded(stderr, chunk, maxBytes)
args.onStderr?.(chunk)
})
child.on('error', (error) => {
fail(error)
})
child.on('close', (exitCode, signal) => {
finish({ stdout, stderr, exitCode, signal, ...(aborted ? { aborted: true } : {}) })
})
if (args.signal?.aborted) {
abort()
} else {
args.signal?.addEventListener('abort', abort, { once: true })
}
if (args.stdin) {
child.stdin.end(args.stdin)
} else {
child.stdin.end()
}
})
}
function killRecipeProcess(child: ChildProcessWithoutNullStreams, force = false): void {
const signal = force ? 'SIGKILL' : 'SIGTERM'
if (process.platform === 'win32') {
// Recipes run through `cmd.exe /c` (shell: true), so child.kill() would only
// terminate the wrapper and orphan the actual recipe subprocess (e.g. a cloud
// CLI mid-provision). taskkill /T walks and kills the whole tree.
if (child.pid) {
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
windowsHide: true,
stdio: 'ignore'
})
killer.on('error', () => child.kill(signal))
return
}
child.kill(signal)
return
}
if (child.pid) {
try {
// Recipes run through a shell; kill the process group so shell children do not linger.
process.kill(-child.pid, signal)
return
} catch {
// Fall back to killing the direct child if the process group is already gone.
}
}
child.kill(signal)
}
function buildRecipeEnv(
env: NodeJS.ProcessEnv | undefined,
mode: 'create' | 'suspend' | 'resume' | 'destroy',
context: EphemeralVmRecipeContext,
resultSchemaVersion: 1 | 2
): NodeJS.ProcessEnv {
return {
...process.env,
...env,
ORCA_VM_MODE: mode,
ORCA_VM_INSTANCE_ID: context.instanceId ?? '',
ORCA_RECIPE_ID: context.recipeId,
ORCA_PROJECT_ID: context.projectId ?? '',
ORCA_WORKSPACE_ID: context.workspaceId ?? '',
ORCA_WORKSPACE_NAME: context.workspaceName ?? '',
ORCA_REPO_PATH: context.repoPath,
ORCA_REPO_URL: context.repoUrl ?? '',
ORCA_REPO_BRANCH: context.branch ?? '',
ORCA_REPO_REF: context.ref ?? '',
ORCA_REPO_REF_HEAD: context.expectedRefHead ?? '',
ORCA_RECIPE_RESULT_SCHEMA_VERSION: String(resultSchemaVersion),
ORCA_VERSION: context.orcaVersion ?? ''
}
}
function appendBounded(current: string, chunk: string, maxBytes: number): string {
if (maxBytes <= 0) {
return ''
}
const chunkBytes = Buffer.byteLength(chunk, 'utf8')
if (chunkBytes >= maxBytes) {
return utf8Tail(chunk, maxBytes)
}
return utf8Tail(current, maxBytes - chunkBytes) + chunk
}
function utf8Tail(value: string, maxBytes: number): string {
const bytes = Buffer.from(value, 'utf8')
if (bytes.byteLength <= maxBytes) {
return value
}
let start = bytes.byteLength - maxBytes
while (start < bytes.byteLength && (bytes[start]! & 0xc0) === 0x80) {
start += 1
}
return bytes.subarray(start).toString('utf8')
}