diff --git a/Dockerfile b/Dockerfile index 1381964afaf..d9e47fb702b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,6 +28,8 @@ RUN npm ci && npm run build # Stage 2: Runtime image — pull cached base from GHCR # hadolint ignore=DL3006 FROM ${BASE_IMAGE} +ARG OPENCLAW_VERSION=2026.5.22 +ARG OPENCLAW_2026_5_22_INTEGRITY=sha512-m+zgBELGbCHjWB1IWF5WSWNPr480cMKOMff2OF72c8A0AMD4hC/9+qwYtzjYmGkETcffnB711JymlVsQnh2Tow== # Harden: remove unnecessary build tools and network probes from base image (#830) # Protect runtime tools before autoremove — the GHCR base may predate the @@ -88,24 +90,39 @@ RUN chmod 755 /usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.js \ # silently skipping patches (leaving the sandbox unpatched), upgrade OpenClaw # in-place so every build gets the version the patches expect. # -# The minimum required version comes from nemoclaw-blueprint/blueprint.yaml -# (already COPYed to /opt/nemoclaw-blueprint/ above). +# OPENCLAW_VERSION is the NemoClaw runtime build target. It must be at least the +# blueprint minimum, which also supports the legacy direct-blueprint image path. # hadolint ignore=DL3059,DL4006 RUN set -eu; \ + echo "$OPENCLAW_VERSION" | grep -qxE '[0-9]+(\.[0-9]+)*' \ + || { echo "ERROR: OPENCLAW_VERSION='$OPENCLAW_VERSION' is invalid (expected e.g. 2026.3.11)" >&2; exit 1; }; \ MIN_VER=$(grep -m 1 'min_openclaw_version' /opt/nemoclaw-blueprint/blueprint.yaml | awk '{print $2}' | tr -d '"'); \ [ -n "$MIN_VER" ] || { echo "ERROR: Could not parse min_openclaw_version from blueprint.yaml" >&2; exit 1; }; \ + if [ "$(printf '%s\n%s' "$MIN_VER" "$OPENCLAW_VERSION" | sort -V | head -n1)" != "$MIN_VER" ]; then \ + echo "ERROR: OpenClaw build target ${OPENCLAW_VERSION} is below blueprint minimum ${MIN_VER}" >&2; exit 1; \ + fi; \ + EXPECTED_INTEGRITY=""; \ + if [ "$OPENCLAW_VERSION" = "2026.5.22" ]; then EXPECTED_INTEGRITY="$OPENCLAW_2026_5_22_INTEGRITY"; fi; \ + if [ -n "$EXPECTED_INTEGRITY" ]; then \ + REGISTRY_INTEGRITY=$(npm view "openclaw@${OPENCLAW_VERSION}" dist.integrity); \ + if [ "$REGISTRY_INTEGRITY" != "$EXPECTED_INTEGRITY" ]; then \ + echo "ERROR: OpenClaw ${OPENCLAW_VERSION} npm integrity mismatch" >&2; \ + echo "Expected: ${EXPECTED_INTEGRITY}" >&2; \ + echo "Actual: ${REGISTRY_INTEGRITY}" >&2; exit 1; \ + fi; \ + fi; \ CUR_VER=$(openclaw --version 2>/dev/null | awk '{print $2}' || echo "0.0.0"); \ - if [ "$(printf '%s\n%s' "$MIN_VER" "$CUR_VER" | sort -V | head -n1)" = "$MIN_VER" ]; then \ - echo "INFO: OpenClaw $CUR_VER is current (>= $MIN_VER), no upgrade needed"; \ + if [ "$(printf '%s\n%s' "$OPENCLAW_VERSION" "$CUR_VER" | sort -V | head -n1)" = "$OPENCLAW_VERSION" ]; then \ + echo "INFO: OpenClaw $CUR_VER is current (>= $OPENCLAW_VERSION), no upgrade needed"; \ else \ - echo "INFO: Base image has OpenClaw $CUR_VER, upgrading to $MIN_VER (minimum required)"; \ + echo "INFO: Base image has OpenClaw $CUR_VER, upgrading to $OPENCLAW_VERSION"; \ # npm 10's atomic-move install can hit EROFS on overlayfs when the # prior install spans multiple image layers (e.g. openclaw was # baked into sandbox-base, then we upgrade on top here). Clearing # at the shell level first gives npm a clean slate and avoids the # rmdir failure inside npm's own install path. rm -rf /usr/local/lib/node_modules/openclaw /usr/local/bin/openclaw; \ - npm install -g --no-audit --no-fund --no-progress "openclaw@${MIN_VER}"; \ + npm install -g --no-audit --no-fund --no-progress "openclaw@${OPENCLAW_VERSION}"; \ fi; \ # Pre-install the codex-acp package so the embedded ACPx runtime can # call the local binary instead of `npx @zed-industries/codex-acp`. @@ -170,7 +187,7 @@ RUN set -eu; \ # build time. They apply the legacy patch when the old target exists, skip # only when the dist shape proves OpenClaw no longer needs that patch, and # fail with the OpenClaw version plus dist path for mixed or unknown shapes. -# When bumping OPENCLAW_VERSION or min_openclaw_version, verify the new dist +# When bumping OPENCLAW_VERSION, verify the new dist # takes the expected branch and update the regex / sed replacement if needed. # hadolint ignore=SC2016,DL3059,DL4006 RUN set -eu; \ @@ -308,7 +325,7 @@ RUN set -eu; \ RUN node /usr/local/lib/nemoclaw/patch-openclaw-chat-send.js \ /usr/local/lib/node_modules/openclaw/dist -# Patch OpenClaw's pinned 2026.5.18 compiled selection runtime to expose a +# Patch OpenClaw's pinned 2026.5.22 compiled selection runtime to expose a # compact searchable tool catalog to the model while preserving the full # effective tool set behind tool_call. NEMOCLAW_TOOL_CATALOG=0 disables this # wrapper if an emergency rollback is needed. The script fails closed if the diff --git a/Dockerfile.base b/Dockerfile.base index 485dd4c9131..b24a1ff3197 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -178,7 +178,8 @@ RUN printf '%s\n' \ # OpenClaw version: change the OPENCLAW_VERSION ARG default so CI rebuilds # the base image on push to main, or use workflow_dispatch on base-image.yaml # with the openclaw_version input for a one-off build without editing this file. -ARG OPENCLAW_VERSION=2026.5.18 +ARG OPENCLAW_VERSION=2026.5.22 +ARG OPENCLAW_2026_5_22_INTEGRITY=sha512-m+zgBELGbCHjWB1IWF5WSWNPr480cMKOMff2OF72c8A0AMD4hC/9+qwYtzjYmGkETcffnB711JymlVsQnh2Tow== SHELL ["/bin/bash", "-o", "pipefail", "-c"] @@ -199,6 +200,16 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep echo "Error: OpenClaw version ${OPENCLAW_VERSION} not found on npm registry"; \ echo "Hint: Check available versions with: npm view openclaw versions"; exit 1; \ fi; \ + EXPECTED_INTEGRITY=""; \ + if [ "$OPENCLAW_VERSION" = "2026.5.22" ]; then EXPECTED_INTEGRITY="$OPENCLAW_2026_5_22_INTEGRITY"; fi; \ + if [ -n "$EXPECTED_INTEGRITY" ]; then \ + REGISTRY_INTEGRITY=$(npm view "openclaw@${OPENCLAW_VERSION}" dist.integrity); \ + if [ "$REGISTRY_INTEGRITY" != "$EXPECTED_INTEGRITY" ]; then \ + echo "Error: OpenClaw ${OPENCLAW_VERSION} npm integrity mismatch"; \ + echo "Expected: ${EXPECTED_INTEGRITY}"; \ + echo "Actual: ${REGISTRY_INTEGRITY}"; exit 1; \ + fi; \ + fi; \ npm install -g "openclaw@${OPENCLAW_VERSION}" \ && pip3 install --no-cache-dir --break-system-packages "pyyaml==6.0.3" diff --git a/agents/openclaw/manifest.yaml b/agents/openclaw/manifest.yaml index 94fb5727dec..7819860cebc 100644 --- a/agents/openclaw/manifest.yaml +++ b/agents/openclaw/manifest.yaml @@ -19,7 +19,7 @@ homepage: "https://openclaw.ai" install_method: npm # npm install -g openclaw@ binary_path: /usr/local/bin/openclaw version_command: "openclaw --version" -expected_version: "2026.5.18" +expected_version: "2026.5.22" gateway_command: "openclaw gateway run" # ── Health probe ──────────────────────────────────────────────── diff --git a/docs/about/release-notes.mdx b/docs/about/release-notes.mdx index 5720b3d2dbe..9c4bb9eac0a 100644 --- a/docs/about/release-notes.mdx +++ b/docs/about/release-notes.mdx @@ -194,6 +194,8 @@ The installer error message in v0.0.35+ surfaces all three invocations directly ## Component Version Policy -NemoClaw pins the OpenClaw version inside the sandbox at build time via `min_openclaw_version` in `nemoclaw-blueprint/blueprint.yaml`; existing sandboxes do not auto-upgrade. +NemoClaw pins the OpenClaw version inside the sandbox at build time via `OPENCLAW_VERSION` in the NemoClaw Dockerfiles. +The `min_openclaw_version` field in `nemoclaw-blueprint/blueprint.yaml` is the compatibility floor for direct blueprint consumers and may be lower than the NemoClaw runtime target. +Existing sandboxes do not auto-upgrade. Run `nemoclaw status` to see the OpenClaw version currently running in a sandbox, and `nemoclaw rebuild` to pick up a newer pin from a NemoClaw upgrade. See [Checking the OpenClaw version](/reference/commands#checking-the-openclaw-version) for the full policy. diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx index 790b90d742f..91067b2c3a6 100644 --- a/docs/reference/architecture.mdx +++ b/docs/reference/architecture.mdx @@ -204,9 +204,11 @@ flowchart LR ## Sandbox Environment -The sandbox runs the -[`ghcr.io/nvidia/openshell-community/sandboxes/openclaw`](https://github.com/NVIDIA/OpenShell-Community) -container image. Inside the sandbox: +Normal NemoClaw onboarding builds from the +[`ghcr.io/nvidia/nemoclaw/sandbox-base`](https://github.com/NVIDIA/NemoClaw/pkgs/container/nemoclaw%2Fsandbox-base) +base image and layers the NemoClaw runtime Dockerfile on top. The direct blueprint +runner still carries a pinned OpenShell Community OpenClaw image for legacy +`openshell sandbox create --from` compatibility. Inside the sandbox: - OpenClaw runs with the NemoClaw plugin pre-installed. - Inference calls are routed through OpenShell to the configured provider. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 909749d3eeb..f891cb9e995 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -407,7 +407,8 @@ $ nemoclaw my-assistant status #### Checking the OpenClaw version NemoClaw pins the OpenClaw version inside the sandbox at build time, not at runtime. -The minimum version comes from `min_openclaw_version` in `nemoclaw-blueprint/blueprint.yaml`, and the sandbox image upgrades OpenClaw to that version during `docker build` if the cached base image is older. +The NemoClaw runtime build target is declared by `OPENCLAW_VERSION` in the NemoClaw Dockerfiles. +The `min_openclaw_version` field in `nemoclaw-blueprint/blueprint.yaml` remains the compatibility floor for direct blueprint consumers, so it can be lower than the Dockerfile target. Existing sandboxes do not auto-upgrade when a newer NemoClaw release ships a newer pin — you upgrade by rebuilding the sandbox. `nemoclaw status` prints the running OpenClaw version on the `Agent` line: @@ -415,7 +416,7 @@ Existing sandboxes do not auto-upgrade when a newer NemoClaw release ships a new ```console $ nemoclaw my-assistant status ... - Agent: OpenClaw v2026.5.18 + Agent: OpenClaw v2026.5.22 ... ``` diff --git a/nemoclaw-blueprint/blueprint.yaml b/nemoclaw-blueprint/blueprint.yaml index 8b8c91ef889..c34323bf516 100644 --- a/nemoclaw-blueprint/blueprint.yaml +++ b/nemoclaw-blueprint/blueprint.yaml @@ -4,7 +4,7 @@ version: "0.1.0" min_openshell_version: "0.0.44" max_openshell_version: "0.0.44" -min_openclaw_version: "2026.5.18" +min_openclaw_version: "2026.3.11" # Mirrors the components.sandbox.image manifest digest below. Lets a # downstream consumer (or release tooling) verify the blueprint declares # a specific sandbox image without parsing the components tree, and diff --git a/nemoclaw/package.json b/nemoclaw/package.json index f00d35efba4..5baae0d308b 100644 --- a/nemoclaw/package.json +++ b/nemoclaw/package.json @@ -11,11 +11,11 @@ "./dist/index.js" ], "compat": { - "pluginApi": ">=2026.5.18", - "minGatewayVersion": "2026.5.18" + "pluginApi": ">=2026.5.22", + "minGatewayVersion": "2026.5.22" }, "build": { - "openclawVersion": "2026.5.18" + "openclawVersion": "2026.5.22" } }, "scripts": { diff --git a/nemoclaw/src/package-metadata.test.ts b/nemoclaw/src/package-metadata.test.ts index 5485993de85..c68336aa969 100644 --- a/nemoclaw/src/package-metadata.test.ts +++ b/nemoclaw/src/package-metadata.test.ts @@ -20,8 +20,8 @@ const packageJson = JSON.parse( describe("OpenClaw package metadata", () => { it("declares the required external plugin compatibility fields", () => { - expect(packageJson.openclaw?.compat?.pluginApi).toBe(">=2026.5.18"); - expect(packageJson.openclaw?.compat?.minGatewayVersion).toBe("2026.5.18"); - expect(packageJson.openclaw?.build?.openclawVersion).toBe("2026.5.18"); + expect(packageJson.openclaw?.compat?.pluginApi).toBe(">=2026.5.22"); + expect(packageJson.openclaw?.compat?.minGatewayVersion).toBe("2026.5.22"); + expect(packageJson.openclaw?.build?.openclawVersion).toBe("2026.5.22"); }); }); diff --git a/schemas/blueprint.schema.json b/schemas/blueprint.schema.json index 8627d11de5f..bc5bff506df 100644 --- a/schemas/blueprint.schema.json +++ b/schemas/blueprint.schema.json @@ -25,7 +25,7 @@ "min_openclaw_version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", - "description": "Minimum compatible OpenClaw version." + "description": "Minimum compatible OpenClaw version for direct blueprint consumers. NemoClaw's built runtime target is declared by Dockerfile OPENCLAW_VERSION." }, "digest": { "type": "string", diff --git a/scripts/patch-openclaw-chat-send.js b/scripts/patch-openclaw-chat-send.js index 8ff17744c58..35e5748119a 100755 --- a/scripts/patch-openclaw-chat-send.js +++ b/scripts/patch-openclaw-chat-send.js @@ -103,8 +103,18 @@ function patchFollowupRunnerFile(file) { } if (!source.includes("preserve chat.send run ids in followup queue")) { + const hasOptsBinding = + /\bfunction\s+runQueuedFollowup\(\s*queued,\s*opts\b/.test(source) || + /\bconst\s+\{[^}]*\bopts\b[^}]*\}\s*=\s*params;/.test(source); + if (!hasOptsBinding) { + fail(`OpenClaw followup runner opts binding not recognized in ${file}`); + } + + // Source boundary: OpenClaw 2026.5.18 passed opts into runQueuedFollowup, + // while 2026.5.22 closes over params.opts. Both shapes must have opts in + // scope before this NemoClaw run-id preservation shim is inserted. const next = source.replace( - /(replyOperation = createReplyOperation\(\{\n\s*sessionId: run\.sessionId,\n\s*sessionKey: replySessionKey \?\? "",\n\s*resetTriggered: false,\n\s*upstreamAbortSignal: queued\.abortSignal \?\? opts\?\.abortSignal\n\s*\}\);\n\s*)const runId = crypto\.randomUUID\(\);/, + /(replyOperation = createReplyOperation\(\{\n\s*sessionId: run\.sessionId,\n\s*sessionKey: replySessionKey \?\? "",\n\s*resetTriggered: false,\n\s*upstreamAbortSignal: queued\.abortSignal(?: \?\? opts\?\.abortSignal)?\n\s*\}\);\n\s*)const runId = crypto\.randomUUID\(\);/, (_match, prefix) => `${prefix}const runId = queued.runId ?? opts?.runId ?? crypto.randomUUID(); ` + `// nemoclaw: preserve chat.send run ids in followup queue (#2603, #3145)`, diff --git a/src/lib/sandbox/version.test.ts b/src/lib/sandbox/version.test.ts index d54fd93df3f..8e2da099b08 100644 --- a/src/lib/sandbox/version.test.ts +++ b/src/lib/sandbox/version.test.ts @@ -36,7 +36,7 @@ vi.mock("../agent/defs.js", () => ({ name, displayName: name === "openclaw" ? "OpenClaw" : "Hermes Agent", versionCommand: name === "openclaw" ? "openclaw --version" : "hermes --version", - expectedVersion: name === "openclaw" ? "2026.5.18" : "2026.5.16", + expectedVersion: name === "openclaw" ? "2026.5.22" : "2026.5.16", stateDirs: [], configPaths: { dir: "/sandbox/.openclaw" }, })), @@ -77,12 +77,12 @@ describe("checkAgentVersion", () => { registry.registerSandbox({ name: "test-sb", agent: null, - agentVersion: "2026.5.18", + agentVersion: "2026.5.22", }); const result = checkAgentVersion("test-sb"); expect(result.detectionMethod).toBe("registry"); - expect(result.sandboxVersion).toBe("2026.5.18"); + expect(result.sandboxVersion).toBe("2026.5.22"); expect(result.isStale).toBe(false); }); @@ -103,7 +103,7 @@ describe("checkAgentVersion", () => { registry.registerSandbox({ name: "test-sb", agent: null, - agentVersion: "2026.5.18", + agentVersion: "2026.5.22", }); const result = checkAgentVersion("test-sb"); @@ -120,7 +120,7 @@ describe("checkAgentVersion", () => { vi.mocked(spawnSync).mockReturnValue({ status: 0, - stdout: "OpenClaw 2026.5.18 (abc123)\n", + stdout: "OpenClaw 2026.5.22 (abc123)\n", stderr: "", pid: 1234, output: [], @@ -129,7 +129,7 @@ describe("checkAgentVersion", () => { const result = checkAgentVersion("test-sb"); expect(result.detectionMethod).toBe("ssh-exec"); - expect(result.sandboxVersion).toBe("2026.5.18"); + expect(result.sandboxVersion).toBe("2026.5.22"); expect(result.isStale).toBe(false); expect(captureSandboxSshConfigCommand).toHaveBeenCalledWith( "/usr/local/bin/openshell", @@ -139,7 +139,7 @@ describe("checkAgentVersion", () => { // Should have cached the version in registry const updated = registry.getSandbox("test-sb"); - expect(updated?.agentVersion).toBe("2026.5.18"); + expect(updated?.agentVersion).toBe("2026.5.22"); }); it("returns unavailable when SSH config fails", () => { @@ -183,7 +183,7 @@ describe("checkAgentVersion", () => { vi.mocked(spawnSync).mockReturnValue({ status: 0, - stdout: "OpenClaw 2026.5.18 (abc123)\n", + stdout: "OpenClaw 2026.5.22 (abc123)\n", stderr: "", pid: 1234, output: [], @@ -192,7 +192,7 @@ describe("checkAgentVersion", () => { const result = checkAgentVersion("test-sb", { forceProbe: true }); expect(result.detectionMethod).toBe("ssh-exec"); - expect(result.sandboxVersion).toBe("2026.5.18"); + expect(result.sandboxVersion).toBe("2026.5.22"); }); }); @@ -219,14 +219,14 @@ describe("formatStalenessWarning", () => { it("includes sandbox name, versions, and rebuild hint", () => { const lines = formatStalenessWarning("my-sb", { sandboxVersion: "2026.3.11", - expectedVersion: "2026.5.18", + expectedVersion: "2026.5.22", isStale: true, detectionMethod: "registry", }); const joined = lines.join("\n"); expect(joined).toContain("my-sb"); expect(joined).toContain("2026.3.11"); - expect(joined).toContain("2026.5.18"); + expect(joined).toContain("2026.5.22"); expect(joined).toContain("rebuild"); }); }); diff --git a/src/lib/verify-deployment.test.ts b/src/lib/verify-deployment.test.ts index 6eeaa4cabe3..d9abb31e93f 100644 --- a/src/lib/verify-deployment.test.ts +++ b/src/lib/verify-deployment.test.ts @@ -124,13 +124,13 @@ describe("verifyDeployment", () => { const deps = makeDeps({ executeSandboxCommand: (_name: string, script: string) => { if (script.includes("openclaw --version")) { - return { status: 0, stdout: "2026.5.18", stderr: "" }; + return { status: 0, stdout: "2026.5.22", stderr: "" }; } return { status: 0, stdout: "200", stderr: "" }; }, }); const result = await verifyDeployment("my-sandbox", chain, deps, NO_RETRY); - expect(result.verification.gatewayVersion).toBe("2026.5.18"); + expect(result.verification.gatewayVersion).toBe("2026.5.22"); }); it("reports null version when gateway is down (skips version probe)", async () => { @@ -179,7 +179,7 @@ describe("verifyDeployment", () => { const deps = makeDeps({ executeSandboxCommand: (_name: string, script: string) => { if (script.includes("openclaw --version")) { - return { status: 0, stdout: "2026.5.18", stderr: "" }; + return { status: 0, stdout: "2026.5.22", stderr: "" }; } if (script.includes("inference.local")) { return { status: 0, stdout: "200", stderr: "" }; @@ -240,14 +240,14 @@ describe("formatVerificationDiagnostics", () => { const result = await verifyDeployment("my-sandbox", chain, makeDeps({ executeSandboxCommand: (_name: string, script: string) => { if (script.includes("openclaw --version")) { - return { status: 0, stdout: "2026.5.18", stderr: "" }; + return { status: 0, stdout: "2026.5.22", stderr: "" }; } return { status: 0, stdout: "200", stderr: "" }; }, }), NO_RETRY); const lines = formatVerificationDiagnostics(result); expect(lines.some((l) => l.includes("verified"))).toBe(true); - expect(lines.some((l) => l.includes("2026.5.18"))).toBe(true); + expect(lines.some((l) => l.includes("2026.5.22"))).toBe(true); }); it("prints failure diagnostics with hints when unhealthy", async () => { diff --git a/test/e2e/test-openclaw-tui-chat-correlation.sh b/test/e2e/test-openclaw-tui-chat-correlation.sh index 6491b2c5e85..99d105420bc 100755 --- a/test/e2e/test-openclaw-tui-chat-correlation.sh +++ b/test/e2e/test-openclaw-tui-chat-correlation.sh @@ -46,8 +46,8 @@ openclaw_version="$( openshell sandbox exec --name "$SANDBOX_NAME" -- openclaw --version 2>&1 || true )" echo "Sandbox OpenClaw version: ${openclaw_version}" -if ! grep -q "2026.5.18" <<<"$openclaw_version"; then - echo "Expected fresh sandbox to run OpenClaw 2026.5.18" >&2 +if ! grep -q "2026.5.22" <<<"$openclaw_version"; then + echo "Expected fresh sandbox to run OpenClaw 2026.5.22" >&2 exit 1 fi diff --git a/test/fetch-guard-patch-regression.test.ts b/test/fetch-guard-patch-regression.test.ts index b6da45783da..7660deeebd9 100644 --- a/test/fetch-guard-patch-regression.test.ts +++ b/test/fetch-guard-patch-regression.test.ts @@ -10,8 +10,14 @@ import { describe, expect, it } from "vitest"; const DOCKERFILE = path.join(import.meta.dirname, "..", "Dockerfile"); const DOCKERFILE_BASE = path.join(import.meta.dirname, "..", "Dockerfile.base"); const BLUEPRINT = path.join(import.meta.dirname, "..", "nemoclaw-blueprint", "blueprint.yaml"); -const REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSIONS = ["2026.4.24", "2026.5.18"] as const; -const CURRENT_REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSION = "2026.5.18"; +const REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSIONS = [ + "2026.4.24", + "2026.5.18", + "2026.5.22", +] as const; +const CURRENT_REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSION = "2026.5.22"; +const EXPECTED_OPENCLAW_INTEGRITY = + "sha512-m+zgBELGbCHjWB1IWF5WSWNPr480cMKOMff2OF72c8A0AMD4hC/9+qwYtzjYmGkETcffnB711JymlVsQnh2Tow=="; function readRequiredMatch(file: string, pattern: RegExp, description: string): string { const match = fs.readFileSync(file, "utf-8").match(pattern); @@ -21,6 +27,22 @@ function readRequiredMatch(file: string, pattern: RegExp, description: string): return match[1]; } +function compareDotVersions(left: string, right: string): number { + const lhs = left.split(".").map((part) => Number.parseInt(part, 10) || 0); + const rhs = right.split(".").map((part) => Number.parseInt(part, 10) || 0); + const length = Math.max(lhs.length, rhs.length); + for (let index = 0; index < length; index += 1) { + const a = lhs[index] ?? 0; + const b = rhs[index] ?? 0; + if (a !== b) return a - b; + } + return 0; +} + +function expectVersionAtLeast(actual: string, minimum: string, message: string) { + expect(compareDotVersions(actual, minimum), message).toBeGreaterThanOrEqual(0); +} + function readBlueprintMinOpenClawVersion(): string { return readRequiredMatch(BLUEPRINT, /min_openclaw_version:\s*"([^"]+)"/, "OpenClaw minimum"); } @@ -33,6 +55,26 @@ function readDockerfileBaseOpenClawVersion(): string { ); } +function readDockerfileOpenClawVersion(): string { + return readRequiredMatch(DOCKERFILE, /^ARG OPENCLAW_VERSION=([^\s]+)/m, "OpenClaw runtime version"); +} + +function readDockerfileBaseOpenClawIntegrity(): string { + return readRequiredMatch( + DOCKERFILE_BASE, + /^ARG OPENCLAW_2026_5_22_INTEGRITY=([^\s]+)/m, + "OpenClaw base image integrity", + ); +} + +function readDockerfileOpenClawIntegrity(): string { + return readRequiredMatch( + DOCKERFILE, + /^ARG OPENCLAW_2026_5_22_INTEGRITY=([^\s]+)/m, + "OpenClaw runtime integrity", + ); +} + function dockerRunCommandBetween(startMarker: string, endMarker: string): string { const dockerfile = fs.readFileSync(DOCKERFILE, "utf-8"); const start = dockerfile.indexOf(startMarker); @@ -62,11 +104,13 @@ function runOpenClawUpgradeBlock(currentVersion: string) { const log = path.join(tmp, "calls.log"); const openclawInstall = path.join(tmp, "openclaw-global"); const openclawShim = path.join(tmp, "openclaw-bin"); - fs.writeFileSync(blueprint, 'min_openclaw_version: "2026.4.2"\n'); + const openclawVersion = readDockerfileOpenClawVersion(); + const openclawIntegrity = readDockerfileOpenClawIntegrity(); + fs.writeFileSync(blueprint, `min_openclaw_version: "${readBlueprintMinOpenClawVersion()}"\n`); fs.mkdirSync(openclawInstall, { recursive: true }); fs.writeFileSync(openclawShim, ""); const command = dockerRunCommandBetween( - "# The minimum required version comes from nemoclaw-blueprint/blueprint.yaml", + "# OPENCLAW_VERSION is the NemoClaw runtime build target", "# Patch OpenClaw media fetch", ) .replaceAll("/opt/nemoclaw-blueprint/blueprint.yaml", blueprint) @@ -76,14 +120,21 @@ function runOpenClawUpgradeBlock(currentVersion: string) { "#!/usr/bin/env bash", "set -euo pipefail", `call_log=${JSON.stringify(log)}`, + `OPENCLAW_VERSION=${JSON.stringify(openclawVersion)}`, + `OPENCLAW_2026_5_22_INTEGRITY=${JSON.stringify(openclawIntegrity)}`, `openclaw() { if [ "\${1:-}" = "--version" ]; then printf 'openclaw ${currentVersion}\\n'; else return 127; fi; }`, - 'npm() { printf "npm %s\\n" "$*" >> "$call_log"; }', + "npm() {", + ' printf "npm %s\\n" "$*" >> "$call_log";', + ' if [ "${1:-}" = "view" ] && [ "${2:-}" = "openclaw@${OPENCLAW_VERSION}" ] && [ "${3:-}" = "dist.integrity" ]; then', + ' printf "%s\\n" "$OPENCLAW_2026_5_22_INTEGRITY";', + " fi", + "}", 'command() { if [ "${1:-}" = "-v" ] && [ "${2:-}" = "codex-acp" ]; then return 0; fi; builtin command "$@"; }', command, ].join("\n"); const scriptPath = path.join(tmp, "run.sh"); fs.writeFileSync(scriptPath, script, { mode: 0o700 }); - const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 10000 }); const calls = fs.existsSync(log) ? fs.readFileSync(log, "utf-8") : ""; fs.rmSync(tmp, { recursive: true, force: true }); return { result, calls }; @@ -130,7 +181,7 @@ function runDockerfilePatchBlock( dist: string, tmp: string, endMarker: string, - version = "2026.5.18", + version = "2026.5.22", ) { const command = dockerRunCommandBetween( "# Patch OpenClaw media fetch for proxy-only sandbox", @@ -150,11 +201,11 @@ function runDockerfilePatchBlock( return spawnSync("bash", [scriptPath], { encoding: "utf-8", env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH || ""}` }, - timeout: 5000, + timeout: 10000, }); } -function runFetchGuardPatchBlock(dist: string, tmp: string, version = "2026.5.18") { +function runFetchGuardPatchBlock(dist: string, tmp: string, version = "2026.5.22") { return runDockerfilePatchBlock( dist, tmp, @@ -180,32 +231,49 @@ describe("fetch-guard patch regression guard", () => { expect(result.status).toBe(42); }); - it("upgrades stale OpenClaw from the blueprint minimum and leaves current installs alone", () => { + it("upgrades stale OpenClaw to the runtime build target and leaves current installs alone", () => { const stale = runOpenClawUpgradeBlock("2026.3.11"); expect(stale.result.status).toBe(0); - expect(stale.result.stdout).toContain("upgrading to 2026.4.2"); + expect(stale.result.stdout).toContain( + `upgrading to ${CURRENT_REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSION}`, + ); expect(stale.calls).toContain( - "npm install -g --no-audit --no-fund --no-progress openclaw@2026.4.2", + `npm install -g --no-audit --no-fund --no-progress openclaw@${CURRENT_REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSION}`, ); - const current = runOpenClawUpgradeBlock("2026.4.2"); + const current = runOpenClawUpgradeBlock(CURRENT_REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSION); expect(current.result.status).toBe(0); - expect(current.result.stdout).toContain("is current (>= 2026.4.2)"); - expect(current.calls).not.toContain("openclaw@2026.4.2"); + expect(current.result.stdout).toContain( + `is current (>= ${CURRENT_REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSION})`, + ); + expect(current.calls).not.toContain( + `npm install -g --no-audit --no-fund --no-progress openclaw@${CURRENT_REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSION}`, + ); }); - it("requires classifier review when the pinned OpenClaw build version changes", () => { + it("requires classifier review and integrity evidence when the OpenClaw build pin changes", () => { const reviewMessage = "Update fetch-guard classifier expectations before changing the OpenClaw build version."; const blueprintMinVersion = readBlueprintMinOpenClawVersion(); const baseImageVersion = readDockerfileBaseOpenClawVersion(); + const runtimeVersion = readDockerfileOpenClawVersion(); - expect(baseImageVersion, "Dockerfile.base and blueprint must pin the same OpenClaw version.").toBe( + expectVersionAtLeast( + baseImageVersion, blueprintMinVersion, + "Dockerfile.base OpenClaw target must satisfy the blueprint minimum.", + ); + expect(runtimeVersion, "Dockerfile and Dockerfile.base must build the same OpenClaw target.").toBe( + baseImageVersion, ); + expect(readDockerfileBaseOpenClawIntegrity()).toBe(EXPECTED_OPENCLAW_INTEGRITY); + expect(readDockerfileOpenClawIntegrity()).toBe(EXPECTED_OPENCLAW_INTEGRITY); expect([...REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSIONS], reviewMessage).toContain( - blueprintMinVersion, + runtimeVersion, + ); + expect([...REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSIONS], reviewMessage).toContain( + baseImageVersion, ); }); @@ -387,7 +455,7 @@ if (globalThis.proxyChecks.length !== 0) throw new Error('sandbox proxy validati ); try { - const patch = runFetchGuardPatchBlock(dist, tmp, "2026.5.18"); + const patch = runFetchGuardPatchBlock(dist, tmp, "2026.5.22"); expect(patch.status, `${patch.stdout}${patch.stderr}`).toBe(0); expect(patch.stdout).toContain("Patch 1 applied"); expect(patch.stdout).toContain("Patch 2 applied"); @@ -633,7 +701,7 @@ if (globalThis.proxyChecks.length !== 0) throw new Error('sandbox proxy validati ); try { - const patch = runFetchGuardPatchBlock(dist, tmp, "2026.5.18"); + const patch = runFetchGuardPatchBlock(dist, tmp, "2026.5.22"); expect(patch.status, `${patch.stdout}${patch.stderr}`).toBe(0); expect(patch.stdout).toContain("Patch 2 applied"); const patched = fs.readFileSync(modulePath, "utf-8"); @@ -667,7 +735,7 @@ if (globalThis.proxyChecks.length !== 0) throw new Error('sandbox proxy validati ); try { - const patch = runFetchGuardPatchBlock(dist, tmp, "2026.5.18"); + const patch = runFetchGuardPatchBlock(dist, tmp, "2026.5.22"); expect(patch.status, `${patch.stdout}${patch.stderr}`).toBe(0); expect(patch.stdout).toContain("Patch 2 applied"); const patched = fs.readFileSync(modulePath, "utf-8"); @@ -710,7 +778,7 @@ if (globalThis.proxyChecks.length !== 0) throw new Error('sandbox proxy validati ); try { - const patch = runFetchGuardPatchBlock(dist, tmp, "2026.5.18"); + const patch = runFetchGuardPatchBlock(dist, tmp, "2026.5.22"); expect(patch.status, `${patch.stdout}${patch.stderr}`).toBe(0); expect(patch.stdout).toContain("Patch 2 applied"); const patched = fs.readFileSync(modulePath, "utf-8"); diff --git a/test/openclaw-chat-send-patch.test.ts b/test/openclaw-chat-send-patch.test.ts index 4c84c7cba14..46513571994 100644 --- a/test/openclaw-chat-send-patch.test.ts +++ b/test/openclaw-chat-send-patch.test.ts @@ -5,6 +5,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import vm from "node:vm"; import { describe, expect, it } from "vitest"; const PATCH_SCRIPT = path.join( @@ -93,6 +94,65 @@ function writeFollowupRunnerFixture(dist: string): string { return fixture; } +function writeFollowupRunner20260522Fixture(dist: string): string { + const fixture = path.join(dist, "agent-runner.fixture.js"); + fs.writeFileSync( + fixture, + [ + "function createFollowupRunner(params) {", + " const { opts, typing, sessionEntry } = params;", + " return async (queued) => {", + " let replyOperation;", + " let run = queued.run;", + " replyOperation = createReplyOperation({", + " sessionId: run.sessionId,", + ' sessionKey: replySessionKey ?? "",', + " resetTriggered: false,", + " upstreamAbortSignal: queued.abortSignal", + " });", + " const runId = crypto.randomUUID();", + " if (run.sessionKey) registerAgentRunContext(runId, {", + " sessionKey: run.sessionKey,", + " verboseLevel: run.verboseLevel", + " });", + " return runId;", + " }", + "}", + "", + ].join("\n"), + ); + return fixture; +} + +function writeFollowupRunnerWithoutOptsBindingFixture(dist: string): string { + const fixture = path.join(dist, "agent-runner.fixture.js"); + fs.writeFileSync( + fixture, + [ + "function createFollowupRunner(params) {", + " return async (queued) => {", + " let replyOperation;", + " let run = queued.run;", + " replyOperation = createReplyOperation({", + " sessionId: run.sessionId,", + ' sessionKey: replySessionKey ?? "",', + " resetTriggered: false,", + " upstreamAbortSignal: queued.abortSignal", + " });", + " const runId = crypto.randomUUID();", + " if (run.sessionKey) registerAgentRunContext(runId, {", + " sessionKey: run.sessionKey,", + " verboseLevel: run.verboseLevel", + " });", + " return runId;", + " }", + "}", + "", + ].join("\n"), + ); + return fixture; +} + function writeGetReplyFixture(dist: string): string { const fixture = path.join(dist, "get-reply.fixture.js"); fs.writeFileSync( @@ -132,10 +192,39 @@ function writeGetReplyFixture(dist: string): string { function runPatch(dist: string) { return spawnSync(process.execPath, [PATCH_SCRIPT, dist], { encoding: "utf-8", - timeout: 5000, + timeout: 10000, }); } +type FollowupQueuedFixture = { + runId?: string; + abortSignal?: AbortSignal; + run: { sessionId: string; sessionKey: string }; +}; + +async function runPatchedFollowupFixture( + patchedSource: string, + params: { opts?: { runId?: string } }, + queued: FollowupQueuedFixture, +) { + const registeredRuns: string[] = []; + const context = { + createReplyOperation: (value: unknown) => value, + crypto: { randomUUID: () => "fallback-run-id" }, + registerAgentRunContext: (runId: string) => registeredRuns.push(runId), + replySessionKey: "reply-session", + }; + const createFollowupRunner = vm.runInNewContext( + `${patchedSource}\ncreateFollowupRunner;`, + context, + ) as (params: { opts?: { runId?: string } }) => ( + queued: FollowupQueuedFixture, + ) => Promise; + + const runId = await createFollowupRunner(params)(queued); + return { registeredRuns, runId }; +} + describe("OpenClaw chat.send compatibility patch", () => { it("correlates agent runs, idempotently appends transcripts, and suppresses empty finals", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-chat-send-")); @@ -193,6 +282,64 @@ describe("OpenClaw chat.send compatibility patch", () => { } }); + it("recognizes the 2026.5.22 followup runner abort-signal shape", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-chat-send-522-")); + const dist = path.join(tmp, "dist"); + fs.mkdirSync(dist); + writeChatSendFixture(dist); + const followupFixture = writeFollowupRunner20260522Fixture(dist); + writeGetReplyFixture(dist); + + try { + const patch = runPatch(dist); + expect(patch.status, `${patch.stdout}${patch.stderr}`).toBe(0); + const patchedFollowup = fs.readFileSync(followupFixture, "utf-8"); + expect(patchedFollowup).toContain( + "const runId = queued.runId ?? opts?.runId ?? crypto.randomUUID(); // nemoclaw: preserve chat.send run ids in followup queue (#2603, #3145)", + ); + await expect( + runPatchedFollowupFixture( + patchedFollowup, + { opts: { runId: "opts-run-id" } }, + { runId: "queued-run-id", run: { sessionId: "session", sessionKey: "key" } }, + ), + ).resolves.toMatchObject({ runId: "queued-run-id", registeredRuns: ["queued-run-id"] }); + await expect( + runPatchedFollowupFixture( + patchedFollowup, + { opts: { runId: "opts-run-id" } }, + { run: { sessionId: "session", sessionKey: "key" } }, + ), + ).resolves.toMatchObject({ runId: "opts-run-id", registeredRuns: ["opts-run-id"] }); + await expect( + runPatchedFollowupFixture( + patchedFollowup, + {}, + { run: { sessionId: "session", sessionKey: "key" } }, + ), + ).resolves.toMatchObject({ runId: "fallback-run-id", registeredRuns: ["fallback-run-id"] }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("fails closed when the followup runner opts binding is absent", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-chat-send-no-opts-")); + const dist = path.join(tmp, "dist"); + fs.mkdirSync(dist); + writeChatSendFixture(dist); + writeFollowupRunnerWithoutOptsBindingFixture(dist); + writeGetReplyFixture(dist); + + try { + const patch = runPatch(dist); + expect(patch.status).toBe(1); + expect(patch.stderr).toContain("OpenClaw followup runner opts binding not recognized"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("fails closed when the OpenClaw chat.send source shape changes", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-chat-send-missing-")); const dist = path.join(tmp, "dist");