Files
orca/src/main/runtime/rpc/core.ts
T
Jinjing 1c8908b791 Fix orchestration gate authorization to scope by Run binding (#11802)
* fix(orchestration): gate methods route calls to the caller's Run with `f

Gates are Run-scoped state; every gate command now resolves the caller's active Run
(via pane binding or explicit --from flag) and authorizes within that Run's scope.
Settled adopted work no longer requires --takeover-legacy, and the legacy coordinator
fence respects both binding-based and attestation-based proof of authority.

* fix(orchestration): gate methods route calls to the caller's Run with at

Gate and run methods now verify that declared terminal handles match the caller's
attested identity, preventing spoofing of other coordinators. Extracted shared
`resolveRunScope` to enforce one authorization rule across all orchestration
mutations. Added comprehensive regression tests for #11745.
2026-07-31 10:56:20 -07:00

203 lines
7.2 KiB
TypeScript

// Why: single boundary between raw RPC frames and OrcaRuntimeService; keeps schema, handler, and result type on one object.
import { ZodError, type ZodType } from 'zod'
import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol'
import type { OrcaRuntimeService, OrchestrationCompatibilityCallerAuthority } from '../orca-runtime'
import type {
DeviceCredentialInstalled,
PairingGetEndpointsParams,
PairingGetEndpointsResult,
PairingProvisionRelayParams
} from '../../../shared/mobile-relay-credential-contract'
import type { RuntimeCapability } from '../../../shared/protocol-version'
import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence'
export type PairingRpcContext = {
getEndpoints(params: PairingGetEndpointsParams): Promise<PairingGetEndpointsResult>
provisionRelay(params: PairingProvisionRelayParams): Promise<DeviceCredentialInstalled>
}
export type RpcEnvelopeMeta = {
runtimeId: string
}
export type RpcSuccess = {
id: string
ok: true
result: unknown
streaming?: true
_meta: RpcEnvelopeMeta
}
export type RpcFailure = {
id: string
ok: false
error: {
code: string
message: string
data?: unknown
}
_meta: RpcEnvelopeMeta
}
export type RpcResponse = RpcSuccess | RpcFailure
export type RpcRequest = {
id: string
authToken: string
method: string
params?: unknown
orchestrationCapability?: string
orchestrationContractVersion?: number
orchestrationRequestId?: string
compatibilityInvocationId?: string
orchestrationCompatibilityEvidence?: OrchestrationCompatibilityEvidence
}
export type LegacyCoordinatorAuthorityProof = Readonly<{
runId: string
principalId: string | null
terminalHandle: string
paneKey: string
consumerGeneration: number
}>
export type RpcContext = {
runtime: OrcaRuntimeService
// Why: lets long-poll handlers release immediately on client disconnect instead of running down timeoutMs. See design doc §3.1.
signal?: AbortSignal
// Why: per-WebSocket key so the server reaps a closing socket's subscriptions without touching sibling sockets sharing the deviceToken.
connectionId?: string
// Why: shared-control multiplexes many logical streams over one socket; the frame id lets handlers register cleanup per logical stream.
requestId?: string
// Why: paired mobile device token; state-owning handlers use it to clean up when that device disconnects.
clientId?: string
// Why: navigation is keyed by revocable device identity, never by the bearer credential or transient socket id.
pairedDeviceId?: string
// Why: lets handlers gate mobile payload truncation to phones only; undefined for in-process callers → treat as full-class (no clip).
clientKind?: 'mobile' | 'runtime'
// Why: negotiation is bound to the authenticated socket, never asserted by a destructive request.
clientCapabilities?: readonly RuntimeCapability[]
// Why: Dispatch authority rides in the authenticated RPC envelope, never in user payload fields.
orchestrationCapability?: string
// Why: long-lived mutations such as ask can durably expose acceptance before their waiter settles.
recordMutationReceipt?: (receipt: unknown) => void
// Why: worker-start commits this identity with its starting Dispatch so crash recovery always has an inspectable operation.
orchestrationMutation?: {
callerFingerprint: string
requestId: string
method: string
payloadHash: string
}
// Why: Run-scoped handlers must compare declared handles with request attestation.
orchestrationCompatibilityEvidence?: OrchestrationCompatibilityEvidence
// Why: only the compatibility authority router can set this trusted scope; user params cannot bypass Run consumer binding.
legacyCoordinatorRunId?: string
legacyCoordinatorAuthority?: LegacyCoordinatorAuthorityProof
revalidateLegacyCoordinator?: () => string
orchestrationCompatibilityCallerAuthority?: OrchestrationCompatibilityCallerAuthority
// Why: federation pins the authenticated saved-environment caller without exposing its token to handlers or storage.
authenticatedCallerFingerprint?: string
pairing?: PairingRpcContext
// Why: mobile terminal traffic bypasses JSON streaming; undefined on Unix/socket and non-E2EE WebSocket paths.
sendBinary?: (bytes: Uint8Array<ArrayBufferLike>) => boolean | void
// Why: binary terminal frames arrive outside JSON-RPC once a stream is established; handlers register only the stream IDs they created.
registerBinaryStreamHandler?: (
streamId: number,
handler: (frame: TerminalStreamFrame) => void
) => () => void
}
export type RpcHandler<TParams> = (params: TParams, ctx: RpcContext) => Promise<unknown> | unknown
// Why: RpcMethod erases the param type; centralizing the cast in defineMethod sidesteps RpcHandler's contravariance.
export type RpcMethod = {
readonly name: string
readonly params: ZodType | null
readonly handler: (params: unknown, ctx: RpcContext) => Promise<unknown> | unknown
}
type DefineMethodSpec<TSchema extends ZodType | null> = {
name: string
params: TSchema
handler: RpcHandler<TSchema extends ZodType ? TSchema['_output'] : void>
}
export function defineMethod<TSchema extends ZodType | null>(
spec: DefineMethodSpec<TSchema>
): RpcMethod {
return {
name: spec.name,
params: spec.params,
handler: spec.handler as RpcMethod['handler']
}
}
export type RpcStreamingHandler<TParams> = (
params: TParams,
ctx: RpcContext,
emit: (result: unknown) => void
) => Promise<void>
// Why: the `stream` flag lets the dispatcher route these to the emit-based path instead of the one-shot Promise path.
export type RpcStreamingMethod = {
readonly name: string
readonly params: ZodType | null
readonly stream: true
readonly handler: (
params: unknown,
ctx: RpcContext,
emit: (result: unknown) => void
) => Promise<void>
}
type DefineStreamingMethodSpec<TSchema extends ZodType | null> = {
name: string
params: TSchema
handler: RpcStreamingHandler<TSchema extends ZodType ? TSchema['_output'] : void>
}
export function defineStreamingMethod<TSchema extends ZodType | null>(
spec: DefineStreamingMethodSpec<TSchema>
): RpcStreamingMethod {
return {
name: spec.name,
params: spec.params,
stream: true,
handler: spec.handler as RpcStreamingMethod['handler']
}
}
export type RpcAnyMethod = RpcMethod | RpcStreamingMethod
export function isStreamingMethod(method: RpcAnyMethod): method is RpcStreamingMethod {
return 'stream' in method && method.stream === true
}
export type RpcRegistry = ReadonlyMap<string, RpcAnyMethod>
export function buildRegistry(methods: readonly RpcAnyMethod[]): RpcRegistry {
const registry = new Map<string, RpcAnyMethod>()
for (const method of methods) {
if (registry.has(method.name)) {
throw new Error(`duplicate_rpc_method:${method.name}`)
}
registry.set(method.name, method)
}
return registry
}
export class InvalidArgumentError extends Error {
constructor(message: string) {
super(message)
this.name = 'InvalidArgumentError'
}
}
// Why: CLI surfaces one string; take the first issue's message, which each schema authors as the user-facing phrasing.
export function formatZodError(error: ZodError): string {
const first = error.issues[0]
return first?.message ?? 'invalid_argument'
}
export { ZodError }