5 Commits

Author SHA1 Message Date
uncle6me-web 2a51d67da0 fix(deploy): cache-bust codeload tarball 下載,修 seed 假綠根因 (Arcrun#13 P2)
根因:acr update / init 從 codeload.github.com/.../tar.gz/main 抓部署源,該 branch
tarball 由 GitHub CDN 快取,push 後可 stale 數分鐘。「push→立刻 acr update」抓到舊
tarball → wrangler deploy 仍回 ✓ 但 ship 舊 code → /init/seed 只灌 23(舊種子數,
不含 telegram/line_notify/kbdb)。「deploy 成功」≠「部到修好的版本」= 假綠同一類。

實證:leo21c GET /auth-recipes 部署後仍持久回 23、無 telegram(非傳播延遲);
source main 已含 27 個種子(git show main 核實),但部署的是舊 bundle。

修:downloadRepoTarball 加 no-cache header + cache: 'no-store' + 唯一 _cb query
param,強制繞過 CDN stale entry,每次抓 ref 的最新內容。

待 leo21c 驗:push 此修 + acr update → /auth-recipes 應變 26+(含 telegram)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:04:52 +08:00
uncle6me-web c1a06df68f feat(exposure): 完全移除 acr push 暴露 consent 閘 (Arcrun#13 P1)
leo 2026-06-29 拍板:arcrun 是給 AI 用的系統,push/暴露不再需要人類確認。
- 刪 cypher-executor/src/lib/exposure-consent.ts(server 閘,MCP push 的真正擋點)
- 刪 cli/src/lib/exposure-warning.ts(CLI 互動 + 非 TTY 拒絕)
- recipes.ts / webhooks-named.ts:移除 checkExposureConsent 403 閘,直接放行
- recipe.ts / push.ts:移除 obtainExposureConsent 呼叫,不再 prompt/拒絕
- init-seed / seed-api-recipes:移除種子層級 consent
- exposure_consent 欄位降為向後相容(讀舊 record 不報錯,不再寫入/檢查)
不補審計線索、不做替代防護(leo:先拿掉,出問題再設置)。
tsc 全綠(cypher-executor + cli)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 20:58:32 +08:00
uncle6me-web 43948d9247 merge: acr search 跨類統一 + acr parts 去 hardcode recipe (Arcrun#13) 2026-06-29 19:30:37 +08:00
uncle6me-web d8fac6750e merge: telegram/line/kbdb auth-recipe seed 漂移修復 (Arcrun#13 A) 2026-06-29 19:30:37 +08:00
uncle6me-web 2aa26a5bdd feat(cli): acr search 跨類統一搜尋 + acr parts 去 hardcode recipe(issue #13 根治)
leo 2026-06-29 重構根因:「telegram 與 google 不一致」其實是查錯表——舊世界要找一個能力
必須先決定它是 component/recipe/workflow 才查對清單,但查到答案前根本不知道屬哪類 →
「先知道分類」成了查詢前置,倒因為果(leo:連我都會查錯表)。這是搜尋設計缺陷,非粗心。

兩部分互補修法:
1. acr parts 清理(W3.1/W3.3):移除 5 個 hardcode 的 recipe(gmail_send/google_sheets_append/
   telegram_send/line_notify_send/notion)——它們是 recipe(動態,存 store)不是零件(component)。
   只留真 WASM 零件。檔頭寫死分流原則 + 移除處留註解防再 hardcode。footer 改指向動態清單 + acr search。
2. acr search <term> 新增(W3.2,正向修法):cli/src/commands/search.ts。fan-out 4 來源——
   component(靜態 BUILTIN_COMPONENTS) + recipe(GET /recipes) + auth-recipe(GET /auth-recipes)
   + workflow(GET /webhooks/named),依類別回 counts+命中。各來源獨立 try(離線降級指路、不連坐)。
   查的人不必先選表 → 結構性消除「查錯表」。

薄殼合規(rule 07 §2):search 只 fan-out 既有清單端點 + 過濾 + 分組 + 印出(介面層暴露/格式化),
不在介面層拼裝能力。component 靜態(PR-only)、recipe/auth/workflow 動態(store) 分流明確、不再 conflate。

實證(對 leo21c 跑,read-only GET):
- acr search telegram → component:0 recipe:1 auth-recipe:0 workflow:1(一眼看出 telegram 是 recipe)
- acr parts → 只剩 http_request 等真零件,5 個 recipe 已不在
- tsc 全綠、build 通過

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:08:11 +08:00
13 changed files with 236 additions and 354 deletions
+23 -71
View File
@@ -1,7 +1,17 @@
/** /**
* acr parts — 列出所有可用零件(內建清單,不依賴 registry.arcrun.dev * acr parts — 列出所有可用零件(component)」(內建清單,不依賴 registry.arcrun.dev
* acr parts scaffold <component> — 輸出 config 範本(可直接貼入 workflow.yaml * acr parts scaffold <component> — 輸出 config 範本(可直接貼入 workflow.yaml
* acr parts publish <component> — 提交零件至公眾 registryPhase 5,封測後) * acr parts publish <component> — 提交零件至公眾 registryPhase 5,封測後)
*
* ⚠️ 分類原則(2026-06-29issue #13 / component-gatekeeping W3):
* - **零件(component= 靜態清單**WASM,只能走 GitHub PR + 人 merge 新增(mindset §4 人類閘門),
* 固定慢增 → 用 BUILTIN_COMPONENTS hardcode 反映真實,正確。
* - **recipe / auth-recipe / workflow = 動態,存在 store**:任何人 `acr recipe push` 即新增 →
* **絕不可 hardcode 在這裡**(會「submitted = invisible」+ 誤導查錯表)。它們各有動態清單:
* recipe → `acr recipe list`GET /recipes)|auth-recipe → `acr auth-recipe list`GET /auth-recipes)|workflow → `acr list`GET /webhooks/named
* - **跨類找東西用 `acr search <term>`**fan-out 上述 4 個來源,不必先知道它是哪一類)。
* 歷史教訓:本檔曾把 gmail_send/telegram_send/notion 等 5 個 recipe hardcode 進零件清單 →
* 誤導「telegram 是零件 / 走不同機制」。已移除(W3.1)。
*/ */
import { readFileSync, existsSync } from 'node:fs'; import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -25,7 +35,7 @@ interface ComponentDef {
credentials_required?: CredentialRequirement[]; credentials_required?: CredentialRequirement[];
} }
const BUILTIN_COMPONENTS: ComponentDef[] = [ export const BUILTIN_COMPONENTS: ComponentDef[] = [
// ── 控制類(Logic) ──────────────────────────────────────────────────────── // ── 控制類(Logic) ────────────────────────────────────────────────────────
{ {
canonical_id: 'if_control', canonical_id: 'if_control',
@@ -193,72 +203,9 @@ const BUILTIN_COMPONENTS: ComponentDef[] = [
body: body:
key: "{{value}}"`, key: "{{value}}"`,
}, },
{ // ⚠️ 此處曾 hardcode gmail_send / google_sheets_append / telegram_send / line_notify_send / notion
canonical_id: 'gmail_send', // 這 5 個是 **recipe(動態,存 store)不是零件(component**,已於 W3.1 移除(issue #13 根治)。
display_name: 'Gmail Send', // 要找它們:`acr search <term>`(跨類)或 `acr recipe list` / `acr auth-recipe list`(動態清單)。
category: 'api',
description: '透過 recipe 寄送 Gmailrecipe gmail_sendauth: google service_account)。注意:Gmail 讀取尚無對應 recipe(待 seed 補 gmail_list)。',
config_example:
` # 寄信用內建 recipe gmail_sendbody 帶 rawbase64url MIME
mail_node:
component: gmail_send
method: POST
body:
raw: "{{mime_base64url}}"`,
},
{
canonical_id: 'google_sheets_append',
display_name: 'Google Sheets Append',
category: 'api',
description: '透過 recipe 追加一列到 Sheetsrecipe google_sheets_append;讀取用 google_sheets_read。auth: google service_account',
config_example:
` # 追加用內建 recipe google_sheets_append(讀取改用 google_sheets_read
sheet_node:
component: google_sheets_append
method: POST
_path: "/v4/spreadsheets/{{spreadsheet_id}}/values/Sheet1!A:B:append?valueInputOption=RAW"
body:
values:
- ["{{name}}", "{{email}}"]`,
},
{
canonical_id: 'telegram_send',
display_name: 'Telegram Send',
category: 'api',
description: '透過 recipe 發送 Telegram 訊息(recipe telegram_sendauth: static_keytoken 注入 URL path',
config_example:
` # 內建 recipe telegram_sendtoken 由 auth 注入 URL,不寫在這)
tg_node:
component: telegram_send
chat_id: "123456789"
text: "{{message}}"`,
},
{
canonical_id: 'line_notify_send',
display_name: 'LINE Notify',
category: 'api',
description: '透過 recipe 發送 LINE Notify 通知(recipe line_notify_sendauth: static_key Bearer',
config_example:
` # 內建 recipe line_notify_sendPOST notifybody form-urlencoded
line_node:
component: line_notify_send
method: POST
body:
message: "{{notification}}"`,
},
{
canonical_id: 'notion',
display_name: 'Notion',
category: 'api',
description: '透過 recipe 操作 Notion API(需先 acr recipe push',
config_example:
` # 先上傳 recipeacr recipe push notion.yaml
notion_node:
component: rec_xxxxxxxx # acr recipe push 後得到的 hash
database_id: "{{db_id}}"
properties:
Name: "{{title}}"`,
},
]; ];
// ── 指令實作 ────────────────────────────────────────────────────────────────── // ── 指令實作 ──────────────────────────────────────────────────────────────────
@@ -277,7 +224,7 @@ export async function cmdParts(): Promise<void> {
grouped[comp.category].push(comp); grouped[comp.category].push(comp);
} }
console.log(chalk.bold(`\n arcrun 零件庫(${BUILTIN_COMPONENTS.length} 個內建零件)\n`)); console.log(chalk.bold(`\n arcrun 零件庫(${BUILTIN_COMPONENTS.length} 個內建零件 / component,靜態 PR-only\n`));
for (const cat of ['logic', 'data', 'ai', 'api']) { for (const cat of ['logic', 'data', 'ai', 'api']) {
const comps = grouped[cat]; const comps = grouped[cat];
@@ -294,8 +241,13 @@ export async function cmdParts(): Promise<void> {
} }
console.log(chalk.gray(' 使用 acr parts scaffold <component> 取得 config 範本')); console.log(chalk.gray(' 使用 acr parts scaffold <component> 取得 config 範本'));
console.log(chalk.gray(' 第三方服務整合(Notion/Slack/GitHub 等):acr auth-recipe list')); console.log('');
console.log(chalk.gray(' API 整合類若需打自訂服務,請用 acr recipe push 建立 recipe\n')); console.log(chalk.bold(' 零件之外(動態,存在 store,不在上面這份靜態清單):'));
console.log(chalk.gray(' • API recipe(打外部服務) acr recipe list'));
console.log(chalk.gray(' • 第三方服務認證(auth-recipe acr auth-recipe list'));
console.log(chalk.gray(' • 已部署的 workflow acr list'));
console.log(chalk.cyan(' • 不確定某能力是哪一類? acr search <關鍵字> ← 跨類一次搜,免先選表'));
console.log('');
} }
export async function cmdPartsScaffold(componentId: string): Promise<void> { export async function cmdPartsScaffold(componentId: string): Promise<void> {
+2 -13
View File
@@ -11,7 +11,6 @@ import chalk from 'chalk';
import ora from 'ora'; import ora from 'ora';
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js'; import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
import { loadWorkflowYaml, parseTriplets, validateRelations } from '../lib/yaml-parser.js'; import { loadWorkflowYaml, parseTriplets, validateRelations } from '../lib/yaml-parser.js';
import { obtainExposureConsent } from '../lib/exposure-warning.js';
export async function cmdPush(filePath: string): Promise<void> { export async function cmdPush(filePath: string): Promise<void> {
const config = loadConfig(); const config = loadConfig();
@@ -96,17 +95,8 @@ export async function cmdPush(filePath: string): Promise<void> {
process.exit(1); process.exit(1);
} }
// 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面)。 // 暴露 consent 閘已移除(leo 2026-06-29Arcrun#13):arcrun 是給 AI 用的系統,
// 已同意過(本機記住)→ 回非 null 自動放行;未同意/取消/非互動 → null → 中止 // push/暴露不再需要人類確認,AI/MCP 隨時可部署。暴露風險由用戶自負(同 n8n 建 webhook
// server 端獨立存法律憑證並強制(防 CLI 被繞過)。
const consent = await obtainExposureConsent({
kind: 'workflow',
resourceName: workflow.name,
destination: `${executorUrl}/webhooks/named/${workflow.name}/trigger`,
});
if (!consent) {
process.exit(1);
}
// POST 至 /webhooks/named // POST 至 /webhooks/named
const deploySpinner = ora(`部署 "${workflow.name}" 至 ${executorUrl}`).start(); const deploySpinner = ora(`部署 "${workflow.name}" 至 ${executorUrl}`).start();
@@ -119,7 +109,6 @@ export async function cmdPush(filePath: string): Promise<void> {
graph, graph,
config: workflow.config ?? {}, config: workflow.config ?? {},
description: workflow.description ?? '', description: workflow.description ?? '',
exposure_consent: consent ?? undefined,
}), }),
}); });
+4 -26
View File
@@ -7,7 +7,6 @@ import chalk from 'chalk';
import ora from 'ora'; import ora from 'ora';
import { readFileSync, existsSync } from 'node:fs'; import { readFileSync, existsSync } from 'node:fs';
import { loadConfig, getCypherExecutorUrl, DEFAULT_PUBLIC_LIBRARY_URL } from '../lib/config.js'; import { loadConfig, getCypherExecutorUrl, DEFAULT_PUBLIC_LIBRARY_URL } from '../lib/config.js';
import { obtainExposureConsent } from '../lib/exposure-warning.js';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
interface RecipeYaml { interface RecipeYaml {
@@ -70,16 +69,7 @@ export async function cmdRecipePush(filePath: string): Promise<void> {
const executorUrl = getCypherExecutorUrl(config); const executorUrl = getCypherExecutorUrl(config);
// 資料外流警示:recipe 定義一個資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁) // 暴露 consent 閘已移除(leo 2026-06-29Arcrun#13):recipe push 不再需要人類確認
// 已同意過(本機記住)→ 回非 null 自動放行;未同意/取消/非互動 → null → 中止。
const consent = await obtainExposureConsent({
kind: 'recipe',
resourceName: recipe.canonical_id,
destination: recipe.endpoint,
});
if (!consent) {
process.exit(1);
}
const spinner = ora(`上傳 recipe "${recipe.canonical_id}"`).start(); const spinner = ora(`上傳 recipe "${recipe.canonical_id}"`).start();
@@ -90,7 +80,7 @@ export async function cmdRecipePush(filePath: string): Promise<void> {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Arcrun-API-Key': config.api_key, 'X-Arcrun-API-Key': config.api_key,
}, },
body: JSON.stringify({ ...recipe, exposure_consent: consent ?? undefined }), body: JSON.stringify(recipe),
}); });
const data = await res.json() as { success: boolean; recipe?: RecipeDefinition; error?: string }; const data = await res.json() as { success: boolean; recipe?: RecipeDefinition; error?: string };
@@ -307,7 +297,7 @@ export async function cmdRecipePull(canonicalId: string, author?: string): Promi
return; return;
} }
// 2. 寫進自己私庫(POST /recipes,帶 derived_from 溯源 + 種子級同意:pull 公庫公共資料非新暴露)。 // 2. 寫進自己私庫(POST /recipes,帶 derived_from 溯源)。
const r = pub.recipe; const r = pub.recipe;
const executorUrl = getCypherExecutorUrl(config); const executorUrl = getCypherExecutorUrl(config);
const installRes = await fetch(`${executorUrl}/recipes`, { const installRes = await fetch(`${executorUrl}/recipes`, {
@@ -316,11 +306,6 @@ export async function cmdRecipePull(canonicalId: string, author?: string): Promi
body: JSON.stringify({ body: JSON.stringify({
...r, ...r,
derived_from: r.uuid, // 溯源:私庫這份來自公庫哪個 uuid derived_from: r.uuid, // 溯源:私庫這份來自公庫哪個 uuid
exposure_consent: {
confirmed_by_human: true,
understood: `pull from public library: ${canonicalId}`,
confirmed_at: new Date().toISOString(),
},
}), }),
}); });
const inst = await installRes.json() as { success: boolean; recipe?: RecipeDefinition; error?: string }; const inst = await installRes.json() as { success: boolean; recipe?: RecipeDefinition; error?: string };
@@ -355,13 +340,7 @@ export async function cmdRecipeSubmitP(canonicalId: string, author?: string): Pr
process.exit(1); process.exit(1);
} }
// 2. 投稿到公庫 = 暴露面 → 取得人類明示同意(mindset §6 // 暴露 consent 閘已移除(leo 2026-06-29Arcrun#13):投稿公庫不再需要人類確認
const consent = await obtainExposureConsent({
kind: 'recipe',
resourceName: canonicalId,
destination: `公庫(${DEFAULT_PUBLIC_LIBRARY_URL}`,
});
if (!consent) process.exit(1);
const spinner = ora(`投稿 recipe「${canonicalId}」到公庫`).start(); const spinner = ora(`投稿 recipe「${canonicalId}」到公庫`).start();
try { try {
@@ -373,7 +352,6 @@ export async function cmdRecipeSubmitP(canonicalId: string, author?: string): Pr
author: author ?? my.recipe.author, author: author ?? my.recipe.author,
derived_from: my.recipe.derived_from ?? my.recipe.uuid, derived_from: my.recipe.derived_from ?? my.recipe.uuid,
submitter: author ?? config.api_key, submitter: author ?? config.api_key,
exposure_consent: consent,
}), }),
}); });
const data = await res.json() as { success: boolean; recipe?: { uuid?: string; author?: string }; error?: string }; const data = await res.json() as { success: boolean; recipe?: { uuid?: string; author?: string }; error?: string };
+170
View File
@@ -0,0 +1,170 @@
/**
* acr search <term> — 跨類統一搜尋(component / recipe / auth-recipe / workflow
*
* 為什麼存在(issue #13leo 2026-06-29):
* 舊世界要找一個能力(如 "telegram")必須**先決定**「它是零件?recipe?還是 workflow?」才能查對的清單。
* 但查的人(人或 AI)在查到答案前**根本不知道**它屬哪一類 →「先知道分類」成了查詢的前置條件,倒因為果。
* leo:「連我都會查錯表。」所以這不是粗心,是搜尋設計缺陷。
* 本指令一次掃全部類別、依類別分組回 counts + 命中 → 查的人不必先選表,看一眼就知道 telegram 是 recipe。
*
* 薄殼原則(rule 07 §2):本指令只做 fan-out 呼叫既有清單來源 + 過濾 + 分組 + 印出(介面層的暴露/格式化)。
* - component:靜態(BUILTIN_COMPONENTSPR-only,複用 parts.ts 同一份,不另存)。
* - recipe / auth-recipe / workflow**動態**,從 store 端點即時抓(GET /recipes、/auth-recipes、/webhooks/named)。
* 不在介面層拼裝任何能力;過濾是對 API 回傳值做的純客戶端格式化(§2.3 允許)。
*/
import chalk from 'chalk';
import { loadConfig, getCypherExecutorUrl } from '../lib/config.js';
import { BUILTIN_COMPONENTS } from './parts.js';
interface Hit {
id: string; // canonical_id / service / workflow name
label: string; // display_name / 描述用名稱
detail?: string; // description(截斷)
}
interface CategoryResult {
key: 'component' | 'recipe' | 'auth-recipe' | 'workflow';
title: string;
dynamic: boolean; // 是否來自 store(動態)
listHint: string; // 列全部的指令
hits: Hit[];
error?: string; // 該來源抓取失敗(離線/服務不可用)→ 降級顯示,不中斷其他類
}
/** term 命中:id / label / detail 任一含 term(不分大小寫) */
function matches(term: string, ...fields: Array<string | undefined>): boolean {
const t = term.toLowerCase();
return fields.some((f) => (f ?? '').toLowerCase().includes(t));
}
function trim(s: string | undefined, n = 70): string | undefined {
if (!s) return undefined;
return s.length > n ? s.slice(0, n - 1) + '…' : s;
}
async function fetchJson(url: string, headers: Record<string, string>): Promise<unknown> {
const res = await fetch(url, { method: 'GET', headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
export async function cmdSearch(term: string): Promise<void> {
const q = (term ?? '').trim();
if (!q) {
console.error(chalk.red(' 請提供搜尋關鍵字:acr search <term>'));
process.exit(1);
}
const config = loadConfig();
const baseUrl = getCypherExecutorUrl(config);
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (config.api_key) headers['X-Arcrun-API-Key'] = config.api_key;
// ── component(靜態,本地,永不失敗)──
const componentRes: CategoryResult = {
key: 'component',
title: '零件 component(靜態 · PR-only · WASM',
dynamic: false,
listHint: 'acr parts',
hits: BUILTIN_COMPONENTS
.filter((c) => matches(q, c.canonical_id, c.display_name, c.description))
.map((c) => ({ id: c.canonical_id, label: c.display_name, detail: trim(c.description) })),
};
// ── recipe / auth-recipe / workflow(動態,store;各自獨立 try,互不連坐)──
const dynamicSpecs: Array<{
key: CategoryResult['key'];
title: string;
listHint: string;
url: string;
extract: (data: unknown) => Hit[];
}> = [
{
key: 'recipe',
title: 'API recipe(動態 · store · 打外部服務)',
listHint: 'acr recipe list',
url: `${baseUrl}/recipes`,
extract: (data) => {
const arr = (data as { recipes?: Array<{ canonical_id: string; display_name?: string; description?: string }> }).recipes ?? [];
return arr
.filter((r) => matches(q, r.canonical_id, r.display_name, r.description))
.map((r) => ({ id: r.canonical_id, label: r.display_name ?? r.canonical_id, detail: trim(r.description) }));
},
},
{
key: 'auth-recipe',
title: '第三方服務認證 auth-recipe(動態 · store',
listHint: 'acr auth-recipe list',
url: `${baseUrl}/auth-recipes`,
extract: (data) => {
const arr = (data as { recipes?: Array<{ service: string; display_name?: string; description?: string }> }).recipes ?? [];
return arr
.filter((r) => matches(q, r.service, r.display_name, r.description))
.map((r) => ({ id: r.service, label: r.display_name ?? r.service, detail: trim(r.description) }));
},
},
{
key: 'workflow',
title: '已部署 workflow(動態 · store',
listHint: 'acr list',
url: `${baseUrl}/webhooks/named`,
extract: (data) => {
const arr = (data as { workflows?: Array<{ name: string; description?: string }> }).workflows ?? [];
return arr
.filter((w) => matches(q, w.name, w.description))
.map((w) => ({ id: w.name, label: w.name, detail: trim(w.description) }));
},
},
];
const dynamicResults = await Promise.all(
dynamicSpecs.map(async (spec): Promise<CategoryResult> => {
try {
const data = await fetchJson(spec.url, headers);
return { key: spec.key, title: spec.title, dynamic: true, listHint: spec.listHint, hits: spec.extract(data) };
} catch (e) {
return { key: spec.key, title: spec.title, dynamic: true, listHint: spec.listHint, hits: [], error: e instanceof Error ? e.message : String(e) };
}
}),
);
const all: CategoryResult[] = [componentRes, ...dynamicResults];
// ── 印出:先一行 counts 摘要(看一眼就知道屬哪類),再各類明細 ──
console.log(chalk.bold(`\n 搜尋「${q}\n`));
const countLine = all
.map((r) => {
const n = r.error ? '?' : String(r.hits.length);
const c = r.error ? chalk.yellow(`${r.key}:${n}`) : (r.hits.length > 0 ? chalk.green(`${r.key}:${n}`) : chalk.gray(`${r.key}:0`));
return c;
})
.join(' ');
console.log(' ' + countLine + '\n');
let anyHit = false;
for (const r of all) {
if (r.error) {
console.log(chalk.yellow(` ${r.title} — 無法讀取(${r.error});離線時改用 ${chalk.cyan(r.listHint)}`));
console.log('');
continue;
}
if (r.hits.length === 0) continue;
anyHit = true;
console.log(chalk.bold.underline(` ${r.title}`));
for (const h of r.hits) {
const label = h.label && h.label !== h.id ? ` ${h.label}` : '';
console.log(` ${chalk.cyan(h.id.padEnd(24))}${label}`);
if (h.detail) console.log(chalk.gray(` ${h.detail}`));
}
console.log('');
}
if (!anyHit && !all.some((r) => r.error)) {
console.log(chalk.yellow(` 四類都沒有命中「${q}」。`));
console.log(chalk.gray(' • 想新增打外部服務的能力 → acr recipe push(建 recipe'));
console.log(chalk.gray(' • 服務認證設定 → acr auth-recipe list 找對應服務'));
console.log(chalk.gray(' • 零件(WASM)只能走 GitHub PR 新增(稀有)'));
console.log('');
}
}
+7
View File
@@ -20,6 +20,7 @@ import { cmdValidate } from './commands/validate.js';
import { cmdParts, cmdPartsScaffold, cmdPartsPublish } from './commands/parts.js'; import { cmdParts, cmdPartsScaffold, cmdPartsPublish } from './commands/parts.js';
import { cmdRecipePush, cmdRecipeList, cmdRecipeDelete, cmdRecipeSearch, cmdRecipePull, cmdRecipeSubmitP } from './commands/recipe.js'; import { cmdRecipePush, cmdRecipeList, cmdRecipeDelete, cmdRecipeSearch, cmdRecipePull, cmdRecipeSubmitP } from './commands/recipe.js';
import { cmdList } from './commands/list.js'; import { cmdList } from './commands/list.js';
import { cmdSearch } from './commands/search.js';
import { cmdLogs } from './commands/logs.js'; import { cmdLogs } from './commands/logs.js';
import { cmdUpdate } from './commands/update.js'; import { cmdUpdate } from './commands/update.js';
import { cmdInstallHarness } from './commands/install-harness.js'; import { cmdInstallHarness } from './commands/install-harness.js';
@@ -205,6 +206,12 @@ program
.description('列出 CF KV 中所有已部署的 workflow') .description('列出 CF KV 中所有已部署的 workflow')
.action(() => cmdList()); .action(() => cmdList());
// acr search <term> — 跨類統一搜尋(component / recipe / auth-recipe / workflow),免先選表
program
.command('search <term>')
.description('跨類搜尋能力(零件/recipe/auth-recipe/workflow),一次掃全部、依類別回 counts+命中')
.action((term: string) => cmdSearch(term));
// acr logs <workflow_name> // acr logs <workflow_name>
program program
.command('logs <workflow>') .command('logs <workflow>')
+2 -3
View File
@@ -34,9 +34,8 @@ export interface ArcrunConfig {
// 未設/false → base 維持 LIKE keywordfree-tier 友善,不建 index、不花費)。 // 未設/false → base 維持 LIKE keywordfree-tier 友善,不建 index、不花費)。
// 開法:設 kbdb_embed:true → redeployacr update)。「CC 幫開」=CC 寫此欄 true + 跑 acr update。 // 開法:設 kbdb_embed:true → redeployacr update)。「CC 幫開」=CC 寫此欄 true + 跑 acr update。
kbdb_embed?: boolean; kbdb_embed?: boolean;
// 資料外流警示:本機記住「已同意暴露 / 選擇不再警示」的資源,避免每次 push 重問(§3 首次問記住)。 // 暴露 consent 閘已移除(leo 2026-06-29Arcrun#13)。此欄位保留只為向後相容舊 config.yaml
// key 格式:`{kind}:{resourceName}`(如 "webhook:contacts_lookup" / "recipe:kbdb_get")。 // (讀到不報錯,不再寫入/檢查)。
// 注意:這只是 CLI 端 UX(不重問);server 端獨立存法律憑證並強制(防 CLI 被繞過)。
exposure_consented?: Record<string, { confirmed_at: string; suppress_future?: boolean }>; exposure_consented?: Record<string, { confirmed_at: string; suppress_future?: boolean }>;
} }
+16 -3
View File
@@ -345,11 +345,24 @@ async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
throw new Error(msg); throw new Error(msg);
} }
/** 下載 codeload tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。*/ /** 下載 codeload tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。
*
* ⚠️ Arcrun#13 P2 根因修復:codeload 的 branch tarballtar.gz/main)由 GitHub CDN 快取,
* push 後該 ref 的 tarball 可能 stale 數分鐘。「push → 立刻 acr update」會抓到舊 tarball →
* wrangler deploy 仍回 ✓(部署成功)但 ship 的是**舊 code** → seed 還是舊數量(假綠:
* 「deploy 成功」≠「部到修好的版本」)。這正是 telegram seed 灌不進 leo21c 的真因。
* 解法:fetch 時帶 no-cache header + 唯一 query param 強制繞過 CDN 快取,每次抓到 ref 的最新內容。*/
async function downloadRepoTarball(ref: string): Promise<string> { async function downloadRepoTarball(ref: string): Promise<string> {
const url = `https://codeload.github.com/${ARCRUN_REPO}/tar.gz/${ref}`; // 唯一 cache-buster query paramcodeload 對不同 query 視為不同資源 → 繞過 stale CDN entry。
const bust = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
const url = `https://codeload.github.com/${ARCRUN_REPO}/tar.gz/${ref}?_cb=${bust}`;
console.log(chalk.gray(` → 從 GitHub 下載最新版本(${ARCRUN_REPO}@${ref},約 1030 秒,視網速)...`)); console.log(chalk.gray(` → 從 GitHub 下載最新版本(${ARCRUN_REPO}@${ref},約 1030 秒,視網速)...`));
const res = await fetch(url, { signal: AbortSignal.timeout(120_000) }); const res = await fetch(url, {
signal: AbortSignal.timeout(120_000),
// 強制繞過任何中間快取,避免抓到 push 後尚未刷新的 stale tarball#13 P2 假綠根因)。
headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache' },
cache: 'no-store',
});
if (!res.ok) throw new Error(`codeload HTTP ${res.status}${url}`); if (!res.ok) throw new Error(`codeload HTTP ${res.status}${url}`);
const buf = Buffer.from(await res.arrayBuffer()); const buf = Buffer.from(await res.arrayBuffer());
-127
View File
@@ -1,127 +0,0 @@
/**
* 資料外流警示 — CLI 互動(data-exfil-warning SDD §1a / B
*
* 觸發策略:只在「資料變成可被外部呼叫」時警示(webhook 部署 / recipe push)。
* 互動形式(richblack):仿 GCP 刪 project —— 要用戶打資源名證明讀了警示(比 y/n 硬,不用打一大串)。
* 同意 = 法律憑證:回傳的 ExposureConsent 帶 understood(用戶打的內容)+ 時間,server 端 log。
* 誠實限制:非 TTYAI 直跑)無 --confirm-exposure → 拒絕(AI 不該替人類確認暴露)。
*/
import { createInterface } from 'node:readline/promises';
import chalk from 'chalk';
import { loadConfig, saveConfig } from './config.js';
export interface ExposureConsent {
confirmed_by_human: true;
understood: string;
confirmed_at: string;
suppress_future?: boolean;
}
// 註(2026-05-30 信任修正):移除 --confirm-exposure / --suppress-warning 旗標。
// 理由:arcrun 是 AI 的工具,AI 自己能加旗標 = 自己批准自己 = 閘門虛設(違 DECISIONS §7)。
// 唯一通過 = 人類在 TTY 互動輸入資源名(AI 非互動環境生不出)。「以後不再問」改成互動中詢問。
export interface ExposureWarningOptions {
// 預留:未來 CI 用「人類預先簽的 token」(非 AI 能生的 flag)。第一期不做。
_reserved?: never;
}
export interface ExposureContext {
/** 動作種類,顯示用:'webhook' | 'recipe' */
kind: string;
/** 資源名(用戶要打這個字確認)*/
resourceName: string;
/** 暴露後的 URL / 去向(顯示用,可選) */
destination?: string;
/** 這個資源讀取/送出什麼(盡力盤,盤不出傳 undefined) */
dataSummary?: string;
}
/**
* 取得暴露同意。回傳 ExposureConsent(放進 push 請求 body)。
* 未取得同意 → 印訊息並 return null(呼叫端應中止)。
*/
export async function obtainExposureConsent(
ctx: ExposureContext,
opts: ExposureWarningOptions = {},
): Promise<ExposureConsent | null> {
const nowIso = new Date().toISOString();
const memKey = `${ctx.kind}:${ctx.resourceName}`;
// §3 首次問記住:本機已記錄同意此資源 → 不重問(server 端仍存法律憑證並強制)。
const cfg = loadConfig();
const prior = cfg.exposure_consented?.[memKey];
if (prior) {
return {
confirmed_by_human: true,
understood: `先前已同意暴露 ${ctx.resourceName}${prior.confirmed_at}${prior.suppress_future ? ',已選不再警示' : ''}`,
confirmed_at: prior.confirmed_at,
suppress_future: prior.suppress_future,
};
}
// 非 TTY(AI 直跑)→ 一律拒絕,無捷徑。AI 不該、也不能替人類確認暴露。
// (移除了 --confirm-exposure 旗標:那是 AI 自己能加的後門,等於自己批准自己。)
if (!process.stdin.isTTY) {
console.error(chalk.red('\n⚠️ 此動作會把資源變成可被外部呼叫(暴露/送出資料),需人類明示同意。'));
console.error(chalk.gray(' 你(AI)無法確認暴露——這必須由人類在終端機親自執行、輸入資源名確認。'));
console.error(chalk.gray(' 請把這件事交給人類做。\n'));
return null;
}
// 互動式警示 + 打資源名確認(唯一通過路徑,AI 生不出這個輸入)
printWarning(ctx);
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
const answer = (await rl.question(
chalk.bold(` 確認暴露?請輸入資源名 "${ctx.resourceName}" 以繼續(或 Ctrl-C 取消):`),
)).trim();
if (answer !== ctx.resourceName) {
console.error(chalk.red(`\n 輸入不符(需輸入 "${ctx.resourceName}")。已取消,未暴露。\n`));
return null;
}
// 互動中詢問「以後不再問」(人類選,不是 AI 加旗標)
const suppressAns = (await rl.question(
chalk.gray(` 以後此資源(${ctx.resourceName})的暴露不再提醒?(y/N)`),
)).trim().toLowerCase();
const suppress = suppressAns === 'y' || suppressAns === 'yes';
rememberConsent(memKey, nowIso, suppress);
return {
confirmed_by_human: true,
understood: `用戶輸入資源名 "${ctx.resourceName}" 確認暴露${ctx.destination ? `(去向:${ctx.destination}` : ''}${suppress ? ';並選擇以後不再提醒' : ''}`,
confirmed_at: nowIso,
suppress_future: suppress,
};
} finally {
rl.close();
}
}
/** 本機記住此資源已同意(避免下次重問;server 端仍獨立存法律憑證並強制) */
function rememberConsent(memKey: string, confirmedAt: string, suppressFuture: boolean): void {
try {
const cfg = loadConfig();
cfg.exposure_consented = cfg.exposure_consented ?? {};
cfg.exposure_consented[memKey] = { confirmed_at: confirmedAt, suppress_future: suppressFuture };
saveConfig(cfg);
} catch {
// 記不住不影響本次同意(server 端仍會擋首次)
}
}
function printWarning(ctx: ExposureContext): void {
console.log(chalk.yellow.bold(`\n⚠️ 資料外流警示`));
console.log(chalk.yellow(` 這個動作會把 ${ctx.kind} "${ctx.resourceName}" 變成可被外部呼叫。`));
if (ctx.destination) {
console.log(chalk.gray(` 去向:${ctx.destination}`));
}
if (ctx.dataSummary) {
console.log(chalk.gray(` 涉及資料:${ctx.dataSummary}`));
} else {
console.log(chalk.gray(` 涉及資料:無法自動判斷,請自行確認此資源是否含敏感資料。`));
}
console.log(chalk.gray(` 任何能呼叫它的人都能取得它的輸出/能力。`));
console.log('');
console.log(chalk.cyan(` arcrun 可幫你保護它:要求呼叫者帶 API Key/設權限/限流(一個動作就能加)。`));
console.log(chalk.gray(` 若這是要公開的資料(如公開 API),可直接確認。`));
console.log('');
}
+2 -10
View File
@@ -13,8 +13,7 @@
* ARCRUN_API_URL - 目標 cypher-executor,預設 https://cypher.arcrun.dev * ARCRUN_API_URL - 目標 cypher-executor,預設 https://cypher.arcrun.dev
* ARCRUN_API_KEY - X-Arcrun-API-KeyPOST /recipes 需要) * ARCRUN_API_KEY - X-Arcrun-API-KeyPOST /recipes 需要)
* *
* 注意:API recipe 帶 endpoint(資料去向)→ POST /recipes 會要 exposure_consent * 注意:暴露 consent 閘已移除(leo 2026-06-29Arcrun#13),POST /recipes 不再需要 consent
* data-exfil-warning)。seed 是平台預建、非用戶 push,腳本帶種子層級的 consent。
* *
* 對應 SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §5 * 對應 SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §5
*/ */
@@ -49,14 +48,7 @@ async function main() {
endpoint: recipe.endpoint, endpoint: recipe.endpoint,
method: recipe.method, method: recipe.method,
auth_service: recipe.auth_service, auth_service: recipe.auth_service,
// 種子層級的暴露同意:平台預建 recipe,非用戶互動 push // 暴露 consent 閘已移除(leo 2026-06-29Arcrun#13):不再帶 exposure_consent
// 格式須符合 cypher-executor ExposureConsentconfirmed_by_human + understood + confirmed_at)。
// 誠實標明來源是 seed,軌跡可審(mindset §7:機制價值是歸責+可審,非防偽)。
exposure_consent: {
confirmed_by_human: true,
understood: `platform seed recipe (api-recipe-seeds.ts): ${recipe.canonical_id}${recipe.endpoint}`,
confirmed_at: new Date().toISOString(),
},
}), }),
}); });
@@ -1,60 +0,0 @@
// 資料外流警示 — 同意憑證機制(data-exfil-warning SDD §7 法律憑證 + §1b API 層)
//
// 觸發策略(richblack):只在「資料變成可被外部呼叫」時要求同意(暴露面)。
// webhook 部署(workflow 變對外 endpoint)、recipe push 都算。
//
// 同意 = 法律憑證:留 log(誰、何時、同意了什麼),真出事時有「用戶明示知情同意」證據,
// 避免 arcrun 訴訟風險。「以後不要警示」(suppress_future)本身也 log。
//
// 誠實限制:AI 能偽造 confirmed_by_human。本機制的價值是「法律歸責 + 可審」,不是技術防偽。
/** 暴露同意憑證(人類明示知情同意把某資源開放/送出) */
export interface ExposureConsent {
confirmed_by_human: true; // 必須為 literal true
understood: string; // 人類說明「我知道這會把什麼開放給誰」(非空)
confirmed_at: string; // ISO timestamp
suppress_future?: boolean; // 「以後不要對此資源警示」(本選擇也 log)
}
/**
* 判斷一個暴露動作是否已取得有效同意。
* @param consent 本次請求帶的同意憑證
* @param priorConsent 既有 record 裡存的同意(首次問、記住:§3)
* @returns null = 放行(已同意或已 suppress);string = 拒絕原因
*/
export function checkExposureConsent(
consent: ExposureConsent | undefined,
priorConsent: ExposureConsent | undefined,
): string | null {
// 既有同意且選了「以後不警示」→ 放行(首次問記住)
if (priorConsent?.suppress_future) return null;
// 既有有效同意(同資源已確認過)→ 放行
if (priorConsent?.confirmed_by_human === true) return null;
// 本次請求帶了有效同意 → 放行
if (
consent?.confirmed_by_human === true &&
typeof consent.understood === 'string' &&
consent.understood.trim() !== ''
) {
return null;
}
return (
'此動作會把資源變成可被外部呼叫(暴露/送出資料)。需人類明示同意。\n' +
'請用 CLI 互動確認(acr 會說明風險並提供保護選項),或帶 exposure_consent。\n' +
'arcrun 可幫你保護:要求呼叫者帶 API Key / 設權限 / 限流。'
);
}
/**
* 正規化要存進 record 的同意憑證(法律憑證,可審)。
* 優先用本次新同意,否則沿用既有。
*/
export function resolveConsentForRecord(
consent: ExposureConsent | undefined,
priorConsent: ExposureConsent | undefined,
): ExposureConsent | undefined {
if (consent?.confirmed_by_human === true) return consent;
return priorConsent;
}
+2 -8
View File
@@ -9,8 +9,7 @@
* 行為: * 行為:
* - 冪等:已存在的 recipe 直接覆寫(重跑安全)。 * - 冪等:已存在的 recipe 直接覆寫(重跑安全)。
* - 一次灌「API recipeAPI_RECIPE_SEEDS+ auth recipeAUTH_RECIPE_SEEDS)」兩者。 * - 一次灌「API recipeAPI_RECIPE_SEEDS+ auth recipeAUTH_RECIPE_SEEDS)」兩者。
* - 直接寫 KV(不走 POST /recipes 的 exposure_consent gate):種子是平台預建、非用戶互動 push, * - 直接寫 KV:種子是平台預建、非用戶互動 push(暴露 consent 閘已於 Arcrun#13 移除)。
* 帶 seed 層級的 consent 憑證(誠實標來源,軌跡可審;mindset §7:機制價值是歸責+可審非防偽)。
* - 誠實回報:逐筆 ok/fail 計數,不假綠。 * - 誠實回報:逐筆 ok/fail 計數,不假綠。
* *
* 對應 SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §5 * 對應 SDD.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §5
@@ -28,11 +27,7 @@ export const initSeedRouter = new Hono<{ Bindings: Bindings }>();
initSeedRouter.post('/init/seed', async (c) => { initSeedRouter.post('/init/seed', async (c) => {
const now = Date.now(); const now = Date.now();
const seedConsent = { // 暴露 consent 閘已移除(leo 2026-06-29Arcrun#13):種子不再帶 exposure_consent。
confirmed_by_human: true as const,
understood: 'platform seed (init/seed): 平台預建 recipe,非用戶互動 push',
confirmed_at: new Date(now).toISOString(),
};
let apiOk = 0; let apiOk = 0;
let apiFail = 0; let apiFail = 0;
@@ -54,7 +49,6 @@ initSeedRouter.post('/init/seed', async (c) => {
endpoint: seed.endpoint, endpoint: seed.endpoint,
method: (seed.method ?? 'POST').toUpperCase(), method: (seed.method ?? 'POST').toUpperCase(),
auth_service: seed.auth_service, auth_service: seed.auth_service,
exposure_consent: existing?.exposure_consent ?? seedConsent,
created_at: existing?.created_at ?? now, created_at: existing?.created_at ?? now,
updated_at: now, updated_at: now,
}; };
+5 -17
View File
@@ -16,8 +16,6 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { Bindings } from '../types'; import type { Bindings } from '../types';
import { deriveRecipeHash } from '../lib/hash'; import { deriveRecipeHash } from '../lib/hash';
import { checkExposureConsent, resolveConsentForRecord } from '../lib/exposure-consent';
import type { ExposureConsent } from '../lib/exposure-consent';
export const recipesRouter = new Hono<{ Bindings: Bindings }>(); export const recipesRouter = new Hono<{ Bindings: Bindings }>();
@@ -46,9 +44,9 @@ export interface RecipeDefinition {
key: string; key: string;
inject_as: string; inject_as: string;
}>; }>;
// 資料外流警示:recipe 定義一個資料去向(endpoint)。push 需人類明示同意(法律憑證)。 // 暴露 consent 閘已移除(leo 2026-06-29 拍板,Arcrun#13):arcrun 是給 AI 用的系統,
// SDD: data-exfil-warning §7(公私一視同仁) // 不再對 push/暴露要求人類確認。此欄位保留只為向後相容舊 KV record(讀到不報錯,不再寫入/檢查)。
exposure_consent?: ExposureConsent; exposure_consent?: unknown;
created_at: number; created_at: number;
updated_at: number; updated_at: number;
} }
@@ -104,11 +102,7 @@ recipesRouter.post('/recipes', async (c) => {
// 讀取順序:先 UUID 模型(installed→uuid),fallback 舊 keymigration 前的種子)。 // 讀取順序:先 UUID 模型(installed→uuid),fallback 舊 keymigration 前的種子)。
const existing = await resolveRecipe(canonicalId, c.env.RECIPES); const existing = await resolveRecipe(canonicalId, c.env.RECIPES);
// 資料外流警示:recipe 定義資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁) // 暴露 consent 閘已移除(leo 2026-06-29Arcrun#13):直接 push,不攔
const consentError = checkExposureConsent(body.exposure_consent, existing?.exposure_consent);
if (consentError !== null) {
return c.json({ success: false, error: consentError, requires: 'exposure_consent' }, 403);
}
const recipe: RecipeDefinition = { const recipe: RecipeDefinition = {
uuid: existing?.uuid ?? crypto.randomUUID(), uuid: existing?.uuid ?? crypto.randomUUID(),
@@ -124,7 +118,6 @@ recipesRouter.post('/recipes', async (c) => {
body: body.body, body: body.body,
auth_service: body.auth_service, auth_service: body.auth_service,
credentials_required: body.credentials_required, credentials_required: body.credentials_required,
exposure_consent: resolveConsentForRecord(body.exposure_consent, existing?.exposure_consent),
created_at: existing?.created_at ?? now, created_at: existing?.created_at ?? now,
updated_at: now, updated_at: now,
}; };
@@ -162,11 +155,7 @@ recipesRouter.post('/recipes/submit', async (c) => {
const hashId = await deriveRecipeHash(canonicalId); const hashId = await deriveRecipeHash(canonicalId);
const now = Date.now(); const now = Date.now();
// 公共庫投稿一定是暴露 → 需明示同意(無同意直接擋)。投稿是新版本,不沿用既有同意 // 暴露 consent 閘已移除(leo 2026-06-29Arcrun#13):公共庫投稿不再需要人類確認
const consentError = checkExposureConsent(body.exposure_consent, undefined);
if (consentError !== null) {
return c.json({ success: false, error: consentError, requires: 'exposure_consent' }, 403);
}
// app-store 模型:**領新 uuid = 新增作者版本**,不覆蓋既有 canonical(§7.5.5)。 // app-store 模型:**領新 uuid = 新增作者版本**,不覆蓋既有 canonical(§7.5.5)。
const recipe: RecipeDefinition = { const recipe: RecipeDefinition = {
@@ -183,7 +172,6 @@ recipesRouter.post('/recipes/submit', async (c) => {
body: body.body, body: body.body,
auth_service: body.auth_service, auth_service: body.auth_service,
credentials_required: body.credentials_required, credentials_required: body.credentials_required,
exposure_consent: resolveConsentForRecord(body.exposure_consent, undefined),
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
+3 -16
View File
@@ -30,8 +30,6 @@ import type { GraphNode } from '../types';
import { extractCronExpr } from '../lib/cron-match'; import { extractCronExpr } from '../lib/cron-match';
import { updateCronIndexEntry, CRON_INDEX_KEY } from '../lib/cron-index'; import { updateCronIndexEntry, CRON_INDEX_KEY } from '../lib/cron-index';
import { recordTelemetry } from '../lib/telemetry'; import { recordTelemetry } from '../lib/telemetry';
import { checkExposureConsent, resolveConsentForRecord } from '../lib/exposure-consent';
import type { ExposureConsent } from '../lib/exposure-consent';
export const webhooksNamedRouter = new Hono<{ Bindings: Bindings }>(); export const webhooksNamedRouter = new Hono<{ Bindings: Bindings }>();
@@ -44,9 +42,8 @@ type NamedWorkflowRecord = {
// 若首節點是 cron 零件,extract cron_expr 存進來供 scheduled() 比對 // 若首節點是 cron 零件,extract cron_expr 存進來供 scheduled() 比對
// 對應 SDD: arcrun.md 三-A P1 #3 // 對應 SDD: arcrun.md 三-A P1 #3
cron_expr?: string; cron_expr?: string;
// 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面) // 暴露 consent 閘已移除(leo 2026-06-29Arcrun#13)。保留欄位只為向後相容舊 KV record
// 存人類明示同意憑證(法律憑證,可審)。SDD: data-exfil-warning §7 exposure_consent?: unknown;
exposure_consent?: ExposureConsent;
}; };
function kvKey(apiKey: string, name: string): string { function kvKey(apiKey: string, name: string): string {
@@ -103,7 +100,6 @@ webhooksNamedRouter.post('/webhooks/named', async (c) => {
graph?: Record<string, unknown>; graph?: Record<string, unknown>;
config?: Record<string, unknown>; config?: Record<string, unknown>;
description?: string; description?: string;
exposure_consent?: ExposureConsent;
} | null; } | null;
if (!body?.name || !body.graph) { if (!body?.name || !body.graph) {
@@ -125,14 +121,7 @@ webhooksNamedRouter.post('/webhooks/named', async (c) => {
return c.json({ error: 'workflow name 只能包含英文字母、數字、底線和連字號' }, 400); return c.json({ error: 'workflow name 只能包含英文字母、數字、底線和連字號' }, 400);
} }
// 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面) // 暴露 consent 閘已移除(leo 2026-06-29Arcrun#13):部署 webhook 不再需要人類確認,直接放行
// 首次部署某 workflow 需人類明示同意;已同意(含 suppress_future)則放行(§3 首次問記住)。
const priorRaw = await c.env.WEBHOOKS.get(kvKey(apiKey, name));
const priorRecord = priorRaw ? (JSON.parse(priorRaw) as NamedWorkflowRecord) : null;
const consentError = checkExposureConsent(body.exposure_consent, priorRecord?.exposure_consent);
if (consentError !== null) {
return c.json({ error: consentError, requires: 'exposure_consent' }, 403);
}
// 偵測首節點是 cron 零件 → 抽 cron_expr 存進 record + 建輕量 index 給 scheduled() // 偵測首節點是 cron 零件 → 抽 cron_expr 存進 record + 建輕量 index 給 scheduled()
const cronExpr = extractCronExpr(body.graph); const cronExpr = extractCronExpr(body.graph);
@@ -144,8 +133,6 @@ webhooksNamedRouter.post('/webhooks/named', async (c) => {
description: body.description.trim(), // R1:已驗非空(見上),存 trim 後的值 description: body.description.trim(), // R1:已驗非空(見上),存 trim 後的值
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
cron_expr: cronExpr ?? undefined, cron_expr: cronExpr ?? undefined,
// 法律憑證:存人類明示同意(本次新同意或沿用既有)
exposure_consent: resolveConsentForRecord(body.exposure_consent, priorRecord?.exposure_consent),
}; };
const start = Date.now(); const start = Date.now();