fix(gateway): warn on legacy env vars

Fixes #53482.

Supersedes #53667.
This commit is contained in:
Vincent Koc
2026-04-28 03:37:57 -07:00
committed by GitHub
parent d770a3b786
commit aa1834a3ff
6 changed files with 203 additions and 44 deletions

View File

@@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai
- Control UI/WebChat: confirm toolbar New Session button resets before dispatching `/new` while leaving typed `/new` and `/reset` commands immediate. Fixes #45800; refs #27065, #56611, #54499, and #27110. Thanks @aethnova, @kosta228-huli, @adambezemek, and @xss925175263 (xianshishan).
- Agents/models: keep per-agent primary models strict when `fallbacks` is omitted, so probe-only custom providers are not tried as hidden fallback candidates unless the agent explicitly opts in. Fixes #73332. Thanks @haumanto.
- Gateway/models: add `models.pricing.enabled` so offline or restricted-network installs can skip startup OpenRouter and LiteLLM pricing-catalog fetches while keeping explicit model costs working. Fixes #53639. Thanks @callebtc, @palewire, and @rjdjohnston.
- Gateway/startup: warn when legacy `CLAWDBOT_*` or `MOLTBOT_*` environment variables are still present, pointing users to `OPENCLAW_*` names instead of failing silently. Fixes #53482; carries forward #53667. Thanks @lndyzwdxhs.
- Onboarding: pin interactive and non-interactive health checks to the just-configured setup token/password so stale `OPENCLAW_GATEWAY_TOKEN` or `OPENCLAW_GATEWAY_PASSWORD` values do not produce false gateway-token-mismatch failures after setup. Fixes #72203. Thanks @galiniliev.
- Doctor/state: require an interactive confirmation before archiving orphan transcript files, so `openclaw doctor --fix` no longer silently renames recoverable session history after upgrades regenerate `sessions.json`. Fixes #73106. Thanks @scottgl9.
- Cron/Telegram: preserve explicit `:topic:` delivery targets over stale session-derived thread IDs when isolated cron announces to Telegram forum topics. Carries forward #59069; refs #49704 and #43808. Thanks @roytong9.

View File

