diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 1812f62438..367914d319 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -619,6 +619,33 @@ reviews: gh workflow run nightly-e2e.yaml --ref -f jobs=cloud-inference-e2e ``` + - path: "test/e2e/test-cron-preflight-inference-local-e2e.sh" + instructions: | + This script onboards a sandbox against the managed cloud + inference provider, then loads OpenClaw's cron isolated-agent + preflight runtime directly from the in-sandbox dist and calls + `preflightCronModelProvider` against the onboarded + `inference.local` provider/model, asserting the result reports + `status: "available"` and never `EAI_AGAIN` or the + "local provider endpoint is not reachable" message. + + Exercises the Dockerfile fetch-guard patch that opts the cron + model-provider preflight into trusted env-proxy mode so the + managed inference hostname (`inference.local`) routes through + the OpenShell L7 proxy instead of failing pinned DNS lookup. + The scheduler boundary is intentionally bypassed: the cron CLI + surfaces require `operator.admin` scope, which the in-sandbox + auto-pair approval sweep deliberately omits. + + **E2E test recommendation:** + - `cron-preflight-inference-local-e2e` — direct cron preflight + runtime probe via OpenShell L7 proxy + + To run selectively: + ``` + gh workflow run nightly-e2e.yaml --ref -f jobs=cron-preflight-inference-local-e2e + ``` + - path: "test/e2e/test-openclaw-inference-switch.sh" instructions: | This script validates OpenClaw model/provider switching with diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index 66615e41e6..7ce45c6c8f 100644 --- a/.github/workflows/nightly-e2e.yaml +++ b/.github/workflows/nightly-e2e.yaml @@ -119,6 +119,7 @@ on: description: >- Comma-separated job names to run (empty = all). Valid: cloud-e2e, cloud-onboard-e2e, cloud-inference-e2e, + cron-preflight-inference-local-e2e, agent-turn-latency-e2e, skill-agent-e2e, openclaw-skill-cli-e2e, docs-validation-e2e, messaging-providers-e2e, openclaw-slack-pairing-e2e, openclaw-tui-chat-correlation-e2e, issue-4434-tui-unreachable-inference-e2e, @@ -239,6 +240,21 @@ jobs: env_json: '{"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE":"1","NEMOCLAW_NON_INTERACTIVE":"1","NEMOCLAW_RECREATE_SANDBOX":"1","NEMOCLAW_SANDBOX_NAME":"e2e-cloud-inference"}' nvidia_api_key: true secrets: *nightly-e2e-default-secrets + cron-preflight-inference-local-e2e: + if: >- + github.repository == 'NVIDIA/NemoClaw' && (github.event_name != 'workflow_dispatch' || + inputs.jobs == '' || + contains(format(',{0},', inputs.jobs), ',cron-preflight-inference-local-e2e,')) + uses: ./.github/workflows/e2e-script.yaml + with: + ref: ${{ inputs.target_ref || github.ref }} + script: test/e2e/test-cron-preflight-inference-local-e2e.sh + timeout_minutes: 30 + artifact_name: "install-log-cron-preflight-inference-local" + artifact_path: "/tmp/nemoclaw-e2e-cron-preflight-install.log" + env_json: '{"NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE":"1","NEMOCLAW_NON_INTERACTIVE":"1","NEMOCLAW_RECREATE_SANDBOX":"1","NEMOCLAW_SANDBOX_NAME":"e2e-cron-preflight"}' + nvidia_api_key: true + secrets: *nightly-e2e-default-secrets agent-turn-latency-e2e: if: >- github.repository == 'NVIDIA/NemoClaw' && (github.event_name != 'workflow_dispatch' || @@ -2229,6 +2245,7 @@ jobs: cloud-e2e, cloud-onboard-e2e, cloud-inference-e2e, + cron-preflight-inference-local-e2e, agent-turn-latency-e2e, skill-agent-e2e, openclaw-skill-cli-e2e, @@ -2346,6 +2363,7 @@ jobs: cloud-e2e, cloud-onboard-e2e, cloud-inference-e2e, + cron-preflight-inference-local-e2e, agent-turn-latency-e2e, skill-agent-e2e, openclaw-skill-cli-e2e, @@ -2522,6 +2540,7 @@ jobs: cloud-e2e, cloud-onboard-e2e, cloud-inference-e2e, + cron-preflight-inference-local-e2e, agent-turn-latency-e2e, skill-agent-e2e, openclaw-skill-cli-e2e, diff --git a/Dockerfile b/Dockerfile index d7646fb1c9..a4053fa806 100644 --- a/Dockerfile +++ b/Dockerfile @@ -382,6 +382,71 @@ RUN set -eu; \ patch_fail "Patch 4 cannot safely skip"; \ fi; \ fi; \ + # --- Patch 6: cron model-provider preflight opts into trusted env-proxy mode --- \ + # Reviewed against openclaw@2026.5.27 dist: the cron isolated-agent preflight \ + # (`probeLocalProviderEndpoint`) calls `fetchWithSsrFGuard` with \ + # `auditContext: "cron-model-provider-preflight"` and a narrow hostname-allowlist \ + # SsrFPolicy from `buildLocalProviderSsrFPolicy`, but does not pass a `mode`. \ + # Default STRICT mode pins DNS for the managed inference hostname \ + # (`inference.local`), which is intentionally only resolvable through the \ + # OpenShell L7 proxy — pinned `dns.lookup` therefore fails with EAI_AGAIN and \ + # the scheduler permanently skips every cron run. Inject \ + # `mode: "trusted_env_proxy"` so the call uses the env proxy dispatcher; SSRF \ + # protection is retained through the existing hostname allowlist and the \ + # proxy's own ACLs. \ + # \ + # The patch keys on the co-located shape of the reviewed preflight call: in \ + # any file that mentions the audit context literal, both the \ + # `fetchWithSsrFGuard(` helper and the `buildLocalProviderSsrFPolicy` policy \ + # builder must appear; the audit literal itself must appear exactly once; and \ + # after patching exactly one patched literal must remain. Any ambiguous \ + # multi-callsite or mixed patched/unpatched layout fails the image build \ + # rather than silently widening the rewrite. \ + # \ + # Removal condition: drop this block (and any related `OC_VERSION` floor bump) \ + # once an OpenClaw release sets `mode: "trusted_env_proxy"` directly at the \ + # preflight call site or otherwise routes the managed inference base URL \ + # through the env-proxy dispatcher by default. The reviewed shape lives at \ + # `src/cron/isolated-agent/model-preflight.runtime.ts` in the openclaw repo. \ + preflight_files="$(grep -RIlF --include='*.js' 'cron-model-provider-preflight' "$OC_DIST" || true)"; \ + if [ -n "$preflight_files" ]; then \ + patched_preflight=0; \ + for f in $preflight_files; do \ + audit_count="$(grep -Fc 'auditContext: "cron-model-provider-preflight"' "$f" || true)"; \ + [ "${audit_count:-0}" -ge 1 ] \ + || patch_fail "Patch 6 shape gate: $f mentions cron-model-provider-preflight but has no auditContext literal"; \ + [ "${audit_count:-0}" -eq 1 ] \ + || patch_fail "Patch 6 shape gate: $f has ${audit_count} auditContext literals (expected exactly 1); refusing ambiguous multi-callsite rewrite"; \ + grep -Fq 'fetchWithSsrFGuard(' "$f" \ + || patch_fail "Patch 6 shape gate: $f has cron-model-provider-preflight but no fetchWithSsrFGuard call"; \ + grep -Fq 'buildLocalProviderSsrFPolicy' "$f" \ + || patch_fail "Patch 6 shape gate: $f has cron-model-provider-preflight but no buildLocalProviderSsrFPolicy"; \ + patched_count="$(grep -Fc 'mode: "trusted_env_proxy", auditContext: "cron-model-provider-preflight"' "$f" || true)"; \ + if [ "${patched_count:-0}" -eq 1 ]; then \ + echo "INFO: Patch 6 already present in $f"; \ + elif [ "${patched_count:-0}" -eq 0 ]; then \ + sed -i -E 's|auditContext: "cron-model-provider-preflight"|mode: "trusted_env_proxy", auditContext: "cron-model-provider-preflight"|g' "$f"; \ + new_patched_count="$(grep -Fc 'mode: "trusted_env_proxy", auditContext: "cron-model-provider-preflight"' "$f" || true)"; \ + [ "${new_patched_count:-0}" -eq 1 ] \ + || patch_fail "Patch 6 verification: expected exactly one patched literal in $f, found ${new_patched_count}"; \ + patched_preflight=1; \ + else \ + patch_fail "Patch 6 shape gate: $f has ${patched_count} already-patched literals (expected 0 or 1); refusing mixed-state rewrite"; \ + fi; \ + done; \ + if [ "$patched_preflight" = "1" ]; then \ + echo "INFO: Patch 6 applied to OpenClaw ${OC_VERSION} cron preflight trusted env-proxy"; \ + fi; \ + else \ + preflight_refs="$(grep -RIlE --include='*.js' 'preflightCronModelProvider|probeLocalProviderEndpoint' "$OC_DIST" || true)"; \ + if [ -z "$preflight_refs" ]; then \ + echo "INFO: OpenClaw ${OC_VERSION} has no cron model-provider preflight; Patch 6 not needed"; \ + else \ + echo "ERROR: Patch 6 target missing but cron preflight references remain:" >&2; \ + printf '%s\n' "$preflight_refs" | head -n 5 >&2; \ + patch_fail "Patch 6 cannot safely skip"; \ + fi; \ + fi; \ # --- Patch 3: follow symlinks in plugin-install path checks (#2203) --- \ # OpenClaw's install-safe-path and install-package-dir reject symlinked \ # directories via lstat. Changing lstat → stat in these two modules lets \ diff --git a/test/e2e-scenario/migration/legacy-inventory.json b/test/e2e-scenario/migration/legacy-inventory.json index e66981d726..68ac006c8a 100644 --- a/test/e2e-scenario/migration/legacy-inventory.json +++ b/test/e2e-scenario/migration/legacy-inventory.json @@ -200,6 +200,17 @@ "deletionReady": false, "notes": "Initial completeness row; classify detailed coverage and deletion evidence in the owning migration issue before deleting." }, + { + "legacyScript": "test/e2e/test-cron-preflight-inference-local-e2e.sh", + "domain": "inference", + "ownerIssue": "#4349", + "status": "not-migrated", + "targetVitestScenarios": [], + "bridgeProbes": [], + "retiredReason": "", + "deletionReady": false, + "notes": "Covers OpenClaw cron isolated-agent provider preflight against the managed inference hostname; classify detailed coverage and deletion evidence in the owning migration issue before deleting." + }, { "legacyScript": "test/e2e/test-common-egress-agent-e2e.sh", "domain": "security-policy", diff --git a/test/e2e/test-cron-preflight-inference-local-e2e.sh b/test/e2e/test-cron-preflight-inference-local-e2e.sh new file mode 100755 index 0000000000..21ad66db32 --- /dev/null +++ b/test/e2e/test-cron-preflight-inference-local-e2e.sh @@ -0,0 +1,366 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Cron preflight inference.local E2E. +# +# Onboards a fresh sandbox against the managed cloud provider (whose base URL +# resolves through `inference.local`), then loads OpenClaw's cron isolated-agent +# preflight runtime directly from the in-sandbox dist and invokes +# `preflightCronModelProvider` against the onboarded provider/model. Asserts +# the call returns `status: "available"` and never reports `EAI_AGAIN` or the +# "local provider endpoint is not reachable" message. +# +# This probes the exact runtime path Patch 6 modifies — the cron CLI surfaces +# (`openclaw cron add` / `openclaw cron run`) need `operator.admin` scope, which +# the in-sandbox auto-pair approval sweep deliberately omits from its allowlist, +# so the scheduler boundary is intentionally bypassed in favour of a direct +# runtime probe. +# +# Prerequisites: +# - Docker running +# - NVIDIA_API_KEY set (real key, starts with nvapi-) +# - NEMOCLAW_NON_INTERACTIVE=1, NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 +# +# Environment: +# NEMOCLAW_SANDBOX_NAME — sandbox name (default: e2e-cron-preflight) +# NEMOCLAW_RECREATE_SANDBOX=1 — destroy + recreate if exists +# NEMOCLAW_CRON_PREFLIGHT_MODEL — cloud model (default: nvidia/nemotron-3-super-120b-a12b) +# NEMOCLAW_CRON_PREFLIGHT_KEEP=1 — keep the sandbox after the test for inspection +# +# Usage: +# NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ +# NVIDIA_API_KEY=nvapi-... bash test/e2e/test-cron-preflight-inference-local-e2e.sh + +set -uo pipefail + +PASS=0 +FAIL=0 +SKIP=0 +TOTAL=0 + +pass() { + PASS=$((PASS + 1)) + TOTAL=$((TOTAL + 1)) + printf '\033[32m PASS: %s\033[0m\n' "$1" +} +fail() { + FAIL=$((FAIL + 1)) + TOTAL=$((TOTAL + 1)) + printf '\033[31m FAIL: %s\033[0m\n' "$1" +} +skip() { + SKIP=$((SKIP + 1)) + TOTAL=$((TOTAL + 1)) + printf '\033[33m SKIP: %s\033[0m\n' "$1" +} +section() { + echo "" + printf '\033[1;36m=== %s ===\033[0m\n' "$1" +} +info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } + +# ── Repo root ── +_script_dir="$(cd "$(dirname "$0")" && pwd)" +_candidate="$(cd "${_script_dir}/../.." && pwd)" +if [ -d /workspace ] && [ -f /workspace/package.json ] && [ -d /workspace/test/e2e ]; then + REPO="/workspace" +elif [ -f "${_candidate}/package.json" ] && [ -d "${_candidate}/test/e2e" ]; then + REPO="${_candidate}" +else + echo "ERROR: Cannot find repo root." + exit 1 +fi +unset _script_dir _candidate +cd "$REPO" || { + echo "ERROR: Cannot cd into repo root '$REPO'." + exit 1 +} + +E2E_DIR="${REPO}/test/e2e" +SANDBOX="${NEMOCLAW_SANDBOX_NAME:-e2e-cron-preflight}" +MODEL="${NEMOCLAW_CRON_PREFLIGHT_MODEL:-nvidia/nemotron-3-super-120b-a12b}" +INSTALL_LOG="/tmp/nemoclaw-e2e-cron-preflight-install.log" + +# shellcheck source=test/e2e/lib/sandbox-teardown.sh +. "${E2E_DIR}/lib/sandbox-teardown.sh" +# shellcheck source=test/e2e/lib/install-path-refresh.sh +. "${E2E_DIR}/lib/install-path-refresh.sh" + +# ── Prereqs ── +section "Prerequisites" +if ! command -v docker >/dev/null 2>&1; then + skip "docker not installed" + echo " Total: $TOTAL Pass: $PASS Fail: $FAIL Skip: $SKIP" + exit 0 +fi +if ! command -v jq >/dev/null 2>&1; then + skip "jq not installed" + echo " Total: $TOTAL Pass: $PASS Fail: $FAIL Skip: $SKIP" + exit 0 +fi +if [ -z "${NVIDIA_API_KEY:-}" ]; then + skip "NVIDIA_API_KEY not set" + echo " Total: $TOTAL Pass: $PASS Fail: $FAIL Skip: $SKIP" + exit 0 +fi +if [ "${NVIDIA_API_KEY:0:6}" != "nvapi-" ]; then + skip "NVIDIA_API_KEY does not start with nvapi-" + echo " Total: $TOTAL Pass: $PASS Fail: $FAIL Skip: $SKIP" + exit 0 +fi +if [ "${NEMOCLAW_NON_INTERACTIVE:-}" != "1" ]; then + skip "NEMOCLAW_NON_INTERACTIVE must be 1; refusing to risk an interactive onboard prompt" + echo " Total: $TOTAL Pass: $PASS Fail: $FAIL Skip: $SKIP" + exit 0 +fi +if [ "${NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE:-}" != "1" ]; then + skip "NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE must be 1; refusing to risk an interactive onboard prompt" + echo " Total: $TOTAL Pass: $PASS Fail: $FAIL Skip: $SKIP" + exit 0 +fi +pass "prerequisites satisfied" + +# ── Install NemoClaw + onboard sandbox ── +section "Install NemoClaw + onboard sandbox '$SANDBOX'" +export NEMOCLAW_SANDBOX_NAME="$SANDBOX" +export NEMOCLAW_RECREATE_SANDBOX="${NEMOCLAW_RECREATE_SANDBOX:-1}" +export NEMOCLAW_PROVIDER="${NEMOCLAW_PROVIDER:-build}" +export NEMOCLAW_MODEL="${NEMOCLAW_MODEL:-$MODEL}" + +info "Installing NemoClaw via install.sh --non-interactive..." +bash install.sh --non-interactive --yes-i-accept-third-party-software >"$INSTALL_LOG" 2>&1 & +install_pid=$! +tail -f "$INSTALL_LOG" --pid=$install_pid 2>/dev/null & +tail_pid=$! +wait "$install_pid" +install_exit=$? +kill "$tail_pid" 2>/dev/null || true +wait "$tail_pid" 2>/dev/null || true + +nemoclaw_refresh_install_env +export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" +# shellcheck source=/dev/null +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" +nemoclaw_ensure_local_bin_on_path + +if [ "$install_exit" -ne 0 ]; then + fail "install.sh failed (exit $install_exit)" + tail -30 "$INSTALL_LOG" + exit 1 +fi +pass "NemoClaw installed + sandbox onboarded" + +command -v nemoclaw >/dev/null 2>&1 || { + fail "nemoclaw not on PATH after install" + exit 1 +} + +# Wire the documented `NEMOCLAW_CRON_PREFLIGHT_KEEP` flag through to the shared +# teardown helper (which honours only `NEMOCLAW_E2E_KEEP_SANDBOX`) so the +# documented escape hatch actually preserves the sandbox for inspection. +export NEMOCLAW_E2E_KEEP_SANDBOX="${NEMOCLAW_E2E_KEEP_SANDBOX:-${NEMOCLAW_CRON_PREFLIGHT_KEEP:-}}" +register_sandbox_for_teardown "$SANDBOX" + +# ── Probe the cron preflight directly ── +# +# The cron CLI surfaces (`openclaw cron add` / `openclaw cron run`) require +# `operator.admin` scope, which the in-sandbox auto-pair approval sweep +# deliberately excludes from its allowlist. There is no declarative way for +# an external CLI to call those RPCs without an interactive scope-upgrade +# approval, which is plumbing noise for what this test is actually checking. +# +# Patch 6 only changes the `fetchWithSsrFGuard` call inside +# `probeLocalProviderEndpoint`. Invoke that function directly via a node +# script loaded from the in-sandbox OpenClaw dist instead: the probe asserts +# the same behaviour (managed inference base URL reachable from cron +# preflight) without any gateway, scheduler, or device pairing involvement. +section "Probe cron preflight against managed inference base URL" + +PROBE_SRC=$( + cat <<'PROBE_JS' +const fs = require("node:fs"); +const path = require("node:path"); +const url = require("node:url"); + +const AUDIT_CONTEXT = "cron-model-provider-preflight"; +const EXPORT_NAME = "preflightCronModelProvider"; +const EXPECTED_HOSTNAME = "inference.local"; +const DIST_ROOTS = [ + "/usr/local/lib/node_modules/openclaw/dist", + "/usr/lib/node_modules/openclaw/dist", +]; + +function isExpectedManagedProvider(provider) { + if (!provider || typeof provider.baseUrl !== "string") return false; + try { + return new URL(provider.baseUrl).hostname.toLowerCase() === EXPECTED_HOSTNAME; + } catch { + return false; + } +} + +function findPreflightModule(root) { + const stack = [root]; + while (stack.length > 0) { + const dir = stack.pop(); + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + stack.push(full); + continue; + } + if (!entry.isFile()) continue; + if (!(full.endsWith(".js") || full.endsWith(".mjs") || full.endsWith(".cjs"))) continue; + let body; + try { + body = fs.readFileSync(full, "utf8"); + } catch { + continue; + } + if (body.includes(AUDIT_CONTEXT) && body.includes(EXPORT_NAME)) { + return full; + } + } + } + return null; +} + +(async () => { + let target = null; + const scanned = []; + for (const root of DIST_ROOTS) { + if (!fs.existsSync(root)) continue; + scanned.push(root); + target = findPreflightModule(root); + if (target) break; + } + if (!target) { + console.error(JSON.stringify({ error: "preflight-source-not-found", scanned })); + process.exit(3); + } + + let mod; + try { + mod = await import(url.pathToFileURL(target).href); + } catch (err) { + console.error( + JSON.stringify({ + error: "preflight-import-threw", + target, + message: String(err && err.stack ? err.stack : err), + }), + ); + process.exit(3); + } + const preflightCronModelProvider = mod[EXPORT_NAME]; + if (typeof preflightCronModelProvider !== "function") { + console.error( + JSON.stringify({ + error: "preflight-export-missing", + target, + exports: Object.keys(mod), + }), + ); + process.exit(3); + } + + const configPath = process.env.OPENCLAW_CONFIG_PATH || "/sandbox/.openclaw/openclaw.json"; + let cfg; + try { + cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); + } catch (err) { + console.error( + JSON.stringify({ error: "config-read-failed", configPath, message: String(err) }), + ); + process.exit(3); + } + + const providers = (cfg.models && cfg.models.providers) || {}; + const providerKey = Object.keys(providers).find((key) => + isExpectedManagedProvider(providers[key]), + ); + if (!providerKey) { + console.error( + JSON.stringify({ + error: "no-managed-inference-local-provider", + expectedHost: EXPECTED_HOSTNAME, + providers: Object.entries(providers).map(([key, value]) => ({ + key, + baseUrl: value && typeof value.baseUrl === "string" ? value.baseUrl : null, + })), + }), + ); + process.exit(3); + } + const providerCfg = providers[providerKey]; + const modelKey = + providerCfg.defaultModel || + (Array.isArray(providerCfg.models) ? providerCfg.models[0] : undefined) || + "ping"; + + try { + const result = await preflightCronModelProvider({ + cfg, + provider: providerKey, + model: modelKey, + }); + console.log( + JSON.stringify({ providerKey, modelKey, baseUrl: providerCfg.baseUrl, target, result }), + ); + process.exit(result && result.status === "available" ? 0 : 1); + } catch (err) { + console.error( + JSON.stringify({ + error: "preflight-threw", + message: String(err && err.stack ? err.stack : err), + }), + ); + process.exit(2); + } +})(); +PROBE_JS +) +PROBE_B64="$(printf '%s' "$PROBE_SRC" | base64 -w 0)" + +# openshell sandbox exec rejects any command argument that contains a newline +# or carriage return ("command argument N contains newline or carriage return +# characters"), so the inner `sh -c` payload must be a single physical line. +# Chain with `&&` for success-only steps and `;` for the cleanup tail so the +# probe exit code is preserved end-to-end. +PROBE_SHELL=". /tmp/nemoclaw-proxy-env.sh && __probe=\"\$(mktemp /tmp/nemoclaw-preflight-probe.XXXXXX.cjs)\" && printf %s '$PROBE_B64' | base64 -d > \"\$__probe\" && node \"\$__probe\"; __rc=\$?; rm -f \"\$__probe\"; exit \"\$__rc\"" +PROBE_OUT="$(nemoclaw "$SANDBOX" exec -- sh -c "$PROBE_SHELL" 2>&1)" +PROBE_RC=$? +info "preflight probe output (rc=$PROBE_RC):" +printf '%s\n' "$PROBE_OUT" | sed 's/^/ /' + +# Probe stdout/stderr are interleaved (captured via 2>&1). Pick the structured +# JSON result line (the only line that starts with `{"providerKey"`) before +# parsing, so undici experimental-feature warnings on stderr do not break jq. +PROBE_JSON="$(printf '%s\n' "$PROBE_OUT" | grep -E '^\s*\{"providerKey"' | tail -n 1)" +STATUS="$(printf '%s' "$PROBE_JSON" | jq -r '.result.status // empty' 2>/dev/null || true)" +REASON="$(printf '%s' "$PROBE_JSON" | jq -r '.result.reason // ""' 2>/dev/null || true)" + +section "Assertions" +if [ "$PROBE_RC" -ge 2 ]; then + fail "probe harness failed (rc=$PROBE_RC); preflight did not run" +elif printf '%s' "$REASON" | grep -qi "EAI_AGAIN"; then + fail "preflight raised EAI_AGAIN; reason='$REASON'" +elif printf '%s' "$REASON" | grep -qi "local provider endpoint is not reachable"; then + fail "preflight reported endpoint unreachable; reason='$REASON'" +elif [ "$STATUS" = "available" ]; then + pass "preflight status=available" +else + fail "unexpected probe status='$STATUS' rc=$PROBE_RC reason='$REASON'" +fi + +section "Summary" +echo " Total: $TOTAL Pass: $PASS Fail: $FAIL Skip: $SKIP" +[ "$FAIL" -eq 0 ] || exit 1 +exit 0 diff --git a/test/fetch-guard-patch-regression.test.ts b/test/fetch-guard-patch-regression.test.ts index df1bf51c52..c310df38e3 100644 --- a/test/fetch-guard-patch-regression.test.ts +++ b/test/fetch-guard-patch-regression.test.ts @@ -1210,4 +1210,210 @@ if (!blocked) throw new Error('private IP literal was not blocked');`, fs.rmSync(tmp, { recursive: true, force: true }); } }); + + function reviewedCronPreflightFixture({ + auditOccurrences = 1, + includeFetchWithSsrFGuard = true, + includeBuildLocalProviderSsrFPolicy = true, + patchedOccurrences = 0, + }: { + auditOccurrences?: number; + includeFetchWithSsrFGuard?: boolean; + includeBuildLocalProviderSsrFPolicy?: boolean; + patchedOccurrences?: number; + } = {}): string { + const lines: string[] = [ + "const PREFLIGHT_TIMEOUT_MS = 2500;", + "function buildProbeUrl(api, baseUrl) { return baseUrl + (api === 'ollama' ? '/api/tags' : '/models'); }", + ]; + const policyHelper = includeBuildLocalProviderSsrFPolicy + ? "buildLocalProviderSsrFPolicy" + : "buildDriftedSsrFPolicy"; + if (includeBuildLocalProviderSsrFPolicy) { + lines.push( + "function buildLocalProviderSsrFPolicy(baseUrl) {", + " const parsed = new URL(baseUrl);", + " return { hostnameAllowlist: [parsed.hostname], allowPrivateNetwork: true };", + "}", + ); + } else { + lines.push( + "function buildDriftedSsrFPolicy(baseUrl) {", + " const parsed = new URL(baseUrl);", + " return { hostnameAllowlist: [parsed.hostname] };", + "}", + ); + } + lines.push("async function probeLocalProviderEndpoint(params) {"); + for (let index = 0; index < patchedOccurrences; index += 1) { + lines.push( + ` const ${index === 0 ? "patched" : `patched_${index}`} = await ${ + includeFetchWithSsrFGuard ? "fetchWithSsrFGuard" : "callPatchedFetch" + }({`, + ` url: buildProbeUrl(params.api, params.baseUrl),`, + ` policy: ${policyHelper}(params.baseUrl),`, + ` timeoutMs: PREFLIGHT_TIMEOUT_MS,`, + ` mode: "trusted_env_proxy", auditContext: "cron-model-provider-preflight",`, + " });", + ); + } + for (let index = 0; index < auditOccurrences - patchedOccurrences; index += 1) { + lines.push( + ` const ${index === 0 ? "result" : `result_${index}`} = await ${ + includeFetchWithSsrFGuard ? "fetchWithSsrFGuard" : "callUnpatchedFetch" + }({`, + ` url: buildProbeUrl(params.api, params.baseUrl),`, + ` policy: ${policyHelper}(params.baseUrl),`, + ` timeoutMs: PREFLIGHT_TIMEOUT_MS,`, + ` auditContext: "cron-model-provider-preflight",`, + " });", + ); + } + lines.push( + " return null;", + "}", + "export { probeLocalProviderEndpoint, preflightCronModelProvider };", + "function preflightCronModelProvider() {}", + "", + ); + return lines.join("\n"); + } + + function writeNeighbouringFetchGuardFixtures(dist: string): void { + // Earlier patches in the same RUN block (1, 2, 2b, 4) only need the dist to + // navigate their "not needed" branches; mirror the shape proven by the + // "skips the strict export patch when strict fetch mode is absent" test so + // execution reaches Patch 6 without classifying the dist as unknown. + fs.writeFileSync( + path.join(dist, "media-runtime.js"), + "export { readRemoteMediaBuffer, saveRemoteMedia, fetchRemoteMedia };\n", + ); + fs.writeFileSync( + path.join(dist, "fetch-guard-neighbour.js"), + [ + "const withTrustedEnvProxyGuardedFetchMode = Symbol('trusted');", + "async function fetchGuardedMediaResponse() {", + " return fetchWithSsrFGuard(withTrustedEnvProxyGuardedFetchMode({}));", + "}", + "export { withTrustedEnvProxyGuardedFetchMode as a };", + "", + ].join("\n"), + ); + } + + it("applies Patch 6 to a reviewed single-callsite cron preflight fixture", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-patch6-happy-")); + const dist = path.join(tmp, "dist"); + fs.mkdirSync(dist, { recursive: true }); + writeNeighbouringFetchGuardFixtures(dist); + const preflightPath = path.join(dist, "model-preflight.runtime.js"); + fs.writeFileSync(preflightPath, reviewedCronPreflightFixture()); + try { + const patch = runFetchGuardPatchBlock(dist, tmp); + expect(patch.status, `${patch.stdout}${patch.stderr}`).toBe(0); + expect(patch.stdout).toContain( + "Patch 6 applied to OpenClaw 2026.5.27 cron preflight trusted env-proxy", + ); + const patched = fs.readFileSync(preflightPath, "utf-8"); + expect( + patched.match(/mode: "trusted_env_proxy", auditContext: "cron-model-provider-preflight"/g) + ?.length, + ).toBe(1); + expect(patched).not.toMatch(/(? { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-patch6-idempotent-")); + const dist = path.join(tmp, "dist"); + fs.mkdirSync(dist, { recursive: true }); + writeNeighbouringFetchGuardFixtures(dist); + const preflightPath = path.join(dist, "model-preflight.runtime.js"); + const source = reviewedCronPreflightFixture({ auditOccurrences: 1, patchedOccurrences: 1 }); + fs.writeFileSync(preflightPath, source); + try { + const patch = runFetchGuardPatchBlock(dist, tmp); + expect(patch.status, `${patch.stdout}${patch.stderr}`).toBe(0); + expect(patch.stdout).toContain("Patch 6 already present in"); + expect(patch.stdout).not.toContain("Patch 6 applied to OpenClaw"); + expect(fs.readFileSync(preflightPath, "utf-8")).toBe(source); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("skips Patch 6 when the dist has no cron preflight references", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-patch6-absent-")); + const dist = path.join(tmp, "dist"); + fs.mkdirSync(dist, { recursive: true }); + writeNeighbouringFetchGuardFixtures(dist); + try { + const patch = runFetchGuardPatchBlock(dist, tmp); + expect(patch.status, `${patch.stdout}${patch.stderr}`).toBe(0); + expect(patch.stdout).toContain( + "OpenClaw 2026.5.27 has no cron model-provider preflight; Patch 6 not needed", + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("fails Patch 6 closed when the fetchWithSsrFGuard helper is missing", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-patch6-no-fetch-")); + const dist = path.join(tmp, "dist"); + fs.mkdirSync(dist, { recursive: true }); + writeNeighbouringFetchGuardFixtures(dist); + fs.writeFileSync( + path.join(dist, "model-preflight.runtime.js"), + reviewedCronPreflightFixture({ includeFetchWithSsrFGuard: false }), + ); + try { + const patch = runFetchGuardPatchBlock(dist, tmp); + expect(patch.status).toBe(1); + expect(patch.stderr).toContain("Patch 6 shape gate: "); + expect(patch.stderr).toContain("no fetchWithSsrFGuard call"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("fails Patch 6 closed when the SsrF policy helper is missing", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-patch6-no-policy-")); + const dist = path.join(tmp, "dist"); + fs.mkdirSync(dist, { recursive: true }); + writeNeighbouringFetchGuardFixtures(dist); + fs.writeFileSync( + path.join(dist, "model-preflight.runtime.js"), + reviewedCronPreflightFixture({ includeBuildLocalProviderSsrFPolicy: false }), + ); + try { + const patch = runFetchGuardPatchBlock(dist, tmp); + expect(patch.status).toBe(1); + expect(patch.stderr).toContain("Patch 6 shape gate: "); + expect(patch.stderr).toContain("no buildLocalProviderSsrFPolicy"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it("fails Patch 6 closed when the audit context literal is ambiguous (multi-callsite)", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-patch6-ambiguous-")); + const dist = path.join(tmp, "dist"); + fs.mkdirSync(dist, { recursive: true }); + writeNeighbouringFetchGuardFixtures(dist); + fs.writeFileSync( + path.join(dist, "model-preflight.runtime.js"), + reviewedCronPreflightFixture({ auditOccurrences: 2 }), + ); + try { + const patch = runFetchGuardPatchBlock(dist, tmp); + expect(patch.status).toBe(1); + expect(patch.stderr).toContain("Patch 6 shape gate: "); + expect(patch.stderr).toContain("refusing ambiguous multi-callsite rewrite"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); });