Files
orca/src/cli/runtime/runtime-access-denied.test.ts
T
Jinwoo Hongandlifeodyssey 9af6a3d798 fix(cli): report a denied runtime connection instead of a dead Orca (#22341)
* fix(cli): report a denied runtime connection instead of a dead Orca

Inside Codex's macOS Seatbelt sandbox, connect() on the runtime socket fails
with EPERM. The CLI dropped the errno and reported "Could not connect ...
Restart Orca", appended "Orca is not running. Run 'orca open' first.", and
`orca status` answered ok:true with `starting` (its pid probe also gets EPERM).
An agent following that advice restarts a healthy app, which cannot help.

EPERM/EACCES on the metadata read or the socket/pipe connect now fails with a
CLI-local `runtime_access_denied` error: ok:false, non-zero exit,
operation/systemCode/processState:"unverifiable"/retryable:false and nextSteps
that say to re-run with escalated permissions and not restart. CODEX_SANDBOX
only picks the wording. `orca open` stops before launching.

The status pid probe is unchanged: a refused or missing socket proves the
caller reached the endpoint, so a later EPERM probe is another uid and keeps
#20098's `starting`. Missing, refused, stale-pid and timeout paths are
unchanged.

Adapted from the diagnosis and tests in #20487 (and #19605, #13583).

Co-authored-by: lifeodyssey <zhenjiazhou0127@outlook.com>

* docs(skills): tell agents runtime_access_denied means escalate, not restart

The shared CLI-resolution block told every bundled skill to run `orca open`
when a command says Orca is not running. Add the counterpart for the new
access-denied code so sandboxed agents re-run with escalated permissions
instead of launching or restarting Orca. Regenerated stubs and manifest.

* refactor(cli): classify only a denied runtime connect, with a leaner error

A denied metadata read was never observed under a sandbox, and it turned an
unreadable user-data path (the Linux launch contract's root-owned HOME) into
runtime_access_denied instead of "Orca is not running". Keep metadata reads as
on main and classify only the socket/pipe connect.

One helper now maps a socket errno to the error or null; the error data keeps
only systemCode and nextSteps. Tests drop cases already pinned by status.test.ts.

* fix(cli): give not-running advice when a denied socket belongs to a dead Orca

A crashed Orca leaves its metadata and socket file behind, and a sandbox denies
the connect with EPERM before the CLI can see ECONNREFUSED. The sandbox still
reports ESRCH for a gone pid, so a denied connect now probes the metadata pid
and falls through to the ordinary unavailable path when the pid is proven gone.
isProcessRunning moves to its own module so transport and status share it.

* refactor(cli): inline the runtime_access_denied code like other CLI error codes

---------

Co-authored-by: lifeodyssey <zhenjiazhou0127@outlook.com>
2026-09-22 22:46:17 -04:00

152 lines
5.2 KiB
TypeScript

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 { RuntimeClient } from './client'
import { launchOrcaApp } from './launch'
import { getCliStatus } from './status'
import { sendRequest } from './transport'
const { connect, tryReadMetadata } = vi.hoisted(() => ({
connect: vi.fn(),
tryReadMetadata: vi.fn()
}))
vi.mock('node:net', () => ({ createConnection: connect }))
vi.mock('./metadata', () => ({ tryReadMetadata, readMetadata: tryReadMetadata }))
vi.mock('./launch', () => ({ launchOrcaApp: vi.fn() }))
vi.mock('./runtime-remote-pairing', () => ({ resolveRemotePairing: () => null }))
const metadata: RuntimeMetadata = {
runtimeId: 'runtime-test',
pid: 12345,
transports: [{ kind: 'unix', endpoint: '/private-runtime.sock' }],
authToken: 'private-runtime-token',
startedAt: 1
}
class TestSocket extends EventEmitter {
setEncoding = vi.fn()
end = vi.fn()
destroy = vi.fn()
write = vi.fn()
}
const RESTART_OR_ABSENT_ADVICE =
/Restart Orca and try again|Orca is not running|Run 'orca open' first/
let socket: TestSocket
beforeEach(() => {
vi.stubEnv('CODEX_SANDBOX', '')
socket = new TestSocket()
connect.mockReturnValue(socket)
tryReadMetadata.mockReturnValue(metadata)
mockKill()
})
afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
vi.clearAllMocks()
})
// Node emits 'error' then 'close' for a refused or denied connect.
function failConnect(code: string): void {
socket.emit('error', Object.assign(new Error(`connect ${code} /private-runtime.sock`), { code }))
socket.emit('close')
}
function mockKill(code?: string): void {
vi.spyOn(process, 'kill').mockImplementation(() => {
if (code) {
throw Object.assign(new Error(`kill ${code}`), { code })
}
return true
})
}
async function deniedRequest(code: string): Promise<unknown> {
const pending = sendRequest(metadata, 'status.get', undefined, 1000)
failConnect(code)
return pending.catch((failure: unknown) => failure)
}
describe('runtime access denied', () => {
it.each(['EPERM', 'EACCES'])('classifies a %s connect without restart advice', async (code) => {
const error = await deniedRequest(code)
expect(error).toMatchObject({ code: 'runtime_access_denied', data: { systemCode: code } })
expect(socket.write).not.toHaveBeenCalled()
const human = formatCliError(error)
expect(human).toContain(
`Permission denied connecting to Orca (${code}). Orca may be running normally`
)
expect(human).toContain("Next step: Do not restart Orca or run 'orca open'")
expect(human).not.toMatch(RESTART_OR_ABSENT_ADVICE)
expect(human).not.toContain('private-runtime')
})
it('names the Codex sandbox only when 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).not.toMatch(RESTART_OR_ABSENT_ADVICE)
})
it('keeps ordinary connect failures as runtime_unavailable', async () => {
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')
await expect(pending).rejects.toMatchObject({ code: 'runtime_access_denied' })
}
)
// Why: a dead Orca leaves its socket behind, and the sandbox denies it before ECONNREFUSED.
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)
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
reportCliError(error, true, { commandPath: ['status'] })
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({
ok: false,
error: {
code: 'runtime_access_denied',
data: { systemCode: 'EPERM', nextSteps: expect.any(Array) }
}
})
})
})