我用 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
2
3
4
5
6
7
8
9
10
11
function modelSupportsImages(model) {
return model.input?.includes("image") ?? false;
}

async function detectAndLoadPromptImages(params) {
// 如果 model 不支援圖片,直接返回空陣列——靜默跳過
if (!modelSupportsImages(params.model)) return {
images: [], detectedRefs: [], loadedCount: 0, skippedCount: 0
};
// ...
}

問題出在 OpenClaw 的 model 解析邏輯。我用的 openai-codex provider 在 OpenClaw 的 static config 裡 models: [] 是空陣列,model registry 查不到這個 model,fallback 路徑直接寫死了 input: ["text"]

1
2
3
4
5
6
7
// resolveModelWithRegistry fallback
return {
provider,
baseUrl: providerConfig?.baseUrl,
input: ["text"], // ← 寫死 text-only,vision pipeline 永遠不會觸發
// ...
};

修法: 直接 patch bundle,把 ["text"] 改成 ["text", "image"]

改完之後,detectAndLoadPromptImages 確實被呼叫了,supports=true。但圖片還是沒進來

第二層:下載函數根本沒被呼叫?

我在 bundle 裡注入了 6 個 console.log debug 點,覆蓋整條管線:

1
2
processInboundMessage → resolveMedia → downloadAndSaveTelegramFile
→ buildInboundMediaNote → detectAndLoadPromptImages

重啟容器,請使用者再傳一張圖。Log 出來了:

1
2
3
4
[DEBUG] processInboundMessage: has_photo=true ✅
[DEBUG] resolveMedia ENTRY: has_photo=true ✅
[DEBUG] getFile result: {"file_path":"photos/file_4.jpg"} ✅
[DEBUG] buildInboundMediaNote MediaPaths=undefined ❌

resolveMedia 進去了,Telegram API 的 getFile 也成功拿到 file_path。但接下來的 downloadAndSaveTelegramFile 裡放的「download succeeded」log 完全沒出現catch 裡的 log 也沒出現。MediaPathsundefined

下載函數被呼叫了,但結果消失在黑洞裡。

第三層:catch 吃掉了錯誤

原來 catch 只用了 logVerbose,這個函數在 Docker logs 裡不一定可見。加了 console.log 到 catch block,重啟再測:

1
2
[DEBUG] resolveMedia DOWNLOAD FAILED: MediaFetchError: Failed to fetch media
from https://api.telegram.org/file/bot.../photos/file_4.jpg: TypeError: fetch failed

圖片下載失敗了。 TypeError: fetch failed — Node.js undici 的泛用錯誤。

第四層:curl 正常,Node.js 不行

在容器裡直接 curl 同一個 URL:

1
2
3
4
$ curl -sS -o /dev/null -w "http_code=%{http_code} time=%{time_total}s" \
"https://api.telegram.org/file/bot.../photos/file_4.jpg"

http_code=200 time=1.453145s size=24795

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function resolveTelegramFetch(proxyFetch, options) {
// WSL2 偵測:關閉 autoSelectFamily,強制 IPv4
// autoSelectFamily=false, dnsResultOrder=ipv4first
// 還有 sticky IPv4 fallback 機制
return async (input, init) => {
const initialInit = withDispatcherIfMissing(init, defaultDispatcher);
try {
return await sourceFetch(input, initialInit);
} catch (err) {
if (shouldRetryWithIpv4Fallback(err)) {
// callerProvidedDispatcher=true 時不觸發 fallback
if (callerProvidedDispatcher) return sourceFetch(input, init ?? {});
// ...
}
}
};
}

這個 wrapper 處理了 WSL2 的 IPv6/DNS 問題。Bot 的 polling 連線用這個 fetch,所以 API 呼叫正常。

但 OpenClaw 的圖片下載走了不同的路:

1
2
3
4
downloadAndSaveTelegramFile
→ fetchRemoteMedia(通用的 media 下載函數)
→ fetchWithSsrFGuard(SSRF 防護層)
→ createPinnedDispatcher(建立自己的 HTTP dispatcher)

OpenClaw 的 fetchWithSsrFGuard 為了防止 SSRF 攻擊,建立了自己的 createPinnedDispatcher。這個 dispatcher 沒有 WSL2 的 autoSelectFamily=falsednsResultOrder=ipv4first 設定。

