openclaw/scripts/lib/docker-e2e-plan.mts
Peter Steinberger ac59199ad3 fix(e2e): stage candidate providers for legacy-operator survivor lanes
Legacy published baselines (2026.7.35, 2026.6.34) author plugins.allow from
their bundled provider inventory. The candidate's doctor restores those
now-external providers at the exact candidate version, which does not exist
on npm or ClawHub before publication, so the prepublish registry must carry
them. Stage every official provider from the selected candidate's external
provider catalog for legacy-operator-state lanes, loaded once per plan.
2026-09-28 15:37:59 -07:00

1114 lines
45 KiB
TypeScript

// Docker E2E scheduler planning helpers.
// This module turns the scenario catalog plus env-driven inputs into a concrete
// lane plan. It intentionally does not define scenario commands.
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { resolveUpgradeSurvivorConfigStepsForBaseline } from "../e2e/lib/upgrade-survivor/config-recipe.mts";
import {
BUNDLED_PLUGIN_INSTALL_UNINSTALL_SHARDS,
allReleasePathLanes,
fleetCacheLane,
mainLanes,
normalizeReleaseProfile,
publicInstallerLanes,
releasePathChunkLanes,
tailLanes,
type DockerE2eImageKind,
type DockerE2eLane,
type DockerE2eReleaseProfile,
type DockerE2eReleaseProfileInput,
} from "./docker-e2e-scenarios.mts";
import officialExternalChannelCatalog from "./official-external-channel-catalog.json" with { type: "json" };
import officialExternalProviderCatalog from "./official-external-provider-catalog.json" with { type: "json" };
import { isRecord } from "./record-shared.mjs";
import {
UPDATE_FIRST_HOP_COMPAT_LANE,
isUpdateFirstHopCompatLane,
listRecordedFirstHopSourceVersions,
updateFirstHopCompatLaneName,
} from "./update-first-hop-lanes.mjs";
import {
assertSupportedUpgradeSurvivorBaselineSpec,
isTrustedHarnessOwnedUpgradeSurvivorScenario,
parseUpgradeSurvivorBaselineSpecs,
parseUpgradeSurvivorScenarios,
supportsUpgradeSurvivorScenarioAtBaseline,
} from "./upgrade-survivor-policy.mjs";
export { normalizeReleaseProfile };
export const DEFAULT_E2E_BARE_IMAGE = "openclaw-docker-e2e-bare:local";
export const DEFAULT_E2E_FUNCTIONAL_IMAGE = "openclaw-docker-e2e-functional:local";
export const DEFAULT_PARALLELISM = 10;
export const DEFAULT_PROFILE = "all";
export const DEFAULT_RESOURCE_LIMITS = {
docker: DEFAULT_PARALLELISM,
live: 9,
"live:claude": 4,
"live:codex": 4,
"live:droid": 4,
"live:gemini": 4,
"live:opencode": 4,
"live:openai": 1,
"live:telegram": 1,
npm: 5,
service: 7,
};
export const DEFAULT_TAIL_PARALLELISM = 10;
export const RELEASE_PATH_PROFILE = "release-path";
type LiveMode = "all" | "only" | "skip";
type DockerProfile = typeof DEFAULT_PROFILE | typeof RELEASE_PATH_PROFILE;
type UpgradeSurvivorExpansion = { lanes: DockerE2eLane[]; omittedLaneNames: string[] };
type InertTargetContract = {
mode: "inert";
source: { readText: (relativePath: string) => string | null };
};
// Inert postbuild declarations: shipped 9.1/9.2 literal outputs and the mapped
// catalog that followed. Selection must not execute a frozen target's build code.
const UPDATE_FIRST_HOP_COMPAT_CATALOGS = new Set([
"3a07518cac2a3f92c0ecb73e177ced4ae3350872be59c8c9a1871c2f0e3c0773",
"edf5302a5bb101f2a2efaf9735cd0ae90081bd1b693e0c77a1f8e56ada865096",
// Node-runner aliases for newer releases moved to the recorded package inventory.
"0a12e16a5b6a2d723472cff04a05b356751da92a54c7b9a19cbb539c94190bb6",
"2cb55271610aae5578175cf1587574231e9a72f9420a544d73370a8d3b8531ec",
// Current outputs retire pre-June memory teardown stubs.
"dfa812490cac8f09a274d698eb54c7c4a4b8474f3e264fbd38008af70b013cb3",
]);
const IOS_WATCH_RELAY_COMMANDS = ['"watch.status"', '"watch.notify"'];
type DockerE2ePlanOptions = {
allowFrozenTargetScenarioOmissions?: boolean;
frozenTarget?: InertTargetContract;
includeOpenWebUI: boolean;
liveMode: LiveMode;
orderLanes: (lanes: DockerE2eLane[], timingStore?: unknown) => DockerE2eLane[];
planReleaseAll: boolean;
profile: string;
releaseChunk: string;
releaseProfile?: DockerE2eReleaseProfileInput;
selectedLaneNames: string[];
timingStore?: unknown;
upgradeSurvivorBaselines?: string;
upgradeSurvivorScenarios?: string;
upgradeSurvivorTargetRoot?: string;
};
export function parseLaneSelection(raw: string | undefined): string[] {
if (!raw) {
return [];
}
const laneAliases = new Map([
["install-e2e", ["install-e2e-openai", "install-e2e-anthropic"]],
[
UPDATE_FIRST_HOP_COMPAT_LANE,
listRecordedFirstHopSourceVersions().map(updateFirstHopCompatLaneName),
],
[
"bundled-plugin-install-uninstall",
Array.from(
{ length: BUNDLED_PLUGIN_INSTALL_UNINSTALL_SHARDS },
(_, index) => `bundled-plugin-install-uninstall-${index}`,
),
],
]);
return [
...new Set(
raw
.split(/[,\s]+/u)
.map((token) => token.trim())
.filter(Boolean)
.flatMap((token) => laneAliases.get(token) ?? [token]),
),
];
}
function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function sanitizeLaneNameSuffix(value: string): string {
return (
value
.replace(/^openclaw@/u, "")
.replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "") || "baseline"
);
}
// Upgrade recipes select an OpenAI model whose runtime is supplied by the
// version-matched Codex companion after the candidate replaces the baseline.
const UPGRADE_SURVIVOR_RUNTIME_COMPANION_PACKAGES = ["@openclaw/codex"];
// Pre-protocol catalogs are content-addressed. Unknown legacy blocks fail
// closed; current catalogs declare capabilities in JSON without executing target code.
const LEGACY_UPGRADE_SURVIVOR_SCENARIO_CATALOGS = new Map([
[
"f2549a057028829ff5286db89d357e5b3d4ec1f5cdb3ca07672b7e34a739b60a",
"base msteams-polls abandoned-update legacy-operator-state workshop-doctor-recovery mobile-pairing-reconnect acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs missing-configured-plugin-migration custom-plugin-siblings projects-doctor taskflow-restoration stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node",
],
[
"b0166f96bf3839d53ce94721b563fcf2b6604ddef04028cd8d9c7769275566c7",
"base msteams-polls abandoned-update legacy-operator-state mobile-pairing-reconnect acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs missing-configured-plugin-migration custom-plugin-siblings projects-doctor taskflow-restoration stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node",
],
[
"7d9d7520c2c34d51fff78e542a7f539b77080bfd04648438e95aec4af3fe362e",
"base msteams-polls abandoned-update legacy-operator-state workshop-doctor-recovery mobile-pairing-reconnect acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs missing-configured-plugin-migration custom-plugin-siblings stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node",
],
[
"5e8821538f3722fdf0dc3b3917ae639853f305e4c7a9b84febb8905601a9b5c7",
"base msteams-polls abandoned-update legacy-operator-state mobile-pairing-reconnect acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs missing-configured-plugin-migration custom-plugin-siblings stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node",
],
[
"a4246e4fc65d037c173b38976f115faea015e476211f845bf328937805d81193",
"base msteams-polls abandoned-update legacy-operator-state mobile-pairing-reconnect acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs custom-plugin-siblings stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node",
],
[
"733fc9c5b6895a9497b759534ad507cf67894d04c57f7b2439350d2000612d55",
"base msteams-polls abandoned-update legacy-operator-state mobile-pairing-reconnect acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node",
],
[
"6b80d370ff2cad1c122700264ddecdf392fb9957112a3b107dc5c9c9731b6646",
"base abandoned-update legacy-operator-state mobile-pairing-reconnect acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node",
],
[
"9c3b79d2fc1317a9b8033f59cb6ae350aebf8bd6ec9575d9704ed8d4b34b210d",
"base legacy-operator-state mobile-pairing-reconnect acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node",
],
[
"28758bbf9d4069d9718fb3325c59ad66a3bc6880248c104aa71b0d7769c54ba3",
"base mobile-pairing-reconnect acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node",
],
[
"dd12482a81dc5cc82dbb23f1cf3128321ec97a2176673c34adecbeed18d00484",
"base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5 watchos-direct-node",
],
[
"bb984a8abf8e4f5a5caaa0c6e10fc36efb6a05ca9f44fb960e4a6583cd52695d",
"base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume recovery-cleanup auth-profile-v2026-7-2-beta-5",
],
[
"f886fb3ca6232eb97cdfe93a9ab7fc8e8cd4a39658c5518bfb736a2242d0c949",
"base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow prerelease-plugin-registry tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume auth-profile-v2026-7-2-beta-5",
],
[
"0c5d3ce3533c035033890923aae7e210f4fdb24e7b8af32371930cdf12a00fd5",
"base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority sqlite-volume auth-profile-v2026-7-2-beta-5",
],
[
"837ab1c89821d52519f385e0f3d2067e0b923f730e3a4791e67f578bf5d29f8e",
"base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority auth-profile-v2026-7-2-beta-5",
],
[
"10ea475027d8b320d6a704cd6e4dd0f7e984c57a93b327048db229a7e0132c8a",
"base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps cron-scheduled-authority",
],
[
"213e004a28814fe0f7bb33018ae59a709c2e6d6e13b273df82a0e7935fbaf5af",
"base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path meeting-transcripts-sqlite versioned-runtime-deps",
],
[
"755557a6ea609e5b9d9fe9d61beb7e75651641e26a3f0ef2fe1fc3a973b398c8",
"base feishu-channel bootstrap-persona channel-post-core-restore plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path versioned-runtime-deps",
],
[
"2657b5cc7b94cf46b9900f9259ee4cef0033837ee71a673e6ed09e7ee36684e8",
"base feishu-channel bootstrap-persona tilde-log-path versioned-runtime-deps",
],
[
"0fefb170b4131932c7403f7f0dcdd1371e300cdcc1c033b9bd2bd6513e08d5e4",
"base feishu-channel bootstrap-persona plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path versioned-runtime-deps",
],
[
"5a2c3a6e3d2a7166025333d4f1a77de0576847f9da7e902055160d0e5f20d4c5",
"base feishu-channel bootstrap-persona plugin-deps-cleanup configured-plugin-installs tilde-log-path versioned-runtime-deps",
],
[
"f226c05636dfb4e759558b5127d1c684d28a609292f4e110ff656c0e4f95ad06",
"base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path versioned-runtime-deps",
],
[
"d9c9edcb27aca88a0b11c72e85592001cba732cf0f96bb8a73c1c0243c8f3678",
"base acpx-openclaw-tools-bridge feishu-channel bootstrap-persona channel-post-core-restore codex-allowlist-survival plugin-deps-cleanup configured-plugin-installs stale-source-plugin-shadow tilde-log-path versioned-runtime-deps",
],
[
"8ac0113158bfe1ebde77272fb1ffb740c281378a170fbc1e9e281d4e44677f02",
"base feishu-channel bootstrap-persona plugin-deps-cleanup tilde-log-path versioned-runtime-deps",
],
]);
function readLegacyFrozenScenarioContract(source: string): string[] | undefined {
const startMarker = "const SCENARIOS = new Set([";
const start = source.indexOf(startMarker);
if (start < 0 || source.lastIndexOf(startMarker) !== start) {
return undefined;
}
const end = source.indexOf("\n]);", start + startMarker.length);
if (end < 0) {
return undefined;
}
const block = source.slice(start, end + 4);
const digest = createHash("sha256").update(block).digest("hex");
return LEGACY_UPGRADE_SURVIVOR_SCENARIO_CATALOGS.get(digest)?.split(" ");
}
function readInertFrozenScenarioContract(
source: string,
targetRoot: string | undefined,
frozenTarget?: InertTargetContract,
): string[] | undefined {
const text = readTargetMetadata(
targetRoot,
"scripts/lib/upgrade-survivor-scenarios.json",
frozenTarget,
);
if (text === null) {
return readLegacyFrozenScenarioContract(source);
}
// Read declared capabilities as data; never evaluate the selected tree's modules.
let catalog: unknown;
try {
catalog = JSON.parse(text);
} catch {
return undefined;
}
if (
!catalog ||
typeof catalog !== "object" ||
Array.isArray(catalog) ||
Object.keys(catalog).length !== 2 ||
!("scenarios" in catalog) ||
!("assertionOnlyScenarios" in catalog) ||
!Array.isArray(catalog.scenarios) ||
catalog.scenarios.length === 0 ||
!Array.isArray(catalog.assertionOnlyScenarios)
) {
return undefined;
}
const scenarios: unknown[] = [...catalog.scenarios, ...catalog.assertionOnlyScenarios];
if (
!scenarios.every(
(scenario): scenario is string =>
typeof scenario === "string" && /^[a-z0-9][a-z0-9-]*$/u.test(scenario),
) ||
new Set(scenarios).size !== scenarios.length
) {
return undefined;
}
return scenarios;
}
function readFrozenScenarioContract(
assertionsFile: string,
targetRoot: string,
allowExecutableContract: boolean,
): string[] {
if (!allowExecutableContract) {
const inertScenarios = readInertFrozenScenarioContract(
readFileSync(assertionsFile, "utf8"),
targetRoot,
);
if (inertScenarios) {
return inertScenarios;
}
throw new Error(
`cannot read frozen upgrade-survivor scenarios from ${assertionsFile}: unrecognized scenario contract; require trusted workflow opt-in before executing target code`,
);
}
// Canonical frozen refs may expose a dependency-free catalog command. Run it
// only after the release workflow explicitly establishes the trust boundary.
const nodeExecPath = process.versions.bun ? "node" : process.execPath;
const result = spawnSync(nodeExecPath, [assertionsFile, "list-scenarios"], {
cwd: targetRoot,
encoding: "utf8",
});
if (result.status !== 0) {
const errorLines = result.stderr.trim().split("\n");
if (result.stderr.includes("unknown upgrade-survivor assertion command: list-scenarios")) {
const legacyScenarios = readInertFrozenScenarioContract(
readFileSync(assertionsFile, "utf8"),
targetRoot,
);
if (legacyScenarios) {
return legacyScenarios;
}
}
const detail =
errorLines.find((line) => line.startsWith("Error:")) ||
errorLines.at(-1) ||
"scenario command failed";
throw new Error(
`cannot read frozen upgrade-survivor scenarios from ${assertionsFile}: ${detail}`,
);
}
let parsed: unknown;
try {
parsed = JSON.parse(result.stdout);
} catch {
throw new Error(
`cannot read frozen upgrade-survivor scenarios from ${assertionsFile}: list-scenarios did not return JSON`,
);
}
if (
!Array.isArray(parsed) ||
parsed.length === 0 ||
parsed.some(
(scenario) => typeof scenario !== "string" || !/^[a-z0-9][a-z0-9-]*$/u.test(scenario),
) ||
new Set(parsed).size !== parsed.length
) {
throw new Error(
`cannot read frozen upgrade-survivor scenarios from ${assertionsFile}: list-scenarios returned an invalid catalog`,
);
}
return parsed;
}
function filterUpgradeSurvivorScenariosForTarget(
scenarios: string[],
targetRoot: string | undefined,
allowExecutableContract: boolean,
frozenTarget?: InertTargetContract,
): string[] {
if (!targetRoot && !frozenTarget) {
return scenarios;
}
const targetOwnedScenarios = scenarios.filter(
(scenario) => !isTrustedHarnessOwnedUpgradeSurvivorScenario(scenario),
);
if (targetOwnedScenarios.length === 0) {
return scenarios;
}
const relativePath = "scripts/e2e/lib/upgrade-survivor/assertions.mjs";
if (frozenTarget) {
const source = frozenTarget.source.readText(relativePath);
const catalog =
source === null
? undefined
: readInertFrozenScenarioContract(source, targetRoot, frozenTarget);
if (!catalog) {
throw new Error(`unrecognized required inert scenario catalog: ${relativePath}`);
}
return scenarios.filter(
(scenario) =>
isTrustedHarnessOwnedUpgradeSurvivorScenario(scenario) || catalog.includes(scenario),
);
}
const assertionsFile = resolve(targetRoot!, relativePath);
if (!existsSync(assertionsFile)) {
return scenarios.filter(isTrustedHarnessOwnedUpgradeSurvivorScenario);
}
const targetScenarios = readFrozenScenarioContract(
assertionsFile,
targetRoot!,
allowExecutableContract,
);
const supportedScenarios = new Set(targetScenarios);
return scenarios.filter(
(scenario) =>
isTrustedHarnessOwnedUpgradeSurvivorScenario(scenario) || supportedScenarios.has(scenario),
);
}
function readTargetMetadata(
targetRoot: string | undefined,
relativePath: string,
frozenTarget?: InertTargetContract,
): string | null {
if (frozenTarget) {
return frozenTarget.source.readText(relativePath);
}
const file = resolve(targetRoot!, relativePath);
return existsSync(file) ? readFileSync(file, "utf8") : null;
}
function supportsUpdateFirstHopCompatForTarget(
laneName: string,
targetRoot: string | undefined,
frozenTarget?: InertTargetContract,
): boolean {
if (!targetRoot && !frozenTarget) {
return true;
}
// A target that records its own inventory only proves the hops it lists.
const inventory = readTargetMetadata(
targetRoot,
"scripts/lib/update-compat-inventory.json",
frozenTarget,
);
if (
inventory !== null &&
!(JSON.parse(inventory).releases as { version: string }[]).some(
(release) => updateFirstHopCompatLaneName(release.version) === laneName,
)
) {
return false;
}
const source = readTargetMetadata(targetRoot, "scripts/runtime-postbuild.mts", frozenTarget);
if (source === null) {
return false;
}
const startMarker = "const LEGACY_CLI_EXIT_COMPAT_CHUNKS = [";
const start = source.indexOf(startMarker);
if (start < 0 || source.lastIndexOf(startMarker) !== start) {
return false;
}
const end = source.indexOf("\n];", start + startMarker.length);
if (end < 0) {
return false;
}
const block = source.slice(start, end + 3);
return UPDATE_FIRST_HOP_COMPAT_CATALOGS.has(createHash("sha256").update(block).digest("hex"));
}
function supportsMobilePairingReconnectForTarget(
targetRoot: string | undefined,
frozenTarget?: InertTargetContract,
): boolean {
if (!targetRoot && !frozenTarget) {
return true;
}
const source = readTargetMetadata(targetRoot, "src/gateway/node-command-policy.ts", frozenTarget);
if (source === null) {
return false;
}
return (
IOS_WATCH_RELAY_COMMANDS.every((command) => source.includes(command)) &&
source.includes('platformId === "ios"') &&
source.includes('normalizeDeviceMetadataForPolicy(node?.deviceFamily) === "iphone"') &&
source.includes("...watchRelayCommands")
);
}
function supportsCorruptPluginUpdateForTarget(
targetRoot: string | undefined,
frozenTarget?: InertTargetContract,
): boolean {
return (
(!targetRoot && !frozenTarget) ||
readTargetMetadata(
targetRoot,
"src/cli/update-cli/update-command-plugin-preflight.ts",
frozenTarget,
) !== null
);
}
function expandedUpgradeSurvivorLaneName(
poolLaneName: string,
baselineSpec: string | undefined,
scenario: string | undefined,
) {
const suffixParts = [
baselineSpec ? sanitizeLaneNameSuffix(baselineSpec) : "",
scenario && scenario !== "base" ? sanitizeLaneNameSuffix(scenario) : "",
].filter(Boolean);
const suffix = suffixParts.join("-");
return suffix ? `${poolLaneName}-${suffix}` : poolLaneName;
}
function expandUpgradeSurvivorBaselineLanes(
poolLanes: DockerE2eLane[],
rawBaselineSpecs: string | undefined,
targetRoot: string | undefined,
rawScenarios = "",
allowExecutableContract = false,
frozenTarget?: InertTargetContract,
): UpgradeSurvivorExpansion {
const hasUpgradeSurvivorLane = poolLanes.some(
(poolLane) =>
poolLane.name === "published-upgrade-survivor" || poolLane.name === "update-migration",
);
if (!hasUpgradeSurvivorLane) {
return { lanes: poolLanes, omittedLaneNames: [] };
}
const baselineSpecs = parseUpgradeSurvivorBaselineSpecs(rawBaselineSpecs);
baselineSpecs.forEach(assertSupportedUpgradeSurvivorBaselineSpec);
// Trusted-current planners may know scenarios that a frozen target's Docker
// harness cannot seed or assert. Filter by the selected tree's concrete
// harness contract so validation never schedules an impossible target lane.
const configuredScenarios = parseUpgradeSurvivorScenarios(rawScenarios);
const requestedScenarios = configuredScenarios.length > 0 ? configuredScenarios : ["base"];
const supportedScenarios = filterUpgradeSurvivorScenariosForTarget(
requestedScenarios,
targetRoot,
allowExecutableContract,
frozenTarget,
);
const supportedScenarioSet = new Set(supportedScenarios);
const unsupportedScenarios =
targetRoot || frozenTarget
? requestedScenarios.filter((scenario) => !supportedScenarioSet.has(scenario))
: [];
const scenarios = configuredScenarios.length > 0 ? supportedScenarios : [];
const matrixBaselines = baselineSpecs.length > 0 ? baselineSpecs : [undefined];
const survivorLanes = poolLanes.filter(
(poolLane) =>
poolLane.name === "published-upgrade-survivor" || poolLane.name === "update-migration",
);
const omittedLaneNames = survivorLanes.flatMap((poolLane) =>
matrixBaselines.flatMap((baselineSpec) =>
unsupportedScenarios
.filter((scenario) => supportsUpgradeSurvivorScenarioAtBaseline(scenario, baselineSpec))
.map((scenario) => expandedUpgradeSurvivorLaneName(poolLane.name, baselineSpec, scenario)),
),
);
if (supportedScenarios.length === 0 && unsupportedScenarios.length > 0) {
return {
lanes: poolLanes.filter(
(poolLane) =>
poolLane.name !== "published-upgrade-survivor" && poolLane.name !== "update-migration",
),
omittedLaneNames,
};
}
if (baselineSpecs.length === 0 && scenarios.length === 0) {
return { lanes: poolLanes, omittedLaneNames };
}
return {
lanes: poolLanes.flatMap((poolLane) => {
if (poolLane.name !== "published-upgrade-survivor" && poolLane.name !== "update-migration") {
return [poolLane];
}
const matrixScenarios = scenarios.length > 0 ? scenarios : [undefined];
return matrixBaselines.flatMap((baselineSpec) =>
matrixScenarios
.filter((scenario) => supportsUpgradeSurvivorScenarioAtBaseline(scenario, baselineSpec))
.map((scenario) => {
const name = expandedUpgradeSurvivorLaneName(poolLane.name, baselineSpec, scenario);
const suffix = name.slice(poolLane.name.length + 1);
const commandPrefix = [
`OPENCLAW_UPGRADE_SURVIVOR_ARTIFACT_DIR="$PWD/.artifacts/upgrade-survivor/${name}"`,
baselineSpec
? `OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=${shellQuote(baselineSpec)}`
: "",
scenario ? `OPENCLAW_UPGRADE_SURVIVOR_SCENARIO=${shellQuote(scenario)}` : "",
]
.filter(Boolean)
.join(" ");
return Object.assign({}, poolLane, {
cacheKey: poolLane.cacheKey
? suffix
? `${poolLane.cacheKey}-${suffix}`
: poolLane.cacheKey
: name,
command: commandPrefix ? `${commandPrefix} ${poolLane.command}` : poolLane.command,
name,
});
}),
);
}),
omittedLaneNames,
};
}
function dedupeLanes(poolLanes: DockerE2eLane[]): DockerE2eLane[] {
const byName = new Map<string, DockerE2eLane>();
for (const poolLane of poolLanes) {
if (!byName.has(poolLane.name)) {
byName.set(poolLane.name, poolLane);
}
}
return [...byName.values()];
}
function selectNamedLanes(
poolLanes: DockerE2eLane[],
selectedNames: string[],
label: string,
): DockerE2eLane[] {
const byName = new Map(poolLanes.map((poolLane) => [poolLane.name, poolLane]));
const missing = selectedNames.filter((name) => !byName.has(name));
if (missing.length > 0) {
throw new Error(
`${label} unknown lane(s): ${missing.join(", ")}. Available lanes: ${[...byName.keys()]
.toSorted((a, b) => a.localeCompare(b))
.join(", ")}`,
);
}
return selectedNames.map((name) => byName.get(name)!);
}
export function parseLiveMode(raw: unknown): LiveMode {
const mode = raw || "all";
if (mode === "all" || mode === "skip" || mode === "only") {
return mode;
}
throw new Error(
`OPENCLAW_DOCKER_ALL_LIVE_MODE must be one of: all, skip, only. Got: ${JSON.stringify(raw)}`,
);
}
export function parseProfile(raw: unknown): DockerProfile {
const profile = raw || DEFAULT_PROFILE;
if (profile === DEFAULT_PROFILE || profile === RELEASE_PATH_PROFILE) {
return profile;
}
throw new Error(
`OPENCLAW_DOCKER_ALL_PROFILE must be one of: ${DEFAULT_PROFILE}, ${RELEASE_PATH_PROFILE}. Got: ${JSON.stringify(raw)}`,
);
}
function applyLiveMode(poolLanes: DockerE2eLane[], mode: LiveMode): DockerE2eLane[] {
if (mode === "all") {
return poolLanes;
}
return poolLanes.filter((poolLane) => (mode === "only" ? poolLane.live : !poolLane.live));
}
export function laneWeight(poolLane: DockerE2eLane): number {
return Math.max(1, poolLane.weight ?? 1);
}
export function laneResources(poolLane: DockerE2eLane): string[] {
return [...new Set(["docker", ...(poolLane.resources ?? [])])];
}
export function laneSummary(poolLane: DockerE2eLane): string {
const resources = laneResources(poolLane).join(",");
const timeout = poolLane.timeoutMs ? ` timeout=${Math.round(poolLane.timeoutMs / 1000)}s` : "";
const noOutputTimeout = poolLane.noOutputTimeoutMs
? ` no-output=${Math.round(poolLane.noOutputTimeoutMs / 1000)}s`
: "";
const cache = poolLane.cacheKey ? ` cache=${poolLane.cacheKey}` : "";
const image = poolLane.e2eImageKind ? ` image=${poolLane.e2eImageKind}` : "";
const state = poolLane.stateScenario ? ` state=${poolLane.stateScenario}` : "";
return `${poolLane.name}(w=${laneWeight(poolLane)} r=${resources}${timeout}${noOutputTimeout}${cache}${image}${state})`;
}
export function lanesNeedE2eImageKind(
poolLanes: DockerE2eLane[],
kind: DockerE2eImageKind,
): boolean {
return poolLanes.some((poolLane) => poolLane.e2eImageKind === kind);
}
export function lanesNeedOpenClawPackage(poolLanes: DockerE2eLane[]): boolean {
return poolLanes.some((poolLane) => poolLane.needsPackage || poolLane.e2eImageKind);
}
export function findLaneByName(name: string): DockerE2eLane | undefined {
return dedupeLanes(
expandUpgradeSurvivorBaselineLanes(
[
...allReleasePathLanes({ includeOpenWebUI: true }),
...publicInstallerLanes,
fleetCacheLane,
...mainLanes,
...tailLanes,
],
process.env.OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS,
undefined,
process.env.OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS,
).lanes,
).find((poolLane) => poolLane.name === name);
}
function laneCredentialRequirements(poolLane: DockerE2eLane): string[] {
const resources = laneResources(poolLane);
const credentials: string[] = [];
if (poolLane.name === "install-e2e-openai") {
credentials.push("openai");
}
if (poolLane.name === "install-e2e-anthropic") {
credentials.push("anthropic");
}
if (resources.includes("live:openai")) {
credentials.push("openai");
}
if (resources.includes("live:codex")) {
credentials.push("codex");
}
if (resources.includes("live:claude")) {
credentials.push(poolLane.name === "live-anthropic-cache" ? "anthropic-api-key" : "anthropic");
}
if (resources.includes("live:droid")) {
credentials.push("factory");
}
if (resources.includes("live:gemini")) {
credentials.push("gemini");
}
if (resources.includes("live:opencode")) {
credentials.push("opencode");
}
if (resources.includes("live:telegram")) {
credentials.push("telegram");
}
return credentials;
}
function unique<T>(values: Array<T | null | undefined>): T[] {
return [...new Set(values.filter((value): value is T => value != null))];
}
function upgradeSurvivorScenarioForLane(poolLane: DockerE2eLane): string | undefined {
if (!poolLane.upgradeSurvivorScenario) {
return undefined;
}
const match = /(?:^|\s)OPENCLAW_UPGRADE_SURVIVOR_SCENARIO=(?:'([^']+)'|"([^"]+)"|([^\s]+))/u.exec(
poolLane.command,
);
return match?.[1] ?? match?.[2] ?? match?.[3] ?? poolLane.upgradeSurvivorScenario;
}
function upgradeSurvivorBaselineVersionForLane(poolLane: DockerE2eLane): string | null {
const match =
/(?:^|\s)OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=(?:'([^']+)'|"([^"]+)"|([^\s]+))/u.exec(
poolLane.command,
);
const spec = match?.[1] ?? match?.[2] ?? match?.[3];
return /(?:^|\/|@)(\d{4}\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/u.exec(spec ?? "")?.[1] ?? null;
}
function legacyOperatorProviderPackages(
targetRoot?: string,
frozenTarget?: InertTargetContract,
): string[] {
const catalogPath = "scripts/lib/official-external-provider-catalog.json";
const text =
targetRoot || frozenTarget ? readTargetMetadata(targetRoot, catalogPath, frozenTarget) : null;
// Older candidates without the external catalog still carry their bundled providers.
const catalog: unknown =
targetRoot || frozenTarget
? text === null
? { entries: [] }
: JSON.parse(text)
: officialExternalProviderCatalog;
if (!isRecord(catalog) || !Array.isArray(catalog.entries)) {
throw new Error(`invalid candidate provider catalog: ${catalogPath}`);
}
return catalog.entries.flatMap((entry: unknown) =>
isRecord(entry) &&
entry.source === "official" &&
typeof entry.name === "string" &&
isRecord(entry.openclaw) &&
isRecord(entry.openclaw.install) &&
entry.openclaw.install.npmSpec === entry.name
? [entry.name]
: [],
);
}
export function requiredPrepublishPluginPackagesForLanes(
poolLanes: DockerE2eLane[],
targetRoot?: string,
frozenTarget?: InertTargetContract,
): string[] {
const configuredChannelIds = new Set<string>();
const requiredPackages = new Set<string>();
let legacyOperatorProviders: string[] | undefined;
for (const poolLane of poolLanes) {
for (const packageName of poolLane.prepublishPluginPackages ?? []) {
requiredPackages.add(packageName);
}
const scenario = upgradeSurvivorScenarioForLane(poolLane);
if (
!scenario ||
scenario === "abandoned-update" ||
scenario === "custom-plugin-siblings" ||
scenario === "projects-doctor" ||
scenario === "channel-owner-policy" ||
scenario === "projects-startup-migration" ||
scenario === "workshop-doctor-recovery" ||
scenario === "update-report-recovery" ||
scenario === "dreaming-cron-doctor"
) {
continue;
}
if (scenario === "legacy-operator-state") {
requiredPackages.add("@openclaw/codex");
requiredPackages.add("@openclaw/discord");
requiredPackages.add("@openclaw/duckduckgo-plugin");
// The baseline authors an allowlist from its enabled bundled inventory.
// Supply candidate providers once, even when several baselines share the registry.
legacyOperatorProviders ??= legacyOperatorProviderPackages(targetRoot, frozenTarget);
for (const packageName of legacyOperatorProviders) {
requiredPackages.add(packageName);
}
continue;
}
for (const packageName of UPGRADE_SURVIVOR_RUNTIME_COMPANION_PACKAGES) {
requiredPackages.add(packageName);
}
const steps = resolveUpgradeSurvivorConfigStepsForBaseline(
scenario,
upgradeSurvivorBaselineVersionForLane(poolLane),
);
for (const step of steps) {
for (const packageName of step.prepublishPluginPackages ?? []) {
requiredPackages.add(packageName);
}
if (step.argv[0] !== "config" || step.argv[1] !== "set") {
continue;
}
const configPaths: string[] =
step.argv[2] === "--batch-json"
? JSON.parse(step.argv[3] ?? "").map((entry: { path: string }) => entry.path)
: [step.argv[2] ?? ""];
for (const configPath of configPaths) {
const channelId = /^channels\.([a-z0-9][a-z0-9-]*)$/u.exec(configPath)?.[1];
if (channelId) {
configuredChannelIds.add(channelId);
}
}
}
}
for (const packageName of (officialExternalChannelCatalog.entries ?? [])
.filter((entry) => {
const channelId = entry.openclaw?.channel?.id;
const install = entry.openclaw?.install;
return (
typeof entry.name === "string" &&
configuredChannelIds.has(channelId) &&
install?.npmSpec === entry.name
);
})
.map((entry) => entry.name)) {
requiredPackages.add(packageName);
}
return [...requiredPackages].toSorted((a, b) => a.localeCompare(b));
}
function buildPlanJson(params: {
includeOpenWebUI: boolean;
omittedUnsupportedLaneNames: string[];
orderedLanes: DockerE2eLane[];
orderedTailLanes: DockerE2eLane[];
profile: string;
releaseChunk: string;
releaseProfile: DockerE2eReleaseProfile;
selectedLaneNames: string[];
targetRoot?: string;
frozenTarget?: InertTargetContract;
}) {
const scheduledLanes = [...params.orderedLanes, ...params.orderedTailLanes];
const imageKinds = unique(scheduledLanes.map((poolLane) => poolLane.e2eImageKind)).toSorted(
(a, b) => a.localeCompare(b),
);
const requiredPrepublishPluginPackages = requiredPrepublishPluginPackagesForLanes(
scheduledLanes,
params.targetRoot,
params.frozenTarget,
);
return {
chunk: params.releaseChunk || undefined,
credentials: unique(scheduledLanes.flatMap(laneCredentialRequirements)).toSorted((a, b) =>
a.localeCompare(b),
),
imageKinds,
includeOpenWebUI: params.includeOpenWebUI,
lanes: scheduledLanes.map((poolLane) => ({
command: poolLane.command,
imageKind: poolLane.e2eImageKind,
live: poolLane.live,
name: poolLane.name,
noOutputTimeoutMs: poolLane.noOutputTimeoutMs,
resources: laneResources(poolLane),
stateScenario: poolLane.stateScenario,
timeoutMs: poolLane.timeoutMs,
weight: laneWeight(poolLane),
})),
mainLanes: params.orderedLanes.map((poolLane) => poolLane.name),
omittedUnsupportedLanes: params.omittedUnsupportedLaneNames,
requiredPrepublishPluginPackages,
needs: {
bareImage: imageKinds.includes("bare"),
e2eImage: imageKinds.length > 0,
functionalImage: imageKinds.includes("functional"),
liveImage: scheduledLanes.some((poolLane) => poolLane.needsLiveImage),
package: lanesNeedOpenClawPackage(scheduledLanes),
prepublishPluginRegistry: requiredPrepublishPluginPackages.length > 0,
},
profile: params.profile,
releaseProfile: params.releaseProfile,
selectedLanes: params.selectedLaneNames,
tailLanes: params.orderedTailLanes.map((poolLane) => poolLane.name),
version: 1,
};
}
export function resolveDockerE2ePlan(options: DockerE2ePlanOptions) {
const releaseProfile = normalizeReleaseProfile(options.releaseProfile);
const upgradeSurvivorBaselines = options.upgradeSurvivorBaselines ?? "";
const upgradeSurvivorScenarios = options.upgradeSurvivorScenarios ?? "";
const unexpandedSelectableLanes = dedupeLanes([
...allReleasePathLanes({
includeOpenWebUI: options.includeOpenWebUI,
releaseProfile: "full",
}),
...publicInstallerLanes,
fleetCacheLane,
...mainLanes,
...tailLanes,
]);
const omittedUnsupportedLaneNames = new Set<string>();
const expandRequestedSurvivorLanes = (poolLanes: DockerE2eLane[]) => {
const expansion = expandUpgradeSurvivorBaselineLanes(
poolLanes,
upgradeSurvivorBaselines,
options.upgradeSurvivorTargetRoot,
upgradeSurvivorScenarios,
options.allowFrozenTargetScenarioOmissions,
options.frozenTarget,
);
const requestedBaselines = parseUpgradeSurvivorBaselineSpecs(upgradeSurvivorBaselines);
if (
poolLanes.some(
(lane) => lane.name === "published-upgrade-survivor" || lane.name === "update-migration",
) &&
parseUpgradeSurvivorScenarios(upgradeSurvivorScenarios).includes("missing-load-path") &&
requestedBaselines.length > 0 &&
requestedBaselines.every(
(baseline) => !supportsUpgradeSurvivorScenarioAtBaseline("missing-load-path", baseline),
)
) {
throw new Error(
`missing-load-path has no compatible published baseline in ${upgradeSurvivorBaselines}: the installed updater must admit invalid config before candidate staging.`,
);
}
for (const laneName of expansion.omittedLaneNames) {
omittedUnsupportedLaneNames.add(laneName);
}
return expansion.lanes;
};
const unfilteredSelectableLanes = dedupeLanes(
expandUpgradeSurvivorBaselineLanes(
unexpandedSelectableLanes,
upgradeSurvivorBaselines,
undefined,
upgradeSurvivorScenarios,
).lanes,
);
// Selectable lanes are a target-agnostic lookup catalog. Frozen target
// contracts are read only after a survivor lane is actually requested.
const releaseLanes =
options.selectedLaneNames.length === 0 && options.profile === RELEASE_PATH_PROFILE
? options.planReleaseAll
? expandRequestedSurvivorLanes(
allReleasePathLanes({ includeOpenWebUI: options.includeOpenWebUI, releaseProfile }),
)
: expandRequestedSurvivorLanes(
releasePathChunkLanes(options.releaseChunk, {
includeOpenWebUI: options.includeOpenWebUI,
releaseProfile,
}),
)
: undefined;
const selectedLanes =
options.selectedLaneNames.length > 0
? options.selectedLaneNames.flatMap((selectedName) => {
const unexpandedLane = unexpandedSelectableLanes.find(
(poolLane) => poolLane.name === selectedName,
);
if (unexpandedLane) {
return expandRequestedSurvivorLanes([unexpandedLane]);
}
const expandedLane = unfilteredSelectableLanes.find(
(poolLane) => poolLane.name === selectedName,
);
if (expandedLane) {
const survivorBaseLane = unexpandedSelectableLanes.find(
(poolLane) =>
(poolLane.name === "published-upgrade-survivor" ||
poolLane.name === "update-migration") &&
selectedName.startsWith(`${poolLane.name}-`),
);
if (!survivorBaseLane) {
return [expandedLane];
}
// Exact-row reruns retain the original matrix only to reconstruct the catalog.
const targetExpansion = expandUpgradeSurvivorBaselineLanes(
[survivorBaseLane],
upgradeSurvivorBaselines,
options.upgradeSurvivorTargetRoot,
upgradeSurvivorScenarios,
options.allowFrozenTargetScenarioOmissions,
options.frozenTarget,
);
const supportedLane = targetExpansion.lanes.find(
(poolLane) => poolLane.name === selectedName,
);
if (supportedLane) {
return [supportedLane];
}
omittedUnsupportedLaneNames.add(selectedName);
return [];
}
selectNamedLanes(unfilteredSelectableLanes, [selectedName], "OPENCLAW_DOCKER_ALL_LANES");
return [];
})
: undefined;
let configuredLanes = selectedLanes
? selectedLanes
: releaseLanes
? applyLiveMode(releaseLanes, options.liveMode)
: options.liveMode === "only"
? applyLiveMode([...mainLanes, ...tailLanes], options.liveMode)
: applyLiveMode(mainLanes, options.liveMode);
if (options.allowFrozenTargetScenarioOmissions) {
const unsupportedLaneRules = [
{
matches: (lane: DockerE2eLane) => isUpdateFirstHopCompatLane(lane.name),
supported: (lane: DockerE2eLane) =>
supportsUpdateFirstHopCompatForTarget(
lane.name,
options.upgradeSurvivorTargetRoot,
options.frozenTarget,
),
},
{
matches: (lane: DockerE2eLane) => lane.name.includes("mobile-pairing-reconnect"),
supported: () =>
supportsMobilePairingReconnectForTarget(
options.upgradeSurvivorTargetRoot,
options.frozenTarget,
),
},
{
matches: (lane: DockerE2eLane) => lane.name === "update-corrupt-plugin",
supported: () =>
supportsCorruptPluginUpdateForTarget(
options.upgradeSurvivorTargetRoot,
options.frozenTarget,
),
},
];
configuredLanes = configuredLanes.filter((lane) => {
const rule = unsupportedLaneRules.find((entry) => entry.matches(lane));
if (!rule || rule.supported(lane)) {
return true;
}
omittedUnsupportedLaneNames.add(lane.name);
return false;
});
}
if (omittedUnsupportedLaneNames.size > 0 && !options.allowFrozenTargetScenarioOmissions) {
throw new Error("unsupported frozen target lanes require authorized scenario omissions");
}
const configuredTailLanes =
selectedLanes || releaseLanes
? []
: options.liveMode === "only"
? []
: applyLiveMode(tailLanes, options.liveMode);
const orderedLanes = options.orderLanes(configuredLanes, options.timingStore);
const orderedTailLanes = options.orderLanes(configuredTailLanes, options.timingStore);
return {
omittedUnsupportedLaneNames: [...omittedUnsupportedLaneNames],
orderedLanes,
orderedTailLanes,
plan: buildPlanJson({
includeOpenWebUI: options.includeOpenWebUI,
omittedUnsupportedLaneNames: [...omittedUnsupportedLaneNames],
orderedLanes,
orderedTailLanes,
profile: options.profile,
releaseChunk: options.releaseChunk,
releaseProfile,
selectedLaneNames: options.selectedLaneNames,
targetRoot: options.upgradeSurvivorTargetRoot,
frozenTarget: options.frozenTarget,
}),
scheduledLanes: [...orderedLanes, ...orderedTailLanes],
};
}