Skip to content

Commit 7dc6007

Browse files
yelogsallyom
andauthored
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 <[email protected]> --------- Signed-off-by: sallyom <[email protected]> Co-authored-by: sallyom <[email protected]>
1 parent 64b1f5f commit 7dc6007

8 files changed

Lines changed: 245 additions & 1 deletion

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ Docs: https://docs.openclaw.ai
7070

7171
### Fixes
7272

73+
- 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.
7374
- 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.
7475
- 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)
7576
- 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.

src/commands/doctor-security.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,54 @@ describe("noteSecurityWarnings gateway exposure", () => {
151151
expect(message).not.toContain("CRITICAL");
152152
});
153153

154+
it("warns when OPENCLAW_GATEWAY_TOKEN env overrides gateway.auth.token config (#74271)", async () => {
155+
process.env.OPENCLAW_GATEWAY_TOKEN = "env-token-123";
156+
const cfg = {
157+
gateway: {
158+
auth: {
159+
token: "config-token-456",
160+
},
161+
},
162+
} as OpenClawConfig;
163+
await noteSecurityWarnings(cfg);
164+
const message = lastMessage();
165+
expect(message).toContain("OPENCLAW_GATEWAY_TOKEN overrides");
166+
expect(message).toContain("env-first precedence");
167+
});
168+
169+
it("does not warn when only env token is set without config token", async () => {
170+
process.env.OPENCLAW_GATEWAY_TOKEN = "env-token-only";
171+
const cfg = { gateway: { bind: "lan" } } as OpenClawConfig;
172+
await noteSecurityWarnings(cfg);
173+
const message = lastMessage();
174+
expect(message).not.toContain("OPENCLAW_GATEWAY_TOKEN overrides");
175+
});
176+
177+
it("does not warn when config token uses OPENCLAW_GATEWAY_TOKEN SecretRef", async () => {
178+
process.env.OPENCLAW_GATEWAY_TOKEN = "env-token-123";
179+
const cfg = {
180+
gateway: { auth: { token: "${OPENCLAW_GATEWAY_TOKEN}" } },
181+
secrets: { providers: { default: { source: "env" } } },
182+
} as OpenClawConfig;
183+
await noteSecurityWarnings(cfg);
184+
const message = lastMessage();
185+
expect(message).not.toContain("OPENCLAW_GATEWAY_TOKEN overrides");
186+
});
187+
188+
it("does not warn about local gateway auth token precedence in remote mode", async () => {
189+
process.env.OPENCLAW_GATEWAY_TOKEN = "env-token-123";
190+
const cfg = {
191+
gateway: {
192+
mode: "remote",
193+
remote: { token: "remote-token" },
194+
auth: { token: "local-token" },
195+
},
196+
} as OpenClawConfig;
197+
await noteSecurityWarnings(cfg);
198+
const message = lastMessage();
199+
expect(message).not.toContain("OPENCLAW_GATEWAY_TOKEN overrides");
200+
});
201+
154202
it("treats whitespace token as missing", async () => {
155203
const cfg = {
156204
gateway: { bind: "lan", auth: { mode: "token", token: " " } },

src/commands/doctor-security.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { formatCliCommand } from "../cli/command-format.js";
44
import type { OpenClawConfig, GatewayBindMode } from "../config/config.js";
55
import type { AgentConfig } from "../config/types.agents.js";
66
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
7+
import { resolveGatewayAuthTokenSourceConflict } from "../gateway/auth-token-source-conflict.js";
78
import { resolveGatewayAuth } from "../gateway/auth.js";
89
import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js";
910
import { resolveExecPolicyScopeSnapshot } from "../infra/exec-approvals-effective.js";
@@ -252,6 +253,11 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) {
252253
}
253254
}
254255

256+
const tokenConflict = resolveGatewayAuthTokenSourceConflict({ cfg, env: process.env });
257+
if (tokenConflict) {
258+
warnings.push(...tokenConflict.warningLines);
259+
}
260+
255261
const warnDmPolicy = async (params: {
256262
label: string;
257263
provider: ChannelId;

src/commands/status.scan.config-shared.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,73 @@ describe("status.scan.config-shared", () => {
8686
secretDiagnostics: ["resolved"],
8787
});
8888
});
89+
90+
it("adds a status diagnostic for gateway token source conflicts", async () => {
91+
const sourceConfig = { gateway: { auth: { token: "config-token" } } };
92+
const resolvedConfig = sourceConfig;
93+
const readBestEffortConfig = vi.fn(async () => sourceConfig);
94+
const resolveConfig = vi.fn(async () => ({
95+
resolvedConfig,
96+
diagnostics: [],
97+
}));
98+
99+
const result = await loadStatusScanCommandConfig({
100+
commandName: "status --json",
101+
readBestEffortConfig,
102+
resolveConfig,
103+
env: { VITEST: "true", OPENCLAW_GATEWAY_TOKEN: "env-token" },
104+
allowMissingConfigFastPath: true,
105+
});
106+
107+
expect(result.secretDiagnostics).toEqual([
108+
expect.stringContaining("OPENCLAW_GATEWAY_TOKEN overrides gateway.auth.token"),
109+
]);
110+
});
111+
112+
it("does not add a status diagnostic when config uses OPENCLAW_GATEWAY_TOKEN", async () => {
113+
const sourceConfig = {
114+
gateway: { auth: { token: "${OPENCLAW_GATEWAY_TOKEN}" } },
115+
secrets: { providers: { default: { source: "env" as const } } },
116+
};
117+
const readBestEffortConfig = vi.fn(async () => sourceConfig);
118+
const resolveConfig = vi.fn(async () => ({
119+
resolvedConfig: sourceConfig,
120+
diagnostics: [],
121+
}));
122+
123+
const result = await loadStatusScanCommandConfig({
124+
commandName: "status --json",
125+
readBestEffortConfig,
126+
resolveConfig,
127+
env: { VITEST: "true", OPENCLAW_GATEWAY_TOKEN: "env-token" },
128+
allowMissingConfigFastPath: true,
129+
});
130+
131+
expect(result.secretDiagnostics).toEqual([]);
132+
});
133+
134+
it("does not add a status diagnostic for remote gateway mode", async () => {
135+
const sourceConfig = {
136+
gateway: {
137+
mode: "remote" as const,
138+
remote: { token: "remote-token" },
139+
auth: { token: "local-token" },
140+
},
141+
};
142+
const readBestEffortConfig = vi.fn(async () => sourceConfig);
143+
const resolveConfig = vi.fn(async () => ({
144+
resolvedConfig: sourceConfig,
145+
diagnostics: [],
146+
}));
147+
148+
const result = await loadStatusScanCommandConfig({
149+
commandName: "status --json",
150+
readBestEffortConfig,
151+
resolveConfig,
152+
env: { VITEST: "true", OPENCLAW_GATEWAY_TOKEN: "env-token" },
153+
allowMissingConfigFastPath: true,
154+
});
155+
156+
expect(result.secretDiagnostics).toEqual([]);
157+
});
89158
});

