#!/usr/bin/env -S node --
import tsx
// Executed directly via Node.js + tsx in the release workflow.
import { spawn } from
"node:child_process" ;
import {
chmodSync,
createWriteStream,
existsSync,
mkdirSync,
readFileSync,
rmSync,
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 { tmpdir } from
"node:os" ;
import { dirname, join, resolve, win32 as pathWin32 } from
"node:path" ;
import { fileURLToPath } from
"node:url" ;
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 providerConfig = {
openai: {
extensionId:
"openai" ,
secretEnv:
"OPENAI_API_KEY" ,
authChoice:
"openai-api-key" ,
model:
"openai/gpt-5.4" ,
},
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" ,
},
};
const PACKAGE_DIST_INVENTORY_RELATIVE_PATH =
"dist/postinstall-inventory.json" ;
const OMITTED_QA_EXTENSION_PREFIXES = [
"dist/extensions/qa-channel/" ,
"dist/extensions/qa-lab/" ,
"dist/extensions/qa-matrix/" ,
];
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 looksLikeReleaseVersionRef(ref) {
const trimmed = normalizeRequestedRef(ref);
return /^v?[
0 -
9 ]{
4 }\.[
0 -
9 ]+\.[
0 -
9 ]+(?:-(?:[
1 -
9 ][
0 -
9 ]*)|[-.](?: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 runners = [
{
os_id: "ubuntu" ,
display_name: "Linux" ,
runner: pick(params.ubuntuRunner, params.varUbuntuRunner, "ubuntu-latest" ),
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, "macos-latest-xlarge" ),
artifact_name: "macos" ,
},
];
return {
include: runners.flatMap((runner) =>
suites.map((suite) =>
Object.assign({}, runner, {
suite,
suite_label: formatSuiteLabel(suite),
lane: suite.includes(`upgrade`) || suite === `dev-update` ? `upgrade` : `fresh`,
}),
),
),
};
}
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" ],
...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 = providerConfig[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=6144" ,
};
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 isPackagedDistPath(relativePath) {
if (!relativePath.startsWith("dist/" )) {
return false ;
}
if (relativePath === PACKAGE_DIST_INVENTORY_RELATIVE_PATH) {
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 ;
}
async function writePackageDistInventoryForCandidate(params) {
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);
logLanePhase(lane, "install-candidate" );
await installTarballPackage({
lane,
env,
tgzPath: params.build.candidateTgz,
logPath: join(params.logsDir, "fresh-install.log" ),
restoreBundledPluginRuntimeDeps: false ,
});
const installed = readInstalledMetadata(lane.prefixDir);
verifyInstalledCandidate(installed, params.build);
logLanePhase(lane, "restore-bundled-plugin-runtime-deps" );
await runBundledPluginPostinstall({
lane,
env,
logPath: join(params.logsDir, "fresh-install.log" ),
});
logLanePhase(lane, "onboard" );
await runOnboard({
lane,
env,
providerConfig: params.providerConfig,
logPath: join(params.logsDir, "fresh-onboard.log" ),
});
logLanePhase(lane, "start-gateway" );
const gateway = await startGateway({
lane,
env,
logPath: join(params.logsDir, "fresh-gateway.log" ),
});
cleanup.push(() => stopGateway(gateway));
logLanePhase(lane, "wait-gateway" );
await waitForGateway({
lane,
env,
logPath: join(params.logsDir, "fresh-gateway-status.log" ),
});
logLanePhase(lane, "dashboard" );
await runDashboardSmoke({
lane,
logPath: join(params.logsDir, "fresh-dashboard.log" ),
});
logLanePhase(lane, "models-set" );
await runModelsSet({
lane,
env,
providerConfig: params.providerConfig,
logPath: join(params.logsDir, "fresh-models-set.log" ),
});
logLanePhase(lane, "agent-turn" );
const agent = await 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,
agentOutput: trimForSummary(agent.stdout),
};
} 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);
logLanePhase(lane, "install-baseline" );
if (!params.baselineTgz && params.baselineSpec) {
await installPackageSpec({
lane,
env,
packageSpec: params.baselineSpec,
logPath: join(params.logsDir, "upgrade-install-baseline.log" ),
});
} else {
await installTarballPackage({
lane,
env,
tgzPath: params.baselineTgz,
logPath: join(params.logsDir, "upgrade-install-baseline.log" ),
restoreBundledPluginRuntimeDeps: false ,
});
}
logLanePhase(lane, "restore-baseline-bundled-plugin-runtime-deps" );
await runBundledPluginPostinstall({
lane,
env,
logPath: join(params.logsDir, "upgrade-install-baseline.log" ),
});
const baseline = {
version: readInstalledVersion(lane.prefixDir),
};
logLanePhase(lane, "update" );
const updateEnv = buildRealUpdateEnv(env);
const updateArgs = [
"update" ,
"--tag" ,
params.candidateUrl,
"--yes" ,
"--json" ,
"--timeout" ,
String(updateStepTimeoutSeconds()),
];
await runOpenClaw({
lane,
env: updateEnv,
args: updateArgs,
logPath: join(params.logsDir, "upgrade-update.log" ),
timeoutMs: updateTimeoutMs(),
});
logLanePhase(lane, "update-status" );
await runOpenClaw({
lane,
env,
args: ["update" , "status" , "--json" ],
logPath: join(params.logsDir, "upgrade-update-status.log" ),
timeoutMs: 2 * 60 * 1000 ,
});
logLanePhase(lane, "restore-bundled-plugin-runtime-deps" );
await runBundledPluginPostinstall({
lane,
env,
logPath: join(params.logsDir, "upgrade-bundled-plugin-postinstall.log" ),
});
const installed = readInstalledMetadata(lane.prefixDir);
verifyInstalledCandidate(installed, params.build);
logLanePhase(lane, "onboard" );
await runOnboard({
lane,
env,
providerConfig: params.providerConfig,
logPath: join(params.logsDir, "upgrade-onboard.log" ),
});
logLanePhase(lane, "start-gateway" );
const gateway = await startGateway({
lane,
env,
logPath: join(params.logsDir, "upgrade-gateway.log" ),
});
cleanup.push(() => stopGateway(gateway));
logLanePhase(lane, "wait-gateway" );
await waitForGateway({
lane,
env,
logPath: join(params.logsDir, "upgrade-gateway-status.log" ),
});
logLanePhase(lane, "dashboard" );
await runDashboardSmoke({
lane,
logPath: join(params.logsDir, "upgrade-dashboard.log" ),
});
logLanePhase(lane, "models-set" );
await runModelsSet({
lane,
env,
providerConfig: params.providerConfig,
logPath: join(params.logsDir, "upgrade-models-set.log" ),
});
logLanePhase(lane, "agent-turn" );
const agent = await 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),
};
} 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);
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" ),
});
}
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, "models-set" );
await runInstalledModelsSet({
cliPath: freshShell.cliPath,
env,
providerConfig: params.providerConfig,
cwd: lane.homeDir,
logPath: join(params.logsDir, "installer-fresh-models-set.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" ,
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) || "<empty>" }.`,
);
}
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" ),
});
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, "models-set" );
await runInstalledModelsSet({
cliPath: verifiedShell.cliPath,
env,
providerConfig: params.providerConfig,
cwd: lane.homeDir,
logPath: join(params.logsDir, "dev-update-models-set.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 ,
};
}
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=6144" ,
[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 shouldRestoreBundledPluginRuntimeDeps() {
return true ;
}
function looksLikeCommitSha(ref) {
return /^[0 -9 a-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 };
delete updateEnv.OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL;
return updateEnv;
}
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 readInstalledMetadata(resolveInstalledPrefixDirFromCliPath(cliPath, platform));
}
function resolveInstalledCliInvocation(cliPath, platform = process.platform) {
if (platform !== "win32" ) {
return { command: cliPath, argsPrefix: [], shell: false };
}
const normalizedCliPath = normalizeWindowsInstalledCliPath(cliPath);
if (!/\.cmd$/iu.test(normalizedCliPath)) {
return { command: normalizedCliPath, argsPrefix: [], shell: false };
}
const entryPath = installedEntryPath(
resolveInstalledPrefixDirFromCliPath(normalizedCliPath, platform),
);
if (existsSync(entryPath)) {
return {
command: process.execPath,
argsPrefix: [entryPath],
shell: false ,
};
}
return { command: normalizedCliPath, argsPrefix: [], shell: true };
}
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);
return runCommand(invocation.command, [...invocation.argsPrefix, ...params.args], {
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 = [
"onboard" ,
"--non-interactive" ,
"--mode" ,
"local" ,
"--auth-choice" ,
params.providerConfig.authChoice,
"--secret-input-mode" ,
"ref" ,
"--gateway-port" ,
String(params.lane.gatewayPort),
"--gateway-bind" ,
"loopback" ,
"--skip-skills" ,
"--accept-risk" ,
"--json" ,
];
if (params.installDaemon) {
args.push("--install-daemon" );
}
if (!params.installDaemon || shouldSkipInstallerDaemonHealthCheck()) {
args.push("--skip-health" );
}
await runInstalledCli({
cliPath: params.cliPath,
args,
cwd: params.lane.homeDir,
env: params.env,
logPath: params.logPath,
timeoutMs: 10 * 60 * 1000 ,
});
});
}
async function startManualGatewayFromInstalledCli(params) {
mkdirSync(dirname(params.logPath), { recursive: true });
const gatewayLog = createWriteStream(params.logPath, { flags: "a" });
const invocation = resolveInstalledCliInvocation(params.cliPath);
const child = spawn(
invocation.command,
[
...invocation.argsPrefix,
"gateway" ,
"run" ,
"--bind" ,
"loopback" ,
"--port" ,
String(params.lane.gatewayPort),
"--force" ,
],
{
cwd: params.lane.homeDir,
env: params.env,
shell: invocation.shell,
stdio: ["ignore" , "pipe" , "pipe" ],
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 ;
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 ,
});
if (
requireRpc &&
(help.stdout.includes("--require-rpc" ) || help.stderr.includes("--require-rpc" ))
) {
return ["gateway" , "status" , "--deep" , "--require-rpc" , "--timeout" , "5000" ];
}
return ["gateway" , "status" , "--deep" ];
}
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: 20 _000 ,
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: 20 _000 ,
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 ,
});
}
async function runInstalledAgentTurn(params) {
const sessionId = `cross-os-release-check-${params.label}-${Date.now()}`;
const result = await runInstalledCli({
cliPath: params.cliPath,
args: [
"agent" ,
"--agent" ,
"main" ,
"--session-id" ,
sessionId,
"--message" ,
"Reply with exact ASCII text OK only." ,
"--json" ,
],
cwd: params.cwd,
env: params.env,
logPath: params.logPath,
timeoutMs: 10 * 60 * 1000 ,
});
if (!agentOutputHasExpectedOkMarker(result.stdout, { logPath: params.logPath })) {
throw new Error("Agent output did not contain the expected OK marker." );
}
return result;
}
export function verifyDevUpdateStatus(stdout, options = {}) {
let payload = null ;
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 ?? "<missing>" }.`,
);
}
if (channelValue !== "dev" ) {
throw new Error(
`Dev update status did not report channel=dev. Found ${channelValue ?? "<missing>" }.`,
);
}
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 ?? "<missing>" }.`,
);
}
return ;
}
if (branch !== expectedRef) {
throw new Error(
`Dev update status did not report branch=${expectedRef}. Found ${branch ?? "<missing>" }.`,
);
}
}
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,
});
}
async function waitForDiscordMessage(params) {
const deadline = Date.now() + 3 * 60 * 1000 ;
while (Date.now() < deadline) {
const response = await fetch(
`https://discord.com/api/v10/channels/${params.channelId}/messages?limit=20`,
{
headers: {
Authorization: `Bot ${params.token}`,
},
},
);
const text = await response.text();
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}.`);
}
async function postDiscordMessage(params) {
const response = await fetch(
`https://discord.com/api/v10/channels/${params.channelId}/messages`,
{
method: "POST" ,
headers: {
Authorization: `Bot ${params.token}`,
"Content-Type" : "application/json" ,
},
body: JSON.stringify({
content: params.content,
flags: 4096 ,
}),
},
);
const text = await response.text();
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}`,
{
method: "DELETE" ,
headers: {
Authorization: `Bot ${params.token}`,
},
},
).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,
});
if (
params.restoreBundledPluginRuntimeDeps !== false &&
shouldRestoreBundledPluginRuntimeDeps({ 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(),
[
"install" ,
"-g" ,
params.packageSpec,
"--omit=dev" ,
"--no-fund" ,
"--no-audit" ,
"--loglevel=notice" ,
],
{
cwd: params.lane.homeDir,
env: installEnv,
logPath: params.logPath,
timeoutMs: params.timeoutMs ?? installTimeoutMs(),
},
);
}
function installTimeoutMs() {
return process.platform === "win32" ? 45 * 60 * 1000 : 20 * 60 * 1000 ;
}
function updateTimeoutMs() {
return process.platform === "win32" ? 30 * 60 * 1000 : 20 * 60 * 1000 ;
}
function updateStepTimeoutSeconds() {
return process.platform === "win32" ? 1800 : 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 ,
});
}
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, 0 o755);
}
async function runOnboard(params) {
await withAllocatedGatewayPort(params.lane, async () => {
await runOpenClaw({
lane: params.lane,
env: params.env,
args: [
"onboard" ,
"--non-interactive" ,
"--mode" ,
"local" ,
"--auth-choice" ,
params.providerConfig.authChoice,
"--secret-input-mode" ,
"ref" ,
"--gateway-port" ,
String(params.lane.gatewayPort),
"--gateway-bind" ,
"loopback" ,
"--skip-skills" ,
"--skip-health" ,
"--accept-risk" ,
"--json" ,
],
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 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" ],
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 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: 20 _000 ,
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" ? 5 * 60 * 1000 : 90 _000 ;
}
async function resolveGatewayStatusArgs(lane, env, logPath) {
const help = await runOpenClaw({
lane,
env,
args: ["gateway" , "status" , "--help" ],
logPath,
timeoutMs: 15 _000 ,
check: false ,
});
if (help.stdout.includes("--require-rpc" ) || help.stderr.includes("--require-rpc" )) {
return ["gateway" , "status" , "--deep" , "--require-rpc" , "--timeout" , "5000" ];
}
return ["gateway" , "status" , "--deep" ];
}
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 ,
});
}
async function runAgentTurn(params) {
const sessionId = `cross-os-release-check-${params.label}-${Date.now()}`;
const result = await runOpenClaw({
lane: params.lane,
env: params.env,
args: [
"agent" ,
"--agent" ,
"main" ,
"--session-id" ,
sessionId,
"--message" ,
"Reply with exact ASCII text OK only." ,
"--json" ,
],
logPath: params.logPath,
timeoutMs: 10 * 60 * 1000 ,
});
if (!agentOutputHasExpectedOkMarker(result.stdout, { logPath: params.logPath })) {
throw new Error("Agent output did not contain the expected OK marker." );
}
return result;
}
export function agentOutputHasExpectedOkMarker(stdout, options = {}) {
const payloadTexts = parseAgentPayloadTexts(stdout);
if (payloadTexts.some((text) => text.trim() === "OK" )) {
return true ;
}
if (typeof options.logPath !== "string" ) {
return false ;
}
try {
const logTexts = parseAgentPayloadTexts(readFileSync(options.logPath, "utf8" ));
return logTexts.some((text) => text.trim() === "OK" );
} catch {
return false ;
}
}
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() + 30 _000 ;
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(5 _000 ),
});
const html = await response.text();
if (
response.ok &&
html.includes("<title>OpenClaw Control</title>" ) &&
html.includes("<openclaw-app></openclaw-app>" )
) {
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("<title>OpenClaw Control</title>" )} app=${html.includes("<openclaw-app></openclaw-app>" )}\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}.`);
}
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 (gateway.child.exitCode !== null ) {
return ;
}
gateway.child.kill("SIGTERM" );
const exitedAfterTerm = await waitForChildExit(gateway.child, 2 _000 );
if (!exitedAfterTerm && gateway.child.exitCode === null ) {
gateway.child.kill("SIGKILL" );
await waitForChildExit(gateway.child, 5 _000 );
}
} finally {
await gateway?.closeLog?.();
}
}
async function waitForChildExit(child, timeoutMs) {
if (child.exitCode !== null ) {
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);
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);
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 || "<missing>" }.`,
);
}
if (installed.commit !== build.sourceSha) {
throw new Error(
`Installed build commit mismatch. Expected ${build.sourceSha}, found ${installed.commit || "<missing>" }.`,
);
}
}
function installedPackageRoot(prefixDir) {
return process.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" ;
}
async function runCommand(command, args, options) {
return new Promise((resolvePromise, rejectPromise) => {
const useWindowsShell = process.platform === "win32" && /\.(cmd|bat)$/iu.test(command);
const child = spawn(command, args, {
cwd: options.cwd,
env: options.env,
shell: useWindowsShell,
stdio: ["ignore" , "pipe" , "pipe" ],
windowsHide: true ,
});
const logStream = createWriteStream(options.logPath, { flags: "a" });
let stdout = "" ;
let stderr = "" ;
let timedOut = false ;
let settled = false ;
const clearTimers = () => {
if (timer) {
clearTimeout(timer);
}
if (killWaitTimer) {
clearTimeout(killWaitTimer);
}
};
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 ;
}
}
child.kill(process.platform === "win32" ? undefined : "SIGKILL" );
};
let killWaitTimer = null ;
const timer =
options.timeoutMs && Number.isFinite(options.timeoutMs)
? setTimeout(() => {
timedOut = true ;
logStream.write(
`${new Date().toISOString()} timeout command=${command} args=${args.join(" " )}\n`,
);
requestKill();
killWaitTimer = setTimeout(() => {
finalize(() => {
rejectPromise(
new Error(
`Command timed out and could not be terminated cleanly: ${command} ${args.join(" " )}`,
),
);
});
}, 15 _000 );
}, options.timeoutMs)
: null ;
child.stdout?.on("data" , (chunk) => {
const text = chunk.toString();
stdout += text;
logStream.write(text);
});
child.stderr?.on("data" , (chunk) => {
const text = chunk.toString();
stderr += text;
logStream.write(text);
});
child.on("error" , (error) => {
finalize(() => rejectPromise(error));
});
child.on("close" , (exitCode) => {
finalize(() => {
const result = {
exitCode: exitCode ?? 1 ,
stdout,
stderr,
};
if (timedOut) {
rejectPromise(new Error(`Command timed out: ${command} ${args.join(" " )}`));
return ;
}
if ((options.check ?? true ) && result.exitCode !== 0 ) {
rejectPromise(
new Error(
`Command failed (${result.exitCode}): ${command} ${args.join(" " )}\n${trimForSummary(
`${stdout}\n${stderr}`,
)}`,
),
);
return ;
}
resolvePromise(result);
});
});
});
}
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 fileBytes = readFileSync(params.filePath);
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(fileBytes.length));
response.end(fileBytes);
});
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();
});
}),
};
}
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 );
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);
}
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 lastError ?? new Error("Failed to allocate a gateway port." );
}
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);
}
Messung V0.5 in Prozent C=93 H=96 G=94
¤ Dauer der Verarbeitung: 0.56 Sekunden
(vorverarbeitet am 2026-06-05)
¤
*© Formatika GbR, Deutschland