feat(arcrun): Phase 2 降級假零件成 recipe + credential 鏈路修復

Phase 1(credential 注入鏈路):
- 修 auth_static_key ENCRYPTION_KEY 漂移根因(見 docs/incidents)
- component-loader: readBodyOnce() 修 "Body has already been used"

Phase 2(降級假零件成 recipe,registry/components 33→22):
- 引擎: RecipeDefinition 加 auth_service(多 recipe 共用一把 auth)
  auth-dispatcher 先查 recipe.auth_service 再 fallback componentId
- 引擎: auth_static_key inject.path + makeRecipeRunner {{auth.K}}
  (endpoint 可插 secret,解 telegram 類 URL-path token)
- 引擎: makeRecipeRunner auto-body 剔除 _ 前綴內部欄位
- 降級並刪除: kbdb_{get,create_block,patch_block,delete,ingest}
  gmail/telegram/line_notify/google_sheets(改建為 recipe)
- 刪除: ai_transform_{compile,run}(Arcrun 是 AI 呼叫的工具,
  工作流不該內嵌 AI 節點回頭呼叫 AI)
- deferred(源碼暫留): claude_api/km_writer(交 Mira 收成工作流)、
  kbdb_upsert_block(交 KBDB 出 upsert endpoint)

文件: DECISIONS.md(工作流是 default/建零件人類閘門/AI→工具)、
BACKLOG.md、auth-recipe.md §七、docs/incidents 加密 key 漂移

