From 52b624ccae8921608004d586dd7183e529897786 Mon Sep 17 00:00:00 2001 From: Sebastian <19554889+sebslight@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:24:09 -0500 Subject: [PATCH] fix(doctor): audit env-only gateway tokens --- CHANGELOG.md | 1 + src/commands/doctor-gateway-services.test.ts | 86 +++++++++++++++++++- src/commands/doctor-gateway-services.ts | 19 ++++- 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a157fe19b7..39e4f66387c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ Docs: https://docs.openclaw.ai - Sessions/Maintenance: archive transcripts when pruning stale sessions, clean expired media in subdirectories, and purge old deleted transcript archives to prevent disk leaks. (#18538) - Infra/Fetch: ensure foreign abort-signal listener cleanup never masks original fetch successes/failures, while still preventing detached-finally unhandled rejection noise in `wrapFetchWithAbortSignal`. Thanks @Jackten. - Heartbeat: allow suppressing tool error warning payloads during heartbeat runs via a new heartbeat config flag. (#18497) Thanks @thewilloftheshadow. +- Heartbeat: include sender metadata (From/To/Provider) in heartbeat prompts so model context matches the delivery target. (#18532) - Heartbeat/Telegram: strip configured `responsePrefix` before heartbeat ack detection (with boundary-safe matching) so prefixed `HEARTBEAT_OK` replies are correctly suppressed instead of leaking into DMs. (#18602) ## 2026.2.15 diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts index 343ba357987..eee37c02df1 100644 --- a/src/commands/doctor-gateway-services.test.ts +++ b/src/commands/doctor-gateway-services.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; const mocks = vi.hoisted(() => ({ @@ -52,6 +52,10 @@ vi.mock("./daemon-install-helpers.js", () => ({ import { maybeRepairGatewayServiceConfig } from "./doctor-gateway-services.js"; describe("maybeRepairGatewayServiceConfig", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("treats gateway.auth.token as source of truth for service token repairs", async () => { mocks.readCommand.mockResolvedValue({ programArguments: ["/usr/bin/node", "/usr/local/bin/openclaw", "gateway", "--port", "18789"], @@ -114,4 +118,84 @@ describe("maybeRepairGatewayServiceConfig", () => { ); expect(mocks.install).toHaveBeenCalledTimes(1); }); + + it("uses OPENCLAW_GATEWAY_TOKEN when config token is missing", async () => { + const previousToken = process.env.OPENCLAW_GATEWAY_TOKEN; + process.env.OPENCLAW_GATEWAY_TOKEN = "env-token"; + try { + mocks.readCommand.mockResolvedValue({ + programArguments: [ + "/usr/bin/node", + "/usr/local/bin/openclaw", + "gateway", + "--port", + "18789", + ], + environment: { + OPENCLAW_GATEWAY_TOKEN: "stale-token", + }, + }); + mocks.auditGatewayServiceConfig.mockResolvedValue({ + ok: false, + issues: [ + { + code: "gateway-token-mismatch", + message: "Gateway service OPENCLAW_GATEWAY_TOKEN does not match gateway.auth.token", + level: "recommended", + }, + ], + }); + mocks.buildGatewayInstallPlan.mockResolvedValue({ + programArguments: [ + "/usr/bin/node", + "/usr/local/bin/openclaw", + "gateway", + "--port", + "18789", + ], + workingDirectory: "/tmp", + environment: { + OPENCLAW_GATEWAY_TOKEN: "env-token", + }, + }); + mocks.install.mockResolvedValue(undefined); + + const cfg: OpenClawConfig = { + gateway: {}, + }; + + await maybeRepairGatewayServiceConfig( + cfg, + "local", + { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + { + confirm: vi.fn().mockResolvedValue(true), + confirmRepair: vi.fn().mockResolvedValue(true), + confirmAggressive: vi.fn().mockResolvedValue(true), + confirmSkipInNonInteractive: vi.fn().mockResolvedValue(true), + select: vi.fn().mockResolvedValue("node"), + shouldRepair: false, + shouldForce: false, + }, + ); + + expect(mocks.auditGatewayServiceConfig).toHaveBeenCalledWith( + expect.objectContaining({ + expectedGatewayToken: "env-token", + }), + ); + expect(mocks.buildGatewayInstallPlan).toHaveBeenCalledWith( + expect.objectContaining({ + token: "env-token", + }), + ); + expect(mocks.install).toHaveBeenCalledTimes(1); + } finally { + if (previousToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = previousToken; + } + } + }); }); diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index a81881212fc..15bbcf8add4 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -4,6 +4,8 @@ import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import type { OpenClawConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js"; import { resolveGatewayPort, resolveIsNixMode } from "../config/paths.js"; import { findExtraGatewayServices, renderGatewayServiceCleanupHints } from "../daemon/inspect.js"; import { renderSystemNodeWarning, resolveSystemNodeInfo } from "../daemon/runtime-paths.js"; @@ -13,11 +15,9 @@ import { SERVICE_AUDIT_CODES, } from "../daemon/service-audit.js"; import { resolveGatewayService } from "../daemon/service.js"; -import type { RuntimeEnv } from "../runtime.js"; import { note } from "../terminal/note.js"; import { buildGatewayInstallPlan } from "./daemon-install-helpers.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME, type GatewayDaemonRuntime } from "./daemon-runtime.js"; -import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js"; const execFileAsync = promisify(execFile); @@ -50,6 +50,16 @@ function normalizeExecutablePath(value: string): string { return path.resolve(value); } +function resolveGatewayAuthToken(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string | undefined { + const configToken = cfg.gateway?.auth?.token?.trim(); + if (configToken) { + return configToken; + } + const envToken = env.OPENCLAW_GATEWAY_TOKEN ?? env.CLAWDBOT_GATEWAY_TOKEN; + const trimmedEnvToken = envToken?.trim(); + return trimmedEnvToken || undefined; +} + function extractDetailPath(detail: string, prefix: string): string | null { if (!detail.startsWith(prefix)) { return null; @@ -115,10 +125,11 @@ export async function maybeRepairGatewayServiceConfig( return; } + const expectedGatewayToken = resolveGatewayAuthToken(cfg, process.env); const audit = await auditGatewayServiceConfig({ env: process.env, command, - expectedGatewayToken: cfg.gateway?.auth?.token, + expectedGatewayToken, }); const needsNodeRuntime = needsNodeRuntimeMigration(audit.issues); const systemNodeInfo = needsNodeRuntime @@ -141,7 +152,7 @@ export async function maybeRepairGatewayServiceConfig( const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({ env: process.env, port, - token: cfg.gateway?.auth?.token ?? process.env.OPENCLAW_GATEWAY_TOKEN, + token: expectedGatewayToken, runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice, nodePath: systemNodePath ?? undefined, warn: (message, title) => note(message, title),