From 74c00443e0ab7641fa2ee969aac48622f55b27b3 Mon Sep 17 00:00:00 2001 From: mbelinky Date: Mon, 13 Apr 2026 16:02:39 +0200 Subject: [PATCH] Gateway: harden service entrypoint resolution --- extensions/whatsapp/package.json | 3 + src/cli/update-cli.test.ts | 9 +-- src/cli/update-cli/update-command.test.ts | 41 ++++++++++++++ src/cli/update-cli/update-command.ts | 29 +++------- src/daemon/gateway-entrypoint.ts | 67 +++++++++++++++++++++++ src/daemon/program-args.test.ts | 48 +++++++++++++++- src/daemon/program-args.ts | 24 +++++++- 7 files changed, 190 insertions(+), 31 deletions(-) create mode 100644 src/cli/update-cli/update-command.test.ts create mode 100644 src/daemon/gateway-entrypoint.ts diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index aabe94b108b..c281b59f993 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -49,6 +49,9 @@ "build": { "openclawVersion": "2026.4.12" }, + "bundle": { + "stageRuntimeDependencies": true + }, "release": { "publishToClawHub": true, "publishToNpm": true diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index a1d2b3eaf51..cad3b55f03a 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -327,10 +327,11 @@ describe("update-cli", () => { const setupUpdatedRootRefresh = (params?: { gatewayUpdateImpl?: () => Promise; + entrypoints?: string[]; }) => { const root = createCaseDir("openclaw-updated-root"); - const entryPath = path.join(root, "dist", "entry.js"); - pathExists.mockImplementation(async (candidate: string) => candidate === entryPath); + const entrypoints = params?.entrypoints ?? [path.join(root, "dist", "entry.js")]; + pathExists.mockImplementation(async (candidate: string) => entrypoints.includes(candidate)); if (params?.gatewayUpdateImpl) { vi.mocked(runGatewayUpdate).mockImplementation(params.gatewayUpdateImpl); } else { @@ -343,7 +344,7 @@ describe("update-cli", () => { }); } serviceLoaded.mockResolvedValue(true); - return { root, entryPath }; + return { root, entrypoints }; }; beforeEach(() => { @@ -1321,7 +1322,7 @@ describe("update-cli", () => { mock: { calls: Array<[unknown, { cwd?: string }?]> }; }; const root = setup?.root ?? runCommandWithTimeoutMock.mock.calls[0]?.[1]?.cwd; - const entryPath = setup?.entryPath ?? path.join(String(root), "dist", "entry.js"); + const entryPath = setup?.entrypoints?.[0] ?? path.join(String(root), "dist", "entry.js"); expect(runCommandWithTimeout).toHaveBeenCalledWith( [expect.stringMatching(/node/), entryPath, "gateway", "install", "--force"], diff --git a/src/cli/update-cli/update-command.test.ts b/src/cli/update-cli/update-command.test.ts new file mode 100644 index 00000000000..19de7151d7e --- /dev/null +++ b/src/cli/update-cli/update-command.test.ts @@ -0,0 +1,41 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + buildGatewayInstallEntrypointCandidates as resolveGatewayInstallEntrypointCandidates, + resolveGatewayInstallEntrypoint, +} from "../../daemon/gateway-entrypoint.js"; + +describe("resolveGatewayInstallEntrypointCandidates", () => { + it("prefers index.js before legacy entry.js", () => { + expect(resolveGatewayInstallEntrypointCandidates("/tmp/openclaw-root")).toEqual([ + path.join("/tmp/openclaw-root", "dist", "index.js"), + path.join("/tmp/openclaw-root", "dist", "index.mjs"), + path.join("/tmp/openclaw-root", "dist", "entry.js"), + path.join("/tmp/openclaw-root", "dist", "entry.mjs"), + ]); + }); +}); + +describe("resolveGatewayInstallEntrypoint", () => { + it("prefers dist/index.js over dist/entry.js when both exist", async () => { + const root = "/tmp/openclaw-root"; + const indexPath = path.join(root, "dist", "index.js"); + const entryPath = path.join(root, "dist", "entry.js"); + + await expect( + resolveGatewayInstallEntrypoint( + root, + async (candidate) => candidate === indexPath || candidate === entryPath, + ), + ).resolves.toBe(indexPath); + }); + + it("falls back to dist/entry.js when index.js is missing", async () => { + const root = "/tmp/openclaw-root"; + const entryPath = path.join(root, "dist", "entry.js"); + + await expect( + resolveGatewayInstallEntrypoint(root, async (candidate) => candidate === entryPath), + ).resolves.toBe(entryPath); + }); +}); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index c6b334a7f9b..3003c6c3afa 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -13,6 +13,7 @@ import { } from "../../config/config.js"; import { formatConfigIssueLines } from "../../config/issue-format.js"; import { asResolvedSourceConfig, asRuntimeConfig } from "../../config/materialize.js"; +import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js"; import { resolveGatewayService } from "../../daemon/service.js"; import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js"; import { @@ -43,7 +44,6 @@ import { runCommandWithTimeout } from "../../process/exec.js"; import { defaultRuntime } from "../../runtime.js"; import { stylePromptMessage } from "../../terminal/prompt-style.js"; import { theme } from "../../terminal/theme.js"; -import { pathExists } from "../../utils.js"; import { replaceCliName, resolveCliName } from "../cli-name.js"; import { formatCliCommand } from "../command-format.js"; import { installCompletion } from "../completion-runtime.js"; @@ -114,19 +114,6 @@ function pickUpdateQuip(): string { function isPackageManagerUpdateMode(mode: UpdateRunResult["mode"]): mode is "npm" | "pnpm" | "bun" { return mode === "npm" || mode === "pnpm" || mode === "bun"; } - -function resolveGatewayInstallEntrypointCandidates(root?: string): string[] { - if (!root) { - return []; - } - return [ - path.join(root, "dist", "entry.js"), - path.join(root, "dist", "entry.mjs"), - path.join(root, "dist", "index.js"), - path.join(root, "dist", "index.mjs"), - ]; -} - function formatCommandFailure(stdout: string, stderr: string): string { const detail = (stderr || stdout).trim(); if (!detail) { @@ -267,11 +254,9 @@ async function refreshGatewayServiceEnv(params: { args.push("--json"); } - for (const candidate of resolveGatewayInstallEntrypointCandidates(params.result.root)) { - if (!(await pathExists(candidate))) { - continue; - } - const res = await runCommandWithTimeout([resolveNodeRunner(), candidate, ...args], { + const entrypoint = await resolveGatewayInstallEntrypoint(params.result.root); + if (entrypoint) { + const res = await runCommandWithTimeout([resolveNodeRunner(), entrypoint, ...args], { cwd: params.result.root, env: resolveServiceRefreshEnv(process.env, params.invocationCwd), timeoutMs: SERVICE_REFRESH_TIMEOUT_MS, @@ -280,7 +265,7 @@ async function refreshGatewayServiceEnv(params: { return; } throw new Error( - `updated install refresh failed (${candidate}): ${formatCommandFailure(res.stdout, res.stderr)}`, + `updated install refresh failed (${entrypoint}): ${formatCommandFailure(res.stdout, res.stderr)}`, ); } @@ -418,8 +403,8 @@ async function runPackageInstallUpdate(params: { stdoutTail: null, }); } - const entryPath = path.join(verifiedPackageRoot, "dist", "entry.js"); - if (await pathExists(entryPath)) { + const entryPath = await resolveGatewayInstallEntrypoint(verifiedPackageRoot); + if (entryPath) { const doctorStep = await runUpdateStep({ name: `${CLI_NAME} doctor`, argv: [resolveNodeRunner(), entryPath, "doctor", "--non-interactive"], diff --git a/src/daemon/gateway-entrypoint.ts b/src/daemon/gateway-entrypoint.ts new file mode 100644 index 00000000000..9e95927335c --- /dev/null +++ b/src/daemon/gateway-entrypoint.ts @@ -0,0 +1,67 @@ +import path from "node:path"; +import { pathExists } from "../utils.js"; + +const GATEWAY_DIST_ENTRYPOINT_BASENAMES = [ + "index.js", + "index.mjs", + "entry.js", + "entry.mjs", +] as const; + +export function isGatewayDistEntrypointPath(inputPath: string): boolean { + return /[/\\]dist[/\\].+\.(cjs|js|mjs)$/.test(inputPath); +} + +export function buildGatewayInstallEntrypointCandidates(root?: string): string[] { + if (!root) { + return []; + } + return GATEWAY_DIST_ENTRYPOINT_BASENAMES.map((basename) => path.join(root, "dist", basename)); +} + +export function buildGatewayDistEntrypointCandidates(...inputs: string[]): string[] { + const distDirs: string[] = []; + const seenDirs = new Set(); + + for (const inputPath of inputs) { + if (!isGatewayDistEntrypointPath(inputPath)) { + continue; + } + const distDir = path.dirname(inputPath); + if (seenDirs.has(distDir)) { + continue; + } + seenDirs.add(distDir); + distDirs.push(distDir); + } + + const candidates: string[] = []; + for (const basename of GATEWAY_DIST_ENTRYPOINT_BASENAMES) { + for (const distDir of distDirs) { + candidates.push(path.join(distDir, basename)); + } + } + return candidates; +} + +export async function findFirstAccessibleGatewayEntrypoint( + candidates: string[], + exists: (candidate: string) => Promise = pathExists, +): Promise { + for (const candidate of candidates) { + if (await exists(candidate)) { + return candidate; + } + } + return undefined; +} + +export async function resolveGatewayInstallEntrypoint( + root: string | undefined, + exists: (candidate: string) => Promise = pathExists, +): Promise { + return findFirstAccessibleGatewayEntrypoint( + buildGatewayInstallEntrypointCandidates(root), + exists, + ); +} diff --git a/src/daemon/program-args.test.ts b/src/daemon/program-args.test.ts index 5a1fcaf4413..4c46687b076 100644 --- a/src/daemon/program-args.test.ts +++ b/src/daemon/program-args.test.ts @@ -42,6 +42,48 @@ afterEach(() => { }); describe("resolveGatewayProgramArguments", () => { + it("prefers index.js over legacy entry.js when both exist in the same dist directory", async () => { + const entryPath = path.resolve("/opt/openclaw/dist/entry.js"); + const indexPath = path.resolve("/opt/openclaw/dist/index.js"); + process.argv = ["node", entryPath]; + fsMocks.realpath.mockResolvedValue(entryPath); + fsMocks.access.mockResolvedValue(undefined); + + const result = await resolveGatewayProgramArguments({ port: 18789 }); + + expect(result.programArguments).toEqual([ + process.execPath, + indexPath, + "gateway", + "--port", + "18789", + ]); + }); + + it("keeps entry.js when index.js is missing", async () => { + const entryPath = path.resolve("/opt/openclaw/dist/entry.js"); + const indexPath = path.resolve("/opt/openclaw/dist/index.js"); + const indexMjsPath = path.resolve("/opt/openclaw/dist/index.mjs"); + process.argv = ["node", entryPath]; + fsMocks.realpath.mockResolvedValue(entryPath); + fsMocks.access.mockImplementation(async (target: string) => { + if (target === indexPath || target === indexMjsPath) { + throw new Error("missing"); + } + return; + }); + + const result = await resolveGatewayProgramArguments({ port: 18789 }); + + expect(result.programArguments).toEqual([ + process.execPath, + entryPath, + "gateway", + "--port", + "18789", + ]); + }); + it("uses realpath-resolved dist entry when running via npx shim", async () => { const argv1 = path.resolve("/tmp/.npm/_npx/63c3/node_modules/.bin/openclaw"); const entryPath = path.resolve("/tmp/.npm/_npx/63c3/node_modules/openclaw/dist/entry.js"); @@ -80,8 +122,10 @@ describe("resolveGatewayProgramArguments", () => { const result = await resolveGatewayProgramArguments({ port: 18789 }); - // Should use the symlinked path, not the realpath-resolved versioned path - expect(result.programArguments[1]).toBe(symlinkPath); + // Should use the symlinked canonical index.js path, not the realpath-resolved versioned path + expect(result.programArguments[1]).toBe( + path.resolve("/Users/test/Library/pnpm/global/5/node_modules/openclaw/dist/index.js"), + ); expect(result.programArguments[1]).not.toContain("@2026.1.21-2"); }); diff --git a/src/daemon/program-args.ts b/src/daemon/program-args.ts index fd5c7b468ef..d435649fe60 100644 --- a/src/daemon/program-args.ts +++ b/src/daemon/program-args.ts @@ -1,5 +1,10 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { + buildGatewayDistEntrypointCandidates, + findFirstAccessibleGatewayEntrypoint, + isGatewayDistEntrypointPath, +} from "./gateway-entrypoint.js"; import { isBunRuntime, isNodeRuntime } from "./runtime-binary.js"; type GatewayProgramArgs = { @@ -17,15 +22,28 @@ async function resolveCliEntrypointPathForService(): Promise { const normalized = path.resolve(argv1); const resolvedPath = await resolveRealpathSafe(normalized); - const looksLikeDist = /[/\\]dist[/\\].+\.(cjs|js|mjs)$/.test(resolvedPath); + const looksLikeDist = isGatewayDistEntrypointPath(resolvedPath); if (looksLikeDist) { - await fs.access(resolvedPath); + const preferredDistEntrypoint = await findFirstAccessibleGatewayEntrypoint( + buildGatewayDistEntrypointCandidates(normalized, resolvedPath), + async (candidate) => { + try { + await fs.access(candidate); + return true; + } catch { + return false; + } + }, + ); + if (preferredDistEntrypoint) { + return preferredDistEntrypoint; + } // Prefer the original (possibly symlinked) path over the resolved realpath. // This keeps LaunchAgent/systemd paths stable across package version updates, // since symlinks like node_modules/openclaw -> .pnpm/openclaw@X.Y.Z/... // are automatically updated by pnpm, while the resolved path contains // version-specific directories that break after updates. - const normalizedLooksLikeDist = /[/\\]dist[/\\].+\.(cjs|js|mjs)$/.test(normalized); + const normalizedLooksLikeDist = isGatewayDistEntrypointPath(normalized); if (normalizedLooksLikeDist && normalized !== resolvedPath) { try { await fs.access(normalized);