feat(credential-store-migration): T4 wasi-shim secret_get host function

新增 secret_get(ref) host function:讀 CF Workers per-script Secrets 的值,
host 端實作 = env[ref] 動態字串索引(T1.5 spike ② 已證可行,零網路呼叫)。

- ArcrunHostEnv 加 index signature 支援任意 secret_ref 動態取值
- WasiHostFunctions 加 secret_get,u6u WASI imports 比照 kv_get 同款
  pointer/memory-write 機制 wiring
- 安全邊界:只允許 CRED_ 前綴(拒絕讀 ENCRYPTION_KEY/CF_SECRETS_API_TOKEN
  等非 credential 機密),比照既有 routedKvGet 前綴檢查精神

驗證:tsc --noEmit exit 0;vitest 18/18 通過(新增 5 案例)。
撞牆記錄:JSPI Suspending 物件不可在單元測試直接呼叫(既有架構環境限制,
kv_get 同樣受影響),已移除不可行的 2 個測試並記錄於測試檔註解。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018D6QoC5waFkcjc2N7csJBB
This commit is contained in:
Claude
2026-07-03 22:56:42 +00:00
parent f9dad311ff
commit c3c0a8b17d
3 changed files with 133 additions and 3 deletions
+67 -1
View File
@@ -4,7 +4,7 @@
*/
import { describe, it, expect } from 'vitest';
import { createWasiShim } from '../src/lib/wasi-shim';
import { createWasiShim, createArcrunHostFunctions, type ArcrunHostEnv } from '../src/lib/wasi-shim';
// 建立一個最小的 fake WebAssembly.Memory(用 ArrayBuffer 模擬)
function makeFakeMemory(size = 65536): WebAssembly.Memory {
@@ -213,3 +213,69 @@ describe('createWasiShim', () => {
});
});
});
// ── credential-store-migration T4secret_get host function ────────────────────
//
// 讀取 CF Workers per-script Secrets 的值:env[ref] 動態字串索引(§2.5/§5T1.5 spike ②)。
// 兩層測試:
// 1. createArcrunHostFunctions 對 fake env 物件的行為(CRED_ 前綴放行、其他前綴拒絕、找不到回 null)
// 2. u6u.secret_get 的 WASI import wiringpointer/memory-write 機制,比照 kv_get 同款測法)
function makeFakeEnv(overrides: Record<string, unknown> = {}): ArcrunHostEnv {
const fakeKv = {
get: async () => null,
put: async () => {},
delete: async () => {},
list: async () => ({ keys: [], list_complete: true, cacheStatus: null }),
} as unknown as KVNamespace;
return {
CREDENTIALS_KV: fakeKv,
RECIPES: fakeKv,
ENCRYPTION_KEY: 'deadbeef'.repeat(8),
...overrides,
};
}
describe('createArcrunHostFunctions — secret_get', () => {
it('CRED_ 前綴的 ref 存在 → 回傳值', async () => {
const env = makeFakeEnv({ CRED_TELEGRAM_BOT_TOKEN_A1B2C3D4: 'sk-fake-secret-value' });
const hostFns = createArcrunHostFunctions(env, 'ak_test');
await expect(hostFns.secret_get!('CRED_TELEGRAM_BOT_TOKEN_A1B2C3D4')).resolves.toBe('sk-fake-secret-value');
});
it('CRED_ 前綴但 ref 不存在(env 沒有這個 key)→ null', async () => {
const env = makeFakeEnv();
const hostFns = createArcrunHostFunctions(env, 'ak_test');
await expect(hostFns.secret_get!('CRED_NOT_SET')).resolves.toBeNull();
});
it('非 CRED_ 前綴 → 一律拒絕回 null(即使 env 上真的有這個值,如 ENCRYPTION_KEY', async () => {
const env = makeFakeEnv();
const hostFns = createArcrunHostFunctions(env, 'ak_test');
// ENCRYPTION_KEY 是 worker 自己的機密,WASM 不該透過 secret_get 拿到(安全邊界)
await expect(hostFns.secret_get!('ENCRYPTION_KEY')).resolves.toBeNull();
await expect(hostFns.secret_get!('CF_SECRETS_API_TOKEN')).resolves.toBeNull();
});
it('CRED_ 前綴但值非字串(型別不符)→ null', async () => {
const env = makeFakeEnv({ CRED_WEIRD: 12345 });
const hostFns = createArcrunHostFunctions(env, 'ak_test');
await expect(hostFns.secret_get!('CRED_WEIRD')).resolves.toBeNull();
});
});
describe('u6u.secret_get — WASI import wiring', () => {
// 誠實註記(撞牆記錄):vitest-pool-workers 環境的 WebAssembly 支援 JSPIhostWrap() 因此把
// secret_get(以及既有的 kv_get / crypto_decrypt 等所有 async host function)包成
// `WebAssembly.Suspending` 物件而非一般函式——這類物件設計上只能當 WASM import 綁定使用,
// 不能在 JS 端直接 `fn(...)` 呼叫(會拋 "is not a function")。用 probe 測試證實
// kv_get 的 import 同樣是 `Suspending` 物件、同樣不可直接呼叫——這是既有架構的環境限制,
// 不是 secret_get 本身的缺陷;pointer/memory-write 機制無法在不起真實 WASM instance 的
// 單元測試裡直接驗證(真實路徑靠 T5 的 wrangler 部署端到端驗證涵蓋)。
// 這裡改測 hostWrap 不介入的同步路徑:沒注入 secret_get 時的 fallback。
it('沒有注入 secret_get host function → 回傳 1(未實作,同步 fallback 路徑,非 hostWrap 包裝)', () => {
const shim = createWasiShim('', {}); // 沒給 secret_get
const secretGetImport = (shim.imports.u6u as Record<string, Function>).secret_get;
expect(secretGetImport()).toBe(1);
});
});