fix(gateway): harden service entrypoint resolution (#65984)

Merged via squash.

Prepared head SHA: 31cbc3349c
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-04-13 17:14:29 +02:00
committed by GitHub
parent 418cb55cb9
commit 8dbe1b4f5a
8 changed files with 211 additions and 32 deletions

View File

@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
- Plugins/status: report the registered context-engine IDs in `plugins inspect` instead of the owning plugin ID, so non-matching engine IDs and multi-engine plugins are classified correctly. (#58766) thanks @zhuisDEV
- Context engines: reject resolved plugin engines whose reported `info.id` does not match their registered slot id, so malformed engines fail fast before id-based runtime branches can misbehave. (#63222) Thanks @fuller-stack-dev.
- WhatsApp: patch installed Baileys media encryption writes during OpenClaw postinstall so the default npm/install.sh delivery path waits for encrypted media files to finish flushing before readback, avoiding transient `ENOENT` crashes on image sends. (#65896) Thanks @frankekn.
- Gateway/update: unify service entrypoint resolution around the canonical bundled gateway entrypoint so update, reinstall, and doctor repair stop drifting between stale `dist/entry.js` and current `dist/index.js` paths. (#65984) Thanks @mbelinky.
## 2026.4.12
### Changes
@@ -769,6 +770,24 @@ Docs: https://docs.openclaw.ai
- Agents/MCP: dispose bundled MCP runtimes after one-shot `openclaw agent --local` runs finish, while preserving bundled MCP state across in-run retries so local JSON runs exit cleanly without restarting stateful MCP tools mid-run.
- Gateway/OpenAI HTTP: restore default operator scopes for bearer-authenticated requests that omit `x-openclaw-scopes`, so headless `/v1/chat/completions` and session-history callers work again after the recent method-scope hardening. (#57596) Thanks @openperf.
- Gateway/attachments: offload large inbound images without leaking `media://` markers into text-only runs, preserve mixed attachment order for model input/transcripts, and fail closed when model image capability cannot be resolved. (#55513) Thanks @Syysean.
- Agents/subagents: fix interim subagent runtime display so `/subagents list` and `/subagents info` stop inflating short runtimes and show second-level durations correctly. (#57739) Thanks @samzong.
- Diffs/config: preserve schema-shaped plugin config parsing from `diffsPluginConfigSchema.safeParse()`, so direct callers keep `defaults` and `security` sections instead of receiving flattened tool defaults. (#57904) Thanks @gumadeiras.
- Diffs: fall back to plain text when `lang` hints are invalid during diff render and viewer hydration, so bad or stale language values no longer break the diff viewer. (#57902) Thanks @gumadeiras.
- Doctor/plugins: skip false Matrix legacy-helper warnings when no migration plans exist, and keep bundled `enabledByDefault` plugins in the gateway startup set. (#57931) Thanks @dinakars777.
- Matrix/CLI send: start one-off Matrix send clients before outbound delivery so `openclaw message send --channel matrix` restores E2EE in encrypted rooms instead of sending plain events. (#57936) Thanks @gumadeiras.
- xAI/Responses: normalize image-bearing tool results for xAI responses payloads, including OpenResponses-style `input_image.source` parts, so image tool replays no longer 422 on the follow-up turn. (#58017) Thanks @neeravmakwana.
- Cron/isolated sessions: carry the full live-session provider, model, and auth-profile selection across retry restarts so cron jobs with model overrides no longer fail or loop on mid-run model-switch requests. (#57972) Thanks @issaba1.
- Matrix/direct rooms: stop trusting remote `is_direct`, honor explicit local `is_direct: false` for discovered DM candidates, and avoid extra member-state lookups for shared rooms so DM routing and repair stay aligned. (#57124) Thanks @w-sss.
- Agents/sandbox: make remote FS bridge reads pin the parent path and open the file atomically in the helper so read access cannot race path resolution. Thanks @AntAISecurityLab and @vincentkoc.
- Tools/web_fetch: add an explicit trusted env-proxy path for proxy-only installs while keeping strict SSRF fetches on the pinned direct path, so trusted proxy routing does not weaken strict destination binding. (#50650) Thanks @kkav004.
- Exec/env: block Python package index override variables from request-scoped host exec environment sanitization so package fetches cannot be redirected through a caller-supplied index. Thanks @nexrin and @vincentkoc.
- Telegram/audio: transcode Telegram voice-note `.ogg` attachments before the local `whisper-cli` auto fallback runs, and keep mention-preflight transcription enabled in auto mode when `tools.media.audio` is unset.
- Matrix/direct rooms: recover fresh auto-joined 1:1 DMs without eagerly persisting invite-only `m.direct` mappings, while keeping named, aliased, and explicitly configured rooms on the room path. (#58024) Thanks @gumadeiras.
- TTS: Restore 3.28 schema compatibility and fallback observability. (#57953) Thanks @joshavant.
- Telegram/forum topics: restore reply routing to the active topic and keep ACP `sessions_spawn(..., thread=true, mode="session")` bound to that same topic instead of falling back to root chat or losing follow-up routing. (#56060) Thanks @one27001.
- Config/SecretRef + Control UI: harden SecretRef redaction round-trip restore, block unsafe raw fallback (force Form mode when raw is unavailable), and preflight submitted-config SecretRefs before config write RPC persistence. (#58044) Thanks @joshavant.
- Config/Telegram: migrate removed `channels.telegram.groupMentionsOnly` into `channels.telegram.groups["*"].requireMention` on load so legacy configs no longer crash at startup. (#55336) thanks @jameslcowan.
- Gateway/SecretRef: resolve restart token drift checks with merged service/runtime env sources and hard-fail unsupported mutable SecretRef plus OAuth-profile combinations so restart warnings and policy enforcement match runtime behavior. (#58141) Thanks @joshavant.
- Telegram/outbound chunking: use static markdown chunking when Telegram runtime state is unavailable so long outbound Telegram messages still split correctly after cold starts. (#57816) Thanks @ForestDengHK.
- Update/Corepack: disable interactive Corepack download prompts during update preflight install unless `COREPACK_ENABLE_DOWNLOAD_PROMPT` is already explicitly set, so `openclaw update` can fetch the repo-pinned pnpm version non-interactively. (#61456) Thanks @p6l-richard.

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(() => {
@@ -457,13 +458,13 @@ describe("update-cli", () => {
});
it("respawns into the updated package root before running post-update tasks", async () => {
const { entryPath } = setupUpdatedRootRefresh();
const { entrypoints } = setupUpdatedRootRefresh();
await updateCommand({ yes: true });
expect(spawn).toHaveBeenCalledWith(
expect.stringMatching(/node/),
[entryPath, "update", "--yes"],
[entrypoints[0], "update", "--yes"],
expect.objectContaining({
stdio: "inherit",
env: expect.objectContaining({
@@ -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 {
@@ -114,19 +115,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 +255,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 +266,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 +404,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);