Files
openclaw/src/gateway/server-methods.ts
T
Peter Steinberger 0c99a4e362 feat(ui): manage DM pairing requests in Channels (#112401)
* feat(ui): manage DM pairing requests

* fix(ui): clear pairing data across auth changes

* test(ui): tighten pairing page fixture type

* fix(gateway): complete pairing protocol contracts

* fix(ui): guard pairing mutations across epochs

* fix(ui): restore chat teardown gates

* fix(ui): isolate channel auth lifecycles

* fix(ui): remove stale chat view export
2026-07-22 04:54:20 -07:00

1078 lines
33 KiB
TypeScript

import {
ErrorCodes,
errorShape,
missingScopeErrorShape,
} from "../../packages/gateway-protocol/src/index.js";
import {
gatewayStartupUnavailableDetails,
GATEWAY_STARTUP_RETRY_AFTER_MS,
} from "../../packages/gateway-protocol/src/startup-unavailable.js";
import { getPluginRegistryState } from "../plugins/runtime-state.js";
import { withPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js";
import {
getGatewaySuspendAdmissionPhase,
isGatewayRestartDraining,
tryBeginGatewayRootWorkAdmission,
} from "../process/gateway-work-admission.js";
import { formatControlPlaneActor, resolveControlPlaneActor } from "./control-plane-audit.js";
import {
consumeControlPlaneWriteBudget,
CONTROL_PLANE_RATE_LIMIT_MAX_REQUESTS,
CONTROL_PLANE_RATE_LIMIT_WINDOW_MS,
} from "./control-plane-rate-limit.js";
import {
ADMIN_SCOPE,
authorizeOperatorScopesForMethod,
authorizeOperatorScopesForRequiredScope,
resolveLeastPrivilegeOperatorScopesForMethod,
} from "./method-scopes.js";
import {
createCoreGatewayMethodDescriptors,
createGatewayMethodDescriptorsFromHandlers,
createGatewayMethodRegistry,
createPluginGatewayMethodDescriptors,
isCoreGatewayMethodClassified,
type GatewayMethodRegistry,
} from "./methods/registry.js";
import { isOperatorScope } from "./operator-scopes.js";
import { isRoleAuthorizedForMethod, parseGatewayRole } from "./role-policy.js";
import { NODE_PAIR_GATEWAY_METHODS } from "./server-methods-node-methods.js";
import { createLazyCoreHandlers, lazyHandlerModule } from "./server-methods/lazy-core-handlers.js";
import { SKILLS_GATEWAY_METHOD_NAMES } from "./server-methods/skills-method-names.js";
import type {
GatewayRequestHandler,
GatewayRequestHandlers,
GatewayRequestOptions,
} from "./server-methods/types.js";
const loadAgentHandlers = lazyHandlerModule(
() => import("./server-methods/agent.js"),
(module) => module.agentHandlers,
);
const loadAgentsHandlers = lazyHandlerModule(
() => import("./server-methods/agents.js"),
(module) => module.agentsHandlers,
);
const loadAgentsWorkspaceHandlers = lazyHandlerModule(
() => import("./server-methods/agents-workspace.js"),
(module) => module.agentsWorkspaceHandlers,
);
const loadArtifactsHandlers = lazyHandlerModule(
() => import("./server-methods/artifacts.js"),
(module) => module.artifactsHandlers,
);
const loadBoardHandlers = lazyHandlerModule(
() => import("./server-methods/board.js"),
(module) => module.boardHandlers,
);
const loadAuditHandlers = lazyHandlerModule(
() => import("./server-methods/audit.js"),
(module) => module.auditHandlers,
);
const loadUsersHandlers = lazyHandlerModule(
() => import("./server-methods/users.js"),
(module) => module.usersHandlers,
);
const loadAttachHandlers = lazyHandlerModule(
() => import("./server-methods/attach.js"),
(module) => module.attachHandlers,
);
const loadChannelsHandlers = lazyHandlerModule(
() => import("./server-methods/channels.js"),
(module) => module.channelsHandlers,
);
const loadChannelPairingHandlers = lazyHandlerModule(
() => import("./server-methods/channel-pairing.js"),
(module) => module.channelPairingHandlers,
);
const loadChatHandlers = lazyHandlerModule(
() => import("./server-methods/chat.js"),
(module) => module.chatHandlers,
);
const loadCommandsHandlers = lazyHandlerModule(
() => import("./server-methods/commands.js"),
(module) => module.commandsHandlers,
);
const loadConfigHandlers = lazyHandlerModule(
() => import("./server-methods/config.js"),
(module) => module.configHandlers,
);
const loadConversationHandlers = lazyHandlerModule(
() => import("./server-methods/conversations.js"),
(module) => module.conversationHandlers,
);
const loadConnectHandlers = lazyHandlerModule(
() => import("./server-methods/connect.js"),
(module) => module.connectHandlers,
);
const loadControlUiHandlers = lazyHandlerModule(
() => import("./server-methods/control-ui.js"),
(module) => module.controlUiHandlers,
);
const loadCronHandlers = lazyHandlerModule(
() => import("./server-methods/cron.js"),
(module) => module.cronHandlers,
);
const loadDeviceHandlers = lazyHandlerModule(
() => import("./server-methods/devices.js"),
(module) => module.deviceHandlers,
);
const loadDevicePairSetupHandlers = lazyHandlerModule(
() => import("./server-methods/device-pair-setup.js"),
(module) => module.devicePairSetupHandlers,
);
const loadDiagnosticsHandlers = lazyHandlerModule(
() => import("./server-methods/diagnostics.js"),
(module) => module.diagnosticsHandlers,
);
const loadDoctorHandlers = lazyHandlerModule(
() => import("./server-methods/doctor.js"),
(module) => module.doctorHandlers,
);
const loadEnvironmentsHandlers = lazyHandlerModule(
() => import("./server-methods/environments.js"),
(module) => module.environmentsHandlers,
);
const loadWorktreesHandlers = lazyHandlerModule(
() => import("./server-methods/worktrees.js"),
(module) => module.worktreesHandlers,
);
const loadExecApprovalsHandlers = lazyHandlerModule(
() => import("./server-methods/exec-approvals.js"),
(module) => module.execApprovalsHandlers,
);
const loadFsHandlers = lazyHandlerModule(
() => import("./server-methods/fs.js"),
(module) => module.fsHandlers,
);
const loadHealthHandlers = lazyHandlerModule(
() => import("./server-methods/health.js"),
(module) => module.healthHandlers,
);
const loadLogsHandlers = lazyHandlerModule(
() => import("./server-methods/logs.js"),
(module) => module.logsHandlers,
);
const loadTerminalHandlers = lazyHandlerModule(
() => import("./server-methods/terminal.js"),
(module) => module.terminalHandlers,
);
const loadUiCommandHandlers = lazyHandlerModule(
() => import("./server-methods/ui-command.js"),
(module) => module.uiCommandHandlers,
);
const loadModelsAuthStatusHandlers = lazyHandlerModule(
() => import("./server-methods/models-auth-status.js"),
(module) => module.modelsAuthStatusHandlers,
);
const loadModelsHandlers = lazyHandlerModule(
() => import("./server-methods/models.js"),
(module) => module.modelsHandlers,
);
const loadModelsProbeHandlers = lazyHandlerModule(
() => import("./server-methods/models-probe.js"),
(module) => module.modelsProbeHandlers,
);
const loadNativeHookRelayHandlers = lazyHandlerModule(
() => import("./server-methods/native-hook-relay.js"),
(module) => module.nativeHookRelayHandlers,
);
const loadNodePendingHandlers = lazyHandlerModule(
() => import("./server-methods/nodes-pending.js"),
(module) => module.nodePendingHandlers,
);
const loadNodeHandlers = lazyHandlerModule(
() => import("./server-methods/nodes.js"),
(module) => module.nodeHandlers,
);
const loadPluginHostHookHandlers = lazyHandlerModule(
() => import("./server-methods/plugin-host-hooks.js"),
(module) => module.pluginHostHookHandlers,
);
const loadPluginsHandlers = lazyHandlerModule(
() => import("./server-methods/plugins.js"),
(module) => module.pluginsHandlers,
);
const loadMigrationsHandlers = lazyHandlerModule(
() => import("./server-methods/migrations.js"),
(module) => module.migrationsHandlers,
);
const loadPushHandlers = lazyHandlerModule(
() => import("./server-methods/push.js"),
(module) => module.pushHandlers,
);
const loadRestartHandlers = lazyHandlerModule(
() => import("./server-methods/restart.js"),
(module) => module.restartHandlers,
);
const loadSuspendHandlers = lazyHandlerModule(
() => import("./server-methods/suspend.js"),
(module) => module.suspendHandlers,
);
const loadSendHandlers = lazyHandlerModule(
() => import("./server-methods/send.js"),
(module) => module.sendHandlers,
);
const loadSessionsFilesHandlers = lazyHandlerModule(
() => import("./server-methods/sessions-files.js"),
(module) => module.sessionsFilesHandlers,
);
const loadSessionsDiffHandlers = lazyHandlerModule(
() => import("./server-methods/sessions-diff.js"),
(module) => module.sessionsDiffHandlers,
);
const loadSessionsHandlers = lazyHandlerModule(
() => import("./server-methods/sessions.js"),
(module) => module.sessionsHandlers,
);
const loadSessionCatalogHandlers = lazyHandlerModule(
() => import("./server-methods/session-catalog.js"),
(module) => module.sessionCatalogHandlers,
);
const loadSessionDiscussionHandlers = lazyHandlerModule(
() => import("./server-methods/session-discussion.js"),
(module) => module.sessionDiscussionHandlers,
);
const loadSessionObserverHandlers = lazyHandlerModule(
() => import("./session-observer-rpc.js"),
(module) => module.sessionObserverHandlers,
);
const loadSkillsHandlers = lazyHandlerModule(
() => import("./server-methods/skills.js"),
(module) => module.skillsHandlers,
);
const loadSystemHandlers = lazyHandlerModule(
() => import("./server-methods/system.js"),
(module) => module.systemHandlers,
);
const loadTalkHandlers = lazyHandlerModule(
() => import("./server-methods/talk.js"),
(module) => module.talkHandlers,
);
const loadTasksHandlers = lazyHandlerModule(
() => import("./server-methods/tasks.js"),
(module) => module.tasksHandlers,
);
const loadTaskSuggestionsHandlers = lazyHandlerModule(
() => import("./server-methods/task-suggestions.js"),
(module) => module.taskSuggestionsHandlers,
);
const loadToolsCatalogHandlers = lazyHandlerModule(
() => import("./server-methods/tools-catalog.js"),
(module) => module.toolsCatalogHandlers,
);
const loadToolsEffectiveHandlers = lazyHandlerModule(
() => import("./server-methods/tools-effective.js"),
(module) => module.toolsEffectiveHandlers,
);
const loadToolsInvokeHandlers = lazyHandlerModule(
() => import("./server-methods/tools-invoke.js"),
(module) => module.toolsInvokeHandlers,
);
const loadMcpAppHandlers = lazyHandlerModule(
() => import("./server-methods/mcp-app.js"),
(module) => module.mcpAppHandlers,
);
const loadTtsHandlers = lazyHandlerModule(
() => import("./server-methods/tts.js"),
(module) => module.ttsHandlers,
);
const loadUpdateHandlers = lazyHandlerModule(
() => import("./server-methods/update.js"),
(module) => module.updateHandlers,
);
const loadUsageHandlers = lazyHandlerModule(
() => import("./server-methods/usage.js"),
(module) => module.usageHandlers,
);
const loadVoicewakeRoutingHandlers = lazyHandlerModule(
() => import("./server-methods/voicewake-routing.js"),
(module) => module.voicewakeRoutingHandlers,
);
const loadVoicewakeHandlers = lazyHandlerModule(
() => import("./server-methods/voicewake.js"),
(module) => module.voicewakeHandlers,
);
const loadWebHandlers = lazyHandlerModule(
() => import("./server-methods/web.js"),
(module) => module.webHandlers,
);
const loadSystemAgentHandlers = lazyHandlerModule(
() => import("./server-methods/system-agent.js"),
(module) => module.systemAgentHandlers,
);
const loadSystemChangesHandlers = lazyHandlerModule(
() => import("./server-methods/system-changes.js"),
(module) => module.systemChangesHandlers,
);
const loadWizardHandlers = lazyHandlerModule(
() => import("./server-methods/wizard.js"),
(module) => module.wizardHandlers,
);
function authorizeGatewayMethod(
method: string,
client: GatewayRequestOptions["client"],
params: unknown,
methodRegistry: GatewayMethodRegistry,
) {
// Pre-connect and health requests are allowed through; role/scope checks require the
// authenticated connect metadata established by the gateway handshake.
if (!client?.connect) {
return null;
}
if (method === "health") {
return null;
}
const roleRaw = client.connect.role ?? "operator";
const role = parseGatewayRole(roleRaw);
if (!role) {
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${roleRaw}`);
}
const scopes = client.connect.scopes ?? [];
if (!isRoleAuthorizedForMethod(role, method)) {
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
}
if (role === "node") {
return null;
}
if (scopes.includes(ADMIN_SCOPE)) {
return null;
}
const registeredScope = methodRegistry.getScope(method);
const scopeAuth = isOperatorScope(registeredScope)
? authorizeOperatorScopesForRequiredScope(registeredScope, scopes)
: authorizeOperatorScopesForMethod(method, scopes, params);
if (!scopeAuth.allowed) {
const resolvedRequiredScopes = isOperatorScope(registeredScope)
? [registeredScope]
: resolveLeastPrivilegeOperatorScopesForMethod(method, params);
return missingScopeErrorShape({
missingScope: scopeAuth.missingScope,
requiredScopes:
resolvedRequiredScopes.length > 0 ? resolvedRequiredScopes : [scopeAuth.missingScope],
});
}
return null;
}
const SUSPEND_CONTROL_METHODS = new Set([
"gateway.suspend.prepare",
"gateway.suspend.status",
"gateway.suspend.resume",
]);
function isGatewayMethodAllowedDuringSuspension(method: string): boolean {
return SUSPEND_CONTROL_METHODS.has(method);
}
export const coreGatewayHandlers: GatewayRequestHandlers = {
...createLazyCoreHandlers({
methods: ["connect"],
loadHandlers: loadConnectHandlers,
}),
...createLazyCoreHandlers({
methods: ["attach.grant", "attach.revoke"],
loadHandlers: loadAttachHandlers,
}),
...createLazyCoreHandlers({
methods: ["logs.tail"],
loadHandlers: loadLogsHandlers,
}),
...createLazyCoreHandlers({
methods: ["openclaw.changes.list"],
loadHandlers: loadSystemChangesHandlers,
}),
...createLazyCoreHandlers({
methods: [
"terminal.open",
"terminal.input",
"terminal.resize",
"terminal.close",
"terminal.attach",
"terminal.list",
"terminal.text",
"terminal.upload",
],
loadHandlers: loadTerminalHandlers,
}),
...createLazyCoreHandlers({
methods: ["ui.command"],
loadHandlers: loadUiCommandHandlers,
}),
...createLazyCoreHandlers({
methods: [
"board.get",
"board.update",
"board.widget.put",
"board.widget.grant",
"board.widget.appView",
"board.event",
"board.prompt.authorize",
"board.data.read",
"board.action",
],
loadHandlers: loadBoardHandlers,
}),
...createLazyCoreHandlers({
methods: ["voicewake.get", "voicewake.set"],
loadHandlers: loadVoicewakeHandlers,
}),
...createLazyCoreHandlers({
methods: ["voicewake.routing.get", "voicewake.routing.set"],
loadHandlers: loadVoicewakeRoutingHandlers,
}),
...createLazyCoreHandlers({
methods: ["health", "status"],
loadHandlers: loadHealthHandlers,
}),
...createLazyCoreHandlers({
methods: ["channels.status", "channels.start", "channels.stop", "channels.logout"],
loadHandlers: loadChannelsHandlers,
}),
...createLazyCoreHandlers({
methods: ["channels.pairing.list", "channels.pairing.approve", "channels.pairing.dismiss"],
loadHandlers: loadChannelPairingHandlers,
}),
...createLazyCoreHandlers({
methods: [
"chat.history",
"chat.startup",
"chat.metadata",
"chat.message.get",
"chat.toolTitles",
"chat.abort",
"chat.send",
"chat.inject",
],
loadHandlers: loadChatHandlers,
}),
...createLazyCoreHandlers({
methods: ["commands.list"],
loadHandlers: loadCommandsHandlers,
}),
...createLazyCoreHandlers({
methods: [
"wake",
"cron.list",
"cron.status",
"cron.get",
"cron.add",
"cron.update",
"cron.remove",
"cron.run",
"cron.runs",
],
loadHandlers: loadCronHandlers,
}),
...createLazyCoreHandlers({
methods: [
"device.pair.list",
"device.pair.approve",
"device.pair.reject",
"device.pair.remove",
"device.pair.rename",
"device.token.rotate",
"device.token.revoke",
],
loadHandlers: loadDeviceHandlers,
}),
...createLazyCoreHandlers({
methods: ["device.pair.setupCode"],
loadHandlers: loadDevicePairSetupHandlers,
}),
...createLazyCoreHandlers({
methods: ["diagnostics.stability"],
loadHandlers: loadDiagnosticsHandlers,
}),
...createLazyCoreHandlers({
methods: ["controlUi.githubPreview", "controlUi.sessionPullRequests"],
loadHandlers: loadControlUiHandlers,
}),
...createLazyCoreHandlers({
methods: [
"doctor.memory.status",
"doctor.memory.dreamDiary",
"doctor.memory.backfillDreamDiary",
"doctor.memory.resetDreamDiary",
"doctor.memory.resetGroundedShortTerm",
"doctor.memory.repairDreamingArtifacts",
"doctor.memory.dedupeDreamDiary",
"doctor.memory.remHarness",
],
loadHandlers: loadDoctorHandlers,
}),
...createLazyCoreHandlers({
methods: [
"environments.list",
"environments.status",
"environments.create",
"environments.destroy",
],
loadHandlers: loadEnvironmentsHandlers,
}),
...createLazyCoreHandlers({
methods: [
"worktrees.list",
"worktrees.branches",
"worktrees.create",
"worktrees.remove",
"worktrees.restore",
"worktrees.gc",
],
loadHandlers: loadWorktreesHandlers,
}),
...createLazyCoreHandlers({
methods: [
"exec.approvals.get",
"exec.approvals.set",
"exec.approvals.node.get",
"exec.approvals.node.set",
],
loadHandlers: loadExecApprovalsHandlers,
}),
...createLazyCoreHandlers({
methods: ["fs.listDir"],
loadHandlers: loadFsHandlers,
}),
...createLazyCoreHandlers({
methods: ["web.login.start", "web.login.wait"],
loadHandlers: loadWebHandlers,
}),
...createLazyCoreHandlers({
methods: ["models.list"],
loadHandlers: loadModelsHandlers,
}),
...createLazyCoreHandlers({
methods: ["models.probe"],
loadHandlers: loadModelsProbeHandlers,
}),
...createLazyCoreHandlers({
methods: ["models.authLogout", "models.authStatus"],
loadHandlers: loadModelsAuthStatusHandlers,
}),
...createLazyCoreHandlers({
methods: ["nativeHook.invoke"],
loadHandlers: loadNativeHookRelayHandlers,
}),
...createLazyCoreHandlers({
methods: ["plugins.uiDescriptors", "plugins.sessionAction"],
loadHandlers: loadPluginHostHookHandlers,
}),
...createLazyCoreHandlers({
methods: [
"plugins.list",
"plugins.search",
"plugins.install",
"plugins.setEnabled",
"plugins.uninstall",
"plugins.refresh",
],
loadHandlers: loadPluginsHandlers,
}),
...createLazyCoreHandlers({
methods: [
"config.get",
"config.schema",
"config.schema.lookup",
"config.set",
"config.patch",
"config.apply",
"config.openFile",
],
loadHandlers: loadConfigHandlers,
}),
...createLazyCoreHandlers({
methods: ["wizard.start", "wizard.next", "wizard.cancel", "wizard.status"],
loadHandlers: loadWizardHandlers,
}),
...createLazyCoreHandlers({
methods: [
"openclaw.chat",
"openclaw.chat.history",
"openclaw.approval.list",
"openclaw.setup.detect",
"openclaw.setup.verify",
"openclaw.setup.activate",
"openclaw.setup.auth.start",
"openclaw.setup.prepare.start",
],
loadHandlers: loadSystemAgentHandlers,
}),
...createLazyCoreHandlers({
methods: [
"talk.session.create",
"talk.session.join",
"talk.session.appendAudio",
"talk.session.startTurn",
"talk.session.endTurn",
"talk.session.cancelTurn",
"talk.session.cancelOutput",
"talk.session.acknowledgeMark",
"talk.session.submitToolResult",
"talk.session.steer",
"talk.session.close",
"talk.client.create",
"talk.client.transcript",
"talk.client.close",
"talk.client.toolCall",
"talk.client.steer",
"talk.catalog",
"talk.config",
"talk.speak",
"talk.mode",
],
loadHandlers: loadTalkHandlers,
}),
...createLazyCoreHandlers({
methods: ["audit.list", "audit.activity.list"],
loadHandlers: loadAuditHandlers,
}),
...createLazyCoreHandlers({
methods: [
"users.list",
"users.self",
"users.linkEmail",
"users.setDisplayName",
"users.setAvatar",
],
loadHandlers: loadUsersHandlers,
}),
...createLazyCoreHandlers({
methods: ["tasks.list", "tasks.get", "tasks.cancel"],
loadHandlers: loadTasksHandlers,
}),
...createLazyCoreHandlers({
methods: [
"taskSuggestions.list",
"taskSuggestions.create",
"taskSuggestions.accept",
"taskSuggestions.dismiss",
],
loadHandlers: loadTaskSuggestionsHandlers,
}),
...createLazyCoreHandlers({
methods: ["tools.catalog"],
loadHandlers: loadToolsCatalogHandlers,
}),
...createLazyCoreHandlers({
methods: ["tools.effective"],
loadHandlers: loadToolsEffectiveHandlers,
}),
...createLazyCoreHandlers({
methods: ["tools.invoke"],
loadHandlers: loadToolsInvokeHandlers,
}),
...createLazyCoreHandlers({
methods: [
"mcp.app.view",
"mcp.app.callTool",
"mcp.app.listTools",
"mcp.app.listResources",
"mcp.app.listResourceTemplates",
"mcp.app.readResource",
"mcp.app.updateModelContext",
],
loadHandlers: loadMcpAppHandlers,
}),
...createLazyCoreHandlers({
methods: [
"tts.status",
"tts.enable",
"tts.disable",
"tts.convert",
"tts.speak",
"tts.setProvider",
"tts.personas",
"tts.setPersona",
"tts.providers",
],
loadHandlers: loadTtsHandlers,
}),
...createLazyCoreHandlers({
methods: SKILLS_GATEWAY_METHOD_NAMES,
loadHandlers: loadSkillsHandlers,
}),
...createLazyCoreHandlers({
methods: [
"sessions.catalog.list",
"sessions.catalog.read",
"sessions.catalog.continue",
"sessions.catalog.archive",
],
loadHandlers: loadSessionCatalogHandlers,
}),
...createLazyCoreHandlers({
methods: ["session.discussion.info", "session.discussion.open"],
loadHandlers: loadSessionDiscussionHandlers,
}),
...createLazyCoreHandlers({
methods: ["sessions.observer.ask", "sessions.observer.visibility"],
loadHandlers: loadSessionObserverHandlers,
}),
...createLazyCoreHandlers({
methods: [
"sessions.list",
"sessions.search",
"sessions.cleanup",
"sessions.subscribe",
"sessions.unsubscribe",
"sessions.messages.subscribe",
"sessions.messages.unsubscribe",
"sessions.preview",
"sessions.describe",
"sessions.resolve",
"sessions.compaction.list",
"sessions.compaction.get",
"sessions.create",
"sessions.compaction.branch",
"sessions.compaction.restore",
"sessions.branches.list",
"sessions.branches.switch",
"sessions.rewind",
"sessions.fork",
"sessions.send",
"sessions.steer",
"sessions.abort",
"sessions.patch",
"sessions.pluginPatch",
"sessions.reset",
"sessions.delete",
"sessions.get",
"sessions.compact",
"sessions.groups.list",
"sessions.groups.put",
"sessions.groups.rename",
"sessions.groups.delete",
"sessions.dispatch",
"sessions.reclaim",
],
loadHandlers: loadSessionsHandlers,
}),
...createLazyCoreHandlers({
methods: [
"gateway.identity.get",
"last-heartbeat",
"set-heartbeats",
"system-presence",
"system.info",
"system-event",
],
loadHandlers: loadSystemHandlers,
}),
...createLazyCoreHandlers({
methods: ["update.status", "update.run"],
loadHandlers: loadUpdateHandlers,
}),
...createLazyCoreHandlers({
methods: [
...NODE_PAIR_GATEWAY_METHODS,
"node.pair.remove",
"node.rename",
"node.list",
"node.describe",
"plugin.surface.refresh",
"node.pluginSurface.refresh",
"node.pluginTools.update",
"node.skills.update",
"node.pending.pull",
"node.pending.ack",
"node.invoke",
"node.invoke.progress",
"node.invoke.result",
"node.event",
],
loadHandlers: loadNodeHandlers,
}),
...createLazyCoreHandlers({
methods: ["node.pending.drain", "node.pending.enqueue"],
loadHandlers: loadNodePendingHandlers,
}),
...createLazyCoreHandlers({
methods: [
"push.test",
"push.web.vapidPublicKey",
"push.web.subscribe",
"push.web.unsubscribe",
"push.web.test",
],
loadHandlers: loadPushHandlers,
}),
...createLazyCoreHandlers({
methods: ["gateway.restart.request", "gateway.restart.preflight"],
loadHandlers: loadRestartHandlers,
}),
...createLazyCoreHandlers({
methods: ["gateway.suspend.prepare", "gateway.suspend.status", "gateway.suspend.resume"],
loadHandlers: loadSuspendHandlers,
}),
...createLazyCoreHandlers({
methods: ["message.action", "send", "poll"],
loadHandlers: loadSendHandlers,
}),
...createLazyCoreHandlers({
methods: [
"conversations.list",
"conversations.send",
"conversations.turn",
"conversations.turn.cancel",
],
loadHandlers: loadConversationHandlers,
}),
...createLazyCoreHandlers({
methods: [
"usage.status",
"usage.cost",
"sessions.usage",
"sessions.usage.timeseries",
"sessions.usage.logs",
],
loadHandlers: loadUsageHandlers,
}),
...createLazyCoreHandlers({
methods: ["agent", "agent.identity.get", "agent.wait"],
loadHandlers: loadAgentHandlers,
}),
...createLazyCoreHandlers({
methods: [
"agents.list",
"agents.create",
"agents.update",
"agents.delete",
"agents.files.list",
"agents.files.get",
"agents.files.set",
],
loadHandlers: loadAgentsHandlers,
}),
...createLazyCoreHandlers({
methods: ["agents.workspace.list", "agents.workspace.get"],
loadHandlers: loadAgentsWorkspaceHandlers,
}),
...createLazyCoreHandlers({
methods: ["artifacts.list", "artifacts.get", "artifacts.download"],
loadHandlers: loadArtifactsHandlers,
}),
...createLazyCoreHandlers({
methods: [
"sessions.files.list",
"sessions.files.get",
"sessions.files.set",
"sessions.files.reveal",
],
loadHandlers: loadSessionsFilesHandlers,
}),
...createLazyCoreHandlers({
methods: ["sessions.diff"],
loadHandlers: loadSessionsDiffHandlers,
}),
...createLazyCoreHandlers({
methods: ["migrations.memory.plan", "migrations.memory.apply"],
loadHandlers: loadMigrationsHandlers,
}),
};
/** Builds the per-request method registry from core, plugin, and explicit extra handlers. */
function createRequestGatewayMethodRegistry(
extraHandlers?: GatewayRequestHandlers,
): GatewayMethodRegistry {
const activePluginRegistry = getPluginRegistryState()?.activeRegistry;
const activePluginHandlers = activePluginRegistry?.gatewayHandlers ?? {};
const extraHandlerEntries = Object.entries(extraHandlers ?? {});
const pluginMethodNames = new Set(Object.keys(activePluginHandlers));
const coreDescriptorHandlers = { ...coreGatewayHandlers };
for (const [method, extraHandler] of extraHandlerEntries) {
// Tests and local harnesses can override classified core methods, but plugin-provided
// methods win so a loaded plugin cannot be shadowed by a caller-local extra handler.
if (!pluginMethodNames.has(method) && isCoreGatewayMethodClassified(method)) {
coreDescriptorHandlers[method] = extraHandler;
}
}
const coreDescriptors = createCoreGatewayMethodDescriptors(coreDescriptorHandlers);
for (const descriptor of coreDescriptors) {
const extraHandler = extraHandlers?.[descriptor.name];
if (extraHandler && !pluginMethodNames.has(descriptor.name)) {
descriptor.handler = extraHandler;
}
}
const coreMethodNames = new Set(coreDescriptors.map((descriptor) => descriptor.name));
const auxHandlers = Object.fromEntries(
extraHandlerEntries.filter(
([method]) => !pluginMethodNames.has(method) && !coreMethodNames.has(method),
),
);
return createGatewayMethodRegistry([
...coreDescriptors,
...(activePluginRegistry ? createPluginGatewayMethodDescriptors(activePluginRegistry) : []),
...createGatewayMethodDescriptorsFromHandlers({
handlers: auxHandlers,
owner: { kind: "aux", area: "gateway-extra" },
defaultScope: ADMIN_SCOPE,
}),
]);
}
/** Authorizes and dispatches one gateway JSON-RPC-style request. */
export async function handleGatewayRequest(
opts: GatewayRequestOptions & { extraHandlers?: GatewayRequestHandlers },
): Promise<void> {
const { req, respond, client, isWebchatConnect, context } = opts;
// Prefer the caller-attached registry when it owns the requested method so plugin dispatch
// metadata newer than global runtime state still authorizes and dispatches correctly. 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;
}
if (
client?.connect.role === "node" &&
(!client.connId || !(await context.nodeRegistry.isConnectionCurrentPairingState(client.connId)))
) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, "node pairing changed before request dispatch", {
retryable: true,
details: { code: "PAIRING_CHANGED" },
}),
);
return;
}
if (context.unavailableGatewayMethods?.has(req.method)) {
// During startup, methods can be listed before their runtime is ready. Return the protocol
// retry shape so clients can back off without treating startup as a permanent unknown method.
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, `${req.method} unavailable during gateway startup`, {
retryable: true,
retryAfterMs: GATEWAY_STARTUP_RETRY_AFTER_MS,
details: { ...gatewayStartupUnavailableDetails(), method: req.method },
}),
);
return;
}
const rejectRateLimitedControlPlaneWrite = (): boolean => {
if (!methodRegistry.isControlPlaneWrite(req.method)) {
return false;
}
const budget = consumeControlPlaneWriteBudget({ client, method: req.method });
if (budget.allowed) {
return false;
}
const actor = resolveControlPlaneActor(client);
context.logGateway.warn(
`control-plane write rate-limited method=${req.method} ${formatControlPlaneActor(actor)} retryAfterMs=${budget.retryAfterMs} key=${budget.key}`,
);
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
`rate limit exceeded for ${req.method}; retry after ${Math.ceil(budget.retryAfterMs / 1000)}s`,
{
retryable: true,
retryAfterMs: budget.retryAfterMs,
details: {
method: req.method,
limit: `${CONTROL_PLANE_RATE_LIMIT_MAX_REQUESTS} per ${CONTROL_PLANE_RATE_LIMIT_WINDOW_MS / 1000}s`,
},
},
),
);
return true;
};
const isSuspendPrepare = req.method === "gateway.suspend.prepare";
if (isSuspendPrepare && rejectRateLimitedControlPlaneWrite()) {
// Preparation must stay protected even before it owns the root admission that it closes.
return;
}
const handler = methodRegistry.getHandler(req.method) as GatewayRequestHandler | undefined;
if (!handler) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unknown method: ${req.method}`),
);
return;
}
const rootWorkAdmission = tryBeginGatewayRootWorkAdmission();
if (
req.method === "gateway.suspend.prepare" &&
rootWorkAdmission &&
!rootWorkAdmission.ownsRoot
) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, "gateway suspension cannot begin from a nested request", {
retryable: true,
retryAfterMs: 1_000,
details: { method: req.method, reason: "nested-gateway-request" },
}),
);
return;
}
if (!rootWorkAdmission && !isGatewayMethodAllowedDuringSuspension(req.method)) {
const restartDraining = isGatewayRestartDraining();
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
`${req.method} unavailable during gateway ${restartDraining ? "restart" : "suspension"}`,
{
retryable: true,
retryAfterMs: 1_000,
details: {
method: req.method,
reason: restartDraining ? "gateway-restarting" : "gateway-suspending",
phase: getGatewaySuspendAdmissionPhase(),
},
},
),
);
return;
}
if (!isSuspendPrepare && rejectRateLimitedControlPlaneWrite()) {
// A closed admission must reject first so refused writes do not exhaust the controller's
// budget and strand it behind rate limiting after suspension resumes.
rootWorkAdmission?.release();
return;
}
const invokeHandler = () =>
handler({
req,
params: (req.params ?? {}) as Record<string, unknown>,
client,
isWebchatConnect,
respond,
context,
});
// All handlers run inside a request scope so that plugin runtime
// subagent methods (e.g. context engine tools spawning sub-agents
// during tool execution) can dispatch back into the gateway.
// The scope also carries caller identity into plugin-owned gateway methods.
const invokeWithRequestScope = async () =>
await withPluginRuntimeGatewayRequestScope(
{ context, client, isWebchatConnect },
invokeHandler,
);
if (!rootWorkAdmission) {
await invokeWithRequestScope();
return;
}
try {
await rootWorkAdmission.run(invokeWithRequestScope);
} finally {
rootWorkAdmission.release();
}
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */