Skip to content

fix(sandbox): restore Telegram media downloads in proxy-only sandbox#1755

Merged
cv merged 20 commits intoNVIDIA:mainfrom
tommylin-signalpro:fix/telegram-media-download-in-sandbox
Apr 16, 2026
Merged

fix(sandbox): restore Telegram media downloads in proxy-only sandbox#1755
cv merged 20 commits intoNVIDIA:mainfrom
tommylin-signalpro:fix/telegram-media-download-in-sandbox

Conversation

@tommylin-signalpro
Copy link
Copy Markdown
Contributor

@tommylin-signalpro tommylin-signalpro commented Apr 10, 2026

Summary

Restore Telegram media downloads inside NemoClaw's proxy-only sandbox. The agent receives photo messages but MediaFetchError: Blocked hostname (not in allowlist): 10.200.0.1 aborts the download before the file reaches the model. This PR makes the existing fetchRemoteMedia callsite route through the L7 proxy and stops OpenClaw's new SSRF check from rejecting the proxy hostname.

Scope: Telegram only. Discord/Slack are not part of this PR — see Implementation scope below for what does and doesn't extend to them.

Related Issue

Root Cause

NemoClaw forces all sandbox egress through the OpenShell L7 proxy (default 10.200.0.1:3128). OpenClaw's media download path requires two things to work in this environment, and OpenClaw 2026.4.2 broke the second one.

Layer 1 — wrong fetch mode (long-standing)

openclaw/dist/fetch-ClF-ZgDC.js calls

fetchWithSsrFGuard(withStrictGuardedFetchMode({ ... }))

unconditionally. Strict mode does DNS pinning + direct connect, which fails in the sandbox netns where only the proxy is reachable. The agent needs withTrustedEnvProxyGuardedFetchMode (uses EnvHttpProxyAgent) for any callsite running in a proxy-only environment.

Layer 2 — SSRF check rejects the proxy itself (regression in 2026.4.2)

OpenClaw 2026.4.2 added assertExplicitProxyAllowed() in fetch-guard-*.js:

async function assertExplicitProxyAllowed(dispatcherPolicy, lookupFn, policy) {
    if (!dispatcherPolicy || dispatcherPolicy.mode !== "explicit-proxy") return;
    ...
    await resolvePinnedHostnameWithPolicy(parsedProxyUrl.hostname, {
        lookupFn,
        policy: dispatcherPolicy.allowPrivateProxy === true
            ? { ...policy, allowPrivateNetwork: true }
            : policy
    });
}

