diff --git a/CHANGELOG.md b/CHANGELOG.md index 97cea44031c..8b646a3f133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai - CLI/Doctor: ensure `openclaw doctor --fix --non-interactive --yes` exits promptly after completion so one-shot automation no longer hangs. (#18502) - CLI/Doctor: auto-repair `dmPolicy="open"` configs missing wildcard allowlists and write channel-correct repair paths (including `channels.googlechat.dm.allowFrom`) so `openclaw doctor --fix` no longer leaves Google Chat configs invalid after attempted repair. (#18544) - CLI/Doctor: detect gateway service token drift when the gateway token is only provided via environment variables, keeping service repairs aligned after token rotation. +- CLI/Daemon: warn when a gateway restart sees a stale service token so users can reinstall with `openclaw gateway install --force`, and skip drift warnings for non-gateway service restarts. (#18018) - CLI/Status: fix `openclaw status --all` token summaries for bot-token-only channels so Mattermost/Zalo no longer show a bot+app warning. (#18527) Thanks @echo931. - CLI/Configure: make the `/model picker` allowlist prompt searchable with tokenized matching in `openclaw configure` so users can filter huge model lists by typing terms like `gpt-5.2 openai/`. (#19010) Thanks @bjesuiter. - Voice Call: add an optional stale call reaper (`staleCallReaperSeconds`) to end stuck calls when enabled. (#18437) diff --git a/src/cli/daemon-cli/lifecycle-core.test.ts b/src/cli/daemon-cli/lifecycle-core.test.ts new file mode 100644 index 00000000000..3f13e2b253a --- /dev/null +++ b/src/cli/daemon-cli/lifecycle-core.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const loadConfig = vi.fn(() => ({ + gateway: { + auth: { + token: "config-token", + }, + }, +})); + +const runtimeLogs: string[] = []; +const defaultRuntime = { + log: (message: string) => runtimeLogs.push(message), + error: vi.fn(), + exit: (code: number) => { + throw new Error(`__exit__:${code}`); + }, +}; + +const service = { + label: "TestService", + loadedText: "loaded", + notLoadedText: "not loaded", + install: vi.fn(), + uninstall: vi.fn(), + stop: vi.fn(), + isLoaded: vi.fn(), + readCommand: vi.fn(), + readRuntime: vi.fn(), + restart: vi.fn(), +}; + +vi.mock("../../config/config.js", () => ({ + loadConfig: () => loadConfig(), +})); + +vi.mock("../../runtime.js", () => ({ + defaultRuntime, +})); + +describe("runServiceRestart token drift", () => { + beforeEach(() => { + runtimeLogs.length = 0; + loadConfig.mockClear(); + service.isLoaded.mockClear(); + service.readCommand.mockClear(); + service.restart.mockClear(); + service.isLoaded.mockResolvedValue(true); + service.readCommand.mockResolvedValue({ + environment: { OPENCLAW_GATEWAY_TOKEN: "service-token" }, + }); + service.restart.mockResolvedValue(undefined); + vi.unstubAllEnvs(); + vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", ""); + vi.stubEnv("CLAWDBOT_GATEWAY_TOKEN", ""); + }); + + it("emits drift warning when enabled", async () => { + const { runServiceRestart } = await import("./lifecycle-core.js"); + + await runServiceRestart({ + serviceNoun: "Gateway", + service, + renderStartHints: () => [], + opts: { json: true }, + checkTokenDrift: true, + }); + + expect(loadConfig).toHaveBeenCalledTimes(1); + const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{")); + const payload = JSON.parse(jsonLine ?? "{}") as { warnings?: string[] }; + expect(payload.warnings?.[0]).toContain("gateway install --force"); + }); + + it("skips drift warning when disabled", async () => { + const { runServiceRestart } = await import("./lifecycle-core.js"); + + await runServiceRestart({ + serviceNoun: "Node", + service, + renderStartHints: () => [], + opts: { json: true }, + }); + + expect(loadConfig).not.toHaveBeenCalled(); + expect(service.readCommand).not.toHaveBeenCalled(); + const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{")); + const payload = JSON.parse(jsonLine ?? "{}") as { warnings?: string[] }; + expect(payload.warnings).toBeUndefined(); + }); +}); diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index 981328997e6..8038bb55660 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -236,6 +236,7 @@ export async function runServiceRestart(params: { service: GatewayService; renderStartHints: () => string[]; opts?: DaemonLifecycleOptions; + checkTokenDrift?: boolean; }): Promise { const json = Boolean(params.opts?.json); const { stdout, emit, fail } = createActionIO({ action: "restart", json }); @@ -259,31 +260,33 @@ export async function runServiceRestart(params: { return false; } - // Check for token drift before restart (service token vs config token) const warnings: string[] = []; - try { - const command = await params.service.readCommand(process.env); - const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN; - const cfg = loadConfig(); - const configToken = - cfg.gateway?.auth?.token || - process.env.OPENCLAW_GATEWAY_TOKEN || - process.env.CLAWDBOT_GATEWAY_TOKEN; - const driftIssue = checkTokenDrift({ serviceToken, configToken }); - if (driftIssue) { - const warning = driftIssue.detail - ? `${driftIssue.message} ${driftIssue.detail}` - : driftIssue.message; - warnings.push(warning); - if (!json) { - defaultRuntime.log(`\n⚠️ ${driftIssue.message}`); - if (driftIssue.detail) { - defaultRuntime.log(` ${driftIssue.detail}\n`); + if (params.checkTokenDrift) { + // Check for token drift before restart (service token vs config token) + try { + const command = await params.service.readCommand(process.env); + const serviceToken = command?.environment?.OPENCLAW_GATEWAY_TOKEN; + const cfg = loadConfig(); + const configToken = + cfg.gateway?.auth?.token || + process.env.OPENCLAW_GATEWAY_TOKEN || + process.env.CLAWDBOT_GATEWAY_TOKEN; + const driftIssue = checkTokenDrift({ serviceToken, configToken }); + if (driftIssue) { + const warning = driftIssue.detail + ? `${driftIssue.message} ${driftIssue.detail}` + : driftIssue.message; + warnings.push(warning); + if (!json) { + defaultRuntime.log(`\n⚠️ ${driftIssue.message}`); + if (driftIssue.detail) { + defaultRuntime.log(` ${driftIssue.detail}\n`); + } } } + } catch { + // Non-fatal: token drift check is best-effort } - } catch { - // Non-fatal: token drift check is best-effort } try { diff --git a/src/cli/daemon-cli/lifecycle.ts b/src/cli/daemon-cli/lifecycle.ts index c1b03d9f749..1a0a8f38709 100644 --- a/src/cli/daemon-cli/lifecycle.ts +++ b/src/cli/daemon-cli/lifecycle.ts @@ -46,5 +46,6 @@ export async function runDaemonRestart(opts: DaemonLifecycleOptions = {}): Promi service: resolveGatewayService(), renderStartHints: renderGatewayServiceStartHints, opts, + checkTokenDrift: true, }); }