diff --git a/CHANGELOG.md b/CHANGELOG.md index 60dcb753f32..c7f243a8c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Doctor/status: warn when `OPENCLAW_GATEWAY_TOKEN` would shadow a different active `gateway.auth.token` source for local CLI commands, while avoiding false positives when config points at the same env token. Fixes #74271. Thanks @yelog. - Gateway/OpenAI-compatible: send the assistant role SSE chunk as soon as streaming chat-completion headers are accepted, so cold agent setup cannot leave `/v1/chat/completions` clients with a bodyless 200 response until their idle timeout fires. - Agents/media: avoid direct generated-media completion fallback while the announce-agent run is still pending, so async video and music completions do not duplicate raw media messages. (#77754) - TUI/sessions: bound the session picker to recent rows and use exact lookup-style refreshes for the active session, so dusty stores no longer make TUI hydrate weeks-old transcripts before becoming responsive. Thanks @vincentkoc. diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts index 486afc3f6a3..64b745fb709 100644 --- a/src/commands/doctor-security.test.ts +++ b/src/commands/doctor-security.test.ts @@ -151,6 +151,54 @@ describe("noteSecurityWarnings gateway exposure", () => { expect(message).not.toContain("CRITICAL"); }); + it("warns when OPENCLAW_GATEWAY_TOKEN env overrides gateway.auth.token config (#74271)", async () => { + process.env.OPENCLAW_GATEWAY_TOKEN = "env-token-123"; + const cfg = { + gateway: { + auth: { + token: "config-token-456", + }, + }, + } as OpenClawConfig; + await noteSecurityWarnings(cfg); + const message = lastMessage(); + expect(message).toContain("OPENCLAW_GATEWAY_TOKEN overrides"); + expect(message).toContain("env-first precedence"); + }); + + it("does not warn when only env token is set without config token", async () => { + process.env.OPENCLAW_GATEWAY_TOKEN = "env-token-only"; + const cfg = { gateway: { bind: "lan" } } as OpenClawConfig; + await noteSecurityWarnings(cfg); + const message = lastMessage(); + expect(message).not.toContain("OPENCLAW_GATEWAY_TOKEN overrides"); + }); + + it("does not warn when config token uses OPENCLAW_GATEWAY_TOKEN SecretRef", async () => { + process.env.OPENCLAW_GATEWAY_TOKEN = "env-token-123"; + const cfg = { + gateway: { auth: { token: "${OPENCLAW_GATEWAY_TOKEN}" } }, + secrets: { providers: { default: { source: "env" } } }, + } as OpenClawConfig; + await noteSecurityWarnings(cfg); + const message = lastMessage(); + expect(message).not.toContain("OPENCLAW_GATEWAY_TOKEN overrides"); + }); + + it("does not warn about local gateway auth token precedence in remote mode", async () => { + process.env.OPENCLAW_GATEWAY_TOKEN = "env-token-123"; + const cfg = { + gateway: { + mode: "remote", + remote: { token: "remote-token" }, + auth: { token: "local-token" }, + }, + } as OpenClawConfig; + await noteSecurityWarnings(cfg); + const message = lastMessage(); + expect(message).not.toContain("OPENCLAW_GATEWAY_TOKEN overrides"); + }); + it("treats whitespace token as missing", async () => { const cfg = { gateway: { bind: "lan", auth: { mode: "token", token: " " } }, diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index 38bcece5fff..9b0cce893cf 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -4,6 +4,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig, GatewayBindMode } from "../config/config.js"; import type { AgentConfig } from "../config/types.agents.js"; import { hasConfiguredSecretInput } from "../config/types.secrets.js"; +import { resolveGatewayAuthTokenSourceConflict } from "../gateway/auth-token-source-conflict.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js"; import { resolveExecPolicyScopeSnapshot } from "../infra/exec-approvals-effective.js"; @@ -252,6 +253,11 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) { } } + const tokenConflict = resolveGatewayAuthTokenSourceConflict({ cfg, env: process.env }); + if (tokenConflict) { + warnings.push(...tokenConflict.warningLines); + } + const warnDmPolicy = async (params: { label: string; provider: ChannelId; diff --git a/src/commands/status.scan.config-shared.test.ts b/src/commands/status.scan.config-shared.test.ts index 00ecc2696b7..e166f0c6bae 100644 --- a/src/commands/status.scan.config-shared.test.ts +++ b/src/commands/status.scan.config-shared.test.ts @@ -86,4 +86,73 @@ describe("status.scan.config-shared", () => { secretDiagnostics: ["resolved"], }); }); + + it("adds a status diagnostic for gateway token source conflicts", async () => { + const sourceConfig = { gateway: { auth: { token: "config-token" } } }; + const resolvedConfig = sourceConfig; + const readBestEffortConfig = vi.fn(async () => sourceConfig); + const resolveConfig = vi.fn(async () => ({ + resolvedConfig, + diagnostics: [], + })); + + const result = await loadStatusScanCommandConfig({ + commandName: "status --json", + readBestEffortConfig, + resolveConfig, + env: { VITEST: "true", OPENCLAW_GATEWAY_TOKEN: "env-token" }, + allowMissingConfigFastPath: true, + }); + + expect(result.secretDiagnostics).toEqual([ + expect.stringContaining("OPENCLAW_GATEWAY_TOKEN overrides gateway.auth.token"), + ]); + }); + + it("does not add a status diagnostic when config uses OPENCLAW_GATEWAY_TOKEN", async () => { + const sourceConfig = { + gateway: { auth: { token: "${OPENCLAW_GATEWAY_TOKEN}" } }, + secrets: { providers: { default: { source: "env" as const } } }, + }; + const readBestEffortConfig = vi.fn(async () => sourceConfig); + const resolveConfig = vi.fn(async () => ({ + resolvedConfig: sourceConfig, + diagnostics: [], + })); + + const result = await loadStatusScanCommandConfig({ + commandName: "status --json", + readBestEffortConfig, + resolveConfig, + env: { VITEST: "true", OPENCLAW_GATEWAY_TOKEN: "env-token" }, + allowMissingConfigFastPath: true, + }); + + expect(result.secretDiagnostics).toEqual([]); + }); + + it("does not add a status diagnostic for remote gateway mode", async () => { + const sourceConfig = { + gateway: { + mode: "remote" as const, + remote: { token: "remote-token" }, + auth: { token: "local-token" }, + }, + }; + const readBestEffortConfig = vi.fn(async () => sourceConfig); + const resolveConfig = vi.fn(async () => ({ + resolvedConfig: sourceConfig, + diagnostics: [], + })); + + const result = await loadStatusScanCommandConfig({ + commandName: "status --json", + readBestEffortConfig, + resolveConfig, + env: { VITEST: "true", OPENCLAW_GATEWAY_TOKEN: "env-token" }, + allowMissingConfigFastPath: true, + }); + + expect(result.secretDiagnostics).toEqual([]); + }); }); diff --git a/src/commands/status.scan.config-shared.ts b/src/commands/status.scan.config-shared.ts index 1cdddddff2b..1025dde8494 100644 --- a/src/commands/status.scan.config-shared.ts +++ b/src/commands/status.scan.config-shared.ts @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import { resolveConfigPath } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.js"; +import { resolveGatewayAuthTokenSourceConflict } from "../gateway/auth-token-source-conflict.js"; export function shouldSkipStatusScanMissingConfigFastPath( env: NodeJS.ProcessEnv = process.env, @@ -45,10 +46,11 @@ export async function loadStatusScanCommandConfig(params: { coldStart && params.allowMissingConfigFastPath === true ? { resolvedConfig: sourceConfig, diagnostics: [] } : await params.resolveConfig(sourceConfig); + const tokenConflict = resolveGatewayAuthTokenSourceConflict({ cfg: sourceConfig, env }); return { coldStart, sourceConfig, resolvedConfig, - secretDiagnostics: diagnostics, + secretDiagnostics: tokenConflict ? [...diagnostics, tokenConflict.diagnostic] : diagnostics, }; } diff --git a/src/gateway/auth-token-source-conflict.ts b/src/gateway/auth-token-source-conflict.ts new file mode 100644 index 00000000000..9aedeecfb89 --- /dev/null +++ b/src/gateway/auth-token-source-conflict.ts @@ -0,0 +1,68 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeSecretInputString, resolveSecretInputRef } from "../config/types.secrets.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; + +const GATEWAY_ENV_TOKEN = "OPENCLAW_GATEWAY_TOKEN"; + +export type GatewayAuthTokenSourceConflict = { + checkId: "gateway.env_token_overrides_config"; + title: string; + detail: string; + remediation: string; + warningLines: string[]; + diagnostic: string; +}; + +export function resolveGatewayAuthTokenSourceConflict(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): GatewayAuthTokenSourceConflict | null { + const envToken = normalizeOptionalString(params.env.OPENCLAW_GATEWAY_TOKEN); + if (!envToken) { + return null; + } + + if (params.cfg.gateway?.mode === "remote") { + return null; + } + + const authMode = params.cfg.gateway?.auth?.mode; + if (authMode === "password" || authMode === "none" || authMode === "trusted-proxy") { + return null; + } + + const tokenInput = params.cfg.gateway?.auth?.token; + const { ref } = resolveSecretInputRef({ + value: tokenInput, + defaults: params.cfg.secrets?.defaults, + }); + if (ref?.source === "env" && ref.id === GATEWAY_ENV_TOKEN) { + return null; + } + + const configToken = ref ? undefined : normalizeSecretInputString(tokenInput); + if (!ref && !configToken) { + return null; + } + if (configToken === envToken) { + return null; + } + + const title = `${GATEWAY_ENV_TOKEN} overrides gateway.auth.token for CLI commands`; + const detail = + `${GATEWAY_ENV_TOKEN} is set while gateway.auth.token uses a different configured source. ` + + "CLI commands use env-first precedence, but the gateway server uses config-first precedence. " + + "If the values differ, CLI commands can fail to authenticate with the running gateway."; + const remediation = + `Remove ${GATEWAY_ENV_TOKEN} from the shell if gateway.auth.token is intended, ` + + "or point gateway.auth.token at the same env source if the env var should be canonical."; + + return { + checkId: "gateway.env_token_overrides_config", + title, + detail, + remediation, + warningLines: [`- WARNING: ${title}.`, ` ${detail}`, ` Fix: ${remediation}`], + diagnostic: `${title}: ${remediation}`, + }; +} diff --git a/src/security/audit-gateway-config.ts b/src/security/audit-gateway-config.ts index 018ce01490f..8318f0e8771 100644 --- a/src/security/audit-gateway-config.ts +++ b/src/security/audit-gateway-config.ts @@ -2,6 +2,7 @@ import { isIP } from "node:net"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { hasConfiguredSecretInput } from "../config/types.secrets.js"; import { resolveGatewayAuth } from "../gateway/auth-resolve.js"; +import { resolveGatewayAuthTokenSourceConflict } from "../gateway/auth-token-source-conflict.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, @@ -116,6 +117,17 @@ export function collectGatewayConfigFindings( }); } + const tokenConflict = resolveGatewayAuthTokenSourceConflict({ cfg: sourceConfig, env }); + if (tokenConflict) { + findings.push({ + checkId: tokenConflict.checkId, + severity: "warn", + title: tokenConflict.title, + detail: tokenConflict.detail, + remediation: tokenConflict.remediation, + }); + } + if (bind === "loopback" && controlUiEnabled && trustedProxies.length === 0) { findings.push({ checkId: "gateway.trusted_proxies_missing", diff --git a/src/security/audit-gateway.test.ts b/src/security/audit-gateway.test.ts index 664fc6e4409..6f3ed1e0d3b 100644 --- a/src/security/audit-gateway.test.ts +++ b/src/security/audit-gateway.test.ts @@ -111,4 +111,42 @@ describe("security audit gateway config findings", () => { })(), ]); }); + + it("warns when OPENCLAW_GATEWAY_TOKEN shadows a different configured token source", async () => { + const cfg: OpenClawConfig = { + gateway: { auth: { token: "config-token" } }, + }; + const findings = collectGatewayConfigFindings(cfg, cfg, { + OPENCLAW_GATEWAY_TOKEN: "env-token", + }); + + expect(hasFinding("gateway.env_token_overrides_config", findings)).toBe(true); + }); + + it("does not warn when gateway.auth.token resolves from OPENCLAW_GATEWAY_TOKEN", async () => { + const cfg: OpenClawConfig = { + gateway: { auth: { token: "${OPENCLAW_GATEWAY_TOKEN}" } }, + secrets: { providers: { default: { source: "env" } } }, + }; + const findings = collectGatewayConfigFindings(cfg, cfg, { + OPENCLAW_GATEWAY_TOKEN: "env-token", + }); + + expect(hasFinding("gateway.env_token_overrides_config", findings)).toBe(false); + }); + + it("does not warn about local gateway auth token precedence in remote mode", async () => { + const cfg: OpenClawConfig = { + gateway: { + mode: "remote", + remote: { token: "remote-token" }, + auth: { token: "local-token" }, + }, + }; + const findings = collectGatewayConfigFindings(cfg, cfg, { + OPENCLAW_GATEWAY_TOKEN: "env-token", + }); + + expect(hasFinding("gateway.env_token_overrides_config", findings)).toBe(false); + }); });