diff --git a/docs/install/development-channels.md b/docs/install/development-channels.md index c3ccc33ba131..70d0d438cb39 100644 --- a/docs/install/development-channels.md +++ b/docs/install/development-channels.md @@ -120,7 +120,9 @@ Switching channels with `openclaw update` also syncs plugin sources: packages. - `extended-stable` resolves eligible official npm plugins with bare/default or `latest` intent to the exact installed core version. It does not query - plugin `@extended-stable` tags at runtime. + plugin `@extended-stable` tags at runtime. Version-bound runtime plugins use + the base release cohort for correction versions (for example, `YYYY.M.P-2` + uses plugin `YYYY.M.P`). - npm-installed plugins are updated after the core update completes. ## Checking current status diff --git a/docs/install/updating.md b/docs/install/updating.md index 3e7d3aa60ab6..e5c00bd8b067 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -54,6 +54,9 @@ omit `--allow-scripts=openclaw`. After the core swap, eligible official npm plugins with bare/default or `latest` intent converge to that exact core version. Exact pins and explicit non-`latest` tags, third-party plugins, and non-npm sources remain unchanged. +Version-bound runtime plugins converge to the base release cohort when the +core is a correction release (for example, `YYYY.M.P-2` uses plugin +`YYYY.M.P`). Catalog installs created by current OpenClaw versions retain that default intent. Older records that contain only an exact version remain pinned because OpenClaw cannot safely distinguish an old automatic pin from a user pin; run diff --git a/scripts/e2e/codex-on-demand-docker.sh b/scripts/e2e/codex-on-demand-docker.sh index 6745cd02d289..d4c4857ac2fe 100755 --- a/scripts/e2e/codex-on-demand-docker.sh +++ b/scripts/e2e/codex-on-demand-docker.sh @@ -11,6 +11,8 @@ IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-codex-on-demand-e2e" OPENCLAW_C DOCKER_TARGET="${OPENCLAW_CODEX_ON_DEMAND_DOCKER_TARGET:-bare}" HOST_BUILD="${OPENCLAW_CODEX_ON_DEMAND_HOST_BUILD:-1}" PACKAGE_TGZ="${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" +PREPUBLISH_PLUGIN_REGISTRY_ARGS=() +AUTO_PREPUBLISH_PLUGIN_REGISTRY_ROOT="" run_log="" # This lane installs the package and then exercises a managed npm install of Codex. @@ -18,10 +20,30 @@ run_log="" # the Codex assertions instead of failing as a silent package-install timeout. export OPENCLAW_E2E_NPM_INSTALL_TIMEOUT="${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-1200s}" +configure_prepublish_plugin_registry() { + local registry_dir="$1" + local resolved_registry_dir + resolved_registry_dir="$(cd "$registry_dir" && pwd)" + if [ ! -f "$resolved_registry_dir/prepublish-plugin-registry.json" ]; then + echo "Prepublish plugin registry manifest is missing." >&2 + exit 1 + fi + PREPUBLISH_PLUGIN_REGISTRY_ARGS=( + -e OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR=/tmp/openclaw-prepublish-plugin-registry + -v "$resolved_registry_dir:/tmp/openclaw-prepublish-plugin-registry:ro" + ) +} +if [ -n "${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR:-}" ]; then + configure_prepublish_plugin_registry "$OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR" +fi + cleanup() { if [ -n "${PACKAGE_TGZ:-}" ]; then docker_e2e_cleanup_package_tgz "$PACKAGE_TGZ" fi + if [ -n "$AUTO_PREPUBLISH_PLUGIN_REGISTRY_ROOT" ]; then + rm -rf "$AUTO_PREPUBLISH_PLUGIN_REGISTRY_ROOT" + fi if [ -n "${run_log:-}" ]; then rm -f "$run_log" fi @@ -44,6 +66,20 @@ prepare_package_tgz() { prepare_package_tgz +if [ -z "${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR:-}" ] && + [ -z "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}" ] && + [ "$HOST_BUILD" != "0" ]; then + AUTO_PREPUBLISH_PLUGIN_REGISTRY_ROOT="$( + mktemp -d "${TMPDIR:-/tmp}/openclaw-codex-on-demand-plugin-registry.XXXXXX" + )" + OPENCLAW_DOCKER_ALL_LANES=codex-on-demand \ + OPENCLAW_DOCKER_ALL_LOG_DIR="$AUTO_PREPUBLISH_PLUGIN_REGISTRY_ROOT" \ + OPENCLAW_DOCKER_ALL_TIMINGS=0 \ + node "$ROOT_DIR/scripts/test-docker-all.mjs" --prepare-plugin-registry >/dev/null + configure_prepublish_plugin_registry \ + "$AUTO_PREPUBLISH_PLUGIN_REGISTRY_ROOT/prepublish-plugin-registry" +fi + docker_e2e_package_mount_args "$PACKAGE_TGZ" run_log="$(docker_e2e_run_log codex-on-demand)" OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 codex-on-demand empty)" @@ -52,6 +88,7 @@ echo "Running Codex on-demand Docker E2E..." if ! docker_e2e_run_with_harness \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \ + ${PREPUBLISH_PLUGIN_REGISTRY_ARGS[@]+"${PREPUBLISH_PLUGIN_REGISTRY_ARGS[@]}"} \ "${DOCKER_E2E_PACKAGE_ARGS[@]}" \ -i "$IMAGE_NAME" bash -s >"$run_log" 2>&1 <<'EOF'; then set -euo pipefail @@ -71,12 +108,71 @@ dump_debug_logs() { echo "Codex on-demand scenario failed with exit code $status" >&2 openclaw_e2e_dump_logs \ /tmp/openclaw-install.log \ + /tmp/openclaw-codex-registry/server.log \ /tmp/openclaw-onboard.json \ /tmp/openclaw-plugins-list.json \ /tmp/openclaw-codex-inspect.json } trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR +plugin_registry_pid="" +cleanup_inner() { + openclaw_e2e_stop_process "${plugin_registry_pid:-}" +} +trap cleanup_inner EXIT + +configure_plugin_registry() { + [ -n "${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR:-}" ] || return 0 + local registry_root="/tmp/openclaw-codex-registry" + local manifest="$OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR/prepublish-plugin-registry.json" + local package_name package_version package_tarball + IFS=$'\t' read -r package_name package_version package_tarball < <( + PREPUBLISH_PLUGIN_REGISTRY_MANIFEST="$manifest" node <<'NODE' +const fs = require("node:fs"); +const path = require("node:path"); +const manifestPath = process.env.PREPUBLISH_PLUGIN_REGISTRY_MANIFEST; +const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); +const matches = Array.isArray(manifest.packages) + ? manifest.packages.filter((entry) => entry?.name === "@openclaw/codex") + : []; +if (matches.length !== 1) { + throw new Error("prepublish plugin registry must contain exactly one @openclaw/codex package"); +} +const entry = matches[0]; +if ( + typeof entry.version !== "string" || + typeof entry.tarball !== "string" || + path.basename(entry.tarball) !== entry.tarball +) { + throw new Error("invalid @openclaw/codex prepublish plugin registry entry"); +} +process.stdout.write( + `${entry.name}\t${entry.version}\t${path.join(path.dirname(manifestPath), entry.tarball)}\n`, +); +NODE + ) + mkdir -p "$registry_root" + OPENCLAW_NPM_REGISTRY_DIST_TAGS="latest=0.0.0,beta=$package_version" \ + OPENCLAW_NPM_REGISTRY_UPSTREAM=https://registry.npmjs.org \ + node scripts/e2e/lib/plugins/npm-registry-server.mjs \ + "$registry_root/port" \ + "$package_name" "$package_version" "$package_tarball" \ + >"$registry_root/server.log" 2>&1 & + plugin_registry_pid="$!" + for _ in $(seq 1 100); do + [ -s "$registry_root/port" ] && break + openclaw_e2e_process_alive "$plugin_registry_pid" || break + sleep 0.1 + done + if [ ! -s "$registry_root/port" ]; then + openclaw_e2e_print_log "$registry_root/server.log" >&2 + echo "Timed out waiting for Codex npm registry." >&2 + return 1 + fi + export NPM_CONFIG_REGISTRY="http://127.0.0.1:$(cat "$registry_root/port")" + export npm_config_registry="$NPM_CONFIG_REGISTRY" +} + mkdir -p "$NPM_CONFIG_PREFIX" "$XDG_CACHE_HOME" "$NPM_CONFIG_CACHE" chmod 700 "$XDG_CACHE_HOME" "$NPM_CONFIG_CACHE" || true @@ -87,6 +183,8 @@ openclaw_e2e_enable_openclaw_cli_timeout openclaw_e2e_assert_dep_absent "@openclaw/codex" "$HOME/.openclaw" "$NPM_CONFIG_PREFIX" openclaw_e2e_assert_dep_absent "@openai/codex" "$HOME/.openclaw" "$NPM_CONFIG_PREFIX" +configure_plugin_registry + echo "Running non-interactive OpenAI onboarding; Codex should install on demand..." openclaw onboard --non-interactive --accept-risk \ --mode local \ diff --git a/scripts/lib/docker-e2e-plan.mts b/scripts/lib/docker-e2e-plan.mts index 75afe2b469bc..d0e9870a5a65 100644 --- a/scripts/lib/docker-e2e-plan.mts +++ b/scripts/lib/docker-e2e-plan.mts @@ -746,6 +746,9 @@ export function requiredPrepublishPluginPackagesForLanes(poolLanes: DockerE2eLan const configuredChannelIds = new Set(); const requiredPackages = new Set(); for (const poolLane of poolLanes) { + for (const packageName of poolLane.prepublishPluginPackages ?? []) { + requiredPackages.add(packageName); + } const scenario = upgradeSurvivorScenarioForLane(poolLane); if (!scenario) { continue; diff --git a/scripts/lib/docker-e2e-scenarios.mts b/scripts/lib/docker-e2e-scenarios.mts index c296c9cebef9..9e809cf461eb 100644 --- a/scripts/lib/docker-e2e-scenarios.mts +++ b/scripts/lib/docker-e2e-scenarios.mts @@ -16,6 +16,7 @@ export type DockerE2eLane = { name: string; needsLiveImage?: boolean; noOutputTimeoutMs?: number; + prepublishPluginPackages?: string[]; resources: string[]; retries: number; retryPatterns: RegExp[]; @@ -106,6 +107,7 @@ function lane(name: string, command: string, options: LaneOptions = {}): DockerE noOutputTimeoutMs: options.noOutputTimeoutMs, name, needsLiveImage: options.needsLiveImage, + prepublishPluginPackages: options.prepublishPluginPackages, retryPatterns: options.retryPatterns ?? [], retries: options.retries ?? 0, resources: options.resources ?? [], @@ -442,6 +444,7 @@ export const mainLanes: DockerE2eLane[] = [ weight: 2, }), npmLane("codex-on-demand", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:codex-on-demand", { + prepublishPluginPackages: ["@openclaw/codex"], resources: ["service"], stateScenario: "empty", weight: 3, diff --git a/src/commands/codex-runtime-plugin-install.test.ts b/src/commands/codex-runtime-plugin-install.test.ts index 9d3293c43a8e..b61e6cb6e9c3 100644 --- a/src/commands/codex-runtime-plugin-install.test.ts +++ b/src/commands/codex-runtime-plugin-install.test.ts @@ -175,6 +175,7 @@ describe("Codex runtime plugin install repair", () => { label: "Codex", install: { npmSpec: "@openclaw/codex", defaultChoice: "npm" }, trustedSourceLinkedOfficialInstall: true, + versionBoundToOpenClaw: true, }, }), ); diff --git a/src/commands/codex-runtime-plugin-install.ts b/src/commands/codex-runtime-plugin-install.ts index e984dcf2adb1..52be052997f4 100644 --- a/src/commands/codex-runtime-plugin-install.ts +++ b/src/commands/codex-runtime-plugin-install.ts @@ -10,6 +10,7 @@ const CODEX_RUNTIME_PLUGIN_DESCRIPTOR = { label: CODEX_RUNTIME_PLUGIN_LABEL, npmSpec: CODEX_RUNTIME_PLUGIN_NPM_SPEC, warningLabel: CODEX_RUNTIME_PLUGIN_LABEL, + versionBoundToOpenClaw: true, }; const codexRuntimePluginInstall = createRuntimePluginModelSelectionHelpers({ diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts index 2ac5807834d9..d741ce29c91c 100644 --- a/src/commands/onboarding-plugin-install.test.ts +++ b/src/commands/onboarding-plugin-install.test.ts @@ -827,6 +827,47 @@ describe("ensureOnboardingPluginInstalled", () => { ); }); + it("keeps version-bound official runtime plugins aligned with the stable core version", async () => { + installPluginFromNpmSpec.mockResolvedValueOnce({ + ok: true, + pluginId: "codex", + targetDir: "/tmp/codex", + version: VERSION, + npmResolution: { + name: "@openclaw/codex", + version: VERSION, + resolvedSpec: `@openclaw/codex@${VERSION}`, + }, + }); + + await ensureOnboardingPluginInstalled({ + cfg: { update: { channel: "stable" } }, + entry: { + pluginId: "codex", + label: "Codex", + install: { npmSpec: "@openclaw/codex" }, + trustedSourceLinkedOfficialInstall: true, + versionBoundToOpenClaw: true, + }, + prompter: { + select: vi.fn(async () => "npm"), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + } as never, + runtime: {} as never, + promptInstall: false, + }); + + const [npmCall] = readFirstMockCall(installPluginFromNpmSpec, "installPluginFromNpmSpec") as [ + NpmSpecInstallCall, + ]; + expect(npmCall.spec).toBe(`@openclaw/codex@${VERSION}`); + const [, recordUpdate] = readFirstMockCall(recordPluginInstall, "recordPluginInstall") as [ + OpenClawConfig, + PluginInstallRecord, + ]; + expect(recordUpdate.spec).toBe("@openclaw/codex"); + }); + it("logs npm install warnings once while shortening the progress label", async () => { const warning = "npm rejected managed npm alias overrides; retrying plugin install without alias overrides for this npm version."; diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts index 5b8108694991..fae69339d297 100644 --- a/src/commands/onboarding-plugin-install.ts +++ b/src/commands/onboarding-plugin-install.ts @@ -73,6 +73,8 @@ export type OnboardingPluginInstallEntry = { label: string; install: PluginPackageInstall; trustedSourceLinkedOfficialInstall?: boolean; + /** Keep this official runtime package on the same release cohort as OpenClaw. */ + versionBoundToOpenClaw?: boolean; preferRemoteInstall?: boolean; }; @@ -1145,6 +1147,7 @@ export async function ensureOnboardingPluginInstalled(params: { ? parseRegistryNpmSpec(npmSpec)?.name : undefined, coreVersion: VERSION, + versionBoundToCore: entry.versionBoundToOpenClaw, }) : null; const clawhubInstallSpec = clawhubSpecs?.installSpec ?? clawhubSpec; diff --git a/src/commands/runtime-plugin-install.ts b/src/commands/runtime-plugin-install.ts index 08ce7e0a9cd7..179283b3c5cd 100644 --- a/src/commands/runtime-plugin-install.ts +++ b/src/commands/runtime-plugin-install.ts @@ -20,6 +20,8 @@ type RuntimePluginInstallDescriptor = { label: string; npmSpec: string; warningLabel: string; + /** Keep this official runtime package on the same release cohort as OpenClaw. */ + versionBoundToOpenClaw?: boolean; }; /** Result returned after ensuring a runtime plugin for a selected model. */ @@ -139,6 +141,7 @@ async function ensureRuntimePluginForModelSelection(params: { defaultChoice: "npm", }, trustedSourceLinkedOfficialInstall: true, + ...(params.descriptor.versionBoundToOpenClaw ? { versionBoundToOpenClaw: true } : {}), }, prompter: params.prompter, runtime: params.runtime, diff --git a/src/gateway/server-methods/agent.base.test-utils.ts b/src/gateway/server-methods/agent.base.test-utils.ts index 85480966e6bf..ccaa0d56ccff 100644 --- a/src/gateway/server-methods/agent.base.test-utils.ts +++ b/src/gateway/server-methods/agent.base.test-utils.ts @@ -1492,13 +1492,7 @@ describe("gateway agent handler", () => { vi.useFakeTimers({ toFake: ["Date"] }); setDateOnlyFakeClockActive(true); vi.setSystemTime(now); - mocks.readTranscriptStatsSync.mockReturnValue({ - eventCount: 1, - lastMutationAtMs: now - 1_000, - lastObservedMutationAtMs: now - 10_000, - maxSeq: 0, - sizeBytes: 64, - }); + mocks.hasTerminalMainSessionTranscriptNewerThanRegistrySync.mockReturnValue(true); await withTestDir({ prefix: "openclaw-gateway-terminal-main-newer-" }, async (root) => { const sessionsDir = `${root}/sessions`; diff --git a/src/gateway/server-methods/agent.test-harness.ts b/src/gateway/server-methods/agent.test-harness.ts index 5a0f9be09e89..bb9f45c2a808 100644 --- a/src/gateway/server-methods/agent.test-harness.ts +++ b/src/gateway/server-methods/agent.test-harness.ts @@ -70,6 +70,7 @@ const mocks = vi.hoisted(() => ({ lastInteractionAt: entry?.lastInteractionAt, }), ), + hasTerminalMainSessionTranscriptNewerThanRegistrySync: vi.fn(() => false), lifecycleGeneration: "test-generation", })); @@ -99,6 +100,8 @@ vi.mock("../../config/sessions.js", async () => { return m?.[1] ?? "main"; }, resolveExplicitAgentSessionKey: mocks.resolveExplicitAgentSessionKey, + hasTerminalMainSessionTranscriptNewerThanRegistrySync: + mocks.hasTerminalMainSessionTranscriptNewerThanRegistrySync, resolveAgentMainSessionKey: ({ cfg, agentId, @@ -1089,6 +1092,7 @@ export const describe0AfterEach0 = () => { lastInteractionAt: entry?.lastInteractionAt, }), ); + mocks.hasTerminalMainSessionTranscriptNewerThanRegistrySync.mockReset().mockReturnValue(false); mocks.lifecycleGeneration = "test-generation"; dateOnlyFakeClockActive = false; vi.useRealTimers(); diff --git a/src/infra/npm-registry-spec.test.ts b/src/infra/npm-registry-spec.test.ts index 01718c1278cf..c635f38c464b 100644 --- a/src/infra/npm-registry-spec.test.ts +++ b/src/infra/npm-registry-spec.test.ts @@ -8,6 +8,7 @@ import { isPrereleaseSemverVersion, isPrereleaseResolutionAllowed, parseRegistryNpmSpec, + resolveOpenClawReleaseCohortVersion, resolveNpmJsonEntries, validateRegistryNpmSpec, } from "./npm-registry-spec.js"; @@ -147,6 +148,16 @@ describe("npm registry spec parsing helpers", () => { ])("compares OpenClaw release versions for %s and %s", ({ left, right, expected }) => { expect(compareOpenClawReleaseVersions(left, right)).toBe(expected); }); + + it.each([ + { version: "2026.7.1-2", expected: "2026.7.1" }, + { version: " 2026.7.1-1 ", expected: "2026.7.1" }, + { version: "2026.7.1", expected: "2026.7.1" }, + { version: "2026.7.1-beta.3", expected: "2026.7.1-beta.3" }, + { version: "1.2.3-1", expected: "1.2.3-1" }, + ])("resolves the OpenClaw release cohort for $version", ({ version, expected }) => { + expect(resolveOpenClawReleaseCohortVersion(version)).toBe(expected); + }); }); describe("npm prerelease resolution policy", () => { diff --git a/src/infra/npm-registry-spec.ts b/src/infra/npm-registry-spec.ts index 288a33299497..0526a1675168 100644 --- a/src/infra/npm-registry-spec.ts +++ b/src/infra/npm-registry-spec.ts @@ -201,6 +201,15 @@ function isOpenClawStableCorrectionVersion(value: string): boolean { return parsed !== null && isOpenClawCorrectionSemver(parsed); } +/** Resolves stable correction releases to their shared base release cohort. */ +export function resolveOpenClawReleaseCohortVersion(value: string): string { + const trimmed = value.trim(); + const parsed = parseOpenClawReleaseVersion(trimmed); + return parsed && isOpenClawCorrectionSemver(parsed) + ? `${parsed.major}.${parsed.minor}.${parsed.patch}` + : trimmed; +} + /** Compares OpenClaw monthly patch release versions across alpha, beta, stable, and corrections. */ export function compareOpenClawReleaseVersions(left: string, right: string): number | null { const parsedLeft = parseOpenClawReleaseVersion(left); diff --git a/src/plugins/install-channel-specs.test.ts b/src/plugins/install-channel-specs.test.ts index a2af927653f4..05041be0b2ba 100644 --- a/src/plugins/install-channel-specs.test.ts +++ b/src/plugins/install-channel-specs.test.ts @@ -75,6 +75,24 @@ describe("resolveNpmInstallSpecsForUpdateChannel", () => { }); }); + it.each([ + { channel: "stable" as const, expectedVersion: "2026.7.1" }, + { channel: "extended-stable" as const, expectedVersion: "2026.7.1" }, + ])("preserves the $channel release-cohort contract", ({ channel, expectedVersion }) => { + expect( + resolveNpmInstallSpecsForUpdateChannel({ + spec: "@openclaw/codex", + updateChannel: channel, + officialPackageName: "@openclaw/codex", + coreVersion: "2026.7.1-2", + versionBoundToCore: true, + }), + ).toEqual({ + installSpec: `@openclaw/codex@${expectedVersion}`, + recordSpec: "@openclaw/codex", + }); + }); + it("preserves beta behavior for a version-bound plugin", () => { expect( resolveNpmInstallSpecsForUpdateChannel({ diff --git a/src/plugins/install-channel-specs.ts b/src/plugins/install-channel-specs.ts index 9c9a6b657940..ac8cd80a9018 100644 --- a/src/plugins/install-channel-specs.ts +++ b/src/plugins/install-channel-specs.ts @@ -1,6 +1,10 @@ // Parses channel-oriented plugin install specs from package inputs. import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js"; -import { isExactSemverVersion, parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; +import { + isExactSemverVersion, + parseRegistryNpmSpec, + resolveOpenClawReleaseCohortVersion, +} from "../infra/npm-registry-spec.js"; import type { UpdateChannel } from "../infra/update-channels.js"; type ChannelInstallSpecs = { @@ -56,8 +60,11 @@ export function resolveNpmInstallSpecsForUpdateChannel(params: { `${policy} plugin resolution for ${target.name} requires an exact core version.`, ); } + const installVersion = params.versionBoundToCore + ? resolveOpenClawReleaseCohortVersion(coreVersion) + : coreVersion; return { - installSpec: `${target.name}@${coreVersion}`, + installSpec: `${target.name}@${installVersion}`, recordSpec: params.spec, }; } diff --git a/src/plugins/plugin-version-drift.ts b/src/plugins/plugin-version-drift.ts index c030332af45e..e77bfafed3a5 100644 --- a/src/plugins/plugin-version-drift.ts +++ b/src/plugins/plugin-version-drift.ts @@ -2,7 +2,10 @@ import type { OpenClawConfig } from "../config/types.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js"; -import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; +import { + parseRegistryNpmSpec, + resolveOpenClawReleaseCohortVersion, +} from "../infra/npm-registry-spec.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { resolveTrustedSourceLinkedOfficialClawHubInstall, @@ -38,7 +41,7 @@ function resolveExactNpmPinPackageName(entry: PluginVersionDriftEntry): string | export function resolvePluginVersionDriftUpdateCommand(entry: PluginVersionDriftEntry): string { const exactNpmPackageName = resolveExactNpmPinPackageName(entry); if (exactNpmPackageName) { - const exactNpmTarget = `${exactNpmPackageName}@${normalizeVersion(entry.gatewayVersion)}`; + const exactNpmTarget = `${exactNpmPackageName}@${resolveOpenClawReleaseCohortVersion(entry.gatewayVersion)}`; if (parseRegistryNpmSpec(exactNpmTarget)?.selectorKind === "exact-version") { return `openclaw plugins update ${exactNpmTarget}`; } @@ -46,15 +49,6 @@ export function resolvePluginVersionDriftUpdateCommand(entry: PluginVersionDrift return `openclaw plugins update ${entry.pluginId}`; } -/** - * Strip a trailing build qualifier (e.g. `2026.5.4-1` -> `2026.5.4`) so that - * a gateway packaged as `2026.5.4-1` is not reported as drifted from a - * plugin packaged as `2026.5.4`. Both ends are normalized identically. - */ -function normalizeVersion(value: string): string { - return value.replace(/-\d+$/, ""); -} - function isPluginEnabled(config: OpenClawConfig | undefined, pluginId: string): boolean { const normalizedPluginConfig = normalizePluginsConfig(config?.plugins); return resolveEffectiveEnableState({ @@ -104,7 +98,7 @@ export function detectPluginVersionDrift(params: { config?: OpenClawConfig; }): PluginVersionDriftReport { const { gatewayVersion, installRecords, config } = params; - const normalizedGateway = normalizeVersion(gatewayVersion); + const normalizedGateway = resolveOpenClawReleaseCohortVersion(gatewayVersion); const drifts: PluginVersionDriftEntry[] = []; for (const [pluginId, record] of Object.entries(installRecords)) { @@ -129,7 +123,7 @@ export function detectPluginVersionDrift(params: { // separately if desired. continue; } - if (normalizeVersion(installedVersion) === normalizedGateway) { + if (resolveOpenClawReleaseCohortVersion(installedVersion) === normalizedGateway) { continue; } drifts.push({ diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index a3d125b48af7..c83edeebc33e 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -3878,6 +3878,24 @@ grep -Fxq preserved "$TMPDIR/caller-fd" ); }); + it("serves the version-matched Codex candidate during package onboarding", () => { + const runner = readFileSync(CODEX_ON_DEMAND_DOCKER_E2E_PATH, "utf8"); + + expectTextToIncludeAll(runner, [ + "OPENCLAW_DOCKER_ALL_LANES=codex-on-demand", + "OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR=/tmp/openclaw-prepublish-plugin-registry", + "node scripts/e2e/lib/plugins/npm-registry-server.mjs", + 'OPENCLAW_NPM_REGISTRY_DIST_TAGS="latest=0.0.0,beta=$package_version"', + "OPENCLAW_NPM_REGISTRY_UPSTREAM=https://registry.npmjs.org", + ]); + expect(runner.indexOf("openclaw_e2e_install_package")).toBeLessThan( + runner.indexOf("\nconfigure_plugin_registry\n"), + ); + expect(runner.indexOf("\nconfigure_plugin_registry\n")).toBeLessThan( + runner.indexOf("\nopenclaw onboard --non-interactive"), + ); + }); + it("cleans package-backed onboarding and plugin Docker artifacts on every exit path", () => { for (const path of [ CODEX_ON_DEMAND_DOCKER_E2E_PATH, diff --git a/test/scripts/docker-e2e-plan.test.ts b/test/scripts/docker-e2e-plan.test.ts index 8c0c1a1f5957..ed1fcd7e9070 100644 --- a/test/scripts/docker-e2e-plan.test.ts +++ b/test/scripts/docker-e2e-plan.test.ts @@ -1463,6 +1463,8 @@ describe("scripts/lib/docker-e2e-plan", () => { expect(lane.timeoutMs).toBe(1_800_000); expect(plan.needs.bareImage).toBe(true); expect(plan.needs.package).toBe(true); + expect(plan.requiredPrepublishPluginPackages).toEqual(["@openclaw/codex"]); + expect(plan.needs.prepublishPluginRegistry).toBe(true); }); it("plans the plugin binding command escape lane as source Docker proof", () => {