mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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: " " } },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
68
src/gateway/auth-token-source-conflict.ts
Normal file
68
src/gateway/auth-token-source-conflict.ts
Normal 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}`,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user