import { randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { buildAllowedModelSet, modelKey, parseModelRef, resolveDefaultModelForAgent, } from "../agents/model-selection.js"; import { loadConfig } from "../config/config.js"; import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.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 } from "./http-common.js"; import { ADMIN_SCOPE, CLI_DEFAULT_OPERATOR_SCOPES } from "./method-scopes.js"; import { loadGatewayModelCatalog } from "./server-model-catalog.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[name.toLowerCase()]; if (typeof raw === "string") { return raw; } if (Array.isArray(raw)) { return raw[0]; } return undefined; } export function getBearerToken(req: IncomingMessage): string | undefined { const raw = getHeader(req, "authorization")?.trim() ?? ""; if (!raw.toLowerCase().startsWith("bearer ")) { return undefined; } const token = raw.slice(7).trim(); return token || undefined; } type SharedSecretGatewayAuth = Pick; export type AuthorizedGatewayHttpRequest = { authMethod?: GatewayAuthResult["method"]; trustDeclaredOperatorScopes: boolean; }; 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 token = getBearerToken(params.req); const browserOriginPolicy = resolveHttpBrowserOriginPolicy(params.req); 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) { sendGatewayAuthFailure(params.res, authResult); return null; } return { 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 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 = getHeader(req, "x-openclaw-agent-id")?.trim() || getHeader(req, "x-openclaw-agent")?.trim() || ""; if (!raw) { return undefined; } return normalizeAgentId(raw); } export function resolveAgentIdFromModel( model: string | undefined, cfg = loadConfig(), ): string | undefined { const raw = model?.trim(); if (!raw) { return undefined; } const lowered = raw.toLowerCase(); if (lowered === OPENCLAW_MODEL_ID || lowered === OPENCLAW_DEFAULT_MODEL_ID) { return resolveDefaultAgentId(cfg); } const m = raw.match(/^openclaw[:/](?[a-z0-9][a-z0-9_-]{0,63})$/i) ?? raw.match(/^agent:(?[a-z0-9][a-z0-9_-]{0,63})$/i); const agentId = m?.groups?.agentId; if (!agentId) { return undefined; } return normalizeAgentId(agentId); } export async function resolveOpenAiCompatModelOverride(params: { req: IncomingMessage; agentId: string; model: string | undefined; }): Promise<{ modelOverride?: string; errorMessage?: string }> { const requestModel = params.model?.trim(); if (requestModel && !resolveAgentIdFromModel(requestModel)) { return { errorMessage: "Invalid `model`. Use `openclaw` or `openclaw/`.", }; } const raw = getHeader(params.req, "x-openclaw-model")?.trim(); if (!raw) { return {}; } const cfg = loadConfig(); const defaultModelRef = resolveDefaultModelForAgent({ cfg, agentId: params.agentId }); const defaultProvider = defaultModelRef.provider; const parsed = parseModelRef(raw, defaultProvider); if (!parsed) { return { errorMessage: "Invalid `x-openclaw-model`." }; } const catalog = await loadGatewayModelCatalog(); const allowed = buildAllowedModelSet({ cfg, catalog, defaultProvider, agentId: params.agentId, }); const normalized = modelKey(parsed.provider, parsed.model); if (!allowed.allowAny && !allowed.allowedKeys.has(normalized)) { return { errorMessage: `Model '${normalized}' is not allowed for agent '${params.agentId}'.`, }; } return { modelOverride: raw }; } export function resolveAgentIdForRequest(params: { req: IncomingMessage; model: string | undefined; }): string { const cfg = loadConfig(); const fromHeader = resolveAgentIdFromHeader(params.req); if (fromHeader) { return fromHeader; } const fromModel = resolveAgentIdFromModel(params.model, cfg); return fromModel ?? resolveDefaultAgentId(cfg); } export function resolveSessionKey(params: { req: IncomingMessage; agentId: string; user?: string | undefined; prefix: string; }): string { const explicit = getHeader(params.req, "x-openclaw-session-key")?.trim(); if (explicit) { return explicit; } const user = params.user?.trim(); const mainKey = user ? `${params.prefix}-user:${user}` : `${params.prefix}:${randomUUID()}`; return buildAgentMainSessionKey({ agentId: params.agentId, mainKey }); } export function resolveGatewayRequestContext(params: { req: IncomingMessage; model: string | undefined; user?: string | undefined; sessionPrefix: string; defaultMessageChannel: string; useMessageChannelHeader?: boolean; }): { agentId: string; sessionKey: string; messageChannel: string } { const agentId = resolveAgentIdForRequest({ req: params.req, model: params.model }); const sessionKey = resolveSessionKey({ req: params.req, agentId, user: params.user, prefix: params.sessionPrefix, }); const messageChannel = params.useMessageChannelHeader ? (normalizeMessageChannel(getHeader(params.req, "x-openclaw-message-channel")) ?? params.defaultMessageChannel) : params.defaultMessageChannel; return { agentId, sessionKey, messageChannel }; }