diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index 0ae4b013bb2e..3fe033155b9c 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -495,7 +495,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return case let .continueLaunch(startUpdater): if startUpdater { - self.updaterController.start() + if OpenClawConfigFile.gatewayUpdateChannel() == nil { + self.updaterController.startAfterResolvingGatewayUpdateChannel() + } else { + self.updaterController.start() + } } } // Remote startup can spawn an SSH child. Admit tunnel work only after the @@ -769,11 +773,16 @@ protocol UpdaterProviding: AnyObject { var isAvailable: Bool { get } var updateStatus: UpdateStatus { get } func start() + func startAfterResolvingGatewayUpdateChannel() func checkForUpdates(_ sender: Any?) } extension UpdaterProviding { func start() {} + + func startAfterResolvingGatewayUpdateChannel() { + self.start() + } } /// No-op updater used for debug/dev runs to suppress Sparkle dialogs. @@ -807,8 +816,28 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding { userDriverDelegate: nil) let updateStatus = UpdateStatus() private var started = false + private var gatewayUpdateChannel: String? + private var resolvingGatewayUpdateChannel = false + private let gatewayUpdateChannelResolver: @MainActor @Sendable () async throws -> String? + private let onStart: (() -> Void)? - init(savedAutoUpdate: Bool) { + init( + savedAutoUpdate: Bool, + gatewayUpdateChannelResolver: (@MainActor @Sendable () async throws -> String?)? = nil, + onStart: (() -> Void)? = nil) + { + self.gatewayUpdateChannelResolver = gatewayUpdateChannelResolver ?? { + struct UpdateStatusResponse: Decodable { + let effectiveChannel: String? + } + guard let data = try? await GatewayConnection.shared.requestRaw( + method: "update.status", + timeoutMs: 5000), + let response = try? JSONDecoder().decode(UpdateStatusResponse.self, from: data) + else { return nil } + return OpenClawConfigFile.normalizedGatewayUpdateChannel(response.effectiveChannel) + } + self.onStart = onStart super.init() let updater = self.controller.updater updater.automaticallyChecksForUpdates = savedAutoUpdate @@ -818,7 +847,24 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding { func start() { guard !self.started else { return } self.started = true - self.controller.startUpdater() + if let onStart = self.onStart { + onStart() + } else { + self.controller.startUpdater() + } + } + + func startAfterResolvingGatewayUpdateChannel() { + guard !self.started, !self.resolvingGatewayUpdateChannel else { return } + self.resolvingGatewayUpdateChannel = true + Task { @MainActor [weak self] in + guard let self else { return } + defer { self.resolvingGatewayUpdateChannel = false } + self.gatewayUpdateChannel = try? await self.gatewayUpdateChannelResolver() + // Older or unreachable Gateways cannot report effectiveChannel. Preserve + // their existing stable Sparkle behavior instead of disabling updates. + self.start() + } } var automaticallyChecksForUpdates: Bool { @@ -874,14 +920,44 @@ func allowedSparkleChannels(forGatewayUpdateChannel channel: String?) -> Set Bool { + channel != "extended-stable" || itemChannel == "extended-stable" +} + extension SparkleUpdaterController: SPUUpdaterDelegate { func allowedChannels(for _: SPUUpdater) -> Set { - allowedSparkleChannels(forGatewayUpdateChannel: OpenClawConfigFile.gatewayUpdateChannel()) + allowedSparkleChannels( + forGatewayUpdateChannel: self.gatewayUpdateChannel ?? OpenClawConfigFile.gatewayUpdateChannel()) + } + + func bestValidUpdate(in appcast: SUAppcast, for _: SPUUpdater) -> SUAppcastItem? { + guard self.gatewayUpdateChannel ?? OpenClawConfigFile.gatewayUpdateChannel() == "extended-stable" else { + return nil + } + let comparator = SUStandardVersionComparator.default + let currentVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + // Sparkle always admits the default channel. Filter it here so an + // extended-stable Gateway is never prompted to leave its release train. + let eligibleItems = appcast.items.filter { + guard isSparkleUpdateAllowed( + itemChannel: $0.channel, + forGatewayUpdateChannel: "extended-stable") + else { return false } + guard let currentVersion else { return true } + return comparator.compareVersion( + $0.versionString, + toVersion: currentVersion) == .orderedDescending + } + return eligibleItems.max { left, right in + comparator.compareVersion(left.versionString, toVersion: right.versionString) == .orderedAscending + } } func updater(_: SPUUpdater, willInstallUpdate item: SUAppcastItem) { diff --git a/apps/macos/Tests/OpenClawIPCTests/UpdateOrchestrationTests.swift b/apps/macos/Tests/OpenClawIPCTests/UpdateOrchestrationTests.swift index 6e5e0da5b0fd..e5ffa22b7c32 100644 --- a/apps/macos/Tests/OpenClawIPCTests/UpdateOrchestrationTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/UpdateOrchestrationTests.swift @@ -313,9 +313,14 @@ struct UpdateOrchestrationTests { OpenClawConfigFile.normalizedGatewayUpdateChannel(" BETA \n")) == ["beta"]) #expect(OpenClawConfigFile.normalizedGatewayUpdateChannel(" \n") == nil) #expect(allowedSparkleChannels(forGatewayUpdateChannel: "stable").isEmpty) - #expect(allowedSparkleChannels(forGatewayUpdateChannel: "extended-stable").isEmpty) + #expect(allowedSparkleChannels(forGatewayUpdateChannel: "extended-stable") == ["extended-stable"]) #expect(allowedSparkleChannels(forGatewayUpdateChannel: "future").isEmpty) #expect(allowedSparkleChannels(forGatewayUpdateChannel: nil).isEmpty) + #expect(isSparkleUpdateAllowed(itemChannel: nil, forGatewayUpdateChannel: "stable")) + #expect(!isSparkleUpdateAllowed(itemChannel: nil, forGatewayUpdateChannel: "extended-stable")) + #expect(isSparkleUpdateAllowed( + itemChannel: "extended-stable", + forGatewayUpdateChannel: "extended-stable")) } #if canImport(Sparkle) @@ -326,6 +331,38 @@ struct UpdateOrchestrationTests { updater.checkForUpdates(nil) #expect(!updater.isAvailable) } + + @Test func `Sparkle keeps legacy stable fallback when Gateway omits update channel`() async { + var starts = 0 + let updater = SparkleUpdaterController( + savedAutoUpdate: false, + gatewayUpdateChannelResolver: { nil }, + onStart: { starts += 1 }) + + updater.startAfterResolvingGatewayUpdateChannel() + for _ in 0 ..< 10 where !updater.isAvailable { + await Task.yield() + } + + #expect(updater.isAvailable) + #expect(starts == 1) + } + + @Test func `Sparkle keeps legacy stable fallback when Gateway channel lookup fails`() async { + var starts = 0 + let updater = SparkleUpdaterController( + savedAutoUpdate: false, + gatewayUpdateChannelResolver: { throw CancellationError() }, + onStart: { starts += 1 }) + + updater.startAfterResolvingGatewayUpdateChannel() + for _ in 0 ..< 10 where !updater.isAvailable { + await Task.yield() + } + + #expect(updater.isAvailable) + #expect(starts == 1) + } #endif @Test func `dashboard accepts only start update payloads`() { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 73c0546aaaad..9ddc87e2144b 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -18070,21 +18070,25 @@ public struct UpdateStatusParams: Codable, Sendable {} public struct UpdateStatusResult: Codable, Sendable { public let sentinel: AnyCodable public let updateavailable: AnyCodable + public let effectivechannel: AnyCodable? public let schedule: UpdateScheduleState? public init( sentinel: AnyCodable, updateavailable: AnyCodable, + effectivechannel: AnyCodable? = nil, schedule: UpdateScheduleState? = nil) { self.sentinel = sentinel self.updateavailable = updateavailable + self.effectivechannel = effectivechannel self.schedule = schedule } private enum CodingKeys: String, CodingKey { case sentinel case updateavailable = "updateAvailable" + case effectivechannel = "effectiveChannel" case schedule } } diff --git a/docs/install/development-channels.md b/docs/install/development-channels.md index bfe3627ab8d6..def7a3f07c23 100644 --- a/docs/install/development-channels.md +++ b/docs/install/development-channels.md @@ -13,8 +13,9 @@ OpenClaw ships four update channels: - **stable**: npm dist-tag `latest`. Recommended for most users. - **extended-stable**: npm dist-tag `extended-stable`. A net-new, trailing supported-month package channel. It is package-only, and installation is - foreground-only. A stored selection receives read-only update hints when - `update.checkOnStart` is enabled, but never applies automatically. + foreground-only. It receives read-only update hints when `update.checkOnStart` + is enabled, including direct final extended-stable package installs, but never + applies automatically. - **beta**: npm dist-tag `beta`. Falls back to `latest` when `beta` is missing or older than the current stable release. - **dev**: moving head of `main` (git). npm dist-tag `dev` when published. `main` diff --git a/docs/install/updating.md b/docs/install/updating.md index 86d54f989810..bb892a135efb 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -46,7 +46,8 @@ or inconsistent registry data fails closed; it never falls back to `latest`. If the selected version is older than the installed version, the normal downgrade confirmation still applies. The CLI persists the channel after a successful core update; a direct `npm install -g openclaw@extended-stable` -does not update `update.channel`. +does not update `update.channel`, but a final extended-stable package version +still checks only the verified `extended-stable` selector for update availability. After the core swap, eligible official npm plugins with bare/default or `latest` intent converge to that exact core version. Exact pins and explicit non-`latest` tags, third-party plugins, and non-npm sources remain unchanged. diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index a4d15850c281..f3c96062a339 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -78,8 +78,9 @@ updates only the local Mac node runtime and skips the notification when the remote Gateway is older than the app. Sparkle follows the Gateway's `update.channel` setting. `beta` and `dev` opt in -to beta app builds; `stable`, `extended-stable`, and missing or unknown values -stay on stable app builds. +to beta app builds; `extended-stable` accepts only extended-stable app releases, +so it stays quiet when no matching app release exists. `stable`, missing, and +unknown values stay on stable app builds. ## Open dashboard links diff --git a/packages/gateway-protocol/src/schema/config.test.ts b/packages/gateway-protocol/src/schema/config.test.ts index 571165c6254c..f4376cffbc49 100644 --- a/packages/gateway-protocol/src/schema/config.test.ts +++ b/packages/gateway-protocol/src/schema/config.test.ts @@ -104,6 +104,7 @@ describe("update protocol schemas", () => { { sha: "def5678", subject: "Second change" }, ], }, + effectiveChannel: "dev", schedule: { channel: "dev", autoEnabled: true, diff --git a/packages/gateway-protocol/src/schema/config.ts b/packages/gateway-protocol/src/schema/config.ts index ddfa96fc1eb7..02197f698b82 100644 --- a/packages/gateway-protocol/src/schema/config.ts +++ b/packages/gateway-protocol/src/schema/config.ts @@ -162,6 +162,14 @@ export const UpdateScheduleStateSchema = closedObject({ export const UpdateStatusResultSchema = closedObject({ sentinel: Type.Unknown(), updateAvailable: Type.Union([UpdateAvailableSchema, Type.Null()]), + effectiveChannel: Type.Optional( + Type.Union([ + Type.Literal("stable"), + Type.Literal("extended-stable"), + Type.Literal("beta"), + Type.Literal("dev"), + ]), + ), schedule: Type.Optional(UpdateScheduleStateSchema), }); diff --git a/scripts/make_appcast.sh b/scripts/make_appcast.sh index 2c6702f50d41..7546a08c756c 100755 --- a/scripts/make_appcast.sh +++ b/scripts/make_appcast.sh @@ -68,6 +68,8 @@ if [[ "$VERSION" == *-alpha.* || "$VERSION" == *.alpha.* ]]; then fi if [[ "$VERSION" == *-beta.* || "$VERSION" == *.beta.* ]]; then CHANNEL_ARGS=(--channel beta) +elif [[ "$VERSION" =~ ^[0-9]{4}\.[1-9][0-9]*\.([0-9]+)$ && "${BASH_REMATCH[1]}" -ge 33 ]]; then + CHANNEL_ARGS=(--channel extended-stable) fi TMP_DIR="$(mktemp -d)" diff --git a/src/cli/update-cli/status.ts b/src/cli/update-cli/status.ts index 3c5e96ac34b2..5683354f7586 100644 --- a/src/cli/update-cli/status.ts +++ b/src/cli/update-cli/status.ts @@ -4,12 +4,12 @@ import { theme } from "../../../packages/terminal-core/src/theme.js"; import { formatUpdateAvailableHint, formatUpdateOneLiner, + resolveStatusRegistryUpdateChannel, resolveUpdateAvailability, } from "../../commands/status.update.js"; import { readSourceConfigBestEffort } from "../../config/config.js"; import { normalizeUpdateChannel, - resolveRegistryUpdateChannel, resolveUpdateChannelDisplay, } from "../../infra/update-channels.js"; import { checkUpdateStatus } from "../../infra/update-check.js"; @@ -49,10 +49,12 @@ export async function updateStatusCommand(opts: UpdateStatusOptions): Promise + resolveStatusRegistryUpdateChannel({ + configChannel, + installKind, + git, + }), }); const channelInfo = resolveUpdateChannelDisplay({ diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 78fc94373f6a..23c84fd49a54 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -15,9 +15,9 @@ import { import { channelToNpmTag, DEFAULT_GIT_CHANNEL, - DEFAULT_PACKAGE_CHANNEL, EXTENDED_STABLE_TAG_UNSUPPORTED_REASON, normalizeUpdateChannel, + resolveEffectiveUpdateChannel, } from "../../infra/update-channels.js"; import { fetchNpmPackageTargetStatus } from "../../infra/update-check-package-target.js"; import { @@ -38,6 +38,7 @@ import { cleanupStaleManagedServiceUpdateHandoffs } from "../../infra/update-man import { loadInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-records.js"; import { defaultRuntime } from "../../runtime.js"; import type { OpenClawSchemaVersions } from "../../state/openclaw-schema-versions.js"; +import { VERSION } from "../../version.js"; import { resolveCliName } from "../cli-name.js"; import { createUpdateProgress } from "./progress.js"; import { @@ -206,7 +207,12 @@ async function updateCommandInternal( const selectedChannel = requestedChannel ?? storedChannel ?? - (installKind === "git" ? DEFAULT_GIT_CHANNEL : DEFAULT_PACKAGE_CHANNEL); + (installKind === "git" + ? DEFAULT_GIT_CHANNEL + : resolveEffectiveUpdateChannel({ + currentVersion: VERSION, + installKind, + }).channel); if (selectedChannel === "extended-stable" && installKind === "git") { await reportPreMutationUpdateFailure({ root, @@ -221,9 +227,15 @@ async function updateCommandInternal( const switchToPackage = requestedChannel !== null && requestedChannel !== "dev" && installKind === "git"; const updateInstallKind = switchToGit ? "git" : switchToPackage ? "package" : installKind; - const defaultChannel = - updateInstallKind === "git" ? DEFAULT_GIT_CHANNEL : DEFAULT_PACKAGE_CHANNEL; - const channel = requestedChannel ?? storedChannel ?? defaultChannel; + const channel = + requestedChannel ?? + storedChannel ?? + (updateInstallKind === "git" + ? DEFAULT_GIT_CHANNEL + : resolveEffectiveUpdateChannel({ + currentVersion: VERSION, + installKind: updateInstallKind, + }).channel); const devTargetRef = channel === "dev" ? process.env.OPENCLAW_UPDATE_DEV_TARGET_REF?.trim() || undefined : undefined; diff --git a/src/cli/update-cli/wizard.ts b/src/cli/update-cli/wizard.ts index 3c2cb22c918b..99612aa59b4b 100644 --- a/src/cli/update-cli/wizard.ts +++ b/src/cli/update-cli/wizard.ts @@ -13,6 +13,7 @@ import { import { checkUpdateStatus } from "../../infra/update-check.js"; import { defaultRuntime } from "../../runtime.js"; import { pathExists } from "../../utils.js"; +import { VERSION } from "../../version.js"; import { isEmptyDir, isGitCheckout, @@ -54,6 +55,7 @@ export async function updateWizardCommand(opts: UpdateWizardOptions = {}): Promi : null; const channelInfo = resolveEffectiveUpdateChannel({ configChannel, + currentVersion: VERSION, installKind: updateStatus.installKind, git: updateStatus.git ? { tag: updateStatus.git.tag, branch: updateStatus.git.branch } diff --git a/src/commands/status.update-channel.test.ts b/src/commands/status.update-channel.test.ts new file mode 100644 index 000000000000..69406120031c --- /dev/null +++ b/src/commands/status.update-channel.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; + +const versionMock = vi.hoisted(() => ({ value: "2026.6.33" })); + +vi.mock("../version.js", () => ({ + get VERSION() { + return versionMock.value; + }, +})); + +const { resolveStatusRegistryUpdateChannel } = await import("./status.update.js"); + +describe("resolveStatusRegistryUpdateChannel", () => { + it("uses extended-stable only for a verified package install", () => { + expect( + resolveStatusRegistryUpdateChannel({ + installKind: "package", + }), + ).toBe("extended-stable"); + expect( + resolveStatusRegistryUpdateChannel({ + installKind: "git", + git: { + root: "/tmp/openclaw", + sha: null, + tag: null, + branch: "main", + upstream: "origin/main", + dirty: false, + ahead: 0, + behind: 0, + fetchOk: true, + }, + }), + ).toBe("dev"); + }); +}); diff --git a/src/commands/status.update.ts b/src/commands/status.update.ts index 1f4c90618797..ea8afa4c9fe4 100644 --- a/src/commands/status.update.ts +++ b/src/commands/status.update.ts @@ -3,7 +3,11 @@ import { formatCliCommand } from "../cli/command-format.js"; import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js"; -import { normalizeUpdateChannel, resolveRegistryUpdateChannel } from "../infra/update-channels.js"; +import { + normalizeUpdateChannel, + resolveEffectiveUpdateChannel, + type UpdateChannel, +} from "../infra/update-channels.js"; import { checkUpdateStatus, compareSemverStrings, @@ -11,6 +15,20 @@ import { } from "../infra/update-check.js"; import { VERSION } from "../version.js"; +/** Chooses a registry tag only after the status check has identified the install. */ +export function resolveStatusRegistryUpdateChannel(params: { + configChannel?: UpdateChannel | null; + installKind: UpdateCheckResult["installKind"]; + git?: UpdateCheckResult["git"]; +}): UpdateChannel { + return resolveEffectiveUpdateChannel({ + configChannel: params.configChannel, + currentVersion: VERSION, + installKind: params.installKind, + git: params.git, + }).channel; +} + /** Runs the update check using the configured update channel and current install root. */ export async function getUpdateCheckResult(params: { timeoutMs: number; @@ -29,10 +47,12 @@ export async function getUpdateCheckResult(params: { timeoutMs: params.timeoutMs, fetchGit: params.fetchGit, includeRegistry: params.includeRegistry, - registryChannel: resolveRegistryUpdateChannel({ - configChannel, - currentVersion: VERSION, - }), + resolveRegistryChannel: ({ installKind, git }) => + resolveStatusRegistryUpdateChannel({ + configChannel, + installKind, + git, + }), }); } diff --git a/src/gateway/server-methods/update-effective-channel.test.ts b/src/gateway/server-methods/update-effective-channel.test.ts new file mode 100644 index 000000000000..c7854ceaa93c --- /dev/null +++ b/src/gateway/server-methods/update-effective-channel.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type TestUpdateAvailable = { + currentVersion: string; + latestVersion: string; + channel: string; +} | null; +type TestUpdateSentinel = { + kind: string; + status: string; + ts: number; + stats: Record; +} | null; +type TestUpdateSchedule = + | import("../../../packages/gateway-protocol/src/index.js").UpdateScheduleState + | null; + +const checkUpdateStatusMock = vi.hoisted(() => vi.fn()); +const versionMock = vi.hoisted(() => ({ value: "1.0.0" })); +const getUpdateAvailableMock = vi.hoisted(() => vi.fn<() => TestUpdateAvailable>(() => null)); +const getUpdateScheduleMock = vi.hoisted(() => vi.fn<() => TestUpdateSchedule>(() => null)); +const refreshGatewayUpdateStatusMock = vi.hoisted(() => vi.fn(async () => {})); +const getLatestUpdateRestartSentinelMock = vi.hoisted(() => + vi.fn<() => TestUpdateSentinel>(() => null), +); +const refreshLatestUpdateRestartSentinelMock = vi.hoisted(() => + vi.fn<() => Promise>(async () => null), +); + +vi.mock("../../infra/openclaw-root.js", async () => { + const actual = await vi.importActual( + "../../infra/openclaw-root.js", + ); + return { ...actual, resolveOpenClawPackageRoot: async () => "/tmp/openclaw" }; +}); + +vi.mock("../../infra/update-check.js", () => ({ + checkUpdateStatus: checkUpdateStatusMock, +})); + +vi.mock("../../infra/update-startup.js", () => ({ + getUpdateAvailable: getUpdateAvailableMock, + getUpdateSchedule: getUpdateScheduleMock, + refreshGatewayUpdateStatus: refreshGatewayUpdateStatusMock, +})); + +vi.mock("../../version.js", () => ({ + get VERSION() { + return versionMock.value; + }, +})); + +vi.mock("../server-restart-sentinel.js", () => ({ + getLatestUpdateRestartSentinel: getLatestUpdateRestartSentinelMock, + refreshLatestUpdateRestartSentinel: refreshLatestUpdateRestartSentinelMock, +})); + +vi.mock("./validation.js", () => ({ + assertValidParams: () => true, +})); + +beforeEach(() => { + versionMock.value = "1.0.0"; + checkUpdateStatusMock.mockReset(); + getUpdateAvailableMock.mockReset(); + getUpdateAvailableMock.mockReturnValue(null); + getUpdateScheduleMock.mockReset(); + getUpdateScheduleMock.mockReturnValue(null); + refreshGatewayUpdateStatusMock.mockReset(); + refreshGatewayUpdateStatusMock.mockResolvedValue(undefined); + getLatestUpdateRestartSentinelMock.mockReset(); + getLatestUpdateRestartSentinelMock.mockReturnValue(null); + refreshLatestUpdateRestartSentinelMock.mockReset(); + refreshLatestUpdateRestartSentinelMock.mockResolvedValue(null); +}); + +describe("update.status effective channel", () => { + it("reports a verified configless extended-stable package channel", async () => { + versionMock.value = "2026.6.33"; + checkUpdateStatusMock.mockResolvedValueOnce({ + root: "/tmp/openclaw", + installKind: "package", + packageManager: "npm", + }); + const { updateHandlers } = await import("./update.js"); + const respond = vi.fn(); + + const handler = updateHandlers["update.status"]; + if (!handler) { + throw new Error("update.status handler is unavailable"); + } + await handler({ + params: {}, + respond, + context: { getRuntimeConfig: () => ({ update: {} }) }, + } as never); + + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ effectiveChannel: "extended-stable" }), + ); + }); + + it("refreshes the latest update sentinel before responding", async () => { + getUpdateAvailableMock.mockReturnValueOnce({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: "latest", + }); + getLatestUpdateRestartSentinelMock.mockReturnValueOnce({ + kind: "update", + status: "skipped", + ts: 1, + stats: { reason: "restart-health-pending" }, + }); + refreshLatestUpdateRestartSentinelMock.mockResolvedValueOnce({ + kind: "update", + status: "ok", + ts: 2, + stats: { after: { version: "2.0.0" } }, + }); + getUpdateScheduleMock.mockReturnValueOnce({ channel: "beta", autoEnabled: true }); + const { updateHandlers } = await import("./update.js"); + const respond = vi.fn(); + + const handler = updateHandlers["update.status"]; + if (!handler) { + throw new Error("update.status handler is unavailable"); + } + await handler({ params: {}, respond } as never); + + expect(refreshLatestUpdateRestartSentinelMock).toHaveBeenCalledTimes(1); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ + sentinel: expect.objectContaining({ kind: "update", status: "ok" }), + updateAvailable: expect.objectContaining({ latestVersion: "2.0.0" }), + schedule: expect.objectContaining({ channel: "beta" }), + }), + ); + }); + + it("falls back to the cached update sentinel when refresh fails", async () => { + refreshLatestUpdateRestartSentinelMock.mockRejectedValueOnce(new Error("read failed")); + getLatestUpdateRestartSentinelMock.mockReturnValueOnce({ + kind: "update", + status: "skipped", + ts: 1, + stats: { reason: "restart-health-pending" }, + }); + const warn = vi.fn(); + const { updateHandlers } = await import("./update.js"); + const respond = vi.fn(); + + const handler = updateHandlers["update.status"]; + if (!handler) { + throw new Error("update.status handler is unavailable"); + } + await handler({ params: {}, respond, context: { logGateway: { warn } } } as never); + + expect(warn).toHaveBeenCalledWith("update.status sentinel refresh failed: read failed"); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ sentinel: expect.objectContaining({ status: "skipped" }) }), + ); + }); +}); diff --git a/src/gateway/server-methods/update-run-campaign.test.ts b/src/gateway/server-methods/update-run-campaign.test.ts index 1c0f9d9611d4..9e6f00db69be 100644 --- a/src/gateway/server-methods/update-run-campaign.test.ts +++ b/src/gateway/server-methods/update-run-campaign.test.ts @@ -12,6 +12,7 @@ import { withEnvAsync } from "../../test-utils/env.js"; let currentCampaignId: string | undefined; let updateSchedule: UpdateScheduleState | null; let updateChannel: "stable" | "beta" | "dev" | null; +const versionMock = vi.hoisted(() => ({ value: "1.0.0" })); type UpdateCampaignAdoption = NonNullable>; const adoptCampaignMock = vi.fn<() => UpdateCampaignAdoption | undefined>(() => ({ @@ -103,9 +104,12 @@ vi.mock("../../infra/update-campaign.js", () => ({ }, })); -vi.mock("../../infra/update-channels.js", () => ({ - normalizeUpdateChannel: () => updateChannel, -})); +vi.mock("../../infra/update-channels.js", async () => { + const actual = await vi.importActual( + "../../infra/update-channels.js", + ); + return { ...actual, normalizeUpdateChannel: () => updateChannel }; +}); vi.mock("../../infra/update-managed-service-handoff.js", () => ({ buildManagedServiceHandoffUnavailableMessage: () => "handoff unavailable", @@ -131,6 +135,12 @@ vi.mock("../../infra/update-startup.js", () => ({ getUpdateSchedule: () => updateSchedule, })); +vi.mock("../../version.js", () => ({ + get VERSION() { + return versionMock.value; + }, +})); + vi.mock("../server-restart-sentinel.js", () => ({ getLatestUpdateRestartSentinel: () => null, recordLatestUpdateRestartSentinel: () => undefined, @@ -157,6 +167,7 @@ beforeEach(() => { currentCampaignId = "campaign-1"; updateSchedule = null; updateChannel = null; + versionMock.value = "1.0.0"; adoptCampaignMock.mockReset(); adoptCampaignMock.mockReturnValue({ campaignId: "campaign-1", @@ -269,6 +280,23 @@ describe("update.run campaign ownership", () => { ); }); + it("keeps a configless extended-stable package install on that channel", async () => { + versionMock.value = "2026.6.33"; + detectRespawnSupervisorMock.mockReturnValueOnce("launchd"); + resolveUpdateInstallSurfaceMock.mockResolvedValueOnce({ + kind: "global", + mode: "npm", + root: "/tmp/openclaw", + packageRoot: "/tmp/openclaw", + }); + + await withEnvAsync({ OPENCLAW_LAUNCHD_LABEL: "ai.openclaw.gateway" }, invokeUpdateRun); + + expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "extended-stable" }), + ); + }); + it("keeps a plain package update on the moving configured channel", async () => { updateChannel = "beta"; adoptCampaignMock.mockReturnValueOnce(undefined); diff --git a/src/gateway/server-methods/update.test.ts b/src/gateway/server-methods/update.test.ts index f877deaf74df..cd999224543c 100644 --- a/src/gateway/server-methods/update.test.ts +++ b/src/gateway/server-methods/update.test.ts @@ -31,6 +31,8 @@ const refreshLatestUpdateRestartSentinelMock = vi.fn<() => Promise true); const readPackageVersionMock = vi.fn(async () => "1.0.0"); +const checkUpdateStatusMock = vi.fn(); +const versionMock = vi.hoisted(() => ({ value: "1.0.0" })); const detectRespawnSupervisorMock = vi.fn<() => RespawnSupervisor | null>(() => null); const normalizeUpdateChannelMock = vi.fn((): UpdateChannel | null => null); const getUpdateAvailableMock = vi.fn( @@ -149,13 +151,26 @@ vi.mock("../../infra/package-json.js", () => ({ readPackageVersion: readPackageVersionMock, })); +vi.mock("../../infra/update-check.js", () => ({ + checkUpdateStatus: checkUpdateStatusMock, +})); + +vi.mock("../../version.js", () => ({ + get VERSION() { + return versionMock.value; + }, +})); + vi.mock("../../infra/supervisor-markers.js", () => ({ detectRespawnSupervisor: detectRespawnSupervisorMock, })); -vi.mock("../../infra/update-channels.js", () => ({ - normalizeUpdateChannel: normalizeUpdateChannelMock, -})); +vi.mock("../../infra/update-channels.js", async () => { + const actual = await vi.importActual( + "../../infra/update-channels.js", + ); + return { ...actual, normalizeUpdateChannel: normalizeUpdateChannelMock }; +}); vi.mock("../../infra/update-startup.js", () => ({ getUpdateAvailable: getUpdateAvailableMock, @@ -235,6 +250,7 @@ beforeEach(() => { isRestartEnabledMock.mockReturnValue(true); readPackageVersionMock.mockClear(); readPackageVersionMock.mockResolvedValue("1.0.0"); + versionMock.value = "1.0.0"; normalizeUpdateChannelMock.mockReset(); normalizeUpdateChannelMock.mockReturnValue(null); getUpdateAvailableMock.mockReset(); @@ -1037,94 +1053,3 @@ describe("update.run post-core plugin finalize", () => { expect(startManagedServiceUpdateHandoffMock).toHaveBeenCalledTimes(1); }); }); - -describe("update.status", () => { - it("refreshes the latest update sentinel before responding", async () => { - getUpdateAvailableMock.mockReturnValueOnce({ - currentVersion: "1.0.0", - latestVersion: "2.0.0", - channel: "latest", - }); - getLatestUpdateRestartSentinelMock.mockReturnValueOnce({ - kind: "update", - status: "skipped", - ts: 1, - stats: { - reason: "restart-health-pending", - }, - }); - refreshLatestUpdateRestartSentinelMock.mockResolvedValueOnce({ - kind: "update", - status: "ok", - ts: 2, - stats: { - after: { version: "2.0.0" }, - }, - }); - getUpdateScheduleMock.mockReturnValueOnce({ - channel: "beta", - autoEnabled: true, - }); - const { updateHandlers } = await import("./update.js"); - const respond = vi.fn(); - - await expectDefined( - updateHandlers["update.status"], - 'updateHandlers["update.status"] test invariant', - )({ - params: {}, - respond, - } as never); - - expect(respond).toHaveBeenCalledTimes(1); - const [ok, response] = firstMockCall(respond, "update status response") as [ - boolean, - ( - | { - sentinel?: { kind?: string; status?: string }; - updateAvailable?: { latestVersion?: string } | null; - schedule?: { channel?: string }; - } - | undefined - ), - ]; - expect(ok).toBe(true); - expect(refreshLatestUpdateRestartSentinelMock).toHaveBeenCalledTimes(1); - expect(response?.sentinel?.kind).toBe("update"); - expect(response?.sentinel?.status).toBe("ok"); - expect(response?.updateAvailable?.latestVersion).toBe("2.0.0"); - expect(response?.schedule?.channel).toBe("beta"); - }); - - it("falls back to the cached update sentinel when refresh fails", async () => { - refreshLatestUpdateRestartSentinelMock.mockRejectedValueOnce(new Error("read failed")); - getLatestUpdateRestartSentinelMock.mockReturnValueOnce({ - kind: "update", - status: "skipped", - ts: 1, - stats: { - reason: "restart-health-pending", - }, - }); - const warn = vi.fn(); - const { updateHandlers } = await import("./update.js"); - const respond = vi.fn(); - - await expectDefined( - updateHandlers["update.status"], - 'updateHandlers["update.status"] test invariant', - )({ - params: {}, - respond, - context: { logGateway: { warn } }, - } as never); - - expect(warn).toHaveBeenCalledWith("update.status sentinel refresh failed: read failed"); - const [, response] = firstMockCall(respond, "update status response") as [ - boolean, - { sentinel?: { kind?: string; status?: string } } | undefined, - ]; - expect(response?.sentinel?.kind).toBe("update"); - expect(response?.sentinel?.status).toBe("skipped"); - }); -}); diff --git a/src/gateway/server-methods/update.ts b/src/gateway/server-methods/update.ts index a9c64dcbf73d..d341e8249257 100644 --- a/src/gateway/server-methods/update.ts +++ b/src/gateway/server-methods/update.ts @@ -28,7 +28,11 @@ import { } from "../../infra/restart.js"; import { detectRespawnSupervisor } from "../../infra/supervisor-markers.js"; import { gatewayUpdateCampaign } from "../../infra/update-campaign.js"; -import { normalizeUpdateChannel } from "../../infra/update-channels.js"; +import { + normalizeUpdateChannel, + resolveEffectiveUpdateChannel, +} from "../../infra/update-channels.js"; +import { checkUpdateStatus } from "../../infra/update-check.js"; import { CONTROL_PLANE_UPDATE_HANDOFF_STARTED_REASON } from "../../infra/update-control-plane-sentinel.js"; import { buildManagedServiceHandoffUnavailableMessage, @@ -51,6 +55,7 @@ import { getUpdateSchedule, refreshGatewayUpdateStatus, } from "../../infra/update-startup.js"; +import { VERSION } from "../../version.js"; import { formatControlPlaneActor, resolveControlPlaneActor } from "../control-plane-audit.js"; import { getLatestUpdateRestartSentinel, @@ -79,6 +84,32 @@ function tryResolveProcessCwd(): string | undefined { } } +async function resolveGatewayEffectiveUpdateChannel( + configChannel: ReturnType, +) { + const invocationCwd = tryResolveProcessCwd(); + const root = await resolveOpenClawPackageRoot({ + moduleUrl: import.meta.url, + argv1: process.argv[1], + ...(invocationCwd ? { cwd: invocationCwd } : {}), + }); + const status = await checkUpdateStatus({ + root, + timeoutMs: 2500, + fetchGit: false, + includeRegistry: false, + }); + if (status.installKind === "unknown") { + return null; + } + return resolveEffectiveUpdateChannel({ + configChannel, + currentVersion: VERSION, + installKind: status.installKind, + git: status.git, + }).channel; +} + async function readPreUpdateConfigForPostCoreFinalize(): Promise< PreUpdateConfigRestoreInput | undefined > { @@ -149,6 +180,9 @@ export const updateHandlers: GatewayRequestHandlers = { ); sentinel = getLatestUpdateRestartSentinel(); } + const configChannel = context?.getRuntimeConfig + ? normalizeUpdateChannel(context.getRuntimeConfig().update?.channel) + : null; if (context?.getRuntimeConfig) { try { await refreshGatewayUpdateStatus(context.getRuntimeConfig()); @@ -159,9 +193,13 @@ export const updateHandlers: GatewayRequestHandlers = { } } const schedule = getUpdateSchedule(); + const effectiveChannel = await resolveGatewayEffectiveUpdateChannel(configChannel).catch( + () => null, + ); const result = { sentinel, updateAvailable: getUpdateAvailable(), + ...(effectiveChannel ? { effectiveChannel } : {}), ...(schedule ? { schedule } : {}), }; if (!validateUpdateStatusResult(result)) { @@ -280,6 +318,16 @@ export const updateHandlers: GatewayRequestHandlers = { cwd: root, argv1: process.argv[1], }); + const effectiveChannel = resolveEffectiveUpdateChannel({ + configChannel, + currentVersion: VERSION, + installKind: + installSurface.kind === "git" + ? "git" + : installSurface.kind === "global" || installSurface.kind === "package-root" + ? "package" + : "unknown", + }).channel; const supervisor = detectRespawnSupervisor(process.env, process.platform); const hasHandoffContext = supervisor ? hasManagedServiceHandoffContext(process.env, supervisor) @@ -326,7 +374,11 @@ export const updateHandlers: GatewayRequestHandlers = { }; } else if (requiresManagedServiceHandoff) { const handoffChannel = - installSurface.kind === "git" ? undefined : (configChannel ?? undefined); + installSurface.kind === "git" + ? undefined + : effectiveChannel === "extended-stable" + ? effectiveChannel + : (configChannel ?? undefined); const command = formatManagedServiceUpdateCommand({ timeoutMs, ...(handoffChannel ? { channel: handoffChannel } : {}), @@ -468,7 +520,12 @@ export const updateHandlers: GatewayRequestHandlers = { timeoutMs, cwd: root, argv1: process.argv[1], - channel: configChannel ?? undefined, + channel: + installSurface.kind === "git" + ? (configChannel ?? undefined) + : effectiveChannel === "extended-stable" + ? effectiveChannel + : (configChannel ?? undefined), ...(adoptedPackageTargetVersion ? { tag: adoptedPackageTargetVersion } : {}), ...(adoptedDevTargetRef ? { devTargetRef: adoptedDevTargetRef } : {}), allowGatewayServiceRepair: false, diff --git a/src/infra/update-channels.test.ts b/src/infra/update-channels.test.ts index 7390885e20a7..49bd3a5a2f3f 100644 --- a/src/infra/update-channels.test.ts +++ b/src/infra/update-channels.test.ts @@ -102,6 +102,14 @@ describe("resolveEffectiveUpdateChannel", () => { }, expected: { channel: "extended-stable", source: "config" }, }, + { + name: "uses installed extended-stable version without config", + params: { + currentVersion: "2026.6.33", + installKind: "package" as const, + }, + expected: { channel: "extended-stable", source: "installed-version" }, + }, { name: "uses beta git tag", params: { installKind: "git" as const, git: { tag: "v2026.2.24-beta.1" } }, @@ -242,6 +250,20 @@ describe("resolveUpdateChannelDisplay", () => { }); describe("resolveRegistryUpdateChannel", () => { + it.each([ + { currentVersion: "2026.6.32", expected: "stable" }, + { currentVersion: "2026.6.33", expected: "stable" }, + { currentVersion: "2026.6.34", expected: "stable" }, + { currentVersion: "2026.6.33-1", expected: "stable" }, + { currentVersion: "1.33.1", expected: "stable" }, + { currentVersion: "1.6.33", expected: "stable" }, + ] as const)( + "does not infer a package-only channel for $currentVersion", + ({ currentVersion, expected }) => { + expect(resolveRegistryUpdateChannel({ currentVersion })).toBe(expected); + }, + ); + it("queries beta when the installed version is beta even if config is stale stable", () => { expect( resolveRegistryUpdateChannel({ diff --git a/src/infra/update-channels.ts b/src/infra/update-channels.ts index cbb9b67650f8..a40205a30624 100644 --- a/src/infra/update-channels.ts +++ b/src/infra/update-channels.ts @@ -61,6 +61,21 @@ export function isBetaTag(tag: string): boolean { return /(?:^|[.-])beta(?:[.-]|$)/i.test(tag); } +/** Returns whether a final monthly release belongs to the extended-stable line. */ +function isExtendedStableReleaseVersion(version: string): boolean { + const parsed = parseSemver(version.trim()); + return ( + parsed !== null && + parsed.build.length === 0 && + parsed.prerelease.length === 0 && + parsed.major >= 1000 && + parsed.major <= 9999 && + parsed.minor >= 1 && + parsed.minor <= 12 && + parsed.patch >= 33 + ); +} + /** Detects prerelease tags, including legacy dot-beta tags and named prerelease channels. */ function isPrereleaseTag(tag: string): boolean { const parsed = parseSemver(normalizeLegacyDotBetaVersion(tag)); @@ -115,6 +130,12 @@ export function resolveEffectiveUpdateChannel(params: { return { channel: params.configChannel, source: "config" }; } + if (params.installKind === "package" && params.currentVersion) { + if (isExtendedStableReleaseVersion(params.currentVersion)) { + return { channel: "extended-stable", source: "installed-version" }; + } + } + if (params.installKind === "git") { const tag = params.git?.tag; if (tag) { @@ -156,7 +177,7 @@ export function formatUpdateChannelLabel(params: { : `${params.channel} (branch)`; } if (params.source === "installed-version") { - return "beta (installed version)"; + return `${params.channel} (installed version)`; } return `${params.channel} (default)`; } diff --git a/src/infra/update-check.test.ts b/src/infra/update-check.test.ts index ec291a15dd9d..26bfcdc77774 100644 --- a/src/infra/update-check.test.ts +++ b/src/infra/update-check.test.ts @@ -816,6 +816,31 @@ describe("checkUpdateStatus", () => { }); }); + it("resolves a status registry channel after detecting the install kind", async () => { + await withTempDir({ prefix: "openclaw-update-check-registry-channel-" }, async (root) => { + await fs.writeFile( + path.join(root, "package.json"), + JSON.stringify({ packageManager: "npm@10.0.0" }), + "utf8", + ); + await fs.writeFile(path.join(root, "package-lock.json"), "lock", "utf8"); + await fs.mkdir(path.join(root, "node_modules"), { recursive: true }); + const resolveRegistryChannel = vi.fn(() => "extended-stable" as const); + + await checkUpdateStatus({ + root, + includeRegistry: false, + fetchGit: false, + resolveRegistryChannel, + }); + + expect(resolveRegistryChannel).toHaveBeenCalledWith({ + installKind: "package", + git: undefined, + }); + }); + }); + it.each([ { name: "text lockfile", diff --git a/src/infra/update-check.ts b/src/infra/update-check.ts index 00fa8253070c..0ac36a6f7866 100644 --- a/src/infra/update-check.ts +++ b/src/infra/update-check.ts @@ -592,22 +592,28 @@ export async function checkUpdateStatus(params: { fetchGit?: boolean; includeRegistry?: boolean; registryChannel?: UpdateChannel; + resolveRegistryChannel?: ( + status: Pick, + ) => UpdateChannel; }): Promise { const timeoutMs = params.timeoutMs ?? 6000; - const fetchRegistry = () => - params.registryChannel + const resolveRegistryChannel = (status: Pick) => + params.registryChannel ?? params.resolveRegistryChannel?.(status); + const fetchRegistry = (registryChannel: UpdateChannel | undefined) => + registryChannel ? fetchNpmRegistryVersionForChannel({ - channel: params.registryChannel, + channel: registryChannel, timeoutMs, }) : fetchNpmLatestVersion({ timeoutMs }); const root = params.root ? path.resolve(params.root) : null; if (!root) { + const registryChannel = resolveRegistryChannel({ installKind: "unknown" }); return { root: null, installKind: "unknown", packageManager: "unknown", - registry: params.includeRegistry ? await fetchRegistry() : undefined, + registry: params.includeRegistry ? await fetchRegistry(registryChannel) : undefined, }; } @@ -626,17 +632,6 @@ export async function checkUpdateStatus(params: { ? "npm" : detectedPackageManager; - const registry = params.includeRegistry - ? params.registryChannel === "extended-stable" && isGit - ? { - latestVersion: null, - tag: "extended-stable", - error: "unsupported_git_channel", - reason: "unsupported_git_channel" as const, - } - : await fetchRegistry() - : undefined; - const installKind: UpdateCheckResult["installKind"] = isGit ? "git" : "package"; const [git, deps] = await Promise.all([ isGit @@ -648,6 +643,17 @@ export async function checkUpdateStatus(params: { : Promise.resolve(undefined), checkDepsStatus({ root, manager: packageManager }), ]); + const registryChannel = resolveRegistryChannel({ installKind, git }); + const registry = params.includeRegistry + ? registryChannel === "extended-stable" && isGit + ? { + latestVersion: null, + tag: "extended-stable", + error: "unsupported_git_channel", + reason: "unsupported_git_channel" as const, + } + : await fetchRegistry(registryChannel) + : undefined; return { root, diff --git a/src/infra/update-startup.test.ts b/src/infra/update-startup.test.ts index 736facf1a956..4c8c5a6c84f6 100644 --- a/src/infra/update-startup.test.ts +++ b/src/infra/update-startup.test.ts @@ -28,6 +28,7 @@ const { refreshRemoteModelCatalogMock, scheduleGatewaySigusr1RestartMock, startManagedServiceUpdateHandoffMock, + versionMock, } = vi.hoisted(() => ({ detectRespawnSupervisorMock: vi.fn(), getRuntimeConfigMock: vi.fn(() => ({})), @@ -48,6 +49,7 @@ const { command: "openclaw update --yes --channel beta --timeout 2700", logPath: "/tmp/openclaw-handoff.log", })), + versionMock: { value: "1.0.0" }, })); vi.mock("../config/config.js", () => ({ @@ -111,7 +113,9 @@ vi.mock("./update-check.js", async () => { }); vi.mock("../version.js", () => ({ - VERSION: "1.0.0", + get VERSION() { + return versionMock.value; + }, })); vi.mock("../process/exec.js", () => ({ @@ -229,6 +233,7 @@ describe("update-startup", () => { } beforeEach(async () => { + versionMock.value = "1.0.0"; vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-17T10:00:00Z")); testState = await createOpenClawTestState({ @@ -779,6 +784,49 @@ describe("update-startup", () => { await expectPathMissing(path.join(tempDir, "update-check.json")); }); + it("uses the extended-stable selector for an installed final extended-stable package", async () => { + versionMock.value = "2026.6.33"; + mockPackageUpdateStatus("extended-stable", "2026.7.33"); + const onUpdateAvailableChange = vi.fn(); + + await runGatewayUpdateCheck({ + cfg: {}, + log: { info: vi.fn() }, + isNixMode: false, + allowInTests: true, + onUpdateAvailableChange, + }); + + expect(resolveNpmChannelTag).toHaveBeenCalledWith({ + channel: "extended-stable", + timeoutMs: 2500, + }); + expect(onUpdateAvailableChange).toHaveBeenCalledWith({ + currentVersion: "2026.6.33", + latestVersion: "2026.7.33", + channel: "extended-stable", + }); + }); + + it("does not query extended-stable when configless startup hints are disabled", async () => { + versionMock.value = "2026.6.33"; + mockPackageInstallStatus(); + const runAutoUpdate = createAutoUpdateSuccessMock(); + + await runGatewayUpdateCheck({ + cfg: { update: { checkOnStart: false, auto: { enabled: true } } }, + log: { info: vi.fn() }, + isNixMode: false, + allowInTests: true, + runAutoUpdate, + }); + + expect(checkUpdateStatus).toHaveBeenCalledOnce(); + expect(resolveNpmChannelTag).not.toHaveBeenCalled(); + expect(runAutoUpdate).not.toHaveBeenCalled(); + expect(readPersistedUpdateCheckState()).toBeNull(); + }); + it("discovers and deduplicates an exact extended-stable update without auto-applying", async () => { const onUpdateAvailableChange = vi.fn(); const runAutoUpdate = createAutoUpdateSuccessMock(); @@ -972,6 +1020,24 @@ describe("update-startup", () => { expectStableAutoRolloutStatePreserved(); }); + it("uses the verified package install kind for a configless extended-stable release", async () => { + versionMock.value = "2026.6.33"; + mockPackageInstallStatus(); + mockNpmChannelTag("extended-stable", "2026.6.34"); + + await runGatewayUpdateCheck({ + cfg: {}, + log: { info: vi.fn() }, + isNixMode: false, + allowInTests: true, + }); + + expect(resolveNpmChannelTag).toHaveBeenCalledWith({ + channel: "extended-stable", + timeoutMs: 2500, + }); + }); + it("skips all extended-stable work in Nix mode", async () => { const runAutoUpdate = createAutoUpdateSuccessMock(); diff --git a/src/infra/update-startup.ts b/src/infra/update-startup.ts index 1a5a9ab99f28..e5508af281d4 100644 --- a/src/infra/update-startup.ts +++ b/src/infra/update-startup.ts @@ -47,6 +47,7 @@ import { gatewayUpdateCampaign, type UpdateCampaignController } from "./update-c import { channelToNpmTag, normalizeUpdateChannel, + resolveEffectiveUpdateChannel, DEFAULT_PACKAGE_CHANNEL, type UpdateChannel, } from "./update-channels.js"; @@ -838,12 +839,55 @@ export async function runGatewayUpdateCheck(params: { if (params.isNixMode) { return; } - const configuredChannel = - normalizeUpdateChannel(params.cfg.update?.channel) ?? DEFAULT_PACKAGE_CHANNEL; + const configChannel = normalizeUpdateChannel(params.cfg.update?.channel); const updateCampaign = params.updateCampaign ?? gatewayUpdateCampaign; const auto = resolveAutoUpdatePolicy(params.cfg); const autoDisabledByEnv = isTruthyEnvValue(process.env.OPENCLAW_NO_AUTO_UPDATE); const autoDisabledByExternalSupervisor = isGatewayExternallySupervised(); + const shouldRunUpdateHints = params.cfg.update?.checkOnStart !== false; + const potentialChannel = resolveEffectiveUpdateChannel({ + configChannel, + currentVersion: VERSION, + installKind: "package", + }).channel; + const potentialAutoDesired = + (potentialChannel === "stable" || potentialChannel === "beta" || potentialChannel === "dev") && + auto.enabled && + !autoDisabledByEnv && + !autoDisabledByExternalSupervisor; + if (!shouldRunUpdateHints && !potentialAutoDesired && configChannel === "extended-stable") { + updateCampaign.clear(); + setUpdateAvailableCache({ + next: null, + onUpdateAvailableChange: params.onUpdateAvailableChange, + }); + const priorSchedule = + updateScheduleCache?.channel === potentialChannel ? updateScheduleCache : null; + const initialSchedule: UpdateScheduleState = priorSchedule + ? { ...priorSchedule, autoEnabled: auto.enabled } + : { channel: potentialChannel, autoEnabled: auto.enabled }; + setUpdateScheduleCache({ + next: withoutTarget(initialSchedule), + onUpdateScheduleChange: params.onUpdateScheduleChange, + }); + return; + } + const mightUseInstalledExtendedStableChannel = + configChannel === null && potentialChannel === "extended-stable"; + let installStatus: Awaited> | undefined; + if ( + configChannel === "extended-stable" || + configChannel === "dev" || + mightUseInstalledExtendedStableChannel + ) { + installStatus = await resolveStartupInstallStatus(configChannel === "dev"); + } + const configuredChannel = resolveEffectiveUpdateChannel({ + configChannel, + currentVersion: VERSION, + installKind: installStatus?.status.installKind ?? "unknown", + git: installStatus?.status.git, + }).channel; const autoDesired = (configuredChannel === "stable" || configuredChannel === "beta" || @@ -851,7 +895,6 @@ export async function runGatewayUpdateCheck(params: { auto.enabled && !autoDisabledByEnv && !autoDisabledByExternalSupervisor; - const shouldRunUpdateHints = params.cfg.update?.checkOnStart !== false; if (updateScheduleCache?.channel !== configuredChannel) { updateCampaign.clear(); @@ -918,9 +961,10 @@ export async function runGatewayUpdateCheck(params: { return; } - let installStatus: Awaited> | undefined; - if (configuredChannel === "extended-stable" || configuredChannel === "dev") { + if ((configuredChannel === "extended-stable" || configuredChannel === "dev") && !installStatus) { installStatus = await resolveStartupInstallStatus(configuredChannel === "dev"); + } + if (installStatus && (configuredChannel === "extended-stable" || configuredChannel === "dev")) { setUpdateScheduleCache({ next: withInstallStatus( updateScheduleCache ?? initialSchedule, diff --git a/test/scripts/make-appcast.test.ts b/test/scripts/make-appcast.test.ts index 0cb7d89d6022..b38d0d6584d0 100644 --- a/test/scripts/make-appcast.test.ts +++ b/test/scripts/make-appcast.test.ts @@ -22,11 +22,13 @@ describe("make_appcast cleanup", () => { expect(setupBlock).toContain('rm -f "$NOTES_HTML"'); }); - it("adds the beta channel and refuses alpha releases", () => { + it("adds release-train channels and refuses alpha releases", () => { const script = readFileSync(scriptPath, "utf8"); expect(script).toContain('if [[ "$VERSION" == *-beta.* || "$VERSION" == *.beta.* ]]; then'); expect(script).toContain("CHANNEL_ARGS=(--channel beta)"); + expect(script).toContain("CHANNEL_ARGS=(--channel extended-stable)"); + expect(script).toContain('"${BASH_REMATCH[1]}" -ge 33'); expect(script).toContain('if [[ "$VERSION" == *-alpha.* || "$VERSION" == *.alpha.* ]]; then'); expect(script).toContain('"${CHANNEL_ARGS[@]}"'); });