diff --git a/.component-builds/auth_oauth2/component.wasm b/.component-builds/auth_oauth2/component.wasm index 1b2d74e..badd901 100644 Binary files a/.component-builds/auth_oauth2/component.wasm and b/.component-builds/auth_oauth2/component.wasm differ diff --git a/.component-builds/auth_service_account/component.wasm b/.component-builds/auth_service_account/component.wasm index 1598e66..f353b51 100644 Binary files a/.component-builds/auth_service_account/component.wasm and b/.component-builds/auth_service_account/component.wasm differ diff --git a/.component-builds/auth_static_key/component.wasm b/.component-builds/auth_static_key/component.wasm index afb0a1c..e9d5848 100644 Binary files a/.component-builds/auth_static_key/component.wasm and b/.component-builds/auth_static_key/component.wasm differ diff --git a/.component-builds/http_request/component.wasm b/.component-builds/http_request/component.wasm index 1356d94..a706813 100644 Binary files a/.component-builds/http_request/component.wasm and b/.component-builds/http_request/component.wasm differ diff --git a/cypher-executor/src/lib/component-loader.ts b/cypher-executor/src/lib/component-loader.ts index f900b9d..e7b32d3 100644 --- a/cypher-executor/src/lib/component-loader.ts +++ b/cypher-executor/src/lib/component-loader.ts @@ -273,8 +273,13 @@ function makeHttpRunner(url: string): ComponentRunner { const text = await res.text(); return { success: false, status: res.status, error: text.slice(0, 200) }; } - try { return await res.json(); } - catch { return { success: true, data: await res.text() }; } + // 只讀一次 body(同檔 readBodyOnce 的註解已寫明這個坑,這裡以前卻正好踩到): + // 舊寫法 `try { res.json() } catch { res.text() }` 在零件回非 JSON 時, + // res.json() 失敗當下 body 已被消費 → 第二次讀丟 "Body has already been used", + // 使用者看到的是這句跟真因(零件回了非 JSON)完全無關的訊息(Arcrun#92 同類)。 + const text = await res.text(); + try { return JSON.parse(text); } + catch { return { success: true, data: text }; } }; } diff --git a/cypher-executor/src/lib/wasi-shim.ts b/cypher-executor/src/lib/wasi-shim.ts index 6763cf1..b2f86a5 100644 --- a/cypher-executor/src/lib/wasi-shim.ts +++ b/cypher-executor/src/lib/wasi-shim.ts @@ -27,6 +27,21 @@ export interface ArcrunHostEnv { const WASI_ESUCCESS = 0; const WASI_ENOSYS = 76; +// ── host function 回傳碼(u6u.*)───────────────────────────────────────────── +// 零件(main.go)用同一組數字判斷,改這裡要同步改 registry/components/*/main.go。 +export const HOST_OK = 0; +/** host 端出錯(memory 不可用 / 例外)— 零件無從得知細節 */ +export const HOST_ERROR = 1; +/** 查無此 key / ref(kv_get、secret_get 用) */ +export const HOST_NOT_FOUND = 2; +/** + * Arcrun#92:資料塞不進零件宣告的接收緩衝區(**不是**連線失敗、**不是**對方報錯)。 + * 舊行為是「照寫下去」——data 比零件的 outBuf 大時會覆寫零件堆積體,零件接著用 + * `outBuf[:outLen]` 切片會 panic,或 writeOut 撞到 memory 邊界丟例外 → 回 1 → + * 零件印一句與真因無關的 "HTTP request failed"。使用者照那句去查連線,方向全錯。 + */ +export const HOST_TOO_LARGE = 3; + // fd 常數 const FD_STDIN = 0; const FD_STDOUT = 1; @@ -75,6 +90,51 @@ export interface WasiHostFunctions { crypto_sign_rs256?: (data: Uint8Array, pkcs8: Uint8Array) => Promise; } +/** + * 容量握手的判定規則(Arcrun#92):資料塞不塞得進零件宣告的緩衝區? + * declaredCapacity === 0 ⇒ 舊零件沒宣告容量,host 無從得知上限 → 維持舊行為照寫, + * 不可自作聰明套一個預設值(各零件緩衝區大小不同:http_request 64KB、claude_api 1MB, + * 硬套會把原本正常的大回應誤判成「太大」——那是換一種說謊)。 + */ +export function outFitsCapacity(declaredCapacity: number, dataLength: number): boolean { + return declaredCapacity === 0 || dataLength <= declaredCapacity; +} + +/** 位元組數轉人看得懂的單位(訊息裡要出現真實數字,不能只說「太大」) */ +export function formatBytes(n: number): string { + if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; + if (n >= 1024) return `${Math.round(n / 1024)} KB`; + return `${n} bytes`; +} + +/** + * Arcrun#92:「回應太大」的 error envelope。 + * + * 寫法上的三個要求(票上的紅線:不准換一句含糊的萬用句): + * 1. 講**發生什麼**:多大、上限多少(真實數字,不是「太大」兩個字) + * 2. 講**不是什麼**:不是連線失敗、資料也沒被偷偷截半——避免使用者往錯方向查 + * 3. 講**怎麼辦**:縮小回應的具體手段 + * 另附機器可讀欄位(code / actual_bytes / limit_bytes),讓上層能判斷而不必比對字串。 + * + * status 用 0 而不是 413:對方伺服器並沒有回 413,寫 413 等於偽造一個上游狀態碼 + * (與 fetch 失敗的 envelope 同慣例,0 = 根本沒拿到 HTTP 狀態)。 + */ +export function oversizeResponseEnvelope(actualBytes: number, limitBytes: number) { + return { + error: + `回應太大,裝不下:對方回了 ${formatBytes(actualBytes)},` + + `超過這個零件單次能接收的 ${formatBytes(limitBytes)} 上限。` + + `這不是連線失敗,資料也沒有被截掉一半——是整包放不進零件。` + + `做法:用來源 API 的分頁或篩選參數(例如 limit / page / per_page / fields)把回應縮小再重試;` + + `真的需要整包資料時,改成分頁多抓幾次、每次處理一批。`, + code: 'response_too_large', + actual_bytes: actualBytes, + limit_bytes: limitBytes, + status: 0, + body: '', + }; +} + /** * 建立 WASI shim 實例 * @param stdinData - 要寫入 stdin 的 UTF-8 字串(通常是 JSON.stringify(input)) @@ -95,14 +155,34 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti } // 寫入結果到 WASM 的 outPtr buffer(host function 共用) - // 回傳 0 = 成功,1 = memory 不可用 + // 回傳 HOST_OK / HOST_ERROR / HOST_TOO_LARGE + // + // 容量握手(Arcrun#92):零件在呼叫 host function 前,把自己 outBuf 的長度預先寫進 + // *outLenPtr;host 在寫回前讀這個值當容量上限。塞不下就**不寫**(避免覆寫零件記憶體) + // 並回 HOST_TOO_LARGE,讓上層改寫一段講真話的訊息。 + // + // 舊零件(沒做握手)讀到 0 = 「未宣告容量」→ 維持舊行為。這裡不能自作聰明假設 64KB: + // 各零件緩衝區大小不同(http_request 64KB、claude_api 1MB),統一硬套會把原本 + // 跑得好好的大回應誤判成太大。 function writeOut(buf: ArrayBuffer, outPtr: number, outLenPtr: number, data: Uint8Array): number { try { + const view = new DataView(buf); + const declaredCapacity = view.getUint32(outLenPtr, true); + if (!outFitsCapacity(declaredCapacity, data.length)) return HOST_TOO_LARGE; new Uint8Array(buf, outPtr, data.length).set(data); - new DataView(buf).setUint32(outLenPtr, data.length, true); - return 0; + view.setUint32(outLenPtr, data.length, true); + return HOST_OK; } catch { - return 1; + return HOST_ERROR; + } + } + + /** 讀零件宣告的緩衝區容量(0 = 舊零件沒宣告) */ + function declaredCapacityOf(buf: ArrayBuffer, outLenPtr: number): number { + try { + return new DataView(buf).getUint32(outLenPtr, true); + } catch { + return 0; } } @@ -352,12 +432,28 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti try { const result = await hostFunctions!.http_request!(url, method, headers, body); // await 後重新拿 memory.buffer(grow 會產生新的 ArrayBuffer) - return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result)); + const encoded = new TextEncoder().encode(result); + const status = writeOut(memory.buffer, outPtr, outLenPtr, encoded); + if (status !== HOST_TOO_LARGE) return status; + + // Arcrun#92:回應塞不進零件緩衝區。以前這裡會硬寫(覆寫零件記憶體)或回 1, + // 零件對外只講得出 "HTTP request failed"——訊息與真因脫節。 + // 現在改寫一個講真話的 error envelope(零件既有的 parsed["error"] 判定鏈 + // 會原樣帶到使用者面前,不必改零件也能講對原因)。 + const capacity = declaredCapacityOf(memory.buffer, outLenPtr); + const envelope = new TextEncoder().encode( + JSON.stringify(oversizeResponseEnvelope(encoded.length, capacity)), + ); + const envStatus = writeOut(memory.buffer, outPtr, outLenPtr, envelope); + // 連這段說明都塞不下(緩衝區極小)→ 回 3,由零件自己講「回應太大」 + return envStatus === HOST_OK ? HOST_OK : HOST_TOO_LARGE; } catch (e) { // t117: 寫錯誤 envelope 到 WASM 輸出(main.go 讀 error key → success:false + 詳情); // 取代只 return 1(WASM 寫無資訊的 "HTTP request failed")。 // writeOut 失敗(memory 壞)才 fallback return 1。 - const errDetail = e instanceof Error ? e.message : String(e); + // 訊息截到 200 字:這段本身若超過零件緩衝區會被判成 HOST_TOO_LARGE, + // 零件就會把「連不上」說成「回應太大」——又一次訊息與真因脫節(Arcrun#92)。 + const errDetail = (e instanceof Error ? e.message : String(e)).slice(0, 200); const errEnv = new TextEncoder().encode( JSON.stringify({ error: `fetch failed: ${errDetail}`, status: 0, body: '' }) ); @@ -366,7 +462,8 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti }) : () => 1, - // kv_get(keyPtr, keyLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 key + // kv_get(keyPtr, keyLen, outPtr, outLenPtr) + // → 0 成功;1 錯誤;2 找不到 key;3 值太大塞不進零件緩衝區(Arcrun#92) kv_get: hostFunctions?.kv_get ? hostWrap(async (keyPtr: number, keyLen: number, outPtr: number, outLenPtr: number): Promise => { if (!memory) { console.error('[kv_get] memory null'); return 1; } @@ -387,7 +484,8 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti }) : () => 1, - // secret_get(refPtr, refLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 ref + // secret_get(refPtr, refLen, outPtr, outLenPtr) + // → 0 成功;1 錯誤;2 找不到 ref;3 值太大塞不進零件緩衝區(Arcrun#92) // 與 kv_get 同款 pointer/memory-write 機制;差別只在 host 端實作來源(env[ref] 而非 KV.get)。 secret_get: hostFunctions?.secret_get ? hostWrap(async (refPtr: number, refLen: number, outPtr: number, outLenPtr: number): Promise => { diff --git a/cypher-executor/tests/oversize-response-truth.test.ts b/cypher-executor/tests/oversize-response-truth.test.ts new file mode 100644 index 0000000..ec5ca46 --- /dev/null +++ b/cypher-executor/tests/oversize-response-truth.test.ts @@ -0,0 +1,79 @@ +/** + * Arcrun#92 — 「回應太大」不准再被說成「請求失敗」 + * + * 背景:零件(main.go)給 host function 的接收緩衝區是固定大小(http_request 64KB、 + * claude_api 1MB)。回應超過這個大小時,舊 host 會照寫不誤 → 覆寫零件記憶體 → 零件 + * 切片 panic,或 writeOut 撞 memory 邊界丟例外 → 回 1 → 零件印一句 + * "HTTP request failed"。使用者拿到那句會去查連線/URL/防火牆,全部查錯方向。 + * + * 這一支測的是**訊息有沒有講真話**,不是「有沒有回錯誤」: + * 1. 判定規則本身(沒宣告容量的舊零件不可被誤判成太大) + * 2. 訊息內容:真實數字 + 撇清錯誤方向 + 具體該怎麼辦 + 機器可讀欄位 + */ +import { describe, it, expect } from 'vitest'; +import { + outFitsCapacity, + formatBytes, + oversizeResponseEnvelope, + HOST_TOO_LARGE, +} from '../src/lib/wasi-shim'; + +describe('容量握手的判定規則', () => { + it('宣告 64KB、資料 200KB → 塞不下', () => { + expect(outFitsCapacity(65536, 200_000)).toBe(false); + }); + + it('剛好等於容量 → 塞得下(不可 off-by-one 誤殺)', () => { + expect(outFitsCapacity(65536, 65536)).toBe(true); + }); + + it('舊零件沒宣告容量(0)→ 一律視為塞得下,維持舊行為', () => { + // 這條是防「換一種說謊」:不能因為新規則就把 claude_api 那種 1MB 緩衝區的 + // 大回應統統誤判成「太大」。沒宣告 = host 不知道上限 = 不准亂猜。 + expect(outFitsCapacity(0, 900_000)).toBe(true); + }); + + it('HOST_TOO_LARGE 與零件端的 hostTooLarge 常數同值(registry/components/*/main.go)', () => { + expect(HOST_TOO_LARGE).toBe(3); + }); +}); + +describe('formatBytes', () => { + it('分別用 bytes / KB / MB', () => { + expect(formatBytes(512)).toBe('512 bytes'); + expect(formatBytes(65536)).toBe('64 KB'); + expect(formatBytes(3_355_443)).toBe('3.2 MB'); + }); +}); + +describe('「回應太大」的訊息本身', () => { + const env = oversizeResponseEnvelope(3_355_443, 65536); + + it('講出實際大小與上限(不是只說「太大」)', () => { + expect(env.error).toContain('3.2 MB'); + expect(env.error).toContain('64 KB'); + expect(env.actual_bytes).toBe(3_355_443); + expect(env.limit_bytes).toBe(65536); + }); + + it('明講「不是連線失敗」,把使用者從錯誤方向拉回來', () => { + expect(env.error).toContain('不是連線失敗'); + }); + + it('給得出下一步(分頁/篩選),不是叫人「稍後再試」', () => { + expect(env.error).toMatch(/分頁|篩選/); + expect(env.error).not.toMatch(/稍後再試|請重新操作/); + }); + + it('不准退回萬用句', () => { + expect(env.error).not.toMatch(/請求失敗|HTTP request failed|未知錯誤/); + }); + + it('帶機器可讀欄位,上層不必比對字串', () => { + expect(env.code).toBe('response_too_large'); + }); + + it('status 不偽造上游狀態碼(對方沒回 413)', () => { + expect(env.status).toBe(0); + }); +}); diff --git a/registry/components/auth_oauth2/main.go b/registry/components/auth_oauth2/main.go index 0f5430b..298adeb 100644 --- a/registry/components/auth_oauth2/main.go +++ b/registry/components/auth_oauth2/main.go @@ -30,6 +30,13 @@ import ( "unsafe" ) +// host function 回傳碼,與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組。 +const ( + hostOK uint32 = 0 + hostError uint32 = 1 + hostTooLarge uint32 = 3 // 資料塞不進零件宣告的接收緩衝區(Arcrun#92) +) + // ── host function 宣告 ─────────────────────────────────────────────────────── //go:wasmimport u6u kv_get @@ -320,9 +327,17 @@ func doRefresh(input Input, recipe AuthRecipe) (string, int64, bool) { formBody := form.Encode() headersJSON := `{"Content-Type":"application/x-www-form-urlencoded"}` - respStr, ok2 := httpRequest(cfg.TokenEndpoint, "POST", headersJSON, formBody) - if !ok2 { - writeError("token endpoint HTTP 請求失敗") + respStr, code := httpRequest(cfg.TokenEndpoint, "POST", headersJSON, formBody) + if code == hostTooLarge { + writeError("token endpoint 的回應太大,裝不下:超過這個零件單次能接收的 64 KB 上限。" + + "這不是連線失敗——請求有送出去、對方也有回,只是整包塞不進零件。" + + "多半表示 " + cfg.TokenEndpoint + " 回的不是正常的 token JSON(例如回了一整頁 HTML 錯誤頁);" + + "請確認 auth recipe 的 token_endpoint 指向正確的 token 端點。") + return "", 0, false + } + if code != hostOK { + writeError("token endpoint 沒有拿到回應:引擎的 host function 回傳錯誤碼 " + + strconv.Itoa(int(code)) + "(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題。") return "", 0, false } @@ -387,7 +402,7 @@ func writeError(msg string) { func kvGet(key string) (string, uint32) { keyBytes := []byte(key) outBuf := make([]byte, 65536) - var outLen uint32 + outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92),見 wasi-shim.ts writeOut status := hostKvGet( uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)), @@ -419,7 +434,7 @@ func cryptoDecrypt(encB64, ivB64 string) (string, bool) { return "", false } outBuf := make([]byte, 65536) - var outLen uint32 + outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92) status := hostCryptoDecrypt( uintptr(unsafe.Pointer(&encBytes[0])), uint32(len(encBytes)), @@ -432,18 +447,22 @@ func cryptoDecrypt(encB64, ivB64 string) (string, bool) { return string(outBuf[:outLen]), true } -func httpRequest(reqURL, method, headersJSON, body string) (string, bool) { +// httpRequest 回傳 (回應原文, host function 回傳碼)。 +// 回傳碼與 wasi-shim.ts 的 HOST_* 同一組:0=成功 1=引擎端錯誤 3=回應塞不下緩衝區。 +// 之所以不再只回 bool:bool 把「連不上」和「回應太大」混成同一句話, +// 使用者拿到 "token endpoint HTTP 請求失敗" 會往連線方向查,方向全錯(Arcrun#92)。 +func httpRequest(reqURL, method, headersJSON, body string) (string, uint32) { urlBytes := []byte(reqURL) methodBytes := []byte(method) headersBytes := []byte(headersJSON) bodyBytes := []byte(body) if len(urlBytes) == 0 { - return "", false + return "", hostError } outBuf := make([]byte, 65536) - var outLen uint32 + outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92) var bodyPtr uintptr if len(bodyBytes) > 0 { @@ -462,9 +481,9 @@ func httpRequest(reqURL, method, headersJSON, body string) (string, bool) { uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)), ) if status != 0 { - return "", false + return "", status } - return string(outBuf[:outLen]), true + return string(outBuf[:outLen]), hostOK } func interpolateTemplate(template string, secrets, runtime map[string]string) string { diff --git a/registry/components/auth_service_account/main.go b/registry/components/auth_service_account/main.go index d319a0d..0c23995 100644 --- a/registry/components/auth_service_account/main.go +++ b/registry/components/auth_service_account/main.go @@ -19,11 +19,19 @@ import ( "io" "net/url" "os" + "strconv" "strings" "time" "unsafe" ) +// host function 回傳碼,與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組。 +const ( + hostOK uint32 = 0 + hostError uint32 = 1 + hostTooLarge uint32 = 3 // 資料塞不進零件宣告的接收緩衝區(Arcrun#92) +) + // ── host function 宣告 ─────────────────────────────────────────────────────── //go:wasmimport u6u kv_get @@ -267,9 +275,17 @@ func main() { headersJSON := `{"Content-Type":"application/x-www-form-urlencoded"}` - respStr, ok := httpRequest(recipe.TokenExchange.Endpoint, "POST", headersJSON, formBody) - if !ok { - writeError("token exchange HTTP 失敗") + respStr, code := httpRequest(recipe.TokenExchange.Endpoint, "POST", headersJSON, formBody) + if code == hostTooLarge { + writeError("token exchange 的回應太大,裝不下:超過這個零件單次能接收的 64 KB 上限。" + + "這不是連線失敗——請求有送出去、對方也有回,只是整包塞不進零件。" + + "多半表示 " + recipe.TokenExchange.Endpoint + " 回的不是正常的 token JSON" + + "(例如回了一整頁 HTML 錯誤頁);請確認 auth recipe 的 token_exchange.endpoint 正確。") + return + } + if code != hostOK { + writeError("token exchange 沒有拿到回應:引擎的 host function 回傳錯誤碼 " + + strconv.Itoa(int(code)) + "(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題。") return } @@ -344,11 +360,12 @@ func pemToPkcs8(pem string) ([]byte, error) { return base64.StdEncoding.DecodeString(cleaned) } -// kvGet 呼叫 host function,回傳 (value, status)。status: 0=成功 1=錯誤 2=找不到 +// kvGet 呼叫 host function,回傳 (value, status)。 +// status: 0=成功 1=錯誤 2=找不到 3=值太大塞不進 outBuf(Arcrun#92) func kvGet(key string) (string, uint32) { keyBytes := []byte(key) outBuf := make([]byte, 65536) - var outLen uint32 + outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92),見 wasi-shim.ts writeOut status := hostKvGet( uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)), @@ -364,7 +381,7 @@ func cryptoDecrypt(encB64, ivB64 string) (string, bool) { encBytes := []byte(encB64) ivBytes := []byte(ivB64) outBuf := make([]byte, 65536) - var outLen uint32 + outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92) if len(encBytes) == 0 || len(ivBytes) == 0 { return "", false @@ -387,7 +404,7 @@ func cryptoSignRS256(data, pkcs8 []byte) ([]byte, bool) { return nil, false } outBuf := make([]byte, 1024) // RSA-2048 簽章 = 256 bytes,1KB 綽綽有餘 - var outLen uint32 + outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92) status := hostCryptoSignRS256( uintptr(unsafe.Pointer(&data[0])), uint32(len(data)), @@ -400,19 +417,21 @@ func cryptoSignRS256(data, pkcs8 []byte) ([]byte, bool) { return outBuf[:outLen], true } -// httpRequest 呼叫 host,回傳 response body 字串(host 側把 status + body 串好) -func httpRequest(url, method, headersJSON, body string) (string, bool) { +// httpRequest 呼叫 host,回傳 (response body 字串, host function 回傳碼)。 +// 回傳碼與 wasi-shim.ts 的 HOST_* 同一組:0=成功 1=引擎端錯誤 3=回應塞不下緩衝區。 +// 不再只回 bool 的理由(Arcrun#92):bool 把「連不上」與「回應太大」講成同一句話。 +func httpRequest(url, method, headersJSON, body string) (string, uint32) { urlBytes := []byte(url) methodBytes := []byte(method) headersBytes := []byte(headersJSON) bodyBytes := []byte(body) if len(urlBytes) == 0 { - return "", false + return "", hostError } outBuf := make([]byte, 65536) - var outLen uint32 + outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92) // bodyBytes 可能為空(GET),host function 允許 len=0 var bodyPtr uintptr @@ -432,9 +451,9 @@ func httpRequest(url, method, headersJSON, body string) (string, bool) { uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)), ) if status != 0 { - return "", false + return "", status } - return string(outBuf[:outLen]), true + return string(outBuf[:outLen]), hostOK } // interpolateTemplate 展開 {{secret.X}} 與 {{runtime.X}}。未知 key 展開為空字串。 diff --git a/registry/components/auth_static_key/main.go b/registry/components/auth_static_key/main.go index 30d03cc..ba1fef8 100644 --- a/registry/components/auth_static_key/main.go +++ b/registry/components/auth_static_key/main.go @@ -287,11 +287,14 @@ func writeError(msg string) { os.Stdout.Write(out) } -// kvGet 呼叫 host function,回傳 (value, status)。status: 0=成功 1=錯誤 2=找不到 +// kvGet 呼叫 host function,回傳 (value, status)。 +// status: 0=成功 1=錯誤 2=找不到 3=值太大塞不進 outBuf(Arcrun#92) func kvGet(key string) (string, uint32) { keyBytes := []byte(key) outBuf := make([]byte, 65536) - var outLen uint32 + // 容量握手(Arcrun#92):先把緩衝區大小告訴 host,host 才能在值塞不下時 + // 回 status=3(值太大)而不是硬寫爆這塊記憶體。見 wasi-shim.ts writeOut。 + outLen := uint32(len(outBuf)) status := hostKvGet( uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)), @@ -309,7 +312,7 @@ func cryptoDecrypt(encB64, ivB64 string) (string, bool) { encBytes := []byte(encB64) ivBytes := []byte(ivB64) outBuf := make([]byte, 65536) - var outLen uint32 + outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92) // 處理空字串的防呆(TinyGo 取 &[]byte{}[0] 會 panic) if len(encBytes) == 0 || len(ivBytes) == 0 { diff --git a/registry/components/claude_api/main.go b/registry/components/claude_api/main.go index b4ebcc1..5e35544 100644 --- a/registry/components/claude_api/main.go +++ b/registry/components/claude_api/main.go @@ -15,9 +15,14 @@ import ( "encoding/json" "io" "os" + "strconv" "unsafe" ) +// 與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組回傳碼。 +// 3 = 資料塞不進零件宣告的接收緩衝區(Arcrun#92) +const hostTooLarge uint32 = 3 + //go:wasmimport u6u http_request func hostHttpRequest( urlPtr uintptr, urlLen uint32, @@ -102,7 +107,9 @@ func main() { methodBytes := []byte("POST") outBuf := make([]byte, 1024*1024) // 1MB - var outLen uint32 + // 容量握手(Arcrun#92):先把緩衝區大小告訴 host,塞不下時 host 會回一段 + // 講明「回應太大 + 實際/上限大小 + 該怎麼辦」的 envelope,而不是硬寫爆記憶體。 + outLen := uint32(len(outBuf)) urlPtr, urlLen := safePtr(urlBytes) methodPtr, methodLen := safePtr(methodBytes) @@ -117,8 +124,16 @@ func main() { uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)), ) + // 回傳碼與 wasi-shim.ts 的 HOST_* 同一組:0=成功 1=引擎端錯誤 3=回應塞不下緩衝區 + if result == hostTooLarge { + writeError("Mira 的回應太大,裝不下:超過這個零件單次能接收的 1 MB 上限。" + + "這不是連線失敗,也不是 Mira 沒回應。" + + "做法:把 prompt 改成請 Mira 回短一點(或分段回),或改用 callback_url 走非同步取回。") + return + } if result != 0 { - writeError("Mira daemon request failed (host_http_request returned non-zero)") + writeError("沒有拿到 Mira 的回應:引擎的 host function 回傳錯誤碼 " + strconv.Itoa(int(result)) + + "(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題,不是 prompt 寫錯。") return } diff --git a/registry/components/http_request/main.go b/registry/components/http_request/main.go index af6b978..b811b04 100644 --- a/registry/components/http_request/main.go +++ b/registry/components/http_request/main.go @@ -9,9 +9,14 @@ 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 @@ -86,7 +91,11 @@ func main() { headersBytes := []byte(headersJSON) bodyBytes := []byte(bodyStr) outBuf := make([]byte, 65536) // 64KB output buffer - var outLen uint32 + // 容量握手(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) @@ -101,8 +110,18 @@ func main() { 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("HTTP request failed") + writeError("沒有拿到回應:引擎的 host function 回傳錯誤碼 " + strconv.Itoa(int(result)) + + "(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題,不是你的 workflow 參數寫錯。") return } diff --git a/scripts/repro-oversize-response.mjs b/scripts/repro-oversize-response.mjs new file mode 100644 index 0000000..00c6072 --- /dev/null +++ b/scripts/repro-oversize-response.mjs @@ -0,0 +1,91 @@ +/** + * Arcrun#92 重現腳本 — 「回應太大」到底會讓使用者看到什麼訊息 + * + * 為什麼要有這支:http_request 零件的接收緩衝區是 64 KB。回應超過這個大小時, + * 舊版會硬把資料寫進零件記憶體(寫爆)或讓 host 丟例外回 1,零件對外只講得出一句 + * "HTTP request failed"。使用者照那句去查連線/防火牆/URL,方向全錯。 + * 這支腳本把那個情境真的做出來,讓「修之前 / 修之後」的訊息可以並排比。 + * + * 用法(本機,不碰任何線上實例): + * + * # 修之後(工作區現在的 wasm) + * node scripts/repro-oversize-response.mjs 200000 + * + * # 修之前(把 main 上的舊 wasm 取出來當對照組;舊 wasm 不做容量握手 → 走舊路徑) + * git show origin/main:.component-builds/http_request/component.wasm > /tmp/old-http_request.wasm + * node scripts/repro-oversize-response.mjs 200000 /tmp/old-http_request.wasm + * + * # 對照:沒超過上限時兩者都應該正常 + * node scripts/repro-oversize-response.mjs 1024 + * + * Node < 22.18 請加 --experimental-strip-types(本檔會 import 一支 .ts)。 + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, '..'); + +const { createWasiShim } = await import( + resolve(repoRoot, 'cypher-executor/src/lib/wasi-shim.ts') +); + +const responseBytes = Number(process.argv[2] ?? 200_000); +const wasmPath = process.argv[3] + ? resolve(process.argv[3]) + : resolve(repoRoot, '.component-builds/http_request/component.wasm'); + +// 假裝遠端回了一包很大的 JSON(2xx,host function 照原樣把 body 交給零件) +const filler = 'x'.repeat(Math.max(0, responseBytes - 14)); +const remoteBody = JSON.stringify({ items: filler }); + +const shim = createWasiShim( + JSON.stringify({ url: 'https://example.com/big-list', method: 'GET' }), + { http_request: async () => remoteBody }, +); + +const instance = await WebAssembly.instantiate( + await WebAssembly.compile(readFileSync(wasmPath)), + shim.imports, +); +shim.setMemory(instance.exports.memory); + +let crashed = null; +try { + await shim.run(instance); +} catch (e) { + crashed = e instanceof Error ? e.message : String(e); +} + +const stdout = shim.getStdout().trim(); +const stderr = shim.getStderr().trim(); + +console.log(`wasm : ${wasmPath}`); +console.log(`模擬回應大小 : ${remoteBody.length} bytes(零件緩衝區上限 65536 bytes)`); +console.log(''); + +// 以下複刻 .component-builds/http_request/src/index.ts 的收尾, +// 印出「使用者真的會拿到的那一包」 +if (stderr) console.log(`零件 stderr : ${stderr.slice(0, 300)}`); +if (crashed) console.log(`WASM 執行中止 : ${crashed}`); + +if (!stdout) { + console.log('使用者看到 : HTTP 500 {"success":false,"error":"WASM component produced no output"}'); + process.exit(0); +} + +let parsed; +try { + parsed = JSON.parse(stdout); +} catch (e) { + console.log(`使用者看到 : HTTP 500 {"success":false,"error":"${e.message}"}`); + process.exit(0); +} + +console.log(`success : ${parsed.success}`); +console.log(`error : ${parsed.error ?? '(無)'}`); +if (parsed.success) { + const body = parsed.data?.body ?? ''; + console.log(`data.body 長度 : ${String(body).length} bytes`); +} diff --git a/system-dev/wiki/status.md b/system-dev/wiki/status.md index 000419a..9b99d4f 100644 --- a/system-dev/wiki/status.md +++ b/system-dev/wiki/status.md @@ -341,6 +341,7 @@ arcrun 不自管加密金鑰,`crypto_decrypt` host function 已成永遠回失 |------|--------|------|------| | ~~**credential 注入 401**~~ | ✅ 已解 | **8.1-8.5 全完成(2026-06-25 確認)** | 機制(auth_static_key `resolve_credentials` + graph-executor `resolveCredentialRefs`)已端到端實證:2026-06-13 Notion `{{credential.notion_token}}` 真讀到資料(同等於 8.5 OpenAI 驗收,機制與服務無關)。tasks.md 8.5 已補 `[x]` | | §8 P1/P2 recipe/workflow list 遷 D1 | 🔴 高 | 架構已拍板未動 code | 走 kbdb /entries HTTP 雙寫不加 binding;依賴 D1(現已可建)。另開 session 做 | +| 零件接收緩衝區有硬上限(http_request 64KB/claude_api 1MB) | 🟡 中 | 訊息已誠實(Arcrun#92),**上限本身還在** | 回應超過上限=真的抓不回來。修的是「以前說成 HTTP request failed」,現在改說「回應太大+實際/上限大小+改用分頁」。host↔零件走**容量握手**(零件先把 outBuf 長度寫進 `*outLenPtr`,host 塞不下回 `HOST_TOO_LARGE=3`,見 `wasi-shim.ts`)。要真的支援大回應得另外設計(分頁/串流),不是調大 buffer 就好 | | 4 份 inline http_request host fn 抽共用 helper | 🟡 中 | 待 dedup | http_request/claude_api/kbdb_upsert_block/km_writer 各自複製貼上同段(這次假綠修也是逐份改) | | `arcrun.dev/llms.txt` 404 | 🟡 中 | 未 serve | landing/public 缺檔;GitHub repo 內正常(test/5 走 GitHub 不阻擋) | | MCP account-source | 🟡 中 | 記錄中 | self-hosted MCP 指官方不指自己(§5.2 已知) |