feat(credentials): T6 讀取/注入接新家 + T7 雙讀 fallback (credential-store-migration 方案 A)

D19「擁有目錄不擁有內容物」遷移的讀取斷點。密文住 cypher per-script secrets、
D1 只存 secret_ref、auth worker 讀不到 cypher 的 secrets → cypher 先取值塞 payload。

WASM 端(T7 fallback 骨架,零行為改變可獨立部署驗):
- auth_static_key/main.go + auth_service_account/main.go:Input 加 resolved_secrets,
  解密處改「有 resolved 就用、沒有才 fallback 舊 kv_get+crypto_decrypt」。default 等於舊碼。
- tinygo build 兩支通過,copy 到 .component-builds/*/component.wasm。

TS 端(T6 主路徑):
- auth-dispatcher.ts 新增 resolveSecretsFromNewHome:查 D1 拿 secret_ref →
  secret_get(env[ref], T4) 取明文 → 組 map(取不到的 name 缺席,不放空字串) →
  更新 last_used_at。tryAuthDispatch + resolveCredentialRefs 都塞 resolved_secrets。
- rule 02 §2.2 對齊:只查 ref/取值/塞字串,不解密不展開模板不組 JWT。

驗證:cypher-executor + cli tsc exit 0;vitest 41/42(新增 auth-dispatcher.test.ts 6 案例全過,
剩 1 pre-existing 無關失敗)。待 leo21c 部署驗 WASM 端到端(不由本任務部署,acr update
硬綁 GitHub codeload=mistakes #23)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-07-05 13:44:26 +08:00
parent 55a47d7c18
commit ed2e42e007
7 changed files with 314 additions and 26 deletions
Binary file not shown.
+113 -1
View File
@@ -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 secretsT5 寫入)。解密發生在獨立的
// 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<Record<string, string>> {
const resolved: Record<string, string> = {};
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<CredentialRefRow>();
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 舊 KVT7)。
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 舊 KVT7)。
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,
}),
});
@@ -0,0 +1,105 @@
/**
* credential-store-migration T6/T7(方案 AD19)測試:auth-dispatcher 的
* `resolveSecretsFromNewHome`——「查 D1 拿 secret_ref → secret_get(env[ref]) 取值 → 組 map」。
*
* 覆蓋 T6 主路徑 + T7 雙讀分流的判準:
* - D1 有 ref 且 secret 存在(新家有值)→ name 進 mappayload 會含 resolved_secrets[name]
* - D1 無 ref(從未回填)→ name 缺席 map(讓 WASM fallback 舊 KVT7
* - 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<void> {
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<void> {
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<string, string>): Bindings {
return { ...(env as unknown as Record<string, unknown>), ...secrets } as unknown as Bindings;
}
describe('resolveSecretsFromNewHome (T6/T7 方案 A)', () => {
beforeEach(clearRows);
it('D1 有 ref 且新家有值 → name 進 mapT6 命中新家)', 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 舊 KVT7', 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');
});
});
@@ -62,6 +62,11 @@ type Input struct {
APIKey string `json:"api_key"`
Service string `json:"service"`
Request json.RawMessage `json:"request,omitempty"`
// ResolvedSecretscredential-store 遷移 T6/T7 方案 AD19)——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 方案 Acypher 已從新家(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
@@ -50,6 +50,11 @@ type Input struct {
// Namesresolve_credentials action 用——要解密的 credential 名稱清單
// (用戶在 workflow node.data 寫 {{credential.NAME}} 時,graph-executor 收集後傳入)。
Names []string `json:"names,omitempty"`
// ResolvedSecretscredential-store 遷移 T6/T7 方案 AD19)——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 方案 Acypher 已從新家(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 {
@@ -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)接手,方案 Aleo 已拍板〕**接續 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)`T4host 端 = `env[ref]`
取明文 → 組 `{name:value}` map,**取不到值的 name 缺席(不放空字串)** 讓 WASM fallbackT7)→
取到值的 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