src/commands/status.scan.config-shared.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { existsSync } from "node:fs";
22
import { resolveConfigPath } from "../config/paths.js";
33
import type { OpenClawConfig } from "../config/types.js";
4+
import { resolveGatewayAuthTokenSourceConflict } from "../gateway/auth-token-source-conflict.js";
45

56
export function shouldSkipStatusScanMissingConfigFastPath(
67
env: NodeJS.ProcessEnv = process.env,
@@ -45,10 +46,11 @@ export async function loadStatusScanCommandConfig(params: {
4546
coldStart && params.allowMissingConfigFastPath === true
4647
? { resolvedConfig: sourceConfig, diagnostics: [] }
4748
: await params.resolveConfig(sourceConfig);
49+
const tokenConflict = resolveGatewayAuthTokenSourceConflict({ cfg: sourceConfig, env });
4850
return {
4951
coldStart,
5052
sourceConfig,
5153
resolvedConfig,
52-
secretDiagnostics: diagnostics,
54+
secretDiagnostics: tokenConflict ? [...diagnostics, tokenConflict.diagnostic] : diagnostics,
5355
};
5456
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { OpenClawConfig } from "../config/types.openclaw.js";
2+
import { normalizeSecretInputString, resolveSecretInputRef } from "../config/types.secrets.js";
3+
import { normalizeOptionalString } from "../shared/string-coerce.js";
4+
5+
const GATEWAY_ENV_TOKEN = "OPENCLAW_GATEWAY_TOKEN";
6+
7+
export type GatewayAuthTokenSourceConflict = {
8+
checkId: "gateway.env_token_overrides_config";
9+
title: string;
10+
detail: string;
11+
remediation: string;
12+
warningLines: string[];
13+
diagnostic: string;
14+
};
15+
16+
export function resolveGatewayAuthTokenSourceConflict(params: {
17+
cfg: OpenClawConfig;
18+
env: NodeJS.ProcessEnv;
19+
}): GatewayAuthTokenSourceConflict | null {
20+
const envToken = normalizeOptionalString(params.env.OPENCLAW_GATEWAY_TOKEN);
21+
if (!envToken) {
22+
return null;
23+
}
24+
25+
if (params.cfg.gateway?.mode === "remote") {
26+
return null;
27+
}
28+
29+
const authMode = params.cfg.gateway?.auth?.mode;
30+
if (authMode === "password" || authMode === "none" || authMode === "trusted-proxy") {
31+
return null;
32+
}
33+
34+
const tokenInput = params.cfg.gateway?.auth?.token;
35+
const { ref } = resolveSecretInputRef({
36+
value: tokenInput,
37+
defaults: params.cfg.secrets?.defaults,
38+
});
39+
if (ref?.source === "env" && ref.id === GATEWAY_ENV_TOKEN) {
40+
return null;
41+
}
42+
43+
const configToken = ref ? undefined : normalizeSecretInputString(tokenInput);
44+
if (!ref && !configToken) {
45+
return null;
46+
}
47+
if (configToken === envToken) {
48+
return null;
49+
}
50+
51+
const title = `${GATEWAY_ENV_TOKEN} overrides gateway.auth.token for CLI commands`;
52+
const detail =
53+
`${GATEWAY_ENV_TOKEN} is set while gateway.auth.token uses a different configured source. ` +
54+
"CLI commands use env-first precedence, but the gateway server uses config-first precedence. " +
55+
"If the values differ, CLI commands can fail to authenticate with the running gateway.";
56+
const remediation =
57+
`Remove ${GATEWAY_ENV_TOKEN} from the shell if gateway.auth.token is intended, ` +
58+
"or point gateway.auth.token at the same env source if the env var should be canonical.";
59+
60+
return {
61+
checkId: "gateway.env_token_overrides_config",
62+
title,
63+
detail,
64+
remediation,
65+
warningLines: [`- WARNING: ${title}.`, ` ${detail}`, ` Fix: ${remediation}`],
66+
diagnostic: `${title}: ${remediation}`,
67+
};
68+
}

src/security/audit-gateway-config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { isIP } from "node:net";
22
import type { OpenClawConfig } from "../config/types.openclaw.js";
33
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
44
import { resolveGatewayAuth } from "../gateway/auth-resolve.js";
5+
import { resolveGatewayAuthTokenSourceConflict } from "../gateway/auth-token-source-conflict.js";
56
import {
67
normalizeLowercaseStringOrEmpty,
78
normalizeOptionalLowercaseString,
@@ -116,6 +117,17 @@ export function collectGatewayConfigFindings(
116117
});
117118
}
118119

120+
const tokenConflict = resolveGatewayAuthTokenSourceConflict({ cfg: sourceConfig, env });
121+
if (tokenConflict) {
122+
findings.push({
123+
checkId: tokenConflict.checkId,
124+
severity: "warn",
125+
title: tokenConflict.title,
126+
detail: tokenConflict.detail,
127+
remediation: tokenConflict.remediation,
128+
});
129+
}
130+
119131
if (bind === "loopback" && controlUiEnabled && trustedProxies.length === 0) {
120132
findings.push({
121133
checkId: "gateway.trusted_proxies_missing",

src/security/audit-gateway.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,42 @@ describe("security audit gateway config findings", () => {
111111
})(),
112112
]);
113113
});
114+
115+
it("warns when OPENCLAW_GATEWAY_TOKEN shadows a different configured token source", async () => {
116+
const cfg: OpenClawConfig = {
117+
gateway: { auth: { token: "config-token" } },
118+
};
119+
const findings = collectGatewayConfigFindings(cfg, cfg, {
120+
OPENCLAW_GATEWAY_TOKEN: "env-token",
121+
});
122+
123+
expect(hasFinding("gateway.env_token_overrides_config", findings)).toBe(true);
124+
});
125+
126+
it("does not warn when gateway.auth.token resolves from OPENCLAW_GATEWAY_TOKEN", async () => {
127+
const cfg: OpenClawConfig = {
128+
gateway: { auth: { token: "${OPENCLAW_GATEWAY_TOKEN}" } },
129+
secrets: { providers: { default: { source: "env" } } },
130+
};
131+
const findings = collectGatewayConfigFindings(cfg, cfg, {
132+
OPENCLAW_GATEWAY_TOKEN: "env-token",
133+
});
134+
135+
expect(hasFinding("gateway.env_token_overrides_config", findings)).toBe(false);
136+
});
137+
138+
it("does not warn about local gateway auth token precedence in remote mode", async () => {
139+
const cfg: OpenClawConfig = {
140+
gateway: {
141+
mode: "remote",
142+
remote: { token: "remote-token" },
143+
auth: { token: "local-token" },
144+
},
145+
};
146+
const findings = collectGatewayConfigFindings(cfg, cfg, {
147+
OPENCLAW_GATEWAY_TOKEN: "env-token",
148+
});
149+
150+
expect(hasFinding("gateway.env_token_overrides_config", findings)).toBe(false);
151+
});
114152
});

0 commit comments

Comments
 (0)