Gateway: harden service entrypoint resolution

This commit is contained in:
mbelinky
2026-04-13 16:02:39 +02:00
parent 418cb55cb9
commit 74c00443e0
7 changed files with 190 additions and 31 deletions

View File

@@ -49,6 +49,9 @@
"build": {
"openclawVersion": "2026.4.12"
},
"bundle": {
"stageRuntimeDependencies": true
},
"release": {
"publishToClawHub": true,
"publishToNpm": true

View File

@@ -327,10 +327,11 @@ describe("update-cli", () => {
const setupUpdatedRootRefresh = (params?: {
gatewayUpdateImpl?: () => Promise<UpdateRunResult>;
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"],

View File

@@ -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);
});
});

View File

@@ -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"],

View File

@@ -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<string>();
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<boolean> = pathExists,
): Promise<string | undefined> {
for (const candidate of candidates) {
if (await exists(candidate)) {
return candidate;
}
}
return undefined;
}
export async function resolveGatewayInstallEntrypoint(
root: string | undefined,
exists: (candidate: string) => Promise<boolean> = pathExists,
): Promise<string | undefined> {
return findFirstAccessibleGatewayEntrypoint(
buildGatewayInstallEntrypointCandidates(root),
exists,
);
}

View File

@@ -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");
});

View File

@@ -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<string> {
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);