diff --git a/.component-builds/auth_static_key/component.wasm b/.component-builds/auth_static_key/component.wasm index 86e9940..99fb8d7 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 f9b1e69..3101c0d 100644 --- a/cypher-executor/src/actions/auth-dispatcher.ts +++ b/cypher-executor/src/actions/auth-dispatcher.ts @@ -105,3 +105,85 @@ export async function tryAuthDispatch( _auth_path: result.auth_path ?? {}, }; } + +// ── 用戶面 {{credential.NAME}} 注入(design §8)──────────────────────────────── + +/** 匹配 {{credential.NAME}}(NAME 為 word 字元) */ +const CREDENTIAL_REF = /\{\{credential\.(\w+)\}\}/g; + +/** 遞迴收集任意值(string / 物件 / 陣列)裡所有 {{credential.NAME}} 的 NAME */ +function collectCredentialNames(value: unknown, out: Set): void { + if (typeof value === 'string') { + for (const m of value.matchAll(CREDENTIAL_REF)) out.add(m[1]); + } else if (Array.isArray(value)) { + for (const v of value) collectCredentialNames(v, out); + } else if (value && typeof value === 'object') { + for (const v of Object.values(value as Record)) collectCredentialNames(v, out); + } +} + +/** 遞迴把 {{credential.NAME}} 替換成 resolved[NAME](未知 name 原樣保留) */ +function replaceCredentialRefs(value: unknown, resolved: Record): unknown { + if (typeof value === 'string') { + return value.replace(CREDENTIAL_REF, (orig, name: string) => + Object.prototype.hasOwnProperty.call(resolved, name) ? resolved[name] : orig, + ); + } + if (Array.isArray(value)) return value.map((v) => replaceCredentialRefs(v, resolved)); + if (value && typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = replaceCredentialRefs(v, resolved); + } + return out; + } + return value; +} + +/** + * 展開節點 data 裡用戶寫的 `{{credential.NAME}}`(design §8)。 + * + * 嚴格邊界(rule 02 §2.2):本函式**不解密**。偵測到 {{credential.X}} 後,把 names 交給 + * auth_static_key WASM 的 `resolve_credentials` action(WASM 內 kv_get + crypto_decrypt), + * 拿回明文後只做字串回填。ENCRYPTION_KEY 永不經此處。 + * + * - 無 {{credential.}} → 原樣回傳(不打 WASM,零開銷) + * - 解密失敗 / 缺 credential → throw(誠實報錯,不假綠) + */ +export async function resolveCredentialRefs( + data: Record, + env: Bindings, + apiKey: string, +): Promise> { + const names = new Set(); + collectCredentialNames(data, names); + if (names.size === 0) return data; + + const url = wasmWorkerUrl('auth_static_key', env.WORKER_SUBDOMAIN); + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'resolve_credentials', + api_key: apiKey, + names: [...names], + }), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`credential resolve 回傳 ${res.status}: ${text.slice(0, 200)}`); + } + + const result = (await res.json().catch(() => null)) as { + success?: boolean; + error?: string; + credentials?: Record; + } | null; + + if (!result || result.success === false) { + throw new Error(`credential resolve 失敗: ${result?.error ?? '未知錯誤'}`); + } + + return replaceCredentialRefs(data, result.credentials ?? {}) as Record; +} diff --git a/cypher-executor/src/graph-executor.ts b/cypher-executor/src/graph-executor.ts index 7f8abbe..f660399 100644 --- a/cypher-executor/src/graph-executor.ts +++ b/cypher-executor/src/graph-executor.ts @@ -2,7 +2,7 @@ import type { ExecutionGraph, GraphNode, TraceStep, ComponentRunner, KVContextStore, EdgeType, Bindings } from './types'; import { kvSetNodeOutput, kvGetNodeOutput, ExecutionError, WorkflowPaused } from './types'; import { injectCredentials } from './actions/credential-injector'; -import { tryAuthDispatch } from './actions/auth-dispatcher'; +import { tryAuthDispatch, resolveCredentialRefs } from './actions/auth-dispatcher'; import { expandPromptRecipe } from './lib/recipe-expander'; import { resolveRecipe } from './routes/recipes'; import { persistPausedRun, isResumablePending, parseRecipeOutput } from './lib/paused-runs'; @@ -245,6 +245,13 @@ export class GraphExecutor { ...resolvedData, }; + // 用戶面 {{credential.NAME}} 展開(design §8):偵測 node.data 裡用戶寫的 + // {{credential.X}} → 交 auth_static_key WASM resolve_credentials 解密回填。 + // 解密在 WASM(rule 02 §2.2),此處只偵測+回填,不碰 ENCRYPTION_KEY。 + if (this.env && this.apiKey) { + mergedContext = await resolveCredentialRefs(mergedContext, this.env, this.apiKey); + } + // Resumable workflow callback_url 注入(SDD: resumable-workflow/design.md §2.2) // claude_api 容器拿到後會透傳給 Mira daemon,daemon task 完成時 POST 進來 // hostname 暫從 PUBLIC_BASE_URL 取,沒設則用 cypher.arcrun.dev 預設 diff --git a/registry/components/auth_static_key/main.go b/registry/components/auth_static_key/main.go index 544b102..02c194a 100644 --- a/registry/components/auth_static_key/main.go +++ b/registry/components/auth_static_key/main.go @@ -47,6 +47,9 @@ type Input struct { APIKey string `json:"api_key"` Service string `json:"service"` Request json.RawMessage `json:"request,omitempty"` + // Names:resolve_credentials action 用——要解密的 credential 名稱清單 + // (用戶在 workflow node.data 寫 {{credential.NAME}} 時,graph-executor 收集後傳入)。 + Names []string `json:"names,omitempty"` } type SecretRequirement struct { @@ -96,12 +99,20 @@ func main() { writeError("api_key 必填") return } + + // resolve_credentials:用戶面 {{credential.NAME}} 入口。不查 recipe、不要求 service, + // 直接給 names 解密回明文。在 service 必填檢查之前分流(只有 authenticate 才需要 recipe)。 + if input.Action == "resolve_credentials" { + handleResolveCredentials(input) + return + } + if input.Service == "" { writeError("service 必填") return } if input.Action != "" && input.Action != "authenticate" { - writeError("auth_static_key 僅支援 action=authenticate") + writeError("auth_static_key 僅支援 action=authenticate / resolve_credentials") return } @@ -195,6 +206,52 @@ func main() { os.Stdout.Write(out) } +// handleResolveCredentials 處理用戶面 {{credential.NAME}} 入口: +// 對每個 name 讀 {api_key}:cred:{name} + 解密,回傳明文 map。 +// 不查 auth recipe(與 authenticate 分流)。缺任一 name → success:false + error 指明(不假綠)。 +func handleResolveCredentials(input Input) { + if len(input.Names) == 0 { + writeError("resolve_credentials 需要 names(要解密的 credential 名稱清單)") + return + } + + credentials := make(map[string]string, len(input.Names)) + for _, name := range input.Names { + if name == "" { + continue + } + kvKey := input.APIKey + ":cred:" + name + encJSON, s := kvGet(kvKey) + if s == 2 { + writeError("缺少 credential: " + name + "。修復: 編輯 credentials.yaml 後執行 acr creds push") + return + } + if s != 0 { + writeError("kv_get 失敗(credential " + name + ")") + return + } + + var rec EncryptedRecord + if err := json.Unmarshal([]byte(encJSON), &rec); err != nil { + writeError("credential " + name + " 格式錯誤: " + err.Error()) + return + } + + plaintext, ok := cryptoDecrypt(rec.Encrypted, rec.IV) + if !ok { + writeError("credential " + name + " 解密失敗") + return + } + credentials[name] = plaintext + } + + out, _ := json.Marshal(map[string]interface{}{ + "success": true, + "credentials": credentials, + }) + os.Stdout.Write(out) +} + // ── helpers ────────────────────────────────────────────────────────────────── func writeError(msg string) {