diff --git a/config/docker/headless-serve-shutdown/Dockerfile b/config/docker/headless-serve-shutdown/Dockerfile index 8ee7b942499..83a28f77a5d 100644 --- a/config/docker/headless-serve-shutdown/Dockerfile +++ b/config/docker/headless-serve-shutdown/Dockerfile @@ -12,6 +12,8 @@ RUN for attempt in 1 2 3 4 5; do \ bash \ ca-certificates \ dbus-x11 \ + fuse \ + git \ iproute2 \ jq \ libatk-bridge2.0-0 \ diff --git a/config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh b/config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh index 59a6bef0e5c..1beb27058cc 100755 --- a/config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh +++ b/config/docker/headless-serve-shutdown/run-appimage-desktop-startup-case.sh @@ -8,21 +8,7 @@ if [[ $# -gt 1 ]]; then exit 64 fi -if ((EUID == 0)); then - if ! state_dir=$(mktemp -d /tmp/orca-appimage-startup.XXXXXX); then - echo 'FAIL: unable to create the AppImage startup state directory' >&2 - exit 1 - fi - if ! chown orca:orca "$state_dir"; then - echo "FAIL: unable to hand the AppImage startup state directory to orca: $state_dir" >&2 - rm -rf -- "$state_dir" || true - exit 1 - fi - exec runuser --user orca --preserve-environment -- env \ - ORCA_STARTUP_STATE_DIR="$state_dir" \ - ORCA_STARTUP_STATE_DIR_CLEANUP=1 \ - "$0" "$@" -fi +((EUID == 0)) || { echo 'FAIL: PID namespace assertion requires container root' >&2; exit 1; } remove_state_dir_on_exit=${ORCA_STARTUP_STATE_DIR_CLEANUP:-0} if [[ -n "${ORCA_STARTUP_STATE_DIR:-}" ]]; then @@ -41,6 +27,8 @@ launcher_start_ticks= launcher_pgid= launcher_status= launcher_waited=false +bundled_cli= +terminal_handle= tree_pids=() declare -A tree_start_ticks=() @@ -178,6 +166,13 @@ report_launcher_exit() { cleanup() { local status=$? trap - EXIT + if [[ -n "$terminal_handle" && -x "$bundled_cli" ]]; then + ORCA_USER_DATA_PATH="$profile" timeout 15s "$bundled_cli" terminal close \ + --terminal "$terminal_handle" --json >/dev/null 2>&1 || true + fi + if identity_alive "$launcher_pid" "$launcher_start_ticks"; then + collect_process_tree + fi signal_process_group TERM || true signal_owned_processes TERM || true if ! wait_for_owned_exit 10; then @@ -198,9 +193,21 @@ cleanup() { } trap cleanup EXIT -mkdir -p "$state_dir/home" "$state_dir/config" "$state_dir/cache" "$state_dir/runtime" +profile="$state_dir/profile" +mkdir -p "$state_dir/home" "$state_dir/config" "$state_dir/cache" "$state_dir/runtime" "$profile" chmod 700 "$state_dir/runtime" export HOME="$state_dir/home" +export USERPROFILE="$HOME" +export XDG_DATA_HOME="$state_dir/data" +export XDG_STATE_HOME="$state_dir/state" +export CODEX_HOME="$HOME/.codex" +export CLAUDE_CONFIG_DIR="$HOME/.claude" +export ORCA_E2E_HOME_DIR="$HOME" +export ORCA_E2E_USER_DATA_DIR="$profile" +export ORCA_BACKGROUND_LAUNCH=1 +export ORCA_DISABLE_MACOS_LOGIN_SHELL=1 +# The desktop must produce these values itself for its packaged PTY. +unset ORCA_CLI_COMMAND ORCA_USER_DATA_PATH export XDG_CONFIG_HOME="$state_dir/config" export XDG_CACHE_HOME="$state_dir/cache" export XDG_RUNTIME_DIR="$state_dir/runtime" @@ -211,7 +218,13 @@ ulimit -c 0 [[ -r "$appimage" ]] || { echo "FAIL: AppImage is not readable: $appimage" >&2; exit 1; } [[ -x "$appimage" ]] || { echo "FAIL: AppImage is not executable: $appimage" >&2; exit 1; } -setsid --wait dbus-run-session -- xvfb-run -a "$appimage" --appimage-extract-and-run --no-sandbox \ +appimage_args=(--appimage-extract-and-run) +if [[ -c /dev/fuse && ${ORCA_STARTUP_APPIMAGE_EXTRACT_AND_RUN:-0} != 1 ]]; then + appimage_args=() +fi +printf 'AppImage desktop launch mode: %s\n' "${appimage_args[*]:-fuse}" +setsid --wait dbus-run-session -- xvfb-run -a "$appimage" "${appimage_args[@]}" --no-sandbox \ + --password-store=basic --use-mock-keychain \ >"$stdout_log" 2>"$stderr_log" & launcher_pid=$! launcher_start_ticks=$(read_start_ticks "$launcher_pid" 2>/dev/null || true) @@ -244,6 +257,112 @@ if ! identity_alive "$launcher_pid" "$launcher_start_ticks"; then exit 1 fi +collect_process_tree +shim="$profile/linux-orca-cli-shim/orca" +[[ -x "$shim" ]] || { echo 'FAIL: first-use desktop shim was not written' >&2; exit 1; } +# A cached dispatcher would miss the first-use live-launcher boundary. +grep -q '^expected_runtime_identity=' "$shim" +grep -q '^expected_launcher_identity=' "$shim" +bundled_cli=$(sed -n "s/^launcher='\([^']*\)'$/\1/p" "$shim") +[[ "$bundled_cli" == */resources/bin/orca-ide && -x "$bundled_cli" ]] +[[ "$bundled_cli" != "$XDG_CACHE_HOME/"* ]] +runtime_root=$(dirname "$(dirname "$(dirname "$bundled_cli")")") +# Window/updater readiness runs in parallel with RPC startup and metadata publication. +runtime_ready=false +deadline=$((SECONDS + 30)) +while ((SECONDS < deadline)); do + if ! identity_alive "$launcher_pid" "$launcher_start_ticks"; then + report_launcher_exit 'runtime transport readiness' + fi + if read -r runtime_pid runtime_id < <(jq -er '[.pid, .runtimeId] | @tsv' \ + "$profile/orca-runtime.json" 2>/dev/null) && + [[ "$runtime_pid" =~ ^[1-9][0-9]*$ && -n "$runtime_id" ]] && + ORCA_USER_DATA_PATH="$profile" timeout --kill-after=1s 2s "$bundled_cli" status --json \ + > "$state_dir/readiness-status.json" 2> "$state_dir/readiness-status.stderr" && + jq -e --arg id "$runtime_id" \ + '.ok == true and .result.runtime.reachable == true and .result.runtime.runtimeId == $id' \ + "$state_dir/readiness-status.json" >/dev/null 2>&1; then + runtime_ready=true + break + fi + sleep 0.2 +done +if [[ "$runtime_ready" != true ]]; then + echo 'FAIL: desktop runtime metadata/status did not become ready within 30s' >&2 + cat "$state_dir/readiness-status.stderr" >&2 2>/dev/null || true + exit 1 +fi +identity_alive "$launcher_pid" "$launcher_start_ticks" +[[ $(readlink -f "/proc/$runtime_pid/exe") == "$runtime_root/orca-ide" ]] +parent_pid_namespace=$(readlink /proc/self/ns/pid) +namespace_command=(unshare --pid --fork --kill-child --mount-proc) +if ! timeout --kill-after=5s 10s "${namespace_command[@]}" true 2> "$state_dir/pidns-setup.stderr"; then + cat "$state_dir/pidns-setup.stderr" >&2 + bwrap_path=$(command -v bwrap || true) + printf 'bwrap availability: %s\n' "${bwrap_path:-unavailable}" >&2 + if [[ -z "$bwrap_path" ]] || + ! grep -Eq '^unshare: .* (Operation not permitted|Permission denied)$' "$state_dir/pidns-setup.stderr"; then + exit 1 + fi + namespace_command=("$bwrap_path" --unshare-pid --bind / / --proc /proc --die-with-parent --) +fi +ORCA_USER_DATA_PATH="$profile" timeout --kill-after=5s 30s "${namespace_command[@]}" \ + bash -euc ' + [[ $(readlink /proc/self/ns/pid) != "$4" ]] + [[ ! -e "/proc/$1" ]] + if kill -0 "$1" 2>/dev/null; then exit 1; fi + "$2" status --json > "$3" + [[ ! -e "/proc/$1" ]] + if kill -0 "$1" 2>/dev/null; then exit 1; fi + ' -- "$runtime_pid" "$shim" "$state_dir/namespace-status.json" "$parent_pid_namespace" +jq -e --arg id "$runtime_id" \ + '.ok == true and .result.runtime.reachable == true and .result.runtime.runtimeId == $id' \ + "$state_dir/namespace-status.json" >/dev/null +jq -cn --arg shim "$shim" --arg id "$runtime_id" --argjson pid "$runtime_pid" \ + '{assertion:"appimage-live-shim-hidden-pid", shim:$shim, runtimeId:$id, publishedPid:$pid, pidHidden:true, reachable:true}' + +repo="$HOME/desktop-oracle-repo" +git init --quiet "$repo" +git -C "$repo" -c user.name=DesktopOracle -c user.email=desktop-oracle@example.invalid \ + commit --quiet --allow-empty -m 'Initialize scratch repository' +cli() { + ORCA_USER_DATA_PATH="$profile" timeout 30s "$bundled_cli" "$@" --json +} +cli repo add --path "$repo" > "$state_dir/repo-add.json" +# Only the real terminal shell expands the two public path variables; no inherited override. +cat > "$HOME/desktop-oracle-env.sh" <<'TERMINAL_SCRIPT' +#!/usr/bin/env bash +printf '%s\n' "$ORCA_CLI_COMMAND" "$ORCA_USER_DATA_PATH" > "$HOME/desktop-oracle-env.txt" +exec bash --noprofile --norc +TERMINAL_SCRIPT +if ! cli terminal create --worktree "path:$repo" \ + --command "bash '$HOME/desktop-oracle-env.sh'" > "$state_dir/terminal-create.json"; then + echo 'FAIL: actual packaged terminal creation failed under Xvfb; no alternate terminal used' >&2 + cat "$state_dir/terminal-create.json" >&2 + exit 1 +fi +terminal_handle=$(jq -er '.result.terminal.handle' "$state_dir/terminal-create.json") +jq -e '.ok == true and (.result.terminal.ptyId | type == "string" and length > 0)' \ + "$state_dir/terminal-create.json" >/dev/null +deadline=$((SECONDS + 30)) +while [[ ! -s "$HOME/desktop-oracle-env.txt" ]] && ((SECONDS < deadline)); do + sleep 0.2 +done +if [[ ! -s "$HOME/desktop-oracle-env.txt" ]]; then + echo 'FAIL: actual packaged PTY did not execute the path-variable query' >&2 + cli terminal read --terminal "$terminal_handle" >&2 || true + exit 1 +fi +mapfile -t terminal_env < "$HOME/desktop-oracle-env.txt" +if [[ ${#terminal_env[@]} != 2 || ${terminal_env[0]} != "$shim" || ${terminal_env[1]} != "$profile" ]]; then + echo 'FAIL: packaged PTY owning CLI command/profile mismatch' >&2 + cat "$HOME/desktop-oracle-env.txt" >&2 + exit 1 +fi +jq -cn --slurpfile created "$state_dir/terminal-create.json" \ + --arg command "${terminal_env[0]}" --arg profile "${terminal_env[1]}" \ + '{assertion:"packaged-terminal-owning-cli", terminal:$created[0].result.terminal.handle, ptyId:$created[0].result.terminal.ptyId, ORCA_CLI_COMMAND:$command, ORCA_USER_DATA_PATH:$profile}' + collect_process_tree xvfb_pids=() for pid in "${tree_pids[@]}"; do diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index 4506c581638..3af5aff99a4 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -769,7 +769,6 @@ 4 test-no-wall-clock:src/cli/runtime/serve-bundle-park.test.ts 5 test-no-wall-clock:src/cli/runtime/serve-pid1-init-reaper.test.ts 1 test-no-wall-clock:src/cli/runtime/status-caller.test.ts -2 test-no-wall-clock:src/cli/runtime/status.test.ts 1 test-no-wall-clock:src/main/acp/acp-structured-mail-admission.test.ts 2 test-no-wall-clock:src/main/agent-hooks/managed-hook-timeout.test.ts 2 test-no-wall-clock:src/main/agent-hooks/manual-compact-hook-stream.test.ts diff --git a/config/scripts/headless-serve-shutdown-matrix.test.mjs b/config/scripts/headless-serve-shutdown-matrix.test.mjs index 37e8ccce495..2f91c000f9d 100644 --- a/config/scripts/headless-serve-shutdown-matrix.test.mjs +++ b/config/scripts/headless-serve-shutdown-matrix.test.mjs @@ -50,6 +50,8 @@ describe('packaged shutdown matrix', () => { args.some((arg) => arg.includes('120s /input/orca.AppImage --appimage-extract')) ) expect(startup).toHaveLength(1) + expect(startup[0][startup[0].indexOf('--user') + 1]).toBe('0') + expect(startup[0][startup[0].indexOf('--cap-add') + 1]).toBe('SYS_ADMIN') expect(extraction).toHaveLength(1) expect(commands().indexOf(startup[0])).toBeLessThan(commands().indexOf(extraction[0])) expect(signalRuns()).toHaveLength(6) @@ -91,6 +93,64 @@ describe('packaged shutdown matrix', () => { ]) }) + it.each(['mount-denied', 'generic-exit', 'app-started', 'extract-assertion-failed'])( + 'bounds desktop extraction fallback: %s', + async (scenario) => { + // Mount diagnostics captured in package job123685326492; Docker execution here is mocked. + const mountFailure = { + status: 127, + stdout: 'AppImage desktop launch mode: fuse\n', + stderr: + 'FAIL: desktop launcher exited before the updater-setup-done marker (status=127)\n' + + 'Cannot mount AppImage, please check your FUSE setup.\n' + + '/usr/bin/fusermount3: mount failed: Permission denied\n' + } + let attempts = 0 + spawnSync.mockImplementation((_, args) => { + if (!args.includes('/usr/local/bin/run-appimage-desktop-startup-case')) { + return succeeded + } + attempts++ + if (attempts > 1) { + return scenario === 'extract-assertion-failed' + ? { + ...succeeded, + status: 1, + stderr: 'FAIL: packaged PTY owning CLI command/profile mismatch' + } + : succeeded + } + if (scenario === 'generic-exit') { + return { ...mountFailure, stderr: 'Unexpected crash\n' } + } + if (scenario === 'app-started') { + return { ...mountFailure, stderr: `${mountFailure.stderr}[startup] app-ready t=10\n` } + } + return mountFailure + }) + await (scenario === 'mount-denied' + ? run('--all-entrypoints') + : expect(run('--all-entrypoints')).rejects.toThrow( + `failed with status ${scenario === 'extract-assertion-failed' ? 1 : 127}` + )) + const startup = commands().filter((args) => + args.includes('/usr/local/bin/run-appimage-desktop-startup-case') + ) + const retries = scenario === 'mount-denied' || scenario === 'extract-assertion-failed' + expect(startup).toHaveLength(retries ? 2 : 1) + expect(startup[0]).not.toContain('ORCA_STARTUP_APPIMAGE_EXTRACT_AND_RUN=1') + if (retries) { + expect(startup[1]).toContain('ORCA_STARTUP_APPIMAGE_EXTRACT_AND_RUN=1') + expect(startup.every((args) => args.includes('--rm'))).toBe(true) + expect(startup.every((args) => args.includes(`${artifact}:/input/orca.AppImage:ro`))).toBe( + true + ) + } + expect(signalRuns()).toHaveLength(scenario === 'mount-denied' ? 6 : 0) + expect(commands().at(-2).slice(0, 2)).toEqual(['volume', 'rm']) + } + ) + it('attributes failures and still attempts later cases before cleanup', async () => { spawnSync.mockImplementation((_, args) => args.at(-1) === 'INT' ? { ...succeeded, status: 7 } : succeeded diff --git a/config/scripts/run-headless-serve-shutdown-docker.mjs b/config/scripts/run-headless-serve-shutdown-docker.mjs index f5119961c83..550f24ecc1e 100755 --- a/config/scripts/run-headless-serve-shutdown-docker.mjs +++ b/config/scripts/run-headless-serve-shutdown-docker.mjs @@ -176,7 +176,8 @@ try { function runDesktopStartupOracle({ image, appImage, platform }) { console.log('Running original AppImage desktop startup oracle...') - docker([ + const fuseDevicePresent = existsSync('/dev/fuse') + const startupOptions = [ 'run', '--rm', '--init', @@ -191,19 +192,52 @@ function runDesktopStartupOracle({ image, appImage, platform }) { '256m', '--cap-drop', 'ALL', + '--cap-add', + 'SYS_ADMIN', + // Only this oracle needs a nested PID namespace; the restricted CLI cases stay unchanged. '--security-opt', - 'no-new-privileges', + 'seccomp=unconfined', + '--security-opt', + 'apparmor=unconfined', + ...(fuseDevicePresent ? ['--device', '/dev/fuse'] : ['--security-opt', 'no-new-privileges']), '--user', - 'orca', + '0', '--entrypoint', '/usr/local/bin/run-appimage-desktop-startup-case', '-e', 'ORCA_STARTUP_DIAGNOSTICS=1', '-v', - `${appImage}:/input/orca.AppImage:ro`, - image, - '/input/orca.AppImage' - ]) + `${appImage}:/input/orca.AppImage:ro` + ] + const launch = (extraOptions = []) => + docker([...startupOptions, ...extraOptions, image, '/input/orca.AppImage'], { + allowFailure: true + }) + let result = launch() + process.stdout.write(result.stdout) + process.stderr.write(result.stderr) + const output = result.stdout + result.stderr + // Device access alone does not prove that this fixture may mount the AppImage. + if ( + result.status === 127 && + result.stdout.includes('AppImage desktop launch mode: fuse\n') && + output.includes( + 'FAIL: desktop launcher exited before the updater-setup-done marker (status=127)' + ) && + output.includes('Cannot mount AppImage, please check your FUSE setup.') && + /^\/usr\/bin\/fusermount3?: mount failed: Permission denied\r?$/m.test(output) && + !output.includes('[startup]') + ) { + console.log( + 'FUSE mount unavailable (permission denied); trying original AppImage --appimage-extract-and-run in a fresh container.' + ) + result = launch(['-e', 'ORCA_STARTUP_APPIMAGE_EXTRACT_AND_RUN=1']) + process.stdout.write(result.stdout) + process.stderr.write(result.stderr) + } + if (result.status !== 0) { + fail(`Desktop AppImage startup oracle failed with status ${result.status}`) + } } function valueAfter(flag) { diff --git a/src/cli/handlers/agent-hooks.test.ts b/src/cli/handlers/agent-hooks.test.ts index c5bf3a0fecd..c113af351c4 100644 --- a/src/cli/handlers/agent-hooks.test.ts +++ b/src/cli/handlers/agent-hooks.test.ts @@ -57,7 +57,14 @@ const { vi.mock('../runtime-client', () => { class RuntimeClient { call = callMock - getCliStatus = getCliStatusMock + async getOfflineRuntimeAdmission() { + const status = await getCliStatusMock() + return { + reachable: status.result.runtime.reachable, + legacyPidRunning: status.result.app.running, + pid: status.result.app.pid ?? null + } + } } class RuntimeClientError extends Error { diff --git a/src/cli/handlers/agent-hooks.ts b/src/cli/handlers/agent-hooks.ts index d4637605823..b8027df243e 100644 --- a/src/cli/handlers/agent-hooks.ts +++ b/src/cli/handlers/agent-hooks.ts @@ -114,9 +114,9 @@ async function updateAdmittedEnabledOnDisk(enabled: boolean): Promise<{ } async function updateRunningRuntime(client: RuntimeClient, enabled: boolean): Promise { - const status = await client.getCliStatus() - if (!status.result.runtime.reachable) { - if (status.result.app.running) { + const status = await client.getOfflineRuntimeAdmission() + if (!status.reachable) { + if (status.legacyPidRunning) { throw new RuntimeClientError( 'runtime_error', 'Orca is running but unavailable. Retry when it responds, or stop Orca before changing agent hooks offline.' diff --git a/src/cli/handlers/profile-state-recovery-admission.test.ts b/src/cli/handlers/profile-state-recovery-admission.test.ts index 0bbe7d79738..cd641e0c5a6 100644 --- a/src/cli/handlers/profile-state-recovery-admission.test.ts +++ b/src/cli/handlers/profile-state-recovery-admission.test.ts @@ -42,7 +42,14 @@ const mocks = vi.hoisted(() => ({ root: vi.fn(), status: vi.fn() })) vi.mock('../runtime-client', () => ({ getDefaultUserDataPath: mocks.root, RuntimeClient: class { - getCliStatus = mocks.status + async getOfflineRuntimeAdmission() { + const status = await mocks.status() + return { + reachable: status.result.runtime.reachable, + legacyPidRunning: status.result.app.running, + pid: status.result.app.pid ?? null + } + } }, RuntimeClientError: class extends Error { constructor( diff --git a/src/cli/handlers/profile-state.test.ts b/src/cli/handlers/profile-state.test.ts index b4f779ef9b5..48a2f77ac4b 100644 --- a/src/cli/handlers/profile-state.test.ts +++ b/src/cli/handlers/profile-state.test.ts @@ -43,7 +43,14 @@ vi.mock('../runtime-client', () => { } class RuntimeClient { - getCliStatus = getCliStatusMock + async getOfflineRuntimeAdmission() { + const status = await getCliStatusMock() + return { + reachable: status.result.runtime.reachable, + legacyPidRunning: status.result.app.running, + pid: status.result.app.pid ?? null + } + } constructor( _userDataPath?: string, diff --git a/src/cli/handlers/profile-state.ts b/src/cli/handlers/profile-state.ts index 3c934f8c725..b0a08e5e0fc 100644 --- a/src/cli/handlers/profile-state.ts +++ b/src/cli/handlers/profile-state.ts @@ -83,8 +83,8 @@ function rejectProfileStateRemoteSelection(flags: ReadonlyMap { - const status = await client.getCliStatus() - if (status.result.runtime.reachable || status.result.app.running) { + const status = await client.getOfflineRuntimeAdmission() + if (status.reachable || status.legacyPidRunning) { throw new RuntimeClientError( 'runtime_error', 'Stop Orca before profile-state rollback so no process can write the SQLite database.' diff --git a/src/cli/runtime-client-deferral.test.ts b/src/cli/runtime-client-deferral.test.ts index aabc13f1487..8310934860b 100644 --- a/src/cli/runtime-client-deferral.test.ts +++ b/src/cli/runtime-client-deferral.test.ts @@ -8,14 +8,14 @@ const { constructorArgsMock, moduleEvaluationMock, callMock, - getCliStatusMock, + getOfflineRuntimeAdmissionMock, testUserDataPathRef } = vi.hoisted(() => ({ applyAgentStatusHooksEnabledMock: vi.fn(async () => []), constructorArgsMock: vi.fn(), moduleEvaluationMock: vi.fn(), callMock: vi.fn(), - getCliStatusMock: vi.fn(), + getOfflineRuntimeAdmissionMock: vi.fn(), testUserDataPathRef: { current: '' } })) @@ -38,7 +38,7 @@ vi.mock('./runtime-client', () => { moduleEvaluationMock() class RuntimeClient { call = callMock - getCliStatus = getCliStatusMock + getOfflineRuntimeAdmission = getOfflineRuntimeAdmissionMock openOrca = vi.fn() constructor(...args: unknown[]) { @@ -62,7 +62,7 @@ describe('RuntimeClient module-graph deferral', () => { applyAgentStatusHooksEnabledMock.mockClear() constructorArgsMock.mockClear() callMock.mockReset() - getCliStatusMock.mockReset() + getOfflineRuntimeAdmissionMock.mockReset() logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) }) @@ -104,8 +104,10 @@ describe('RuntimeClient module-graph deferral', () => { async (_name, argv, constructs) => { vi.stubEnv('ORCA_PAIRING_CODE', 'pairing-code') vi.stubEnv('ORCA_ENVIRONMENT', 'some-environment') - getCliStatusMock.mockResolvedValue({ - result: { runtime: { reachable: false }, app: { running: false } } + getOfflineRuntimeAdmissionMock.mockResolvedValue({ + reachable: false, + legacyPidRunning: false, + pid: null }) await main(argv, '/tmp/repo') diff --git a/src/cli/runtime-client.test.ts b/src/cli/runtime-client.test.ts index cd7c6493731..8400e4e6fd4 100644 --- a/src/cli/runtime-client.test.ts +++ b/src/cli/runtime-client.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY } from '../shared/protocol-version' import { RuntimeClient, RuntimeClientError, RuntimeRpcFailureError } from './runtime-client' import { launchOrcaApp } from './runtime/launch' +import { formatCliError } from './cli-error' vi.mock('./runtime/launch', () => ({ launchOrcaApp: vi.fn() @@ -235,15 +236,16 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { }) }) - it('reports stale_bootstrap when bootstrap artifacts exist but no runtime is reachable', async () => { + it('reports an unavailable endpoint and lets explicit open recover from it', async () => { const userDataPath = makeRuntimeClientFixture() writeMetadata(userDataPath, join(userDataPath, 'missing.sock'), 'token', findUnusedPid()) - const client = new RuntimeClient(userDataPath, 100) - const status = await client.getCliStatus() - expect(status.result.runtime.state).toBe('stale_bootstrap') - expect(status.result.runtime.reachable).toBe(false) + await expect(client.getCliStatus()).rejects.toMatchObject({ code: 'runtime_unavailable' }) + const error = await client.getCliStatus().catch((failure: unknown) => failure) + expect(formatCliError(error)).not.toContain('Orca is not running') + await expect(client.openOrca(1)).rejects.toMatchObject({ code: 'runtime_open_timeout' }) + expect(launchOrcaApp).toHaveBeenCalledTimes(1) }) it('reports graph_not_ready when the runtime is reachable but graph is unavailable', async () => { @@ -424,12 +426,12 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { it('preserves a dropped read-only orchestration failure exactly', async () => { const userDataPath = makeRuntimeClientFixture() const endpoint = join(userDataPath, 'runtime.sock') - let request: Record | undefined + let request: unknown const server = createServer((socket) => { sockets.add(socket) socket.once('close', () => sockets.delete(socket)) socket.once('data', (data) => { - request = JSON.parse(String(data).trim()) as Record + request = JSON.parse(String(data).trim()) socket.end() }) }) @@ -443,10 +445,13 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { .catch((error: unknown) => error) expect(failure).toBeInstanceOf(RuntimeClientError) - expect((failure as RuntimeClientError).message).toBe( - 'The Orca runtime closed the connection before responding. Restart Orca and try again.' - ) - expect((failure as RuntimeClientError).data).toBeUndefined() + if (!(failure instanceof RuntimeClientError)) { + throw new Error('Expected a runtime client error', { cause: failure }) + } + expect(failure.message).toBe('Orca closed the connection before responding.') + expect(failure.data).toEqual({ + nextSteps: ['Check that Orca is open and that this terminal can access it.'] + }) expect(request).toMatchObject({ method: 'orchestration.workerShow' }) expect(request).not.toHaveProperty('orchestrationRequestId') }) diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index cddf11bb215..951984e7bdd 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -9,9 +9,14 @@ import { orchestrationMigrationData } from '../../shared/orchestration-rpc-contract' import type { PairedRuntimeConnection } from '../../shared/paired-runtime-connection' -import { launchOrcaApp } from './launch' +import { openLocalOrca } from './open-local-orca' import { getDefaultUserDataPath, readMetadata } from './metadata' -import { getCliStatus, projectRemoteAppStatus } from './status' +import { + getCliStatus, + getOfflineRuntimeAdmission, + type OfflineRuntimeAdmission, + projectRemoteAppStatus +} from './status' import { sendRequest } from './transport' import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' import { @@ -249,6 +254,18 @@ export class RuntimeClient { return getCliStatus(this.userDataPath) } + async getOfflineRuntimeAdmission(): Promise { + if (this.remote) { + const status = await this.getCliStatus() + return { + reachable: status.result.runtime.reachable, + legacyPidRunning: status.result.app.running, + pid: status.result.app.pid ?? null + } + } + return getOfflineRuntimeAdmission(this.userDataPath) + } + private async ensureOrchestrationContractCompatible(timeoutMs: number): Promise { if (!this.orchestrationContractCheck) { this.orchestrationContractCheck = this.checkOrchestrationContractCompatibility( @@ -277,44 +294,6 @@ export class RuntimeClient { } async openOrca(timeoutMs = 15_000): Promise> { - const initial = await this.getCliStatus() - if (this.remote) { - return initial - } - - // A blocked runtime cannot open a window; a second app would only hit its instance lock. - if (initial.result.app.desktopWindowStatus === 'blocked') { - throwDesktopActivationBlocked() - } - launchOrcaApp() - if (initial.result.app.desktopWindowStatus === 'available') { - return initial - } - - const startedAt = Date.now() - while (Date.now() - startedAt < timeoutMs) { - const status = await this.getCliStatus() - if (status.result.app.desktopWindowStatus === 'blocked') { - throwDesktopActivationBlocked() - } - if (status.result.app.desktopWindowStatus === 'available') { - return status - } - await delay(250) - } - - throw new RuntimeClientError( - 'runtime_open_timeout', - 'Timed out waiting for an Orca desktop window. The runtime may still be running headlessly.' - ) + return this.remote ? this.getCliStatus() : openLocalOrca(() => this.getCliStatus(), timeoutMs) } } - -function throwDesktopActivationBlocked(): never { - throw new RuntimeClientError( - 'desktop_activation_blocked', - 'Orca is running headlessly, but it cannot open a desktop window safely because the persistent terminal provider is unavailable. Quit Orca normally and start the app again; do not use open -n.' - ) -} - -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) diff --git a/src/cli/runtime/fixtures/codex-0.165.0-linux-connect-denied.json b/src/cli/runtime/fixtures/codex-0.165.0-linux-connect-denied.json new file mode 100644 index 00000000000..f24c27b01f0 --- /dev/null +++ b/src/cli/runtime/fixtures/codex-0.165.0-linux-connect-denied.json @@ -0,0 +1,36 @@ +{ + "provenance": { + "capture": "QA3286, real rendered remote Orca terminal, Linux arm64 glibc", + "codexVersion": "0.165.0", + "command": ["orca", "status", "--json"], + "policy": { + "sandbox_mode": "workspace-write", + "sandbox_workspace_write.network_access": false + }, + "publishedPidProbeSystemCode": "ESRCH", + "source": "linux-container/query-codex-restricted-kernel.txt", + "rawSha256": "2b0af9d523c949d747b17ffe88364659c3be772a08b4a379807a39104e6128ae", + "cliSha256": "c4520c5afe063accedb22175f7d75f2f99f90fa6ea72bf7530be456050a0d97a", + "deployedVersion": "0.1.0+da3cb3b77bd0", + "sanitization": "Command executable path reduced to orca; response content unchanged." + }, + "exitCode": 1, + "response": { + "id": "local", + "ok": false, + "error": { + "code": "runtime_access_denied", + "message": "Permission denied connecting to Orca (EPERM). Orca may be running normally; this command's sandbox or OS permissions block the connection.", + "data": { + "systemCode": "EPERM", + "nextSteps": [ + "Re-run this command outside its sandbox, or as a user allowed to reach the Orca runtime.", + "Do not restart Orca or run 'orca open'; a restart cannot grant this command access." + ] + } + }, + "_meta": { + "runtimeId": null + } + } +} diff --git a/src/cli/runtime/launch-serve-preflight.test.ts b/src/cli/runtime/launch-serve-preflight.test.ts index 400e6a7f16e..608e008ecd3 100644 --- a/src/cli/runtime/launch-serve-preflight.test.ts +++ b/src/cli/runtime/launch-serve-preflight.test.ts @@ -1,6 +1,10 @@ import { EventEmitter } from 'node:events' -import { resolve } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { createServer, type Socket } from 'node:net' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ServePreflight from './serve-electron-preflight' import { MacServeStartupAbortError } from './serve-signal-exit-diagnostic' const { spawnMock, preflightMock, recordAbortMock, superviseMock } = vi.hoisted(() => ({ @@ -11,7 +15,8 @@ const { spawnMock, preflightMock, recordAbortMock, superviseMock } = vi.hoisted( })) vi.mock('child_process', () => ({ spawn: spawnMock, spawnSync: vi.fn() })) -vi.mock('./serve-electron-preflight', () => ({ +vi.mock('./serve-electron-preflight', async (original) => ({ + ...(await original()), preflightElectronServe: preflightMock, recordElectronServeStartupAbort: recordAbortMock })) @@ -22,6 +27,11 @@ vi.mock('./serve-update-supervisor', async (importOriginal) => ({ import { serveOrcaApp } from './launch' +let actualPreflight: typeof ServePreflight +beforeAll(async () => { + actualPreflight = await vi.importActual('./serve-electron-preflight') +}) + class FakeChildProcess extends EventEmitter { stdout = new EventEmitter() kill = vi.fn() @@ -69,3 +79,57 @@ describe('serveOrcaApp: Electron serve preflight', () => { expect(recordAbortMock).toHaveBeenCalledWith(PROFILE, 'aborted at startup') }) }) + +describe.skipIf(process.platform === 'win32')( + 'serve launch with actual preflight dependencies', + () => { + it.each(['absent', 'accepting'])( + 'reaches the existing launch/refusal seam for a %s endpoint', + async (state) => { + const root = mkdtempSync(join(tmpdir(), 'orca-serve-launch-')) + const endpoint = join(root, 'runtime.sock') + const sockets = new Set() + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.resume() + }) + try { + vi.clearAllMocks() + vi.stubEnv('ORCA_USER_DATA_PATH', root) + vi.stubEnv('ORCA_APP_EXECUTABLE', '/opt/orca/orca') + vi.stubEnv('ORCA_SERVE_RUNTIME', 'electron') + writeFileSync( + join(root, 'orca-runtime.json'), + JSON.stringify({ + runtimeId: 'leftover', + pid: 2147483647, + authToken: 'fixture', + transports: [{ kind: 'unix', endpoint }], + startedAt: 1 + }) + ) + if (state === 'accepting') { + await new Promise((resolve) => server.listen(endpoint, resolve)) + } + preflightMock.mockImplementation(actualPreflight.preflightElectronServe) + superviseMock.mockResolvedValue(0) + spawnMock.mockReturnValue(new FakeChildProcess()) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + expect(await serveOrcaApp({})).toBe(state === 'absent' ? 0 : 3) + expect(spawnMock).toHaveBeenCalledTimes(state === 'absent' ? 1 : 0) + } finally { + for (const socket of sockets) { + socket.destroy() + } + if (server.listening) { + await new Promise((resolve) => server.close(() => resolve())) + } + vi.restoreAllMocks() + vi.unstubAllEnvs() + rmSync(root, { recursive: true, force: true }) + } + } + ) + } +) diff --git a/src/cli/runtime/offline-admission-commands.test.ts b/src/cli/runtime/offline-admission-commands.test.ts new file mode 100644 index 00000000000..45527e3df55 --- /dev/null +++ b/src/cli/runtime/offline-admission-commands.test.ts @@ -0,0 +1,305 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + writeFileSync +} from 'node:fs' +import { createServer, type Socket } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { setAppEnvironment } from '../../shared/app-environment' +import { readAgentHookSettingsFromProfileState } from '../../main/persistence/profile-state/profile-state-offline-settings' +import { acquireProfileStateMaintenance } from '../../main/persistence/profile-state/profile-state-access' +import { PROFILE_STATE_RECOVERY_RESULT_PREFIX } from '../../shared/profile-state-recovery-command' +import type * as ProcessHost from '@orca/process-host' +import { main } from '../index' + +const { runChild } = vi.hoisted(() => ({ runChild: vi.fn() })) +vi.mock('@orca/process-host', async (original) => ({ + ...(await original()), + runProcess: runChild +})) + +// Exercise the existing hook installer on one isolated target; do not discover installed agents. +vi.mock('../../main/agent-hooks/managed-agent-hook-controls', async () => { + const { claudeHookService } = await import('../../main/claude/hook-service.js') + return { + applyAgentStatusHooksEnabled: vi.fn(async (enabled: boolean) => [ + enabled ? claudeHookService.install({ claudeVersion: '2.1.0' }) : claudeHookService.remove() + ]), + getManagedAgentHookStatuses: () => [claudeHookService.getStatus()] + } +}) + +const priorExit = process.exitCode +const sockets = new Set() +let root: string +let profile: string +let endpoint: string +let server: ReturnType | undefined + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-offline-command-')) + profile = join(root, 'profiles', 'fixture') + endpoint = join(root, 'runtime.sock') + mkdirSync(profile, { recursive: true }) + writeFileSync( + join(root, 'orca-profile-index.json'), + JSON.stringify({ activeProfileId: 'fixture', profiles: [{ id: 'fixture' }] }) + ) + writeFileSync( + join(profile, 'orca-data.json'), + JSON.stringify({ settings: { agentStatusHooksEnabled: false }, retained: { value: 7 } }) + ) + for (const key of [ + 'ORCA_PAIRING_CODE', + 'ORCA_REMOTE_PAIRING', + 'ORCA_ENVIRONMENT', + 'ELECTRON_RUN_AS_NODE', + 'ORCA_APP_EXECUTABLE', + 'WSL_DISTRO_NAME' + ]) { + vi.stubEnv(key, undefined) + } + for (const key of [ + 'HOME', + 'USERPROFILE', + 'XDG_CONFIG_HOME', + 'XDG_DATA_HOME', + 'XDG_CACHE_HOME', + 'CODEX_HOME', + 'CLAUDE_CONFIG_DIR' + ]) { + vi.stubEnv(key, join(root, key)) + } + vi.stubEnv('ORCA_USER_DATA_PATH', root) + setAppEnvironment({ + getPath: () => root, + getAppPath: () => root, + getVersion: () => 'test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + runChild.mockReset() + process.exitCode = 0 + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) +afterEach(async () => { + for (const socket of sockets) { + socket.destroy() + } + sockets.clear() + if (server) { + const listening = server + await new Promise((resolve) => listening.close(() => resolve())) + } + server = undefined + vi.restoreAllMocks() + vi.unstubAllEnvs() + process.exitCode = priorExit + rmSync(root, { recursive: true, force: true }) +}) + +function metadata(shape = 'complete', pid = 2147483647) { + writeFileSync( + join(root, 'orca-runtime.json'), + JSON.stringify({ + runtimeId: 'runtime-before', + pid, + authToken: shape === 'incomplete' ? null : 'fixture-token', + startedAt: 1, + ...(shape === 'singular' + ? { transport: { kind: 'unix', endpoint } } + : { transports: [{ kind: 'unix', endpoint }] }) + }) + ) +} +function settings() { + return readAgentHookSettingsFromProfileState({ + dataFile: join(profile, 'orca-data.json'), + databaseFile: join(profile, 'profile-state.db'), + profileId: 'fixture' + }) +} +function output() { + return JSON.parse(String(vi.mocked(console.log).mock.calls.at(-1)?.[0])) +} +async function refusedEndpoint() { + const listener = createServer() + await new Promise((resolve) => listener.listen(endpoint, resolve)) + renameSync(endpoint, `${endpoint}.stale`) + await new Promise((resolve) => listener.close(() => resolve())) + renameSync(`${endpoint}.stale`, endpoint) +} + +describe.skipIf(process.platform === 'win32')( + 'offline CLI admission through dispatch and persistence', + () => { + it.each(['complete', 'singular', 'incomplete', 'refused'])( + 'recovers JSON and toggles hooks both ways with %s stopped metadata', + async (shape) => { + metadata(shape) + if (shape === 'refused') { + await refusedEndpoint() + } + await main(['profile', 'state', 'rollback', '--current-json', '--json'], root) + expect(process.exitCode).toBe(0) + expect(output()).toMatchObject({ ok: true, result: { storage: 'json' } }) + expect(JSON.parse(readFileSync(join(profile, 'orca-data.json'), 'utf8'))).toMatchObject({ + retained: { value: 7 } + }) + for (const enabled of [true, false]) { + await main(['agent', 'hooks', enabled ? 'on' : 'off', '--json'], root) + expect(process.exitCode).toBe(0) + expect(output()).toMatchObject({ + ok: true, + result: { + enabled, + appliedBy: 'offline', + statuses: [expect.objectContaining({ agent: 'claude', managedHooksPresent: enabled })] + } + }) + expect(settings().agentStatusHooksEnabled).toBe(enabled) + const config = JSON.parse( + readFileSync(join(root, 'HOME', '.claude', 'settings.json'), 'utf8') + ) + expect(JSON.stringify(config.hooks ?? {}).includes('orca')).toBe(enabled) + } + } + ) + + it.each(['packaged', 'dev', 'older-override'])( + 'retains the %s helper selection and never replays an uncertain child result', + async (kind) => { + metadata() + const executable = kind === 'older-override' ? join(root, 'older-Orca') : process.execPath + if (kind === 'older-override') { + vi.stubEnv('ORCA_APP_EXECUTABLE', executable) + } else { + vi.stubEnv('ELECTRON_RUN_AS_NODE', '1') + } + vi.stubEnv('ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT', kind === 'dev' ? '1' : undefined) + const result = { + profileId: 'fixture', + dataFile: join(profile, 'orca-data.json'), + databaseFile: join(profile, 'profile-state.db'), + exportPaths: [], + backups: [], + revision: null, + quarantineDirectory: profile, + removedDatabaseFiles: [], + storage: 'json', + restoredPath: join(profile, 'orca-data.json') + } + runChild.mockImplementationOnce(async () => { + // Parent observation precedes the existing child, with no new parent lease. + acquireProfileStateMaintenance(root).release() + return { + code: 0, + signal: null, + timedOut: false, + stdout: PROFILE_STATE_RECOVERY_RESULT_PREFIX + JSON.stringify({ ok: true, result }), + stderr: '' + } + }) + await main(['profile', 'state', 'rollback', '--current-json', '--json'], root) + expect(process.exitCode).toBe(0) + expect(runChild).toHaveBeenCalledOnce() + expect(runChild.mock.calls[0][0]).toMatchObject({ + program: executable, + timeoutMs: null, + env: { ORCA_BACKGROUND_LAUNCH: '1', ORCA_USER_DATA_PATH: realpathSync(root) } + }) + expect(runChild.mock.calls[0][0].args).toContain('--serve') + expect(JSON.parse(runChild.mock.calls[0][0].args.at(-1))).toMatchObject({ + selector: { kind: 'current-json' } + }) + runChild.mockClear().mockResolvedValue({ + code: 1, + signal: null, + timedOut: false, + stdout: 'partial', + stderr: 'uncertain result' + }) + await main(['profile', 'state', 'rollback', '--current-json', '--json'], root) + expect(process.exitCode).toBe(1) + expect(runChild).toHaveBeenCalledOnce() + expect(existsSync(join(profile, 'profile-state.db'))).toBe(false) + } + ) + + it('refuses a visible PID without creating a database or changing profile and hook files', async () => { + metadata('complete', process.pid) + const before = readFileSync(join(profile, 'orca-data.json')) + for (const args of [ + ['profile', 'state', 'rollback', '--current-json'], + ['agent', 'hooks', 'on'], + ['agent', 'hooks', 'off'] + ]) { + process.exitCode = 0 + await main([...args, '--json'], root) + expect(process.exitCode).toBe(1) + expect(output()).toMatchObject({ ok: false, error: { code: 'runtime_error' } }) + expect(readFileSync(join(profile, 'orca-data.json'))).toEqual(before) + expect(existsSync(join(profile, 'profile-state.db'))).toBe(false) + expect(existsSync(join(root, 'HOME', '.claude'))).toBe(false) + } + }) + + it.each(['close', 'server-error'])( + 'never replays a failed online settings update offline (%s)', + async (failure) => { + metadata() + const methods: string[] = [] + const listener = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.once('data', (data) => { + const request = JSON.parse(String(data)) + methods.push(request.method) + if (request.method === 'settings.update' && failure === 'close') { + socket.end() + return + } + socket.end( + `${JSON.stringify({ + id: request.id, + ...(request.method === 'status.get' + ? { + ok: true, + result: { + runtimeId: 'runtime-before', + graphStatus: 'ready', + authoritativeWindowId: null + } + } + : { + ok: false, + error: { code: 'runtime_unavailable', message: 'update rejected' } + }), + _meta: { runtimeId: 'runtime-before' } + })}\n` + ) + }) + }) + server = listener + await new Promise((resolve) => listener.listen(endpoint, resolve)) + const before = readFileSync(join(profile, 'orca-data.json')) + await main(['agent', 'hooks', 'on', '--json'], root) + expect(process.exitCode).toBe(1) + expect(methods).toEqual(['status.get', 'settings.update']) + expect(readFileSync(join(profile, 'orca-data.json'))).toEqual(before) + expect(existsSync(join(profile, 'profile-state.db'))).toBe(false) + expect(existsSync(join(root, 'HOME', '.claude'))).toBe(false) + } + ) + } +) diff --git a/src/cli/runtime/open-local-orca.ts b/src/cli/runtime/open-local-orca.ts new file mode 100644 index 00000000000..18c87338954 --- /dev/null +++ b/src/cli/runtime/open-local-orca.ts @@ -0,0 +1,65 @@ +import type { CliStatusResult } from '../../shared/runtime-types' +import { launchOrcaApp } from './launch' +import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' + +type ReadStatus = () => Promise> + +export async function openLocalOrca( + readStatus: ReadStatus, + timeoutMs: number +): Promise> { + const initial = await getStatusForOpen(readStatus) + + // A blocked runtime cannot open a window; a second app would only hit its instance lock. + if (initial?.result.app.desktopWindowStatus === 'blocked') { + throwDesktopActivationBlocked() + } + launchOrcaApp() + if (initial?.result.app.desktopWindowStatus === 'available') { + return initial + } + + const startedAt = Date.now() + while (Date.now() - startedAt < timeoutMs) { + const status = await getStatusForOpen(readStatus) + if (status?.result.app.desktopWindowStatus === 'blocked') { + throwDesktopActivationBlocked() + } + if (status?.result.app.desktopWindowStatus === 'available') { + return status + } + await delay(250) + } + + throw new RuntimeClientError( + 'runtime_open_timeout', + 'Timed out waiting for an Orca desktop window. The runtime may still be running headlessly.' + ) +} + +async function getStatusForOpen( + readStatus: ReadStatus +): Promise | null> { + try { + return await readStatus() + } catch (error) { + // Explicit open may launch after an unavailable endpoint; a denied or rejected call cannot. + if ( + error instanceof RuntimeClientError && + !(error instanceof RuntimeRpcFailureError) && + error.code === 'runtime_unavailable' + ) { + return null + } + throw error + } +} + +function throwDesktopActivationBlocked(): never { + throw new RuntimeClientError( + 'desktop_activation_blocked', + 'Orca is running headlessly, but it cannot open a desktop window safely because the persistent terminal provider is unavailable. Quit Orca normally and start the app again; do not use open -n.' + ) +} + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) diff --git a/src/cli/runtime/runtime-access-denied.test.ts b/src/cli/runtime/runtime-access-denied.test.ts index d4f62231201..2b110e04fb7 100644 --- a/src/cli/runtime/runtime-access-denied.test.ts +++ b/src/cli/runtime/runtime-access-denied.test.ts @@ -1,10 +1,13 @@ import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeMetadata } from '../../shared/runtime-bootstrap' -import { formatCliError, reportCliError } from '../cli-error' +import { formatCliError } from '../cli-error' +import { main } from '../index' +import recordedCodexDenial from './fixtures/codex-0.165.0-linux-connect-denied.json' import { RuntimeClient } from './client' import { launchOrcaApp } from './launch' -import { getCliStatus } from './status' +import { getCliStatus, getOfflineRuntimeAdmission } from './status' +import { preflightElectronServe } from './serve-electron-preflight' import { sendRequest } from './transport' const { connect, tryReadMetadata } = vi.hoisted(() => ({ @@ -12,7 +15,11 @@ const { connect, tryReadMetadata } = vi.hoisted(() => ({ tryReadMetadata: vi.fn() })) vi.mock('node:net', () => ({ createConnection: connect })) -vi.mock('./metadata', () => ({ tryReadMetadata, readMetadata: tryReadMetadata })) +vi.mock('./metadata', () => ({ + tryReadMetadata, + readMetadata: tryReadMetadata, + getDefaultUserDataPath: () => '/test' +})) vi.mock('./launch', () => ({ launchOrcaApp: vi.fn() })) vi.mock('./runtime-remote-pairing', () => ({ resolveRemoteConnection: () => null })) @@ -41,15 +48,16 @@ beforeEach(() => { socket = new TestSocket() connect.mockReturnValue(socket) tryReadMetadata.mockReturnValue(metadata) - mockKill() + mockKill('ESRCH') }) afterEach(() => { vi.unstubAllEnvs() vi.restoreAllMocks() vi.clearAllMocks() + vi.useRealTimers() }) -// Node emits 'error' then 'close' for a refused or denied connect. +// Synthetic Node error/events; recorded CLI output supplies only the errno for the replay below. function failConnect(code: string): void { socket.emit('error', Object.assign(new Error(`connect ${code} /private-runtime.sock`), { code })) socket.emit('close') @@ -85,12 +93,13 @@ describe('runtime access denied', () => { expect(human).not.toContain('private-runtime') }) - it('names the Codex sandbox only when CODEX_SANDBOX is set', async () => { + it('names the Codex sandbox only when synthetic CODEX_SANDBOX is set', async () => { vi.stubEnv('CODEX_SANDBOX', 'seatbelt') const human = formatCliError(await deniedRequest('EPERM')) expect(human).toContain('The Codex sandbox blocked this command from connecting to Orca') - expect(human).toContain('escalated permissions, outside the Codex sandbox') + expect(human).toContain("Codex's existing escalation when the session permits it") + expect(human).toContain('otherwise run the same command from a terminal outside the sandbox') expect(human).not.toMatch(RESTART_OR_ABSENT_ADVICE) }) @@ -98,54 +107,248 @@ describe('runtime access denied', () => { expect(await deniedRequest('ECONNREFUSED')).toMatchObject({ code: 'runtime_unavailable' }) }) - it.each([undefined, 'EPERM'])( - 'fails status instead of guessing a state (kill %s)', - async (killCode) => { - mockKill(killCode) - const pending = getCliStatus('/test') - failConnect('EPERM') + it.each(['EPERM', 'EACCES'])( + 'preserves %s with a hidden host PID through status and open without launching', + async (code) => { + const status = getCliStatus('/test') + failConnect(code) + await expect(status).rejects.toMatchObject({ code: 'runtime_access_denied' }) - await expect(pending).rejects.toMatchObject({ code: 'runtime_access_denied' }) + const open = new RuntimeClient('/test', 1000, null, null).openOrca() + failConnect(code) + await expect(open).rejects.toMatchObject({ code: 'runtime_access_denied' }) + expect(launchOrcaApp).not.toHaveBeenCalled() + expect(connect).toHaveBeenCalledTimes(2) + expect(process.kill).not.toHaveBeenCalled() } ) - // Sandbox denial can mask ECONNREFUSED on a dead runtime’s leftover socket. - it('gives not-running advice when the denied endpoint belongs to a dead pid', async () => { - mockKill('ESRCH') - const human = formatCliError(await deniedRequest('EPERM')) - - expect(human).toContain("Orca is not running. Run 'orca open' first.") - expect(human).not.toContain('Do not restart') - const pending = getCliStatus('/test') - failConnect('EPERM') - await expect(pending).resolves.toMatchObject({ - result: { app: { running: false }, runtime: { state: 'stale_bootstrap' } } - }) - }) - - it('does not launch or poll Orca when the initial status is denied', async () => { - const pending = new RuntimeClient('/test', 1000, null, null).openOrca() - failConnect('EPERM') - - await expect(pending).rejects.toMatchObject({ code: 'runtime_access_denied' }) - expect(launchOrcaApp).not.toHaveBeenCalled() - expect(connect).toHaveBeenCalledTimes(1) - }) - - it('reports status --json as an ok:false envelope with the recovery data', async () => { - const pending = getCliStatus('/test') - failConnect('EPERM') - const error = await pending.catch((failure: unknown) => failure) + it('preserves recorded Codex Linux denial and recovery advice through status --json', async () => { + vi.stubEnv('ORCA_PAIRING_CODE', '') + vi.stubEnv('ORCA_ENVIRONMENT', '') + vi.stubEnv('ORCA_REMOTE_PAIRING', '') + mockKill(recordedCodexDenial.provenance.publishedPidProbeSystemCode) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const priorExitCode = process.exitCode + try { + const pending = main(['status', '--json'], '/test') + await vi.waitFor(() => expect(connect).toHaveBeenCalledOnce()) + failConnect(recordedCodexDenial.response.error.data.systemCode) + await pending - reportCliError(error, true, { commandPath: ['status'] }) + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual(recordedCodexDenial.response) + expect(process.exitCode).toBe(recordedCodexDenial.exitCode) + expect(process.kill).not.toHaveBeenCalled() + expect(launchOrcaApp).not.toHaveBeenCalled() + } finally { + process.exitCode = priorExitCode + } + }) +}) - expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ +// Deterministic socket errno/events are synthetic, not a recorded sandbox transcript. +describe('private offline policy versus public connection truth', () => { + it.each([ + ['alive-to-absent', [true, false], 'runtime_access_denied', 1], + ['absent-to-alive', [false, true], true, 2], + ['absent-to-absent', [false, false], false, 2] + ] as const)( + 'samples denial at the original event and catch points: %s', + async (_name, samples, outcome, count) => { + const observations = [...samples] + vi.mocked(process.kill).mockImplementation(() => { + if (!observations.shift()) { + throw Object.assign(new Error('gone'), { code: 'ESRCH' }) + } + return true + }) + const pending = getOfflineRuntimeAdmission('/test').catch((error: unknown) => error) + failConnect('EPERM') + expect(process.kill).toHaveBeenCalledTimes(1) + // The transport's original await settles before the admission catch continuation. + await Promise.resolve() + expect(process.kill).toHaveBeenCalledTimes(1) + await Promise.resolve() + expect(process.kill).toHaveBeenCalledTimes(count) + expect(await pending).toMatchObject( + typeof outcome === 'string' + ? { code: outcome } + : { reachable: false, legacyPidRunning: outcome, pid: outcome ? metadata.pid : null } + ) + expect(tryReadMetadata).toHaveBeenCalledTimes(1) + expect(connect).toHaveBeenCalledTimes(1) + expect(vi.mocked(process.kill).mock.calls.every(([pid]) => pid === metadata.pid)).toBe(true) + } + ) + + function reply(value: unknown): void { + socket.emit('connect') + const request = JSON.parse(String(socket.write.mock.calls.at(-1)?.[0])) + socket.emit( + 'data', + `${JSON.stringify({ + id: request.id, + ...Object(value), + _meta: { runtimeId: metadata.runtimeId } + })}\n` + ) + } + + it('never probes for server denial, even with socket-shaped data; independent late error still maps', async () => { + const pending = getOfflineRuntimeAdmission('/test').catch((error: unknown) => error) + reply({ ok: false, error: { code: 'runtime_access_denied', - data: { systemCode: 'EPERM', nextSteps: expect.any(Array) } + message: 'server denied', + data: { code: 'EPERM', systemCode: 'EPERM' } } }) + await expect(pending).resolves.toMatchObject({ code: 'runtime_access_denied' }) + expect(process.kill).not.toHaveBeenCalled() + socket.emit('error', Object.assign(new Error('late'), { code: 'EPERM' })) + expect(process.kill).toHaveBeenCalledTimes(1) + await expect(pending).resolves.toMatchObject({ code: 'runtime_access_denied' }) + }) + + it.each([ + 'EMFILE', + 'ECONNREFUSED', + 'ECONNRESET', + 'UNKNOWN', + 'sync', + 'close', + 'protocol', + 'request-id', + 'replacement', + 'projection', + 'server', + 'timeout' + ])('retains the full outer catch domain and captured PID for %s', async (mode) => { + vi.useFakeTimers() + if (mode === 'sync') { + connect.mockImplementationOnce(() => { + throw new Error('construction failed') + }) + } + const pending = getOfflineRuntimeAdmission('/test') + tryReadMetadata.mockReturnValue({ ...metadata, pid: 99999 }) + if (mode === 'timeout') { + await vi.advanceTimersByTimeAsync(1000) + } else if (mode === 'close') { + socket.emit('close') + } else if (mode === 'protocol') { + socket.emit('data', 'invalid\n') + } else if (mode === 'request-id') { + socket.emit('data', '{"id":"wrong","ok":true,"result":{}}\n') + } else if (mode === 'replacement') { + socket.emit('connect') + const request = JSON.parse(String(socket.write.mock.calls.at(-1)?.[0])) + socket.emit( + 'data', + `${JSON.stringify({ + id: request.id, + ok: true, + result: {}, + _meta: { runtimeId: 'replacement' } + })}\n` + ) + } else if (mode === 'projection') { + reply({ ok: true, result: null }) + } else if (mode === 'server') { + reply({ ok: false, error: { code: 'runtime_unavailable', message: 'refused' } }) + } else if (mode !== 'sync') { + failConnect(mode) + } + expect(await pending).toEqual({ reachable: false, legacyPidRunning: false, pid: null }) + expect(process.kill).toHaveBeenCalledExactlyOnceWith(metadata.pid, 0) + expect(connect).toHaveBeenCalledTimes(1) + expect(tryReadMetadata).toHaveBeenCalledTimes(1) + vi.useRealTimers() + }) + + it('does not probe incomplete metadata, and projects the entire success before admission', async () => { + tryReadMetadata.mockReturnValueOnce({ ...metadata, authToken: null }) + expect(await getOfflineRuntimeAdmission('/test')).toEqual({ + reachable: false, + legacyPidRunning: false, + pid: null + }) + expect(connect).not.toHaveBeenCalled() + const pending = new RuntimeClient('/test', 1000, null, null).getOfflineRuntimeAdmission() + reply({ + ok: true, + result: { runtimeId: metadata.runtimeId, graphStatus: 'ready', authoritativeWindowId: null } + }) + expect(await pending).toEqual({ reachable: true, legacyPidRunning: true, pid: metadata.pid }) + expect(process.kill).not.toHaveBeenCalled() + expect(connect).toHaveBeenCalledTimes(1) + }) + + it.each([0, -1, null])( + 'retains nonpositive or missing legacy PID %s without a syscall', + async (pid) => { + tryReadMetadata.mockReturnValueOnce({ ...metadata, pid }) + const pending = getOfflineRuntimeAdmission('/test') + failConnect('EPERM') + expect(await pending).toEqual({ reachable: false, legacyPidRunning: false, pid: null }) + expect(process.kill).not.toHaveBeenCalled() + } + ) + + it.each(['EPERM', 'EINVAL', 'non-error'])( + 'retains unverifiable legacy PID on %s probe exception', + async (code) => { + vi.mocked(process.kill).mockImplementation(() => { + throw code === 'non-error' ? code : Object.assign(new Error(code), { code }) + }) + const pending = getOfflineRuntimeAdmission('/test') + failConnect('ECONNREFUSED') + expect(await pending).toEqual({ reachable: false, legacyPidRunning: true, pid: metadata.pid }) + expect(process.kill).toHaveBeenCalledExactlyOnceWith(metadata.pid, 0) + } + ) + + it('keeps malformed transport outside the private catch and public projection errors raw', async () => { + tryReadMetadata.mockReturnValueOnce({ ...metadata, transports: [null] }) + await expect(getOfflineRuntimeAdmission('/test')).rejects.toBeInstanceOf(TypeError) + expect(process.kill).not.toHaveBeenCalled() + const pending = getCliStatus('/test').catch((error: unknown) => error) + reply({ ok: true, result: null }) + const error = await pending + expect(error).toBeInstanceOf(TypeError) + expect(formatCliError(error)).toContain('null') + expect(process.kill).not.toHaveBeenCalled() }) }) + +describe('serve default admission on synthetic socket denials', () => { + it.each([ + [true, 'EPERM', 1, 'credentials'], + [false, 'EPERM', 2, 'cannot be shown to be free'], + [false, 'ENOENT', 2, null] + ] as const)( + 'preserves event-time alive=%s and listener errno=%s', + async (alive, listenerCode, connections, message) => { + if (alive) { + vi.mocked(process.kill).mockReturnValue(true) + } + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const pending = preflightElectronServe({}, '/test', { platform: 'linux' }) + failConnect('EPERM') + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + if (!alive) { + socket.emit('error', Object.assign(new Error('listener'), { code: listenerCode })) + } + expect(await pending).toBe(message ? 3 : null) + expect(connect).toHaveBeenCalledTimes(connections) + expect(process.kill).toHaveBeenCalledTimes(alive ? 1 : 2) + if (message) { + expect(String(stderr.mock.calls.at(-1)?.[0])).toContain(message) + } + } + ) +}) diff --git a/src/cli/runtime/runtime-access-denied.ts b/src/cli/runtime/runtime-access-denied.ts index 36393a88173..33b8dde90b3 100644 --- a/src/cli/runtime/runtime-access-denied.ts +++ b/src/cli/runtime/runtime-access-denied.ts @@ -1,14 +1,9 @@ import { RuntimeClientError } from './types' -import { isProcessRunning } from './runtime-pid-liveness' // Errno determines denial; CODEX_SANDBOX only selects wording. -export function runtimeAccessDeniedError( - socketError: Error, - pid: number -): RuntimeClientError | null { +export function runtimeAccessDeniedError(socketError: Error): RuntimeClientError | null { const systemCode = 'code' in socketError ? socketError.code : undefined - // ESRCH identifies a dead runtime even when its socket is access denied. - if ((systemCode !== 'EPERM' && systemCode !== 'EACCES') || !isProcessRunning(pid)) { + if (systemCode !== 'EPERM' && systemCode !== 'EACCES') { return null } const codexSandbox = Boolean(process.env.CODEX_SANDBOX) @@ -16,7 +11,7 @@ export function runtimeAccessDeniedError( ? `The Codex sandbox blocked this command from connecting to Orca (${systemCode}). Orca may be running normally.` : `Permission denied connecting to Orca (${systemCode}). Orca may be running normally; this command's sandbox or OS permissions block the connection.` const retryStep = codexSandbox - ? 'Re-run this command with escalated permissions, outside the Codex sandbox.' + ? "Retry through Codex's existing escalation when the session permits it; otherwise run the same command from a terminal outside the sandbox." : 'Re-run this command outside its sandbox, or as a user allowed to reach the Orca runtime.' return new RuntimeClientError('runtime_access_denied', message, { systemCode, diff --git a/src/cli/runtime/runtime-pid-liveness.ts b/src/cli/runtime/runtime-pid-liveness.ts index ceb9450b4a5..bbe7b8da93b 100644 --- a/src/cli/runtime/runtime-pid-liveness.ts +++ b/src/cli/runtime/runtime-pid-liveness.ts @@ -1,3 +1,4 @@ +// Only the private offline compatibility policy uses this unqualified legacy PID observation. export function isProcessRunning(pid: number | null | undefined): boolean { if (!pid || pid <= 0) { return false @@ -6,7 +7,6 @@ export function isProcessRunning(pid: number | null | undefined): boolean { process.kill(pid, 0) return true } catch (error) { - // Only ESRCH proves absence; EPERM indicates a live PID under another uid. return !(error instanceof Error && 'code' in error && error.code === 'ESRCH') } } diff --git a/src/cli/runtime/serve-electron-preflight.test.ts b/src/cli/runtime/serve-electron-preflight.test.ts index 06dd51ecadb..e612a5f9161 100644 --- a/src/cli/runtime/serve-electron-preflight.test.ts +++ b/src/cli/runtime/serve-electron-preflight.test.ts @@ -1,12 +1,24 @@ -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' +import { createServer, type Socket } from 'node:net' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeMetadata } from '../../shared/runtime-bootstrap' import { readServeAbortPark, writeServeAbortPark } from '../../shared/serve-abort-park' import { preflightElectronServe, recordElectronServeStartupAbort } from './serve-electron-preflight' +import * as metadataReader from './metadata' import { RuntimeClientError } from './types' +vi.mock('./metadata', async (original) => { + const actual = await original() + return { ...actual, tryReadMetadata: vi.fn(actual.tryReadMetadata) } +}) + +let actualMetadata: typeof metadataReader +beforeAll(async () => { + actualMetadata = await vi.importActual('./metadata') +}) + const METADATA: RuntimeMetadata = { runtimeId: 'runtime-1', pid: 4242, @@ -16,16 +28,7 @@ const METADATA: RuntimeMetadata = { } function status(reachable: boolean, pid: number | null = 4242) { - return { - id: 'local', - ok: true as const, - result: { - app: { running: reachable, pid }, - runtime: { state: reachable ? 'ready' : 'stale_bootstrap', reachable, runtimeId: null }, - graph: { state: 'not_running' } - }, - _meta: { runtimeId: null } - } + return { reachable, legacyPidRunning: reachable, pid } } describe('preflightElectronServe', () => { @@ -54,7 +57,7 @@ describe('preflightElectronServe', () => { ) { return { readMetadata: () => (overrides.metadata === undefined ? METADATA : overrides.metadata), - readStatus: vi.fn(async () => { + readAdmission: vi.fn(async () => { if (overrides.statusError) { throw overrides.statusError } @@ -176,3 +179,79 @@ describe('preflightElectronServe', () => { }) }) }) + +// Real bootstrap/transport/listener coverage of the default admission dependency. +describe.skipIf(process.platform === 'win32')('serve private admission snapshots', () => { + let root: string + let server: ReturnType | undefined + const sockets = new Set() + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-serve-admission-')) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + }) + afterEach(async () => { + for (const socket of sockets) { + socket.destroy() + } + sockets.clear() + if (server) { + const listening = server + await new Promise((resolve) => listening.close(() => resolve())) + } + server = undefined + vi.restoreAllMocks() + vi.mocked(metadataReader.tryReadMetadata).mockReset() + vi.mocked(metadataReader.tryReadMetadata).mockImplementation(actualMetadata.tryReadMetadata) + rmSync(root, { recursive: true, force: true }) + }) + function publish(pid = 2147483647, endpoint = join(root, 'runtime.sock')) { + const metadata = { ...METADATA, pid, transports: [{ kind: 'unix', endpoint }] } + writeFileSync(join(root, 'orca-runtime.json'), JSON.stringify(metadata)) + return metadata + } + it('keeps the missing-M0 default path synchronous without a second read', () => { + expect(preflightElectronServe({}, root, { platform: 'linux' })).toBeNull() + expect(metadataReader.tryReadMetadata).toHaveBeenCalledTimes(1) + }) + it.each([2147483647, process.pid])( + 'retains launch permission for absent listener even with PID %s', + async (pid) => { + publish(pid) + await expect(preflightElectronServe({}, root, { platform: 'linux' })).resolves.toBeNull() + expect(metadataReader.tryReadMetadata).toHaveBeenCalledTimes(2) + } + ) + it.each(['disappeared', 'replacement', 'incomplete'])( + 'probes original M0 listener after M1 %s', + async (change) => { + const first = publish() + const listener = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.resume() + }) + server = listener + await new Promise((resolve) => listener.listen(first.transports[0].endpoint, resolve)) + vi.mocked(metadataReader.tryReadMetadata).mockImplementationOnce((path) => { + const m0 = actualMetadata.tryReadMetadata(path) + if (change === 'disappeared') { + unlinkSync(join(root, 'orca-runtime.json')) + } else { + publish(process.pid, join(root, 'new.sock')) + if (change === 'incomplete') { + writeFileSync( + join(root, 'orca-runtime.json'), + JSON.stringify({ ...first, authToken: null }) + ) + } + } + return m0 + }) + await expect(preflightElectronServe({}, root, { platform: 'linux' })).resolves.toBe(3) + expect(String(vi.mocked(process.stderr.write).mock.calls.at(-1)?.[0])).toContain( + change === 'replacement' ? `pid ${process.pid}` : 'pid 2147483647' + ) + expect(metadataReader.tryReadMetadata).toHaveBeenCalledTimes(2) + } + ) +}) diff --git a/src/cli/runtime/serve-electron-preflight.ts b/src/cli/runtime/serve-electron-preflight.ts index 99b944daa52..44b474b7621 100644 --- a/src/cli/runtime/serve-electron-preflight.ts +++ b/src/cli/runtime/serve-electron-preflight.ts @@ -11,7 +11,7 @@ import { } from '../../shared/serve-abort-park' import { tryReadMetadata } from './metadata' import { probeRuntimeListener, type RuntimeListenerProbe } from './runtime-listener-probe' -import { getCliStatus } from './status' +import { getOfflineRuntimeAdmission, type OfflineRuntimeAdmission } from './status' import { RuntimeClientError } from './types' /** Only an absent owner permits a new serve process. */ @@ -20,19 +20,15 @@ export type ServeProfileVerdict = | { verdict: 'unverifiable'; reason: 'endpoint-held' | 'access-denied'; pid: number | null } | { verdict: 'absent' } -type ServeProfileStatus = { - result: { app: { pid?: number | null }; runtime: { reachable: boolean } } -} - type PreflightDeps = { readMetadata: (userDataPath: string) => RuntimeMetadata | null - readStatus: (userDataPath: string) => Promise + readAdmission: (userDataPath: string) => Promise probeListener: (metadata: RuntimeMetadata) => Promise } const DEFAULT_DEPS: PreflightDeps = { readMetadata: tryReadMetadata, - readStatus: getCliStatus, + readAdmission: getOfflineRuntimeAdmission, probeListener: probeRuntimeListener } @@ -42,17 +38,17 @@ export async function decideServeProfileVerdict( metadata: RuntimeMetadata, deps: PreflightDeps = DEFAULT_DEPS ): Promise { - let status: ServeProfileStatus + let status: OfflineRuntimeAdmission try { - status = await deps.readStatus(userDataPath) + status = await deps.readAdmission(userDataPath) } catch (error) { if (error instanceof RuntimeClientError && error.code === 'runtime_access_denied') { return { verdict: 'unverifiable', reason: 'access-denied', pid: metadata.pid ?? null } } throw error } - const pid = status.result.app.pid ?? metadata.pid ?? null - if (status.result.runtime.reachable) { + const pid = status.pid ?? metadata.pid ?? null + if (status.reachable) { return { verdict: 'live', evidence: 'rpc', pid } } const probe = await deps.probeListener(metadata) diff --git a/src/cli/runtime/status.test.ts b/src/cli/runtime/status.test.ts index f137eab6267..d1e519d904d 100644 --- a/src/cli/runtime/status.test.ts +++ b/src/cli/runtime/status.test.ts @@ -66,10 +66,10 @@ describe.skipIf(process.platform === 'win32')('CLI runtime status', () => { getRuntimeMetadataPath(userDataPath), JSON.stringify({ runtimeId: 'runtime-legacy', - pid: process.pid, + pid: 424242, transport: { kind: 'unix', endpoint }, authToken: 'token', - startedAt: Date.now() + startedAt: 1 }) ) @@ -85,51 +85,67 @@ describe.skipIf(process.platform === 'win32')('CLI runtime status', () => { }) }) -// EPERM proves a PID exists under another uid; it must not mark a live runtime dead. -describe.skipIf(process.platform === 'win32')('CLI status pid fallback', () => { - async function statusWithUnreachableRuntime( - killError: NodeJS.ErrnoException - ): Promise>> { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-status-probe-')) +// A persisted host PID cannot establish liveness in the caller's namespace. +describe.skipIf(process.platform === 'win32')('CLI status connection authority', () => { + it.each(['EPERM', 'ESRCH'])( + 'reports an unavailable endpoint without using a PID probe (%s)', + async (code) => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-status-probe-')) + writeFileSync( + getRuntimeMetadataPath(userDataPath), + JSON.stringify({ + runtimeId: 'runtime-unreachable', + pid: 424242, + transport: { kind: 'unix', endpoint: join(userDataPath, 'absent.sock') }, + authToken: 'token', + startedAt: 1 + }) + ) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error(`kill ${code}`), { code }) + }) + try { + await expect(new RuntimeClient(userDataPath).getCliStatus()).rejects.toMatchObject({ + code: 'runtime_unavailable' + }) + expect(killSpy).not.toHaveBeenCalled() + } finally { + killSpy.mockRestore() + } + } + ) + + it('reports a status timeout from a live listener without declaring its hidden PID dead', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-status-timeout-')) + const endpoint = join(userDataPath, 'runtime.sock') + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.resume() + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) writeFileSync( getRuntimeMetadataPath(userDataPath), JSON.stringify({ - runtimeId: 'runtime-unreachable', + runtimeId: 'runtime-timeout', pid: 424242, - // Nothing is listening here, so `status.get` fails and the pid probe decides. - transport: { kind: 'unix', endpoint: join(userDataPath, 'absent.sock') }, + transports: [{ kind: 'unix', endpoint }], authToken: 'token', - startedAt: Date.now() + startedAt: 1 }) ) const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { - throw killError + throw Object.assign(new Error('hidden PID'), { code: 'ESRCH' }) }) try { - return await new RuntimeClient(userDataPath).getCliStatus() + await expect(new RuntimeClient(userDataPath).getCliStatus()).rejects.toMatchObject({ + code: 'runtime_timeout' + }) + expect(killSpy).not.toHaveBeenCalled() } finally { killSpy.mockRestore() } - } - - it('keeps an unsignalable app running rather than calling the bootstrap stale', async () => { - const status = await statusWithUnreachableRuntime( - Object.assign(new Error('kill EPERM'), { code: 'EPERM' }) - ) - - expect(status.result.app).toMatchObject({ running: true, pid: 424242 }) - expect(status.result.runtime.state).toBe('starting') - expect(status.result.graph.state).toBe('starting') - }) - - it('still reports a stale bootstrap when the host proves the pid is gone', async () => { - const status = await statusWithUnreachableRuntime( - Object.assign(new Error('kill ESRCH'), { code: 'ESRCH' }) - ) - - expect(status.result.app).toMatchObject({ running: false, pid: null }) - expect(status.result.runtime.state).toBe('stale_bootstrap') - expect(status.result.graph.state).toBe('not_running') }) }) diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index b49cb7abc59..0abe4d62c9e 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -1,14 +1,20 @@ import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' import { runtimeHostConnectionState } from '../../shared/runtime-host-connection-state' -import { findTransport } from '../../shared/runtime-bootstrap' +import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap' import { tryReadMetadata } from './metadata' import { sendRequest } from './transport' import { projectRemoteAppStatus, resolveDesktopWindowStatus } from '../../shared/cli-app-status-projection' -import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' +import { + RuntimeClientError, + RuntimeRpcFailureError, + type RuntimeRpcResponse, + type RuntimeRpcSuccess +} from './types' import { isProcessRunning } from './runtime-pid-liveness' +import { runtimeAccessDeniedError } from './runtime-access-denied' export { projectRemoteAppStatus, resolveDesktopWindowStatus } @@ -35,62 +41,91 @@ export async function getCliStatus( }) } + const response = await sendRequest(metadata, 'status.get', undefined, 1000) + return projectConnectedStatus(metadata, response) +} + +export type OfflineRuntimeAdmission = { + reachable: boolean + legacyPidRunning: boolean + pid: number | null +} + +// Shipped offline commands retain their original admission policy, separate from connection truth. +export async function getOfflineRuntimeAdmission( + userDataPath: string +): Promise { + const metadata = tryReadMetadata(userDataPath) + const transport = metadata ? findTransport(metadata, 'unix', 'named-pipe') : null + if (!transport || !metadata?.authToken) { + return { reachable: false, legacyPidRunning: false, pid: null } + } try { - const response = await sendRequest(metadata, 'status.get', undefined, 1000) - if (response.ok === false) { - throw new RuntimeRpcFailureError(response) - } - const graphState = response.result.graphStatus - const desktopWindowStatus = resolveDesktopWindowStatus(response.result) - return buildCliStatusResponse({ - app: { - running: true, - pid: metadata.pid, - ...(desktopWindowStatus ? { desktopWindowStatus } : {}) - }, - runtime: { - state: graphState === 'ready' ? 'ready' : 'graph_not_ready', - reachable: true, - connectionState: runtimeHostConnectionState({ - hasStatusEntry: true, - status: response.result - }), - runtimeId: response.result.runtimeId, - ...(response.result.appVersion ? { appVersion: response.result.appVersion } : {}), - ...(response.result.remoteUpdateSupport - ? { remoteUpdateSupport: response.result.remoteUpdateSupport } - : {}), - ...(response.result.capabilities ? { capabilities: response.result.capabilities } : {}), - ...(response.result.degradations ? { degradations: response.result.degradations } : {}) - }, - graph: { - state: graphState + const response = await sendRequest( + metadata, + 'status.get', + undefined, + 1000, + undefined, + undefined, + (error) => { + const code = 'code' in error ? error.code : undefined + return (code === 'EPERM' || code === 'EACCES') && isProcessRunning(metadata.pid) + ? runtimeAccessDeniedError(error) + : null } - }) + ) + const status = projectConnectedStatus(metadata, response) + return { + reachable: status.result.runtime.reachable, + legacyPidRunning: status.result.app.running, + pid: status.result.app.pid ?? null + } } catch (error) { - // Access denial does not establish runtime liveness. if (error instanceof RuntimeClientError && error.code === 'runtime_access_denied') { throw error } const running = isProcessRunning(metadata.pid) - return buildCliStatusResponse({ - app: { - running, - pid: running ? metadata.pid : null - }, - runtime: { - state: running ? 'starting' : 'stale_bootstrap', - reachable: false, - connectionState: 'disconnected', - runtimeId: null - }, - graph: { - state: running ? 'starting' : 'not_running' - } - }) + return { reachable: false, legacyPidRunning: running, pid: running ? metadata.pid : null } } } +function projectConnectedStatus( + metadata: RuntimeMetadata, + response: RuntimeRpcResponse +): RuntimeRpcSuccess { + if (response.ok === false) { + throw new RuntimeRpcFailureError(response) + } + const graphState = response.result.graphStatus + const desktopWindowStatus = resolveDesktopWindowStatus(response.result) + return buildCliStatusResponse({ + app: { + running: true, + pid: metadata.pid, + ...(desktopWindowStatus ? { desktopWindowStatus } : {}) + }, + runtime: { + state: graphState === 'ready' ? 'ready' : 'graph_not_ready', + reachable: true, + connectionState: runtimeHostConnectionState({ + hasStatusEntry: true, + status: response.result + }), + runtimeId: response.result.runtimeId, + ...(response.result.appVersion ? { appVersion: response.result.appVersion } : {}), + ...(response.result.remoteUpdateSupport + ? { remoteUpdateSupport: response.result.remoteUpdateSupport } + : {}), + ...(response.result.capabilities ? { capabilities: response.result.capabilities } : {}), + ...(response.result.degradations ? { degradations: response.result.degradations } : {}) + }, + graph: { + state: graphState + } + }) +} + function buildCliStatusResponse(result: CliStatusResult): RuntimeRpcSuccess { return { id: 'local-status', diff --git a/src/cli/runtime/transport.test.ts b/src/cli/runtime/transport.test.ts index 8c49dc4707b..caab9ceb794 100644 --- a/src/cli/runtime/transport.test.ts +++ b/src/cli/runtime/transport.test.ts @@ -7,6 +7,7 @@ import type * as Net from 'node:net' import { afterEach, describe, expect, it, vi } from 'vitest' import type { RuntimeMetadata } from '../../shared/runtime-bootstrap' import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay' +import { formatCliError } from '../cli-error' import { sendRequest } from './transport' vi.mock('node:net', async (importOriginal) => { @@ -70,6 +71,45 @@ describe('runtime transport timeout validation', () => { // These fixtures create Unix domain sockets rather than Windows named pipes. describe.skipIf(process.platform === 'win32')('runtime transport', () => { + it('rejects a responding replacement runtime with retry advice in human output', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-transport-')) + roots.push(userDataPath) + const endpoint = join(userDataPath, 'runtime.sock') + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.once('data', (data) => { + const request: { id: string } = JSON.parse(String(data).trim()) + socket.end( + `${JSON.stringify({ + id: request.id, + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-replacement' } + })}\n` + ) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + + const metadata: RuntimeMetadata = { + runtimeId: 'runtime-original', + pid: 123, + transports: [{ kind: 'unix', endpoint }], + authToken: 'token', + startedAt: 1 + } + const error: unknown = await sendRequest(metadata, 'status.get', undefined, 1000).catch( + (error: unknown) => error + ) + + expect(error).toMatchObject({ code: 'runtime_unavailable' }) + expect(formatCliError(error)).toBe( + 'The Orca runtime changed while the request was in flight.\nNext step: Retry the command.' + ) + }) + it('refreshes the per-call timeout when the runtime sends keepalive frames', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-transport-')) roots.push(userDataPath) @@ -151,8 +191,7 @@ describe.skipIf(process.platform === 'win32')('runtime transport', () => { await vi.runAllTimersAsync() expect(await response).toMatchObject({ code: 'runtime_unavailable', - message: - 'The Orca runtime closed the connection before responding. Restart Orca and try again.' + message: 'Orca closed the connection before responding.' }) expect(vi.getTimerCount()).toBe(0) }) diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index f6b795eda92..981deaa87ec 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -17,7 +17,8 @@ export async function sendRequest( timeoutMs: number, envelope?: RuntimeOrchestrationEnvelope, // Federation forwards envelopes; the runtime fence must stay local. - expectedRuntimeSource?: ExpectedRuntimeSource + expectedRuntimeSource?: ExpectedRuntimeSource, + classifySocketError: (error: Error) => RuntimeClientError | null = runtimeAccessDeniedError ): Promise> { if (!isSafeTimerDelayMs(timeoutMs)) { throw new RuntimeClientError( @@ -79,10 +80,11 @@ export async function sendRequest( ok: false, // Access denial does not establish that the app is dead. error: - runtimeAccessDeniedError(error, metadata.pid) ?? + classifySocketError(error) ?? new RuntimeClientError( 'runtime_unavailable', - 'Could not connect to the running Orca app. Restart Orca and try again.' + 'This terminal could not connect to Orca.', + { nextSteps: ['Check that Orca is open and that this terminal can access it.'] } ) }) }) @@ -92,7 +94,8 @@ export async function sendRequest( ok: false, error: new RuntimeClientError( 'runtime_unavailable', - 'The Orca runtime closed the connection before responding. Restart Orca and try again.' + 'Orca closed the connection before responding.', + { nextSteps: ['Check that Orca is open and that this terminal can access it.'] } ) }) }) @@ -171,7 +174,8 @@ export async function sendRequest( ok: false, error: new RuntimeClientError( 'runtime_unavailable', - 'The Orca runtime changed while the request was in flight. Retry the command.' + 'The Orca runtime changed while the request was in flight.', + { nextSteps: ['Retry the command.'] } ) }) return diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index aa027240a8b..c2cf50088e7 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -74,6 +74,11 @@ describe('CLI remote WebSocket transport', () => { }) const client = new RuntimeClient('/tmp/unused', 5_000, pairingUrl) const response = await client.call<{ runtimeId: string }>('status.get') + expect(await client.getOfflineRuntimeAdmission()).toEqual({ + reachable: true, + legacyPidRunning: false, + pid: null + }) expect(response.ok).toBe(true) expect(response.result.runtimeId).toBe('runtime-ws-1') diff --git a/src/main/cli/linux-terminal-orca-cli-shim.test.ts b/src/main/cli/linux-terminal-orca-cli-shim.test.ts index a70ecd54241..754503b414c 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.test.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.test.ts @@ -13,7 +13,7 @@ import { resolveAppImageLauncherEndpointPath } from './appimage-stable-launcher' import { ensureLinuxTerminalOrcaCliShimDir } from './linux-terminal-orca-cli-shim' const created: string[] = [] -const canFenceAppImageRuntime = process.platform === 'linux' && existsSync('/proc/self/stat') +const canFenceAppImageRuntime = process.platform === 'linux' async function makeFixture(): Promise<{ userDataPath: string; resourcesPath: string }> { const root = await mkdtemp(join(tmpdir(), 'orca-terminal-cli-shim-')) @@ -82,8 +82,7 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { const shimPath = join(shimDir!, 'orca') const content = readFileSync(shimPath, 'utf8') expect(content).toContain(liveLauncherPath) - expect(content).toContain('runtime_pid=') - expect(content).toContain('/proc/$runtime_pid/stat') + expect(content).not.toContain('/proc/') expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'live'))).toBe(false) await expect( runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) @@ -158,32 +157,4 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { ).resolves.toMatchObject({ code: 1, stdout: '' }) } ) - - it.skipIf(!canFenceAppImageRuntime)( - 'rejects a shim after its owning AppImage process generation changes', - async () => { - const { userDataPath, resourcesPath } = await makeFixture() - const appImagePath = join(userDataPath, 'Orca.AppImage') - await mkdir(userDataPath, { recursive: true }) - await writeFile(appImagePath, '#!/usr/bin/env bash\n', { mode: 0o755 }) - const liveLauncher = join(resourcesPath, 'bin', 'orca-ide') - writeFileSync(liveLauncher, '#!/usr/bin/env bash\nprintf original', { mode: 0o755 }) - const shimDir = ensureLinuxTerminalOrcaCliShimDir({ - userDataPath, - resourcesPath, - appImagePath, - appImageCacheRootPath: join(userDataPath, 'cache') - }) - const shimPath = join(shimDir!, 'orca') - const staleContent = readFileSync(shimPath, 'utf8').replace( - /runtime_start_time='[^']*'/, - "runtime_start_time='stale-process'" - ) - writeFileSync(shimPath, staleContent, { mode: 0o755 }) - - await expect( - runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) - ).resolves.toMatchObject({ code: 1, stdout: '' }) - } - ) }) diff --git a/src/main/cli/linux-terminal-orca-cli-shim.ts b/src/main/cli/linux-terminal-orca-cli-shim.ts index fdbd8bb30e6..df313ac5507 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.ts @@ -100,8 +100,6 @@ function ensureAppImageShim( } type AppImageRuntimeFence = { - pid: number - startTime: string runtimeRoot: string runtimeIdentity: string launcherIdentity: string @@ -116,11 +114,8 @@ function captureAppImageRuntimeFence(launcherPath: string): AppImageRuntimeFence const runtimeRoot = dirname(dirname(dirname(resolvedLauncherPath))) const runtimeIdentity = readFileIdentity(runtimeRoot) const launcherIdentity = readFileIdentity(resolvedLauncherPath) - const startTime = readLinuxProcessStartTime(process.pid) - return runtimeIdentity && launcherIdentity && startTime + return runtimeIdentity && launcherIdentity ? { - pid: process.pid, - startTime, runtimeRoot, runtimeIdentity, launcherIdentity, @@ -148,36 +143,15 @@ function formatFileIdentity(stats: Stats): string { ].join(':') } -function readLinuxProcessStartTime(pid: number): string | null { - try { - const content = readFileSync(join('/proc', String(pid), 'stat'), 'utf8') - const commandEnd = content.lastIndexOf(') ') - if (commandEnd === -1) { - return null - } - const fields = content - .slice(commandEnd + 2) - .trim() - .split(/\s+/) - return fields[19] ?? null - } catch { - return null - } -} - function buildAppImageLiveLauncherScript( launcherPath: string, fence: AppImageRuntimeFence ): string { - const runtimePid = quotePosixShellDoubleQuotedApostrophe(String(fence.pid)) - const runtimeStartTime = quotePosixShellDoubleQuotedApostrophe(fence.startTime) const runtimeRoot = quotePosixShellDoubleQuotedApostrophe(fence.runtimeRoot) const expectedRuntimeIdentity = quotePosixShellDoubleQuotedApostrophe(fence.runtimeIdentity) const expectedLauncherIdentity = quotePosixShellDoubleQuotedApostrophe(fence.launcherIdentity) const quotedLauncherPath = quotePosixShellDoubleQuotedApostrophe(launcherPath) return `#!/usr/bin/env bash -runtime_pid=${runtimePid} -runtime_start_time=${runtimeStartTime} runtime_root=${runtimeRoot} launcher=${quotedLauncherPath} expected_runtime_identity=${expectedRuntimeIdentity} @@ -186,12 +160,6 @@ fail() { printf 'Orca CLI is unavailable; reopen Orca or register the CLI again.\\n' >&2 exit 1 } -proc_stat_path="/proc/$runtime_pid/stat" -[[ -r "$proc_stat_path" ]] || fail -proc_stat="$(<"$proc_stat_path")" || fail -proc_fields=() -read -r -a proc_fields <<< "\${proc_stat##*) }" || fail -[[ "\${proc_fields[19]:-}" == "$runtime_start_time" ]] || fail runtime_identity="$(stat -Lc '%d:%i:%s:%Y:%Z' -- "$runtime_root" 2>/dev/null)" || fail [[ "$runtime_identity" == "$expected_runtime_identity" ]] || fail launcher_identity="$(stat -Lc '%d:%i:%s:%Y:%Z' -- "$launcher" 2>/dev/null)" || fail diff --git a/src/main/cli/orca-cli-child-path.test.ts b/src/main/cli/orca-cli-child-path.test.ts index 9c162e3656f..28394099360 100644 --- a/src/main/cli/orca-cli-child-path.test.ts +++ b/src/main/cli/orca-cli-child-path.test.ts @@ -45,6 +45,24 @@ describe('prependOrcaCliDirToChildPath', () => { expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled() } ) + it('exports the owning packaged Linux launcher and profile together with PATH', () => { + const env = { + PATH: `/usr/bin:${SHIM_DIR}::/bin`, + ORCA_CLI_COMMAND: '/other/orca', + ORCA_USER_DATA_PATH: '/other/profile' + } + const launcher = prependOrcaCliDirToChildPath(env, { + isPackaged: true, + userDataPath: USER_DATA, + platform: 'linux' + }) + + expect(launcher).toBe(join(SHIM_DIR, 'orca')) + expect(env.ORCA_CLI_COMMAND).toBe(launcher) + expect(env.ORCA_USER_DATA_PATH).toBe(USER_DATA) + expect(env.PATH).toBe(`${SHIM_DIR}:/usr/bin:/bin`) + }) + it('puts a Windows execution host CLI first on Path without a POSIX restore dir', () => { const binDir = join(USER_DATA, 'cli', 'bin') const launcher = join(binDir, 'orca.exe') diff --git a/src/main/cli/orca-cli-child-path.ts b/src/main/cli/orca-cli-child-path.ts index acdc66a5e80..c684087c992 100644 --- a/src/main/cli/orca-cli-child-path.ts +++ b/src/main/cli/orca-cli-child-path.ts @@ -59,7 +59,10 @@ export function prependOrcaCliDirToChildPath( .split(pathDelimiter) .filter((entry) => entry.length > 0 && entry !== shimDir) env.PATH = [shimDir, ...inheritedEntries].join(pathDelimiter) - return join(shimDir, 'orca') + const launcher = join(shimDir, 'orca') + env.ORCA_CLI_COMMAND = launcher + env.ORCA_USER_DATA_PATH = opts.userDataPath + return launcher } } else if (opts.resourcesPath && (platform === 'darwin' || platform === 'win32')) { const bundledCliBin = join(opts.resourcesPath, 'bin') diff --git a/src/main/ipc/pty/pty-daemon-spawn-agent-home-env.test.ts b/src/main/ipc/pty/pty-daemon-spawn-agent-home-env.test.ts index 00277207ab1..546183ca6a9 100644 --- a/src/main/ipc/pty/pty-daemon-spawn-agent-home-env.test.ts +++ b/src/main/ipc/pty/pty-daemon-spawn-agent-home-env.test.ts @@ -448,7 +448,8 @@ describe('registerPtyHandlers', () => { // Orca terminals must resolve this CLI before the GNOME screen reader (#7904). expect(entries.indexOf(shimDir)).toBeGreaterThanOrEqual(0) expect(entries.indexOf(shimDir)).toBeLessThan(entries.indexOf('/usr/bin')) - expect(env.ORCA_CLI_COMMAND).toBeUndefined() + expect(env.ORCA_CLI_COMMAND).toBe(join(shimDir, 'orca')) + expect(env.ORCA_USER_DATA_PATH).toBe('/tmp/orca-user-data') } finally { Object.defineProperty(process, 'platform', { configurable: true,