Files
openclaw/scripts/lib/cross-os-release-checks/lanes.ts
Peter Steinberger 9a2f91a79a fix(release): keep prerelease companions exact in cross-OS checks (#120556)
* fix(release): install exact cross-os companion artifacts

* fix(release): derive companion package type from resolver

* fix(release): produce provider-owned companion registries

* fix(release): preserve companions for npm candidates

* refactor(release): simplify companion registry flow

* test(release): align package acceptance contracts

* fix(release): guard optional registry JSON parsing

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-08-08 21:45:16 +08:00

1168 lines
37 KiB
TypeScript

import { appendFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir, userInfo } from "node:os";
import { join } from "node:path";
import type {
CandidateBuild,
Cleanup,
CommandResult,
GatewayHandle,
LaneBaseParams,
LaneState,
ProviderConfig,
} from "./config.ts";
import {
buildPackagedUpgradeUpdateArgs,
buildRealUpdateEnv,
isRecoverableWindowsPackagedUpgradeSwapCleanupFailure,
isRecoverableWindowsPackagedUpgradeTimeoutError,
normalizeRequestedRef,
resolveDevUpdateVerificationRef,
resolveExpectedDevUpdateRef,
shouldExerciseManagedGatewayLifecycleAfterInstall,
shouldRunMainChannelDevUpdate,
shouldRunPackagedUpgradeStatusProbe,
shouldStopManagedGatewayBeforeManualFallback,
shouldUseManagedGatewayForInstallerRuntime,
shouldUseManagedGatewayService,
updateTimeoutMs,
verifyPackagedUpgradeUpdateResult,
verifyWindowsPackagedUpgradeFallbackInstall,
} from "./config.ts";
import {
binDirForPrefix,
ensureLocalNpmShim,
installPackageSpec,
installTarballPackage,
readInstalledMetadata,
readInstalledMetadataFromCliPath,
readInstalledVersion,
resolveInstalledPrefixDirFromCliPath,
resolvePublishedInstallerUrl,
runBundledPluginPostinstall,
runInstalledBrowserOverrideImportSmoke,
shouldRunWindowsInstalledBrowserOverrideImportSmoke,
verifyInstalledCandidate,
} from "./install.ts";
import {
ensureDevUpdateGitInstall,
ensureManagedGatewayReady,
resolveInstalledGatewayStopArgs,
resolveInstallerTargetVersion,
runInstalledAgentTurn,
runInstalledCli,
runInstalledModelsSet,
runInstallerSmoke,
runOnboardWithInstalledCli,
startManualGatewayFromInstalledCli,
verifyFreshShellCommand,
verifyWindowsDevUpdateToolchain,
waitForInstalledGateway,
waitForInstalledGatewayToStop,
} from "./installed.ts";
import { maybeRunDiscordRoundtrip } from "./network-smokes.ts";
import {
reserveGatewayPortForLane,
runCleanup,
startStaticFileServer,
stopGateway,
} from "./process.ts";
import { logLanePhase, runTimedLanePhase } from "./reporting.ts";
import {
exerciseManagedGatewayLifecycle,
runAgentTurn,
runDashboardSmoke,
runModelsSet,
runOnboard,
runOpenClaw,
startGateway,
waitForGateway,
} from "./runtime.ts";
import { formatError, trimForSummary } from "./shared.ts";
async function installLaneCompanions(
params: LaneBaseParams & {
lane: LaneState;
env: NodeJS.ProcessEnv;
cliPath?: string;
},
) {
if (params.companions.length === 0) {
return;
}
await runTimedLanePhase(params.lane, "install-companions", async () => {
for (const companion of params.companions) {
const logPath = join(
params.logsDir,
`companion-${companion.name.replace(/[^a-z0-9]+/giu, "-")}.log`,
);
const args = ["plugins", "install", `npm-pack:${companion.tarballPath}`, "--force"];
if (params.cliPath) {
await runInstalledCli({
cliPath: params.cliPath,
args,
env: params.env,
cwd: params.lane.homeDir,
logPath,
timeoutMs: 10 * 60 * 1000,
});
continue;
}
await runOpenClaw({
lane: params.lane,
args,
env: params.env,
logPath,
timeoutMs: 10 * 60 * 1000,
});
}
});
}
export async function runFreshLane(params: LaneBaseParams & { build: CandidateBuild }) {
const lane = createLaneState("fresh");
const cleanup: Cleanup[] = [];
const gatewayHolder: { current: GatewayHandle | null } = { current: null };
try {
const env = buildLaneEnv(lane, params.providerConfig, params.providerSecretValue);
await runTimedLanePhase(lane, "install-candidate", async () => {
await installTarballPackage({
lane,
env,
tgzPath: params.build.candidateTgz,
logPath: join(params.logsDir, "fresh-install.log"),
restoreBundledPluginPostinstall: false,
});
});
const installed = readInstalledMetadata(lane.prefixDir);
verifyInstalledCandidate(installed, params.build);
await runTimedLanePhase(lane, "run-bundled-plugin-postinstall", async () => {
await runBundledPluginPostinstall({
lane,
env,
logPath: join(params.logsDir, "fresh-install.log"),
});
});
let browserOverrideImportStatus = "skipped";
if (shouldRunWindowsInstalledBrowserOverrideImportSmoke()) {
browserOverrideImportStatus = await runTimedLanePhase(
lane,
"windows-browser-override-import",
async () =>
runInstalledBrowserOverrideImportSmoke({
lane,
env,
prefixDir: lane.prefixDir,
logPath: join(params.logsDir, "fresh-windows-browser-override-import.log"),
}),
);
}
await installLaneCompanions({ ...params, lane, env });
await runTimedLanePhase(lane, "onboard", async () => {
await runOnboard({
lane,
env,
providerConfig: params.providerConfig,
logPath: join(params.logsDir, "fresh-onboard.log"),
});
});
await runTimedLanePhase(lane, "models-set", async () => {
await runModelsSet({
lane,
env,
providerConfig: params.providerConfig,
logPath: join(params.logsDir, "fresh-models-set.log"),
});
});
const gateway = await runTimedLanePhase(lane, "start-gateway", async () =>
startGateway({
lane,
env,
logPath: join(params.logsDir, "fresh-gateway.log"),
}),
);
gatewayHolder.current = gateway;
cleanup.push(() => stopGateway(gatewayHolder.current));
await runTimedLanePhase(lane, "wait-gateway", async () => {
await waitForGateway({
lane,
env,
gatewayHolder,
gatewayLogPath: join(params.logsDir, "fresh-gateway.log"),
logPath: join(params.logsDir, "fresh-gateway-status.log"),
});
});
await runTimedLanePhase(lane, "dashboard", async () => {
await runDashboardSmoke({
lane,
logPath: join(params.logsDir, "fresh-dashboard.log"),
});
});
const agent = await runTimedLanePhase(lane, "agent-turn", async () =>
runAgentTurn({
lane,
env,
label: "fresh",
logPath: join(params.logsDir, "fresh-agent.log"),
}),
);
return {
status: "pass",
installedVersion: installed.version,
installedCommit: installed.commit,
dashboardStatus: "pass",
gatewayPort: lane.gatewayPort,
browserOverrideImportStatus,
agentOutput: trimForSummary(agent.stdout),
phaseTimings: lane.phaseTimings,
};
} finally {
await runCleanup(cleanup);
}
}
export async function runUpgradeLane(
params: LaneBaseParams & {
baselineSpec: string;
baselineTgz: string;
build: CandidateBuild;
candidateUrl: string;
},
) {
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: Cleanup[] = [];
const gatewayHolder: { current: GatewayHandle | null } = { current: null };
try {
const env = buildLaneEnv(lane, params.providerConfig, params.providerSecretValue);
await runTimedLanePhase(lane, "install-baseline", async () => {
if (!params.baselineTgz && params.baselineSpec) {
await installPackageSpec({
lane,
env,
packageSpec: params.baselineSpec,
logPath: join(params.logsDir, "upgrade-install-baseline.log"),
ignoreScripts: true,
});
} else {
await installTarballPackage({
lane,
env,
tgzPath: params.baselineTgz,
logPath: join(params.logsDir, "upgrade-install-baseline.log"),
ignoreScripts: true,
restoreBundledPluginPostinstall: false,
});
}
});
await runTimedLanePhase(lane, "run-baseline-bundled-plugin-postinstall", async () => {
await runBundledPluginPostinstall({
lane,
env,
logPath: join(params.logsDir, "upgrade-install-baseline.log"),
});
});
const baseline = {
version: readInstalledVersion(lane.prefixDir),
};
const updateEnv = buildRealUpdateEnv(env);
const updateArgs = buildPackagedUpgradeUpdateArgs(params.candidateUrl);
const updateLogPath = join(params.logsDir, "upgrade-update.log");
let updateResult: CommandResult | undefined;
let usedWindowsPackagedUpgradeTimeoutFallback = false;
await runTimedLanePhase(lane, "update", async () => {
try {
updateResult = await runOpenClaw({
lane,
env: updateEnv,
args: updateArgs,
logPath: updateLogPath,
timeoutMs: updateTimeoutMs(),
check: false,
});
} catch (error) {
if (!isRecoverableWindowsPackagedUpgradeTimeoutError(error, process.platform)) {
throw error;
}
usedWindowsPackagedUpgradeTimeoutFallback = true;
appendFileSync(
updateLogPath,
`\n[release-checks] Windows baseline updater timed out after fetching candidate; falling back to direct candidate install: ${formatError(error)}\n`,
);
updateResult = {
exitCode: 124,
stdout: "",
stderr: formatError(error),
};
}
});
if (!updateResult) {
throw new Error("Packaged update completed without a command result.");
}
const usedWindowsPackagedUpgradeFallback =
usedWindowsPackagedUpgradeTimeoutFallback ||
isRecoverableWindowsPackagedUpgradeSwapCleanupFailure(updateResult, process.platform);
if (usedWindowsPackagedUpgradeFallback) {
await runTimedLanePhase(lane, "update-fallback-install", async () => {
await installPackageSpec({
lane,
env,
packageSpec: params.candidateUrl,
logPath: join(params.logsDir, "upgrade-update-fallback-install.log"),
ignoreScripts: true,
});
const fallbackInstalledVersion = readInstalledVersion(lane.prefixDir);
verifyWindowsPackagedUpgradeFallbackInstall({
installedVersion: fallbackInstalledVersion,
candidateVersion: params.build.candidateVersion,
});
appendFileSync(
updateLogPath,
`\n[release-checks] Windows fallback install verified candidate version ${fallbackInstalledVersion}\n`,
);
});
} else {
verifyPackagedUpgradeUpdateResult(updateResult, {
candidateVersion: params.build.candidateVersion,
});
}
if (
shouldRunPackagedUpgradeStatusProbe({
platform: process.platform,
usedWindowsPackagedUpgradeFallback,
})
) {
await runTimedLanePhase(lane, "update-status", async () => {
await runOpenClaw({
lane,
env: updateEnv,
args: ["update", "status", "--json"],
logPath: join(params.logsDir, "upgrade-update-status.log"),
timeoutMs: 2 * 60 * 1000,
});
});
}
await runTimedLanePhase(lane, "run-bundled-plugin-postinstall", async () => {
await runBundledPluginPostinstall({
lane,
env,
logPath: join(params.logsDir, "upgrade-bundled-plugin-postinstall.log"),
});
});
const installed = readInstalledMetadata(lane.prefixDir);
verifyInstalledCandidate(installed, params.build);
await installLaneCompanions({ ...params, lane, env });
await runTimedLanePhase(lane, "onboard", async () => {
await runOnboard({
lane,
env,
providerConfig: params.providerConfig,
logPath: join(params.logsDir, "upgrade-onboard.log"),
});
});
await runTimedLanePhase(lane, "models-set", async () => {
await runModelsSet({
lane,
env,
providerConfig: params.providerConfig,
logPath: join(params.logsDir, "upgrade-models-set.log"),
});
});
const gateway = await runTimedLanePhase(lane, "start-gateway", async () =>
startGateway({
lane,
env,
logPath: join(params.logsDir, "upgrade-gateway.log"),
}),
);
gatewayHolder.current = gateway;
cleanup.push(() => stopGateway(gatewayHolder.current));
await runTimedLanePhase(lane, "wait-gateway", async () => {
await waitForGateway({
lane,
env,
gatewayHolder,
gatewayLogPath: join(params.logsDir, "upgrade-gateway.log"),
logPath: join(params.logsDir, "upgrade-gateway-status.log"),
});
});
await runTimedLanePhase(lane, "dashboard", async () => {
await runDashboardSmoke({
lane,
logPath: join(params.logsDir, "upgrade-dashboard.log"),
});
});
const agent = await runTimedLanePhase(lane, "agent-turn", async () =>
runAgentTurn({
lane,
env,
label: "upgrade",
logPath: join(params.logsDir, "upgrade-agent.log"),
}),
);
return {
status: "pass",
baselineVersion: baseline.version,
installedVersion: installed.version,
installedCommit: installed.commit,
dashboardStatus: "pass",
gatewayPort: lane.gatewayPort,
agentOutput: trimForSummary(agent.stdout),
phaseTimings: lane.phaseTimings,
};
} finally {
await runCleanup(cleanup);
}
}
export async function runInstallerFreshSuite(
params: LaneBaseParams & { build: CandidateBuild; runDiscordRoundtrip: boolean },
) {
const lane = createLaneState("installer-fresh");
const cleanup: Cleanup[] = [];
const usesManagedGateway = shouldUseManagedGatewayService();
const useManagedGatewayAfterInstall = shouldUseManagedGatewayForInstallerRuntime();
const manualGateway: { current: GatewayHandle | null } = { current: null };
const managedHostLease: { current: ManagedGatewayInstallerHostLease | null } = { current: null };
let managedHostOwned = false;
let managedHostEnv: NodeJS.ProcessEnv | null = null;
let managedHostCliPath = "";
const run = async () => {
const installerEnv = 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: installerEnv,
installerUrl,
installTarget,
logPath: join(params.logsDir, "installer-fresh-install.log"),
});
logLanePhase(lane, "fresh-shell");
const freshShell = await verifyFreshShellCommand({
lane,
env: installerEnv,
expectedNeedle: params.build.candidateVersion,
logPath: join(params.logsDir, "installer-fresh-shell.log"),
});
const installed = readInstalledMetadataFromCliPath(freshShell.cliPath);
verifyInstalledCandidate(installed, params.build);
let browserOverrideImportStatus = "skipped";
if (shouldRunWindowsInstalledBrowserOverrideImportSmoke()) {
logLanePhase(lane, "windows-browser-override-import");
browserOverrideImportStatus = await runInstalledBrowserOverrideImportSmoke({
lane,
env: installerEnv,
prefixDir: resolveInstalledPrefixDirFromCliPath(freshShell.cliPath),
logPath: join(params.logsDir, "installer-fresh-windows-browser-override-import.log"),
});
}
// Host services must use the runner account's real default identity. Keep the
// public installer isolated, then switch only the managed-service lifecycle.
const env = resolveManagedGatewayInstallerEnv({
env: installerEnv,
enabled: usesManagedGateway,
});
if (usesManagedGateway) {
const accountHome = env.HOME;
if (!accountHome) {
throw new Error("Managed installer service checks require the host account home.");
}
managedHostLease.current = acquireManagedGatewayInstallerHostLease(accountHome);
assertManagedGatewayInstallerHostAvailable({
accountHome,
serviceInstalled: false,
});
const serviceStatus = await runInstalledCli({
cliPath: freshShell.cliPath,
args: ["gateway", "status", "--json", "--no-probe"],
env,
cwd: lane.homeDir,
logPath: join(params.logsDir, "installer-fresh-gateway-preflight.log"),
timeoutMs: 2 * 60 * 1000,
check: false,
});
assertManagedGatewayInstallerHostAvailable({
accountHome,
serviceInstalled: parseManagedGatewayServiceInstalled(serviceStatus),
pathExists: () => false,
});
managedHostOwned = true;
managedHostEnv = env;
managedHostCliPath = freshShell.cliPath;
}
await installLaneCompanions({ ...params, lane, env, cliPath: freshShell.cliPath });
// Hold the configured port through onboarding and model setup so another runner process
// cannot claim it before the manual gateway starts. Release immediately before spawn.
const gatewayPortReservation = usesManagedGateway
? null
: await reserveGatewayPortForLane(lane);
if (gatewayPortReservation) {
cleanup.push(() => gatewayPortReservation.release());
}
logLanePhase(lane, "onboard");
await runOnboardWithInstalledCli({
lane,
cliPath: freshShell.cliPath,
env,
providerConfig: params.providerConfig,
installDaemon: usesManagedGateway,
logPath: join(params.logsDir, "installer-fresh-onboard.log"),
allocateGatewayPort: gatewayPortReservation === null,
});
if (shouldExerciseManagedGatewayLifecycleAfterInstall()) {
await exerciseManagedGatewayLifecycle({
lane,
cliPath: freshShell.cliPath,
env,
logPrefix: join(params.logsDir, "installer-fresh-gateway"),
});
}
logLanePhase(lane, "models-set");
await runInstalledModelsSet({
cliPath: freshShell.cliPath,
env,
providerConfig: params.providerConfig,
cwd: lane.homeDir,
logPath: join(params.logsDir, "installer-fresh-models-set.log"),
});
if (!useManagedGatewayAfterInstall) {
// Keep the Windows installer lane validating Scheduled Task registration during
// onboarding and lifecycle commands, but use a manual gateway for the runtime
// checks after that so the installer validation does not depend on the more
// failure-prone managed Windows session state for the remainder of the lane.
if (shouldStopManagedGatewayBeforeManualFallback()) {
logLanePhase(lane, "gateway-stop-managed");
await runInstalledCli({
cliPath: freshShell.cliPath,
args: await resolveInstalledGatewayStopArgs({
cliPath: freshShell.cliPath,
cwd: lane.homeDir,
env,
logPath: join(params.logsDir, "installer-fresh-gateway-stop-managed-help.log"),
}),
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"),
});
}
await gatewayPortReservation?.release();
logLanePhase(lane, "gateway-start");
const gateway = await startManualGatewayFromInstalledCli({
lane,
cliPath: freshShell.cliPath,
env,
logPath: join(params.logsDir, "installer-fresh-gateway.log"),
});
manualGateway.current = gateway;
if (!usesManagedGateway) {
cleanup.push(() => stopGateway(manualGateway.current));
}
logLanePhase(lane, "gateway-status");
await waitForInstalledGateway({
lane,
cliPath: freshShell.cliPath,
env,
gatewayHolder: manualGateway,
gatewayLogPath: join(params.logsDir, "installer-fresh-gateway.log"),
logPath: join(params.logsDir, "installer-fresh-gateway-status.log"),
});
}
logLanePhase(lane, "dashboard");
await runDashboardSmoke({
lane,
logPath: join(params.logsDir, "installer-fresh-dashboard.log"),
});
logLanePhase(lane, "agent-turn");
const agent = await runInstalledAgentTurn({
cliPath: freshShell.cliPath,
env,
cwd: lane.homeDir,
label: "installer-fresh",
logPath: join(params.logsDir, "installer-fresh-agent.log"),
});
let discordStatus = "skipped";
if (params.runDiscordRoundtrip && process.platform === "darwin") {
logLanePhase(lane, "discord-roundtrip");
discordStatus = await maybeRunDiscordRoundtrip({
lane,
cliPath: freshShell.cliPath,
env,
gatewayHolder: manualGateway,
logPath: join(params.logsDir, "installer-fresh-discord.log"),
});
}
return {
status: "pass",
installTarget,
installVersion: installed.version,
cliPath: freshShell.cliPath,
installedVersion: installed.version,
installedCommit: installed.commit,
gatewayPort: lane.gatewayPort,
dashboardStatus: "pass",
browserOverrideImportStatus,
discordStatus,
agentOutput: trimForSummary(agent.stdout),
};
};
let result: Awaited<ReturnType<typeof run>> | undefined;
let runError: Error | undefined;
try {
result = await run();
} catch (error) {
runError = error instanceof Error ? error : new Error(formatError(error));
}
let managedCleanupError: Error | undefined;
const acquiredManagedHostLease = managedHostLease.current;
if (acquiredManagedHostLease) {
let hostCleanupError: Error | undefined;
try {
if (managedHostOwned && managedHostEnv && managedHostCliPath) {
await cleanupManagedGatewayInstallerHost({
accountHome: acquiredManagedHostLease.accountHome,
cliPath: managedHostCliPath,
env: managedHostEnv,
lane,
logsDir: params.logsDir,
manualGateway: manualGateway.current,
});
}
} catch (error) {
hostCleanupError = error instanceof Error ? error : new Error(formatError(error));
}
let leaseReleaseError: Error | undefined;
try {
acquiredManagedHostLease.release();
} catch (error) {
leaseReleaseError = error instanceof Error ? error : new Error(formatError(error));
}
if (hostCleanupError && leaseReleaseError) {
managedCleanupError = new AggregateError(
[hostCleanupError, leaseReleaseError],
"Managed-service cleanup and host-lease release both failed.",
{ cause: leaseReleaseError },
);
} else {
managedCleanupError = hostCleanupError ?? leaseReleaseError;
}
}
await runCleanup(cleanup);
if (managedCleanupError && runError) {
throw new AggregateError(
[runError, managedCleanupError],
"Installer release check and managed-service cleanup both failed.",
{ cause: managedCleanupError },
);
}
if (managedCleanupError) {
throw managedCleanupError;
}
if (runError) {
throw runError;
}
if (!result) {
throw new Error("Installer release check completed without a result.");
}
return result;
}
export async function runDevUpdateSuite(
params: LaneBaseParams & {
baselineSpec: string;
ref: string;
sourceSha: string;
runDiscordRoundtrip: boolean;
},
) {
const lane = createLaneState("dev-update");
const cleanup: 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: GatewayHandle | null } = { 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"),
});
}
await installLaneCompanions({ ...params, lane, env, cliPath: verifiedShell.cliPath });
logLanePhase(lane, "onboard");
await runOnboardWithInstalledCli({
lane,
cliPath: verifiedShell.cliPath,
env,
providerConfig: params.providerConfig,
installDaemon: useManagedGatewayAfterDevUpdate,
logPath: join(params.logsDir, "dev-update-onboard.log"),
});
logLanePhase(lane, "models-set");
await runInstalledModelsSet({
cliPath: verifiedShell.cliPath,
env,
providerConfig: params.providerConfig,
cwd: lane.homeDir,
logPath: join(params.logsDir, "dev-update-models-set.log"),
});
if (!useManagedGatewayAfterDevUpdate) {
logLanePhase(lane, "gateway-start");
const gateway = await startManualGatewayFromInstalledCli({
lane,
cliPath: verifiedShell.cliPath,
env,
logPath: join(params.logsDir, "dev-update-gateway.log"),
});
manualGateway.current = gateway;
cleanup.push(() => stopGateway(manualGateway.current));
logLanePhase(lane, "gateway-status");
await waitForInstalledGateway({
lane,
cliPath: verifiedShell.cliPath,
env,
gatewayHolder: manualGateway,
gatewayLogPath: join(params.logsDir, "dev-update-gateway.log"),
logPath: join(params.logsDir, "dev-update-gateway-status.log"),
});
} else {
logLanePhase(lane, "gateway-ready");
await ensureManagedGatewayReady({
lane,
cliPath: verifiedShell.cliPath,
env,
logPath: join(params.logsDir, "dev-update-gateway-ready.log"),
});
}
logLanePhase(lane, "dashboard");
await runDashboardSmoke({
lane,
logPath: join(params.logsDir, "dev-update-dashboard.log"),
});
logLanePhase(lane, "agent-turn");
const agent = await runInstalledAgentTurn({
cliPath: verifiedShell.cliPath,
env,
cwd: lane.homeDir,
label: "dev-update",
logPath: join(params.logsDir, "dev-update-agent.log"),
});
let discordStatus = "skipped";
if (params.runDiscordRoundtrip && process.platform === "darwin") {
logLanePhase(lane, "discord-roundtrip");
discordStatus = await maybeRunDiscordRoundtrip({
lane,
cliPath: verifiedShell.cliPath,
env,
gatewayHolder: manualGateway,
logPath: join(params.logsDir, "dev-update-discord.log"),
});
}
return {
status: "pass",
installVersion: installTarget,
cliPath: updatedShell.cliPath,
gatewayPort: lane.gatewayPort,
dashboardStatus: "pass",
discordStatus,
agentOutput: trimForSummary(agent.stdout),
};
} finally {
await runCleanup(cleanup);
}
}
function createLaneState(name: string): LaneState {
const rootDir = mkdtempSync(join(tmpdir(), `openclaw-${name}-`));
const prefixDir = join(rootDir, "prefix");
const homeDir = join(rootDir, "home");
const stateDir = join(homeDir, ".openclaw");
const appDataDir = process.platform === "win32" ? join(homeDir, "AppData", "Roaming") : stateDir;
mkdirSync(prefixDir, { recursive: true });
mkdirSync(homeDir, { recursive: true });
mkdirSync(stateDir, { recursive: true });
mkdirSync(appDataDir, { recursive: true });
if (process.platform !== "win32") {
writeFileSync(join(homeDir, ".bashrc"), "", "utf8");
writeFileSync(join(homeDir, ".zshrc"), "", "utf8");
}
return {
name,
rootDir,
prefixDir,
homeDir,
stateDir,
appDataDir,
gatewayPort: 0,
phaseTimings: [],
};
}
function buildLaneEnv(
lane: LaneState,
providerMeta: ProviderConfig,
providerSecretValue: string,
): NodeJS.ProcessEnv {
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: LaneState,
providerMeta: ProviderConfig,
providerSecretValue: string,
): NodeJS.ProcessEnv {
const localAppData = join(lane.homeDir, "AppData", "Local");
mkdirSync(localAppData, { recursive: true });
return {
...process.env,
HOME: lane.homeDir,
USERPROFILE: lane.homeDir,
APPDATA: lane.appDataDir,
LOCALAPPDATA: localAppData,
OPENCLAW_HOME: lane.homeDir,
OPENCLAW_STATE_DIR: lane.stateDir,
OPENCLAW_CONFIG_PATH: join(lane.stateDir, "openclaw.json"),
OPENCLAW_DISABLE_BONJOUR: "1",
OPENCLAW_NO_ONBOARD: "1",
OPENCLAW_NO_PROMPT: "1",
CI: "1",
NODE_OPTIONS: "--max-old-space-size=8192",
[providerMeta.secretEnv]: providerSecretValue,
};
}
export function resolveManagedGatewayInstallerEnv(params: {
env: NodeJS.ProcessEnv;
enabled: boolean;
accountHome?: string;
hostEnv?: NodeJS.ProcessEnv;
}): NodeJS.ProcessEnv {
if (!params.enabled) {
return params.env;
}
const accountHome = params.accountHome ?? userInfo().homedir;
const hostEnv = params.hostEnv ?? process.env;
const env: NodeJS.ProcessEnv = {
...params.env,
HOME: accountHome,
USERPROFILE: accountHome,
APPDATA: hostEnv.APPDATA,
LOCALAPPDATA: hostEnv.LOCALAPPDATA,
};
const isolatedIdentityKeys = new Set(
[
"OPENCLAW_HOME",
"OPENCLAW_PROFILE",
"OPENCLAW_STATE_DIR",
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_WINDOWS_TASK_NAME",
"OPENCLAW_TASK_SCRIPT_NAME",
"OPENCLAW_TASK_SCRIPT",
"OPENCLAW_SERVICE_KIND",
].map((key) => key.toUpperCase()),
);
// Windows environment keys are case-insensitive. Remove every casing variant
// so the installed CLI cannot inherit the isolated lane identity.
for (const key of Object.keys(env)) {
if (isolatedIdentityKeys.has(key.toUpperCase())) {
delete env[key];
}
}
return env;
}
export function parseManagedGatewayServiceInstalled(result: CommandResult): boolean {
if (result.exitCode !== 0) {
throw new Error(`Managed gateway preflight failed with exit code ${result.exitCode}.`);
}
let parsed: unknown;
try {
parsed = JSON.parse(result.stdout);
} catch {
throw new Error("Managed gateway preflight did not return JSON status.");
}
// The managed installer lane is Windows-only. Its status `loaded` field is backed by
// isScheduledTaskInstalled, which covers both the registered task and Startup fallback.
const installed =
parsed && typeof parsed === "object" && "service" in parsed
? (parsed.service as { loaded?: unknown }).loaded
: undefined;
if (typeof installed !== "boolean") {
throw new Error("Managed gateway preflight omitted service.loaded.");
}
return installed;
}
export function assertManagedGatewayInstallerHostAvailable(params: {
accountHome: string;
serviceInstalled: boolean;
pathExists?: (path: string) => boolean;
}): void {
const pathExists = params.pathExists ?? existsSync;
const occupiedStateDirs = [".openclaw", ".clawdbot"]
.map((name) => join(params.accountHome, name))
.filter((path) => pathExists(path));
if (params.serviceInstalled || occupiedStateDirs.length > 0) {
throw new Error(
"Managed installer service checks require a pristine host account with no OpenClaw service or state.",
);
}
}
type ManagedGatewayInstallerHostLease = {
accountHome: string;
release: () => void;
};
export function acquireManagedGatewayInstallerHostLease(
accountHome: string,
): ManagedGatewayInstallerHostLease {
const lockDir = join(accountHome, ".openclaw-release-check.lock");
try {
mkdirSync(lockDir);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") {
throw new Error(
"Managed installer service checks require exclusive access to the host account; another check or stale lease is present.",
{ cause: error },
);
}
throw error;
}
try {
writeFileSync(
join(lockDir, "owner.json"),
`${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`,
"utf8",
);
} catch (error) {
rmSync(lockDir, { recursive: true, force: true });
throw error;
}
let released = false;
return {
accountHome,
release: () => {
if (released) {
return;
}
rmSync(lockDir, { recursive: true });
released = true;
},
};
}
async function cleanupManagedGatewayInstallerHost(params: {
accountHome: string;
cliPath: string;
env: NodeJS.ProcessEnv;
lane: LaneState;
logsDir: string;
manualGateway: GatewayHandle | null;
}): Promise<void> {
const cleanupErrors: Error[] = [];
try {
await stopGateway(params.manualGateway);
} catch (error) {
cleanupErrors.push(error instanceof Error ? error : new Error(formatError(error)));
}
let serviceRemoved = false;
try {
const uninstallResult = await runInstalledCli({
cliPath: params.cliPath,
args: ["gateway", "uninstall"],
env: params.env,
cwd: params.lane.homeDir,
logPath: join(params.logsDir, "installer-fresh-gateway-uninstall.log"),
timeoutMs: 2 * 60 * 1000,
check: false,
});
if (uninstallResult.exitCode === 0) {
serviceRemoved = true;
} else {
const statusResult = await runInstalledCli({
cliPath: params.cliPath,
args: ["gateway", "status", "--json", "--no-probe"],
env: params.env,
cwd: params.lane.homeDir,
logPath: join(params.logsDir, "installer-fresh-gateway-cleanup-status.log"),
timeoutMs: 2 * 60 * 1000,
check: false,
});
if (parseManagedGatewayServiceInstalled(statusResult)) {
throw new Error(
`Managed gateway uninstall failed with exit code ${uninstallResult.exitCode}; the service remains installed.`,
);
}
serviceRemoved = true;
}
} catch (error) {
cleanupErrors.push(error instanceof Error ? error : new Error(formatError(error)));
}
if (serviceRemoved) {
try {
rmSync(join(params.accountHome, ".openclaw"), { recursive: true, force: true });
rmSync(join(params.accountHome, ".clawdbot"), { recursive: true, force: true });
} catch (error) {
cleanupErrors.push(error instanceof Error ? error : new Error(formatError(error)));
}
}
const firstCleanupError = cleanupErrors[0];
if (cleanupErrors.length === 1 && firstCleanupError) {
throw firstCleanupError;
}
if (cleanupErrors.length > 1) {
throw new AggregateError(cleanupErrors, "Managed-service cleanup failed.", {
cause: cleanupErrors.at(-1),
});
}
}