mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
* 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.
203 lines
7.2 KiB
TypeScript
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 }
|