驗收: KBDB get/create/ingest/delete 2xx;telegram auth 注入綠;
gmail/sheets/line recipe 正確但缺 credential 未驗收;
kbdb patch 403 為 KBDB 端 bug(已交 kbdb/docs)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 16:18:18 +08:00
parent 8c1dedaa2f
commit 17a076d35c
88 changed files with 661 additions and 15449 deletions
+15 -3
View File
@@ -18,7 +18,7 @@
*/
import type { Bindings } from '../types';
import { resolveAuthRecipe } from '../routes/recipes';
import { resolveAuthRecipe, resolveRecipe } from '../routes/recipes';
import { wasmWorkerUrl } from '../lib/component-loader';
/** 對應 Phase 1-4 會部署的 auth primitive Worker */
@@ -48,7 +48,17 @@ export async function tryAuthDispatch(
return null;
}
const recipe = await resolveAuthRecipe(componentId, env.RECIPES);
// 決定 auth service name
// 1. 若 API recipe 宣告了 auth_service(例 recipe:kbdb_get → "kbdb")→ 用它,
// 讓多個 recipe 共用同一把 auth_recipe(不必每個 action 複製 auth recipe)。
// 2. 否則 fallback 到把 componentId 當 service name(向後相容舊行為)。
let service = componentId;
const apiRecipe = await resolveRecipe(componentId, env.RECIPES);
if (apiRecipe?.auth_service) {
service = apiRecipe.auth_service;
}
const recipe = await resolveAuthRecipe(service, env.RECIPES);
if (!recipe) return null;
if (!SUPPORTED_PRIMITIVES.has(recipe.primitive)) return null;
@@ -61,7 +71,7 @@ export async function tryAuthDispatch(
body: JSON.stringify({
action: 'authenticate',
api_key: apiKey,
service: componentId,
service,
}),
});
@@ -78,6 +88,7 @@ export async function tryAuthDispatch(
auth_headers?: Record<string, string>;
auth_query?: Record<string, string>;
auth_body?: Record<string, string>;
auth_path?: Record<string, string>;
} | null;
if (!result || result.success === false) {
@@ -91,5 +102,6 @@ export async function tryAuthDispatch(
_auth_headers: result.auth_headers ?? {},
_auth_query: result.auth_query ?? {},
_auth_body: result.auth_body ?? {},
_auth_path: result.auth_path ?? {},
};
}
+33 -13
View File
@@ -36,11 +36,9 @@ import type { Bindings, ComponentRunner, ServiceBinding } from '../types';
const WASM_HTTP_RUNNER_IDS: ReadonlySet<string> = new Set([
// 通用 HTTP 零件
'http_request',
// 下一階段待降級為 recipehttp_request + 固定設定)
'gmail',
'telegram',
'line_notify',
'google_sheets',
// gmail / telegram / line_notify / google_sheets 已降級為 recipe2026-05-29 Phase 2):
// recipe:gmail_send / telegram_send / line_notify_send / google_sheets_read|append
// 走 step 6 KV recipe 解析,不再是零件。零件目錄已刪。
'cron',
// Auth primitives
'auth_static_key',
@@ -80,8 +78,8 @@ const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
number_ops: 'SVC_NUMBER_OPS',
date_ops: 'SVC_DATE_OPS',
validate_json: 'SVC_VALIDATE_JSON',
ai_transform_compile: 'SVC_AI_TRANSFORM_COMPILE',
ai_transform_run: 'SVC_AI_TRANSFORM_RUN',
// ai_transform_compile / ai_transform_run 已刪除(2026-05-29):
// Arcrun 是 AI 呼叫的工具,工作流不該內嵌 AI 節點回頭呼叫 AI(n8n 才需要,因它沒大腦)。
};
export function createComponentLoader(env: Bindings) {
@@ -272,12 +270,20 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition):
return async (ctx: unknown) => {
const ctxObj = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
// 模板替換:{{key}} 換成 ctx 裡的值
// 模板替換:{{key}} ctx 取;{{auth.K}} 從 _auth_path 取
// _auth_path 由 auth primitive 解密後注入,供 URL path 用,如 telegram /bot{{auth.token}}/
const authPath = (ctxObj._auth_path as Record<string, string>) ?? {};
const interpolate = (s: string) =>
s.replace(/\{\{(\w+)\}\}/g, (_, k) => String(ctxObj[k] ?? ''));
s.replace(/\{\{(auth\.)?(\w+)\}\}/g, (_, authPrefix, k) =>
String(authPrefix ? (authPath[k] ?? '') : (ctxObj[k] ?? '')),
);
const method = (recipe.method ?? 'POST').toUpperCase();
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const authHeaders = (ctxObj._auth_headers as Record<string, string>) ?? {};
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...authHeaders,
};
for (const [k, v] of Object.entries(recipe.headers ?? {})) {
headers[k] = interpolate(v);
}
@@ -287,7 +293,12 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition):
if (recipe.body) {
bodyStr = interpolate(JSON.stringify(recipe.body));
} else if (method !== 'GET') {
bodyStr = JSON.stringify(ctxObj);
// 沒指定 body template → 用 ctx 當 body,但剔除 _ 前綴的內部欄位
// _path / _auth_headers / _auth_query / _auth_body 不該漏進下游請求)
const bodyObj = Object.fromEntries(
Object.entries(ctxObj).filter(([k]) => !k.startsWith('_')),
);
bodyStr = JSON.stringify(bodyObj);
}
const res = await fetch(interpolate(recipe.endpoint), {
@@ -296,7 +307,7 @@ function makeRecipeRunner(recipe: import('../routes/recipes').RecipeDefinition):
body: bodyStr,
});
const data = await res.json().catch(() => res.text());
const data = await readBodyOnce(res);
return { success: res.ok, status: res.status, data };
};
}
@@ -338,8 +349,17 @@ function makeAuthRecipeRunner(recipe: AuthRecipeDefinition): ComponentRunner {
body: method !== 'GET' ? JSON.stringify(bodyObj) : undefined,
});
const data = await res.json().catch(() => res.text());
const data = await readBodyOnce(res);
return { success: res.ok, status: res.status, data };
};
}
// 讀 response body 一次:先取 text,再嘗試 parse JSON。
// 不可用 `res.json().catch(() => res.text())` —— res.json() 失敗時 body 已被消費,
// 第二次讀會丟 "Body has already been used"。
async function readBodyOnce(res: Response): Promise<unknown> {
const text = await res.text();
try { return JSON.parse(text); }
catch { return text; }
}
+2 -1
View File
@@ -17,8 +17,9 @@ executeRouter.post('/execute', async (c) => {
}
const { graph, context } = parsed.data;
const apiKey = c.req.header('x-arcrun-api-key') ?? undefined;
const loader = createComponentLoader(c.env);
const executor = new GraphExecutor(loader);
const executor = new GraphExecutor(loader, undefined, c.env, apiKey);
const start = Date.now();
try {
+7
View File
@@ -28,6 +28,12 @@ export interface RecipeDefinition {
method?: string; // GET | POST | PUT | PATCH | DELETE,預設 POST
headers?: Record<string, string>;
body?: Record<string, unknown>;
/**
* 此 recipe 要用哪個 auth recipeauth_recipe:{auth_service})。
* 讓多個 recipe 共用同一把 auth(例:kbdb_get / kbdb_create_block 都設 "kbdb")。
* 未設時 auth-dispatcher fallback 到把 canonical_id 當 service name(向後相容)。
*/
auth_service?: string;
credentials_required?: Array<{
key: string;
inject_as: string;
@@ -64,6 +70,7 @@ recipesRouter.post('/recipes', async (c) => {
method: (body.method ?? 'POST').toUpperCase(),
headers: body.headers,
body: body.body,
auth_service: body.auth_service,
credentials_required: body.credentials_required,
created_at: existing?.created_at ?? now,
updated_at: now,