Skip to content

RPC method table and request dispatch

源码版本v2026.6.11

Responsibilities

server-methods.ts is the RPC hub of the gateway: it dispatches incoming JSON-RPC requests by method name to handlers, and handles authorization, rate limiting, and request-scoped isolation. The handlers that do the real work live in family-split submodules under server-methods/ — chat, agents, cron, channels, device, artifacts, connect, and so on.

This layer does four things: aggregates core methods into a table (coreGatewayHandlers), merges them per-request into a registry, authorizes and hands off to a handler, and finally runs the handler inside a plugin runtime request scope so that subagents spawned from within the handler can still call back into gateway methods.

Design motivation

Why not just a big switch(method) table? Because the gateway has to support plugin hot-registration of methods, has to support tests overriding core methods, and has to advertise the method table early in startup (when many handler modules aren't loaded yet). These three constraints together force the method table to be declaration-first, load-deferred, merged per request.

Declaration-first means coreGatewayHandlers is just a {method-name: handler} map. Handlers are wrapped with createLazyCoreHandlers doing dynamic imports — the module loads only on first invocation, and subsequent calls reuse the same import promise (handlersPromise ??= cache inside lazyHandlerModule), avoiding concurrent duplicate loads. Merged per request means createRequestGatewayMethodRegistry merges core with the current active plugin handlers and any caller-provided extra handlers — so plugin hot-registered methods become visible to in-flight requests without a restart.

Key files

Data flow

The core method table is a giant object literal (coreGatewayHandlers:269), grouped by family, each family wrapped in createLazyCoreHandlers:

typescript
export const coreGatewayHandlers: GatewayRequestHandlers = {
  ...createLazyCoreHandlers({
    methods: ["chat.history", "chat.startup", "chat.metadata",
             "chat.message.get", "chat.abort", "chat.send", "chat.inject"],
    loadHandlers: loadChatHandlers,
  }),
  ...createLazyCoreHandlers({
    methods: ["wake", "cron.list", "cron.status", "cron.get", "cron.add",
             "cron.update", "cron.remove", "cron.run", "cron.runs"],
    loadHandlers: loadCronHandlers,
  }),
  // ...channels/device/agents/artifacts/sessions...
};

Method names use dotted hierarchy: chat.* for chat sessions, cron.* for scheduled jobs, channels.* for channel start/stop, device.pair.* for device pairing. wake is a singular top-level wake method, not under a family prefix.

createLazyCoreHandlers (createLazyCoreHandlers:44) generates a wrapper per method name; first call triggers loadHandlers() and caches it in the closure. The key point is that descriptor drift throws hard:

typescript
async (opts: GatewayRequestHandlerOptions) => {
  const handlers = await params.loadHandlers();
  const handler = handlers[method];
  if (!handler) {
    // Descriptor drift should fail loudly: advertised core methods must exist in the
    // loaded family module once the lazy boundary resolves.
    throw new Error(`lazy gateway handler not found: ${method}`);
  }
  await handler(opts);
}

If a method name is declared but the loaded family module has no matching handler, that's a config mismatch and must throw — not silently return unknown method.

When a request arrives, handleGatewayRequest (handleGatewayRequest:638) first picks which registry to use, then authorizes, then runs the handler:

typescript
// When the attached snapshot does not own the method, rebuild from the live plugin registry
// so plugin RPC methods registered after the startup snapshot stay reachable (#94127).
const methodRegistry =
  opts.methodRegistry?.getHandler(req.method) !== undefined
    ? opts.methodRegistry
    : createRequestGatewayMethodRegistry(opts.extraHandlers);
const authError = authorizeGatewayMethod(req.method, client, req.params, methodRegistry);
if (authError) {
  respond(false, undefined, authError);
  return;
}

This fixes #94127: the startup snapshot captured the method table, but plugin methods hot-registered afterwards aren't in the snapshot. The fix is to check whether the snapshot owns the method; if not, fall back to createRequestGatewayMethodRegistry rebuilt from the live plugin state. Rebuild is cheap and only happens on snapshot miss.

Authorization runs two layers: first role (operator / node / admin); node and connections with ADMIN_SCOPE pass straight through. Other roles then check the method's registered scope via authorizeOperatorScopesForMethod or authorizeOperatorScopesForRequiredScope to verify the client's scopes cover it.

After authorization, if it's a control-plane write (isControlPlaneWrite), it goes through rate limiting (control-plane rate limit:669). Default quota is 3 per 60 seconds; overage returns UNAVAILABLE + retryAfterMs. Rate limiting sits before handler lookup so plugin and aux-registered write methods share the same gate.

Finally the handler runs inside the plugin runtime request scope (withPluginRuntimeGatewayRequestScope:706): withPluginRuntimeGatewayRequestScope({ context, client, isWebchatConnect }, invokeHandler) wraps handler({ req, params, client, isWebchatConnect, respond, context }). The scope's purpose is to let handlers spawn subagents and have those subagents call gateway methods (like chat.send during tool execution), with nested calls inheriting the caller identity and plugin-registered RPC methods still seeing the current client context. Without the scope, nested calls would lose identity and the isWebchatConnect flag, breaking downstream authorization.

Boundaries and failures

  • descriptor drift throws hard: when a family module loads but a declared method isn't found, it throws new Error. Mismatched config can't be silently swallowed — otherwise clients see unknown method but server logs show no exception.
  • startup-phase method reachability: the method table is advertised early, but handler modules may not be fully loaded. handleGatewayRequest checks context.unavailableGatewayMethods; on hit it returns UNAVAILABLE + retryable: true + retryAfterMs: GATEWAY_STARTUP_RETRY_AFTER_MS to let clients back off per the protocol (startup unavailable:655).
  • #94127 hot-registered handler reachability: the methodRegistry selection logic — use the snapshot if it owns the method, otherwise rebuild from the live plugin registry. Breaking this makes plugin methods registered after startup invisible.
  • plugin handler priority: in createRequestGatewayMethodRegistry, plugin handlers always win over extra handlers (if (!pluginMethodNames.has(method))), preventing caller-provided extra handlers from shadowing loaded plugin methods — plugins are explicitly installed by the user, so they take priority over harness-local injections.
  • control-plane rate-limit position: rate limiting is before handler lookup, not after. Putting it inside the handler would let plugin and aux write methods bypass the gate.
  • scope failure: an error from withPluginRuntimeGatewayRequestScope fails the request, but rollback of the half-executed handler state is the handler's own responsibility — the scope provides no transactional semantics.

Summary

The method table is declaration-first: coreGatewayHandlers is a single table listing methods grouped by family. Loading is deferred: createLazyCoreHandlers wraps dynamic imports in cached wrappers. Merging is per-request: createRequestGatewayMethodRegistry merges core, plugin, and extra into one, with plugin taking priority. Authorization is role + scope in two layers; control-plane writes additionally go through rate limiting; handlers run inside the plugin request scope to support nested callbacks.

How requests reach this layer and how broadcast primitives are injected is in Gateway core; how agent events are delivered back to clients after the handler runs is in Chat broadcast and event delivery; how the handler internally drives the agent main loop is in Embedded runner.

Official references: Gateway docs · README.