mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-30 13:36:45 +02:00
fix(gateway): trim startup imports
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
253
src/gateway/http-auth-utils.ts
Normal file
253
src/gateway/http-auth-utils.ts
Normal 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);
|
||||
}
|
||||
@@ -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")) ||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user