feat(credential-injection): {{credential.X}} 用戶面語法(credential-primitives §8)

壓測 401 根因:{{credential.X}} 系統沒實裝,三條 template 展開路徑都不認
credential. namespace → 注入空值 → 目標 API 401(test_arcrun/5 Haiku 實證)。

修法(design §8,richblack 確認方向 B「讓 {{credential.X}} 真的能用」):
- auth_static_key 加 resolve_credentials action:給 names → WASM 內 kv_get +
  crypto_decrypt → 回明文 map(不查 recipe、缺則誠實報錯)
- auth-dispatcher 加 resolveCredentialRefs:遞迴偵測 {{credential.X}} → 交 WASM
  解密 → 回填(無 ref 則零開銷不打 WASM)
- graph-executor 在 node.data interpolate 後呼叫,不碰 ENCRYPTION_KEY(rule 02 §2.2)

解密全程在 WASM,TS 只偵測+回填。tinygo build OK + tsc 0 + §2.2 自檢綠。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-06-10 13:55:31 +08:00
parent 465c505000
commit da84425d25
4 changed files with 148 additions and 2 deletions
+58 -1
View File
@@ -47,6 +47,9 @@ type Input struct {
APIKey string `json:"api_key"`
Service string `json:"service"`
Request json.RawMessage `json:"request,omitempty"`
// Namesresolve_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) {