mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 15:30:39 +00:00
fix(doctor): canonicalize gateway service entrypoint paths (#43882)
Merged via squash.
Prepared head SHA: 9f530d2a86
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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.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,
|
||||
|
||||
Reference in New Issue
Block a user