我用 OpenClaw 在 WSL2 + Docker 環境架了一個 Telegram Bot,接上 OpenAI Codex 的 vision model,打算讓它能看圖回答問題。結果使用者傳圖片過來,Bot 只回了一句「我看到的是 <media:image>,沒有實際圖片內容」。
這個 Bug 花了我整個晚上,最後發現根因是:OpenClaw 內部的 SSRF 防護機制建立了自己的 HTTP dispatcher,覆蓋掉了 WSL2 專用的 IPv4 網路設定,導致圖片下載靜默失敗。 curl 完全正常,Node.js fetch 卻怎麼都不行。
這篇文章記錄完整的追蹤過程。如果你也用 OpenClaw 架 Telegram Bot、或在 WSL2 + Docker 裡跑 Node.js 服務遇到 TypeError: fetch failed,這篇或許能幫上忙。
什麼是 OpenClaw
OpenClaw 是一個開源的 AI agent gateway,可以把 LLM(Claude、GPT、Ollama 等)接上 Telegram、Discord、Slack 等聊天平台。它跑在 Docker 容器裡,用 Node.js 22 + grammy 處理 Telegram Bot API,內建 media 下載、vision pipeline、SSRF 防護等功能。
我的設定是 OpenClaw gateway 跑在 WSL2 的 Docker 裡,接 OpenAI Codex GPT-5.3(支援 vision),透過 Telegram Bot 跟使用者互動。
環境
- Windows 11 + WSL2 (Ubuntu 24.04)
- Docker Desktop,容器內跑 Node.js 22
- OpenClaw 2026.3.9(Docker compose 部署,2026.3.13 已官方修復)
- Model:OpenAI Codex GPT-5.3(支援 vision)
症狀:事件進來了,圖片沒進來
使用者傳圖片到 Telegram Bot,OpenClaw 收到事件,Bot 開始打字,然後兩分鐘後超時。回覆內容裡只看得到 <media:image> 這個 placeholder 文字,LLM 完全沒看到實際的圖片。
在 OpenClaw 的 Discord 社群問了其他人,圖片功能正常。GitHub 上也有類似的 issue(#7564、#23452),但都沒有針對 WSL2 環境的解法。問題只出在我的環境。
第一層:Model 不認為自己支援圖片
先看 log,完全找不到任何 media 相關的記錄。零。
追進 OpenClaw 的 compiled bundle(/app/dist/reply-Bk3-pw7h.js,沒有原始碼可以直接改),發現 vision pipeline 的入口有一個 gate:
1 | function modelSupportsImages(model) { |
問題出在 OpenClaw 的 model 解析邏輯。我用的 openai-codex provider 在 OpenClaw 的 static config 裡 models: [] 是空陣列,model registry 查不到這個 model,fallback 路徑直接寫死了 input: ["text"]:
1 | // resolveModelWithRegistry fallback |
修法: 直接 patch bundle,把 ["text"] 改成 ["text", "image"]。
改完之後,detectAndLoadPromptImages 確實被呼叫了,supports=true。但圖片還是沒進來。
第二層:下載函數根本沒被呼叫?
我在 bundle 裡注入了 6 個 console.log debug 點,覆蓋整條管線:
1 | processInboundMessage → resolveMedia → downloadAndSaveTelegramFile |
重啟容器,請使用者再傳一張圖。Log 出來了:
1 | [DEBUG] processInboundMessage: has_photo=true ✅ |
resolveMedia 進去了,Telegram API 的 getFile 也成功拿到 file_path。但接下來的 downloadAndSaveTelegramFile 裡放的「download succeeded」log 完全沒出現,catch 裡的 log 也沒出現。MediaPaths 是 undefined。
下載函數被呼叫了,但結果消失在黑洞裡。
第三層:catch 吃掉了錯誤
原來 catch 只用了 logVerbose,這個函數在 Docker logs 裡不一定可見。加了 console.log 到 catch block,重啟再測:
1 | [DEBUG] resolveMedia DOWNLOAD FAILED: MediaFetchError: Failed to fetch media |
圖片下載失敗了。 TypeError: fetch failed — Node.js undici 的泛用錯誤。
第四層:curl 正常,Node.js 不行
在容器裡直接 curl 同一個 URL:
1 | $ curl -sS -o /dev/null -w "http_code=%{http_code} time=%{time_total}s" \ |
200,1.4 秒,24KB。完全正常。
但 Node.js 的 fetch 就是不行。3 次 retry 全部失敗,耗時 6 秒後放棄。
curl 用系統 DNS resolver;Node.js 22 用 undici 自己的 HTTP client。WSL2 的 DNS proxy(10.255.255.254)在 IPv6 場景下會出問題,Node.js undici 特別容易踩到。
第五層:找到真正的兇手
OpenClaw 的 Telegram 模組有一個專門為 WSL2 打造的 fetch wrapper:
1 | function resolveTelegramFetch(proxyFetch, options) { |
這個 wrapper 處理了 WSL2 的 IPv6/DNS 問題。Bot 的 polling 連線用這個 fetch,所以 API 呼叫正常。
但 OpenClaw 的圖片下載走了不同的路:
1 | downloadAndSaveTelegramFile |
OpenClaw 的 fetchWithSsrFGuard 為了防止 SSRF 攻擊,建立了自己的 createPinnedDispatcher。這個 dispatcher 沒有 WSL2 的 autoSelectFamily=false 和 dnsResultOrder=ipv4first 設定。
更妙的是,因為 SSRF guard 在 init 裡塞了自己的 dispatcher,WSL2 fetch wrapper 偵測到 callerProvidedDispatcher=true,判斷「呼叫者有自己的 dispatcher,我不該覆蓋」,所以 IPv4 fallback 也不會觸發。
完美的死鎖:
- SSRF guard 的 dispatcher 沒有 WSL2 設定 → fetch 失敗
- WSL2 wrapper 看到有 caller dispatcher → 不觸發 fallback
- 3 次 retry 全部用同樣壞掉的 dispatcher → 全部失敗
- catch block 用
logVerbose→ 錯誤被靜默吞掉 resolveMedia返回null→MediaPaths=undefined→ 圖片消失
修法
api.telegram.org 是可信來源,不需要 SSRF 防護。直接讓 downloadAndSaveTelegramFile 用 telegramFetchImpl(已包含 WSL2 設定的 fetch),繞過 fetchRemoteMedia 和 SSRF guard:
1 | async function downloadAndSaveTelegramFile(params) { |
Patch 之後再傳圖片,log 終於出現了完整的管線:
1 | [DEBUG] resolveMedia: download succeeded, path=/home/node/.openclaw/media/inbound/file_4---2de5c498.jpg |
Bot 回覆:「你傳的是 Windows 10 預設桌布,藍色背景,右側有發光的 Windows logo。」
踩過的坑
| 坑 | 症狀 | 原因 |
|---|---|---|
| Model 不支援圖片 | vision pipeline 被靜默跳過 | fallback 路徑 hardcode input: ["text"] |
| 下載失敗被吞掉 | log 裡完全看不到錯誤 | catch 用 logVerbose 不一定輸出到 stdout |
| curl 正常但 fetch 不行 | TypeError: fetch failed |
SSRF guard dispatcher 沒有 WSL2 IPv4 設定 |
| IPv4 fallback 不生效 | 重試全部失敗 | SSRF guard 提供了自己的 dispatcher,觸發了 callerProvidedDispatcher 判斷 |
除錯方法論
這次追了五層才找到根因。回頭看,有效的做法是:
在 compiled bundle 裡注入 console.log。 OpenClaw 跑的是 compiled JS bundle,沒有原始碼可以改,沒有 debug mode 可以開。用 Python 腳本做字串替換,在關鍵函數的入口、出口、catch block 各放一個 log。重啟容器就生效。
二分法定位。 管線有 7 個環節,先在頭尾放 log 確認範圍,再逐步縮小。第一輪就確認「resolveMedia 有進去但結果消失」,大幅縮小搜索空間。
永遠在 catch block 加 console.log。 logVerbose、log.debug、log.warn 這些在容器環境可能不會輸出到 docker logs。追 bug 的時候,console.log 最可靠。
懷疑網路時,先 curl。 如果 curl 正常但 Node.js 不行,問題在 Node.js 的 HTTP client 層。WSL2 + Node.js 22 (undici) 是已知的問題組合。
給 WSL2 + Docker + Node.js 使用者的建議
- docker-compose.yml 加 DNS:
dns: [8.8.8.8, 8.8.4.4],避免用 WSL2 的 DNS proxy - 注意 SSRF guard:OpenClaw 等框架的 media 下載會經過 SSRF 防護層,這層建立的 HTTP dispatcher 通常沒有 WSL2 的特殊設定
- **Node.js 22 的
autoSelectFamily**:預設會嘗試 IPv6 → IPv4 fallback,在 WSL2 環境可能造成 timeout。設autoSelectFamily: false強制只用 IPv4 - **
dnsResultOrder: "ipv4first"**:讓 DNS 解析優先返回 IPv4 地址 - hot-patch 要做記錄:容器重建後 patch 會消失,把修改腳本和說明存好
這個 Bug 的本質不複雜——就是 HTTP dispatcher 設定不一致。但它被埋在 OpenClaw 的五層抽象底下,每一層都有合理的設計理由(SSRF 防護、DNS pinning、IPv6 支援),只是組合起來在 WSL2 這個特殊環境下剛好壞掉。
順帶一提,這些 hot-patch 在 OpenClaw 容器重建後會消失。我把修改腳本存了下來,每次 docker compose up 之後重新跑一次就好。如果你也遇到一樣的問題,可以參考 GitHub issue #7564 和 #23452 的討論。
後記:官方在 2026.3.13 正式修復了
這篇文章發出兩天後,OpenClaw 2026.3.13 版本合併了 PR #44639(fix(telegram): thread media transport policy into SSRF),正式修復了這個問題。
官方的修法和我的分析完全一致:問題在於 SSRF guard 建立 dispatcher 時沒有把 Telegram 模組的 transport policy(WSL2 的 autoSelectFamily=false、dnsResultOrder=ipv4first)帶進去。修復方式是把 media transport policy 一路 thread 進 SSRF 層,讓 pinned dispatcher 也能繼承 WSL2 的網路設定。
更新到 2026.3.13 之後,之前的 hot-patch 就不需要了。圖片下載直接走官方的程式碼路徑,SSRF 防護和 WSL2 相容性兩者兼得。
回頭看,這次除錯最有價值的部分不是 patch 本身,而是定位問題的過程——從「圖片消失」到「SSRF dispatcher 覆蓋 WSL2 設定」,穿過五層抽象,每一層都需要在沒有原始碼的 compiled bundle 裡注入 debug log 才能看清。即使官方修了,這套方法論在下次遇到類似問題時還是用得上。
有時候 Bug 不是誰寫錯了,是環境的排列組合超出了所有人的預期。
