diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bf5bdfcee0..7b5478dd9f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai - Exec approvals: detect `env -S` split-string command-carrier risks when `-S`/`-s` is combined with other env short options, so approval explanations do not miss split payloads hidden behind `env -iS...`. Thanks @vincentkoc. - Google Meet: log the concrete agent-mode TTS provider, model, voice, output format, and sample rate after speech synthesis, so Meet logs show which voice backend spoke each reply. - Voice Call: mark realtime calls completed when the realtime provider closes normally, so Twilio/OpenAI/Google realtime stop events do not leave active call records behind. Thanks @vincentkoc. +- Gateway/update: keep the shutdown close path behind a stable runtime chunk and ship compatibility aliases for recent `server-close-*` hashes, so manual npm package replacement cannot leave an already-running Gateway unable to shut down cleanly. Fixes #77087. Thanks @westlife219. - Exec approvals: treat POSIX `exec` as a command carrier for inline eval, shell-wrapper, and eval/source detection, so approval explanations and command-risk checks do not miss payloads hidden behind `exec`. Thanks @vincentkoc. - Google Meet: log the resolved audio provider model when starting Chrome and paired-node Meet talk-back bridges, so agent-mode joins show the STT model and bidi joins show the realtime voice model. - Diagnostics: handle missing session-tail files in cron recovery context without tripping extension test typecheck. Thanks @vincentkoc. diff --git a/docs/install/updating.md b/docs/install/updating.md index 8052b7e4ecf..c609c5c2bc0 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -93,6 +93,12 @@ curl -fsSL https://openclaw.ai/install.sh | bash -s -- --install-method npm --ve npm i -g openclaw@latest ``` +Prefer `openclaw update` for supervised installs because it can coordinate the +package swap with the running Gateway service. If you update manually while a +managed Gateway is running, restart the Gateway immediately after the package +manager finishes so the old process does not keep serving from replaced package +files. + When `openclaw update` manages a global npm install, it installs the target into a temporary npm prefix first, verifies the packaged `dist` inventory, then swaps the clean package tree into the real global prefix. That avoids npm overlaying a diff --git a/scripts/runtime-postbuild.mjs b/scripts/runtime-postbuild.mjs index 15384e913ba..83a45b594f4 100644 --- a/scripts/runtime-postbuild.mjs +++ b/scripts/runtime-postbuild.mjs @@ -36,6 +36,10 @@ const LEGACY_ROOT_RUNTIME_COMPAT_ALIASES = [ ["route-reply.runtime-uzaOjbd1.js", "route-reply.runtime.js"], ["runtime-plugins.runtime-CNAfmQRG.js", "runtime-plugins.runtime.js"], ["tts.runtime-D-THXDsp.js", "tts.runtime.js"], + // v2026.5.2 -> v2026.5.3-beta.3 gateway shutdown chunks. The running + // gateway may resolve these only after an npm package tree replacement. + ["server-close-DsVPJDIx.js", "server-close.runtime.js"], + ["server-close-DvAvfgr8.js", "server-close.runtime.js"], ]; const LEGACY_CLI_EXIT_COMPAT_CHUNKS = [ { diff --git a/src/gateway/server-close.runtime.ts b/src/gateway/server-close.runtime.ts new file mode 100644 index 00000000000..55b9929b829 --- /dev/null +++ b/src/gateway/server-close.runtime.ts @@ -0,0 +1 @@ +export * from "./server-close.js"; diff --git a/src/gateway/server-close.test.ts b/src/gateway/server-close.test.ts index 22e2f4155d1..820c7ec5f5c 100644 --- a/src/gateway/server-close.test.ts +++ b/src/gateway/server-close.test.ts @@ -246,6 +246,22 @@ describe("createGatewayCloseHandler", () => { expect(stopChannel).toHaveBeenCalledTimes(2); }); + it("uses caller-provided channel ids instead of the local channel registry", async () => { + mocks.listChannelPlugins.mockReturnValue([]); + const stopChannel = vi.fn(async (_id: string) => undefined); + const close = createGatewayCloseHandler( + createGatewayCloseTestDeps({ + channelIds: ["telegram", "discord"], + stopChannel, + }), + ); + + await close({ reason: "test shutdown" }); + + expect(mocks.listChannelPlugins).not.toHaveBeenCalled(); + expect(stopChannel.mock.calls.map(([id]) => id)).toEqual(["telegram", "discord"]); + }); + it("unsubscribes lifecycle listeners and disposes bundle runtimes during shutdown", async () => { const lifecycleUnsub = vi.fn(); const transcriptUnsub = vi.fn(); diff --git a/src/gateway/server-close.ts b/src/gateway/server-close.ts index 07e08fd666f..467c8415b69 100644 --- a/src/gateway/server-close.ts +++ b/src/gateway/server-close.ts @@ -175,6 +175,7 @@ export function createGatewayCloseHandler(params: { canvasHost: CanvasHostHandler | null; canvasHostServer: CanvasHostServer | null; releasePluginRouteRegistry?: (() => void) | null; + channelIds?: readonly ChannelId[]; stopChannel: (name: ChannelId, accountId?: string) => Promise; pluginServices: PluginServicesHandle | null; disposeSessionMcpRuntimes?: () => Promise; @@ -270,8 +271,9 @@ export function createGatewayCloseHandler(params: { if (params.canvasHostServer) { await shutdownStep("canvas-host-server", () => params.canvasHostServer!.close(), warnings); } - for (const plugin of listChannelPlugins()) { - await shutdownStep(`channel/${plugin.id}`, () => params.stopChannel(plugin.id), warnings); + const channelIds = params.channelIds ?? listChannelPlugins().map((plugin) => plugin.id); + for (const channelId of channelIds) { + await shutdownStep(`channel/${channelId}`, () => params.stopChannel(channelId), warnings); } await shutdownStep("agent-harnesses", () => disposeRegisteredAgentHarnesses(), warnings); await Promise.all([ diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index b6f0e23dea1..e29f2d20f5c 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -169,10 +169,10 @@ async function closeMcpLoopbackServerOnDemand(): Promise { await closeMcpLoopbackServer(); } -let gatewayCloseModulePromise: Promise | null = null; +let gatewayCloseModulePromise: Promise | null = null; -function loadGatewayCloseModule(): Promise { - gatewayCloseModulePromise ??= import("./server-close.js"); +function loadGatewayCloseModule(): Promise { + gatewayCloseModulePromise ??= import("./server-close.runtime.js"); return gatewayCloseModulePromise; } @@ -925,6 +925,7 @@ export async function startGatewayServer( }); const createCloseHandler = () => async (opts?: { reason?: string; restartExpectedMs?: number | null }) => { + const channelIds = listLoadedChannelPlugins().map((plugin) => plugin.id as ChannelId); const { createGatewayCloseHandler } = await loadGatewayCloseModule(); await createGatewayCloseHandler({ bonjourStop: runtimeState.bonjourStop, @@ -932,6 +933,7 @@ export async function startGatewayServer( canvasHost, canvasHostServer, releasePluginRouteRegistry, + channelIds, stopChannel, pluginServices: runtimeState.pluginServices, cron: runtimeState.cronState.cron, diff --git a/test/scripts/runtime-postbuild.test.ts b/test/scripts/runtime-postbuild.test.ts index 8c0f1d02900..d4592ea6a50 100644 --- a/test/scripts/runtime-postbuild.test.ts +++ b/test/scripts/runtime-postbuild.test.ts @@ -197,6 +197,36 @@ describe("runtime postbuild static assets", () => { ); }); + it("rewrites gateway shutdown imports to stable runtime aliases", async () => { + const rootDir = createTempDir("openclaw-runtime-postbuild-"); + const distDir = path.join(rootDir, "dist"); + await fs.mkdir(distDir, { recursive: true }); + await fs.writeFile( + path.join(distDir, "server-close.runtime-AbCd1234.js"), + "export const close = true;\n", + "utf8", + ); + await fs.writeFile( + path.join(distDir, "server.impl-OldHash.js"), + [ + 'const closeModule = () => import("./server-close.runtime-AbCd1234.js");', + 'const ordinaryChunk = () => import("./server-close-OldHash.js");', + "", + ].join("\n"), + "utf8", + ); + + rewriteRootRuntimeImportsToStableAliases({ rootDir }); + + expect(await fs.readFile(path.join(distDir, "server.impl-OldHash.js"), "utf8")).toBe( + [ + 'const closeModule = () => import("./server-close.runtime.js");', + 'const ordinaryChunk = () => import("./server-close-OldHash.js");', + "", + ].join("\n"), + ); + }); + it("keeps hashed imports when a stable runtime alias would collide", async () => { const rootDir = createTempDir("openclaw-runtime-postbuild-"); const distDir = path.join(rootDir, "dist"); @@ -274,6 +304,26 @@ describe("runtime postbuild static assets", () => { ).toBe('export * from "./runtime-plugins.runtime.js";\n'); }); + it("writes compatibility aliases for previous gateway shutdown chunk names", async () => { + const rootDir = createTempDir("openclaw-runtime-postbuild-"); + const distDir = path.join(rootDir, "dist"); + await fs.mkdir(distDir, { recursive: true }); + await fs.writeFile( + path.join(distDir, "server-close.runtime.js"), + 'export * from "./server-close.runtime-NewHash.js";\n', + "utf8", + ); + + writeLegacyRootRuntimeCompatAliases({ rootDir }); + + expect(await fs.readFile(path.join(distDir, "server-close-DsVPJDIx.js"), "utf8")).toBe( + 'export * from "./server-close.runtime.js";\n', + ); + expect(await fs.readFile(path.join(distDir, "server-close-DvAvfgr8.js"), "utf8")).toBe( + 'export * from "./server-close.runtime.js";\n', + ); + }); + it("writes legacy CLI exit compatibility chunks", async () => { const rootDir = createTempDir("openclaw-runtime-postbuild-"); diff --git a/tsdown.config.ts b/tsdown.config.ts index 3f6bc9fee85..c3069661654 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -204,6 +204,7 @@ function buildCoreDistEntries(): Record { "agents/model-catalog.runtime": "src/agents/model-catalog.runtime.ts", "agents/models-config.runtime": "src/agents/models-config.runtime.ts", "cli/gateway-lifecycle.runtime": "src/cli/gateway-cli/lifecycle.runtime.ts", + "server-close.runtime": "src/gateway/server-close.runtime.ts", "plugins/memory-state": "src/plugins/memory-state.ts", "subagent-registry.runtime": "src/agents/subagent-registry.runtime.ts", "task-registry-control.runtime": "src/tasks/task-registry-control.runtime.ts",