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:
@@ -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<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` action(WASM 內 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>;
|
||||
}
|
||||
|
||||
@@ -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 預設
|
||||
|
||||
Reference in New Issue
Block a user