mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:40:44 +00:00
Gateway: harden service entrypoint resolution
This commit is contained in:
@@ -49,6 +49,9 @@
|
||||
"build": {
|
||||
"openclawVersion": "2026.4.12"
|
||||
},
|
||||
"bundle": {
|
||||
"stageRuntimeDependencies": true
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
|
||||
@@ -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"],
|
||||
|
||||
41
src/cli/update-cli/update-command.test.ts
Normal file
41
src/cli/update-cli/update-command.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
|
||||
67
src/daemon/gateway-entrypoint.ts
Normal file
67
src/daemon/gateway-entrypoint.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user