mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
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:
+45
-108
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user