diff --git a/Dockerfile b/Dockerfile index bdae654e97c..7b25ff2e2f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ # to all FROM directives. Can be overridden via --build-arg. ARG BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base:latest -# Stage 1: Build TypeScript plugin and build-time verifier from source +# Stage 1: Build TypeScript plugin from source FROM node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0414325232e0b175d AS builder ENV NPM_CONFIG_AUDIT=false \ NPM_CONFIG_FUND=false \ @@ -24,18 +24,6 @@ COPY nemoclaw/package.json nemoclaw/package-lock.json nemoclaw/tsconfig.json /op COPY nemoclaw/src/ /opt/nemoclaw/src/ WORKDIR /opt/nemoclaw RUN npm ci && npm run build -COPY scripts/verify-openclaw-fetch-guard-runtime.ts /opt/nemoclaw-verifier-src/verify-openclaw-fetch-guard-runtime.ts -RUN /opt/nemoclaw/node_modules/.bin/tsc \ - /opt/nemoclaw-verifier-src/verify-openclaw-fetch-guard-runtime.ts \ - --target ES2022 \ - --module ES2022 \ - --moduleResolution bundler \ - --types node \ - --strict \ - --skipLibCheck \ - --esModuleInterop \ - --noEmitOnError \ - --outDir /opt/nemoclaw-verifier # Stage 2: Build TypeScript messaging runtime preloads. FROM builder AS runtime-preload-builder @@ -112,10 +100,8 @@ ENV NPM_CONFIG_AUDIT=false \ RUN npm ci --omit=dev COPY scripts/patch-openclaw-tool-catalog.js /usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.js COPY scripts/patch-openclaw-chat-send.js /usr/local/lib/nemoclaw/patch-openclaw-chat-send.js -COPY --from=builder /opt/nemoclaw-verifier/verify-openclaw-fetch-guard-runtime.js /usr/local/lib/nemoclaw/verify-openclaw-fetch-guard-runtime.mjs RUN chmod 755 /usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.js \ - /usr/local/lib/nemoclaw/patch-openclaw-chat-send.js \ - /usr/local/lib/nemoclaw/verify-openclaw-fetch-guard-runtime.mjs + /usr/local/lib/nemoclaw/patch-openclaw-chat-send.js # Upgrade OpenClaw if the base image is stale. # @@ -220,20 +206,23 @@ RUN set -eu; \ # sandbox. The generic SSRF helper and strict/direct DNS-pinned paths remain # unmodified, so metadata/link-local/private IP literals are unchanged. # -# === Patch 4: default bare SSRF fetches to trusted env proxy in sandbox === -# (NVIDIA/NemoClaw#396, #4687, openclaw#5129). fetchWithSsrFGuard defaults to -# STRICT mode when callers omit `mode`. STRICT does local DNS pinning before it -# can route through any proxy path, which fails for OpenShell-only hostnames -# such as inference.local and public endpoints reachable only through the -# sandbox egress proxy. OpenClaw web_search already avoids this by wrapping -# calls with withTrustedEnvProxyGuardedFetchMode(); the repeated failure class is -# bare fetchWithSsrFGuard({...}) callsites. -# -# Patch resolveGuardedFetchMode() so missing mode defaults to -# TRUSTED_ENV_PROXY only when OPENSHELL_SANDBOX=1. Explicit modes still win. -# The deprecated `proxy: "env"` compatibility branch is removed; outside an -# OpenShell sandbox, omitted mode remains STRICT. This broad default replaces -# narrower callsite-specific rewrites such as the old cron preflight patch. +# === Patch 4: route unconfigured strict SSRF fetches through the egress proxy === +# (NVIDIA/NemoClaw#4687). fetchWithSsrFGuard builds a per-request DNS-pinned +# *direct* undici dispatcher for STRICT-mode fetches that pass no explicit +# dispatcherPolicy — e.g. the @openclaw/googlechat inbound JWT signing-cert +# fetch from www.googleapis.com/service_accounts/v1/metadata/x509/.... A direct +# dispatcher ignores the global EnvHttpProxyAgent installed by +# NODE_USE_ENV_PROXY=1, so the request never reaches the OpenShell L7 proxy and +# fails in the proxy-only sandbox netns — rejecting every inbound Google Chat +# webhook. OpenClaw already has a "managed proxy" branch that routes such +# fetches through the env proxy (createHttp1EnvHttpProxyAgent) while still +# resolving + SSRF-validating the target hostname, but it is gated on +# isManagedProxyActive() (OPENCLAW_PROXY_ACTIVE=1), which NemoClaw does not set. +# Inside an OpenShell sandbox the configured egress proxy IS the managed proxy, +# so extend that activation to OPENSHELL_SANDBOX=1 for fetches that supply no +# explicit dispatcherPolicy. Explicit-proxy and direct(mTLS) dispatcher policies +# (Google auth proxy / client-cert paths) keep their existing behavior, and +# resolvePinnedHostnameWithPolicy still blocks private/link-local targets. # # === Removal criteria === # Patch 1: drop when OpenClaw deprecates withStrictGuardedFetchMode or @@ -244,9 +233,9 @@ RUN set -eu; \ # Patch 2b: drop when OpenClaw ships a reviewed web_fetch trusted-proxy SSRF # policy surface that can allow host.openshell.internal without allowing # broader private/special-use hostnames. -# Patch 4: drop when OpenClaw defaults bare fetchWithSsrFGuard calls to -# trusted_env_proxy in an OpenShell sandbox, or when all sandbox-sensitive -# callsites explicitly pass mode: "trusted_env_proxy". +# Patch 4: drop when OpenClaw routes unconfigured strict fetches through the +# env proxy in proxy-only environments without OPENCLAW_PROXY_ACTIVE, or when +# NemoClaw sets OPENCLAW_PROXY_ACTIVE=1 in the sandbox runtime instead. # # SYNC WITH OPENCLAW: these patches classify the compiled OpenClaw dist at # build time. They apply the legacy patch when the old target exists, skip @@ -365,52 +354,105 @@ RUN set -eu; \ patch_fail "Patch 2b cannot safely skip"; \ fi; \ fi; \ - # --- Patch 4: default bare guarded fetches to trusted env-proxy in sandbox --- \ - # Reviewed against openclaw@2026.5.27 dist fetch-guard: \ - # resolveGuardedFetchMode() returns explicit params.mode, then the deprecated \ - # proxy+dangerous opt-in, then STRICT. Remove the deprecated proxy opt-in and \ - # make omitted mode resolve to trusted_env_proxy only when OPENSHELL_SANDBOX=1 \ - # and no caller-owned dispatcher behavior would be discarded. A plain env-proxy \ - # policy is equivalent to the sandbox default; direct, explicit-proxy, connect, \ - # and proxyTls policies retain STRICT semantics. \ - mode_files="$(grep -RIlE --include='*.js' 'function resolveGuardedFetchMode\(params\)' "$OC_DIST" || true)"; \ - if [ -n "$mode_files" ]; then \ - patched_mode_default=0; \ - for f in $mode_files; do \ - if grep -q 'nemoclaw: default bare guarded fetches to trusted env proxy' "$f"; then \ + # --- Patch 4: route unconfigured strict fetches through the sandbox egress proxy (#4687) --- \ + # Reviewed against openclaw@2026.5.27 dist fetch-guard: the STRICT-mode \ + # managed-proxy gate is `mode === GUARDED_FETCH_MODE.STRICT && \ + # isManagedProxyActive() && hasProxyEnvConfigured()`. Extend activation to \ + # OPENSHELL_SANDBOX=1 only for fetches with no explicit dispatcherPolicy so \ + # the per-request direct dispatcher reuses the env proxy (EnvHttpProxyAgent) \ + # like the managed-proxy path already does; explicit-proxy / direct dispatcher \ + # policies and out-of-sandbox behavior are unchanged. \ + mp_files="$(grep -RIlF --include='*.js' 'const canUseManagedProxy = mode === GUARDED_FETCH_MODE.STRICT && isManagedProxyActive() && hasProxyEnvConfigured();' "$OC_DIST" || true)"; \ + if [ -n "$mp_files" ]; then \ + patched_managed_proxy=0; \ + for f in $mp_files; do \ + if grep -q 'nemoclaw: route unconfigured strict fetch' "$f"; then \ echo "INFO: Patch 4 already present in $f"; \ else \ - grep -Fq 'params.dangerouslyAllowEnvProxyWithoutPinnedDns === true' "$f" \ - || patch_fail "Patch 4 target $f is missing reviewed deprecated env-proxy opt-in"; \ - grep -Fq 'return GUARDED_FETCH_MODE.STRICT;' "$f" \ - || patch_fail "Patch 4 target $f is missing reviewed strict default"; \ - sed -i -E '/function resolveGuardedFetchMode\(params\)/,/return GUARDED_FETCH_MODE\.STRICT;/ { /if \(params\.proxy === "env" \&\& params\.dangerouslyAllowEnvProxyWithoutPinnedDns === true\) return GUARDED_FETCH_MODE\.TRUSTED_ENV_PROXY;/ d; /if \(params\.proxy === "env" \&\& params\.dangerouslyAllowEnvProxyWithoutPinnedDns === true\) \{/,/^[[:space:]]*\}/ d; }' "$f"; \ - sed -i -E '/function resolveGuardedFetchMode\(params\)/,/return GUARDED_FETCH_MODE\.STRICT;/ s#return GUARDED_FETCH_MODE\.STRICT;#if (process.env.OPENSHELL_SANDBOX === "1" \&\& (!params.dispatcherPolicy || (params.dispatcherPolicy.mode === "env-proxy" \&\& !params.dispatcherPolicy.connect \&\& !params.dispatcherPolicy.proxyTls))) return GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY; return GUARDED_FETCH_MODE.STRICT; /* nemoclaw: default bare guarded fetches to trusted env proxy in OpenShell sandbox, see Dockerfile */#' "$f"; \ - grep -Fq 'if (process.env.OPENSHELL_SANDBOX === "1" && (!params.dispatcherPolicy || (params.dispatcherPolicy.mode === "env-proxy" && !params.dispatcherPolicy.connect && !params.dispatcherPolicy.proxyTls))) return GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY; return GUARDED_FETCH_MODE.STRICT; /* nemoclaw: default bare guarded fetches to trusted env proxy in OpenShell sandbox, see Dockerfile */' "$f" \ - || patch_fail "Patch 4 verification failed to add sandbox default in $f"; \ - patched_resolver="$(sed -n '/function resolveGuardedFetchMode(params)/,/nemoclaw: default bare guarded fetches to trusted env proxy/p' "$f")"; \ - if printf '%s\n' "$patched_resolver" | grep -Fq 'params.proxy === "env"'; then \ - patch_fail "Patch 4 verification left deprecated proxy env opt-in in $f"; \ - fi; \ - if grep -Fq 'dangerouslyAllowEnvProxyWithoutPinnedDns' "$f"; then \ - patch_fail "Patch 4 verification left deprecated dangerous env-proxy opt-in in $f"; \ - fi; \ - patched_mode_default=1; \ + sed -i -E 's#const canUseManagedProxy = mode === GUARDED_FETCH_MODE\.STRICT \&\& isManagedProxyActive\(\) \&\& hasProxyEnvConfigured\(\);#const canUseManagedProxy = mode === GUARDED_FETCH_MODE.STRICT \&\& (isManagedProxyActive() || (process.env.OPENSHELL_SANDBOX === "1" \&\& !params.dispatcherPolicy)) \&\& hasProxyEnvConfigured(); /* nemoclaw: route unconfigured strict fetch through sandbox egress proxy, see Dockerfile */#' "$f"; \ + grep -Fq 'process.env.OPENSHELL_SANDBOX === "1" && !params.dispatcherPolicy' "$f" \ + || patch_fail "Patch 4 verification failed for $f"; \ + patched_managed_proxy=1; \ fi; \ done; \ - if [ "$patched_mode_default" = "1" ]; then \ - echo "INFO: Patch 4 applied to OpenClaw ${OC_VERSION} sandbox trusted env-proxy default"; \ + if [ "$patched_managed_proxy" = "1" ]; then \ + echo "INFO: Patch 4 applied to OpenClaw ${OC_VERSION} managed-proxy strict-fetch activation"; \ fi; \ else \ - mode_refs="$(grep -RIlE --include='*.js' 'resolveGuardedFetchMode|dangerouslyAllowEnvProxyWithoutPinnedDns' "$OC_DIST" || true)"; \ - if [ -z "$mode_refs" ]; then \ - echo "INFO: OpenClaw ${OC_VERSION} has no guarded-fetch mode resolver; Patch 4 not needed"; \ + managed_proxy_refs="$(grep -RIlE --include='*.js' 'canUseManagedProxy|isManagedProxyActive' "$OC_DIST" || true)"; \ + if [ -z "$managed_proxy_refs" ]; then \ + echo "INFO: OpenClaw ${OC_VERSION} has no managed-proxy strict-fetch gate; Patch 4 not needed"; \ else \ - echo "ERROR: Patch 4 target missing but guarded-fetch mode/proxy references remain:" >&2; \ - printf '%s\n' "$mode_refs" | head -n 5 >&2; \ + echo "ERROR: Patch 4 target missing but managed-proxy references remain:" >&2; \ + printf '%s\n' "$managed_proxy_refs" | head -n 5 >&2; \ 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 \ @@ -475,14 +517,9 @@ RUN node /usr/local/lib/nemoclaw/patch-openclaw-chat-send.js \ # effective tool set behind tool_call. NEMOCLAW_TOOL_CATALOG=0 disables this # wrapper if an emergency rollback is needed. The script fails closed if the # pinned selection-*.js shape changes. -# The same build layer then imports the npm-installed, compiled fetch guard with -# an injected fetch implementation (no external request) to prove proxy dispatch, -# no local DNS, literal SSRF denies, and redirect re-checks after patching. # hadolint ignore=DL3059 RUN node /usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.js \ - /usr/local/lib/node_modules/openclaw/dist \ - && node /usr/local/lib/nemoclaw/verify-openclaw-fetch-guard-runtime.mjs \ - /usr/local/lib/node_modules/openclaw/dist + /usr/local/lib/node_modules/openclaw/dist # Set up blueprint for local resolution. # Blueprints are immutable at runtime; DAC protection (root ownership) is applied diff --git a/scripts/verify-openclaw-fetch-guard-runtime.ts b/scripts/verify-openclaw-fetch-guard-runtime.ts deleted file mode 100755 index 2928a0e9d9b..00000000000 --- a/scripts/verify-openclaw-fetch-guard-runtime.ts +++ /dev/null @@ -1,240 +0,0 @@ -#!/usr/bin/env node -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import assert from "node:assert/strict"; -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -const GOOGLE_CHAT_CERT_URL = - "https://www.googleapis.com/service_accounts/v1/metadata/x509/chat%40system.gserviceaccount.com"; -const TEST_PROXY_URL = "http://127.0.0.1:3128"; -type GuardedFetchResult = { - release: () => Promise | void; - response: Response; -}; - -type FetchInitWithDispatcher = RequestInit & { - dispatcher?: { constructor?: { name?: string } } | null; -}; - -type GuardedFetchParams = { - auditContext: string; - fetchImpl: (url: string, init?: FetchInitWithDispatcher) => Promise; - lookupFn: () => Promise; - url: string; -}; - -type FetchWithSsrFGuard = (params: GuardedFetchParams) => Promise; - -type RuntimeVerificationResult = { - blockedTargets: string[]; - dispatcherName: string; - fetchGuardFile: string; - googleChatCertProxyWithoutLocalDns: true; - redirectToPrivateBlocked: true; -}; - -const BLOCKED_TARGETS: ReadonlyArray = [ - ["localhost", "http://localhost/"], - ["IPv4 loopback", "http://127.0.0.1/"], - ["IPv4 private", "http://10.0.0.1/"], - ["IPv6 loopback", "http://[::1]/"], - ["IPv6 link-local", "http://[fe80::1]/"], - ["IPv6 unique-local", "http://[fd00::1]/"], -]; -const PATCH_MARKER = "nemoclaw: default bare guarded fetches to trusted env proxy"; -const PROXY_ENV_KEYS: readonly string[] = [ - "OPENSHELL_SANDBOX", - "HTTP_PROXY", - "HTTPS_PROXY", - "http_proxy", - "https_proxy", - "NO_PROXY", - "no_proxy", -]; - -function listJavaScriptFiles(root: string): string[] { - const files: string[] = []; - const pending: string[] = [root]; - while (pending.length > 0) { - const directory = pending.pop(); - if (directory === undefined) break; - for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { - const target = path.join(directory, entry.name); - if (entry.isDirectory()) pending.push(target); - else if (entry.isFile() && entry.name.endsWith(".js")) files.push(target); - } - } - return files; -} - -function findPatchedFetchGuard(distDirectory: string): string { - const candidates = listJavaScriptFiles(distDirectory).filter((file) => { - const source = fs.readFileSync(file, "utf8"); - return ( - source.includes("async function fetchWithSsrFGuard(params)") && - source.includes("function resolveGuardedFetchMode(params)") - ); - }); - assert.equal( - candidates.length, - 1, - `expected exactly one compiled fetch guard in ${distDirectory}, found ${candidates.length}`, - ); - const file = candidates[0]; - assert.match(fs.readFileSync(file, "utf8"), new RegExp(PATCH_MARKER)); - return file; -} - -function restoreEnvironment(snapshot: ReadonlyMap): void { - for (const [key, value] of snapshot) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } -} - -function isSsrfBlock(error: unknown): boolean { - return /blocked|private|loopback|special-use/i.test(String(error)); -} - -async function assertTargetBlocked( - fetchWithSsrFGuard: FetchWithSsrFGuard, - label: string, - url: string, -): Promise { - let fetchCalls = 0; - let lookupCalls = 0; - await assert.rejects( - () => - fetchWithSsrFGuard({ - auditContext: `nemoclaw-build-verification-${label}`, - fetchImpl: async () => { - fetchCalls += 1; - return new Response("unexpected fetch"); - }, - lookupFn: async () => { - lookupCalls += 1; - throw new Error("unexpected DNS lookup"); - }, - url, - }), - isSsrfBlock, - `${label} must be rejected by the compiled fetch guard`, - ); - assert.equal(fetchCalls, 0, `${label} reached fetch before SSRF rejection`); - assert.equal(lookupCalls, 0, `${label} reached DNS before SSRF rejection`); -} - -async function verifyGoogleCertProxyPath(fetchWithSsrFGuard: FetchWithSsrFGuard): Promise { - let fetchCalls = 0; - let lookupCalls = 0; - let dispatcherName = ""; - const guarded = await fetchWithSsrFGuard({ - auditContext: "nemoclaw-build-verification-google-chat-cert", - fetchImpl: async (url, init) => { - fetchCalls += 1; - assert.equal(url, GOOGLE_CHAT_CERT_URL); - assert.equal(init?.redirect, "manual"); - dispatcherName = init?.dispatcher?.constructor?.name ?? ""; - return new Response("certificate", { status: 200 }); - }, - lookupFn: async () => { - lookupCalls += 1; - throw new Error("Google certificate fetch attempted local DNS"); - }, - url: GOOGLE_CHAT_CERT_URL, - }); - try { - assert.equal(guarded.response.status, 200); - assert.equal(await guarded.response.text(), "certificate"); - } finally { - await guarded.release(); - } - assert.equal(fetchCalls, 1); - assert.equal(lookupCalls, 0); - assert.ok(dispatcherName, "Google certificate fetch did not receive a proxy dispatcher"); - return dispatcherName; -} - -async function verifyRedirectToPrivateIsBlocked( - fetchWithSsrFGuard: FetchWithSsrFGuard, -): Promise { - let fetchCalls = 0; - let lookupCalls = 0; - await assert.rejects( - () => - fetchWithSsrFGuard({ - auditContext: "nemoclaw-build-verification-private-redirect", - fetchImpl: async () => { - fetchCalls += 1; - assert.equal(fetchCalls, 1, "private redirect target reached fetch"); - return new Response(null, { - headers: { location: "http://169.254.169.254/latest/meta-data" }, - status: 302, - }); - }, - lookupFn: async () => { - lookupCalls += 1; - throw new Error("redirect verification attempted local DNS"); - }, - url: GOOGLE_CHAT_CERT_URL, - }), - isSsrfBlock, - "redirect to metadata must be rejected by the compiled fetch guard", - ); - assert.equal(fetchCalls, 1); - assert.equal(lookupCalls, 0); -} - -export async function verifyOpenClawFetchGuardRuntime( - distDirectory: string, -): Promise { - assert.ok(fs.statSync(distDirectory).isDirectory(), `${distDirectory} is not a directory`); - const fetchGuardFile = findPatchedFetchGuard(distDirectory); - const module = (await import(pathToFileURL(fetchGuardFile).href)) as Record; - const fetchWithSsrFGuard = Object.values(module).find( - (value) => typeof value === "function" && value.name === "fetchWithSsrFGuard", - ); - assert.equal(typeof fetchWithSsrFGuard, "function", "compiled fetch guard export not found"); - const guardedFetch = fetchWithSsrFGuard as FetchWithSsrFGuard; - - const environment = new Map( - PROXY_ENV_KEYS.map((key): [string, string | undefined] => [key, process.env[key]]), - ); - process.env.OPENSHELL_SANDBOX = "1"; - process.env.HTTP_PROXY = TEST_PROXY_URL; - process.env.HTTPS_PROXY = TEST_PROXY_URL; - process.env.http_proxy = TEST_PROXY_URL; - process.env.https_proxy = TEST_PROXY_URL; - delete process.env.NO_PROXY; - delete process.env.no_proxy; - - try { - const dispatcherName = await verifyGoogleCertProxyPath(guardedFetch); - for (const [label, url] of BLOCKED_TARGETS) { - await assertTargetBlocked(guardedFetch, label, url); - } - await verifyRedirectToPrivateIsBlocked(guardedFetch); - return { - blockedTargets: BLOCKED_TARGETS.map(([label]) => label), - dispatcherName, - fetchGuardFile: path.basename(fetchGuardFile), - googleChatCertProxyWithoutLocalDns: true, - redirectToPrivateBlocked: true, - }; - } finally { - restoreEnvironment(environment); - } -} - -const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); -if (isMain) { - const distDirectory = process.argv[2]; - if (!distDirectory) throw new Error("usage: verify-openclaw-fetch-guard-runtime.mjs "); - const result = await verifyOpenClawFetchGuardRuntime(path.resolve(distDirectory)); - console.log( - `OpenClaw compiled fetch-guard runtime verification passed: ${JSON.stringify(result)}`, - ); -} diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index 0d9bcc86a44..015408ec9dc 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -173,10 +173,6 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "patch-openclaw-chat-send.js"), path.join(stagedScriptsDir, "patch-openclaw-chat-send.js"), ); - fs.copyFileSync( - path.join(rootDir, "scripts", "verify-openclaw-fetch-guard-runtime.ts"), - path.join(stagedScriptsDir, "verify-openclaw-fetch-guard-runtime.ts"), - ); return { buildCtx, stagedDockerfile }; } diff --git a/test/fetch-guard-patch-regression.test.ts b/test/fetch-guard-patch-regression.test.ts index 5bc550a1ce2..9f3398b4adf 100644 --- a/test/fetch-guard-patch-regression.test.ts +++ b/test/fetch-guard-patch-regression.test.ts @@ -32,13 +32,8 @@ const REVIEWED_OPENCLAW_2026_5_27_WEB_FETCH_SHAPE = [ " return fetchWithSsrFGuard(useEnvProxy ? withTrustedEnvProxyGuardedFetchMode(resolved) : withStrictGuardedFetchMode(resolved));", "}", ].join("\n"); -const REVIEWED_OPENCLAW_2026_5_27_GUARDED_MODE_SHAPE = [ - "function resolveGuardedFetchMode(params) {", - " if (params.mode) return params.mode;", - ' if (params.proxy === "env" && params.dangerouslyAllowEnvProxyWithoutPinnedDns === true) return GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY;', - " return GUARDED_FETCH_MODE.STRICT;", - "}", -].join("\n"); +const REVIEWED_OPENCLAW_2026_5_27_MANAGED_PROXY_SHAPE = + "const canUseManagedProxy = mode === GUARDED_FETCH_MODE.STRICT && isManagedProxyActive() && hasProxyEnvConfigured();"; const REVIEWED_OPENCLAW_2026_5_27_SSRF_POLICY_SHAPE = [ "function shouldSkipPrivateNetworkChecks(hostname, policy) {", " return isPrivateNetworkAllowedByPolicy(policy) || normalizeHostnameSet(policy?.allowedHostnames).has(hostname);", @@ -333,12 +328,6 @@ describe("fetch-guard patch regression guard", () => { expect(REVIEWED_OPENCLAW_2026_5_27_WEB_FETCH_SHAPE).toContain( "withTrustedEnvProxyGuardedFetchMode(resolved)", ); - expect(REVIEWED_OPENCLAW_2026_5_27_GUARDED_MODE_SHAPE).toContain( - "function resolveGuardedFetchMode(params)", - ); - expect(REVIEWED_OPENCLAW_2026_5_27_GUARDED_MODE_SHAPE).toContain( - "params.dangerouslyAllowEnvProxyWithoutPinnedDns === true", - ); expect(REVIEWED_OPENCLAW_2026_5_27_SSRF_POLICY_SHAPE).toContain( "normalizeHostnameSet(policy?.allowedHostnames).has(hostname)", ); @@ -1103,23 +1092,25 @@ if (!blocked) throw new Error('private IP literal was not blocked');`, } }); - it("defaults bare guarded fetches to trusted env proxy only inside the sandbox (#396, #4687)", async () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fetch-guard-mode-default-")); + it("activates the managed-proxy path for unconfigured strict fetches only inside the sandbox (#4687)", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fetch-guard-managed-proxy-")); const dist = path.join(tmp, "dist"); fs.mkdirSync(dist, { recursive: true }); fs.writeFileSync(path.join(tmp, "package.json"), '{"type":"module"}\n'); - const modulePath = path.join(dist, "fetch-guard-mode-default.js"); + const modulePath = path.join(dist, "fetch-guard-managed-proxy.js"); fs.writeFileSync( modulePath, [ "const withStrictGuardedFetchMode = Symbol('strict');", "const withTrustedEnvProxyGuardedFetchMode = Symbol('trusted');", - "const GUARDED_FETCH_MODE = { STRICT: 'strict', TRUSTED_ENV_PROXY: 'trusted_env_proxy' };", - REVIEWED_OPENCLAW_2026_5_27_GUARDED_MODE_SHAPE, - "function preserveUnrelatedLegacyProxyLiteral(params) {", - ' return params.proxy === "env";', + "const GUARDED_FETCH_MODE = { STRICT: 'strict' };", + "function isManagedProxyActive() { return process.env.OPENCLAW_PROXY_ACTIVE === '1'; }", + "function hasProxyEnvConfigured() { return true; }", + "function computeCanUseManagedProxy(mode, params) {", + ` ${REVIEWED_OPENCLAW_2026_5_27_MANAGED_PROXY_SHAPE}`, + " return canUseManagedProxy;", "}", - "export { withStrictGuardedFetchMode as a, withTrustedEnvProxyGuardedFetchMode as b, resolveGuardedFetchMode as g };", + "export { withStrictGuardedFetchMode as a, withTrustedEnvProxyGuardedFetchMode as b, computeCanUseManagedProxy as g };", "", ].join("\n"), ); @@ -1133,299 +1124,294 @@ if (!blocked) throw new Error('private IP literal was not blocked');`, expect(patch.status, `${patch.stdout}${patch.stderr}`).toBe(0); expect(patch.stdout).toContain("Patch 4 applied"); const patched = fs.readFileSync(modulePath, "utf-8"); - expect(patched).toContain("nemoclaw: default bare guarded fetches"); - const resolverBlock = - patched.match( - /function resolveGuardedFetchMode\(params\) \{[\s\S]*?\/\* nemoclaw: default bare guarded fetches[^\n]*/, - )?.[0] ?? ""; - expect(resolverBlock).not.toContain("dangerouslyAllowEnvProxyWithoutPinnedDns === true"); - expect(resolverBlock).not.toContain('params.proxy === "env"'); - expect(patched).toContain('params.proxy === "env";'); - expect(patched).toContain("process.env.OPENSHELL_SANDBOX"); - expect(patched).toContain( - 'process.env.OPENSHELL_SANDBOX === "1" && (!params.dispatcherPolicy || (params.dispatcherPolicy.mode === "env-proxy" && !params.dispatcherPolicy.connect && !params.dispatcherPolicy.proxyTls))', - ); - expect(patched).not.toContain("OPENCLAW_PROXY_ACTIVE"); + expect(patched).toContain("nemoclaw: route unconfigured strict fetch"); const mod = await import(`${modulePath}?${Date.now()}`); const prevSandbox = process.env.OPENSHELL_SANDBOX; + const prevManaged = process.env.OPENCLAW_PROXY_ACTIVE; try { - // In-sandbox, omitted mode uses trusted env proxy and avoids local DNS pinning. + // In-sandbox, no explicit dispatcher policy -> reuse the env proxy. process.env.OPENSHELL_SANDBOX = "1"; - expect(mod.g({})).toBe("trusted_env_proxy"); - // A plain env-proxy policy is equivalent to the sandbox default. - expect(mod.g({ dispatcherPolicy: { mode: "env-proxy" } })).toBe("trusted_env_proxy"); - // Caller-owned dispatcher behavior must not be discarded. - expect( - mod.g({ dispatcherPolicy: { mode: "env-proxy", connect: { cert: "client-cert" } } }), - ).toBe("strict"); - expect( - mod.g({ dispatcherPolicy: { mode: "env-proxy", proxyTls: { ca: "proxy-ca" } } }), - ).toBe("strict"); - expect(mod.g({ dispatcherPolicy: { mode: "direct" } })).toBe("strict"); - expect(mod.g({ dispatcherPolicy: { mode: "explicit-proxy" } })).toBe("strict"); - // Explicit mode remains caller-owned. - expect(mod.g({ mode: "strict" })).toBe("strict"); - expect(mod.g({ mode: "trusted_env_proxy" })).toBe("trusted_env_proxy"); - expect(mod.g({ mode: "strict", dispatcherPolicy: { mode: "env-proxy" } })).toBe("strict"); - expect(mod.g({ mode: "trusted_env_proxy", dispatcherPolicy: { mode: "direct" } })).toBe( - "trusted_env_proxy", - ); - - // Outside the sandbox -> omitted mode remains strict. + delete process.env.OPENCLAW_PROXY_ACTIVE; + expect(mod.g("strict", {})).toBe(true); + // In-sandbox but an explicit dispatcher policy is supplied -> untouched. + expect(mod.g("strict", { dispatcherPolicy: { mode: "explicit-proxy" } })).toBe(false); + // Outside the sandbox -> original strict/direct behavior is preserved. delete process.env.OPENSHELL_SANDBOX; - expect(mod.g({})).toBe("strict"); - expect(mod.g({ proxy: "env" })).toBe("strict"); + expect(mod.g("strict", {})).toBe(false); + // Upstream managed-proxy activation still works regardless of sandbox. + process.env.OPENCLAW_PROXY_ACTIVE = "1"; + expect(mod.g("strict", {})).toBe(true); + // Non-strict modes never take the managed-proxy branch. + process.env.OPENSHELL_SANDBOX = "1"; + delete process.env.OPENCLAW_PROXY_ACTIVE; + expect(mod.g("trusted_env_proxy", {})).toBe(false); } finally { if (prevSandbox === undefined) delete process.env.OPENSHELL_SANDBOX; else process.env.OPENSHELL_SANDBOX = prevSandbox; + if (prevManaged === undefined) delete process.env.OPENCLAW_PROXY_ACTIVE; + else process.env.OPENCLAW_PROXY_ACTIVE = prevManaged; } } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); - it("fails closed when the deprecated dangerous proxy opt-in survives outside the resolver", () => { + it("reports Patch 4 not needed when the managed-proxy gate is absent", () => { const tmp = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-fetch-guard-mode-dangerous-survivor-"), + path.join(os.tmpdir(), "nemoclaw-fetch-guard-managed-proxy-absent-"), ); const dist = path.join(tmp, "dist"); fs.mkdirSync(dist, { recursive: true }); fs.writeFileSync( - path.join(dist, "fetch-guard-mode-dangerous-survivor.js"), + path.join(dist, "fetch-guard-no-managed-proxy.js"), [ "const withStrictGuardedFetchMode = Symbol('strict');", "const withTrustedEnvProxyGuardedFetchMode = Symbol('trusted');", - "const GUARDED_FETCH_MODE = { STRICT: 'strict', TRUSTED_ENV_PROXY: 'trusted_env_proxy' };", - REVIEWED_OPENCLAW_2026_5_27_GUARDED_MODE_SHAPE, - "function staleDangerousProxyOptIn(params) {", - " return params.dangerouslyAllowEnvProxyWithoutPinnedDns === true;", - "}", "export { withStrictGuardedFetchMode as a, withTrustedEnvProxyGuardedFetchMode as b };", "", ].join("\n"), ); try { - const patch = runFetchGuardPatchBlock( - dist, - tmp, - CURRENT_REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSION, - ); - expect(patch.status).toBe(1); - expect(patch.stderr).toContain( - "Patch 4 verification left deprecated dangerous env-proxy opt-in", - ); + const patch = runFetchGuardPatchBlock(dist, tmp, "2026.6.1"); + expect(patch.status, `${patch.stdout}${patch.stderr}`).toBe(0); + expect(patch.stdout).toContain("Patch 4 not needed"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); - it("bare omitted-mode fetches skip pinned DNS in sandbox while preserving SSRF denies", async () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fetch-guard-mode-contract-")); + it("fails closed when the managed-proxy gate drifts but managed-proxy references remain", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fetch-guard-managed-proxy-drift-")); const dist = path.join(tmp, "dist"); fs.mkdirSync(dist, { recursive: true }); - fs.writeFileSync(path.join(tmp, "package.json"), '{"type":"module"}\n'); - const modulePath = path.join(dist, "fetch-guard-mode-contract.js"); fs.writeFileSync( - modulePath, + path.join(dist, "fetch-guard-managed-proxy-drift.js"), [ - "const pinnedDnsCalls = [];", - "const policyChecks = [];", - "const withStrictGuardedFetchMode = (params) => ({ ...params, mode: GUARDED_FETCH_MODE.STRICT });", - "const withTrustedEnvProxyGuardedFetchMode = (params) => ({ ...params, mode: GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY });", - "const GUARDED_FETCH_MODE = { STRICT: 'strict', TRUSTED_ENV_PROXY: 'trusted_env_proxy' };", - REVIEWED_OPENCLAW_2026_5_27_GUARDED_MODE_SHAPE, - "function normalizeHostname(value) { return String(value || '').toLowerCase().replace(/\\.+$/, ''); }", - "function assertHostnameAllowedWithPolicy(hostname, policy) {", - " const normalized = normalizeHostname(hostname);", - " policyChecks.push({ normalized, policy });", - " const allowedHostnames = new Set((policy?.allowedHostnames ?? []).map(normalizeHostname));", - " if (allowedHostnames.has(normalized)) return normalized;", - " if (normalized === '169.254.169.254' || normalized === '10.0.0.1' || normalized.endsWith('.internal')) throw new Error('blocked ' + normalized);", - " return normalized;", - "}", - "async function resolvePinnedHostnameWithPolicy(hostname, params = {}) {", - " pinnedDnsCalls.push({ hostname: normalizeHostname(hostname), policy: params.policy });", - " return { hostname: assertHostnameAllowedWithPolicy(hostname, params.policy), addresses: ['203.0.113.10'] };", - "}", - "function shouldUseEnvHttpProxyForUrl() { return true; }", - "function createHttp1EnvHttpProxyAgent() { return { kind: 'env-proxy' }; }", - "function createPinnedDispatcher(pinned) { return { kind: 'pinned', pinned }; }", - "async function fetchWithSsrFGuard(params) {", - " const parsedUrl = new URL(params.url);", - " const mode = resolveGuardedFetchMode(params);", - " const canUseTrustedEnvProxy = mode === GUARDED_FETCH_MODE.TRUSTED_ENV_PROXY && shouldUseEnvHttpProxyForUrl(parsedUrl.toString());", - " if (canUseTrustedEnvProxy) {", - " assertHostnameAllowedWithPolicy(parsedUrl.hostname, params.policy);", - " return { mode, dispatcher: createHttp1EnvHttpProxyAgent().kind, hostname: parsedUrl.hostname };", - " }", - " return { mode, dispatcher: createPinnedDispatcher(await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, { policy: params.policy })).kind, hostname: parsedUrl.hostname };", - "}", - "export { withStrictGuardedFetchMode as a, withTrustedEnvProxyGuardedFetchMode as b, fetchWithSsrFGuard as f, resolveGuardedFetchMode as g, pinnedDnsCalls as p, policyChecks as c };", + "const withStrictGuardedFetchMode = Symbol('strict');", + "const withTrustedEnvProxyGuardedFetchMode = Symbol('trusted');", + "function isManagedProxyActive() { return process.env.OPENCLAW_PROXY_ACTIVE === '1'; }", + "function proxyEnvSet() { return true; }", + // Drifted shape: renamed variables, so the exact reviewed gate is gone. + "const canUseManagedProxy = currentMode === 'strict' && isManagedProxyActive() && proxyEnvSet();", + "export { withStrictGuardedFetchMode as a, withTrustedEnvProxyGuardedFetchMode as b };", "", ].join("\n"), ); try { - const patch = runFetchGuardPatchBlock( - dist, - tmp, - CURRENT_REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSION, - ); - expect(patch.status, `${patch.stdout}${patch.stderr}`).toBe(0); - const mod = await import(`${modulePath}?${Date.now()}`); - const prevSandbox = process.env.OPENSHELL_SANDBOX; - try { - process.env.OPENSHELL_SANDBOX = "1"; - await expect( - mod.f({ - url: "https://www.googleapis.com/service_accounts/v1/metadata/x509/chat%40system.gserviceaccount.com", - }), - ).resolves.toMatchObject({ - mode: "trusted_env_proxy", - dispatcher: "env-proxy", - hostname: "www.googleapis.com", - }); - expect(mod.p).toEqual([]); - expect(mod.c).toContainEqual({ normalized: "www.googleapis.com", policy: undefined }); - - await expect(mod.f({ url: "http://169.254.169.254/latest/meta-data" })).rejects.toThrow( - /blocked 169\.254\.169\.254/, - ); - await expect(mod.f({ url: "http://10.0.0.1/" })).rejects.toThrow(/blocked 10\.0\.0\.1/); - await expect(mod.f({ url: "http://foo.internal/" })).rejects.toThrow( - /blocked foo\.internal/, - ); - expect(mod.p).toEqual([]); - - delete process.env.OPENSHELL_SANDBOX; - await expect(mod.f({ url: "https://www.googleapis.com/" })).resolves.toMatchObject({ - mode: "strict", - dispatcher: "pinned", - hostname: "www.googleapis.com", - }); - expect(mod.p).toContainEqual({ hostname: "www.googleapis.com", policy: undefined }); - } finally { - if (prevSandbox === undefined) delete process.env.OPENSHELL_SANDBOX; - else process.env.OPENSHELL_SANDBOX = prevSandbox; - } + const patch = runFetchGuardPatchBlock(dist, tmp, "2026.6.1"); + expect(patch.status).toBe(1); + expect(patch.stderr).toContain("Patch 4 target missing but managed-proxy references remain"); + expect(patch.stderr).toContain("Patch 4 cannot safely skip"); + expect(patch.stderr).toContain("OpenClaw 2026.6.1"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); - it("leaves cron preflight callsites unmodified because omitted mode is patched centrally", async () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fetch-guard-cron-central-")); - const dist = path.join(tmp, "dist"); - fs.mkdirSync(dist, { recursive: true }); - fs.writeFileSync(path.join(tmp, "package.json"), '{"type":"module"}\n'); - const fetchGuardPath = path.join(dist, "fetch-guard-mode-default.js"); - const preflightPath = path.join(dist, "model-preflight.runtime.js"); + 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( - fetchGuardPath, - [ - "const withStrictGuardedFetchMode = Symbol('strict');", - "const withTrustedEnvProxyGuardedFetchMode = Symbol('trusted');", - "const GUARDED_FETCH_MODE = { STRICT: 'strict', TRUSTED_ENV_PROXY: 'trusted_env_proxy' };", - REVIEWED_OPENCLAW_2026_5_27_GUARDED_MODE_SHAPE, - "export { withStrictGuardedFetchMode as a, withTrustedEnvProxyGuardedFetchMode as b, resolveGuardedFetchMode as g };", - "", - ].join("\n"), + path.join(dist, "media-runtime.js"), + "export { readRemoteMediaBuffer, saveRemoteMedia, fetchRemoteMedia };\n", ); fs.writeFileSync( - preflightPath, + path.join(dist, "fetch-guard-neighbour.js"), [ - "async function probeLocalProviderEndpoint(params) {", - " return fetchWithSsrFGuard({", - " url: params.baseUrl + '/v1/models',", - " policy: { hostnameAllowlist: ['inference.local'] },", - ' auditContext: "cron-model-provider-preflight",', - " });", + "const withTrustedEnvProxyGuardedFetchMode = Symbol('trusted');", + "async function fetchGuardedMediaResponse() {", + " return fetchWithSsrFGuard(withTrustedEnvProxyGuardedFetchMode({}));", "}", - "export { probeLocalProviderEndpoint };", + "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, - CURRENT_REVIEWED_OPENCLAW_PATCH_CLASSIFIER_VERSION, - ); + const patch = runFetchGuardPatchBlock(dist, tmp); expect(patch.status, `${patch.stdout}${patch.stderr}`).toBe(0); - expect(patch.stdout).toContain("Patch 4 applied"); - expect(patch.stdout).not.toContain("Patch 6"); + 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 }); + } + }); - const mod = await import(`${fetchGuardPath}?${Date.now()}`); - const prevSandbox = process.env.OPENSHELL_SANDBOX; - try { - process.env.OPENSHELL_SANDBOX = "1"; - expect(mod.g({})).toBe("trusted_env_proxy"); - } finally { - if (prevSandbox === undefined) delete process.env.OPENSHELL_SANDBOX; - else process.env.OPENSHELL_SANDBOX = prevSandbox; - } + 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("reports Patch 4 not needed when the guarded-fetch mode resolver is absent", () => { - const tmp = fs.mkdtempSync( - path.join(os.tmpdir(), "nemoclaw-fetch-guard-mode-resolver-absent-"), - ); + 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, "fetch-guard-no-mode-resolver.js"), - [ - "const withStrictGuardedFetchMode = Symbol('strict');", - "const withTrustedEnvProxyGuardedFetchMode = Symbol('trusted');", - "export { withStrictGuardedFetchMode as a, withTrustedEnvProxyGuardedFetchMode as b };", - "", - ].join("\n"), + path.join(dist, "model-preflight.runtime.js"), + reviewedCronPreflightFixture({ includeFetchWithSsrFGuard: false }), ); - try { - const patch = runFetchGuardPatchBlock(dist, tmp, "2026.6.1"); - expect(patch.status, `${patch.stdout}${patch.stderr}`).toBe(0); - expect(patch.stdout).toContain("Patch 4 not needed"); - expect(patch.stdout).toContain("has no guarded-fetch mode resolver"); + 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 closed when the mode resolver is missing but guarded-fetch proxy references remain", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-fetch-guard-mode-resolver-drift-")); + 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, "fetch-guard-mode-resolver-drift.js"), - [ - "const withStrictGuardedFetchMode = Symbol('strict');", - "const withTrustedEnvProxyGuardedFetchMode = Symbol('trusted');", - // Drifted shape: legacy env-proxy fields remain, but the reviewed resolver is gone. - "const legacyProxyOptIn = params.dangerouslyAllowEnvProxyWithoutPinnedDns === true;", - "export { withStrictGuardedFetchMode as a, withTrustedEnvProxyGuardedFetchMode as b };", - "", - ].join("\n"), + 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, "2026.6.1"); + const patch = runFetchGuardPatchBlock(dist, tmp); expect(patch.status).toBe(1); - expect(patch.stderr).toContain( - "Patch 4 target missing but guarded-fetch mode/proxy references remain", - ); - expect(patch.stderr).toContain("Patch 4 cannot safely skip"); - expect(patch.stderr).toContain("OpenClaw 2026.6.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 }); } diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index c01deeabcfb..ea172d43713 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -86,7 +86,6 @@ describe("sandbox build context staging", () => { ); writeFixture(path.join("scripts", "patch-openclaw-tool-catalog.js")); writeFixture(path.join("scripts", "patch-openclaw-chat-send.js")); - writeFixture(path.join("scripts", "verify-openclaw-fetch-guard-runtime.ts")); } function expectDockerfileScriptCopiesExist(buildCtx: string, stagedDockerfile: string) { @@ -300,9 +299,6 @@ describe("sandbox build context staging", () => { expect(fs.existsSync(path.join(buildCtx, "scripts", "patch-openclaw-chat-send.js"))).toBe( true, ); - expect( - fs.existsSync(path.join(buildCtx, "scripts", "verify-openclaw-fetch-guard-runtime.ts")), - ).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "lib", "sandbox-init.sh"))).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "lib", "sandbox-rlimits.sh"))).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "setup.sh"))).toBe(false);