Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a51d67da0 | |||
| c1a06df68f | |||
| 43948d9247 | |||
| d8fac6750e | |||
| 2aa26a5bdd | |||
| 90777e3877 | |||
| 764f657201 | |||
| 225aa9f9e7 | |||
| caa8e103ff | |||
| a234201235 | |||
| f21906ca6a | |||
| 222a382d49 |
+26
-98
@@ -1,7 +1,17 @@
|
||||
/**
|
||||
* acr parts — 列出所有可用零件(內建清單,不依賴 registry.arcrun.dev)
|
||||
* acr parts — 列出所有可用「零件(component)」(內建清單,不依賴 registry.arcrun.dev)
|
||||
* acr parts scaffold <component> — 輸出 config 範本(可直接貼入 workflow.yaml)
|
||||
* acr parts publish <component> — 提交零件至公眾 registry(Phase 5,封測後)
|
||||
*
|
||||
* ⚠️ 分類原則(2026-06-29,issue #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 { join } from 'node:path';
|
||||
@@ -25,7 +35,7 @@ interface ComponentDef {
|
||||
credentials_required?: CredentialRequirement[];
|
||||
}
|
||||
|
||||
const BUILTIN_COMPONENTS: ComponentDef[] = [
|
||||
export const BUILTIN_COMPONENTS: ComponentDef[] = [
|
||||
// ── 控制類(Logic) ────────────────────────────────────────────────────────
|
||||
{
|
||||
canonical_id: 'if_control',
|
||||
@@ -174,27 +184,9 @@ const BUILTIN_COMPONENTS: ComponentDef[] = [
|
||||
type: string
|
||||
format: email`,
|
||||
},
|
||||
// ── AI 類 ──────────────────────────────────────────────────────────────────
|
||||
{
|
||||
canonical_id: 'ai_transform_compile',
|
||||
display_name: 'AI Transform Compile',
|
||||
category: 'ai',
|
||||
description: '將自然語言規則編譯成可執行轉換程式',
|
||||
config_example:
|
||||
` compile_node:
|
||||
component: ai_transform_compile
|
||||
rule: "把 name 轉成大寫,並在前面加上 Hello "`,
|
||||
},
|
||||
{
|
||||
canonical_id: 'ai_transform_run',
|
||||
display_name: 'AI Transform Run',
|
||||
category: 'ai',
|
||||
description: '執行 ai_transform_compile 產生的轉換程式',
|
||||
config_example:
|
||||
` run_node:
|
||||
component: ai_transform_run
|
||||
program: "{{compiled_program}}"`,
|
||||
},
|
||||
// ── AI 類:已移除 ──────────────────────────────────────────────────────────
|
||||
// ai_transform_compile / ai_transform_run 於 2026-05-29 刪除(mindset §2:arcrun 是
|
||||
// AI 呼叫的工具,不是工具回頭呼叫 LLM)。需要 AI 判斷/轉換由操盤的 CC 自己做。
|
||||
// ── API 整合類(Recipe 型,不需 deploy Worker) ────────────────────────────
|
||||
{
|
||||
canonical_id: 'http_request',
|
||||
@@ -211,78 +203,9 @@ const BUILTIN_COMPONENTS: ComponentDef[] = [
|
||||
body:
|
||||
key: "{{value}}"`,
|
||||
},
|
||||
{
|
||||
canonical_id: 'gmail',
|
||||
display_name: 'Gmail',
|
||||
category: 'api',
|
||||
description: '寄送 Gmail(需要 gmail_token credential)',
|
||||
config_example:
|
||||
` mail_node:
|
||||
component: gmail
|
||||
to: "recipient@example.com"
|
||||
subject: "來自 arcrun 的通知"
|
||||
body: "{{message}}"`,
|
||||
credentials_required: [
|
||||
{ key: 'gmail_token', type: 'OAuth2 access token', inject_as: 'access_token' },
|
||||
],
|
||||
},
|
||||
{
|
||||
canonical_id: 'google_sheets',
|
||||
display_name: 'Google Sheets',
|
||||
category: 'api',
|
||||
description: 'Google Sheets 讀寫(需要 google_oauth credential)',
|
||||
config_example:
|
||||
` sheet_node:
|
||||
component: google_sheets
|
||||
spreadsheet_id: "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms"
|
||||
range: "Sheet1!A:B"
|
||||
operation: append
|
||||
values:
|
||||
- ["{{name}}", "{{email}}"]`,
|
||||
credentials_required: [
|
||||
{ key: 'google_oauth', type: 'OAuth2 access token', inject_as: 'access_token' },
|
||||
],
|
||||
},
|
||||
{
|
||||
canonical_id: 'telegram',
|
||||
display_name: 'Telegram',
|
||||
category: 'api',
|
||||
description: '發送 Telegram 訊息(需要 telegram_bot_token credential)',
|
||||
config_example:
|
||||
` tg_node:
|
||||
component: telegram
|
||||
chat_id: "123456789"
|
||||
text: "{{message}}"`,
|
||||
credentials_required: [
|
||||
{ key: 'telegram_bot_token', type: 'Bot token', inject_as: 'bot_token' },
|
||||
],
|
||||
},
|
||||
{
|
||||
canonical_id: 'line_notify',
|
||||
display_name: 'LINE Notify',
|
||||
category: 'api',
|
||||
description: '發送 LINE Notify 通知(需要 line_token credential)',
|
||||
config_example:
|
||||
` line_node:
|
||||
component: line_notify
|
||||
message: "{{notification}}"`,
|
||||
credentials_required: [
|
||||
{ key: 'line_token', type: 'LINE Notify token', inject_as: 'token' },
|
||||
],
|
||||
},
|
||||
{
|
||||
canonical_id: 'notion',
|
||||
display_name: 'Notion',
|
||||
category: 'api',
|
||||
description: '透過 recipe 操作 Notion API(需先 acr recipe push)',
|
||||
config_example:
|
||||
` # 先上傳 recipe:acr recipe push notion.yaml
|
||||
notion_node:
|
||||
component: rec_xxxxxxxx # acr recipe push 後得到的 hash
|
||||
database_id: "{{db_id}}"
|
||||
properties:
|
||||
Name: "{{title}}"`,
|
||||
},
|
||||
// ⚠️ 此處曾 hardcode gmail_send / google_sheets_append / telegram_send / line_notify_send / notion
|
||||
// 這 5 個是 **recipe(動態,存 store)不是零件(component)**,已於 W3.1 移除(issue #13 根治)。
|
||||
// 要找它們:`acr search <term>`(跨類)或 `acr recipe list` / `acr auth-recipe list`(動態清單)。
|
||||
];
|
||||
|
||||
// ── 指令實作 ──────────────────────────────────────────────────────────────────
|
||||
@@ -301,7 +224,7 @@ export async function cmdParts(): Promise<void> {
|
||||
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']) {
|
||||
const comps = grouped[cat];
|
||||
@@ -318,8 +241,13 @@ export async function cmdParts(): Promise<void> {
|
||||
}
|
||||
|
||||
console.log(chalk.gray(' 使用 acr parts scaffold <component> 取得 config 範本'));
|
||||
console.log(chalk.gray(' 第三方服務整合(Notion/Slack/GitHub 等):acr auth-recipe list'));
|
||||
console.log(chalk.gray(' API 整合類若需打自訂服務,請用 acr recipe push 建立 recipe\n'));
|
||||
console.log('');
|
||||
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> {
|
||||
|
||||
@@ -11,7 +11,6 @@ import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { loadConfig, getCypherExecutorUrl } from '../lib/config.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> {
|
||||
const config = loadConfig();
|
||||
@@ -96,17 +95,8 @@ export async function cmdPush(filePath: string): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面)。
|
||||
// 已同意過(本機記住)→ 回非 null 自動放行;未同意/取消/非互動 → null → 中止。
|
||||
// server 端獨立存法律憑證並強制(防 CLI 被繞過)。
|
||||
const consent = await obtainExposureConsent({
|
||||
kind: 'workflow',
|
||||
resourceName: workflow.name,
|
||||
destination: `${executorUrl}/webhooks/named/${workflow.name}/trigger`,
|
||||
});
|
||||
if (!consent) {
|
||||
process.exit(1);
|
||||
}
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):arcrun 是給 AI 用的系統,
|
||||
// push/暴露不再需要人類確認,AI/MCP 隨時可部署。暴露風險由用戶自負(同 n8n 建 webhook)。
|
||||
|
||||
// POST 至 /webhooks/named
|
||||
const deploySpinner = ora(`部署 "${workflow.name}" 至 ${executorUrl}`).start();
|
||||
@@ -119,7 +109,6 @@ export async function cmdPush(filePath: string): Promise<void> {
|
||||
graph,
|
||||
config: workflow.config ?? {},
|
||||
description: workflow.description ?? '',
|
||||
exposure_consent: consent ?? undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { loadConfig, getCypherExecutorUrl, DEFAULT_PUBLIC_LIBRARY_URL } from '../lib/config.js';
|
||||
import { obtainExposureConsent } from '../lib/exposure-warning.js';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
interface RecipeYaml {
|
||||
@@ -70,16 +69,7 @@ export async function cmdRecipePush(filePath: string): Promise<void> {
|
||||
|
||||
const executorUrl = getCypherExecutorUrl(config);
|
||||
|
||||
// 資料外流警示:recipe 定義一個資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁)。
|
||||
// 已同意過(本機記住)→ 回非 null 自動放行;未同意/取消/非互動 → null → 中止。
|
||||
const consent = await obtainExposureConsent({
|
||||
kind: 'recipe',
|
||||
resourceName: recipe.canonical_id,
|
||||
destination: recipe.endpoint,
|
||||
});
|
||||
if (!consent) {
|
||||
process.exit(1);
|
||||
}
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):recipe push 不再需要人類確認。
|
||||
|
||||
const spinner = ora(`上傳 recipe "${recipe.canonical_id}"`).start();
|
||||
|
||||
@@ -90,7 +80,7 @@ export async function cmdRecipePush(filePath: string): Promise<void> {
|
||||
'Content-Type': 'application/json',
|
||||
'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 };
|
||||
@@ -307,7 +297,7 @@ export async function cmdRecipePull(canonicalId: string, author?: string): Promi
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 寫進自己私庫(POST /recipes,帶 derived_from 溯源 + 種子級同意:pull 公庫公共資料非新暴露)。
|
||||
// 2. 寫進自己私庫(POST /recipes,帶 derived_from 溯源)。
|
||||
const r = pub.recipe;
|
||||
const executorUrl = getCypherExecutorUrl(config);
|
||||
const installRes = await fetch(`${executorUrl}/recipes`, {
|
||||
@@ -316,11 +306,6 @@ export async function cmdRecipePull(canonicalId: string, author?: string): Promi
|
||||
body: JSON.stringify({
|
||||
...r,
|
||||
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 };
|
||||
@@ -355,13 +340,7 @@ export async function cmdRecipeSubmitP(canonicalId: string, author?: string): Pr
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. 投稿到公庫 = 暴露面 → 取得人類明示同意(mindset §6)。
|
||||
const consent = await obtainExposureConsent({
|
||||
kind: 'recipe',
|
||||
resourceName: canonicalId,
|
||||
destination: `公庫(${DEFAULT_PUBLIC_LIBRARY_URL})`,
|
||||
});
|
||||
if (!consent) process.exit(1);
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):投稿公庫不再需要人類確認。
|
||||
|
||||
const spinner = ora(`投稿 recipe「${canonicalId}」到公庫`).start();
|
||||
try {
|
||||
@@ -373,7 +352,6 @@ export async function cmdRecipeSubmitP(canonicalId: string, author?: string): Pr
|
||||
author: author ?? my.recipe.author,
|
||||
derived_from: my.recipe.derived_from ?? my.recipe.uuid,
|
||||
submitter: author ?? config.api_key,
|
||||
exposure_consent: consent,
|
||||
}),
|
||||
});
|
||||
const data = await res.json() as { success: boolean; recipe?: { uuid?: string; author?: string }; error?: string };
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* acr search <term> — 跨類統一搜尋(component / recipe / auth-recipe / workflow)
|
||||
*
|
||||
* 為什麼存在(issue #13,leo 2026-06-29):
|
||||
* 舊世界要找一個能力(如 "telegram")必須**先決定**「它是零件?recipe?還是 workflow?」才能查對的清單。
|
||||
* 但查的人(人或 AI)在查到答案前**根本不知道**它屬哪一類 →「先知道分類」成了查詢的前置條件,倒因為果。
|
||||
* leo:「連我都會查錯表。」所以這不是粗心,是搜尋設計缺陷。
|
||||
* 本指令一次掃全部類別、依類別分組回 counts + 命中 → 查的人不必先選表,看一眼就知道 telegram 是 recipe。
|
||||
*
|
||||
* 薄殼原則(rule 07 §2):本指令只做 fan-out 呼叫既有清單來源 + 過濾 + 分組 + 印出(介面層的暴露/格式化)。
|
||||
* - component:靜態(BUILTIN_COMPONENTS,PR-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('');
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { cmdValidate } from './commands/validate.js';
|
||||
import { cmdParts, cmdPartsScaffold, cmdPartsPublish } from './commands/parts.js';
|
||||
import { cmdRecipePush, cmdRecipeList, cmdRecipeDelete, cmdRecipeSearch, cmdRecipePull, cmdRecipeSubmitP } from './commands/recipe.js';
|
||||
import { cmdList } from './commands/list.js';
|
||||
import { cmdSearch } from './commands/search.js';
|
||||
import { cmdLogs } from './commands/logs.js';
|
||||
import { cmdUpdate } from './commands/update.js';
|
||||
import { cmdInstallHarness } from './commands/install-harness.js';
|
||||
@@ -205,6 +206,12 @@ program
|
||||
.description('列出 CF KV 中所有已部署的 workflow')
|
||||
.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>
|
||||
program
|
||||
.command('logs <workflow>')
|
||||
|
||||
@@ -34,9 +34,8 @@ export interface ArcrunConfig {
|
||||
// 未設/false → base 維持 LIKE keyword(free-tier 友善,不建 index、不花費)。
|
||||
// 開法:設 kbdb_embed:true → redeploy(acr update)。「CC 幫開」=CC 寫此欄 true + 跑 acr update。
|
||||
kbdb_embed?: boolean;
|
||||
// 資料外流警示:本機記住「已同意暴露 / 選擇不再警示」的資源,避免每次 push 重問(§3 首次問記住)。
|
||||
// key 格式:`{kind}:{resourceName}`(如 "webhook:contacts_lookup" / "recipe:kbdb_get")。
|
||||
// 注意:這只是 CLI 端 UX(不重問);server 端獨立存法律憑證並強制(防 CLI 被繞過)。
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13)。此欄位保留只為向後相容舊 config.yaml
|
||||
// (讀到不報錯,不再寫入/檢查)。
|
||||
exposure_consented?: Record<string, { confirmed_at: string; suppress_future?: boolean }>;
|
||||
}
|
||||
|
||||
|
||||
+16
-3
@@ -345,11 +345,24 @@ async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
/** 下載 codeload tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。*/
|
||||
/** 下載 codeload tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。
|
||||
*
|
||||
* ⚠️ Arcrun#13 P2 根因修復:codeload 的 branch tarball(tar.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> {
|
||||
const url = `https://codeload.github.com/${ARCRUN_REPO}/tar.gz/${ref}`;
|
||||
// 唯一 cache-buster query param:codeload 對不同 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},約 10–30 秒,視網速)...`));
|
||||
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})`);
|
||||
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* 資料外流警示 — CLI 互動(data-exfil-warning SDD §1a / B)
|
||||
*
|
||||
* 觸發策略:只在「資料變成可被外部呼叫」時警示(webhook 部署 / recipe push)。
|
||||
* 互動形式(richblack):仿 GCP 刪 project —— 要用戶打資源名證明讀了警示(比 y/n 硬,不用打一大串)。
|
||||
* 同意 = 法律憑證:回傳的 ExposureConsent 帶 understood(用戶打的內容)+ 時間,server 端 log。
|
||||
* 誠實限制:非 TTY(AI 直跑)無 --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('');
|
||||
}
|
||||
@@ -13,8 +13,7 @@
|
||||
* ARCRUN_API_URL - 目標 cypher-executor,預設 https://cypher.arcrun.dev
|
||||
* ARCRUN_API_KEY - X-Arcrun-API-Key(POST /recipes 需要)
|
||||
*
|
||||
* 注意:API recipe 帶 endpoint(資料去向)→ POST /recipes 會要 exposure_consent
|
||||
* (data-exfil-warning)。seed 是平台預建、非用戶 push,腳本帶種子層級的 consent。
|
||||
* 注意:暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13),POST /recipes 不再需要 consent。
|
||||
*
|
||||
* 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §5
|
||||
*/
|
||||
@@ -49,14 +48,7 @@ async function main() {
|
||||
endpoint: recipe.endpoint,
|
||||
method: recipe.method,
|
||||
auth_service: recipe.auth_service,
|
||||
// 種子層級的暴露同意:平台預建 recipe,非用戶互動 push。
|
||||
// 格式須符合 cypher-executor ExposureConsent(confirmed_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(),
|
||||
},
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):不再帶 exposure_consent。
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,13 @@ import type { AuthRecipeDefinition } from '../routes/recipes';
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// ⚠️ 已知 source/live drift(2026-06-29 盤點,未在此檔修):
|
||||
// prod RECIPES KV 另有 `auth_recipe:google_user`(primitive: oauth2)。**故意不回灌 source**,原因:
|
||||
// (1) 它內嵌 client_secret(GOCSPX-...)= 機密,不可進 git(wiki-secret-scan / 一般安全);
|
||||
// (2) 本檔的 AuthRecipeDefinition 介面尚無 oauth2 欄位(client_id/secret/token_endpoint/scopes),
|
||||
// 回灌前需先擴 schema + 把 secret 改成部署期注入(wrangler secret / env),屬獨立工作。
|
||||
// → google_user 留待 oauth2 seed 機制(含 secret 注入)獨立處理;本次只修無機密的 static_key 漂移。
|
||||
|
||||
export const AUTH_RECIPE_SEEDS: AuthRecipeDefinition[] = [
|
||||
// ── Static Key 類 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -556,6 +563,94 @@ export const AUTH_RECIPE_SEEDS: AuthRecipeDefinition[] = [
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
// ── 訊息 / URL-path 注入類(static_key)────────────────────────────────────
|
||||
//
|
||||
// 2026-06-29 補:以下三個 static_key auth recipe 一直存在於 prod RECIPES KV(手動 seed 過),
|
||||
// 但**從未進 source seed**(auth-recipe-seeds.ts)→ 任何全新 self-hosted `POST /init/seed`
|
||||
// 只會 seed 23 個、漏掉 telegram/line_notify/kbdb → self-host(mira/leo21c)的 telegram 發訊
|
||||
// 走不通(telegram_send 的 auth_service:'telegram' 找不到 auth recipe → {{auth.bot_token}} 注入空)。
|
||||
// 這正是「source vs live drift = 假綠」(總管反覆踩的同一類)。把 prod 現役定義回灌 source,
|
||||
// 讓 official 與 self-host 共用同一份種子。形態取自 prod GET /auth-recipes/{service}(2026-06-29)。
|
||||
// 設計權威:auth-recipe.md §六(line 70-71, telegram path 注入) + §七(line 150-151, kbdb 共用)。
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'telegram',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://api.telegram.org',
|
||||
display_name: 'Telegram Bot',
|
||||
description: 'Telegram Bot API — sendMessage 等(bot token 注入 URL path /bot{token}/)',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'telegram_bot_token',
|
||||
label: 'Bot Token(從 @BotFather 取得)',
|
||||
help: '在 Telegram 對 @BotFather 送 /newbot 建立 bot,取得格式為 123456:ABC... 的 token',
|
||||
help_url: 'https://core.telegram.org/bots/features#botfather',
|
||||
},
|
||||
],
|
||||
// path 注入:recipe:telegram_send 的 endpoint 用 {{auth.bot_token}} 從 _auth_path 取值
|
||||
// (auth_static_key WASM 解密後輸出 auth_path → auth-dispatcher 帶進 _auth_path
|
||||
// → makeRecipeRunner interpolate)。token 不落 header/query/body,符合 Telegram 的 URL-path 慣例。
|
||||
inject: {
|
||||
path: {
|
||||
bot_token: '{{secret.telegram_bot_token}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'line_notify',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://notify-api.line.me',
|
||||
display_name: 'LINE Notify',
|
||||
description: 'LINE Notify — 推播訊息(static_key Bearer)',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'line_token',
|
||||
label: 'LINE Notify Token',
|
||||
help: '至 https://notify-bot.line.me/my/ 發行個人存取權杖',
|
||||
help_url: 'https://notify-bot.line.me/my/',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Bearer {{secret.line_token}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
{
|
||||
kind: 'auth_recipe',
|
||||
service: 'kbdb',
|
||||
version: 1,
|
||||
primitive: 'static_key',
|
||||
base_url: 'https://kbdb.finally.click',
|
||||
display_name: 'KBDB',
|
||||
description: 'KBDB partner API — block 讀寫(static_key Bearer)。kbdb_* recipe 共用此把 auth。',
|
||||
required_secrets: [
|
||||
{
|
||||
key: 'kbdb_api_key',
|
||||
label: 'KBDB API Key(至 arcrun 取統一 API Key 當 credential)',
|
||||
help: 'KBDB 採 Supabase 模式:要用 → 去 arcrun 取統一 API Key 當此 credential',
|
||||
help_url: 'https://arcrun.dev',
|
||||
},
|
||||
],
|
||||
inject: {
|
||||
header: {
|
||||
Authorization: 'Bearer {{secret.kbdb_api_key}}',
|
||||
},
|
||||
},
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
// ── Service Account 類(Google 家族,共用同一份 service_account_json)────────
|
||||
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -9,8 +9,7 @@
|
||||
* 行為:
|
||||
* - 冪等:已存在的 recipe 直接覆寫(重跑安全)。
|
||||
* - 一次灌「API recipe(API_RECIPE_SEEDS)+ auth recipe(AUTH_RECIPE_SEEDS)」兩者。
|
||||
* - 直接寫 KV(不走 POST /recipes 的 exposure_consent gate):種子是平台預建、非用戶互動 push,
|
||||
* 帶 seed 層級的 consent 憑證(誠實標來源,軌跡可審;mindset §7:機制價值是歸責+可審非防偽)。
|
||||
* - 直接寫 KV:種子是平台預建、非用戶互動 push(暴露 consent 閘已於 Arcrun#13 移除)。
|
||||
* - 誠實回報:逐筆 ok/fail 計數,不假綠。
|
||||
*
|
||||
* 對應 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) => {
|
||||
const now = Date.now();
|
||||
const seedConsent = {
|
||||
confirmed_by_human: true as const,
|
||||
understood: 'platform seed (init/seed): 平台預建 recipe,非用戶互動 push',
|
||||
confirmed_at: new Date(now).toISOString(),
|
||||
};
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):種子不再帶 exposure_consent。
|
||||
|
||||
let apiOk = 0;
|
||||
let apiFail = 0;
|
||||
@@ -54,7 +49,6 @@ initSeedRouter.post('/init/seed', async (c) => {
|
||||
endpoint: seed.endpoint,
|
||||
method: (seed.method ?? 'POST').toUpperCase(),
|
||||
auth_service: seed.auth_service,
|
||||
exposure_consent: existing?.exposure_consent ?? seedConsent,
|
||||
created_at: existing?.created_at ?? now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
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 }>();
|
||||
|
||||
@@ -46,9 +44,9 @@ export interface RecipeDefinition {
|
||||
key: string;
|
||||
inject_as: string;
|
||||
}>;
|
||||
// 資料外流警示:recipe 定義一個資料去向(endpoint)。push 需人類明示同意(法律憑證)。
|
||||
// SDD: data-exfil-warning §7(公私一視同仁)
|
||||
exposure_consent?: ExposureConsent;
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29 拍板,Arcrun#13):arcrun 是給 AI 用的系統,
|
||||
// 不再對 push/暴露要求人類確認。此欄位保留只為向後相容舊 KV record(讀到不報錯,不再寫入/檢查)。
|
||||
exposure_consent?: unknown;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
@@ -104,11 +102,7 @@ recipesRouter.post('/recipes', async (c) => {
|
||||
// 讀取順序:先 UUID 模型(installed→uuid),fallback 舊 key(migration 前的種子)。
|
||||
const existing = await resolveRecipe(canonicalId, c.env.RECIPES);
|
||||
|
||||
// 資料外流警示:recipe 定義資料去向(endpoint)。首次 push 需人類明示同意(公私一視同仁)。
|
||||
const consentError = checkExposureConsent(body.exposure_consent, existing?.exposure_consent);
|
||||
if (consentError !== null) {
|
||||
return c.json({ success: false, error: consentError, requires: 'exposure_consent' }, 403);
|
||||
}
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):直接 push,不攔。
|
||||
|
||||
const recipe: RecipeDefinition = {
|
||||
uuid: existing?.uuid ?? crypto.randomUUID(),
|
||||
@@ -124,7 +118,6 @@ recipesRouter.post('/recipes', async (c) => {
|
||||
body: body.body,
|
||||
auth_service: body.auth_service,
|
||||
credentials_required: body.credentials_required,
|
||||
exposure_consent: resolveConsentForRecord(body.exposure_consent, existing?.exposure_consent),
|
||||
created_at: existing?.created_at ?? now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -162,11 +155,7 @@ recipesRouter.post('/recipes/submit', async (c) => {
|
||||
const hashId = await deriveRecipeHash(canonicalId);
|
||||
const now = Date.now();
|
||||
|
||||
// 公共庫投稿一定是暴露 → 需明示同意(無同意直接擋)。投稿是新版本,不沿用既有同意。
|
||||
const consentError = checkExposureConsent(body.exposure_consent, undefined);
|
||||
if (consentError !== null) {
|
||||
return c.json({ success: false, error: consentError, requires: 'exposure_consent' }, 403);
|
||||
}
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):公共庫投稿不再需要人類確認。
|
||||
|
||||
// app-store 模型:**領新 uuid = 新增作者版本**,不覆蓋既有 canonical(§7.5.5)。
|
||||
const recipe: RecipeDefinition = {
|
||||
@@ -183,7 +172,6 @@ recipesRouter.post('/recipes/submit', async (c) => {
|
||||
body: body.body,
|
||||
auth_service: body.auth_service,
|
||||
credentials_required: body.credentials_required,
|
||||
exposure_consent: resolveConsentForRecord(body.exposure_consent, undefined),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -460,6 +448,11 @@ export interface AuthInjectSpec {
|
||||
header?: Record<string, string>; // e.g. { Authorization: "Bearer {{secret.token}}" }
|
||||
query?: Record<string, string>;
|
||||
body?: Record<string, string>;
|
||||
// path:注入 endpoint URL path 的 secret(auth-recipe.md §六,2026-05-29 加)。
|
||||
// 解 telegram 類「token 在 URL path」(/bot{token}/)—— header/query/body 都不適用。
|
||||
// key = 模板變數名(recipe endpoint 用 {{auth.K}} 引用),value = {{secret.X}} 模板。
|
||||
// auth_static_key WASM 解密後輸出為 auth_path → auth-dispatcher 帶進 _auth_path → makeRecipeRunner interpolate。
|
||||
path?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface AuthRecipeDefinition {
|
||||
|
||||
@@ -30,8 +30,6 @@ import type { GraphNode } from '../types';
|
||||
import { extractCronExpr } from '../lib/cron-match';
|
||||
import { updateCronIndexEntry, CRON_INDEX_KEY } from '../lib/cron-index';
|
||||
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 }>();
|
||||
|
||||
@@ -44,9 +42,8 @@ type NamedWorkflowRecord = {
|
||||
// 若首節點是 cron 零件,extract cron_expr 存進來供 scheduled() 比對
|
||||
// 對應 SDD: arcrun.md 三-A P1 #3
|
||||
cron_expr?: string;
|
||||
// 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面)。
|
||||
// 存人類明示同意憑證(法律憑證,可審)。SDD: data-exfil-warning §7
|
||||
exposure_consent?: ExposureConsent;
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13)。保留欄位只為向後相容舊 KV record。
|
||||
exposure_consent?: unknown;
|
||||
};
|
||||
|
||||
function kvKey(apiKey: string, name: string): string {
|
||||
@@ -103,7 +100,6 @@ webhooksNamedRouter.post('/webhooks/named', async (c) => {
|
||||
graph?: Record<string, unknown>;
|
||||
config?: Record<string, unknown>;
|
||||
description?: string;
|
||||
exposure_consent?: ExposureConsent;
|
||||
} | null;
|
||||
|
||||
if (!body?.name || !body.graph) {
|
||||
@@ -125,14 +121,7 @@ webhooksNamedRouter.post('/webhooks/named', async (c) => {
|
||||
return c.json({ error: 'workflow name 只能包含英文字母、數字、底線和連字號' }, 400);
|
||||
}
|
||||
|
||||
// 資料外流警示:部署 webhook = 把 workflow 變對外可呼叫 endpoint(暴露面)。
|
||||
// 首次部署某 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);
|
||||
}
|
||||
// 暴露 consent 閘已移除(leo 2026-06-29,Arcrun#13):部署 webhook 不再需要人類確認,直接放行。
|
||||
|
||||
// 偵測首節點是 cron 零件 → 抽 cron_expr 存進 record + 建輕量 index 給 scheduled()
|
||||
const cronExpr = extractCronExpr(body.graph);
|
||||
@@ -144,8 +133,6 @@ webhooksNamedRouter.post('/webhooks/named', async (c) => {
|
||||
description: body.description.trim(), // R1:已驗非空(見上),存 trim 後的值
|
||||
created_at: new Date().toISOString(),
|
||||
cron_expr: cronExpr ?? undefined,
|
||||
// 法律憑證:存人類明示同意(本次新同意或沿用既有)
|
||||
exposure_consent: resolveConsentForRecord(body.exposure_consent, priorRecord?.exposure_consent),
|
||||
};
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
+17
-9
@@ -13,15 +13,23 @@ workers_dev = true # 對齊 arcrun 部署慣例(rule 05):deploy 掃描
|
||||
# SDD: sdk-and-website/mcp-account-source.md §5.5;HANDOFF §3b。
|
||||
[vars]
|
||||
|
||||
# Service Bindings
|
||||
# 2026-05-07:COMPONENT_REGISTRY 從 inkstone-component-registry 改為 arcrun-registry
|
||||
# 原因:舊的 inkstone-component-registry 期望不同 query 參數名,MCP search 失敗。
|
||||
# 新的 arcrun-registry(registry.arcrun.dev)才是現役。
|
||||
services = [
|
||||
{ binding = "COMPONENT_REGISTRY", service = "arcrun-registry" },
|
||||
{ binding = "CYPHER_EXECUTOR", service = "arcrun-cypher-executor" },
|
||||
{ binding = "KBDB", service = "arcrun-kbdb" }
|
||||
]
|
||||
# Service Bindings(issue #12:用 [[services]] array-of-tables,不用 services=[...] inline)
|
||||
# ⚠️ 為何不能用 inline `services = [...]`:它在 [vars] table 之後 → TOML 會把它吸成
|
||||
# `vars.services`(普通 var 陣列)而非頂層 service bindings → wrangler 看不到 binding。
|
||||
# self-hosted 部署注入 MULTI_TENANT 進 [vars] 後此問題暴露(MCP 報 CYPHER_EXECUTOR not configured)。
|
||||
# array-of-tables `[[services]]` 是獨立頂層 table,不受 [vars] 影響(對齊官方 cypher-executor/wrangler.toml)。
|
||||
# 2026-05-07:COMPONENT_REGISTRY 從 inkstone-component-registry 改為 arcrun-registry(現役)。
|
||||
[[services]]
|
||||
binding = "COMPONENT_REGISTRY"
|
||||
service = "arcrun-registry"
|
||||
|
||||
[[services]]
|
||||
binding = "CYPHER_EXECUTOR"
|
||||
service = "arcrun-cypher-executor"
|
||||
|
||||
[[services]]
|
||||
binding = "KBDB"
|
||||
service = "arcrun-kbdb"
|
||||
|
||||
# Route — MCP 搬進 arcrun 主庫後改用 arcrun.dev zone(與其他 worker 一致)。
|
||||
# 舊的 studio.finally.click 是 inkstone 平台 zone,arcrun 帳號沒有該 zone → 部署 route 失敗。
|
||||
|
||||
@@ -54,7 +54,7 @@ config:
|
||||
格式:markdown bullets,每條 < 30 字,標明來源。
|
||||
|
||||
push_digest:
|
||||
component: telegram
|
||||
component: telegram_send
|
||||
chat_id: "{{secret.LEO_TELEGRAM_CHAT_ID}}"
|
||||
text: |
|
||||
☀️ 早安 {{_today}}
|
||||
|
||||
@@ -12,6 +12,7 @@ config:
|
||||
cron_expr: "0 8 * * *" # 每天 08:00 UTC(依需求調時區)
|
||||
|
||||
fetch_unread:
|
||||
# TODO(#13): gmail 讀取尚無對應 recipe,待 seed 補 gmail_list 後再映射;gmail_send 是送信端點,不可用於 action:list
|
||||
component: gmail
|
||||
action: "list"
|
||||
query: "is:unread newer_than:1d"
|
||||
@@ -35,7 +36,7 @@ config:
|
||||
{{fetch_unread.messages}}
|
||||
|
||||
push_to_telegram:
|
||||
component: telegram
|
||||
component: telegram_send
|
||||
chat_id: "{{secret.LEO_TELEGRAM_CHAT_ID}}"
|
||||
text: |
|
||||
📬 今日 email 摘要
|
||||
|
||||
@@ -35,7 +35,7 @@ config:
|
||||
# 純記錄成功,下游若需要可繼續鏈
|
||||
|
||||
final_fail_notify:
|
||||
component: telegram
|
||||
component: telegram_send
|
||||
chat_id: "{{secret.LEO_TELEGRAM_CHAT_ID}}"
|
||||
text: |
|
||||
⚠️ workflow {{input.workflow_name}} 兩次重試都失敗
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
- [[自力救濟階梯-缺能力怎麼補]] — 自家API缺→補API/第三方→workflow補丁/純計算→code-node
|
||||
- [[薄殼原則-能力長在API]] — CLI/MCP/lib 只暴露,齊的單位是「能力」不是「端點」
|
||||
- [[薄殼規則晚於實作-MCP漂移是歷史債]] — 為何 MCP/CLI 不一致:紀律 2026-06-07 才補、補前漂移
|
||||
- [[薄殼防複發-能力對照表加smoke]] — 防死端點假綠:對照清單 + 本機 smoke(非 CI),自驗能攔
|
||||
|
||||
## 串接 / 部署
|
||||
|
||||
|
||||
@@ -34,4 +34,5 @@ MCP 和 CLI 不一致的根因不是「MCP 更早開發所以舊」,而是**
|
||||
- 薄殼原則 >> 修正 >> MCP 漂移
|
||||
### 卡片關係
|
||||
- [[薄殼規則晚於實作-MCP漂移是歷史債]] >> 補充歷史成因於 >> [[薄殼原則-能力長在API]]
|
||||
- [[薄殼規則晚於實作-MCP漂移是歷史債]] >> 被治理於 >> [[薄殼防複發-能力對照表加smoke]]
|
||||
- (相關 memory:`mcp-self-hosted-bug-fixed`、`thin-shell-capability-in-api` — MCP 後端接線與薄殼鐵律)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
tags: [薄殼, 平台原則, 架構決策, 機制說明]
|
||||
gloss: 防 CLI/MCP 薄殼漂移(死端點假綠)的雙層機制——靜態能力對照清單 + 本機 smoke test 對真端點斷言非 404。
|
||||
---
|
||||
# 薄殼防複發 — 能力對照表 + 本機 smoke
|
||||
|
||||
← [[decisions/00-INDEX]]
|
||||
|
||||
**來源**:`system-dev/docs/3-specs/thin-shell-alignment/design.md §5`(issue #11)、`docs/4-guides/cli-mcp-capability-matrix.md`、`scripts/thin-shell-smoke.sh`
|
||||
**最後更新**:2026-06-27
|
||||
|
||||
## 摘要
|
||||
治「薄殼打了不存在的 server 端點」(死端點假綠)用雙層:靜態能力對照清單(review 防線)+ 本機 smoke test(對真端點斷言非 404,死端點當場現形)。本機手動跑,非 CI。
|
||||
|
||||
## 重點
|
||||
- **根因是假綠**:MCP deploy 在宣稱「一致性落地」的同一 commit 裡打了不存在的 `/workflows/deploy`(必 404),從未端到端跑過。光靠「宣稱對齊」會再犯。
|
||||
- **層 1 能力對照清單**(`docs/4-guides/cli-mcp-capability-matrix.md`):表「能力 × CLI 端點 × MCP 端點 × route 存在? × 同源?」,新薄殼能力必填一行,填前 grep 確認 route 存在。
|
||||
- **層 2 本機 smoke**(`scripts/thin-shell-smoke.sh`):對每能力打真端點斷言非 404。**本機手動跑非 CI/cron/輪詢**(守 flag 紅線,對齊「執行鏈路不依賴 CI」鐵律)。
|
||||
- **機制自驗**:注入故意死端點 → smoke 當場攔下、exit 1(已驗證能攔)。
|
||||
- **smoke 區分「code 有 route」vs「prod 真部署」**:首跑就揭 #8 search/backfill 在 prod 仍 404(code 已 commit 但未部署)——這正是它要揭的假綠。
|
||||
- **不用 CI 高頻打真端點**(違 flag 紅線);smoke 是「宣布完成前手動跑一次」的閘。
|
||||
|
||||
## 實體
|
||||
- **能力對照清單**(cli-mcp-capability-matrix)— 靜態表列每能力的 CLI/MCP 端點與 route 存在性,review 防線。
|
||||
- **本機 smoke test**(thin-shell-smoke.sh)— 對真端點打、斷言非 404 的腳本,死端點現形,本機手動跑。
|
||||
- **死端點假綠**(dead endpoint)— 薄殼打了 server 不存在的 route(404),卻被宣稱成完成。
|
||||
- **機制自驗**(注入死端點測試)— 故意加不存在端點驗 smoke 能攔,證明機制有效。
|
||||
|
||||
## 關聯
|
||||
### 內文知識關係
|
||||
- 死端點假綠 >> 被攔於 >> 本機 smoke test
|
||||
- 能力對照清單 >> 互補於 >> 本機 smoke test
|
||||
- 機制自驗 >> 證明 >> 本機 smoke test
|
||||
- 本機 smoke test >> 區分 >> 死端點假綠
|
||||
### 卡片關係
|
||||
- [[薄殼防複發-能力對照表加smoke]] >> 治理 >> [[薄殼規則晚於實作-MCP漂移是歷史債]]
|
||||
- [[薄殼防複發-能力對照表加smoke]] >> 落實 >> [[薄殼原則-能力長在API]]
|
||||
@@ -327,6 +327,41 @@ code-node 規則已定、零件實作屬 wishlist C1 另案。**詳見**:DECIS
|
||||
|
||||
---
|
||||
|
||||
## 薄殼防複發機制=對照清單 + 本機 smoke(2026-06-27,issue #11)
|
||||
|
||||
**Q:CLI/MCP 薄殼漂移(死端點假綠)怎麼防複發?**
|
||||
|
||||
**決策**:雙層,非 CI。
|
||||
- **層 1 能力對照清單** `docs/4-guides/cli-mcp-capability-matrix.md`:一張表「能力 × CLI 端點 × MCP 端點 × route 存在? × 同源?」,新增薄殼能力**必填一行**,PR review 對照。
|
||||
- **層 2 本機 smoke test** `scripts/thin-shell-smoke.sh`:對每能力打真端點、**斷言非 404**(死端點當場現形)。**本機手動跑非 CI/cron/輪詢**(守 flag 紅線,對齊「執行鏈路不依賴 CI」)。
|
||||
- **機制自驗**:注入故意死端點 → smoke 當場攔(已驗證能攔,exit 1)。
|
||||
|
||||
**為什麼**:根因是**假綠**——宣稱「一致性落地」但端點不存在,光靠宣稱會再犯。smoke 區分「code 有 route」vs「prod 真部署」(首跑就揭 #8 search/backfill 在 prod 仍 404=未部署)。
|
||||
|
||||
**避坑**:不用 CI 高頻打真端點(違 flag 紅線);smoke 是「宣布完成前手動跑一次」的閘。
|
||||
|
||||
**詳見**:thin-shell-alignment SDD §5、[[薄殼規則晚於實作-MCP漂移是歷史債]] 卡、mistakes #19。
|
||||
|
||||
---
|
||||
|
||||
## workflow description 由操盤 CC 據實生成、用戶可改(2026-06-27,issue #8)
|
||||
|
||||
**Q:強制 workflow 填 description,但 low-code 用戶不知道要填,怎麼辦?**
|
||||
|
||||
**決策**:強制非空落 API(不變);**空時由操盤的 CC 據實寫一句「能做什麼」,用戶可改**——非逼用戶手填、非介面層機械塞佔位。
|
||||
|
||||
**調和關鍵(分清兩種「生成」)**:
|
||||
- ✅ **操盤 CC 據實生成**:CC 剛建這 workflow、最懂它做什麼 → 寫真描述(leo 例「呼叫可 Upsert Google Sheets」)。**真描述非假裝**,不違 mindset §7。
|
||||
- ❌ **介面層機械塞佔位**(從 name 複製、`workflow_xxx`)=假描述,仍禁。
|
||||
|
||||
**為什麼**:low-code 用戶不知道要填 description(逼填違北極星「不增負擔」);但自動機械填=假裝有(違誠實)。北極星「執行力外包給 AI」=AI 據實幫他寫好、他可改。
|
||||
|
||||
**避坑**:description 是一句「能做什麼」供語意搜尋,**非寫文章**。
|
||||
|
||||
**詳見**:workflow-discovery SDD §3.2。
|
||||
|
||||
---
|
||||
|
||||
## 快速決策樹
|
||||
|
||||
```
|
||||
|
||||
@@ -387,6 +387,64 @@ listEntries 加 `source` filter 即可,表結構一個欄位都不動。
|
||||
|
||||
---
|
||||
|
||||
## 19. 薄殼打的端點 server 不一定存在——死端點假綠(2026-06-27,issue #8/#11)
|
||||
|
||||
**錯誤模式**:以為「tsc 綠 + 介面有這個工具」=能力通了。實際 MCP `u6u_deploy_workflow` 打
|
||||
`/workflows/deploy`、CLI `run.ts` 打 `/webhooks/<name>`——**兩個端點 cypher 根本沒有,必 404**,
|
||||
且當初 commit message 還宣稱「部署一致性落地」。**從未端到端跑過**。
|
||||
|
||||
**症狀**:宣稱對齊/完成,但薄殼打的 route 在 server 不存在;tsc 綠掩蓋了端點不存在。
|
||||
|
||||
**正確做法**:
|
||||
1. 宣稱對齊/完成前,**grep server route 清單確認端點存在**
|
||||
(`grep -rE "Router\.(post|get)\('/xxx'" cypher-executor/src/routes/`)。
|
||||
2. 或跑 `scripts/thin-shell-smoke.sh`(對真端點打、斷言非 404,死端點當場現形)。
|
||||
3. **tsc 綠 ≠ 端點存在 ≠ 端到端通**。三者分開驗(mindset §7 完成=客觀證據)。
|
||||
|
||||
**通用教訓**:薄殼漂移(CLI/MCP 各長各的)的根因常是「規則晚於實作 + 假綠」。
|
||||
防複發見 [[薄殼防複發-能力對照表加smoke]] 卡 + `docs/4-guides/cli-mcp-capability-matrix.md`。日期:2026-06-27。
|
||||
|
||||
---
|
||||
|
||||
## 20. gitignored 檔查不到 git 史——別用 git log 推斷時間/狀態(2026-06-27)
|
||||
|
||||
**錯誤模式**:想用 `git log` 查 rule 07 / SDD / wiki 的建立時間 → 全空白,卡住。
|
||||
|
||||
**症狀**:`git log -- .claude/rules/07-thin-shell.md` 等回空,誤以為檔案有問題或 follow 寫錯。
|
||||
|
||||
**正確做法**:先 `git check-ignore <path>` 確認。`.claude/`、`docs/`、部分 `system-dev/docs/`
|
||||
**全 gitignored** → git 無這些檔的歷史。查它們的時序只能靠**檔內日期註記**,且要誠實標
|
||||
「估計非 git 鐵證」。**注**:`system-dev/wiki/` **不是** gitignored(可 commit),只有 `docs/` + `.claude/hooks/` 是。
|
||||
|
||||
**通用教訓**:查歷史前先確認檔在不在 git 追蹤範圍;gitignored 的東西「精確時間」不可考,別假裝有鐵證。日期:2026-06-27。
|
||||
|
||||
---
|
||||
|
||||
## 21. wrangler.toml 的 `services = [...]` inline 放在 `[vars]` 後 → binding 消失(2026-06-27,issue #12)
|
||||
|
||||
**錯誤模式**:以為「toml 裡有 `services = [{ binding="CYPHER_EXECUTOR", ... }]`」= binding 就會生效。
|
||||
實際 self-hosted MCP 報 `CYPHER_EXECUTOR service binding not configured`,binding 像憑空消失。
|
||||
|
||||
**根因(TOML 語法坑)**:`services = [...]` 是 **inline array 形式**,且位置在 `[vars]` table **之後**。
|
||||
TOML 規則:`[vars]` header 後的所有 key(含 `services`)都歸 `vars` table → `services` 被解析成
|
||||
**`vars.services`(一個普通 env var 陣列)而非頂層 service bindings** → wrangler 看不到任何 binding。
|
||||
self-hosted 部署 `injectMultiTenant` 往 `[vars]` 注入 `MULTI_TENANT` 後此問題暴露(官方不注入故沒早爆,
|
||||
且 whoami 不需 binding)。
|
||||
|
||||
**正確做法**:service binding 一律用 **`[[services]]` array-of-tables**(每個 binding 獨立頂層 table,
|
||||
不受 `[vars]` 影響),**不要用 inline `services = [...]`**——尤其檔案有 `[vars]` 時。對齊官方
|
||||
`cypher-executor/wrangler.toml` 慣例(它用 `[[services]]` 故不中招)。
|
||||
|
||||
**驗法(決定性)**:`wrangler deploy --dry-run --outdir /tmp/x` 列 bindings:
|
||||
- 正確 → `env.CYPHER_EXECUTOR (arcrun-cypher-executor) Worker`
|
||||
- 中招 → `env.services ([{"binding":...}]) Environment Variable`(binding 變成一個叫 services 的 env var)。
|
||||
|
||||
**通用教訓**:TOML 頂層 key 必須在任何 `[table]` header 之前;要在 table 後宣告的集合用
|
||||
array-of-tables(`[[x]]`)不用 inline array。改 toml 後用 `wrangler --dry-run` 驗 binding 真的在
|
||||
(別只看「檔裡有寫」)。連帶:這跟 mistakes #17「注入順序」、#11「死端點假綠」同類——**配置寫了 ≠ 生效**,要驗。日期:2026-06-27。
|
||||
|
||||
---
|
||||
|
||||
## 快速檢查清單(做新功能前)
|
||||
|
||||
- [ ] 這是工作流還是零件?問「有必要嗎?」
|
||||
@@ -400,3 +458,6 @@ listEntries 加 `source` filter 即可,表結構一個欄位都不動。
|
||||
- [ ] 碰 KBDB?確認打基本盤現存 route(/entries /templates /records /entries/search),別假設 v3 /blocks /search 還在
|
||||
- [ ] KBDB 要新可查欄位?用 json_extract 查 metadata_json,別加真欄(表不變鐵律,#18)
|
||||
- [ ] 改 deploy.ts toml 注入順序?想「後面還有沒有 pass(如 strip)會動同一段」(#17)
|
||||
- [ ] 新增/改薄殼工具?grep 確認它打的 server route 真的存在,別假綠(#19);宣稱對齊前跑 thin-shell-smoke.sh
|
||||
- [ ] 想用 git log 查某檔歷史?先 git check-ignore——gitignored 檔無 git 史,時間靠檔內註記(#20)
|
||||
- [ ] 改 wrangler.toml service binding?用 [[services]] 不用 inline services=[...]([vars] 後會被吸走);改完 wrangler --dry-run 驗 binding 真在(#21)
|
||||
|
||||
@@ -21,7 +21,10 @@ metadata:
|
||||
> - ✅ **已實作 tsc 全綠**:1.1 `/webhooks/named` 強制 description|2.2+Q4 KBDB base 通用 entry_type filter(改4處:searchEntries/semanticSearch/route/proxy)|2.1 部署雙寫 embeddable entry(注意 KBDB 用 metadata_json 字串)|3.1 cypher `/workflows/search`|3.2 MCP `u6u_search_workflows`|4.1 `/workflows/backfill-search-entries`|1.3b `GET /webhooks/named` 補 description/created_at 欄位。
|
||||
> - ⏸ **卡待總管定**:Phase 1.2/1.3(MCP deploy 改打 /webhooks/named)卡在 ①-a/b/c——實作期發現 /webhooks/named 吃 graph 非 YAML,YAML→graph 編排寫在 CLI push.ts 介面層,MCP 複製=違 rule 07。①-c(先通債另開 issue)我推薦,待總管定。
|
||||
> - **完成標準**:tsc 綠≠完成,框架級待 leo21c 端到端實證(強制填擋空/搜尋命中/租戶隔離/降級 hint/MCP 不再 404)。issue open。
|
||||
> - **未 commit**(待 leo 明示;wiki 骨架與 #8 SDD/code 建議分兩 commit)。署名鐵律:跨 repo comment 開頭 `[arcrun CC]`(#12)。
|
||||
> - **已 merge 進 main**(fast-forward,3 commit:934b926 #4/5/7/8 功能 / 558e80b wiki-init / 5d38b59 #11 薄殼對齊)+ push origin main。repo Actions `enabled:false`(leo 關),merge **未觸發部署**。**merge≠部署**:#5/#7/#8/#11 端到端待 leo21c wrangler 直推(Mira 線)。
|
||||
> - **issue #11 全做**(P0 run 死端點/P1 list 同源/R4 防複發機制:對照清單+smoke+自驗能攔);P2 validate 依賴 #10、tag resource_id 語意債待方向①。
|
||||
> - **issue #12(self-hosted MCP 缺 CYPHER_EXECUTOR binding)✅ 修+merge main(commit 222a382)**:根因非總管假設的 strip 誤清,是 **TOML 坑——mcp toml `services=[...]` inline 在 [vars] 後被吸成 vars.services**(binding 消失);修法 inline→`[[services]]` array-of-tables(對齊官方 cypher)。wrangler --dry-run 雙向驗(修法後 binding 在/舊版變 env.services)。**端到端待 leo21c acr update 重部 MCP**;連帶解鎖 #11 self-hosted MCP + mira #6 門鈴 + acr push。見 mistakes #21。
|
||||
> - 署名鐵律:跨 repo comment 開頭 `[arcrun CC]`(#12 issue 慣例,非本次 bug)。
|
||||
>
|
||||
> **2026-06-26 上個 session(issue #4/#5/#6/#7 一批)**:
|
||||
> - **#6**(base `PATCH /records/:id`):✅ updateRecord + route,三表 append-only 不破。tsc 綠,端到端待 leo21c。issue open。
|
||||
|
||||
Reference in New Issue
Block a user