feat(serve): run orca serve on the local orcad slot behind ORCA_SERVE_RUNTIME=orcad (T6-11) (#24608)

* feat(serve): run orca serve on the local orcad slot behind ORCA_SERVE_RUNTIME=orcad (T6-11)

* fix(serve): keep orcad selection app-side and wait out Windows temp cleanup

* refactor(orcad): move the data-root privacy check out of the instance lock

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-02 03:28:13 -07:00
committed by GitHub
co-authored by m4air
parent 9292d18f65
commit ff4b313f04
27 changed files with 1434 additions and 246 deletions
+45 -108
View File
@@ -1,16 +1,11 @@
import { spawn as spawnProcess, type SpawnOptions } from 'node:child_process'
import { existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { StringDecoder } from 'node:string_decoder'
import { runProcessSync } from '../../shared/child-process/run-process'
import {
SERVE_UPDATE_HANDOFF_PATH_ENV,
getServeUpdateHandoffPath
} from '../../shared/serve-update-handoff'
import {
getEphemeralVmRecipeResultConnection,
parseEphemeralVmRecipeResult
} from '../../shared/ephemeral-vm-recipes'
import { getDefaultUserDataPath } from './metadata'
import { getMacAppBundlePath } from './mac-app-update-bundle'
import {
@@ -19,8 +14,10 @@ import {
superviseForegroundServe
} from './serve-update-supervisor'
import { RuntimeClientError } from './types'
import { SERVE_RUNTIME_ENV } from '../../shared/orcad-local-serve-selection'
import { resolveLocalServeRuntime, serveWithOrcad } from './serve-orcad-launch'
import { waitForRecipeJson } from './serve-recipe-json'
const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout'
const USER_NAMESPACE_PROBE_TIMEOUT_MS = 2_000
export function launchOrcaApp(): void {
@@ -77,18 +74,49 @@ function spawnDetached(command: string, args: string[], options: SpawnOptions):
child.unref()
}
export function serveOrcaApp(
args: {
json?: boolean
port?: string | null
pairingAddress?: string | null
noPairing?: boolean
mobilePairing?: boolean
recipeJson?: boolean
projectRoot?: string | null
} = {}
): Promise<number> {
export type ServeOrcaAppArgs = {
json?: boolean
port?: string | null
pairingAddress?: string | null
noPairing?: boolean
mobilePairing?: boolean
recipeJson?: boolean
projectRoot?: string | null
}
export function serveOrcaApp(args: ServeOrcaAppArgs = {}): Promise<number> {
const executable = resolveForegroundOrcaExecutable()
// Why synchronous unless opted in: the default path must spawn Electron exactly as before.
if (process.env[SERVE_RUNTIME_ENV] !== 'orcad') {
return serveWithElectron(executable, args)
}
if (args.recipeJson && !args.projectRoot) {
throw new RuntimeClientError('invalid_argument', 'Recipe JSON output requires --project-root.')
}
return serveWithSelectedRuntime(executable, args)
}
async function serveWithSelectedRuntime(
executable: string,
args: ServeOrcaAppArgs
): Promise<number> {
const selection = await resolveLocalServeRuntime({
executable,
appRoot: resolveAppRoot(),
userDataPath: getDefaultUserDataPath(),
usesMacUpdateHandoff: args.recipeJson !== true && getMacAppBundlePath(executable) !== null
})
if (selection.kind === 'orcad') {
process.stderr.write(`[serve] running on orcad ${selection.version}\n`)
return serveWithOrcad(selection, args, getDefaultUserDataPath(), spawnProcess)
}
if (selection.reason) {
process.stderr.write(`[serve] using Electron serve: ${selection.reason}\n`)
}
return serveWithElectron(executable, args)
}
function serveWithElectron(executable: string, args: ServeOrcaAppArgs): Promise<number> {
const childArgs = [...getExecutableAppArgs(executable)]
childArgs.push('--serve')
if (args.json) {
@@ -164,97 +192,6 @@ export function serveOrcaApp(
})
}
function waitForRecipeJson(child: ReturnType<typeof spawnProcess>): Promise<number> {
return new Promise((resolve, reject) => {
let output = ''
let settled = false
const timeout = setTimeout(() => {
finish(new RuntimeClientError('runtime_serve_failed', 'Timed out waiting for recipe JSON.'))
child.kill('SIGTERM')
}, 60000)
const finish = (error?: Error): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
child.stdout?.off('data', onData)
child.off('error', onError)
child.off('close', onClose)
if (error) {
reject(error)
return
}
child.stdout?.destroy?.()
child.unref()
resolve(0)
}
const writeIgnoredRecipeStdout = (): void => {
// Why: non-readiness child stdout is untrusted and cannot be safely
// redacted, including schema-valid results with arbitrary user data.
process.stderr.write(`${IGNORED_NON_RECIPE_STDOUT}\n`)
}
const processRecipeOutputLine = (line: string): void => {
const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line
if (!normalizedLine.trim()) {
return
}
const parsed = parseEphemeralVmRecipeResult(normalizedLine)
if (!parsed.ok) {
writeIgnoredRecipeStdout()
return
}
if (getEphemeralVmRecipeResultConnection(parsed.result).type !== 'orca-server') {
writeIgnoredRecipeStdout()
return
}
process.stdout.write(`${normalizedLine.trim()}\n`)
finish()
}
const stdoutDecoder = new StringDecoder('utf8')
const onData = (chunk: Buffer | string): void => {
output += typeof chunk === 'string' ? chunk : stdoutDecoder.write(chunk)
while (!settled) {
const newlineIndex = output.indexOf('\n')
if (newlineIndex === -1) {
return
}
const line = output.slice(0, newlineIndex)
output = output.slice(newlineIndex + 1)
processRecipeOutputLine(line)
}
}
const onError = (error: Error): void => {
finish(error)
}
const onClose = (code: number | null, signal: NodeJS.Signals | null): void => {
if (settled) {
return
}
output += stdoutDecoder.end()
if (output.trim()) {
processRecipeOutputLine(output)
}
if (settled) {
return
}
finish(
new RuntimeClientError(
'runtime_serve_failed',
typeof code === 'number'
? `Orca serve exited before printing valid recipe JSON with code ${code}.`
: `Orca serve exited before printing valid recipe JSON via ${signal}.`
)
)
}
child.stdout?.on('data', onData)
child.once('error', onError)
// Why: `exit` can precede the final piped stdout data. `close` waits until
// stdio closes so a last recipe chunk is not mistaken for missing output.
child.once('close', onClose)
})
}
export function getExecutableAppArgs(executable: string): string[] {
const args = process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT === '1' ? [resolveAppRoot()] : []
if (shouldDisableExtractedAppImageSandbox(executable)) {
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from 'vitest'
import { formatServeRuntimeSelection } from '../../shared/orcad-local-serve-selection'
import type { runProcess } from '../../shared/child-process/run-process'
import { orcadServeArgs, resolveLocalServeRuntime } from './serve-orcad-launch'
type RunProcess = typeof runProcess
function answering(stdout: string, code = 0): RunProcess {
return vi.fn<RunProcess>(async () => ({
code,
signal: null,
stdout,
stderr: '',
timedOut: false
}))
}
const options = {
executable: '/Applications/Orca.app/Contents/MacOS/Orca',
appRoot: '/Applications/Orca.app/Contents/Resources/app.asar',
userDataPath: '/Users/u/Library/Application Support/orca',
usesMacUpdateHandoff: false
}
describe('orca serve asking the app which host to run', () => {
it("runs the app's own selection entry as plain Node and reads its answer", async () => {
const selection = {
kind: 'orcad' as const,
runtime: '/rt/node',
entry: '/slot/orcad.js',
version: '1'
}
const run = answering(`noise\n${formatServeRuntimeSelection(selection)}\n`)
expect(await resolveLocalServeRuntime({ ...options, usesMacUpdateHandoff: true }, run)).toEqual(
selection
)
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
program: options.executable,
args: [
`${options.appRoot}/out/main/orcad/orcad-local-serve-selection-entry.js`,
'--user-data',
options.userDataPath,
'--app-root',
options.appRoot,
'--mac-update-handoff'
],
env: expect.objectContaining({ ELECTRON_RUN_AS_NODE: '1' })
})
)
})
it('serves on Electron, and says why, when the app gives no answer', async () => {
expect(await resolveLocalServeRuntime(options, answering('', 1))).toEqual({
kind: 'electron',
reason: expect.stringContaining('did not answer')
})
const failing = vi.fn<RunProcess>(async () => {
throw new Error('spawn ENOENT')
})
expect(await resolveLocalServeRuntime(options, failing)).toEqual({
kind: 'electron',
reason: expect.stringContaining('spawn ENOENT')
})
})
it('forwards every desktop serve flag, binding wide as Electron serve does', () => {
expect(
orcadServeArgs({
json: true,
port: '6768',
pairingAddress: '10.0.0.5',
noPairing: true,
mobilePairing: true,
recipeJson: true,
projectRoot: '/work/app'
})
).toEqual([
'--bind',
'0.0.0.0',
'--json',
'--port',
'6768',
'--pairing-address',
'10.0.0.5',
'--no-pairing',
'--mobile-pairing',
'--recipe-json',
'--project-root',
'/work/app'
])
expect(orcadServeArgs({})).toEqual(['--bind', '0.0.0.0'])
})
})
+114
View File
@@ -0,0 +1,114 @@
/**
* `orca serve` on this machine's orcad slot. Whether to (and the slot itself) is decided
* app-side by `src/main/orcad/orcad-local-serve-selection.ts`; the CLI only asks and runs.
*/
import { dirname, join } from 'node:path'
import { runProcess } from '../../shared/child-process/run-process'
import {
ORCAD_LOCAL_SERVE_SELECTION_ENTRY,
ORCAD_LOCAL_SERVE_SELECTION_FLAGS as FLAGS,
parseServeRuntimeSelection,
type ServeRuntimeSelection
} from '../../shared/orcad-local-serve-selection'
import type { ServeOrcaAppArgs } from './launch'
import { waitForRecipeJson } from './serve-recipe-json'
import { superviseForegroundServe } from './serve-update-supervisor'
type SupervisorArgs = Parameters<typeof superviseForegroundServe>[0]
/** A first run may download and verify the pinned Node; bound it well past that. */
const SELECTION_TIMEOUT_MS = 10 * 60_000
/** Asks the app's own entry, run on the app's executable as plain Node, which host to serve on. */
export async function resolveLocalServeRuntime(
options: {
executable: string
appRoot: string
userDataPath: string
usesMacUpdateHandoff: boolean
},
run: typeof runProcess = runProcess
): Promise<ServeRuntimeSelection> {
const entry = join(options.appRoot, 'out', 'main', `${ORCAD_LOCAL_SERVE_SELECTION_ENTRY}.js`)
try {
const result = await run({
program: options.executable,
args: [
entry,
FLAGS.userData,
options.userDataPath,
FLAGS.appRoot,
options.appRoot,
...(options.usesMacUpdateHandoff ? [FLAGS.macUpdateHandoff] : [])
],
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
timeoutMs: SELECTION_TIMEOUT_MS
})
return (
parseServeRuntimeSelection(result.stdout) ?? {
kind: 'electron',
reason: `the app did not answer which serve host to use (exit ${String(result.code)})`
}
)
} catch (error) {
return {
kind: 'electron',
reason: `the app could not check orcad: ${error instanceof Error ? error.message : String(error)}`
}
}
}
/** Electron serve binds every interface (`exposeNetworkByDefault`); orcad does it on request. */
export function serveWithOrcad(
selection: Extract<ServeRuntimeSelection, { kind: 'orcad' }>,
args: ServeOrcaAppArgs,
userDataPath: string,
spawnProcess: SupervisorArgs['spawnChild']
): Promise<number> {
const childArgs = [selection.entry, ...orcadServeArgs(args)]
const spawnOptions: SupervisorArgs['spawnOptions'] = {
detached: args.recipeJson === true,
cwd: dirname(selection.entry),
stdio: args.recipeJson === true ? ['ignore', 'pipe', 'inherit'] : 'inherit',
env: {
...withoutElectronRunAsNode(process.env),
// The desktop's profile: its instance lock makes the two refuse each other.
ORCA_USER_DATA: userDataPath,
ORCA_VERSION: selection.version
}
}
const child = spawnProcess(selection.runtime, childArgs, spawnOptions)
if (args.recipeJson) {
return waitForRecipeJson(child)
}
return superviseForegroundServe({
executable: selection.runtime,
childArgs,
spawnOptions,
spawnChild: spawnProcess,
child,
handoffPath: null,
expectedHandoff: null
})
}
export function orcadServeArgs(args: ServeOrcaAppArgs): string[] {
return [
'--bind',
'0.0.0.0',
...(args.json ? ['--json'] : []),
...(args.port ? ['--port', args.port] : []),
...(args.pairingAddress ? ['--pairing-address', args.pairingAddress] : []),
...(args.noPairing ? ['--no-pairing'] : []),
...(args.mobilePairing ? ['--mobile-pairing'] : []),
...(args.recipeJson && args.projectRoot
? ['--recipe-json', '--project-root', args.projectRoot]
: [])
]
}
function withoutElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const next = { ...env }
delete next.ELECTRON_RUN_AS_NODE
return next
}
+101
View File
@@ -0,0 +1,101 @@
import type { ChildProcessHandle } from '../../shared/child-process/process-spec'
import { StringDecoder } from 'node:string_decoder'
import {
getEphemeralVmRecipeResultConnection,
parseEphemeralVmRecipeResult
} from '../../shared/ephemeral-vm-recipes'
import { RuntimeClientError } from './types'
const IGNORED_NON_RECIPE_STDOUT = '[serve] ignored non-recipe stdout'
/** Relays the one recipe line a detached serve child prints, then lets the CLI exit. */
export function waitForRecipeJson(child: ChildProcessHandle): Promise<number> {
return new Promise((resolve, reject) => {
let output = ''
let settled = false
const timeout = setTimeout(() => {
finish(new RuntimeClientError('runtime_serve_failed', 'Timed out waiting for recipe JSON.'))
child.kill('SIGTERM')
}, 60000)
const finish = (error?: Error): void => {
if (settled) {
return
}
settled = true
clearTimeout(timeout)
child.stdout?.off('data', onData)
child.off('error', onError)
child.off('close', onClose)
if (error) {
reject(error)
return
}
child.stdout?.destroy?.()
child.unref()
resolve(0)
}
const writeIgnoredRecipeStdout = (): void => {
// Why: non-readiness child stdout is untrusted and cannot be safely
// redacted, including schema-valid results with arbitrary user data.
process.stderr.write(`${IGNORED_NON_RECIPE_STDOUT}\n`)
}
const processRecipeOutputLine = (line: string): void => {
const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line
if (!normalizedLine.trim()) {
return
}
const parsed = parseEphemeralVmRecipeResult(normalizedLine)
if (!parsed.ok) {
writeIgnoredRecipeStdout()
return
}
if (getEphemeralVmRecipeResultConnection(parsed.result).type !== 'orca-server') {
writeIgnoredRecipeStdout()
return
}
process.stdout.write(`${normalizedLine.trim()}\n`)
finish()
}
const stdoutDecoder = new StringDecoder('utf8')
const onData = (chunk: Buffer | string): void => {
output += typeof chunk === 'string' ? chunk : stdoutDecoder.write(chunk)
while (!settled) {
const newlineIndex = output.indexOf('\n')
if (newlineIndex === -1) {
return
}
const line = output.slice(0, newlineIndex)
output = output.slice(newlineIndex + 1)
processRecipeOutputLine(line)
}
}
const onError = (error: Error): void => {
finish(error)
}
const onClose = (code: number | null, signal: NodeJS.Signals | null): void => {
if (settled) {
return
}
output += stdoutDecoder.end()
if (output.trim()) {
processRecipeOutputLine(output)
}
if (settled) {
return
}
finish(
new RuntimeClientError(
'runtime_serve_failed',
typeof code === 'number'
? `Orca serve exited before printing valid recipe JSON with code ${code}.`
: `Orca serve exited before printing valid recipe JSON via ${signal}.`
)
)
}
child.stdout?.on('data', onData)
child.once('error', onError)
// Why: `exit` can precede the final piped stdout data. `close` waits until
// stdio closes so a last recipe chunk is not mistaken for missing output.
child.once('close', onClose)
})
}