diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index a4990d2de695..6728a5d67336 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -30355,7 +30355,7 @@ }, { "kind": "ui-localized-call", - "line": 429, + "line": 490, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "Repairing the OpenClaw Gateway update…", "surface": "apple", @@ -30363,7 +30363,7 @@ }, { "kind": "ui-localized-call", - "line": 430, + "line": 491, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "Updating the OpenClaw Gateway to \\(targetVersion)…", "surface": "apple", @@ -30371,7 +30371,7 @@ }, { "kind": "ui-localized-call", - "line": 451, + "line": 512, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "Gateway update needs attention.", "surface": "apple", @@ -30379,7 +30379,7 @@ }, { "kind": "ui-localized-call", - "line": 453, + "line": 514, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "Gateway update failed.", "surface": "apple", @@ -30387,7 +30387,7 @@ }, { "kind": "ui-localized-call", - "line": 468, + "line": 529, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "Gateway update finished, but verification failed.", "surface": "apple", @@ -30395,7 +30395,7 @@ }, { "kind": "ui-localized-call", - "line": 475, + "line": 536, "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", "source": "OpenClaw Gateway \\(installedVersion) is installed.", "surface": "apple", @@ -33123,7 +33123,7 @@ }, { "kind": "conditional-branch", - "line": 569, + "line": 578, "path": "apps/macos/Sources/OpenClaw/GatewayProcessManager.swift", "source": "not linked", "surface": "apple", @@ -33131,7 +33131,7 @@ }, { "kind": "conditional-branch", - "line": 582, + "line": 591, "path": "apps/macos/Sources/OpenClaw/GatewayProcessManager.swift", "source": "unknown error", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/CLIInstaller.swift b/apps/macos/Sources/OpenClaw/CLIInstaller.swift index 7cec12fd6384..4c096d7f764c 100644 --- a/apps/macos/Sources/OpenClaw/CLIInstaller.swift +++ b/apps/macos/Sources/OpenClaw/CLIInstaller.swift @@ -330,10 +330,12 @@ enum CLIInstaller { await statusHandler("Install failed: installer resource is missing. Reinstall OpenClaw.") return false } + let appVersion = GatewayEnvironment.appVersionString() let cmd = self.installScriptCommand( target: target, prefix: prefix, - scriptPath: installerURL.path) + scriptPath: installerURL.path, + compatibleWith: target.requiresExactVersion ? nil : appVersion) let response = await ShellExecutor.runDetailed( command: cmd, cwd: nil, @@ -343,10 +345,22 @@ enum CLIInstaller { if response.success { let expectedVersion = target.requiresExactVersion ? GatewayEnvironment.appVersionString() : nil let managedStatus = await self.managedStatus(expectedVersion: expectedVersion) - guard managedStatus.isReady else { + guard case let .ready(_, verifiedVersion) = managedStatus else { await statusHandler("Install failed: \(managedStatus.message)") return false } + if case let .channel(channel) = target, + let appVersion, + !self.channelInstallIsCompatible( + installedVersion: verifiedVersion, + appVersion: appVersion) + { + await statusHandler( + "Install failed: \(channel.label) resolved to Gateway \(verifiedVersion), " + + "which is older than this app (\(appVersion)). Choose a newer CLI channel " + + "or retry after the channel is updated.") + return false + } let parsed = self.parseInstallEvents(response.stdout) let installedVersion = parsed.last { $0.event == "done" }?.version let summary = installedVersion.map { "Installed openclaw \($0)." } ?? "Installed openclaw." @@ -368,6 +382,45 @@ enum CLIInstaller { return false } + static func channelInstallIsCompatible( + installedVersion: String, + appVersion: String) -> Bool + { + guard let installed = Semver.parse(installedVersion), + let app = Semver.parse(appVersion) + else { + return false + } + if installed != app { return installed > app } + + // The CLI's future-config guard permits all same-base stable/correction families. + // For prerelease app builds, only an older prerelease would block the service write. + guard let appPrerelease = self.prereleaseTail(appVersion), + !self.isCorrectionPrerelease(appPrerelease) + else { + return true + } + guard let installedPrerelease = self.prereleaseTail(installedVersion), + !self.isCorrectionPrerelease(installedPrerelease) + else { + return true + } + return installedPrerelease.compare(appPrerelease, options: .numeric) != .orderedAscending + } + + private static func prereleaseTail(_ version: String) -> String? { + let withoutBuild = version + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: "+", maxSplits: 1, omittingEmptySubsequences: false)[0] + guard let separator = withoutBuild.firstIndex(of: "-") else { return nil } + let tail = String(withoutBuild[withoutBuild.index(after: separator)...]) + return tail.isEmpty ? nil : tail + } + + private static func isCorrectionPrerelease(_ prerelease: String) -> Bool { + !prerelease.isEmpty && prerelease.allSatisfy(\.isNumber) + } + static func installWatchdogTimeout(for target: InstallTarget) -> TimeInterval { // Dev installs clone/fetch source, install dependencies, and build the UI // plus CLI. Keep that workflow bounded without killing healthy cold builds. @@ -380,7 +433,12 @@ enum CLIInstaller { .path } - static func installScriptCommand(target: InstallTarget, prefix: String, scriptPath: String) -> [String] { + static func installScriptCommand( + target: InstallTarget, + prefix: String, + scriptPath: String, + compatibleWith appVersion: String? = nil) -> [String] + { var command = [ "/bin/bash", scriptPath, @@ -391,6 +449,9 @@ enum CLIInstaller { "--version", target.selector, ] + if let appVersion, !target.requiresExactVersion { + command.append(contentsOf: ["--compatible-with", appVersion]) + } if target == .channel(.dev) { command.append(contentsOf: [ "--install-method", diff --git a/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift b/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift index 8ea5bb856428..666bd39008e7 100644 --- a/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift +++ b/apps/macos/Sources/OpenClaw/GatewayProcessManager.swift @@ -346,6 +346,15 @@ final class GatewayProcessManager { self.status = .stopped return } + guard OpenClawConfigFile.migrateRetiredAppMetadataForGatewayStart() else { + let message = + "Could not repair retired macOS config metadata. Run `openclaw doctor --fix`, then retry." + self.status = .failed(message) + self.lastFailureReason = message + self.appendLog("[gateway] \(message)\n") + self.logger.error("gateway config metadata migration failed") + return + } // Many surfaces can call `setActive(true)` in quick succession (startup, Canvas, health checks). // Avoid spawning multiple concurrent "start" tasks that can thrash launchd and flap the port. switch self.status { diff --git a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift index 98b840c65eba..5fe3dacc742c 100644 --- a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift +++ b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift @@ -223,6 +223,21 @@ enum OpenClawConfigFile { let browser = root["browser"] as? [String: Any] return browser?["enabled"] as? Bool ?? defaultValue } + + /// Beta macOS builds wrote this retired key after core moved it to SQLite. + /// Repair only that app-owned shape before local Gateway validation can reject it. + static func migrateRetiredAppMetadataForGatewayStart() -> Bool { + self.withFileLock { + let root = self.loadDict() + guard let meta = root["meta"] as? [String: Any], + meta.keys.contains("lastTouchedAt") + else { + return true + } + self.logger.notice("removing retired app-written config metadata before Gateway start") + return self.saveDict(root) + } + } } extension OpenClawConfigFile { @@ -455,7 +470,9 @@ extension OpenClawConfigFile { var meta = root["meta"] as? [String: Any] ?? [:] let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "macos-app" meta["lastTouchedVersion"] = version - meta["lastTouchedAt"] = ISO8601DateFormatter().string(from: Date()) + // Machine-state timestamps moved to SQLite. Keeping this retired config key makes the + // matching CLI reject the app's config before the Gateway can start. + meta.removeValue(forKey: "lastTouchedAt") root["meta"] = meta } diff --git a/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift b/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift index c40a9699b7e7..c0c00186f223 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift @@ -36,7 +36,8 @@ struct CLIInstallerTests { let command = CLIInstaller.installScriptCommand( target: .exact("2026.7.3-beta.1"), prefix: "/Users/Test User/.openclaw", - scriptPath: "/Applications/OpenClaw.app/Contents/Resources/install-cli.sh") + scriptPath: "/Applications/OpenClaw.app/Contents/Resources/install-cli.sh", + compatibleWith: "2026.7.4") #expect(command == [ "/bin/bash", @@ -49,17 +50,32 @@ struct CLIInstallerTests { "2026.7.3-beta.1", ]) #expect(!command.contains("curl")) + #expect(!command.contains("--compatible-with")) + } + + @Test func `channel installer checks compatibility before replacing the managed CLI`() { + let command = CLIInstaller.installScriptCommand( + target: .channel(.stable), + prefix: "/Users/Test User/.openclaw", + scriptPath: "/Applications/OpenClaw.app/Contents/Resources/install-cli.sh", + compatibleWith: "2026.7.3-beta.8") + + #expect(command.suffix(3) == [ + "latest", + "--compatible-with", + "2026.7.3-beta.8", + ]) } @Test func `dev installer uses a managed git main checkout`() { let command = CLIInstaller.installScriptCommand( target: .channel(.dev), prefix: "/Users/Test User/.openclaw", - scriptPath: "/Applications/OpenClaw.app/Contents/Resources/install-cli.sh") + scriptPath: "/Applications/OpenClaw.app/Contents/Resources/install-cli.sh", + compatibleWith: "2026.7.3") - #expect(command.suffix(6) == [ - "--version", - "main", + #expect(command.suffix(5) == [ + "2026.7.3", "--install-method", "git", "--git-dir", @@ -230,6 +246,27 @@ struct CLIInstallerTests { required: "2026.7.3")) } + @Test func `channel install cannot bootstrap with an older config writer`() { + #expect(!CLIInstaller.channelInstallIsCompatible( + installedVersion: "2026.7.1-2", + appVersion: "2026.7.2")) + #expect(!CLIInstaller.channelInstallIsCompatible( + installedVersion: "2026.7.2-beta.6", + appVersion: "2026.7.2-beta.7")) + #expect(CLIInstaller.channelInstallIsCompatible( + installedVersion: "2026.7.2", + appVersion: "2026.7.2-beta.7")) + #expect(CLIInstaller.channelInstallIsCompatible( + installedVersion: "2026.7.2-beta.7", + appVersion: "2026.7.2")) + #expect(CLIInstaller.channelInstallIsCompatible( + installedVersion: "2026.7.2-1", + appVersion: "2026.7.2-2")) + #expect(CLIInstaller.channelInstallIsCompatible( + installedVersion: "2026.7.3-beta.1", + appVersion: "2026.7.2")) + } + @Test func `compatible external CLI satisfies setup`() async throws { let root = FileManager().temporaryDirectory.appendingPathComponent( "openclaw-compatible-cli-\(UUID().uuidString)") diff --git a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift index ff231d011c5e..ae8f6ff92d62 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift @@ -114,6 +114,53 @@ struct OpenClawConfigFileTests { } } + @MainActor + @Test + func `save dict removes retired config metadata`() async throws { + let configPath = self.makeConfigOverridePath() + defer { try? FileManager().removeItem(at: URL(fileURLWithPath: configPath).deletingLastPathComponent()) } + + try await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) { + #expect(OpenClawConfigFile.saveDict([ + "gateway": ["mode": "local"], + "meta": ["lastTouchedAt": "2026-08-05T22:45:14Z"], + ])) + + let data = try Data(contentsOf: URL(fileURLWithPath: configPath)) + let root = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let meta = try #require(root["meta"] as? [String: Any]) + #expect(meta["lastTouchedVersion"] as? String != nil) + #expect(meta["lastTouchedAt"] == nil) + } + } + + @MainActor + @Test + func `gateway start migration repairs existing retired config metadata`() async throws { + let configPath = self.makeConfigOverridePath() + let configURL = URL(fileURLWithPath: configPath) + defer { try? FileManager().removeItem(at: configURL.deletingLastPathComponent()) } + + try FileManager().createDirectory( + at: configURL.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data( + #"{"gateway":{"mode":"local"},"meta":{"lastTouchedAt":"2026-08-05T22:45:14Z"}}"#.utf8) + .write(to: configURL) + + try await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) { + #expect(OpenClawConfigFile.migrateRetiredAppMetadataForGatewayStart()) + + let data = try Data(contentsOf: configURL) + let root = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let gateway = try #require(root["gateway"] as? [String: Any]) + let meta = try #require(root["meta"] as? [String: Any]) + #expect(gateway["mode"] as? String == "local") + #expect(meta["lastTouchedVersion"] as? String != nil) + #expect(meta["lastTouchedAt"] == nil) + } + } + @MainActor @Test func `save dict preserves gateway auth unless explicitly allowed`() async throws { diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index 780a5a5b8571..0ebb65cb8e96 100755 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -64,6 +64,7 @@ resolve_openclaw_effective_home() { OPENCLAW_EFFECTIVE_HOME="$(resolve_openclaw_effective_home)" PREFIX="${OPENCLAW_PREFIX:-${HOME}/.openclaw}" OPENCLAW_VERSION="${OPENCLAW_VERSION:-latest}" +REQUIRED_COMPATIBLE_VERSION="" DEFAULT_NODE_VERSION="24.15.0" ARMV7_DEFAULT_NODE_VERSION="22.22.3" NODE_VERSION="${OPENCLAW_NODE_VERSION:-${DEFAULT_NODE_VERSION}}" @@ -95,6 +96,7 @@ Usage: install-cli.sh [options] --git, --github Shortcut for --install-method git --git-dir, --dir Checkout directory (default: ~/openclaw, or \$OPENCLAW_HOME/openclaw) --version OpenClaw version (default: latest) + --compatible-with Refuse a CLI that cannot modify config written by --node-version Node version (default: 24.15.0; 22.22.3 on Linux ARMv7) --onboard Run "openclaw onboard" after install --no-onboard Skip onboarding (default) @@ -289,6 +291,13 @@ parse_args() { OPENCLAW_VERSION="$2" shift 2 ;; + --compatible-with) + if [[ $# -lt 2 || "${2:-}" == --* ]]; then + fail "Missing value for $1" + fi + REQUIRED_COMPATIBLE_VERSION="$2" + shift 2 + ;; --node-version) if [[ $# -lt 2 || "${2:-}" == --* ]]; then fail "Missing value for $1" @@ -725,6 +734,122 @@ is_openclaw_source_package_install_spec() { return 1 } +openclaw_version_is_compatible_with() { + local candidate="$1" + local config_writer="$2" + + "$(node_bin)" - "$candidate" "$config_writer" <<'NODE' +const candidateRaw = process.argv[2]; +const writerRaw = process.argv[3]; + +function parse(raw) { + let value = String(raw ?? "").trim(); + const legacyBeta = /^([vV]?\d+\.\d+\.\d+)\.beta(?:\.([0-9A-Za-z.-]+))?$/.exec(value); + if (legacyBeta) { + value = `${legacyBeta[1]}-beta${legacyBeta[2] ? `.${legacyBeta[2]}` : ""}`; + } + const match = /^[vV]?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/.exec(value); + if (!match) return null; + const parseIdentifiers = (rawIdentifiers) => { + if (!rawIdentifiers) return []; + const identifiers = rawIdentifiers.split("."); + if (identifiers.some((identifier) => identifier.length === 0)) return null; + return identifiers.map((identifier) => { + if (!/^\d+$/.test(identifier)) return identifier; + if (identifier.length > 1 && identifier.startsWith("0")) return null; + return Number(identifier); + }); + }; + const prerelease = parseIdentifiers(match[4]); + const build = parseIdentifiers(match[5]); + if (!prerelease || !build || prerelease.includes(null) || build.includes(null)) return null; + return { + core: [Number(match[1]), Number(match[2]), Number(match[3])], + prerelease, + build, + }; +} + +function compareIdentifiers(left, right) { + for (let index = 0; index < Math.max(left.length, right.length); index += 1) { + const a = left[index]; + const b = right[index]; + if (a === undefined) return -1; + if (b === undefined) return 1; + if (a === b) continue; + if (typeof a === "number" && typeof b === "number") return a < b ? -1 : 1; + if (typeof a === "number") return -1; + if (typeof b === "number") return 1; + return a < b ? -1 : 1; + } + return 0; +} + +function isCorrection(version) { + return version.prerelease.length === 1 && typeof version.prerelease[0] === "number"; +} + +function comparable(version) { + if (!isCorrection(version)) return version; + return { ...version, prerelease: [], build: [version.prerelease[0]] }; +} + +function compare(leftRaw, rightRaw) { + const left = comparable(leftRaw); + const right = comparable(rightRaw); + for (let index = 0; index < left.core.length; index += 1) { + if (left.core[index] !== right.core[index]) { + return left.core[index] < right.core[index] ? -1 : 1; + } + } + if (left.prerelease.length === 0 && right.prerelease.length > 0) return 1; + if (right.prerelease.length === 0 && left.prerelease.length > 0) return -1; + const prereleaseOrder = compareIdentifiers(left.prerelease, right.prerelease); + return prereleaseOrder === 0 ? compareIdentifiers(left.build, right.build) : prereleaseOrder; +} + +const candidate = parse(candidateRaw); +const writer = parse(writerRaw); +if (!candidate || !writer) process.exit(2); +const sameCore = candidate.core.every((part, index) => part === writer.core[index]); +if (sameCore && (writer.prerelease.length === 0 || isCorrection(writer))) process.exit(0); +process.exit(compare(candidate, writer) < 0 ? 1 : 0); +NODE +} + +require_openclaw_version_compatible() { + local candidate="$1" + local config_writer="${REQUIRED_COMPATIBLE_VERSION:-}" + if [[ -z "$config_writer" ]]; then + return 0 + fi + + if openclaw_version_is_compatible_with "$candidate" "$config_writer"; then + return 0 + fi + local status="$?" + if [[ "$status" -eq 2 ]]; then + fail "Cannot compare resolved OpenClaw version '${candidate}' with config writer '${config_writer}'." + fi + fail "OpenClaw ${candidate} is older than config writer ${config_writer}. Choose a newer CLI channel or retry after the channel is updated." +} + +resolve_npm_openclaw_version() { + local requested="$1" + "$(npm_bin)" view "openclaw@${requested}" version 2>/dev/null | awk 'NF { value = $0 } END { print value }' +} + +resolve_git_checkout_openclaw_version() { + local repo_dir="$1" + "$(node_bin)" -e ' + const fs = require("node:fs"); + const path = require("node:path"); + const value = JSON.parse(fs.readFileSync(path.join(process.argv[1], "package.json"), "utf8")).version; + if (typeof value !== "string" || value.trim() === "") process.exit(1); + process.stdout.write(value.trim()); + ' "$repo_dir" +} + resolve_git_openclaw_ref() { local requested="${OPENCLAW_VERSION:-latest}" local resolved_version="" @@ -1102,6 +1227,14 @@ install_openclaw() { --no-audit "$freshness_flag" ) + local resolved_requested="$requested" + if [[ -n "${REQUIRED_COMPATIBLE_VERSION:-}" ]]; then + resolved_requested="$(resolve_npm_openclaw_version "$requested")" + if [[ -z "$resolved_requested" ]]; then + fail "Could not resolve OpenClaw ${requested} before compatibility checking." + fi + require_openclaw_version_compatible "$resolved_requested" + fi emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"start\",\"version\":\"${requested}\"}" log "Installing OpenClaw (${requested})..." if [[ "$SET_NPM_PREFIX" -eq 1 ]]; then @@ -1109,14 +1242,22 @@ install_openclaw() { fi if [[ "${requested}" == "latest" ]]; then - if ! env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@latest"; then + if ! env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@${resolved_requested}"; then log "npm install openclaw@latest failed; retrying openclaw@next" emit_json "{\"event\":\"step\",\"name\":\"openclaw\",\"status\":\"retry\",\"version\":\"next\"}" - env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@next" + resolved_requested="next" + if [[ -n "${REQUIRED_COMPATIBLE_VERSION:-}" ]]; then + resolved_requested="$(resolve_npm_openclaw_version next)" + if [[ -z "$resolved_requested" ]]; then + fail "Could not resolve OpenClaw next before compatibility checking." + fi + require_openclaw_version_compatible "$resolved_requested" + fi + env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@${resolved_requested}" requested="next" fi else - env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@${requested}" + env -u NPM_CONFIG_BEFORE -u npm_config_before -u NPM_CONFIG_MIN_RELEASE_AGE -u npm_config_min_release_age -u npm_config_min-release-age "$(npm_bin)" install -g --prefix "$(node_dir)" "${npm_args[@]}" "openclaw@${resolved_requested}" fi mkdir -p "${PREFIX}/bin" @@ -1212,6 +1353,15 @@ install_openclaw_from_git() { log "Repo is dirty; skipping git checkout/update" fi + if [[ -n "${REQUIRED_COMPATIBLE_VERSION:-}" ]]; then + local resolved_version + resolved_version="$(resolve_git_checkout_openclaw_version "$repo_dir" 2>/dev/null || true)" + if [[ -z "$resolved_version" ]]; then + fail "Could not resolve the Git checkout version before compatibility checking." + fi + require_openclaw_version_compatible "$resolved_version" + fi + cleanup_legacy_submodules "$repo_dir" ensure_pnpm_git_prepare_allowlist "$repo_dir" activate_repo_pnpm_version "$repo_dir" diff --git a/test/scripts/install-cli.test.ts b/test/scripts/install-cli.test.ts index a9147ec1ce37..e3efaf1fb554 100644 --- a/test/scripts/install-cli.test.ts +++ b/test/scripts/install-cli.test.ts @@ -217,6 +217,87 @@ describe("install-cli.sh", () => { expect(result.stdout + result.stderr).not.toContain("unbound variable"); }); + it("matches the Gateway future-config compatibility rule", () => { + const result = runInstallCliShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + node_bin() { command -v node; } + set +e + for pair in \ + 2026.7.1-2:2026.7.2 \ + 2026.7.2-beta.6:2026.7.2-beta.7 \ + 2026.7.2:2026.7.2-beta.7 \ + 2026.7.2-beta.7:2026.7.2 \ + 2026.7.2-1:2026.7.2-2 \ + 2026.7.3-beta.1:2026.7.2; do + candidate="\${pair%%:*}" + writer="\${pair#*:}" + openclaw_version_is_compatible_with "$candidate" "$writer" + printf '%s=%s\\n' "$pair" "$?" + done + `); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("2026.7.1-2:2026.7.2=1"); + expect(result.stdout).toContain("2026.7.2-beta.6:2026.7.2-beta.7=1"); + expect(result.stdout).toContain("2026.7.2:2026.7.2-beta.7=0"); + expect(result.stdout).toContain("2026.7.2-beta.7:2026.7.2=0"); + expect(result.stdout).toContain("2026.7.2-1:2026.7.2-2=0"); + expect(result.stdout).toContain("2026.7.3-beta.1:2026.7.2=0"); + }); + + it("rejects an incompatible channel before replacing an existing managed CLI", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-compatible-")); + const prefix = join(tmp, "prefix"); + const bin = join(prefix, "bin"); + const openclaw = join(bin, "openclaw"); + mkdirSync(bin, { recursive: true }); + writeFileSync(openclaw, "existing-managed-cli\n"); + + try { + const result = runInstallCliShell(` + set -euo pipefail + source "${SCRIPT_PATH}" + PREFIX=${JSON.stringify(prefix)} + OPENCLAW_VERSION=latest + REQUIRED_COMPATIBLE_VERSION=2026.7.2 + node_bin() { command -v node; } + npm_bin() { printf 'npm\\n'; } + npm_config_has_raw_key() { return 1; } + npm() { + if [[ "$1" == "view" ]]; then printf '2026.7.1-2\\n'; return 0; fi + if [[ "$1" == "config" ]]; then printf 'null\\n'; return 0; fi + printf 'unexpected mutation: %s\\n' "$*" >&2 + return 99 + } + install_openclaw + `); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("OpenClaw 2026.7.1-2 is older than config writer 2026.7.2"); + expect(result.stderr).not.toContain("unexpected mutation"); + expect(readFileSync(openclaw, "utf8")).toBe("existing-managed-cli\n"); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + }); + + it("checks a git checkout version before dependency install or wrapper replacement", () => { + const checkoutIndex = script.indexOf('checkout_git_openclaw_ref "$repo_dir" "$git_ref"'); + const compatibilityIndex = script.indexOf( + 'require_openclaw_version_compatible "$resolved_version"', + ); + const dependencyInstallIndex = script.indexOf( + 'CI="${CI:-true}" run_pnpm -C "$repo_dir" install "$install_lockfile_flag"', + ); + const wrapperIndex = script.indexOf('cat > "${PREFIX}/bin/openclaw"', compatibilityIndex); + + expect(checkoutIndex).toBeGreaterThan(-1); + expect(compatibilityIndex).toBeGreaterThan(checkoutIndex); + expect(dependencyInstallIndex).toBeGreaterThan(compatibilityIndex); + expect(wrapperIndex).toBeGreaterThan(compatibilityIndex); + }); + it("does not restart a gateway again after force-install activates it", () => { const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-gateway-refresh-")); const prefix = join(tmp, "prefix");