diff --git a/CHANGELOG.md b/CHANGELOG.md index 305d44054ba..8b9b299c0d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 0cd3e9a3dd8..51245b6b906 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -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) { diff --git a/src/gateway/env-deprecation.test.ts b/src/gateway/env-deprecation.test.ts new file mode 100644 index 00000000000..c04bbad1af5 --- /dev/null +++ b/src/gateway/env-deprecation.test.ts @@ -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; + + 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; +} diff --git a/src/gateway/env-deprecation.ts b/src/gateway/env-deprecation.ts new file mode 100644 index 00000000000..f72917b0e0f --- /dev/null +++ b/src/gateway/env-deprecation.ts @@ -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(); + 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; +} diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts index 0d395e11549..88deef0e3bd 100644 --- a/src/gateway/server-runtime-config.ts +++ b/src/gateway/server-runtime-config.ts @@ -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 { + 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 ( diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index ecd3114d309..eb59edf8af1 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -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((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((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 () => {