From d6e85aa6ba208a1c04d3b1b10514821343844e10 Mon Sep 17 00:00:00 2001 From: Operative-001 Date: Mon, 16 Feb 2026 14:03:28 +0100 Subject: [PATCH] fix(daemon): warn on token drift during restart (#18018) When the gateway token in config differs from the token embedded in the service plist/unit file, restart will not apply the new token. This can cause silent auth failures after OAuth token switches. Changes: - Add checkTokenDrift() to service-audit.ts - Call it in runServiceRestart() before restarting - Warn user with suggestion to run 'openclaw gateway install --force' Closes #18018 --- src/cli/daemon-cli/lifecycle-core.ts | 23 +++++++++++++++++ src/daemon/service-audit.test.ts | 38 +++++++++++++++++++++++++++- src/daemon/service-audit.ts | 30 ++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/cli/daemon-cli/lifecycle-core.ts b/src/cli/daemon-cli/lifecycle-core.ts index 9c8b6836b73..a202dc28606 100644 --- a/src/cli/daemon-cli/lifecycle-core.ts +++ b/src/cli/daemon-cli/lifecycle-core.ts @@ -1,5 +1,7 @@ import type { GatewayService } from "../../daemon/service.js"; +import { loadConfig } from "../../config/config.js"; import { resolveIsNixMode } from "../../config/paths.js"; +import { checkTokenDrift } from "../../daemon/service-audit.js"; import { renderSystemdUnavailableHints } from "../../daemon/systemd-hints.js"; import { isSystemdUserServiceAvailable } from "../../daemon/systemd.js"; import { isWSL } from "../../infra/wsl.js"; @@ -255,6 +257,27 @@ export async function runServiceRestart(params: { }); return false; } + + // 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 && !json) { + defaultRuntime.log(`\n⚠️ ${driftIssue.message}`); + if (driftIssue.detail) { + defaultRuntime.log(` ${driftIssue.detail}\n`); + } + } + } catch { + // Non-fatal: token drift check is best-effort + } + try { await params.service.restart({ env: process.env, stdout }); let restarted = true; diff --git a/src/daemon/service-audit.test.ts b/src/daemon/service-audit.test.ts index 10fcd214ae4..4286f4cee60 100644 --- a/src/daemon/service-audit.test.ts +++ b/src/daemon/service-audit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { auditGatewayServiceConfig, SERVICE_AUDIT_CODES } from "./service-audit.js"; +import { auditGatewayServiceConfig, checkTokenDrift, SERVICE_AUDIT_CODES } from "./service-audit.js"; import { buildMinimalServicePath } from "./service-env.js"; describe("auditGatewayServiceConfig", () => { @@ -97,3 +97,39 @@ describe("auditGatewayServiceConfig", () => { ).toBe(false); }); }); + +describe("checkTokenDrift", () => { + it("returns null when both tokens are undefined", () => { + const result = checkTokenDrift({ serviceToken: undefined, configToken: undefined }); + expect(result).toBeNull(); + }); + + it("returns null when both tokens are empty strings", () => { + const result = checkTokenDrift({ serviceToken: "", configToken: "" }); + expect(result).toBeNull(); + }); + + it("returns null when tokens match", () => { + const result = checkTokenDrift({ serviceToken: "same-token", configToken: "same-token" }); + expect(result).toBeNull(); + }); + + it("detects drift when config has token but service has different token", () => { + const result = checkTokenDrift({ serviceToken: "old-token", configToken: "new-token" }); + expect(result).not.toBeNull(); + expect(result?.code).toBe(SERVICE_AUDIT_CODES.gatewayTokenDrift); + expect(result?.message).toContain("differs from service token"); + }); + + it("detects drift when config has token but service has no token", () => { + const result = checkTokenDrift({ serviceToken: undefined, configToken: "new-token" }); + expect(result).not.toBeNull(); + expect(result?.code).toBe(SERVICE_AUDIT_CODES.gatewayTokenDrift); + }); + + it("returns null when service has token but config does not", () => { + // This is not really drift - service will work, just config is incomplete + const result = checkTokenDrift({ serviceToken: "service-token", configToken: undefined }); + expect(result).toBeNull(); + }); +}); diff --git a/src/daemon/service-audit.ts b/src/daemon/service-audit.ts index ce12969fd0a..77a8486a79e 100644 --- a/src/daemon/service-audit.ts +++ b/src/daemon/service-audit.ts @@ -38,6 +38,7 @@ export const SERVICE_AUDIT_CODES = { gatewayRuntimeBun: "gateway-runtime-bun", gatewayRuntimeNodeVersionManager: "gateway-runtime-node-version-manager", gatewayRuntimeNodeSystemMissing: "gateway-runtime-node-system-missing", + gatewayTokenDrift: "gateway-token-drift", launchdKeepAlive: "launchd-keep-alive", launchdRunAtLoad: "launchd-run-at-load", systemdAfterNetworkOnline: "systemd-after-network-online", @@ -360,6 +361,35 @@ async function auditGatewayRuntime( } } +/** + * Check if the service's embedded token differs from the config file token. + * Returns an issue if drift is detected (service will use old token after restart). + */ +export function checkTokenDrift(params: { + serviceToken: string | undefined; + configToken: string | undefined; +}): ServiceConfigIssue | null { + const { serviceToken, configToken } = params; + + // No drift if both are undefined/empty + if (!serviceToken && !configToken) { + return null; + } + + // Drift: config has token, service has different or no token + if (configToken && serviceToken !== configToken) { + return { + code: SERVICE_AUDIT_CODES.gatewayTokenDrift, + message: + "Config token differs from service token. The daemon will use the old token after restart.", + detail: "Run `openclaw gateway install --force` to sync the token.", + level: "recommended", + }; + } + + return null; +} + export async function auditGatewayServiceConfig(params: { env: Record; command: GatewayServiceCommand;