mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +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:
@@ -42,6 +42,7 @@ const PLAIN_NODE_ENTRY_NAMES = [
|
||||
'parcel-watcher-process-entry',
|
||||
'computer-sidecar',
|
||||
'wsl-transcript-fs-process-entry',
|
||||
'orcad/orcad-local-serve-selection-entry',
|
||||
...CLI_MAIN_ENTRY_NAMES
|
||||
] as const
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ export function nodeServerTestPaths({ artifact = false, crossRuntime = false } =
|
||||
'src/main/orcad/orcad-node-launcher.integration.test.ts',
|
||||
'src/main/orcad/orcad-stop-request-shutdown.integration.test.ts',
|
||||
'src/main/orcad/orcad-windows-conpty-breakaway.integration.test.ts',
|
||||
'src/main/orcad/orcad-serve-parity.integration.test.ts',
|
||||
'config/scripts/zip-extractor-command.test.mjs'
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CLI_MAIN_ENTRY_NAMES,
|
||||
createPlainNodeEntryGuardPlugin
|
||||
} from './config/build-plugins/plain-node-entry-guard'
|
||||
import { ORCAD_LOCAL_SERVE_SELECTION_ENTRY } from './src/shared/orcad-local-serve-selection'
|
||||
import packageJson from './package.json' with { type: 'json' }
|
||||
|
||||
const BUNDLED_MAIN_DEPENDENCIES = new Set([
|
||||
@@ -265,6 +266,10 @@ export const electronViteConfig: UserConfig = {
|
||||
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
|
||||
// can't take down the main process (issue #7547).
|
||||
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
|
||||
// Why: `orca serve` runs it under ELECTRON_RUN_AS_NODE so the CLI never bundles orcad prep.
|
||||
[ORCAD_LOCAL_SERVE_SELECTION_ENTRY]: resolve(
|
||||
'src/main/orcad/orcad-local-serve-selection-entry.ts'
|
||||
),
|
||||
// Why: a worker thread survives the macOS 26 AppKit main-thread deadlock
|
||||
// without paying for another Electron process.
|
||||
'main-thread-hang-watchdog-entry': resolve(
|
||||
|
||||
+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)
|
||||
})
|
||||
}
|
||||
+14
-1
@@ -1,6 +1,7 @@
|
||||
/** Executable entry for `orcad`. See `./orcad-entry.ts`. */
|
||||
import process from 'node:process'
|
||||
import { main, resolveOrcadExitCode } from './orcad-entry'
|
||||
import { reserveServeStdoutForReadiness } from '../server/serve-stdout-boundary'
|
||||
import { runOrcadNativePreflight } from './orcad-native-preflight'
|
||||
import {
|
||||
ORCAD_PROFILE_PREFLIGHT_FLAG,
|
||||
@@ -8,6 +9,10 @@ import {
|
||||
} from '../../shared/orcad-profile-preflight'
|
||||
import { preflightBundledOrcadStartup, runOrcadProfilePreflight } from './orcad-profile-preflight'
|
||||
import { handoffToBundledOrcad } from './orcad-bundled-runtime'
|
||||
import {
|
||||
formatOrcadNativePreflightReport,
|
||||
ORCAD_NATIVE_PREFLIGHT_FLAG
|
||||
} from '../../shared/orcad-native-preflight-report'
|
||||
import {
|
||||
ORCAD_CANCEL_MANAGED_STOP_FLAG,
|
||||
ORCAD_COMPLETE_MANAGED_STOP_FLAG
|
||||
@@ -57,7 +62,13 @@ function startOrcadProcess(): void {
|
||||
try {
|
||||
if (!handoffToBundledOrcad()) {
|
||||
const flag = process.argv[2]
|
||||
if (
|
||||
if (flag === ORCAD_NATIVE_PREFLIGHT_FLAG && process.argv.length === 3) {
|
||||
void import('./node-pty-precondition').then(({ checkNodePtyPrecondition }) => {
|
||||
const verdict = checkNodePtyPrecondition()
|
||||
const report = formatOrcadNativePreflightReport(verdict.status, verdict.reason ?? null)
|
||||
process.stdout.write(`${report}\n`, () => process.exit(0))
|
||||
}, failStartup)
|
||||
} else if (
|
||||
(flag === ORCAD_PROFILE_PREFLIGHT_FLAG || flag === ORCAD_STARTUP_PREFLIGHT_FLAG) &&
|
||||
process.argv.length === 4
|
||||
) {
|
||||
@@ -68,6 +79,8 @@ function startOrcadProcess(): void {
|
||||
.then(() => process.stdout.write('', () => process.exit(0)))
|
||||
.catch(failStartup)
|
||||
} else {
|
||||
// Why: stdout is the serve readiness API; incidental diagnostics go to stderr.
|
||||
reserveServeStdoutForReadiness()
|
||||
void preflightBundledOrcadStartup()
|
||||
.then(() => {
|
||||
runOrcadNativePreflight()
|
||||
|
||||
@@ -21,6 +21,17 @@ export function parseArgs(argv: string[]): OrcadOptions {
|
||||
options.json = true
|
||||
} else if (arg === '--no-pairing') {
|
||||
options.noPairing = true
|
||||
} else if (arg === '--mobile-pairing') {
|
||||
options.mobilePairing = true
|
||||
} else if (arg === '--recipe-json') {
|
||||
options.recipeJson = true
|
||||
} else if (arg === '--project-root') {
|
||||
const value = argv[i + 1]
|
||||
if (!value) {
|
||||
throw new Error('--project-root expects a value')
|
||||
}
|
||||
options.projectRoot = value
|
||||
i += 1
|
||||
} else if (arg === '--bind') {
|
||||
const value = argv[i + 1]
|
||||
if (value === undefined) {
|
||||
@@ -39,5 +50,8 @@ export function parseArgs(argv: string[]): OrcadOptions {
|
||||
throw new Error(`Unknown argument: ${arg}`)
|
||||
}
|
||||
}
|
||||
if (options.recipeJson && !options.projectRoot) {
|
||||
throw new Error('--recipe-json requires --project-root')
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Test fixture: a terminal client that talks to a profile's terminal daemon directly, the way a
|
||||
// paired client's terminal does, so a test can prove a session outlives a serve-host switch.
|
||||
import { build } from 'esbuild'
|
||||
|
||||
export type DaemonSessionClientResult = { pid: number; isReattach: boolean; output: boolean }
|
||||
|
||||
/** Bundles the client to `outfile`; run it with Node as `<op: create|attach> <daemonDir> <sessionId> <marker> <cwd>`. */
|
||||
export async function buildDaemonSessionClient(outfile: string): Promise<void> {
|
||||
await build({
|
||||
stdin: {
|
||||
contents: `
|
||||
import { DaemonPtyAdapter } from './src/main/daemon/daemon-pty-adapter'
|
||||
import { getDaemonPidPath, getDaemonSocketPath, getDaemonTokenPath } from './src/main/daemon/daemon-spawner'
|
||||
import { setAppEnvironment } from './src/shared/app-environment'
|
||||
const [op, runtimeDir, sessionId, marker, cwd] = process.argv.slice(2)
|
||||
setAppEnvironment({
|
||||
getPath: () => cwd, getAppPath: () => cwd, getVersion: () => 'test', isPackaged: () => true,
|
||||
onWillQuit() {}, exit: code => process.exit(code), getAppMetrics: () => []
|
||||
})
|
||||
const adapter = new DaemonPtyAdapter({
|
||||
socketPath: getDaemonSocketPath(runtimeDir), tokenPath: getDaemonTokenPath(runtimeDir),
|
||||
pidPath: getDaemonPidPath(runtimeDir), profileScope: runtimeDir, runtimeDir
|
||||
})
|
||||
let output = ''
|
||||
adapter.onData(event => { if (event.id === sessionId) output += event.data })
|
||||
const deadline = setTimeout(() => { console.error('timed out; output: ' + output); process.exit(98) }, 20_000)
|
||||
;(async () => {
|
||||
const spawned = await adapter.spawn(op === 'create'
|
||||
? { sessionId, cols: 80, rows: 24, cwd, shellOverride: '/bin/sh' }
|
||||
: { sessionId, cols: 80, rows: 24 })
|
||||
adapter.write(spawned.id, "printf 'ORCA_SERVE_%s\\\\n' " + marker + "\\r")
|
||||
while (!output.includes('ORCA_SERVE_' + marker)) await new Promise(r => setTimeout(r, 50))
|
||||
clearTimeout(deadline)
|
||||
await adapter.disconnectOnly()
|
||||
console.log(JSON.stringify({ pid: spawned.pid, isReattach: spawned.isReattach === true, output: true }))
|
||||
process.exit(0)
|
||||
})().catch(error => { console.error(error); process.exit(1) })
|
||||
`,
|
||||
resolveDir: process.cwd(),
|
||||
loader: 'ts'
|
||||
},
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
target: 'node18',
|
||||
external: ['electron', 'node-pty', '@parcel/watcher', '*.node'],
|
||||
outfile,
|
||||
logLevel: 'silent'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { chmodSync, statSync } from 'node:fs'
|
||||
import process from 'node:process'
|
||||
import { restrictWindowsPathSync } from '../../shared/secure-path-windows-acl'
|
||||
|
||||
export type OrcadInstanceLockCode =
|
||||
| 'orcad_data_root_unusable'
|
||||
| 'orcad_data_root_wrong_owner'
|
||||
| 'orcad_data_root_shared'
|
||||
| 'orcad_instance_lock_held'
|
||||
| 'orcad_instance_lock_foreign_identity'
|
||||
| 'orcad_instance_lock_unreadable'
|
||||
|
||||
export class OrcadInstanceLockError extends Error {
|
||||
constructor(
|
||||
readonly code: OrcadInstanceLockCode,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'OrcadInstanceLockError'
|
||||
}
|
||||
}
|
||||
|
||||
export type OrcadDataRootPrivacyHooks = {
|
||||
platform?: NodeJS.Platform
|
||||
/** Windows: restrict the data root's ACL to this user; false when it could not be applied. */
|
||||
restrictWindowsDataRoot?: (dataRoot: string) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail closed on a data root other identities can read or write.
|
||||
*
|
||||
* Why self-heal first and refuse second: orcad stores credentials unsealed (there is no OS
|
||||
* keyring on this host), so a group- or world-accessible root is a real exposure — but if
|
||||
* we own the directory, tightening it is strictly better than refusing to start. We refuse
|
||||
* only when the permissions are not ours to fix.
|
||||
*/
|
||||
export function assertOrcadDataRootIsPrivate(
|
||||
dataRoot: string,
|
||||
hooks: OrcadDataRootPrivacyHooks
|
||||
): void {
|
||||
// Windows ACLs are not expressible as a POSIX mode, and `statSync().mode` there reports a
|
||||
// synthesized one, so Windows restricts and verifies the ACL instead (icacls, no PowerShell).
|
||||
if ((hooks.platform ?? process.platform) === 'win32') {
|
||||
const restrict =
|
||||
hooks.restrictWindowsDataRoot ?? ((path: string) => restrictWindowsPathSync(path, true))
|
||||
if (!restrict(dataRoot)) {
|
||||
throw new OrcadInstanceLockError(
|
||||
'orcad_data_root_shared',
|
||||
`Could not restrict the orcad data root ${dataRoot} to this user. orcad stores ` +
|
||||
'credentials there unsealed, so it refuses to start. Point ORCA_USER_DATA at a ' +
|
||||
'directory this account owns.'
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
let stats
|
||||
try {
|
||||
stats = statSync(dataRoot)
|
||||
} catch (error) {
|
||||
throw new OrcadInstanceLockError(
|
||||
'orcad_data_root_unusable',
|
||||
`Cannot stat the orcad data root ${dataRoot}: ${(error as Error).message}`
|
||||
)
|
||||
}
|
||||
const uid = process.getuid?.()
|
||||
if (uid !== undefined && stats.uid !== uid) {
|
||||
throw new OrcadInstanceLockError(
|
||||
'orcad_data_root_wrong_owner',
|
||||
`The orcad data root ${dataRoot} is owned by uid ${stats.uid}, not by uid ${uid} running ` +
|
||||
'this process. Give orcad its own data root (ORCA_USER_DATA) or chown this one.'
|
||||
)
|
||||
}
|
||||
if ((stats.mode & 0o077) === 0) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
chmodSync(dataRoot, 0o700)
|
||||
} catch {
|
||||
// Fall through to the re-stat, which produces the actionable message.
|
||||
}
|
||||
let mode: number
|
||||
try {
|
||||
mode = statSync(dataRoot).mode
|
||||
} catch (error) {
|
||||
throw new OrcadInstanceLockError(
|
||||
'orcad_data_root_unusable',
|
||||
`Cannot stat the orcad data root ${dataRoot}: ${(error as Error).message}`
|
||||
)
|
||||
}
|
||||
if ((mode & 0o077) !== 0) {
|
||||
throw new OrcadInstanceLockError(
|
||||
'orcad_data_root_shared',
|
||||
`The orcad data root ${dataRoot} is accessible to other users (mode ` +
|
||||
`${(mode & 0o777).toString(8)}) and could not be tightened. orcad stores credentials ` +
|
||||
'there unsealed, so it refuses to start. Run `chmod 700` on it, or point ORCA_USER_DATA ' +
|
||||
'at a private directory.'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,11 @@ export type OrcadOptions = {
|
||||
json?: boolean
|
||||
noPairing?: boolean
|
||||
pairingAddress?: string
|
||||
/** Desktop `orca serve` parity: a mobile-scoped offer with a terminal QR. */
|
||||
mobilePairing?: boolean
|
||||
/** Desktop `orca serve` parity: print only the ephemeral-VM recipe line. */
|
||||
recipeJson?: boolean
|
||||
projectRoot?: string
|
||||
/** Literal IP to bind. Defaults to loopback; see orcad-bind-address.ts. */
|
||||
bind?: string
|
||||
}
|
||||
@@ -145,6 +150,8 @@ async function startOrcadRuntime(
|
||||
closeOrcadObservability = installOrcadObservability()
|
||||
const { resolveAdvertisedPairingEndpoint } = await import('../runtime/pairing-endpoint')
|
||||
const { ServeReadinessPublisher } = await import('../server/serve-readiness')
|
||||
const { assertServeProjectRoot, renderServePairingQr } =
|
||||
await import('../server/serve-pairing-output')
|
||||
const { createOrcadProfileStateStartup } = await import('./orcad-profile-state-startup')
|
||||
const { startOrcadDaemon, stopOrcadDaemon } = await import('./orcad-daemon-supervision')
|
||||
const { daemonOwnsFreshPersistentPtys } = await import('../daemon/daemon-init')
|
||||
@@ -344,8 +351,8 @@ async function startOrcadRuntime(
|
||||
} as const)
|
||||
: rpc.createPairingOffer({
|
||||
address: options.pairingAddress,
|
||||
name: `CLI ${new Date().toLocaleDateString()}`,
|
||||
scope: 'runtime'
|
||||
name: `${options.mobilePairing ? 'Mobile' : 'CLI'} ${new Date().toLocaleDateString()}`,
|
||||
scope: options.mobilePairing ? 'mobile' : 'runtime'
|
||||
})
|
||||
|
||||
const readiness: ServeReadiness = {
|
||||
@@ -362,8 +369,8 @@ async function startOrcadRuntime(
|
||||
endpoint: offer.endpoint,
|
||||
deviceId: offer.deviceId,
|
||||
webClientUrl: offer.webClientUrl,
|
||||
scope: 'runtime',
|
||||
qr: null
|
||||
scope: options.mobilePairing ? 'mobile' : 'runtime',
|
||||
qr: options.mobilePairing ? await renderServePairingQr(offer.pairingUrl) : null
|
||||
}
|
||||
: offer,
|
||||
// Why in the readiness payload: this is the one message a supervisor and a deploy
|
||||
@@ -372,9 +379,12 @@ async function startOrcadRuntime(
|
||||
health: await collectOrcadHealth(getAppEnvironment().getVersion(), profileStateAuthority)
|
||||
}
|
||||
|
||||
await new ServeReadinessPublisher().publish(readiness, {
|
||||
mode: options.json ? 'json' : 'human'
|
||||
})
|
||||
await new ServeReadinessPublisher().publish(
|
||||
readiness,
|
||||
options.recipeJson && options.projectRoot
|
||||
? { mode: 'recipe-json', projectRoot: assertServeProjectRoot(options.projectRoot) }
|
||||
: { mode: options.json ? 'json' : 'human' }
|
||||
)
|
||||
|
||||
return { readiness }
|
||||
}
|
||||
|
||||
@@ -254,4 +254,47 @@ describe('acquireOrcadInstanceLock', () => {
|
||||
lock.release()
|
||||
}
|
||||
)
|
||||
|
||||
it('makes the desktop app and orcad refuse each other on one profile', () => {
|
||||
const root = makeRoot()
|
||||
const desktop = acquireOrcadInstanceLock(root, hooks({ role: 'desktop' }))
|
||||
expect(JSON.parse(readFileSync(desktop.path, 'utf8')).role).toBe('desktop')
|
||||
expect(() => acquireOrcadInstanceLock(root, hooks({ processIsAlive: () => true }))).toThrow(
|
||||
expect.objectContaining({
|
||||
code: 'orcad_instance_lock_held',
|
||||
message: expect.stringContaining('The Orca desktop app')
|
||||
})
|
||||
)
|
||||
desktop.release()
|
||||
const orcad = acquireOrcadInstanceLock(root, hooks())
|
||||
expect(() =>
|
||||
acquireOrcadInstanceLock(root, hooks({ role: 'desktop', processIsAlive: () => true }))
|
||||
).toThrow(
|
||||
expect.objectContaining({
|
||||
code: 'orcad_instance_lock_held',
|
||||
message: expect.stringContaining('Another orcad')
|
||||
})
|
||||
)
|
||||
orcad.release()
|
||||
})
|
||||
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
"leaves the desktop profile's permissions as they were",
|
||||
() => {
|
||||
const root = makeRoot()
|
||||
chmodSync(root, 0o755)
|
||||
const restricted: string[] = []
|
||||
acquireOrcadInstanceLock(root, hooks({ role: 'desktop' })).release()
|
||||
acquireOrcadInstanceLock(
|
||||
root,
|
||||
hooks({
|
||||
role: 'desktop',
|
||||
platform: 'win32',
|
||||
restrictWindowsDataRoot: (path) => restricted.push(path) > 0
|
||||
})
|
||||
).release()
|
||||
expect(restricted).toEqual([])
|
||||
expect(statSync(root).mode & 0o777).toBe(0o755)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -13,15 +13,7 @@
|
||||
* it says nothing about the daemon, which is what makes a non-destructive restart possible.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
chmodSync,
|
||||
linkSync,
|
||||
mkdirSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { linkSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { userInfo } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
@@ -30,29 +22,17 @@ import {
|
||||
orcadProcessStartTimeMatches,
|
||||
readOrcadProcessStartedAtMs
|
||||
} from './orcad-process-start-time'
|
||||
import { restrictWindowsPathSync } from '../../shared/secure-path-windows-acl'
|
||||
import {
|
||||
assertOrcadDataRootIsPrivate,
|
||||
OrcadInstanceLockError,
|
||||
type OrcadDataRootPrivacyHooks
|
||||
} from './orcad-data-root-privacy'
|
||||
import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader'
|
||||
|
||||
export const ORCAD_LOCK_FILE_NAME = 'orcad.lock'
|
||||
const MAX_ORCAD_LOCK_BYTES = 64 * 1024
|
||||
|
||||
export type OrcadInstanceLockCode =
|
||||
| 'orcad_data_root_unusable'
|
||||
| 'orcad_data_root_wrong_owner'
|
||||
| 'orcad_data_root_shared'
|
||||
| 'orcad_instance_lock_held'
|
||||
| 'orcad_instance_lock_foreign_identity'
|
||||
| 'orcad_instance_lock_unreadable'
|
||||
|
||||
export class OrcadInstanceLockError extends Error {
|
||||
constructor(
|
||||
readonly code: OrcadInstanceLockCode,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'OrcadInstanceLockError'
|
||||
}
|
||||
}
|
||||
export { OrcadInstanceLockError, type OrcadInstanceLockCode } from './orcad-data-root-privacy'
|
||||
|
||||
const OrcadLockRecordSchema = z.object({
|
||||
pid: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
||||
@@ -63,7 +43,9 @@ const OrcadLockRecordSchema = z.object({
|
||||
version: z.string().min(1).max(255),
|
||||
acquiredAt: z.iso.datetime({ offset: true }),
|
||||
/** Distinguishes our record from a replacement written after we lost the race. */
|
||||
nonce: z.string().min(1).max(255)
|
||||
nonce: z.string().min(1).max(255),
|
||||
/** Absent in records orcad wrote before the desktop app shared this lock. */
|
||||
role: z.enum(['orcad', 'desktop']).optional()
|
||||
})
|
||||
|
||||
export type OrcadLockRecord = z.infer<typeof OrcadLockRecordSchema>
|
||||
@@ -79,7 +61,7 @@ export type OrcadInstanceLock = {
|
||||
release(): void
|
||||
}
|
||||
|
||||
export type OrcadInstanceLockHooks = {
|
||||
export type OrcadInstanceLockHooks = OrcadDataRootPrivacyHooks & {
|
||||
identity?: () => string
|
||||
version?: () => string
|
||||
now?: () => Date
|
||||
@@ -87,9 +69,8 @@ export type OrcadInstanceLockHooks = {
|
||||
processIsAlive?: (pid: number) => boolean
|
||||
startedAtMs?: (pid: number) => number | null
|
||||
startTimeMatches?: (pid: number, expected: number | null) => boolean
|
||||
platform?: NodeJS.Platform
|
||||
/** Windows: restrict the data root's ACL to this user; false when it could not be applied. */
|
||||
restrictWindowsDataRoot?: (dataRoot: string) => boolean
|
||||
/** Who takes the profile. The desktop app takes it too, so the two refuse each other. */
|
||||
role?: 'orcad' | 'desktop'
|
||||
}
|
||||
|
||||
function defaultIdentity(): string {
|
||||
@@ -123,75 +104,6 @@ export function parseOrcadInstanceLockRecord(content: string): OrcadLockRecord |
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail closed on a data root other identities can read or write.
|
||||
*
|
||||
* Why self-heal first and refuse second: orcad stores credentials unsealed (there is no OS
|
||||
* keyring on this host), so a group- or world-accessible root is a real exposure — but if
|
||||
* we own the directory, tightening it is strictly better than refusing to start. We refuse
|
||||
* only when the permissions are not ours to fix.
|
||||
*/
|
||||
function assertDataRootIsPrivate(dataRoot: string, hooks: OrcadInstanceLockHooks): void {
|
||||
// Windows ACLs are not expressible as a POSIX mode, and `statSync().mode` there reports a
|
||||
// synthesized one, so Windows restricts and verifies the ACL instead (icacls, no PowerShell).
|
||||
if ((hooks.platform ?? process.platform) === 'win32') {
|
||||
const restrict =
|
||||
hooks.restrictWindowsDataRoot ?? ((path: string) => restrictWindowsPathSync(path, true))
|
||||
if (!restrict(dataRoot)) {
|
||||
throw new OrcadInstanceLockError(
|
||||
'orcad_data_root_shared',
|
||||
`Could not restrict the orcad data root ${dataRoot} to this user. orcad stores ` +
|
||||
'credentials there unsealed, so it refuses to start. Point ORCA_USER_DATA at a ' +
|
||||
'directory this account owns.'
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
let stats
|
||||
try {
|
||||
stats = statSync(dataRoot)
|
||||
} catch (error) {
|
||||
throw new OrcadInstanceLockError(
|
||||
'orcad_data_root_unusable',
|
||||
`Cannot stat the orcad data root ${dataRoot}: ${(error as Error).message}`
|
||||
)
|
||||
}
|
||||
const uid = process.getuid?.()
|
||||
if (uid !== undefined && stats.uid !== uid) {
|
||||
throw new OrcadInstanceLockError(
|
||||
'orcad_data_root_wrong_owner',
|
||||
`The orcad data root ${dataRoot} is owned by uid ${stats.uid}, not by uid ${uid} running ` +
|
||||
'this process. Give orcad its own data root (ORCA_USER_DATA) or chown this one.'
|
||||
)
|
||||
}
|
||||
if ((stats.mode & 0o077) === 0) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
chmodSync(dataRoot, 0o700)
|
||||
} catch {
|
||||
// Fall through to the re-stat, which produces the actionable message.
|
||||
}
|
||||
let mode: number
|
||||
try {
|
||||
mode = statSync(dataRoot).mode
|
||||
} catch (error) {
|
||||
throw new OrcadInstanceLockError(
|
||||
'orcad_data_root_unusable',
|
||||
`Cannot stat the orcad data root ${dataRoot}: ${(error as Error).message}`
|
||||
)
|
||||
}
|
||||
if ((mode & 0o077) !== 0) {
|
||||
throw new OrcadInstanceLockError(
|
||||
'orcad_data_root_shared',
|
||||
`The orcad data root ${dataRoot} is accessible to other users (mode ` +
|
||||
`${(mode & 0o777).toString(8)}) and could not be tightened. orcad stores credentials ` +
|
||||
'there unsealed, so it refuses to start. Run `chmod 700` on it, or point ORCA_USER_DATA ' +
|
||||
'at a private directory.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the lock, or throw an `OrcadInstanceLockError` naming why.
|
||||
*
|
||||
@@ -215,7 +127,10 @@ export function acquireOrcadInstanceLock(
|
||||
`Cannot create the orcad data root ${dataRoot}: ${(error as Error).message}`
|
||||
)
|
||||
}
|
||||
assertDataRootIsPrivate(dataRoot, hooks)
|
||||
// The desktop's profile keeps the permissions it was created with; orcad tightens its own.
|
||||
if ((hooks.role ?? 'orcad') === 'orcad') {
|
||||
assertOrcadDataRootIsPrivate(dataRoot, hooks)
|
||||
}
|
||||
|
||||
const lockPath = join(dataRoot, ORCAD_LOCK_FILE_NAME)
|
||||
const record: OrcadLockRecord = {
|
||||
@@ -224,7 +139,8 @@ export function acquireOrcadInstanceLock(
|
||||
identity,
|
||||
version: (hooks.version ?? (() => process.env.ORCA_VERSION ?? 'unknown'))(),
|
||||
acquiredAt: (hooks.now ?? (() => new Date()))().toISOString(),
|
||||
nonce: randomUUID()
|
||||
nonce: randomUUID(),
|
||||
role: hooks.role ?? 'orcad'
|
||||
}
|
||||
const serialized = JSON.stringify(record)
|
||||
|
||||
@@ -268,9 +184,9 @@ export function acquireOrcadInstanceLock(
|
||||
if (isAlive(existing.pid) && matchesStartTime(existing.pid, existing.startedAtMs)) {
|
||||
throw new OrcadInstanceLockError(
|
||||
'orcad_instance_lock_held',
|
||||
`Another orcad (pid ${existing.pid}, started ${existing.acquiredAt || 'unknown'}) already ` +
|
||||
`owns the data root ${dataRoot}. Stop it before starting another, or use a different ` +
|
||||
'ORCA_USER_DATA.'
|
||||
`${describeLockHolder(existing)} (pid ${existing.pid}, started ` +
|
||||
`${existing.acquiredAt || 'unknown'}) already owns the data root ${dataRoot}. Stop it ` +
|
||||
'before starting another, or use a different ORCA_USER_DATA.'
|
||||
)
|
||||
}
|
||||
// Why rename-and-then-publish rather than unlink-and-write: rename claims one exact
|
||||
@@ -315,6 +231,10 @@ export function acquireOrcadInstanceLock(
|
||||
return makeLock(lockPath, record)
|
||||
}
|
||||
|
||||
function describeLockHolder(record: OrcadLockRecord): string {
|
||||
return record.role === 'desktop' ? 'The Orca desktop app' : 'Another orcad'
|
||||
}
|
||||
|
||||
/** No-clobber restore: a third contender's newer record at the canonical path stays authoritative. */
|
||||
function restoreDisplacedLock(
|
||||
claimPath: string,
|
||||
|
||||
@@ -26,6 +26,25 @@ describe('parseArgs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('takes the desktop serve flags orca serve forwards', () => {
|
||||
expect(
|
||||
parseArgs([
|
||||
'--mobile-pairing',
|
||||
'--recipe-json',
|
||||
'--project-root',
|
||||
'/work/app',
|
||||
'--no-pairing'
|
||||
])
|
||||
).toEqual({
|
||||
mobilePairing: true,
|
||||
recipeJson: true,
|
||||
projectRoot: '/work/app',
|
||||
noPairing: true
|
||||
})
|
||||
expect(() => parseArgs(['--recipe-json'])).toThrow('--recipe-json requires --project-root')
|
||||
expect(() => parseArgs(['--project-root'])).toThrow('--project-root expects a value')
|
||||
})
|
||||
|
||||
it('rejects --bind with no value rather than silently binding the default', () => {
|
||||
expect(() => parseArgs(['--bind'])).toThrow('--bind expects a value')
|
||||
expect(() => parseArgs(['--bind', '--json'])).not.toThrow()
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* `orca serve`'s app-side question, run by the CLI with the app's own executable under
|
||||
* ELECTRON_RUN_AS_NODE: prepare this machine's orcad slot if it can serve, and print one
|
||||
* `ORCA_SERVE_RUNTIME` line saying which host to run. Diagnostics go to stderr.
|
||||
*/
|
||||
import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import {
|
||||
formatServeRuntimeSelection,
|
||||
ORCAD_LOCAL_SERVE_SELECTION_FLAGS as FLAGS
|
||||
} from '../../shared/orcad-local-serve-selection'
|
||||
import { selectServeRuntime } from './orcad-local-serve-selection'
|
||||
|
||||
function flagValue(argv: readonly string[], flag: string): string | null {
|
||||
const index = argv.indexOf(flag)
|
||||
return index === -1 ? null : (argv[index + 1] ?? null)
|
||||
}
|
||||
|
||||
async function run(argv: readonly string[]): Promise<void> {
|
||||
const userDataPath = flagValue(argv, FLAGS.userData)
|
||||
const appRoot = flagValue(argv, FLAGS.appRoot)
|
||||
if (!userDataPath || !appRoot) {
|
||||
throw new Error(`${FLAGS.userData} and ${FLAGS.appRoot} are required`)
|
||||
}
|
||||
const selection = await selectServeRuntime({
|
||||
env: process.env,
|
||||
platform: process.platform,
|
||||
userDataPath,
|
||||
templateDirs: [
|
||||
...(process.resourcesPath ? [join(process.resourcesPath, 'orcad-template')] : []),
|
||||
join(appRoot, 'out', 'orcad-template')
|
||||
],
|
||||
usesMacUpdateHandoff: argv.includes(FLAGS.macUpdateHandoff)
|
||||
})
|
||||
process.stdout.write(`${formatServeRuntimeSelection(selection)}\n`, () => process.exit(0))
|
||||
}
|
||||
|
||||
run(process.argv.slice(2)).catch((error: unknown) => {
|
||||
// Any failure here is a reason to serve on Electron, never to fail `orca serve`.
|
||||
const reason = `orcad selection failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
process.stdout.write(`${formatServeRuntimeSelection({ kind: 'electron', reason })}\n`, () =>
|
||||
process.exit(0)
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NODE_RUNTIME_ASSETS } from '../../shared/node-runtime-pin'
|
||||
import {
|
||||
ORCAD_NODE_RUNTIME_MARKER_FILENAME,
|
||||
ORCAD_SERVER_TARGET_FILENAME,
|
||||
ORCAD_VERSION_FILENAME,
|
||||
orcadNodeRuntimeRelativePath
|
||||
} from '../../shared/orcad-artifacts'
|
||||
import { formatOrcadNativePreflightReport } from '../../shared/orcad-native-preflight-report'
|
||||
import { SERVE_RUNTIME_ENV } from '../../shared/orcad-local-serve-selection'
|
||||
import { selectServeRuntime, type ServeRuntimeSelectionInput } from './orcad-local-serve-selection'
|
||||
|
||||
const TARGET = 'linux-x64-glibc'
|
||||
const SHA = NODE_RUNTIME_ASSETS[TARGET].executableSha256
|
||||
let root = ''
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'orca-serve-runtime-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** A materialized slot as the template would leave it: no runtime of its own yet. */
|
||||
function slotFixture(): string {
|
||||
const slot = join(root, 'orcad-artifacts', TARGET, '0.1.0+abc')
|
||||
mkdirSync(slot, { recursive: true })
|
||||
writeFileSync(join(slot, ORCAD_SERVER_TARGET_FILENAME), `${TARGET}\n`)
|
||||
writeFileSync(join(slot, ORCAD_NODE_RUNTIME_MARKER_FILENAME), `${SHA}\n`)
|
||||
writeFileSync(join(slot, ORCAD_VERSION_FILENAME), '0.1.0+abc\n')
|
||||
writeFileSync(join(slot, 'orcad.js'), '')
|
||||
return slot
|
||||
}
|
||||
|
||||
function input(overrides: Partial<ServeRuntimeSelectionInput> = {}): ServeRuntimeSelectionInput {
|
||||
const template = join(root, 'orcad-template')
|
||||
mkdirSync(template, { recursive: true })
|
||||
const cachedNode = join(root, 'cached-node')
|
||||
writeFileSync(cachedNode, '#!/bin/sh\n')
|
||||
return {
|
||||
env: { [SERVE_RUNTIME_ENV]: 'orcad' },
|
||||
platform: 'linux',
|
||||
userDataPath: root,
|
||||
templateDirs: [join(root, 'missing-template'), template],
|
||||
usesMacUpdateHandoff: false,
|
||||
hostTarget: () => TARGET,
|
||||
materializeSlot: vi.fn(async () => slotFixture()),
|
||||
materializeRuntime: vi.fn(async () => cachedNode),
|
||||
nativePreflight: async () => `${formatOrcadNativePreflightReport('ok', null)}\n`,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('orca serve runtime selection', () => {
|
||||
it('stays on Electron, silently, unless orcad is asked for', async () => {
|
||||
expect(await selectServeRuntime(input({ env: {} }))).toEqual({
|
||||
kind: 'electron',
|
||||
reason: null
|
||||
})
|
||||
})
|
||||
|
||||
it('runs the local slot on its pinned Node, linked into userData beside it', async () => {
|
||||
const options = input()
|
||||
const selection = await selectServeRuntime(options)
|
||||
const slot = join(root, 'orcad-artifacts', TARGET, '0.1.0+abc')
|
||||
const runtime = join(slot, ...orcadNodeRuntimeRelativePath(TARGET, SHA))
|
||||
expect(selection).toEqual({
|
||||
kind: 'orcad',
|
||||
runtime,
|
||||
entry: join(slot, 'orcad.js'),
|
||||
version: '0.1.0+abc'
|
||||
})
|
||||
expect(existsSync(runtime)).toBe(true)
|
||||
expect(runtime.startsWith(join(root, 'orcad-artifacts'))).toBe(true)
|
||||
expect(options.materializeSlot).toHaveBeenCalledWith(TARGET, {
|
||||
templateDir: join(root, 'orcad-template'),
|
||||
cacheRoot: join(root, 'orcad-artifacts')
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Windows', { platform: 'win32' as const }, 'local orcad on Windows is not enabled'],
|
||||
['packaged macOS', { usesMacUpdateHandoff: true }, 'no macOS app-update handoff'],
|
||||
['an unsupported host', { hostTarget: () => 'linux-riscv64-glibc' }, 'no orcad build exists'],
|
||||
['an install without the template', { templateDirs: [] }, 'carries no orcad template'],
|
||||
[
|
||||
'an offline first run',
|
||||
{
|
||||
materializeRuntime: vi.fn(async (): Promise<string> => {
|
||||
throw new Error('fetch failed')
|
||||
})
|
||||
},
|
||||
'could not be prepared: fetch failed'
|
||||
],
|
||||
[
|
||||
'a host whose node-pty cannot load',
|
||||
{
|
||||
nativePreflight: async () => formatOrcadNativePreflightReport('blocked', 'load_crashed')
|
||||
},
|
||||
'cannot run terminals here (blocked: load_crashed)'
|
||||
],
|
||||
[
|
||||
'a silent preflight',
|
||||
{ nativePreflight: async () => '' },
|
||||
'did not answer its native preflight'
|
||||
]
|
||||
])('falls back to Electron on %s and says why', async (_name, overrides, reason) => {
|
||||
const selection = await selectServeRuntime(input(overrides))
|
||||
expect(selection).toEqual({ kind: 'electron', reason: expect.stringContaining(reason) })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Which host runs `orca serve`: orcad on this machine's packaged slot, or Electron `--serve`.
|
||||
* App-side on purpose: the CLI runs it through `orcad-local-serve-selection-entry.ts`, so the
|
||||
* CLI bundle never carries the materializers.
|
||||
*
|
||||
* Opt-in while orcad serve is new: `ORCA_SERVE_RUNTIME=orcad`. Anything that stops orcad from
|
||||
* serving this machine (no slot for the target, no pinned Node, a native module it cannot load,
|
||||
* a host or packaging path it does not cover yet) falls back to Electron with one stderr line
|
||||
* saying why. Everything this writes stays inside the desktop's userData (design D7): the slot
|
||||
* under `orcad-artifacts/`, assembled from the template inside the app bundle.
|
||||
*/
|
||||
import { chmodSync, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import { NODE_RUNTIME_ASSETS, type ServerTarget } from '../../shared/node-runtime-pin'
|
||||
import { ORCAD_VERSION_FILENAME, orcadNodeRuntimeRelativePath } from '../../shared/orcad-artifacts'
|
||||
import {
|
||||
ORCAD_NATIVE_PREFLIGHT_FLAG,
|
||||
parseOrcadNativePreflightReport
|
||||
} from '../../shared/orcad-native-preflight-report'
|
||||
import {
|
||||
SERVE_RUNTIME_ENV,
|
||||
type ServeRuntimeSelection
|
||||
} from '../../shared/orcad-local-serve-selection'
|
||||
import { resolveBundledOrcadRuntime } from './orcad-bundled-runtime'
|
||||
import { detectNativeHostAbi, nativeSlotName } from './native-host-abi'
|
||||
import { materializeOrcadArtifact } from '../ssh/orcad-artifact-materializer'
|
||||
import { materializeCachedNodeRuntime } from '../ssh/pinned-runtime-materializer'
|
||||
|
||||
const NATIVE_PREFLIGHT_TIMEOUT_MS = 30_000
|
||||
|
||||
export type ServeRuntimeSelectionInput = {
|
||||
env: NodeJS.ProcessEnv
|
||||
platform: NodeJS.Platform
|
||||
userDataPath: string
|
||||
templateDirs: readonly string[]
|
||||
/** Packaged macOS serve keeps Electron: orcad has no app-update handoff yet. */
|
||||
usesMacUpdateHandoff: boolean
|
||||
hostTarget?: () => string
|
||||
materializeSlot?: typeof materializeOrcadArtifact
|
||||
materializeRuntime?: typeof materializeCachedNodeRuntime
|
||||
nativePreflight?: (runtime: string, entry: string) => Promise<string>
|
||||
}
|
||||
|
||||
function isServerTarget(value: string): value is ServerTarget {
|
||||
return Object.keys(NODE_RUNTIME_ASSETS).includes(value)
|
||||
}
|
||||
|
||||
function electron(reason: string): ServeRuntimeSelection {
|
||||
return { kind: 'electron', reason }
|
||||
}
|
||||
|
||||
export async function selectServeRuntime(
|
||||
input: ServeRuntimeSelectionInput
|
||||
): Promise<ServeRuntimeSelection> {
|
||||
if (input.env[SERVE_RUNTIME_ENV] !== 'orcad') {
|
||||
return { kind: 'electron', reason: null }
|
||||
}
|
||||
if (input.platform === 'win32') {
|
||||
return electron('local orcad on Windows is not enabled in this build')
|
||||
}
|
||||
if (input.usesMacUpdateHandoff) {
|
||||
return electron(
|
||||
'orcad has no macOS app-update handoff yet; packaged macOS serve stays on Electron'
|
||||
)
|
||||
}
|
||||
const target = (input.hostTarget ?? (() => nativeSlotName(detectNativeHostAbi())))()
|
||||
if (!isServerTarget(target)) {
|
||||
return electron(`no orcad build exists for this host (${target})`)
|
||||
}
|
||||
const templateDir = input.templateDirs.find((candidate) => existsSync(candidate))
|
||||
if (!templateDir) {
|
||||
return electron('this Orca install carries no orcad template')
|
||||
}
|
||||
const cacheRoot = join(input.userDataPath, 'orcad-artifacts')
|
||||
let slotDir: string
|
||||
try {
|
||||
slotDir = await (input.materializeSlot ?? materializeOrcadArtifact)(target, {
|
||||
templateDir,
|
||||
cacheRoot
|
||||
})
|
||||
await placeSlotRuntime(slotDir, target, cacheRoot, input.materializeRuntime)
|
||||
} catch (error) {
|
||||
return electron(`the orcad slot for ${target} could not be prepared: ${errorText(error)}`)
|
||||
}
|
||||
let runtime: string | null
|
||||
try {
|
||||
runtime = resolveBundledOrcadRuntime(slotDir)
|
||||
} catch (error) {
|
||||
return electron(`the orcad slot is incomplete: ${errorText(error)}`)
|
||||
}
|
||||
if (!runtime) {
|
||||
return electron('the orcad slot names no pinned runtime')
|
||||
}
|
||||
const entry = join(slotDir, 'orcad.js')
|
||||
const report = parseOrcadNativePreflightReport(
|
||||
await (input.nativePreflight ?? runNativePreflight)(runtime, entry).catch(() => '')
|
||||
)
|
||||
if (!report) {
|
||||
return electron('orcad did not answer its native preflight')
|
||||
}
|
||||
if (report.status === 'blocked' || report.status === 'degraded') {
|
||||
return electron(
|
||||
`orcad cannot run terminals here (${report.status}: ${report.reason ?? 'unknown'})`
|
||||
)
|
||||
}
|
||||
return {
|
||||
kind: 'orcad',
|
||||
runtime,
|
||||
entry,
|
||||
version: readFileSync(join(slotDir, ORCAD_VERSION_FILENAME), 'utf8').trim()
|
||||
}
|
||||
}
|
||||
|
||||
/** The slot references its runtime beside it; the cached pinned Node is linked into place. */
|
||||
async function placeSlotRuntime(
|
||||
slotDir: string,
|
||||
target: ServerTarget,
|
||||
cacheRoot: string,
|
||||
materializeRuntime: typeof materializeCachedNodeRuntime = materializeCachedNodeRuntime
|
||||
): Promise<void> {
|
||||
const destination = join(
|
||||
slotDir,
|
||||
...orcadNodeRuntimeRelativePath(target, NODE_RUNTIME_ASSETS[target].executableSha256)
|
||||
)
|
||||
if (existsSync(destination)) {
|
||||
return
|
||||
}
|
||||
const cached = await materializeRuntime(target, cacheRoot, { fetcher: fetch })
|
||||
mkdirSync(dirname(destination), { recursive: true })
|
||||
try {
|
||||
linkSync(cached, destination)
|
||||
} catch {
|
||||
copyFileSync(cached, destination)
|
||||
chmodSync(destination, 0o755)
|
||||
}
|
||||
}
|
||||
|
||||
async function runNativePreflight(runtime: string, entry: string): Promise<string> {
|
||||
const result = await runProcess({
|
||||
program: runtime,
|
||||
args: [entry, ORCAD_NATIVE_PREFLIGHT_FLAG],
|
||||
timeoutMs: NATIVE_PREFLIGHT_TIMEOUT_MS
|
||||
})
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* `orca serve` on orcad, against the packaged slot: the native preflight the launcher asks
|
||||
* first, desktop-serve flag parity on stdout (the readiness contract), and the shared-profile
|
||||
* refusal while the desktop app holds the profile.
|
||||
*/
|
||||
import { existsSync, mkdtempSync, readdirSync, realpathSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir, userInfo } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { runProcess, spawnProcess } from '../../shared/child-process/run-process'
|
||||
import { removeTreeSync } from '../../shared/windows-transient-lock-removal'
|
||||
import { readDaemonPidRecord } from '../daemon/daemon-endpoint-incarnation'
|
||||
import { ORCAD_NODE_RUNTIME_MARKER_FILENAME } from '../../shared/orcad-artifacts'
|
||||
import {
|
||||
ORCAD_NATIVE_PREFLIGHT_FLAG,
|
||||
parseOrcadNativePreflightReport
|
||||
} from '../../shared/orcad-native-preflight-report'
|
||||
import { ORCAD_LOCK_FILE_NAME } from './orcad-instance-lock'
|
||||
import { resolveBundledOrcadRuntime } from './orcad-bundled-runtime'
|
||||
import { skipForMissingInputs } from './orcad-node-slot-fixture'
|
||||
import { buildDaemonSessionClient } from './orcad-daemon-session-client-fixture'
|
||||
|
||||
const slotDir = resolve('out/orcad')
|
||||
const runtime = existsSync(join(slotDir, ORCAD_NODE_RUNTIME_MARKER_FILENAME))
|
||||
? resolveBundledOrcadRuntime(slotDir)
|
||||
: null
|
||||
const skip = skipForMissingInputs('artifact', runtime ? [] : ['a Node orcad slot in out/orcad'])
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
// The terminal daemon outlives orcad by design; on Windows its open files pin the profile.
|
||||
stopProfileDaemons(root)
|
||||
removeTreeSync(root)
|
||||
}
|
||||
})
|
||||
|
||||
function stopProfileDaemons(userData: string): void {
|
||||
const daemonDir = join(userData, 'daemon')
|
||||
for (const name of existsSync(daemonDir) ? readdirSync(daemonDir) : []) {
|
||||
const pid = /^daemon-v\d+\.pid$/u.test(name)
|
||||
? readDaemonPidRecord(join(daemonDir, name))?.pid
|
||||
: undefined
|
||||
try {
|
||||
if (pid) {
|
||||
process.kill(pid, 'SIGKILL')
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/** Kills a serve child and waits until it has really exited, so nothing holds its files. */
|
||||
async function killAndWait(child: ReturnType<typeof spawnProcess>): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return
|
||||
}
|
||||
const exited = new Promise((settle) => child.once('exit', settle))
|
||||
child.kill('SIGKILL')
|
||||
await exited
|
||||
}
|
||||
|
||||
/** Without Vitest's markers: daemon-entry.js does not start its server under VITEST. */
|
||||
function serveEnv(userData: string): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) => !key.startsWith('VITEST') && key !== 'NODE_ENV')
|
||||
),
|
||||
ORCA_BACKGROUND_LAUNCH: '1',
|
||||
ORCA_DISABLE_MACOS_LOGIN_SHELL: '1',
|
||||
ORCA_USER_DATA: userData
|
||||
}
|
||||
}
|
||||
|
||||
function profile(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orcad-serve-parity-'))
|
||||
roots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
describe.skipIf(skip)('orca serve on orcad', () => {
|
||||
it('answers the launcher native preflight with one report line', async () => {
|
||||
const result = await runProcess({
|
||||
program: runtime!,
|
||||
args: [join(slotDir, 'orcad.js'), ORCAD_NATIVE_PREFLIGHT_FLAG],
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
expect(parseOrcadNativePreflightReport(result.stdout)?.status).toMatch(/^(ok|unverifiable)$/u)
|
||||
})
|
||||
|
||||
it('prints only the recipe line, exactly as Electron serve does', async () => {
|
||||
const projectRoot = profile()
|
||||
const child = spawnProcess({
|
||||
program: runtime!,
|
||||
args: [
|
||||
join(slotDir, 'orcad.js'),
|
||||
'--bind',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'0',
|
||||
'--recipe-json',
|
||||
'--project-root',
|
||||
projectRoot
|
||||
],
|
||||
env: serveEnv(profile()),
|
||||
timeoutMs: null
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
|
||||
try {
|
||||
await new Promise<void>((resolveLine, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`no recipe line: ${stderr}`)), 120_000)
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf8')
|
||||
if (stdout.includes('\n')) {
|
||||
clearTimeout(timer)
|
||||
resolveLine()
|
||||
}
|
||||
})
|
||||
child.once('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error(`orcad exited ${String(code)}: ${stderr}`))
|
||||
})
|
||||
})
|
||||
// Anything after the recipe line would break a reader of this contract.
|
||||
await new Promise((settle) => setTimeout(settle, 1_000))
|
||||
} finally {
|
||||
await killAndWait(child)
|
||||
}
|
||||
const lines = stdout.split('\n').filter(Boolean)
|
||||
expect(lines, stderr).toHaveLength(1)
|
||||
expect(JSON.parse(lines[0]!)).toEqual({
|
||||
schemaVersion: 1,
|
||||
pairingCode: expect.any(String),
|
||||
projectRoot
|
||||
})
|
||||
}, 150_000)
|
||||
|
||||
it('refuses with exit 78 while the desktop app holds the profile', async () => {
|
||||
const userData = profile()
|
||||
writeFileSync(
|
||||
join(userData, ORCAD_LOCK_FILE_NAME),
|
||||
JSON.stringify({
|
||||
pid: process.pid,
|
||||
startedAtMs: null,
|
||||
identity: process.platform === 'win32' ? userInfo().username : String(process.getuid?.()),
|
||||
version: 'desktop-test',
|
||||
acquiredAt: new Date().toISOString(),
|
||||
nonce: 'desktop',
|
||||
role: 'desktop'
|
||||
})
|
||||
)
|
||||
const result = await runProcess({
|
||||
program: runtime!,
|
||||
args: [join(slotDir, 'orcad.js'), '--bind', '127.0.0.1', '--port', '0', '--json'],
|
||||
env: serveEnv(userData),
|
||||
timeoutMs: 60_000
|
||||
})
|
||||
expect(result.code).toBe(78)
|
||||
expect(result.stdout).toBe('')
|
||||
expect(result.stderr).toContain('The Orca desktop app')
|
||||
}, 90_000)
|
||||
|
||||
// POSIX: the client spawns /bin/sh, and a short /tmp root keeps the daemon socket path legal.
|
||||
it.runIf(process.platform !== 'win32')(
|
||||
'keeps a live terminal across a serve restart on the shared profile (D7)',
|
||||
async () => {
|
||||
const userData = realpathSync(mkdtempSync('/tmp/orca-serve-d7-'))
|
||||
roots.push(userData)
|
||||
const client = join(userData, 'daemon-client.cjs')
|
||||
await buildDaemonSessionClient(client)
|
||||
const session = async (op: 'create' | 'attach', marker: string) => {
|
||||
const result = await runProcess({
|
||||
program: runtime!,
|
||||
args: [client, op, join(userData, 'daemon'), 'serve-d7', marker, userData],
|
||||
env: serveEnv(userData),
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
return JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}')
|
||||
}
|
||||
const first = await serveOnce(userData)
|
||||
let daemonPid: number | null = first
|
||||
try {
|
||||
const created = await session('create', 'BEFORE')
|
||||
expect(created).toMatchObject({ isReattach: false, output: true })
|
||||
await stopServe(userData)
|
||||
// The same daemon, in the same \`<userData>/daemon\` Electron serve uses, is adopted.
|
||||
expect(await serveOnce(userData)).toBe(first)
|
||||
expect(await session('attach', 'AFTER')).toEqual({
|
||||
pid: created.pid,
|
||||
isReattach: true,
|
||||
output: true
|
||||
})
|
||||
} finally {
|
||||
await stopServe(userData)
|
||||
if (daemonPid) {
|
||||
try {
|
||||
process.kill(daemonPid, 'SIGKILL')
|
||||
} catch {}
|
||||
daemonPid = null
|
||||
}
|
||||
}
|
||||
},
|
||||
300_000
|
||||
)
|
||||
})
|
||||
|
||||
const running = new Map<string, ReturnType<typeof spawnProcess>>()
|
||||
|
||||
/** Starts `orca serve --json` on orcad and returns the terminal daemon pid its readiness names. */
|
||||
async function serveOnce(userData: string): Promise<number> {
|
||||
const child = spawnProcess({
|
||||
program: runtime!,
|
||||
args: [join(slotDir, 'orcad.js'), '--bind', '127.0.0.1', '--port', '0', '--json'],
|
||||
env: serveEnv(userData),
|
||||
timeoutMs: null
|
||||
})
|
||||
running.set(userData, child)
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
|
||||
const line = await new Promise<string>((resolveLine, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`no readiness: ${stderr}`)), 120_000)
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf8')
|
||||
const newline = stdout.indexOf('\n')
|
||||
if (newline !== -1) {
|
||||
clearTimeout(timer)
|
||||
resolveLine(stdout.slice(0, newline))
|
||||
}
|
||||
})
|
||||
})
|
||||
const daemon = JSON.parse(line).health?.terminalDaemon
|
||||
expect(daemon?.state, stderr).toBe('live')
|
||||
return daemon.pid
|
||||
}
|
||||
|
||||
/** SIGTERM is serve's graceful stop on POSIX; it leaves the daemon running by design. */
|
||||
async function stopServe(userData: string): Promise<void> {
|
||||
const child = running.get(userData)
|
||||
running.delete(userData)
|
||||
if (!child || child.exitCode !== null) {
|
||||
return
|
||||
}
|
||||
const exited = new Promise((settle) => child.once('exit', settle))
|
||||
child.kill('SIGTERM')
|
||||
await exited
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Serve-readiness pieces shared by the desktop `--serve` host and orcad, so their stdout matches. */
|
||||
import { statSync } from 'node:fs'
|
||||
import { isAbsolute } from 'node:path'
|
||||
|
||||
export async function renderServePairingQr(pairingUrl: string): Promise<string | null> {
|
||||
// Why dynamic: qrcode is only reachable from mobile pairing, so launch should
|
||||
// not parse it for the majority who never pair a device.
|
||||
const QRCode = await import('qrcode')
|
||||
try {
|
||||
return await QRCode.toString(pairingUrl, { type: 'terminal', small: true })
|
||||
} catch {
|
||||
try {
|
||||
return await QRCode.toString(pairingUrl, { type: 'utf8' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The recipe line names this root, so it must be a real absolute directory. */
|
||||
export function assertServeProjectRoot(projectRoot: string): string {
|
||||
if (!isAbsolute(projectRoot)) {
|
||||
throw new Error(`--serve-project-root must be absolute: ${projectRoot}`)
|
||||
}
|
||||
if (!statSync(projectRoot).isDirectory()) {
|
||||
throw new Error(`--serve-project-root must be a directory: ${projectRoot}`)
|
||||
}
|
||||
return projectRoot
|
||||
}
|
||||
@@ -114,6 +114,9 @@ vi.mock('../server/serve-stdout-boundary')
|
||||
vi.mock('./serve-desktop-activation', () => ({
|
||||
createServeDesktopActivationGate: () => ({})
|
||||
}))
|
||||
vi.mock('./desktop-profile-instance-lock', () => ({
|
||||
acquireDesktopProfileInstanceLock: vi.fn(() => ({ state: 'unavailable' }))
|
||||
}))
|
||||
vi.mock('./single-instance-lock', () => ({
|
||||
shouldBypassSingleInstanceLock: () => false,
|
||||
shouldSkipSingleInstanceLock: () => false,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { OrcadInstanceLockError, type OrcadInstanceLock } from '../orcad/orcad-instance-lock'
|
||||
import { acquireDesktopProfileInstanceLock } from './desktop-profile-instance-lock'
|
||||
|
||||
describe('the desktop share of the profile instance lock', () => {
|
||||
it('takes the lock as the desktop', () => {
|
||||
const lock: OrcadInstanceLock = Object.assign(Object.create(null), { release: vi.fn() })
|
||||
const acquire = vi.fn(() => lock)
|
||||
expect(acquireDesktopProfileInstanceLock('/profile', acquire)).toEqual({
|
||||
state: 'acquired',
|
||||
lock
|
||||
})
|
||||
expect(acquire).toHaveBeenCalledWith('/profile', { role: 'desktop' })
|
||||
})
|
||||
|
||||
it('refuses while a live orcad serves the profile, and says so', () => {
|
||||
const write = vi.fn()
|
||||
const result = acquireDesktopProfileInstanceLock(
|
||||
'/profile',
|
||||
() => {
|
||||
throw new OrcadInstanceLockError('orcad_instance_lock_held', 'Another orcad (pid 7) ...')
|
||||
},
|
||||
write
|
||||
)
|
||||
expect(result).toEqual({
|
||||
state: 'held',
|
||||
message: '[single-instance] Another orcad (pid 7) ...'
|
||||
})
|
||||
expect(write).toHaveBeenCalledWith(2, '[single-instance] Another orcad (pid 7) ...\n')
|
||||
})
|
||||
|
||||
it('still starts when the lock cannot be taken for any other reason', () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
expect(
|
||||
acquireDesktopProfileInstanceLock('/profile', () => {
|
||||
throw new OrcadInstanceLockError('orcad_instance_lock_unreadable', 'unreadable')
|
||||
})
|
||||
).toEqual({ state: 'unavailable' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* The desktop app takes orcad's instance lock on its userData profile, so `orca serve` on
|
||||
* orcad and the desktop refuse each other instead of both writing one profile.
|
||||
*
|
||||
* Electron's single-instance lock only fences other Electron launches; orcad is a plain Node
|
||||
* process that cannot see it. Sharing orcad's lock file gives both hosts one owner record.
|
||||
*/
|
||||
import {
|
||||
acquireOrcadInstanceLock,
|
||||
OrcadInstanceLockError,
|
||||
type OrcadInstanceLock
|
||||
} from '../orcad/orcad-instance-lock'
|
||||
import { writeStartupDiagnosticLine, type StartupDiagnosticSink } from './startup-diagnostics'
|
||||
|
||||
export type DesktopProfileLockResult =
|
||||
| { state: 'acquired'; lock: OrcadInstanceLock }
|
||||
| { state: 'held'; message: string }
|
||||
/** The lock could not be taken for another reason; the desktop starts as it did before it. */
|
||||
| { state: 'unavailable' }
|
||||
|
||||
export function acquireDesktopProfileInstanceLock(
|
||||
userDataPath: string,
|
||||
acquire: typeof acquireOrcadInstanceLock = acquireOrcadInstanceLock,
|
||||
write?: StartupDiagnosticSink
|
||||
): DesktopProfileLockResult {
|
||||
try {
|
||||
const lock = acquire(userDataPath, { role: 'desktop' })
|
||||
// Best effort: a lock left by a dead pid is reclaimed on the next start anyway.
|
||||
process.once('exit', () => lock.release())
|
||||
return { state: 'acquired', lock }
|
||||
} catch (error) {
|
||||
if (error instanceof OrcadInstanceLockError && error.code === 'orcad_instance_lock_held') {
|
||||
const message = `[single-instance] ${error.message}`
|
||||
writeStartupDiagnosticLine(message, write)
|
||||
return { state: 'held', message }
|
||||
}
|
||||
// Why not refuse: only a proven live holder may stop a desktop that never took this lock before.
|
||||
console.warn('[single-instance] Could not take the shared profile lock:', error)
|
||||
return { state: 'unavailable' }
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import { setSpawnObserver } from '../../shared/child-process/spawn-observer'
|
||||
import { settledDiffCache } from '../git/source-control/git-read-cache-invalidation'
|
||||
import { reserveServeStdoutForReadiness } from '../server/serve-stdout-boundary'
|
||||
import { acquireDesktopProfileInstanceLock } from './desktop-profile-instance-lock'
|
||||
import { createServeDesktopActivationGate } from './serve-desktop-activation'
|
||||
import {
|
||||
shouldBypassSingleInstanceLock,
|
||||
@@ -261,6 +262,14 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b
|
||||
app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE)
|
||||
return false
|
||||
}
|
||||
// Why after Electron's lock: that one fences other desktops; this one fences orcad `orca serve`.
|
||||
if (!skip && !bypass) {
|
||||
const profileLock = acquireDesktopProfileInstanceLock(getCanonicalUserDataPath())
|
||||
if (profileLock.state === 'held') {
|
||||
app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE)
|
||||
return false
|
||||
}
|
||||
}
|
||||
state.profileStateAdmission = acquireProfileStateRuntimeAdmission(getCanonicalUserDataPath())
|
||||
// Renderer and worker defaults must be fixed before any session exists.
|
||||
initializeBrowserProcessUserAgent(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { isAbsolute, join } from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import { resolveAdvertisedPairingEndpoint } from '../runtime/pairing-endpoint'
|
||||
import { notifyServeSupervisorReady } from '../serve-update-handoff'
|
||||
import { assertServeProjectRoot, renderServePairingQr } from '../server/serve-pairing-output'
|
||||
import { mainProcessState as state } from './main-process-state'
|
||||
import { getServeOptions, type ServeOptions } from './serve-options'
|
||||
|
||||
@@ -18,21 +19,6 @@ export function getBundledWebClientRoot(): string | undefined {
|
||||
return roots.find((root) => existsSync(join(root, 'web-index.html')))
|
||||
}
|
||||
|
||||
async function renderTerminalPairingQr(pairingUrl: string): Promise<string | null> {
|
||||
// Why dynamic: qrcode is only reachable from mobile pairing, so launch should
|
||||
// not parse it for the majority who never pair a device.
|
||||
const QRCode = await import('qrcode')
|
||||
try {
|
||||
return await QRCode.toString(pairingUrl, { type: 'terminal', small: true })
|
||||
} catch {
|
||||
try {
|
||||
return await QRCode.toString(pairingUrl, { type: 'utf8' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function printServeReady(options: ServeOptions): Promise<void> {
|
||||
const runtime = state.runtime
|
||||
const runtimeRpc = state.runtimeRpc
|
||||
@@ -43,12 +29,7 @@ export async function printServeReady(options: ServeOptions): Promise<void> {
|
||||
if (!options.projectRoot) {
|
||||
throw new Error('--serve-recipe-json requires --serve-project-root')
|
||||
}
|
||||
if (!isAbsolute(options.projectRoot)) {
|
||||
throw new Error(`--serve-project-root must be absolute: ${options.projectRoot}`)
|
||||
}
|
||||
if (!statSync(options.projectRoot).isDirectory()) {
|
||||
throw new Error(`--serve-project-root must be a directory: ${options.projectRoot}`)
|
||||
}
|
||||
assertServeProjectRoot(options.projectRoot)
|
||||
}
|
||||
const boundEndpoint = runtimeRpc.getWebSocketEndpoint()
|
||||
const advertised = boundEndpoint
|
||||
@@ -67,7 +48,7 @@ export async function printServeReady(options: ServeOptions): Promise<void> {
|
||||
})
|
||||
const pairingQr =
|
||||
pairing.available && options.mobilePairing
|
||||
? await renderTerminalPairingQr(pairing.pairingUrl)
|
||||
? await renderServePairingQr(pairing.pairingUrl)
|
||||
: null
|
||||
await state.serveReadinessPublisher.publish(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* The contract between `orca serve` (CLI) and the app-side entry that decides whether this
|
||||
* machine can serve on orcad. The CLI never loads that logic: it runs the packaged app's
|
||||
* `out/main/<ORCAD_LOCAL_SERVE_SELECTION_ENTRY>.js` under ELECTRON_RUN_AS_NODE and reads one line.
|
||||
*/
|
||||
export const SERVE_RUNTIME_ENV = 'ORCA_SERVE_RUNTIME'
|
||||
export const ORCAD_LOCAL_SERVE_SELECTION_ENTRY = 'orcad/orcad-local-serve-selection-entry'
|
||||
export const ORCAD_LOCAL_SERVE_SELECTION_FLAGS = {
|
||||
userData: '--user-data',
|
||||
appRoot: '--app-root',
|
||||
macUpdateHandoff: '--mac-update-handoff'
|
||||
} as const
|
||||
const RESULT_MARKER = 'ORCA_SERVE_RUNTIME'
|
||||
|
||||
export type ServeRuntimeSelection =
|
||||
| { kind: 'orcad'; runtime: string; entry: string; version: string }
|
||||
/** `reason` is null when orcad was not asked for, so nothing is printed. */
|
||||
| { kind: 'electron'; reason: string | null }
|
||||
|
||||
export function formatServeRuntimeSelection(selection: ServeRuntimeSelection): string {
|
||||
return `${RESULT_MARKER} ${JSON.stringify(selection)}`
|
||||
}
|
||||
|
||||
/** The last result line in `output`, or null when none parses. */
|
||||
export function parseServeRuntimeSelection(output: string): ServeRuntimeSelection | null {
|
||||
const line = output
|
||||
.split(/\r?\n/u)
|
||||
.findLast((candidate) => candidate.startsWith(`${RESULT_MARKER} `))
|
||||
if (!line) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(line.slice(RESULT_MARKER.length + 1))
|
||||
if (!parsed || typeof parsed !== 'object' || !('kind' in parsed)) {
|
||||
return null
|
||||
}
|
||||
const record = Object.fromEntries(Object.entries(parsed))
|
||||
if (
|
||||
record.kind === 'orcad' &&
|
||||
typeof record.runtime === 'string' &&
|
||||
typeof record.entry === 'string' &&
|
||||
typeof record.version === 'string'
|
||||
) {
|
||||
return {
|
||||
kind: 'orcad',
|
||||
runtime: record.runtime,
|
||||
entry: record.entry,
|
||||
version: record.version
|
||||
}
|
||||
}
|
||||
if (record.kind === 'electron') {
|
||||
return { kind: 'electron', reason: typeof record.reason === 'string' ? record.reason : null }
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* `orcad.js --orcad-native-preflight`: the node-pty precondition orcad checks at boot, run on
|
||||
* its own so `orca serve` can choose Electron before handing the terminal to an orcad that
|
||||
* would refuse to start. One JSON line on stdout, exit 0 whatever the verdict.
|
||||
*/
|
||||
export const ORCAD_NATIVE_PREFLIGHT_FLAG = '--orcad-native-preflight'
|
||||
const REPORT_TYPE = 'orcad_native_preflight'
|
||||
|
||||
export type OrcadNativePreflightReport = {
|
||||
type: typeof REPORT_TYPE
|
||||
status: 'ok' | 'degraded' | 'blocked' | 'unverifiable'
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
export function formatOrcadNativePreflightReport(
|
||||
status: OrcadNativePreflightReport['status'],
|
||||
reason: string | null
|
||||
): string {
|
||||
return JSON.stringify({ type: REPORT_TYPE, status, reason })
|
||||
}
|
||||
|
||||
/** The last report line in `output`, or null when none parses. */
|
||||
export function parseOrcadNativePreflightReport(output: string): OrcadNativePreflightReport | null {
|
||||
for (const line of output.split(/\r?\n/u).toReversed()) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(line)
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
'type' in parsed &&
|
||||
parsed.type === REPORT_TYPE &&
|
||||
'status' in parsed &&
|
||||
(parsed.status === 'ok' ||
|
||||
parsed.status === 'degraded' ||
|
||||
parsed.status === 'blocked' ||
|
||||
parsed.status === 'unverifiable')
|
||||
) {
|
||||
const reason =
|
||||
'reason' in parsed && typeof parsed.reason === 'string' ? parsed.reason : null
|
||||
return { type: REPORT_TYPE, status: parsed.status, reason }
|
||||
}
|
||||
} catch {
|
||||
// Not the report line.
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user