From 0275281e8dfa45c5099dfd65e946b2a1a2c2474e Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 19 Feb 2026 02:15:13 -0500 Subject: [PATCH] refactor(gateway): use auth mode provenance for bootstrap persistence --- src/cli/gateway-cli/run.ts | 25 +++++++--------- src/gateway/auth.test.ts | 18 ++++++++++++ src/gateway/auth.ts | 43 ++++++++++++++++++++++++++-- src/gateway/server-runtime-config.ts | 8 ++---- src/gateway/server.impl.ts | 4 +-- src/gateway/startup-auth.ts | 24 ++++++---------- 6 files changed, 82 insertions(+), 40 deletions(-) diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 4602292db0a..74c8394b5e4 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -239,14 +239,17 @@ async function runGatewayCommand(opts: GatewayRunOpts) { } const miskeys = extractGatewayMiskeys(snapshot?.parsed); - const authConfig = { - ...cfg.gateway?.auth, - ...(authMode ? { mode: authMode } : {}), - ...(passwordRaw ? { password: passwordRaw } : {}), - ...(tokenRaw ? { token: tokenRaw } : {}), - }; + const authOverride = + authMode || passwordRaw || tokenRaw || authModeRaw + ? { + ...(authMode ? { mode: authMode } : {}), + ...(tokenRaw ? { token: tokenRaw } : {}), + ...(passwordRaw ? { password: passwordRaw } : {}), + } + : undefined; const resolvedAuth = resolveGatewayAuth({ - authConfig, + authConfig: cfg.gateway?.auth, + authOverride, env: process.env, tailscaleMode: tailscaleMode ?? cfg.gateway?.tailscale?.mode ?? "off", }); @@ -303,14 +306,6 @@ async function runGatewayCommand(opts: GatewayRunOpts) { defaultRuntime.exit(1); return; } - const authOverride = - authMode || passwordRaw || tokenRaw || authModeRaw - ? { - ...(authMode ? { mode: authMode } : {}), - ...(tokenRaw ? { token: tokenRaw } : {}), - ...(passwordRaw ? { password: passwordRaw } : {}), - } - : undefined; const tailscaleOverride = tailscaleMode || opts.tailscaleResetOnExit ? { diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 75177de4874..3c6c0c743d6 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -34,6 +34,7 @@ describe("gateway auth", () => { }), ).toMatchObject({ mode: "password", + modeSource: "password", token: "env-token", password: "env-password", }); @@ -50,6 +51,7 @@ describe("gateway auth", () => { }), ).toMatchObject({ mode: "token", + modeSource: "default", token: undefined, password: undefined, }); @@ -63,11 +65,27 @@ describe("gateway auth", () => { }), ).toMatchObject({ mode: "none", + modeSource: "config", token: undefined, password: undefined, }); }); + it("marks mode source as override when runtime mode override is provided", () => { + expect( + resolveGatewayAuth({ + authConfig: { mode: "password", password: "config-password" }, + authOverride: { mode: "token" }, + env: {} as NodeJS.ProcessEnv, + }), + ).toMatchObject({ + mode: "token", + modeSource: "override", + token: undefined, + password: "config-password", + }); + }); + it("does not throw when req is missing socket", async () => { const res = await authorizeGatewayConnect({ auth: { mode: "token", token: "secret", allowTailscale: false }, diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 22a4ecfd6d0..f3a7f2d9056 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -20,9 +20,16 @@ import { } from "./net.js"; export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy"; +export type ResolvedGatewayAuthModeSource = + | "override" + | "config" + | "password" + | "token" + | "default"; export type ResolvedGatewayAuth = { mode: ResolvedGatewayAuthMode; + modeSource?: ResolvedGatewayAuthModeSource; token?: string; password?: string; allowTailscale: boolean; @@ -178,24 +185,55 @@ async function resolveVerifiedTailscaleUser(params: { export function resolveGatewayAuth(params: { authConfig?: GatewayAuthConfig | null; + authOverride?: GatewayAuthConfig | null; env?: NodeJS.ProcessEnv; tailscaleMode?: GatewayTailscaleMode; }): ResolvedGatewayAuth { - const authConfig = params.authConfig ?? {}; + const baseAuthConfig = params.authConfig ?? {}; + const authOverride = params.authOverride ?? undefined; + const authConfig: GatewayAuthConfig = { ...baseAuthConfig }; + if (authOverride) { + if (authOverride.mode !== undefined) { + authConfig.mode = authOverride.mode; + } + if (authOverride.token !== undefined) { + authConfig.token = authOverride.token; + } + if (authOverride.password !== undefined) { + authConfig.password = authOverride.password; + } + if (authOverride.allowTailscale !== undefined) { + authConfig.allowTailscale = authOverride.allowTailscale; + } + if (authOverride.rateLimit !== undefined) { + authConfig.rateLimit = authOverride.rateLimit; + } + if (authOverride.trustedProxy !== undefined) { + authConfig.trustedProxy = authOverride.trustedProxy; + } + } const env = params.env ?? process.env; const token = authConfig.token ?? env.OPENCLAW_GATEWAY_TOKEN ?? undefined; const password = authConfig.password ?? env.OPENCLAW_GATEWAY_PASSWORD ?? undefined; const trustedProxy = authConfig.trustedProxy; let mode: ResolvedGatewayAuth["mode"]; - if (authConfig.mode) { + let modeSource: ResolvedGatewayAuth["modeSource"]; + if (authOverride?.mode !== undefined) { + mode = authOverride.mode; + modeSource = "override"; + } else if (authConfig.mode) { mode = authConfig.mode; + modeSource = "config"; } else if (password) { mode = "password"; + modeSource = "password"; } else if (token) { mode = "token"; + modeSource = "token"; } else { mode = "token"; + modeSource = "default"; } const allowTailscale = @@ -204,6 +242,7 @@ export function resolveGatewayAuth(params: { return { mode, + modeSource, token, password, allowTailscale, diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts index eafd32c3202..614b8c0b542 100644 --- a/src/gateway/server-runtime-config.ts +++ b/src/gateway/server-runtime-config.ts @@ -12,7 +12,7 @@ import { import { normalizeControlUiBasePath } from "./control-ui-shared.js"; import { resolveHooksConfig } from "./hooks.js"; import { isLoopbackHost, resolveGatewayBindHost } from "./net.js"; -import { mergeGatewayAuthConfig, mergeGatewayTailscaleConfig } from "./startup-auth.js"; +import { mergeGatewayTailscaleConfig } from "./startup-auth.js"; export type GatewayRuntimeConfig = { bindHost: string; @@ -58,15 +58,13 @@ export async function resolveGatewayRuntimeConfig(params: { typeof controlUiRootRaw === "string" && controlUiRootRaw.trim().length > 0 ? controlUiRootRaw.trim() : undefined; - const authBase = params.cfg.gateway?.auth ?? {}; - const authOverrides = params.auth ?? {}; - const authConfig = mergeGatewayAuthConfig(authBase, authOverrides); const tailscaleBase = params.cfg.gateway?.tailscale ?? {}; const tailscaleOverrides = params.tailscale ?? {}; const tailscaleConfig = mergeGatewayTailscaleConfig(tailscaleBase, tailscaleOverrides); const tailscaleMode = tailscaleConfig.mode ?? "off"; const resolvedAuth = resolveGatewayAuth({ - authConfig, + authConfig: params.cfg.gateway?.auth, + authOverride: params.auth, env: process.env, tailscaleMode, }); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index f7d9d12ca3b..a4add4d9488 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -243,8 +243,8 @@ export async function startGatewayServer( "Gateway auth token was missing. Generated a new token and saved it to config (gateway.auth.token).", ); } else { - log.info( - "Gateway auth token was missing. Generated a runtime token for this startup without changing config.", + log.warn( + "Gateway auth token was missing. Generated a runtime token for this startup without changing config; restart will generate a different token. Persist one with `openclaw config set gateway.auth.mode token` and `openclaw config set gateway.auth.token `.", ); } } diff --git a/src/gateway/startup-auth.ts b/src/gateway/startup-auth.ts index e888d6a0c6c..ec1ef7dd56e 100644 --- a/src/gateway/startup-auth.ts +++ b/src/gateway/startup-auth.ts @@ -1,12 +1,11 @@ import crypto from "node:crypto"; import type { GatewayAuthConfig, - GatewayAuthMode, GatewayTailscaleConfig, OpenClawConfig, } from "../config/config.js"; import { writeConfigFile } from "../config/config.js"; -import { resolveGatewayAuth } from "./auth.js"; +import { resolveGatewayAuth, type ResolvedGatewayAuth } from "./auth.js"; export function mergeGatewayAuthConfig( base?: GatewayAuthConfig, @@ -60,13 +59,13 @@ function resolveGatewayAuthFromConfig(params: { authOverride?: GatewayAuthConfig; tailscaleOverride?: GatewayTailscaleConfig; }) { - const authConfig = mergeGatewayAuthConfig(params.cfg.gateway?.auth, params.authOverride); const tailscaleConfig = mergeGatewayTailscaleConfig( params.cfg.gateway?.tailscale, params.tailscaleOverride, ); return resolveGatewayAuth({ - authConfig, + authConfig: params.cfg.gateway?.auth, + authOverride: params.authOverride, env: params.env, tailscaleMode: tailscaleConfig.mode ?? "off", }); @@ -74,16 +73,15 @@ function resolveGatewayAuthFromConfig(params: { function shouldPersistGeneratedToken(params: { persistRequested: boolean; - baselineMode: GatewayAuthMode; - overrideMode?: GatewayAuthMode; + resolvedAuth: ResolvedGatewayAuth; }): boolean { if (!params.persistRequested) { return false; } - // Keep CLI/runtime auth mode overrides ephemeral when they switch from an - // explicit non-token config mode to token mode. - if (params.overrideMode === "token" && params.baselineMode !== "token") { + // Keep CLI/runtime mode overrides ephemeral: startup should not silently + // mutate durable auth policy when mode was chosen by an override flag. + if (params.resolvedAuth.modeSource === "override") { return false; } @@ -104,11 +102,6 @@ export async function ensureGatewayStartupAuth(params: { }> { const env = params.env ?? process.env; const persistRequested = params.persist === true; - const baselineResolved = resolveGatewayAuthFromConfig({ - cfg: params.cfg, - env, - tailscaleOverride: params.tailscaleOverride, - }); const resolved = resolveGatewayAuthFromConfig({ cfg: params.cfg, env, @@ -133,8 +126,7 @@ export async function ensureGatewayStartupAuth(params: { }; const persist = shouldPersistGeneratedToken({ persistRequested, - baselineMode: baselineResolved.mode, - overrideMode: params.authOverride?.mode, + resolvedAuth: resolved, }); if (persist) { await writeConfigFile(nextCfg);