8e9bd09072
真因:零件(main.go)給 host function 的接收緩衝區是固定大小(http_request 64KB、
claude_api 1MB)。回應超過這個大小時,wasi-shim 的 writeOut 照寫不誤:
new Uint8Array(buf, outPtr, data.length).set(data)
data 比零件的 outBuf 大 → 覆寫零件堆積體,零件接著 outBuf[:outLen] 切片 panic;
或 writeOut 撞 memory 邊界丟例外 → 回 1 → 零件印一句 "HTTP request failed"。
使用者照那句去查連線/URL/防火牆,方向全錯。
修法(容量握手,不改 host function 簽名、向後相容):
- 零件呼叫前把 outBuf 長度預先寫進 *outLenPtr(宣告容量)
- host 在寫回前讀這個值當上限;塞不下就**不寫**(不再覆寫零件記憶體),回新的
HOST_TOO_LARGE=3
- http_request host fn 收到 3 → 改寫一段講真話的 error envelope(實際大小+上限+
「不是連線失敗」+分頁/篩選的具體做法+機器可讀 code/actual_bytes/limit_bytes),
沿用既有 parsed["error"] 判定鏈原樣送到使用者面前
- 舊零件沒宣告容量(讀到 0)→ 維持舊行為。不可硬套 64KB 預設:各零件緩衝區大小不同,
硬套會把原本正常的大回應誤判成「太大」,那只是換一種說謊
同一條路徑上另一個「訊息與真因脫節」一併修:component-loader 的 makeHttpRunner
`try res.json() catch res.text()`,在零件回非 JSON 時 body 已被消費 → 丟
"Body has already been used",與真因無關(同檔 readBodyOnce 的註解早就寫明這個坑)。
改成只讀一次。
驗證狀態(誠實標示,mindset §7):
- 通:5 顆零件 tinygo build 全過,wasm 已重編進 .component-builds/
(claude_api 依 .gitignore 慣例不入庫,由部署端重編)
- 未跑:runtime 驗證。本 session 的權限層擋掉 node/vitest/wasmtime,
before/after 實測輸出待人跑 scripts/repro-oversize-response.mjs
- 未做:.worker-builds/ 重編(需 node scripts/build-worker-artifacts.mjs),
否則修法不會進 self-hosted 安裝路徑(Arcrun#93 同款陷阱)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
164 lines
5.8 KiB
Go
164 lines
5.8 KiB
Go
// http_request — 發送任意 HTTP 請求,回傳 status + body
|
||
// 透過 host function 發出 HTTP,.wasm 本身不含網路 syscall
|
||
//
|
||
//go:build tinygo
|
||
|
||
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"io"
|
||
"os"
|
||
"strconv"
|
||
"unsafe"
|
||
)
|
||
|
||
// host function 回傳碼,與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組數字。
|
||
// 3 = 資料塞不進零件宣告的接收緩衝區(Arcrun#92:以前這種情況會被說成 "HTTP request failed")
|
||
const hostTooLarge uint32 = 3
|
||
|
||
// host function 宣告(由 WASI shim 注入)
|
||
//
|
||
//go:wasmimport u6u http_request
|
||
func hostHttpRequest(
|
||
urlPtr uintptr, urlLen uint32,
|
||
methodPtr uintptr, methodLen uint32,
|
||
headersPtr uintptr, headersLen uint32,
|
||
bodyPtr uintptr, bodyLen uint32,
|
||
outPtr uintptr, outLenPtr uintptr,
|
||
) uint32
|
||
|
||
type Input struct {
|
||
URL string `json:"url"`
|
||
Method string `json:"method"`
|
||
Headers map[string]string `json:"headers"`
|
||
Body string `json:"body"` // 模式 A:直接 string body
|
||
BodyJSON map[string]interface{} `json:"body_json"` // 模式 B:物件,內部 stringify(避免 yaml 端要自己組 JSON 字串)
|
||
}
|
||
|
||
// dummy byte for safe zero-length unsafe.Pointer operations
|
||
var dummy [1]byte
|
||
|
||
// safePtr returns a valid pointer for an empty-or-nonempty byte slice.
|
||
// TinyGo panics with "index out of range" when taking &b[0] on empty b.
|
||
func safePtr(b []byte) (uintptr, uint32) {
|
||
if len(b) == 0 {
|
||
return uintptr(unsafe.Pointer(&dummy[0])), 0
|
||
}
|
||
return uintptr(unsafe.Pointer(&b[0])), uint32(len(b))
|
||
}
|
||
|
||
func main() {
|
||
raw, err := io.ReadAll(os.Stdin)
|
||
if err != nil {
|
||
writeError("failed to read stdin: " + err.Error())
|
||
return
|
||
}
|
||
|
||
var input Input
|
||
if err := json.Unmarshal(raw, &input); err != nil {
|
||
writeError("invalid input JSON: " + err.Error())
|
||
return
|
||
}
|
||
|
||
if input.URL == "" {
|
||
writeError("url 必填")
|
||
return
|
||
}
|
||
|
||
method := input.Method
|
||
if method == "" {
|
||
method = "GET"
|
||
}
|
||
|
||
headersJSON := "{}"
|
||
if len(input.Headers) > 0 {
|
||
b, _ := json.Marshal(input.Headers)
|
||
headersJSON = string(b)
|
||
}
|
||
|
||
// body 來源優先順序:body_json(物件 → JSON 字串)> body(直接 string)
|
||
bodyStr := input.Body
|
||
if input.BodyJSON != nil {
|
||
b, err := json.Marshal(input.BodyJSON)
|
||
if err == nil {
|
||
bodyStr = string(b)
|
||
}
|
||
}
|
||
|
||
urlBytes := []byte(input.URL)
|
||
methodBytes := []byte(method)
|
||
headersBytes := []byte(headersJSON)
|
||
bodyBytes := []byte(bodyStr)
|
||
outBuf := make([]byte, 65536) // 64KB output buffer
|
||
// 容量握手(Arcrun#92):呼叫前先把緩衝區大小告訴 host。
|
||
// host(cypher-executor/src/lib/wasi-shim.ts 的 writeOut)拿這個值當上限——
|
||
// 塞不下時不會硬寫爆這塊記憶體,而是改寫一段「回應太大 + 實際/上限大小 + 該怎麼辦」
|
||
// 的 error envelope 回來,由下面既有的 parsed["error"] 判定鏈原樣交給使用者。
|
||
outLen := uint32(len(outBuf))
|
||
|
||
urlPtr, urlLen := safePtr(urlBytes)
|
||
methodPtr, methodLen := safePtr(methodBytes)
|
||
headersPtr, headersLen := safePtr(headersBytes)
|
||
bodyPtr, bodyLen := safePtr(bodyBytes)
|
||
|
||
result := hostHttpRequest(
|
||
urlPtr, urlLen,
|
||
methodPtr, methodLen,
|
||
headersPtr, headersLen,
|
||
bodyPtr, bodyLen,
|
||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||
)
|
||
|
||
// host function 回傳碼(定義在 wasi-shim.ts):0=成功 1=host 端錯誤 3=回應塞不下緩衝區
|
||
if result == hostTooLarge {
|
||
// 走到這裡=連「回應太大」的說明本身都塞不進緩衝區(極端情況),
|
||
// 所以零件自己講。訊息一樣要講清楚真因,不能退回 "HTTP request failed"。
|
||
writeError("回應太大,裝不下:對方的回應超過這個零件單次能接收的 64 KB 上限。" +
|
||
"這不是連線失敗,資料也沒有被截掉一半。" +
|
||
"做法:用來源 API 的分頁或篩選參數(例如 limit / page / per_page / fields)把回應縮小再重試。")
|
||
return
|
||
}
|
||
if result != 0 {
|
||
writeError("沒有拿到回應:引擎的 host function 回傳錯誤碼 " + strconv.Itoa(int(result)) +
|
||
"(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題,不是你的 workflow 參數寫錯。")
|
||
return
|
||
}
|
||
|
||
responseStr := string(outBuf[:outLen])
|
||
|
||
// 偵測 JSON `{"error":"..."}` 模式視為失敗。
|
||
// 2026-06-09 修架構債:host function(.component-builds/http_request/src/index.ts)現在對非 2xx
|
||
// 回 envelope `{"error":"HTTP <status>","status":<code>,"body":<原文>}`——故此處 parsed["error"]
|
||
// 能正確 catch 所有 4xx/5xx(含 Notion 401 那種 body 用 {"object":"error"} 不帶 error key 的)。
|
||
// 之前 host fn 只回 body 原文丟掉 status → 401 被判 success(系統假綠根因,已修)。
|
||
// 註:claude_api/kbdb_upsert_block/km_writer 已同樣修(非 2xx 回 error envelope)。
|
||
// auth_service_account 不套此 envelope——它 main.go 自己解析 OAuth token 回應的
|
||
// {access_token,error,error_description},access_token 空即視為失敗,已有自己的判定,
|
||
// 套 envelope 反而會丟掉 error_description 破壞 token exchange 錯誤處理。
|
||
// 待辦:4 份 inline host fn 最好抽成共用 helper(dedup,目前複製貼上)。
|
||
var parsed map[string]interface{}
|
||
if err := json.Unmarshal([]byte(responseStr), &parsed); err == nil {
|
||
if errVal, ok := parsed["error"]; ok && errVal != nil {
|
||
out, _ := json.Marshal(map[string]interface{}{
|
||
"success": false,
|
||
"error": errVal,
|
||
"data": map[string]interface{}{"body": responseStr},
|
||
})
|
||
os.Stdout.Write(out)
|
||
return
|
||
}
|
||
}
|
||
|
||
out, _ := json.Marshal(map[string]interface{}{
|
||
"success": true,
|
||
"data": map[string]interface{}{"body": responseStr},
|
||
})
|
||
os.Stdout.Write(out)
|
||
}
|
||
|
||
func writeError(msg string) {
|
||
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
|
||
os.Stdout.Write(out)
|
||
}
|