Files
openclaw/scripts/lib/cross-os-release-checks/config.ts
2026-07-13 04:01:40 -07:00

683 lines
20 KiB
TypeScript

import type { ChildProcess } from "node:child_process";
import { basename, dirname, resolve, win32 as pathWin32 } from "node:path";
import { trimForSummary } from "./shared.ts";
export type CrossOsSuite = "packaged-fresh" | "installer-fresh" | "packaged-upgrade" | "dev-update";
export type CrossOsMode = "fresh" | "upgrade" | "both";
export type CrossOsOsId = "ubuntu" | "windows" | "macos";
export type ProviderId = "openai" | "anthropic" | "minimax";
export type ProviderConfig = {
extensionId: string;
secretEnv: string;
authChoice: string;
model: string;
baseUrl?: string;
timeoutSeconds?: number;
};
export type ParsedArgs = Record<string, string>;
export type LaneResult = { status: string; error?: string } & Record<string, unknown>;
export type CandidateBuild = {
candidateTgz: string;
candidateVersion: string;
candidateFileName: string;
sourceDir: string;
sourceSha: string;
};
export type PackageJson = {
name?: string;
version?: string;
scripts?: Record<string, string>;
openclaw?: { commit?: string };
};
export type LaneState = {
name: string;
rootDir: string;
prefixDir: string;
homeDir: string;
stateDir: string;
appDataDir: string;
gatewayPort: number;
phaseTimings: Array<{ name: string; status: "pass" | "fail"; durationMs: number }>;
};
export type GatewayHandle = {
child: ChildProcess;
closeLog: () => Promise<void>;
logPath: string;
};
export type CommandResult = { exitCode: number; stdout: string; stderr: string };
export type AgentTurnResult = CommandResult | { status: number; stdout: string; stderr: string };
export type CommandOptions = {
cwd?: string;
env?: NodeJS.ProcessEnv;
logPath: string;
timeoutMs?: number;
check?: boolean;
maxOutputBytes?: number;
};
export type CommandInvocation = {
command: string;
args: string[];
shell?: boolean | string;
windowsVerbatimArguments?: boolean;
};
export type Cleanup = () => Promise<void> | void;
export type LaneBaseParams = {
logsDir: string;
providerConfig: ProviderConfig;
providerSecretValue: string;
};
export type LaneCommandParams = {
lane: LaneState;
env: NodeJS.ProcessEnv;
logPath: string;
};
export type AgentOutputOptions = { logText?: string; logPath?: string };
export type SummaryPayload = {
provider: string;
suite: string;
mode: string;
sourceSha?: string;
candidateVersion?: string;
baselineSpec: string;
result?: {
status?: string;
installTarget?: string;
installVersion?: string;
baselineVersion?: string;
installedVersion?: string;
installedCommit?: string;
cliPath?: string;
gatewayPort?: number;
dashboardStatus?: string;
discordStatus?: string;
agentOutput?: string;
error?: string;
phaseTimings?: LaneState["phaseTimings"];
};
};
export const PUBLISHED_INSTALLER_BASE_URL = "https://openclaw.ai";
const SUPPORTED_MODES = new Set<CrossOsMode>(["fresh", "upgrade", "both"]);
const SUPPORTED_SUITES = new Set<CrossOsSuite>([
"packaged-fresh",
"installer-fresh",
"packaged-upgrade",
"dev-update",
]);
const SUPPORTED_OS_IDS = new Set<CrossOsOsId>(["ubuntu", "windows", "macos"]);
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;
export const CROSS_OS_AGENT_LOG_FALLBACK_TAIL_BYTES = 2 * 1024 * 1024;
export const CROSS_OS_NPM_DEBUG_LOG_TAIL_BYTES = 256 * 1024;
export const CROSS_OS_PROCESS_TREE_KILL_AFTER_MS = parsePositiveIntegerEnv(
"OPENCLAW_CROSS_OS_PROCESS_TREE_KILL_AFTER_MS",
15_000,
);
export const CROSS_OS_AGENT_TURN_OPTIONAL = resolveCrossOsAgentTurnOptional();
const providerConfig = {
openai: {
extensionId: "openai",
secretEnv: "OPENAI_API_KEY",
authChoice: "openai-api-key",
model: "openai/gpt-5.6-luna",
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",
},
} satisfies Record<ProviderId, ProviderConfig>;
export function resolveProviderConfig(provider: string, env = process.env): ProviderConfig | null {
if (!Object.hasOwn(providerConfig, provider)) {
return null;
}
const config: ProviderConfig = providerConfig[provider as ProviderId];
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: Pick<ProviderConfig, "extensionId">,
) {
return [...new Set([providerMeta.extensionId, ...RELEASE_SMOKE_PLUGIN_ALLOWLIST_BASE])];
}
export function buildCrossOsReleaseSmokeMemorySlotConfigArgs() {
return ["config", "set", "plugins.slots.memory", JSON.stringify("none"), "--strict-json"];
}
function shouldSeedProviderConfigModels(providerMeta: ProviderConfig) {
return (
typeof providerMeta.baseUrl === "string" || typeof providerMeta.timeoutSeconds === "number"
);
}
export function buildReleaseProviderConfigOverride(providerMeta: ProviderConfig) {
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 }
: {}),
};
}
export const PACKAGE_DIST_INVENTORY_RELATIVE_PATH = "dist/postinstall-inventory.json";
export const INSTALL_STAGE_DEBRIS_DIR_PATTERN = /^\.openclaw-install-stage(?:-[^/]+)?$/iu;
export 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,
);
export function gatewayReadyDeadlineMs(platform = process.platform) {
return platform === "win32"
? CROSS_OS_WINDOWS_GATEWAY_READY_TIMEOUT_MS
: CROSS_OS_GATEWAY_READY_TIMEOUT_MS;
}
export function resolveNpmPackTarballFileName(value: unknown, label = "npm pack") {
const filename = typeof value === "string" ? value.trim() : "";
if (
!filename.endsWith(".tgz") ||
filename.includes("\0") ||
filename !== basename(filename) ||
filename !== pathWin32.basename(filename)
) {
throw new Error(`${label} did not report a safe .tgz filename.`);
}
return filename;
}
export function resolvePackDestinationTarball(
value: unknown,
packDestination: string,
label = "package pack",
) {
const filename = typeof value === "string" ? value.trim() : "";
const fileName = basename(filename);
const destinationDir = resolve(packDestination);
const tarballPath = resolve(destinationDir, filename);
if (
!filename.endsWith(".tgz") ||
filename.includes("\0") ||
!fileName ||
fileName !== pathWin32.basename(filename) ||
dirname(tarballPath) !== destinationDir
) {
throw new Error(`${label} did not report a safe .tgz filename.`);
}
return { fileName, path: tarballPath };
}
export function parseArgs(argv: string[]): ParsedArgs {
const parsed: ParsedArgs = {};
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (token === undefined) {
throw new Error(`Missing cross-OS release argument at index ${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: string, fallback: number, env = process.env): number {
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: string, fallback: boolean, env = process.env): boolean {
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: string) {
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?: string) {
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?: string) {
const trimmed = ref?.trim() || "";
return trimmed.startsWith("refs/tags/") || looksLikeReleaseVersionRef(trimmed);
}
export function resolveRequestedSuites(mode: string, ref: string): CrossOsSuite[] {
if (!SUPPORTED_MODES.has(mode as CrossOsMode)) {
throw new Error(`Unsupported mode "${mode}".`);
}
const suites: CrossOsSuite[] = [];
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: {
mode: string;
ref: string;
suiteFilter?: string;
ubuntuRunner?: string;
windowsRunner?: string;
macosRunner?: string;
varUbuntuRunner?: string;
varWindowsRunner?: string;
varMacosRunner?: string;
}) {
const pick = (...values: Array<string | undefined>) =>
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 as CrossOsOsId, 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: string) {
const tokens = 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 as CrossOsSuite)) {
return { osId: "", suite: token as CrossOsSuite };
}
if (SUPPORTED_OS_IDS.has(token as CrossOsOsId)) {
return { osId: token as CrossOsOsId, 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 as CrossOsSuite)) {
break;
}
return { osId: matchedOs, suite: suite as CrossOsSuite };
}
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: CrossOsOsId, suite: CrossOsSuite) =>
matchers.some((matcher) => {
const osMatches = !matcher.osId || matcher.osId === osId;
const suiteMatches = !matcher.suite || matcher.suite === suite;
return osMatches && suiteMatches;
}),
tokens,
};
}
function normalizeCrossOsSuiteFilterToken(token: string) {
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: CrossOsSuite) {
if (suite === "packaged-fresh") {
return "packaged fresh";
}
if (suite === "installer-fresh") {
return "installer fresh";
}
if (suite === "packaged-upgrade") {
return "packaged upgrade";
}
return "dev update";
}
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);
}
export function shouldRunBundledPluginPostinstall(_options?: { lane?: LaneState }) {
return true;
}
export function looksLikeCommitSha(ref: string) {
return /^[0-9a-f]{7,40}$/iu.test(ref.trim());
}
export function resolveExpectedDevUpdateRef(ref?: string) {
const trimmed = normalizeRequestedRef(ref) || "main";
return trimmed || "main";
}
export function resolveDevUpdateVerificationRef(ref: string, sourceSha?: string) {
if (resolveExpectedDevUpdateRef(ref) === "main" && looksLikeCommitSha(sourceSha ?? "")) {
return sourceSha!.trim();
}
return resolveExpectedDevUpdateRef(ref);
}
export function shouldRunMainChannelDevUpdate(ref: string) {
if (isImmutableReleaseRef(ref)) {
return false;
}
return resolveExpectedDevUpdateRef(ref) === "main";
}
export function shouldSkipInstallerDaemonHealthCheck(platform = process.platform) {
return platform === "win32";
}
export function buildRealUpdateEnv(env: NodeJS.ProcessEnv) {
const updateEnv: NodeJS.ProcessEnv = {
...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: CommandResult,
_options?: { candidateVersion?: string },
) {
if (result.exitCode === 0) {
return;
}
throw new Error(
`Packaged upgrade failed (${result.exitCode}): ${trimForSummary(
`${result.stdout}\n${result.stderr}`,
)}`,
);
}
export function buildPackagedUpgradeUpdateArgs(candidateUrl: string) {
return [
"update",
"--tag",
candidateUrl,
"--yes",
"--json",
"--no-restart",
"--timeout",
String(updateStepTimeoutSeconds()),
];
}
export function isRecoverableWindowsPackagedUpgradeSwapCleanupFailure(
result: CommandResult | undefined,
platform = process.platform,
) {
if (platform !== "win32" || !result || 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: unknown,
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,
}: { platform?: NodeJS.Platform; usedWindowsPackagedUpgradeFallback?: boolean } = {}) {
return !(platform === "win32" && usedWindowsPackagedUpgradeFallback);
}
export function verifyWindowsPackagedUpgradeFallbackInstall({
installedVersion,
candidateVersion,
}: {
installedVersion: string;
candidateVersion: string;
}) {
if (installedVersion !== candidateVersion) {
throw new Error(
`Windows packaged upgrade fallback installed ${installedVersion || "unknown"}, expected ${candidateVersion}`,
);
}
}
export function resolveExplicitBaselineVersion(baselineSpec: string) {
const trimmed = baselineSpec.trim();
if (!trimmed || trimmed === "openclaw@latest") {
return "";
}
if (trimmed.startsWith("openclaw@")) {
return trimmed.slice("openclaw@".length);
}
return trimmed;
}
export function installTimeoutMs() {
return process.platform === "win32" ? 45 * 60 * 1000 : 20 * 60 * 1000;
}
export function updateTimeoutMs() {
return process.platform === "win32"
? CROSS_OS_WINDOWS_PACKAGED_UPGRADE_WRAPPER_TIMEOUT_MS
: 20 * 60 * 1000;
}
export function updateStepTimeoutSeconds() {
return process.platform === "win32"
? CROSS_OS_WINDOWS_PACKAGED_UPGRADE_STEP_TIMEOUT_SECONDS
: 1200;
}
export function isSupportedCrossOsSuite(value: string): value is CrossOsSuite {
return SUPPORTED_SUITES.has(value as CrossOsSuite);
}