Files
Arcrun/cypher-executor/src/lib/wasi-shim.ts
T
uncle6me-web 0860e84d22 feat(t135): 庫目錄可自主移除+標示還在不在同步
leo:「需要加移除按鈕。因為別人裝錯我沒辦法幫他弄,需要可以自主」
+「你應該要顯示這個庫沒有本地對應的 folder,那就不容易刪錯」
(兩態非三態——leo 二修:「分兩種沒意義」,那是內部狀態不是用戶分類)。
- DELETE /portal/admin/libraries/:id(登記簿)與 by-name/:name(auto 庫需輸入庫名確認)
- kbdb 加 deprecate-by-library(auto 庫移除=標 deprecated,資料保留可還原)
- daemon/libraries 存 active 清單 → 卡片標 🟢同步中/灰目前沒有在同步
- 不自動刪(daemon 可能沒開機);daemon 從未回報時整列不標
vitest 24 passed(1 紅=console HTML 搬遷陳舊測試,非本案)。
(實作=子 CC;驗證+commit=總管。含 t116/t117 先前未 commit 的 graph-executor/wasi-shim 修正)
2026-07-29 14:45:44 +08:00

682 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* WASI preview1 輕量 shim
* 只實作 stdin/stdout/stderr 所需的最小 syscall 集合。
* 其餘 syscall 一律回傳 ENOSYS(76),確保零件無法呼叫網路或檔案系統。
*
* 不依賴任何外部套件(不使用 @cloudflare/workers-wasi)。
* Requirements: 3.1, 3.3
*/
/**
* createArcrunHostFunctions 所需的最小 env 子集。
* 不直接依賴 cypher-executor 的 Bindings,讓 auth primitive Worker 這類
* 只綁 CREDENTIALS_KV / RECIPES 的獨立 Worker 也能用。
*/
export interface ArcrunHostEnv {
CREDENTIALS_KV: KVNamespace;
RECIPES: KVNamespace;
/**
* credential-store-migration T4(§2.5/§5):CF Workers per-script Secrets 以 env var 形式
* 注入 worker,值只能靠字串動態索引取得(`env[ref]`T1.5 spike ② 已證可行)。
* 用 index signature 讓 `secret_get` host function 能對任意 secret_ref 字串取值,
* 不需要逐一宣告固定屬性名。
*/
[secretRef: string]: unknown;
}
const WASI_ESUCCESS = 0;
const WASI_ENOSYS = 76;
// fd 常數
const FD_STDIN = 0;
const FD_STDOUT = 1;
const FD_STDERR = 2;
export interface WasiShim {
/** WebAssembly.Imports 物件,傳入 WebAssembly.instantiate */
imports: WebAssembly.Imports;
/** 取得 stdout 的完整輸出(合併所有 chunks) */
getStdout(): string;
/** 取得 stderr 的完整輸出 */
getStderr(): string;
/** 注入 WebAssembly.Memoryinstantiate 後呼叫) */
setMemory(memory: WebAssembly.Memory): void;
/**
* 執行 WASM _start,自動使用 WebAssembly.promisingJSPI)讓 async host
* function 能正確 suspend/resume。若 JSPI 不可用則 fallback 同步執行。
* 必須在 setMemory() 之後呼叫。
*/
run(instance: WebAssembly.Instance): Promise<void>;
}
/**
* Host function 注入介面
* 讓 .wasm 零件能透過 host function 呼叫外部服務,而不需要網路 syscall
*
* 嚴格邊界:
* - `secret_get` 只放行 `CRED_` 前綴,WASM 讀不到 worker 本身的其他機密
* - `kv_get` 必須在 Worker 側檢查 key 前綴以防越權(見 auth-dispatcher.ts
*/
export interface WasiHostFunctions {
/** HTTP 請求 host function.wasm 呼叫此函數發出 HTTP 請求 */
http_request?: (url: string, method: string, headers: string, body: string) => Promise<string>;
/** KV 讀取:key 前綴由 Worker 路由到對應 binding,並做越權檢查 */
kv_get?: (key: string) => Promise<string | null>;
/**
* credential-store-migration T4(§2.5/§5):讀取 CF Workers per-script Secrets 的值。
* ref = D1 credentials 表的 secret_refenv var 名,如 `CRED_TELEGRAM_BOT_TOKEN_A1B2C3D4`)。
* host 端實作=`env[ref]` 動態字串索引(零網路呼叫,值已由 CF runtime 以 env var 注入 worker
* T1.5 spike ② 已證可行)。找不到該 ref → 回傳 null(與 kv_get 的「找不到」語意一致)。
*/
secret_get?: (ref: string) => Promise<string | null>;
/** KV 寫入:用於快取 access_token 等短效值,ttlSeconds=0 表示不設 TTL */
kv_put?: (key: string, value: string, ttlSeconds: number) => Promise<void>;
/** RS256 簽章:用 crypto.subtle 做 RSASSA-PKCS1-v1_5 + SHA-256 */
crypto_sign_rs256?: (data: Uint8Array, pkcs8: Uint8Array) => Promise<Uint8Array>;
}
/**
* 建立 WASI shim 實例
* @param stdinData - 要寫入 stdin 的 UTF-8 字串(通常是 JSON.stringify(input)
* @param hostFunctions - 可選的 host function 注入(讓 .wasm 呼叫外部服務)
*/
export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFunctions): WasiShim {
const stdinBytes = new TextEncoder().encode(stdinData);
let stdinOffset = 0;
const stdoutChunks: Uint8Array[] = [];
const stderrChunks: Uint8Array[] = [];
let memory: WebAssembly.Memory | null = null;
function getMemoryView(): DataView {
if (!memory) throw new Error('WASI memory not set — call setMemory() after instantiate');
return new DataView(memory.buffer);
}
// 寫入結果到 WASM 的 outPtr bufferhost function 共用)
// 回傳 0 = 成功,1 = memory 不可用
function writeOut(buf: ArrayBuffer, outPtr: number, outLenPtr: number, data: Uint8Array): number {
try {
new Uint8Array(buf, outPtr, data.length).set(data);
new DataView(buf).setUint32(outLenPtr, data.length, true);
return 0;
} catch {
return 1;
}
}
/**
* fd_write: 將 iovec 陣列的資料寫入 fdstdout=1 或 stderr=2
* iovec 結構:{ buf: i32, buf_len: i32 }(各 4 byteslittle-endian
*/
function fd_write(fd: number, iovs: number, iovs_len: number, nwritten_ptr: number): number {
if (fd !== FD_STDOUT && fd !== FD_STDERR) return WASI_ENOSYS;
const view = getMemoryView();
const buf = memory!.buffer;
let totalWritten = 0;
for (let i = 0; i < iovs_len; i++) {
const iov_base = view.getUint32(iovs + i * 8, true);
const iov_len = view.getUint32(iovs + i * 8 + 4, true);
if (iov_len === 0) continue;
const chunk = new Uint8Array(buf, iov_base, iov_len);
const copy = new Uint8Array(iov_len);
copy.set(chunk);
if (fd === FD_STDOUT) stdoutChunks.push(copy);
else stderrChunks.push(copy);
totalWritten += iov_len;
}
view.setUint32(nwritten_ptr, totalWritten, true);
return WASI_ESUCCESS;
}
/**
* fd_read: 從 stdin 讀取資料到 iovec 陣列
*/
function fd_read(fd: number, iovs: number, iovs_len: number, nread_ptr: number): number {
if (fd !== FD_STDIN) return WASI_ENOSYS;
const view = getMemoryView();
const buf = memory!.buffer;
let totalRead = 0;
for (let i = 0; i < iovs_len; i++) {
const iov_base = view.getUint32(iovs + i * 8, true);
const iov_len = view.getUint32(iovs + i * 8 + 4, true);
if (iov_len === 0) continue;
const remaining = stdinBytes.length - stdinOffset;
if (remaining <= 0) break;
const toCopy = Math.min(iov_len, remaining);
const dest = new Uint8Array(buf, iov_base, toCopy);
dest.set(stdinBytes.subarray(stdinOffset, stdinOffset + toCopy));
stdinOffset += toCopy;
totalRead += toCopy;
}
view.setUint32(nread_ptr, totalRead, true);
return WASI_ESUCCESS;
}
/**
* proc_exit: 零件呼叫 exit(),拋出 Error 中止執行
*/
function proc_exit(code: number): never {
throw new Error(`wasm exit: ${code}`);
}
/**
* random_get: 填充隨機 bytes(使用 Web Crypto API
*/
function random_get(buf_ptr: number, buf_len: number): number {
const view = new Uint8Array(memory!.buffer, buf_ptr, buf_len);
crypto.getRandomValues(view);
return WASI_ESUCCESS;
}
// ── Asyncify protocol ──────────────────────────────────────────────────────
// TinyGo WASI target 永遠使用 asyncify scheduler。Asyncify 讓 WASM 能在呼叫 host
// function 時「unwind」(保存 call stack),待 async 工作完成後再「rewind」(恢復)。
//
// 協議流程(每次 async host function 呼叫):
// 1. WASM 呼叫 host import(例如 http_request
// 2. Host 檢查 asyncify_get_state()
// - state=1Unwinding: 正在展開,host 應直接回傳 0(佔位值)
// - state=2Rewinding: 正在恢復,host 應回傳上一次 async 結果(已存在 asyncifyResult
// - state=0Normal: 正常執行,host 啟動 async 工作並呼叫 asyncify_start_unwind
// 3. WASM 的 _start 控制流回到 run()asyncify 讓 _start 提前返回)
// 4. run() await async 工作,呼叫 asyncify_start_rewind,再次呼叫 _start
// 5. WASM 從 host import 返回點繼續執行,host 回傳儲存的結果
//
// 注意:每次 _start 呼叫只能處理一個 async 中斷點。若 WASM 有多個連續的 async host call
// run() 會在 while 迴圈裡重複 rewind 直到 asyncify_get_state() == 0Normal)。
// Asyncify 資料緩衝區設定(TinyGo asyncify 用於保存 call stack
// 位址在 run() 中設定(WASM memory 末尾分配 1MB
let asyncifyDataPtr = 0;
const ASYNCIFY_BUF_SIZE = 1024 * 1024; // 1MB stack buffer
// 儲存 async host function 的結果和 Promise
let asyncifyPendingPromise: Promise<number> | null = null;
let asyncifyResult: number = 0;
// asyncify exportsrun() 設定後才可用)
let asyncifyExports: {
get_state: () => number;
start_unwind: (ptr: number) => void;
stop_unwind: () => void;
start_rewind: (ptr: number) => void;
stop_rewind: () => void;
} | null = null;
// JSPI helper:若環境支援 WebAssembly.Suspending,用它包裝 async import function
// 用於 scheduler=none 編譯的 WASM(無 asyncify exports
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function jspiSuspending<T extends (...args: any[]) => Promise<unknown>>(fn: T): T {
const SuspendingCtor = (WebAssembly as unknown as Record<string, unknown>)['Suspending'] as
(new (fn: T) => T) | undefined;
return SuspendingCtor ? new SuspendingCtor(fn) : fn;
}
// 建立一個 asyncify-aware 的 host function wrapper
// 協議:Normal 時啟動 async 工作並呼叫 start_unwindRewinding 時回傳已存的結果
// 用於 scheduler=asyncify 編譯的 WASM(有 asyncify exports
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function asyncifyWrap(fn: (...args: any[]) => Promise<number>): (...args: any[]) => number {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (...args: any[]): number => {
if (!memory) return 1;
const ax = asyncifyExports;
if (!ax) return 0; // asyncify 尚未初始化(sync fallback
const state = ax.get_state();
if (state === 2) {
// Rewinding:回傳上次 async 的真實結果
return asyncifyResult;
}
if (state === 1) {
// Unwinding 中:直接回傳 0WASM 在 unwind,不使用此值)
return 0;
}
// Normalstate=0):啟動 async 工作,觸發 asyncify unwind
asyncifyPendingPromise = fn(...args);
// asyncify_start_unwind 設定 WASM 內部 unwind flag
// host function 返回後 WASM 開始保存 call stack,最終 _start() 返回
ax.start_unwind(asyncifyDataPtr);
return 0; // WASM 忽略此值(正在 unwind
};
}
// 根據 WASM 是否有 asyncify exports 決定使用哪種包裝方式
// JSPI mode: scheduler=none WASM + WebAssembly.Suspending
// asyncify mode: scheduler=asyncify WASM + asyncify protocol
// 初始化時先用 asyncifyWraprun() 後若沒有 asyncify exports 就切換到 jspiSuspending
// 但因為 imports 在 instantiate 前就需要確定,這裡統一先用 asyncifyWrap
// run() 時若發現沒有 asyncify exports 且有 JSPI,則使用 JSPI 模式
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function hostWrap(fn: (...args: any[]) => Promise<number>): (...args: any[]) => number | Promise<number> {
// 嘗試使用 JSPI Suspending(若環境支援)
const SuspendingCtor = (WebAssembly as unknown as Record<string, unknown>)['Suspending'] as
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(new (fn: any) => any) | undefined;
if (SuspendingCtor) {
// JSPI 可用:包裝為 Suspending,讓 WASM 能 suspend 等待 async 結果
// 這適用於 scheduler=none 的 WASM(無 asyncify 干擾)
return new SuspendingCtor(fn);
}
// fallbackasyncify 協議(scheduler=asyncify WASM
return asyncifyWrap(fn);
}
const shim: WasiShim = {
imports: {
wasi_snapshot_preview1: { fd_write,
fd_read,
proc_exit,
random_get,
// 其餘 syscall 回傳 ENOSYS(不允許網路/檔案系統操作)
fd_seek: () => WASI_ENOSYS,
fd_close: () => WASI_ESUCCESS,
fd_fdstat_get: () => WASI_ENOSYS,
fd_prestat_get: () => WASI_ENOSYS,
fd_prestat_dir_name: () => WASI_ENOSYS,
environ_get: () => WASI_ESUCCESS,
environ_sizes_get: (count_ptr: number, size_ptr: number) => {
if (memory) {
const view = getMemoryView();
view.setUint32(count_ptr, 0, true);
view.setUint32(size_ptr, 0, true);
}
return WASI_ESUCCESS;
},
args_get: () => WASI_ESUCCESS,
args_sizes_get: (argc_ptr: number, argv_buf_size_ptr: number) => {
if (memory) {
const view = getMemoryView();
view.setUint32(argc_ptr, 0, true);
view.setUint32(argv_buf_size_ptr, 0, true);
}
return WASI_ESUCCESS;
},
clock_time_get: (id: number, precision: bigint, time_ptr: number) => {
if (memory) {
const view = getMemoryView();
const now = BigInt(Date.now()) * 1_000_000n;
view.setBigUint64(time_ptr, now, true);
}
return WASI_ESUCCESS;
},
clock_res_get: () => WASI_ENOSYS,
poll_oneoff: () => WASI_ENOSYS,
sched_yield: () => WASI_ESUCCESS,
proc_raise: () => WASI_ENOSYS,
sock_accept: () => WASI_ENOSYS,
sock_recv: () => WASI_ENOSYS,
sock_send: () => WASI_ENOSYS,
sock_shutdown: () => WASI_ENOSYS,
path_open: () => WASI_ENOSYS,
path_create_directory: () => WASI_ENOSYS,
path_remove_directory: () => WASI_ENOSYS,
path_rename: () => WASI_ENOSYS,
path_unlink_file: () => WASI_ENOSYS,
path_filestat_get: () => WASI_ENOSYS,
path_readlink: () => WASI_ENOSYS,
path_symlink: () => WASI_ENOSYS,
path_link: () => WASI_ENOSYS,
},
// u6u host functions:讓 .wasm 零件透過 host function 呼叫外部服務
// .wasm 零件用 //go:wasmimport u6u <name> 宣告
// 所有 async host function 透過 asyncifyWrap 包裝,實作 asyncify 協議
u6u: {
http_request: hostFunctions?.http_request
? hostWrap(async (urlPtr: number, urlLen: number, methodPtr: number, methodLen: number,
headersPtr: number, headersLen: number, bodyPtr: number, bodyLen: number,
outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) return 1;
// 在 await 前讀完所有輸入(memory.buffer 在 await 後可能因 grow 而失效)
const snapBuf = memory.buffer;
const dec = new TextDecoder();
const url = dec.decode(new Uint8Array(snapBuf, urlPtr, urlLen));
const method = dec.decode(new Uint8Array(snapBuf, methodPtr, methodLen));
const headers = dec.decode(new Uint8Array(snapBuf, headersPtr, headersLen));
const body = dec.decode(new Uint8Array(snapBuf, bodyPtr, bodyLen));
try {
const result = await hostFunctions!.http_request!(url, method, headers, body);
// await 後重新拿 memory.buffergrow 會產生新的 ArrayBuffer
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result));
} catch (e) {
// t117: 寫錯誤 envelope 到 WASM 輸出(main.go 讀 error key → success:false + 詳情);
// 取代只 return 1WASM 寫無資訊的 "HTTP request failed")。
// writeOut 失敗(memory 壞)才 fallback return 1。
const errDetail = e instanceof Error ? e.message : String(e);
const errEnv = new TextEncoder().encode(
JSON.stringify({ error: `fetch failed: ${errDetail}`, status: 0, body: '' })
);
return writeOut(memory.buffer, outPtr, outLenPtr, errEnv);
}
})
: () => 1,
// kv_get(keyPtr, keyLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 key
kv_get: hostFunctions?.kv_get
? hostWrap(async (keyPtr: number, keyLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) { console.error('[kv_get] memory null'); return 1; }
const key = new TextDecoder().decode(new Uint8Array(memory.buffer, keyPtr, keyLen));
console.error(`[kv_get] key="${key}" keyPtr=${keyPtr} keyLen=${keyLen} outPtr=${outPtr} outLenPtr=${outLenPtr}`);
try {
const result = await hostFunctions!.kv_get!(key);
console.error(`[kv_get] result=${result === null ? 'null' : result.slice(0, 80)}`);
if (result === null) return 2;
const encoded = new TextEncoder().encode(result);
const status = writeOut(memory.buffer, outPtr, outLenPtr, encoded);
console.error(`[kv_get] writeOut status=${status} encodedLen=${encoded.length} memBufLen=${memory.buffer.byteLength}`);
return status;
} catch (e) {
console.error(`[kv_get] error: ${e}`);
return 1;
}
})
: () => 1,
// secret_get(refPtr, refLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 ref
// 與 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<number> => {
if (!memory) return 1;
const ref = new TextDecoder().decode(new Uint8Array(memory.buffer, refPtr, refLen));
try {
const result = await hostFunctions!.secret_get!(ref);
if (result === null) return 2;
const encoded = new TextEncoder().encode(result);
return writeOut(memory.buffer, outPtr, outLenPtr, encoded);
} catch {
return 1;
}
})
: () => 1,
// kv_put(keyPtr, keyLen, valPtr, valLen, ttlSeconds) → 0 成功;1 錯誤
kv_put: hostFunctions?.kv_put
? hostWrap(async (keyPtr: number, keyLen: number, valPtr: number, valLen: number, ttlSeconds: number): Promise<number> => {
if (!memory) return 1;
const dec = new TextDecoder();
const key = dec.decode(new Uint8Array(memory.buffer, keyPtr, keyLen));
const value = dec.decode(new Uint8Array(memory.buffer, valPtr, valLen));
try {
await hostFunctions!.kv_put!(key, value, ttlSeconds);
return 0;
} catch {
return 1;
}
})
: () => 1,
// crypto_decrypt — 已停用,永遠回 1(失敗)。
//
// ⚠️ 不能整條移除:現役 auth_static_key / auth_service_account / auth_oauth2 的
// .wasm 仍宣告 `//go:wasmimport u6u crypto_decrypt`import 缺項會讓 WASM
// **instantiate 直接失敗**(不是呼叫才失敗)→ 所有認證零件全掛。故保留成 stub,
// 讓連結成立。待三個零件的 Go 原始碼移除該 wasmimport 並重編 wasm 後,才可刪掉這條。
crypto_decrypt: () => 1,
// crypto_sign_rs256(dataPtr, dataLen, pkcs8Ptr, pkcs8Len, outPtr, outLenPtr) → 0 成功
crypto_sign_rs256: hostFunctions?.crypto_sign_rs256
? hostWrap(async (dataPtr: number, dataLen: number, pkcs8Ptr: number, pkcs8Len: number,
outPtr: number, outLenPtr: number): Promise<number> => {
if (!memory) return 1;
// await 前複製 typed array(避免 memory grow 後 buffer 失效)
const data = new Uint8Array(new Uint8Array(memory.buffer, dataPtr, dataLen));
const pkcs8 = new Uint8Array(new Uint8Array(memory.buffer, pkcs8Ptr, pkcs8Len));
try {
const sig = await hostFunctions!.crypto_sign_rs256!(data, pkcs8);
return writeOut(memory.buffer, outPtr, outLenPtr, sig);
} catch {
return 1;
}
})
: () => 1,
},
},
setMemory(mem: WebAssembly.Memory) {
memory = mem;
},
async run(instance: WebAssembly.Instance): Promise<void> {
const exp = instance.exports as Record<string, unknown>;
const startFn = (exp._start ?? exp.main) as (() => void) | undefined;
if (typeof startFn !== 'function') throw new Error('WASM missing _start or main export');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const promisingFn = (WebAssembly as unknown as Record<string, unknown>)['promising'] as
((fn: () => void) => () => Promise<void>) | undefined;
// 若環境支援 JSPICloudflare Workers 2025+),優先使用 WebAssembly.promising
// hostWrap() 已將 imports 包裝為 WebAssembly.Suspending,不需要 asyncify 協議
if (promisingFn) {
try {
await promisingFn(startFn)();
} catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
}
return;
}
// JSPI 不可用:使用 asyncify 協議(需要 WASM 有 asyncify exports
const asyncifyGetState = exp.asyncify_get_state as (() => number) | undefined;
const asyncifyStartUnwind = exp.asyncify_start_unwind as ((ptr: number) => void) | undefined;
const asyncifyStopUnwind = exp.asyncify_stop_unwind as (() => void) | undefined;
const asyncifyStartRewind = exp.asyncify_start_rewind as ((ptr: number) => void) | undefined;
const asyncifyStopRewind = exp.asyncify_stop_rewind as (() => void) | undefined;
if (asyncifyGetState && asyncifyStartUnwind && asyncifyStopUnwind &&
asyncifyStartRewind && asyncifyStopRewind) {
asyncifyExports = {
get_state: asyncifyGetState,
start_unwind: asyncifyStartUnwind,
stop_unwind: asyncifyStopUnwind,
start_rewind: asyncifyStartRewind,
stop_rewind: asyncifyStopRewind,
};
const mallocFn = exp.malloc as ((size: number) => number) | undefined;
if (mallocFn && memory) {
const totalSize = ASYNCIFY_BUF_SIZE;
asyncifyDataPtr = mallocFn(totalSize);
const view = new DataView(memory.buffer);
view.setInt32(asyncifyDataPtr, asyncifyDataPtr + 8, true);
view.setInt32(asyncifyDataPtr + 4, asyncifyDataPtr + totalSize, true);
} else if (memory) {
const memBytes = memory.buffer.byteLength;
asyncifyDataPtr = memBytes - ASYNCIFY_BUF_SIZE;
if (asyncifyDataPtr > 8) {
const view = new DataView(memory.buffer);
view.setInt32(asyncifyDataPtr, asyncifyDataPtr + 8, true);
view.setInt32(asyncifyDataPtr + 4, asyncifyDataPtr + ASYNCIFY_BUF_SIZE, true);
}
}
}
// JSPI 不可用且無 asyncify exports:同步執行(host function 不能 async
if (!asyncifyExports) {
try { startFn(); } catch (e) {
if (!(e instanceof Error && e.message === 'wasm exit: 0')) throw e;
}
return;
}
// 主執行迴圈:每次呼叫 _start,若 asyncify 捕捉到 pending promise 就 await 再 rewind
let rewinding = false;
while (true) {
asyncifyPendingPromise = null;
try {
if (rewinding) {
asyncifyExports.start_rewind(asyncifyDataPtr);
startFn();
asyncifyExports.stop_rewind();
} else {
startFn();
}
} catch (e) {
if (e instanceof Error && e.message === 'wasm exit: 0') break;
throw e;
}
// 若 asyncifyWrap 觸發了 unwind_start 會因 unwind 返回(沒有 exit
// asyncifyWrap 已呼叫 start_unwind,這裡只需 stop_unwind 並 await promise
if (asyncifyPendingPromise !== null) {
asyncifyExports.stop_unwind();
asyncifyResult = await asyncifyPendingPromise;
asyncifyPendingPromise = null;
rewinding = true;
continue;
}
// 沒有 pending promise 且沒有 exit → 正常完成
break;
}
},
getStdout(): string {
if (stdoutChunks.length === 0) return '';
const total = stdoutChunks.reduce((n, c) => n + c.length, 0);
const merged = new Uint8Array(total);
let offset = 0;
for (const chunk of stdoutChunks) {
merged.set(chunk, offset);
offset += chunk.length;
}
return new TextDecoder().decode(merged);
},
getStderr(): string {
if (stderrChunks.length === 0) return '';
const total = stderrChunks.reduce((n, c) => n + c.length, 0);
const merged = new Uint8Array(total);
let offset = 0;
for (const chunk of stderrChunks) {
merged.set(chunk, offset);
offset += chunk.length;
}
return new TextDecoder().decode(merged);
},
};
return shim;
}
// ── Worker 端 host function 實作(Phase 0.6)──────────────────────────────────
//
// 唯一合法位置:RS256 簽章只准出現在本檔(02-forbidden.md §2.2)。
// 由 component-loader 的 WASM runner 路徑呼叫,注入進 createWasiShim。
//
// 安全邊界:
// 1. `secret_get` 只放行 `CRED_` 前綴,WASM 讀不到 worker 本身的其他 env 機密
// 2. `kv_get` 依 key 前綴路由,且 `{api_key}:cred:*` 必須符合 stdin 傳入的 api_key(越權檢查)
// 3. 未知前綴回傳 nullWASM 收到 kv_get 回傳 2 = 找不到)
function hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
return bytes;
}
function base64ToUint8Array(b64: string): Uint8Array {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
/**
* 依 key 前綴路由到對應 KV binding,並做越權檢查。
* - `auth_recipe:{service}` → env.RECIPES
* - `{apiKey}:cred:{name}` → env.CREDENTIALS_KV(前綴必須等於 caller 的 apiKey
* - 其他前綴 → null(拒絕)
*/
async function routedKvGet(env: ArcrunHostEnv, apiKey: string, key: string): Promise<string | null> {
if (key.startsWith('auth_recipe:')) {
return env.RECIPES.get(key);
}
const credMatch = key.match(/^([^:]+):cred:.+$/);
if (credMatch) {
if (credMatch[1] !== apiKey) {
// 越權:WASM 嘗試讀其他租戶的 credential
return null;
}
return env.CREDENTIALS_KV.get(key);
}
return null;
}
/**
* 依 key 前綴路由寫入 KV。只允許寫 oauth2 cache key(短效 access_token)。
* - `{apiKey}:oauth2:{service}:*` → env.CREDENTIALS_KV(越權檢查)
*/
async function routedKvPut(env: ArcrunHostEnv, apiKey: string, key: string, value: string, ttlSeconds: number): Promise<void> {
const oauth2Match = key.match(/^([^:]+):oauth2:.+$/);
if (oauth2Match && oauth2Match[1] === apiKey) {
const opts = ttlSeconds > 0 ? { expirationTtl: ttlSeconds } : undefined;
await env.CREDENTIALS_KV.put(key, value, opts);
return;
}
// 其他 key 前綴拒絕寫入(安全邊界)
}
/**
* RSASSA-PKCS1-v1_5 + SHA-256 簽章。private key 以 PKCS8 bytes 傳入(由 WASM 零件解析 PEM 後送進來)。
*/
async function rsaPkcs1Sha256Sign(data: Uint8Array, pkcs8: Uint8Array): Promise<Uint8Array> {
const cryptoKey = await crypto.subtle.importKey(
'pkcs8',
pkcs8,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['sign'],
);
const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', cryptoKey, data);
return new Uint8Array(sig);
}
/**
* 讀取 CF Workers per-script Secrets 的值:`env[ref]` 動態字串索引
* credential-store-migration T4,§2.5/§5T1.5 spike ② 已證此場景成立,零網路呼叫)。
*
* 安全邊界(比照 `routedKvGet` 的前綴檢查精神;design.md §2.3「secret_ref 命名需以 CRED_
* 前綴隔離命名空間」):只允許讀 `CRED_` 開頭的 ref。WASM 不該、也不需要讀到 worker 本身的
* 其他機密(`CF_SECRETS_API_TOKEN` 等非 credential 用途的 env var)。
* 不符前綴或值非字串 → 回傳 null(與 `kv_get` 的「拒絕/找不到」語意一致)。
*/
function secretGet(env: ArcrunHostEnv, ref: string): string | null {
if (!/^CRED_/.test(ref)) return null;
const value = env[ref];
return typeof value === 'string' ? value : null;
}
/**
* 建立 arcrun host function 組合(kv_get / kv_put / crypto_sign_rs256 / secret_get)。
* 由 WASM runnercomponent-loader 的 WASM 路徑)呼叫,與 api_key 綁定以做越權檢查。
*
* http_request 不由本 factory 提供 — auth primitive WASM 與 API WASM 零件若需要
* 發 HTTP,由呼叫者(component-loader)另外注入,以便個別限制可達主機。
*/
export function createArcrunHostFunctions(env: ArcrunHostEnv, apiKey: string): WasiHostFunctions {
return {
kv_get: (key: string) => routedKvGet(env, apiKey, key),
kv_put: (key: string, value: string, ttlSeconds: number) => routedKvPut(env, apiKey, key, value, ttlSeconds),
crypto_sign_rs256: (data: Uint8Array, pkcs8: Uint8Array) => rsaPkcs1Sha256Sign(data, pkcs8),
secret_get: async (ref: string) => secretGet(env, ref),
};
}