mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:20:44 +00: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).
|
- 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/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 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.
|
- 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.
|
- 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.
|
- 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 type { OutboundSendDeps } from "../infra/outbound/send-deps.js";
|
||||||
import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js";
|
import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js";
|
||||||
import type { CliDeps } from "./deps.types.js";
|
import type { CliDeps } from "./deps.types.js";
|
||||||
import { createOutboundSendDepsFromCliSource } from "./outbound-send-mapping.js";
|
import {
|
||||||
import { createChannelOutboundRuntimeSend } from "./send-runtime/channel-outbound-send.js";
|
CLI_OUTBOUND_SEND_FACTORY,
|
||||||
|
createOutboundSendDepsFromCliSource,
|
||||||
|
} from "./outbound-send-mapping.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazy-loaded per-channel send functions, keyed by channel ID.
|
* Lazy-loaded per-channel send functions, keyed by channel ID.
|
||||||
@@ -17,6 +18,35 @@ type RuntimeSendModule = {
|
|||||||
runtimeSend: RuntimeSend;
|
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.
|
// Per-channel module caches for lazy loading.
|
||||||
const senderCache = new Map<string, Promise<RuntimeSend>>();
|
const senderCache = new Map<string, Promise<RuntimeSend>>();
|
||||||
|
|
||||||
@@ -41,22 +71,40 @@ function createLazySender(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createDefaultDeps(): CliDeps {
|
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 = {};
|
const deps: CliDeps = {};
|
||||||
for (const plugin of listChannelPlugins()) {
|
const resolveSender = (channelId: string) =>
|
||||||
deps[plugin.id] = createLazySender(
|
createLazySender(channelId, async () => {
|
||||||
plugin.id,
|
const { createChannelOutboundRuntimeSend } =
|
||||||
async () =>
|
await import("./send-runtime/channel-outbound-send.js");
|
||||||
({
|
return {
|
||||||
runtimeSend: createChannelOutboundRuntimeSend({
|
runtimeSend: createChannelOutboundRuntimeSend({
|
||||||
channelId: plugin.id,
|
channelId: channelId as import("../channels/plugins/types.public.js").ChannelId,
|
||||||
unavailableMessage: `${plugin.meta.label ?? plugin.id} outbound adapter is unavailable.`,
|
unavailableMessage: `${channelId} outbound adapter is unavailable.`,
|
||||||
}) as RuntimeSend,
|
}) as RuntimeSend,
|
||||||
}) satisfies RuntimeSendModule,
|
} satisfies RuntimeSendModule;
|
||||||
);
|
});
|
||||||
}
|
|
||||||
return deps;
|
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 {
|
export function createOutboundSendDeps(deps: CliDeps): OutboundSendDeps {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { normalizeAnyChannelId } from "../channels/registry.js";
|
|
||||||
import {
|
import {
|
||||||
resolveLegacyOutboundSendDepKeys,
|
resolveLegacyOutboundSendDepKeys,
|
||||||
type OutboundSendDeps,
|
type OutboundSendDeps,
|
||||||
@@ -9,7 +8,15 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
|||||||
* CLI-internal send function sources, keyed by channel ID.
|
* CLI-internal send function sources, keyed by channel ID.
|
||||||
* Each value is a lazily-loaded send function for that channel.
|
* 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 {
|
function normalizeLegacyChannelStem(raw: string): string {
|
||||||
const normalized = normalizeLowercaseStringOrEmpty(
|
const normalized = normalizeLowercaseStringOrEmpty(
|
||||||
@@ -27,7 +34,16 @@ function resolveChannelIdFromLegacySourceKey(key: string): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const normalizedStem = normalizeLegacyChannelStem(match[1] ?? "");
|
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 {
|
export function createOutboundSendDepsFromCliSource(deps: CliOutboundSendSource): OutboundSendDeps {
|
||||||
const outbound: OutboundSendDeps = { ...deps };
|
const outbound: OutboundSendDeps = { ...deps };
|
||||||
|
const sendFactory = deps[CLI_OUTBOUND_SEND_FACTORY];
|
||||||
|
|
||||||
for (const legacySourceKey of Object.keys(deps)) {
|
for (const legacySourceKey of Object.keys(deps)) {
|
||||||
const channelId = resolveChannelIdFromLegacySourceKey(legacySourceKey);
|
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 { 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 { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
import {
|
import {
|
||||||
buildAllowedModelSet,
|
buildAllowedModelSet,
|
||||||
@@ -8,264 +8,34 @@ import {
|
|||||||
resolveDefaultModelForAgent,
|
resolveDefaultModelForAgent,
|
||||||
} from "../agents/model-selection.js";
|
} from "../agents/model-selection.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
||||||
import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js";
|
import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js";
|
||||||
import {
|
import {
|
||||||
normalizeLowercaseStringOrEmpty,
|
normalizeLowercaseStringOrEmpty,
|
||||||
normalizeOptionalString,
|
normalizeOptionalString,
|
||||||
} from "../shared/string-coerce.js";
|
} from "../shared/string-coerce.js";
|
||||||
import { normalizeMessageChannel } from "../utils/message-channel.js";
|
import { normalizeMessageChannel } from "../utils/message-channel.js";
|
||||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
import { getHeader } from "./http-auth-utils.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 { loadGatewayModelCatalog } from "./server-model-catalog.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_MODEL_ID = "openclaw";
|
||||||
export const OPENCLAW_DEFAULT_MODEL_ID = "openclaw/default";
|
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 {
|
export function resolveAgentIdFromHeader(req: IncomingMessage): string | undefined {
|
||||||
const raw =
|
const raw =
|
||||||
normalizeOptionalString(getHeader(req, "x-openclaw-agent-id")) ||
|
normalizeOptionalString(getHeader(req, "x-openclaw-agent-id")) ||
|
||||||
|
|||||||
@@ -56,13 +56,13 @@ import {
|
|||||||
resolveHookChannel,
|
resolveHookChannel,
|
||||||
resolveHookDeliver,
|
resolveHookDeliver,
|
||||||
} from "./hooks.js";
|
} from "./hooks.js";
|
||||||
import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js";
|
|
||||||
import {
|
import {
|
||||||
type AuthorizedGatewayHttpRequest,
|
type AuthorizedGatewayHttpRequest,
|
||||||
authorizeGatewayHttpRequestOrReply,
|
authorizeGatewayHttpRequestOrReply,
|
||||||
getBearerToken,
|
getBearerToken,
|
||||||
resolveHttpBrowserOriginPolicy,
|
resolveHttpBrowserOriginPolicy,
|
||||||
} from "./http-utils.js";
|
} from "./http-auth-utils.js";
|
||||||
|
import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js";
|
||||||
import { resolveRequestClientIp } from "./net.js";
|
import { resolveRequestClientIp } from "./net.js";
|
||||||
import { DEDUPE_MAX, DEDUPE_TTL_MS } from "./server-constants.js";
|
import { DEDUPE_MAX, DEDUPE_TTL_MS } from "./server-constants.js";
|
||||||
import { authorizeCanvasRequest, isCanvasPath } from "./server/http-auth.js";
|
import { authorizeCanvasRequest, isCanvasPath } from "./server/http-auth.js";
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
type ResolvedGatewayAuth,
|
type ResolvedGatewayAuth,
|
||||||
} from "../auth.js";
|
} from "../auth.js";
|
||||||
import { CANVAS_CAPABILITY_TTL_MS } from "../canvas-capability.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";
|
import type { GatewayWsClient } from "./ws-types.js";
|
||||||
|
|
||||||
export function isCanvasPath(pathname: string): boolean {
|
export function isCanvasPath(pathname: string): boolean {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
getHeader,
|
getHeader,
|
||||||
resolveTrustedHttpOperatorScopes,
|
resolveTrustedHttpOperatorScopes,
|
||||||
type AuthorizedGatewayHttpRequest,
|
type AuthorizedGatewayHttpRequest,
|
||||||
} from "../http-utils.js";
|
} from "../http-auth-utils.js";
|
||||||
import { CLI_DEFAULT_OPERATOR_SCOPES, WRITE_SCOPE } from "../method-scopes.js";
|
import { CLI_DEFAULT_OPERATOR_SCOPES, WRITE_SCOPE } from "../method-scopes.js";
|
||||||
|
|
||||||
export type PluginRouteRuntimeScopeSurface = "write-default" | "trusted-operator";
|
export type PluginRouteRuntimeScopeSurface = "write-default" | "trusted-operator";
|
||||||
|
|||||||
Reference in New Issue
Block a user