mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-05 14:21:32 +00:00
resolveTrustedHttpOperatorScopes() returns [] when the x-openclaw-scopes
header is absent, even for trusted requests (--auth none). This causes
403 "missing scope: operator.write" on /v1/chat/completions.
Root cause: src/gateway/http-utils.ts:138-140. PR #57783 (f0af18672)
replaced the old resolveGatewayRequestedOperatorScopes which had an
explicit fallback to CLI_DEFAULT_OPERATOR_SCOPES when no header was
present. The new function treats absent header the same as empty header
— both return [].
Fix: distinguish absent header (undefined → return defaults) from empty
header ("" → return []). Trusted clients without an explicit scope
header get the default operator scopes, matching pre-#57783 behavior.
Closes #58357
Signed-off-by: HCL <chenglunhu@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
320 lines
10 KiB
TypeScript
320 lines
10 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
|
import {
|
|
buildAllowedModelSet,
|
|
modelKey,
|
|
parseModelRef,
|
|
resolveDefaultModelForAgent,
|
|
} from "../agents/model-selection.js";
|
|
import { loadConfig } from "../config/config.js";
|
|
import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js";
|
|
import { normalizeMessageChannel } from "../utils/message-channel.js";
|
|
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
|
import {
|
|
authorizeHttpGatewayConnect,
|
|
type GatewayAuthResult,
|
|
type ResolvedGatewayAuth,
|
|
} from "./auth.js";
|
|
import { sendGatewayAuthFailure } from "./http-common.js";
|
|
import { ADMIN_SCOPE, CLI_DEFAULT_OPERATOR_SCOPES } from "./method-scopes.js";
|
|
import { loadGatewayModelCatalog } from "./server-model-catalog.js";
|
|
|
|
export const OPENCLAW_MODEL_ID = "openclaw";
|
|
export const OPENCLAW_DEFAULT_MODEL_ID = "openclaw/default";
|
|
|
|
export function getHeader(req: IncomingMessage, name: string): string | undefined {
|
|
const raw = req.headers[name.toLowerCase()];
|
|
if (typeof raw === "string") {
|
|
return raw;
|
|
}
|
|
if (Array.isArray(raw)) {
|
|
return raw[0];
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function getBearerToken(req: IncomingMessage): string | undefined {
|
|
const raw = getHeader(req, "authorization")?.trim() ?? "";
|
|
if (!raw.toLowerCase().startsWith("bearer ")) {
|
|
return undefined;
|
|
}
|
|
const token = raw.slice(7).trim();
|
|
return token || undefined;
|
|
}
|
|
|
|
type SharedSecretGatewayAuth = Pick<ResolvedGatewayAuth, "mode">;
|
|
export type AuthorizedGatewayHttpRequest = {
|
|
authMethod?: GatewayAuthResult["method"];
|
|
trustDeclaredOperatorScopes: boolean;
|
|
};
|
|
|
|
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 token = getBearerToken(params.req);
|
|
const browserOriginPolicy = resolveHttpBrowserOriginPolicy(params.req);
|
|
const authResult = await authorizeHttpGatewayConnect({
|
|
auth: params.auth,
|
|
connectAuth: token ? { token, password: token } : null,
|
|
req: params.req,
|
|
trustedProxies: params.trustedProxies,
|
|
allowRealIpFallback: params.allowRealIpFallback,
|
|
rateLimiter: params.rateLimiter,
|
|
browserOriginPolicy,
|
|
});
|
|
if (!authResult.ok) {
|
|
sendGatewayAuthFailure(params.res, authResult);
|
|
return null;
|
|
}
|
|
return {
|
|
authMethod: authResult.method,
|
|
// Shared-secret bearer auth proves possession of the gateway secret, but it
|
|
// does not prove a narrower per-request operator identity. HTTP endpoints
|
|
// must opt in explicitly if they want to treat that shared-secret path as a
|
|
// full trusted-operator surface.
|
|
trustDeclaredOperatorScopes: !usesSharedSecretGatewayMethod(authResult.method),
|
|
};
|
|
}
|
|
|
|
export function isGatewayBearerHttpRequest(
|
|
req: IncomingMessage,
|
|
auth?: SharedSecretGatewayAuth,
|
|
): boolean {
|
|
return usesSharedSecretHttpAuth(auth) && Boolean(getBearerToken(req));
|
|
}
|
|
|
|
export function resolveTrustedHttpOperatorScopes(
|
|
req: IncomingMessage,
|
|
authOrRequest?:
|
|
| SharedSecretGatewayAuth
|
|
| Pick<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 =
|
|
getHeader(req, "x-openclaw-agent-id")?.trim() ||
|
|
getHeader(req, "x-openclaw-agent")?.trim() ||
|
|
"";
|
|
if (!raw) {
|
|
return undefined;
|
|
}
|
|
return normalizeAgentId(raw);
|
|
}
|
|
|
|
export function resolveAgentIdFromModel(
|
|
model: string | undefined,
|
|
cfg = loadConfig(),
|
|
): string | undefined {
|
|
const raw = model?.trim();
|
|
if (!raw) {
|
|
return undefined;
|
|
}
|
|
const lowered = raw.toLowerCase();
|
|
if (lowered === OPENCLAW_MODEL_ID || lowered === OPENCLAW_DEFAULT_MODEL_ID) {
|
|
return resolveDefaultAgentId(cfg);
|
|
}
|
|
|
|
const m =
|
|
raw.match(/^openclaw[:/](?<agentId>[a-z0-9][a-z0-9_-]{0,63})$/i) ??
|
|
raw.match(/^agent:(?<agentId>[a-z0-9][a-z0-9_-]{0,63})$/i);
|
|
const agentId = m?.groups?.agentId;
|
|
if (!agentId) {
|
|
return undefined;
|
|
}
|
|
return normalizeAgentId(agentId);
|
|
}
|
|
|
|
export async function resolveOpenAiCompatModelOverride(params: {
|
|
req: IncomingMessage;
|
|
agentId: string;
|
|
model: string | undefined;
|
|
}): Promise<{ modelOverride?: string; errorMessage?: string }> {
|
|
const requestModel = params.model?.trim();
|
|
if (requestModel && !resolveAgentIdFromModel(requestModel)) {
|
|
return {
|
|
errorMessage: "Invalid `model`. Use `openclaw` or `openclaw/<agentId>`.",
|
|
};
|
|
}
|
|
|
|
const raw = getHeader(params.req, "x-openclaw-model")?.trim();
|
|
if (!raw) {
|
|
return {};
|
|
}
|
|
|
|
const cfg = loadConfig();
|
|
const defaultModelRef = resolveDefaultModelForAgent({ cfg, agentId: params.agentId });
|
|
const defaultProvider = defaultModelRef.provider;
|
|
const parsed = parseModelRef(raw, defaultProvider);
|
|
if (!parsed) {
|
|
return { errorMessage: "Invalid `x-openclaw-model`." };
|
|
}
|
|
|
|
const catalog = await loadGatewayModelCatalog();
|
|
const allowed = buildAllowedModelSet({
|
|
cfg,
|
|
catalog,
|
|
defaultProvider,
|
|
agentId: params.agentId,
|
|
});
|
|
const normalized = modelKey(parsed.provider, parsed.model);
|
|
if (!allowed.allowAny && !allowed.allowedKeys.has(normalized)) {
|
|
return {
|
|
errorMessage: `Model '${normalized}' is not allowed for agent '${params.agentId}'.`,
|
|
};
|
|
}
|
|
|
|
return { modelOverride: raw };
|
|
}
|
|
|
|
export function resolveAgentIdForRequest(params: {
|
|
req: IncomingMessage;
|
|
model: string | undefined;
|
|
}): string {
|
|
const cfg = loadConfig();
|
|
const fromHeader = resolveAgentIdFromHeader(params.req);
|
|
if (fromHeader) {
|
|
return fromHeader;
|
|
}
|
|
|
|
const fromModel = resolveAgentIdFromModel(params.model, cfg);
|
|
return fromModel ?? resolveDefaultAgentId(cfg);
|
|
}
|
|
|
|
export function resolveSessionKey(params: {
|
|
req: IncomingMessage;
|
|
agentId: string;
|
|
user?: string | undefined;
|
|
prefix: string;
|
|
}): string {
|
|
const explicit = getHeader(params.req, "x-openclaw-session-key")?.trim();
|
|
if (explicit) {
|
|
return explicit;
|
|
}
|
|
|
|
const user = params.user?.trim();
|
|
const mainKey = user ? `${params.prefix}-user:${user}` : `${params.prefix}:${randomUUID()}`;
|
|
return buildAgentMainSessionKey({ agentId: params.agentId, mainKey });
|
|
}
|
|
|
|
export function resolveGatewayRequestContext(params: {
|
|
req: IncomingMessage;
|
|
model: string | undefined;
|
|
user?: string | undefined;
|
|
sessionPrefix: string;
|
|
defaultMessageChannel: string;
|
|
useMessageChannelHeader?: boolean;
|
|
}): { agentId: string; sessionKey: string; messageChannel: string } {
|
|
const agentId = resolveAgentIdForRequest({ req: params.req, model: params.model });
|
|
const sessionKey = resolveSessionKey({
|
|
req: params.req,
|
|
agentId,
|
|
user: params.user,
|
|
prefix: params.sessionPrefix,
|
|
});
|
|
|
|
const messageChannel = params.useMessageChannelHeader
|
|
? (normalizeMessageChannel(getHeader(params.req, "x-openclaw-message-channel")) ??
|
|
params.defaultMessageChannel)
|
|
: params.defaultMessageChannel;
|
|
|
|
return { agentId, sessionKey, messageChannel };
|
|
}
|