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:
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` 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 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 解密回填。
|
||||||
|
// 解密在 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)
|
// Resumable workflow callback_url 注入(SDD: resumable-workflow/design.md §2.2)
|
||||||
// claude_api 容器拿到後會透傳給 Mira daemon,daemon task 完成時 POST 進來
|
// claude_api 容器拿到後會透傳給 Mira daemon,daemon task 完成時 POST 進來
|
||||||
// hostname 暫從 PUBLIC_BASE_URL 取,沒設則用 cypher.arcrun.dev 預設
|
// hostname 暫從 PUBLIC_BASE_URL 取,沒設則用 cypher.arcrun.dev 預設
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
// Names:resolve_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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user