@@ -29,6 +29,9 @@ export {
type ResolvedGatewayAuthModeSource,
} from "./auth-resolve.js";
const LEGACY_OPENCLAW_ENV_NOTE =
" Legacy CLAWDBOT_* and MOLTBOT_* environment variables are ignored; use OPENCLAW_* names.";
export type GatewayAuthResult = {
ok: boolean;
method?:
@@ -223,7 +226,7 @@ export function assertGatewayAuthConfigured(
return;
}
throw new Error(
"gateway auth mode is token, but no token was configured (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN)",
`gateway auth mode is token, but no token was configured (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN).${LEGACY_OPENCLAW_ENV_NOTE}`,
);
}
if (auth.mode === "password" && !auth.password) {
@@ -235,7 +238,9 @@ export function assertGatewayAuthConfigured(
"gateway auth mode is password, but gateway.auth.password contains a provider reference object instead of a resolved string — bootstrap secrets (gateway.auth.password) must be plaintext strings or set via the OPENCLAW_GATEWAY_PASSWORD environment variable because the secrets provider system has not initialised yet at gateway startup", // pragma: allowlist secret
);
}
throw new Error("gateway auth mode is password, but no password was configured");
throw new Error(
`gateway auth mode is password, but no password was configured.${LEGACY_OPENCLAW_ENV_NOTE}`,
);
}
if (auth.mode === "trusted-proxy") {
if (!auth.trustedProxy) {

View File

@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
resetLegacyOpenClawEnvWarningForTest,
warnLegacyOpenClawEnvVars,
} from "./env-deprecation.js";
describe("warnLegacyOpenClawEnvVars", () => {
const originalNodeEnv = process.env.NODE_ENV;
const originalVitest = process.env.VITEST;
let emitWarning: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
resetLegacyOpenClawEnvWarningForTest();
emitWarning = vi.spyOn(process, "emitWarning").mockImplementation(() => {});
delete process.env.NODE_ENV;
delete process.env.VITEST;
});
afterEach(() => {
emitWarning.mockRestore();
resetLegacyOpenClawEnvWarningForTest();
restoreEnv("NODE_ENV", originalNodeEnv);
restoreEnv("VITEST", originalVitest);
});
it("warns with counts and prefixes instead of secret-shaped env names", () => {
warnLegacyOpenClawEnvVars({
CLAWDBOT_GATEWAY_TOKEN: "old-token",
MOLTBOT_GATEWAY_PASSWORD: "old-password", // pragma: allowlist secret
"CLAWDBOT_MALICIOUS\nforged": "old-value",
});
expect(emitWarning).toHaveBeenCalledOnce();
const [message, options] = emitWarning.mock.calls[0] as [
string,
{ code: string; type: string },
];
expect(message).toContain("Legacy CLAWDBOT_*, MOLTBOT_* environment variables");
expect(message).toContain("3 total");
expect(message).toContain("replacing the legacy prefix with OPENCLAW_");
expect(message).not.toContain("GATEWAY_TOKEN");
expect(message).not.toContain("GATEWAY_PASSWORD");
expect(message).not.toContain("forged");
expect(options).toEqual({
code: "OPENCLAW_LEGACY_ENV_VARS",
type: "DeprecationWarning",
});
});
it("does not warn for current OPENCLAW names", () => {
warnLegacyOpenClawEnvVars({ OPENCLAW_GATEWAY_TOKEN: "token" });
expect(emitWarning).not.toHaveBeenCalled();
});
it("warns only once after a successful emit", () => {
warnLegacyOpenClawEnvVars({ CLAWDBOT_GATEWAY_TOKEN: "old-token" });
warnLegacyOpenClawEnvVars({ MOLTBOT_GATEWAY_TOKEN: "old-token" });
expect(emitWarning).toHaveBeenCalledOnce();
});
it("retries if emitWarning throws before the warning is emitted", () => {
emitWarning
.mockImplementationOnce(() => {
throw new Error("warning sink failed");
})
.mockImplementationOnce(() => {});
expect(() => warnLegacyOpenClawEnvVars({ CLAWDBOT_GATEWAY_TOKEN: "old-token" })).toThrow(
"warning sink failed",
);
warnLegacyOpenClawEnvVars({ CLAWDBOT_GATEWAY_TOKEN: "old-token" });
expect(emitWarning).toHaveBeenCalledTimes(2);
});
it("suppresses warning noise based on the passed env", () => {
warnLegacyOpenClawEnvVars({
CLAWDBOT_GATEWAY_TOKEN: "old-token",
VITEST: "true",
});
expect(emitWarning).not.toHaveBeenCalled();
});
it("does not let process.env test flags suppress a synthetic env", () => {
process.env.VITEST = "true";
warnLegacyOpenClawEnvVars({ CLAWDBOT_GATEWAY_TOKEN: "old-token" });
expect(emitWarning).toHaveBeenCalledOnce();
});
});
function restoreEnv(name: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[name];
return;
}
process.env[name] = value;
}

View File

@@ -0,0 +1,42 @@
import { isVitestRuntimeEnv } from "../infra/env.js";
const LEGACY_ENV_PREFIXES = ["CLAWDBOT_", "MOLTBOT_"] as const;
type LegacyEnvPrefix = (typeof LEGACY_ENV_PREFIXES)[number];
let warned = false;
export function warnLegacyOpenClawEnvVars(env: NodeJS.ProcessEnv = process.env): void {
if (warned || isVitestRuntimeEnv(env)) {
return;
}
const prefixCounts = new Map<LegacyEnvPrefix, number>();
for (const key of Object.keys(env)) {
const prefix = LEGACY_ENV_PREFIXES.find((candidate) => key.startsWith(candidate));
if (prefix) {
prefixCounts.set(prefix, (prefixCounts.get(prefix) ?? 0) + 1);
}
}
const legacyVarCount = [...prefixCounts.values()].reduce((total, count) => total + count, 0);
if (legacyVarCount === 0) {
return;
}
const detectedPrefixes = LEGACY_ENV_PREFIXES.filter((prefix) => prefixCounts.has(prefix))
.map((prefix) => `${prefix}*`)
.join(", ");
process.emitWarning(
[
`Legacy ${detectedPrefixes} environment variables were detected (${legacyVarCount} total), but OpenClaw only reads OPENCLAW_* names now.`,
"Rename them by replacing the legacy prefix with OPENCLAW_; the old names are ignored.",
].join("\n"),
{ code: "OPENCLAW_LEGACY_ENV_VARS", type: "DeprecationWarning" },
);
warned = true;
}
export function resetLegacyOpenClawEnvWarningForTest(): void {
warned = false;
}

