fix(gateway): trim startup imports

This commit is contained in:
Vincent Koc
2026-04-26 22:47:56 -07:00
parent 06b3e4ef8a
commit b02cca4e00
8 changed files with 395 additions and 274 deletions

View File

@@ -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.

View File

@@ -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<string, Promise<RuntimeSend>>();
@@ -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 {

View File

@@ -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);
},
});
}

View File

@@ -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<ResolvedGatewayAuth, "mode">;
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<Parameters<typeof authorizeHttpGatewayConnect>[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<AuthorizedGatewayHttpRequest, "trustDeclaredOperatorScopes">
| 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<AuthorizedGatewayHttpRequest | null> {
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<GatewayHttpRequestAuthCheckResult> {
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<AuthorizedGatewayHttpRequest, "trustDeclaredOperatorScopes">,
): 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<AuthorizedGatewayHttpRequest, "trustDeclaredOperatorScopes">,
): 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);
}

View File

@@ -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<ResolvedGatewayAuth, "mode">;
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<Parameters<typeof authorizeHttpGatewayConnect>[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<AuthorizedGatewayHttpRequest, "trustDeclaredOperatorScopes">
| 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<AuthorizedGatewayHttpRequest | null> {
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<GatewayHttpRequestAuthCheckResult> {
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<AuthorizedGatewayHttpRequest, "trustDeclaredOperatorScopes">,
): 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<AuthorizedGatewayHttpRequest, "trustDeclaredOperatorScopes">,
): 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")) ||

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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";