diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index a9310978a99..f8bc454c7d5 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -3350,85 +3350,6 @@ jobs: shell: bash run: bash .github/scripts/docker-auth-cleanup.sh - sandbox-rebuild: - needs: generate-matrix - if: ${{ (github.event_name != 'workflow_dispatch' || (inputs.jobs == '' && inputs.targets == '')) || contains(format(',{0},', inputs.jobs), ',sandbox-rebuild,') || contains(format(',{0},', inputs.targets), ',sandbox-rebuild,') }} - runs-on: ubuntu-latest - timeout-minutes: 90 - env: - E2E_JOB: "1" - E2E_TARGET_ID: "sandbox-rebuild" - E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/sandbox-rebuild - NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js - NEMOCLAW_RUN_LIVE_E2E: "1" - NEMOCLAW_E2E_USE_HOSTED_INFERENCE: "1" - NEMOCLAW_NON_INTERACTIVE: "1" - NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" - OPENSHELL_GATEWAY: nemoclaw - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ inputs.checkout_repository || github.repository }} - ref: ${{ inputs.checkout_sha || github.sha }} - persist-credentials: false - - - *dockerhub-auth - - - id: selector_compatibility - name: Classify sandbox-rebuild candidate selector - env: - LEGACY_TEST_FILE: test/e2e/live/sandbox-rebuild.test.ts - shell: bash - run: | - set -euo pipefail - if [[ -f "$LEGACY_TEST_FILE" ]]; then - retired=false - else - retired=true - fi - printf 'retired=%s\n' "$retired" >> "$GITHUB_OUTPUT" - - - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 - - - name: Install OpenShell - if: ${{ steps.selector_compatibility.outputs.retired != 'true' }} - env: - NEMOCLAW_NON_INTERACTIVE: "1" - run: | - set -euo pipefail - env -u DOCKER_CONFIG -u DOCKERHUB_USERNAME -u DOCKERHUB_TOKEN -u NVIDIA_API_KEY -u NVIDIA_INFERENCE_API_KEY -u GITHUB_TOKEN bash scripts/install-openshell.sh - - - name: Run sandbox rebuild live test - if: ${{ steps.selector_compatibility.outputs.retired != 'true' }} - env: - NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }} - run: | - set -euo pipefail - export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH" - if command -v openshell >/dev/null 2>&1; then - OPENSHELL_BIN="$(command -v openshell)" - elif [ -x "$HOME/.local/bin/openshell" ]; then - OPENSHELL_BIN="$HOME/.local/bin/openshell" - else - echo "::error::OpenShell CLI not found after install" - ls -la /usr/local/bin/openshell "$HOME/.local/bin/openshell" 2>&1 || true - exit 1 - fi - export OPENSHELL_BIN - "$OPENSHELL_BIN" --version - npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/sandbox-rebuild.test.ts - - - name: Upload sandbox rebuild artifacts - if: always() - uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57 - - - name: Clean up Docker auth - if: always() - shell: bash - run: bash .github/scripts/docker-auth-cleanup.sh - - sandbox-rlimits-connect: needs: generate-matrix if: ${{ contains(format(',{0},', inputs.jobs), ',sandbox-rlimits-connect,') || contains(format(',{0},', inputs.targets), ',sandbox-rlimits-connect,') }} @@ -3601,85 +3522,6 @@ jobs: shell: bash run: bash .github/scripts/docker-auth-cleanup.sh - upgrade-stale-sandbox: - needs: generate-matrix - if: ${{ (github.event_name != 'workflow_dispatch' || (inputs.jobs == '' && inputs.targets == '')) || contains(format(',{0},', inputs.jobs), ',upgrade-stale-sandbox,') || contains(format(',{0},', inputs.targets), ',upgrade-stale-sandbox,') }} - runs-on: ubuntu-latest - timeout-minutes: 85 - env: - E2E_JOB: "1" - E2E_TARGET_ID: "upgrade-stale-sandbox" - E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/upgrade-stale-sandbox - NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js - NEMOCLAW_RUN_LIVE_E2E: "1" - NEMOCLAW_E2E_USE_HOSTED_INFERENCE: "1" - NEMOCLAW_NON_INTERACTIVE: "1" - NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" - NEMOCLAW_SANDBOX_NAME: "e2e-upgrade-stale" - OPENSHELL_GATEWAY: "nemoclaw" - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ inputs.checkout_repository || github.repository }} - ref: ${{ inputs.checkout_sha || github.sha }} - persist-credentials: false - - - *dockerhub-auth - - - id: selector_compatibility - name: Classify upgrade-stale-sandbox candidate selector - env: - LEGACY_TEST_FILE: test/e2e/live/upgrade-stale-sandbox.test.ts - shell: bash - run: | - set -euo pipefail - if [[ -f "$LEGACY_TEST_FILE" ]]; then - retired=false - else - retired=true - fi - printf 'retired=%s\n' "$retired" >> "$GITHUB_OUTPUT" - - - name: Prepare E2E workspace - uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@f6304bc25fc35bfaa441c8c2fbfee38f72805a75 - - - name: Install OpenShell CLI - if: ${{ steps.selector_compatibility.outputs.retired != 'true' }} - run: | - set -euo pipefail - env -u DOCKER_CONFIG -u DOCKERHUB_USERNAME -u DOCKERHUB_TOKEN -u NVIDIA_API_KEY -u NVIDIA_INFERENCE_API_KEY -u GITHUB_TOKEN \ - bash scripts/install-openshell.sh - - - name: Run upgrade stale sandbox live Vitest test - if: ${{ steps.selector_compatibility.outputs.retired != 'true' }} - env: - NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }} - run: | - set -euo pipefail - export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH" - if command -v openshell >/dev/null 2>&1; then - OPENSHELL_BIN="$(command -v openshell)" - elif [ -x "$HOME/.local/bin/openshell" ]; then - OPENSHELL_BIN="$HOME/.local/bin/openshell" - else - echo "::error::OpenShell CLI not found after install" - ls -la /usr/local/bin/openshell "$HOME/.local/bin/openshell" 2>&1 || true - exit 1 - fi - export OPENSHELL_BIN - "$OPENSHELL_BIN" --version - npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/upgrade-stale-sandbox.test.ts - - - name: Upload upgrade stale sandbox artifacts - if: always() - uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57 - - - name: Clean up Docker auth - if: always() - shell: bash - run: bash .github/scripts/docker-auth-cleanup.sh - - double-onboard: needs: generate-matrix if: ${{ (github.event_name != 'workflow_dispatch' || (inputs.jobs == '' && inputs.targets == '')) || contains(format(',{0},', inputs.jobs), ',double-onboard,') || contains(format(',{0},', inputs.targets), ',double-onboard,') }} @@ -5960,11 +5802,9 @@ jobs: rebuild-openclaw, rebuild-hermes, rebuild-hermes-stale-base, - sandbox-rebuild, sandbox-rlimits-connect, overlayfs-autofix, state-backup-restore, - upgrade-stale-sandbox, openshell-gateway-upgrade, token-rotation, messaging-compatible-endpoint, diff --git a/test/e2e/README.md b/test/e2e/README.md index 05797774eae..cdf2234e1b1 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -103,14 +103,31 @@ The retired `hermes-dashboard` selector remains a compatibility alias for the manually selected `mock`, `internal-nvidia`, or `public-nvidia` inference mode. -## Retired selector transition +## Retired selector compatibility -When a candidate no longer contains `test/e2e/live/sandbox-rebuild.test.ts` or -`test/e2e/live/upgrade-stale-sandbox.test.ts`, PR gate requests for the -corresponding job or target selector run focused replacement tests through the -compatibility controller. While the file remains, the selector runs its -dedicated live E2E test. `rebuild-openclaw` is the canonical live rebuild and -upgrade target. +PR gate requests using the retired `sandbox-rebuild` and +`upgrade-stale-sandbox` job or target selectors run focused replacement tests +through the compatibility controller. `rebuild-openclaw` is the canonical live +rebuild and upgrade target. + +## Current OpenClaw plugin EXDEV lifecycle + +The `openclaw-plugin-runtime-exdev` job keeps one current-version lifecycle: + +1. Onboard the custom weather plugin as v1. +2. Restart the gateway and verify v1. +3. Recreate the sandbox with the plugin changed to v2. +4. Run the cross-device runtime-dependency replacement probe. + +The recreation remains the replacement boundary. It verifies the v2 plugin +with runtime inspection, `tools.catalog`, and `tools.invoke`, and it preserves +the workspace marker. The job also keeps the test-only tmpfs mount, unchanged +stock policy-source bytes, and the distinct-device and source-side `EXDEV` +checks. The duplicate v3 rebuild is removed from this job. The +`rebuild-openclaw` job remains the canonical live rebuild coverage. + +The runtime target for `openclaw-plugin-runtime-exdev` is 16–17 minutes. +Scheduled-run timing for the reduced lifecycle has not yet been measured. ## Larger-runner routing @@ -572,9 +589,16 @@ and advisor concurrency groups include that eligibility, so an ignored metadata-edit run cannot cancel an eligible run for the same PR. The trusted controller reads all changed files after eligible PR CI completes and builds the deterministic risk plan. -Runtime families and changes to workflow-wired live tests select -canonical selectors from the trusted `e2e.yaml` inventory independently of -advisor output. Ordinary internal changes execute those focused selections. +Runtime families and changes to workflow-wired live tests or their owning +helpers select canonical jobs from the trusted `e2e.yaml` inventory +independently of advisor output. A workflow-wired live test or owning helper +selects one to three focused E2E journeys. A gateway-migration live test or +owning helper selects `openshell-gateway-upgrade`. + +Changes only under `test/e2e/support/` select no credentialed live E2E job. +The `e2e-support` Vitest project runs those support tests in PR CI. A new or +renamed live test that does not match the trusted workflow inventory keeps the +conservative control-plane floor until its canonical job mapping is added. Gate initialization, CI coordination, automatic internal dispatch, and fork maintainer approval share one non-cancelling FIFO concurrency group for the repository, PR number, PR SHA, and base SHA. `queue: max` keeps pending jobs for @@ -595,9 +619,10 @@ GitHub consequently returns no head-repository object. Shared sandbox-boundary changes have a floor of `full-e2e`, `hermes-e2e`, and `security-posture`. E2E control-plane changes select `cloud-onboard`, `cloud-inference`, and `security-posture`. The `e2e-control-plane` -family is a conservative path boundary that includes non-documentation files -under `tools/e2e/` and `test/e2e/`, plus the E2E and PR-CI workflows, risk -policy, dependency and test configuration, and preparation and upload actions. +family remains the conservative boundary for shared E2E tools, workflow and +security files, unknown live test paths, risk policy, dependency and test +configuration, and preparation and upload actions. These cross-cutting changes +keep the broad three-job floor. Repository-root `Dockerfile` changes additionally select `full-e2e` alongside the platform-install `cloud-onboard` floor so OpenClaw final-image changes run through cold onboarding and a real first turn. diff --git a/test/e2e/live/openclaw-plugin-runtime-exdev-lifecycle.ts b/test/e2e/live/openclaw-plugin-runtime-exdev-lifecycle.ts new file mode 100644 index 00000000000..206afe7bb73 --- /dev/null +++ b/test/e2e/live/openclaw-plugin-runtime-exdev-lifecycle.ts @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const CURRENT_LIFECYCLE_PHASES = [ + "confirm Docker CLI and clear the current plugin sandbox", + "clone and prepare the current plugin fixture", + "install and validate current OpenShell", + "build and onboard plugin v1", + "restart the gateway and confirm plugin v1", + "recreate the sandbox with plugin v2", + "prove cross-device runtime dependency replacement", +] as const; + +export type WeatherFixtureVersion = "v1" | "v2"; + +type LifecycleCommand = { + command: "node"; + args: string[]; +}; + +export function currentLifecycleCommands(input: { + cliEntrypoint: string; + dockerfilePath: string; + sandboxName: string; +}): { + onboard: LifecycleCommand; + recreate: LifecycleCommand; + restart: LifecycleCommand; +} { + return { + onboard: { + command: "node", + args: [ + input.cliEntrypoint, + "onboard", + "--fresh", + "--non-interactive", + "--yes-i-accept-third-party-software", + "--agent", + "openclaw", + "--from", + input.dockerfilePath, + ], + }, + recreate: { + command: "node", + args: [ + input.cliEntrypoint, + "onboard", + "--fresh", + "--recreate-sandbox", + "--non-interactive", + "--yes", + "--yes-i-accept-third-party-software", + "--name", + input.sandboxName, + "--agent", + "openclaw", + "--from", + input.dockerfilePath, + ], + }, + restart: { + command: "node", + args: [input.cliEntrypoint, input.sandboxName, "gateway", "restart"], + }, + }; +} diff --git a/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts b/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts index be2ce2ada0e..ac5c5c9a9a7 100644 --- a/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts +++ b/test/e2e/live/openclaw-plugin-runtime-exdev.test.ts @@ -35,6 +35,11 @@ import { RELEASE_SANDBOX_BASE_IMAGE_REF, resolveOpenClawPluginRuntimeExdevFixture, } from "./openclaw-plugin-runtime-exdev-fixture.ts"; +import { + CURRENT_LIFECYCLE_PHASES, + currentLifecycleCommands, + type WeatherFixtureVersion, +} from "./openclaw-plugin-runtime-exdev-lifecycle.ts"; import { createOpenShellDriverConfigTestWrapper, type OpenShellComponents, @@ -44,7 +49,7 @@ import { } from "./openshell-driver-config-test-wrapper.ts"; // Keep this contract as a focused live test: build a deterministic custom plugin -// on top of the complete managed runtime, prove it survives restart/rebuild, then +// on top of the complete managed runtime, prove it survives restart/recreation, then // run the in-sandbox Node replacement probe that guards #3513/#3127's EXDEV // cross-device runtime-deps failure mode. No registry or ledger is required. @@ -86,7 +91,6 @@ const CURRENT_BUILDER_IMAGE_REF = const TOOL_DISCLOSURE_ENV_REFERENCE = "${NEMOCLAW_TOOL_DISCLOSURE}"; const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-openclaw-plugin-exdev"; const ONBOARD_TIMEOUT_MS = 25 * 60_000; -const REBUILD_TIMEOUT_MS = 20 * 60_000; const PROBE_TIMEOUT_MS = 60_000; const EXDEV_TMPFS_MOUNT = "/tmp/nemoclaw-exdev-tmpfs"; const EXDEV_TMPFS_SOURCE = `${EXDEV_TMPFS_MOUNT}/source`; @@ -121,8 +125,6 @@ const EXDEV_PATTERNS = [ /EXDEV: cross-device link not permitted/i, /cross-device link not permitted/i, ]; -type WeatherFixtureVersion = "v1" | "v2" | "v3"; - const GATEWAY_CATALOG_CALL_SOURCE = String.raw` import { Buffer } from "node:buffer"; import { accessSync, constants, realpathSync } from "node:fs"; @@ -1159,19 +1161,10 @@ test("the release-baseline custom plugin loads with its exact NemoClaw and OpenS }); }); -test("the current-lifecycle custom plugin survives restart, recreation, and rebuild without EXDEV failures (#6108)", { - timeout: ONBOARD_TIMEOUT_MS * 2 + REBUILD_TIMEOUT_MS + 15 * 60_000, +test("the current-lifecycle custom plugin survives restart and recreation without EXDEV failures (#6108)", { + timeout: ONBOARD_TIMEOUT_MS * 2 + 15 * 60_000, meta: { - e2ePhases: [ - "confirm Docker CLI and clear the current plugin sandbox", - "clone and prepare the current plugin fixture", - "install and validate current OpenShell", - "build and onboard plugin v1", - "restart the gateway and confirm plugin v1", - "recreate the sandbox with plugin v2", - "rebuild the sandbox with plugin v3", - "prove cross-device runtime dependency replacement", - ], + e2ePhases: [...CURRENT_LIFECYCLE_PHASES], }, }, async ({ artifacts, cleanup, host, progress, sandbox, skip }) => { const fixture = resolveOpenClawPluginRuntimeExdevFixture(CURRENT_LIFECYCLE_TEST_SELECTOR); @@ -1183,10 +1176,10 @@ test("the current-lifecycle custom plugin survives restart, recreation, and rebu "the current CLI uses OpenShell 0.0.85 for current lifecycle coverage", "the CLI and Dockerfile use the same checkout source and a compatible sandbox base image", "gateway log, runtime inspection, tools.catalog, and tools.invoke prove weather/get_weather", - "custom-plugin v1 survives restart, recreation installs v2, and rebuild installs v3", - "workspace state survives both onboarding recreation and rebuild", + "custom-plugin v1 survives restart and recreation installs v2", + "workspace state survives onboarding recreation", `test-only driver config mounts tmpfs at ${EXDEV_TMPFS_MOUNT} without changing production policies`, - "stock OpenClaw policy source bytes remain unchanged through onboard and rebuild", + "stock OpenClaw policy source bytes remain unchanged through onboard and recreation", `sandbox proves ${EXDEV_TMPFS_SOURCE} and plugin-runtime-deps are distinct devices`, `legacy source-side staging fails with EXDEV across the same ${EXDEV_TMPFS_SOURCE} to plugin-runtime-deps boundary`, "OpenClaw-style target-side plugin runtime-deps replacement completes without EXDEV", @@ -1272,21 +1265,16 @@ test("the current-lifecycle custom plugin survives restart, recreation, and rebu "current OpenShell wrapper and components must pass onboard coherence preflight", ).toBe(true); const sandboxEnv = withOpenShellWrapperEnv(deploymentEnv, openshellWrapper, pinnedOpenshell); + const lifecycleCommands = currentLifecycleCommands({ + cliEntrypoint: CLI_ENTRYPOINT, + dockerfilePath: customPluginContext.dockerfilePath, + sandboxName: SANDBOX_NAME, + }); progress.phase("build and onboard plugin v1"); const onboard = await host.command( - "node", - [ - CLI_ENTRYPOINT, - "onboard", - "--fresh", - "--non-interactive", - "--yes-i-accept-third-party-software", - "--agent", - "openclaw", - "--from", - customPluginContext.dockerfilePath, - ], + lifecycleCommands.onboard.command, + lifecycleCommands.onboard.args, { artifactName: "openclaw-plugin-exdev-onboard", env: sandboxEnv, @@ -1309,11 +1297,15 @@ test("the current-lifecycle custom plugin survives restart, recreation, and rebu ); progress.phase("restart the gateway and confirm plugin v1"); - const restart = await host.command("node", [CLI_ENTRYPOINT, SANDBOX_NAME, "gateway", "restart"], { - artifactName: "openclaw-weather-plugin-gateway-restart", - env: sandboxEnv, - timeoutMs: 180_000, - }); + const restart = await host.command( + lifecycleCommands.restart.command, + lifecycleCommands.restart.args, + { + artifactName: "openclaw-weather-plugin-gateway-restart", + env: sandboxEnv, + timeoutMs: 180_000, + }, + ); expect(restart.exitCode, resultText(restart)).toBe(0); const weatherAfterRestart = await assertWeatherPluginRuntime( sandbox, @@ -1327,28 +1319,14 @@ test("the current-lifecycle custom plugin survives restart, recreation, and rebu const workspaceMarker = `plugin-lifecycle-${randomUUID()}`; await writeWorkspaceMarker(sandbox, workspaceMarker); - // Change an actual build-context input so rebuild must produce a distinct - // plugin artifact. Onboarding recreation must preserve the fresh v2 + // Change an actual build-context input so recreation must produce a distinct + // plugin artifact. Recreation must preserve the fresh v2 // extension instead of replacing it with the backed-up v1 directory. progress.phase("recreate the sandbox with plugin v2"); writeCustomPluginVersion(customPluginContext.versionSourcePath, "v2"); const recreate = await host.command( - "node", - [ - CLI_ENTRYPOINT, - "onboard", - "--fresh", - "--recreate-sandbox", - "--non-interactive", - "--yes", - "--yes-i-accept-third-party-software", - "--name", - SANDBOX_NAME, - "--agent", - "openclaw", - "--from", - customPluginContext.dockerfilePath, - ], + lifecycleCommands.recreate.command, + lifecycleCommands.recreate.args, { artifactName: "openclaw-weather-plugin-recreate", env: sandboxEnv, @@ -1368,28 +1346,6 @@ test("the current-lifecycle custom plugin survives restart, recreation, and rebu expect(weatherAfterRecreate.imageMarker).not.toBe(weatherAfterOnboard.imageMarker); await assertWorkspaceMarker(sandbox, "after-recreate", workspaceMarker); - // A subsequent rebuild exercises the same semantic recreated-sandbox - // restore boundary with another fresh image artifact. - progress.phase("rebuild the sandbox with plugin v3"); - writeCustomPluginVersion(customPluginContext.versionSourcePath, "v3"); - const rebuild = await host.command("node", [CLI_ENTRYPOINT, SANDBOX_NAME, "rebuild", "--yes"], { - artifactName: "openclaw-weather-plugin-rebuild", - env: sandboxEnv, - timeoutMs: REBUILD_TIMEOUT_MS, - }); - expect(rebuild.exitCode, resultText(rebuild)).toBe(0); - const tmpfsMountedAfterRebuild = await assertExdevTmpfsMounted(sandbox, "after-rebuild"); - assertPolicySourcesUnchanged(policySourceSnapshot, "rebuild"); - const weatherAfterRebuild = await assertWeatherPluginRuntime( - sandbox, - "after-rebuild", - "v3", - customPluginContext.runtimeOpenClawVersion, - fixture.openClawModulePath, - ); - expect(weatherAfterRebuild.imageMarker).not.toBe(weatherAfterRecreate.imageMarker); - await assertWorkspaceMarker(sandbox, "after-rebuild", workspaceMarker); - progress.phase("prove cross-device runtime dependency replacement"); const df = await sandbox.execShell( SANDBOX_NAME, @@ -1426,7 +1382,6 @@ test("the current-lifecycle custom plugin survives restart, recreation, and rebu onboardExitCode: onboard.exitCode, restartExitCode: restart.exitCode, recreateExitCode: recreate.exitCode, - rebuildExitCode: rebuild.exitCode, filesystemProbeExitCode: df.exitCode, runtimeDepsProbeExitCode: probe.exitCode, runtimeOpenClawVersion: customPluginContext.runtimeOpenClawVersion, @@ -1444,10 +1399,6 @@ test("the current-lifecycle custom plugin survives restart, recreation, and rebu weatherAfterRecreate.inspectLoaded && weatherAfterRecreate.catalogToolIds.includes("get_weather") && weatherAfterRecreate.toolInvoked, - weatherAfterRebuild: - weatherAfterRebuild.inspectLoaded && - weatherAfterRebuild.catalogToolIds.includes("get_weather") && - weatherAfterRebuild.toolInvoked, v1MarkerStableThroughRestart: weatherAfterOnboard.imageMarker === weatherAfterRestart.imageMarker && weatherAfterOnboard.fixtureVersion === "v1" && @@ -1455,9 +1406,6 @@ test("the current-lifecycle custom plugin survives restart, recreation, and rebu recreatedV2ReplacedV1: weatherAfterRecreate.imageMarker !== weatherAfterOnboard.imageMarker && weatherAfterRecreate.fixtureVersion === "v2", - rebuiltV3ReplacedV2: - weatherAfterRebuild.imageMarker !== weatherAfterRecreate.imageMarker && - weatherAfterRebuild.fixtureVersion === "v3", distinctDevices: /source_device=\d+ target_device=\d+/.test(probeText), sourceSideExdevSelfCheck: probeText.includes( "source-side staging failure self-check completed", @@ -1465,8 +1413,7 @@ test("the current-lifecycle custom plugin survives restart, recreation, and rebu noExdevSignature: !EXDEV_PATTERNS.some((pattern) => pattern.test(probeText)), successMarker: probeText.includes("runtime deps replacement completed"), workspaceStatePreserved: true, - testOnlyTmpfsMounted: - tmpfsMountedAfterOnboard && tmpfsMountedAfterRecreate && tmpfsMountedAfterRebuild, + testOnlyTmpfsMounted: tmpfsMountedAfterOnboard && tmpfsMountedAfterRecreate, stockPolicySourcesUnchanged: true, }, }); diff --git a/test/e2e/live/sandbox-rebuild.test.ts b/test/e2e/live/sandbox-rebuild.test.ts deleted file mode 100644 index dc32f56a2db..00000000000 --- a/test/e2e/live/sandbox-rebuild.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; -import { resultText } from "../fixtures/clients/command.ts"; -import { expect, test } from "../fixtures/e2e-test.ts"; -import { - latestRebuildBackupDir, - listCredentialLeakPaths, - patchRegistrySandboxEntry, - readRegistrySandboxEntry, - restoreRegistryAndSession, - snapshotRegistryAndSession, -} from "../fixtures/phases/state-validation.ts"; -import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; - -// This dependent migration reuses the rebuild/state helper shape seeded by the -// OpenClaw rebuild anchor while keeping the contract focused: onboard a real -// sandbox, mark workspace state, force stale registry metadata, run the real -// `nemoclaw rebuild --yes`, then verify state preservation, registry -// refresh, and backup credential hygiene. - -const MARKER_FILE = "/sandbox/.openclaw/workspace/rebuild-marker.txt"; -const STALE_AGENT_VERSION = "0.0.1"; -const TEST_SANDBOX_PREFIX = "e2e-sandbox-rebuild"; -const SANDBOX_NAME = - process.env.NEMOCLAW_SANDBOX_NAME ?? - [TEST_SANDBOX_PREFIX, process.env.GITHUB_RUN_ID, process.env.GITHUB_RUN_ATTEMPT, process.pid] - .filter(Boolean) - .join("-"); -const TEST_TIMEOUT_MS = Number(process.env.NEMOCLAW_E2E_TIMEOUT_SECONDS ?? 1_200) * 1_000; -const STATUS_TIMEOUT_MS = 60_000; -const ONBOARD_TIMEOUT_MS = TEST_TIMEOUT_MS; -const REBUILD_TIMEOUT_MS = TEST_TIMEOUT_MS; -const MARKER_CONTENT = `REBUILD_E2E_${Date.now()}`; - -function sandboxRebuildEnv(apiKey: string, extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { - return { - ...buildAvailabilityProbeEnv(), - ...extra, - NVIDIA_INFERENCE_API_KEY: apiKey, - NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", - NEMOCLAW_NON_INTERACTIVE: "1", - NEMOCLAW_SANDBOX_NAME: SANDBOX_NAME, - }; -} - -function assertTestOwnedSandboxName(): void { - if (!SANDBOX_NAME.startsWith(TEST_SANDBOX_PREFIX)) { - throw new Error( - `sandbox-rebuild live test is destructive and only accepts sandbox names with prefix ${TEST_SANDBOX_PREFIX}; got ${SANDBOX_NAME}`, - ); - } -} - -async function bestEffortPreclean(run: () => Promise): Promise { - try { - await run(); - } catch { - // Cleanup remains best-effort so earlier lifecycle failures stay visible. - } -} - -// biome-ignore format: preserve legacy live-test body formatting so phase-only changes stay reviewable. -test( - "sandbox-rebuild: rebuild preserves marker state and refreshes registry metadata", - { - timeout: TEST_TIMEOUT_MS * 3, - meta: { - e2ePhases: [ - "confirm Docker and environment readiness", - "onboard the OpenClaw rebuild sandbox", - "write state and mark the registry stale", - "rebuild the stale sandbox", - "confirm marker preservation and registry refresh", - "scan the rebuild backup for credential leaks", - ], - }, - }, - async ({ - artifacts, - cleanup, - environment, - host, - lifecycle, - onboard, - progress, - sandbox, - secrets, - skip, - stateValidation, - }) => { - assertTestOwnedSandboxName(); - const apiKey = secrets.required("NVIDIA_INFERENCE_API_KEY"); - const dockerInfo = await host.command("docker", ["info"], { - artifactName: "prereq-docker-info", - env: buildAvailabilityProbeEnv(), - timeoutMs: 30_000, - }); - if (dockerInfo.exitCode !== 0) { - if (process.env.GITHUB_ACTIONS === "true") { - throw new Error( - `Docker is required for sandbox-rebuild live coverage: ${resultText(dockerInfo)}`, - ); - } - skip("Docker is required for sandbox-rebuild live coverage"); - } - - const ready = await environment.assertReady({ - platform: "ubuntu-local", - install: "repo-current", - runtime: "docker-running", - onboarding: "cloud-openclaw", - }); - - await artifacts.writeJson("contract.json", { - sandboxName: SANDBOX_NAME, - markerFile: MARKER_FILE, - staleAgentVersion: STALE_AGENT_VERSION, - preservedBoundaries: [ - "real nemoclaw onboard with Docker/OpenShell", - "openshell sandbox exec marker write/read", - "local registry stale agentVersion mutation", - "real nemoclaw rebuild --yes", - "backup credential leak scan under ~/.nemoclaw/rebuild-backups", - ], - }); - - const stateSnapshot = snapshotRegistryAndSession(); - const backupRoot = path.join( - process.env.HOME ?? os.homedir(), - ".nemoclaw", - "rebuild-backups", - SANDBOX_NAME, - ); - cleanup.trackDisposable(`restore NemoClaw state files for ${SANDBOX_NAME}`, () => { - restoreRegistryAndSession(stateSnapshot); - fs.rmSync(backupRoot, { recursive: true, force: true }); - }); - if (process.env.NEMOCLAW_E2E_KEEP_SANDBOX !== "1") { - cleanup.trackDisposable(`delete OpenShell sandbox ${SANDBOX_NAME}`, () => - sandbox.cleanupSandbox(SANDBOX_NAME, { - artifactName: "cleanup-openshell-sandbox-delete", - env: buildAvailabilityProbeEnv(), - timeoutMs: 60_000, - }), - ); - cleanup.trackSandbox(host, SANDBOX_NAME, { - artifactName: "cleanup-nemoclaw-destroy", - env: buildAvailabilityProbeEnv(), - timeoutMs: 15 * 60_000, - }); - } - - await bestEffortPreclean(() => - onboard.destroySandbox(SANDBOX_NAME, "pre-cleanup-nemoclaw-destroy"), - ); - await bestEffortPreclean(() => - sandbox.openshell(["sandbox", "delete", SANDBOX_NAME], { - artifactName: "pre-cleanup-openshell-sandbox-delete", - env: buildAvailabilityProbeEnv(), - timeoutMs: 60_000, - }), - ); - - progress.phase("onboard the OpenClaw rebuild sandbox"); - const instance = await onboard.from(ready, { - sandboxName: SANDBOX_NAME, - timeoutMs: ONBOARD_TIMEOUT_MS, - }); - - const status = await host.nemoclaw([SANDBOX_NAME, "status"], { - artifactName: "phase-2-status-version-detection", - env: sandboxRebuildEnv(apiKey), - redactionValues: [apiKey], - timeoutMs: STATUS_TIMEOUT_MS, - }); - await artifacts.writeText("phase-2-status-output.txt", resultText(status)); - if (/Agent:.*v?\d+\.\d+/i.test(resultText(status))) { - await artifacts.writeJson("phase-2-status-version-summary.json", { versionVisible: true }); - } else { - await artifacts.writeJson("phase-2-status-version-summary.json", { - versionVisible: false, - note: "Legacy shell accepted first-run status output without cached version.", - }); - } - - progress.phase("write state and mark the registry stale"); - await stateValidationWriteMarker(); - - patchRegistrySandboxEntry(SANDBOX_NAME, { agentVersion: STALE_AGENT_VERSION }); - await artifacts.writeJson("phase-4-stale-registry-summary.json", { - sandboxName: SANDBOX_NAME, - agentVersion: readRegistrySandboxEntry(SANDBOX_NAME).agentVersion, - }); - - const staleStatus = await host.nemoclaw([SANDBOX_NAME, "status"], { - artifactName: "phase-4-status-stale-warning", - env: sandboxRebuildEnv(apiKey), - redactionValues: [apiKey], - timeoutMs: STATUS_TIMEOUT_MS, - }); - expect(staleStatus.exitCode, resultText(staleStatus)).toBe(0); - expect(resultText(staleStatus)).toMatch(/rebuild/i); - - progress.phase("rebuild the stale sandbox"); - await lifecycle.rebuildSandbox(instance, { - artifactName: "phase-5-nemoclaw-rebuild", - env: sandboxRebuildEnv(apiKey), - redactionValues: [apiKey], - timeoutMs: REBUILD_TIMEOUT_MS, - }); - await lifecycle.assertSandboxReadyAfterRebuild(instance, { - artifactNamePrefix: "phase-5-sandbox-ready-after-rebuild", - env: buildAvailabilityProbeEnv(), - attempts: 12, - delayMs: 5_000, - }); - - progress.phase("confirm marker preservation and registry refresh"); - await stateValidation.expectMarkerFileContent(instance, MARKER_FILE, MARKER_CONTENT, { - artifactName: "phase-6-read-marker-after-rebuild", - env: buildAvailabilityProbeEnv(), - timeoutMs: 60_000, - }); - - const updatedVersion = stateValidation.expectRegistryAgentVersionUpdated( - SANDBOX_NAME, - STALE_AGENT_VERSION, - ); - await artifacts.writeJson("phase-7-registry-version-summary.json", { - sandboxName: SANDBOX_NAME, - staleVersion: STALE_AGENT_VERSION, - updatedVersion, - }); - - progress.phase("scan the rebuild backup for credential leaks"); - const backupDir = latestRebuildBackupDir(SANDBOX_NAME); - const leaks = listCredentialLeakPaths(backupDir, { extraSecrets: [apiKey] }); - await artifacts.writeJson("phase-8-backup-credential-scan.json", { - backupDir: backupDir ?? null, - leaks, - note: backupDir ? undefined : "No backup directory found; former shell skipped this check.", - }); - expect(leaks, "backup files must not contain credential-shaped values").toEqual([]); - - async function stateValidationWriteMarker(): Promise { - await stateValidation.writeMarkerFile(instance, MARKER_FILE, MARKER_CONTENT, { - artifactName: "phase-3-write-marker", - env: buildAvailabilityProbeEnv(), - timeoutMs: 60_000, - }); - await stateValidation.expectMarkerFileContent(instance, MARKER_FILE, MARKER_CONTENT, { - artifactName: "phase-3-read-marker-before-rebuild", - env: buildAvailabilityProbeEnv(), - timeoutMs: 60_000, - }); - } - }, -); diff --git a/test/e2e/live/upgrade-stale-sandbox-helpers.ts b/test/e2e/live/upgrade-stale-sandbox-helpers.ts deleted file mode 100644 index 588ba3bc8ce..00000000000 --- a/test/e2e/live/upgrade-stale-sandbox-helpers.ts +++ /dev/null @@ -1,327 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { findAvailableDashboardPort } from "../../../src/lib/onboard/dashboard-port.ts"; -import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; -import type { CleanupRegistry } from "../fixtures/cleanup.ts"; -import type { HostCliClient } from "../fixtures/clients/host.ts"; -import { assertExitZero, resultText } from "../fixtures/clients/index.ts"; -import { type SandboxClient, validateSandboxName } from "../fixtures/clients/sandbox.ts"; -import { expect } from "../fixtures/e2e-test.ts"; -import { - readJsonFileOrFallback, - restoreFile, - snapshotFile, - writeJsonFile, -} from "../fixtures/file-state.ts"; -import { REPO_ROOT } from "../fixtures/paths.ts"; -import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; -import { isTransientProviderValidationFailure } from "./network-policy-transient-provider.ts"; -import { createOldBaseBuildContext } from "./rebuild-openclaw-old-base-context.ts"; - -export { REPO_ROOT }; - -const TEST_SANDBOX_PREFIX = "e2e-upgrade-stale"; -export const SANDBOX_NAME = - process.env.NEMOCLAW_SANDBOX_NAME ?? - [TEST_SANDBOX_PREFIX, process.env.GITHUB_RUN_ID, process.env.GITHUB_RUN_ATTEMPT, process.pid] - .filter(Boolean) - .join("-"); -export const SIBLING_SANDBOX_NAME = `${SANDBOX_NAME}-peer`; -export const SANDBOX_NAMES = [SANDBOX_NAME, SIBLING_SANDBOX_NAME] as const; -validateSandboxName(SANDBOX_NAME); -validateSandboxName(SIBLING_SANDBOX_NAME); -assertSafeSandboxName(); -export const OLD_OPENCLAW_VERSION = "2026.3.11"; -export const OLD_BASE_TAG = `nemoclaw-old-base:${SANDBOX_NAME.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-")}`; -const REGISTRY_FILE = path.join(os.homedir(), ".nemoclaw", "sandboxes.json"); -const SESSION_FILE = path.join(os.homedir(), ".nemoclaw", "onboard-session.json"); -const INSTALL_ATTEMPTS = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true" ? 3 : 1; - -function assertSafeSandboxName(): void { - for (const sandboxName of SANDBOX_NAMES) { - if (!sandboxName.startsWith(TEST_SANDBOX_PREFIX)) { - throw new Error( - `upgrade-stale-sandbox live test is destructive and only accepts sandbox names with prefix ${TEST_SANDBOX_PREFIX}; got ${sandboxName}`, - ); - } - } -} - -export function commandEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { - return { - ...buildAvailabilityProbeEnv(), - ...extra, - NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", - NEMOCLAW_NON_INTERACTIVE: "1", - NEMOCLAW_REBUILD_VERBOSE: "1", - NEMOCLAW_RECREATE_SANDBOX: "1", - NEMOCLAW_SANDBOX_NAME: SANDBOX_NAME, - OPENSHELL_GATEWAY: process.env.OPENSHELL_GATEWAY ?? "nemoclaw", - }; -} - -async function bestEffortPreclean(run: () => Promise): Promise { - try { - await run(); - } catch { - // Cleanup must not mask the primary assertion failure. - } -} - -export function allocateSiblingDashboardPort(forwardListOutput: string | null): number { - const registry = readJsonFileOrFallback<{ - sandboxes?: Record>; - }>(REGISTRY_FILE, {}); - const primaryDashboardPort = registry.sandboxes?.[SANDBOX_NAME]?.dashboardPort; - expect( - typeof primaryDashboardPort === "number" && - Number.isInteger(primaryDashboardPort) && - primaryDashboardPort > 0 && - primaryDashboardPort <= 65535, - "initial onboard must persist the dashboard port used by authoritative rebuild", - ).toBe(true); - const occupied = new Map([[String(primaryDashboardPort), SANDBOX_NAME]]); - return findAvailableDashboardPort( - SIBLING_SANDBOX_NAME, - primaryDashboardPort === 18790 ? 18791 : 18790, - forwardListOutput, - undefined, - occupied, - ); -} - -export function writeStaleRegistryEntries(siblingDashboardPort: number): void { - const session = readJsonFileOrFallback>(SESSION_FILE, {}); - const envProvider = - process.env.NEMOCLAW_PROVIDER === "custom" - ? "compatible-endpoint" - : process.env.NEMOCLAW_PROVIDER; - const provider = - typeof session.provider === "string" && session.provider - ? session.provider - : envProvider || "compatible-endpoint"; - const model = - (typeof session.model === "string" && session.model) || - process.env.NEMOCLAW_MODEL || - process.env.NEMOCLAW_COMPAT_MODEL || - "nvidia/nvidia/nemotron-3-ultra"; - const registry = readJsonFileOrFallback<{ - sandboxes?: Record>; - defaultSandbox?: string; - }>(REGISTRY_FILE, {}); - const currentEntry = registry.sandboxes?.[SANDBOX_NAME] ?? {}; - const dashboardPort = currentEntry.dashboardPort; - expect( - typeof dashboardPort === "number" && - Number.isInteger(dashboardPort) && - dashboardPort > 0 && - dashboardPort <= 65535, - "initial onboard must persist the dashboard port used by authoritative rebuild", - ).toBe(true); - const endpointUrl = - (typeof currentEntry.endpointUrl === "string" && currentEntry.endpointUrl) || - (typeof session.endpointUrl === "string" && session.endpointUrl) || - null; - const preferredInferenceApi = - (typeof currentEntry.preferredInferenceApi === "string" && - currentEntry.preferredInferenceApi) || - (typeof session.preferredInferenceApi === "string" && session.preferredInferenceApi) || - null; - if (provider === "compatible-endpoint" || provider === "compatible-anthropic-endpoint") { - expect(endpointUrl, "custom stale route must retain its durable endpoint").toBeTruthy(); - expect( - preferredInferenceApi, - "custom stale route must retain its durable inference API family", - ).toBeTruthy(); - } - registry.sandboxes = registry.sandboxes ?? {}; - for (const [sandboxName, assignedDashboardPort] of [ - [SANDBOX_NAME, dashboardPort], - [SIBLING_SANDBOX_NAME, siblingDashboardPort], - ] as const) { - registry.sandboxes[sandboxName] = { - name: sandboxName, - createdAt: new Date().toISOString(), - model, - provider, - endpointUrl, - preferredInferenceApi, - gpuEnabled: false, - policies: [], - policyTier: null, - fromDockerfile: null, - dashboardPort: assignedDashboardPort, - gatewayName: "nemoclaw", - openshellVersion: "0.0.71", - nemoclawVersion: "0.0.71", - agent: null, - agentVersion: OLD_OPENCLAW_VERSION, - // Deliberately omit credentialEnv on both legacy rows. Rebuild must - // migrate the shared provider identity before deleting either sandbox. - }; - } - registry.defaultSandbox = SANDBOX_NAME; - writeJsonFile(REGISTRY_FILE, registry); - writeJsonFile(SESSION_FILE, { ...session, sandboxName: SANDBOX_NAME, status: "complete" }); -} - -export function assertDockerAvailable( - result: ShellProbeResult, - skip: (note?: string) => never, -): void { - result.exitCode === 0 || process.env.GITHUB_ACTIONS === "true" - ? undefined - : skip(`Docker is required for stale sandbox upgrade E2E: ${resultText(result)}`); - result.exitCode === 0 || - process.env.GITHUB_ACTIONS !== "true" || - (() => { - throw new Error(`Docker is required for stale sandbox upgrade E2E: ${resultText(result)}`); - })(); -} - -export function registerStateRestore(cleanup: Pick): void { - const registrySnapshot = snapshotFile(REGISTRY_FILE); - const sessionSnapshot = snapshotFile(SESSION_FILE); - cleanup.trackDisposable(`restore NemoClaw state files for ${SANDBOX_NAME}`, () => { - restoreFile(REGISTRY_FILE, registrySnapshot); - restoreFile(SESSION_FILE, sessionSnapshot); - }); -} - -export async function precleanStaleSandbox( - host: HostCliClient, - sandbox: SandboxClient, -): Promise { - for (const sandboxName of SANDBOX_NAMES) { - await bestEffortPreclean(() => - host.nemoclaw([sandboxName, "destroy", "--yes"], { - artifactName: `cleanup-nemoclaw-destroy-${sandboxName}`, - env: commandEnv(), - timeoutMs: 120_000, - }), - ); - await bestEffortPreclean(() => - sandbox.openshell(["sandbox", "delete", sandboxName], { - artifactName: `cleanup-openshell-delete-${sandboxName}`, - env: commandEnv(), - timeoutMs: 60_000, - }), - ); - } -} - -export async function cleanupOldImage(host: HostCliClient): Promise { - const result = await host.command("docker", ["image", "rm", "-f", OLD_BASE_TAG], { - artifactName: "cleanup-docker-image-upgrade-stale", - env: buildAvailabilityProbeEnv(), - timeoutMs: 60_000, - }); - if (result.exitCode === 0 || /No such image|image[^\n]*not found/i.test(resultText(result))) - return; - assertExitZero(result, `cleanup Docker image ${OLD_BASE_TAG}`); -} - -export async function installCurrentNemoclaw( - host: HostCliClient, - hosted: { apiKey: string; env: NodeJS.ProcessEnv }, -): Promise { - let install: ShellProbeResult | undefined; - for (let attempt = 1; attempt <= INSTALL_ATTEMPTS; attempt += 1) { - install = await host.command("bash", ["install.sh", "--non-interactive"], { - artifactName: - attempt === 1 - ? "phase-1-install-current-nemoclaw" - : `phase-1-install-current-nemoclaw-attempt-${attempt}`, - cwd: REPO_ROOT, - env: commandEnv(hosted.env), - redactionValues: [hosted.apiKey], - timeoutMs: 20 * 60_000, - }); - const retry = - install.exitCode !== 0 && - isTransientProviderValidationFailure(install) && - attempt < INSTALL_ATTEMPTS; - install.exitCode === 0 && (attempt = INSTALL_ATTEMPTS + 1); - retry && (await new Promise((resolve) => setTimeout(resolve, 10_000 * attempt))); - !retry && install.exitCode !== 0 && (attempt = INSTALL_ATTEMPTS + 1); - } - if (!install) throw new Error("install command did not run"); - return install; -} - -export function assertDeleteInstalledSandboxAllowed(result: ShellProbeResult): void { - result.exitCode === 0 || expect(result.exitCode, resultText(result)).toBe(1); - result.exitCode === 0 || - expect(resultText(result)).toMatch(/not found|does not exist|no sandbox/i); -} - -export async function buildOldOpenClawBase(host: HostCliClient): Promise { - const oldBaseBuildContext = createOldBaseBuildContext(); - try { - return await host.command( - "docker", - [ - "build", - "--build-arg", - `OPENCLAW_VERSION=${OLD_OPENCLAW_VERSION}`, - "--build-arg", - "NEMOCLAW_E2E_FIXTURE_LEGACY_OPENCLAW=1", - "-f", - path.join(REPO_ROOT, "Dockerfile.base"), - "-t", - OLD_BASE_TAG, - oldBaseBuildContext, - ], - { - artifactName: "phase-2-build-old-openclaw-base", - env: buildAvailabilityProbeEnv(), - timeoutMs: 20 * 60_000, - }, - ); - } finally { - fs.rmSync(oldBaseBuildContext, { recursive: true, force: true }); - } -} - -export function createFixtureDockerfile(cleanup: Pick): string { - const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-old-openclaw-")); - cleanup.trackDisposable("remove stale sandbox fixture Dockerfile", () => - fs.rmSync(fixtureDir, { recursive: true, force: true }), - ); - const fixtureDockerfile = path.join(fixtureDir, "Dockerfile"); - fs.writeFileSync( - fixtureDockerfile, - [ - `FROM ${OLD_BASE_TAG}`, - "USER sandbox", - "WORKDIR /sandbox", - "RUN mkdir -p /sandbox/.openclaw/workspace /sandbox/.openclaw && echo '{}' > /sandbox/.openclaw/openclaw.json", - 'CMD ["/bin/bash"]', - "", - ].join("\n"), - ); - return fixtureDockerfile; -} - -export async function waitSandboxReady( - host: HostCliClient, - sandboxName: string, - artifactName: string, -): Promise { - return await host.command( - "bash", - [ - "-lc", - `for _i in $(seq 1 30); do openshell sandbox list 2>/dev/null | grep -q '${sandboxName}.*Ready' && exit 0; sleep 5; done; openshell sandbox list >&2; exit 1`, - ], - { artifactName, env: commandEnv(), timeoutMs: 180_000 }, - ); -} - -export function registeredStaleSandboxJson(): string { - return fs.readFileSync(REGISTRY_FILE, "utf8"); -} diff --git a/test/e2e/live/upgrade-stale-sandbox.test.ts b/test/e2e/live/upgrade-stale-sandbox.test.ts deleted file mode 100644 index d328556e9f6..00000000000 --- a/test/e2e/live/upgrade-stale-sandbox.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -/** - * Preserves the #1904 contract with real Docker/OpenShell/NemoClaw - * boundaries: onboard current NemoClaw, create an old OpenClaw sandbox from a - * real image, register two legacy sandboxes on one gateway, prove - * upgrade-sandboxes detects both, rebuild them as one batch, and prove the - * stale version and missing shared-route credential metadata are gone. - */ - -import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; -import { resultText } from "../fixtures/clients/index.ts"; -import { expect, test } from "../fixtures/e2e-test.ts"; -import { requireHostedInferenceConfig } from "../fixtures/hosted-inference.ts"; -import { - allocateSiblingDashboardPort, - assertDeleteInstalledSandboxAllowed, - assertDockerAvailable, - buildOldOpenClawBase, - cleanupOldImage, - commandEnv, - createFixtureDockerfile, - installCurrentNemoclaw, - OLD_OPENCLAW_VERSION, - precleanStaleSandbox, - registeredStaleSandboxJson, - registerStateRestore, - SANDBOX_NAME, - SANDBOX_NAMES, - SIBLING_SANDBOX_NAME, - waitSandboxReady, - writeStaleRegistryEntries, -} from "./upgrade-stale-sandbox-helpers.ts"; - -const LIVE_TIMEOUT_MS = 75 * 60_000; - -test("upgrade-sandboxes rebuilds two legacy sandboxes on one shared route (#1904, #7798)", { - timeout: LIVE_TIMEOUT_MS, - meta: { - e2ePhases: [ - "confirm Docker and install current NemoClaw", - "construct two old OpenClaw sandboxes on one gateway", - "register stale shared-route sandbox metadata", - "detect both stale sandboxes", - "rebuild both to the current OpenClaw runtime", - "confirm the upgrade check is clean", - ], - }, -}, async ({ artifacts, cleanup, host, progress, sandbox, secrets, skip }) => { - const hosted = requireHostedInferenceConfig(secrets); - - await artifacts.target.declare({ - id: "upgrade-stale-sandbox", - boundary: "install.sh + Docker old base image + OpenShell sandbox create + NemoClaw rebuild", - sandboxName: SANDBOX_NAME, - sandboxNames: [...SANDBOX_NAMES], - oldOpenClawVersion: OLD_OPENCLAW_VERSION, - contracts: [ - "current NemoClaw install/onboard succeeds before stale fixture creation", - "an old OpenClaw base image can be created with the legacy version", - "two legacy sandboxes with missing credentialEnv share one complete inference route", - "both sandboxes are reported stale by upgrade-sandboxes --check", - "upgrade-sandboxes --auto upgrades both without orphaning the first sandbox", - "both registry rows carry the canonical shared credential identity after rebuild", - "upgrade-sandboxes --check reports up-to-date after rebuild", - ], - }); - - const dockerInfo = await host.command("docker", ["info"], { - artifactName: "phase-0-docker-info", - env: buildAvailabilityProbeEnv(), - timeoutMs: 30_000, - }); - assertDockerAvailable(dockerInfo, skip); - - registerStateRestore(cleanup); - cleanup.trackDisposable("remove stale OpenClaw test image", () => cleanupOldImage(host)); - for (const sandboxName of SANDBOX_NAMES) { - cleanup.trackDisposable(`delete OpenShell sandbox ${sandboxName}`, () => - sandbox.cleanupSandbox(sandboxName, { - artifactName: `cleanup-openshell-delete-${sandboxName}`, - env: commandEnv(), - timeoutMs: 60_000, - }), - ); - cleanup.trackSandbox(host, sandboxName, { - artifactName: `cleanup-nemoclaw-destroy-${sandboxName}`, - env: commandEnv(), - timeoutMs: 120_000, - }); - } - await precleanStaleSandbox(host, sandbox); - - const install = await installCurrentNemoclaw(host, hosted); - expect(install.exitCode, resultText(install)).toBe(0); - - progress.phase("construct two old OpenClaw sandboxes on one gateway"); - const deleteInstalledSandbox = await sandbox.openshell(["sandbox", "delete", SANDBOX_NAME], { - artifactName: "phase-2-delete-installed-sandbox", - env: commandEnv(), - timeoutMs: 120_000, - }); - assertDeleteInstalledSandboxAllowed(deleteInstalledSandbox); - const forwardList = await sandbox.openshell(["forward", "list"], { - artifactName: "phase-2-forward-list-before-stale-fixture", - env: commandEnv(), - timeoutMs: 60_000, - }); - expect(forwardList.exitCode, resultText(forwardList)).toBe(0); - const siblingDashboardPort = allocateSiblingDashboardPort(resultText(forwardList)); - - const buildOldBase = await buildOldOpenClawBase(host); - expect(buildOldBase.exitCode, resultText(buildOldBase)).toBe(0); - - const fixtureDockerfile = createFixtureDockerfile(cleanup); - for (const sandboxName of SANDBOX_NAMES) { - const createOldSandbox = await sandbox.openshell( - [ - "sandbox", - "create", - "--name", - sandboxName, - "--from", - fixtureDockerfile, - "--gateway", - "nemoclaw", - "--no-tty", - "--", - "true", - ], - { - artifactName: `phase-3-create-old-openclaw-${sandboxName}`, - env: commandEnv(), - timeoutMs: 15 * 60_000, - }, - ); - expect(createOldSandbox.exitCode, resultText(createOldSandbox)).toBe(0); - - const waitReady = await waitSandboxReady( - host, - sandboxName, - `phase-3-wait-old-${sandboxName}-ready`, - ); - expect(waitReady.exitCode, resultText(waitReady)).toBe(0); - - const oldVersion = await sandbox.exec(sandboxName, ["openclaw", "--version"], { - artifactName: `phase-3-old-openclaw-version-${sandboxName}`, - env: commandEnv(), - timeoutMs: 60_000, - }); - expect(oldVersion.exitCode, resultText(oldVersion)).toBe(0); - expect(resultText(oldVersion)).toContain(OLD_OPENCLAW_VERSION); - } - - progress.phase("register stale shared-route sandbox metadata"); - writeStaleRegistryEntries(siblingDashboardPort); - const staleRegistryJson = registeredStaleSandboxJson(); - await artifacts.writeText("registered-stale-sandboxes.json", staleRegistryJson); - const staleRegistry = JSON.parse(staleRegistryJson) as { - sandboxes: Record>; - }; - for (const sandboxName of SANDBOX_NAMES) { - expect(staleRegistry.sandboxes[sandboxName]).toBeDefined(); - expect(Object.hasOwn(staleRegistry.sandboxes[sandboxName]!, "credentialEnv")).toBe(false); - } - - progress.phase("detect both stale sandboxes"); - const staleCheck = await host.nemoclaw(["upgrade-sandboxes", "--check"], { - artifactName: "phase-5-upgrade-sandboxes-check-stale", - env: commandEnv(hosted.env), - redactionValues: [hosted.apiKey], - timeoutMs: 120_000, - }); - expect(staleCheck.exitCode, resultText(staleCheck)).toBe(0); - expect(resultText(staleCheck)).toMatch(/stale|need upgrading/i); - expect(resultText(staleCheck)).not.toMatch(/up to date/i); - expect(resultText(staleCheck)).toContain(SANDBOX_NAME); - expect(resultText(staleCheck)).toContain(SIBLING_SANDBOX_NAME); - - progress.phase("rebuild both to the current OpenClaw runtime"); - const rebuild = await host.nemoclaw(["upgrade-sandboxes", "--auto"], { - artifactName: "phase-6-upgrade-both-stale-sandboxes", - env: commandEnv(hosted.env), - redactionValues: [hosted.apiKey], - timeoutMs: 45 * 60_000, - }); - expect(rebuild.exitCode, resultText(rebuild)).toBe(0); - expect(resultText(rebuild)).toMatch(/2 sandbox\(es\) rebuilt/i); - - for (const sandboxName of SANDBOX_NAMES) { - const waitRebuiltReady = await waitSandboxReady( - host, - sandboxName, - `phase-6-wait-rebuilt-${sandboxName}-ready`, - ); - expect(waitRebuiltReady.exitCode, resultText(waitRebuiltReady)).toBe(0); - - const newVersion = await sandbox.exec(sandboxName, ["openclaw", "--version"], { - artifactName: `phase-6-new-openclaw-version-${sandboxName}`, - env: commandEnv(), - timeoutMs: 60_000, - }); - expect(newVersion.exitCode, resultText(newVersion)).toBe(0); - expect(resultText(newVersion)).not.toContain(OLD_OPENCLAW_VERSION); - } - const rebuiltRegistry = JSON.parse(registeredStaleSandboxJson()) as { - sandboxes: Record>; - }; - for (const sandboxName of SANDBOX_NAMES) { - expect(rebuiltRegistry.sandboxes[sandboxName]?.credentialEnv).toBe(hosted.credentialEnv); - } - - progress.phase("confirm the upgrade check is clean"); - const cleanCheck = await host.nemoclaw(["upgrade-sandboxes", "--check"], { - artifactName: "phase-7-upgrade-sandboxes-check-clean", - env: commandEnv(hosted.env), - redactionValues: [hosted.apiKey], - timeoutMs: 120_000, - }); - expect(cleanCheck.exitCode, resultText(cleanCheck)).toBe(0); - expect(resultText(cleanCheck)).toMatch(/up to date/i); -}); diff --git a/test/e2e/mock-parity.json b/test/e2e/mock-parity.json index 256810f497c..7911db86cf1 100644 --- a/test/e2e/mock-parity.json +++ b/test/e2e/mock-parity.json @@ -412,13 +412,6 @@ "test/e2e/support/e2e-clients.test.ts" ] }, - { - "live": "test/e2e/live/sandbox-rebuild.test.ts", - "fast": [ - "test/e2e/support/e2e-cleanup-resources.test.ts", - "test/e2e/support/e2e-clients.test.ts" - ] - }, { "live": "test/e2e/live/sandbox-survival.test.ts", "fast": [ @@ -475,13 +468,6 @@ "test/e2e/support/e2e-clients.test.ts" ] }, - { - "live": "test/e2e/live/upgrade-stale-sandbox.test.ts", - "fast": [ - "test/e2e/support/e2e-cleanup-resources.test.ts", - "test/e2e/support/e2e-clients.test.ts" - ] - }, { "live": "test/e2e/live/state-dir-guard-metadata.test.ts", "liveOnlyReason": "Runs the installed guard from both production images against a real bind-mounted Linux filesystem; mocks cannot reproduce kernel xattr copying, POSIX ACL mask synchronization, inode replacement, or allocated-byte behavior." diff --git a/test/e2e/support/e2e-workflow.test.ts b/test/e2e/support/e2e-workflow.test.ts index 226da682145..894ed0edbd9 100644 --- a/test/e2e/support/e2e-workflow.test.ts +++ b/test/e2e/support/e2e-workflow.test.ts @@ -748,6 +748,28 @@ describe("e2e workflow boundary", () => { matchedFiles: ["test/e2e/live/token-rotation.test.ts"], }, ]); + expect( + focusedE2eJobsForChangedFiles( + ["test/e2e/live/openclaw-plugin-runtime-exdev-lifecycle.ts"], + inventory, + ), + ).toEqual([ + { + id: "openclaw-plugin-runtime-exdev", + matchedFiles: ["test/e2e/live/openclaw-plugin-runtime-exdev-lifecycle.ts"], + }, + ]); + expect( + focusedE2eJobsForChangedFiles( + ["test/e2e/live/openshell-gateway-upgrade-helpers.ts"], + inventory, + ), + ).toEqual([ + { + id: "openshell-gateway-upgrade", + matchedFiles: ["test/e2e/live/openshell-gateway-upgrade-helpers.ts"], + }, + ]); }); it("rejects malformed free-standing workflow metadata before matrix generation", { diff --git a/test/e2e/support/inference-switch-workflow-boundary.test.ts b/test/e2e/support/inference-switch-workflow-boundary.test.ts index b27328c51ed..6095b8ee339 100644 --- a/test/e2e/support/inference-switch-workflow-boundary.test.ts +++ b/test/e2e/support/inference-switch-workflow-boundary.test.ts @@ -106,6 +106,21 @@ describe("inference switch workflow boundary", () => { ); }); + it("rejects a step-scoped hosted inference override in a local switch job", () => { + const hostedInference = readInferenceSwitchWorkflow(); + const runStep = hostedInference.jobs["openclaw-inference-switch"].steps!.find( + (step) => step.name === "Run OpenClaw inference switch live test", + )!; + runStep.env = { + ...runStep.env, + NEMOCLAW_E2E_USE_HOSTED_INFERENCE: "1", + }; + + expect(validateInferenceSwitchWorkflow(hostedInference)).toContain( + "openclaw-inference-switch must not define NEMOCLAW_E2E_USE_HOSTED_INFERENCE at step scope for its Anthropic-compatible mode", + ); + }); + it("accepts shared guarded Docker authentication without mode-specific auth scripts", () => { const workflow = readInferenceSwitchWorkflow(); const steps = workflow.jobs["openclaw-inference-switch"].steps!; diff --git a/test/e2e/support/openclaw-plugin-runtime-exdev-fixture.test.ts b/test/e2e/support/openclaw-plugin-runtime-exdev-fixture.test.ts index 592f849d763..f4427a63fdd 100644 --- a/test/e2e/support/openclaw-plugin-runtime-exdev-fixture.test.ts +++ b/test/e2e/support/openclaw-plugin-runtime-exdev-fixture.test.ts @@ -9,6 +9,10 @@ import { RELEASE_SANDBOX_BASE_IMAGE_REF, resolveOpenClawPluginRuntimeExdevFixture, } from "../live/openclaw-plugin-runtime-exdev-fixture.ts"; +import { + CURRENT_LIFECYCLE_PHASES, + currentLifecycleCommands, +} from "../live/openclaw-plugin-runtime-exdev-lifecycle.ts"; describe("OpenClaw plugin runtime EXDEV fixture selection", () => { it("keeps the release baseline on its matching source and sandbox base image", () => { @@ -31,3 +35,61 @@ describe("OpenClaw plugin runtime EXDEV fixture selection", () => { }); }); }); + +describe("OpenClaw plugin runtime EXDEV current lifecycle", () => { + it("maps the retained lifecycle to restart and recreation without a duplicate rebuild (#7917)", () => { + expect( + currentLifecycleCommands({ + cliEntrypoint: "/repo/bin/nemoclaw.js", + dockerfilePath: "/fixture/Dockerfile", + sandboxName: "e2e-openclaw-plugin-exdev", + }), + ).toEqual({ + onboard: { + command: "node", + args: [ + "/repo/bin/nemoclaw.js", + "onboard", + "--fresh", + "--non-interactive", + "--yes-i-accept-third-party-software", + "--agent", + "openclaw", + "--from", + "/fixture/Dockerfile", + ], + }, + recreate: { + command: "node", + args: [ + "/repo/bin/nemoclaw.js", + "onboard", + "--fresh", + "--recreate-sandbox", + "--non-interactive", + "--yes", + "--yes-i-accept-third-party-software", + "--name", + "e2e-openclaw-plugin-exdev", + "--agent", + "openclaw", + "--from", + "/fixture/Dockerfile", + ], + }, + restart: { + command: "node", + args: ["/repo/bin/nemoclaw.js", "e2e-openclaw-plugin-exdev", "gateway", "restart"], + }, + }); + expect(CURRENT_LIFECYCLE_PHASES).toEqual([ + "confirm Docker CLI and clear the current plugin sandbox", + "clone and prepare the current plugin fixture", + "install and validate current OpenShell", + "build and onboard plugin v1", + "restart the gateway and confirm plugin v1", + "recreate the sandbox with plugin v2", + "prove cross-device runtime dependency replacement", + ]); + }); +}); diff --git a/test/e2e/support/retired-selector-compatibility-workflow-boundary.test.ts b/test/e2e/support/retired-selector-compatibility-workflow-boundary.test.ts index 45d72fcee11..17db1e41722 100644 --- a/test/e2e/support/retired-selector-compatibility-workflow-boundary.test.ts +++ b/test/e2e/support/retired-selector-compatibility-workflow-boundary.test.ts @@ -37,15 +37,6 @@ function compatibilitySteps(): { return { steps, workflow }; } -function jobSteps(workflow: MutableWorkflow, jobName: string): WorkflowStep[] { - return ( - workflow.jobs[jobName]?.steps ?? - (() => { - throw new Error(`${jobName} steps are required`); - })() - ); -} - const DRIFT_CASES = [ { name: "target selector gate", @@ -106,33 +97,6 @@ const DRIFT_CASES = [ }, error: "retired-selector-compatibility job must upload compatibility evidence", }, - { - name: "sandbox rebuild candidate classifier", - mutate: (_steps: WorkflowStep[], workflow: MutableWorkflow) => { - const steps = jobSteps(workflow, "sandbox-rebuild"); - const classifier = requiredStep( - steps, - (candidate) => candidate.name === "Classify sandbox-rebuild candidate selector", - "sandbox rebuild candidate classifier", - ); - steps.splice(steps.indexOf(classifier), 1); - }, - error: "sandbox-rebuild job must classify candidate selector retirement", - }, - { - name: "upgrade stale sandbox candidate guard", - mutate: (_steps: WorkflowStep[], workflow: MutableWorkflow) => { - const steps = jobSteps(workflow, "upgrade-stale-sandbox"); - const run = requiredStep( - steps, - (candidate) => candidate.name === "Run upgrade stale sandbox live Vitest test", - "upgrade stale sandbox live test", - ); - run.if = "always()"; - }, - error: - "upgrade-stale-sandbox step 'Run upgrade stale sandbox live Vitest test' must skip a retired candidate selector", - }, ] as const; it.each(DRIFT_CASES)("rejects retired-selector compatibility drift in $name (#7615)", ({ diff --git a/test/e2e/support/workflow-plan.test.ts b/test/e2e/support/workflow-plan.test.ts index d6360813f4d..fba5f7f1ba8 100644 --- a/test/e2e/support/workflow-plan.test.ts +++ b/test/e2e/support/workflow-plan.test.ts @@ -31,7 +31,9 @@ function firstId(rows: readonly T[], label: string): s function retiredControllerSelectorIds(): string[] { const allowedJobs = new Set(readFreeStandingJobsInventory().allowedJobs); - return RETIRED_CONTROLLER_SELECTOR_IDS.filter((id) => !allowedJobs.has(id)); + const retiredIds = RETIRED_CONTROLLER_SELECTOR_IDS.filter((id) => !allowedJobs.has(id)); + expect(retiredIds).toEqual([...RETIRED_CONTROLLER_SELECTOR_IDS]); + return retiredIds; } describe("E2E workflow plan", () => { @@ -185,34 +187,51 @@ describe("E2E workflow plan", () => { } }); - it("rejects a controller plan made only of retired selectors (#7616)", () => { + it.each( + RETIRED_CONTROLLER_SELECTOR_IDS, + )("emits an empty live plan for retired controller job %s (#7616)", (job) => { const directory = mkdtempSync(path.join(tmpdir(), "nemoclaw-workflow-plan-cli-")); + const output = path.join(directory, "github-output"); + const summary = path.join(directory, "summary.md"); + const plan = { + matrix: [], + testMatrix: [], + hermesSelected: false, + explicitOnlyJobs: readFreeStandingJobsInventory().explicitOnlyJobs, + }; try { const result = spawnSync(TSX, [PLANNER_CLI, "--ci-output"], { cwd: REPO_ROOT, encoding: "utf8", env: { ...process.env, - GITHUB_OUTPUT: path.join(directory, "github-output"), - GITHUB_STEP_SUMMARY: path.join(directory, "summary.md"), + GITHUB_OUTPUT: output, + GITHUB_STEP_SUMMARY: summary, INFERENCE_MODE: "mock", - JOBS: retiredControllerSelectorIds().join(","), + JOBS: job, TARGETS: "", NEMOCLAW_E2E_EXPECTED_SHA: "a".repeat(40), }, timeout: 30_000, }); - expect(result.status).toBe(1); - expect(result.stderr).toContain( - "::error::retired selector compatibility requires another controller-selected job", + expect(result.status, result.stderr).toBe(0); + expect(readFileSync(output, "utf8")).toBe( + [ + "matrix=[]", + "test_matrix=[]", + "hermes_selected=false", + `explicit_only_jobs=${plan.explicitOnlyJobs.join(",")}`, + "", + ].join("\n"), ); + expect(readFileSync(summary, "utf8")).toBe(renderE2eWorkflowPlanSummary(plan)); } finally { rmSync(directory, { force: true, recursive: true }); } }); - it("emits an empty matrix for free-standing rebuild selectors during transition (#7615)", () => { + it("emits an empty matrix for retired free-standing rebuild selectors (#7615)", () => { const directory = mkdtempSync(path.join(tmpdir(), "nemoclaw-workflow-plan-cli-")); const output = path.join(directory, "github-output"); const summary = path.join(directory, "summary.md"); diff --git a/test/pr-e2e-gate.test.ts b/test/pr-e2e-gate.test.ts index 4c6de108729..6df9e482ed6 100644 --- a/test/pr-e2e-gate.test.ts +++ b/test/pr-e2e-gate.test.ts @@ -287,12 +287,7 @@ describe("PR E2E controller", () => { expect(validateRiskPlan(focusedPlan, new Set(riskPlanRequiredJobIds(focusedPlan)))).toEqual( focusedPlan, ); - expect(riskPlanRequiredJobIds(focusedPlan)).toEqual([ - "cloud-inference", - "cloud-onboard", - "security-posture", - "token-rotation", - ]); + expect(riskPlanRequiredJobIds(focusedPlan)).toEqual(["token-rotation"]); const targetPlan = buildRiskPlan({ headSha: HEAD_SHA, changedFiles: [DCODE_CHECK] }); expect(validateRiskPlan(targetPlan, new Set(riskPlanRequiredJobIds(targetPlan)))).toEqual( targetPlan, diff --git a/test/pr-risk-plan.test.ts b/test/pr-risk-plan.test.ts index 8434b40e1db..bc92c7d0592 100644 --- a/test/pr-risk-plan.test.ts +++ b/test/pr-risk-plan.test.ts @@ -27,7 +27,7 @@ describe("deterministic PR risk plan", () => { const second = plan("src/lib/onboard.ts", "src/lib/state/registry.ts"); expect(first).toEqual(second); - expect(first.version).toBe(8); + expect(first.version).toBe(9); expect(first.headSha).toBe(HEAD_SHA); expect(first.planHash).toMatch(/^[a-f0-9]{64}$/u); expect(first.changedFiles).toEqual(["src/lib/onboard.ts", "src/lib/state/registry.ts"]); @@ -42,22 +42,18 @@ describe("deterministic PR risk plan", () => { expect(result.requiredTargets).toEqual([]); }); - it("keeps every live test behind the control-plane exception and preserves the cloud floor (#6446)", () => { - const canonical = plan("test/e2e/live/cloud-onboard.test.ts"); - const ordinaryLiveTest = plan("test/e2e/live/full.test.ts"); + it("keeps an unmapped live test behind the control-plane exception and cloud floor (#6446)", () => { + const result = plan("test/e2e/live/full.test.ts"); - expect(canonical.families.map((family) => family.id)).toContain("platform-install"); - expect(canonical.families.map((family) => family.id)).toContain("e2e-control-plane"); - expect(riskPlanRequiredJobIds(canonical)).toContain("cloud-onboard"); - expect(ordinaryLiveTest.families.map((family) => family.id)).toEqual(["e2e-control-plane"]); - expect(riskPlanRequiredJobIds(ordinaryLiveTest)).toEqual([ + expect(result.families.map((family) => family.id)).toEqual(["e2e-control-plane"]); + expect(riskPlanRequiredJobIds(result)).toEqual([ "cloud-inference", "cloud-onboard", "security-posture", ]); }); - it("hashes trusted focused E2E selections into their canonical jobs", () => { + it("maps a workflow-wired live test only to its canonical job (#7921)", () => { const changedFiles = ["test/e2e/live/token-rotation.test.ts"]; const focusedE2eJobs = focusedE2eJobsForChangedFiles(changedFiles); const result = buildRiskPlan({ headSha: HEAD_SHA, changedFiles, focusedE2eJobs }); @@ -83,9 +79,89 @@ describe("deterministic PR risk plan", () => { matchedFiles: ["test/e2e/live/token-rotation.test.ts"], }), ); + expect(riskPlanRequiredJobIds(result)).toEqual(["token-rotation"]); + expect(result.families.map((family) => family.id)).toEqual(["focused-e2e"]); expect(result.planHash).not.toBe(withoutFocusedSelection.planHash); }); + it("leaves E2E support-only changes in the fast e2e-support project (#7921)", () => { + const changedFiles = ["test/e2e/support/workflow-plan.test.ts"]; + const focusedE2eJobs = focusedE2eJobsForChangedFiles(changedFiles); + const result = buildRiskPlan({ headSha: HEAD_SHA, changedFiles, focusedE2eJobs }); + + expect(focusedE2eJobs).toEqual([]); + expect(result.tier).toBe(0); + expect(result.families).toEqual([]); + expect(result.requiredJobs).toEqual([]); + }); + + it("maps a shared gateway live test to the retained migration job (#7921)", () => { + const changedFiles = ["test/e2e/live/openshell-gateway-upgrade.test.ts"]; + const focusedE2eJobs = focusedE2eJobsForChangedFiles(changedFiles); + const result = buildRiskPlan({ headSha: HEAD_SHA, changedFiles, focusedE2eJobs }); + + expect(focusedE2eJobs).toEqual([ + { + id: "openshell-gateway-upgrade", + matchedFiles: changedFiles, + }, + ]); + expect(riskPlanRequiredJobIds(result)).toEqual(["openshell-gateway-upgrade"]); + }); + + it("keeps an unknown live test behind the broad control-plane floor (#7921)", () => { + const changedFiles = ["test/e2e/live/new-retained-journey.test.ts"]; + const focusedE2eJobs = focusedE2eJobsForChangedFiles(changedFiles); + const result = buildRiskPlan({ headSha: HEAD_SHA, changedFiles, focusedE2eJobs }); + + expect(focusedE2eJobs).toEqual([]); + expect(riskPlanRequiredJobIds(result)).toEqual([ + "cloud-inference", + "cloud-onboard", + "security-posture", + ]); + expect(result.families.map((family) => family.id)).toEqual(["e2e-control-plane"]); + }); + + it("keeps a renamed live test broad until the new path has an owning job (#7921)", () => { + const changedFiles = [ + "test/e2e/live/token-rotation.test.ts", + "test/e2e/live/token-rotation-renamed.test.ts", + ]; + const focusedE2eJobs = focusedE2eJobsForChangedFiles(changedFiles); + const result = buildRiskPlan({ headSha: HEAD_SHA, changedFiles, focusedE2eJobs }); + + expect(focusedE2eJobs).toEqual([ + { + id: "token-rotation", + matchedFiles: ["test/e2e/live/token-rotation.test.ts"], + }, + ]); + expect(riskPlanRequiredJobIds(result)).toEqual([ + "cloud-inference", + "cloud-onboard", + "security-posture", + "token-rotation", + ]); + expect( + result.families.find((family) => family.id === "e2e-control-plane")?.matchedFiles, + ).toEqual(["test/e2e/live/token-rotation-renamed.test.ts"]); + }); + + it("keeps a shared E2E workflow change behind the broad control-plane floor (#7921)", () => { + const result = plan(".github/workflows/e2e.yaml"); + + expect(riskPlanRequiredJobIds(result)).toEqual([ + "cloud-inference", + "cloud-onboard", + "security-posture", + ]); + expect(result.families.map((family) => family.id)).toEqual([ + "platform-install", + "e2e-control-plane", + ]); + }); + it("runs snapshot commands for restored-gateway pairing runtime changes (#7431)", () => { const runtimeFiles = [ "src/lib/actions/sandbox/restore-gateway-pairing.ts", diff --git a/tools/advisors/risk-plan.mts b/tools/advisors/risk-plan.mts index 9717aa8bf47..09514a3766a 100644 --- a/tools/advisors/risk-plan.mts +++ b/tools/advisors/risk-plan.mts @@ -3,7 +3,7 @@ import { createHash } from "node:crypto"; -export const RISK_PLAN_VERSION = 8 as const; +export const RISK_PLAN_VERSION = 9 as const; export const PR_E2E_TYPED_TARGET_IDS = [ "ubuntu-repo-cloud-langchain-deepagents-code", @@ -107,6 +107,7 @@ const RISK_RELEVANT_TEST_FILES = new Set([ "test/e2e/live/cloud-onboard.test.ts", "test/e2e/risk-signal-reporter.ts", ]); +const E2E_SUPPORT_FILE = /^test\/e2e\/support\//; const FOCUSED_E2E_SUMMARY = "Changed runtime surfaces and workflow-wired E2E tests must execute through their trusted canonical jobs or typed targets."; const FOCUSED_E2E_INVARIANTS = [ @@ -363,6 +364,7 @@ function normalizeFocusedE2eJobs( function isRuntimeRelevant(file: string): boolean { if (RISK_RELEVANT_TEST_FILES.has(file)) return true; + if (E2E_SUPPORT_FILE.test(file)) return false; if (file.startsWith("tools/e2e/") || file.startsWith("test/e2e/")) { return !/\.(?:md|mdx)$/u.test(file); } @@ -386,8 +388,13 @@ export function buildRiskPlan(options: { }): RiskPlan { const changedFiles = stableUnique(options.changedFiles); const runtimeFiles = changedFiles.filter(isRuntimeRelevant); + const focusedE2eJobs = normalizeFocusedE2eJobs(options.focusedE2eJobs ?? [], changedFiles); + const focusedLiveFiles = new Set(focusedE2eJobs.flatMap((selection) => selection.matchedFiles)); const staticFamilies: RiskPlanFamily[] = RISK_RULES.flatMap((rule) => { - const matchedFiles = runtimeFiles.filter(rule.matches); + const matchedFiles = runtimeFiles.filter( + (file) => + rule.matches(file) && !(rule.id === "e2e-control-plane" && focusedLiveFiles.has(file)), + ); if (matchedFiles.length === 0) return []; return [ { @@ -401,7 +408,6 @@ export function buildRiskPlan(options: { }, ]; }); - const focusedE2eJobs = normalizeFocusedE2eJobs(options.focusedE2eJobs ?? [], changedFiles); const focusedE2eTargets = normalizeFocusedE2eJobs( focusedPrE2eTargetsForChangedFiles(changedFiles), changedFiles, diff --git a/tools/e2e/inference-switch-workflow-boundary.mts b/tools/e2e/inference-switch-workflow-boundary.mts index 3ca24e262fa..364b049bad5 100644 --- a/tools/e2e/inference-switch-workflow-boundary.mts +++ b/tools/e2e/inference-switch-workflow-boundary.mts @@ -103,6 +103,11 @@ function validateJob(errors: string[], spec: JobSpec, job: WorkflowJob): void { errors.push(`${spec.job} must not enable hosted inference for its Anthropic-compatible mode`); } for (const step of job.steps ?? []) { + if (step.env?.NEMOCLAW_E2E_USE_HOSTED_INFERENCE !== undefined) { + errors.push( + `${spec.job} must not define NEMOCLAW_E2E_USE_HOSTED_INFERENCE at step scope for its Anthropic-compatible mode`, + ); + } if (step.env?.NVIDIA_INFERENCE_API_KEY !== undefined) { errors.push( `${spec.job} must not expose NVIDIA_INFERENCE_API_KEY in its Anthropic-compatible mode`, diff --git a/tools/e2e/workflow-boundary.mts b/tools/e2e/workflow-boundary.mts index fa2e2a8e0d1..13987587f36 100644 --- a/tools/e2e/workflow-boundary.mts +++ b/tools/e2e/workflow-boundary.mts @@ -577,6 +577,11 @@ const RESTORED_GATEWAY_PAIRING_RUNTIME_FILES = new Set([ "src/lib/actions/sandbox/restore-gateway-pairing.ts", "src/lib/adapters/openshell/restore-gateway-pairing.ts", ]); +const LIVE_E2E_OWNING_FILE_JOBS = new Map([ + ["test/e2e/live/openclaw-plugin-runtime-exdev-lifecycle.ts", ["openclaw-plugin-runtime-exdev"]], + ["test/e2e/live/openshell-gateway-upgrade-helpers.ts", ["openshell-gateway-upgrade"]], + ["test/e2e/live/openshell-gateway-upgrade-old-installer.ts", ["openshell-gateway-upgrade"]], +]); export function focusedE2eJobsForChangedFiles( changedFiles: readonly string[], @@ -587,6 +592,9 @@ export function focusedE2eJobsForChangedFiles( for (const job of inventory.liveTestToJobs.get(file) ?? []) { addMapValue(matchedFilesByJob, job, file); } + for (const job of LIVE_E2E_OWNING_FILE_JOBS.get(file) ?? []) { + if (inventory.allowedJobs.includes(job)) addMapValue(matchedFilesByJob, job, file); + } if (RESTORED_GATEWAY_PAIRING_RUNTIME_FILES.has(file)) { addMapValue(matchedFilesByJob, "snapshot-commands", file); } @@ -1060,61 +1068,6 @@ function validateFreeStandingJobSelector( } } -const CANDIDATE_SELECTOR_ACTIVE_IF = - "${{ steps.selector_compatibility.outputs.retired != 'true' }}"; -const CANDIDATE_SELECTOR_CLASSIFIER = [ - "set -euo pipefail", - 'if [[ -f "$LEGACY_TEST_FILE" ]]; then', - " retired=false", - "else", - " retired=true", - "fi", - `printf 'retired=%s\\n' "$retired" >> "$GITHUB_OUTPUT"`, -].join("\n"); - -function validateCandidateSelectorCompatibility( - errors: string[], - options: { - installStep: string; - jobName: string; - legacyTestFile: string; - runStep: string; - steps: readonly WorkflowStep[]; - }, -): void { - const classifierName = `Classify ${options.jobName} candidate selector`; - const classifier = namedStep(options.steps, classifierName); - if ( - stringValue(classifier?.id) !== "selector_compatibility" || - asRecord(classifier?.env).LEGACY_TEST_FILE !== options.legacyTestFile || - stringValue(classifier?.run).trim() !== CANDIDATE_SELECTOR_CLASSIFIER - ) { - errors.push(`${options.jobName} job must classify candidate selector retirement`); - } - - const dockerAuthIndex = options.steps.findIndex( - (step) => step.name === "Authenticate to Docker Hub", - ); - const classifierIndex = options.steps.indexOf(classifier ?? {}); - const prepareIndex = options.steps.findIndex((step) => step.name === "Prepare E2E workspace"); - if ( - dockerAuthIndex < 0 || - classifierIndex <= dockerAuthIndex || - prepareIndex < 0 || - classifierIndex >= prepareIndex - ) { - errors.push( - `${options.jobName} candidate selector classifier must follow Docker Hub authentication`, - ); - } - - for (const stepName of [options.installStep, options.runStep]) { - if (namedStep(options.steps, stepName)?.if !== CANDIDATE_SELECTOR_ACTIVE_IF) { - errors.push(`${options.jobName} step '${stepName}' must skip a retired candidate selector`); - } - } -} - function validateGatewayGuardRecoveryJob(errors: string[], jobs: WorkflowRecord): void { const job = asRecord(jobs["gateway-guard-recovery"]); if (Object.keys(job).length === 0) return; @@ -2038,94 +1991,6 @@ function validateRebuildHermesJob( } } -function validateSandboxRebuildJob(errors: string[], jobs: WorkflowRecord): void { - const jobName = "sandbox-rebuild"; - const targetName = "sandbox-rebuild"; - const job = asRecord(jobs[jobName]); - if (Object.keys(job).length === 0) { - errors.push("workflow missing sandbox-rebuild job"); - return; - } - - if (job["runs-on"] !== "ubuntu-latest") { - errors.push("sandbox-rebuild job must run on ubuntu-latest"); - } - validateFreeStandingJobSelector(errors, jobs, jobName, targetName); - if (job["timeout-minutes"] !== 90) { - errors.push("sandbox-rebuild job must keep the legacy 90 minute timeout"); - } - const jobEnv = asRecord(job.env); - if (jobEnv.NEMOCLAW_RUN_LIVE_E2E !== "1") { - errors.push("sandbox-rebuild job must set NEMOCLAW_RUN_LIVE_E2E=1"); - } - if (jobEnv.E2E_ARTIFACT_DIR !== "${{ github.workspace }}/e2e-artifacts/live/sandbox-rebuild") { - errors.push( - "sandbox-rebuild job must write artifacts under e2e-artifacts/live/sandbox-rebuild", - ); - } - if (jobEnv.NEMOCLAW_CLI_BIN !== "${{ github.workspace }}/bin/nemoclaw.js") { - errors.push("sandbox-rebuild job must point NEMOCLAW_CLI_BIN at the repo CLI"); - } - if (jobEnv.OPENSHELL_GATEWAY !== "nemoclaw") { - errors.push("sandbox-rebuild job must force OPENSHELL_GATEWAY=nemoclaw"); - } - for (const secret of [ - "NVIDIA_INFERENCE_API_KEY", - "DOCKERHUB_USERNAME", - "DOCKERHUB_TOKEN", - "GITHUB_TOKEN", - ]) { - requireEnvDoesNotExposeSecret(errors, "sandbox-rebuild job", jobEnv, secret); - } - - const steps = asSteps(job.steps); - validateCandidateSelectorCompatibility(errors, { - installStep: "Install OpenShell", - jobName, - legacyTestFile: "test/e2e/live/sandbox-rebuild.test.ts", - runStep: "Run sandbox rebuild live test", - steps, - }); - requireNoDispatchInputInterpolation(errors, steps); - for (const step of steps) { - const stepName = `sandbox-rebuild step '${step.name ?? step.uses ?? ""}'`; - const stepEnv = asRecord(step.env); - if (step.name !== "Run sandbox rebuild live test") { - requireEnvDoesNotExposeSecret(errors, stepName, stepEnv, "NVIDIA_INFERENCE_API_KEY"); - } - if (step.name !== "Authenticate to Docker Hub") { - requireEnvDoesNotExposeSecret(errors, stepName, stepEnv, "DOCKERHUB_USERNAME"); - requireEnvDoesNotExposeSecret(errors, stepName, stepEnv, "DOCKERHUB_TOKEN"); - requireNoDockerHubAuthInRun(errors, stepName, stringValue(step.run)); - } - requireEnvDoesNotExposeSecret(errors, stepName, stepEnv, "GITHUB_TOKEN"); - } - - const checkout = steps.find((step) => stringValue(step.uses).startsWith("actions/checkout@")); - if (!checkout) errors.push("sandbox-rebuild job missing checkout step"); - requireFullShaAction(errors, checkout, "sandbox-rebuild checkout"); - if (asRecord(checkout?.with)["persist-credentials"] !== false) { - errors.push("sandbox-rebuild checkout step must set persist-credentials=false"); - } - - const installOpenShell = requireJobStep(errors, jobName, steps, "Install OpenShell"); - requireRunContains(errors, installOpenShell, "bash scripts/install-openshell.sh"); - requireRunContains(errors, installOpenShell, "env -u DOCKER_CONFIG"); - requireRunContains(errors, installOpenShell, "-u DOCKERHUB_USERNAME"); - requireRunContains(errors, installOpenShell, "-u DOCKERHUB_TOKEN"); - requireRunContains(errors, installOpenShell, "-u NVIDIA_INFERENCE_API_KEY"); - requireRunContains(errors, installOpenShell, "-u GITHUB_TOKEN"); - - const runVitest = requireJobStep(errors, jobName, steps, "Run sandbox rebuild live test"); - const runVitestEnv = asRecord(runVitest?.env); - if (runVitestEnv.NVIDIA_INFERENCE_API_KEY !== "${{ secrets.NVIDIA_INFERENCE_API_KEY }}") { - errors.push("sandbox-rebuild step must receive NVIDIA_INFERENCE_API_KEY from secrets"); - } - requireRunContains(errors, runVitest, "OPENSHELL_BIN"); - requireRunContains(errors, runVitest, "tools/e2e/live-vitest-invocation.mts run --test-path"); - requireRunContains(errors, runVitest, "test/e2e/live/sandbox-rebuild.test.ts"); -} - function validateStateBackupRestoreJob(errors: string[], jobs: WorkflowRecord): void { const jobName = "state-backup-restore"; const targetName = "state-backup-restore"; @@ -2218,104 +2083,6 @@ function validateStateBackupRestoreJob(errors: string[], jobs: WorkflowRecord): requireRunContains(errors, runVitest, "test/e2e/live/state-backup-restore.test.ts"); } -function validateUpgradeStaleSandboxJob(errors: string[], jobs: WorkflowRecord): void { - const jobName = "upgrade-stale-sandbox"; - const targetName = "upgrade-stale-sandbox"; - const job = asRecord(jobs[jobName]); - if (Object.keys(job).length === 0) { - errors.push("workflow missing upgrade-stale-sandbox job"); - return; - } - - if (job["runs-on"] !== "ubuntu-latest") { - errors.push("upgrade-stale-sandbox job must run on ubuntu-latest"); - } - validateFreeStandingJobSelector(errors, jobs, jobName, targetName); - if (job["timeout-minutes"] !== 85) { - errors.push("upgrade-stale-sandbox job must keep the two-sandbox 85 minute timeout"); - } - - const jobEnv = asRecord(job.env); - if (jobEnv.NEMOCLAW_RUN_LIVE_E2E !== "1") { - errors.push("upgrade-stale-sandbox job must set NEMOCLAW_RUN_LIVE_E2E=1"); - } - if ( - jobEnv.E2E_ARTIFACT_DIR !== "${{ github.workspace }}/e2e-artifacts/live/upgrade-stale-sandbox" - ) { - errors.push( - "upgrade-stale-sandbox job must write artifacts under e2e-artifacts/live/upgrade-stale-sandbox", - ); - } - if (jobEnv.NEMOCLAW_CLI_BIN !== "${{ github.workspace }}/bin/nemoclaw.js") { - errors.push("upgrade-stale-sandbox job must point NEMOCLAW_CLI_BIN at the repo CLI"); - } - if (jobEnv.OPENSHELL_GATEWAY !== "nemoclaw") { - errors.push("upgrade-stale-sandbox job must force OPENSHELL_GATEWAY=nemoclaw"); - } - if (jobEnv.NEMOCLAW_SANDBOX_NAME !== "e2e-upgrade-stale") { - errors.push("upgrade-stale-sandbox job must set NEMOCLAW_SANDBOX_NAME=e2e-upgrade-stale"); - } - if ("DOCKER_CONFIG" in jobEnv) { - errors.push("upgrade-stale-sandbox job must not set DOCKER_CONFIG at job level"); - } - for (const secret of [...COMMON_SECRET_ENV_NAMES]) { - requireEnvDoesNotExposeSecret(errors, "upgrade-stale-sandbox job", jobEnv, secret); - } - - const steps = asSteps(job.steps); - validateCandidateSelectorCompatibility(errors, { - installStep: "Install OpenShell CLI", - jobName, - legacyTestFile: "test/e2e/live/upgrade-stale-sandbox.test.ts", - runStep: "Run upgrade stale sandbox live Vitest test", - steps, - }); - requireNoDispatchInputInterpolation(errors, steps); - for (const step of steps) { - const stepName = `upgrade-stale-sandbox step '${step.name ?? step.uses ?? ""}'`; - const stepEnv = asRecord(step.env); - if (step.name !== "Run upgrade stale sandbox live Vitest test") { - requireEnvDoesNotExposeSecret(errors, stepName, stepEnv, "NVIDIA_INFERENCE_API_KEY"); - requireEnvDoesNotExposeSecret(errors, stepName, stepEnv, "NVIDIA_API_KEY"); - } - if (step.name !== "Authenticate to Docker Hub") { - requireEnvDoesNotExposeSecret(errors, stepName, stepEnv, "DOCKERHUB_USERNAME"); - requireEnvDoesNotExposeSecret(errors, stepName, stepEnv, "DOCKERHUB_TOKEN"); - requireNoDockerHubAuthInRun(errors, stepName, stringValue(step.run)); - } - requireEnvDoesNotExposeSecret(errors, stepName, stepEnv, "GITHUB_TOKEN"); - } - - const checkout = steps.find((step) => stringValue(step.uses).startsWith("actions/checkout@")); - if (!checkout) errors.push("upgrade-stale-sandbox job missing checkout step"); - requireFullShaAction(errors, checkout, "upgrade-stale-sandbox checkout"); - if (asRecord(checkout?.with)["persist-credentials"] !== false) { - errors.push("upgrade-stale-sandbox checkout step must set persist-credentials=false"); - } - - const installOpenShell = requireJobStep(errors, jobName, steps, "Install OpenShell CLI"); - requireRunContains(errors, installOpenShell, "bash scripts/install-openshell.sh"); - requireRunContains(errors, installOpenShell, "env -u DOCKER_CONFIG"); - requireRunContains(errors, installOpenShell, "-u DOCKERHUB_USERNAME"); - requireRunContains(errors, installOpenShell, "-u DOCKERHUB_TOKEN"); - requireRunContains(errors, installOpenShell, "-u NVIDIA_INFERENCE_API_KEY"); - requireRunContains(errors, installOpenShell, "-u GITHUB_TOKEN"); - - const runVitest = requireJobStep( - errors, - jobName, - steps, - "Run upgrade stale sandbox live Vitest test", - ); - const runVitestEnv = asRecord(runVitest?.env); - if (runVitestEnv.NVIDIA_INFERENCE_API_KEY !== "${{ secrets.NVIDIA_INFERENCE_API_KEY }}") { - errors.push("upgrade-stale-sandbox step must receive NVIDIA_INFERENCE_API_KEY from secrets"); - } - requireRunContains(errors, runVitest, "OPENSHELL_BIN"); - requireRunContains(errors, runVitest, "tools/e2e/live-vitest-invocation.mts run --test-path"); - requireRunContains(errors, runVitest, "test/e2e/live/upgrade-stale-sandbox.test.ts"); -} - function validateTokenRotationJob(errors: string[], jobs: WorkflowRecord): void { const jobName = "token-rotation"; const job = asRecord(jobs[jobName]); @@ -5063,9 +4830,7 @@ export function validateE2eWorkflow(workflowValue: unknown): string[] { validateRebuildOpenClawJob(errors, jobs); validateRebuildHermesJob(errors, jobs, { staleBase: false }); validateRebuildHermesJob(errors, jobs, { staleBase: true }); - validateSandboxRebuildJob(errors, jobs); validateStateBackupRestoreJob(errors, jobs); - validateUpgradeStaleSandboxJob(errors, jobs); validateTokenRotationJob(errors, jobs); validateMessagingCompatibleEndpointJob(errors, jobs); validateFreeStandingJobSelector(errors, jobs, "gateway-guard-recovery", "gateway-guard-recovery"); diff --git a/tools/e2e/workflow-plan.mts b/tools/e2e/workflow-plan.mts index f2a296ffbc1..f3356f82f6f 100644 --- a/tools/e2e/workflow-plan.mts +++ b/tools/e2e/workflow-plan.mts @@ -31,7 +31,7 @@ type WorkflowPlanCliOptions = WorkflowPlanSelectors & { }; type TrustedControllerSelectorMap = { - retiredTargetSelected: boolean; + retiredSelectorSelected: boolean; selectors: WorkflowPlanSelectors; }; @@ -142,7 +142,7 @@ function mapTrustedControllerJobs( environment: NodeJS.ProcessEnv, ): TrustedControllerSelectorMap { if (!COMMIT_SHA_PATTERN.test(environment.NEMOCLAW_E2E_EXPECTED_SHA ?? "")) { - return { retiredTargetSelected: false, selectors }; + return { retiredSelectorSelected: false, selectors }; } const inventory = readFreeStandingJobsInventory(); @@ -169,15 +169,12 @@ function mapTrustedControllerJobs( ); const compatibleJobs = jobs.filter((job) => !retiredJobs.has(job)); const compatibleTargets = targets.filter((target) => !retiredTargets.has(target)); - if (jobs.length > 0 && compatibleJobs.length === 0 && !selectors.targets) { - throw new Error("retired selector compatibility requires another controller-selected job"); - } // Trusted main can select a renamed or newly retired job until the candidate // workflow becomes the controller. Keep the raw IDs for evidence, but plan // only jobs that still execute in the candidate. return { - retiredTargetSelected: retiredTargets.size > 0, + retiredSelectorSelected: retiredJobs.size > 0 || retiredTargets.size > 0, selectors: { ...selectors, jobs: compatibleJobs.join(","), @@ -252,13 +249,13 @@ export function validateE2eWorkflowPlan(plan: unknown): E2eWorkflowPlan { function expectedHermesSelection( selectors: WorkflowPlanSelectors, - retiredTargetSelected: boolean, + retiredSelectorSelected: boolean, ): boolean { const selected = [ ...selectorIds(selectors.jobs, "jobs"), ...selectorIds(selectors.targets, "targets"), ]; - return (selected.length === 0 && !retiredTargetSelected) || selected.includes(HERMES_JOB_ID); + return (selected.length === 0 && !retiredSelectorSelected) || selected.includes(HERMES_JOB_ID); } export function renderE2eWorkflowPlanSummary(plan: E2eWorkflowPlan): string { @@ -289,13 +286,13 @@ export function writeE2eWorkflowPlanCiOutput( const plannerSelectors = controllerMap.selectors; const hasPlannerSelectors = Boolean(plannerSelectors.jobs || plannerSelectors.targets); const plan = validateE2eWorkflowPlan( - controllerMap.retiredTargetSelected && !hasPlannerSelectors + controllerMap.retiredSelectorSelected && !hasPlannerSelectors ? emptyE2eWorkflowPlan() : buildE2eWorkflowPlan(plannerSelectors), ); if ( plan.hermesSelected !== - expectedHermesSelection(plannerSelectors, controllerMap.retiredTargetSelected) + expectedHermesSelection(plannerSelectors, controllerMap.retiredSelectorSelected) ) { throw new Error("E2E planner changed the trusted Hermes selection"); }