diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts new file mode 100644 index 00000000000..9b4bae3d4e3 --- /dev/null +++ b/src/main/runtime/rpc/core.ts @@ -0,0 +1,101 @@ +// Why: this is the single boundary between raw RPC frames and the OrcaRuntimeService. +// Keeping the schema, handler, and result type attached to one object makes the +// CLI-facing contract greppable and lets the dispatcher verify every payload +// against the same shape the handler consumed during development. +import { ZodError, type ZodType } from 'zod' +import type { OrcaRuntimeService } from '../orca-runtime' + +export type RpcEnvelopeMeta = { + runtimeId: string +} + +export type RpcSuccess = { + id: string + ok: true + result: unknown + _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 +} + +export type RpcContext = { + runtime: OrcaRuntimeService +} + +export type RpcHandler = (params: TParams, ctx: RpcContext) => Promise | unknown + +// Why: defineMethod preserves the inferred param type locally so each handler +// is fully typed, but the erased `RpcMethod` form is what the dispatcher +// actually stores. The erasure lives in one cast inside defineMethod rather +// than in every method file, which is the tradeoff for the variance problem +// of `RpcHandler` being contravariant in its param type. +export type RpcMethod = { + readonly name: string + readonly params: ZodType | null + readonly handler: (params: unknown, ctx: RpcContext) => Promise | unknown +} + +type DefineMethodSpec = { + name: string + params: TSchema + handler: RpcHandler +} + +export function defineMethod( + spec: DefineMethodSpec +): RpcMethod { + return { + name: spec.name, + params: spec.params, + handler: spec.handler as RpcMethod['handler'] + } +} + +export type RpcRegistry = ReadonlyMap + +export function buildRegistry(methods: readonly RpcMethod[]): RpcRegistry { + const registry = new Map() + 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: zod aggregates all failing fields into `issues`, but the CLI surfaces +// a single string to users. Pick the first issue's message so callers see a +// message that matches the original handler's `Missing terminal handle`-style +// phrasing (each schema supplies that literal message on its own constraint). +export function formatZodError(error: ZodError): string { + const first = error.issues[0] + return first?.message ?? 'invalid_argument' +} + +export { ZodError } diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts new file mode 100644 index 00000000000..105fba0bc55 --- /dev/null +++ b/src/main/runtime/rpc/dispatcher.ts @@ -0,0 +1,78 @@ +// Why: the dispatcher is the one place that knows how to turn a validated +// RPC request into a response envelope. Splitting it from the transport +// makes it unit-testable without spinning up a socket, and keeps +// runtime-rpc.ts focused on framing/auth/connection bookkeeping. +import { + ZodError, + buildRegistry, + formatZodError, + type RpcEnvelopeMeta, + type RpcMethod, + type RpcRegistry, + type RpcRequest, + type RpcResponse +} from './core' +import { errorResponse, mapBrowserError, mapRuntimeError, successResponse } from './errors' +import { ALL_RPC_METHODS } from './methods' +import type { OrcaRuntimeService } from '../orca-runtime' + +export type DispatcherOptions = { + runtime: OrcaRuntimeService + methods?: readonly RpcMethod[] +} + +export class RpcDispatcher { + private readonly runtime: OrcaRuntimeService + private readonly registry: RpcRegistry + + constructor({ runtime, methods = ALL_RPC_METHODS }: DispatcherOptions) { + this.runtime = runtime + this.registry = buildRegistry(methods) + } + + async dispatch(request: RpcRequest): Promise { + const meta = this.meta() + const method = this.registry.get(request.method) + if (!method) { + return errorResponse( + request.id, + meta, + 'method_not_found', + `Unknown method: ${request.method}` + ) + } + + let parsedParams: unknown + if (method.params === null) { + parsedParams = undefined + } else { + const rawParams = request.params ?? {} + const result = method.params.safeParse(rawParams) + if (!result.success) { + return errorResponse(request.id, meta, 'invalid_argument', formatZodError(result.error)) + } + parsedParams = result.data + } + + try { + const result = await method.handler(parsedParams, { runtime: this.runtime }) + return successResponse(request.id, meta, result) + } catch (error) { + // Why: browser methods throw BrowserError with a structured `code`; + // every other runtime error has a plain-message code. Routing by method + // prefix keeps the mapping a single decision rather than a per-method + // flag callers must remember to set. + if (request.method.startsWith('browser.')) { + return mapBrowserError(request.id, meta, error) + } + if (error instanceof ZodError) { + return errorResponse(request.id, meta, 'invalid_argument', formatZodError(error)) + } + return mapRuntimeError(request.id, meta, error) + } + } + + private meta(): RpcEnvelopeMeta { + return { runtimeId: this.runtime.getRuntimeId() } + } +} diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts new file mode 100644 index 00000000000..e53ab52c276 --- /dev/null +++ b/src/main/runtime/rpc/errors.ts @@ -0,0 +1,73 @@ +// Why: every RPC response needs the same runtimeId envelope, and the +// runtime/browser error allowlists define the contract the CLI relies on to +// format human-facing messages. Centralizing this mapping keeps the allowlist +// auditable in one place instead of spread across per-method branches. +import type { RpcEnvelopeMeta, RpcFailure, RpcSuccess } from './core' + +export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess { + return { + id, + ok: true, + result, + _meta: meta + } +} + +export function errorResponse( + id: string, + meta: RpcEnvelopeMeta, + code: string, + message: string, + data?: unknown +): RpcFailure { + return { + id, + ok: false, + error: data === undefined ? { code, message } : { code, message, data }, + _meta: meta + } +} + +// Why: the OrcaRuntimeService throws plain Error objects whose `message` is +// actually a stable error code. This allowlist is the contract the CLI relies +// on — expanding or renaming entries without updating the CLI would silently +// change user-visible error codes. +const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ + 'runtime_unavailable', + 'selector_not_found', + 'selector_ambiguous', + 'terminal_handle_stale', + 'terminal_not_writable', + 'terminal_exited', + 'terminal_gone', + 'no_active_terminal', + 'repo_not_found', + 'timeout', + 'invalid_limit' +]) + +export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure { + const message = error instanceof Error ? error.message : String(error) + if (RUNTIME_PASSTHROUGH_CODES.has(message)) { + return errorResponse(id, meta, message, message) + } + if (message === 'invalid_terminal_send') { + return errorResponse(id, meta, 'invalid_argument', 'Missing terminal send payload') + } + return errorResponse(id, meta, 'runtime_error', message) +} + +// Why: browser errors carry a structured .code property (BrowserError from +// cdp-bridge.ts) that maps directly to agent-facing error codes. We forward +// that code rather than falling back to the runtime allowlist, because the +// browser surface area uses its own code namespace (browser_no_tab, etc.). +export function mapBrowserError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure { + if ( + error instanceof Error && + 'code' in error && + typeof (error as { code: unknown }).code === 'string' + ) { + return errorResponse(id, meta, (error as { code: string }).code, error.message) + } + return mapRuntimeError(id, meta, error) +} diff --git a/src/main/runtime/rpc/methods/browser-core.ts b/src/main/runtime/rpc/methods/browser-core.ts new file mode 100644 index 00000000000..5d272137300 --- /dev/null +++ b/src/main/runtime/rpc/methods/browser-core.ts @@ -0,0 +1,233 @@ +import { defineMethod, type RpcMethod } from '../core' +import { BrowserTarget } from '../schemas' +import { + Check, + Drag, + Element, + Eval, + Exec, + Fill, + Find, + FullScreenshot, + Get, + Goto, + Highlight, + Is, + KeyboardInsert, + Keypress, + LimitParam, + Screenshot, + Scroll, + Select, + SelectorPath, + TabClose, + TabCreate, + TabList, + TabSwitch, + Type, + Upload, + Wait +} from './browser-schemas' + +export const BROWSER_CORE_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'browser.snapshot', + params: BrowserTarget, + handler: async (params, { runtime }) => runtime.browserSnapshot(params) + }), + defineMethod({ + name: 'browser.click', + params: Element, + handler: async (params, { runtime }) => runtime.browserClick(params) + }), + defineMethod({ + name: 'browser.goto', + params: Goto, + handler: async (params, { runtime }) => runtime.browserGoto(params) + }), + defineMethod({ + name: 'browser.fill', + params: Fill, + handler: async (params, { runtime }) => runtime.browserFill(params) + }), + defineMethod({ + name: 'browser.type', + params: Type, + handler: async (params, { runtime }) => runtime.browserType(params) + }), + defineMethod({ + name: 'browser.select', + params: Select, + handler: async (params, { runtime }) => runtime.browserSelect(params) + }), + defineMethod({ + name: 'browser.scroll', + params: Scroll, + handler: async (params, { runtime }) => runtime.browserScroll(params) + }), + defineMethod({ + name: 'browser.back', + params: BrowserTarget, + handler: async (params, { runtime }) => runtime.browserBack(params) + }), + defineMethod({ + name: 'browser.reload', + params: BrowserTarget, + handler: async (params, { runtime }) => runtime.browserReload(params) + }), + defineMethod({ + name: 'browser.screenshot', + params: Screenshot, + handler: async (params, { runtime }) => runtime.browserScreenshot(params) + }), + defineMethod({ + name: 'browser.eval', + params: Eval, + handler: async (params, { runtime }) => runtime.browserEval(params) + }), + defineMethod({ + name: 'browser.tabList', + params: TabList, + handler: async (params, { runtime }) => runtime.browserTabList(params) + }), + defineMethod({ + name: 'browser.tabSwitch', + params: TabSwitch, + handler: async (params, { runtime }) => runtime.browserTabSwitch(params) + }), + defineMethod({ + name: 'browser.tabCreate', + params: TabCreate, + handler: async (params, { runtime }) => runtime.browserTabCreate(params) + }), + defineMethod({ + name: 'browser.tabClose', + params: TabClose, + handler: async (params, { runtime }) => runtime.browserTabClose(params) + }), + defineMethod({ + name: 'browser.hover', + params: Element, + handler: async (params, { runtime }) => runtime.browserHover(params) + }), + defineMethod({ + name: 'browser.drag', + params: Drag, + handler: async (params, { runtime }) => runtime.browserDrag(params) + }), + defineMethod({ + name: 'browser.upload', + params: Upload, + handler: async (params, { runtime }) => runtime.browserUpload(params) + }), + defineMethod({ + name: 'browser.wait', + params: Wait, + handler: async (params, { runtime }) => runtime.browserWait(params) + }), + defineMethod({ + name: 'browser.check', + params: Check, + handler: async (params, { runtime }) => runtime.browserCheck(params) + }), + defineMethod({ + name: 'browser.focus', + params: Element, + handler: async (params, { runtime }) => runtime.browserFocus(params) + }), + defineMethod({ + name: 'browser.clear', + params: Element, + handler: async (params, { runtime }) => runtime.browserClear(params) + }), + defineMethod({ + name: 'browser.selectAll', + params: Element, + handler: async (params, { runtime }) => runtime.browserSelectAll(params) + }), + defineMethod({ + name: 'browser.keypress', + params: Keypress, + handler: async (params, { runtime }) => runtime.browserKeypress(params) + }), + defineMethod({ + name: 'browser.pdf', + params: BrowserTarget, + handler: async (params, { runtime }) => runtime.browserPdf(params) + }), + defineMethod({ + name: 'browser.fullScreenshot', + params: FullScreenshot, + handler: async (params, { runtime }) => runtime.browserFullScreenshot(params) + }), + defineMethod({ + name: 'browser.dblclick', + params: Element, + handler: async (params, { runtime }) => runtime.browserDblclick(params) + }), + defineMethod({ + name: 'browser.forward', + params: BrowserTarget, + handler: async (params, { runtime }) => runtime.browserForward(params) + }), + defineMethod({ + name: 'browser.scrollIntoView', + params: Element, + handler: async (params, { runtime }) => runtime.browserScrollIntoView(params) + }), + defineMethod({ + name: 'browser.get', + params: Get, + handler: async (params, { runtime }) => runtime.browserGet(params) + }), + defineMethod({ + name: 'browser.is', + params: Is, + handler: async (params, { runtime }) => runtime.browserIs(params) + }), + defineMethod({ + name: 'browser.keyboardInsertText', + params: KeyboardInsert, + handler: async (params, { runtime }) => runtime.browserKeyboardInsertText(params) + }), + defineMethod({ + name: 'browser.find', + params: Find, + handler: async (params, { runtime }) => runtime.browserFind(params) + }), + defineMethod({ + name: 'browser.console', + params: LimitParam, + handler: async (params, { runtime }) => runtime.browserConsoleLog(params) + }), + defineMethod({ + name: 'browser.network', + params: LimitParam, + handler: async (params, { runtime }) => runtime.browserNetworkLog(params) + }), + defineMethod({ + name: 'browser.exec', + params: Exec, + handler: async (params, { runtime }) => runtime.browserExec(params) + }), + defineMethod({ + name: 'browser.capture.start', + params: BrowserTarget, + handler: async (params, { runtime }) => runtime.browserCaptureStart(params) + }), + defineMethod({ + name: 'browser.capture.stop', + params: BrowserTarget, + handler: async (params, { runtime }) => runtime.browserCaptureStop(params) + }), + defineMethod({ + name: 'browser.download', + params: SelectorPath, + handler: async (params, { runtime }) => runtime.browserDownload(params) + }), + defineMethod({ + name: 'browser.highlight', + params: Highlight, + handler: async (params, { runtime }) => runtime.browserHighlight(params) + }) +] diff --git a/src/main/runtime/rpc/methods/browser-extras.ts b/src/main/runtime/rpc/methods/browser-extras.ts new file mode 100644 index 00000000000..87d463feb9d --- /dev/null +++ b/src/main/runtime/rpc/methods/browser-extras.ts @@ -0,0 +1,160 @@ +import { defineMethod, type RpcMethod } from '../core' +import { BrowserTarget } from '../schemas' +import { + ClipboardWrite, + CookieDelete, + CookieGet, + CookieSet, + DialogAccept, + Geolocation, + InterceptEnable, + MouseButton, + MouseWheel, + MouseXY, + SetCredentials, + SetDevice, + SetHeaders, + SetMedia, + SetOffline, + StorageKey, + StorageKeyValue, + Viewport +} from './browser-schemas' + +export const BROWSER_EXTRA_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'browser.cookie.get', + params: CookieGet, + handler: async (params, { runtime }) => runtime.browserCookieGet(params) + }), + defineMethod({ + name: 'browser.cookie.set', + params: CookieSet, + handler: async (params, { runtime }) => runtime.browserCookieSet(params) + }), + defineMethod({ + name: 'browser.cookie.delete', + params: CookieDelete, + handler: async (params, { runtime }) => runtime.browserCookieDelete(params) + }), + defineMethod({ + name: 'browser.viewport', + params: Viewport, + handler: async (params, { runtime }) => runtime.browserSetViewport(params) + }), + defineMethod({ + name: 'browser.geolocation', + params: Geolocation, + handler: async (params, { runtime }) => runtime.browserSetGeolocation(params) + }), + defineMethod({ + name: 'browser.intercept.enable', + params: InterceptEnable, + handler: async (params, { runtime }) => runtime.browserInterceptEnable(params) + }), + defineMethod({ + name: 'browser.intercept.disable', + params: BrowserTarget, + handler: async (params, { runtime }) => runtime.browserInterceptDisable(params) + }), + defineMethod({ + name: 'browser.intercept.list', + params: BrowserTarget, + handler: async (params, { runtime }) => runtime.browserInterceptList(params) + }), + defineMethod({ + name: 'browser.mouseMove', + params: MouseXY, + handler: async (params, { runtime }) => runtime.browserMouseMove(params) + }), + defineMethod({ + name: 'browser.mouseDown', + params: MouseButton, + handler: async (params, { runtime }) => runtime.browserMouseDown(params) + }), + defineMethod({ + name: 'browser.mouseUp', + params: MouseButton, + handler: async (params, { runtime }) => runtime.browserMouseUp(params) + }), + defineMethod({ + name: 'browser.mouseWheel', + params: MouseWheel, + handler: async (params, { runtime }) => runtime.browserMouseWheel(params) + }), + defineMethod({ + name: 'browser.setDevice', + params: SetDevice, + handler: async (params, { runtime }) => runtime.browserSetDevice(params) + }), + defineMethod({ + name: 'browser.setOffline', + params: SetOffline, + handler: async (params, { runtime }) => runtime.browserSetOffline(params) + }), + defineMethod({ + name: 'browser.setHeaders', + params: SetHeaders, + handler: async (params, { runtime }) => runtime.browserSetHeaders(params) + }), + defineMethod({ + name: 'browser.setCredentials', + params: SetCredentials, + handler: async (params, { runtime }) => runtime.browserSetCredentials(params) + }), + defineMethod({ + name: 'browser.setMedia', + params: SetMedia, + handler: async (params, { runtime }) => runtime.browserSetMedia(params) + }), + defineMethod({ + name: 'browser.clipboardRead', + params: BrowserTarget, + handler: async (params, { runtime }) => runtime.browserClipboardRead(params) + }), + defineMethod({ + name: 'browser.clipboardWrite', + params: ClipboardWrite, + handler: async (params, { runtime }) => runtime.browserClipboardWrite(params) + }), + defineMethod({ + name: 'browser.dialogAccept', + params: DialogAccept, + handler: async (params, { runtime }) => runtime.browserDialogAccept(params) + }), + defineMethod({ + name: 'browser.dialogDismiss', + params: BrowserTarget, + handler: async (params, { runtime }) => runtime.browserDialogDismiss(params) + }), + defineMethod({ + name: 'browser.storage.local.get', + params: StorageKey, + handler: async (params, { runtime }) => runtime.browserStorageLocalGet(params) + }), + defineMethod({ + name: 'browser.storage.local.set', + params: StorageKeyValue, + handler: async (params, { runtime }) => runtime.browserStorageLocalSet(params) + }), + defineMethod({ + name: 'browser.storage.local.clear', + params: BrowserTarget, + handler: async (params, { runtime }) => runtime.browserStorageLocalClear(params) + }), + defineMethod({ + name: 'browser.storage.session.get', + params: StorageKey, + handler: async (params, { runtime }) => runtime.browserStorageSessionGet(params) + }), + defineMethod({ + name: 'browser.storage.session.set', + params: StorageKeyValue, + handler: async (params, { runtime }) => runtime.browserStorageSessionSet(params) + }), + defineMethod({ + name: 'browser.storage.session.clear', + params: BrowserTarget, + handler: async (params, { runtime }) => runtime.browserStorageSessionClear(params) + }) +] diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts new file mode 100644 index 00000000000..3a8e3cf0ace --- /dev/null +++ b/src/main/runtime/rpc/methods/browser-schemas.ts @@ -0,0 +1,303 @@ +// Why: the browser method surface area is large enough that keeping every +// schema in the same file as its handler registration pushes the file past +// the 300-line lint cap. Grouping all browser schemas here keeps each +// handler file focused on dispatch wiring. +import { z } from 'zod' +import { + BrowserTarget, + OptionalBoolean, + OptionalFiniteNumber, + OptionalPlainString, + OptionalString, + requiredString +} from '../schemas' + +export const Element = BrowserTarget.extend({ + element: requiredString('Missing required --element') +}) + +export const Goto = BrowserTarget.extend({ + url: requiredString('Missing required --url') +}) + +export const Fill = BrowserTarget.extend({ + element: requiredString('Missing required --element'), + value: z.custom((v) => typeof v === 'string', { + message: 'Missing required --value' + }) +}) + +export const Type = BrowserTarget.extend({ + input: requiredString('Missing required --input') +}) + +export const Select = BrowserTarget.extend({ + element: requiredString('Missing required --element'), + value: z.custom((v) => typeof v === 'string', { + message: 'Missing required --value' + }) +}) + +export const Scroll = BrowserTarget.extend({ + direction: z.custom<'up' | 'down'>((v) => v === 'up' || v === 'down', { + message: 'Missing required --direction (up or down)' + }), + amount: z + .unknown() + .transform((v) => (typeof v === 'number' && v > 0 ? v : undefined)) + .pipe(z.number().optional()) +}) + +export const Screenshot = BrowserTarget.extend({ + format: z + .unknown() + .transform((v) => (v === 'png' || v === 'jpeg' ? v : undefined)) + .pipe(z.enum(['png', 'jpeg']).optional()) +}) + +export const FullScreenshot = BrowserTarget.extend({ + format: z + .unknown() + .transform((v) => (v === 'jpeg' ? 'jpeg' : 'png')) + .pipe(z.enum(['png', 'jpeg'])) +}) + +export const Eval = BrowserTarget.extend({ + expression: requiredString('Missing required --expression') +}) + +export const TabList = z.object({ + worktree: OptionalString +}) + +// Why: --index xor --page must be present. The refine guards that invariant +// so the dispatcher surfaces a single legible error instead of either shape +// leaking into the runtime. +export const TabSwitch = BrowserTarget.extend({ + index: z + .unknown() + .transform((v) => (typeof v === 'number' ? v : undefined)) + .pipe(z.number().optional()) +}).refine( + (val) => { + if (val.page !== undefined) { + return true + } + return val.index !== undefined && Number.isInteger(val.index) && val.index >= 0 + }, + { message: 'Missing required --index (non-negative integer) or --page' } +) + +export const TabCreate = z.object({ + url: OptionalString, + worktree: OptionalString +}) + +export const TabClose = z.object({ + index: z + .unknown() + .transform((v) => (typeof v === 'number' ? v : undefined)) + .pipe(z.number().optional()), + page: OptionalString, + worktree: OptionalString +}) + +export const Drag = BrowserTarget.extend({ + from: requiredString('Missing required --from and --to element refs'), + to: requiredString('Missing required --from and --to element refs') +}) + +export const Upload = BrowserTarget.extend({ + element: requiredString('Missing required --element and --files'), + files: z.custom( + (v) => Array.isArray(v) && v.length > 0 && v.every((f) => typeof f === 'string'), + { message: 'Missing required --element and --files' } + ) +}) + +export const Wait = BrowserTarget.extend({ + selector: OptionalPlainString, + timeout: z + .unknown() + .transform((v) => (typeof v === 'number' && v > 0 ? v : undefined)) + .pipe(z.number().optional()), + text: OptionalPlainString, + url: OptionalPlainString, + load: OptionalPlainString, + fn: OptionalPlainString, + state: OptionalPlainString +}) + +export const Check = BrowserTarget.extend({ + element: requiredString('Missing required --element'), + checked: z + .unknown() + .transform((v) => v !== false) + .pipe(z.boolean()) +}) + +export const Keypress = BrowserTarget.extend({ + key: requiredString('Missing required --key') +}) + +export const SelectorPath = BrowserTarget.extend({ + selector: requiredString('Missing required --selector and --path'), + path: requiredString('Missing required --selector and --path') +}) + +export const Highlight = BrowserTarget.extend({ + selector: requiredString('Missing required --selector') +}) + +export const Exec = BrowserTarget.extend({ + command: requiredString('Missing required --command') +}) + +export const Get = BrowserTarget.extend({ + what: requiredString('Missing required --what'), + selector: OptionalString +}) + +export const Is = BrowserTarget.extend({ + what: z.custom((v) => typeof v === 'string' && v.length > 0, { + message: 'Missing required --what and --element' + }), + selector: z.custom((v) => typeof v === 'string' && v.length > 0, { + message: 'Missing required --what and --element' + }) +}) + +export const KeyboardInsert = BrowserTarget.extend({ + text: requiredString('Missing required --text') +}) + +export const LimitParam = BrowserTarget.extend({ + limit: OptionalFiniteNumber +}) + +export const Find = BrowserTarget.extend({ + locator: requiredString('Missing required --locator, --value, and --action'), + value: requiredString('Missing required --locator, --value, and --action'), + action: requiredString('Missing required --locator, --value, and --action'), + text: OptionalString +}) + +export const CookieGet = BrowserTarget.extend({ + url: OptionalPlainString +}) + +export const CookieSet = BrowserTarget.extend({ + name: z.custom((v) => typeof v === 'string' && v.length > 0, { + message: 'Missing name or value' + }), + value: z.custom((v) => typeof v === 'string', { + message: 'Missing name or value' + }), + domain: OptionalPlainString, + path: OptionalPlainString, + secure: OptionalBoolean, + httpOnly: OptionalBoolean, + sameSite: OptionalPlainString, + expires: OptionalFiniteNumber +}) + +export const CookieDelete = BrowserTarget.extend({ + name: requiredString('Missing cookie name'), + domain: OptionalPlainString, + url: OptionalPlainString +}) + +export const Viewport = BrowserTarget.extend({ + width: z.custom((v) => typeof v === 'number' && v > 0, { + message: 'Width and height must be positive numbers' + }), + height: z.custom((v) => typeof v === 'number' && v > 0, { + message: 'Width and height must be positive numbers' + }), + deviceScaleFactor: OptionalFiniteNumber, + mobile: OptionalBoolean +}) + +export const Geolocation = BrowserTarget.extend({ + latitude: z.custom((v) => typeof v === 'number', { + message: 'Missing latitude or longitude' + }), + longitude: z.custom((v) => typeof v === 'number', { + message: 'Missing latitude or longitude' + }), + accuracy: OptionalFiniteNumber +}) + +export const InterceptEnable = BrowserTarget.extend({ + patterns: z + .unknown() + .transform((v) => (Array.isArray(v) ? (v as string[]) : undefined)) + .pipe(z.array(z.string()).optional()) +}) + +export const MouseXY = BrowserTarget.extend({ + x: z.custom((v) => typeof v === 'number', { + message: 'Missing required x and y coordinates' + }), + y: z.custom((v) => typeof v === 'number', { + message: 'Missing required x and y coordinates' + }) +}) + +export const MouseButton = BrowserTarget.extend({ + button: OptionalPlainString +}) + +export const MouseWheel = BrowserTarget.extend({ + dy: z.custom((v) => typeof v === 'number', { + message: 'Missing required --dy' + }), + dx: OptionalFiniteNumber +}) + +export const SetDevice = BrowserTarget.extend({ + name: requiredString('Missing required --name') +}) + +export const SetOffline = BrowserTarget.extend({ + state: OptionalPlainString +}) + +export const SetHeaders = BrowserTarget.extend({ + headers: requiredString('Missing required --headers (JSON string)') +}) + +export const SetCredentials = BrowserTarget.extend({ + user: z.custom((v) => typeof v === 'string' && v.length > 0, { + message: 'Missing required --user and --pass' + }), + pass: z.custom((v) => typeof v === 'string', { + message: 'Missing required --user and --pass' + }) +}) + +export const SetMedia = BrowserTarget.extend({ + colorScheme: OptionalPlainString, + reducedMotion: OptionalPlainString +}) + +export const ClipboardWrite = BrowserTarget.extend({ + text: requiredString('Missing required --text') +}) + +export const DialogAccept = BrowserTarget.extend({ + text: OptionalPlainString +}) + +export const StorageKey = BrowserTarget.extend({ + key: requiredString('Missing required --key') +}) + +export const StorageKeyValue = BrowserTarget.extend({ + key: z.custom((v) => typeof v === 'string' && v.length > 0, { + message: 'Missing required --key and --value' + }), + value: z.custom((v) => typeof v === 'string', { + message: 'Missing required --key and --value' + }) +}) diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts new file mode 100644 index 00000000000..bff8a215bc2 --- /dev/null +++ b/src/main/runtime/rpc/methods/index.ts @@ -0,0 +1,19 @@ +import type { RpcMethod } from '../core' +import { STATUS_METHODS } from './status' +import { REPO_METHODS } from './repo' +import { WORKTREE_METHODS } from './worktree' +import { TERMINAL_METHODS } from './terminal' +import { BROWSER_CORE_METHODS } from './browser-core' +import { BROWSER_EXTRA_METHODS } from './browser-extras' + +// Why: a flat manifest keeps registration order explicit and provides one +// grep-point for "what methods does the RPC server expose?" — useful when +// auditing the security boundary or wiring new CLI commands. +export const ALL_RPC_METHODS: readonly RpcMethod[] = [ + ...STATUS_METHODS, + ...REPO_METHODS, + ...WORKTREE_METHODS, + ...TERMINAL_METHODS, + ...BROWSER_CORE_METHODS, + ...BROWSER_EXTRA_METHODS +] diff --git a/src/main/runtime/rpc/methods/repo.ts b/src/main/runtime/rpc/methods/repo.ts new file mode 100644 index 00000000000..b3a851dac0d --- /dev/null +++ b/src/main/runtime/rpc/methods/repo.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' +import { OptionalFiniteNumber, requiredString } from '../schemas' + +const RepoSelector = z.object({ + repo: requiredString('Missing repo selector') +}) + +const RepoPath = z.object({ + path: requiredString('Missing repo path') +}) + +const RepoSetBaseRef = z.object({ + repo: requiredString('Missing repo selector'), + ref: requiredString('Missing base ref') +}) + +const RepoSearchRefs = z.object({ + repo: requiredString('Missing repo selector'), + query: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : undefined)) + .pipe(z.string({ message: 'Missing query' })), + limit: OptionalFiniteNumber +}) + +export const REPO_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'repo.list', + params: null, + handler: (_params, { runtime }) => ({ repos: runtime.listRepos() }) + }), + defineMethod({ + name: 'repo.add', + params: RepoPath, + handler: async (params, { runtime }) => ({ repo: await runtime.addRepo(params.path) }) + }), + defineMethod({ + name: 'repo.show', + params: RepoSelector, + handler: async (params, { runtime }) => ({ repo: await runtime.showRepo(params.repo) }) + }), + defineMethod({ + name: 'repo.setBaseRef', + params: RepoSetBaseRef, + handler: async (params, { runtime }) => ({ + repo: await runtime.setRepoBaseRef(params.repo, params.ref) + }) + }), + defineMethod({ + name: 'repo.searchRefs', + params: RepoSearchRefs, + handler: async (params, { runtime }) => + runtime.searchRepoRefs(params.repo, params.query, params.limit) + }) +] diff --git a/src/main/runtime/rpc/methods/status.ts b/src/main/runtime/rpc/methods/status.ts new file mode 100644 index 00000000000..083b566e700 --- /dev/null +++ b/src/main/runtime/rpc/methods/status.ts @@ -0,0 +1,9 @@ +import { defineMethod, type RpcMethod } from '../core' + +export const STATUS_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'status.get', + params: null, + handler: (_params, { runtime }) => runtime.getStatus() + }) +] diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts new file mode 100644 index 00000000000..cc3db888eb9 --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -0,0 +1,174 @@ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' +import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' + +const TerminalHandle = z.object({ + terminal: requiredString('Missing terminal handle') +}) + +const TerminalListParams = z.object({ + worktree: OptionalString, + limit: OptionalFiniteNumber +}) + +const TerminalResolveActive = z.object({ + worktree: OptionalString +}) + +const TerminalRead = TerminalHandle.extend({ + cursor: z + .unknown() + .transform((value) => { + if (value === undefined) { + return undefined + } + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + return Number.NaN + } + return value + }) + .pipe( + z + .number() + .optional() + .refine((v) => v === undefined || Number.isFinite(v), { + message: 'Cursor must be a non-negative integer' + }) + ) +}) + +// Why: the legacy handler allowed `title: string | null` and rejected every +// other shape (including `undefined`) with a specific message, which is how +// the CLI signals an intentional "reset". Preserve that distinction exactly. +const TerminalRename = TerminalHandle.extend({ + title: z.custom((value) => value === null || typeof value === 'string', { + message: 'Missing --title (pass empty string or null to reset)' + }) +}) + +const TerminalSend = TerminalHandle.extend({ + text: OptionalString, + enter: z.unknown().optional(), + interrupt: z.unknown().optional() +}) + +const TerminalWait = TerminalHandle.extend({ + for: z.custom<'exit' | 'tui-idle'>((value) => value === 'exit' || value === 'tui-idle', { + message: 'Invalid --for value. Supported: exit, tui-idle' + }), + timeoutMs: OptionalFiniteNumber +}) + +const TerminalCreateParams = z.object({ + worktree: OptionalString, + command: OptionalString, + title: OptionalString +}) + +const TerminalSplit = TerminalHandle.extend({ + direction: z + .unknown() + .transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined)) + .pipe(z.enum(['vertical', 'horizontal']).optional()), + command: OptionalString +}) + +const TerminalStop = z.object({ + worktree: requiredString('Missing worktree selector') +}) + +export const TERMINAL_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'terminal.list', + params: TerminalListParams, + handler: async (params, { runtime }) => runtime.listTerminals(params.worktree, params.limit) + }), + defineMethod({ + name: 'terminal.resolveActive', + params: TerminalResolveActive, + handler: async (params, { runtime }) => ({ + handle: await runtime.resolveActiveTerminal(params.worktree) + }) + }), + defineMethod({ + name: 'terminal.show', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + terminal: await runtime.showTerminal(params.terminal) + }) + }), + defineMethod({ + name: 'terminal.read', + params: TerminalRead, + handler: async (params, { runtime }) => ({ + terminal: await runtime.readTerminal(params.terminal, { cursor: params.cursor }) + }) + }), + defineMethod({ + name: 'terminal.rename', + params: TerminalRename, + handler: async (params, { runtime }) => ({ + rename: await runtime.renameTerminal(params.terminal, params.title || null) + }) + }), + defineMethod({ + name: 'terminal.send', + params: TerminalSend, + handler: async (params, { runtime }) => ({ + send: await runtime.sendTerminal(params.terminal, { + text: params.text, + enter: params.enter === true, + interrupt: params.interrupt === true + }) + }) + }), + defineMethod({ + name: 'terminal.wait', + params: TerminalWait, + handler: async (params, { runtime }) => ({ + wait: await runtime.waitForTerminal(params.terminal, { + condition: params.for, + timeoutMs: params.timeoutMs + }) + }) + }), + defineMethod({ + name: 'terminal.create', + params: TerminalCreateParams, + handler: async (params, { runtime }) => ({ + terminal: await runtime.createTerminal(params.worktree, { + command: params.command, + title: params.title + }) + }) + }), + defineMethod({ + name: 'terminal.split', + params: TerminalSplit, + handler: async (params, { runtime }) => ({ + split: await runtime.splitTerminal(params.terminal, { + direction: params.direction, + command: params.command + }) + }) + }), + defineMethod({ + name: 'terminal.stop', + params: TerminalStop, + handler: async (params, { runtime }) => runtime.stopTerminalsForWorktree(params.worktree) + }), + defineMethod({ + name: 'terminal.focus', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + focus: await runtime.focusTerminal(params.terminal) + }) + }), + defineMethod({ + name: 'terminal.close', + params: TerminalHandle, + handler: async (params, { runtime }) => ({ + close: await runtime.closeTerminal(params.terminal) + }) + }) +] diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts new file mode 100644 index 00000000000..acc588d049c --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -0,0 +1,99 @@ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' +import { + OptionalBoolean, + OptionalFiniteNumber, + OptionalString, + TriStateLinkedIssue +} from '../schemas' + +const WorktreeListParams = z.object({ + repo: OptionalString, + limit: OptionalFiniteNumber +}) + +const WorktreePsParams = z.object({ + limit: OptionalFiniteNumber +}) + +const WorktreeSelector = z.object({ + worktree: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing worktree selector')) +}) + +const WorktreeCreate = z.object({ + repo: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing repo selector')), + name: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(z.string().min(1, 'Missing worktree name')), + baseBranch: OptionalString, + linkedIssue: TriStateLinkedIssue, + comment: OptionalString +}) + +const WorktreeSet = WorktreeSelector.extend({ + displayName: OptionalString, + linkedIssue: TriStateLinkedIssue, + comment: OptionalString +}) + +const WorktreeRemove = WorktreeSelector.extend({ + force: OptionalBoolean +}) + +export const WORKTREE_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'worktree.ps', + params: WorktreePsParams, + handler: async (params, { runtime }) => runtime.getWorktreePs(params.limit) + }), + defineMethod({ + name: 'worktree.list', + params: WorktreeListParams, + handler: async (params, { runtime }) => runtime.listManagedWorktrees(params.repo, params.limit) + }), + defineMethod({ + name: 'worktree.show', + params: WorktreeSelector, + handler: async (params, { runtime }) => ({ + worktree: await runtime.showManagedWorktree(params.worktree) + }) + }), + defineMethod({ + name: 'worktree.create', + params: WorktreeCreate, + handler: async (params, { runtime }) => + runtime.createManagedWorktree({ + repoSelector: params.repo, + name: params.name, + baseBranch: params.baseBranch, + linkedIssue: params.linkedIssue, + comment: params.comment + }) + }), + defineMethod({ + name: 'worktree.set', + params: WorktreeSet, + handler: async (params, { runtime }) => ({ + worktree: await runtime.updateManagedWorktreeMeta(params.worktree, { + displayName: params.displayName, + linkedIssue: params.linkedIssue, + comment: params.comment + }) + }) + }), + defineMethod({ + name: 'worktree.rm', + params: WorktreeRemove, + handler: async (params, { runtime }) => { + await runtime.removeManagedWorktree(params.worktree, params.force === true) + return { removed: true } + } + }) +] diff --git a/src/main/runtime/rpc/schemas.ts b/src/main/runtime/rpc/schemas.ts new file mode 100644 index 00000000000..689585a5b85 --- /dev/null +++ b/src/main/runtime/rpc/schemas.ts @@ -0,0 +1,79 @@ +// Why: the RPC boundary ingests loosely-typed JSON from a CLI that grew +// organically, so these reusable pieces capture the validation shapes that +// recur across domains (optional worktree selector, bounded limit, browser +// target envelope, etc.). Methods compose these to declare their real +// contract without repeating the same `typeof` gymnastics 90 times. +import { z } from 'zod' + +// Why: the original handlers treated non-numeric/NaN limit values as "no +// limit" rather than as errors. Preserve that forgiving behavior so CLI +// callers passing stringified numbers or Infinity still reach the runtime. +export const OptionalFiniteNumber = z + .unknown() + .transform((value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)) + .pipe(z.number().optional()) + +export const OptionalPositiveInt = z + .unknown() + .transform((value) => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined + ) + .pipe(z.number().optional()) + +export const OptionalString = z + .unknown() + .transform((value) => (typeof value === 'string' && value.length > 0 ? value : undefined)) + .pipe(z.string().optional()) + +export const OptionalPlainString = z + .unknown() + .transform((value) => (typeof value === 'string' ? value : undefined)) + .pipe(z.string().optional()) + +export const OptionalBoolean = z + .unknown() + .transform((value) => (typeof value === 'boolean' ? value : undefined)) + .pipe(z.boolean().optional()) + +// Why: runtime handlers accept `linkedIssue: number | null | undefined` with +// distinct meanings — undefined means "no update", null means "clear", number +// means "set". The ambient JSON decode produces all three shapes as-is. +export const TriStateLinkedIssue = z + .unknown() + .transform((value) => { + if (value === null) { + return null + } + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + return undefined + }) + .pipe(z.union([z.number(), z.null(), z.undefined()])) + +// Why: the legacy extractBrowserTarget treated worktree as a plain-string +// passthrough (empty string preserved) but `page` as non-empty-string. The +// browser bridge uses worktree-as-empty-string to mean "any worktree", so +// keep that asymmetry intact to avoid widening scope unexpectedly. +export const BrowserTarget = z.object({ + worktree: OptionalPlainString, + page: OptionalString +}) + +export type BrowserTargetInput = z.infer + +export function requiredString(message: string) { + return z + .unknown() + .transform((value) => (typeof value === 'string' ? value : '')) + .pipe(z.string().min(1, message)) +} + +export function requiredNumber(message: string) { + return z + .unknown() + .transform((value) => + typeof value === 'number' && Number.isFinite(value) ? value : Number.NaN + ) + .pipe(z.number().refine((v) => Number.isFinite(v), { message })) +} diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 1d81375deae..e1275c37dcb 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -1,4 +1,8 @@ -/* eslint-disable max-lines -- Why: the local RPC server is a single security boundary for the bundled CLI, so transport validation and method routing are intentionally reviewed together. */ +// Why: this is the single security boundary for the bundled CLI. It owns +// transport setup (unix socket / named pipe), auth-token enforcement, and +// bootstrap-metadata publication so a running runtime is always discoverable +// via exactly one on-disk file. Method handling lives in `rpc/` so this file +// stays easy to audit in one sitting. import { randomBytes } from 'crypto' import { createServer, type Server, type Socket } from 'net' import { chmodSync, existsSync, rmSync } from 'fs' @@ -6,35 +10,9 @@ import { join } from 'path' import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap' import type { OrcaRuntimeService } from './orca-runtime' import { writeRuntimeMetadata } from './runtime-metadata' - -type RuntimeRpcRequest = { - id: string - authToken: string - method: string - params?: unknown -} - -type RuntimeRpcResponse = - | { - id: string - ok: true - result: unknown - _meta: { - runtimeId: string - } - } - | { - id: string - ok: false - error: { - code: string - message: string - data?: unknown - } - _meta: { - runtimeId: string - } - } +import { RpcDispatcher } from './rpc/dispatcher' +import type { RpcRequest, RpcResponse } from './rpc/core' +import { errorResponse } from './rpc/errors' type OrcaRuntimeRpcServerOptions = { runtime: OrcaRuntimeService @@ -49,6 +27,7 @@ const MAX_RUNTIME_RPC_CONNECTIONS = 32 export class OrcaRuntimeRpcServer { private readonly runtime: OrcaRuntimeService + private readonly dispatcher: RpcDispatcher private readonly userDataPath: string private readonly pid: number private readonly platform: NodeJS.Platform @@ -63,6 +42,7 @@ export class OrcaRuntimeRpcServer { platform = process.platform }: OrcaRuntimeRpcServerOptions) { this.runtime = runtime + this.dispatcher = new RpcDispatcher({ runtime }) this.userDataPath = userDataPath this.pid = pid this.platform = platform @@ -174,7 +154,7 @@ export class OrcaRuntimeRpcServer { // unbounded buffer and stall the app. if (Buffer.byteLength(buffer, 'utf8') > MAX_RUNTIME_RPC_MESSAGE_BYTES) { socket.write( - `${JSON.stringify(this.errorResponse('unknown', 'request_too_large', 'RPC request exceeds the maximum size'))}\n` + `${JSON.stringify(this.buildError('unknown', 'request_too_large', 'RPC request exceeds the maximum size'))}\n` ) socket.end() return @@ -193,1937 +173,32 @@ export class OrcaRuntimeRpcServer { }) } - private async handleMessage(rawMessage: string): Promise { - let request: RuntimeRpcRequest + private async handleMessage(rawMessage: string): Promise { + let request: RpcRequest try { - request = JSON.parse(rawMessage) as RuntimeRpcRequest + request = JSON.parse(rawMessage) as RpcRequest } catch { - return this.errorResponse('unknown', 'bad_request', 'Invalid JSON request') + return this.buildError('unknown', 'bad_request', 'Invalid JSON request') } if (typeof request.id !== 'string' || request.id.length === 0) { - return this.errorResponse('unknown', 'bad_request', 'Missing request id') + return this.buildError('unknown', 'bad_request', 'Missing request id') } if (typeof request.method !== 'string' || request.method.length === 0) { - return this.errorResponse(request.id, 'bad_request', 'Missing RPC method') + return this.buildError(request.id, 'bad_request', 'Missing RPC method') } if (typeof request.authToken !== 'string' || request.authToken.length === 0) { - return this.errorResponse(request.id, 'unauthorized', 'Missing auth token') + return this.buildError(request.id, 'unauthorized', 'Missing auth token') } - if (request.authToken !== this.authToken) { - return this.errorResponse(request.id, 'unauthorized', 'Invalid auth token') + return this.buildError(request.id, 'unauthorized', 'Invalid auth token') } - if (request.method === 'status.get') { - return { - id: request.id, - ok: true, - result: this.runtime.getStatus(), - _meta: { - runtimeId: this.runtime.getRuntimeId() - } - } - } - - if (request.method === 'terminal.list') { - try { - const params = - request.params && typeof request.params === 'object' && request.params !== null - ? (request.params as { worktree?: unknown; limit?: unknown }) - : null - const worktreeSelector = params?.worktree ?? null - - const result = await this.runtime.listTerminals( - typeof worktreeSelector === 'string' ? worktreeSelector : undefined, - typeof params?.limit === 'number' && Number.isFinite(params.limit) - ? params.limit - : undefined - ) - - return { - id: request.id, - ok: true, - result, - _meta: { - runtimeId: this.runtime.getRuntimeId() - } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - if (request.method === 'terminal.resolveActive') { - try { - const params = - request.params && typeof request.params === 'object' && request.params !== null - ? (request.params as { worktree?: unknown }) - : null - const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined - const handle = await this.runtime.resolveActiveTerminal(worktree) - return { - id: request.id, - ok: true, - result: { handle }, - _meta: { runtimeId: this.runtime.getRuntimeId() } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - if (request.method === 'terminal.show') { - try { - const terminalHandle = - request.params && typeof request.params === 'object' && request.params !== null - ? ((request.params as { terminal?: unknown }).terminal ?? null) - : null - - if (typeof terminalHandle !== 'string' || terminalHandle.length === 0) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle') - } - - const result = await this.runtime.showTerminal(terminalHandle) - return { - id: request.id, - ok: true, - result: { terminal: result }, - _meta: { - runtimeId: this.runtime.getRuntimeId() - } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - if (request.method === 'terminal.read') { - try { - const params = - request.params && typeof request.params === 'object' && request.params !== null - ? (request.params as { terminal?: unknown; cursor?: unknown }) - : null - - const terminalHandle = params?.terminal ?? null - if (typeof terminalHandle !== 'string' || terminalHandle.length === 0) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle') - } - - if ( - params?.cursor !== undefined && - (!Number.isInteger(params.cursor) || (params.cursor as number) < 0) - ) { - return this.errorResponse( - request.id, - 'invalid_argument', - 'Cursor must be a non-negative integer' - ) - } - - const cursor = - typeof params?.cursor === 'number' && Number.isFinite(params.cursor) - ? params.cursor - : undefined - - const result = await this.runtime.readTerminal(terminalHandle, { cursor }) - return { - id: request.id, - ok: true, - result: { terminal: result }, - _meta: { - runtimeId: this.runtime.getRuntimeId() - } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - if (request.method === 'terminal.rename') { - try { - const params = - request.params && typeof request.params === 'object' && request.params !== null - ? (request.params as { terminal?: unknown; title?: unknown }) - : null - const terminalHandle = params?.terminal ?? null - if (typeof terminalHandle !== 'string' || terminalHandle.length === 0) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle') - } - const title = - params?.title === null - ? null - : typeof params?.title === 'string' - ? params.title - : undefined - if (title === undefined) { - return this.errorResponse( - request.id, - 'invalid_argument', - 'Missing --title (pass empty string or null to reset)' - ) - } - const result = await this.runtime.renameTerminal(terminalHandle, title || null) - return { - id: request.id, - ok: true, - result: { rename: result }, - _meta: { runtimeId: this.runtime.getRuntimeId() } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - if (request.method === 'terminal.send') { - try { - const params = - request.params && typeof request.params === 'object' && request.params !== null - ? (request.params as { - terminal?: unknown - text?: unknown - enter?: unknown - interrupt?: unknown - }) - : null - - const terminalHandle = params?.terminal ?? null - if (typeof terminalHandle !== 'string' || terminalHandle.length === 0) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle') - } - - const result = await this.runtime.sendTerminal(terminalHandle, { - text: typeof params?.text === 'string' ? params.text : undefined, - enter: params?.enter === true, - interrupt: params?.interrupt === true - }) - return { - id: request.id, - ok: true, - result: { send: result }, - _meta: { - runtimeId: this.runtime.getRuntimeId() - } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - if (request.method === 'terminal.wait') { - try { - const params = - request.params && typeof request.params === 'object' && request.params !== null - ? (request.params as { - terminal?: unknown - for?: unknown - timeoutMs?: unknown - }) - : null - - const terminalHandle = params?.terminal ?? null - if (typeof terminalHandle !== 'string' || terminalHandle.length === 0) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle') - } - - const forCondition = params?.for - if (forCondition !== 'exit' && forCondition !== 'tui-idle') { - return this.errorResponse( - request.id, - 'invalid_argument', - 'Invalid --for value. Supported: exit, tui-idle' - ) - } - - const timeoutMs = - typeof params?.timeoutMs === 'number' && Number.isFinite(params.timeoutMs) - ? params.timeoutMs - : undefined - - const result = await this.runtime.waitForTerminal(terminalHandle, { - condition: forCondition, - timeoutMs - }) - return { - id: request.id, - ok: true, - result: { wait: result }, - _meta: { - runtimeId: this.runtime.getRuntimeId() - } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - if (request.method === 'worktree.ps') { - try { - const limit = - request.params && typeof request.params === 'object' && request.params !== null - ? ((request.params as { limit?: unknown }).limit ?? null) - : null - const result = await this.runtime.getWorktreePs( - typeof limit === 'number' && Number.isFinite(limit) ? limit : undefined - ) - return { - id: request.id, - ok: true, - result, - _meta: { - runtimeId: this.runtime.getRuntimeId() - } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - if (request.method === 'repo.list') { - return { - id: request.id, - ok: true, - result: { repos: this.runtime.listRepos() }, - _meta: { - runtimeId: this.runtime.getRuntimeId() - } - } - } - - if (request.method === 'repo.add') { - try { - const pathValue = - request.params && typeof request.params === 'object' && request.params !== null - ? ((request.params as { path?: unknown }).path ?? null) - : null - if (typeof pathValue !== 'string' || pathValue.length === 0) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing repo path') - } - const result = await this.runtime.addRepo(pathValue) - return { - id: request.id, - ok: true, - result: { repo: result }, - _meta: { - runtimeId: this.runtime.getRuntimeId() - } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - if (request.method === 'repo.show') { - try { - const selector = - request.params && typeof request.params === 'object' && request.params !== null - ? ((request.params as { repo?: unknown }).repo ?? null) - : null - if (typeof selector !== 'string' || selector.length === 0) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing repo selector') - } - const result = await this.runtime.showRepo(selector) - return { - id: request.id, - ok: true, - result: { repo: result }, - _meta: { - runtimeId: this.runtime.getRuntimeId() - } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - if (request.method === 'repo.setBaseRef') { - try { - const params = - request.params && typeof request.params === 'object' && request.params !== null - ? (request.params as { repo?: unknown; ref?: unknown }) - : null - const selector = params?.repo - const ref = params?.ref - if (typeof selector !== 'string' || selector.length === 0) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing repo selector') - } - if (typeof ref !== 'string' || ref.length === 0) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing base ref') - } - const result = await this.runtime.setRepoBaseRef(selector, ref) - return { - id: request.id, - ok: true, - result: { repo: result }, - _meta: { - runtimeId: this.runtime.getRuntimeId() - } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - if (request.method === 'repo.searchRefs') { - try { - const params = - request.params && typeof request.params === 'object' && request.params !== null - ? (request.params as { repo?: unknown; query?: unknown; limit?: unknown }) - : null - const selector = params?.repo - const query = params?.query - if (typeof selector !== 'string' || selector.length === 0) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing repo selector') - } - if (typeof query !== 'string') { - return this.errorResponse(request.id, 'invalid_argument', 'Missing query') - } - const result = await this.runtime.searchRepoRefs( - selector, - query, - typeof params?.limit === 'number' ? params.limit : undefined - ) - return { - id: request.id, - ok: true, - result, - _meta: { - runtimeId: this.runtime.getRuntimeId() - } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - if (request.method === 'worktree.list') { - try { - const params = - request.params && typeof request.params === 'object' && request.params !== null - ? (request.params as { repo?: unknown; limit?: unknown }) - : null - const repoSelector = params?.repo ?? null - const result = await this.runtime.listManagedWorktrees( - typeof repoSelector === 'string' ? repoSelector : undefined, - typeof params?.limit === 'number' && Number.isFinite(params.limit) - ? params.limit - : undefined - ) - return { - id: request.id, - ok: true, - result, - _meta: { - runtimeId: this.runtime.getRuntimeId() - } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - if (request.method === 'worktree.show') { - try { - const selector = - request.params && typeof request.params === 'object' && request.params !== null - ? ((request.params as { worktree?: unknown }).worktree ?? null) - : null - if (typeof selector !== 'string' || selector.length === 0) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing worktree selector') - } - const result = await this.runtime.showManagedWorktree(selector) - return { - id: request.id, - ok: true, - result: { worktree: result }, - _meta: { - runtimeId: this.runtime.getRuntimeId() - } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - if (request.method === 'worktree.create') { - try { - const params = - request.params && typeof request.params === 'object' && request.params !== null - ? (request.params as { - repo?: unknown - name?: unknown - baseBranch?: unknown - linkedIssue?: unknown - comment?: unknown - }) - : null - const repoSelector = params?.repo - const name = params?.name - if (typeof repoSelector !== 'string' || repoSelector.length === 0) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing repo selector') - } - if (typeof name !== 'string' || name.length === 0) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing worktree name') - } - const result = await this.runtime.createManagedWorktree({ - repoSelector, - name, - baseBranch: typeof params?.baseBranch === 'string' ? params.baseBranch : undefined, - linkedIssue: - typeof params?.linkedIssue === 'number' - ? params.linkedIssue - : params?.linkedIssue === null - ? null - : undefined, - comment: typeof params?.comment === 'string' ? params.comment : undefined - }) - return { - id: request.id, - ok: true, - result, - _meta: { - runtimeId: this.runtime.getRuntimeId() - } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - if (request.method === 'worktree.set') { - try { - const params = - request.params && typeof request.params === 'object' && request.params !== null - ? (request.params as { - worktree?: unknown - displayName?: unknown - linkedIssue?: unknown - comment?: unknown - }) - : null - const selector = params?.worktree - if (typeof selector !== 'string' || selector.length === 0) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing worktree selector') - } - const result = await this.runtime.updateManagedWorktreeMeta(selector, { - displayName: typeof params?.displayName === 'string' ? params.displayName : undefined, - linkedIssue: - typeof params?.linkedIssue === 'number' - ? params.linkedIssue - : params?.linkedIssue === null - ? null - : undefined, - comment: typeof params?.comment === 'string' ? params.comment : undefined - }) - return { - id: request.id, - ok: true, - result: { worktree: result }, - _meta: { - runtimeId: this.runtime.getRuntimeId() - } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - if (request.method === 'worktree.rm') { - try { - const params = - request.params && typeof request.params === 'object' && request.params !== null - ? (request.params as { worktree?: unknown; force?: unknown }) - : null - const selector = params?.worktree - if (typeof selector !== 'string' || selector.length === 0) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing worktree selector') - } - await this.runtime.removeManagedWorktree(selector, params?.force === true) - return { - id: request.id, - ok: true, - result: { removed: true }, - _meta: { - runtimeId: this.runtime.getRuntimeId() - } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - if (request.method === 'terminal.create') { - try { - const params = - request.params && typeof request.params === 'object' && request.params !== null - ? (request.params as { worktree?: unknown; command?: unknown; title?: unknown }) - : null - const worktreeSelector = - typeof params?.worktree === 'string' && params.worktree.length > 0 - ? params.worktree - : undefined - const result = await this.runtime.createTerminal(worktreeSelector, { - command: typeof params?.command === 'string' ? params.command : undefined, - title: typeof params?.title === 'string' ? params.title : undefined - }) - return { - id: request.id, - ok: true, - result: { terminal: result }, - _meta: { runtimeId: this.runtime.getRuntimeId() } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - if (request.method === 'terminal.split') { - try { - const params = - request.params && typeof request.params === 'object' && request.params !== null - ? (request.params as { terminal?: unknown; direction?: unknown; command?: unknown }) - : null - const terminalHandle = params?.terminal - if (typeof terminalHandle !== 'string' || terminalHandle.length === 0) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle') - } - const direction = - params?.direction === 'vertical' || params?.direction === 'horizontal' - ? params.direction - : undefined - const result = await this.runtime.splitTerminal(terminalHandle, { - direction, - command: typeof params?.command === 'string' ? params.command : undefined - }) - return { - id: request.id, - ok: true, - result: { split: result }, - _meta: { runtimeId: this.runtime.getRuntimeId() } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - if (request.method === 'terminal.stop') { - try { - const params = - request.params && typeof request.params === 'object' && request.params !== null - ? (request.params as { worktree?: unknown }) - : null - const selector = params?.worktree - if (typeof selector !== 'string' || selector.length === 0) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing worktree selector') - } - const result = await this.runtime.stopTerminalsForWorktree(selector) - return { - id: request.id, - ok: true, - result: result, - _meta: { - runtimeId: this.runtime.getRuntimeId() - } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - // ── Browser automation routes ── - // Why: all browser routes extract optional worktree param for worktree-scoped tab routing - - if (request.method === 'browser.snapshot') { - try { - const params = this.extractParams(request) - const result = await this.runtime.browserSnapshot(this.extractBrowserTarget(params)) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.click') { - try { - const params = this.extractParams(request) - const element = typeof params?.element === 'string' ? params.element : null - if (!element) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --element') - } - const result = await this.runtime.browserClick({ - element, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.goto') { - try { - const params = this.extractParams(request) - const url = typeof params?.url === 'string' ? params.url : null - if (!url) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --url') - } - const result = await this.runtime.browserGoto({ url, ...this.extractBrowserTarget(params) }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.fill') { - try { - const params = this.extractParams(request) - const element = typeof params?.element === 'string' ? params.element : null - const value = typeof params?.value === 'string' ? params.value : null - if (!element) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --element') - } - if (value === null) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --value') - } - const result = await this.runtime.browserFill({ - element, - value, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.type') { - try { - const params = this.extractParams(request) - const input = typeof params?.input === 'string' ? params.input : null - if (!input) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --input') - } - const result = await this.runtime.browserType({ - input, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.select') { - try { - const params = this.extractParams(request) - const element = typeof params?.element === 'string' ? params.element : null - const value = typeof params?.value === 'string' ? params.value : null - if (!element) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --element') - } - if (value === null) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --value') - } - const result = await this.runtime.browserSelect({ - element, - value, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.scroll') { - try { - const params = this.extractParams(request) - const direction = typeof params?.direction === 'string' ? params.direction : null - if (direction !== 'up' && direction !== 'down') { - return this.errorResponse( - request.id, - 'invalid_argument', - 'Missing required --direction (up or down)' - ) - } - const amount = - typeof params?.amount === 'number' && params.amount > 0 ? params.amount : undefined - const result = await this.runtime.browserScroll({ - direction, - amount, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.back') { - try { - const params = this.extractParams(request) - const result = await this.runtime.browserBack(this.extractBrowserTarget(params)) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.reload') { - try { - const params = this.extractParams(request) - const result = await this.runtime.browserReload(this.extractBrowserTarget(params)) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.screenshot') { - try { - const params = this.extractParams(request) - const format = - typeof params?.format === 'string' && - (params.format === 'png' || params.format === 'jpeg') - ? params.format - : undefined - const result = await this.runtime.browserScreenshot({ - format, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.eval') { - try { - const params = this.extractParams(request) - const expression = typeof params?.expression === 'string' ? params.expression : null - if (!expression) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --expression') - } - const result = await this.runtime.browserEval({ - expression, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.tabList') { - try { - const params = this.extractParams(request) - const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined - const result = await this.runtime.browserTabList({ worktree }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.tabSwitch') { - try { - const params = this.extractParams(request) - const index = typeof params?.index === 'number' ? params.index : undefined - const page = - typeof params?.page === 'string' && params.page.length > 0 ? params.page : undefined - if (page === undefined && (index === undefined || !Number.isInteger(index) || index < 0)) { - return this.errorResponse( - request.id, - 'invalid_argument', - 'Missing required --index (non-negative integer) or --page' - ) - } - const result = await this.runtime.browserTabSwitch({ - index, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.hover') { - try { - const params = this.extractParams(request) - const element = typeof params?.element === 'string' ? params.element : null - if (!element) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --element') - } - const result = await this.runtime.browserHover({ - element, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.drag') { - try { - const params = this.extractParams(request) - const from = typeof params?.from === 'string' ? params.from : null - const to = typeof params?.to === 'string' ? params.to : null - if (!from || !to) { - return this.errorResponse( - request.id, - 'invalid_argument', - 'Missing required --from and --to element refs' - ) - } - const result = await this.runtime.browserDrag({ - from, - to, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.upload') { - try { - const params = this.extractParams(request) - const element = typeof params?.element === 'string' ? params.element : null - const files = Array.isArray(params?.files) ? (params.files as string[]) : null - if (!element || !files || files.length === 0) { - return this.errorResponse( - request.id, - 'invalid_argument', - 'Missing required --element and --files' - ) - } - const result = await this.runtime.browserUpload({ - element, - files, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.wait') { - try { - const params = this.extractParams(request) - const selector = typeof params?.selector === 'string' ? params.selector : undefined - const raw = typeof params?.timeout === 'number' ? params.timeout : undefined - const timeout = raw !== undefined && raw > 0 ? raw : undefined - const text = typeof params?.text === 'string' ? params.text : undefined - const url = typeof params?.url === 'string' ? params.url : undefined - const load = typeof params?.load === 'string' ? params.load : undefined - const fn = typeof params?.fn === 'string' ? params.fn : undefined - const state = typeof params?.state === 'string' ? params.state : undefined - const result = await this.runtime.browserWait({ - selector, - timeout, - text, - url, - load, - fn, - state, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.check') { - try { - const params = this.extractParams(request) - const element = typeof params?.element === 'string' ? params.element : null - const checked = params?.checked !== false - if (!element) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --element') - } - const result = await this.runtime.browserCheck({ - element, - checked, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.focus') { - try { - const params = this.extractParams(request) - const element = typeof params?.element === 'string' ? params.element : null - if (!element) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --element') - } - const result = await this.runtime.browserFocus({ - element, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.clear') { - try { - const params = this.extractParams(request) - const element = typeof params?.element === 'string' ? params.element : null - if (!element) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --element') - } - const result = await this.runtime.browserClear({ - element, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.selectAll') { - try { - const params = this.extractParams(request) - const element = typeof params?.element === 'string' ? params.element : null - if (!element) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --element') - } - const result = await this.runtime.browserSelectAll({ - element, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.keypress') { - try { - const params = this.extractParams(request) - const key = typeof params?.key === 'string' ? params.key : null - if (!key) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --key') - } - const result = await this.runtime.browserKeypress({ - key, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.pdf') { - try { - const params = this.extractParams(request) - const result = await this.runtime.browserPdf(this.extractBrowserTarget(params)) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.fullScreenshot') { - try { - const params = this.extractParams(request) - const format = params?.format === 'jpeg' ? ('jpeg' as const) : ('png' as const) - const result = await this.runtime.browserFullScreenshot({ - format, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - // ── Cookie management ── - - if (request.method === 'browser.cookie.get') { - try { - const params = this.extractParams(request) - const url = typeof params?.url === 'string' ? params.url : undefined - const result = await this.runtime.browserCookieGet({ - url, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.cookie.set') { - try { - const params = this.extractParams(request) - const name = typeof params?.name === 'string' ? params.name : null - const value = typeof params?.value === 'string' ? params.value : null - if (!name || value === null) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing name or value') - } - const result = await this.runtime.browserCookieSet({ - name, - value, - domain: typeof params?.domain === 'string' ? params.domain : undefined, - path: typeof params?.path === 'string' ? params.path : undefined, - secure: typeof params?.secure === 'boolean' ? params.secure : undefined, - httpOnly: typeof params?.httpOnly === 'boolean' ? params.httpOnly : undefined, - sameSite: typeof params?.sameSite === 'string' ? params.sameSite : undefined, - expires: typeof params?.expires === 'number' ? params.expires : undefined, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.cookie.delete') { - try { - const params = this.extractParams(request) - const name = typeof params?.name === 'string' ? params.name : null - if (!name) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing cookie name') - } - const result = await this.runtime.browserCookieDelete({ - name, - domain: typeof params?.domain === 'string' ? params.domain : undefined, - url: typeof params?.url === 'string' ? params.url : undefined, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - // ── Viewport emulation ── - - if (request.method === 'browser.viewport') { - try { - const params = this.extractParams(request) - const width = typeof params?.width === 'number' ? params.width : null - const height = typeof params?.height === 'number' ? params.height : null - if (width === null || height === null || width <= 0 || height <= 0) { - return this.errorResponse( - request.id, - 'invalid_argument', - 'Width and height must be positive numbers' - ) - } - const result = await this.runtime.browserSetViewport({ - width, - height, - deviceScaleFactor: - typeof params?.deviceScaleFactor === 'number' ? params.deviceScaleFactor : undefined, - mobile: typeof params?.mobile === 'boolean' ? params.mobile : undefined, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - // ── Geolocation ── - - if (request.method === 'browser.geolocation') { - try { - const params = this.extractParams(request) - const latitude = typeof params?.latitude === 'number' ? params.latitude : null - const longitude = typeof params?.longitude === 'number' ? params.longitude : null - if (latitude === null || longitude === null) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing latitude or longitude') - } - const result = await this.runtime.browserSetGeolocation({ - latitude, - longitude, - accuracy: typeof params?.accuracy === 'number' ? params.accuracy : undefined, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - // ── Request interception ── - - if (request.method === 'browser.intercept.enable') { - try { - const params = this.extractParams(request) - const patterns = Array.isArray(params?.patterns) ? (params.patterns as string[]) : undefined - const result = await this.runtime.browserInterceptEnable({ - patterns, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.intercept.disable') { - try { - const params = this.extractParams(request) - const result = await this.runtime.browserInterceptDisable(this.extractBrowserTarget(params)) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.intercept.list') { - try { - const params = this.extractParams(request) - const result = await this.runtime.browserInterceptList(this.extractBrowserTarget(params)) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - // ── Console/network capture ── - - if (request.method === 'browser.capture.start') { - try { - const params = this.extractParams(request) - const result = await this.runtime.browserCaptureStart(this.extractBrowserTarget(params)) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.capture.stop') { - try { - const params = this.extractParams(request) - const result = await this.runtime.browserCaptureStop(this.extractBrowserTarget(params)) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.console') { - try { - const params = this.extractParams(request) - const limit = typeof params?.limit === 'number' ? params.limit : undefined - const result = await this.runtime.browserConsoleLog({ - limit, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.network') { - try { - const params = this.extractParams(request) - const limit = typeof params?.limit === 'number' ? params.limit : undefined - const result = await this.runtime.browserNetworkLog({ - limit, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - // ── Additional core commands ── - - if (request.method === 'browser.dblclick') { - try { - const params = this.extractParams(request) - const element = typeof params?.element === 'string' ? params.element : null - if (!element) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --element') - } - const result = await this.runtime.browserDblclick({ - element, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.forward') { - try { - const params = this.extractParams(request) - const result = await this.runtime.browserForward(this.extractBrowserTarget(params)) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.scrollIntoView') { - try { - const params = this.extractParams(request) - const element = typeof params?.element === 'string' ? params.element : null - if (!element) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --element') - } - const result = await this.runtime.browserScrollIntoView({ - element, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.get') { - try { - const params = this.extractParams(request) - const what = typeof params?.what === 'string' ? params.what : null - if (!what) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --what') - } - const selector = typeof params?.selector === 'string' ? params.selector : undefined - const result = await this.runtime.browserGet({ - what, - selector, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.is') { - try { - const params = this.extractParams(request) - const what = typeof params?.what === 'string' ? params.what : null - const selector = typeof params?.selector === 'string' ? params.selector : null - if (!what || !selector) { - return this.errorResponse( - request.id, - 'invalid_argument', - 'Missing required --what and --element' - ) - } - const result = await this.runtime.browserIs({ - what, - selector, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - // ── Keyboard insert text ── - - if (request.method === 'browser.keyboardInsertText') { - try { - const params = this.extractParams(request) - const text = typeof params?.text === 'string' ? params.text : null - if (!text) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --text') - } - const result = await this.runtime.browserKeyboardInsertText({ - text, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - // ── Mouse commands ── - - if (request.method === 'browser.mouseMove') { - try { - const params = this.extractParams(request) - const x = typeof params?.x === 'number' ? params.x : null - const y = typeof params?.y === 'number' ? params.y : null - if (x === null || y === null) { - return this.errorResponse( - request.id, - 'invalid_argument', - 'Missing required x and y coordinates' - ) - } - const result = await this.runtime.browserMouseMove({ - x, - y, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.mouseDown') { - try { - const params = this.extractParams(request) - const button = typeof params?.button === 'string' ? params.button : undefined - const result = await this.runtime.browserMouseDown({ - button, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.mouseUp') { - try { - const params = this.extractParams(request) - const button = typeof params?.button === 'string' ? params.button : undefined - const result = await this.runtime.browserMouseUp({ - button, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.mouseWheel') { - try { - const params = this.extractParams(request) - const dy = typeof params?.dy === 'number' ? params.dy : null - if (dy === null) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --dy') - } - const dx = typeof params?.dx === 'number' ? params.dx : undefined - const result = await this.runtime.browserMouseWheel({ - dy, - dx, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - // ── Find (semantic locators) ── - - if (request.method === 'browser.find') { - try { - const params = this.extractParams(request) - const locator = typeof params?.locator === 'string' ? params.locator : null - const value = typeof params?.value === 'string' ? params.value : null - const action = typeof params?.action === 'string' ? params.action : null - if (!locator || !value || !action) { - return this.errorResponse( - request.id, - 'invalid_argument', - 'Missing required --locator, --value, and --action' - ) - } - const text = typeof params?.text === 'string' ? params.text : undefined - const result = await this.runtime.browserFind({ - locator, - value, - action, - text, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - // ── Set commands ── - - if (request.method === 'browser.setDevice') { - try { - const params = this.extractParams(request) - const name = typeof params?.name === 'string' ? params.name : null - if (!name) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --name') - } - const result = await this.runtime.browserSetDevice({ - name, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.setOffline') { - try { - const params = this.extractParams(request) - const state = typeof params?.state === 'string' ? params.state : undefined - const result = await this.runtime.browserSetOffline({ - state, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.setHeaders') { - try { - const params = this.extractParams(request) - const headers = typeof params?.headers === 'string' ? params.headers : null - if (!headers) { - return this.errorResponse( - request.id, - 'invalid_argument', - 'Missing required --headers (JSON string)' - ) - } - const result = await this.runtime.browserSetHeaders({ - headers, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.setCredentials') { - try { - const params = this.extractParams(request) - const user = typeof params?.user === 'string' ? params.user : null - const pass = typeof params?.pass === 'string' ? params.pass : null - if (!user || pass === null) { - return this.errorResponse( - request.id, - 'invalid_argument', - 'Missing required --user and --pass' - ) - } - const result = await this.runtime.browserSetCredentials({ - user, - pass, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.setMedia') { - try { - const params = this.extractParams(request) - const colorScheme = typeof params?.colorScheme === 'string' ? params.colorScheme : undefined - const reducedMotion = - typeof params?.reducedMotion === 'string' ? params.reducedMotion : undefined - const result = await this.runtime.browserSetMedia({ - colorScheme, - reducedMotion, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - // ── Clipboard commands ── - - if (request.method === 'browser.clipboardRead') { - try { - const params = this.extractParams(request) - const result = await this.runtime.browserClipboardRead(this.extractBrowserTarget(params)) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.clipboardWrite') { - try { - const params = this.extractParams(request) - const text = typeof params?.text === 'string' ? params.text : null - if (!text) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --text') - } - const result = await this.runtime.browserClipboardWrite({ - text, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - // ── Dialog commands ── - - if (request.method === 'browser.dialogAccept') { - try { - const params = this.extractParams(request) - const text = typeof params?.text === 'string' ? params.text : undefined - const result = await this.runtime.browserDialogAccept({ - text, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.dialogDismiss') { - try { - const params = this.extractParams(request) - const result = await this.runtime.browserDialogDismiss(this.extractBrowserTarget(params)) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - // ── Storage commands ── - - if (request.method === 'browser.storage.local.get') { - try { - const params = this.extractParams(request) - const key = typeof params?.key === 'string' ? params.key : null - if (!key) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --key') - } - const result = await this.runtime.browserStorageLocalGet({ - key, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.storage.local.set') { - try { - const params = this.extractParams(request) - const key = typeof params?.key === 'string' ? params.key : null - const value = typeof params?.value === 'string' ? params.value : null - if (!key || value === null) { - return this.errorResponse( - request.id, - 'invalid_argument', - 'Missing required --key and --value' - ) - } - const result = await this.runtime.browserStorageLocalSet({ - key, - value, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.storage.local.clear') { - try { - const params = this.extractParams(request) - const result = await this.runtime.browserStorageLocalClear( - this.extractBrowserTarget(params) - ) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.storage.session.get') { - try { - const params = this.extractParams(request) - const key = typeof params?.key === 'string' ? params.key : null - if (!key) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --key') - } - const result = await this.runtime.browserStorageSessionGet({ - key, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.storage.session.set') { - try { - const params = this.extractParams(request) - const key = typeof params?.key === 'string' ? params.key : null - const value = typeof params?.value === 'string' ? params.value : null - if (!key || value === null) { - return this.errorResponse( - request.id, - 'invalid_argument', - 'Missing required --key and --value' - ) - } - const result = await this.runtime.browserStorageSessionSet({ - key, - value, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.storage.session.clear') { - try { - const params = this.extractParams(request) - const result = await this.runtime.browserStorageSessionClear( - this.extractBrowserTarget(params) - ) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - // ── Download command ── - - if (request.method === 'browser.download') { - try { - const params = this.extractParams(request) - const selector = typeof params?.selector === 'string' ? params.selector : null - const path = typeof params?.path === 'string' ? params.path : null - if (!selector || !path) { - return this.errorResponse( - request.id, - 'invalid_argument', - 'Missing required --selector and --path' - ) - } - const result = await this.runtime.browserDownload({ - selector, - path, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - // ── Highlight command ── - - if (request.method === 'browser.highlight') { - try { - const params = this.extractParams(request) - const selector = typeof params?.selector === 'string' ? params.selector : null - if (!selector) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --selector') - } - const result = await this.runtime.browserHighlight({ - selector, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - // ── New: exec passthrough + tab lifecycle ── - - if (request.method === 'browser.exec') { - try { - const params = this.extractParams(request) - const command = typeof params?.command === 'string' ? params.command : null - if (!command) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing required --command') - } - const result = await this.runtime.browserExec({ - command, - ...this.extractBrowserTarget(params) - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.tabCreate') { - try { - const params = this.extractParams(request) - const url = typeof params?.url === 'string' ? params.url : undefined - const worktree = typeof params?.worktree === 'string' ? params.worktree : undefined - const result = await this.runtime.browserTabCreate({ url, worktree }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'browser.tabClose') { - try { - const params = this.extractParams(request) - const index = typeof params?.index === 'number' ? params.index : undefined - const page = - typeof params?.page === 'string' && params.page.length > 0 ? params.page : undefined - const result = await this.runtime.browserTabClose({ - index, - page, - worktree: typeof params?.worktree === 'string' ? params.worktree : undefined - }) - return this.successResponse(request.id, result) - } catch (error) { - return this.browserErrorResponse(request.id, error) - } - } - - if (request.method === 'terminal.focus') { - try { - const params = - request.params && typeof request.params === 'object' && request.params !== null - ? (request.params as { terminal?: unknown }) - : null - const terminalHandle = params?.terminal - if (typeof terminalHandle !== 'string' || terminalHandle.length === 0) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle') - } - const result = await this.runtime.focusTerminal(terminalHandle) - return { - id: request.id, - ok: true, - result: { focus: result }, - _meta: { runtimeId: this.runtime.getRuntimeId() } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - if (request.method === 'terminal.close') { - try { - const params = - request.params && typeof request.params === 'object' && request.params !== null - ? (request.params as { terminal?: unknown }) - : null - const terminalHandle = params?.terminal - if (typeof terminalHandle !== 'string' || terminalHandle.length === 0) { - return this.errorResponse(request.id, 'invalid_argument', 'Missing terminal handle') - } - const result = await this.runtime.closeTerminal(terminalHandle) - return { - id: request.id, - ok: true, - result: { close: result }, - _meta: { runtimeId: this.runtime.getRuntimeId() } - } - } catch (error) { - return this.runtimeErrorResponse(request.id, error) - } - } - - return this.errorResponse(request.id, 'method_not_found', `Unknown method: ${request.method}`) + return this.dispatcher.dispatch(request) } - private errorResponse(id: string, code: string, message: string): RuntimeRpcResponse { - return { - id, - ok: false, - error: { - code, - message - }, - _meta: { - runtimeId: this.runtime.getRuntimeId() - } - } - } - - private successResponse(id: string, result: unknown): RuntimeRpcResponse { - return { - id, - ok: true, - result, - _meta: { - runtimeId: this.runtime.getRuntimeId() - } - } - } - - private extractParams(request: { params?: unknown }): Record | null { - return request.params && typeof request.params === 'object' && request.params !== null - ? (request.params as Record) - : null - } - - private extractBrowserTarget(params: Record | null): { - worktree?: string - page?: string - } { - return { - worktree: typeof params?.worktree === 'string' ? params.worktree : undefined, - page: typeof params?.page === 'string' && params.page.length > 0 ? params.page : undefined - } - } - - // Why: browser errors carry a structured .code property (BrowserError from - // cdp-bridge.ts) that maps directly to agent-facing error codes. We forward - // that code rather than relying on the message-matching pattern used by - // runtimeErrorResponse, which would require adding 10+ entries to the allowlist. - private browserErrorResponse(id: string, error: unknown): RuntimeRpcResponse { - if ( - error instanceof Error && - 'code' in error && - typeof (error as { code: unknown }).code === 'string' - ) { - return this.errorResponse(id, (error as { code: string }).code, error.message) - } - return this.runtimeErrorResponse(id, error) - } - - private runtimeErrorResponse(id: string, error: unknown): RuntimeRpcResponse { - const message = error instanceof Error ? error.message : String(error) - if ( - message === 'runtime_unavailable' || - message === 'selector_not_found' || - message === 'selector_ambiguous' || - message === 'terminal_handle_stale' || - message === 'terminal_not_writable' || - message === 'terminal_exited' || - message === 'terminal_gone' || - message === 'no_active_terminal' || - message === 'repo_not_found' || - message === 'timeout' || - message === 'invalid_limit' - ) { - return this.errorResponse(id, message, message) - } - if (message === 'invalid_terminal_send') { - return this.errorResponse(id, 'invalid_argument', 'Missing terminal send payload') - } - return this.errorResponse(id, 'runtime_error', message) + private buildError(id: string, code: string, message: string): RpcResponse { + return errorResponse(id, { runtimeId: this.runtime.getRuntimeId() }, code, message) } private writeMetadata(): void {