arcrun — AI workflow execution engine (clean history)

Self-hosted 開源:WASM 零件 + recipe + cypher-executor,跑在你自己的 Cloudflare。

此為重建的乾淨歷史起點(移除曾誤 commit 的 GCP SA 金鑰,舊歷史保留在
richblack/arcrun 與本地 backup 分支)。含:
- acr init --self-hosted installer(建 KV/R2 + codeload 拉預編譯 wasm + wrangler deploy + seed recipe)
- recipe push 把關(資料外流提醒 + 打通檢查)
- 19 個正當零件預編譯 wasm(claude_api/km_writer/kbdb_upsert_block 排除:違反 DECISIONS §1)
- CLI / cypher-executor / registry / 完整 SDD

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-06-03 15:52:38 +08:00
commit 922a57fe34
485 changed files with 89356 additions and 0 deletions
@@ -0,0 +1,235 @@
/**
* Credential Injector
*
* 執行順序:
* 1. 檢查是否有對應的 auth recipeauth_recipe:{componentId} in RECIPES KV
* → 有:走 auth recipe 路徑(支援 static_key, service_account
* → 無:走舊有 flat injection 路徑(向後相容)
*
* Auth Recipe 路徑:
* - static_key:展開 inject.header/query/body 的 {{secret.KEY}} 模板
* - service_accountJWT signing → token exchange → 展開 {{runtime.access_token}}
* - 注入結果以 _auth_headers / _auth_query / _auth_body 攜帶,不污染業務欄位
*
* 舊有路徑(向後相容):
* - 從 RECIPES KV 讀取 credentials_required(動態 recipe
* - 或從 BUILTIN_CREDENTIALS_MAP(內建清單)
* - 解密後以 inject_as 欄位名稱直接注入 context
*/
import type { Bindings } from '../types';
import { resolveRecipe, resolveAuthRecipe } from '../routes/recipes';
import type { AuthRecipeDefinition } from '../routes/recipes';
export interface CredentialRequirement {
key: string; // CREDENTIALS_KV 的 credential 名稱(如 gmail_token
inject_as: string; // 注入到 input 的欄位名稱(如 access_token
}
/** 內建 API recipe 的 credentials_required(對應 component-loader 的 BUILTIN_API_RECIPES*/
const BUILTIN_CREDENTIALS_MAP: Record<string, CredentialRequirement[]> = {
gmail: [{ key: 'gmail_token', inject_as: 'access_token' }],
google_sheets: [{ key: 'google_oauth', inject_as: 'access_token' }],
telegram: [{ key: 'telegram_bot_token', inject_as: 'bot_token' }],
line_notify: [{ key: 'line_token', inject_as: 'token' }],
};
// ── AES-GCM 解密 ──────────────────────────────────────────────────────────────
async function decryptCredential(encryptedJson: string, encryptionKey: string): Promise<string> {
const { encrypted, iv } = JSON.parse(encryptedJson) as { encrypted: string; iv: string };
const keyBytes = hexToUint8Array(encryptionKey);
const cryptoKey = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt'],
);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToUint8Array(iv) },
cryptoKey,
base64ToUint8Array(encrypted),
);
return new TextDecoder().decode(decrypted);
}
function hexToUint8Array(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
return bytes;
}
function base64ToUint8Array(b64: string): Uint8Array {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
// ── 解密所有 required_secrets → { key: decryptedValue } ──────────────────────
async function decryptSecrets(
recipe: AuthRecipeDefinition,
apiKey: string,
env: Bindings,
): Promise<Record<string, string>> {
const result: Record<string, string> = {};
for (const req of recipe.required_secrets) {
if (req.optional) continue;
const kvKey = `${apiKey}:cred:${req.key}`;
const record = await env.CREDENTIALS_KV.get(kvKey);
if (!record) {
throw new Error(
`缺少 credential${req.key}${req.label}\n` +
`修復步驟:\n` +
` 1. 在 credentials.yaml 加入 ${req.key}: "your-value"\n` +
` 2. 執行:acr creds push`,
);
}
result[req.key] = await decryptCredential(record, env.ENCRYPTION_KEY);
}
return result;
}
// ── Template 展開:{{secret.KEY}} 和 {{runtime.KEY}} ─────────────────────────
function interpolateTemplate(
template: string,
secrets: Record<string, string>,
runtime: Record<string, string>,
): string {
return template.replace(/\{\{(secret|runtime)\.(\w+)\}\}/g, (_, ns, key) => {
if (ns === 'secret') return secrets[key] ?? '';
if (ns === 'runtime') return runtime[key] ?? '';
return '';
});
}
function interpolateRecord(
record: Record<string, string>,
secrets: Record<string, string>,
runtime: Record<string, string>,
): Record<string, string> {
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(record)) {
result[k] = interpolateTemplate(v, secrets, runtime);
}
return result;
}
// ── Auth Recipe 注入(新路徑)────────────────────────────────────────────────
async function injectFromAuthRecipe(
recipe: AuthRecipeDefinition,
input: Record<string, unknown>,
env: Bindings,
apiKey: string,
): Promise<Record<string, unknown>> {
// 解密所有 required_secrets
const secrets = await decryptSecrets(recipe, apiKey, env);
// runtime tokenservice_account 路徑已改走 auth-dispatcher → auth_service_account WASM;
// 這條 TS fallback 只處理 static_key (runtime 為空即可),service_account 永遠不會走到這裡
const runtime: Record<string, string> = {};
if (recipe.primitive === 'service_account') {
throw new Error(
`service_account primitive 應由 auth-dispatcher → auth_service_account WASM 處理,` +
`不應進到 credential-injector TS fallback (service=${recipe.service})`,
);
}
// 展開 inject 模板
const authHeaders = recipe.inject.header
? interpolateRecord(recipe.inject.header, secrets, runtime)
: {};
const authQuery = recipe.inject.query
? interpolateRecord(recipe.inject.query, secrets, runtime)
: {};
const authBody = recipe.inject.body
? interpolateRecord(recipe.inject.body, secrets, runtime)
: {};
return {
...input,
_auth_headers: authHeaders,
_auth_query: authQuery,
_auth_body: authBody,
};
}
// ── 舊有路徑:flat injection(向後相容)──────────────────────────────────────
async function loadCredentialsRequired(
componentId: string,
env: Bindings,
): Promise<CredentialRequirement[]> {
const recipe = await resolveRecipe(componentId, env.RECIPES);
if (recipe?.credentials_required?.length) {
return recipe.credentials_required;
}
return BUILTIN_CREDENTIALS_MAP[componentId] ?? [];
}
// ── 主入口 ────────────────────────────────────────────────────────────────────
/**
* 執行 credential 注入。
*
* @param componentId - 零件 canonical_id 或 hash
* @param input - 節點的 merged context
* @param env - Cloudflare Worker Bindings
* @param apiKey - 用戶的 API Keyak_前綴),作為 KV namespace
*/
export async function injectCredentials(
componentId: string,
input: Record<string, unknown>,
env: Bindings,
apiKey?: string,
): Promise<Record<string, unknown>> {
// 沒有 api_key → local 模式,略過
if (!apiKey) return input;
// ── 新路徑:auth recipe ──
const authRecipe = await resolveAuthRecipe(componentId, env.RECIPES);
if (authRecipe) {
return injectFromAuthRecipe(authRecipe, input, env, apiKey);
}
// ── 舊路徑:flat injection(向後相容)──
const required = await loadCredentialsRequired(componentId, env);
if (required.length === 0) return input;
const enriched = { ...input };
for (const cred of required) {
const kvKey = `${apiKey}:cred:${cred.key}`;
const record = await env.CREDENTIALS_KV.get(kvKey);
if (!record) {
throw new Error(
`缺少 credential${cred.key}\n` +
`修復步驟:\n` +
` 1. 在 credentials.yaml 中加入 ${cred.key}: "your-token"\n` +
` 2. 執行:acr creds push`,
);
}
try {
const decrypted = await decryptCredential(record, env.ENCRYPTION_KEY);
enriched[cred.inject_as] = decrypted;
} catch (e) {
throw new Error(
`credential "${cred.key}" 解密失敗:${e instanceof Error ? e.message : String(e)}\n` +
`修復步驟:重新執行 acr creds push。`,
);
}
}
return enriched;
}