From d4d607d838e2dba9e9a676d1a735043cbf237f9e Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 12 Jun 2026 11:46:32 -0700 Subject: [PATCH 1/4] test(e2e): migrate OpenClaw inference switch scenario Signed-off-by: Carlos Villela --- .github/workflows/e2e-vitest-scenarios.yaml | 88 ++ .../live/openclaw-inference-switch.test.ts | 920 ++++++++++++++++++ .../e2e-scenarios-workflow.test.ts | 22 +- tools/e2e-scenarios/free-standing-jobs.env | 6 +- 4 files changed, 1032 insertions(+), 4 deletions(-) create mode 100644 test/e2e-scenario/live/openclaw-inference-switch.test.ts diff --git a/.github/workflows/e2e-vitest-scenarios.yaml b/.github/workflows/e2e-vitest-scenarios.yaml index f199050c83d..6363f6a074f 100644 --- a/.github/workflows/e2e-vitest-scenarios.yaml +++ b/.github/workflows/e2e-vitest-scenarios.yaml @@ -595,6 +595,93 @@ jobs: if-no-files-found: ignore retention-days: 14 + openclaw-inference-switch-vitest: + needs: generate-matrix + if: ${{ (inputs.jobs == '' && inputs.scenarios == '') || contains(format(',{0},', inputs.jobs), ',openclaw-inference-switch-vitest,') || contains(format(',{0},', inputs.scenarios), ',openclaw-inference-switch,') }} + runs-on: ubuntu-latest + timeout-minutes: 90 + env: + E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/vitest/openclaw-inference-switch + NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js + NEMOCLAW_RUN_E2E_SCENARIOS: "1" + NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" + NEMOCLAW_SANDBOX_NAME: "e2e-openclaw-inference-switch" + OPENSHELL_GATEWAY: "nemoclaw" + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Authenticate to Docker Hub + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + if [[ -z "${DOCKERHUB_USERNAME}" || -z "${DOCKERHUB_TOKEN}" ]]; then + echo "::notice::Docker Hub credentials not configured; continuing with anonymous pulls." + exit 0 + fi + login_succeeded=0 + for attempt in 1 2 3; do + if echo "${DOCKERHUB_TOKEN}" | timeout 30s docker login docker.io --username "${DOCKERHUB_USERNAME}" --password-stdin; then + login_succeeded=1 + break + fi + if [[ "$attempt" -lt 3 ]]; then + echo "::warning::Docker Hub login attempt ${attempt} failed; retrying." + sleep 5 + fi + done + if [[ "$login_succeeded" -ne 1 ]]; then + echo "::warning::Docker Hub login failed after 3 attempts; continuing with anonymous pulls." + fi + + - name: Set up Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.0.0 + with: + node-version: 22 + cache: npm + + - name: Install root dependencies + run: npm ci --ignore-scripts + + - name: Build CLI + run: npm run build:cli + + - name: Run OpenClaw inference switch live test + # Migrated from test/e2e/test-openclaw-inference-switch.sh. Preserves + # the real install.sh/OpenShell/Docker/live-provider boundary while + # Vitest owns the route, config, registry, inference.local, and agent + # assertions. + env: + NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} + run: | + set -euo pipefail + npx vitest run --project e2e-scenarios-live \ + test/e2e-scenario/live/openclaw-inference-switch.test.ts \ + --silent=false --reporter=default + + - name: Upload OpenClaw inference switch artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-vitest-scenarios-openclaw-inference-switch + path: | + e2e-artifacts/vitest/openclaw-inference-switch/ + /tmp/nemoclaw-e2e-openclaw-inference-switch-install.log + include-hidden-files: false + if-no-files-found: ignore + retention-days: 14 + + - name: Clean up Docker auth + if: always() + shell: bash + run: | + docker logout docker.io >/dev/null 2>&1 || true + issue-4434-tui-unreachable-inference-vitest: needs: generate-matrix if: ${{ (inputs.jobs == '' && inputs.scenarios == '') || contains(format(',{0},', inputs.jobs), ',issue-4434-tui-unreachable-inference-vitest,') || contains(format(',{0},', inputs.scenarios), ',issue-4434-tui-unreachable-inference,') }} @@ -1891,6 +1978,7 @@ jobs: model-router-provider-routed-inference-vitest, sandbox-survival-vitest, openclaw-tui-chat-correlation-vitest, + openclaw-inference-switch-vitest, gateway-guard-recovery, issue-4434-tui-unreachable-inference-vitest, ] diff --git a/test/e2e-scenario/live/openclaw-inference-switch.test.ts b/test/e2e-scenario/live/openclaw-inference-switch.test.ts new file mode 100644 index 00000000000..a101ad93c86 --- /dev/null +++ b/test/e2e-scenario/live/openclaw-inference-switch.test.ts @@ -0,0 +1,920 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Live Vitest migration for test/e2e/test-openclaw-inference-switch.sh. + * + * Preserve the script's real user-visible boundary: install.sh onboards an + * OpenClaw sandbox, `nemoclaw inference set` switches the running route, then + * OpenShell route state, OpenClaw config/hash state, registry/session state, + * inference.local, and a real OpenClaw agent turn are checked from the live + * host/sandbox boundary. Helpers stay local because this is one focused + * free-standing migration rather than a new shared fixture family. + */ + +import fs from "node:fs"; +import http, { type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import os from "node:os"; +import path from "node:path"; + +import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; +import type { HostCliClient } from "../fixtures/clients/host.ts"; +import { + type SandboxClient, + trustedSandboxShellScript, + validateSandboxName, +} from "../fixtures/clients/sandbox.ts"; +import { expect, test } from "../fixtures/e2e-test.ts"; +import { shouldRunLiveE2EScenarios } from "../fixtures/live-project-gate.ts"; +import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; + +const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); +const CLI_ENTRYPOINT = path.join(REPO_ROOT, "bin", "nemoclaw.js"); +const INSTALL_LOG = "/tmp/nemoclaw-e2e-openclaw-inference-switch-install.log"; +const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-openclaw-inference-switch"; +const SWITCH_PROVIDER = process.env.NEMOCLAW_SWITCH_PROVIDER ?? "nvidia-prod"; +const SWITCH_MODEL = process.env.NEMOCLAW_SWITCH_MODEL ?? "z-ai/glm-5.1"; +const SWITCH_INFERENCE_API = process.env.NEMOCLAW_SWITCH_INFERENCE_API ?? "openai-completions"; +const SWITCH_MOCK_ANTHROPIC = process.env.NEMOCLAW_SWITCH_MOCK_ANTHROPIC ?? "0"; +const SWITCH_MOCK_PORT = Number(process.env.NEMOCLAW_SWITCH_MOCK_PORT ?? 18767); +const TEST_TIMEOUT_MS = 75 * 60_000; +const INSTALL_TIMEOUT_MS = 30 * 60_000; +const COMMAND_TIMEOUT_MS = 120_000; +const INFERENCE_TIMEOUT_MS = 150_000; +const AGENT_TIMEOUT_MS = 150_000; +const RUN_OPENCLAW_INFERENCE_SWITCH_TEST = shouldRunLiveE2EScenarios() ? test : test.skip; + +validateSandboxName(SANDBOX_NAME); + +interface ChatCompletionResponse { + choices?: Array<{ + message?: { + content?: unknown; + reasoning_content?: unknown; + reasoning?: unknown; + }; + text?: unknown; + }>; +} + +interface AnthropicResponse { + content?: Array<{ text?: unknown }>; +} + +interface OpenClawConfig { + agents?: { + defaults?: { + model?: { + primary?: unknown; + }; + }; + }; + models?: { + providers?: Record< + string, + { + baseUrl?: unknown; + api?: unknown; + models?: Array<{ id?: unknown; name?: unknown }>; + } + >; + }; +} + +interface SandboxRegistry { + sandboxes?: Record; +} + +interface OnboardSession { + sandboxName?: unknown; + provider?: unknown; + model?: unknown; +} + +interface MockAnthropicProvider { + endpointUrl: string; + close(): Promise; +} + +function resultText(result: Pick): string { + return [result.stdout, result.stderr].filter(Boolean).join("\n"); +} + +function stripAnsi(value: string): string { + return value.replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, ""); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +function parsePositiveIntEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new Error(`${name} must be a positive integer; got ${raw}`); + } + return parsed; +} + +function commandEnv(home: string, extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + const base = buildAvailabilityProbeEnv(); + return { + ...base, + HOME: home, + PATH: [path.join(home, ".local", "bin"), path.join(home, ".npm-global", "bin"), base.PATH] + .filter(Boolean) + .join(":"), + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1", + NEMOCLAW_SANDBOX_NAME: SANDBOX_NAME, + OPENSHELL_GATEWAY: process.env.OPENSHELL_GATEWAY ?? "nemoclaw", + ...extra, + }; +} + +async function bestEffort(run: () => Promise): Promise { + try { + await run(); + } catch { + // Cleanup probes are intentionally best-effort so early setup failures do + // not hide the primary assertion or install failure. + } +} + +async function runNemoclaw( + host: HostCliClient, + home: string, + args: string[], + options: { artifactName: string; timeoutMs?: number; redactionValues?: string[] } = { + artifactName: "nemoclaw", + }, +): Promise { + return host.command("node", [CLI_ENTRYPOINT, ...args], { + artifactName: options.artifactName, + env: commandEnv(home), + timeoutMs: options.timeoutMs ?? COMMAND_TIMEOUT_MS, + redactionValues: options.redactionValues, + }); +} + +async function sandboxShell( + sandbox: SandboxClient, + home: string, + script: string, + options: { artifactName: string; timeoutMs?: number; redactionValues?: string[] }, +): Promise { + return sandbox.execShell(SANDBOX_NAME, trustedSandboxShellScript(script), { + artifactName: options.artifactName, + env: commandEnv(home), + timeoutMs: options.timeoutMs ?? COMMAND_TIMEOUT_MS, + redactionValues: options.redactionValues, + }); +} + +async function cleanupOpenClawInferenceSwitchState( + host: HostCliClient, + sandbox: SandboxClient, + home: string, + artifactPrefix: string, +): Promise { + await bestEffort(() => + runNemoclaw(host, home, [SANDBOX_NAME, "destroy", "--yes"], { + artifactName: `${artifactPrefix}-nemoclaw-destroy-openclaw-inference-switch`, + timeoutMs: 120_000, + }), + ); + await bestEffort(() => + sandbox.openshell(["sandbox", "delete", SANDBOX_NAME], { + artifactName: `${artifactPrefix}-openshell-sandbox-delete-openclaw-inference-switch`, + env: commandEnv(home), + timeoutMs: 60_000, + }), + ); + await bestEffort(() => + sandbox.openshell(["gateway", "destroy", "-g", "nemoclaw"], { + artifactName: `${artifactPrefix}-openshell-gateway-destroy-openclaw-inference-switch`, + env: commandEnv(home), + timeoutMs: 120_000, + }), + ); +} + +function jsonResponse(res: http.ServerResponse, status: number, payload: unknown): void { + const body = JSON.stringify(payload); + res.writeHead(status, { + "content-type": "application/json", + "content-length": Buffer.byteLength(body), + }); + res.end(body); +} + +function sseResponse(res: http.ServerResponse, events: Array<[string, unknown]>): void { + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + }); + for (const [name, payload] of events) { + res.write(`event: ${name}\n`); + res.write(`data: ${JSON.stringify(payload)}\n\n`); + } + res.end(); +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +async function startMockAnthropicProvider(): Promise { + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://mock.local"); + if (req.method === "GET" && url.pathname === "/health") { + jsonResponse(res, 200, { ok: true }); + return; + } + if ( + req.method === "GET" && + ["/v1/models", "/v1/models/mock-anthropic-model"].includes(url.pathname) + ) { + jsonResponse(res, 200, { data: [{ id: "mock-anthropic-model" }] }); + return; + } + if (req.method !== "POST" || url.pathname !== "/v1/messages") { + jsonResponse(res, 404, { error: "not found", path: url.pathname }); + return; + } + + let raw = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + raw += chunk; + }); + req.on("end", () => { + let payload: { model?: unknown; stream?: unknown } = {}; + try { + payload = JSON.parse(raw || "{}") as { model?: unknown; stream?: unknown }; + } catch { + payload = {}; + } + const model = typeof payload.model === "string" ? payload.model : "mock-anthropic-model"; + if (payload.stream === true) { + const message = { + id: "msg_mock", + type: "message", + role: "assistant", + model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 0 }, + }; + sseResponse(res, [ + ["message_start", { type: "message_start", message }], + [ + "content_block_start", + { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + }, + ], + [ + "content_block_delta", + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "PONG" } }, + ], + ["content_block_stop", { type: "content_block_stop", index: 0 }], + [ + "message_delta", + { + type: "message_delta", + delta: { stop_reason: "end_turn", stop_sequence: null }, + usage: { output_tokens: 1 }, + }, + ], + ["message_stop", { type: "message_stop" }], + ]); + return; + } + jsonResponse(res, 200, { + id: "msg_mock", + type: "message", + role: "assistant", + model, + content: [{ type: "text", text: "PONG" }], + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + }); + }); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(SWITCH_MOCK_PORT, "0.0.0.0", () => { + server.off("error", reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") { + await closeServer(server); + throw new Error("mock Anthropic provider did not expose a TCP port"); + } + const port = (address as AddressInfo).port; + return { + endpointUrl: `http://host.openshell.internal:${port}`, + close: () => closeServer(server), + }; +} + +async function ensureCompatibleAnthropicSwitchProvider( + host: HostCliClient, + home: string, + mockProvider: MockAnthropicProvider | undefined, +): Promise { + if (SWITCH_PROVIDER !== "compatible-anthropic-endpoint") return; + if (SWITCH_INFERENCE_API !== "anthropic-messages") return; + + const endpointUrl = process.env.NEMOCLAW_SWITCH_ENDPOINT_URL ?? mockProvider?.endpointUrl ?? ""; + const apiKey = process.env.COMPATIBLE_ANTHROPIC_API_KEY ?? "test-compatible-anthropic-key"; + expect( + endpointUrl, + "NEMOCLAW_SWITCH_ENDPOINT_URL is required for compatible Anthropic inference switches", + ).not.toBe(""); + expect( + apiKey, + "COMPATIBLE_ANTHROPIC_API_KEY is required for compatible Anthropic inference switches", + ).not.toBe(""); + + const providerScript = [ + "set -euo pipefail", + "if openshell provider get -g nemoclaw compatible-anthropic-endpoint >/dev/null 2>&1; then", + ' openshell provider update -g nemoclaw compatible-anthropic-endpoint --credential COMPATIBLE_ANTHROPIC_API_KEY --config "ANTHROPIC_BASE_URL=${SWITCH_ENDPOINT_URL}"', + "else", + ' openshell provider create -g nemoclaw --name compatible-anthropic-endpoint --type anthropic --credential COMPATIBLE_ANTHROPIC_API_KEY --config "ANTHROPIC_BASE_URL=${SWITCH_ENDPOINT_URL}"', + "fi", + ].join("\n"); + const provider = await host.command("bash", ["-lc", providerScript], { + artifactName: "register-compatible-anthropic-switch-provider", + env: commandEnv(home, { + COMPATIBLE_ANTHROPIC_API_KEY: apiKey, + SWITCH_ENDPOINT_URL: endpointUrl, + }), + redactionValues: [apiKey], + timeoutMs: COMMAND_TIMEOUT_MS, + }); + expect(provider.exitCode, resultText(provider)).toBe(0); +} + +async function openclawGatewayPid(sandbox: SandboxClient, home: string): Promise { + const result = await sandboxShell( + sandbox, + home, + 'ps -eo pid=,comm=,args= 2>/dev/null | awk \'$2 != "sh" && $2 != "bash" && $2 != "awk" && $0 ~ /openclaw/ && $0 ~ /gateway run/ { print $1; exit }\' || true', + { + artifactName: "openclaw-gateway-pid", + timeoutMs: 30_000, + }, + ); + return result.stdout.trim(); +} + +async function getRouteOutput(host: HostCliClient, home: string): Promise { + return host.command( + "bash", + ["-lc", "openshell inference get -g nemoclaw 2>&1 || openshell inference get 2>&1"], + { + artifactName: "openshell-inference-get-after-switch", + env: commandEnv(home), + timeoutMs: COMMAND_TIMEOUT_MS, + }, + ); +} + +async function assertOpenShellRoute(host: HostCliClient, home: string): Promise { + const route = await getRouteOutput(host, home); + expect(route.exitCode, resultText(route)).toBe(0); + const plain = stripAnsi(resultText(route)); + expect(plain).toContain(`Provider: ${SWITCH_PROVIDER}`); + expect(plain).toContain(`Model: ${SWITCH_MODEL}`); +} + +async function assertRegistryAndSession(home: string): Promise { + const registryPath = path.join(home, ".nemoclaw", "sandboxes.json"); + const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")) as SandboxRegistry; + const sandbox = registry.sandboxes?.[SANDBOX_NAME]; + expect(sandbox, `sandbox ${SANDBOX_NAME} missing from registry`).toBeTruthy(); + expect(sandbox?.provider).toBe(SWITCH_PROVIDER); + expect(sandbox?.model).toBe(SWITCH_MODEL); + + const sessionPath = path.join(home, ".nemoclaw", "onboard-session.json"); + const session = JSON.parse(fs.readFileSync(sessionPath, "utf8")) as OnboardSession; + expect(Object.keys(session).length, "onboard session is empty").toBeGreaterThan(0); + expect(session.sandboxName).toBe(SANDBOX_NAME); + expect(session.provider).toBe(SWITCH_PROVIDER); + expect(session.model).toBe(SWITCH_MODEL); +} + +async function assertOpenClawConfig(sandbox: SandboxClient, home: string): Promise { + const configResult = await sandbox.exec( + SANDBOX_NAME, + ["cat", "/sandbox/.openclaw/openclaw.json"], + { + artifactName: "read-openclaw-config-after-inference-switch", + env: commandEnv(home), + timeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(configResult.exitCode, resultText(configResult)).toBe(0); + const config = JSON.parse(configResult.stdout) as OpenClawConfig; + const expectedProviderKey = + SWITCH_INFERENCE_API === "anthropic-messages" ? "anthropic" : "inference"; + const expectedPrimary = `${expectedProviderKey}/${SWITCH_MODEL}`; + const provider = config.models?.providers?.[expectedProviderKey]; + const firstModel = provider?.models?.[0]; + + expect(config.agents?.defaults?.model?.primary).toBe(expectedPrimary); + expect(provider?.baseUrl).toBe( + SWITCH_INFERENCE_API === "anthropic-messages" + ? "https://inference.local" + : "https://inference.local/v1", + ); + expect(provider?.api).toBe(SWITCH_INFERENCE_API); + expect(firstModel?.id).toBe(SWITCH_MODEL); + expect(firstModel?.name).toBe(expectedPrimary); + + const hashCheck = await sandboxShell( + sandbox, + home, + "cd /sandbox/.openclaw && sha256sum -c .config-hash --status && echo OK", + { + artifactName: "openclaw-config-hash-after-inference-switch", + timeoutMs: COMMAND_TIMEOUT_MS, + }, + ); + expect(hashCheck.exitCode, resultText(hashCheck)).toBe(0); + expect(hashCheck.stdout.trim()).toBe("OK"); +} + +function isTransientLiveHttpCode(status: string): boolean { + return ["502", "503", "504"].includes(status); +} + +function httpStatusFromResponse(response: string): string { + return ( + response + .split("\n") + .filter((line) => line.startsWith("__NEMOCLAW_HTTP_STATUS__=")) + .at(-1) + ?.replace("__NEMOCLAW_HTTP_STATUS__=", "") + .trim() ?? "" + ); +} + +function httpBodyFromResponse(response: string): string { + return response + .split("\n") + .filter((line) => !line.startsWith("__NEMOCLAW_HTTP_STATUS__=")) + .join("\n"); +} + +function parseChatContent(raw: string): string { + const response = JSON.parse(raw) as ChatCompletionResponse; + return (response.choices ?? []) + .map((choice) => { + const message = choice.message ?? {}; + if (typeof message.content === "string") return message.content; + if (typeof message.reasoning_content === "string") return message.reasoning_content; + if (typeof message.reasoning === "string") return message.reasoning; + if (typeof choice.text === "string") return choice.text; + return ""; + }) + .join("\n") + .trim(); +} + +function parseAnthropicContent(raw: string): string { + const response = JSON.parse(raw) as AnthropicResponse; + return (response.content ?? []) + .map((part) => (typeof part.text === "string" ? part.text : "")) + .filter(Boolean) + .join(" ") + .trim(); +} + +async function checkSandboxInference( + sandbox: SandboxClient, + home: string, +): Promise<"ok" | { skipped: string }> { + const payload = + SWITCH_INFERENCE_API === "anthropic-messages" + ? { + model: SWITCH_MODEL, + messages: [{ role: "user", content: "Reply with exactly one word: PONG" }], + max_tokens: 32, + } + : { + model: SWITCH_MODEL, + messages: [{ role: "user", content: "Reply with exactly one word: PONG" }], + max_tokens: 100, + }; + const payloadB64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64"); + const curlCommand = + SWITCH_INFERENCE_API === "anthropic-messages" + ? `curl -sS -o "$tmp" -w '%{http_code}' --max-time 90 https://inference.local/v1/messages -H 'Content-Type: application/json' -H 'anthropic-version: 2023-06-01' --data-binary @/tmp/nemoclaw-switch-payload.json` + : `curl -sS -o "$tmp" -w '%{http_code}' --max-time 90 https://inference.local/v1/chat/completions -H 'Content-Type: application/json' --data-binary @/tmp/nemoclaw-switch-payload.json`; + const script = [ + "set -u", + "tmp=$(mktemp)", + `printf '%s' ${shellQuote(payloadB64)} | base64 -d >/tmp/nemoclaw-switch-payload.json`, + "set +e", + `code=$(${curlCommand})`, + "rc=$?", + "set -e", + 'cat "$tmp"', + 'rm -f "$tmp" /tmp/nemoclaw-switch-payload.json', + 'printf "\\n__NEMOCLAW_HTTP_STATUS__=%s\\n" "${code:-000}"', + 'exit "$rc"', + ].join("\n"); + + let lastFailure = "not attempted"; + let transient = false; + for (let attempt = 1; attempt <= 3; attempt += 1) { + transient = false; + const result = await sandboxShell(sandbox, home, script, { + artifactName: `sandbox-inference-local-after-switch-${attempt}`, + timeoutMs: INFERENCE_TIMEOUT_MS, + }); + const response = resultText(result); + const httpCode = httpStatusFromResponse(response) || "000"; + const body = httpBodyFromResponse(response); + + if (result.exitCode !== 0) { + transient = result.exitCode === 28; + lastFailure = `curl failed with exit ${result.exitCode}; HTTP ${httpCode}: ${body.slice(0, 300)}`; + } else if (isTransientLiveHttpCode(httpCode)) { + transient = true; + lastFailure = `transient HTTP ${httpCode}: ${body.slice(0, 300)}`; + } else if (httpCode !== "200") { + lastFailure = `HTTP ${httpCode}: ${body.slice(0, 300)}`; + } else { + const content = + SWITCH_INFERENCE_API === "anthropic-messages" + ? parseAnthropicContent(body) + : parseChatContent(body); + if (/\bPONG\b/i.test(content)) return "ok"; + lastFailure = `expected PONG, got ${content.slice(0, 300)}`; + } + + if (attempt < 3) await sleep(5_000); + } + + if (transient) { + return { + skipped: `Sandbox inference.local transient failure after switch; route/config checks already passed: ${lastFailure}`, + }; + } + throw new Error(`Sandbox inference.local did not work after switch: ${lastFailure}`); +} + +function collectOpenClawAgentText(value: unknown, parts: string[], visited: Set): void { + if (value === null || value === undefined || visited.has(value)) return; + if (typeof value === "string") { + if (value.trim()) parts.push(value.trim()); + return; + } + if (typeof value !== "object") return; + visited.add(value); + if (Array.isArray(value)) { + for (const item of value) collectOpenClawAgentText(item, parts, visited); + return; + } + + const record = value as Record; + for (const key of ["text", "content", "reasoning_content"]) { + const text = record[key]; + if (typeof text === "string" && text.trim()) parts.push(text.trim()); + } + const choices = record.choices; + if (Array.isArray(choices)) { + for (const choice of choices) { + if (!choice || typeof choice !== "object") continue; + const choiceRecord = choice as Record; + collectOpenClawAgentText(choiceRecord.message, parts, visited); + collectOpenClawAgentText(choiceRecord.delta, parts, visited); + const text = choiceRecord.text; + if (typeof text === "string" && text.trim()) parts.push(text.trim()); + } + } + for (const key of [ + "result", + "payloads", + "payload", + "messages", + "response", + "data", + "output", + "outputs", + "items", + "segments", + "delta", + ]) { + if (Object.hasOwn(record, key)) collectOpenClawAgentText(record[key], parts, visited); + } +} + +function parseOpenClawAgentText(raw: string): string { + if (!raw.trim()) return ""; + const parts: string[] = []; + try { + const doc = JSON.parse(raw) as unknown; + const root = + doc && typeof doc === "object" && "result" in doc + ? (doc as { result?: unknown }).result + : doc; + collectOpenClawAgentText(root, parts, new Set()); + } catch { + const decoder = new RegExp("{", "g"); + let match: RegExpExecArray | null; + while ((match = decoder.exec(raw)) !== null) { + try { + const doc = JSON.parse(raw.slice(match.index)) as unknown; + const before = parts.length; + const root = + doc && typeof doc === "object" && "result" in doc + ? (doc as { result?: unknown }).result + : doc; + collectOpenClawAgentText(root, parts, new Set()); + if (parts.length > before) break; + } catch { + // Try the next JSON-looking offset; wrappers sometimes print chatter + // before the actual OpenClaw JSON envelope. + } + } + } + return parts.join("\n"); +} + +async function checkOpenClawAgentTurn( + host: HostCliClient, + home: string, +): Promise<"ok" | { skipped: string }> { + const sessionId = `e2e-inference-switch-openclaw-${Date.now()}-${process.pid}`; + const script = String.raw` +set -u +ssh_config="$(mktemp)" +stderr_file="$(mktemp)" +cleanup() { rm -f "$ssh_config" "$stderr_file"; } +trap cleanup EXIT +openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config" 2>/dev/null || exit 70 +run_with_timeout() { + if command -v timeout >/dev/null 2>&1; then + timeout "$@" + elif command -v gtimeout >/dev/null 2>&1; then + gtimeout "$@" + else + shift + "$@" + fi +} +set +e +run_with_timeout 120s ssh -F "$ssh_config" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=10 \ + -o LogLevel=ERROR \ + "openshell-${SANDBOX_NAME}" \ + "openclaw agent --agent main --json --session-id '$SESSION_ID' -m 'Reply with exactly one word: PONG'" \ + 2>"$stderr_file" +rc=$? +set -e +printf '\n__NEMOCLAW_AGENT_STDERR__\n' +cat "$stderr_file" 2>/dev/null || true +exit "$rc" +`; + const result = await host.command("bash", ["-lc", script], { + artifactName: "openclaw-agent-turn-after-inference-switch", + env: commandEnv(home, { + SANDBOX_NAME, + SESSION_ID: sessionId, + }), + timeoutMs: AGENT_TIMEOUT_MS, + }); + const [raw = "", warnings = ""] = result.stdout.split("\n__NEMOCLAW_AGENT_STDERR__\n", 2); + const reply = parseOpenClawAgentText(raw); + if (result.exitCode === 0 && /\bPONG\b/i.test(reply)) return "ok"; + if (result.exitCode === 124) { + return { + skipped: "OpenClaw agent turn timed out after switch; route/config checks already passed", + }; + } + throw new Error( + [ + `OpenClaw agent turn failed after switch (exit ${result.exitCode})`, + `reply=${reply.slice(0, 200)}`, + `raw=${raw.slice(0, 200)}`, + `stderr=${[warnings, result.stderr].filter(Boolean).join("\n").slice(0, 200)}`, + ].join("; "), + ); +} + +function isTransientInferenceSetFailure(text: string): boolean { + return /timed? out|timeout|ETIMEDOUT|ECONNRESET|EAI_AGAIN|ENOTFOUND|failed to connect|error sending request|failed to verify inference endpoint|502|503|504|temporar/i.test( + text, + ); +} + +async function runInferenceSetWithRetry( + host: HostCliClient, + home: string, + redactionValues: string[], +): Promise { + const attempts = parsePositiveIntEnv("NEMOCLAW_SWITCH_SET_ATTEMPTS", 3); + const args = [ + "inference", + "set", + "--provider", + SWITCH_PROVIDER, + "--model", + SWITCH_MODEL, + "--sandbox", + SANDBOX_NAME, + ]; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const result = await runNemoclaw(host, home, args, { + artifactName: `nemoclaw-inference-set-${attempt}`, + redactionValues, + timeoutMs: COMMAND_TIMEOUT_MS, + }); + if (result.exitCode === 0) return result; + + const output = resultText(result); + const transient = isTransientInferenceSetFailure(output); + if (!transient) return result; + if (attempt < attempts) { + await sleep(attempt * 5_000); + continue; + } + + return runNemoclaw(host, home, [...args, "--no-verify"], { + artifactName: "nemoclaw-inference-set-no-verify-after-transient-failures", + redactionValues, + timeoutMs: COMMAND_TIMEOUT_MS, + }); + } + throw new Error("Inference switch retry loop completed without running an attempt."); +} + +RUN_OPENCLAW_INFERENCE_SWITCH_TEST( + "openclaw-inference-switch: switches route and preserves live OpenClaw behavior", + { timeout: TEST_TIMEOUT_MS }, + async ({ artifacts, cleanup, host, sandbox, secrets, skip }) => { + await artifacts.writeJson("scenario.json", { + id: "openclaw-inference-switch", + runner: "vitest", + boundary: "install-sh-openclaw-inference-set-and-live-agent-turn", + legacySource: "test/e2e/test-openclaw-inference-switch.sh", + sandboxName: SANDBOX_NAME, + switchProvider: SWITCH_PROVIDER, + switchModel: SWITCH_MODEL, + switchInferenceApi: SWITCH_INFERENCE_API, + contracts: [ + "Docker is running and NVIDIA_API_KEY is nvapi-prefixed", + "install.sh --non-interactive onboards an OpenClaw sandbox", + "nemoclaw inference set switches the running sandbox route", + "OpenClaw gateway process stays running across the switch when its PID is observable", + "OpenShell route points at the switched provider/model", + "OpenClaw config and .config-hash reflect the switched inference API/model", + "registry and onboard session record the switched provider/model", + "sandbox inference.local returns PONG after the switch", + "openclaw agent answers through the switched inference route", + ], + }); + + expect( + fs.existsSync(CLI_ENTRYPOINT), + "run `npm run build:cli` before live repo CLI scenarios", + ).toBe(true); + + const docker = await host.command("docker", ["info"], { + artifactName: "prereq-docker-info-openclaw-inference-switch", + env: buildAvailabilityProbeEnv(), + timeoutMs: 30_000, + }); + if (docker.exitCode !== 0) { + if (process.env.GITHUB_ACTIONS === "true") { + throw new Error( + `Docker is required for OpenClaw inference switch E2E: ${resultText(docker)}`, + ); + } + skip("Docker is required for OpenClaw inference switch E2E"); + } + + const apiKey = secrets.required("NVIDIA_API_KEY"); + expect(apiKey.startsWith("nvapi-"), "NVIDIA_API_KEY must start with nvapi-").toBe(true); + + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-switch-home-")); + let mockProvider: MockAnthropicProvider | undefined; + cleanup.add(`destroy OpenClaw inference switch sandbox ${SANDBOX_NAME}`, async () => { + await cleanupOpenClawInferenceSwitchState(host, sandbox, home, "cleanup"); + if (mockProvider) await mockProvider.close(); + fs.rmSync(home, { recursive: true, force: true }); + }); + + await cleanupOpenClawInferenceSwitchState(host, sandbox, home, "pre-cleanup"); + + const install = await host.command( + "bash", + ["install.sh", "--non-interactive", "--yes-i-accept-third-party-software"], + { + artifactName: "install-and-onboard-openclaw-inference-switch", + cwd: REPO_ROOT, + env: commandEnv(home, { + NVIDIA_API_KEY: apiKey, + NEMOCLAW_RECREATE_SANDBOX: "1", + }), + redactionValues: [apiKey], + timeoutMs: INSTALL_TIMEOUT_MS, + }, + ); + expect(install.exitCode, resultText(install)).toBe(0); + + if (SWITCH_PROVIDER === "compatible-anthropic-endpoint" && SWITCH_MOCK_ANTHROPIC === "1") { + mockProvider = await startMockAnthropicProvider(); + await artifacts.writeJson("mock-anthropic-provider.json", { + endpointUrl: mockProvider.endpointUrl, + }); + } + await ensureCompatibleAnthropicSwitchProvider(host, home, mockProvider); + + const pidBefore = await openclawGatewayPid(sandbox, home); + const switchResult = await runInferenceSetWithRetry(host, home, [apiKey]); + expect(switchResult.exitCode, resultText(switchResult)).toBe(0); + + const pidAfter = await openclawGatewayPid(sandbox, home); + const gatewayPidStable = pidBefore && pidAfter ? pidBefore === pidAfter : null; + if (gatewayPidStable !== null) { + expect( + gatewayPidStable, + `OpenClaw gateway process changed (${pidBefore} -> ${pidAfter})`, + ).toBe(true); + } + + await assertOpenShellRoute(host, home); + await assertOpenClawConfig(sandbox, home); + await assertRegistryAndSession(home); + + const inference = await checkSandboxInference(sandbox, home); + if (inference !== "ok") { + await artifacts.writeJson("scenario-result.json", { + id: "openclaw-inference-switch", + status: "skipped", + reason: inference.skipped, + routeAndConfigChecksPassed: true, + }); + skip(inference.skipped); + } + + const agentTurn = await checkOpenClawAgentTurn(host, home); + if (agentTurn !== "ok") { + await artifacts.writeJson("scenario-result.json", { + id: "openclaw-inference-switch", + status: "skipped", + reason: agentTurn.skipped, + routeConfigAndInferenceChecksPassed: true, + }); + skip(agentTurn.skipped); + } + + if (process.env.NEMOCLAW_E2E_KEEP_SANDBOX !== "1") { + await cleanupOpenClawInferenceSwitchState(host, sandbox, home, "final"); + const registryPath = path.join(home, ".nemoclaw", "sandboxes.json"); + const registryText = fs.existsSync(registryPath) ? fs.readFileSync(registryPath, "utf8") : ""; + expect(registryText).not.toContain(`"${SANDBOX_NAME}"`); + } + + await artifacts.writeJson("scenario-result.json", { + id: "openclaw-inference-switch", + status: "passed", + assertions: { + dockerRunning: docker.exitCode === 0, + installCompleted: install.exitCode === 0, + inferenceSetCompleted: switchResult.exitCode === 0, + gatewayPidStable, + routeChecked: true, + configChecked: true, + registryAndSessionChecked: true, + inferenceLocalPong: true, + openClawAgentPong: true, + }, + }); + }, +); diff --git a/test/e2e-scenario/support-tests/e2e-scenarios-workflow.test.ts b/test/e2e-scenario/support-tests/e2e-scenarios-workflow.test.ts index 354712ecde2..dfee90c3faa 100644 --- a/test/e2e-scenario/support-tests/e2e-scenarios-workflow.test.ts +++ b/test/e2e-scenario/support-tests/e2e-scenarios-workflow.test.ts @@ -255,6 +255,26 @@ describe("e2e-vitest-scenarios workflow boundary", () => { selectedFreeStandingJobs: ["model-router-provider-routed-inference-vitest"], registryScenarios: [], }); + expect( + evaluateE2eVitestWorkflowDispatchSelectors({ + scenarios: "openclaw-inference-switch", + }), + ).toMatchObject({ + valid: true, + liveScenariosRuns: false, + selectedFreeStandingJobs: ["openclaw-inference-switch-vitest"], + registryScenarios: [], + }); + expect( + evaluateE2eVitestWorkflowDispatchSelectors({ + jobs: "openclaw-inference-switch-vitest", + }), + ).toMatchObject({ + valid: true, + liveScenariosRuns: false, + selectedFreeStandingJobs: ["openclaw-inference-switch-vitest"], + registryScenarios: [], + }); }); it("keeps the free-standing inventory internally consistent and data-only", () => { @@ -376,7 +396,7 @@ describe("e2e-vitest-scenarios workflow boundary", () => { registryScenarios: [], }); } - }); + }, 15_000); it("flags direct dispatch-input interpolation and unsafe artifact upload", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-vitest-workflow-")); diff --git a/tools/e2e-scenarios/free-standing-jobs.env b/tools/e2e-scenarios/free-standing-jobs.env index 68ce23e36df..2e9376c6105 100644 --- a/tools/e2e-scenarios/free-standing-jobs.env +++ b/tools/e2e-scenarios/free-standing-jobs.env @@ -1,5 +1,5 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -allowed_jobs=openshell-version-pin-vitest,onboard-negative-paths-vitest,skill-agent-vitest,inference-routing-vitest,credential-migration-vitest,runtime-overrides-vitest,hermes-e2e-vitest,hermes-root-entrypoint-smoke-vitest,network-policy-vitest,shields-config-vitest,rebuild-openclaw-vitest,sandbox-rebuild-vitest,token-rotation-vitest,launchable-smoke-vitest,openclaw-tui-chat-correlation-vitest,gateway-guard-recovery,double-onboard-vitest,issue-4434-tui-unreachable-inference-vitest,model-router-provider-routed-inference-vitest,credential-sanitization-vitest,sandbox-survival-vitest -free_standing_scenarios_csv=openshell-version-pin,onboard-negative-paths,skill-agent,inference-routing,runtime-overrides,hermes-e2e,hermes-root-entrypoint-smoke,network-policy,shields-config,rebuild-openclaw,sandbox-rebuild,token-rotation,openclaw-tui-chat-correlation,double-onboard,issue-4434-tui-unreachable-inference,model-router-provider-routed-inference,credential-sanitization,sandbox-survival -free_standing_scenario_jobs_csv=openshell-version-pin:openshell-version-pin-vitest,onboard-negative-paths:onboard-negative-paths-vitest,skill-agent:skill-agent-vitest,inference-routing:inference-routing-vitest,runtime-overrides:runtime-overrides-vitest,hermes-e2e:hermes-e2e-vitest,hermes-root-entrypoint-smoke:hermes-root-entrypoint-smoke-vitest,network-policy:network-policy-vitest,shields-config:shields-config-vitest,rebuild-openclaw:rebuild-openclaw-vitest,sandbox-rebuild:sandbox-rebuild-vitest,token-rotation:token-rotation-vitest,openclaw-tui-chat-correlation:openclaw-tui-chat-correlation-vitest,double-onboard:double-onboard-vitest,issue-4434-tui-unreachable-inference:issue-4434-tui-unreachable-inference-vitest,model-router-provider-routed-inference:model-router-provider-routed-inference-vitest,credential-sanitization:credential-sanitization-vitest,sandbox-survival:sandbox-survival-vitest +allowed_jobs=openshell-version-pin-vitest,onboard-negative-paths-vitest,skill-agent-vitest,inference-routing-vitest,credential-migration-vitest,runtime-overrides-vitest,hermes-e2e-vitest,hermes-root-entrypoint-smoke-vitest,network-policy-vitest,shields-config-vitest,rebuild-openclaw-vitest,sandbox-rebuild-vitest,token-rotation-vitest,launchable-smoke-vitest,openclaw-tui-chat-correlation-vitest,openclaw-inference-switch-vitest,gateway-guard-recovery,double-onboard-vitest,issue-4434-tui-unreachable-inference-vitest,model-router-provider-routed-inference-vitest,credential-sanitization-vitest,sandbox-survival-vitest +free_standing_scenarios_csv=openshell-version-pin,onboard-negative-paths,skill-agent,inference-routing,runtime-overrides,hermes-e2e,hermes-root-entrypoint-smoke,network-policy,shields-config,rebuild-openclaw,sandbox-rebuild,token-rotation,openclaw-tui-chat-correlation,openclaw-inference-switch,double-onboard,issue-4434-tui-unreachable-inference,model-router-provider-routed-inference,credential-sanitization,sandbox-survival +free_standing_scenario_jobs_csv=openshell-version-pin:openshell-version-pin-vitest,onboard-negative-paths:onboard-negative-paths-vitest,skill-agent:skill-agent-vitest,inference-routing:inference-routing-vitest,runtime-overrides:runtime-overrides-vitest,hermes-e2e:hermes-e2e-vitest,hermes-root-entrypoint-smoke:hermes-root-entrypoint-smoke-vitest,network-policy:network-policy-vitest,shields-config:shields-config-vitest,rebuild-openclaw:rebuild-openclaw-vitest,sandbox-rebuild:sandbox-rebuild-vitest,token-rotation:token-rotation-vitest,openclaw-tui-chat-correlation:openclaw-tui-chat-correlation-vitest,openclaw-inference-switch:openclaw-inference-switch-vitest,double-onboard:double-onboard-vitest,issue-4434-tui-unreachable-inference:issue-4434-tui-unreachable-inference-vitest,model-router-provider-routed-inference:model-router-provider-routed-inference-vitest,credential-sanitization:credential-sanitization-vitest,sandbox-survival:sandbox-survival-vitest From 7809f5bc2812eb125345a36018b666c56e01f0ce Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 12 Jun 2026 12:21:10 -0700 Subject: [PATCH 2/4] test(e2e): harden inference switch live scenario Signed-off-by: Carlos Villela --- .../live/openclaw-inference-switch.test.ts | 40 +++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/test/e2e-scenario/live/openclaw-inference-switch.test.ts b/test/e2e-scenario/live/openclaw-inference-switch.test.ts index a101ad93c86..302eaafc76d 100644 --- a/test/e2e-scenario/live/openclaw-inference-switch.test.ts +++ b/test/e2e-scenario/live/openclaw-inference-switch.test.ts @@ -164,18 +164,27 @@ async function runNemoclaw( }); } +function singleLineSandboxShellScript(script: string): string { + if (!/[\r\n]/.test(script)) return script; + return `printf '%s' ${shellQuote(Buffer.from(script, "utf8").toString("base64"))} | base64 -d | sh`; +} + async function sandboxShell( sandbox: SandboxClient, home: string, script: string, options: { artifactName: string; timeoutMs?: number; redactionValues?: string[] }, ): Promise { - return sandbox.execShell(SANDBOX_NAME, trustedSandboxShellScript(script), { - artifactName: options.artifactName, - env: commandEnv(home), - timeoutMs: options.timeoutMs ?? COMMAND_TIMEOUT_MS, - redactionValues: options.redactionValues, - }); + return sandbox.execShell( + SANDBOX_NAME, + trustedSandboxShellScript(singleLineSandboxShellScript(script)), + { + artifactName: options.artifactName, + env: commandEnv(home), + timeoutMs: options.timeoutMs ?? COMMAND_TIMEOUT_MS, + redactionValues: options.redactionValues, + }, + ); } async function cleanupOpenClawInferenceSwitchState( @@ -731,6 +740,13 @@ function isTransientInferenceSetFailure(text: string): boolean { ); } +function isExternalProviderValidationFailure(text: string): boolean { + return ( + /NVIDIA Endpoints endpoint validation failed/i.test(text) && + /HTTP 429|rate limit|quota|temporarily unavailable|timed out|timeout/i.test(text) + ); +} + async function runInferenceSetWithRetry( host: HostCliClient, home: string, @@ -845,7 +861,17 @@ RUN_OPENCLAW_INFERENCE_SWITCH_TEST( timeoutMs: INSTALL_TIMEOUT_MS, }, ); - expect(install.exitCode, resultText(install)).toBe(0); + const installText = resultText(install); + if (install.exitCode !== 0 && isExternalProviderValidationFailure(installText)) { + await artifacts.writeJson("scenario-result.json", { + id: "openclaw-inference-switch", + status: "skipped", + reason: "external-provider-validation-unavailable-before-inference-switch", + installExitCode: install.exitCode, + }); + skip("NVIDIA endpoint validation was unavailable/rate-limited during onboarding"); + } + expect(install.exitCode, installText).toBe(0); if (SWITCH_PROVIDER === "compatible-anthropic-endpoint" && SWITCH_MOCK_ANTHROPIC === "1") { mockProvider = await startMockAnthropicProvider(); From abe968d63f4a06a4479634f85977ba981a67d4e6 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 12 Jun 2026 12:37:17 -0700 Subject: [PATCH 3/4] test(e2e): remove unused inference switch log constant Signed-off-by: Carlos Villela --- test/e2e-scenario/live/openclaw-inference-switch.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/e2e-scenario/live/openclaw-inference-switch.test.ts b/test/e2e-scenario/live/openclaw-inference-switch.test.ts index 302eaafc76d..b594a40657a 100644 --- a/test/e2e-scenario/live/openclaw-inference-switch.test.ts +++ b/test/e2e-scenario/live/openclaw-inference-switch.test.ts @@ -31,7 +31,6 @@ import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); const CLI_ENTRYPOINT = path.join(REPO_ROOT, "bin", "nemoclaw.js"); -const INSTALL_LOG = "/tmp/nemoclaw-e2e-openclaw-inference-switch-install.log"; const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-openclaw-inference-switch"; const SWITCH_PROVIDER = process.env.NEMOCLAW_SWITCH_PROVIDER ?? "nvidia-prod"; const SWITCH_MODEL = process.env.NEMOCLAW_SWITCH_MODEL ?? "z-ai/glm-5.1"; From 9bf744963d6c5f5af0c16d62d4e1daaff7c777c9 Mon Sep 17 00:00:00 2001 From: Carlos Villela Date: Fri, 12 Jun 2026 15:13:35 -0700 Subject: [PATCH 4/4] test(e2e): isolate inference switch live defaults Signed-off-by: Carlos Villela --- .../live/openclaw-inference-switch.test.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/test/e2e-scenario/live/openclaw-inference-switch.test.ts b/test/e2e-scenario/live/openclaw-inference-switch.test.ts index b594a40657a..d834c357ee1 100644 --- a/test/e2e-scenario/live/openclaw-inference-switch.test.ts +++ b/test/e2e-scenario/live/openclaw-inference-switch.test.ts @@ -31,12 +31,13 @@ import type { ShellProbeResult } from "../fixtures/shell-probe.ts"; const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); const CLI_ENTRYPOINT = path.join(REPO_ROOT, "bin", "nemoclaw.js"); -const SANDBOX_NAME = process.env.NEMOCLAW_SANDBOX_NAME ?? "e2e-openclaw-inference-switch"; +const SANDBOX_NAME = + process.env.NEMOCLAW_SANDBOX_NAME ?? uniqueSandboxName("e2e-openclaw-inference-switch"); const SWITCH_PROVIDER = process.env.NEMOCLAW_SWITCH_PROVIDER ?? "nvidia-prod"; const SWITCH_MODEL = process.env.NEMOCLAW_SWITCH_MODEL ?? "z-ai/glm-5.1"; const SWITCH_INFERENCE_API = process.env.NEMOCLAW_SWITCH_INFERENCE_API ?? "openai-completions"; const SWITCH_MOCK_ANTHROPIC = process.env.NEMOCLAW_SWITCH_MOCK_ANTHROPIC ?? "0"; -const SWITCH_MOCK_PORT = Number(process.env.NEMOCLAW_SWITCH_MOCK_PORT ?? 18767); +const SWITCH_MOCK_PORT = parsePortEnv("NEMOCLAW_SWITCH_MOCK_PORT", 0); const TEST_TIMEOUT_MS = 75 * 60_000; const INSTALL_TIMEOUT_MS = 30 * 60_000; const COMMAND_TIMEOUT_MS = 120_000; @@ -112,6 +113,20 @@ function shellQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'`; } +function uniqueSandboxName(prefix: string): string { + return `${prefix}-${process.pid}-${Date.now()}`; +} + +function parsePortEnv(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const parsed = Number(raw); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65_535) { + throw new Error(`${name} must be an integer between 0 and 65535; got ${raw}`); + } + return parsed; +} + function parsePositiveIntEnv(name: string, fallback: number): number { const raw = process.env[name]; if (!raw) return fallback;