fix(doctor): warn when OPENCLAW_GATEWAY_TOKEN env overrides gateway.auth.token config (#74433)

* fix(doctor): warn when OPENCLAW_GATEWAY_TOKEN env overrides gateway.auth.token config (#74271)

* fix(doctor): narrow gateway token source warning

* test(status): type env secret provider fixture

* fix(doctor): scope gateway token conflict warning to local mode

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
This commit is contained in:
Logan Ye
2026-05-06 02:54:15 +08:00
committed by GitHub
parent 64b1f5fbf4
commit 7dc6007aee
8 changed files with 245 additions and 1 deletions

View File

@@ -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.

View File

@@ -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: " " } },

View File

@@ -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;

View File

@@ -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([]);
});
});

View File

@@ -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,
};
}

View File

@@ -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}`,
};
}

View File

@@ -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",

View File

@@ -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);
});
});