更妙的是,因為 SSRF guard 在 init 裡塞了自己的 dispatcher,WSL2 fetch wrapper 偵測到 callerProvidedDispatcher=true,判斷「呼叫者有自己的 dispatcher,我不該覆蓋」,所以 IPv4 fallback 也不會觸發

完美的死鎖:

  1. SSRF guard 的 dispatcher 沒有 WSL2 設定 → fetch 失敗
  2. WSL2 wrapper 看到有 caller dispatcher → 不觸發 fallback
  3. 3 次 retry 全部用同樣壞掉的 dispatcher → 全部失敗
  4. catch block 用 logVerbose → 錯誤被靜默吞掉
  5. resolveMedia 返回 nullMediaPaths=undefined → 圖片消失

修法

api.telegram.org 是可信來源,不需要 SSRF 防護。直接讓 downloadAndSaveTelegramFiletelegramFetchImpl(已包含 WSL2 設定的 fetch),繞過 fetchRemoteMedia 和 SSRF guard:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
async function downloadAndSaveTelegramFile(params) {
const url = `https://api.telegram.org/file/bot${params.token}/${params.filePath}`;

const doFetch = async () => {
// 直接用 telegramFetchImpl,它有 WSL2 的 IPv4 設定
const res = await params.fetchImpl(url, {
signal: AbortSignal.timeout(30000)
});
if (!res.ok) {
throw new MediaFetchError("http_error",
`HTTP ${res.status} from ${url}`);
}
const buffer = Buffer.from(await res.arrayBuffer());
if (params.maxBytes && buffer.byteLength > params.maxBytes) {
throw new MediaFetchError("max_bytes",
`Size ${buffer.byteLength} exceeds ${params.maxBytes}`);
}
return { buffer, contentType: res.headers.get("content-type") };
};

const fetched = await retryAsync(doFetch, {
attempts: 3, minDelayMs: 1500, maxDelayMs: 6000, jitter: 0.2
});

return saveMediaBuffer(
fetched.buffer, fetched.contentType, "inbound", params.maxBytes
);
}

Patch 之後再傳圖片,log 終於出現了完整的管線:

1
2
3
4
[DEBUG] resolveMedia: download succeeded, path=/home/node/.openclaw/media/inbound/file_4---2de5c498.jpg
[DEBUG] buildInboundMediaNote MediaPaths=["/home/node/.openclaw/media/inbound/file_4---2de5c498.jpg"]
[DEBUG] mediaNote="[media attached: /home/node/.openclaw/media/inbound/file_4---2de5c498.jpg (image/jpeg)]"
[DEBUG] detectAndLoadPromptImages model.input=["text","image"] supports=true

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 logVerboselog.debuglog.warn 這些在容器環境可能不會輸出到 docker logs。追 bug 的時候,console.log 最可靠。

懷疑網路時,先 curl。 如果 curl 正常但 Node.js 不行,問題在 Node.js 的 HTTP client 層。WSL2 + Node.js 22 (undici) 是已知的問題組合。

給 WSL2 + Docker + Node.js 使用者的建議

  1. docker-compose.yml 加 DNSdns: [8.8.8.8, 8.8.4.4],避免用 WSL2 的 DNS proxy
  2. 注意 SSRF guard:OpenClaw 等框架的 media 下載會經過 SSRF 防護層,這層建立的 HTTP dispatcher 通常沒有 WSL2 的特殊設定
  3. **Node.js 22 的 autoSelectFamily**:預設會嘗試 IPv6 → IPv4 fallback,在 WSL2 環境可能造成 timeout。設 autoSelectFamily: false 強制只用 IPv4
  4. **dnsResultOrder: "ipv4first"**:讓 DNS 解析優先返回 IPv4 地址
  5. 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 #44639fix(telegram): thread media transport policy into SSRF),正式修復了這個問題。

官方的修法和我的分析完全一致:問題在於 SSRF guard 建立 dispatcher 時沒有把 Telegram 模組的 transport policy(WSL2 的 autoSelectFamily=falsednsResultOrder=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 不是誰寫錯了,是環境的排列組合超出了所有人的預期。