From 4f620bebe5fbb4beec91c59ff0e5f1015168fb67 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Thu, 12 Mar 2026 12:39:22 +0200 Subject: [PATCH] fix(doctor): canonicalize gateway service entrypoint paths (#43882) Merged via squash. Prepared head SHA: 9f530d2a86b5d30822d4419db7cffae6a560ddca Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Reviewed-by: @ngutman --- CHANGELOG.md | 2 + extensions/zalouser/src/channel.test.ts | 49 +++++-- src/commands/doctor-gateway-services.test.ts | 132 +++++++++++++++++++ src/commands/doctor-gateway-services.ts | 21 ++- 4 files changed, 186 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56374c47b20..e7ed2833506 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ Docs: https://docs.openclaw.ai - Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz. - Sandbox/write: preserve pinned mutation-helper payload stdin so sandboxed `write` no longer reports success while creating empty files. (#43876) Thanks @glitch418x. - Gateway/main-session routing: keep TUI and other `mode:UI` main-session sends on the internal surface when `deliver` is enabled, so replies no longer inherit the session's persisted Telegram/WhatsApp route. (#43918) Thanks @obviyus. +- Doctor/gateway service audit: canonicalize service entrypoint paths before comparing them so symlink-vs-realpath installs no longer trigger false "entrypoint does not match the current install" repair prompts. (#43882) Thanks @ngutman. +- Doctor/gateway service audit: earlier groundwork for this fix landed in the superseded #28338 branch. Thanks @realriphub. ## 2026.3.11 diff --git a/extensions/zalouser/src/channel.test.ts b/extensions/zalouser/src/channel.test.ts index 5580ddfb2e1..f54539ed809 100644 --- a/extensions/zalouser/src/channel.test.ts +++ b/extensions/zalouser/src/channel.test.ts @@ -1,42 +1,65 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { chunkMarkdownText } from "../../../src/auto-reply/chunk.js"; import { zalouserPlugin } from "./channel.js"; import { setZalouserRuntime } from "./runtime.js"; -import { sendReactionZalouser } from "./send.js"; +import { sendMessageZalouser, sendReactionZalouser } from "./send.js"; vi.mock("./send.js", async (importOriginal) => { const actual = (await importOriginal()) as Record; return { ...actual, + sendMessageZalouser: vi.fn(async () => ({ ok: true, messageId: "mid-1" })), sendReactionZalouser: vi.fn(async () => ({ ok: true })), }; }); +const mockSendMessage = vi.mocked(sendMessageZalouser); const mockSendReaction = vi.mocked(sendReactionZalouser); -describe("zalouser outbound chunker", () => { +describe("zalouser outbound", () => { beforeEach(() => { + mockSendMessage.mockClear(); setZalouserRuntime({ channel: { text: { - chunkMarkdownText, + resolveChunkMode: vi.fn(() => "newline"), + resolveTextChunkLimit: vi.fn(() => 10), }, }, } as never); }); - it("chunks without empty strings and respects limit", () => { - const chunker = zalouserPlugin.outbound?.chunker; - expect(chunker).toBeTypeOf("function"); - if (!chunker) { + it("passes markdown chunk settings through sendText", async () => { + const sendText = zalouserPlugin.outbound?.sendText; + expect(sendText).toBeTypeOf("function"); + if (!sendText) { return; } - const limit = 10; - const chunks = chunker("hello world\nthis is a test", limit); - expect(chunks.length).toBeGreaterThan(1); - expect(chunks.every((c) => c.length > 0)).toBe(true); - expect(chunks.every((c) => c.length <= limit)).toBe(true); + const result = await sendText({ + cfg: { channels: { zalouser: { enabled: true } } } as never, + to: "group:123456", + text: "hello world\nthis is a test", + accountId: "default", + } as never); + + expect(mockSendMessage).toHaveBeenCalledWith( + "123456", + "hello world\nthis is a test", + expect.objectContaining({ + profile: "default", + isGroup: true, + textMode: "markdown", + textChunkMode: "newline", + textChunkLimit: 10, + }), + ); + expect(result).toEqual( + expect.objectContaining({ + channel: "zalouser", + messageId: "mid-1", + ok: true, + }), + ); }); }); diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts index 66dd090f2b8..7809f6b003d 100644 --- a/src/commands/doctor-gateway-services.test.ts +++ b/src/commands/doctor-gateway-services.test.ts @@ -2,6 +2,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { withEnvAsync } from "../test-utils/env.js"; +const fsMocks = vi.hoisted(() => ({ + realpath: vi.fn(), +})); + +vi.mock("node:fs/promises", async () => { + const actual = await vi.importActual("node:fs/promises"); + return { + ...actual, + default: { + ...actual, + realpath: fsMocks.realpath, + }, + realpath: fsMocks.realpath, + }; +}); + const mocks = vi.hoisted(() => ({ readCommand: vi.fn(), install: vi.fn(), @@ -137,6 +153,7 @@ function setupGatewayTokenRepairScenario() { describe("maybeRepairGatewayServiceConfig", () => { beforeEach(() => { vi.clearAllMocks(); + fsMocks.realpath.mockImplementation(async (value: string) => value); mocks.resolveGatewayAuthTokenForService.mockImplementation(async (cfg: OpenClawConfig, env) => { const configToken = typeof cfg.gateway?.auth?.token === "string" ? cfg.gateway.auth.token.trim() : undefined; @@ -218,6 +235,121 @@ describe("maybeRepairGatewayServiceConfig", () => { }); }); + it("does not flag entrypoint mismatch when symlink and realpath match", async () => { + mocks.readCommand.mockResolvedValue({ + programArguments: [ + "/usr/bin/node", + "/Users/test/Library/pnpm/global/5/node_modules/openclaw/dist/index.js", + "gateway", + "--port", + "18789", + ], + environment: {}, + }); + mocks.auditGatewayServiceConfig.mockResolvedValue({ + ok: true, + issues: [], + }); + mocks.buildGatewayInstallPlan.mockResolvedValue({ + programArguments: [ + "/usr/bin/node", + "/Users/test/Library/pnpm/global/5/node_modules/.pnpm/openclaw@2026.3.12/node_modules/openclaw/dist/index.js", + "gateway", + "--port", + "18789", + ], + environment: {}, + }); + fsMocks.realpath.mockImplementation(async (value: string) => { + if (value.includes("/global/5/node_modules/openclaw/")) { + return value.replace( + "/global/5/node_modules/openclaw/", + "/global/5/node_modules/.pnpm/openclaw@2026.3.12/node_modules/openclaw/", + ); + } + return value; + }); + + await runRepair({ gateway: {} }); + + expect(mocks.note).not.toHaveBeenCalledWith( + expect.stringContaining("Gateway service entrypoint does not match the current install."), + "Gateway service config", + ); + expect(mocks.install).not.toHaveBeenCalled(); + }); + + it("does not flag entrypoint mismatch when realpath fails but normalized absolute paths match", async () => { + mocks.readCommand.mockResolvedValue({ + programArguments: [ + "/usr/bin/node", + "/opt/openclaw/../openclaw/dist/index.js", + "gateway", + "--port", + "18789", + ], + environment: {}, + }); + mocks.auditGatewayServiceConfig.mockResolvedValue({ + ok: true, + issues: [], + }); + mocks.buildGatewayInstallPlan.mockResolvedValue({ + programArguments: [ + "/usr/bin/node", + "/opt/openclaw/dist/index.js", + "gateway", + "--port", + "18789", + ], + environment: {}, + }); + fsMocks.realpath.mockRejectedValue(new Error("no realpath")); + + await runRepair({ gateway: {} }); + + expect(mocks.note).not.toHaveBeenCalledWith( + expect.stringContaining("Gateway service entrypoint does not match the current install."), + "Gateway service config", + ); + expect(mocks.install).not.toHaveBeenCalled(); + }); + + it("still flags entrypoint mismatch when canonicalized paths differ", async () => { + mocks.readCommand.mockResolvedValue({ + programArguments: [ + "/usr/bin/node", + "/Users/test/.nvm/versions/node/v22.0.0/lib/node_modules/openclaw/dist/index.js", + "gateway", + "--port", + "18789", + ], + environment: {}, + }); + mocks.auditGatewayServiceConfig.mockResolvedValue({ + ok: true, + issues: [], + }); + mocks.buildGatewayInstallPlan.mockResolvedValue({ + programArguments: [ + "/usr/bin/node", + "/Users/test/Library/pnpm/global/5/node_modules/openclaw/dist/index.js", + "gateway", + "--port", + "18789", + ], + environment: {}, + }); + + await runRepair({ gateway: {} }); + + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining("Gateway service entrypoint does not match the current install."), + "Gateway service config", + ); + expect(mocks.install).toHaveBeenCalledTimes(1); + }); + it("treats SecretRef-managed gateway token as non-persisted service state", async () => { mocks.readCommand.mockResolvedValue({ programArguments: gatewayProgramArguments, diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 68adf9374c6..4a6d0fca8ca 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -54,8 +54,13 @@ function findGatewayEntrypoint(programArguments?: string[]): string | null { return programArguments[gatewayIndex - 1] ?? null; } -function normalizeExecutablePath(value: string): string { - return path.resolve(value); +async function normalizeExecutablePath(value: string): Promise { + const resolvedPath = path.resolve(value); + try { + return await fs.realpath(resolvedPath); + } catch { + return resolvedPath; + } } function extractDetailPath(detail: string, prefix: string): string | null { @@ -269,10 +274,16 @@ export async function maybeRepairGatewayServiceConfig( }); const expectedEntrypoint = findGatewayEntrypoint(programArguments); const currentEntrypoint = findGatewayEntrypoint(command.programArguments); + const normalizedExpectedEntrypoint = expectedEntrypoint + ? await normalizeExecutablePath(expectedEntrypoint) + : null; + const normalizedCurrentEntrypoint = currentEntrypoint + ? await normalizeExecutablePath(currentEntrypoint) + : null; if ( - expectedEntrypoint && - currentEntrypoint && - normalizeExecutablePath(expectedEntrypoint) !== normalizeExecutablePath(currentEntrypoint) + normalizedExpectedEntrypoint && + normalizedCurrentEntrypoint && + normalizedExpectedEntrypoint !== normalizedCurrentEntrypoint ) { audit.issues.push({ code: SERVICE_AUDIT_CODES.gatewayEntrypointMismatch,