Files
openclaw/src/gateway/server-request-context.ts
T
Peter Steinberger ff72f287c3 fix(gateway): declare viewer presence explicitly instead of deriving it from subscriptions (#116001)
* fix(gateway): declare viewer presence explicitly instead of deriving it from subscriptions

* refactor(ui): isolate viewer presence lifecycle ownership

* chore(protocol): register viewer presence schema owner

* fix(ui): keep viewer presence cleared after detach

* chore: drop changelog edits from this PR

CHANGELOG.md is release-owned; release generation derives entries from merged
PRs. Release-note context stays in the PR body.
2026-07-29 13:56:54 -04:00

350 lines
16 KiB
TypeScript

// Gateway request context factory.
// Wires live runtime state into method handlers and client management helpers.
import {
GATEWAY_CLIENT_CAPS,
GATEWAY_CLIENT_IDS,
hasGatewayClientCap,
type GatewayClientId,
} from "../../packages/gateway-protocol/src/client-info.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { GatewayServerLiveState } from "./server-live-state.js";
import type { GatewayClient, GatewayRequestContext } from "./server-methods/types.js";
import { disconnectAllSharedGatewayAuthClients } from "./server-shared-auth-generation.js";
import type { SessionCompanionService } from "./session-companion.js";
import type { SessionObserverService } from "./session-observer-contract.js";
type GatewayRequestContextClient = GatewayClient & {
socket: { close: (code: number, reason: string) => void };
usesSharedGatewayAuth?: boolean;
invalidated?: boolean;
invalidatedReason?: string;
};
type GatewayRequestContextParams = {
deps: GatewayRequestContext["deps"];
runtimeState: Pick<
GatewayServerLiveState,
"cronState" | "configReloader" | "controlUiSessionPullRequests" | "sessionViewerPresence"
>;
getRuntimeConfig: GatewayRequestContext["getRuntimeConfig"];
sessionCompanion: SessionCompanionService;
sessionObserver: SessionObserverService;
getMcpAppSandboxPort?: GatewayRequestContext["getMcpAppSandboxPort"];
ensureSandboxHostPort?: GatewayRequestContext["ensureSandboxHostPort"];
resolveTerminalLaunchPolicy: GatewayRequestContext["resolveTerminalLaunchPolicy"];
isTerminalEnabled: GatewayRequestContext["isTerminalEnabled"];
execApprovalManager: GatewayRequestContext["execApprovalManager"];
cancelRunBoundApprovals?: (runId: string, context: GatewayRequestContext) => number;
forwardPluginApprovalRequest?: GatewayRequestContext["forwardPluginApprovalRequest"];
pluginApprovalIosPushDelivery?: GatewayRequestContext["pluginApprovalIosPushDelivery"];
pluginApprovalManager: GatewayRequestContext["pluginApprovalManager"];
systemAgentApprovalManager?: GatewayRequestContext["systemAgentApprovalManager"];
listSessionPendingApprovals: GatewayRequestContext["listSessionPendingApprovals"];
loadGatewayModelCatalog: GatewayRequestContext["loadGatewayModelCatalog"];
loadGatewayModelCatalogSnapshot: GatewayRequestContext["loadGatewayModelCatalogSnapshot"];
getHealthCache: GatewayRequestContext["getHealthCache"];
refreshHealthSnapshot: GatewayRequestContext["refreshHealthSnapshot"];
logHealth: GatewayRequestContext["logHealth"];
logGateway: GatewayRequestContext["logGateway"];
incrementPresenceVersion: GatewayRequestContext["incrementPresenceVersion"];
getHealthVersion: GatewayRequestContext["getHealthVersion"];
broadcast: GatewayRequestContext["broadcast"];
broadcastToConnIds: GatewayRequestContext["broadcastToConnIds"];
nodeSendToSession: GatewayRequestContext["nodeSendToSession"];
nodeSendToAllSubscribed: GatewayRequestContext["nodeSendToAllSubscribed"];
nodeSubscribe: GatewayRequestContext["nodeSubscribe"];
nodeUnsubscribe: GatewayRequestContext["nodeUnsubscribe"];
nodeUnsubscribeAll: GatewayRequestContext["nodeUnsubscribeAll"];
hasConnectedTalkNode: GatewayRequestContext["hasConnectedTalkNode"];
clients: Set<GatewayRequestContextClient>;
invalidateDeviceTransports?: (
deviceId: string,
opts?: { role?: string; reason?: string },
) => void;
disconnectDeviceTransports?: (deviceId: string, opts?: { role?: string }) => void;
enforceSharedGatewayAuthGenerationForConfigWrite: (nextConfig: OpenClawConfig) => void;
claimControlUiDeviceAuthMigration?: (deviceId: string) => boolean;
releaseControlUiDeviceAuthMigrationClaim?: (deviceId: string) => void;
completeControlUiDeviceAuthMigration?: (device: {
deviceId: string;
publicKey: string;
scopes: string[];
}) => void;
nodeRegistry: GatewayRequestContext["nodeRegistry"];
workerEnvironmentService?: GatewayRequestContext["workerEnvironmentService"];
workerSessionPlacementService?: GatewayRequestContext["workerSessionPlacementService"];
workerPlacementDispatchService?: GatewayRequestContext["workerPlacementDispatchService"];
terminalSessions?: GatewayRequestContext["terminalSessions"];
agentRunSeq: GatewayRequestContext["agentRunSeq"];
chatAbortControllers: GatewayRequestContext["chatAbortControllers"];
chatQueuedTurns: GatewayRequestContext["chatQueuedTurns"];
chatRunState: GatewayRequestContext["chatRunState"];
addChatRun: GatewayRequestContext["addChatRun"];
removeChatRun: GatewayRequestContext["removeChatRun"];
subscribeSessionEvents: GatewayRequestContext["subscribeSessionEvents"];
unsubscribeSessionEvents: GatewayRequestContext["unsubscribeSessionEvents"];
subscribeSessionMessageEvents: GatewayRequestContext["subscribeSessionMessageEvents"];
unsubscribeSessionMessageEvents: GatewayRequestContext["unsubscribeSessionMessageEvents"];
unsubscribeAllSessionEvents: GatewayRequestContext["unsubscribeAllSessionEvents"];
getSessionEventSubscriberConnIds: GatewayRequestContext["getSessionEventSubscriberConnIds"];
registerToolEventRecipient: GatewayRequestContext["registerToolEventRecipient"];
dedupe: GatewayRequestContext["dedupe"];
wizardSessions: GatewayRequestContext["wizardSessions"];
systemAgentSessions: GatewayRequestContext["systemAgentSessions"];
findRunningWizard: GatewayRequestContext["findRunningWizard"];
purgeWizardSession: GatewayRequestContext["purgeWizardSession"];
getRuntimeSnapshot: GatewayRequestContext["getRuntimeSnapshot"];
getEventLoopHealth?: GatewayRequestContext["getEventLoopHealth"];
startChannel: GatewayRequestContext["startChannel"];
stopChannel: GatewayRequestContext["stopChannel"];
markChannelLoggedOut: GatewayRequestContext["markChannelLoggedOut"];
wizardRunner: GatewayRequestContext["wizardRunner"];
channelWizardRunner: GatewayRequestContext["channelWizardRunner"];
broadcastVoiceWakeChanged: GatewayRequestContext["broadcastVoiceWakeChanged"];
broadcastVoiceWakeRoutingChanged: GatewayRequestContext["broadcastVoiceWakeRoutingChanged"];
unavailableGatewayMethods: ReadonlySet<string>;
};
const ALL_APPROVAL_CLIENT_IDS: ReadonlySet<GatewayClientId> = new Set([
GATEWAY_CLIENT_IDS.CONTROL_UI,
]);
const EXEC_APPROVAL_CLIENT_IDS: ReadonlySet<GatewayClientId> = new Set([
GATEWAY_CLIENT_IDS.MACOS_APP,
GATEWAY_CLIENT_IDS.IOS_APP,
GATEWAY_CLIENT_IDS.ANDROID_APP,
]);
const PLUGIN_APPROVAL_CLIENT_IDS: ReadonlySet<GatewayClientId> = new Set([GATEWAY_CLIENT_IDS.TUI]);
function canDeliverApprovals(
gatewayClient: GatewayRequestContextClient,
approvalKind: "exec" | "plugin" | "system-agent",
): boolean {
if (gatewayClient.invalidated) {
return false;
}
const scopes = Array.isArray(gatewayClient.connect.scopes) ? gatewayClient.connect.scopes : [];
const hasApprovalScope =
scopes.includes("operator.admin") || scopes.includes("operator.approvals");
if (!hasApprovalScope) {
return false;
}
// Scope grants approval access; it does not prove the client renders this approval kind.
// Stable ids preserve shipped clients while explicit caps describe newer non-UI bridges.
return (
gatewayClient.internal?.approvalRuntime === true ||
ALL_APPROVAL_CLIENT_IDS.has(gatewayClient.connect.client.id) ||
hasGatewayClientCap(gatewayClient.connect.caps, GATEWAY_CLIENT_CAPS.APPROVALS) ||
(approvalKind === "exec" &&
(EXEC_APPROVAL_CLIENT_IDS.has(gatewayClient.connect.client.id) ||
hasGatewayClientCap(gatewayClient.connect.caps, GATEWAY_CLIENT_CAPS.EXEC_APPROVALS))) ||
(approvalKind === "plugin" &&
(PLUGIN_APPROVAL_CLIENT_IDS.has(gatewayClient.connect.client.id) ||
hasGatewayClientCap(gatewayClient.connect.caps, GATEWAY_CLIENT_CAPS.PLUGIN_APPROVALS)))
);
}
export type GatewayRequestContextWithClientLookup = GatewayRequestContext & {
getClientConnIds?: (filter?: (client: GatewayClient) => boolean) => ReadonlySet<string>;
};
export function createGatewayRequestContext(
params: GatewayRequestContextParams,
): GatewayRequestContextWithClientLookup {
const context: GatewayRequestContextWithClientLookup = {
deps: params.deps,
// Keep cron reads live so config hot reload can swap cron/store state without rebuilding
// every handler closure that already holds this request context.
get cron() {
return params.runtimeState.cronState.cron;
},
get cronStorePath() {
return params.runtimeState.cronState.storePath;
},
getRuntimeConfig: params.getRuntimeConfig,
controlUiSessionPullRequests: params.runtimeState.controlUiSessionPullRequests,
sessionViewerPresence: params.runtimeState.sessionViewerPresence,
sessionCompanion: params.sessionCompanion,
sessionObserver: params.sessionObserver,
notifyPluginMetadataChanged: () =>
params.runtimeState.configReloader.notifyPluginMetadataChanged(),
getMcpAppSandboxPort: params.getMcpAppSandboxPort,
ensureSandboxHostPort: params.ensureSandboxHostPort,
resolveTerminalLaunchPolicy: params.resolveTerminalLaunchPolicy,
isTerminalEnabled: params.isTerminalEnabled,
execApprovalManager: params.execApprovalManager,
cancelRunBoundApprovals: params.cancelRunBoundApprovals
? (runId) => params.cancelRunBoundApprovals!(runId, context)
: undefined,
forwardPluginApprovalRequest: params.forwardPluginApprovalRequest,
pluginApprovalIosPushDelivery: params.pluginApprovalIosPushDelivery,
pluginApprovalManager: params.pluginApprovalManager,
systemAgentApprovalManager: params.systemAgentApprovalManager,
listSessionPendingApprovals: params.listSessionPendingApprovals,
loadGatewayModelCatalog: params.loadGatewayModelCatalog,
loadGatewayModelCatalogSnapshot: params.loadGatewayModelCatalogSnapshot,
getHealthCache: params.getHealthCache,
refreshHealthSnapshot: params.refreshHealthSnapshot,
logHealth: params.logHealth,
logGateway: params.logGateway,
incrementPresenceVersion: params.incrementPresenceVersion,
getHealthVersion: params.getHealthVersion,
broadcast: params.broadcast,
broadcastToConnIds: params.broadcastToConnIds,
nodeSendToSession: params.nodeSendToSession,
nodeSendToAllSubscribed: params.nodeSendToAllSubscribed,
nodeSubscribe: params.nodeSubscribe,
nodeUnsubscribe: params.nodeUnsubscribe,
nodeUnsubscribeAll: params.nodeUnsubscribeAll,
hasConnectedTalkNode: params.hasConnectedTalkNode,
isConnectionActive: (connId) =>
[...params.clients].some((client) => client.connId === connId && !client.invalidated),
hasExecApprovalClients: (excludeConnId?: string) => {
for (const gatewayClient of params.clients) {
if (excludeConnId && gatewayClient.connId === excludeConnId) {
continue;
}
if (canDeliverApprovals(gatewayClient, "exec")) {
return true;
}
}
return false;
},
getApprovalClientConnIds: (opts = {}) => {
const connIds = new Set<string>();
for (const gatewayClient of params.clients) {
if (!gatewayClient.connId) {
continue;
}
if (opts.excludeConnId && gatewayClient.connId === opts.excludeConnId) {
continue;
}
if (!canDeliverApprovals(gatewayClient, opts.approvalKind ?? "exec")) {
continue;
}
if (opts.filter && !opts.filter(gatewayClient, opts.record)) {
continue;
}
connIds.add(gatewayClient.connId);
}
return connIds;
},
getClientConnIds: (filter) => {
const connIds = new Set<string>();
for (const gatewayClient of params.clients) {
if (!gatewayClient.connId || gatewayClient.invalidated) {
continue;
}
if (filter && !filter(gatewayClient)) {
continue;
}
connIds.add(gatewayClient.connId);
}
return connIds;
},
hasConnectedClientsForDevice: (deviceId: string) => {
for (const gatewayClient of params.clients) {
if (gatewayClient.connect.device?.id === deviceId && !gatewayClient.invalidated) {
return true;
}
}
return false;
},
invalidateClientsForDevice: (deviceId: string, opts?: { role?: string; reason?: string }) => {
const reason = opts?.reason ?? "device-invalidated";
for (const gatewayClient of params.clients) {
if (gatewayClient.connect.device?.id !== deviceId) {
continue;
}
if (opts?.role && gatewayClient.connect.role !== opts.role) {
continue;
}
// Retire node-owned projections and pending invokes synchronously; socket
// close remains separate so already-buffered requests fail authorization.
if (gatewayClient.connId) {
params.nodeRegistry.invalidateConnectionForPairingChange(gatewayClient.connId, reason);
}
gatewayClient.invalidated = true;
gatewayClient.invalidatedReason = reason;
}
params.invalidateDeviceTransports?.(deviceId, opts);
},
disconnectClientsForDevice: (deviceId: string, opts?: { role?: string }) => {
for (const gatewayClient of params.clients) {
if (gatewayClient.connect.device?.id !== deviceId) {
continue;
}
if (opts?.role && gatewayClient.connect.role !== opts.role) {
continue;
}
// Mark before closing so any RPCs already pipelined in the WS buffer
// are rejected at the per-request dispatch check, regardless of
// whether socket.close() takes effect synchronously.
gatewayClient.invalidated = true;
gatewayClient.invalidatedReason ??= "device-removed";
try {
gatewayClient.socket.close(4001, "device removed");
} catch {
/* ignore */
}
}
params.disconnectDeviceTransports?.(deviceId, opts);
},
disconnectClientsUsingSharedGatewayAuth: () => {
disconnectAllSharedGatewayAuthClients(params.clients);
},
enforceSharedGatewayAuthGenerationForConfigWrite:
params.enforceSharedGatewayAuthGenerationForConfigWrite,
claimControlUiDeviceAuthMigration: params.claimControlUiDeviceAuthMigration,
releaseControlUiDeviceAuthMigrationClaim: params.releaseControlUiDeviceAuthMigrationClaim,
completeControlUiDeviceAuthMigration: params.completeControlUiDeviceAuthMigration,
nodeRegistry: params.nodeRegistry,
...(params.workerEnvironmentService
? { workerEnvironmentService: params.workerEnvironmentService }
: {}),
...(params.workerSessionPlacementService
? { workerSessionPlacementService: params.workerSessionPlacementService }
: {}),
...(params.workerPlacementDispatchService
? { workerPlacementDispatchService: params.workerPlacementDispatchService }
: {}),
terminalSessions: params.terminalSessions,
agentRunSeq: params.agentRunSeq,
chatAbortControllers: params.chatAbortControllers,
chatQueuedTurns: params.chatQueuedTurns,
chatRunState: params.chatRunState,
addChatRun: params.addChatRun,
removeChatRun: params.removeChatRun,
subscribeSessionEvents: params.subscribeSessionEvents,
unsubscribeSessionEvents: params.unsubscribeSessionEvents,
subscribeSessionMessageEvents: params.subscribeSessionMessageEvents,
unsubscribeSessionMessageEvents: params.unsubscribeSessionMessageEvents,
unsubscribeAllSessionEvents: (connId) => {
params.unsubscribeAllSessionEvents(connId);
// PR replace-sets share this websocket cleanup boundary with session events.
params.runtimeState.controlUiSessionPullRequests?.unsubscribe(connId);
params.runtimeState.sessionViewerPresence?.unsubscribe(connId);
},
getSessionEventSubscriberConnIds: params.getSessionEventSubscriberConnIds,
registerToolEventRecipient: params.registerToolEventRecipient,
dedupe: params.dedupe,
wizardSessions: params.wizardSessions,
systemAgentSessions: params.systemAgentSessions,
findRunningWizard: params.findRunningWizard,
purgeWizardSession: params.purgeWizardSession,
getRuntimeSnapshot: params.getRuntimeSnapshot,
getEventLoopHealth: params.getEventLoopHealth,
getConfigReloaderHotReloadStatus: () => params.runtimeState.configReloader.hotReloadStatus?.(),
startChannel: params.startChannel,
stopChannel: params.stopChannel,
markChannelLoggedOut: params.markChannelLoggedOut,
wizardRunner: params.wizardRunner,
channelWizardRunner: params.channelWizardRunner,
broadcastVoiceWakeChanged: params.broadcastVoiceWakeChanged,
broadcastVoiceWakeRoutingChanged: params.broadcastVoiceWakeRoutingChanged,
unavailableGatewayMethods: params.unavailableGatewayMethods,
};
return context;
}