diff --git a/CHANGELOG.md b/CHANGELOG.md index 46367249f60..6e5f103e557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. 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..7bbf8e8be23 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(() => { @@ -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"], 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..6eb79fbac7f 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 { @@ -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"], 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);