mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix(cli): preserve runtime access across Linux PID namespaces (#35769)
* fix(cli): preserve runtime access across Linux PID namespaces * fix(cli): give retry advice after runtime replacement * fix(cli): preserve private offline admission policy * test(cli): align admission and launcher contracts * test(cli): prune resolved wall-clock allowance * test(cli): narrow dropped runtime errors without casts * test(cli): use explicit extension for hook service import * test(cli): replay recorded Linux sandbox denial * test: verify packaged Linux desktop CLI ownership * test: handle denied FUSE mounts in desktop fixture * test: use privileged PID namespace in desktop fixture
This commit is contained in:
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -114,9 +114,9 @@ async function updateAdmittedEnabledOnDisk(enabled: boolean): Promise<{
|
||||
}
|
||||
|
||||
async function updateRunningRuntime(client: RuntimeClient, enabled: boolean): Promise<boolean> {
|
||||
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.'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -83,8 +83,8 @@ function rejectProfileStateRemoteSelection(flags: ReadonlyMap<string, string | b
|
||||
}
|
||||
|
||||
async function requireStoppedRuntime(client: RuntimeClient): Promise<void> {
|
||||
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.'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown>
|
||||
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')
|
||||
})
|
||||
|
||||
+20
-41
@@ -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<OfflineRuntimeAdmission> {
|
||||
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<void> {
|
||||
if (!this.orchestrationContractCheck) {
|
||||
this.orchestrationContractCheck = this.checkOrchestrationContractCompatibility(
|
||||
@@ -277,44 +294,6 @@ export class RuntimeClient {
|
||||
}
|
||||
|
||||
async openOrca(timeoutMs = 15_000): Promise<RuntimeRpcSuccess<CliStatusResult>> {
|
||||
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<void>((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<typeof ServePreflight>()),
|
||||
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<typeof ServePreflight>('./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<Socket>()
|
||||
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<void>((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<void>((resolve) => server.close(() => resolve()))
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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<typeof ProcessHost>()),
|
||||
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<Socket>()
|
||||
let root: string
|
||||
let profile: string
|
||||
let endpoint: string
|
||||
let server: ReturnType<typeof createServer> | 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<void>((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<void>((resolve) => listener.listen(endpoint, resolve))
|
||||
renameSync(endpoint, `${endpoint}.stale`)
|
||||
await new Promise<void>((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<void>((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)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -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<RuntimeRpcSuccess<CliStatusResult>>
|
||||
|
||||
export async function openLocalOrca(
|
||||
readStatus: ReadStatus,
|
||||
timeoutMs: number
|
||||
): Promise<RuntimeRpcSuccess<CliStatusResult>> {
|
||||
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<RuntimeRpcSuccess<CliStatusResult> | 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<void>((resolve) => setTimeout(resolve, ms))
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof metadataReader>()
|
||||
return { ...actual, tryReadMetadata: vi.fn(actual.tryReadMetadata) }
|
||||
})
|
||||
|
||||
let actualMetadata: typeof metadataReader
|
||||
beforeAll(async () => {
|
||||
actualMetadata = await vi.importActual<typeof metadataReader>('./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<typeof createServer> | undefined
|
||||
const sockets = new Set<Socket>()
|
||||
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<void>((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<void>((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)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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<ServeProfileStatus>
|
||||
readAdmission: (userDataPath: string) => Promise<OfflineRuntimeAdmission>
|
||||
probeListener: (metadata: RuntimeMetadata) => Promise<RuntimeListenerProbe>
|
||||
}
|
||||
|
||||
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<ServeProfileVerdict> {
|
||||
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)
|
||||
|
||||
@@ -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<Awaited<ReturnType<RuntimeClient['getCliStatus']>>> {
|
||||
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<void>((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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+83
-48
@@ -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<RuntimeStatus>(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<OfflineRuntimeAdmission> {
|
||||
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<RuntimeStatus>(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<RuntimeStatus>(
|
||||
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<RuntimeStatus>
|
||||
): RuntimeRpcSuccess<CliStatusResult> {
|
||||
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<CliStatusResult> {
|
||||
return {
|
||||
id: 'local-status',
|
||||
|
||||
@@ -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<void>((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)
|
||||
})
|
||||
|
||||
@@ -17,7 +17,8 @@ export async function sendRequest<TResult>(
|
||||
timeoutMs: number,
|
||||
envelope?: RuntimeOrchestrationEnvelope,
|
||||
// Federation forwards envelopes; the runtime fence must stay local.
|
||||
expectedRuntimeSource?: ExpectedRuntimeSource
|
||||
expectedRuntimeSource?: ExpectedRuntimeSource,
|
||||
classifySocketError: (error: Error) => RuntimeClientError | null = runtimeAccessDeniedError
|
||||
): Promise<RuntimeRpcResponse<TResult>> {
|
||||
if (!isSafeTimerDelayMs(timeoutMs)) {
|
||||
throw new RuntimeClientError(
|
||||
@@ -79,10 +80,11 @@ export async function sendRequest<TResult>(
|
||||
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<TResult>(
|
||||
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<TResult>(
|
||||
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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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: '' })
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user