fix(doctor): canonicalize gateway service entrypoint paths

This commit is contained in:
Nimrod Gutman
2026-03-12 11:11:24 +02:00
parent 783a0d540f
commit ab2961841a
3 changed files with 78 additions and 5 deletions

View File

@@ -29,6 +29,7 @@ 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 @realriphub.
## 2026.3.11

View File

@@ -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<typeof import("node:fs/promises")>("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,50 @@ 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("treats SecretRef-managed gateway token as non-persisted service state", async () => {
mocks.readCommand.mockResolvedValue({
programArguments: gatewayProgramArguments,

View File

@@ -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<string> {
const resolvedPath = path.isAbsolute(value) ? value : 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,