From b02cca4e00cdda9df4dc5bbeabdc3efddbf58d2f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 22:47:56 -0700 Subject: [PATCH] fix(gateway): trim startup imports --- CHANGELOG.md | 2 + src/cli/deps.ts | 84 ++++-- src/cli/outbound-send-mapping.ts | 56 +++- src/gateway/http-auth-utils.ts | 253 +++++++++++++++++ src/gateway/http-utils.ts | 266 ++---------------- src/gateway/server-http.ts | 4 +- src/gateway/server/http-auth.ts | 2 +- .../server/plugin-route-runtime-scopes.ts | 2 +- 8 files changed, 395 insertions(+), 274 deletions(-) create mode 100644 src/gateway/http-auth-utils.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d838a2ae495..953f2bfa018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ Docs: https://docs.openclaw.ai - Memory/doctor: treat the specific `gateway timeout after ...` gateway memory probe result as inconclusive instead of reporting embeddings not ready, while preserving warnings for explicit failures. Fixes #44426; carries forward #46576 with the Greptile review feedback applied. Thanks Cengiz (@ghost). - Gateway/memory: defer QMD startup for implicit non-default agents and scope memory runtime loading to the selected memory slot so Gateway boot and first memory recall avoid broad plugin runtime fanout. Thanks @vincentkoc. - Gateway/startup: keep core request handlers, setup wizard, and channel runtime helpers off the boot path until the first matching request, wizard run, or channel start, reducing no-plugin Gateway ready RSS and avoidable startup imports. Thanks @vincentkoc. +- Gateway/startup: keep CLI outbound channel send dependencies as lazy request-time senders so Gateway boot no longer imports channel plugin registration just to construct default deps. Thanks @vincentkoc. +- Gateway/startup: split lightweight HTTP auth helpers away from model-override helpers so Gateway bind no longer imports model catalog selection while wiring base HTTP routes. Thanks @vincentkoc. - CLI/Gateway: use a parse-only config snapshot for plain `gateway status` reads and reuse same-path service config context so status no longer spends tens of seconds in full config validation before printing. Thanks @vincentkoc. - Lobster/Gateway: memoize repeated Ajv schema compilation before loading the embedded Lobster runtime so scheduled workflows and `llm.invoke` loops stop growing gateway heap on content-identical schemas. Fixes #71148. Thanks @cmi525, @vsolaz, and @vincentkoc. - Codex harness: normalize cached input tokens before session/context accounting so prompt cache reads are not double-counted in `/status`, `session_status`, or persisted `sessionEntry.totalTokens`. Fixes #69298. Thanks @richardmqq. diff --git a/src/cli/deps.ts b/src/cli/deps.ts index f9bdb568814..8f0ece675b9 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -1,9 +1,10 @@ -import { listChannelPlugins } from "../channels/plugins/index.js"; import type { OutboundSendDeps } from "../infra/outbound/send-deps.js"; import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import type { CliDeps } from "./deps.types.js"; -import { createOutboundSendDepsFromCliSource } from "./outbound-send-mapping.js"; -import { createChannelOutboundRuntimeSend } from "./send-runtime/channel-outbound-send.js"; +import { + CLI_OUTBOUND_SEND_FACTORY, + createOutboundSendDepsFromCliSource, +} from "./outbound-send-mapping.js"; /** * Lazy-loaded per-channel send functions, keyed by channel ID. @@ -17,6 +18,35 @@ type RuntimeSendModule = { runtimeSend: RuntimeSend; }; +const NON_CHANNEL_DEP_KEYS = new Set([ + "__proto__", + "constructor", + "cron", + "cronConfig", + "cronEnabled", + "defaultAgentId", + "enqueueSystemEvent", + "getQueueSize", + "hasOwnProperty", + "inspect", + "log", + "migrateOrphanedSessionKeys", + "nowMs", + "onEvent", + "requestHeartbeatNow", + "resolveSessionStorePath", + "runHeartbeatOnce", + "runIsolatedAgentJob", + "runtime", + "sendCronFailureAlert", + "sessionStorePath", + "storePath", + "then", + "toJSON", + "toString", + "valueOf", +]); + // Per-channel module caches for lazy loading. const senderCache = new Map>(); @@ -41,22 +71,40 @@ function createLazySender( } export function createDefaultDeps(): CliDeps { - // Keep the default dependency barrel limited to lazy senders so callers that - // only need outbound deps do not pull channel runtime boundaries on import. const deps: CliDeps = {}; - for (const plugin of listChannelPlugins()) { - deps[plugin.id] = createLazySender( - plugin.id, - async () => - ({ - runtimeSend: createChannelOutboundRuntimeSend({ - channelId: plugin.id, - unavailableMessage: `${plugin.meta.label ?? plugin.id} outbound adapter is unavailable.`, - }) as RuntimeSend, - }) satisfies RuntimeSendModule, - ); - } - return deps; + const resolveSender = (channelId: string) => + createLazySender(channelId, async () => { + const { createChannelOutboundRuntimeSend } = + await import("./send-runtime/channel-outbound-send.js"); + return { + runtimeSend: createChannelOutboundRuntimeSend({ + channelId: channelId as import("../channels/plugins/types.public.js").ChannelId, + unavailableMessage: `${channelId} outbound adapter is unavailable.`, + }) as RuntimeSend, + } satisfies RuntimeSendModule; + }); + + Object.defineProperty(deps, CLI_OUTBOUND_SEND_FACTORY, { + configurable: false, + enumerable: false, + value: resolveSender, + writable: false, + }); + + return new Proxy(deps, { + get(target, property, receiver) { + if (typeof property !== "string") { + return Reflect.get(target, property, receiver); + } + const existing = Reflect.get(target, property, receiver); + if (existing !== undefined || NON_CHANNEL_DEP_KEYS.has(property)) { + return existing; + } + const sender = resolveSender(property); + Reflect.set(target, property, sender, receiver); + return sender; + }, + }); } export function createOutboundSendDeps(deps: CliDeps): OutboundSendDeps { diff --git a/src/cli/outbound-send-mapping.ts b/src/cli/outbound-send-mapping.ts index a5f8dd866cd..2464452c126 100644 --- a/src/cli/outbound-send-mapping.ts +++ b/src/cli/outbound-send-mapping.ts @@ -1,4 +1,3 @@ -import { normalizeAnyChannelId } from "../channels/registry.js"; import { resolveLegacyOutboundSendDepKeys, type OutboundSendDeps, @@ -9,7 +8,15 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; * CLI-internal send function sources, keyed by channel ID. * Each value is a lazily-loaded send function for that channel. */ -export type CliOutboundSendSource = { [channelId: string]: unknown }; +export const CLI_OUTBOUND_SEND_FACTORY: unique symbol = Symbol.for( + "openclaw.cliOutboundSendFactory", +) as never; + +export type CliOutboundSendFactory = (channelId: string) => unknown; +export type CliOutboundSendSource = { + [channelId: string]: unknown; + [CLI_OUTBOUND_SEND_FACTORY]?: CliOutboundSendFactory; +}; function normalizeLegacyChannelStem(raw: string): string { const normalized = normalizeLowercaseStringOrEmpty( @@ -27,7 +34,16 @@ function resolveChannelIdFromLegacySourceKey(key: string): string | undefined { return undefined; } const normalizedStem = normalizeLegacyChannelStem(match[1] ?? ""); - return normalizeAnyChannelId(normalizedStem) ?? (normalizedStem || undefined); + return normalizedStem || undefined; +} + +function resolveChannelIdFromLegacyOutboundKey(key: string): string | undefined { + const match = key.match(/^send(.+)$/); + if (!match) { + return undefined; + } + const normalizedStem = normalizeLegacyChannelStem(match[1] ?? ""); + return normalizedStem || undefined; } /** @@ -36,6 +52,7 @@ function resolveChannelIdFromLegacySourceKey(key: string): string | undefined { */ export function createOutboundSendDepsFromCliSource(deps: CliOutboundSendSource): OutboundSendDeps { const outbound: OutboundSendDeps = { ...deps }; + const sendFactory = deps[CLI_OUTBOUND_SEND_FACTORY]; for (const legacySourceKey of Object.keys(deps)) { const channelId = resolveChannelIdFromLegacySourceKey(legacySourceKey); @@ -60,5 +77,36 @@ export function createOutboundSendDepsFromCliSource(deps: CliOutboundSendSource) } } - return outbound; + if (!sendFactory) { + return outbound; + } + + const resolveFactoryValue = (key: string): unknown => { + const channelId = + outbound[key] === undefined ? (resolveChannelIdFromLegacyOutboundKey(key) ?? key) : key; + if (!channelId || channelId === "then" || channelId === "toJSON") { + return undefined; + } + const value = sendFactory(channelId); + if (value !== undefined) { + outbound[channelId] = value; + for (const legacyDepKey of resolveLegacyOutboundSendDepKeys(channelId)) { + outbound[legacyDepKey] ??= value; + } + } + return value; + }; + + return new Proxy(outbound, { + get(target, property, receiver) { + if (typeof property !== "string") { + return Reflect.get(target, property, receiver); + } + const existing = Reflect.get(target, property, receiver); + if (existing !== undefined) { + return existing; + } + return resolveFactoryValue(property); + }, + }); } diff --git a/src/gateway/http-auth-utils.ts b/src/gateway/http-auth-utils.ts new file mode 100644 index 00000000000..9ad4b513ead --- /dev/null +++ b/src/gateway/http-auth-utils.ts @@ -0,0 +1,253 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { loadConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../shared/string-coerce.js"; +import type { AuthRateLimiter } from "./auth-rate-limit.js"; +import { + authorizeHttpGatewayConnect, + type GatewayAuthResult, + type ResolvedGatewayAuth, +} from "./auth.js"; +import { sendGatewayAuthFailure, sendJson } from "./http-common.js"; +import { ADMIN_SCOPE, CLI_DEFAULT_OPERATOR_SCOPES } from "./method-scopes.js"; +import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; + +export function getHeader(req: IncomingMessage, name: string): string | undefined { + const raw = req.headers[normalizeLowercaseStringOrEmpty(name)]; + if (typeof raw === "string") { + return raw; + } + if (Array.isArray(raw)) { + return raw[0]; + } + return undefined; +} + +export function getBearerToken(req: IncomingMessage): string | undefined { + const raw = normalizeOptionalString(getHeader(req, "authorization")) ?? ""; + if (!normalizeLowercaseStringOrEmpty(raw).startsWith("bearer ")) { + return undefined; + } + return normalizeOptionalString(raw.slice(7)); +} + +type SharedSecretGatewayAuth = Pick; +export type AuthorizedGatewayHttpRequest = { + authMethod?: GatewayAuthResult["method"]; + trustDeclaredOperatorScopes: boolean; +}; + +export type GatewayHttpRequestAuthCheckResult = + | { + ok: true; + requestAuth: AuthorizedGatewayHttpRequest; + } + | { + ok: false; + authResult: GatewayAuthResult; + }; + +export function resolveHttpBrowserOriginPolicy( + req: IncomingMessage, + cfg = loadConfig(), +): NonNullable[0]["browserOriginPolicy"]> { + return { + requestHost: getHeader(req, "host"), + origin: getHeader(req, "origin"), + allowedOrigins: cfg.gateway?.controlUi?.allowedOrigins, + allowHostHeaderOriginFallback: + cfg.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true, + }; +} + +function usesSharedSecretHttpAuth(auth: SharedSecretGatewayAuth | undefined): boolean { + return auth?.mode === "token" || auth?.mode === "password"; +} + +function usesSharedSecretGatewayMethod(method: GatewayAuthResult["method"] | undefined): boolean { + return method === "token" || method === "password"; +} + +function shouldTrustDeclaredHttpOperatorScopes( + req: IncomingMessage, + authOrRequest: + | SharedSecretGatewayAuth + | Pick + | undefined, +): boolean { + if (authOrRequest && "trustDeclaredOperatorScopes" in authOrRequest) { + return authOrRequest.trustDeclaredOperatorScopes; + } + return !isGatewayBearerHttpRequest(req, authOrRequest); +} + +export async function authorizeGatewayHttpRequestOrReply(params: { + req: IncomingMessage; + res: ServerResponse; + auth: ResolvedGatewayAuth; + trustedProxies?: string[]; + allowRealIpFallback?: boolean; + rateLimiter?: AuthRateLimiter; +}): Promise { + const result = await checkGatewayHttpRequestAuth(params); + if (!result.ok) { + sendGatewayAuthFailure(params.res, result.authResult); + return null; + } + return result.requestAuth; +} + +export async function checkGatewayHttpRequestAuth(params: { + req: IncomingMessage; + auth: ResolvedGatewayAuth; + trustedProxies?: string[]; + allowRealIpFallback?: boolean; + rateLimiter?: AuthRateLimiter; + cfg?: OpenClawConfig; +}): Promise { + const token = getBearerToken(params.req); + const browserOriginPolicy = resolveHttpBrowserOriginPolicy(params.req, params.cfg); + const authResult = await authorizeHttpGatewayConnect({ + auth: params.auth, + connectAuth: token ? { token, password: token } : null, + req: params.req, + trustedProxies: params.trustedProxies, + allowRealIpFallback: params.allowRealIpFallback, + rateLimiter: params.rateLimiter, + browserOriginPolicy, + }); + if (!authResult.ok) { + return { + ok: false, + authResult, + }; + } + return { + ok: true, + requestAuth: { + authMethod: authResult.method, + // Shared-secret bearer auth proves possession of the gateway secret, but it + // does not prove a narrower per-request operator identity. HTTP endpoints + // must opt in explicitly if they want to treat that shared-secret path as a + // full trusted-operator surface. + trustDeclaredOperatorScopes: !usesSharedSecretGatewayMethod(authResult.method), + }, + }; +} + +export async function authorizeScopedGatewayHttpRequestOrReply(params: { + req: IncomingMessage; + res: ServerResponse; + auth: ResolvedGatewayAuth; + trustedProxies?: string[]; + allowRealIpFallback?: boolean; + rateLimiter?: AuthRateLimiter; + operatorMethod: string; + resolveOperatorScopes: ( + req: IncomingMessage, + requestAuth: AuthorizedGatewayHttpRequest, + ) => string[]; +}): Promise<{ cfg: OpenClawConfig; requestAuth: AuthorizedGatewayHttpRequest } | null> { + const cfg = loadConfig(); + const requestAuth = await authorizeGatewayHttpRequestOrReply({ + req: params.req, + res: params.res, + auth: params.auth, + trustedProxies: params.trustedProxies ?? cfg.gateway?.trustedProxies, + allowRealIpFallback: params.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback, + rateLimiter: params.rateLimiter, + }); + if (!requestAuth) { + return null; + } + + const requestedScopes = params.resolveOperatorScopes(params.req, requestAuth); + const scopeAuth = authorizeOperatorScopesForMethod(params.operatorMethod, requestedScopes); + if (!scopeAuth.allowed) { + sendJson(params.res, 403, { + ok: false, + error: { + type: "forbidden", + message: `missing scope: ${scopeAuth.missingScope}`, + }, + }); + return null; + } + + return { cfg, requestAuth }; +} + +export function isGatewayBearerHttpRequest( + req: IncomingMessage, + auth?: SharedSecretGatewayAuth, +): boolean { + return usesSharedSecretHttpAuth(auth) && Boolean(getBearerToken(req)); +} + +export function resolveTrustedHttpOperatorScopes( + req: IncomingMessage, + authOrRequest?: + | SharedSecretGatewayAuth + | Pick, +): string[] { + if (!shouldTrustDeclaredHttpOperatorScopes(req, authOrRequest)) { + // Gateway bearer auth only proves possession of the shared secret. Do not + // let HTTP clients self-assert operator scopes through request headers. + return []; + } + + const headerValue = getHeader(req, "x-openclaw-scopes"); + if (headerValue === undefined) { + // No scope header present - trusted clients without an explicit header + // get the default operator scopes (matching pre-#57783 behavior). + return [...CLI_DEFAULT_OPERATOR_SCOPES]; + } + const raw = headerValue.trim(); + if (!raw) { + return []; + } + return raw + .split(",") + .map((scope) => scope.trim()) + .filter((scope) => scope.length > 0); +} + +export function resolveOpenAiCompatibleHttpOperatorScopes( + req: IncomingMessage, + requestAuth: AuthorizedGatewayHttpRequest, +): string[] { + if (usesSharedSecretGatewayMethod(requestAuth.authMethod)) { + // Shared-secret HTTP bearer auth is a documented trusted-operator surface + // for the compat APIs and direct /tools/invoke. This is designed-as-is: + // token/password auth proves possession of the gateway operator secret, not + // a narrower per-request scope identity, so restore the normal defaults. + return [...CLI_DEFAULT_OPERATOR_SCOPES]; + } + return resolveTrustedHttpOperatorScopes(req, requestAuth); +} + +export function resolveHttpSenderIsOwner( + req: IncomingMessage, + authOrRequest?: + | SharedSecretGatewayAuth + | Pick, +): boolean { + return resolveTrustedHttpOperatorScopes(req, authOrRequest).includes(ADMIN_SCOPE); +} + +export function resolveOpenAiCompatibleHttpSenderIsOwner( + req: IncomingMessage, + requestAuth: AuthorizedGatewayHttpRequest, +): boolean { + if (usesSharedSecretGatewayMethod(requestAuth.authMethod)) { + // Shared-secret HTTP bearer auth also carries owner semantics on the compat + // APIs and direct /tools/invoke. This is intentional: there is no separate + // per-request owner primitive on that shared-secret path, so owner-only + // tool policy follows the documented trusted-operator contract. + return true; + } + return resolveHttpSenderIsOwner(req, requestAuth); +} diff --git a/src/gateway/http-utils.ts b/src/gateway/http-utils.ts index 9efa4db6fda..9319cab56b6 100644 --- a/src/gateway/http-utils.ts +++ b/src/gateway/http-utils.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import type { IncomingMessage, ServerResponse } from "node:http"; +import type { IncomingMessage } from "node:http"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { buildAllowedModelSet, @@ -8,264 +8,34 @@ import { resolveDefaultModelForAgent, } from "../agents/model-selection.js"; import { loadConfig } from "../config/config.js"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "../shared/string-coerce.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; -import type { AuthRateLimiter } from "./auth-rate-limit.js"; -import { - authorizeHttpGatewayConnect, - type GatewayAuthResult, - type ResolvedGatewayAuth, -} from "./auth.js"; -import { sendGatewayAuthFailure, sendJson } from "./http-common.js"; -import { ADMIN_SCOPE, CLI_DEFAULT_OPERATOR_SCOPES } from "./method-scopes.js"; -import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; +import { getHeader } from "./http-auth-utils.js"; import { loadGatewayModelCatalog } from "./server-model-catalog.js"; +export { + authorizeGatewayHttpRequestOrReply, + authorizeScopedGatewayHttpRequestOrReply, + checkGatewayHttpRequestAuth, + getBearerToken, + getHeader, + isGatewayBearerHttpRequest, + resolveHttpBrowserOriginPolicy, + resolveHttpSenderIsOwner, + resolveOpenAiCompatibleHttpOperatorScopes, + resolveOpenAiCompatibleHttpSenderIsOwner, + resolveTrustedHttpOperatorScopes, + type AuthorizedGatewayHttpRequest, + type GatewayHttpRequestAuthCheckResult, +} from "./http-auth-utils.js"; + export const OPENCLAW_MODEL_ID = "openclaw"; export const OPENCLAW_DEFAULT_MODEL_ID = "openclaw/default"; -export function getHeader(req: IncomingMessage, name: string): string | undefined { - const raw = req.headers[normalizeLowercaseStringOrEmpty(name)]; - if (typeof raw === "string") { - return raw; - } - if (Array.isArray(raw)) { - return raw[0]; - } - return undefined; -} - -export function getBearerToken(req: IncomingMessage): string | undefined { - const raw = normalizeOptionalString(getHeader(req, "authorization")) ?? ""; - if (!normalizeLowercaseStringOrEmpty(raw).startsWith("bearer ")) { - return undefined; - } - return normalizeOptionalString(raw.slice(7)); -} - -type SharedSecretGatewayAuth = Pick; -export type AuthorizedGatewayHttpRequest = { - authMethod?: GatewayAuthResult["method"]; - trustDeclaredOperatorScopes: boolean; -}; - -export type GatewayHttpRequestAuthCheckResult = - | { - ok: true; - requestAuth: AuthorizedGatewayHttpRequest; - } - | { - ok: false; - authResult: GatewayAuthResult; - }; - -export function resolveHttpBrowserOriginPolicy( - req: IncomingMessage, - cfg = loadConfig(), -): NonNullable[0]["browserOriginPolicy"]> { - return { - requestHost: getHeader(req, "host"), - origin: getHeader(req, "origin"), - allowedOrigins: cfg.gateway?.controlUi?.allowedOrigins, - allowHostHeaderOriginFallback: - cfg.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true, - }; -} - -function usesSharedSecretHttpAuth(auth: SharedSecretGatewayAuth | undefined): boolean { - return auth?.mode === "token" || auth?.mode === "password"; -} - -function usesSharedSecretGatewayMethod(method: GatewayAuthResult["method"] | undefined): boolean { - return method === "token" || method === "password"; -} - -function shouldTrustDeclaredHttpOperatorScopes( - req: IncomingMessage, - authOrRequest: - | SharedSecretGatewayAuth - | Pick - | undefined, -): boolean { - if (authOrRequest && "trustDeclaredOperatorScopes" in authOrRequest) { - return authOrRequest.trustDeclaredOperatorScopes; - } - return !isGatewayBearerHttpRequest(req, authOrRequest); -} - -export async function authorizeGatewayHttpRequestOrReply(params: { - req: IncomingMessage; - res: ServerResponse; - auth: ResolvedGatewayAuth; - trustedProxies?: string[]; - allowRealIpFallback?: boolean; - rateLimiter?: AuthRateLimiter; -}): Promise { - const result = await checkGatewayHttpRequestAuth(params); - if (!result.ok) { - sendGatewayAuthFailure(params.res, result.authResult); - return null; - } - return result.requestAuth; -} - -export async function checkGatewayHttpRequestAuth(params: { - req: IncomingMessage; - auth: ResolvedGatewayAuth; - trustedProxies?: string[]; - allowRealIpFallback?: boolean; - rateLimiter?: AuthRateLimiter; - cfg?: OpenClawConfig; -}): Promise { - const token = getBearerToken(params.req); - const browserOriginPolicy = resolveHttpBrowserOriginPolicy(params.req, params.cfg); - const authResult = await authorizeHttpGatewayConnect({ - auth: params.auth, - connectAuth: token ? { token, password: token } : null, - req: params.req, - trustedProxies: params.trustedProxies, - allowRealIpFallback: params.allowRealIpFallback, - rateLimiter: params.rateLimiter, - browserOriginPolicy, - }); - if (!authResult.ok) { - return { - ok: false, - authResult, - }; - } - return { - ok: true, - requestAuth: { - authMethod: authResult.method, - // Shared-secret bearer auth proves possession of the gateway secret, but it - // does not prove a narrower per-request operator identity. HTTP endpoints - // must opt in explicitly if they want to treat that shared-secret path as a - // full trusted-operator surface. - trustDeclaredOperatorScopes: !usesSharedSecretGatewayMethod(authResult.method), - }, - }; -} - -export async function authorizeScopedGatewayHttpRequestOrReply(params: { - req: IncomingMessage; - res: ServerResponse; - auth: ResolvedGatewayAuth; - trustedProxies?: string[]; - allowRealIpFallback?: boolean; - rateLimiter?: AuthRateLimiter; - operatorMethod: string; - resolveOperatorScopes: ( - req: IncomingMessage, - requestAuth: AuthorizedGatewayHttpRequest, - ) => string[]; -}): Promise<{ cfg: OpenClawConfig; requestAuth: AuthorizedGatewayHttpRequest } | null> { - const cfg = loadConfig(); - const requestAuth = await authorizeGatewayHttpRequestOrReply({ - req: params.req, - res: params.res, - auth: params.auth, - trustedProxies: params.trustedProxies ?? cfg.gateway?.trustedProxies, - allowRealIpFallback: params.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback, - rateLimiter: params.rateLimiter, - }); - if (!requestAuth) { - return null; - } - - const requestedScopes = params.resolveOperatorScopes(params.req, requestAuth); - const scopeAuth = authorizeOperatorScopesForMethod(params.operatorMethod, requestedScopes); - if (!scopeAuth.allowed) { - sendJson(params.res, 403, { - ok: false, - error: { - type: "forbidden", - message: `missing scope: ${scopeAuth.missingScope}`, - }, - }); - return null; - } - - return { cfg, requestAuth }; -} - -export function isGatewayBearerHttpRequest( - req: IncomingMessage, - auth?: SharedSecretGatewayAuth, -): boolean { - return usesSharedSecretHttpAuth(auth) && Boolean(getBearerToken(req)); -} - -export function resolveTrustedHttpOperatorScopes( - req: IncomingMessage, - authOrRequest?: - | SharedSecretGatewayAuth - | Pick, -): string[] { - if (!shouldTrustDeclaredHttpOperatorScopes(req, authOrRequest)) { - // Gateway bearer auth only proves possession of the shared secret. Do not - // let HTTP clients self-assert operator scopes through request headers. - return []; - } - - const headerValue = getHeader(req, "x-openclaw-scopes"); - if (headerValue === undefined) { - // No scope header present — trusted clients without an explicit header - // get the default operator scopes (matching pre-#57783 behavior). - return [...CLI_DEFAULT_OPERATOR_SCOPES]; - } - const raw = headerValue.trim(); - if (!raw) { - return []; - } - return raw - .split(",") - .map((scope) => scope.trim()) - .filter((scope) => scope.length > 0); -} - -export function resolveOpenAiCompatibleHttpOperatorScopes( - req: IncomingMessage, - requestAuth: AuthorizedGatewayHttpRequest, -): string[] { - if (usesSharedSecretGatewayMethod(requestAuth.authMethod)) { - // Shared-secret HTTP bearer auth is a documented trusted-operator surface - // for the compat APIs and direct /tools/invoke. This is designed-as-is: - // token/password auth proves possession of the gateway operator secret, not - // a narrower per-request scope identity, so restore the normal defaults. - return [...CLI_DEFAULT_OPERATOR_SCOPES]; - } - return resolveTrustedHttpOperatorScopes(req, requestAuth); -} - -export function resolveHttpSenderIsOwner( - req: IncomingMessage, - authOrRequest?: - | SharedSecretGatewayAuth - | Pick, -): boolean { - return resolveTrustedHttpOperatorScopes(req, authOrRequest).includes(ADMIN_SCOPE); -} - -export function resolveOpenAiCompatibleHttpSenderIsOwner( - req: IncomingMessage, - requestAuth: AuthorizedGatewayHttpRequest, -): boolean { - if (usesSharedSecretGatewayMethod(requestAuth.authMethod)) { - // Shared-secret HTTP bearer auth also carries owner semantics on the compat - // APIs and direct /tools/invoke. This is intentional: there is no separate - // per-request owner primitive on that shared-secret path, so owner-only - // tool policy follows the documented trusted-operator contract. - return true; - } - return resolveHttpSenderIsOwner(req, requestAuth); -} - export function resolveAgentIdFromHeader(req: IncomingMessage): string | undefined { const raw = normalizeOptionalString(getHeader(req, "x-openclaw-agent-id")) || diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 8d3f0982e74..83936c58893 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -56,13 +56,13 @@ import { resolveHookChannel, resolveHookDeliver, } from "./hooks.js"; -import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js"; import { type AuthorizedGatewayHttpRequest, authorizeGatewayHttpRequestOrReply, getBearerToken, resolveHttpBrowserOriginPolicy, -} from "./http-utils.js"; +} from "./http-auth-utils.js"; +import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js"; import { resolveRequestClientIp } from "./net.js"; import { DEDUPE_MAX, DEDUPE_TTL_MS } from "./server-constants.js"; import { authorizeCanvasRequest, isCanvasPath } from "./server/http-auth.js"; diff --git a/src/gateway/server/http-auth.ts b/src/gateway/server/http-auth.ts index b5d392daefd..82cb9a34c9d 100644 --- a/src/gateway/server/http-auth.ts +++ b/src/gateway/server/http-auth.ts @@ -8,7 +8,7 @@ import { type ResolvedGatewayAuth, } from "../auth.js"; import { CANVAS_CAPABILITY_TTL_MS } from "../canvas-capability.js"; -import { getBearerToken, resolveHttpBrowserOriginPolicy } from "../http-utils.js"; +import { getBearerToken, resolveHttpBrowserOriginPolicy } from "../http-auth-utils.js"; import type { GatewayWsClient } from "./ws-types.js"; export function isCanvasPath(pathname: string): boolean { diff --git a/src/gateway/server/plugin-route-runtime-scopes.ts b/src/gateway/server/plugin-route-runtime-scopes.ts index eea5a55b00d..89df1646607 100644 --- a/src/gateway/server/plugin-route-runtime-scopes.ts +++ b/src/gateway/server/plugin-route-runtime-scopes.ts @@ -3,7 +3,7 @@ import { getHeader, resolveTrustedHttpOperatorScopes, type AuthorizedGatewayHttpRequest, -} from "../http-utils.js"; +} from "../http-auth-utils.js"; import { CLI_DEFAULT_OPERATOR_SCOPES, WRITE_SCOPE } from "../method-scopes.js"; export type PluginRouteRuntimeScopeSurface = "write-default" | "trusted-operator";