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
Binary file not shown.
@@ -105,3 +105,85 @@ export async function tryAuthDispatch(
_auth_path: result.auth_path ?? {}, _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<string>): 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<string, unknown>)) collectCredentialNames(v, out);
}
}
/** 遞迴把 {{credential.NAME}} 替換成 resolved[NAME](未知 name 原樣保留) */
function replaceCredentialRefs(value: unknown, resolved: Record<string, string>): 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<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
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` actionWASM 內 kv_get + crypto_decrypt),
* 拿回明文後只做字串回填。ENCRYPTION_KEY 永不經此處。
*
* - 無 {{credential.}} → 原樣回傳(不打 WASM,零開銷)
* - 解密失敗 / 缺 credential → throw(誠實報錯,不假綠)
*/
export async function resolveCredentialRefs(
data: Record<string, unknown>,
env: Bindings,
apiKey: string,
): Promise<Record<string, unknown>> {
const names = new Set<string>();
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<string, string>;
} | null;
if (!result || result.success === false) {
throw new Error(`credential resolve 失敗: ${result?.error ?? '未知錯誤'}`);
}
return replaceCredentialRefs(data, result.credentials ?? {}) as Record<string, unknown>;
}
+8 -1
View File
@@ -2,7 +2,7 @@
import type { ExecutionGraph, GraphNode, TraceStep, ComponentRunner, KVContextStore, EdgeType, Bindings } from './types'; import type { ExecutionGraph, GraphNode, TraceStep, ComponentRunner, KVContextStore, EdgeType, Bindings } from './types';
import { kvSetNodeOutput, kvGetNodeOutput, ExecutionError, WorkflowPaused } from './types'; import { kvSetNodeOutput, kvGetNodeOutput, ExecutionError, WorkflowPaused } from './types';
import { injectCredentials } from './actions/credential-injector'; 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 { expandPromptRecipe } from './lib/recipe-expander';
import { resolveRecipe } from './routes/recipes'; import { resolveRecipe } from './routes/recipes';
import { persistPausedRun, isResumablePending, parseRecipeOutput } from './lib/paused-runs'; import { persistPausedRun, isResumablePending, parseRecipeOutput } from './lib/paused-runs';
@@ -245,6 +245,13 @@ export class GraphExecutor {
...resolvedData, ...resolvedData,
}; };
// 用戶面 {{credential.NAME}} 展開(design §8):偵測 node.data 裡用戶寫的
// {{credential.X}} → 交 auth_static_key WASM resolve_credentials 解密回填。
// 解密在 WASMrule 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 // Resumable workflow callback_url 注入(SDD: resumable-workflow/design.md §2.2
// claude_api 容器拿到後會透傳給 Mira daemondaemon task 完成時 POST 進來 // claude_api 容器拿到後會透傳給 Mira daemondaemon task 完成時 POST 進來
// hostname 暫從 PUBLIC_BASE_URL 取,沒設則用 cypher.arcrun.dev 預設 // hostname 暫從 PUBLIC_BASE_URL 取,沒設則用 cypher.arcrun.dev 預設
+58 -1
View File
@@ -47,6 +47,9 @@ type Input struct {
APIKey string `json:"api_key"` APIKey string `json:"api_key"`
Service string `json:"service"` Service string `json:"service"`
Request json.RawMessage `json:"request,omitempty"` 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 { type SecretRequirement struct {
@@ -96,12 +99,20 @@ func main() {
writeError("api_key 必填") writeError("api_key 必填")
return return
} }
// resolve_credentials:用戶面 {{credential.NAME}} 入口。不查 recipe、不要求 service
// 直接給 names 解密回明文。在 service 必填檢查之前分流(只有 authenticate 才需要 recipe)。
if input.Action == "resolve_credentials" {
handleResolveCredentials(input)
return
}
if input.Service == "" { if input.Service == "" {
writeError("service 必填") writeError("service 必填")
return return
} }
if input.Action != "" && input.Action != "authenticate" { if input.Action != "" && input.Action != "authenticate" {
writeError("auth_static_key 僅支援 action=authenticate") writeError("auth_static_key 僅支援 action=authenticate / resolve_credentials")
return return
} }
@@ -195,6 +206,52 @@ func main() {
os.Stdout.Write(out) 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 ────────────────────────────────────────────────────────────────── // ── helpers ──────────────────────────────────────────────────────────────────
func writeError(msg string) { func writeError(msg string) {