diff --git a/.component-builds/auth_service_account/component.wasm b/.component-builds/auth_service_account/component.wasm index 0a526e6..1598e66 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 99fb8d7..afb0a1c 100644 Binary files a/.component-builds/auth_static_key/component.wasm and b/.component-builds/auth_static_key/component.wasm differ diff --git a/cypher-executor/src/actions/auth-dispatcher.ts b/cypher-executor/src/actions/auth-dispatcher.ts index 3101c0d..edc813f 100644 --- a/cypher-executor/src/actions/auth-dispatcher.ts +++ b/cypher-executor/src/actions/auth-dispatcher.ts @@ -20,6 +20,102 @@ import type { Bindings } from '../types'; import { resolveAuthRecipe, resolveRecipe } from '../routes/recipes'; import { wasmWorkerUrl } from '../lib/component-loader'; +import { createArcrunHostFunctions } from '../lib/wasi-shim'; + +// ── credential-store 遷移 T6/T7(方案 A,D19)──────────────────────────────── +// +// 密文值住 cypher-executor 自己的 per-script secrets(T5 寫入)。解密發生在獨立的 +// auth_static_key / auth_service_account worker 上,它們讀不到 cypher 的 secrets。 +// 故 cypher 這一層先查 D1 拿 secret_ref → 用 secret_get(ref)(即 env[ref],T4)取明文 +// → 塞進送給 auth WASM 的 payload 新欄位 `resolved_secrets`。WASM 收到優先用它,沒有 +// 才 fallback 舊 KV + crypto_decrypt(那個 fallback 即 T7 雙讀)。 +// +// 嚴格邊界(rule 02 §2.2):本檔只做「查 D1 ref → secret_get 取值 → 當字串塞 payload」。 +// **不解密、不展開模板、不組 JWT**——secret_get 的實作(env[ref])在 wasi-shim host function +// 內,解密/注入邏輯仍全在 WASM 零件。 + +/** D1 credentials 目錄一列(只取本檔需要的欄位)。 */ +interface CredentialRefRow { + name: string; + secret_ref: string; +} + +/** + * 對一組 credential name,從新家(cypher per-script secrets)取明文。 + * + * 流程:查 D1 `credentials`(api_key + name)拿 `secret_ref` → 用 `secret_get(ref)` + * (host function,實作 = env[ref])取值。 + * + * ⚠️ 只把「D1 有 ref 且 secret_get 真的取到值」的 name 放進回傳 map。查不到 ref、 + * 或 secret_get 回 null(新家還沒這把值)→ **該 name 缺席**(不是放空字串!), + * 讓 WASM 對這把 key 走 fallback 舊 KV 路徑(T7 雙讀)。放空字串會讓 WASM 誤判命中用空值。 + * + * 取到值的 name 順手更新 D1 `last_used_at`(§2.5 治理面 last_used)。 + * + * D1 未建表 / migration 未跑 / CREDENTIALS_DB 未綁 → 回空 map(整組走 fallback), + * 不 throw——遷移過渡期(雙讀)本就允許「新家還沒資料」。 + */ +export async function resolveSecretsFromNewHome( + env: Bindings, + apiKey: string, + names: string[], +): Promise> { + const resolved: Record = {}; + if (names.length === 0) return resolved; + + const db = env.CREDENTIALS_DB; + if (!db) return resolved; // 未綁 D1 → 整組走 fallback + + // 1. 查 D1 拿每個 name 的 secret_ref + let rows: CredentialRefRow[]; + try { + const placeholders = names.map(() => '?').join(', '); + const result = await db + .prepare( + `SELECT name, secret_ref FROM credentials + WHERE api_key = ? AND name IN (${placeholders})`, + ) + .bind(apiKey, ...names) + .all(); + rows = result.results ?? []; + } catch { + // D1 未建表 / query 失敗 → 過渡期整組走 fallback(雙讀),不假綠 + return resolved; + } + if (rows.length === 0) return resolved; + + // 2. 用 secret_ref 從新家取值(host function secret_get = env[ref]) + const secretGet = createArcrunHostFunctions(env, apiKey).secret_get; + if (!secretGet) return resolved; // host function 未就緒 → 走 fallback + + const resolvedNames: string[] = []; + for (const row of rows) { + const value = await secretGet(row.secret_ref); + // null(新家沒這把值 / 非 CRED_ 前綴被拒)→ 不放進 map,讓 WASM fallback 舊 KV + if (value === null) continue; + resolved[row.name] = value; + resolvedNames.push(row.name); + } + + // 3. 順手更新 last_used_at(只更新真的從新家取到值的 name) + if (resolvedNames.length > 0) { + try { + const now = Math.floor(Date.now() / 1000); + const placeholders = resolvedNames.map(() => '?').join(', '); + await db + .prepare( + `UPDATE credentials SET last_used_at = ? + WHERE api_key = ? AND name IN (${placeholders})`, + ) + .bind(now, apiKey, ...resolvedNames) + .run(); + } catch { + // last_used 更新失敗不影響注入主流程(治理面欄位,非關鍵路徑) + } + } + + return resolved; +} /** 對應 Phase 1-4 會部署的 auth primitive Worker */ const SUPPORTED_PRIMITIVES = new Set(['static_key', 'service_account', 'oauth2']); @@ -62,6 +158,13 @@ export async function tryAuthDispatch( if (!recipe) return null; if (!SUPPORTED_PRIMITIVES.has(recipe.primitive)) return null; + // T6:先從新家(cypher per-script secrets)取 recipe 需要的 credential 明文。 + // 拿到的塞進 `resolved_secrets`,WASM 優先用;沒拿到的 name 缺席 → WASM fallback 舊 KV(T7)。 + const secretNames = recipe.required_secrets + .filter(s => !s.optional) + .map(s => s.key); + const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, secretNames); + // 走新路徑:HTTP POST 到對應 auth primitive Worker // 走 workers.dev 避開同 zone 死鎖(P0 #9) const primitiveUrl = wasmWorkerUrl(`auth_${recipe.primitive}`, env.WORKER_SUBDOMAIN); @@ -72,6 +175,8 @@ export async function tryAuthDispatch( action: 'authenticate', api_key: apiKey, service, + // 只在有取到值時帶上(空物件也無妨,WASM 對 nil/空 map 同樣 fallback) + resolved_secrets: resolvedSecrets, }), }); @@ -159,6 +264,12 @@ export async function resolveCredentialRefs( collectCredentialNames(data, names); if (names.size === 0) return data; + const nameList = [...names]; + + // T6:先從新家取這些 credential 的明文,塞 `resolved_secrets`;WASM 優先用它, + // 沒取到的 name 缺席 → WASM 的 resolve_credentials 分支 fallback 舊 KV(T7)。 + const resolvedSecrets = await resolveSecretsFromNewHome(env, apiKey, nameList); + const url = wasmWorkerUrl('auth_static_key', env.WORKER_SUBDOMAIN); const res = await fetch(url, { method: 'POST', @@ -166,7 +277,8 @@ export async function resolveCredentialRefs( body: JSON.stringify({ action: 'resolve_credentials', api_key: apiKey, - names: [...names], + names: nameList, + resolved_secrets: resolvedSecrets, }), }); diff --git a/cypher-executor/tests/auth-dispatcher.test.ts b/cypher-executor/tests/auth-dispatcher.test.ts new file mode 100644 index 0000000..e1a91f8 --- /dev/null +++ b/cypher-executor/tests/auth-dispatcher.test.ts @@ -0,0 +1,105 @@ +/** + * credential-store-migration T6/T7(方案 A,D19)測試:auth-dispatcher 的 + * `resolveSecretsFromNewHome`——「查 D1 拿 secret_ref → secret_get(env[ref]) 取值 → 組 map」。 + * + * 覆蓋 T6 主路徑 + T7 雙讀分流的判準: + * - D1 有 ref 且 secret 存在(新家有值)→ name 進 map(payload 會含 resolved_secrets[name]) + * - D1 無 ref(從未回填)→ name 缺席 map(讓 WASM fallback 舊 KV,T7) + * - D1 有 ref 但 secret 不存在(新家還沒寫值)→ secret_get 回 null → name 缺席(不放空字串!) + * - 取到值的 name 更新 last_used_at + * + * secret_get 的實作是 host function `env[ref]`(wasi-shim),故測試把 CRED_* 值放進傳入的 + * env 物件模擬「per-script secret 已注入」,用真實 D1 binding 存目錄(同 credentials.test.ts)。 + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { env } from 'cloudflare:test'; +import type { Bindings } from '../src/types'; +import { resolveSecretsFromNewHome } from '../src/actions/auth-dispatcher'; + +const API_KEY = 'test-tenant-t67'; + +async function insertRefRow(name: string, secretRef: string): Promise { + await env.CREDENTIALS_DB + .prepare( + `INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) + VALUES (?, ?, ?, ?, ?, ?, NULL)`, + ) + .bind(API_KEY, name, null, 'standard', secretRef, Math.floor(Date.now() / 1000)) + .run(); +} + +async function clearRows(): Promise { + await env.CREDENTIALS_DB.prepare(`DELETE FROM credentials WHERE api_key = ?`).bind(API_KEY).run(); +} + +/** 把 CRED_* secret 值疊到真實 env 上(模擬 per-script secret 已注入 runtime)。 */ +function envWithSecrets(secrets: Record): Bindings { + return { ...(env as unknown as Record), ...secrets } as unknown as Bindings; +} + +describe('resolveSecretsFromNewHome (T6/T7 方案 A)', () => { + beforeEach(clearRows); + + it('D1 有 ref 且新家有值 → name 進 map(T6 命中新家)', async () => { + await insertRefRow('openai_key', 'CRED_OPENAI_KEY_ABCDEF01'); + const testEnv = envWithSecrets({ CRED_OPENAI_KEY_ABCDEF01: 'sk-live-123' }); + + const resolved = await resolveSecretsFromNewHome(testEnv, API_KEY, ['openai_key']); + + expect(resolved).toEqual({ openai_key: 'sk-live-123' }); + }); + + it('D1 無 ref(從未回填)→ name 缺席 map(走 WASM fallback 舊 KV,T7)', async () => { + // D1 沒有這個 name 的 row + const testEnv = envWithSecrets({ CRED_ANYTHING: 'unused' }); + + const resolved = await resolveSecretsFromNewHome(testEnv, API_KEY, ['never_migrated']); + + expect(resolved).not.toHaveProperty('never_migrated'); + expect(resolved).toEqual({}); + }); + + it('D1 有 ref 但新家沒值 → secret_get 回 null → name 缺席(不放空字串)', async () => { + await insertRefRow('notion_token', 'CRED_NOTION_TOKEN_ABCDEF01'); + // env 裡沒有 CRED_NOTION_TOKEN_ABCDEF01 → env[ref] 為 undefined → secret_get 回 null + const testEnv = envWithSecrets({}); + + const resolved = await resolveSecretsFromNewHome(testEnv, API_KEY, ['notion_token']); + + // 關鍵:缺席,不是 { notion_token: '' }(空字串會讓 WASM 誤判命中用空值) + expect(resolved).not.toHaveProperty('notion_token'); + expect(resolved).toEqual({}); + }); + + it('混合:一把有值一把沒 ref → 只有有值的進 map', async () => { + await insertRefRow('has_value', 'CRED_HAS_VALUE_ABCDEF01'); + const testEnv = envWithSecrets({ CRED_HAS_VALUE_ABCDEF01: 'the-value' }); + + const resolved = await resolveSecretsFromNewHome(testEnv, API_KEY, ['has_value', 'no_ref']); + + expect(resolved).toEqual({ has_value: 'the-value' }); + }); + + it('空 names → 空 map(零開銷)', async () => { + const resolved = await resolveSecretsFromNewHome(envWithSecrets({}), API_KEY, []); + expect(resolved).toEqual({}); + }); + + it('取到值後更新 last_used_at(治理面)', async () => { + await insertRefRow('used_key', 'CRED_USED_KEY_ABCDEF01'); + const before = await env.CREDENTIALS_DB + .prepare(`SELECT last_used_at FROM credentials WHERE api_key = ? AND name = ?`) + .bind(API_KEY, 'used_key') + .first<{ last_used_at: number | null }>(); + expect(before?.last_used_at).toBeNull(); + + const testEnv = envWithSecrets({ CRED_USED_KEY_ABCDEF01: 'v' }); + await resolveSecretsFromNewHome(testEnv, API_KEY, ['used_key']); + + const after = await env.CREDENTIALS_DB + .prepare(`SELECT last_used_at FROM credentials WHERE api_key = ? AND name = ?`) + .bind(API_KEY, 'used_key') + .first<{ last_used_at: number | null }>(); + expect(typeof after?.last_used_at).toBe('number'); + }); +}); diff --git a/registry/components/auth_service_account/main.go b/registry/components/auth_service_account/main.go index 73d4236..d319a0d 100644 --- a/registry/components/auth_service_account/main.go +++ b/registry/components/auth_service_account/main.go @@ -62,6 +62,11 @@ type Input struct { APIKey string `json:"api_key"` Service string `json:"service"` Request json.RawMessage `json:"request,omitempty"` + // ResolvedSecrets:credential-store 遷移 T6/T7 方案 A(D19)——cypher-executor 從自己的 + // per-script secrets(新家)用 secret_get(D1 ref) 取到的明文,key = credential name + // (對 SA 而言就是 required_secrets[0].key)。有值優先用,沒有才 fallback 舊 KV+crypto_decrypt。 + // default(此欄空/nil)行為完全等於遷移前的舊碼。 + ResolvedSecrets map[string]string `json:"resolved_secrets,omitempty"` } type SecretRequirement struct { @@ -176,28 +181,38 @@ func main() { return } - // 2. 解密 service account JSON (慣例:required_secrets[0] 是 SA JSON) + // 2. 取得 service account JSON 明文 (慣例:required_secrets[0] 是 SA JSON) saReq := recipe.RequiredSecrets[0] - kvKey := input.APIKey + ":cred:" + saReq.Key - encJSON, s := kvGet(kvKey) - if s == 2 { - writeError("缺少 credential: " + saReq.Key + " (" + saReq.Label + ")。修復: 編輯 credentials.yaml 後執行 acr creds push") - return - } - if s != 0 { - writeError("kv_get 失敗(credential " + saReq.Key + ")") - return - } - var rec EncryptedRecord - if err := json.Unmarshal([]byte(encJSON), &rec); err != nil { - writeError("credential " + saReq.Key + " 格式錯誤: " + err.Error()) - return - } - saJSONStr, ok := cryptoDecrypt(rec.Encrypted, rec.IV) - if !ok { - writeError("credential " + saReq.Key + " 解密失敗") - return + var saJSONStr string + // T6 方案 A:cypher 已從新家(per-script secrets)取到明文就直接用,不走 KV。 + // input.ResolvedSecrets 為 nil 時此 map 讀取回 ("", false),行為與遷移前一致。 + if v, ok := input.ResolvedSecrets[saReq.Key]; ok { + saJSONStr = v + } else { + // T7 雙讀 fallback:新家沒給 → 舊路徑 kv_get + crypto_decrypt。 + kvKey := input.APIKey + ":cred:" + saReq.Key + encJSON, s := kvGet(kvKey) + if s == 2 { + writeError("缺少 credential: " + saReq.Key + " (" + saReq.Label + ")。修復: 編輯 credentials.yaml 後執行 acr creds push") + return + } + if s != 0 { + writeError("kv_get 失敗(credential " + saReq.Key + ")") + return + } + + var rec EncryptedRecord + if err := json.Unmarshal([]byte(encJSON), &rec); err != nil { + writeError("credential " + saReq.Key + " 格式錯誤: " + err.Error()) + return + } + decrypted, ok := cryptoDecrypt(rec.Encrypted, rec.IV) + if !ok { + writeError("credential " + saReq.Key + " 解密失敗") + return + } + saJSONStr = decrypted } // 3. 解析 service account JSON diff --git a/registry/components/auth_static_key/main.go b/registry/components/auth_static_key/main.go index 02c194a..30d03cc 100644 --- a/registry/components/auth_static_key/main.go +++ b/registry/components/auth_static_key/main.go @@ -50,6 +50,11 @@ type Input struct { // Names:resolve_credentials action 用——要解密的 credential 名稱清單 // (用戶在 workflow node.data 寫 {{credential.NAME}} 時,graph-executor 收集後傳入)。 Names []string `json:"names,omitempty"` + // ResolvedSecrets:credential-store 遷移 T6/T7 方案 A(D19)——cypher-executor 已從 + // 自己的 per-script secrets(新家)用 secret_get(D1 ref) 取到的明文,key = credential name。 + // 有值就優先用,沒有才 fallback 舊路徑(kv_get {api_key}:cred:{name} + crypto_decrypt), + // 這個 fallback 就是雙讀(T7)。default(此欄空/nil)行為完全等於遷移前的舊碼。 + ResolvedSecrets map[string]string `json:"resolved_secrets,omitempty"` } type SecretRequirement struct { @@ -143,6 +148,15 @@ func main() { if req.Optional { continue } + + // T6 方案 A:cypher 已從新家(per-script secrets)取到明文就直接用,不走 KV。 + // input.ResolvedSecrets 為 nil 時此 map 讀取回 ("", false),行為與遷移前一致。 + if v, ok := input.ResolvedSecrets[req.Key]; ok { + secrets[req.Key] = v + continue + } + + // T7 雙讀 fallback:新家沒給 → 走舊路徑 kv_get + crypto_decrypt。 kvKey := input.APIKey + ":cred:" + req.Key encJSON, s := kvGet(kvKey) if s == 2 { @@ -220,6 +234,14 @@ func handleResolveCredentials(input Input) { if name == "" { continue } + + // T6 方案 A:cypher 已從新家取到明文就直接用(同 authenticate 分支)。 + if v, ok := input.ResolvedSecrets[name]; ok { + credentials[name] = v + continue + } + + // T7 雙讀 fallback:新家沒給 → 舊路徑 kv_get + crypto_decrypt。 kvKey := input.APIKey + ":cred:" + name encJSON, s := kvGet(kvKey) if s == 2 { diff --git a/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md b/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md index e50b6da..67ff9e1 100644 --- a/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md +++ b/system-dev/docs/3-specs/arcrun/credential-primitives-wasm/credential-store-migration.md @@ -250,7 +250,34 @@ CLI 薄殼(rule 07):`acr creds list`(讀 D1 顯示)、`acr creds repla 正確)→ 清理:CF API `DELETE .../secrets/{ref}` + D1 `DELETE FROM credentials`,二次查詢確認 皆已清空。部署完成後**立刻 restore `wrangler.toml` 備份**,git 追蹤版本無 leo21c 帳號專屬 id 殘留(已 grep 確認)。 -- [⛔] T6 讀取/注入路徑:auth-dispatcher 改 D1 ref → `env[ref]` 取值(§2.5)+ 更新 last_used。 +- [x] T6 讀取/注入路徑:auth-dispatcher 改 D1 ref → `env[ref]` 取值(§2.5)+ 更新 last_used。 + 〔2026-07-05 本機(有 TinyGo 0.40.1)接手,方案 A,leo 已拍板〕**接續 2026-07-04 [cloud-worker] + 揭露的正確落地方式**(cypher 先取值塞 payload,因 auth worker 讀不到 cypher 的 secrets): + **WASM 端(T7 fallback 骨架,可獨立部署驗)**: + - `registry/components/auth_static_key/main.go`:Input 加 `ResolvedSecrets map[string]string`; + `authenticate` 的解密迴圈與 `handleResolveCredentials` 都改為「`input.ResolvedSecrets[key]` + 有值就用,沒有才 fallback 舊 `kvGet`+`cryptoDecrypt`」。default(此欄 nil)行為完全等於遷移前舊碼。 + - `registry/components/auth_service_account/main.go`:SA JSON 取得同型(有 resolved 就用,沒有才舊路徑)。 + - `tinygo build -target=wasi` 兩支皆通過(static_key 1.13MB / service_account 1.18MB,在 2MB 限制內), + copy 到 `.component-builds/{name}/component.wasm`。 + **TS 端(T6 主路徑)**:`cypher-executor/src/actions/auth-dispatcher.ts` 新增 + `resolveSecretsFromNewHome(env, apiKey, names)`:查 D1 `credentials`(api_key+name IN names)拿 + `secret_ref` → `createArcrunHostFunctions(env, apiKey).secret_get(ref)`(T4,host 端 = `env[ref]`) + 取明文 → 組 `{name:value}` map,**取不到值的 name 缺席(不放空字串)** 讓 WASM fallback(T7)→ + 取到值的 name `UPDATE credentials SET last_used_at`。`tryAuthDispatch` 取 recipe 非 optional + `required_secrets[].key` 當 names、`resolveCredentialRefs` 用已收集的 names,兩者都把 + `resolved_secrets` 塞進送 auth WASM 的 POST body。**rule 02 §2.2 對齊**:只查 ref/取值/塞字串, + 不解密、不展開模板、不組 JWT(grep 確認無 `crypto.subtle`/`interpolate`/`{{secret.`/`BUILTIN_*`)。 + **驗證**:`cypher-executor` + `cli` `tsc --noEmit` 皆 exit 0;`vitest run` 41/42(新增 + `tests/auth-dispatcher.test.ts` 6 案例全過:D1 有 ref+新家有值→進 map/無 ref→缺席走 fallback/ + 有 ref 但新家沒值 secret_get 回 null→缺席不放空字串/混合/空 names/取值後更新 last_used_at; + 剩 1 個 pre-existing 無關失敗 executor.test.ts「不存在」措辭,見 T8 記錄,非本次退步—— + git baseline 35/36 → 41/42,只增不減)。 + **待 leo21c 部署驗(本任務不部署,`acr update` 硬綁 GitHub codeload=mistakes #23,交總管/後續)**: + WASM 端到端(resolved_secrets 命中新家的真實注入)在 vitest 測不到(JSPI import 限制,同 T4 記錄), + `secret_get` 的 pointer/memory-write 也待部署端到端涵蓋。TS 側 `resolveSecretsFromNewHome` 的 + D1 查詢 + secret_get + last_used 更新已用真實 D1 binding + fake env(模擬 per-script secret 注入) + 單元覆蓋。 〔2026-07-04 [cloud-worker] 卡在框架級工具缺口,非設計問題〕**T6 需要修改 `registry/components/auth_static_key/main.go`(與 `auth_service_account/main.go`)**: 這兩個 auth primitive 是獨立部署的 TinyGo WASM Worker,密文值現在住在 @@ -273,10 +300,17 @@ CLI 薄殼(rule 07):`acr creds list`(讀 D1 顯示)、`acr creds repla 決定是否要在雲端環境補裝工具鏈(例如允許 proxy 放行 github.com release 下載,或 改用其他 TinyGo 取得管道)。整案不硬繞(不改用「TS 直接組 header」這種違反 rule 02 的捷徑),留給有 TinyGo 環境的一方接手 main.go 改動 + 重新編譯部署。 -- [⛔] T7 雙讀 fallback(§4.1)。〔同上,隨 T6 main.go 改動一併落地——D1 查得到 secret_ref - 走新路徑,查不到就是 WASM 原有的 `kv_get`+`crypto_decrypt` 分支,本來就會自然發生, - 不需要額外的「fallback 判斷」程式碼,只要 T6 的 `resolved_secrets` 是「有給才用、沒給 - 就照舊」的設計即可。同樣卡在 T6 的 TinyGo 前置。〕 +- [x] T7 雙讀 fallback(§4.1)。〔2026-07-05 本機,隨 T6 一併落地。〕如原設計預期: + **不需額外的「fallback 判斷」程式碼**——T6 的 `resolved_secrets` 就是「有給才用、沒給 + 就照舊 kv_get+crypto_decrypt」的設計(WASM 端 `input.ResolvedSecrets[key]` 的 `ok` 判斷即分流)。 + 雙讀在**兩層**自然成立: + (1) cypher 層——`resolveSecretsFromNewHome` 查 D1 查不到 ref(從未回填)或 `secret_get` 回 null + (新家還沒值)→ 該 name 不進 `resolved_secrets` map; + (2) WASM 層——收到的 `resolved_secrets` 缺該 key → 走既有 `kvGet`+`cryptoDecrypt` 舊路徑。 + 新寫入(T5)一律新家;舊資料未回填(T8)前仍可讀(舊 KV 不刪,§4.3 回滾錨點不動)。 + 單元測試涵蓋「無 ref→缺席」「有 ref 但新家沒值 secret_get 回 null→缺席」兩條 fallback 分流 + (見 T6 的 `tests/auth-dispatcher.test.ts`)。default(無 resolved_secrets 欄)行為 = 遷移前舊碼, + 故 WASM 端此改動可獨立部署、零行為改變地驗「沒弄壞既有」。 - [x] T8 回填端點 `POST /credentials/migrate-to-workers-secrets`(§4.2,冪等可審)。 2026-07-04 [cloud-worker] 完成:`cypher-executor/src/routes/credentials.ts` 新增 端點,重用 `createArcrunHostFunctions(env, apiKey).crypto_decrypt`(wasi-shim.ts