mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
3a4f337802
* fix(gateway): pair local CLI before runtime-token readiness * docs(gateway): explain first-boot loopback CLI auth * fix(gateway): keep startup pairing result internal * chore(gateway): leave release note to release process
494 lines
19 KiB
TypeScript
494 lines
19 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
|
import { listLoadedChannelPlugins } from "../channels/plugins/registry-loaded.js";
|
|
import type { ChannelId } from "../channels/plugins/types.public.js";
|
|
import { createDefaultDeps } from "../cli/deps.js";
|
|
import { getRuntimeConfig } from "../config/io.js";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import { isTruthyEnvValue } from "../infra/env.js";
|
|
import type { createSubsystemLogger } from "../logging/subsystem.js";
|
|
import { runtimeForLogger } from "../logging/subsystem.js";
|
|
import { isGatewayDraining } from "../process/command-queue.js";
|
|
import type { RuntimeEnv } from "../runtime.js";
|
|
import { getActiveSecretsRuntimeConfigSnapshot } from "../secrets/runtime-state.js";
|
|
import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js";
|
|
import { resolveGatewayAuth } from "./auth.js";
|
|
import { isLoopbackHost } from "./net.js";
|
|
import { createNodeReapprovalCoordinator } from "./node-reapproval-coordinator.js";
|
|
import { resolveGatewayPluginConfig } from "./runtime-plugin-config.js";
|
|
import { resolveGatewayControlUiRootState } from "./server-control-ui-root.js";
|
|
import type { GatewayInstanceRuntime } from "./server-instance-runtime.types.js";
|
|
import type { GatewayServerLiveState } from "./server-live-state.js";
|
|
import type { GatewayRequestContext } from "./server-methods/types.js";
|
|
import { createGatewayRuntimeState } from "./server-runtime-state.js";
|
|
import type { SharedGatewaySessionGenerationState } from "./server-shared-auth-generation.js";
|
|
import type { prepareGatewayServerBootstrap } from "./server-startup-bootstrap.js";
|
|
import { createWizardSessionTracker } from "./server-wizard-sessions.js";
|
|
import { createGatewayEventLoopHealthMonitor } from "./server/event-loop-health.js";
|
|
import { resolveHookClientIpConfig } from "./server/hook-client-ip-config.js";
|
|
import { createReadinessChecker } from "./server/readiness.js";
|
|
import { loadGatewayTlsRuntime } from "./server/tls.js";
|
|
import { resolveSharedGatewaySessionGeneration } from "./server/ws-shared-generation.js";
|
|
|
|
type GatewayBootstrap = Awaited<ReturnType<typeof prepareGatewayServerBootstrap>>;
|
|
type GatewayLogger = ReturnType<typeof createSubsystemLogger>;
|
|
type ChannelRuntime = ReturnType<
|
|
(typeof import("../plugins/runtime/runtime-channel.js"))["createRuntimeChannel"]
|
|
>;
|
|
|
|
type AuthRateLimitConfig = Parameters<typeof createAuthRateLimiter>[0];
|
|
|
|
function createGatewayAuthRateLimiters(rateLimitConfig: AuthRateLimitConfig | undefined): {
|
|
rateLimiter: AuthRateLimiter;
|
|
browserRateLimiter: AuthRateLimiter;
|
|
} {
|
|
// Remote non-browser and HTTP attempts keep the normal loopback exemption.
|
|
const rateLimiter = createAuthRateLimiter(rateLimitConfig ?? {});
|
|
// Browser-origin WebSocket attempts are always throttled, including loopback.
|
|
const browserRateLimiter = createAuthRateLimiter({ ...rateLimitConfig, exemptLoopback: false });
|
|
return { rateLimiter, browserRateLimiter };
|
|
}
|
|
|
|
type GatewayStartupChannelPlugin = {
|
|
id: ChannelId;
|
|
gatewayMethods?: readonly string[];
|
|
gatewayMethodDescriptors?: readonly { name: string }[];
|
|
meta: { aliases?: readonly string[] };
|
|
};
|
|
|
|
function listGatewayStartupChannelPlugins(): GatewayStartupChannelPlugin[] {
|
|
return listLoadedChannelPlugins() as GatewayStartupChannelPlugin[];
|
|
}
|
|
|
|
export async function prepareGatewayRuntimeState(params: {
|
|
bootstrap: GatewayBootstrap;
|
|
port: number;
|
|
opts: GatewayBootstrap["opts"];
|
|
log: GatewayLogger;
|
|
logChannels: GatewayLogger;
|
|
logHooks: GatewayLogger;
|
|
logPlugins: GatewayLogger;
|
|
gatewayRuntime: ReturnType<typeof import("../logging/subsystem.js").runtimeForLogger>;
|
|
resolveChannelRuntime: () => Promise<ChannelRuntime>;
|
|
loadWorkerEnvironmentStartupModule: () => Promise<
|
|
typeof import("./server-worker-environment-startup.js")
|
|
>;
|
|
loadWorkerPlacementStartupModule: () => Promise<
|
|
typeof import("./server-worker-placement-startup.js")
|
|
>;
|
|
}) {
|
|
const {
|
|
bootstrap,
|
|
port,
|
|
opts,
|
|
log,
|
|
logChannels,
|
|
logHooks,
|
|
logPlugins,
|
|
gatewayRuntime,
|
|
resolveChannelRuntime: getChannelRuntime,
|
|
loadWorkerEnvironmentStartupModule,
|
|
loadWorkerPlacementStartupModule,
|
|
} = params;
|
|
const {
|
|
pluginBootstrap,
|
|
gatewayPluginConfigAtStart,
|
|
workerEnvironmentStartup,
|
|
startupTrace,
|
|
cfgAtStart,
|
|
resolvedStartupAuthOverride,
|
|
startupTailscaleOverride,
|
|
ambientAutostartSuppressedChannelIds,
|
|
minimalTestGateway,
|
|
} = bootstrap;
|
|
const pluginRuntime = {
|
|
registry: pluginBootstrap.pluginRegistry,
|
|
baseGatewayMethods: pluginBootstrap.baseGatewayMethods,
|
|
};
|
|
// Unconfigured clean installs get no service; durable rows still need list/status projection.
|
|
const hasConfiguredWorkerProfiles =
|
|
Object.keys(gatewayPluginConfigAtStart.cloudWorkers?.profiles ?? {}).length > 0;
|
|
const shouldStartWorkerEnvironmentService =
|
|
hasConfiguredWorkerProfiles ||
|
|
Boolean(workerEnvironmentStartup?.records.length) ||
|
|
Boolean(workerEnvironmentStartup?.hasNonlocalPlacementRecords);
|
|
const workerGatewayEndpoint = {
|
|
resolve: (() => undefined) as () => { host: "127.0.0.1" | "::1"; port: number } | undefined,
|
|
};
|
|
const workerEnvironmentRuntime =
|
|
workerEnvironmentStartup && shouldStartWorkerEnvironmentService
|
|
? await startupTrace.measure("worker-environments.runtime-imports", async () => {
|
|
const workerModule = await loadWorkerEnvironmentStartupModule();
|
|
return await workerModule.createGatewayWorkerEnvironmentRuntime({
|
|
getPluginRegistry: () => pluginRuntime.registry,
|
|
resolveWorkerGateway: () => workerGatewayEndpoint.resolve(),
|
|
startup: workerEnvironmentStartup,
|
|
log,
|
|
});
|
|
})
|
|
: {};
|
|
const { workerEnvironmentService, workerLiveEvents } = workerEnvironmentRuntime;
|
|
// Assigned once approval managers exist; placement dispatch must not run before then.
|
|
const workerDispatchAuthority = {
|
|
revoke: (_params: { sessionId: string; sessionKeys: readonly string[] }): void => {
|
|
throw new Error("Worker dispatch authority revocation is not ready");
|
|
},
|
|
};
|
|
const workerPlacementRuntime =
|
|
workerEnvironmentService && workerEnvironmentStartup
|
|
? await startupTrace.measure("worker-environments.placement-runtime", async () => {
|
|
const placementModule = await loadWorkerPlacementStartupModule();
|
|
return placementModule.createGatewayWorkerPlacementRuntime({
|
|
placements: workerEnvironmentStartup.placementStore,
|
|
environments: workerEnvironmentService,
|
|
admitNewPlacements: hasConfiguredWorkerProfiles,
|
|
revokeSessionAuthority: (request) => workerDispatchAuthority.revoke(request),
|
|
warn: (message) => log.warn(message),
|
|
});
|
|
})
|
|
: undefined;
|
|
// Without configured profiles, existing placements still reconcile but new dispatches stay off.
|
|
const workerPlacementControlAvailable = workerPlacementRuntime?.dispatchService;
|
|
const workerPlacementDispatchAvailable = hasConfiguredWorkerProfiles
|
|
? workerPlacementControlAvailable
|
|
: undefined;
|
|
const channelLogs = Object.fromEntries(
|
|
listGatewayStartupChannelPlugins().map((plugin) => [plugin.id, logChannels.child(plugin.id)]),
|
|
) as Record<ChannelId, ReturnType<typeof createSubsystemLogger>>;
|
|
const channelRuntimeEnvs = Object.fromEntries(
|
|
Object.entries(channelLogs).map(([id, logger]) => [id, runtimeForLogger(logger)]),
|
|
) as unknown as Record<ChannelId, RuntimeEnv>;
|
|
const listStartupChannelGatewayMethods = () => {
|
|
const methods: string[] = [];
|
|
for (const plugin of listGatewayStartupChannelPlugins()) {
|
|
methods.push(...(plugin.gatewayMethods ?? []));
|
|
for (const descriptor of plugin.gatewayMethodDescriptors ?? []) {
|
|
methods.push(descriptor.name);
|
|
}
|
|
}
|
|
return methods;
|
|
};
|
|
const listActiveGatewayMethods = (nextBaseGatewayMethods: string[]) =>
|
|
uniqueStrings([...nextBaseGatewayMethods, ...listStartupChannelGatewayMethods()]).filter(
|
|
(method) =>
|
|
(workerPlacementDispatchAvailable || method !== "sessions.dispatch") &&
|
|
(workerPlacementControlAvailable || method !== "sessions.reclaim"),
|
|
);
|
|
const runtimeConfig = await startupTrace.measure("runtime.config", async () => {
|
|
const { resolveGatewayRuntimeConfig } = await import("./server-runtime-config.js");
|
|
return resolveGatewayRuntimeConfig({
|
|
cfg: cfgAtStart,
|
|
port,
|
|
bind: opts.bind,
|
|
host: opts.host,
|
|
controlUiEnabled: opts.controlUiEnabled,
|
|
openAiChatCompletionsEnabled: opts.openAiChatCompletionsEnabled,
|
|
openResponsesEnabled: opts.openResponsesEnabled,
|
|
auth: resolvedStartupAuthOverride,
|
|
tailscale: startupTailscaleOverride,
|
|
});
|
|
});
|
|
const {
|
|
bindHost,
|
|
controlUiEnabled,
|
|
openAiChatCompletionsEnabled,
|
|
openAiChatCompletionsConfig,
|
|
openResponsesEnabled,
|
|
openResponsesConfig,
|
|
strictTransportSecurityHeader,
|
|
controlUiBasePath,
|
|
controlUiRoot: controlUiRootOverride,
|
|
resolvedAuth,
|
|
tailscaleConfig,
|
|
tailscaleMode,
|
|
} = runtimeConfig;
|
|
if (bootstrap.generatedStartupAuthToken && isLoopbackHost(bindHost)) {
|
|
const { ensureStartupLocalCliPairing } = await import("./startup-local-cli-pairing.js");
|
|
const pairingResult = await startupTrace.measure("runtime.local-cli-pairing", () =>
|
|
ensureStartupLocalCliPairing(),
|
|
);
|
|
if (pairingResult === "created") {
|
|
log.info("runtime-only gateway auth paired the local CLI device before readiness");
|
|
} else if (pairingResult === "unavailable") {
|
|
log.warn(
|
|
"runtime-only gateway auth could not prepare local CLI device credentials; configure gateway.auth.token or gateway.auth.password for CLI access",
|
|
);
|
|
}
|
|
}
|
|
const getResolvedAuth = () =>
|
|
resolveGatewayAuth({
|
|
authConfig:
|
|
getActiveSecretsRuntimeConfigSnapshot()?.config.gateway?.auth ??
|
|
getRuntimeConfig().gateway?.auth,
|
|
authOverride: resolvedStartupAuthOverride,
|
|
env: process.env,
|
|
tailscaleMode,
|
|
});
|
|
const resolveSharedGatewaySessionGenerationForConfig = (config: OpenClawConfig) =>
|
|
resolveSharedGatewaySessionGeneration(
|
|
resolveGatewayAuth({
|
|
authConfig: config.gateway?.auth,
|
|
authOverride: resolvedStartupAuthOverride,
|
|
env: process.env,
|
|
tailscaleMode,
|
|
}),
|
|
config.gateway?.trustedProxies,
|
|
);
|
|
const resolveCurrentSharedGatewaySessionGeneration = () =>
|
|
resolveSharedGatewaySessionGeneration(
|
|
getResolvedAuth(),
|
|
getRuntimeConfig().gateway?.trustedProxies,
|
|
);
|
|
const resolveSharedGatewaySessionGenerationForRuntimeSnapshot = () =>
|
|
resolveSharedGatewaySessionGeneration(
|
|
resolveGatewayAuth({
|
|
authConfig: getRuntimeConfig().gateway?.auth,
|
|
authOverride: resolvedStartupAuthOverride,
|
|
env: process.env,
|
|
tailscaleMode,
|
|
}),
|
|
getRuntimeConfig().gateway?.trustedProxies,
|
|
);
|
|
const sharedGatewaySessionGenerationState: SharedGatewaySessionGenerationState = {
|
|
current: resolveCurrentSharedGatewaySessionGeneration(),
|
|
required: null,
|
|
};
|
|
const preauthHandshakeTimeoutMs = undefined;
|
|
const initialHooksConfig = runtimeConfig.hooksConfig;
|
|
const initialHookClientIpConfig = resolveHookClientIpConfig(cfgAtStart);
|
|
|
|
// Create auth rate limiters used by connect/auth flows.
|
|
const rateLimitConfig = cfgAtStart.gateway?.auth?.rateLimit;
|
|
const { rateLimiter: authRateLimiter, browserRateLimiter: browserAuthRateLimiter } =
|
|
createGatewayAuthRateLimiters(rateLimitConfig);
|
|
const nodeReapprovalCoordinator = createNodeReapprovalCoordinator(rateLimitConfig);
|
|
|
|
const controlUiRootState = await startupTrace.measure("control-ui.root", () =>
|
|
resolveGatewayControlUiRootState({
|
|
controlUiRootOverride,
|
|
controlUiEnabled,
|
|
gatewayRuntime,
|
|
log,
|
|
}),
|
|
);
|
|
const { createTerminalLaunchPolicy } = await import("./terminal/launch.js");
|
|
const terminalLaunchPolicy = createTerminalLaunchPolicy(cfgAtStart);
|
|
|
|
const { runDefaultChannelSetupWizard, runDefaultSetupWizard } =
|
|
await import("./server-methods/wizard.js");
|
|
const wizardRunner = opts.wizardRunner ?? runDefaultSetupWizard;
|
|
const channelWizardRunner = opts.channelWizardRunner ?? runDefaultChannelSetupWizard;
|
|
const { wizardSessions, findRunningWizard, purgeWizardSession } = createWizardSessionTracker();
|
|
const systemAgentSessions: GatewayRequestContext["systemAgentSessions"] = new Map();
|
|
|
|
const deps = createDefaultDeps();
|
|
const runtimeStateRef: { current: GatewayServerLiveState | null } = { current: null };
|
|
const cronStartState = { handled: false };
|
|
const gatewayTls = await startupTrace.measure("tls.runtime", () =>
|
|
loadGatewayTlsRuntime(cfgAtStart.gateway?.tls, log.child("tls")),
|
|
);
|
|
if (cfgAtStart.gateway?.tls?.enabled && !gatewayTls.enabled) {
|
|
throw new Error(gatewayTls.error ?? "gateway tls: failed to enable");
|
|
}
|
|
const serverStartedAt = Date.now();
|
|
const readinessEventLoopHealth = createGatewayEventLoopHealthMonitor();
|
|
const startupState = {
|
|
sidecarsReady: minimalTestGateway,
|
|
pendingReason: "startup-sidecars",
|
|
dispatchReady: false,
|
|
};
|
|
let releaseStartupAccountStarts = () => {};
|
|
const startupAccountStartsReady = new Promise<void>((resolve) => {
|
|
releaseStartupAccountStarts = resolve;
|
|
});
|
|
const gatewayInstanceRuntimeRef: { current: GatewayInstanceRuntime | undefined } = {
|
|
current: undefined,
|
|
};
|
|
// Internal principals belong to this server generation and become usable only after bind.
|
|
// Closing flips this first so delayed recovery/channel work cannot enter a retired context.
|
|
|
|
const { createChannelManager } = await import("./server-channels.js");
|
|
const channelManager = createChannelManager({
|
|
getRuntimeConfig: () => {
|
|
const runtimeConfigLocal = getRuntimeConfig();
|
|
return resolveGatewayPluginConfig({
|
|
config: runtimeConfigLocal,
|
|
});
|
|
},
|
|
channelLogs,
|
|
channelRuntimeEnvs,
|
|
resolveChannelRuntime: getChannelRuntime,
|
|
getPluginHttpRouteRegistry: () => pluginRuntime.registry,
|
|
startupTrace,
|
|
deferStartupAccountStartsUntil: startupAccountStartsReady,
|
|
getNativeApprovalRuntime: () => gatewayInstanceRuntimeRef.current?.nativeApprovals,
|
|
ambientAutostartSuppressedChannelIds,
|
|
});
|
|
channelManager.setAutostartSuppression(opts.channelAutostartSuppression ?? null);
|
|
const sidecarStartup = opts.sidecarStartup ?? "start";
|
|
const isGatewayStartupPending = () => !startupState.sidecarsReady && sidecarStartup === "start";
|
|
const getReadiness = createReadinessChecker({
|
|
channelManager,
|
|
startedAt: serverStartedAt,
|
|
getStartupPending: isGatewayStartupPending,
|
|
getStartupPendingReason: () => startupState.pendingReason,
|
|
getGatewayDraining: isGatewayDraining,
|
|
getEventLoopHealth: readinessEventLoopHealth.snapshot,
|
|
shouldSkipChannelReadiness: () =>
|
|
isTruthyEnvValue(process.env.OPENCLAW_SKIP_CHANNELS) ||
|
|
isTruthyEnvValue(process.env.OPENCLAW_SKIP_PROVIDERS),
|
|
});
|
|
log.info("starting HTTP server...");
|
|
const pluginGatewayContext: { current: GatewayRequestContext | undefined } = {
|
|
current: undefined,
|
|
};
|
|
const watchNodeRequestHandler: {
|
|
current?: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
|
} = {};
|
|
const {
|
|
releasePluginRouteRegistry,
|
|
httpServer,
|
|
httpServers,
|
|
httpBindHosts,
|
|
startListening,
|
|
wss,
|
|
preauthConnectionBudget,
|
|
clients,
|
|
broadcast,
|
|
broadcastToConnIds,
|
|
broadcastPluginEvent,
|
|
getBufferedAmount,
|
|
agentRunSeq,
|
|
dedupe,
|
|
chatRunState,
|
|
addChatRun,
|
|
removeChatRun,
|
|
chatAbortControllers,
|
|
chatQueuedTurns,
|
|
toolEventRecipients,
|
|
sessionEventSubscribers,
|
|
sessionMessageSubscribers,
|
|
getWorkerIngressEndpoint,
|
|
getMcpAppSandboxPort,
|
|
ensureSandboxHostPort,
|
|
} = await startupTrace.measure("runtime.state", () =>
|
|
createGatewayRuntimeState({
|
|
cfg: cfgAtStart,
|
|
getRuntimeConfig,
|
|
bindHost,
|
|
port,
|
|
controlUiEnabled,
|
|
controlUiBasePath,
|
|
controlUiRoot: controlUiRootState,
|
|
openAiChatCompletionsEnabled,
|
|
openAiChatCompletionsConfig,
|
|
openResponsesEnabled,
|
|
openResponsesConfig,
|
|
strictTransportSecurityHeader,
|
|
resolvedAuth,
|
|
rateLimiter: authRateLimiter,
|
|
isTerminalEnabled: terminalLaunchPolicy.isEnabled,
|
|
gatewayTls,
|
|
getResolvedAuth,
|
|
hooksConfig: () => runtimeStateRef.current?.hooksConfig ?? initialHooksConfig,
|
|
getHookClientIpConfig: () =>
|
|
runtimeStateRef.current?.hookClientIpConfig ?? initialHookClientIpConfig,
|
|
pluginRegistry: pluginRuntime.registry,
|
|
getPluginRouteRegistry: () => pluginRuntime.registry,
|
|
getGatewayRequestContext: () => pluginGatewayContext.current,
|
|
pinChannelRegistry: !minimalTestGateway,
|
|
deps,
|
|
log,
|
|
logHooks,
|
|
logPlugins,
|
|
getReadiness,
|
|
handleWatchNodeRequest: async (req, res) =>
|
|
(await watchNodeRequestHandler.current?.(req, res)) ?? false,
|
|
workerIngressEnabled: Boolean(workerEnvironmentService),
|
|
}),
|
|
);
|
|
|
|
return {
|
|
...bootstrap,
|
|
pluginRuntime,
|
|
hasConfiguredWorkerProfiles,
|
|
workerEnvironmentService,
|
|
workerLiveEvents,
|
|
workerDispatchAuthority,
|
|
workerPlacementRuntime,
|
|
workerPlacementControlAvailable,
|
|
workerPlacementDispatchAvailable,
|
|
channelLogs,
|
|
channelRuntimeEnvs,
|
|
listStartupChannelGatewayMethods,
|
|
listActiveGatewayMethods,
|
|
bindHost,
|
|
controlUiEnabled,
|
|
openAiChatCompletionsEnabled,
|
|
openAiChatCompletionsConfig,
|
|
openResponsesEnabled,
|
|
openResponsesConfig,
|
|
strictTransportSecurityHeader,
|
|
controlUiBasePath,
|
|
resolvedAuth,
|
|
tailscaleConfig,
|
|
tailscaleMode,
|
|
getResolvedAuth,
|
|
resolveSharedGatewaySessionGenerationForConfig,
|
|
resolveSharedGatewaySessionGenerationForRuntimeSnapshot,
|
|
sharedGatewaySessionGenerationState,
|
|
preauthHandshakeTimeoutMs,
|
|
initialHooksConfig,
|
|
initialHookClientIpConfig,
|
|
authRateLimiter,
|
|
browserAuthRateLimiter,
|
|
nodeReapprovalCoordinator,
|
|
terminalLaunchPolicy,
|
|
wizardRunner,
|
|
channelWizardRunner,
|
|
wizardSessions,
|
|
findRunningWizard,
|
|
purgeWizardSession,
|
|
systemAgentSessions,
|
|
deps,
|
|
runtimeStateRef,
|
|
cronStartState,
|
|
gatewayTls,
|
|
readinessEventLoopHealth,
|
|
startupState,
|
|
releaseStartupAccountStarts,
|
|
gatewayInstanceRuntimeRef,
|
|
channelManager,
|
|
sidecarStartup,
|
|
isGatewayStartupPending,
|
|
pluginGatewayContext,
|
|
watchNodeRequestHandler,
|
|
releasePluginRouteRegistry,
|
|
httpServer,
|
|
httpServers,
|
|
httpBindHosts,
|
|
startListening,
|
|
wss,
|
|
preauthConnectionBudget,
|
|
clients,
|
|
broadcast,
|
|
broadcastToConnIds,
|
|
broadcastPluginEvent,
|
|
getBufferedAmount,
|
|
agentRunSeq,
|
|
dedupe,
|
|
chatRunState,
|
|
addChatRun,
|
|
removeChatRun,
|
|
chatAbortControllers,
|
|
chatQueuedTurns,
|
|
toolEventRecipients,
|
|
sessionEventSubscribers,
|
|
sessionMessageSubscribers,
|
|
getWorkerIngressEndpoint,
|
|
getMcpAppSandboxPort,
|
|
ensureSandboxHostPort,
|
|
workerGatewayEndpoint,
|
|
};
|
|
}
|