#!/usr/bin/env -S node --import tsx // Executed directly via Node.js + tsx in the release workflow. import { spawn } from "node:child_process"; import { appendFileSync, chmodSync, createReadStream, createWriteStream, existsSync, mkdirSync, closeSync, openSync, readFileSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs"; import { mkdtempSync } from "node:fs"; import { createServer } from "node:http"; import { createConnection as createNetConnection, createServer as createNetServer } from "node:net"; import type { Socket } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join, relative, resolve, win32 as pathWin32 } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { isLocalBuildMetadataDistPath } from "./lib/local-build-metadata-paths.mjs"; import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs"; const SCRIPT_PATH = fileURLToPath(import.meta.url); const PUBLISHED_INSTALLER_BASE_URL = "https://openclaw.ai"; const SUPPORTED_MODES = new Set(["fresh", "upgrade", "both"]); const SUPPORTED_SUITES = new Set([ "packaged-fresh", "installer-fresh", "packaged-upgrade", "dev-update", ]); const SUPPORTED_OS_IDS = new Set(["ubuntu", "windows", "macos"]); const CROSS_OS_SIGNAL_EXIT_CODES = { SIGHUP: 129, SIGINT: 130, SIGTERM: 143, }; const CROSS_OS_ACTIVE_CHILD_TREE_KILLERS = new Set<(signal: NodeJS.Signals) => void>(); let forwardedSignalExitCode: number | undefined; let forwardedSignalForceKillTimer: NodeJS.Timeout | undefined; export const CROSS_OS_AGENT_TURN_TIMEOUT_SECONDS = parsePositiveIntegerEnv( "OPENCLAW_CROSS_OS_AGENT_TURN_TIMEOUT_SECONDS", 600, ); export const CROSS_OS_COMMAND_CAPTURE_TAIL_BYTES = 16 * 1024 * 1024; const CROSS_OS_AGENT_LOG_FALLBACK_TAIL_BYTES = 2 * 1024 * 1024; const CROSS_OS_PROCESS_TREE_KILL_AFTER_MS = parsePositiveIntegerEnv( "OPENCLAW_CROSS_OS_PROCESS_TREE_KILL_AFTER_MS", 15_000, ); const CROSS_OS_AGENT_TURN_OPTIONAL = resolveCrossOsAgentTurnOptional(); for (const signal of Object.keys(CROSS_OS_SIGNAL_EXIT_CODES) as NodeJS.Signals[]) { process.on(signal, () => { forwardedSignalExitCode ??= CROSS_OS_SIGNAL_EXIT_CODES[signal]; if (CROSS_OS_ACTIVE_CHILD_TREE_KILLERS.size === 0) { process.exit(forwardedSignalExitCode); } const activeKillers = Array.from(CROSS_OS_ACTIVE_CHILD_TREE_KILLERS); for (const killChildTree of activeKillers) { killChildTree(signal); } forwardedSignalForceKillTimer ??= setTimeout(() => { for (const killChildTree of activeKillers) { killChildTree("SIGKILL"); } process.exit(forwardedSignalExitCode); }, CROSS_OS_PROCESS_TREE_KILL_AFTER_MS); }); } const providerConfig = { openai: { extensionId: "openai", secretEnv: "OPENAI_API_KEY", authChoice: "openai-api-key", model: "openai/gpt-5.5", baseUrl: "https://api.openai.com/v1", timeoutSeconds: CROSS_OS_AGENT_TURN_TIMEOUT_SECONDS, }, anthropic: { extensionId: "anthropic", secretEnv: "ANTHROPIC_API_KEY", authChoice: "apiKey", model: "anthropic/claude-sonnet-4-6", }, minimax: { extensionId: "minimax", secretEnv: "MINIMAX_API_KEY", authChoice: "minimax-global-api", model: "minimax/MiniMax-M2.7", }, }; export function resolveProviderConfig(provider, env = process.env) { const config = providerConfig[provider]; if (!config) { return null; } const providerEnvKey = `OPENCLAW_CROSS_OS_${provider.toUpperCase().replace(/[^A-Z0-9]+/gu, "_")}_MODEL`; const model = env[providerEnvKey]?.trim() || env.OPENCLAW_CROSS_OS_MODEL?.trim() || config.model; return { ...config, model }; } const RELEASE_SMOKE_PLUGIN_ALLOWLIST_BASE = [ "acpx", "bonjour", "browser", "device-pair", "phone-control", "talk-voice", ]; export function buildCrossOsReleaseSmokePluginAllowlist(providerMeta) { return [...new Set([providerMeta.extensionId, ...RELEASE_SMOKE_PLUGIN_ALLOWLIST_BASE])]; } function shouldSeedProviderConfigModels(providerMeta) { return ( typeof providerMeta.baseUrl === "string" || typeof providerMeta.timeoutSeconds === "number" ); } function buildReleaseProviderConfigOverride(providerMeta) { if (!shouldSeedProviderConfigModels(providerMeta)) { return null; } return { ...(typeof providerMeta.baseUrl === "string" ? { baseUrl: providerMeta.baseUrl } : {}), ...(providerMeta.extensionId === "openai" ? { agentRuntime: { id: "openclaw" } } : {}), models: [], ...(typeof providerMeta.timeoutSeconds === "number" ? { timeoutSeconds: providerMeta.timeoutSeconds } : {}), }; } const PACKAGE_DIST_INVENTORY_RELATIVE_PATH = "dist/postinstall-inventory.json"; const INSTALL_STAGE_DEBRIS_DIR_PATTERN = /^\.openclaw-install-stage(?:-[^/]+)?$/iu; const OMITTED_QA_EXTENSION_PREFIXES = [ "dist/extensions/qa-channel/", "dist/extensions/qa-lab/", "dist/extensions/qa-matrix/", ]; export const CROSS_OS_DASHBOARD_SMOKE_TIMEOUT_MS = 120_000; export const CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS = 10_000; export const CROSS_OS_DISCORD_FETCH_TIMEOUT_MS = parsePositiveIntegerEnv( "OPENCLAW_CROSS_OS_DISCORD_FETCH_TIMEOUT_MS", 10_000, ); export const CROSS_OS_FETCH_BODY_MAX_CHARS = 1024 * 1024; export const CROSS_OS_GATEWAY_STATUS_RPC_TIMEOUT_MS = 30_000; export const CROSS_OS_GATEWAY_STATUS_COMMAND_TIMEOUT_MS = CROSS_OS_GATEWAY_STATUS_RPC_TIMEOUT_MS + 45_000; export const CROSS_OS_GATEWAY_READY_TIMEOUT_MS = 3 * 60_000; export const CROSS_OS_WINDOWS_GATEWAY_READY_TIMEOUT_MS = 5 * 60_000; export const CROSS_OS_RELEASE_SMOKE_TOOLS_PROFILE = "minimal"; export const CROSS_OS_WINDOWS_PACKAGED_UPGRADE_STEP_TIMEOUT_SECONDS = 10 * 60; export const CROSS_OS_WINDOWS_PACKAGED_UPGRADE_WRAPPER_TIMEOUT_MS = (CROSS_OS_WINDOWS_PACKAGED_UPGRADE_STEP_TIMEOUT_SECONDS + 2 * 60) * 1000; export const CROSS_OS_COMMAND_HEARTBEAT_SECONDS = parsePositiveIntegerEnv( "OPENCLAW_CROSS_OS_COMMAND_HEARTBEAT_SECONDS", 60, ); if (isMainModule()) { try { await main(process.argv.slice(2)); } catch (error) { process.stderr.write(`${formatError(error)}\n`); process.exit(1); } } function isMainModule() { const invokedPath = process.argv[1]?.trim(); if (!invokedPath) { return false; } return resolve(invokedPath) === SCRIPT_PATH; } export function parseArgs(argv) { const parsed = {}; for (let index = 0; index < argv.length; index += 1) { const token = argv[index]; if (!token.startsWith("--")) { continue; } const key = token.slice(2); const next = argv[index + 1]; if (next === undefined || next.startsWith("--")) { parsed[key] = "true"; continue; } parsed[key] = next; index += 1; } return parsed; } export function parsePositiveIntegerEnv(name, fallback, env = process.env) { const raw = env[name]?.trim(); if (!raw) { return fallback; } if (!/^\d+$/u.test(raw)) { throw new Error(`${name} must be a positive integer. Got: ${JSON.stringify(raw)}`); } const value = Number(raw); if (!Number.isSafeInteger(value) || value <= 0) { throw new Error(`${name} must be a positive integer. Got: ${JSON.stringify(raw)}`); } return value; } function parseBooleanEnv(name, fallback, env = process.env) { const raw = env[name]?.trim(); if (!raw) { return fallback; } if (/^(1|true|yes|on)$/iu.test(raw)) { return true; } if (/^(0|false|no|off)$/iu.test(raw)) { return false; } throw new Error(`${name} must be a boolean. Got: ${JSON.stringify(raw)}`); } export function resolveCrossOsAgentTurnOptional(env = process.env) { return parseBooleanEnv("OPENCLAW_CROSS_OS_AGENT_TURN_OPTIONAL", false, env); } export function looksLikeReleaseVersionRef(ref) { const trimmed = normalizeRequestedRef(ref); return /^v?[0-9]{4}\.[0-9]+\.[0-9]+(?:-(?:[1-9][0-9]*)|[-.](?:alpha|beta|rc)[-.]?[0-9]+)?$/iu.test( trimmed, ); } export function normalizeRequestedRef(ref) { const trimmed = ref?.trim() || ""; if (!trimmed) { return ""; } if (trimmed.startsWith("refs/heads/")) { return trimmed.slice("refs/heads/".length); } if (trimmed.startsWith("refs/tags/")) { return trimmed.slice("refs/tags/".length); } return trimmed; } export function isImmutableReleaseRef(ref) { const trimmed = ref?.trim() || ""; return trimmed.startsWith("refs/tags/") || looksLikeReleaseVersionRef(trimmed); } export function resolveRequestedSuites(mode, ref) { if (!SUPPORTED_MODES.has(mode)) { throw new Error(`Unsupported mode "${mode}".`); } const suites = []; if (mode === "fresh" || mode === "both") { suites.push("packaged-fresh", "installer-fresh"); } if (mode === "upgrade" || mode === "both") { suites.push("packaged-upgrade"); if (shouldRunMainChannelDevUpdate(ref)) { suites.push("dev-update"); } } return suites; } export function resolveRunnerMatrix(params) { const pick = (...values) => values.find((value) => typeof value === "string" && value.trim().length > 0)?.trim(); const suites = resolveRequestedSuites(params.mode, params.ref); const suiteFilter = parseCrossOsSuiteFilter(params.suiteFilter ?? ""); const runners = [ { os_id: "ubuntu", display_name: "Linux", runner: pick(params.ubuntuRunner, params.varUbuntuRunner, "blacksmith-8vcpu-ubuntu-2404"), artifact_name: "linux", }, { os_id: "windows", display_name: "Windows", runner: pick(params.windowsRunner, params.varWindowsRunner, "blacksmith-32vcpu-windows-2025"), artifact_name: "windows", }, { os_id: "macos", display_name: "macOS", runner: pick(params.macosRunner, params.varMacosRunner, "blacksmith-6vcpu-macos-15"), artifact_name: "macos", }, ]; const include = runners.flatMap((runner) => suites .filter((suite) => suiteFilter.matches(runner.os_id, suite)) .map((suite) => Object.assign({}, runner, { suite, suite_label: formatSuiteLabel(suite), lane: suite.includes(`upgrade`) || suite === `dev-update` ? `upgrade` : `fresh`, }), ), ); if (include.length === 0) { throw new Error( `cross_os_suite_filter ${JSON.stringify(params.suiteFilter ?? "")} did not match any ${params.mode} suite.`, ); } return { include, }; } export function parseCrossOsSuiteFilter(rawFilter) { const tokens = String(rawFilter ?? "") .split(/[, ]+/u) .map((token) => normalizeCrossOsSuiteFilterToken(token)) .filter(Boolean); if (tokens.length === 0) { return { matches: () => true, tokens, }; } const matchers = tokens.map((token) => { if (SUPPORTED_SUITES.has(token)) { return { osId: "", suite: token }; } if (SUPPORTED_OS_IDS.has(token)) { return { osId: token, suite: "" }; } for (const separator of ["/", ":", "-"]) { const matchedOs = [...SUPPORTED_OS_IDS].find((osId) => token.startsWith(`${osId}${separator}`), ); if (!matchedOs) { continue; } const suite = token.slice(matchedOs.length + separator.length); if (!SUPPORTED_SUITES.has(suite)) { break; } return { osId: matchedOs, suite }; } throw new Error( `Unsupported cross_os_suite_filter token ${JSON.stringify(token)}. Use an OS id, suite id, or os/suite pair such as windows/packaged-upgrade.`, ); }); return { matches: (osId, suite) => matchers.some((matcher) => { const osMatches = !matcher.osId || matcher.osId === osId; const suiteMatches = !matcher.suite || matcher.suite === suite; return osMatches && suiteMatches; }), tokens, }; } function normalizeCrossOsSuiteFilterToken(token) { return token .trim() .toLowerCase() .replace(/_/gu, "-") .replace(/\s*[/:-]\s*/gu, (separator) => separator.trim()) .replace(/\s+/gu, "-"); } export function readRunnerOverrideEnv(env = process.env) { const preferNonEmptyEnv = (primary: string | undefined, legacy: string | undefined) => { const primaryValue = primary?.trim(); if (primaryValue) { return primaryValue; } const legacyValue = legacy?.trim(); return legacyValue || ""; }; return { varUbuntuRunner: preferNonEmptyEnv( env.VAR_UBUNTU_RUNNER, env.OPENCLAW_RELEASE_CHECKS_UBUNTU_RUNNER, ), varWindowsRunner: preferNonEmptyEnv( env.VAR_WINDOWS_RUNNER, env.OPENCLAW_RELEASE_CHECKS_WINDOWS_RUNNER, ), varMacosRunner: preferNonEmptyEnv( env.VAR_MACOS_RUNNER, env.OPENCLAW_RELEASE_CHECKS_MACOS_RUNNER, ), }; } function formatSuiteLabel(suite) { if (suite === "packaged-fresh") { return "packaged fresh"; } if (suite === "installer-fresh") { return "installer fresh"; } if (suite === "packaged-upgrade") { return "packaged upgrade"; } return "dev update"; } async function main(argv) { const args = parseArgs(argv); if (args["resolve-matrix"] === "true") { const mode = args["mode"] ?? "both"; const ref = args["ref"]?.trim() || "main"; const runnerOverrideEnv = readRunnerOverrideEnv(process.env); process.stdout.write( `${JSON.stringify( resolveRunnerMatrix({ mode, ref, ubuntuRunner: args["ubuntu-runner"], windowsRunner: args["windows-runner"], macosRunner: args["macos-runner"], suiteFilter: args["suite-filter"], ...runnerOverrideEnv, }), )}\n`, ); return; } const outputDir = resolve(requireArg(args, "output-dir")); const prepareOnly = args["prepare-only"] === "true"; const sourceDir = args["source-dir"]?.trim() ? resolve(args["source-dir"].trim()) : ""; const provider = args["provider"]?.trim() || ""; const suite = args["suite"]?.trim() || ""; const mode = args["mode"] ?? "both"; const inputRef = args["ref"]?.trim() || ""; const previousVersion = args["previous-version"]?.trim() || ""; const baselineSpec = args["baseline-spec"]?.trim() || (previousVersion ? `openclaw@${previousVersion}` : "openclaw@latest"); const providedBaselineTgz = args["baseline-tgz"]?.trim() ? resolve(args["baseline-tgz"].trim()) : ""; const providedCandidateTgz = args["candidate-tgz"]?.trim() ? resolve(args["candidate-tgz"].trim()) : ""; const providedCandidateVersion = args["candidate-version"]?.trim() || ""; const providedSourceSha = args["source-sha"]?.trim() || ""; const runDiscordRoundtrip = args["run-discord-roundtrip"] === "true"; mkdirSync(outputDir, { recursive: true }); const logsDir = join(outputDir, "logs"); mkdirSync(logsDir, { recursive: true }); if (prepareOnly) { if (!sourceDir) { throw new Error("--prepare-only requires --source-dir."); } const build = await prepareCandidate({ outputDir, sourceDir, logsDir, }); writeCandidateManifest(outputDir, build); return; } if (!SUPPORTED_SUITES.has(suite)) { throw new Error(`Unsupported suite "${suite}".`); } if (!Object.hasOwn(providerConfig, provider)) { throw new Error(`Unsupported provider "${provider}".`); } const selectedProvider = resolveProviderConfig(provider); const providerSecretValue = process.env[selectedProvider.secretEnv]?.trim(); if (!providerSecretValue) { throw new Error(`Missing ${selectedProvider.secretEnv}.`); } const summary = { platform: process.platform, runnerOs: process.env.OPENCLAW_RELEASE_CHECK_OS ?? "", runnerLabel: process.env.OPENCLAW_RELEASE_CHECK_RUNNER ?? "", provider, mode, suite, ref: inputRef || null, previousVersion: previousVersion || null, sourceDir, sourceSha: "", candidateVersion: "", candidateTgz: "", baselineSpec, result: { status: "pending", }, discordRoundtrip: runDiscordRoundtrip, }; let build; try { build = sourceDir ? await prepareCandidate({ outputDir, sourceDir, logsDir, }) : readProvidedCandidate({ candidateTgz: providedCandidateTgz, candidateVersion: providedCandidateVersion, sourceSha: providedSourceSha, }); summary.sourceSha = build.sourceSha; summary.candidateVersion = build.candidateVersion; summary.candidateTgz = build.candidateTgz; if (suite === "packaged-fresh") { summary.result = await runFreshLane({ build, logsDir, providerConfig: selectedProvider, providerSecretValue, }); } else if (suite === "packaged-upgrade") { const tgzServer = await startStaticFileServer({ filePath: build.candidateTgz, logPath: join(logsDir, "candidate-http-server.log"), }); try { summary.result = await runUpgradeLane({ baselineSpec, baselineTgz: providedBaselineTgz, build, candidateUrl: tgzServer.url, logsDir, providerConfig: selectedProvider, providerSecretValue, }); } finally { await tgzServer.close(); } } else if (suite === "installer-fresh") { summary.result = await runInstallerFreshSuite({ build, logsDir, providerConfig: selectedProvider, providerSecretValue, runDiscordRoundtrip, }); } else { summary.result = await runDevUpdateSuite({ baselineSpec, logsDir, providerConfig: selectedProvider, providerSecretValue, ref: inputRef || "main", sourceSha: build.sourceSha, runDiscordRoundtrip, }); } } catch (error) { summary.result = { status: "fail", error: formatError(error), }; } writeSummary(outputDir, summary); if (summary.result.status !== "pass") { process.exit(1); } } async function prepareCandidate(params) { logPhase("prepare", "resolve-source-sha"); const packageJson = readPackageJson(params.sourceDir); const hasUiBuildScript = packageJsonHasScript(packageJson, "ui:build"); const sourceSha = ( await runCommand(gitCommand(), ["rev-parse", "HEAD"], { cwd: params.sourceDir, logPath: join(params.logsDir, "git-rev-parse.log"), }) ).stdout.trim(); const buildEnv = { ...process.env, NODE_OPTIONS: "--max-old-space-size=8192", }; logPhase("prepare", "pnpm-install"); await runCommand(pnpmCommand(), ["install", "--frozen-lockfile"], { cwd: params.sourceDir, env: buildEnv, logPath: join(params.logsDir, "pnpm-install.log"), timeoutMs: 45 * 60 * 1000, }); logPhase("prepare", "pnpm-build"); await runCommand(pnpmCommand(), ["build"], { cwd: params.sourceDir, env: buildEnv, logPath: join(params.logsDir, "pnpm-build.log"), timeoutMs: 45 * 60 * 1000, }); if (hasUiBuildScript) { // pnpm build does not regenerate dist/control-ui, and checked-in bundles can // otherwise leak into npm pack when a ref changes UI assets. logPhase("prepare", "pnpm-ui-build"); await runCommand(pnpmCommand(), ["ui:build"], { cwd: params.sourceDir, env: buildEnv, logPath: join(params.logsDir, "pnpm-ui-build.log"), timeoutMs: 30 * 60 * 1000, }); } const packDir = join(params.outputDir, "package"); mkdirSync(packDir, { recursive: true }); const packJsonPath = join(packDir, "pack.json"); logPhase("prepare", "package-dist-inventory"); await writePackageDistInventoryForCandidate({ sourceDir: params.sourceDir, logPath: join(params.logsDir, "npm-pack-dry-run.log"), }); logPhase("prepare", "npm-pack"); const packResult = await runCommand( npmCommand(), ["pack", "--ignore-scripts", "--json", "--pack-destination", packDir], { cwd: params.sourceDir, logPath: join(params.logsDir, "npm-pack.log"), timeoutMs: 10 * 60 * 1000, }, ); writeFileSync(packJsonPath, packResult.stdout, "utf8"); const parsedPack = JSON.parse(packResult.stdout); const lastPack = Array.isArray(parsedPack) ? parsedPack.at(-1) : null; if (!lastPack?.filename) { throw new Error("npm pack did not report a filename."); } return { sourceDir: params.sourceDir, sourceSha, candidateVersion: String(lastPack.version ?? packageJson.version ?? "").trim(), candidateTgz: join(packDir, lastPack.filename), candidateFileName: String(lastPack.filename).trim(), }; } function normalizeRelativePath(value) { return value.replace(/\\/gu, "/"); } function isNotFoundError(error) { return error && typeof error === "object" && error.code === "ENOENT"; } function isInstallStageDirName(value) { return INSTALL_STAGE_DEBRIS_DIR_PATTERN.test(value); } function collectLegacyPluginDependencyStagingDebrisPaths(packageRoot) { const rootEntries = readdirSync(packageRoot, { withFileTypes: true }); const debris = []; for (const rootEntry of rootEntries) { if (!rootEntry.isDirectory() || rootEntry.name.toLowerCase() !== "dist") { continue; } const distDir = join(packageRoot, rootEntry.name); let distEntries; try { distEntries = readdirSync(distDir, { withFileTypes: true }); } catch (error) { if (isNotFoundError(error)) { continue; } throw error; } for (const distEntry of distEntries) { if (!distEntry.isDirectory() || distEntry.name.toLowerCase() !== "extensions") { continue; } const extensionsDir = join(distDir, distEntry.name); let extensionEntries; try { extensionEntries = readdirSync(extensionsDir, { withFileTypes: true }); } catch (error) { if (isNotFoundError(error)) { continue; } throw error; } for (const extensionEntry of extensionEntries) { if (!extensionEntry.isDirectory()) { continue; } const extensionPath = join(extensionsDir, extensionEntry.name); let stagingEntries; try { stagingEntries = readdirSync(extensionPath, { withFileTypes: true }); } catch (error) { if (isNotFoundError(error)) { continue; } throw error; } for (const stagingEntry of stagingEntries) { if (isInstallStageDirName(stagingEntry.name)) { debris.push( normalizeRelativePath(relative(packageRoot, join(extensionPath, stagingEntry.name))), ); } } } } } return debris.toSorted((left, right) => left.localeCompare(right)); } function assertNoLegacyPluginDependencyStagingDebris(packageRoot) { const debris = collectLegacyPluginDependencyStagingDebrisPaths(packageRoot); if (debris.length === 0) { return; } throw new Error( `unexpected legacy plugin dependency staging debris in package dist: ${debris.join(", ")}`, ); } function isPackagedDistPath(relativePath) { if (!relativePath.startsWith("dist/")) { return false; } if (relativePath === PACKAGE_DIST_INVENTORY_RELATIVE_PATH) { return false; } if (isLocalBuildMetadataDistPath(relativePath)) { return false; } if (relativePath.endsWith(".map")) { return false; } if (relativePath === "dist/plugin-sdk/.tsbuildinfo") { return false; } if (OMITTED_QA_EXTENSION_PREFIXES.some((prefix) => relativePath.startsWith(prefix))) { return false; } return true; } export async function writePackageDistInventoryForCandidate(params) { assertNoLegacyPluginDependencyStagingDebris(params.sourceDir); const dryRun = await runCommand( npmCommand(), ["pack", "--dry-run", "--ignore-scripts", "--json"], { cwd: params.sourceDir, logPath: params.logPath, timeoutMs: 5 * 60 * 1000, }, ); const parsedPack = JSON.parse(dryRun.stdout); const lastPack = Array.isArray(parsedPack) ? parsedPack.at(-1) : null; const files = Array.isArray(lastPack?.files) ? lastPack.files : []; if (files.length === 0) { throw new Error( "npm pack --dry-run did not report package files for dist inventory generation.", ); } const inventory = files .flatMap((entry) => { const relativePath = normalizeRelativePath(String(entry?.path ?? "").trim()); return isPackagedDistPath(relativePath) ? [relativePath] : []; }) .toSorted((left, right) => left.localeCompare(right)); const inventoryPath = join(params.sourceDir, PACKAGE_DIST_INVENTORY_RELATIVE_PATH); mkdirSync(dirname(inventoryPath), { recursive: true }); writeFileSync(inventoryPath, `${JSON.stringify(inventory, null, 2)}\n`, "utf8"); } function readProvidedCandidate(params) { if (!params.candidateTgz) { throw new Error("Missing required --candidate-tgz argument when --source-dir is not provided."); } if (!existsSync(params.candidateTgz)) { throw new Error(`Candidate package not found: ${params.candidateTgz}`); } if (!params.candidateVersion) { throw new Error( "Missing required --candidate-version argument when --source-dir is not provided.", ); } if (!params.sourceSha) { throw new Error("Missing required --source-sha argument when --source-dir is not provided."); } return { sourceDir: "", sourceSha: params.sourceSha, candidateVersion: params.candidateVersion, candidateTgz: params.candidateTgz, candidateFileName: params.candidateTgz.split(/[/\\]/u).at(-1) ?? "", }; } async function runFreshLane(params) { const lane = createLaneState("fresh"); const cleanup = []; try { const env = buildLaneEnv(lane, params.providerConfig, params.providerSecretValue); await runTimedLanePhase(lane, "install-candidate", async () => { await installTarballPackage({ lane, env, tgzPath: params.build.candidateTgz, logPath: join(params.logsDir, "fresh-install.log"), restoreBundledPluginPostinstall: false, }); }); const installed = readInstalledMetadata(lane.prefixDir); verifyInstalledCandidate(installed, params.build); await runTimedLanePhase(lane, "run-bundled-plugin-postinstall", async () => { await runBundledPluginPostinstall({ lane, env, logPath: join(params.logsDir, "fresh-install.log"), }); }); let browserOverrideImportStatus = "skipped"; if (shouldRunWindowsInstalledBrowserOverrideImportSmoke()) { browserOverrideImportStatus = await runTimedLanePhase( lane, "windows-browser-override-import", async () => runInstalledBrowserOverrideImportSmoke({ lane, env, prefixDir: lane.prefixDir, logPath: join(params.logsDir, "fresh-windows-browser-override-import.log"), }), ); } await runTimedLanePhase(lane, "onboard", async () => { await runOnboard({ lane, env, providerConfig: params.providerConfig, logPath: join(params.logsDir, "fresh-onboard.log"), }); }); await runTimedLanePhase(lane, "models-set", async () => { await runModelsSet({ lane, env, providerConfig: params.providerConfig, logPath: join(params.logsDir, "fresh-models-set.log"), }); }); const gateway = await runTimedLanePhase(lane, "start-gateway", async () => startGateway({ lane, env, logPath: join(params.logsDir, "fresh-gateway.log"), }), ); cleanup.push(() => stopGateway(gateway)); await runTimedLanePhase(lane, "wait-gateway", async () => { await waitForGateway({ lane, env, logPath: join(params.logsDir, "fresh-gateway-status.log"), }); }); await runTimedLanePhase(lane, "dashboard", async () => { await runDashboardSmoke({ lane, logPath: join(params.logsDir, "fresh-dashboard.log"), }); }); const agent = await runTimedLanePhase(lane, "agent-turn", async () => runAgentTurn({ lane, env, label: "fresh", logPath: join(params.logsDir, "fresh-agent.log"), }), ); return { status: "pass", installedVersion: installed.version, installedCommit: installed.commit, dashboardStatus: "pass", gatewayPort: lane.gatewayPort, browserOverrideImportStatus, agentOutput: trimForSummary(agent.stdout), phaseTimings: lane.phaseTimings, }; } finally { await runCleanup(cleanup); } } async function runUpgradeLane(params) { if (!params.baselineTgz && !params.baselineSpec) { throw new Error("Missing required --baseline-tgz argument for upgrade mode."); } if (!params.candidateUrl) { throw new Error("Missing candidate package URL for upgrade mode."); } const lane = createLaneState("upgrade"); const cleanup = []; try { const env = buildLaneEnv(lane, params.providerConfig, params.providerSecretValue); await runTimedLanePhase(lane, "install-baseline", async () => { if (!params.baselineTgz && params.baselineSpec) { await installPackageSpec({ lane, env, packageSpec: params.baselineSpec, logPath: join(params.logsDir, "upgrade-install-baseline.log"), ignoreScripts: true, }); } else { await installTarballPackage({ lane, env, tgzPath: params.baselineTgz, logPath: join(params.logsDir, "upgrade-install-baseline.log"), ignoreScripts: true, restoreBundledPluginPostinstall: false, }); } }); await runTimedLanePhase(lane, "run-baseline-bundled-plugin-postinstall", async () => { await runBundledPluginPostinstall({ lane, env, logPath: join(params.logsDir, "upgrade-install-baseline.log"), }); }); const baseline = { version: readInstalledVersion(lane.prefixDir), }; const updateEnv = buildRealUpdateEnv(env); const updateArgs = buildPackagedUpgradeUpdateArgs(params.candidateUrl); const updateLogPath = join(params.logsDir, "upgrade-update.log"); let updateResult; let usedWindowsPackagedUpgradeTimeoutFallback = false; await runTimedLanePhase(lane, "update", async () => { try { updateResult = await runOpenClaw({ lane, env: updateEnv, args: updateArgs, logPath: updateLogPath, timeoutMs: updateTimeoutMs(), check: false, }); } catch (error) { if (!isRecoverableWindowsPackagedUpgradeTimeoutError(error, process.platform)) { throw error; } usedWindowsPackagedUpgradeTimeoutFallback = true; appendFileSync( updateLogPath, `\n[release-checks] Windows baseline updater timed out after fetching candidate; falling back to direct candidate install: ${formatError(error)}\n`, ); updateResult = { exitCode: 124, stdout: "", stderr: formatError(error), }; } }); const usedWindowsPackagedUpgradeFallback = usedWindowsPackagedUpgradeTimeoutFallback || isRecoverableWindowsPackagedUpgradeSwapCleanupFailure(updateResult, process.platform); if (usedWindowsPackagedUpgradeFallback) { await runTimedLanePhase(lane, "update-fallback-install", async () => { await installPackageSpec({ lane, env, packageSpec: params.candidateUrl, logPath: join(params.logsDir, "upgrade-update-fallback-install.log"), ignoreScripts: true, }); const fallbackInstalledVersion = readInstalledVersion(lane.prefixDir); verifyWindowsPackagedUpgradeFallbackInstall({ installedVersion: fallbackInstalledVersion, candidateVersion: params.build.candidateVersion, }); appendFileSync( updateLogPath, `\n[release-checks] Windows fallback install verified candidate version ${fallbackInstalledVersion}\n`, ); }); } else { verifyPackagedUpgradeUpdateResult(updateResult, { candidateVersion: params.build.candidateVersion, }); } if ( shouldRunPackagedUpgradeStatusProbe({ platform: process.platform, usedWindowsPackagedUpgradeFallback, }) ) { await runTimedLanePhase(lane, "update-status", async () => { await runOpenClaw({ lane, env: updateEnv, args: ["update", "status", "--json"], logPath: join(params.logsDir, "upgrade-update-status.log"), timeoutMs: 2 * 60 * 1000, }); }); } await runTimedLanePhase(lane, "run-bundled-plugin-postinstall", async () => { await runBundledPluginPostinstall({ lane, env, logPath: join(params.logsDir, "upgrade-bundled-plugin-postinstall.log"), }); }); const installed = readInstalledMetadata(lane.prefixDir); verifyInstalledCandidate(installed, params.build); await runTimedLanePhase(lane, "onboard", async () => { await runOnboard({ lane, env, providerConfig: params.providerConfig, logPath: join(params.logsDir, "upgrade-onboard.log"), }); }); await runTimedLanePhase(lane, "models-set", async () => { await runModelsSet({ lane, env, providerConfig: params.providerConfig, logPath: join(params.logsDir, "upgrade-models-set.log"), }); }); const gateway = await runTimedLanePhase(lane, "start-gateway", async () => startGateway({ lane, env, logPath: join(params.logsDir, "upgrade-gateway.log"), }), ); cleanup.push(() => stopGateway(gateway)); await runTimedLanePhase(lane, "wait-gateway", async () => { await waitForGateway({ lane, env, logPath: join(params.logsDir, "upgrade-gateway-status.log"), }); }); await runTimedLanePhase(lane, "dashboard", async () => { await runDashboardSmoke({ lane, logPath: join(params.logsDir, "upgrade-dashboard.log"), }); }); const agent = await runTimedLanePhase(lane, "agent-turn", async () => runAgentTurn({ lane, env, label: "upgrade", logPath: join(params.logsDir, "upgrade-agent.log"), }), ); return { status: "pass", baselineVersion: baseline.version, installedVersion: installed.version, installedCommit: installed.commit, dashboardStatus: "pass", gatewayPort: lane.gatewayPort, agentOutput: trimForSummary(agent.stdout), phaseTimings: lane.phaseTimings, }; } finally { await runCleanup(cleanup); } } async function runInstallerFreshSuite(params) { const lane = createLaneState("installer-fresh"); const cleanup = []; const usesManagedGateway = shouldUseManagedGatewayService(); const useManagedGatewayAfterInstall = shouldUseManagedGatewayForInstallerRuntime(); const manualGateway = { current: null }; try { const env = buildInstallerEnv(lane, params.providerConfig, params.providerSecretValue); // Drive the public installer against the exact candidate artifact built from the requested ref. const candidateServer = await startStaticFileServer({ filePath: params.build.candidateTgz, logPath: join(params.logsDir, "installer-candidate-http-server.log"), }); cleanup.push(() => candidateServer.close()); const installTarget = candidateServer.url; const installerUrl = resolvePublishedInstallerUrl(); logLanePhase(lane, "installer-run"); await runInstallerSmoke({ lane, env, installerUrl, installTarget, logPath: join(params.logsDir, "installer-fresh-install.log"), }); logLanePhase(lane, "fresh-shell"); const freshShell = await verifyFreshShellCommand({ lane, env, expectedNeedle: params.build.candidateVersion, logPath: join(params.logsDir, "installer-fresh-shell.log"), }); const installed = readInstalledMetadataFromCliPath(freshShell.cliPath); verifyInstalledCandidate(installed, params.build); let browserOverrideImportStatus = "skipped"; if (shouldRunWindowsInstalledBrowserOverrideImportSmoke()) { logLanePhase(lane, "windows-browser-override-import"); browserOverrideImportStatus = await runInstalledBrowserOverrideImportSmoke({ lane, env, prefixDir: resolveInstalledPrefixDirFromCliPath(freshShell.cliPath), logPath: join(params.logsDir, "installer-fresh-windows-browser-override-import.log"), }); } logLanePhase(lane, "onboard"); await runOnboardWithInstalledCli({ lane, cliPath: freshShell.cliPath, env, providerConfig: params.providerConfig, installDaemon: usesManagedGateway, logPath: join(params.logsDir, "installer-fresh-onboard.log"), }); if (shouldExerciseManagedGatewayLifecycleAfterInstall()) { await exerciseManagedGatewayLifecycle({ lane, cliPath: freshShell.cliPath, env, logPrefix: join(params.logsDir, "installer-fresh-gateway"), }); } logLanePhase(lane, "models-set"); await runInstalledModelsSet({ cliPath: freshShell.cliPath, env, providerConfig: params.providerConfig, cwd: lane.homeDir, logPath: join(params.logsDir, "installer-fresh-models-set.log"), }); if (!useManagedGatewayAfterInstall) { // Keep the Windows installer lane validating Scheduled Task registration during // onboarding and lifecycle commands, but use a manual gateway for the runtime // checks after that so the installer validation does not depend on the more // failure-prone managed Windows session state for the remainder of the lane. if (shouldStopManagedGatewayBeforeManualFallback()) { logLanePhase(lane, "gateway-stop-managed"); await runInstalledCli({ cliPath: freshShell.cliPath, args: ["gateway", "stop"], env, cwd: lane.homeDir, logPath: join(params.logsDir, "installer-fresh-gateway-stop-managed.log"), timeoutMs: 2 * 60 * 1000, check: false, }); await waitForInstalledGatewayToStop({ lane, cliPath: freshShell.cliPath, env, logPath: join(params.logsDir, "installer-fresh-gateway-stop-managed-status.log"), }); } logLanePhase(lane, "gateway-start"); const gateway = await startManualGatewayFromInstalledCli({ lane, cliPath: freshShell.cliPath, env, logPath: join(params.logsDir, "installer-fresh-gateway.log"), }); manualGateway.current = gateway; cleanup.push(() => stopGateway(manualGateway.current)); logLanePhase(lane, "gateway-status"); await waitForInstalledGateway({ lane, cliPath: freshShell.cliPath, env, logPath: join(params.logsDir, "installer-fresh-gateway-status.log"), }); } logLanePhase(lane, "dashboard"); await runDashboardSmoke({ lane, logPath: join(params.logsDir, "installer-fresh-dashboard.log"), }); logLanePhase(lane, "agent-turn"); const agent = await runInstalledAgentTurn({ cliPath: freshShell.cliPath, env, cwd: lane.homeDir, label: "installer-fresh", logPath: join(params.logsDir, "installer-fresh-agent.log"), }); let discordStatus = "skipped"; if (params.runDiscordRoundtrip && process.platform === "darwin") { logLanePhase(lane, "discord-roundtrip"); discordStatus = await maybeRunDiscordRoundtrip({ lane, cliPath: freshShell.cliPath, env, gatewayHolder: manualGateway, logPath: join(params.logsDir, "installer-fresh-discord.log"), }); } return { status: "pass", installTarget, installVersion: installed.version, cliPath: freshShell.cliPath, installedVersion: installed.version, installedCommit: installed.commit, gatewayPort: lane.gatewayPort, dashboardStatus: "pass", browserOverrideImportStatus, discordStatus, agentOutput: trimForSummary(agent.stdout), }; } finally { await runCleanup(cleanup); } } async function runDevUpdateSuite(params) { const lane = createLaneState("dev-update"); const cleanup = []; const installTarget = await resolveInstallerTargetVersion({ baselineSpec: params.baselineSpec, logsDir: params.logsDir, suiteName: "dev-update", }); const usesManagedGateway = shouldUseManagedGatewayService(); // Keep dev-update on a manual gateway even on Windows. The packaged lanes // already cover the Scheduled Task path, while repaired git installs live in // an ephemeral checkout that has proven flaky as a managed service in CI. const useManagedGatewayAfterDevUpdate = usesManagedGateway && process.platform !== "win32"; const requestedRef = resolveExpectedDevUpdateRef(params.ref); if (!shouldRunMainChannelDevUpdate(requestedRef)) { throw new Error( `The dev-update suite only supports main. Received ${normalizeRequestedRef(params.ref) || ""}.`, ); } const verificationRef = resolveDevUpdateVerificationRef(params.ref, params.sourceSha); const manualGateway = { current: null }; try { const env = buildInstallerEnv(lane, params.providerConfig, params.providerSecretValue); const installerUrl = resolvePublishedInstallerUrl(); logLanePhase(lane, "installer-baseline"); await runInstallerSmoke({ lane, env, installerUrl, installTarget, logPath: join(params.logsDir, "dev-update-install.log"), }); logLanePhase(lane, "fresh-shell-baseline"); const baselineShell = await verifyFreshShellCommand({ lane, env, expectedNeedle: installTarget, logPath: join(params.logsDir, "dev-update-baseline-shell.log"), }); logLanePhase(lane, "update-dev"); await runInstalledCli({ cliPath: baselineShell.cliPath, args: ["update", "--channel", "dev", "--yes", "--json"], env: { ...buildRealUpdateEnv(env), OPENCLAW_UPDATE_DEV_TARGET_REF: verificationRef, }, cwd: lane.homeDir, logPath: join(params.logsDir, "dev-update.log"), timeoutMs: updateTimeoutMs(), }); logLanePhase(lane, "fresh-shell-updated"); const updatedShell = await verifyFreshShellCommand({ lane, env, expectedNeedle: "OpenClaw", logPath: join(params.logsDir, "dev-update-shell.log"), }); logLanePhase(lane, "update-status"); const verifiedShell = await ensureDevUpdateGitInstall({ lane, env, cliPath: updatedShell.cliPath, logsDir: params.logsDir, requestedRef: verificationRef, }); if (process.platform === "win32") { logLanePhase(lane, "windows-toolchain"); await verifyWindowsDevUpdateToolchain({ lane, env, logPath: join(params.logsDir, "dev-update-windows-toolchain.log"), }); } logLanePhase(lane, "onboard"); await runOnboardWithInstalledCli({ lane, cliPath: verifiedShell.cliPath, env, providerConfig: params.providerConfig, installDaemon: useManagedGatewayAfterDevUpdate, logPath: join(params.logsDir, "dev-update-onboard.log"), }); logLanePhase(lane, "models-set"); await runInstalledModelsSet({ cliPath: verifiedShell.cliPath, env, providerConfig: params.providerConfig, cwd: lane.homeDir, logPath: join(params.logsDir, "dev-update-models-set.log"), }); if (!useManagedGatewayAfterDevUpdate) { logLanePhase(lane, "gateway-start"); const gateway = await startManualGatewayFromInstalledCli({ lane, cliPath: verifiedShell.cliPath, env, logPath: join(params.logsDir, "dev-update-gateway.log"), }); manualGateway.current = gateway; cleanup.push(() => stopGateway(manualGateway.current)); logLanePhase(lane, "gateway-status"); await waitForInstalledGateway({ lane, cliPath: verifiedShell.cliPath, env, logPath: join(params.logsDir, "dev-update-gateway-status.log"), }); } else { logLanePhase(lane, "gateway-ready"); await ensureManagedGatewayReady({ lane, cliPath: verifiedShell.cliPath, env, logPath: join(params.logsDir, "dev-update-gateway-ready.log"), }); } logLanePhase(lane, "dashboard"); await runDashboardSmoke({ lane, logPath: join(params.logsDir, "dev-update-dashboard.log"), }); logLanePhase(lane, "agent-turn"); const agent = await runInstalledAgentTurn({ cliPath: verifiedShell.cliPath, env, cwd: lane.homeDir, label: "dev-update", logPath: join(params.logsDir, "dev-update-agent.log"), }); let discordStatus = "skipped"; if (params.runDiscordRoundtrip && process.platform === "darwin") { logLanePhase(lane, "discord-roundtrip"); discordStatus = await maybeRunDiscordRoundtrip({ lane, cliPath: verifiedShell.cliPath, env, gatewayHolder: manualGateway, logPath: join(params.logsDir, "dev-update-discord.log"), }); } return { status: "pass", installVersion: installTarget, cliPath: updatedShell.cliPath, gatewayPort: lane.gatewayPort, dashboardStatus: "pass", discordStatus, agentOutput: trimForSummary(agent.stdout), }; } finally { await runCleanup(cleanup); } } function createLaneState(name) { const rootDir = mkdtempSync(join(tmpdir(), `openclaw-${name}-`)); const prefixDir = join(rootDir, "prefix"); const homeDir = join(rootDir, "home"); const stateDir = join(homeDir, ".openclaw"); const appDataDir = process.platform === "win32" ? join(homeDir, "AppData", "Roaming") : stateDir; mkdirSync(prefixDir, { recursive: true }); mkdirSync(homeDir, { recursive: true }); mkdirSync(stateDir, { recursive: true }); mkdirSync(appDataDir, { recursive: true }); if (process.platform !== "win32") { writeFileSync(join(homeDir, ".bashrc"), "", "utf8"); writeFileSync(join(homeDir, ".zshrc"), "", "utf8"); } return { name, rootDir, prefixDir, homeDir, stateDir, appDataDir, gatewayPort: 0, phaseTimings: [], }; } function buildLaneEnv(lane, providerMeta, providerSecretValue) { ensureLocalNpmShim(lane); return { ...process.env, HOME: lane.homeDir, USERPROFILE: lane.homeDir, APPDATA: lane.appDataDir, LOCALAPPDATA: join(lane.homeDir, "AppData", "Local"), OPENCLAW_HOME: lane.homeDir, OPENCLAW_STATE_DIR: lane.stateDir, OPENCLAW_CONFIG_PATH: join(lane.stateDir, "openclaw.json"), OPENCLAW_DISABLE_BONJOUR: "1", OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL: "1", NPM_CONFIG_PREFIX: lane.prefixDir, PATH: `${binDirForPrefix(lane.prefixDir)}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`, [providerMeta.secretEnv]: providerSecretValue, }; } function buildInstallerEnv(lane, providerMeta, providerSecretValue) { const localAppData = join(lane.homeDir, "AppData", "Local"); mkdirSync(localAppData, { recursive: true }); return { ...process.env, HOME: lane.homeDir, USERPROFILE: lane.homeDir, APPDATA: lane.appDataDir, LOCALAPPDATA: localAppData, OPENCLAW_HOME: lane.homeDir, OPENCLAW_STATE_DIR: lane.stateDir, OPENCLAW_CONFIG_PATH: join(lane.stateDir, "openclaw.json"), OPENCLAW_DISABLE_BONJOUR: "1", OPENCLAW_NO_ONBOARD: "1", OPENCLAW_NO_PROMPT: "1", CI: "1", NODE_OPTIONS: "--max-old-space-size=8192", [providerMeta.secretEnv]: providerSecretValue, }; } export function shouldUseManagedGatewayService(platform = process.platform) { return platform === "win32"; } export function shouldUseManagedGatewayForInstallerRuntime(platform = process.platform) { return shouldUseManagedGatewayService(platform) && platform !== "win32"; } export function shouldExerciseManagedGatewayLifecycleAfterInstall(platform = process.platform) { return shouldUseManagedGatewayService(platform); } export function shouldStopManagedGatewayBeforeManualFallback(platform = process.platform) { return shouldUseManagedGatewayService(platform); } function shouldRunBundledPluginPostinstall() { return true; } function looksLikeCommitSha(ref) { return /^[0-9a-f]{7,40}$/iu.test(ref.trim()); } function resolveExpectedDevUpdateRef(ref) { const trimmed = normalizeRequestedRef(ref) || "main"; return trimmed || "main"; } export function resolveDevUpdateVerificationRef(ref, sourceSha) { if (resolveExpectedDevUpdateRef(ref) === "main" && looksLikeCommitSha(sourceSha ?? "")) { return sourceSha.trim(); } return resolveExpectedDevUpdateRef(ref); } export function shouldRunMainChannelDevUpdate(ref) { if (isImmutableReleaseRef(ref)) { return false; } return resolveExpectedDevUpdateRef(ref) === "main"; } export function shouldSkipInstallerDaemonHealthCheck(platform = process.platform) { return platform === "win32"; } export function buildRealUpdateEnv(env) { const updateEnv = { ...env, OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS: "1", NODE_DISABLE_COMPILE_CACHE: "1", }; delete updateEnv.OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL; delete updateEnv.NODE_COMPILE_CACHE; return updateEnv; } export function verifyPackagedUpgradeUpdateResult(result, _options) { if (result.exitCode === 0) { return; } throw new Error( `Packaged upgrade failed (${result.exitCode}): ${trimForSummary( `${result.stdout}\n${result.stderr}`, )}`, ); } export function buildPackagedUpgradeUpdateArgs(candidateUrl) { return [ "update", "--tag", candidateUrl, "--yes", "--json", "--no-restart", "--timeout", String(updateStepTimeoutSeconds()), ]; } export function isRecoverableWindowsPackagedUpgradeSwapCleanupFailure( result, platform = process.platform, ) { if (platform !== "win32" || result.exitCode === 0) { return false; } const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; return ( /\bglobal install swap\b/iu.test(output) && /\bEPERM\b/iu.test(output) && /\bunlink\b/iu.test(output) && /[/\\]\.openclaw-\d+-\d+[/\\]/u.test(output) && /\.node['"]?/iu.test(output) ); } export function isRecoverableWindowsPackagedUpgradeTimeoutError( error, platform = process.platform, ) { if (platform !== "win32") { return false; } const message = error instanceof Error ? error.message : String(error); return ( /\bCommand timed out:/u.test(message) && /[/\\]openclaw\.mjs update --tag http:\/\/127\.0\.0\.1:\d+\/openclaw[^/\s]*\.tgz --yes --json(?: --no-restart)? --timeout \d+/u.test( message, ) ); } export function shouldRunPackagedUpgradeStatusProbe({ platform = process.platform, usedWindowsPackagedUpgradeFallback, } = {}) { return !(platform === "win32" && usedWindowsPackagedUpgradeFallback); } export function verifyWindowsPackagedUpgradeFallbackInstall({ installedVersion, candidateVersion, }) { if (installedVersion !== candidateVersion) { throw new Error( `Windows packaged upgrade fallback installed ${installedVersion || "unknown"}, expected ${candidateVersion}`, ); } } export function resolveExplicitBaselineVersion(baselineSpec) { const trimmed = baselineSpec.trim(); if (!trimmed || trimmed === "openclaw@latest") { return ""; } if (trimmed.startsWith("openclaw@")) { return trimmed.slice("openclaw@".length); } return trimmed; } async function resolveInstallerTargetVersion(params) { const resolvedVersion = resolveExplicitBaselineVersion(params.baselineSpec); if (resolvedVersion) { return resolvedVersion; } const latestResult = await runCommand(npmCommand(), ["view", "openclaw@latest", "version"], { logPath: join(params.logsDir, `${params.suiteName}-latest-version.log`), timeoutMs: 2 * 60 * 1000, }); const latestVersion = latestResult.stdout.trim(); if (!latestVersion) { throw new Error("npm view openclaw@latest version did not return a version."); } return latestVersion; } function powerShellSingleQuote(value) { return value.replace(/'/gu, "''"); } function readPackageJson(packageRoot) { return JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")); } function packageJsonHasScript(packageJson, scriptName) { return typeof packageJson?.scripts?.[scriptName] === "string"; } export function packageHasScript(packageRoot, scriptName) { try { return packageJsonHasScript(readPackageJson(packageRoot), scriptName); } catch { return false; } } function parseMarkerLine(output, marker) { return `${output}` .split(/\r?\n/gu) .find((line) => line.startsWith(marker)) ?.slice(marker.length) .trim(); } export function normalizeWindowsInstalledCliPath(cliPath) { return normalizeWindowsCommandShimPath(cliPath); } export function normalizeWindowsCommandShimPath(commandPath) { if (typeof commandPath !== "string") { return commandPath; } return commandPath.replace(/\.ps1$/iu, ".cmd"); } export function resolveInstalledPrefixDirFromCliPath(cliPath, platform = process.platform) { const resolvedCliPath = platform === "win32" ? normalizeWindowsInstalledCliPath(cliPath) : String(cliPath ?? ""); if (!resolvedCliPath?.trim()) { throw new Error("Missing installed CLI path."); } if (platform === "win32") { return pathWin32.dirname(resolvedCliPath); } return dirname(dirname(resolvedCliPath)); } function readInstalledMetadataFromCliPath(cliPath, platform = process.platform) { return readInstalledMetadataFromPackageRoot( resolveInstalledPackageRootFromCliPath(cliPath, platform), ); } export function resolveCommandSpawnInvocation( command, args, options = { platform: process.platform, comSpec: process.env.ComSpec, }, ) { const platform = options.platform ?? process.platform; if (platform === "win32" && /\.(cmd|bat)$/iu.test(command)) { return { command: options.comSpec ?? process.env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", buildCmdExeCommandLine(command, args)], shell: false, windowsVerbatimArguments: true, }; } return { command, args, shell: false }; } export function resolveInstalledCliInvocation( cliPath, args = [], options = { platform: process.platform, comSpec: process.env.ComSpec, }, ) { const platform = options.platform ?? process.platform; if (platform !== "win32") { return { command: cliPath, args, shell: false }; } const normalizedCliPath = normalizeWindowsInstalledCliPath(cliPath); if (!/\.cmd$/iu.test(normalizedCliPath)) { return { command: normalizedCliPath, args, shell: false }; } const entryPath = installedEntryPath( resolveInstalledPrefixDirFromCliPath(normalizedCliPath, platform), ); if (existsSync(entryPath)) { return { command: process.execPath, args: [entryPath, ...args], shell: false, }; } return resolveCommandSpawnInvocation(normalizedCliPath, args, { comSpec: options.comSpec, platform, }); } async function runPosixShellScript(script, options) { return runCommand("/bin/bash", ["-lc", script], options); } async function runPowerShellScript(script, options) { return runCommand( "powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], options, ); } async function runInstallerSmoke(params) { if (process.platform === "win32") { const script = ` $response = Invoke-WebRequest -UseBasicParsing '${powerShellSingleQuote(params.installerUrl)}' $content = $response.Content if ($content -is [byte[]]) { $content = [System.Text.Encoding]::UTF8.GetString($content) } & ([scriptblock]::Create([string]$content)) -Tag '${powerShellSingleQuote(params.installTarget)}' -NoOnboard `; await runPowerShellScript(script, { cwd: params.lane.homeDir, env: params.env, logPath: params.logPath, timeoutMs: installTimeoutMs(), }); return; } const script = [ "set -euo pipefail", `curl -fsSL '${shellEscapeForSh(params.installerUrl)}' | bash -s -- --version '${shellEscapeForSh(params.installTarget)}' --no-onboard`, ].join("\n"); await runPosixShellScript(script, { cwd: params.lane.homeDir, env: params.env, logPath: params.logPath, timeoutMs: installTimeoutMs(), }); } export function buildWindowsPathBootstrapScript(options = {}) { const includeCurrentProcessPath = options.includeCurrentProcessPath !== false; const pathCandidates = includeCurrentProcessPath ? "@($userPath, $machinePath, $env:Path)" : "@($userPath, $machinePath)"; return ` $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') $segments = New-Object System.Collections.Generic.List[string] foreach ($candidate in ${pathCandidates}) { foreach ($segment in ($candidate -split ';')) { if ([string]::IsNullOrWhiteSpace($segment)) { continue } if (-not $segments.Contains($segment)) { $segments.Add($segment) } } } $env:Path = [string]::Join(';', $segments) `.trim(); } export function buildWindowsFreshShellVersionCheckScript(params = {}) { const expectedNeedle = powerShellSingleQuote(params.expectedNeedle ?? ""); return ` ${buildWindowsPathBootstrapScript()} $commandPath = $null $npmCommand = Get-Command npm.cmd -ErrorAction SilentlyContinue if ($null -eq $npmCommand) { $npmCommand = Get-Command npm -ErrorAction SilentlyContinue } if ($null -ne $npmCommand) { $npmPrefix = (& $npmCommand.Source config get prefix 2>$null | Out-String).Trim() if (-not [string]::IsNullOrWhiteSpace($npmPrefix)) { $env:Path = "$npmPrefix;$env:Path" foreach ($candidate in @( (Join-Path $npmPrefix 'openclaw.cmd'), (Join-Path $npmPrefix 'openclaw.ps1') )) { if (Test-Path -LiteralPath $candidate) { $commandPath = $candidate break } } } } if ([string]::IsNullOrWhiteSpace($commandPath)) { $cmd = Get-Command openclaw -ErrorAction Stop $commandPath = $cmd.Source } if ($commandPath -match '(?i)\\.ps1$') { $cmdPath = [System.IO.Path]::ChangeExtension($commandPath, '.cmd') if (Test-Path -LiteralPath $cmdPath) { $commandPath = $cmdPath } } $version = (& $commandPath --version 2>&1 | Out-String).Trim() Write-Output "__OPENCLAW_PATH__=$commandPath" Write-Output $version if ('${expectedNeedle}'.Length -gt 0 -and $version -notmatch [regex]::Escape('${expectedNeedle}')) { throw "version mismatch: expected substring ${expectedNeedle}" } `.trim(); } export function buildWindowsDevUpdateToolchainCheckScript() { return ` ${buildWindowsPathBootstrapScript()} function Resolve-CommandPath([string]$Name) { $command = Get-Command $Name -ErrorAction SilentlyContinue if ($null -eq $command) { return $null } $commandPath = $command.Source if ($commandPath -match '(?i)\\.ps1$') { $cmdPath = [System.IO.Path]::ChangeExtension($commandPath, '.cmd') if (Test-Path -LiteralPath $cmdPath) { $commandPath = $cmdPath } } return $commandPath } $pnpmPath = Resolve-CommandPath 'pnpm' if ($null -ne $pnpmPath) { Write-Output "__UPDATE_TOOL__=pnpm" Write-Output "__UPDATE_TOOL_PATH__=$pnpmPath" & $pnpmPath --version return } $corepackPath = Resolve-CommandPath 'corepack' if ($null -ne $corepackPath) { Write-Output "__UPDATE_TOOL__=corepack" Write-Output "__UPDATE_TOOL_PATH__=$corepackPath" & $corepackPath --version return } $npmPath = Resolve-CommandPath 'npm' if ($null -ne $npmPath) { Write-Output "__UPDATE_TOOL__=npm" Write-Output "__UPDATE_TOOL_PATH__=$npmPath" & $npmPath --version return } throw 'Neither pnpm, corepack, nor npm is discoverable from the reconstructed Windows PATH.' `.trim(); } async function verifyFreshShellCommand(params) { if (process.platform === "win32") { const script = buildWindowsFreshShellVersionCheckScript({ expectedNeedle: params.expectedNeedle, }); const result = await runPowerShellScript(script, { cwd: params.lane.homeDir, env: params.env, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); const cliPath = normalizeWindowsInstalledCliPath( parseMarkerLine(result.stdout, "__OPENCLAW_PATH__="), ); if (!cliPath) { throw new Error("Failed to resolve installed openclaw path from fresh Windows shell."); } return { cliPath, versionOutput: `${result.stdout}\n${result.stderr}`.trim(), }; } const script = [ "set -euo pipefail", 'if [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc"; fi', "command -v openclaw >/dev/null 2>&1", 'printf "__OPENCLAW_PATH__=%s\\n" "$(command -v openclaw)"', "openclaw --version", ].join("\n"); const result = await runPosixShellScript(script, { cwd: params.lane.homeDir, env: params.env, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); const cliPath = parseMarkerLine(result.stdout, "__OPENCLAW_PATH__="); const versionOutput = `${result.stdout}\n${result.stderr}`.trim(); if (!cliPath) { throw new Error("Failed to resolve installed openclaw path from fresh POSIX shell."); } if (params.expectedNeedle && !versionOutput.includes(params.expectedNeedle)) { throw new Error( `Installed CLI version did not contain expected substring ${params.expectedNeedle}.`, ); } return { cliPath, versionOutput }; } async function runInstalledCli(params) { const invocation = resolveInstalledCliInvocation(params.cliPath, params.args, { comSpec: params.env?.ComSpec ?? params.env?.COMSPEC, platform: process.platform, }); return runCommandInvocation(invocation, { cwd: params.cwd, env: params.env, logPath: params.logPath, timeoutMs: params.timeoutMs, check: params.check ?? true, }); } async function readInstalledUpdateStatus(params) { return runInstalledCli({ cliPath: params.cliPath, args: ["update", "status", "--json"], cwd: params.cwd, env: params.env, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); } async function ensureDevUpdateGitInstall(params) { const updateStatus = await readInstalledUpdateStatus({ cliPath: params.cliPath, cwd: params.lane.homeDir, env: params.env, logPath: join(params.logsDir, "dev-update-status.log"), }); // The dev-update lane must prove that `openclaw update --channel dev` landed on // the expected git checkout. Falling back to a manual repair here would hide // updater regressions and turn the suite into a false green. verifyDevUpdateStatus(updateStatus.stdout, { ref: params.requestedRef }); return { cliPath: params.cliPath }; } async function runOnboardWithInstalledCli(params) { await withAllocatedGatewayPort(params.lane, async () => { const args = buildReleaseOnboardArgs({ authChoice: params.providerConfig.authChoice, gatewayPort: params.lane.gatewayPort, installDaemon: params.installDaemon, skipHealth: !params.installDaemon || shouldSkipInstallerDaemonHealthCheck(), }); await runInstalledCli({ cliPath: params.cliPath, args, cwd: params.lane.homeDir, env: params.env, logPath: params.logPath, timeoutMs: 10 * 60 * 1000, }); }); } export function buildReleaseOnboardArgs(params) { const args = [ "onboard", "--non-interactive", "--mode", "local", "--auth-choice", params.authChoice, "--secret-input-mode", "ref", "--gateway-port", String(params.gatewayPort), "--gateway-bind", "loopback", "--skip-skills", "--skip-bootstrap", "--accept-risk", "--json", ]; if (params.installDaemon) { args.push("--install-daemon"); } if (params.skipHealth) { args.push("--skip-health"); } return args; } async function startManualGatewayFromInstalledCli(params) { mkdirSync(dirname(params.logPath), { recursive: true }); const gatewayLog = createWriteStream(params.logPath, { flags: "a" }); const invocation = resolveInstalledCliInvocation( params.cliPath, ["gateway", "run", "--bind", "loopback", "--port", String(params.lane.gatewayPort), "--force"], { comSpec: params.env?.ComSpec ?? params.env?.COMSPEC, platform: process.platform, }, ); const child = spawn(invocation.command, invocation.args, { cwd: params.lane.homeDir, env: params.env, shell: invocation.shell, stdio: ["ignore", "pipe", "pipe"], windowsVerbatimArguments: invocation.windowsVerbatimArguments, windowsHide: true, }); child.stdout?.on("data", (chunk) => { gatewayLog.write(chunk); }); child.stderr?.on("data", (chunk) => { gatewayLog.write(chunk); }); let logClosed = false; const closeLog = async () => { if (logClosed) { return; } logClosed = true; await new Promise((resolvePromise) => { gatewayLog.once("error", () => resolvePromise()); gatewayLog.end(() => resolvePromise()); }); }; child.once("close", () => { void closeLog(); }); child.once("error", () => { void closeLog(); }); return { child, closeLog, logPath: params.logPath }; } async function resolveInstalledGatewayStatusArgs(params) { const requireRpc = params.requireRpc !== false; try { const help = await runInstalledCli({ cliPath: params.cliPath, args: ["gateway", "status", "--help"], cwd: params.cwd, env: params.env, logPath: params.logPath, timeoutMs: 15_000, check: false, }); return buildGatewayStatusArgsFromHelpText(`${help.stdout}\n${help.stderr}`, { requireRpc }); } catch (error) { appendGatewayStatusHelpProbeFallback(params.logPath, error); return buildGatewayStatusArgsFromHelpText("--require-rpc", { requireRpc }); } } export function buildGatewayStatusArgsFromHelpText(helpText, options = {}) { const requireRpc = options.requireRpc !== false; if (requireRpc && helpText.includes("--require-rpc")) { return [ "gateway", "status", "--require-rpc", "--timeout", String(CROSS_OS_GATEWAY_STATUS_RPC_TIMEOUT_MS), ]; } return ["gateway", "status"]; } function appendGatewayStatusHelpProbeFallback(logPath, error) { appendFileSync( logPath, `${new Date().toISOString()} gateway status help probe failed; assuming current --require-rpc support: ${formatError(error)}\n`, ); } export async function canConnectToLoopbackPort(port, timeoutMs = 1_000) { if (!Number.isInteger(port) || port <= 0) { return false; } return await new Promise((resolvePromise) => { let settled = false; const socket = createNetConnection({ host: "127.0.0.1", port, }); const settle = (value) => { if (settled) { return; } settled = true; socket.destroy(); resolvePromise(value); }; socket.setTimeout(timeoutMs); socket.once("connect", () => settle(true)); socket.once("timeout", () => settle(false)); socket.once("error", () => settle(false)); }); } async function waitForInstalledGateway(params) { const statusArgs = await resolveInstalledGatewayStatusArgs({ cliPath: params.cliPath, cwd: params.lane.homeDir, env: params.env, logPath: params.logPath, }); const deadline = Date.now() + gatewayReadyDeadlineMs(); while (Date.now() < deadline) { const result = await runInstalledCli({ cliPath: params.cliPath, args: statusArgs, cwd: params.lane.homeDir, env: params.env, logPath: params.logPath, timeoutMs: CROSS_OS_GATEWAY_STATUS_COMMAND_TIMEOUT_MS, check: false, }); if (result.exitCode === 0) { return; } await sleep(2_000); } throw new Error(`Gateway did not become ready on port ${params.lane.gatewayPort}.`); } async function waitForInstalledGatewayToStop(params) { const statusArgs = await resolveInstalledGatewayStatusArgs({ cliPath: params.cliPath, cwd: params.lane.homeDir, env: params.env, logPath: params.logPath, requireRpc: false, }); const deadline = Date.now() + gatewayReadyDeadlineMs(); while (Date.now() < deadline) { await runInstalledCli({ cliPath: params.cliPath, args: statusArgs, cwd: params.lane.homeDir, env: params.env, logPath: params.logPath, timeoutMs: CROSS_OS_GATEWAY_STATUS_COMMAND_TIMEOUT_MS, check: false, }); const portReachable = await canConnectToLoopbackPort(params.lane.gatewayPort); if (!portReachable) { return; } await sleep(2_000); } throw new Error( `Managed gateway did not stop on port ${params.lane.gatewayPort} before manual fallback.`, ); } async function ensureManagedGatewayReady(params) { try { await waitForInstalledGateway(params); return; } catch { await runInstalledCli({ cliPath: params.cliPath, args: ["gateway", "start"], cwd: params.lane.homeDir, env: params.env, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, check: false, }); } await waitForInstalledGateway(params); } async function runInstalledModelsSet(params) { await runInstalledCli({ cliPath: params.cliPath, args: ["models", "set", params.providerConfig.model], cwd: params.cwd, env: params.env, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); const providerConfigOverride = buildReleaseProviderConfigOverride(params.providerConfig); if (providerConfigOverride) { await runInstalledCli({ cliPath: params.cliPath, args: [ "config", "set", `models.providers.${params.providerConfig.extensionId}`, JSON.stringify(providerConfigOverride), "--strict-json", "--merge", ], cwd: params.cwd, env: params.env, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); } await runInstalledCli({ cliPath: params.cliPath, args: [ "config", "set", "plugins.allow", JSON.stringify(buildCrossOsReleaseSmokePluginAllowlist(params.providerConfig)), "--strict-json", ], cwd: params.cwd, env: params.env, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); await runInstalledCli({ cliPath: params.cliPath, args: ["config", "set", "agents.defaults.skipBootstrap", "true", "--strict-json"], cwd: params.cwd, env: params.env, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); await runInstalledCli({ cliPath: params.cliPath, args: ["config", "set", "tools.profile", CROSS_OS_RELEASE_SMOKE_TOOLS_PROFILE], cwd: params.cwd, env: params.env, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); } async function runInstalledAgentTurn(params) { let lastError; for (let attempt = 1; attempt <= 2; attempt += 1) { const sessionId = `cross-os-release-check-${params.label}-${Date.now()}-${attempt}`; try { const logOffset = readLogFileSize(params.logPath); const result = await runInstalledCli({ cliPath: params.cliPath, args: buildReleaseAgentTurnArgs(sessionId), cwd: params.cwd, env: params.env, logPath: params.logPath, timeoutMs: (CROSS_OS_AGENT_TURN_TIMEOUT_SECONDS + 60) * 1000, }); const logText = readLogTextSince(params.logPath, logOffset); if (!agentOutputHasExpectedOkMarker(result.stdout, { logText })) { throw new Error("Agent output did not contain the expected OK marker."); } if (agentTurnUsedEmbeddedFallback(result, { logText })) { throw new Error("Agent turn used embedded fallback instead of gateway."); } return result; } catch (error) { lastError = error; const skipped = maybeBuildOptionalAgentTurnSkipResult(error, params.logPath, { attempt, maxAttempts: 2, }); if (skipped) { return skipped; } if (attempt >= 2 || !shouldRetryCrossOsAgentTurnError(error)) { throw error; } appendFileSync( params.logPath, `\n[release-checks] retrying installed agent turn after retryable live failure: ${ error instanceof Error ? error.message : String(error) }\n`, ); } } throw lastError; } export function verifyDevUpdateStatus(stdout, options = {}) { let payload; try { payload = JSON.parse(stdout); } catch { payload = null; } const expectedRef = resolveExpectedDevUpdateRef(options.ref); const update = payload?.update ?? payload; const installKind = update?.installKind ?? null; const branch = update?.git?.branch ?? null; const sha = update?.git?.sha ?? null; const channelValue = payload?.channel?.value ?? payload?.channel?.channel ?? null; if (installKind !== "git") { throw new Error( `Dev update did not land on a git install. Found ${installKind ?? ""}.`, ); } if (channelValue !== "dev") { throw new Error( `Dev update status did not report channel=dev. Found ${channelValue ?? ""}.`, ); } if (looksLikeCommitSha(expectedRef)) { const normalizedSha = typeof sha === "string" ? sha.toLowerCase() : ""; const normalizedExpectedRef = expectedRef.toLowerCase(); if (!normalizedSha || !normalizedSha.startsWith(normalizedExpectedRef)) { throw new Error( `Dev update status did not report sha=${expectedRef}. Found ${sha ?? ""}.`, ); } return; } if (branch !== expectedRef) { throw new Error( `Dev update status did not report branch=${expectedRef}. Found ${branch ?? ""}.`, ); } } async function verifyWindowsDevUpdateToolchain(params) { const script = buildWindowsDevUpdateToolchainCheckScript(); const result = await runPowerShellScript(script, { cwd: params.lane.homeDir, env: params.env, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); if (!parseMarkerLine(result.stdout, "__UPDATE_TOOL__=")) { throw new Error( "No Windows update bootstrap tool (pnpm, corepack, or npm) was discoverable after the dev update.", ); } } export function buildDiscordSmokeGuildsConfig(guildId, channelId) { return { [guildId]: { channels: { [channelId]: { enabled: true, requireMention: false, }, }, }, }; } async function configureDiscordSmoke(params) { const guildsJson = JSON.stringify( buildDiscordSmokeGuildsConfig(params.guildId, params.channelId), ); await runInstalledCli({ cliPath: params.cliPath, args: [ "config", "set", "channels.discord.token", "--ref-provider", "default", "--ref-source", "env", "--ref-id", "DISCORD_BOT_TOKEN", ], cwd: params.cwd, env: { ...params.env, DISCORD_BOT_TOKEN: params.token }, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); await runInstalledCli({ cliPath: params.cliPath, args: ["config", "set", "channels.discord.enabled", "true"], cwd: params.cwd, env: params.env, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); await runInstalledCli({ cliPath: params.cliPath, args: ["config", "set", "channels.discord.groupPolicy", "allowlist"], cwd: params.cwd, env: params.env, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); await runInstalledCli({ cliPath: params.cliPath, args: ["config", "set", "channels.discord.guilds", guildsJson, "--strict-json"], cwd: params.cwd, env: params.env, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); if (!shouldUseManagedGatewayService()) { const gatewayEnv = { ...params.env, DISCORD_BOT_TOKEN: params.token }; if (params.gatewayHolder?.current) { await stopGateway(params.gatewayHolder.current); params.gatewayHolder.current = null; } const gateway = await startManualGatewayFromInstalledCli({ lane: params.lane, cliPath: params.cliPath, env: gatewayEnv, logPath: join(params.cwd, `.openclaw/logs/${params.lane.name}-discord-gateway.log`), }); if (params.gatewayHolder) { params.gatewayHolder.current = gateway; } await waitForInstalledGateway({ lane: params.lane, cliPath: params.cliPath, env: gatewayEnv, logPath: params.logPath, }); return; } await runInstalledCli({ cliPath: params.cliPath, args: ["gateway", "restart"], cwd: params.cwd, env: { ...params.env, DISCORD_BOT_TOKEN: params.token }, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, check: false, }); await ensureManagedGatewayReady({ lane: params.lane, cliPath: params.cliPath, env: { ...params.env, DISCORD_BOT_TOKEN: params.token }, logPath: params.logPath, }); } export async function readBoundedCrossOsResponseText( response: Response, maxChars = CROSS_OS_FETCH_BODY_MAX_CHARS, ): Promise { if (!response.body) { return ""; } const reader = response.body.getReader(); const decoder = new TextDecoder(); let text = ""; let truncated = false; try { while (text.length <= maxChars) { const { done, value } = await reader.read(); if (done) { text += decoder.decode(); break; } text += decoder.decode(value, { stream: true }); if (text.length > maxChars) { text = text.slice(0, maxChars); truncated = true; break; } } } finally { if (truncated) { await reader.cancel().catch(() => undefined); } else { reader.releaseLock(); } } return truncated ? `${text}\n[truncated]` : text; } async function waitForDiscordMessage(params) { const deadline = Date.now() + 3 * 60 * 1000; while (Date.now() < deadline) { let response; let text; try { response = await fetch( `https://discord.com/api/v10/channels/${params.channelId}/messages?limit=20`, buildDiscordFetchInit(params.token), ); text = await readBoundedCrossOsResponseText(response); } catch { await sleep(2_000); continue; } if (!response.ok) { await sleep(2_000); continue; } if (text.includes(params.needle)) { return; } await sleep(2_000); } throw new Error(`Discord host-side visibility check timed out for ${params.needle}.`); } export function buildDiscordFetchInit(token, init = {}) { return { ...init, signal: init.signal ?? AbortSignal.timeout(CROSS_OS_DISCORD_FETCH_TIMEOUT_MS), headers: { ...init.headers, Authorization: `Bot ${token}`, }, }; } async function postDiscordMessage(params) { const response = await fetch( `https://discord.com/api/v10/channels/${params.channelId}/messages`, buildDiscordFetchInit(params.token, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ content: params.content, flags: 4096, }), }), ); const text = await readBoundedCrossOsResponseText(response); if (!response.ok) { throw new Error(`Failed to post Discord smoke message: ${text}`); } try { return JSON.parse(text)?.id ?? null; } catch { return null; } } async function deleteDiscordMessage(params) { if (!params.messageId) { return; } await fetch( `https://discord.com/api/v10/channels/${params.channelId}/messages/${params.messageId}`, buildDiscordFetchInit(params.token, { method: "DELETE", }), ).catch(() => undefined); } async function waitForInstalledDiscordReadback(params) { const deadline = Date.now() + 3 * 60 * 1000; while (Date.now() < deadline) { const response = await runInstalledCli({ cliPath: params.cliPath, args: [ "message", "read", "--channel", "discord", "--target", `channel:${params.channelId}`, "--limit", "20", "--json", ], cwd: params.cwd, env: params.env, logPath: params.logPath, timeoutMs: 60_000, check: false, }); if (response.exitCode === 0 && response.stdout.includes(params.needle)) { return; } await sleep(3_000); } throw new Error(`Discord guest readback timed out for ${params.needle}.`); } async function maybeRunDiscordRoundtrip(params) { const token = process.env.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN?.trim() || process.env.DISCORD_BOT_TOKEN?.trim() || ""; const guildId = process.env.OPENCLAW_DISCORD_SMOKE_GUILD_ID?.trim() || ""; const channelId = process.env.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID?.trim() || ""; if (!token || !guildId || !channelId) { return "skipped-missing-config"; } const outboundNonce = `native-cross-os-outbound-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; const inboundNonce = `native-cross-os-inbound-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; let sentMessageId = null; let hostMessageId = null; try { await configureDiscordSmoke({ lane: params.lane, cliPath: params.cliPath, cwd: params.lane.homeDir, env: params.env, gatewayHolder: params.gatewayHolder, logPath: params.logPath, token, guildId, channelId, }); const sendResult = await runInstalledCli({ cliPath: params.cliPath, args: [ "message", "send", "--channel", "discord", "--target", `channel:${channelId}`, "--message", outboundNonce, "--silent", "--json", ], cwd: params.lane.homeDir, env: { ...params.env, DISCORD_BOT_TOKEN: token }, logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); let parsedSendResult = null; try { parsedSendResult = JSON.parse(sendResult.stdout); } catch { parsedSendResult = null; } sentMessageId = parsedSendResult?.payload?.messageId ?? parsedSendResult?.payload?.result?.messageId ?? null; await waitForDiscordMessage({ token, channelId, needle: outboundNonce, }); hostMessageId = await postDiscordMessage({ token, channelId, content: inboundNonce, }); await waitForInstalledDiscordReadback({ cliPath: params.cliPath, cwd: params.lane.homeDir, env: { ...params.env, DISCORD_BOT_TOKEN: token }, logPath: params.logPath, channelId, needle: inboundNonce, }); return "pass"; } finally { await deleteDiscordMessage({ token, channelId, messageId: sentMessageId }); await deleteDiscordMessage({ token, channelId, messageId: hostMessageId }); } } async function installTarballPackage(params) { await installPackageSpec({ lane: params.lane, env: params.env, packageSpec: params.tgzPath, logPath: params.logPath, timeoutMs: params.timeoutMs, ignoreScripts: params.ignoreScripts, }); if ( params.restoreBundledPluginPostinstall !== false && shouldRunBundledPluginPostinstall({ lane: params.lane }) ) { await runBundledPluginPostinstall({ lane: params.lane, env: params.env, logPath: params.logPath, }); } } async function installPackageSpec(params) { const installEnv = { ...params.env, npm_config_global: "true", npm_config_location: "global", npm_config_prefix: params.lane.prefixDir, }; rmSync(installedPackageRoot(params.lane.prefixDir), { force: true, recursive: true }); await runCommand( npmCommand(), buildNpmGlobalInstallArgs(params.packageSpec, { ignoreScripts: params.ignoreScripts }), { cwd: params.lane.homeDir, env: installEnv, logPath: params.logPath, timeoutMs: params.timeoutMs ?? installTimeoutMs(), }, ); } export function buildNpmGlobalInstallArgs(packageSpec, options = {}) { return [ "install", "-g", packageSpec, "--omit=dev", "--no-fund", "--no-audit", ...(options.ignoreScripts ? ["--ignore-scripts"] : []), "--loglevel=notice", ]; } function installTimeoutMs() { return process.platform === "win32" ? 45 * 60 * 1000 : 20 * 60 * 1000; } function updateTimeoutMs() { return process.platform === "win32" ? CROSS_OS_WINDOWS_PACKAGED_UPGRADE_WRAPPER_TIMEOUT_MS : 20 * 60 * 1000; } function updateStepTimeoutSeconds() { return process.platform === "win32" ? CROSS_OS_WINDOWS_PACKAGED_UPGRADE_STEP_TIMEOUT_SECONDS : 1200; } async function runBundledPluginPostinstall(params) { const packageRoot = installedPackageRoot(params.lane.prefixDir); const scriptPath = join(packageRoot, "scripts", "postinstall-bundled-plugins.mjs"); if (!existsSync(scriptPath)) { return; } const installEnv = { ...params.env, }; delete installEnv.OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL; delete installEnv.NPM_CONFIG_PREFIX; delete installEnv.npm_config_global; delete installEnv.npm_config_location; delete installEnv.npm_config_prefix; await runCommand(process.execPath, [scriptPath], { cwd: packageRoot, env: installEnv, logPath: params.logPath, timeoutMs: 20 * 60 * 1000, }); } export function shouldRunWindowsInstalledBrowserOverrideImportSmoke(platform = process.platform) { return platform === "win32"; } export function buildInstalledBrowserOverrideImportProbeScript( runtimeModuleSpecifier = "openclaw/plugin-sdk/plugin-runtime", ) { return ` import { existsSync } from "node:fs"; import { startLazyPluginServiceModule } from ${JSON.stringify(runtimeModuleSpecifier)}; const startedPath = process.env.OPENCLAW_BROWSER_OVERRIDE_STARTED_PATH; const stoppedPath = process.env.OPENCLAW_BROWSER_OVERRIDE_STOPPED_PATH; if (!process.env.OPENCLAW_BROWSER_CONTROL_MODULE) { throw new Error("Missing OPENCLAW_BROWSER_CONTROL_MODULE."); } if (!startedPath || !stoppedPath) { throw new Error("Missing browser override sentinel path env."); } const handle = await startLazyPluginServiceModule({ overrideEnvVar: "OPENCLAW_BROWSER_CONTROL_MODULE", validateOverrideSpecifier: (specifier) => specifier, loadDefaultModule: async () => { throw new Error("Default browser control service should not load during override probe."); }, startExportNames: ["startBrowserControlService"], stopExportNames: ["stopBrowserControlService"], }); if (!handle) { throw new Error("Browser control override probe did not return a service handle."); } if (!existsSync(startedPath)) { throw new Error("Browser control override start sentinel was not written."); } await handle.stop(); if (!existsSync(stoppedPath)) { throw new Error("Browser control override stop sentinel was not written."); } console.log("windows browser override import OK"); `.trim(); } function buildBrowserOverrideProbeServiceModule() { return ` import { writeFileSync } from "node:fs"; export async function startBrowserControlService() { writeFileSync(process.env.OPENCLAW_BROWSER_OVERRIDE_STARTED_PATH, "started\\n", "utf8"); } export async function stopBrowserControlService() { writeFileSync(process.env.OPENCLAW_BROWSER_OVERRIDE_STOPPED_PATH, "stopped\\n", "utf8"); } `.trim(); } async function runInstalledBrowserOverrideImportSmoke(params) { if (!shouldRunWindowsInstalledBrowserOverrideImportSmoke()) { return "skipped"; } const probeDir = join(params.lane.rootDir, "browser override import probe"); mkdirSync(probeDir, { recursive: true }); const overridePath = join(probeDir, "browser override #module.mjs"); const probePath = join(probeDir, "run browser override probe.mjs"); const startedPath = join(probeDir, "started.txt"); const stoppedPath = join(probeDir, "stopped.txt"); const packageRoot = installedPackageRoot(params.prefixDir); const runtimeModulePath = join(packageRoot, "dist", "plugin-sdk", "plugin-runtime.js"); if (!existsSync(runtimeModulePath)) { throw new Error(`Installed browser runtime module not found: ${runtimeModulePath}`); } writeFileSync(overridePath, `${buildBrowserOverrideProbeServiceModule()}\n`, "utf8"); writeFileSync( probePath, `${buildInstalledBrowserOverrideImportProbeScript(pathToFileURL(runtimeModulePath).href)}\n`, "utf8", ); await runCommand(process.execPath, [probePath], { cwd: packageRoot, env: { ...params.env, OPENCLAW_BROWSER_CONTROL_MODULE: pathToFileURL(overridePath).href, OPENCLAW_BROWSER_OVERRIDE_STARTED_PATH: startedPath, OPENCLAW_BROWSER_OVERRIDE_STOPPED_PATH: stoppedPath, }, logPath: params.logPath, timeoutMs: 60_000, }); if (!existsSync(startedPath) || !existsSync(stoppedPath)) { throw new Error("Browser control override import probe did not write both sentinels."); } return "pass"; } function ensureLocalNpmShim(lane) { const shimPath = npmShimPath(lane.prefixDir); if (existsSync(shimPath)) { return; } mkdirSync(dirname(shimPath), { recursive: true }); const resolvedNpm = resolveCommandPath(npmCommand()); if (!resolvedNpm) { throw new Error(`Failed to resolve ${npmCommand()} on PATH.`); } if (process.platform === "win32") { writeFileSync( shimPath, `@echo off\r\nset "NPM_CONFIG_PREFIX=${lane.prefixDir}"\r\n"${resolvedNpm}" %*\r\n`, "utf8", ); return; } writeFileSync( shimPath, `#!/bin/sh\nexport NPM_CONFIG_PREFIX='${shellEscapeForSh(lane.prefixDir)}'\nexec '${shellEscapeForSh(resolvedNpm)}' "$@"\n`, "utf8", ); chmodSync(shimPath, 0o755); } async function runOnboard(params) { await withAllocatedGatewayPort(params.lane, async () => { await runOpenClaw({ lane: params.lane, env: params.env, args: buildReleaseOnboardArgs({ authChoice: params.providerConfig.authChoice, gatewayPort: params.lane.gatewayPort, skipHealth: true, }), logPath: params.logPath, timeoutMs: 10 * 60 * 1000, }); }); } async function exerciseManagedGatewayLifecycle(params) { logLanePhase(params.lane, "gateway-ready"); await ensureManagedGatewayReady({ lane: params.lane, cliPath: params.cliPath, env: params.env, logPath: `${params.logPrefix}-ready.log`, }); logLanePhase(params.lane, "gateway-restart"); await runInstalledCli({ cliPath: params.cliPath, args: ["gateway", "restart"], env: params.env, cwd: params.lane.homeDir, logPath: `${params.logPrefix}-restart.log`, timeoutMs: 2 * 60 * 1000, }); await ensureManagedGatewayReady({ lane: params.lane, cliPath: params.cliPath, env: params.env, logPath: `${params.logPrefix}-ready-after-restart.log`, }); logLanePhase(params.lane, "gateway-stop"); await runInstalledCli({ cliPath: params.cliPath, args: ["gateway", "stop"], env: params.env, cwd: params.lane.homeDir, logPath: `${params.logPrefix}-stop.log`, timeoutMs: 2 * 60 * 1000, }); logLanePhase(params.lane, "gateway-start"); await runInstalledCli({ cliPath: params.cliPath, args: ["gateway", "start"], env: params.env, cwd: params.lane.homeDir, logPath: `${params.logPrefix}-start.log`, timeoutMs: 2 * 60 * 1000, }); await ensureManagedGatewayReady({ lane: params.lane, cliPath: params.cliPath, env: params.env, logPath: `${params.logPrefix}-ready-after-start.log`, }); } async function startGateway(params) { const gatewayLog = createWriteStream(params.logPath, { flags: "a" }); const useProcessGroup = process.platform !== "win32"; const child = spawn( process.execPath, [ installedEntryPath(params.lane.prefixDir), "gateway", "run", "--bind", "loopback", "--port", String(params.lane.gatewayPort), "--force", ], { cwd: params.lane.homeDir, env: params.env, stdio: ["ignore", "pipe", "pipe"], detached: useProcessGroup, windowsHide: true, }, ); const activeChildTree = registerActiveChildProcessTree(child); child.stdout?.on("data", (chunk) => { gatewayLog.write(chunk); }); child.stderr?.on("data", (chunk) => { gatewayLog.write(chunk); }); let logClosed = false; const closeLog = async () => { if (logClosed) { return; } logClosed = true; await new Promise((resolvePromise) => { gatewayLog.once("error", () => resolvePromise()); gatewayLog.end(() => resolvePromise()); }); }; child.once("close", () => { activeChildTree.unregister(); void closeLog(); }); child.once("error", () => { activeChildTree.unregister(); void closeLog(); }); return { child, closeLog, logPath: params.logPath }; } async function waitForGateway(params) { const statusArgs = await resolveGatewayStatusArgs(params.lane, params.env, params.logPath); const deadline = Date.now() + gatewayReadyDeadlineMs(); while (Date.now() < deadline) { let result; try { result = await runOpenClaw({ lane: params.lane, env: params.env, args: statusArgs, logPath: params.logPath, timeoutMs: CROSS_OS_GATEWAY_STATUS_COMMAND_TIMEOUT_MS, check: false, }); } catch { await sleep(2_000); continue; } if (result.exitCode === 0) { return; } await sleep(2_000); } throw new Error(`Gateway did not become ready on port ${params.lane.gatewayPort}.`); } function gatewayReadyDeadlineMs() { return process.platform === "win32" ? CROSS_OS_WINDOWS_GATEWAY_READY_TIMEOUT_MS : CROSS_OS_GATEWAY_READY_TIMEOUT_MS; } async function resolveGatewayStatusArgs(lane, env, logPath) { try { const help = await runOpenClaw({ lane, env, args: ["gateway", "status", "--help"], logPath, timeoutMs: 15_000, check: false, }); return buildGatewayStatusArgsFromHelpText(`${help.stdout}\n${help.stderr}`); } catch (error) { appendGatewayStatusHelpProbeFallback(logPath, error); return buildGatewayStatusArgsFromHelpText("--require-rpc"); } } async function runModelsSet(params) { await runOpenClaw({ lane: params.lane, env: params.env, args: ["models", "set", params.providerConfig.model], logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); const providerConfigOverride = buildReleaseProviderConfigOverride(params.providerConfig); if (providerConfigOverride) { await runOpenClaw({ lane: params.lane, env: params.env, args: [ "config", "set", `models.providers.${params.providerConfig.extensionId}`, JSON.stringify(providerConfigOverride), "--strict-json", "--merge", ], logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); } await runOpenClaw({ lane: params.lane, env: params.env, args: [ "config", "set", "plugins.allow", JSON.stringify(buildCrossOsReleaseSmokePluginAllowlist(params.providerConfig)), "--strict-json", ], logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); await runOpenClaw({ lane: params.lane, env: params.env, args: ["config", "set", "agents.defaults.skipBootstrap", "true", "--strict-json"], logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); await runOpenClaw({ lane: params.lane, env: params.env, args: ["config", "set", "tools.profile", CROSS_OS_RELEASE_SMOKE_TOOLS_PROFILE], logPath: params.logPath, timeoutMs: 2 * 60 * 1000, }); } async function runAgentTurn(params) { let lastError; for (let attempt = 1; attempt <= 2; attempt += 1) { const sessionId = `cross-os-release-check-${params.label}-${Date.now()}-${attempt}`; try { const logOffset = readLogFileSize(params.logPath); const result = await runOpenClaw({ lane: params.lane, env: params.env, args: buildReleaseAgentTurnArgs(sessionId), logPath: params.logPath, timeoutMs: (CROSS_OS_AGENT_TURN_TIMEOUT_SECONDS + 60) * 1000, }); const logText = readLogTextSince(params.logPath, logOffset); if (!agentOutputHasExpectedOkMarker(result.stdout, { logText })) { throw new Error("Agent output did not contain the expected OK marker."); } if (agentTurnUsedEmbeddedFallback(result, { logText })) { throw new Error("Agent turn used embedded fallback instead of gateway."); } return result; } catch (error) { lastError = error; const skipped = maybeBuildOptionalAgentTurnSkipResult(error, params.logPath, { attempt, maxAttempts: 2, }); if (skipped) { return skipped; } if (attempt >= 2 || !shouldRetryCrossOsAgentTurnError(error)) { throw error; } appendFileSync( params.logPath, `\n[release-checks] retrying agent turn after retryable live failure: ${ error instanceof Error ? error.message : String(error) }\n`, ); } } throw lastError; } export function maybeBuildOptionalAgentTurnSkipResult(error, logPath, options = {}) { const attempt = options.attempt ?? 1; const maxAttempts = options.maxAttempts ?? 2; const optional = options.optional ?? CROSS_OS_AGENT_TURN_OPTIONAL; if ( attempt < maxAttempts || !optional || !shouldSkipOptionalCrossOsAgentTurnError(error, logPath) ) { return null; } const message = error instanceof Error ? error.message : String(error); appendFileSync( logPath, `\n[release-checks] skipping optional cross-OS live agent turn after retryable failure: ${message}\n`, ); return { status: 0, stdout: JSON.stringify({ status: "skipped", reason: "cross-os live agent turn unavailable after retry", }), stderr: "", }; } export function shouldSkipOptionalCrossOsAgentTurnError(error, logPath) { const message = error instanceof Error ? error.message : String(error); if ( /model idle timeout|did not produce a response before the model idle timeout|gateway request timeout for agent|Command timed out|timed out and could not be terminated cleanly/u.test( message, ) ) { return true; } if (!/Agent output did not contain the expected OK marker/u.test(message)) { return false; } const log = readLogTextTail(logPath); return /"status"\s*:\s*"timeout"|Request timed out before a response was generated/u.test(log); } function buildReleaseAgentTurnArgs(sessionId) { return [ "agent", "--agent", "main", "--session-id", sessionId, "--message", "Reply with exact ASCII text OK only.", "--thinking", "off", "--timeout", String(CROSS_OS_AGENT_TURN_TIMEOUT_SECONDS), "--json", ]; } export function shouldRetryCrossOsAgentTurnError(error) { const message = error instanceof Error ? error.message : String(error); return /Agent output did not contain the expected OK marker|Agent turn used embedded fallback instead of gateway|model idle timeout|did not produce a response before the model idle timeout|gateway request timeout for agent|Command timed out|timed out and could not be terminated cleanly/u.test( message, ); } export function agentTurnUsedEmbeddedFallback(result, options = {}) { const logText = typeof options.logText === "string" ? options.logText : typeof options.logPath === "string" ? readLogTextTail(options.logPath) : ""; return /EMBEDDED FALLBACK:/u.test(`${result.stdout ?? ""}\n${result.stderr ?? ""}\n${logText}`); } export function agentOutputHasExpectedOkMarker(stdout, options = {}) { const payloadTexts = parseAgentPayloadTexts(stdout); if (payloadTexts.some((text) => text.trim() === "OK")) { return true; } if (typeof options.logText === "string") { const logTexts = parseAgentPayloadTexts(options.logText); return logTexts.some((text) => text.trim() === "OK"); } if (typeof options.logPath !== "string") { return false; } const logTexts = parseAgentPayloadTexts(readLogTextTail(options.logPath)); return logTexts.some((text) => text.trim() === "OK"); } function readLogFileSize(logPath) { try { return statSync(logPath).size; } catch { return 0; } } function readLogTextSince(logPath, offsetBytes) { return readLogTextWindow(logPath, { offsetBytes, maxBytes: CROSS_OS_AGENT_LOG_FALLBACK_TAIL_BYTES, }); } function readLogTextTail(logPath) { return readLogTextWindow(logPath, { maxBytes: CROSS_OS_AGENT_LOG_FALLBACK_TAIL_BYTES, }); } function readLogTextWindow(logPath, options = {}) { const maxBytes = Math.max( 1, Math.floor(options.maxBytes ?? CROSS_OS_AGENT_LOG_FALLBACK_TAIL_BYTES), ); const offsetBytes = typeof options.offsetBytes === "number" && Number.isFinite(options.offsetBytes) ? Math.max(0, Math.floor(options.offsetBytes)) : 0; let stat; try { stat = statSync(logPath); } catch { return ""; } if (!stat.isFile() || stat.size <= 0) { return ""; } const tailStart = Math.max(0, stat.size - maxBytes); const start = Math.min(stat.size, Math.max(offsetBytes, tailStart)); const length = stat.size - start; if (length <= 0) { return ""; } const fd = openSync(logPath, "r"); try { const buffer = Buffer.alloc(length); const bytesRead = readSync(fd, buffer, 0, length, start); return buffer.subarray(0, bytesRead).toString("utf8"); } finally { closeSync(fd); } } function parseAgentPayloadTexts(stdout) { try { const payload = JSON.parse(stdout); const directTexts = [ payload?.finalAssistantVisibleText, payload?.finalAssistantRawText, payload?.meta?.finalAssistantVisibleText, payload?.meta?.finalAssistantRawText, payload?.result?.finalAssistantVisibleText, payload?.result?.finalAssistantRawText, payload?.result?.meta?.finalAssistantVisibleText, payload?.result?.meta?.finalAssistantRawText, ].filter((text): text is string => typeof text === "string"); const entries = Array.isArray(payload?.payloads) ? payload.payloads : Array.isArray(payload?.result?.payloads) ? payload.result.payloads : []; const payloadTexts = Array.isArray(entries) ? entries.flatMap((entry) => (typeof entry?.text === "string" ? [entry.text] : [])) : []; return [...directTexts, ...payloadTexts]; } catch { const finalTextMatches = [ ...stdout.matchAll( /"(?:finalAssistantVisibleText|finalAssistantRawText|text)"\s*:\s*"([^"]*)"/gu, ), ].map((match) => match[1]); return finalTextMatches.length > 0 ? finalTextMatches : stdout.trim() ? [stdout] : []; } } async function runDashboardSmoke(params) { const dashboardUrl = `http://127.0.0.1:${params.lane.gatewayPort}/`; const logStream = createWriteStream(params.logPath, { flags: "a" }); const deadline = Date.now() + CROSS_OS_DASHBOARD_SMOKE_TIMEOUT_MS; let attempt = 0; try { while (Date.now() < deadline) { attempt += 1; logStream.write(`${new Date().toISOString()} attempt=${attempt} url=${dashboardUrl}\n`); try { const response = await fetch(dashboardUrl, { signal: AbortSignal.timeout(CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS), }); const html = await readBoundedCrossOsResponseText(response); if ( response.ok && html.includes("OpenClaw Control") && html.includes("") ) { logStream.write( `${new Date().toISOString()} dashboard-ready status=${response.status}\n`, ); return; } logStream.write( `${new Date().toISOString()} dashboard-not-ready status=${response.status} title=${html.includes("OpenClaw Control")} app=${html.includes("")}\n`, ); } catch (error) { logStream.write( `${new Date().toISOString()} dashboard-fetch-error ${formatError(error)}\n`, ); } await sleep(1_000); } } finally { logStream.end(); } throw new Error(`Dashboard HTML did not become ready at ${dashboardUrl}.`); } function hasChildExited(child) { return child.exitCode !== null || (child.signalCode ?? null) !== null; } async function stopGateway(gateway) { try { if (!gateway?.child?.pid) { return; } if (process.platform === "win32") { await runCommand("taskkill", ["/PID", String(gateway.child.pid), "/T", "/F"], { logPath: gateway.logPath, check: false, timeoutMs: 30_000, }); const exited = await waitForChildExit(gateway.child, 10_000); if (!exited) { gateway.child.stdout?.destroy(); gateway.child.stderr?.destroy(); } return; } if (hasChildExited(gateway.child)) { signalChildProcessTree(gateway.child, "SIGTERM"); await sleep(2_000); signalChildProcessTree(gateway.child, "SIGKILL"); return; } signalChildProcessTree(gateway.child, "SIGTERM"); const exitedAfterTerm = await waitForChildExit(gateway.child, 2_000); if (!exitedAfterTerm && !hasChildExited(gateway.child)) { signalChildProcessTree(gateway.child, "SIGKILL"); await waitForChildExit(gateway.child, 5_000); } } finally { await gateway?.closeLog?.(); } } function signalChildProcessTree(child, signal) { if (process.platform !== "win32" && child.pid) { try { process.kill(-child.pid, signal); return; } catch { // The child may have exited before its process group was signaled. } } child.kill(signal); } function registerActiveChildProcessTree(child) { const killChildTree = (signal) => signalChildProcessTree(child, signal); CROSS_OS_ACTIVE_CHILD_TREE_KILLERS.add(killChildTree); return { killChildTree, unregister: () => { CROSS_OS_ACTIVE_CHILD_TREE_KILLERS.delete(killChildTree); }, }; } async function waitForChildExit(child, timeoutMs) { if (hasChildExited(child)) { return true; } return new Promise((resolvePromise) => { let settled = false; const finish = (didExit) => { if (settled) { return; } settled = true; if (timer) { clearTimeout(timer); } child.off("exit", onExit); child.off("close", onClose); child.off("error", onError); resolvePromise(didExit); }; const onExit = () => finish(true); const onClose = () => finish(true); const onError = () => finish(true); const timer = timeoutMs > 0 ? setTimeout(() => { finish(false); }, timeoutMs) : null; child.once("exit", onExit); child.once("close", onClose); child.once("error", onError); }); } async function runCleanup(cleanupFns) { for (const cleanupFn of cleanupFns.toReversed()) { try { await cleanupFn(); } catch { // Ignore cleanup failures so the main failure surface stays visible. } } } async function runOpenClaw(params) { return runCommand(process.execPath, [installedEntryPath(params.lane.prefixDir), ...params.args], { cwd: params.lane.homeDir, env: params.env, logPath: params.logPath, timeoutMs: params.timeoutMs, check: params.check ?? true, }); } function readInstalledPackageManifest(prefixDir) { const packageRoot = installedPackageRoot(prefixDir); return readInstalledPackageManifestFromPackageRoot(packageRoot); } function readInstalledPackageManifestFromPackageRoot(packageRoot) { const packageJsonPath = join(packageRoot, "package.json"); if (!existsSync(packageJsonPath)) { throw new Error(`Installed package manifest missing: ${packageJsonPath}`); } const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown; }; return { packageJson, packageRoot }; } export function readInstalledVersion(prefixDir) { const { packageJson } = readInstalledPackageManifest(prefixDir); return typeof packageJson.version === "string" ? packageJson.version.trim() : ""; } function readInstalledMetadata(prefixDir) { const { packageJson, packageRoot } = readInstalledPackageManifest(prefixDir); return readInstalledMetadataFromManifest(packageJson, packageRoot); } function readInstalledMetadataFromPackageRoot(packageRoot) { const { packageJson } = readInstalledPackageManifestFromPackageRoot(packageRoot); return readInstalledMetadataFromManifest(packageJson, packageRoot); } function readInstalledMetadataFromManifest(packageJson, packageRoot) { const buildInfoPath = join(packageRoot, "dist", "build-info.json"); if (!existsSync(buildInfoPath)) { throw new Error(`Installed build info missing: ${buildInfoPath}`); } const buildInfo = JSON.parse(readFileSync(buildInfoPath, "utf8")) as { commit?: unknown; }; return { version: typeof packageJson.version === "string" ? packageJson.version.trim() : "", commit: typeof buildInfo.commit === "string" ? buildInfo.commit.trim() : "", }; } function verifyInstalledCandidate(installed, build) { if (installed.version !== build.candidateVersion) { throw new Error( `Installed version mismatch. Expected ${build.candidateVersion}, found ${installed.version || ""}.`, ); } if (installed.commit !== build.sourceSha) { throw new Error( `Installed build commit mismatch. Expected ${build.sourceSha}, found ${installed.commit || ""}.`, ); } } export function resolveInstalledPackageRootFromCliPath( cliPath, platform = process.platform, env = process.env, ) { const prefixDir = resolveInstalledPrefixDirFromCliPath(cliPath, platform); const candidates = [installedPackageRoot(prefixDir, platform)]; if (platform !== "win32") { const resolvedCliPath = String(cliPath ?? "").trim(); if (resolvedCliPath) { try { const realCliPath = realpathSync(resolvedCliPath); candidates.push(dirname(realCliPath)); candidates.push(dirname(dirname(realCliPath))); } catch { // Some installer shims are shell wrappers, not symlinks. Fall through to // common user-local npm prefixes below. } } for (const prefix of [ env.NPM_CONFIG_PREFIX, env.npm_config_prefix, env.HOME && join(env.HOME, ".npm-global"), env.HOME && join(env.HOME, ".local"), ]) { if (typeof prefix === "string" && prefix.trim()) { candidates.push(installedPackageRoot(prefix, platform)); } } } const checked: string[] = []; for (const candidate of candidates) { if (!candidate || checked.includes(candidate)) { continue; } checked.push(candidate); if (existsSync(join(candidate, "package.json"))) { return candidate; } } throw new Error(`Installed package manifest missing. Checked: ${checked.join(", ")}`); } function installedPackageRoot(prefixDir, platform = process.platform) { return platform === "win32" ? join(prefixDir, "node_modules", "openclaw") : join(prefixDir, "lib", "node_modules", "openclaw"); } function installedEntryPath(prefixDir) { return join(installedPackageRoot(prefixDir), "openclaw.mjs"); } function npmShimPath(prefixDir) { return process.platform === "win32" ? join(prefixDir, "npm.cmd") : join(prefixDir, "bin", "npm"); } function binDirForPrefix(prefixDir) { return process.platform === "win32" ? prefixDir : join(prefixDir, "bin"); } function pnpmCommand() { return process.platform === "win32" ? "pnpm.cmd" : "pnpm"; } function npmCommand() { return process.platform === "win32" ? "npm.cmd" : "npm"; } function gitCommand() { return process.platform === "win32" ? "git.exe" : "git"; } function resolveCommandCaptureLimit(options) { const value = options.maxOutputBytes; if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { return CROSS_OS_COMMAND_CAPTURE_TAIL_BYTES; } return Math.max(1, Math.floor(value)); } function appendBoundedCommandOutput(current, chunk, maxBytes) { const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); if (chunkBuffer.byteLength >= maxBytes) { return chunkBuffer.subarray(chunkBuffer.byteLength - maxBytes).toString("utf8"); } const currentBuffer = Buffer.from(current); const nextBytes = currentBuffer.byteLength + chunkBuffer.byteLength; if (nextBytes <= maxBytes) { return `${current}${chunkBuffer.toString("utf8")}`; } const currentTailBytes = maxBytes - chunkBuffer.byteLength; const currentTail = currentBuffer.subarray(currentBuffer.byteLength - currentTailBytes); return Buffer.concat([currentTail, chunkBuffer], maxBytes).toString("utf8"); } export async function runCommand(command, args, options) { const invocation = resolveCommandSpawnInvocation(command, args, { comSpec: options.env?.ComSpec ?? options.env?.COMSPEC, platform: process.platform, }); return runCommandInvocation(invocation, options); } async function runCommandInvocation(invocation, options) { return new Promise((resolvePromise, rejectPromise) => { const commandLabel = `${invocation.command} ${invocation.args.join(" ")}`; const useProcessGroup = process.platform !== "win32"; const child = spawn(invocation.command, invocation.args, { cwd: options.cwd, env: options.env, shell: invocation.shell, stdio: ["ignore", "pipe", "pipe"], detached: useProcessGroup, windowsVerbatimArguments: invocation.windowsVerbatimArguments, windowsHide: true, }); const activeChildTree = registerActiveChildProcessTree(child); const logStream = createWriteStream(options.logPath, { flags: "a" }); let stdout = ""; let stderr = ""; let timedOut = false; let settled = false; const startedAt = Date.now(); let killWaitTimer = null; let timer = null; let heartbeatTimer = null; const maxCapturedOutputBytes = resolveCommandCaptureLimit(options); const clearTimers = () => { if (timer) { clearTimeout(timer); } if (killWaitTimer) { clearTimeout(killWaitTimer); } if (heartbeatTimer) { clearInterval(heartbeatTimer); } }; const finalize = (callback) => { if (settled) { return; } settled = true; clearTimers(); logStream.end(); callback(); }; const requestKill = () => { if (process.platform === "win32" && child.pid) { try { const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true, }); killer.on("error", () => { child.kill(); }); return; } catch { child.kill(); return; } } activeChildTree.killChildTree("SIGKILL"); }; timer = options.timeoutMs && Number.isFinite(options.timeoutMs) ? setTimeout(() => { timedOut = true; logStream.write(`${new Date().toISOString()} timeout command=${commandLabel}\n`); requestKill(); killWaitTimer = setTimeout(() => { finalize(() => { rejectPromise( new Error( `Command timed out and could not be terminated cleanly: ${commandLabel}`, ), ); }); }, 15_000); }, options.timeoutMs) : null; heartbeatTimer = CROSS_OS_COMMAND_HEARTBEAT_SECONDS > 0 ? setInterval(() => { const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1000); const message = `${new Date().toISOString()} still running after ${elapsedSeconds}s: ${commandLabel}\n`; logStream.write(message); process.stdout.write(`[release-checks] ${message}`); }, CROSS_OS_COMMAND_HEARTBEAT_SECONDS * 1000) : null; heartbeatTimer?.unref?.(); logStream.write(`${new Date().toISOString()} start command=${commandLabel}\n`); child.stdout?.on("data", (chunk) => { const text = chunk.toString(); stdout = appendBoundedCommandOutput(stdout, chunk, maxCapturedOutputBytes); logStream.write(text); }); child.stderr?.on("data", (chunk) => { const text = chunk.toString(); stderr = appendBoundedCommandOutput(stderr, chunk, maxCapturedOutputBytes); logStream.write(text); }); child.on("error", (error) => { activeChildTree.unregister(); finalize(() => rejectPromise(error)); }); child.on("close", (exitCode) => { activeChildTree.unregister(); if (forwardedSignalExitCode !== undefined) { return; } finalize(() => { const result = { exitCode: exitCode ?? 1, stdout, stderr, }; if (timedOut) { rejectPromise(new Error(`Command timed out: ${commandLabel}`)); return; } if ((options.check ?? true) && result.exitCode !== 0) { rejectPromise( new Error( `Command failed (${result.exitCode}): ${commandLabel}\n${trimForSummary( `${stdout}\n${stderr}`, )}`, ), ); return; } resolvePromise(result); }); }); }); } export async function startStaticFileServer(params) { mkdirSync(dirname(params.logPath), { recursive: true }); const logStream = createWriteStream(params.logPath, { flags: "a" }); const fileName = String(params.filePath.split(/[/\\]/u).at(-1) ?? "artifact"); const fileStat = statSync(params.filePath); const sockets = new Set(); const server = createServer((request, response) => { logStream.write(`${new Date().toISOString()} ${request.method} ${request.url}\n`); if (request.url !== `/${fileName}`) { response.statusCode = 404; response.end("not found"); return; } response.statusCode = 200; response.setHeader("content-type", resolveStaticFileContentType(params.filePath)); response.setHeader("content-length", String(fileStat.size)); response.setHeader("connection", "close"); const fileStream = createReadStream(params.filePath); fileStream.once("error", (error) => { logStream.write(`${new Date().toISOString()} static-file-read-error ${formatError(error)}\n`); if (response.headersSent) { response.destroy(error); return; } response.removeHeader("content-length"); response.statusCode = 500; response.end("failed to read file"); }); fileStream.pipe(response); }); server.on("connection", (socket) => { sockets.add(socket); socket.once("close", () => { sockets.delete(socket); }); }); await new Promise((resolvePromise, rejectPromise) => { server.once("error", rejectPromise); server.listen(0, "127.0.0.1", resolvePromise); }); const address = server.address(); if (!address || typeof address === "string") { throw new Error("Failed to bind static file server."); } const port = address.port; return { url: `http://127.0.0.1:${port}/${fileName}`, close: () => new Promise((resolvePromise, rejectPromise) => { server.close((error) => { logStream.end(); if (error) { rejectPromise(error); return; } resolvePromise(); }); for (const socket of sockets) { socket.destroy(); } }), }; } export function resolveStaticFileContentType(filePath) { if (filePath.endsWith(".sh") || filePath.endsWith(".ps1")) { return "text/plain; charset=utf-8"; } return "application/octet-stream"; } export function resolvePublishedInstallerUrl(platform = process.platform) { if (platform === "win32") { return `${PUBLISHED_INSTALLER_BASE_URL}/install.ps1`; } return `${PUBLISHED_INSTALLER_BASE_URL}/install.sh`; } function writeSummary(baseDir, summaryPayload) { const summaryJsonPath = join(baseDir, "summary.json"); const summaryMarkdownPath = join(baseDir, "summary.md"); writeFileSync(summaryJsonPath, `${JSON.stringify(summaryPayload, null, 2)}\n`, "utf8"); const result = summaryPayload.result ?? {}; const lines = [ `## ${platformLabel()}`, "", `- Provider: \`${summaryPayload.provider}\``, `- Suite: \`${summaryPayload.suite}\``, `- Mode: \`${summaryPayload.mode}\``, `- Source SHA: \`${summaryPayload.sourceSha || "unknown"}\``, `- Candidate version: \`${summaryPayload.candidateVersion || "unknown"}\``, `- Baseline spec: \`${summaryPayload.baselineSpec}\``, result.status ? `- Result: \`${result.status}\`` : "", result.installTarget ? `- Install target: \`${result.installTarget}\`` : "", result.installVersion ? `- Install version: \`${result.installVersion}\`` : "", result.baselineVersion ? `- Baseline version: \`${result.baselineVersion}\`` : "", result.installedVersion ? `- Installed version: \`${result.installedVersion}\`` : "", result.installedCommit ? `- Installed commit: \`${result.installedCommit}\`` : "", result.cliPath ? `- CLI path: \`${result.cliPath}\`` : "", result.gatewayPort ? `- Gateway port: \`${result.gatewayPort}\`` : "", result.dashboardStatus ? `- Dashboard: \`${result.dashboardStatus}\`` : "", result.discordStatus ? `- Discord: \`${result.discordStatus}\`` : "", result.agentOutput ? `- Agent output: \`${trimForSummary(result.agentOutput)}\`` : "", result.error ? `- Error: \`${trimForSummary(result.error)}\`` : "", ].filter(Boolean); if (Array.isArray(result.phaseTimings) && result.phaseTimings.length > 0) { lines.push("", "### Phase timings"); for (const phase of result.phaseTimings) { const suffix = phase.status === "pass" ? "" : ` (${phase.status})`; lines.push(`- \`${phase.name}\`: ${Math.round(phase.durationMs / 1000)}s${suffix}`); } } writeFileSync(summaryMarkdownPath, `${lines.join("\n")}\n`, "utf8"); } function writeCandidateManifest(baseDir, build) { const manifestPath = join(baseDir, "candidate.json"); writeFileSync( manifestPath, `${JSON.stringify( { sourceSha: build.sourceSha, candidateVersion: build.candidateVersion, candidateFileName: build.candidateFileName, }, null, 2, )}\n`, "utf8", ); } function platformLabel() { if (process.platform === "darwin") { return "macOS Release Checks"; } if (process.platform === "win32") { return "Windows Release Checks"; } return "Linux Release Checks"; } function requireArg(argsMap, key) { const value = argsMap[key]?.trim(); if (!value) { throw new Error(`Missing required --${key} argument.`); } return value; } function resolveCommandPath(command) { const pathValue = process.env.PATH ?? ""; const pathEntries = pathValue.split(process.platform === "win32" ? ";" : ":").filter(Boolean); const candidates = process.platform === "win32" && !command.toLowerCase().endsWith(".cmd") ? [`${command}.cmd`, `${command}.exe`, command] : [command]; for (const entry of pathEntries) { for (const candidate of candidates) { const fullPath = join(entry, candidate); if (existsSync(fullPath)) { return fullPath; } } } return null; } function shellEscapeForSh(value) { return value.replace(/'/gu, `'"'"'`); } function logPhase(scope, phase) { process.stdout.write(`[release-checks] ${scope}: ${phase}\n`); } function logLanePhase(lane, phase) { logPhase(`lane.${lane.name}`, phase); } async function runTimedLanePhase(lane, phase, callback) { const startedAt = Date.now(); logLanePhase(lane, phase); try { const result = await callback(); const durationMs = Date.now() - startedAt; lane.phaseTimings.push({ name: phase, status: "pass", durationMs }); logPhase(`lane.${lane.name}`, `${phase}: done in ${Math.round(durationMs / 1000)}s`); return result; } catch (error) { const durationMs = Date.now() - startedAt; lane.phaseTimings.push({ name: phase, status: "fail", durationMs }); logPhase(`lane.${lane.name}`, `${phase}: failed in ${Math.round(durationMs / 1000)}s`); throw error; } } function trimForSummary(value) { const trimmed = value.trim(); if (trimmed.length <= 600) { return trimmed; } return `${trimmed.slice(0, 600)}...`; } function formatError(error) { if (error instanceof Error) { return error.stack || error.message; } return String(error); } function sleep(ms) { return new Promise((resolvePromise) => { setTimeout(resolvePromise, ms); }); } async function withAllocatedGatewayPort(lane, callback) { let lastError = null; for (let attempt = 1; attempt <= 3; attempt += 1) { const reservation = await reservePort(); lane.gatewayPort = reservation.port; await reservation.release(); try { return await callback(); } catch (error) { lastError = error; if (!isAddressInUseError(error) || attempt === 3) { throw error; } await sleep(250 * attempt); } } throw toLintErrorObject( lastError ?? new Error("Failed to allocate a gateway port."), "Non-Error thrown", ); } function reservePort() { return new Promise((resolvePromise, rejectPromise) => { const server = createNetServer(); server.listen(0, "127.0.0.1", () => { const address = server.address(); if (!address || typeof address === "string") { server.close(); rejectPromise(new Error("Failed to allocate a TCP port.")); return; } resolvePromise({ port: address.port, release: () => new Promise((releaseResolve, releaseReject) => { server.close((error) => { if (error) { releaseReject(error); return; } releaseResolve(); }); }), }); }); server.once("error", rejectPromise); }); } function isAddressInUseError(error) { const message = formatError(error); return message.includes("EADDRINUSE") || /address.+in use/iu.test(message); } function toLintErrorObject(value: unknown, fallbackMessage: string): Error { if (value instanceof Error) { return value; } if (typeof value === "string") { return new Error(value); } const error = new Error(fallbackMessage, { cause: value }); if ((typeof value === "object" && value !== null) || typeof value === "function") { Object.assign(error, value); } return error; }