View File

@@ -10,6 +10,7 @@ import {
resolveGatewayAuth,
} from "./auth.js";
import { normalizeControlUiBasePath } from "./control-ui-shared.js";
import { warnLegacyOpenClawEnvVars } from "./env-deprecation.js";
import { resolveHooksConfig } from "./hooks.js";
import {
defaultGatewayBindMode,
@@ -48,6 +49,8 @@ export async function resolveGatewayRuntimeConfig(params: {
auth?: GatewayAuthConfig;
tailscale?: GatewayTailscaleConfig;
}): Promise<GatewayRuntimeConfig> {
warnLegacyOpenClawEnvVars();
// Tailscale serve/funnel hard-requires loopback. When bind is not
// explicitly set, we must resolve Tailscale mode *before* choosing the
// bind default so that container auto-detection does not override the
@@ -140,7 +143,7 @@ export async function resolveGatewayRuntimeConfig(params: {
}
if (!isLoopbackHost(bindHost) && !hasSharedSecret && authMode !== "trusted-proxy") {
throw new Error(
`refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token/password, or set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD)`,
`refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token/password, or set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD; legacy CLAWDBOT_* and MOLTBOT_* environment variables are ignored)`,
);
}
if (

View File

@@ -3,6 +3,7 @@ import type {
PluginHookGatewayContext,
PluginHookGatewayStartEvent,
} from "../plugins/hook-types.js";
import { withEnvAsync } from "../test-utils/env.js";
const hoisted = vi.hoisted(() => {
const startPluginServices = vi.fn(async () => null);
@@ -319,48 +320,53 @@ describe("startGatewayPostAttachRuntime", () => {
});
it("starts channels without waiting for primary model prewarm completion", async () => {
let resolvePrewarm!: () => void;
const prewarmPrimaryModel = vi.fn(
async () =>
await new Promise<undefined>((resolve) => {
resolvePrewarm = () => resolve(undefined);
}),
await withEnvAsync(
{ OPENCLAW_SKIP_CHANNELS: undefined, OPENCLAW_SKIP_PROVIDERS: undefined },
async () => {
let resolvePrewarm!: () => void;
const prewarmPrimaryModel = vi.fn(
async () =>
await new Promise<undefined>((resolve) => {
resolvePrewarm = () => resolve(undefined);
}),
);
const startChannels = vi.fn(async () => undefined);
const sidecarsPromise = startGatewaySidecars({
cfg: {
hooks: { internal: { enabled: false } },
agents: { defaults: { model: "openai/gpt-5.4" } },
} as never,
pluginRegistry: createPostAttachParams().pluginRegistry,
defaultWorkspaceDir: "/tmp/openclaw-workspace",
deps: {} as never,
startChannels,
prewarmPrimaryModel: prewarmPrimaryModel as never,
log: { warn: vi.fn() },
logHooks: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
logChannels: {
info: vi.fn(),
error: vi.fn(),
},
});
await vi.waitFor(
() => {
expect(prewarmPrimaryModel).toHaveBeenCalledTimes(1);
expect(startChannels).toHaveBeenCalledTimes(1);
},
{ timeout: 2_000 },
);
await sidecarsPromise;
resolvePrewarm();
await Promise.resolve();
},
);
const startChannels = vi.fn(async () => undefined);
const sidecarsPromise = startGatewaySidecars({
cfg: {
hooks: { internal: { enabled: false } },
agents: { defaults: { model: "openai/gpt-5.4" } },
} as never,
pluginRegistry: createPostAttachParams().pluginRegistry,
defaultWorkspaceDir: "/tmp/openclaw-workspace",
deps: {} as never,
startChannels,
prewarmPrimaryModel: prewarmPrimaryModel as never,
log: { warn: vi.fn() },
logHooks: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
logChannels: {
info: vi.fn(),
error: vi.fn(),
},
});
await vi.waitFor(
() => {
expect(prewarmPrimaryModel).toHaveBeenCalledTimes(1);
expect(startChannels).toHaveBeenCalledTimes(1);
},
{ timeout: 2_000 },
);
await sidecarsPromise;
resolvePrewarm();
await Promise.resolve();
});
it("keeps startup-gated methods unavailable while sidecars are still resuming", async () => {