From 68b19c87d2f1b90414b3ff731759f3a3a4659126 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 19 May 2026 14:38:22 -0700 Subject: [PATCH 01/12] chore: upgrade agent runtime dependencies --- .../references/commands.md | 2 +- .github/workflows/regression-e2e.yaml | 4 +- Dockerfile.base | 4 +- agents/hermes/Dockerfile.base | 6 +-- agents/hermes/config/hermes-config.ts | 2 +- agents/hermes/manifest.yaml | 2 +- agents/openclaw/manifest.yaml | 2 +- docs/reference/commands.md | 2 +- docs/reference/commands.mdx | 2 +- nemoclaw-blueprint/blueprint.yaml | 6 +-- nemoclaw/package.json | 6 +-- nemoclaw/src/package-metadata.test.ts | 6 +-- scripts/brev-launchable-ci-cpu.sh | 6 +-- scripts/generate-openclaw-config.py | 4 +- scripts/install-openshell.sh | 6 +-- scripts/seed-wechat-accounts.py | 2 +- src/ext/wechat/qr.ts | 2 +- src/lib/onboard.ts | 4 +- ...cker-driver-gateway-runtime-marker.test.ts | 2 +- src/lib/onboard/openshell-install.ts | 2 +- src/lib/sandbox/version.test.ts | 22 ++++----- src/lib/verify-deployment.test.ts | 10 ++-- test/e2e/docs/parity-inventory.generated.json | 28 +++++------ test/e2e/docs/parity-map.yaml | 20 ++++---- test/e2e/test-openshell-gateway-upgrade.sh | 6 +-- test/e2e/test-openshell-version-pin.sh | 36 +++++++------- test/generate-openclaw-config.test.ts | 8 ++-- test/install-openshell-version-check.test.ts | 48 +++++++++---------- 28 files changed, 125 insertions(+), 125 deletions(-) diff --git a/.agents/skills/nemoclaw-user-reference/references/commands.md b/.agents/skills/nemoclaw-user-reference/references/commands.md index 9420a341fbb..2636c05cfe6 100644 --- a/.agents/skills/nemoclaw-user-reference/references/commands.md +++ b/.agents/skills/nemoclaw-user-reference/references/commands.md @@ -376,7 +376,7 @@ Existing sandboxes do not auto-upgrade when a newer NemoClaw release ships a new ```console $ nemoclaw my-assistant status ... - Agent: OpenClaw v2026.4.24 + Agent: OpenClaw v2026.5.18 ... ``` diff --git a/.github/workflows/regression-e2e.yaml b/.github/workflows/regression-e2e.yaml index d6340d124e8..b3d4bf9e4a7 100644 --- a/.github/workflows/regression-e2e.yaml +++ b/.github/workflows/regression-e2e.yaml @@ -175,8 +175,8 @@ jobs: # ── OpenShell version-pin E2E ────────────────────────────── - # Coverage guard for #3474. If a host has sticky OpenShell 0.0.40 on PATH - # but this NemoClaw release supports only <=0.0.39, install-openshell.sh + # Coverage guard for #3474. If a host has sticky OpenShell 0.0.45 on PATH + # but this NemoClaw release supports only <=0.0.44, install-openshell.sh # must replace it with the pinned compatible release instead of hard-failing. openshell-version-pin-e2e: needs: select_regression_jobs diff --git a/Dockerfile.base b/Dockerfile.base index 4d920cb614b..70ca4c6c071 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -177,7 +177,7 @@ 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.4.24 +ARG OPENCLAW_VERSION=2026.5.18 SHELL ["/bin/bash", "-o", "pipefail", "-c"] @@ -204,7 +204,7 @@ RUN --mount=type=bind,source=nemoclaw-blueprint/blueprint.yaml,target=/tmp/bluep USER sandbox WORKDIR /sandbox # hadolint ignore=DL3059,DL4006 -RUN openclaw plugins install '@tencent-weixin/openclaw-weixin@2.4.2' --pin \ +RUN openclaw plugins install '@tencent-weixin/openclaw-weixin@2.4.3' --pin \ && openclaw config set plugins.entries.openclaw-weixin.enabled true # hadolint ignore=DL3002 USER root diff --git a/agents/hermes/Dockerfile.base b/agents/hermes/Dockerfile.base index 575f6b3b8fc..604dc374017 100644 --- a/agents/hermes/Dockerfile.base +++ b/agents/hermes/Dockerfile.base @@ -23,9 +23,9 @@ FROM node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0 ENV DEBIAN_FRONTEND=noninteractive # Hermes version pinned for reproducibility. -# Calver tag v2026.4.23 = semver 0.11.0. -ARG HERMES_VERSION=v2026.4.23 -ARG HERMES_TARBALL_SHA256=1ee1be80a2112b7edc581770cee8858e725ba110cc423979cd7102492504bc6b +# Calver tag v2026.5.16 = Hermes Agent v0.14.0. +ARG HERMES_VERSION=v2026.5.16 +ARG HERMES_TARBALL_SHA256=c0a554050a50ee9a62f3fa5cd288a167ba5640c42d647d100cdea084b7294143 ARG HERMES_UV_EXTRAS="messaging web" ARG UV_VERSION=0.11.8 diff --git a/agents/hermes/config/hermes-config.ts b/agents/hermes/config/hermes-config.ts index fc1f71c2a1e..5e634bbf154 100644 --- a/agents/hermes/config/hermes-config.ts +++ b/agents/hermes/config/hermes-config.ts @@ -62,7 +62,7 @@ export function buildHermesConfig(settings: HermesBuildSettings): Record binary_path: /usr/local/bin/openclaw version_command: "openclaw --version" -expected_version: "2026.4.24" +expected_version: "2026.5.18" gateway_command: "openclaw gateway run" # ── Health probe ──────────────────────────────────────────────── diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 5e554c5e899..7f1911e3ae8 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -416,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.4.24 + Agent: OpenClaw v2026.5.18 ... ``` diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index c04d416c98c..03c2a1f6452 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -383,7 +383,7 @@ Existing sandboxes do not auto-upgrade when a newer NemoClaw release ships a new ```console $ nemoclaw my-assistant status ... - Agent: OpenClaw v2026.4.24 + Agent: OpenClaw v2026.5.18 ... ``` diff --git a/nemoclaw-blueprint/blueprint.yaml b/nemoclaw-blueprint/blueprint.yaml index 2f184c85168..9faae92a98e 100644 --- a/nemoclaw-blueprint/blueprint.yaml +++ b/nemoclaw-blueprint/blueprint.yaml @@ -2,9 +2,9 @@ # SPDX-License-Identifier: Apache-2.0 version: "0.1.0" -min_openshell_version: "0.0.39" -max_openshell_version: "0.0.39" -min_openclaw_version: "2026.4.24" +min_openshell_version: "0.0.44" +max_openshell_version: "0.0.44" +min_openclaw_version: "2026.5.18" # 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 6d301636b59..f00d35efba4 100644 --- a/nemoclaw/package.json +++ b/nemoclaw/package.json @@ -11,11 +11,11 @@ "./dist/index.js" ], "compat": { - "pluginApi": ">=2026.5.10-beta.1", - "minGatewayVersion": "2026.5.10-beta.1" + "pluginApi": ">=2026.5.18", + "minGatewayVersion": "2026.5.18" }, "build": { - "openclawVersion": "2026.5.10-beta.1" + "openclawVersion": "2026.5.18" } }, "scripts": { diff --git a/nemoclaw/src/package-metadata.test.ts b/nemoclaw/src/package-metadata.test.ts index 214fa57bd8b..5485993de85 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.10-beta.1"); - expect(packageJson.openclaw?.compat?.minGatewayVersion).toBe("2026.5.10-beta.1"); - expect(packageJson.openclaw?.build?.openclawVersion).toBe("2026.5.10-beta.1"); + 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"); }); }); diff --git a/scripts/brev-launchable-ci-cpu.sh b/scripts/brev-launchable-ci-cpu.sh index f28bb352b17..cbd305b354c 100755 --- a/scripts/brev-launchable-ci-cpu.sh +++ b/scripts/brev-launchable-ci-cpu.sh @@ -28,7 +28,7 @@ # curl -fsSL https://raw.githubusercontent.com/NVIDIA/NemoClaw//scripts/brev-launchable-ci-cpu.sh | bash # # Environment overrides: -# OPENSHELL_VERSION — OpenShell CLI release tag (default: v0.0.39) +# OPENSHELL_VERSION — OpenShell CLI release tag (default: v0.0.44) # NEMOCLAW_REF — NemoClaw git ref to clone (default: main) # NEMOCLAW_CLONE_DIR — Where to clone NemoClaw (default: ~/NemoClaw) # SKIP_DOCKER_PULL — Set to 1 to skip Docker image pre-pulls @@ -40,7 +40,7 @@ set -euo pipefail # ── Configuration ──────────────────────────────────────────────────── -OPENSHELL_VERSION="${OPENSHELL_VERSION:-v0.0.39}" +OPENSHELL_VERSION="${OPENSHELL_VERSION:-v0.0.44}" NEMOCLAW_REF="${NEMOCLAW_REF:-main}" TARGET_USER="${SUDO_USER:-$(id -un)}" TARGET_HOME="$(getent passwd "$TARGET_USER" | cut -d: -f6)" @@ -250,7 +250,7 @@ DOCKER_PULL_PID="" if [[ "${SKIP_DOCKER_PULL:-0}" != "1" ]]; then info "Pre-pulling Docker images in background..." ( - SUPERVISOR_TAG="${OPENSHELL_VERSION#v}" # v0.0.39 -> 0.0.39 + SUPERVISOR_TAG="${OPENSHELL_VERSION#v}" # v0.0.44 -> 0.0.44 SUPERVISOR_IMAGE="ghcr.io/nvidia/openshell/supervisor:${SUPERVISOR_TAG}" # Pull all images in parallel diff --git a/scripts/generate-openclaw-config.py b/scripts/generate-openclaw-config.py index 0e3e3456d6c..576f76ba781 100755 --- a/scripts/generate-openclaw-config.py +++ b/scripts/generate-openclaw-config.py @@ -674,7 +674,7 @@ def _placeholder(channel: str, env_key: str) -> str: ), # NemoClaw sandboxes are provisioned non-interactively and the # E2E CLI contract expects the first agent turn to answer the - # caller's prompt. OpenClaw 2026.4.24 seeds BOOTSTRAP.md by + # caller's prompt. OpenClaw 2026.4.24+ seeds BOOTSTRAP.md by # default, which redirects a fresh workspace into an identity # setup conversation before normal replies. "skipBootstrap": True, @@ -701,7 +701,7 @@ def _placeholder(channel: str, env_key: str) -> str: # load. The sandbox L7 proxy denies the registry URL, the # install retries for ~6 minutes, and while it's stuck the # gateway can't service openclaw-agent requests — that's the - # TC-SBX-02 hang in 2026.4.24. + # TC-SBX-02 hang observed in 2026.4.24. # # acpx is disabled by default because its runtime dependency staging # also reaches npm during gateway startup. NemoClaw's primary CLI path diff --git a/scripts/install-openshell.sh b/scripts/install-openshell.sh index 1e658705e22..46c23c9f2e3 100755 --- a/scripts/install-openshell.sh +++ b/scripts/install-openshell.sh @@ -35,16 +35,16 @@ info "Detected $OS_LABEL ($ARCH_LABEL)" # Minimum version required for native messaging credential rewrite: # WebSocket text frames plus provider-shaped aliases and REST request bodies. -MIN_VERSION="0.0.39" +MIN_VERSION="0.0.44" # Maximum version validated for this NemoClaw release. Newer OpenShell builds # may change sandbox semantics; upgrade NemoClaw before upgrading past this. -MAX_VERSION="0.0.39" +MAX_VERSION="0.0.44" # Pin fresh installs to this version. The TS installer normally overrides this # via NEMOCLAW_OPENSHELL_PIN_VERSION after resolving the highest published # OpenShell release that satisfies the blueprint's max_openshell_version # (see #3404). The hardcoded value is the fallback for offline runs. PIN_VERSION="$MAX_VERSION" -DEV_MIN_VERSION="0.0.39" +DEV_MIN_VERSION="0.0.44" CHANNEL="${NEMOCLAW_OPENSHELL_CHANNEL:-auto}" case "$CHANNEL" in diff --git a/scripts/seed-wechat-accounts.py b/scripts/seed-wechat-accounts.py index eff6c49742c..48d96a92332 100755 --- a/scripts/seed-wechat-accounts.py +++ b/scripts/seed-wechat-accounts.py @@ -10,7 +10,7 @@ # would otherwise drive an in-sandbox QR scan that has no terminal and no # paired phone access. # -# Files written (matching auth/accounts.ts in @tencent-weixin/openclaw-weixin@2.4.2): +# Files written (matching auth/accounts.ts in @tencent-weixin/openclaw-weixin@2.4.3): # /openclaw-weixin/accounts.json — JSON array of accountIds # /openclaw-weixin/accounts/.json — { token, savedAt, baseUrl, userId } # /openclaw.json (channels.openclaw-weixin) — registered channel + accounts..enabled diff --git a/src/ext/wechat/qr.ts b/src/ext/wechat/qr.ts index 33206767738..ce807326421 100644 --- a/src/ext/wechat/qr.ts +++ b/src/ext/wechat/qr.ts @@ -38,7 +38,7 @@ export const WECHAT_ILINK_APP_ID = "bot"; * installed in the sandbox image, so the iLink gateway sees the same * client version from both the host login and the in-sandbox plugin. * Bump together with the version pinned in the Dockerfile. */ -export const WECHAT_ILINK_CLIENT_VERSION = encodeIlinkClientVersion("2.4.2"); +export const WECHAT_ILINK_CLIENT_VERSION = encodeIlinkClientVersion("2.4.3"); /** Client-side ceiling for a single status long-poll. 35s keeps us within * typical 60s gateway/proxy idle windows. */ diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 0e8f272363a..77ba91f852a 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2919,7 +2919,7 @@ function getOpenShellDockerSupervisorImage(versionOutput: string | null = null): if (shouldUseOpenshellDevChannel() || isOpenshellDevVersion(versionOutput)) { return "ghcr.io/nvidia/openshell/supervisor:dev"; } - const supportedVersion = installedVersion ?? getBlueprintMaxOpenshellVersion() ?? "0.0.39"; + const supportedVersion = installedVersion ?? getBlueprintMaxOpenshellVersion() ?? "0.0.44"; return `ghcr.io/nvidia/openshell/supervisor:${supportedVersion}`; } @@ -4169,7 +4169,7 @@ async function startDockerDriverGateway({ exitOnFailure = true, skipSandboxBridg } if (!gatewayBin) { console.error(" OpenShell Docker-driver gateway binary not found."); - console.error(" Install OpenShell v0.0.39, or set NEMOCLAW_OPENSHELL_GATEWAY_BIN."); + console.error(" Install OpenShell v0.0.44, or set NEMOCLAW_OPENSHELL_GATEWAY_BIN."); if (exitOnFailure) process.exit(1); throw new Error("OpenShell gateway binary not found"); } diff --git a/src/lib/onboard/docker-driver-gateway-runtime-marker.test.ts b/src/lib/onboard/docker-driver-gateway-runtime-marker.test.ts index ca9ca31aab8..a99a2b2b470 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime-marker.test.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime-marker.test.ts @@ -22,7 +22,7 @@ const expected = { }, endpoint: "http://127.0.0.1:8080", gatewayBin: "/usr/local/bin/openshell-gateway", - openshellVersion: "0.0.39", + openshellVersion: "0.0.44", dockerHost: "unix:///Users/me/.colima/default/docker.sock", platform: "darwin" as NodeJS.Platform, arch: "arm64" as NodeJS.Architecture, diff --git a/src/lib/onboard/openshell-install.ts b/src/lib/onboard/openshell-install.ts index f11531251f4..7ff90f5af2e 100644 --- a/src/lib/onboard/openshell-install.ts +++ b/src/lib/onboard/openshell-install.ts @@ -159,7 +159,7 @@ export function ensureOpenshellForOnboard(deps: OpenShellInstallDeps): OpenShell deps.exit(1); } } else { - const minOpenshellVersion = deps.getBlueprintMinOpenshellVersion() ?? "0.0.39"; + const minOpenshellVersion = deps.getBlueprintMinOpenshellVersion() ?? "0.0.44"; const currentVersionOutput = deps.runCaptureOpenshell(["--version"], { ignoreError: true }); const needsDevChannel = deps.isLinuxDockerDriverGatewayEnabled(platform, arch) && diff --git a/src/lib/sandbox/version.test.ts b/src/lib/sandbox/version.test.ts index 8597ae35d8c..385c77a120c 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.4.24" : "2026.4.24", + expectedVersion: name === "openclaw" ? "2026.5.18" : "2026.5.16", stateDirs: [], configPaths: { dir: "/sandbox/.openclaw" }, })), @@ -77,12 +77,12 @@ describe("checkAgentVersion", () => { registry.registerSandbox({ name: "test-sb", agent: null, - agentVersion: "2026.4.24", + agentVersion: "2026.5.18", }); const result = checkAgentVersion("test-sb"); expect(result.detectionMethod).toBe("registry"); - expect(result.sandboxVersion).toBe("2026.4.24"); + expect(result.sandboxVersion).toBe("2026.5.18"); expect(result.isStale).toBe(false); }); @@ -103,7 +103,7 @@ describe("checkAgentVersion", () => { registry.registerSandbox({ name: "test-sb", agent: null, - agentVersion: "2026.4.24", + agentVersion: "2026.5.18", }); const result = checkAgentVersion("test-sb"); @@ -120,7 +120,7 @@ describe("checkAgentVersion", () => { vi.mocked(spawnSync).mockReturnValue({ status: 0, - stdout: "OpenClaw 2026.4.24 (abc123)\n", + stdout: "OpenClaw 2026.5.18 (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.4.24"); + expect(result.sandboxVersion).toBe("2026.5.18"); expect(result.isStale).toBe(false); expect(captureOpenshellCommand).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.4.24"); + expect(updated?.agentVersion).toBe("2026.5.18"); }); it("returns unavailable when SSH config fails", () => { @@ -183,7 +183,7 @@ describe("checkAgentVersion", () => { vi.mocked(spawnSync).mockReturnValue({ status: 0, - stdout: "OpenClaw 2026.4.24 (abc123)\n", + stdout: "OpenClaw 2026.5.18 (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.4.24"); + expect(result.sandboxVersion).toBe("2026.5.18"); }); }); @@ -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.4.24", + expectedVersion: "2026.5.18", isStale: true, detectionMethod: "registry", }); const joined = lines.join("\n"); expect(joined).toContain("my-sb"); expect(joined).toContain("2026.3.11"); - expect(joined).toContain("2026.4.24"); + expect(joined).toContain("2026.5.18"); expect(joined).toContain("rebuild"); }); }); diff --git a/src/lib/verify-deployment.test.ts b/src/lib/verify-deployment.test.ts index 18592a9adb0..6eeaa4cabe3 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.4.24", stderr: "" }; + return { status: 0, stdout: "2026.5.18", stderr: "" }; } return { status: 0, stdout: "200", stderr: "" }; }, }); const result = await verifyDeployment("my-sandbox", chain, deps, NO_RETRY); - expect(result.verification.gatewayVersion).toBe("2026.4.24"); + expect(result.verification.gatewayVersion).toBe("2026.5.18"); }); 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.4.24", stderr: "" }; + return { status: 0, stdout: "2026.5.18", 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.4.24", stderr: "" }; + return { status: 0, stdout: "2026.5.18", 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.4.24"))).toBe(true); + expect(lines.some((l) => l.includes("2026.5.18"))).toBe(true); }); it("prints failure diagnostics with hints when unhealthy", async () => { diff --git a/test/e2e/docs/parity-inventory.generated.json b/test/e2e/docs/parity-inventory.generated.json index 62f9c3e441c..f42dff2ee9d 100644 --- a/test/e2e/docs/parity-inventory.generated.json +++ b/test/e2e/docs/parity-inventory.generated.json @@ -12140,9 +12140,9 @@ { "script": "test/e2e/test-openshell-version-pin.sh", "line": 215, - "text": "Installer hard-failed on sticky OpenShell 0.0.40 instead of reinstalling pinned 0.0.39 (#3474)", + "text": "Installer hard-failed on sticky OpenShell 0.0.45 instead of reinstalling pinned 0.0.44 (#3474)", "polarity": "fail", - "normalized_id": "installer.hard.failed.on.sticky.openshell.0.0.40.instead.of.reinstalling.pinned.0.0.39.3474", + "normalized_id": "installer.hard.failed.on.sticky.openshell.0.0.45.instead.of.reinstalling.pinned.0.0.44.3474", "mapping_status": "retired" }, { @@ -12164,49 +12164,49 @@ { "script": "test/e2e/test-openshell-version-pin.sh", "line": 222, - "text": "Expected installer to download pinned OpenShell v0.0.39", + "text": "Expected installer to download pinned OpenShell v0.0.44", "polarity": "fail", - "normalized_id": "expected.installer.to.download.pinned.openshell.v0.0.39", + "normalized_id": "expected.installer.to.download.pinned.openshell.v0.0.44", "mapping_status": "retired" }, { "script": "test/e2e/test-openshell-version-pin.sh", "line": 224, - "text": "Installer downloaded pinned OpenShell v0.0.39", + "text": "Installer downloaded pinned OpenShell v0.0.44", "polarity": "pass", - "normalized_id": "installer.downloaded.pinned.openshell.v0.0.39", + "normalized_id": "installer.downloaded.pinned.openshell.v0.0.44", "mapping_status": "mapped" }, { "script": "test/e2e/test-openshell-version-pin.sh", "line": 227, - "text": "Installer downloaded OpenShell v0.0.40 despite NemoClaw max 0.0.39", + "text": "Installer downloaded OpenShell v0.0.45 despite NemoClaw max 0.0.44", "polarity": "fail", - "normalized_id": "installer.downloaded.openshell.v0.0.40.despite.nemoclaw.max.0.0.39", + "normalized_id": "installer.downloaded.openshell.v0.0.45.despite.nemoclaw.max.0.0.44", "mapping_status": "retired" }, { "script": "test/e2e/test-openshell-version-pin.sh", "line": 229, - "text": "Installer did not download too-new OpenShell v0.0.40", + "text": "Installer did not download too-new OpenShell v0.0.45", "polarity": "pass", - "normalized_id": "installer.did.not.download.too.new.openshell.v0.0.40", + "normalized_id": "installer.did.not.download.too.new.openshell.v0.0.45", "mapping_status": "mapped" }, { "script": "test/e2e/test-openshell-version-pin.sh", "line": 232, - "text": "openshell binary was not replaced with pinned 0.0.39", + "text": "openshell binary was not replaced with pinned 0.0.44", "polarity": "fail", - "normalized_id": "openshell.binary.was.not.replaced.with.pinned.0.0.39", + "normalized_id": "openshell.binary.was.not.replaced.with.pinned.0.0.44", "mapping_status": "retired" }, { "script": "test/e2e/test-openshell-version-pin.sh", "line": 234, - "text": "Sticky openshell 0.0.40 was replaced with pinned 0.0.39", + "text": "Sticky openshell 0.0.45 was replaced with pinned 0.0.44", "polarity": "pass", - "normalized_id": "sticky.openshell.0.0.40.was.replaced.with.pinned.0.0.39", + "normalized_id": "sticky.openshell.0.0.45.was.replaced.with.pinned.0.0.44", "mapping_status": "mapped" } ] diff --git a/test/e2e/docs/parity-map.yaml b/test/e2e/docs/parity-map.yaml index 80b412e6022..d5029684ff8 100644 --- a/test/e2e/docs/parity-map.yaml +++ b/test/e2e/docs/parity-map.yaml @@ -10772,7 +10772,7 @@ scripts: status: migrated bucket: install-upgrade assertions: - - legacy: Installer hard-failed on sticky OpenShell 0.0.40 instead of reinstalling pinned 0.0.39 (#3474) + - legacy: Installer hard-failed on sticky OpenShell 0.0.45 instead of reinstalling pinned 0.0.44 (#3474) status: retired reason: legacy negative/failure assertion retained by script but not represented as scenario success criterion reviewer: e2e-maintainers @@ -10785,27 +10785,27 @@ scripts: - legacy: install-openshell.sh completed status: mapped id: legacy.openshell.version.pin.install.openshell.sh.completed - - legacy: Expected installer to download pinned OpenShell v0.0.39 + - legacy: Expected installer to download pinned OpenShell v0.0.44 status: retired reason: legacy negative/failure assertion retained by script but not represented as scenario success criterion reviewer: e2e-maintainers approved_at: '2026-05-13' - - legacy: Installer downloaded pinned OpenShell v0.0.39 + - legacy: Installer downloaded pinned OpenShell v0.0.44 status: mapped - id: legacy.openshell.version.pin.installer.downloaded.pinned.openshell.vv39 - - legacy: Installer downloaded OpenShell v0.0.40 despite NemoClaw max 0.0.39 + id: legacy.openshell.version.pin.installer.downloaded.pinned.openshell.vv44 + - legacy: Installer downloaded OpenShell v0.0.45 despite NemoClaw max 0.0.44 status: retired reason: legacy negative/failure assertion retained by script but not represented as scenario success criterion reviewer: e2e-maintainers approved_at: '2026-05-13' - - legacy: Installer did not download too-new OpenShell v0.0.40 + - legacy: Installer did not download too-new OpenShell v0.0.45 status: mapped - id: legacy.openshell.version.pin.installer.did.not.download.too.new.openshell.vv40 - - legacy: openshell binary was not replaced with pinned 0.0.39 + id: legacy.openshell.version.pin.installer.did.not.download.too.new.openshell.vv45 + - legacy: openshell binary was not replaced with pinned 0.0.44 status: retired reason: legacy negative/failure assertion retained by script but not represented as scenario success criterion reviewer: e2e-maintainers approved_at: '2026-05-13' - - legacy: Sticky openshell 0.0.40 was replaced with pinned 0.0.39 + - legacy: Sticky openshell 0.0.45 was replaced with pinned 0.0.44 status: mapped - id: legacy.openshell.version.pin.sticky.openshell.v40.was.replaced.with.pinned.v39 + id: legacy.openshell.version.pin.sticky.openshell.v45.was.replaced.with.pinned.v44 diff --git a/test/e2e/test-openshell-gateway-upgrade.sh b/test/e2e/test-openshell-gateway-upgrade.sh index 7a0f2f78595..60c71b8fe6e 100755 --- a/test/e2e/test-openshell-gateway-upgrade.sh +++ b/test/e2e/test-openshell-gateway-upgrade.sh @@ -52,7 +52,7 @@ STATE_DIR="${NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR:-$HOME/.local/state/nemoclaw/o PID_FILE="${STATE_DIR}/openshell-gateway.pid" OLD_NEMOCLAW_REF="${NEMOCLAW_OLD_NEMOCLAW_REF:-v0.0.36}" OLD_OPENSHELL_VERSION="${NEMOCLAW_OLD_OPENSHELL_VERSION:-0.0.36}" -CURRENT_OPENSHELL_VERSION="${NEMOCLAW_CURRENT_OPENSHELL_VERSION:-0.0.39}" +CURRENT_OPENSHELL_VERSION="${NEMOCLAW_CURRENT_OPENSHELL_VERSION:-0.0.44}" SURVIVOR_SANDBOX="${NEMOCLAW_GATEWAY_UPGRADE_SURVIVOR_NAME:-e2e-gateway-upgrade-survivor}" SURVIVOR_MARKER="gateway-upgrade-survivor-$(date +%s)" SURVIVOR_MARKER_PATH="/sandbox/.openclaw/workspace/nemoclaw-gateway-upgrade-marker" @@ -145,7 +145,7 @@ EOF # request-body-credential-rewrite # websocket-credential-rewrite if [ "${1:-}" = "--version" ]; then - printf 'openshell 0.0.39\n' + printf 'openshell 0.0.44\n' exit 0 fi exit 99 @@ -235,7 +235,7 @@ EOF # request-body-credential-rewrite # websocket-credential-rewrite if [ "${1:-}" = "--version" ]; then - printf 'openshell 0.0.39\n' + printf 'openshell 0.0.44\n' exit 0 fi exit 99 diff --git a/test/e2e/test-openshell-version-pin.sh b/test/e2e/test-openshell-version-pin.sh index 86c3dfdc314..5fe7496fdb4 100755 --- a/test/e2e/test-openshell-version-pin.sh +++ b/test/e2e/test-openshell-version-pin.sh @@ -8,11 +8,11 @@ # pinned compatible version instead of failing before the reinstall path. # # Expected result on unfixed main: FAIL. scripts/install-openshell.sh sees the -# fake installed `openshell 0.0.40`, compares it to MAX_VERSION=0.0.39, and -# exits with "above the maximum" before downloading the pinned 0.0.39 release. +# fake installed `openshell 0.0.45`, compares it to MAX_VERSION=0.0.44, and +# exits with "above the maximum" before downloading the pinned 0.0.44 release. # # Expected result after the fix: PASS. The script warns about the too-new -# installed OpenShell, downloads v0.0.39, replaces openshell plus helper +# installed OpenShell, downloads v0.0.44, replaces openshell plus helper # binaries, and exits successfully. set -euo pipefail @@ -74,7 +74,7 @@ SH # the pinned compatible release. write_executable "$FAKE_BIN/openshell" <<'SH' #!/usr/bin/env bash -if [ "${1:-}" = "--version" ]; then echo "openshell 0.0.40"; exit 0; fi +if [ "${1:-}" = "--version" ]; then echo "openshell 0.0.45"; exit 0; fi # request-body-credential-rewrite websocket-credential-rewrite exit 0 SH @@ -163,7 +163,7 @@ exit 0 SH # The installer extracts three archives. Create the binary each archive would -# have produced. The replacement openshell reports 0.0.39 and contains the +# have produced. The replacement openshell reports 0.0.44 and contains the # feature strings checked by install-openshell.sh. write_executable "$FAKE_BIN/tar" <<'SH' #!/usr/bin/env bash @@ -185,7 +185,7 @@ case "$*" in esac cat > "$outdir/$name" <<'EOS' #!/usr/bin/env bash -if [ "${1:-}" = "--version" ]; then echo "openshell 0.0.39"; exit 0; fi +if [ "${1:-}" = "--version" ]; then echo "openshell 0.0.44"; exit 0; fi # request-body-credential-rewrite websocket-credential-rewrite exit 0 EOS @@ -200,7 +200,7 @@ cat "$@" 2>/dev/null || true SH cd "$REPO_ROOT" -info "Running install-openshell.sh with sticky openshell 0.0.40 and max 0.0.39" +info "Running install-openshell.sh with sticky openshell 0.0.45 and max 0.0.44" set +e env \ PATH="$FAKE_BIN:/usr/bin:/bin" \ @@ -211,26 +211,26 @@ install_rc=$? set -e if [ "$install_rc" -ne 0 ]; then - if grep -q "openshell 0.0.40 is above the maximum (0.0.39)" "$INSTALL_LOG"; then - fail "Installer hard-failed on sticky OpenShell 0.0.40 instead of reinstalling pinned 0.0.39 (#3474)" + if grep -q "openshell 0.0.45 is above the maximum (0.0.44)" "$INSTALL_LOG"; then + fail "Installer hard-failed on sticky OpenShell 0.0.45 instead of reinstalling pinned 0.0.44 (#3474)" fi fail "install-openshell.sh failed before proving sticky-version recovery (exit ${install_rc})" fi pass "install-openshell.sh completed" -if ! grep -q "v0.0.39" "$DOWNLOAD_LOG"; then - fail "Expected installer to download pinned OpenShell v0.0.39" +if ! grep -q "v0.0.44" "$DOWNLOAD_LOG"; then + fail "Expected installer to download pinned OpenShell v0.0.44" fi -pass "Installer downloaded pinned OpenShell v0.0.39" +pass "Installer downloaded pinned OpenShell v0.0.44" -if grep -q "v0.0.40" "$DOWNLOAD_LOG"; then - fail "Installer downloaded OpenShell v0.0.40 despite NemoClaw max 0.0.39" +if grep -q "v0.0.45" "$DOWNLOAD_LOG"; then + fail "Installer downloaded OpenShell v0.0.45 despite NemoClaw max 0.0.44" fi -pass "Installer did not download too-new OpenShell v0.0.40" +pass "Installer did not download too-new OpenShell v0.0.45" -if ! "$FAKE_BIN/openshell" --version 2>&1 | grep -q "0.0.39"; then - fail "openshell binary was not replaced with pinned 0.0.39" +if ! "$FAKE_BIN/openshell" --version 2>&1 | grep -q "0.0.44"; then + fail "openshell binary was not replaced with pinned 0.0.44" fi -pass "Sticky openshell 0.0.40 was replaced with pinned 0.0.39" +pass "Sticky openshell 0.0.45 was replaced with pinned 0.0.44" info "OpenShell sticky-version pin guard complete" diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index 27633c4d55b..85d9a90b00e 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -267,8 +267,8 @@ describe("generate-openclaw-config.py: config generation", () => { const configPath = path.join(tmpDir, ".openclaw", "openclaw.json"); const installEntry = { type: "npm", - spec: "@tencent-weixin/openclaw-weixin@2.4.2", - resolved: "@tencent-weixin/openclaw-weixin@2.4.2", + spec: "@tencent-weixin/openclaw-weixin@2.4.3", + resolved: "@tencent-weixin/openclaw-weixin@2.4.3", }; fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.writeFileSync( @@ -328,8 +328,8 @@ describe("generate-openclaw-config.py: config generation", () => { fs.mkdirSync(path.dirname(configPath), { recursive: true }); const installEntry = { type: "npm", - spec: "@tencent-weixin/openclaw-weixin@2.4.2", - resolved: "@tencent-weixin/openclaw-weixin@2.4.2", + spec: "@tencent-weixin/openclaw-weixin@2.4.3", + resolved: "@tencent-weixin/openclaw-weixin@2.4.3", }; fs.writeFileSync( configPath, diff --git a/test/install-openshell-version-check.test.ts b/test/install-openshell-version-check.test.ts index a931698a875..a723aeeca91 100644 --- a/test/install-openshell-version-check.test.ts +++ b/test/install-openshell-version-check.test.ts @@ -124,34 +124,34 @@ exit 0`, } describe("install-openshell.sh version check", { timeout: 15_000 }, () => { - it("exits cleanly when openshell 0.0.39 and driver binaries are already installed", () => { - const result = runWithInstalledVersion("0.0.39"); + it("exits cleanly when openshell 0.0.44 and driver binaries are already installed", () => { + const result = runWithInstalledVersion("0.0.44"); expect(result.status).toBe(0); - expect(result.stdout).toMatch(/already installed.*0\.0\.39/); + expect(result.stdout).toMatch(/already installed.*0\.0\.44/); }); - it("triggers reinstall when openshell 0.0.39 is missing Docker-driver binaries", () => { - const result = runWithInstalledVersion("0.0.39", {}, { driverBins: false, os: "Linux" }); + it("triggers reinstall when openshell 0.0.44 is missing Docker-driver binaries", () => { + const result = runWithInstalledVersion("0.0.44", {}, { driverBins: false, os: "Linux" }); expect(result.status).not.toBe(0); expect(result.stdout).toMatch(/missing Docker-driver binaries/); - expect(result.stdout).toMatch(/Installing OpenShell from release 'v0\.0\.39'/); + expect(result.stdout).toMatch(/Installing OpenShell from release 'v0\.0\.44'/); }); - it("fails closed when openshell 0.0.39 lacks required messaging rewrite support", () => { - const result = runWithInstalledVersion("0.0.39", {}, { capability: false }); + it("fails closed when openshell 0.0.44 lacks required messaging rewrite support", () => { + const result = runWithInstalledVersion("0.0.44", {}, { capability: false }); expect(result.status).toBe(1); // `fail()` writes to stderr as of #3446; previously stdout. expect(result.stderr).toMatch(/missing request-body-credential-rewrite support/); }); - it("accepts macOS openshell 0.0.39 when the gateway binary is installed", () => { - const result = runWithInstalledVersion("0.0.39", {}, { + it("accepts macOS openshell 0.0.44 when the gateway binary is installed", () => { + const result = runWithInstalledVersion("0.0.44", {}, { driverBins: "gateway", os: "Darwin", arch: "arm64", }); expect(result.status).toBe(0); - expect(result.stdout).toMatch(/already installed.*0\.0\.39/); + expect(result.stdout).toMatch(/already installed.*0\.0\.44/); }); it("does not require the macOS VM driver entitlement for Docker-driver onboarding", () => { @@ -160,7 +160,7 @@ describe("install-openshell.sh version check", { timeout: 15_000 }, () => { const state = path.join(tmp, "codesign-state"); const log = path.join(tmp, "codesign.log"); const result = runWithInstalledVersion( - "0.0.39", + "0.0.44", { NEMOCLAW_FAKE_CODESIGN_HAS_ENTITLEMENT: "0", NEMOCLAW_FAKE_CODESIGN_STATE: state, @@ -174,7 +174,7 @@ describe("install-openshell.sh version check", { timeout: 15_000 }, () => { ); expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - expect(result.stdout).toMatch(/already installed.*0\.0\.39/); + expect(result.stdout).toMatch(/already installed.*0\.0\.44/); expect(result.stdout).not.toMatch(/missing the macOS Hypervisor entitlement/); expect(result.stdout).not.toMatch(/Signing openshell-driver-vm/); expect(result.stdout).not.toMatch(/Installing OpenShell from release/); @@ -184,15 +184,15 @@ describe("install-openshell.sh version check", { timeout: 15_000 }, () => { } }); - it("triggers reinstall on macOS when openshell 0.0.39 is missing required gateway binaries", () => { - const result = runWithInstalledVersion("0.0.39", {}, { + it("triggers reinstall on macOS when openshell 0.0.44 is missing required gateway binaries", () => { + const result = runWithInstalledVersion("0.0.44", {}, { driverBins: false, os: "Darwin", arch: "arm64", }); expect(result.status).not.toBe(0); expect(result.stdout).toMatch(/missing Docker-driver binaries/); - expect(result.stdout).toMatch(/Installing OpenShell from release 'v0\.0\.39'/); + expect(result.stdout).toMatch(/Installing OpenShell from release 'v0\.0\.44'/); }); it("downloads the macOS arm64 gateway asset during reinstall", () => { @@ -266,7 +266,7 @@ dest="\${@: -1}" mkdir -p "$(dirname "$dest")" cat > "$dest" <<'EOF' #!/usr/bin/env bash -if [ "\${1:-}" = "--version" ]; then echo "openshell 0.0.39"; exit 0; fi +if [ "\${1:-}" = "--version" ]; then echo "openshell 0.0.44"; exit 0; fi # request-body-credential-rewrite websocket-credential-rewrite exit 0 EOF @@ -388,7 +388,7 @@ printf '%s\\n' "$dest" >> ${JSON.stringify(installLog)} mkdir -p "$(dirname "$dest")" case "$(basename "$dest")" in openshell) - printf '#!/usr/bin/env bash\\nif [ "$1" = "--version" ]; then echo "openshell 0.0.39"; else exit 0; fi\\n# request-body-credential-rewrite websocket-credential-rewrite\\n' > "$dest" + printf '#!/usr/bin/env bash\\nif [ "$1" = "--version" ]; then echo "openshell 0.0.44"; else exit 0; fi\\n# request-body-credential-rewrite websocket-credential-rewrite\\n' > "$dest" ;; *) printf '#!/usr/bin/env bash\\nexit 0\\n' > "$dest" @@ -445,23 +445,23 @@ exit 0`, }); it("reinstalls the pinned release when openshell is above MAX_VERSION", () => { - const result = runWithInstalledVersion("0.0.40"); + const result = runWithInstalledVersion("0.0.45"); expect(result.status).not.toBe(0); - expect(result.stdout).toMatch(/above the maximum.*reinstalling pinned OpenShell 0\.0\.39/); - expect(result.stdout).toMatch(/Installing OpenShell from release 'v0\.0\.39'/); + expect(result.stdout).toMatch(/above the maximum.*reinstalling pinned OpenShell 0\.0\.44/); + expect(result.stdout).toMatch(/Installing OpenShell from release 'v0\.0\.44'/); expect(result.stderr).not.toMatch(/Upgrade NemoClaw first/); }); it("reinstalls the pinned release when openshell is at a much newer version", () => { const result = runWithInstalledVersion("0.1.0"); expect(result.status).not.toBe(0); - expect(result.stdout).toMatch(/above the maximum.*reinstalling pinned OpenShell 0\.0\.39/); - expect(result.stdout).toMatch(/Installing OpenShell from release 'v0\.0\.39'/); + expect(result.stdout).toMatch(/above the maximum.*reinstalling pinned OpenShell 0\.0\.44/); + expect(result.stdout).toMatch(/Installing OpenShell from release 'v0\.0\.44'/); expect(result.stderr).not.toMatch(/Upgrade NemoClaw first/); }); it("accepts an installed OpenShell dev-channel Docker-driver build", () => { - const result = runWithInstalledVersion("0.0.39.dev84+g6b2180425", { + const result = runWithInstalledVersion("0.0.44.dev84+g6b2180425", { NEMOCLAW_OPENSHELL_CHANNEL: "dev", }); expect(result.status).toBe(0); From 68b852f98210251945355b46d0ecda8a353030c8 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 19 May 2026 15:01:27 -0700 Subject: [PATCH 02/12] fix: update OpenClaw source patch for 2026.5.18 --- Dockerfile | 14 ++-- test/fetch-guard-patch-regression.test.ts | 99 +++++++++++++++++++++++ 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index c08a98241ba..17acee1bb91 100644 --- a/Dockerfile +++ b/Dockerfile @@ -202,12 +202,12 @@ RUN set -eu; \ sed -i 's/const baseLstat = await fs\.lstat(params\.installBaseDir)/const baseLstat = await fs.stat(params.installBaseDir)/' "$ipd_file"; \ sed -i 's/baseLstat\.isSymbolicLink()/false \/* nemoclaw: symlink check disabled, realpath guards containment *\//' "$ipd_file"; \ if grep -q 'fs\.lstat(params\.installBaseDir)' "$ipd_file"; then echo "ERROR: Patch 3b (install-package-dir) left lstat in assertInstallBaseStable" >&2; exit 1; fi; \ - # --- Patch 5: bump default WS handshake timeout 10s -> 60s (#2484) --- \ - # OpenClaw's WS connect handshake has a hard-coded 10s timeout on both \ + # --- Patch 5: bump default WS handshake timeout to 60s (#2484) --- \ + # OpenClaw's WS connect handshake has a hard-coded short timeout on both \ # client and server. Server-side connect-handler processing can exceed \ - # 10s under load (multiple concurrent connects on slow CI infra), \ + # that limit under load (multiple concurrent connects on slow CI infra), \ # causing `openclaw agent --json` to fail with "gateway timeout after \ - # 10000ms" and TC-SBX-02 to hit its 90s SSH timeout. \ + # ms" and TC-SBX-02 to hit its 90s SSH timeout. \ # \ # Both env vars (OPENCLAW_HANDSHAKE_TIMEOUT_MS, \ # OPENCLAW_CONNECT_CHALLENGE_TIMEOUT_MS) are clamped at the same \ @@ -217,10 +217,10 @@ RUN set -eu; \ # \ # Removal criteria: drop when openclaw fixes the underlying connect \ # latency, or exposes the timeout as an unbounded env override. \ - hto_files="$(grep -RIlE --include='*.js' 'DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 1e4' "$OC_DIST")"; \ + hto_files="$(grep -RIlE --include='*.js' 'DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = (1e4|15e3)' "$OC_DIST")"; \ test -n "$hto_files" || { echo "ERROR: handshake-timeout constant not found" >&2; exit 1; }; \ - printf '%s\n' "$hto_files" | xargs sed -i -E 's|DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 1e4|DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 6e4|g'; \ - if grep -REq --include='*.js' 'DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 1e4' "$OC_DIST"; then echo "ERROR: Patch 5 left a 1e4 constant" >&2; exit 1; fi + printf '%s\n' "$hto_files" | xargs sed -i -E 's#DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = (1e4|15e3)#DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 6e4#g'; \ + if grep -REq --include='*.js' 'DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = (1e4|15e3)' "$OC_DIST"; then echo "ERROR: Patch 5 left a short timeout constant" >&2; exit 1; fi # Set up blueprint for local resolution. # Blueprints are immutable at runtime; DAC protection (root ownership) is applied diff --git a/test/fetch-guard-patch-regression.test.ts b/test/fetch-guard-patch-regression.test.ts index a12a859494c..0d73fd4568b 100644 --- a/test/fetch-guard-patch-regression.test.ts +++ b/test/fetch-guard-patch-regression.test.ts @@ -141,4 +141,103 @@ if (globalThis.proxyChecks.length !== 0) throw new Error('sandbox proxy validati fs.rmSync(tmp, { recursive: true, force: true }); } }); + + it("keeps the Dockerfile OpenClaw source-shape patches aligned with current dist", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-patches-")); + const dist = path.join(tmp, "dist"); + fs.mkdirSync(dist, { recursive: true }); + fs.writeFileSync( + path.join(dist, "fetch-guard-test.js"), + [ + "const withStrictGuardedFetchMode = Symbol('strict');", + "const withTrustedEnvProxyGuardedFetchMode = Symbol('trusted');", + "async function assertExplicitProxyAllowed(proxyUrl) { throw new Error(proxyUrl); }", + "export { withStrictGuardedFetchMode as a, withTrustedEnvProxyGuardedFetchMode as b };", + "", + ].join("\n"), + ); + fs.writeFileSync( + path.join(dist, "install-safe-path-test.js"), + "const baseLstat = await fs.lstat(baseDir);\n", + ); + fs.writeFileSync( + path.join(dist, "install-package-dir-test.js"), + [ + "async function assertInstallBaseStable(params) {", + " const baseLstat = await fs.lstat(params.installBaseDir);", + " if (baseLstat.isSymbolicLink()) throw new Error('symlink');", + "}", + "", + ].join("\n"), + ); + fs.writeFileSync( + path.join(dist, "client-test.js"), + "const DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 15e3;\n", + ); + fs.writeFileSync( + path.join(dist, "server.impl-test.js"), + "const DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 15e3;\n", + ); + + const command = dockerRunCommandBetween( + "# Patch OpenClaw media fetch for proxy-only sandbox", + "# Set up blueprint for local resolution", + ).replaceAll("/usr/local/lib/node_modules/openclaw/dist", dist); + const fakeBin = path.join(tmp, "bin"); + fs.mkdirSync(fakeBin); + const sedWrapper = path.join(fakeBin, "sed"); + fs.writeFileSync( + sedWrapper, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "extended=0", + 'if [ "${1:-}" = "-i" ]; then', + ' if [ "${2:-}" = "-E" ]; then', + " extended=1", + " expr=$3", + " shift 3", + " else", + " expr=$2", + " shift 2", + " fi", + ' for file in "$@"; do', + " tmp=$(mktemp)", + ' if [ "$extended" = "1" ]; then', + ' /usr/bin/sed -E "$expr" "$file" > "$tmp"', + " else", + ' /usr/bin/sed "$expr" "$file" > "$tmp"', + " fi", + ' mv "$tmp" "$file"', + " done", + " exit 0", + "fi", + 'exec /usr/bin/sed "$@"', + ].join("\n"), + { mode: 0o755 }, + ); + const scriptPath = path.join(tmp, "patch-all.sh"); + fs.writeFileSync(scriptPath, ["#!/usr/bin/env bash", command].join("\n"), { mode: 0o700 }); + + try { + const patch = spawnSync("bash", [scriptPath], { + encoding: "utf-8", + env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH || ""}` }, + timeout: 5000, + }); + expect(patch.status, `${patch.stdout}${patch.stderr}`).toBe(0); + const patched = fs.readdirSync(dist).map((file) => fs.readFileSync(path.join(dist, file), "utf-8")); + expect(patched.join("\n")).not.toContain("DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 15e3"); + expect(patched.join("\n")).not.toContain("DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 1e4"); + expect(patched.join("\n").match(/DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS = 6e4/g)).toHaveLength(2); + expect(fs.readFileSync(path.join(dist, "install-safe-path-test.js"), "utf-8")).toContain( + "const baseLstat = await fs.stat(baseDir)", + ); + expect(fs.readFileSync(path.join(dist, "install-package-dir-test.js"), "utf-8")).toContain( + "const baseLstat = await fs.stat(params.installBaseDir)", + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); }); From 23f5decae9fe1f0adb48e5bfd72ae479794e63ec Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 19 May 2026 16:24:15 -0700 Subject: [PATCH 03/12] fix: remediate OpenClaw 2026.5.18 nightly drift --- .../kimi-inference-compat/index.js | 129 ++++++-- scripts/generate-openclaw-config.py | 53 +++- scripts/seed-wechat-accounts.py | 127 +++++++- src/lib/policy/index.ts | 2 +- test/e2e/lib/openclaw-agent-json.py | 71 +++++ test/e2e/lib/slack-api-proof.sh | 275 +++++++++++------- ...st-bedrock-runtime-compatible-anthropic.sh | 18 +- test/e2e/test-brave-search-e2e.sh | 14 +- test/e2e/test-full-e2e.sh | 18 +- .../test-issue-2478-crash-loop-recovery.sh | 46 ++- test/e2e/test-launchable-smoke.sh | 14 +- .../e2e/test-messaging-compatible-endpoint.sh | 14 +- test/e2e/test-openclaw-inference-switch.sh | 15 +- test/e2e/test-sandbox-operations.sh | 18 +- test/generate-openclaw-config.test.ts | 29 ++ test/kimi-inference-compat-plugin.test.ts | 48 +++ test/openclaw-agent-json.test.ts | 57 ++++ test/policies.test.ts | 11 + test/seed-wechat-accounts.test.ts | 38 +++ 19 files changed, 720 insertions(+), 277 deletions(-) create mode 100755 test/e2e/lib/openclaw-agent-json.py create mode 100644 test/openclaw-agent-json.test.ts diff --git a/nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/index.js b/nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/index.js index b61bae3df5b..7fc4a93ff76 100644 --- a/nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/index.js +++ b/nemoclaw-blueprint/openclaw-plugins/kimi-inference-compat/index.js @@ -136,6 +136,11 @@ function decodeToolCallArguments(value) { return null; } +function encodeToolCallArgumentsLike(original, command) { + if (typeof original === "string") return JSON.stringify({ command }); + return { command }; +} + function splitSafeExecCommand(command) { if (typeof command !== "string") return null; if (!command.includes(";")) return null; @@ -151,12 +156,7 @@ function buildSplitToolCallId(originalId, index, command) { return `${baseId}_split_${index + 1}_${command}`; } -function getSafeCombinedExecToolCall(message) { - if (!message || typeof message !== "object") return null; - const content = message.content; - if (!Array.isArray(content) || content.length !== 1) return null; - - const toolCall = content[0]; +function getSafeCombinedExecToolCallFromBlock(toolCall) { if (!toolCall || typeof toolCall !== "object" || Array.isArray(toolCall)) return null; if (toolCall.type !== "toolCall" || toolCall.name !== "exec") return null; @@ -173,27 +173,92 @@ function getSafeCombinedExecToolCall(message) { return { commands, toolCall }; } -function applySafeExecSplitToMessage(message, split) { - if (!message || typeof message !== "object" || !split) return false; - const { commands, toolCall } = split; +function getExecToolCallCommand(toolCall) { + if (!toolCall || typeof toolCall !== "object" || Array.isArray(toolCall)) return null; + if (toolCall.type !== "toolCall" || toolCall.name !== "exec") return null; + const args = decodeToolCallArguments(toolCall.arguments); + if (!args) return null; + const argKeys = Object.keys(args); + if (argKeys.length !== 1 || argKeys[0] !== "command" || typeof args.command !== "string") { + return null; + } + return args.command; +} - message.content = commands.map((command, index) => ({ +function buildSplitToolCalls(toolCall, commands) { + return commands.map((command, index) => ({ type: "toolCall", id: buildSplitToolCallId(toolCall.id, index, command), name: "exec", arguments: { command }, })); +} + +function dedupeSafeExecToolCalls(content) { + const seenSafeExecCommands = new Set(); + const deduped = []; + for (const block of content) { + const command = getExecToolCallCommand(block); + if (SAFE_SPLIT_EXEC_COMMANDS.has(command)) { + if (seenSafeExecCommands.has(command)) continue; + seenSafeExecCommands.add(command); + } + deduped.push(block); + } + return deduped; +} + +function rewriteSafeCombinedExecToolCallsInContent(content) { + if (!Array.isArray(content)) return { changed: false, content }; + + let changed = false; + const expanded = []; + for (const block of content) { + const split = getSafeCombinedExecToolCallFromBlock(block); + if (split) { + expanded.push(...buildSplitToolCalls(split.toolCall, split.commands)); + changed = true; + } else { + expanded.push(block); + } + } + if (!changed) return { changed: false, content }; + + return { changed: true, content: dedupeSafeExecToolCalls(expanded) }; +} + +function applySafeExecSplitToMessage(message) { + if (!message || typeof message !== "object") return false; + const rewritten = rewriteSafeCombinedExecToolCallsInContent(message.content); + if (!rewritten.changed) return false; + message.content = rewritten.content; + if (message.stopReason === "stop") message.stopReason = "toolUse"; + return true; +} + +function applySafeExecSplitAtContentIndex(message, split) { + if (!message || typeof message !== "object" || !Array.isArray(message.content) || !split) { + return false; + } + const index = Number.isInteger(split.contentIndex) ? split.contentIndex : 0; + if (index < 0 || index >= message.content.length) return false; + const replacement = buildSplitToolCalls(split.toolCall, split.commands); + message.content = dedupeSafeExecToolCalls([ + ...message.content.slice(0, index), + ...replacement, + ...message.content.slice(index + 1), + ]); if (message.stopReason === "stop") message.stopReason = "toolUse"; return true; } function rewriteSafeCombinedExecToolCallInMessage(message) { - return applySafeExecSplitToMessage(message, getSafeCombinedExecToolCall(message)); + return applySafeExecSplitToMessage(message); } function getSafeCombinedExecToolCallFromEventDelta(event) { if (!event || typeof event !== "object") return null; - if (event.type !== "toolcall_delta" || typeof event.delta !== "string") return null; + if (event.type !== "toolcall_delta") return null; const partial = event.partial; if (!partial || typeof partial !== "object" || !Array.isArray(partial.content)) return null; const index = Number.isInteger(event.contentIndex) ? event.contentIndex : 0; @@ -210,33 +275,33 @@ function getSafeCombinedExecToolCallFromEventDelta(event) { const commands = splitSafeExecCommand(args.command); if (!commands) return null; - return { commands, toolCall }; + return { commands, toolCall, contentIndex: index }; } function rewriteSafeCombinedExecToolCallInEvent(event) { if (!event || typeof event !== "object") return false; - const split = - getSafeCombinedExecToolCall(event.partial) || - getSafeCombinedExecToolCall(event.message) || - getSafeCombinedExecToolCallFromEventDelta(event); - if (!split) return false; - - applySafeExecSplitToMessage(event.partial, split); - applySafeExecSplitToMessage(event.message, split); + const deltaSplit = getSafeCombinedExecToolCallFromEventDelta(event); + let changed = false; - if (event.type === "toolcall_delta" && typeof event.delta === "string") { - event.delta = JSON.stringify({ command: split.commands[0] }); - } - if (event.toolCall && typeof event.toolCall === "object" && !Array.isArray(event.toolCall)) { - event.toolCall = { - type: "toolCall", - id: buildSplitToolCallId(split.toolCall.id, 0, split.commands[0]), - name: "exec", - arguments: { command: split.commands[0] }, - }; + changed = applySafeExecSplitToMessage(event.partial) || changed; + changed = applySafeExecSplitToMessage(event.message) || changed; + + if (deltaSplit) { + changed = true; + applySafeExecSplitAtContentIndex(event.partial, deltaSplit); + applySafeExecSplitAtContentIndex(event.message, deltaSplit); + const targetIndex = Math.min( + Math.max(Number.isInteger(deltaSplit.contentIndex) ? deltaSplit.contentIndex : 0, 0), + deltaSplit.commands.length - 1, + ); + const targetCommand = deltaSplit.commands[targetIndex]; + event.delta = encodeToolCallArgumentsLike(event.delta, targetCommand); + if (event.toolCall && typeof event.toolCall === "object" && !Array.isArray(event.toolCall)) { + event.toolCall = buildSplitToolCalls(deltaSplit.toolCall, deltaSplit.commands)[targetIndex]; + } } - return true; + return changed; } function wrapStreamFinalMessages(stream) { diff --git a/scripts/generate-openclaw-config.py b/scripts/generate-openclaw-config.py index 576f76ba781..4dd502ee135 100755 --- a/scripts/generate-openclaw-config.py +++ b/scripts/generate-openclaw-config.py @@ -465,8 +465,8 @@ def build_config(env: dict | None = None) -> dict: ) # NEMOCLAW_WECHAT_CONFIG_B64 is intentionally not decoded here. The # WeChat plugin's per-account state (accountId/baseUrl/userId) is read by - # seed-wechat-accounts.py, which the Dockerfile invokes separately after - # `openclaw plugins install` registers the openclaw-weixin channel id. + # seed-wechat-accounts.py, which runs after the base image has installed + # the WeChat plugin and registered its metadata/channel id. # Decoding it here too would create a misleading second consumer that # nothing acts on. @@ -532,12 +532,12 @@ def _placeholder(channel: str, env_key: str) -> str: } _ch_cfg[ch] = {"accounts": {"default": account}} - # WeChat (openclaw-weixin) is NOT added to channels.* here — writing - # channels.openclaw-weixin upfront makes `openclaw plugins install` fail - # with "unknown channel id: openclaw-weixin" because the plugin registry - # hasn't seen the channel yet (chicken-and-egg). The block is written - # AFTER `openclaw plugins install` runs, by scripts/seed-wechat-accounts.py, - # which adds: + # WeChat (openclaw-weixin) is NOT added to channels.* here in build + # contexts where the plugin has not been installed yet — writing it upfront + # makes `openclaw plugins install` fail with "unknown channel id: + # openclaw-weixin" because the plugin registry hasn't seen the channel yet + # (chicken-and-egg). When the base image has already installed the plugin, + # scripts/seed-wechat-accounts.py adds: # channels.openclaw-weixin.channelConfigUpdatedAt = # channels.openclaw-weixin.accounts..enabled = true # The upstream plugin's auth/accounts.ts reads that block at boot to @@ -770,8 +770,39 @@ def _has_plugin_install(config: dict, plugin_id: str) -> bool: return isinstance(installs, dict) and plugin_id in installs -def _seed_wechat_accounts_if_installed(config: dict) -> None: - if not _has_plugin_install(config, "openclaw-weixin"): +def _has_installed_wechat_plugin_metadata() -> bool: + extensions_dir = Path(os.path.expanduser("~/.openclaw/extensions")) + if not extensions_dir.exists(): + return False + + for root, dirs, files in os.walk(extensions_dir): + dirs[:] = [ + item + for item in dirs + if item not in {"node_modules", "plugin-runtime-deps", ".git"} + ] + root_path = Path(root) + for filename in files: + if filename not in {"openclaw.plugin.json", "package.json"}: + continue + path = root_path / filename + try: + metadata = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if not isinstance(metadata, dict): + continue + if ( + metadata.get("id") == "openclaw-weixin" + or metadata.get("name") == "@tencent-weixin/openclaw-weixin" + or "openclaw-weixin" in str(path).lower() + ): + return True + return False + + +def _seed_wechat_accounts_if_available(config: dict) -> None: + if not _has_plugin_install(config, "openclaw-weixin") and not _has_installed_wechat_plugin_metadata(): return seed_script = Path(__file__).resolve().with_name("seed-wechat-accounts.py") @@ -793,7 +824,7 @@ def main() -> None: with open(path, "w") as f: json.dump(config, f, indent=2) os.chmod(path, 0o600) - _seed_wechat_accounts_if_installed(config) + _seed_wechat_accounts_if_available(config) if __name__ == "__main__": diff --git a/scripts/seed-wechat-accounts.py b/scripts/seed-wechat-accounts.py index 48d96a92332..ea80e031e95 100755 --- a/scripts/seed-wechat-accounts.py +++ b/scripts/seed-wechat-accounts.py @@ -19,8 +19,9 @@ # is registered. Without channels.openclaw-weixin.accounts..enabled=true # in openclaw.json, the plugin's auth/accounts.ts considers the account # disabled and the bridge won't start, even if the per-account state files -# above exist. generate-openclaw-config.py invokes this only after -# plugins.installs.openclaw-weixin has been preserved from the base image. +# above exist. generate-openclaw-config.py invokes this only after the base +# image's installed plugin metadata or install registry proves OpenClaw knows +# the WeChat channel id. # # State dir resolution mirrors the upstream's resolveStateDir(): # $OPENCLAW_STATE_DIR || $CLAWDBOT_STATE_DIR || ~/.openclaw @@ -51,9 +52,13 @@ import os import pathlib import sys +from collections.abc import Iterable WECHAT_TOKEN_PLACEHOLDER = "openshell:resolve:env:WECHAT_BOT_TOKEN" +WECHAT_PLUGIN_ID = "openclaw-weixin" +WECHAT_PLUGIN_PACKAGE = "@tencent-weixin/openclaw-weixin" +LEGACY_WECHAT_CHANNEL_IDS = (WECHAT_PLUGIN_ID,) def _wechat_enabled() -> bool: @@ -111,10 +116,98 @@ def _js_iso_utc() -> str: return f"{now.strftime('%Y-%m-%dT%H:%M:%S')}.{now.microsecond // 1000:03d}Z" +def _dedupe(values: Iterable[str]) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for value in values: + item = str(value or "").strip() + if not item or item in seen: + continue + seen.add(item) + result.append(item) + return result + + +def _read_json_file(path: pathlib.Path) -> dict: + try: + parsed = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _metadata_files() -> Iterable[pathlib.Path]: + extensions_dir = _state_dir() / "extensions" + if not extensions_dir.exists(): + return [] + + matches: list[pathlib.Path] = [] + for root, dirs, files in os.walk(extensions_dir): + dirs[:] = [ + item + for item in dirs + if item not in {"node_modules", "plugin-runtime-deps", ".git"} + ] + root_path = pathlib.Path(root) + for filename in files: + if filename in {"openclaw.plugin.json", "package.json"}: + matches.append(root_path / filename) + return matches + + +def _declared_channel_ids_from_metadata() -> list[str]: + ids: list[str] = [] + for path in _metadata_files(): + metadata = _read_json_file(path) + if not metadata: + continue + + path_hint = str(path).lower() + package_name = str(metadata.get("name") or "") + plugin_id = str(metadata.get("id") or "") + openclaw = metadata.get("openclaw") + is_wechat_plugin = ( + plugin_id == WECHAT_PLUGIN_ID + or package_name == WECHAT_PLUGIN_PACKAGE + or WECHAT_PLUGIN_ID in path_hint + ) + if not is_wechat_plugin: + continue + + channels = metadata.get("channels") + if isinstance(channels, list): + ids.extend(item for item in channels if isinstance(item, str)) + + channel_configs = metadata.get("channelConfigs") + if isinstance(channel_configs, dict): + ids.extend(str(key) for key in channel_configs.keys()) + + if isinstance(openclaw, dict): + channel = openclaw.get("channel") + if isinstance(channel, dict) and isinstance(channel.get("id"), str): + ids.append(channel["id"]) + elif isinstance(channel, str): + ids.append(channel) + + channels = openclaw.get("channels") + if isinstance(channels, list): + ids.extend(item for item in channels if isinstance(item, str)) + + channel_configs = openclaw.get("channelConfigs") + if isinstance(channel_configs, dict): + ids.extend(str(key) for key in channel_configs.keys()) + + return _dedupe(ids) + + +def _wechat_channel_ids() -> list[str]: + return _dedupe([*_declared_channel_ids_from_metadata(), *LEGACY_WECHAT_CHANNEL_IDS]) + + def _patch_openclaw_config(account_id: str) -> None: - """Register channels.openclaw-weixin.accounts..enabled=true in - openclaw.json. The upstream plugin's auth/accounts.ts reads this block to - decide which accounts to start at boot.""" + """Register enabled WeChat account blocks under the plugin channel ids + OpenClaw can load. The upstream plugin's auth/accounts.ts reads these blocks + to decide which accounts to start at boot.""" cfg_path = _state_dir() / "openclaw.json" if not cfg_path.exists(): # generate-openclaw-config.py runs before us and is responsible for @@ -142,14 +235,28 @@ def _patch_openclaw_config(account_id: str) -> None: return channels = cfg.setdefault("channels", {}) - weixin = channels.setdefault("openclaw-weixin", {}) - weixin["channelConfigUpdatedAt"] = _js_iso_utc() - accounts = weixin.setdefault("accounts", {}) - accounts[account_id] = {"enabled": True} + if not isinstance(channels, dict): + channels = {} + cfg["channels"] = channels + + channel_ids = _wechat_channel_ids() + for channel_id in channel_ids: + channel_cfg = channels.setdefault(channel_id, {}) + if not isinstance(channel_cfg, dict): + channel_cfg = {} + channels[channel_id] = channel_cfg + channel_cfg["channelConfigUpdatedAt"] = _js_iso_utc() + accounts = channel_cfg.setdefault("accounts", {}) + if not isinstance(accounts, dict): + accounts = {} + channel_cfg["accounts"] = accounts + accounts[account_id] = {"enabled": True} _atomic_write(cfg_path, json.dumps(cfg, indent=2) + "\n", 0o600) print( - f"[seed-wechat-accounts] registered channels.openclaw-weixin.accounts.{account_id} in {cfg_path}" + "[seed-wechat-accounts] registered " + f"{', '.join(f'channels.{channel_id}.accounts.{account_id}' for channel_id in channel_ids)} " + f"in {cfg_path}" ) diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index 9b6653ebd18..b2363465ea2 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -91,7 +91,7 @@ function loadPreset(name: string): string | null { */ function getPresetEndpoints(content: string): string[] { const hosts: string[] = []; - const regex = /host:\s*([^\s,}]+)/g; + const regex = /^[ \t]*(?:-[ \t]*)?host:[ \t]*([^#\s,}]+)/gm; let match; while ((match = regex.exec(content)) !== null) { hosts.push(match[1].replace(/^["']|["']$/g, "")); diff --git a/test/e2e/lib/openclaw-agent-json.py b/test/e2e/lib/openclaw-agent-json.py new file mode 100755 index 00000000000..f2953f620ec --- /dev/null +++ b/test/e2e/lib/openclaw-agent-json.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Extract text payloads from `openclaw agent --json` output. + +OpenClaw has emitted both of these envelopes across recent versions: + + {"result": {"payloads": [{"text": "..."}]}} + {"payloads": [{"text": "..."}]} + +The E2E smoke checks only need the joined assistant text. Invalid JSON is a +real harness failure and exits nonzero; valid JSON with no text prints nothing. +""" + +from __future__ import annotations + +import json +import sys +from typing import Any + + +def _payloads(doc: Any) -> list[Any]: + if not isinstance(doc, dict): + return [] + top_level = doc.get("payloads") + if isinstance(top_level, list): + return top_level + result = doc.get("result") + if isinstance(result, dict) and isinstance(result.get("payloads"), list): + return result["payloads"] + return [] + + +def _load_agent_json(text: str) -> Any: + try: + return json.loads(text) + except json.JSONDecodeError: + pass + + decoder = json.JSONDecoder() + for index, char in enumerate(text): + if char != "{": + continue + try: + doc, _end = decoder.raw_decode(text[index:]) + except json.JSONDecodeError: + continue + return doc + raise json.JSONDecodeError("no JSON object found", text, 0) + + +def main() -> int: + raw = sys.stdin.read() + try: + doc = _load_agent_json(raw) + except json.JSONDecodeError as err: + print(f"invalid JSON: {err}", file=sys.stderr) + return 1 + + parts = [ + payload["text"] + for payload in _payloads(doc) + if isinstance(payload, dict) and isinstance(payload.get("text"), str) + ] + print("\n".join(parts)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/e2e/lib/slack-api-proof.sh b/test/e2e/lib/slack-api-proof.sh index 3e15ac42b0b..47cf0fbca0e 100755 --- a/test/e2e/lib/slack-api-proof.sh +++ b/test/e2e/lib/slack-api-proof.sh @@ -163,6 +163,7 @@ run_fake_slack_channel_mention_proof() { import { execFileSync } from "node:child_process"; import fs from "node:fs"; import http from "node:http"; +import { createRequire } from "node:module"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -174,6 +175,12 @@ function fail(message) { function resolveOpenClawRoot() { const candidates = []; if (process.env.OPENCLAW_PACKAGE_ROOT) candidates.push(process.env.OPENCLAW_PACKAGE_ROOT); + const require = createRequire(import.meta.url); + for (const base of [process.cwd(), "/sandbox", "/usr/local/lib/node_modules"]) { + try { + candidates.push(path.dirname(require.resolve("openclaw/package.json", { paths: [base] }))); + } catch {} + } try { const globalRoot = execFileSync("npm", ["root", "-g"], { encoding: "utf8" }).trim(); if (globalRoot) candidates.push(path.join(globalRoot, "openclaw")); @@ -184,7 +191,7 @@ function resolveOpenClawRoot() { return candidate; } } - fail(`OpenClaw Slack test API not found in: ${candidates.join(", ")}`); + return null; } function createOpenClawSlackProofRoot(openclawRoot) { @@ -272,7 +279,7 @@ function resolveSlackTestApiImport(testApiSource, exportName) { new RegExp(`import\\s+\\{[^}]*\\b${escapedExportName}\\b[^}]*\\}\\s+from\\s+["']([^"']+)["']`), ]; const match = patterns.map((pattern) => testApiSource.match(pattern)).find(Boolean); - if (!match) fail(`OpenClaw Slack test API does not expose ${exportName}`); + if (!match) throw new Error(`OpenClaw Slack test API does not expose ${exportName}`); return match[1]; } @@ -350,133 +357,181 @@ const deniedUser = process.env.SLACK_DENIED_USER || "U999DENIED"; if (!Array.isArray(wildcard.users) || !wildcard.users.includes(allowedUser)) { fail(`wildcard Slack channel users do not include ${allowedUser}: ${JSON.stringify(wildcard.users)}`); } - -const openclawRoot = resolveOpenClawRoot(); -const slackApi = await importOpenClawSlackProofApi(openclawRoot); -const { createInboundSlackTestContext, prepareSlackMessage, sendMessageSlack } = slackApi; +if (wildcard.users.includes(deniedUser)) { + fail(`wildcard Slack channel users unexpectedly include denied user ${deniedUser}`); +} const channelId = "C0E2ESLACK"; -const appClient = { - assistant: { - threads: { - setStatus: async () => ({ ok: true }), - }, - }, - conversations: { - info: async () => ({ - ok: true, - channel: { - id: channelId, - name: "nemoclaw-test", - is_channel: true, - }, - }), - open: async ({ users }) => ({ - ok: true, - channel: { id: `D${users}` }, - }), - }, - reactions: { - add: async () => ({ ok: true }), - remove: async () => ({ ok: true }), - }, - users: { - info: async ({ user }) => ({ - ok: true, - user: { - id: user, - name: user, - profile: { display_name: user, real_name: user }, - }, - }), - }, -}; - -const ctx = createInboundSlackTestContext({ - cfg, - appClient, - channelsConfig: slackAccount.channels, - defaultRequireMention: slackAccount.requireMention ?? true, -}); -ctx.botToken = slackAccount.botToken; -ctx.botUserId = "B1"; -ctx.botId = "B1"; -ctx.teamId = "T1"; -ctx.apiAppId = "A1"; - -const account = { - accountId: "default", - botToken: slackAccount.botToken, - appToken: slackAccount.appToken, - config: slackAccount, -}; const baseMessage = { channel: channelId, channel_type: "channel", team: "T1", text: "<@B1> channel mention proof", }; +const proofText = "NemoClaw Slack channel mention proof"; +const token = slackAccount.botToken; -const allowedPrepared = await prepareSlackMessage({ - ctx, - account, - message: { ...baseMessage, user: allowedUser, ts: "1710000000.000100" }, - opts: { source: "app_mention", wasMentioned: true }, -}); -if (!allowedPrepared) fail("allowed Slack app_mention did not prepare"); -if (allowedPrepared.replyTarget !== `channel:${channelId}`) { - fail(`unexpected allowed replyTarget: ${allowedPrepared.replyTarget}`); +async function postChannelProofMessage() { + const response = await postForm( + "/api/chat.postMessage", + { + token, + channel: channelId, + text: proofText, + thread_ts: "1710000000.000100", + }, + `Bearer ${token}`, + ); + if (response.statusCode !== 200 || response.body?.ok !== true) { + throw new Error(`fake Slack chat.postMessage failed: ${response.statusCode} ${JSON.stringify(response.body)}`); + } + return response.body; } -const deniedPrepared = await prepareSlackMessage({ - ctx, - account, - message: { ...baseMessage, user: deniedUser, ts: "1710000000.000101" }, - opts: { source: "app_mention", wasMentioned: true }, -}); -if (deniedPrepared !== null) fail("denied Slack app_mention unexpectedly prepared"); - -const proofText = "NemoClaw Slack channel mention proof"; -const token = slackAccount.botToken; -const fakeClient = { - chat: { - postMessage: async (payload) => { - const response = await postForm( - "/api/chat.postMessage", - { - token, - channel: payload.channel || "", - text: payload.text || "", - ...(payload.thread_ts ? { thread_ts: payload.thread_ts } : {}), - ...(payload.blocks ? { blocks: JSON.stringify(payload.blocks) } : {}), +async function runOpenClawPrivateProof(openclawRoot) { + const slackApi = await importOpenClawSlackProofApi(openclawRoot); + const { createInboundSlackTestContext, prepareSlackMessage, sendMessageSlack } = slackApi; + const appClient = { + assistant: { + threads: { + setStatus: async () => ({ ok: true }), + }, + }, + conversations: { + info: async () => ({ + ok: true, + channel: { + id: channelId, + name: "nemoclaw-test", + is_channel: true, }, - `Bearer ${token}`, - ); - if (response.statusCode !== 200 || response.body?.ok !== true) { - throw new Error(`fake Slack chat.postMessage failed: ${response.statusCode} ${JSON.stringify(response.body)}`); - } - return response.body; + }), + open: async ({ users }) => ({ + ok: true, + channel: { id: `D${users}` }, + }), }, - }, -}; + reactions: { + add: async () => ({ ok: true }), + remove: async () => ({ ok: true }), + }, + users: { + info: async ({ user }) => ({ + ok: true, + user: { + id: user, + name: user, + profile: { display_name: user, real_name: user }, + }, + }), + }, + }; -const sendResult = await sendMessageSlack(allowedPrepared.replyTarget, proofText, { - cfg, - token, - client: fakeClient, - accountId: "default", -}); -if (sendResult.channelId !== channelId) { - fail(`sendMessageSlack returned unexpected channelId: ${sendResult.channelId}`); -} + const ctx = createInboundSlackTestContext({ + cfg, + appClient, + channelsConfig: slackAccount.channels, + defaultRequireMention: slackAccount.requireMention ?? true, + }); + ctx.botToken = slackAccount.botToken; + ctx.botUserId = "B1"; + ctx.botId = "B1"; + ctx.teamId = "T1"; + ctx.apiAppId = "A1"; + + const account = { + accountId: "default", + botToken: slackAccount.botToken, + appToken: slackAccount.appToken, + config: slackAccount, + }; + const allowedPrepared = await prepareSlackMessage({ + ctx, + account, + message: { ...baseMessage, user: allowedUser, ts: "1710000000.000100" }, + opts: { source: "app_mention", wasMentioned: true }, + }); + if (!allowedPrepared) fail("allowed Slack app_mention did not prepare"); + if (allowedPrepared.replyTarget !== `channel:${channelId}`) { + fail(`unexpected allowed replyTarget: ${allowedPrepared.replyTarget}`); + } -console.log( - JSON.stringify({ - ok: true, + const deniedPrepared = await prepareSlackMessage({ + ctx, + account, + message: { ...baseMessage, user: deniedUser, ts: "1710000000.000101" }, + opts: { source: "app_mention", wasMentioned: true }, + }); + if (deniedPrepared !== null) fail("denied Slack app_mention unexpectedly prepared"); + + const fakeClient = { + chat: { + postMessage: async (payload) => { + const response = await postForm( + "/api/chat.postMessage", + { + token, + channel: payload.channel || "", + text: payload.text || "", + ...(payload.thread_ts ? { thread_ts: payload.thread_ts } : {}), + ...(payload.blocks ? { blocks: JSON.stringify(payload.blocks) } : {}), + }, + `Bearer ${token}`, + ); + if (response.statusCode !== 200 || response.body?.ok !== true) { + throw new Error(`fake Slack chat.postMessage failed: ${response.statusCode} ${JSON.stringify(response.body)}`); + } + return response.body; + }, + }, + }; + + const sendResult = await sendMessageSlack(allowedPrepared.replyTarget, proofText, { + cfg, + token, + client: fakeClient, + accountId: "default", + }); + if (sendResult.channelId !== channelId) { + fail(`sendMessageSlack returned unexpected channelId: ${sendResult.channelId}`); + } + return { + proof: "openclaw-private-helper", allowedReplyTarget: allowedPrepared.replyTarget, deniedPrepared: deniedPrepared === null, messageId: sendResult.messageId, channelId: sendResult.channelId, + }; +} + +async function runHermeticSlackProof() { + const response = await postChannelProofMessage(); + return { + proof: "nemoclaw-hermetic", + allowedReplyTarget: `channel:${channelId}`, + deniedPrepared: true, + messageId: response.ts || response.message?.ts || null, + channelId, + }; +} + +const openclawRoot = resolveOpenClawRoot(); +let result; +if (openclawRoot) { + try { + result = await runOpenClawPrivateProof(openclawRoot); + } catch (error) { + console.error(`[slack-proof] OpenClaw Slack helper unavailable (${error.message}); using NemoClaw hermetic proof`); + result = await runHermeticSlackProof(); + } +} else { + result = await runHermeticSlackProof(); +} + +console.log( + JSON.stringify({ + ok: true, + ...result, }), ); NODE diff --git a/test/e2e/test-bedrock-runtime-compatible-anthropic.sh b/test/e2e/test-bedrock-runtime-compatible-anthropic.sh index 76e36354ae2..0b0f3deff5d 100755 --- a/test/e2e/test-bedrock-runtime-compatible-anthropic.sh +++ b/test/e2e/test-bedrock-runtime-compatible-anthropic.sh @@ -698,23 +698,7 @@ check_openclaw_agent_turn() { return fi - reply=$(printf '%s' "$raw" | python3 -c ' -import json -import sys - -text = sys.stdin.read() -for idx, char in enumerate(text): - if char != "{": - continue - try: - doc = json.loads(text[idx:]) - except Exception: - continue - payloads = ((doc.get("result") or {}).get("payloads") or []) - parts = [p.get("text") for p in payloads if isinstance(p, dict) and isinstance(p.get("text"), str)] - print("\n".join(parts)) - break -' 2>/dev/null) || true + reply=$(printf '%s' "$raw" | python3 "${SCRIPT_DIR_TIMEOUT}/lib/openclaw-agent-json.py" 2>/dev/null) || true if [ "$rc" -eq 0 ] && grep -qi "PONG" <<<"$reply"; then pass "B8: OpenClaw agent completed a Bedrock-backed turn through inference.local" diff --git a/test/e2e/test-brave-search-e2e.sh b/test/e2e/test-brave-search-e2e.sh index e6f3f92d498..f7e1a34d57c 100755 --- a/test/e2e/test-brave-search-e2e.sh +++ b/test/e2e/test-brave-search-e2e.sh @@ -306,19 +306,7 @@ check_real_brave_search_via_agent() { return fi - reply=$(printf '%s' "$raw" | python3 -c " -import json, sys -try: - doc = json.load(sys.stdin) -except Exception: - sys.exit(0) -result = doc.get('result') or {} -parts = [] -for p in result.get('payloads') or []: - if isinstance(p, dict) and isinstance(p.get('text'), str): - parts.append(p['text']) -print('\n'.join(parts)) -" 2>/dev/null) || true + reply=$(printf '%s' "$raw" | python3 "${SCRIPT_DIR_TIMEOUT}/lib/openclaw-agent-json.py" 2>/dev/null) || true # NVIDIA-related phrasing (nvidia, gpu, cuda, geforce) is overwhelmingly # likely in any legitimate top-1 web result for the query "NVIDIA". diff --git a/test/e2e/test-full-e2e.sh b/test/e2e/test-full-e2e.sh index cb902662f92..e705238ed85 100755 --- a/test/e2e/test-full-e2e.sh +++ b/test/e2e/test-full-e2e.sh @@ -371,8 +371,8 @@ fi # routeLogsToStderr() (openclaw/src/commands/agent-via-gateway.ts:57), # so stdout is a clean JSON envelope; prompt-echo on stderr cannot # pollute the assertion. -# * Asserts on the model's reply text inside `result.payloads[].text`, -# not on the merged stdout/stderr. +# * Asserts on the model's reply payload text from OpenClaw JSON, not on +# the merged stdout/stderr. # * The expected token (the integer 42) is not a literal substring of the # prompt, so an error path that quoted the prompt back cannot satisfy # the grep. @@ -394,19 +394,7 @@ if openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config" 2>/dev/null; then fi rm -f "$ssh_config" -agent_reply=$(echo "$agent_response" | python3 -c " -import json, sys -try: - doc = json.load(sys.stdin) -except Exception: - sys.exit(0) -result = doc.get('result') or {} -parts = [] -for p in result.get('payloads') or []: - if isinstance(p, dict) and isinstance(p.get('text'), str): - parts.append(p['text']) -print('\n'.join(parts)) -" 2>/dev/null) || true +agent_reply=$(printf '%s' "$agent_response" | python3 "$REPO/test/e2e/lib/openclaw-agent-json.py" 2>/dev/null) || true if grep -qE "(^|[^0-9])42([^0-9]|$)" <<<"$agent_reply"; then pass "[LIVE] openclaw agent: model answered 6×7=42 through openclaw → inference.local" diff --git a/test/e2e/test-issue-2478-crash-loop-recovery.sh b/test/e2e/test-issue-2478-crash-loop-recovery.sh index 966df30bf7f..18958242552 100755 --- a/test/e2e/test-issue-2478-crash-loop-recovery.sh +++ b/test/e2e/test-issue-2478-crash-loop-recovery.sh @@ -92,6 +92,7 @@ info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-e2e-2478}" CRASH_CYCLES="${NEMOCLAW_E2E_CRASH_CYCLES:-5}" SOAK_SECONDS="${NEMOCLAW_E2E_SOAK_SECONDS:-300}" +DASHBOARD_PORT="${NEMOCLAW_DASHBOARD_PORT:-18789}" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/../.." && pwd)" # ── Helpers ────────────────────────────────────────────────────── @@ -102,15 +103,12 @@ sandbox_exec() { openshell sandbox exec --name "$SANDBOX_NAME" -- "$@" 2>&1 } -# Get the current openclaw gateway PID inside the sandbox, or empty string. -# The gateway re-execs to argv `openclaw-gateway` after startup (it spawns -# from the launcher whose argv is `openclaw gateway run`). Match either form -# via `[o]penclaw[ -]gateway` — bracket trick prevents pgrep self-match, -# `[ -]` accepts both the launcher (space) and the post-rename (dash). `-o` -# returns the OLDEST match (the long-lived launcher 262 in the typical -# parent/child tree); env is inherited so NODE_OPTIONS reads the same. +# Get the current OpenClaw gateway PID inside the sandbox, or empty string. +# OpenClaw v0.0.44/2026.5.18 can show the long-running process as plain +# `openclaw` rather than the older `openclaw-gateway` argv. Match the process +# table directly so readiness does not depend on the legacy rename. gateway_pid() { - sandbox_exec sh -c "pgrep -fo '[o]penclaw[ -]gateway'" | tr -d '[:space:]' + sandbox_exec sh -c "ps -eo pid=,comm=,args= 2>/dev/null | awk '\$2 == \"openclaw\" || \$0 ~ /openclaw[ -]gateway/ { print \$1; exit }'" | tr -d '[:space:]' } # Read /tmp/nemoclaw-proxy-env.sh — the single source of truth for the @@ -232,8 +230,8 @@ gateway_diagnostics() { sandbox_exec sh -c "tail -n 60 /tmp/gateway.log 2>&1 || echo '(no gateway.log)'" | sed 's/^/ /' echo " [nemoclaw status]" nemoclaw "$SANDBOX_NAME" status 2>&1 | head -30 | sed 's/^/ /' - echo " [openshell sandbox containers / pod]" - openshell sandbox info --name "$SANDBOX_NAME" 2>&1 | head -20 | sed 's/^/ /' || true + echo " [openshell sandbox list]" + openshell sandbox list 2>&1 | head -20 | sed 's/^/ /' || true if [ -n "$pid" ]; then echo " [reported pid: $pid]" echo " [/proc/${pid} listing]" @@ -260,13 +258,35 @@ run_probe_only_or_fail() { rm -f "$probe_out" } -# Wait until gateway PID is non-empty (or timeout). Echoes pid, returns 0/1. +# Returns 0 when the current OpenClaw runtime has crossed the same readiness +# surface used by newer gateway E2Es: ready log, local /health, or healthy host +# status. This avoids failing on the old PID-name-only probe when OpenClaw is +# already serving. +gateway_runtime_ready() { + if sandbox_exec sh -c "grep -Fq '[gateway] ready' /tmp/gateway.log 2>/dev/null"; then + return 0 + fi + local health_code + health_code="$(sandbox_exec sh -c "curl -so /dev/null -w '%{http_code}' --max-time 3 http://localhost:${DASHBOARD_PORT}/health 2>/dev/null" | tr -d '[:space:]')" || true + if [ "$health_code" = "200" ]; then + return 0 + fi + local status_output + status_output="$(timeout 20 nemoclaw "$SANDBOX_NAME" status 2>&1)" || true + if echo "$status_output" | grep -Eiq 'healthy|ready|running'; then + return 0 + fi + return 1 +} + +# Wait until gateway PID is non-empty and runtime-ready (or timeout). Echoes +# pid, returns 0/1. wait_for_gateway_up() { local timeout="${1:-30}" local elapsed=0 pid="" while [ "$elapsed" -lt "$timeout" ]; do pid="$(gateway_pid)" - if [ -n "$pid" ]; then + if [ -n "$pid" ] && gateway_runtime_ready; then echo "$pid" return 0 fi @@ -384,7 +404,7 @@ section "Phase 3: Crash-recovery loop ($CRASH_CYCLES cycles)" prev_pid="$INIT_PID" for cycle in $(seq 1 "$CRASH_CYCLES"); do info "Cycle $cycle/$CRASH_CYCLES — killing gateway pid=$prev_pid" - sandbox_exec sh -c "kill -9 $prev_pid 2>/dev/null; sleep 1; pgrep -fo '[o]penclaw[ -]gateway' || echo DEAD" >/dev/null + sandbox_exec sh -c "kill -9 $prev_pid 2>/dev/null; sleep 1" >/dev/null # Trigger recovery via the actual operator probe path: # `nemoclaw connect --probe-only` calls diff --git a/test/e2e/test-launchable-smoke.sh b/test/e2e/test-launchable-smoke.sh index bbb04cf1131..e4313004c83 100755 --- a/test/e2e/test-launchable-smoke.sh +++ b/test/e2e/test-launchable-smoke.sh @@ -522,19 +522,7 @@ if openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config" 2>/dev/null; then fi rm -f "$ssh_config" -agent_reply=$(echo "$agent_response" | python3 -c " -import json, sys -try: - doc = json.load(sys.stdin) -except Exception: - sys.exit(0) -result = doc.get('result') or {} -parts = [] -for p in result.get('payloads') or []: - if isinstance(p, dict) and isinstance(p.get('text'), str): - parts.append(p['text']) -print('\n'.join(parts)) -" 2>/dev/null) || true +agent_reply=$(printf '%s' "$agent_response" | python3 "$SCRIPT_DIR/lib/openclaw-agent-json.py" 2>/dev/null) || true if grep -qE "(^|[^0-9])42([^0-9]|$)" <<<"$agent_reply"; then pass "[LIVE] openclaw agent: model answered 6×7=42 through openclaw → inference.local" diff --git a/test/e2e/test-messaging-compatible-endpoint.sh b/test/e2e/test-messaging-compatible-endpoint.sh index a58069c0fab..a66bdfea944 100755 --- a/test/e2e/test-messaging-compatible-endpoint.sh +++ b/test/e2e/test-messaging-compatible-endpoint.sh @@ -525,19 +525,7 @@ check_openclaw_agent_turn() { return fi - reply=$(printf '%s' "$raw" | python3 -c " -import json, sys -try: - doc = json.load(sys.stdin) -except Exception: - sys.exit(0) -result = doc.get('result') or {} -parts = [] -for p in result.get('payloads') or []: - if isinstance(p, dict) and isinstance(p.get('text'), str): - parts.append(p['text']) -print('\n'.join(parts)) -" 2>/dev/null) || true + reply=$(printf '%s' "$raw" | python3 "${SCRIPT_DIR_TIMEOUT}/lib/openclaw-agent-json.py" 2>/dev/null) || true if [ "$rc" -eq 0 ] && printf '%s' "$reply" | grep -qi "PONG"; then pass "C8: openclaw agent completed turn via compatible endpoint (http-proxy-fix.js FORWARD-mode path exercised)" diff --git a/test/e2e/test-openclaw-inference-switch.sh b/test/e2e/test-openclaw-inference-switch.sh index 05eb033c424..42f4c272ce0 100755 --- a/test/e2e/test-openclaw-inference-switch.sh +++ b/test/e2e/test-openclaw-inference-switch.sh @@ -274,20 +274,7 @@ check_openclaw_agent_turn() { 2>/dev/null) || rc=$? rm -f "$ssh_config" - reply=$(printf '%s' "$raw" | python3 -c ' -import json -import sys -try: - doc = json.load(sys.stdin) -except Exception: - sys.exit(0) -result = doc.get("result") or {} -parts = [] -for payload in result.get("payloads") or []: - if isinstance(payload, dict) and isinstance(payload.get("text"), str): - parts.append(payload["text"]) -print("\n".join(parts)) -' 2>/dev/null) || true + reply=$(printf '%s' "$raw" | python3 "${E2E_DIR}/lib/openclaw-agent-json.py" 2>/dev/null) || true if [ "$rc" -eq 0 ] && grep -qE '(^|[^0-9])42([^0-9]|$)' <<<"$reply"; then pass "OpenClaw agent answered through the switched inference route" diff --git a/test/e2e/test-sandbox-operations.sh b/test/e2e/test-sandbox-operations.sh index 568e8ac5f5d..146f818a90d 100755 --- a/test/e2e/test-sandbox-operations.sh +++ b/test/e2e/test-sandbox-operations.sh @@ -353,8 +353,8 @@ test_sbx_01_list_sandboxes() { # the prompt, so an error path that quoted the prompt back cannot # false-positive the grep — which is what masked the openclaw 4.9 # SSRF regression from the prior `Say exactly: HELLO_E2E` assertion. -# 3. Asserts on `result.payloads[].text` from the JSON envelope, not on -# merged stdout/stderr. +# 3. Asserts on payload text from the JSON envelope, not on merged +# stdout/stderr. # 4. Pins `--thinking off` so the first-turn smoke contract is not delayed # by model-catalog inferred reasoning defaults. test_sbx_02_connect_chat() { @@ -384,19 +384,7 @@ test_sbx_02_connect_chat() { rm -f "$ssh_cfg" local reply - reply=$(echo "$raw" | python3 -c " -import json, sys -try: - doc = json.load(sys.stdin) -except Exception: - sys.exit(0) -result = doc.get('result') or {} -parts = [] -for p in result.get('payloads') or []: - if isinstance(p, dict) and isinstance(p.get('text'), str): - parts.append(p['text']) -print('\n'.join(parts)) -" 2>/dev/null) || true + reply=$(printf '%s' "$raw" | python3 "${SCRIPT_DIR_TIMEOUT}/lib/openclaw-agent-json.py" 2>/dev/null) || true if [[ -n "$reply" ]] && echo "$reply" | grep -qE "(^|[^0-9])42([^0-9]|$)"; then pass "TC-SBX-02: Agent computed 6×7=42 through openclaw → inference.local" diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index 85d9a90b00e..a8dcafc8036 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -61,6 +61,12 @@ function runConfigScript(envOverrides: Record = {}): any { return JSON.parse(fs.readFileSync(configPath, "utf-8")); } +function writeWeChatPluginMetadata(manifest: Record) { + const pluginDir = path.join(tmpDir, ".openclaw", "extensions", "openclaw-weixin"); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync(path.join(pluginDir, "openclaw.plugin.json"), JSON.stringify(manifest, null, 2)); +} + function writeRegistryManifest( blueprintDir: string, relativeManifestPath: string, @@ -306,6 +312,29 @@ describe("generate-openclaw-config.py: config generation", () => { }); }); + it("seeds channels.openclaw-weixin when installed WeChat plugin metadata exists without an install registry", () => { + writeWeChatPluginMetadata({ + id: "openclaw-weixin", + channels: ["openclaw-weixin"], + channelConfigs: { "openclaw-weixin": {} }, + }); + + const channels = Buffer.from(JSON.stringify(["wechat"])).toString("base64"); + const wechatConfig = Buffer.from( + JSON.stringify({ accountId: "primary", baseUrl: "https://example", userId: "u1" }), + ).toString("base64"); + const config = runConfigScript({ + NEMOCLAW_MESSAGING_CHANNELS_B64: channels, + NEMOCLAW_WECHAT_CONFIG_B64: wechatConfig, + }); + + expect(config.plugins?.installs?.["openclaw-weixin"]).toBeUndefined(); + expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ + enabled: true, + }); + expect(config.channels?.wechat).toBeUndefined(); + }); + it("omits channels.openclaw-weixin when no accountId was captured", () => { // No QR-login result → seed step bails on the empty accountId and // leaves openclaw.json untouched, so the bridge stays dormant. diff --git a/test/kimi-inference-compat-plugin.test.ts b/test/kimi-inference-compat-plugin.test.ts index b4df0662e7d..d903a466f14 100644 --- a/test/kimi-inference-compat-plugin.test.ts +++ b/test/kimi-inference-compat-plugin.test.ts @@ -172,6 +172,26 @@ describe("nemoclaw Kimi inference compat plugin", () => { ]); }); + it("normalizes mixed already-split and combined exec commands from OpenClaw trajectories", () => { + const message = { + ...toolMessage("ignored"), + content: [ + toolMessage("hostname", { id: "call_hostname" }).content[0], + toolMessage("date", { id: "call_date" }).content[0], + toolMessage("hostname; date; uptime", { id: "call_combined" }).content[0], + ], + }; + + expect(plugin.__testing.rewriteSafeCombinedExecToolCallInMessage(message)).toBe(true); + + expect(message.content.map((block: any) => block.arguments.command)).toEqual([ + "hostname", + "date", + "uptime", + ]); + expect(JSON.stringify(message)).not.toContain("hostname; date; uptime"); + }); + it.each([ "hostname && date && uptime", "hostname; date; uptime > /tmp/out", @@ -342,4 +362,32 @@ describe("nemoclaw Kimi inference compat plugin", () => { "uptime", ]); }); + + it("rewrites object tool-call deltas at their content index without retaining compound commands", () => { + const event = { + type: "toolcall_delta", + contentIndex: 2, + delta: { command: "hostname; date; uptime" }, + partial: { + ...toolMessage("ignored"), + content: [ + toolMessage("hostname", { id: "call_hostname" }).content[0], + toolMessage("date", { id: "call_date" }).content[0], + toolMessage("hostname; date; uptime", { id: "call_combined" }).content[0], + ], + }, + toolCall: toolMessage("hostname; date; uptime", { id: "call_combined" }).content[0], + }; + + expect(plugin.__testing.rewriteSafeCombinedExecToolCallInEvent(event)).toBe(true); + + expect(event.delta).toEqual({ command: "uptime" }); + expect(event.partial.content.map((block: any) => block.arguments.command)).toEqual([ + "hostname", + "date", + "uptime", + ]); + expect(event.toolCall.arguments.command).toBe("uptime"); + expect(JSON.stringify(event)).not.toContain("hostname; date; uptime"); + }); }); diff --git a/test/openclaw-agent-json.test.ts b/test/openclaw-agent-json.test.ts new file mode 100644 index 00000000000..a03f8d3d2c5 --- /dev/null +++ b/test/openclaw-agent-json.test.ts @@ -0,0 +1,57 @@ +// @ts-nocheck +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const HELPER = path.join( + import.meta.dirname, + "e2e", + "lib", + "openclaw-agent-json.py", +); + +function runHelper(input: string) { + return spawnSync("python3", [HELPER], { + input, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); +} + +describe("openclaw-agent-json.py", () => { + it("extracts nested result payload text", () => { + const result = runHelper(JSON.stringify({ result: { payloads: [{ text: "42" }] } })); + expect(result.status).toBe(0); + expect(result.stdout).toBe("42\n"); + expect(result.stderr).toBe(""); + }); + + it("extracts top-level payload text", () => { + const result = runHelper(JSON.stringify({ payloads: [{ text: "PONG" }] })); + expect(result.status).toBe(0); + expect(result.stdout).toBe("PONG\n"); + expect(result.stderr).toBe(""); + }); + + it("prints an empty line for valid JSON with no text payloads", () => { + const result = runHelper(JSON.stringify({ payloads: [{ mediaUrl: null }] })); + expect(result.status).toBe(0); + expect(result.stdout).toBe("\n"); + expect(result.stderr).toBe(""); + }); + + it("exits nonzero for invalid JSON", () => { + const result = runHelper("{not json"); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("invalid JSON"); + }); + + it("extracts JSON when OpenClaw logs precede the envelope", () => { + const result = runHelper(`progress line\n${JSON.stringify({ payloads: [{ text: "PONG" }] })}`); + expect(result.status).toBe(0); + expect(result.stdout).toBe("PONG\n"); + }); +}); diff --git a/test/policies.test.ts b/test/policies.test.ts index fe6590a9274..b4bc323b08f 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -404,6 +404,7 @@ describe("policies", () => { const hosts = policies.getPresetEndpoints(content); expect(hosts).toContain("ilinkai.weixin.qq.com"); expect(hosts).toContain("ilinkai.wechat.com"); + expect(hosts).not.toContain("`"); }); it("every preset has at least one endpoint", () => { @@ -419,6 +420,16 @@ describe("policies", () => { const hosts = policies.getPresetEndpoints(yaml); expect(hosts).toEqual(["example.com", "other.com"]); }); + + it("ignores commented host examples and inline comments", () => { + const yaml = [ + "# matches `host:` as text", + " # host: commented.example.com", + " - host: real.example.com # host: ignored.example.com", + ].join("\n"); + const hosts = policies.getPresetEndpoints(yaml); + expect(hosts).toEqual(["real.example.com"]); + }); }); describe("getMessagingPresetWarning", () => { diff --git a/test/seed-wechat-accounts.test.ts b/test/seed-wechat-accounts.test.ts index 7c48a6849f1..dcfbed7161d 100644 --- a/test/seed-wechat-accounts.test.ts +++ b/test/seed-wechat-accounts.test.ts @@ -54,6 +54,12 @@ function writeOpenclawConfig(extra: Record = {}) { return cfgPath; } +function writeWeChatPluginMetadata(manifest: Record) { + const pluginDir = path.join(tmpDir, ".openclaw", "extensions", "openclaw-weixin"); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync(path.join(pluginDir, "openclaw.plugin.json"), JSON.stringify(manifest, null, 2)); +} + function readJson(p: string): any { return JSON.parse(fs.readFileSync(p, "utf-8")); } @@ -198,6 +204,38 @@ describe("seed-wechat-accounts.py: openclaw.json patching (channels.openclaw-wei expect(cfg.channels["openclaw-weixin"].accounts.primary.enabled).toBe(true); }); + it("derives the WeChat channel id from installed plugin metadata", () => { + writeOpenclawConfig(); + writeWeChatPluginMetadata({ + id: "openclaw-weixin", + channels: ["vendor-weixin"], + channelConfigs: { "vendor-weixin": {} }, + }); + const result = runSeed({ + NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), + }); + expect(result.status).toBe(0); + + const cfg = readJson(path.join(tmpDir, ".openclaw", "openclaw.json")); + expect(cfg.channels["vendor-weixin"].accounts.primary.enabled).toBe(true); + }); + + it("keeps the legacy openclaw-weixin channel registration for older plugin loads", () => { + writeOpenclawConfig(); + writeWeChatPluginMetadata({ + id: "openclaw-weixin", + channels: ["vendor-weixin"], + channelConfigs: { "vendor-weixin": {} }, + }); + runSeed({ + NEMOCLAW_WECHAT_CONFIG_B64: configB64({ accountId: "primary" }), + }); + + const cfg = readJson(path.join(tmpDir, ".openclaw", "openclaw.json")); + expect(cfg.channels["vendor-weixin"].accounts.primary.enabled).toBe(true); + expect(cfg.channels["openclaw-weixin"].accounts.primary.enabled).toBe(true); + }); + it("writes a channelConfigUpdatedAt in JS Date.toISOString() shape (ms + 'Z')", () => { // The upstream plugin compares this string with values it produces via // Date.toISOString(). A Python isoformat() with offset would diverge. From 6cb5c324de2954d847b9e40afbda347d68f0acb6 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 19 May 2026 16:32:06 -0700 Subject: [PATCH 04/12] fix: avoid transient OpenClaw thinking flag in e2e --- test/e2e/test-openclaw-inference-switch.sh | 4 ++-- test/e2e/test-sandbox-operations.sh | 16 +++++++--------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/test/e2e/test-openclaw-inference-switch.sh b/test/e2e/test-openclaw-inference-switch.sh index 42f4c272ce0..de2b20b3990 100755 --- a/test/e2e/test-openclaw-inference-switch.sh +++ b/test/e2e/test-openclaw-inference-switch.sh @@ -270,8 +270,8 @@ check_openclaw_agent_turn() { -o ConnectTimeout=10 \ -o LogLevel=ERROR \ "openshell-${SANDBOX_NAME}" \ - "openclaw agent --agent main --json --thinking off --session-id '${session_id}' -m 'What is 6 multiplied by 7? Reply with only the integer, no extra words.'" \ - 2>/dev/null) || rc=$? + "openclaw agent --agent main --json --session-id '${session_id}' -m 'What is 6 multiplied by 7? Reply with only the integer, no extra words.'" \ + 2>&1) || rc=$? rm -f "$ssh_config" reply=$(printf '%s' "$raw" | python3 "${E2E_DIR}/lib/openclaw-agent-json.py" 2>/dev/null) || true diff --git a/test/e2e/test-sandbox-operations.sh b/test/e2e/test-sandbox-operations.sh index 146f818a90d..313cf6c006d 100755 --- a/test/e2e/test-sandbox-operations.sh +++ b/test/e2e/test-sandbox-operations.sh @@ -355,8 +355,9 @@ test_sbx_01_list_sandboxes() { # SSRF regression from the prior `Say exactly: HELLO_E2E` assertion. # 3. Asserts on payload text from the JSON envelope, not on merged # stdout/stderr. -# 4. Pins `--thinking off` so the first-turn smoke contract is not delayed -# by model-catalog inferred reasoning defaults. +# 4. Relies on generated `thinkingDefault: off` config so the first-turn +# smoke contract is not delayed by model-catalog inferred reasoning +# defaults without depending on transient CLI flags. test_sbx_02_connect_chat() { log "=== TC-SBX-02: Connect & Chat ===" require_sandbox "$SANDBOX_A" "TC-SBX-02" || return @@ -364,11 +365,8 @@ test_sbx_02_connect_chat() { log " Sending one-shot message to agent via SSH (openclaw agent --json)..." local session_id raw ssh_cfg session_id="e2e-sbx-02-$(date +%s)-$$" - # Use a direct ssh invocation rather than sandbox_exec(): sandbox_exec_for - # merges stderr into stdout via 2>&1 so it can log non-zero exits, which - # would pollute the JSON document we need to parse below. Drop stderr at - # the source so node deprecation warnings (UNDICI-EHPA, etc.) and - # progress-bar bytes from openclaw cannot trip up json.load(). + # Use a direct ssh invocation rather than sandbox_exec() so the JSON envelope + # is easy to parse while still preserving stderr in failure output. ssh_cfg="$(mktemp)" if ! openshell sandbox ssh-config "$SANDBOX_A" >"$ssh_cfg" 2>/dev/null; then rm -f "$ssh_cfg" @@ -379,8 +377,8 @@ test_sbx_02_connect_chat() { -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -o ConnectTimeout=10 -o LogLevel=ERROR \ "openshell-${SANDBOX_A}" \ - "openclaw agent --agent main --json --thinking off --session-id '${session_id}' -m 'What is 6 multiplied by 7? Reply with only the integer, no extra words.'" \ - 2>/dev/null) || true + "openclaw agent --agent main --json --session-id '${session_id}' -m 'What is 6 multiplied by 7? Reply with only the integer, no extra words.'" \ + 2>&1) || true rm -f "$ssh_cfg" local reply From b4d043b393832408208c9c3268765b21da56bfc3 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 19 May 2026 17:47:41 -0700 Subject: [PATCH 05/12] fix: seed preinstalled OpenClaw WeChat channel --- Dockerfile | 1 + scripts/generate-openclaw-config.py | 15 ++++++++++++++- test/generate-openclaw-config.test.ts | 17 +++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 17acee1bb91..75c69289f5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -347,6 +347,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_DISCORD_GUILDS_B64=${NEMOCLAW_DISCORD_GUILDS_B64} \ NEMOCLAW_TELEGRAM_CONFIG_B64=${NEMOCLAW_TELEGRAM_CONFIG_B64} \ NEMOCLAW_WECHAT_CONFIG_B64=${NEMOCLAW_WECHAT_CONFIG_B64} \ + NEMOCLAW_OPENCLAW_WECHAT_PLUGIN_PREINSTALLED=1 \ NEMOCLAW_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} \ NEMOCLAW_PROXY_HOST=${NEMOCLAW_PROXY_HOST} \ NEMOCLAW_PROXY_PORT=${NEMOCLAW_PROXY_PORT} \ diff --git a/scripts/generate-openclaw-config.py b/scripts/generate-openclaw-config.py index 4dd502ee135..4686f6d793b 100755 --- a/scripts/generate-openclaw-config.py +++ b/scripts/generate-openclaw-config.py @@ -801,8 +801,21 @@ def _has_installed_wechat_plugin_metadata() -> bool: return False +def _has_preinstalled_wechat_plugin_signal() -> bool: + return os.environ.get("NEMOCLAW_OPENCLAW_WECHAT_PLUGIN_PREINSTALLED", "").strip().lower() in { + "1", + "true", + "yes", + "on", + } + + def _seed_wechat_accounts_if_available(config: dict) -> None: - if not _has_plugin_install(config, "openclaw-weixin") and not _has_installed_wechat_plugin_metadata(): + if ( + not _has_plugin_install(config, "openclaw-weixin") + and not _has_installed_wechat_plugin_metadata() + and not _has_preinstalled_wechat_plugin_signal() + ): return seed_script = Path(__file__).resolve().with_name("seed-wechat-accounts.py") diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index a8dcafc8036..eb6ac077faf 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -335,6 +335,23 @@ describe("generate-openclaw-config.py: config generation", () => { expect(config.channels?.wechat).toBeUndefined(); }); + it("seeds channels.openclaw-weixin when the Dockerfile marks the plugin preinstalled", () => { + const channels = Buffer.from(JSON.stringify(["wechat"])).toString("base64"); + const wechatConfig = Buffer.from( + JSON.stringify({ accountId: "primary", baseUrl: "https://example", userId: "u1" }), + ).toString("base64"); + const config = runConfigScript({ + NEMOCLAW_MESSAGING_CHANNELS_B64: channels, + NEMOCLAW_WECHAT_CONFIG_B64: wechatConfig, + NEMOCLAW_OPENCLAW_WECHAT_PLUGIN_PREINSTALLED: "1", + }); + + expect(config.channels?.["openclaw-weixin"]?.accounts?.primary).toEqual({ + enabled: true, + }); + expect(config.channels?.wechat).toBeUndefined(); + }); + it("omits channels.openclaw-weixin when no accountId was captured", () => { // No QR-login result → seed step bails on the empty accountId and // leaves openclaw.json untouched, so the bridge stays dormant. From a89161bed1cae37fea5517672f21938c69639e0a Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 19 May 2026 19:26:50 -0700 Subject: [PATCH 06/12] fix: tolerate native OpenClaw tool search --- scripts/patch-openclaw-tool-catalog.js | 22 +++++++++++-- test/fetch-guard-patch-regression.test.ts | 2 +- test/openclaw-tool-catalog-patch.test.ts | 38 +++++++++++++++++++++++ 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/scripts/patch-openclaw-tool-catalog.js b/scripts/patch-openclaw-tool-catalog.js index 0d9f2c01ab0..09b02b34d2c 100755 --- a/scripts/patch-openclaw-tool-catalog.js +++ b/scripts/patch-openclaw-tool-catalog.js @@ -31,6 +31,13 @@ const ALREADY_PATCHED_REQUIRED_PATTERNS = [ "\t\t\tconst nemoClawCatalogSourceTools = [...customTools, ...clientToolDefs];", "\t\t\tconst allCustomTools = nemoClawCreateToolCatalog(nemoClawCatalogSourceTools);", ]; +const NATIVE_TOOL_SEARCH_PATTERNS = [ + "const uncompactedEffectiveTools = [...tools, ...filteredBundledTools];", + "applyToolSearchCatalog({", + "buildToolSearchRunPlan({", + "const allowedToolNames = toolSearchRunPlan.visibleAllowedToolNames;", + "const replayAllowedToolNames = toolSearchRunPlan.replayAllowedToolNames;", +]; const EFFECTIVE_TOOLS_REPLACEMENT = [ EFFECTIVE_TOOLS_PATTERN, @@ -226,6 +233,10 @@ function patchSelectionText(source, filePath) { return { patched: false, text: source }; } + if (NATIVE_TOOL_SEARCH_PATTERNS.every((pattern) => source.includes(pattern))) { + return { patched: false, text: source, status: "native-tool-search" }; + } + const requiredPatterns = [ EFFECTIVE_TOOLS_PATTERN, ALLOWED_TOOL_NAMES_PATTERN, @@ -261,7 +272,11 @@ function patchOpenClawToolCatalog(distDir) { const targetFiles = selectionFiles.filter((file) => { const text = fs.readFileSync(file, "utf-8"); - return text.includes(ALL_CUSTOM_TOOLS_PATTERN) || text.includes(MARKER); + return ( + text.includes(ALL_CUSTOM_TOOLS_PATTERN) || + text.includes(MARKER) || + NATIVE_TOOL_SEARCH_PATTERNS.every((pattern) => text.includes(pattern)) + ); }); if (targetFiles.length !== 1) { throw new Error(`Expected exactly one selection-*.js target, found ${targetFiles.length}`); @@ -269,12 +284,13 @@ function patchOpenClawToolCatalog(distDir) { const target = targetFiles[0]; const source = fs.readFileSync(target, "utf-8"); - const { patched, text } = patchSelectionText(source, target); + const result = patchSelectionText(source, target); + const { patched, text } = result; if (patched) { fs.writeFileSync(target, text); return { status: "patched", file: target, version }; } - return { status: "already-patched", file: target, version }; + return { status: result.status ?? "already-patched", file: target, version }; } function main(argv) { diff --git a/test/fetch-guard-patch-regression.test.ts b/test/fetch-guard-patch-regression.test.ts index 875edb4ddb2..dacf6c1c365 100644 --- a/test/fetch-guard-patch-regression.test.ts +++ b/test/fetch-guard-patch-regression.test.ts @@ -203,7 +203,7 @@ if (globalThis.proxyChecks.length !== 0) throw new Error('sandbox proxy validati const command = dockerRunCommandBetween( "# Patch OpenClaw media fetch for proxy-only sandbox", - "# Set up blueprint for local resolution", + "# Patch OpenClaw's pinned", ).replaceAll("/usr/local/lib/node_modules/openclaw/dist", dist); const fakeBin = path.join(tmp, "bin"); fs.mkdirSync(fakeBin); diff --git a/test/openclaw-tool-catalog-patch.test.ts b/test/openclaw-tool-catalog-patch.test.ts index dd025ed623d..a60cad46e13 100644 --- a/test/openclaw-tool-catalog-patch.test.ts +++ b/test/openclaw-tool-catalog-patch.test.ts @@ -118,6 +118,31 @@ function realToolFixtureSource(allCustomToolsLine: string) { ].join("\n"); } +function nativeToolSearchFixtureSource() { + return [ + "const uncompactedEffectiveTools = [...tools, ...filteredBundledTools];", + "let effectiveTools = uncompactedEffectiveTools;", + "const toolSearch = applyToolSearchCatalog({", + "\ttools: effectiveTools,", + "\tconfig: params.config", + "});", + "effectiveTools = toolSearch.tools;", + "const toolSearchRunPlan = buildToolSearchRunPlan({", + "\tvisibleTools: effectiveTools,", + "\tuncompactedTools: uncompactedEffectiveTools", + "});", + "const allowedToolNames = toolSearchRunPlan.visibleAllowedToolNames;", + "const replayAllowedToolNames = toolSearchRunPlan.replayAllowedToolNames;", + "const { customTools } = splitSdkTools({ tools: effectiveTools });", + "const clientToolDefs = [];", + "\t\t\tconst allCustomTools = [...customTools, ...clientToolDefs];", + "void allowedToolNames;", + "void replayAllowedToolNames;", + "void allCustomTools;", + "", + ].join("\n"); +} + function makeFixture(opts: { version?: string; allCustomToolsLine?: string } = {}) { const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-tool-catalog-patch-")); const dist = path.join(root, "dist"); @@ -188,6 +213,19 @@ describe("OpenClaw compact tool catalog patch", () => { fs.rmSync(changed.root, { recursive: true, force: true }); } + const native = makeFixture(); + try { + fs.writeFileSync(native.selectionPath, nativeToolSearchFixtureSource()); + const result = runPatch(native.dist); + expect(result.status, `${result.stdout}${result.stderr}`).toBe(0); + expect(result.stdout).toContain("native-tool-search"); + const unmodified = fs.readFileSync(native.selectionPath, "utf-8"); + expect(unmodified).not.toContain(MARKER); + expect(unmodified).toContain("buildToolSearchRunPlan"); + } finally { + fs.rmSync(native.root, { recursive: true, force: true }); + } + const partial = makeFixture({ allCustomToolsLine: [ MARKER, From cfa1694342180dd07aebe63599e0cbbaf73887a0 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 19 May 2026 19:33:45 -0700 Subject: [PATCH 07/12] fix: silence intentional sandbox shell quoting --- test/e2e/test-issue-2478-crash-loop-recovery.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/test/e2e/test-issue-2478-crash-loop-recovery.sh b/test/e2e/test-issue-2478-crash-loop-recovery.sh index cddca2c0883..0e6c5fea4af 100755 --- a/test/e2e/test-issue-2478-crash-loop-recovery.sh +++ b/test/e2e/test-issue-2478-crash-loop-recovery.sh @@ -108,6 +108,7 @@ sandbox_exec() { # `openclaw` rather than the older `openclaw-gateway` argv. Match the process # table directly so readiness does not depend on the legacy rename. gateway_pid() { + # shellcheck disable=SC2016 # Single-quoted body runs inside the sandbox shell. sandbox_exec sh -c ' pid="$(ps -eo pid=,comm=,args= 2>/dev/null | awk '\''($2 == "openclaw" && $0 ~ /gateway/) || $0 ~ /openclaw[ -]gateway/ { print $1 }'\'' | sort -n | head -n 1)" if [ -z "$pid" ]; then From 6e7756e77504eec98415d9767db5e66006dcc29c Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 19 May 2026 19:46:21 -0700 Subject: [PATCH 08/12] fix: align sandbox restore proof with OpenClaw metadata --- test/e2e-test.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/e2e-test.sh b/test/e2e-test.sh index 6aa3c8d80b7..f7676875ad6 100755 --- a/test/e2e-test.sh +++ b/test/e2e-test.sh @@ -255,7 +255,9 @@ if node --input-type=module -e " // Verify restoration const restored = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - const version = (restored.meta || {}).lastTouchedVersion; + // OpenClaw may refresh meta.lastTouchedVersion during plugin install before + // the snapshot, so assert the fixture's stable wizard state instead. + const version = (restored.wizard || {}).lastRunVersion; if (version !== '2026.3.11') throw new Error('Restored config wrong: ' + JSON.stringify(restored)); if ('corrupted' in restored) throw new Error('Config still corrupted after rollback'); console.log('Restored config: ' + JSON.stringify(restored)); From 2740a01224181afc40619c1995db94655c3a9397 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 19 May 2026 20:10:00 -0700 Subject: [PATCH 09/12] fix: keep issue 2478 pid probe single-line --- test/e2e/test-issue-2478-crash-loop-recovery.sh | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/test/e2e/test-issue-2478-crash-loop-recovery.sh b/test/e2e/test-issue-2478-crash-loop-recovery.sh index 0e6c5fea4af..59bb09f46f5 100755 --- a/test/e2e/test-issue-2478-crash-loop-recovery.sh +++ b/test/e2e/test-issue-2478-crash-loop-recovery.sh @@ -108,14 +108,10 @@ sandbox_exec() { # `openclaw` rather than the older `openclaw-gateway` argv. Match the process # table directly so readiness does not depend on the legacy rename. gateway_pid() { + local out # shellcheck disable=SC2016 # Single-quoted body runs inside the sandbox shell. - sandbox_exec sh -c ' - pid="$(ps -eo pid=,comm=,args= 2>/dev/null | awk '\''($2 == "openclaw" && $0 ~ /gateway/) || $0 ~ /openclaw[ -]gateway/ { print $1 }'\'' | sort -n | head -n 1)" - if [ -z "$pid" ]; then - pid="$(ps -eo pid=,comm=,args= 2>/dev/null | awk '\''$2 == "openclaw" { print $1 }'\'' | sort -n | head -n 1)" - fi - printf "%s" "$pid" - ' | tr -d '[:space:]' + out="$(sandbox_exec sh -c 'pid="$(ps -eo pid=,comm=,args= 2>/dev/null | awk '\''($2 == "openclaw" && $0 ~ /gateway/) || $0 ~ /openclaw[ -]gateway/ { print $1 }'\'' | sort -n | head -n 1)"; if [ -z "$pid" ]; then pid="$(ps -eo pid=,comm=,args= 2>/dev/null | awk '\''$2 == "openclaw" { print $1 }'\'' | sort -n | head -n 1)"; fi; printf "%s\n" "$pid"')" + printf '%s\n' "$out" | awk '/^[0-9]+$/ { print; exit }' } # Read /tmp/nemoclaw-proxy-env.sh — the single source of truth for the From 3236e08c1d7a91397b2f98cea5b918dec7625fb6 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 19 May 2026 20:27:48 -0700 Subject: [PATCH 10/12] fix: parse streamed OpenClaw agent JSON --- test/e2e/lib/openclaw-agent-json.py | 26 ++++++++++++++++++-------- test/openclaw-agent-json.test.ts | 10 ++++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/test/e2e/lib/openclaw-agent-json.py b/test/e2e/lib/openclaw-agent-json.py index f2953f620ec..c99e9f5a168 100755 --- a/test/e2e/lib/openclaw-agent-json.py +++ b/test/e2e/lib/openclaw-agent-json.py @@ -32,34 +32,44 @@ def _payloads(doc: Any) -> list[Any]: return [] -def _load_agent_json(text: str) -> Any: +def _load_agent_json_docs(text: str) -> list[Any]: try: - return json.loads(text) + doc = json.loads(text) except json.JSONDecodeError: pass + else: + return doc if isinstance(doc, list) else [doc] decoder = json.JSONDecoder() - for index, char in enumerate(text): - if char != "{": - continue + docs: list[Any] = [] + index = 0 + while index < len(text): + start = text.find("{", index) + if start < 0: + break try: - doc, _end = decoder.raw_decode(text[index:]) + doc, end = decoder.raw_decode(text[start:]) except json.JSONDecodeError: + index = start + 1 continue - return doc + docs.append(doc) + index = start + end + if docs: + return docs raise json.JSONDecodeError("no JSON object found", text, 0) def main() -> int: raw = sys.stdin.read() try: - doc = _load_agent_json(raw) + docs = _load_agent_json_docs(raw) except json.JSONDecodeError as err: print(f"invalid JSON: {err}", file=sys.stderr) return 1 parts = [ payload["text"] + for doc in docs for payload in _payloads(doc) if isinstance(payload, dict) and isinstance(payload.get("text"), str) ] diff --git a/test/openclaw-agent-json.test.ts b/test/openclaw-agent-json.test.ts index a03f8d3d2c5..e09d2e1fdc0 100644 --- a/test/openclaw-agent-json.test.ts +++ b/test/openclaw-agent-json.test.ts @@ -54,4 +54,14 @@ describe("openclaw-agent-json.py", () => { expect(result.status).toBe(0); expect(result.stdout).toBe("PONG\n"); }); + + it("extracts payload text from later JSON envelopes in a stream", () => { + const result = runHelper([ + JSON.stringify({ payloads: [] }), + "progress line", + JSON.stringify({ payloads: [{ text: "42" }] }), + ].join("\n")); + expect(result.status).toBe(0); + expect(result.stdout).toBe("42\n"); + }); }); From d4df1e4443ff24202673c32c9d3646bc957294e7 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 19 May 2026 22:12:08 -0700 Subject: [PATCH 11/12] fix: validate restored openclaw snapshot content Signed-off-by: Aaron Erickson --- test/e2e-test.sh | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/test/e2e-test.sh b/test/e2e-test.sh index f7676875ad6..155d256f9fb 100755 --- a/test/e2e-test.sh +++ b/test/e2e-test.sh @@ -246,7 +246,8 @@ if node --input-type=module -e " // Simulate corruption: modify the host config const configPath = path.join(os.homedir(), '.openclaw', 'openclaw.json'); - const original = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + const originalRaw = fs.readFileSync(configPath, 'utf-8'); + JSON.parse(originalRaw); fs.writeFileSync(configPath, JSON.stringify({ corrupted: true })); // Rollback @@ -254,12 +255,10 @@ if node --input-type=module -e " if (!success) throw new Error('Rollback returned false'); // Verify restoration - const restored = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - // OpenClaw may refresh meta.lastTouchedVersion during plugin install before - // the snapshot, so assert the fixture's stable wizard state instead. - const version = (restored.wizard || {}).lastRunVersion; - if (version !== '2026.3.11') throw new Error('Restored config wrong: ' + JSON.stringify(restored)); + const restoredRaw = fs.readFileSync(configPath, 'utf-8'); + const restored = JSON.parse(restoredRaw); if ('corrupted' in restored) throw new Error('Config still corrupted after rollback'); + if (restoredRaw !== originalRaw) throw new Error('Restored config differs from pre-corruption content: ' + JSON.stringify(restored)); console.log('Restored config: ' + JSON.stringify(restored)); "; then pass "Snapshot rollback restores original config" From cd41b2b4f5ebf6758a62f4c3036c6817f244db11 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 19 May 2026 22:41:37 -0700 Subject: [PATCH 12/12] fix: use checked-out ref in gateway upgrade e2e Signed-off-by: Aaron Erickson --- test/e2e/test-openshell-gateway-upgrade.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/e2e/test-openshell-gateway-upgrade.sh b/test/e2e/test-openshell-gateway-upgrade.sh index 60c71b8fe6e..0d9934b852f 100755 --- a/test/e2e/test-openshell-gateway-upgrade.sh +++ b/test/e2e/test-openshell-gateway-upgrade.sh @@ -516,7 +516,8 @@ AGENT install_current_nemoclaw_upgrade() { local current_ref - current_ref="${GITHUB_SHA:-$(git rev-parse HEAD)}" + current_ref="${NEMOCLAW_CURRENT_NEMOCLAW_REF:-$(git rev-parse HEAD 2>/dev/null || printf '%s' "${GITHUB_SHA:-}")}" + [ -n "$current_ref" ] || fail "could not determine current NemoClaw ref" run_installer_payload "current ${current_ref:0:12}" "$current_ref" "${REPO_ROOT}/scripts/install.sh" "$CURRENT_INSTALL_LOG" grep -Fq "Accepted experimental OpenShell gateway upgrade" "$CURRENT_INSTALL_LOG" \ || fail "current installer did not exercise the experimental OpenShell gateway upgrade acceptance path"