Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/agents/models-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ type ModelsConfig = NonNullable<OpenClawConfig["models"]>;

const DEFAULT_MODE: NonNullable<ModelsConfig["mode"]> = "merge";

/**
* In-flight promise for {@link ensureOpenClawModelsJson}. Multiple concurrent
* callers (model catalog, agent context, image tools, embedded runner, …) all
* resolve/write the same `models.json` file. Storing the pending promise here
* lets us deduplicate the work: the first caller does the I/O while subsequent
* callers piggy-back on the same result.
*/
let ensureModelsJsonPromise: Promise<{ agentDir: string; wrote: boolean }> | null = null;

function mergeProviderModels(implicit: ProviderConfig, explicit: ProviderConfig): ProviderConfig {
const implicitModels = Array.isArray(implicit.models) ? implicit.models : [];
const explicitModels = Array.isArray(explicit.models) ? explicit.models : [];
Expand Down Expand Up @@ -81,6 +90,37 @@ async function readJson(pathname: string): Promise<unknown> {
export async function ensureOpenClawModelsJson(
config?: OpenClawConfig,
agentDirOverride?: string,
): Promise<{ agentDir: string; wrote: boolean }> {
// Fast-path: if a write is already in-flight and the caller didn't provide
// custom overrides, reuse the pending promise to avoid duplicate disk I/O.
const hasOverrides = config !== undefined || (agentDirOverride?.trim() ?? "") !== "";
if (!hasOverrides && ensureModelsJsonPromise) {
return ensureModelsJsonPromise;
}

const promise = ensureOpenClawModelsJsonInner(config, agentDirOverride);

if (!hasOverrides) {
ensureModelsJsonPromise = promise;
// Clear once settled so future calls re-evaluate (config may have changed).
void promise.finally(() => {
if (ensureModelsJsonPromise === promise) {
ensureModelsJsonPromise = null;
}
});
}

return promise;
}

/** @internal visible for testing */
export function resetEnsureModelsJsonCacheForTest(): void {
ensureModelsJsonPromise = null;
}

async function ensureOpenClawModelsJsonInner(
config?: OpenClawConfig,
agentDirOverride?: string,
): Promise<{ agentDir: string; wrote: boolean }> {
const cfg = config ?? loadConfig();
const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveOpenClawAgentDir();
Expand Down
4 changes: 3 additions & 1 deletion src/gateway/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,9 @@ export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
);
}
if (auth.mode === "password" && !auth.password) {
throw new Error("gateway auth mode is password, but no password was configured");
throw new Error(
"gateway auth mode is password, but no password was configured (set gateway.auth.password or OPENCLAW_GATEWAY_PASSWORD)",
);
}
if (auth.mode === "trusted-proxy") {
if (!auth.trustedProxy) {
Expand Down
2 changes: 1 addition & 1 deletion src/gateway/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export function ensureExplicitGatewayAuth(params: {
return;
}
const message = [
"gateway url override requires explicit credentials",
"gateway URL override requires explicit credentials — when using a custom gateway URL, you must provide --token/--password or set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD",
params.errorHint,
params.configPath ? `Config: ${params.configPath}` : undefined,
]
Expand Down
46 changes: 43 additions & 3 deletions src/gateway/http-common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,12 @@ export function sendMethodNotAllowed(res: ServerResponse, allow = "POST") {
sendText(res, 405, "Method Not Allowed");
}

export function sendUnauthorized(res: ServerResponse) {
export function sendUnauthorized(res: ServerResponse, reason?: string) {
sendJson(res, 401, {
error: { message: "Unauthorized", type: "unauthorized" },
error: {
message: reason ?? "Unauthorized — check your gateway auth token or password",
type: "unauthorized",
},
});
}

Expand All @@ -42,7 +45,44 @@ export function sendGatewayAuthFailure(res: ServerResponse, authResult: GatewayA
sendRateLimited(res, authResult.retryAfterMs);
return;
}
sendUnauthorized(res);
const reason = authResult.reason;
const message = reason
? formatHttpAuthFailureReason(reason)
: "Unauthorized — check your gateway auth token or password";
sendUnauthorized(res, message);
}

function formatHttpAuthFailureReason(reason: string): string {
switch (reason) {
case "token_missing":
return "Gateway token missing — provide OPENCLAW_GATEWAY_TOKEN or set gateway.auth.token in your config";
case "token_mismatch":
return "Gateway token mismatch — the provided token does not match the gateway. Check OPENCLAW_GATEWAY_TOKEN or gateway.auth.token";
case "token_missing_config":
return "Gateway token not configured on the server — set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN on the gateway host";
case "password_missing":
return "Gateway password missing — provide OPENCLAW_GATEWAY_PASSWORD or set gateway.auth.password in your config";
case "password_mismatch":
return "Gateway password mismatch — the provided password does not match the gateway. Check OPENCLAW_GATEWAY_PASSWORD or gateway.auth.password";
case "password_missing_config":
return "Gateway password not configured on the server — set gateway.auth.password or OPENCLAW_GATEWAY_PASSWORD on the gateway host";
case "trusted_proxy_config_missing":
return "Trusted proxy mode enabled but no proxy config found — set gateway.auth.trustedProxy in your config";
case "trusted_proxy_no_proxies_configured":
return "Trusted proxy mode enabled but gateway.trustedProxies is empty — add at least one trusted proxy IP";
case "trusted_proxy_untrusted_source":
return "Request did not come from a trusted proxy — check gateway.trustedProxies includes your proxy's IP";
case "trusted_proxy_user_missing":
return "Trusted proxy did not provide a user identity header — check your proxy forwards the configured userHeader";
case "trusted_proxy_user_not_allowed":
return "User not in the trusted proxy allowUsers list — add the user to gateway.auth.trustedProxy.allowUsers";
case "device_token_mismatch":
return "Device token mismatch — rotate or reissue the device token";
case "rate_limited":
return "Too many failed authentication attempts — wait and retry later";
default:
return `Unauthorized (${reason})`;
}
}

export function sendInvalidRequest(res: ServerResponse, message: string) {
Expand Down
15 changes: 13 additions & 2 deletions src/gateway/server-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,16 @@ function writeUpgradeAuthFailure(
);
return;
}
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
const reason = auth.reason ?? "unauthorized";
const body = JSON.stringify({
error: {
message: `Authentication failed (${reason}) — check your gateway token or password. See https://docs.openclaw.com/gateway/auth`,
type: "unauthorized",
},
});
socket.write(
`HTTP/1.1 401 Unauthorized\r\nContent-Type: application/json; charset=utf-8\r\nConnection: close\r\n\r\n${body}`,
);
}

export type HooksRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
Expand Down Expand Up @@ -285,7 +294,9 @@ export function createHooksRequestHandler(
}
res.statusCode = 401;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Unauthorized");
res.end(
"Unauthorized — webhook secret mismatch. Check the hook secret matches the one configured in your integration.",
);
return true;
}
clearHookAuthFailure(clientKey);
Expand Down
Loading