From bbe9cb30224af989782d42d6de8e8053abaee51b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 3 Feb 2026 17:57:44 -0800 Subject: [PATCH] fix(update): honor update.channel for update.run --- CHANGELOG.md | 1 + src/gateway/server-methods/update.ts | 5 ++ .../server.roles-allowlist-update.e2e.test.ts | 33 +++++++++++++ src/infra/update-runner.test.ts | 48 +++++++++++++++++++ src/infra/update-runner.ts | 13 ++++- 5 files changed, 98 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11638619e..69ff9d2e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Security: require operator.approvals for gateway /approve commands. (#1) Thanks @mitsuhiko, @yueyueL. +- Updates: honor update.channel for update.run (Control UI) and channel-based npm tags for global installs. - Security: Matrix allowlists now require full MXIDs; ambiguous name resolution no longer grants access. Thanks @MegaManSec. - Security: enforce access-group gating for Slack slash commands when channel type lookup fails. - Security: require validated shared-secret auth before skipping device identity on gateway connect. diff --git a/src/gateway/server-methods/update.ts b/src/gateway/server-methods/update.ts index 5f7bdcee3..fa887c944 100644 --- a/src/gateway/server-methods/update.ts +++ b/src/gateway/server-methods/update.ts @@ -1,4 +1,5 @@ import type { GatewayRequestHandlers } from "./types.js"; +import { loadConfig } from "../../config/config.js"; import { resolveOpenClawPackageRoot } from "../../infra/openclaw-root.js"; import { formatDoctorNonInteractiveHint, @@ -6,6 +7,7 @@ import { writeRestartSentinel, } from "../../infra/restart-sentinel.js"; import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; +import { normalizeUpdateChannel } from "../../infra/update-channels.js"; import { runGatewayUpdate } from "../../infra/update-runner.js"; import { ErrorCodes, @@ -48,6 +50,8 @@ export const updateHandlers: GatewayRequestHandlers = { let result: Awaited>; try { + const config = loadConfig(); + const configChannel = normalizeUpdateChannel(config.update?.channel); const root = (await resolveOpenClawPackageRoot({ moduleUrl: import.meta.url, @@ -58,6 +62,7 @@ export const updateHandlers: GatewayRequestHandlers = { timeoutMs, cwd: root, argv1: process.argv[1], + channel: configChannel ?? undefined, }); } catch (err) { result = { diff --git a/src/gateway/server.roles-allowlist-update.e2e.test.ts b/src/gateway/server.roles-allowlist-update.e2e.test.ts index aa3cf213b..1e63c588e 100644 --- a/src/gateway/server.roles-allowlist-update.e2e.test.ts +++ b/src/gateway/server.roles-allowlist-update.e2e.test.ts @@ -16,6 +16,8 @@ vi.mock("../infra/update-runner.js", () => ({ })), })); +import { writeConfigFile } from "../config/config.js"; +import { runGatewayUpdate } from "../infra/update-runner.js"; import { sleep } from "../utils.js"; import { connectOk, @@ -193,6 +195,37 @@ describe("gateway update.run", () => { process.off("SIGUSR1", sigusr1); } }); + + test("uses configured update channel", async () => { + const sigusr1 = vi.fn(); + process.on("SIGUSR1", sigusr1); + + try { + await writeConfigFile({ update: { channel: "beta" } }); + const updateMock = vi.mocked(runGatewayUpdate); + updateMock.mockClear(); + + const id = "req-update-channel"; + ws.send( + JSON.stringify({ + type: "req", + id, + method: "update.run", + params: { + restartDelayMs: 0, + }, + }), + ); + const res = await onceMessage<{ ok: boolean; payload?: unknown }>( + ws, + (o) => o.type === "res" && o.id === id, + ); + expect(res.ok).toBe(true); + expect(updateMock.mock.calls[0]?.[0]?.channel).toBe("beta"); + } finally { + process.off("SIGUSR1", sigusr1); + } + }); }); describe("gateway node command allowlist", () => { diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index 853d876dd..35e22f2bd 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -208,6 +208,54 @@ describe("runGatewayUpdate", () => { expect(calls.some((call) => call === "npm i -g openclaw@latest")).toBe(true); }); + it("uses update channel for global npm installs when tag is omitted", async () => { + const nodeModules = path.join(tempDir, "node_modules"); + const pkgRoot = path.join(nodeModules, "openclaw"); + await fs.mkdir(pkgRoot, { recursive: true }); + await fs.writeFile( + path.join(pkgRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "1.0.0" }), + "utf-8", + ); + + const calls: string[] = []; + const runCommand = async (argv: string[]) => { + const key = argv.join(" "); + calls.push(key); + if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) { + return { stdout: "", stderr: "not a git repository", code: 128 }; + } + if (key === "npm root -g") { + return { stdout: nodeModules, stderr: "", code: 0 }; + } + if (key === "npm i -g openclaw@beta") { + await fs.writeFile( + path.join(pkgRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2.0.0" }), + "utf-8", + ); + return { stdout: "ok", stderr: "", code: 0 }; + } + if (key === "pnpm root -g") { + return { stdout: "", stderr: "", code: 1 }; + } + return { stdout: "", stderr: "", code: 0 }; + }; + + const result = await runGatewayUpdate({ + cwd: pkgRoot, + runCommand: async (argv, _options) => runCommand(argv), + timeoutMs: 5000, + channel: "beta", + }); + + expect(result.status).toBe("ok"); + expect(result.mode).toBe("npm"); + expect(result.before?.version).toBe("1.0.0"); + expect(result.after?.version).toBe("2.0.0"); + expect(calls.some((call) => call === "npm i -g openclaw@beta")).toBe(true); + }); + it("cleans stale npm rename dirs before global update", async () => { const nodeModules = path.join(tempDir, "node_modules"); const pkgRoot = path.join(nodeModules, "openclaw"); diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index e0d1c2428..498af09bc 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -3,7 +3,14 @@ import os from "node:os"; import path from "node:path"; import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js"; import { trimLogTail } from "./restart-sentinel.js"; -import { DEV_BRANCH, isBetaTag, isStableTag, type UpdateChannel } from "./update-channels.js"; +import { + channelToNpmTag, + DEFAULT_PACKAGE_CHANNEL, + DEV_BRANCH, + isBetaTag, + isStableTag, + type UpdateChannel, +} from "./update-channels.js"; import { compareSemverStrings } from "./update-check.js"; import { cleanupGlobalRenameDirs, @@ -800,7 +807,9 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< globalRoot: path.dirname(pkgRoot), packageName, }); - const spec = `${packageName}@${normalizeTag(opts.tag)}`; + const channel = opts.channel ?? DEFAULT_PACKAGE_CHANNEL; + const tag = normalizeTag(opts.tag ?? channelToNpmTag(channel)); + const spec = `${packageName}@${tag}`; const updateStep = await runStep({ runCommand, name: "global update",