When fetching Telegram media, OpenClaw passes { hostnameAllowlist: ["api.telegram.org"] } as the SSRF policy (the target's allowlist). The new check then validates the proxy hostname (10.200.0.1) against that same allowlist and rejects it because the proxy is not api.telegram.org. Even with allowPrivateProxy: true flipping allowPrivateNetwork, matchesHostnameAllowlist("10.200.0.1", ["api.telegram.org"]) still returns false.

This is an upstream OpenClaw design issue — a proxy is infrastructure, not a fetch target, so the target's hostname allowlist should not apply to it. NemoClaw's proxy-only architecture hits this 100% of the time.

This PR previously worked end-to-end against openclaw@2026.3.11 (which had no assertExplicitProxyAllowed). After main bumped to 2026.4.2 (#1522), only the layer 1 fix survived; the new SSRF check started rejecting every media download.

Implementation scope

This PR contains four changes; their reach is deliberately not the same. Reviewers should know what each one actually affects.

Change Scope Verified
Telegram channel config: inject proxy: <url> into accounts.default Telegram channel only ✅ E2E
Patch 1: rewrite fetch-guard-*.js export so the strict-mode alias maps to withTrustedEnvProxyGuardedFetchMode Every openclaw fetch path inside the sandbox that imports the strict alias ✅ E2E via Telegram media fetch; broader effect intentional
Patch 2: env-gated bypass of assertExplicitProxyAllowed (active only when OPENSHELL_SANDBOX=1, injected by OpenShell at pod startup) Same as Patch 1; gated to OpenShell sandbox runtime only — bare-metal OpenClaw and other wrappers keep the upstream SSRF check ✅ E2E via Telegram media fetch; broader effect intentional
/sandbox/.openclaw-data/media directory + workspace/media symlink Generic OpenClaw media stateDir wiring ✅ Implicitly verified (the Telegram E2E writes a file here)

Why Patches 1 and 2 are necessarily fetch-guard-wide: they operate on the openclaw module's exports, not on individual messaging channels. Inside NemoClaw's proxy-only sandbox, every fetch must go through the proxy and every fetch hits the same SSRF check. Narrowing these patches to "Telegram-only" would require a different patching strategy that NemoClaw maintains long-term. The wider reach is consistent with the sandbox's architectural constraint (proxy-only egress), so it is presented as intended behavior, not collateral.

Discord and Slack are explicitly out of scope. Their account configs are not touched by this PR. Any latent improvement they get from the fetch-guard patches inside the sandbox is incidental and not claimed here. If Discord/Slack media downloads also need restoring, they belong in a separate PR with their own end-to-end verification.

Fail-close design

Both sed patches use set -eu and explicit pre/post-grep verification:

  • Pre: test -n "$matches" aborts if no candidate file matches.
  • Post: positive grep for the inserted marker (Patch 2) and explicit if grep ...; then exit 1; fi that no withStrictGuardedFetchMode as <letter> export remains (Patch 1, written this way because set -e does not propagate failure through !-prefixed commands).

If a future OpenClaw bundle reorganizes the function or exports, the build fails at the next OPENCLAW_VERSION bump instead of silently producing broken media downloads. The next maintainer reviewing the bump PR sees the failure with full context in this Dockerfile comment.

Removal criteria

  • Patch 1 can be dropped when OpenClaw deprecates withStrictGuardedFetchMode, or when all proxy-relevant callsites unconditionally pass useEnvProxy.
  • Patch 2 can be dropped when OpenClaw fixes assertExplicitProxyAllowed to skip the target's hostname allowlist when validating the proxy, or exposes config to disable the check. The OPENSHELL_SANDBOX env var is set by OpenShell, not by this PR — no extra cleanup needed.

Testing

End-to-end on WSL2 + Docker Desktop, against openclaw@2026.4.2 (CI's pinned version):

Build verification

  1. Reproduced the CI build step in isolation (docker build on a minimal Dockerfile that pulls openclaw@2026.4.2 and runs the same RUN block) — patches apply cleanly, post-patch verification passes.
  2. Full docker build with the production Dockerfile (using BASE_IMAGE=ghcr.io/nvidia/nemoclaw/sandbox-base:latest) succeeds. All 19 stages pass, no set -eu aborts.
  3. Inspected the resulting image to confirm both patches are persisted in the openclaw bundle:
    $ grep export ... fetch-guard-*.js
    export { withTrustedEnvProxyGuardedFetchMode as a, withTrustedEnvProxyGuardedFetchMode as i, ... };
    
    $ grep 'async function assertExplicitProxyAllowed' ...
    async function assertExplicitProxyAllowed(...) { if (process.env.OPENSHELL_SANDBOX === "1") return; /* nemoclaw: env-gated bypass */
    
  4. Inside a running OpenShell sandbox, confirmed the env marker that triggers Patch 2 is present (set by OpenShell at pod startup, not by this image):
    $ openshell sandbox exec -n <name> -- env | grep OPENSHELL_SANDBOX
    OPENSHELL_SANDBOX=1
    

End-to-end Telegram round-trip

Setup: nemoclaw onboard with Telegram channel enabled and a real bot token. Inference provider: Anthropic claude-haiku-4-5 (chosen because vision support is reliable, so a successful model description confirms the file actually reached the model intact, not just that download succeeded).

  1. Sent a JPEG photo from Telegram → bot received getUpdates, called POST /bot[CREDENTIAL]/getFile (ALLOWED), then GET /file/bot[CREDENTIAL]/photos/file_X.jpg (ALLOWED — the request that previously never reached the proxy).
  2. OpenClaw application log confirms the file was saved and resized:
    Image resized to fit limits: file_0---*.jpg 719x1280px 126.7KB -> 136.4KB
    
  3. Pulled the saved JPEG out of the sandbox via openshell sandbox download and visually confirmed the bytes match the source photo (motorcycle dashboard, then a Japanese curry menu — the bytes are not corrupted in transit).
  4. Claude Haiku correctly described the contents of both photos in its reply, end-to-end confirming download → save → read tool → model vision pipeline works.
  5. No MediaFetchError, no Blocked hostname, no Failed to download media in any log layer.
  6. Reproduced the exact failure mode prior to Patch 2 on a separate onboard (Patch 1 only, no env-gated bypass):
    MediaFetchError: Failed to fetch media from
    https://api.telegram.org/file/bot.../photos/file_2.jpg:
    Blocked hostname (not in allowlist): 10.200.0.1
    
  7. Reproduced an additional failure mode where the env gate referenced a Dockerfile-set ENV var (NEMOCLAW_SANDBOX=1) — that var did not propagate into the OpenShell sandbox runtime, so the gate stayed false and the bug recurred. Switched the gate to OPENSHELL_SANDBOX (injected by OpenShell at pod startup) and the e2e flow became reliable.

Type of Change

  • Code change (feature, bug fix, or refactor)
  • Code change with doc updates
  • Doc only (prose changes, no code sample modifications)
  • Doc only (includes code sample changes)

Verification

  • npx prek run --all-files passes
  • npm test passes (pre-existing CLI lifecycle-recovery test failures reproduce on origin/main — unrelated)
  • Tests added or updated for new or changed behavior (not applicable — Dockerfile-only patch, validated by build + e2e)
  • No secrets, API keys, or credentials committed
  • Docs updated for user-facing behavior changes (not applicable — internal sandbox patch, no public surface change)
  • make docs builds without warnings (doc changes only)
  • Doc pages follow the style guide (doc changes only)
  • New doc pages include SPDX header and frontmatter (new pages only)

AI Disclosure

  • AI-assisted — tool: Claude Code

Signed-off-by: Tommy Lin tommylin@signalpro.com.tw

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 10, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Build-time Dockerfile changes patch bundled OpenClaw JS to switch SSRF-guarded fetch calls to a trusted-env/proxy mode, add conditional proxy entries for telegram/discord/slack in generated openclaw.json, and extend sandbox initialization to create and symlink a media directory.

Changes

Cohort / File(s) Summary
Dockerfile — build-time JS patching
Dockerfile
Adds a RUN step that scans bundled OpenClaw JS in /usr/local/lib/node_modules/openclaw/dist/ and replaces fetchWithSsrFGuard(withStrictGuardedFetchMode(...)) with fetchWithSsrFGuard(withTrustedEnvProxyGuardedFetchMode(...)), adjusting symbols presence checks.
OpenClaw generated config
.../openclaw.json
Updates generated accounts.default to conditionally include a proxy field (constructed from NEMOCLAW_PROXY_HOST/NEMOCLAW_PROXY_PORT) for telegram, discord, and slack channels.
Sandbox filesystem initialization
Dockerfile (init steps), /sandbox/.openclaw-data/...
Creates /sandbox/.openclaw-data/media, includes media in the loop that creates/overlays .openclaw-data subfolders (logs/credentials/sandbox/media), removes any existing /sandbox/.openclaw-data/workspace/media and symlinks it to /sandbox/.openclaw-data/media.

Sequence Diagram(s)

sequenceDiagram
    participant Build as Build (Dockerfile)
    participant Bundle as OpenClawBundle
    participant Runtime as App Runtime
    participant Proxy as TrustedEnvProxy
    participant External as ExternalHost/Media

    rect rgba(200,200,255,0.5)
    Build->>Bundle: Patch bundled JS\nreplace strict guarded fetch with trusted-env/proxy mode
    end

    rect rgba(200,255,200,0.5)
    Runtime->>Proxy: Request external resource (telegram/discord/slack/media)
    Proxy->>External: Forward request using proxy host/port
    External-->>Proxy: Return response
    Proxy-->>Runtime: Deliver proxied response
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I nudged the bundle, swapped a guarded stitch,
Tuned fetch to hop through a trusted niche.
I planted media roots and linked them tight,
Now messages and images step through the light —
A tiny rabbit cheers the build tonight.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title accurately summarizes the primary fix: restoring Telegram media downloads in proxy-only sandboxes, which aligns with the main technical changes in the Dockerfile.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Dockerfile`:
- Around line 53-57: After the sed rewrite in the Dockerfile's RUN block that
targets openclaw/dist, add post-patch verification that fails the build if the
transformation didn't happen: grep to assert the old import/identifier
"withStrictGuardedFetchMode" (and the old callsite pattern using
fetchWithSsrFGuard(withStrictGuardedFetchMode({) is no longer present) and grep
to assert the new import name "withTrustedEnvProxyGuardedFetchMode" and the new
callsite using fetchWithSsrFGuard(withTrustedEnvProxyGuardedFetchMode({) does
exist; if either check fails, exit non-zero so the image build closes rather
than silently succeeding. Ensure checks run immediately after the sed step that
edits withStrictGuardedFetchMode/fetchWithSsrFGuard.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 0408c174-9bce-4676-90d2-55820441cf9c

📥 Commits

Reviewing files that changed from the base of the PR and between aa52ed8 and 7ae2d98.

📒 Files selected for processing (1)
  • Dockerfile

Comment thread Dockerfile Outdated
Comment thread Dockerfile Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
Dockerfile (1)

72-72: Static analysis flag: NEMOCLAW_PROVIDER_KEY naming is misleading.

Trivy flags this as potential secret exposure. While this is a false positive (the value is used as a provider identifier/name at Line 155, not an actual credential—the API key is hardcoded as 'unused' at Line 157), the naming PROVIDER_KEY suggests it's a secret when it's really a provider name.

Consider renaming to NEMOCLAW_PROVIDER_NAME or NEMOCLAW_PROVIDER_ID in a future cleanup to avoid confusion and static analysis noise.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Dockerfile` at line 72, The ARG NEMOCLAW_PROVIDER_KEY in the Dockerfile is
named like a secret and triggers static analysis; rename the argument to a
non-secret-sounding identifier (e.g., NEMOCLAW_PROVIDER_NAME or
NEMOCLAW_PROVIDER_ID) and update all usages that reference NEMOCLAW_PROVIDER_KEY
(including where the provider value is consumed) to the new name to avoid Trivy
false positives while preserving functionality.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@Dockerfile`:
- Line 72: The ARG NEMOCLAW_PROVIDER_KEY in the Dockerfile is named like a
secret and triggers static analysis; rename the argument to a
non-secret-sounding identifier (e.g., NEMOCLAW_PROVIDER_NAME or
NEMOCLAW_PROVIDER_ID) and update all usages that reference NEMOCLAW_PROVIDER_KEY
(including where the provider value is consumed) to the new name to avoid Trivy
false positives while preserving functionality.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5eb225dc-d403-4cdc-abd0-f7bcddc605ed

📥 Commits

Reviewing files that changed from the base of the PR and between 7ae2d98 and 65cb7be.

📒 Files selected for processing (1)
  • Dockerfile

@wscurran wscurran added the Integration: Telegram Use this label to identify Telegram bot integration issues with NemoClaw. label Apr 13, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
Dockerfile (2)

53-57: ⚠️ Potential issue | 🟠 Major

Fail this dist rewrite closed.

This RUN step still succeeds even when a future openclaw/dist bundle no longer matches these exact minified strings, so proxy-only media downloads can silently regress on base-image updates. Add a post-rewrite assertion that at least one file was patched, the trusted-proxy callsite exists afterward, and the strict callsite is gone.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Dockerfile` around lines 53 - 57, The RUN step that rewrites minified imports
must fail the build if nothing was changed and must verify the replacement
succeeded: after running the sed, add commands to (1) assert at least one file
was modified (e.g., check grep -R -q for withTrustedEnvProxyGuardedFetchMode or
count of changed files), (2) assert the new callsite
fetchWithSsrFGuard(withTrustedEnvProxyGuardedFetchMode({) exists, and (3) assert
the old callsite fetchWithSsrFGuard(withStrictGuardedFetchMode({) no longer
exists; if any check fails, exit non‑zero so the Docker build fails. Use the
existing symbols withStrictGuardedFetchMode,
withTrustedEnvProxyGuardedFetchMode, and fetchWithSsrFGuard to locate/verify
changes.

147-147: ⚠️ Potential issue | 🟠 Major

Honor the proxy build args here.

Line 147 hardcodes 10.200.0.1:3128, so the documented NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT override never reaches Telegram/Discord/Slack media calls on non-default networks.

Suggested fix
 RUN python3 -c "\
 import base64, json, os, secrets; \
 from urllib.parse import urlparse; \
+proxy_url = f\"http://{os.environ['NEMOCLAW_PROXY_HOST']}:{os.environ['NEMOCLAW_PROXY_PORT']}\"; \
 model = os.environ['NEMOCLAW_MODEL']; \
 chat_ui_url = os.environ['CHAT_UI_URL']; \
 provider_key = os.environ['NEMOCLAW_PROVIDER_KEY']; \
@@
-_ch_cfg = {ch: {'accounts': {'default': {_token_keys[ch]: f'openshell:resolve:env:{_env_keys[ch]}', 'enabled': True, **({'proxy': 'http://10.200.0.1:3128'} if ch in ('telegram', 'discord', 'slack') else {}), **({'groupPolicy': 'open'} if ch == 'telegram' else {}), **({'dmPolicy': 'allowlist', 'allowFrom': _allowed_ids[ch]} if ch in _allowed_ids and _allowed_ids[ch] else {})}}} for ch in msg_channels if ch in _token_keys}; \
+_ch_cfg = {ch: {'accounts': {'default': {_token_keys[ch]: f'openshell:resolve:env:{_env_keys[ch]}', 'enabled': True, **({'proxy': proxy_url} if ch in ('telegram', 'discord', 'slack') else {}), **({'groupPolicy': 'open'} if ch == 'telegram' else {}), **({'dmPolicy': 'allowlist', 'allowFrom': _allowed_ids[ch]} if ch in _allowed_ids and _allowed_ids[ch] else {})}}} for ch in msg_channels if ch in _token_keys}; \
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Dockerfile` at line 147, The channel config comprehension that builds _ch_cfg
hardcodes the proxy URL ('http://10.200.0.1:3128') for message channels,
preventing NEMOCLAW_PROXY_HOST/NEMOCLAW_PROXY_PORT build args from taking
effect; update the proxy construction inside the dict comprehension (the branch
for ch in ('telegram', 'discord', 'slack')) to build the proxy string from the
NEMOCLAW_PROXY_HOST and NEMOCLAW_PROXY_PORT environment/build-arg values (or
fall back to the current hardcoded URL if those vars are unset), keeping the
change local to the _ch_cfg expression that uses msg_channels and _token_keys so
the rest of the logic (groupPolicy, dmPolicy, allowFrom) is unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Dockerfile`:
- Line 235: Replace the current ln -sf invocation that links
/sandbox/.openclaw-data/media to /sandbox/.openclaw-data/workspace/media so it
always replaces the target path instead of nesting inside an existing directory;
update the Dockerfile line containing the `ln -sf /sandbox/.openclaw-data/media
/sandbox/.openclaw-data/workspace/media` command to either remove the existing
/sandbox/.openclaw-data/workspace/media before creating the symlink or use a
force-and-treat-destination-as-file ln option (e.g., using -f with -T) so the
target path is replaced rather than creating a nested media directory.

---

Duplicate comments:
In `@Dockerfile`:
- Around line 53-57: The RUN step that rewrites minified imports must fail the
build if nothing was changed and must verify the replacement succeeded: after
running the sed, add commands to (1) assert at least one file was modified
(e.g., check grep -R -q for withTrustedEnvProxyGuardedFetchMode or count of
changed files), (2) assert the new callsite
fetchWithSsrFGuard(withTrustedEnvProxyGuardedFetchMode({) exists, and (3) assert
the old callsite fetchWithSsrFGuard(withStrictGuardedFetchMode({) no longer
exists; if any check fails, exit non‑zero so the Docker build fails. Use the
existing symbols withStrictGuardedFetchMode,
withTrustedEnvProxyGuardedFetchMode, and fetchWithSsrFGuard to locate/verify
changes.
- Line 147: The channel config comprehension that builds _ch_cfg hardcodes the
proxy URL ('http://10.200.0.1:3128') for message channels, preventing
NEMOCLAW_PROXY_HOST/NEMOCLAW_PROXY_PORT build args from taking effect; update
the proxy construction inside the dict comprehension (the branch for ch in
('telegram', 'discord', 'slack')) to build the proxy string from the
NEMOCLAW_PROXY_HOST and NEMOCLAW_PROXY_PORT environment/build-arg values (or
fall back to the current hardcoded URL if those vars are unset), keeping the
change local to the _ch_cfg expression that uses msg_channels and _token_keys so
the rest of the logic (groupPolicy, dmPolicy, allowFrom) is unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4ed54896-70af-498d-a158-b2504e4814be

📥 Commits

Reviewing files that changed from the base of the PR and between 65cb7be and 8b831dd.

📒 Files selected for processing (1)
  • Dockerfile

Comment thread Dockerfile Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
Dockerfile (2)

53-60: ⚠️ Potential issue | 🟠 Major

Fail closed on the import rewrite, not just the callsite rewrite.

Line 59 only proves the callsite text changed. If the exact import replacement on Line 57 stops matching a future OpenClaw bundle, the build still passes but the rewritten file will reference withTrustedEnvProxyGuardedFetchMode without importing it.

Suggested fix
 RUN set -eu; \
     matches="$(grep -RIl --include='*.js' 'fetchWithSsrFGuard(withStrictGuardedFetchMode({' /usr/local/lib/node_modules/openclaw/dist/)"; \
     test -n "$matches"; \
     printf '%s\n' "$matches" | xargs sed -i \
         -e 's|import { n as withStrictGuardedFetchMode, t as fetchWithSsrFGuard }|import { n as withStrictGuardedFetchMode, r as withTrustedEnvProxyGuardedFetchMode, t as fetchWithSsrFGuard }|g' \
         -e 's|fetchWithSsrFGuard(withStrictGuardedFetchMode({|fetchWithSsrFGuard(withTrustedEnvProxyGuardedFetchMode({|g'; \
-    grep -Rq --include='*.js' 'fetchWithSsrFGuard(withTrustedEnvProxyGuardedFetchMode({' /usr/local/lib/node_modules/openclaw/dist/; \
-    ! grep -Rq --include='*.js' 'fetchWithSsrFGuard(withStrictGuardedFetchMode({' /usr/local/lib/node_modules/openclaw/dist/
+    printf '%s\n' "$matches" | while IFS= read -r file; do \
+        grep -q 'r as withTrustedEnvProxyGuardedFetchMode' "$file"; \
+        grep -q 'fetchWithSsrFGuard(withTrustedEnvProxyGuardedFetchMode({' "$file"; \
+        ! grep -q 'fetchWithSsrFGuard(withStrictGuardedFetchMode({' "$file"; \
+    done
#!/bin/bash
set -eu

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

cat >"$tmp/bundle.js" <<'EOF'
import { x as withStrictGuardedFetchMode, t as fetchWithSsrFGuard } from "./guard.js";
export const run = () => fetchWithSsrFGuard(withStrictGuardedFetchMode({}));
EOF

matches="$(grep -RIl --include='*.js' 'fetchWithSsrFGuard(withStrictGuardedFetchMode({' "$tmp")"
printf '%s\n' "$matches" | xargs sed -i \
  -e 's|import { n as withStrictGuardedFetchMode, t as fetchWithSsrFGuard }|import { n as withStrictGuardedFetchMode, r as withTrustedEnvProxyGuardedFetchMode, t as fetchWithSsrFGuard }|g' \
  -e 's|fetchWithSsrFGuard(withStrictGuardedFetchMode({|fetchWithSsrFGuard(withTrustedEnvProxyGuardedFetchMode({|g'

grep -Rq --include='*.js' 'fetchWithSsrFGuard(withTrustedEnvProxyGuardedFetchMode({' "$tmp"
! grep -Rq --include='*.js' 'fetchWithSsrFGuard(withStrictGuardedFetchMode({' "$tmp"

if grep -Rq --include='*.js' 'r as withTrustedEnvProxyGuardedFetchMode' "$tmp"; then
  echo "Unexpected: import rewrite landed"
  exit 1
fi

cat "$tmp/bundle.js"
# Expected: the current verification passes even though the new import fragment is still missing.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Dockerfile` around lines 53 - 60, The import rewrite check is missing so the
build can pass if only the callsite changed; update the Dockerfile block that
edits openclaw dist files (the sed replacements referencing
withStrictGuardedFetchMode, withTrustedEnvProxyGuardedFetchMode and
fetchWithSsrFGuard) to also assert the import replacement occurred by grepping
for the new import fragment (e.g. the token sequence "r as
withTrustedEnvProxyGuardedFetchMode" or the full changed import) and fail the
build if that import string is not present; in short, after the sed invocation
and the existing callsite grep, add a check that verifies the import line
containing withTrustedEnvProxyGuardedFetchMode was inserted and exit non‑zero if
it was not.

239-239: ⚠️ Potential issue | 🟠 Major

Replace workspace/media instead of linking inside it.

Line 239 still uses ln -sfn, which does not replace a real directory target. If /sandbox/.openclaw-data/workspace/media already exists, this creates .../workspace/media/media and leaves the old layout in place.

Suggested fix
-    && ln -sfn /sandbox/.openclaw-data/media /sandbox/.openclaw-data/workspace/media
+    && mkdir -p /sandbox/.openclaw-data/workspace \
+    && if [ -e /sandbox/.openclaw-data/workspace/media ] && [ ! -L /sandbox/.openclaw-data/workspace/media ]; then \
+        cp -a /sandbox/.openclaw-data/workspace/media/. /sandbox/.openclaw-data/media/ 2>/dev/null || true; \
+        rm -rf /sandbox/.openclaw-data/workspace/media; \
+    fi \
+    && ln -sfnT /sandbox/.openclaw-data/media /sandbox/.openclaw-data/workspace/media
#!/bin/bash
set -eu

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

mkdir -p "$tmp/media" "$tmp/workspace/media"
touch "$tmp/workspace/media/existing"

ln -sfn "$tmp/media" "$tmp/workspace/media"

find "$tmp" -maxdepth 3 -mindepth 1 -printf '%P %y -> %l\n' | sort
# Expected if Line 239 is safe: workspace/media becomes a symlink.
# Actual with GNU ln: a nested workspace/media/media symlink appears.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Dockerfile` at line 239, The ln invocation "ln -sfn
/sandbox/.openclaw-data/media /sandbox/.openclaw-data/workspace/media" can
create a nested media directory if the target already exists; change the
Dockerfile so the existing /sandbox/.openclaw-data/workspace/media is removed or
replaced before creating the symlink (e.g., rm -rf
/sandbox/.openclaw-data/workspace/media && ln -sfn /sandbox/.openclaw-data/media
/sandbox/.openclaw-data/workspace/media) or, if you rely on GNU coreutils, use
the -T flag (ln -sfnT ...) to force-replace the target atomically; update the
line referencing that ln command accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@Dockerfile`:
- Around line 53-60: The import rewrite check is missing so the build can pass
if only the callsite changed; update the Dockerfile block that edits openclaw
dist files (the sed replacements referencing withStrictGuardedFetchMode,
withTrustedEnvProxyGuardedFetchMode and fetchWithSsrFGuard) to also assert the
import replacement occurred by grepping for the new import fragment (e.g. the
token sequence "r as withTrustedEnvProxyGuardedFetchMode" or the full changed
import) and fail the build if that import string is not present; in short, after
the sed invocation and the existing callsite grep, add a check that verifies the
import line containing withTrustedEnvProxyGuardedFetchMode was inserted and exit
non‑zero if it was not.
- Line 239: The ln invocation "ln -sfn /sandbox/.openclaw-data/media
/sandbox/.openclaw-data/workspace/media" can create a nested media directory if
the target already exists; change the Dockerfile so the existing
/sandbox/.openclaw-data/workspace/media is removed or replaced before creating
the symlink (e.g., rm -rf /sandbox/.openclaw-data/workspace/media && ln -sfn
/sandbox/.openclaw-data/media /sandbox/.openclaw-data/workspace/media) or, if
you rely on GNU coreutils, use the -T flag (ln -sfnT ...) to force-replace the
target atomically; update the line referencing that ln command accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7b3667fa-7f87-4776-a9f2-0a578f8cab7e

📥 Commits

Reviewing files that changed from the base of the PR and between 8b831dd and 589056c.

📒 Files selected for processing (1)
  • Dockerfile

@wscurran wscurran added enhancement: integration PRs or issues proposing integration of a third-party product or service into NemoClaw. status: rebase PR needs to be rebased against main before review can continue and removed status: rebase PR needs to be rebased against main before review can continue labels Apr 14, 2026
tommylin-signalpro and others added 5 commits April 16, 2026 12:47
…ly sandbox

Three issues prevent inbound media (photos, voice, documents) from working
when messaging channels are enabled in a NemoClaw sandbox:

1. OpenClaw's fetchRemoteMedia uses SSRF strict mode (DNS pinning +
   direct connect) which bypasses the OpenShell L7 proxy. The sandbox
   netns blocks direct egress, so file downloads fail with
   TypeError: fetch failed. This is the same root cause as NVIDIA#1252 but
   affects a different code path (local-roots-*.js and compact-*.js
   bundles) that was not covered by the existing web_search/web_fetch
   patch.

   Fix: patch all OpenClaw dist bundles at image build time to use
   withTrustedEnvProxyGuardedFetchMode instead of
   withStrictGuardedFetchMode for fetchRemoteMedia.

2. OpenClaw's getFile and other grammy-based Telegram API calls also
   bypass the proxy because the channel account config lacks a proxy
   field. Without it, grammy uses its own dispatcher instead of
   EnvHttpProxyAgent.

   Fix: inject proxy: http://10.200.0.1:3128 into all messaging
   channel account configs generated at image build time.

3. Downloaded media is saved to /sandbox/.openclaw/media/inbound/ but
   that directory does not exist. .openclaw/ is Landlock read-only
   and media was not included in the .openclaw-data symlink set.
   This causes EACCES: permission denied, mkdir .openclaw/media.

   Fix: add .openclaw-data/media to the mkdir, chown, and symlink
   loop alongside logs, credentials, and sandbox.

Tested end-to-end: Telegram photo with caption is downloaded, saved,
resized, and passed to the inference model successfully.

Related: NVIDIA#1252, NVIDIA#1301

Signed-off-by: Tommy Lin <tommylin@signalpro.com.tw>
OpenClaw saves inbound media to stateDir/media/inbound/ but the agent
read tool looks in stateDir/workspace/media/inbound/ (upstream bug:
openclaw/openclaw#64434). Bridge the gap with a symlink so downloaded
Telegram/Discord photos are readable by the agent until upstream fixes
the path inconsistency.

Signed-off-by: Tommy Lin <tommylin@signalpro.com.tw>
- Add post-patch verification to fail-close the sed rewrite: assert new
  pattern exists and old pattern is gone, so the build fails if openclaw
  bundle structure changes
- Use NEMOCLAW_PROXY_HOST/NEMOCLAW_PROXY_PORT build args instead of
  hardcoding proxy URL in channel config
- Change ln -sf to ln -sfn to prevent symlink nesting when workspace/media
  already exists as a directory

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add post-patch assertion for import fragment to catch minified alias
  drift that would leave the callsite referencing an unimported symbol
- Replace ln -sfn with rm -rf + ln -s to handle workspace/media as a
  real directory, not just a symlink

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Only patch files that import withStrictGuardedFetchMode via the
module import pattern. Files like mattermost.js and shared-*.js
define the function inline and would crash with ReferenceError if
the callsite is rewritten without a matching import.

Drop the global negative grep assertion since inline-definition
files legitimately retain withStrictGuardedFetchMode callsites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@tommylin-signalpro tommylin-signalpro force-pushed the fix/telegram-media-download-in-sandbox branch from 5e31aec to 32af26e Compare April 16, 2026 04:53
@cv cv added the v0.0.18 Release target label Apr 16, 2026
tommylin-signalpro and others added 4 commits April 16, 2026 18:25
The previous approach matched a specific minified import alias pattern
(`n as withStrictGuardedFetchMode`) that worked on openclaw@2026.3.11
but broke on 2026.4.2 where the aliases became `i`/`n` instead of `n`/`t`.
The CI build failed at the fail-close verification step.

Switch to rewriting the fetch-guard module export so any consumer that
imports the strict-mode alias receives the trusted-env-proxy function.
The export pattern `withStrictGuardedFetchMode as <letter>` is stable
across versions while alias letters vary between minified bundles.

Files that define withStrictGuardedFetchMode locally without exporting
(e.g. mattermost.js) keep their original strict behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…y hostname

OpenClaw 2026.4.2 introduced assertExplicitProxyAllowed() in fetch-guard,
which validates the explicit proxy URL by passing the proxy hostname through
resolvePinnedHostnameWithPolicy() with the *target's* SsrfPolicy. When the
target uses hostnameAllowlist (e.g. Telegram media: ["api.telegram.org"]),
the proxy hostname (10.200.0.1) is rejected because it is not in the
target's allowlist. This breaks every Telegram/Discord/Slack media download
in NemoClaw's proxy-only sandbox, with no config or env var to disable it.

Inject an early `return;` in the function so the proxy hostname is never
validated against the target allowlist. The OpenShell L7 proxy still
enforces per-endpoint network policy, so SSRF protection at the trust
boundary is unchanged.

Both this patch and the existing fetch-guard export rewrite share the same
RUN block with set -eu plus pre/post-grep verification, so a future
OPENCLAW_VERSION bump fails-close at CI when openclaw bundle structure
changes — the next maintainer sees the failure with full context in the
Dockerfile comment.

Replace `! grep` (skipped by set -e under !-prefix) with explicit
`if grep; then exit 1; fi` so Patch 1's negative invariant actually
fails the build when violated (SC2251).

End-to-end verified: Telegram photo download now reaches /file/bot.../path
successfully and the file is saved to /sandbox/.openclaw/media/inbound/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the unconditional `return;` injection in assertExplicitProxyAllowed
with `if (process.env.NEMOCLAW_SANDBOX === "1") return;` and set
`ENV NEMOCLAW_SANDBOX=1` on the production sandbox image. Outside NemoClaw's
sandbox runtime the env var is unset, so the upstream SSRF check on the
explicit proxy hostname stays in effect.

This narrows the bypass from "globally neuter the SSRF check" to
"sandbox-specific compatibility workaround" and keeps the upstream
behavior intact for anyone running OpenClaw bare-metal or in another
wrapper that uses the same bundle without NEMOCLAW_SANDBOX set.

Update Patch 2 fail-close marker grep to match the new injected snippet
so a future OPENCLAW_VERSION bump still aborts the build if the function
signature changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@tommylin-signalpro tommylin-signalpro changed the title fix(sandbox): telegram/discord/slack media downloads fail in proxy-only sandbox fix(sandbox): restore Telegram media downloads in proxy-only sandbox Apr 16, 2026
tommylin-signalpro and others added 3 commits April 16, 2026 23:53
Align implementation scope with PR title and verified test scope. The
previous code injected `proxy: <url>` into telegram, discord, and slack
account configs, but only telegram was end-to-end verified. Narrow the
channel config injection to telegram only so the patch matches what we
claim it fixes.

Discord and Slack remain unaffected by this change: main currently
injects no proxy config for them either, so this is not a regression,
just no extension of the fix to those channels. Future PRs can extend
proxy config to discord/slack with their own end-to-end verification.

The fetch-guard module patches (Patch 1 export rewrite, Patch 2 env-
gated bypass) still apply to every openclaw fetch path inside the
sandbox by construction. That is intentional given NemoClaw's proxy-
only architecture, not an unscoped side effect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous gate used `NEMOCLAW_SANDBOX=1` set via Dockerfile `ENV`, but
OpenShell controls the sandbox pod env at runtime and image-level ENV vars
do not propagate to the running sandbox process. The gate condition
therefore evaluated false and the SSRF check still ran, reproducing the
original "Blocked hostname (not in allowlist): 10.200.0.1" failure on
freshly onboarded sandboxes.

OpenShell injects `OPENSHELL_SANDBOX=1` into every sandbox pod automatically
(observed in the sandbox shell env). Use that as the gate condition; it is
the only env marker reliably present in the sandbox runtime, and its
semantics ("we are inside an OpenShell sandbox") match exactly the scope
the bypass needs.

Drop the unused `ENV NEMOCLAW_SANDBOX=1` from the Dockerfile; it never
propagated to the sandbox runtime and would only mislead future readers.

End-to-end re-verified on a freshly onboarded sandbox: Telegram photo
downloaded successfully and Claude Haiku correctly described the image
content (motorcycle dashboard, Japanese curry menu) — both round-trips
exercised the patched fetch-guard path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@prekshivyas prekshivyas self-assigned this Apr 16, 2026
@prekshivyas prekshivyas requested a review from cv April 16, 2026 21:23
@cv cv merged commit a0e5903 into NVIDIA:main Apr 16, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working Docker Support for Docker containerization enhancement: integration PRs or issues proposing integration of a third-party product or service into NemoClaw. fix Integration: OpenClaw Support for OpenClaw Integration: Telegram Use this label to identify Telegram bot integration issues with NemoClaw. v0.0.18 Release target

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants