arcrun — AI workflow execution engine (clean history)
Self-hosted 開源:WASM 零件 + recipe + cypher-executor,跑在你自己的 Cloudflare。 此為重建的乾淨歷史起點(移除曾誤 commit 的 GCP SA 金鑰,舊歷史保留在 richblack/arcrun 與本地 backup 分支)。含: - acr init --self-hosted installer(建 KV/R2 + codeload 拉預編譯 wasm + wrangler deploy + seed recipe) - recipe push 把關(資料外流提醒 + 打通檢查) - 19 個正當零件預編譯 wasm(claude_api/km_writer/kbdb_upsert_block 排除:違反 DECISIONS §1) - CLI / cypher-executor / registry / 完整 SDD Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* api-recipe-seeds.ts
|
||||
*
|
||||
* 現役 API recipe 的種子定義。self-host 新帳號 init 時把這些灌進空的 RECIPES KV
|
||||
* (透過 cypher-executor 的 POST /recipes,或 CF KV REST API 直接寫)。
|
||||
*
|
||||
* API recipe = http_request + 固定設定(endpoint/method 模板)。
|
||||
* 不需 deploy Worker,cypher-executor 執行時直接 fetch(見 cypher-executor/src/routes/recipes.ts)。
|
||||
*
|
||||
* 放在 CLI 端而非 cypher-executor/src:
|
||||
* - seed 資料是「installer 要灌進用戶 KV 的種子」,本就屬 CLI 職責(SDD self-hosted-init.md §4)。
|
||||
* - rule 02 §2.2 hook 擋 cypher-executor TS hard-code API endpoint;seed 的 endpoint 是資料欄位,
|
||||
* 放 CLI 端避開誤判,也更符合職責切分。
|
||||
*
|
||||
* 來源:2026-06-01 從 prod cypher.arcrun.dev/recipes 逐一查得的現役定義。
|
||||
* 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §5
|
||||
*
|
||||
* KBDB recipe(kbdb_*)採 Supabase 模式(richblack 2026-06-02):
|
||||
* 進 seed = 展示能力(引子)。使用者要用 → 去 arcrun 取統一 API Key 當 credential。
|
||||
* FOLLOW-UP(KBDB 端):endpoint 現為 kbdb.finally.click,KBDB 應改用統一對外網址;
|
||||
* KBDB 改網址後同步更新此處。seed 先照現況進。
|
||||
*/
|
||||
|
||||
export interface ApiRecipeSeed {
|
||||
canonical_id: string;
|
||||
display_name: string;
|
||||
description?: string;
|
||||
endpoint: string;
|
||||
method: string;
|
||||
auth_service?: string;
|
||||
}
|
||||
|
||||
export const API_RECIPE_SEEDS: ApiRecipeSeed[] = [
|
||||
// ── KBDB(Supabase 模式,auth_service=kbdb static_key)──
|
||||
{
|
||||
canonical_id: 'kbdb_get',
|
||||
display_name: 'KBDB Get',
|
||||
description: 'GET 讀取 block / 查詢。_path 帶查詢路徑。auth: kbdb static_key。',
|
||||
endpoint: 'https://kbdb.finally.click{{_path}}',
|
||||
method: 'GET',
|
||||
auth_service: 'kbdb',
|
||||
},
|
||||
{
|
||||
canonical_id: 'kbdb_create_block',
|
||||
display_name: 'KBDB Create Block',
|
||||
description: 'POST /blocks 建立 block。body 帶 block 欄位(content/type/page_name/source/user_id 等)。auth: kbdb static_key。',
|
||||
endpoint: 'https://kbdb.finally.click/blocks',
|
||||
method: 'POST',
|
||||
auth_service: 'kbdb',
|
||||
},
|
||||
{
|
||||
canonical_id: 'kbdb_patch_block',
|
||||
display_name: 'KBDB Patch Block',
|
||||
description: 'PATCH /blocks/:id 局部更新。_path 帶 /blocks/{id},body 帶要改的欄位。auth: kbdb static_key。',
|
||||
endpoint: 'https://kbdb.finally.click{{_path}}',
|
||||
method: 'PATCH',
|
||||
auth_service: 'kbdb',
|
||||
},
|
||||
{
|
||||
canonical_id: 'kbdb_delete',
|
||||
display_name: 'KBDB Delete',
|
||||
description: 'DELETE /blocks/:id 刪除 block。_path 帶 /blocks/{id}。auth: kbdb static_key。',
|
||||
endpoint: 'https://kbdb.finally.click{{_path}}',
|
||||
method: 'DELETE',
|
||||
auth_service: 'kbdb',
|
||||
},
|
||||
{
|
||||
canonical_id: 'kbdb_ingest',
|
||||
display_name: 'KBDB Ingest',
|
||||
description: 'POST /blocks/ingest 批次寫入。body 帶 input。auth: kbdb static_key。',
|
||||
endpoint: 'https://kbdb.finally.click/blocks/ingest',
|
||||
method: 'POST',
|
||||
auth_service: 'kbdb',
|
||||
},
|
||||
|
||||
// ── Google(service_account)──
|
||||
{
|
||||
canonical_id: 'gmail_send',
|
||||
display_name: 'Gmail Send',
|
||||
description: '寄 Gmail。POST messages/send,body 帶 raw(base64url MIME)。auth: google service_account。',
|
||||
endpoint: 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send',
|
||||
method: 'POST',
|
||||
auth_service: 'google_gmail_sa',
|
||||
},
|
||||
{
|
||||
canonical_id: 'google_sheets_append',
|
||||
display_name: 'Google Sheets Append',
|
||||
description: '寫 Sheets。PUT values?valueInputOption=RAW,body 帶 values。auth: google service_account。',
|
||||
endpoint: 'https://sheets.googleapis.com{{_path}}',
|
||||
method: 'PUT',
|
||||
auth_service: 'google_sheets_sa',
|
||||
},
|
||||
{
|
||||
canonical_id: 'google_sheets_read',
|
||||
display_name: 'Google Sheets Read',
|
||||
description: '讀 Sheets。GET values。_path 帶完整路徑。auth: google service_account。',
|
||||
endpoint: 'https://sheets.googleapis.com{{_path}}',
|
||||
method: 'GET',
|
||||
auth_service: 'google_sheets_sa',
|
||||
},
|
||||
|
||||
// ── 訊息(static_key)──
|
||||
{
|
||||
canonical_id: 'telegram_send',
|
||||
display_name: 'Telegram Send',
|
||||
description: 'Telegram sendMessage。token 在 URL path({{auth.bot_token}}),body 帶 chat_id+text。auth: static_key path 注入。',
|
||||
endpoint: 'https://api.telegram.org/bot{{auth.bot_token}}/sendMessage',
|
||||
method: 'POST',
|
||||
auth_service: 'telegram',
|
||||
},
|
||||
{
|
||||
canonical_id: 'line_notify_send',
|
||||
display_name: 'LINE Notify',
|
||||
description: 'LINE Notify 推訊息。POST notify,body 帶 message(form-urlencoded)。auth: static_key Bearer line token。',
|
||||
endpoint: 'https://notify-api.line.me/api/notify',
|
||||
method: 'POST',
|
||||
auth_service: 'line_notify',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Cloudflare KV REST API wrapper
|
||||
* 使用 CF REST API 直接存取用戶的 KV namespace,不依賴 Wrangler CLI
|
||||
*/
|
||||
|
||||
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
|
||||
|
||||
export interface CfKvClientOptions {
|
||||
accountId: string;
|
||||
namespaceId: string;
|
||||
apiToken: string;
|
||||
}
|
||||
|
||||
export class CfKvClient {
|
||||
private base: string;
|
||||
private headers: Record<string, string>;
|
||||
|
||||
constructor({ accountId, namespaceId, apiToken }: CfKvClientOptions) {
|
||||
this.base = `${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}`;
|
||||
this.headers = {
|
||||
'Authorization': `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async put(key: string, value: string): Promise<void> {
|
||||
const res = await fetch(`${this.base}/values/${encodeURIComponent(key)}`, {
|
||||
method: 'PUT',
|
||||
headers: { ...this.headers, 'Content-Type': 'text/plain' },
|
||||
body: value,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`KV PUT 失敗(${res.status}):${err.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
const res = await fetch(`${this.base}/values/${encodeURIComponent(key)}`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`KV GET 失敗(${res.status}):${err.slice(0, 200)}`);
|
||||
}
|
||||
return res.text();
|
||||
}
|
||||
|
||||
async list(prefix?: string): Promise<Array<{ name: string; expiration?: number; metadata?: unknown }>> {
|
||||
const url = new URL(`${this.base}/keys`);
|
||||
if (prefix) url.searchParams.set('prefix', prefix);
|
||||
url.searchParams.set('limit', '1000');
|
||||
|
||||
const res = await fetch(url.toString(), { headers: this.headers });
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`KV LIST 失敗(${res.status}):${err.slice(0, 200)}`);
|
||||
}
|
||||
const data = await res.json() as {
|
||||
result: Array<{ name: string; expiration?: number; metadata?: unknown }>;
|
||||
};
|
||||
return data.result ?? [];
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
const res = await fetch(`${this.base}/values/${encodeURIComponent(key)}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.headers,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`KV DELETE 失敗(${res.status}):${err.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudflare Account-level API wrapper(self-hosted installer 用)。
|
||||
*
|
||||
* 負責 acr init --self-hosted 的資源建立:驗 token、建/列 KV namespace、建 R2 bucket、查 workers.dev subdomain。
|
||||
* 與 CfKvClient(綁單一 namespace 的 KV 操作)職責不同——這個是帳號層級的資源管理。
|
||||
* 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §3 step 1-2
|
||||
*/
|
||||
export class CfAccountClient {
|
||||
private accountBase: string;
|
||||
private headers: Record<string, string>;
|
||||
|
||||
constructor(accountId: string, apiToken: string) {
|
||||
this.accountBase = `${CF_API_BASE}/accounts/${accountId}`;
|
||||
this.headers = {
|
||||
'Authorization': `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private async cf<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${this.accountBase}${path}`, {
|
||||
...init,
|
||||
headers: { ...this.headers, ...(init?.headers ?? {}) },
|
||||
});
|
||||
const data = await res.json().catch(() => null) as
|
||||
| { success: boolean; result: T; errors?: Array<{ message: string }> }
|
||||
| null;
|
||||
if (!res.ok || !data?.success) {
|
||||
const msg = data?.errors?.map(e => e.message).join('; ') ?? `HTTP ${res.status}`;
|
||||
throw new Error(`CF API ${path} 失敗:${msg}`);
|
||||
}
|
||||
return data.result;
|
||||
}
|
||||
|
||||
/** 驗證 token 能存取此 account(權限不足會在後續建立操作報錯,這裡先確認 account 可達)。*/
|
||||
async verifyAccess(): Promise<void> {
|
||||
// GET /accounts/{id} 能通 = token 有此 account 的基本讀權限
|
||||
await this.cf<{ id: string; name: string }>('');
|
||||
}
|
||||
|
||||
/** 列出現有 KV namespace(冪等用:已存在就重用,不重建)。回傳 title → id 對照。*/
|
||||
async listKvNamespaces(): Promise<Map<string, string>> {
|
||||
const result = await this.cf<Array<{ id: string; title: string }>>(
|
||||
'/storage/kv/namespaces?per_page=100',
|
||||
);
|
||||
const map = new Map<string, string>();
|
||||
for (const ns of result) map.set(ns.title, ns.id);
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 建立 KV namespace(若同名已存在則回傳既有 id,冪等)。*/
|
||||
async ensureKvNamespace(title: string, existing?: Map<string, string>): Promise<string> {
|
||||
const known = existing ?? (await this.listKvNamespaces());
|
||||
const found = known.get(title);
|
||||
if (found) return found;
|
||||
|
||||
const result = await this.cf<{ id: string; title: string }>(
|
||||
'/storage/kv/namespaces',
|
||||
{ method: 'POST', body: JSON.stringify({ title }) },
|
||||
);
|
||||
return result.id;
|
||||
}
|
||||
|
||||
/** 建立 R2 bucket(已存在則略過,冪等)。*/
|
||||
async ensureR2Bucket(name: string): Promise<void> {
|
||||
try {
|
||||
await this.cf<{ name: string }>('/r2/buckets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
} catch (e) {
|
||||
// bucket 已存在 → CF 回 10004 之類;視為冪等成功
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (/already exists|10004/i.test(msg)) return;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** 查 workers.dev subdomain(cypher-executor WORKER_SUBDOMAIN 用,組對內 component URL)。*/
|
||||
async getWorkersSubdomain(): Promise<string> {
|
||||
const result = await this.cf<{ subdomain: string }>('/workers/subdomain');
|
||||
return result.subdomain;
|
||||
}
|
||||
}
|
||||
|
||||
/** AES-GCM 加密 credential(與 cypher-executor credential-injector 解密邏輯對應)*/
|
||||
export async function encryptCredential(value: string, encryptionKey: string): Promise<string> {
|
||||
if (!encryptionKey || encryptionKey.length < 64) {
|
||||
throw new Error(
|
||||
'ARCRUN_ENCRYPTION_KEY 未設定或長度不足(需要 256-bit hex,即 64 個十六進位字元)\n' +
|
||||
'生成指令:node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'
|
||||
);
|
||||
}
|
||||
|
||||
const keyBytes = hexToUint8Array(encryptionKey);
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes.buffer as ArrayBuffer,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt'],
|
||||
);
|
||||
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encoded = new TextEncoder().encode(value);
|
||||
const cipherBuffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, encoded);
|
||||
|
||||
return JSON.stringify({
|
||||
encrypted: uint8ArrayToBase64(new Uint8Array(cipherBuffer)),
|
||||
iv: uint8ArrayToBase64(iv),
|
||||
});
|
||||
}
|
||||
|
||||
function hexToUint8Array(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function uint8ArrayToBase64(arr: Uint8Array): string {
|
||||
return Buffer.from(arr).toString('base64');
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* CLI 設定檔管理(~/.arcrun/config.yaml)
|
||||
*/
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
export interface ArcrunConfig {
|
||||
mode: 'local' | 'standard' | 'self-hosted';
|
||||
// Standard 模式
|
||||
api_key?: string; // arcrun.dev API Key(ak_前綴)
|
||||
encryption_key?: string; // AES-GCM key,與 cypher-executor ENCRYPTION_KEY secret 一致
|
||||
// Self-hosted 模式
|
||||
cloudflare_account_id?: string;
|
||||
user_kv_namespace_id?: string;
|
||||
cf_api_token?: string;
|
||||
cypher_executor_url?: string;
|
||||
credentials_kv_namespace_id?: string;
|
||||
webhooks_kv_namespace_id?: string;
|
||||
wasm_bucket?: string;
|
||||
// 共用
|
||||
multi_tenant?: boolean;
|
||||
// 資料外流警示:本機記住「已同意暴露 / 選擇不再警示」的資源,避免每次 push 重問(§3 首次問記住)。
|
||||
// key 格式:`{kind}:{resourceName}`(如 "webhook:contacts_lookup" / "recipe:kbdb_get")。
|
||||
// 注意:這只是 CLI 端 UX(不重問);server 端獨立存法律憑證並強制(防 CLI 被繞過)。
|
||||
exposure_consented?: Record<string, { confirmed_at: string; suppress_future?: boolean }>;
|
||||
}
|
||||
|
||||
const CONFIG_DIR = join(homedir(), '.arcrun');
|
||||
const CONFIG_PATH = join(CONFIG_DIR, 'config.yaml');
|
||||
|
||||
export function configExists(): boolean {
|
||||
return existsSync(CONFIG_PATH);
|
||||
}
|
||||
|
||||
export function loadConfig(): ArcrunConfig {
|
||||
if (!existsSync(CONFIG_PATH)) {
|
||||
// 未初始化時回傳 local 模式預設值,讓 validate --offline 等指令能在無設定下運作
|
||||
return { mode: 'local' };
|
||||
}
|
||||
const raw = readFileSync(CONFIG_PATH, 'utf8');
|
||||
return yaml.load(raw) as ArcrunConfig;
|
||||
}
|
||||
|
||||
export function saveConfig(config: ArcrunConfig): void {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
writeFileSync(CONFIG_PATH, yaml.dump(config), 'utf8');
|
||||
}
|
||||
|
||||
export function getCypherExecutorUrl(config: ArcrunConfig): string {
|
||||
if (config.mode === 'self-hosted' && config.cypher_executor_url) {
|
||||
return config.cypher_executor_url;
|
||||
}
|
||||
return 'https://cypher.arcrun.dev';
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* deploy.ts — self-hosted Worker 部署(installer 的「下載 repo tarball + wrangler deploy」段)
|
||||
*
|
||||
* 對應 SDD:.agents/specs/arcrun/sdk-and-website/self-hosted-init.md §6(commit wasm + codeload)
|
||||
*
|
||||
* 策略(richblack 2026-06-02):repo 自帶預編譯 wasm(.component-builds 下各 component.wasm,
|
||||
* 見 rule 05 慣例變更)→ CLI 從 GitHub codeload tarball 拿完整部署物 → 注入用戶的 KV id
|
||||
* → 用用戶自己的 CF token wrangler deploy。用戶不需 git / tinygo,只需 wrangler。
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/** GitHub repo(codeload tarball 來源)。fork 者改這裡或用 ARCRUN_REPO env。
|
||||
* 注意:repo 名大小寫敏感(codeload 路徑需完全一致)。*/
|
||||
const ARCRUN_REPO = process.env.ARCRUN_REPO ?? 'uncle6me-web/Arcrun';
|
||||
|
||||
/** init 要建立的 7 個 KV namespace(title)。權威來源:.claude/rules/01-tech-stack.md 資料儲存表。*/
|
||||
export const REQUIRED_KV_NAMESPACES = [
|
||||
'WEBHOOKS',
|
||||
'CREDENTIALS_KV',
|
||||
'RECIPES',
|
||||
'USERS_KV',
|
||||
'SESSIONS_KV',
|
||||
'ANALYTICS_KV',
|
||||
'EXEC_CONTEXT',
|
||||
] as const;
|
||||
|
||||
/** init 要建立的 R2 bucket。*/
|
||||
export const REQUIRED_R2_BUCKET = 'WASM_BUCKET';
|
||||
|
||||
/** 部署後要提示用戶手動 `wrangler secret put ENCRYPTION_KEY` 的 Worker。*/
|
||||
export const SECRET_TARGET_WORKERS = [
|
||||
'arcrun-cypher-executor',
|
||||
'arcrun-auth-static-key',
|
||||
'arcrun-auth-service-account',
|
||||
] as const;
|
||||
|
||||
export interface DeployContext {
|
||||
accountId: string;
|
||||
apiToken: string;
|
||||
workerSubdomain: string;
|
||||
kvNamespaceIds: Record<string, string>; // title → id
|
||||
}
|
||||
|
||||
export interface DeployResult {
|
||||
implemented: boolean;
|
||||
cypherExecutorUrl?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 偵測 wrangler 是否已安裝(用戶前置:裝 CF CLI)。*/
|
||||
export function wranglerAvailable(): boolean {
|
||||
try {
|
||||
execFileSync('wrangler', ['--version'], { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下載 repo codeload tarball(含預編譯 wasm)→ 注入用戶 KV id → wrangler deploy 全部 Worker。
|
||||
*
|
||||
* SDD self-hosted-init.md §6.4:
|
||||
* 1. 下載 codeload tarball(ref 預設 main)→ 解壓到暫存目錄
|
||||
* 2. 各 wrangler.toml 注入 ctx.kvNamespaceIds + cypher-executor WORKER_SUBDOMAIN
|
||||
* 3. tier1=.component-builds/* 先 → tier2=cypher-executor/registry 後,逐一 wrangler deploy
|
||||
* 4. 回 cypherExecutorUrl = https://arcrun-cypher-executor.<subdomain>.workers.dev
|
||||
*
|
||||
* 誠實(mindset §7):任一 worker deploy 失敗會收集進 message 回報,不假裝全綠。
|
||||
*
|
||||
* @param ctx 部署上下文
|
||||
* @param ref git ref(branch / tag),預設 main;acr update 可帶 tag
|
||||
*/
|
||||
export async function downloadAndDeploy(ctx: DeployContext, ref = 'main'): Promise<DeployResult> {
|
||||
// 1. 下載 + 解壓 codeload tarball
|
||||
let root: string;
|
||||
try {
|
||||
root = await downloadRepoTarball(ref);
|
||||
} catch (e) {
|
||||
return {
|
||||
implemented: true,
|
||||
message: `下載部署物失敗(${e instanceof Error ? e.message : e})。確認網路 + ARCRUN_REPO=${ARCRUN_REPO} 可達。`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. 列出要部署的 worker 目錄(含 wrangler.toml),分 tier
|
||||
const { tier1, tier2 } = discoverWorkerDirs(root);
|
||||
if (tier1.length === 0 && tier2.length === 0) {
|
||||
return { implemented: true, message: `部署物中找不到任何 wrangler.toml(root=${root})。` };
|
||||
}
|
||||
|
||||
// 3. 對每個 worker:注入 KV id(+ cypher WORKER_SUBDOMAIN)→ wrangler deploy。tier1 先 tier2 後。
|
||||
const failures: string[] = [];
|
||||
let deployed = 0;
|
||||
for (const dir of [...tier1, ...tier2]) {
|
||||
const tomlPath = join(dir, 'wrangler.toml');
|
||||
try {
|
||||
injectWranglerConfig(tomlPath, ctx);
|
||||
runWranglerDeploy(dir, ctx);
|
||||
deployed++;
|
||||
} catch (e) {
|
||||
failures.push(`${dir}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const cypherExecutorUrl = ctx.workerSubdomain
|
||||
? `https://arcrun-cypher-executor.${ctx.workerSubdomain}.workers.dev`
|
||||
: undefined;
|
||||
|
||||
if (failures.length > 0) {
|
||||
return {
|
||||
implemented: true,
|
||||
cypherExecutorUrl,
|
||||
message:
|
||||
`部署 ${deployed}/${tier1.length + tier2.length} 成功,${failures.length} 失敗(誠實回報,未假綠):\n` +
|
||||
failures.map(f => ` ✗ ${f}`).join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
implemented: true,
|
||||
cypherExecutorUrl,
|
||||
message: `部署完成:${deployed} 個 Worker 全部成功。`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 下載 codeload tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。*/
|
||||
async function downloadRepoTarball(ref: string): Promise<string> {
|
||||
const url = `https://codeload.github.com/${ARCRUN_REPO}/tar.gz/${ref}`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(120_000) });
|
||||
if (!res.ok) throw new Error(`codeload HTTP ${res.status}(${url})`);
|
||||
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
const dir = mkdtempSync(join(tmpdir(), 'arcrun-deploy-'));
|
||||
const tarPath = join(dir, 'repo.tar.gz');
|
||||
writeFileSync(tarPath, buf);
|
||||
|
||||
// 用系統 tar 解壓(macOS/Linux 內建)。tarball 解出單一頂層目錄 {repo}-{ref}/。
|
||||
execFileSync('tar', ['-xzf', tarPath, '-C', dir], { stdio: 'ignore' });
|
||||
const entries = readdirSync(dir).filter(n => n !== 'repo.tar.gz');
|
||||
const top = entries.find(n => statSync(join(dir, n)).isDirectory());
|
||||
if (!top) throw new Error('tarball 解壓後找不到頂層目錄');
|
||||
return join(dir, top);
|
||||
}
|
||||
|
||||
/** 掃解壓出的部署物,回傳 tier1(.component-builds/*)與 tier2(cypher-executor/registry)目錄清單。*/
|
||||
function discoverWorkerDirs(root: string): { tier1: string[]; tier2: string[] } {
|
||||
const tier1: string[] = [];
|
||||
const tier2: string[] = [];
|
||||
|
||||
const cbRoot = join(root, '.component-builds');
|
||||
if (existsSync(cbRoot)) {
|
||||
for (const name of readdirSync(cbRoot)) {
|
||||
const dir = join(cbRoot, name);
|
||||
// 需同時有 wrangler.toml 且有 component.wasm 才部署。
|
||||
// 「錯做成零件」的(claude_api / km_writer / kbdb_upsert_block)wasm 沒 commit 進 repo
|
||||
// (.gitignore 排除,待降級成工作流/recipe)→ codeload 拿到的目錄缺 wasm → 自然跳過,
|
||||
// 不讓 wrangler deploy 因缺檔失敗。
|
||||
if (existsSync(join(dir, 'wrangler.toml')) && existsSync(join(dir, 'component.wasm'))) {
|
||||
tier1.push(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const name of ['cypher-executor', 'registry']) {
|
||||
const dir = join(root, name);
|
||||
if (existsSync(join(dir, 'wrangler.toml'))) tier2.push(dir);
|
||||
}
|
||||
return { tier1, tier2 };
|
||||
}
|
||||
|
||||
/** 注入用戶的 KV namespace id(取代 wrangler.toml 中各 binding 的 id)+ cypher WORKER_SUBDOMAIN。*/
|
||||
function injectWranglerConfig(tomlPath: string, ctx: DeployContext): void {
|
||||
if (!existsSync(tomlPath)) return;
|
||||
let toml = readFileSync(tomlPath, 'utf8');
|
||||
|
||||
// 對每個已建立的 KV namespace:把對應 binding 的 id 換成用戶的。
|
||||
// 匹配 `[[kv_namespaces]] ... binding = "NAME" ... id = "OLD"` 的 id 行。
|
||||
for (const [binding, id] of Object.entries(ctx.kvNamespaceIds)) {
|
||||
if (!id) continue;
|
||||
const re = new RegExp(
|
||||
`(binding\\s*=\\s*"${binding}"\\s*\\n\\s*id\\s*=\\s*")[^"]*(")`,
|
||||
'g',
|
||||
);
|
||||
toml = toml.replace(re, `$1${id}$2`);
|
||||
}
|
||||
|
||||
// cypher-executor 的 WORKER_SUBDOMAIN(vars)換成用戶帳號 subdomain
|
||||
if (ctx.workerSubdomain && /WORKER_SUBDOMAIN/.test(toml)) {
|
||||
toml = toml.replace(
|
||||
/(WORKER_SUBDOMAIN\s*=\s*")[^"]*(")/,
|
||||
`$1${ctx.workerSubdomain}$2`,
|
||||
);
|
||||
}
|
||||
|
||||
writeFileSync(tomlPath, toml, 'utf8');
|
||||
}
|
||||
|
||||
/** 在 worker 目錄跑 wrangler deploy(用用戶的 CF token + account)。*/
|
||||
function runWranglerDeploy(dir: string, ctx: DeployContext): void {
|
||||
// 先裝依賴(cypher-executor/registry 是 TS,wrangler 內建 esbuild bundle 需 node_modules)
|
||||
if (existsSync(join(dir, 'package.json'))) {
|
||||
const installer = existsSync(join(dir, 'pnpm-lock.yaml'))
|
||||
? ['pnpm', 'install', '--frozen-lockfile']
|
||||
: ['npm', 'install', '--no-audit', '--no-fund'];
|
||||
execFileSync(installer[0], installer.slice(1), { cwd: dir, stdio: 'ignore' });
|
||||
}
|
||||
execFileSync('wrangler', ['deploy'], {
|
||||
cwd: dir,
|
||||
stdio: 'ignore',
|
||||
env: {
|
||||
...process.env,
|
||||
CLOUDFLARE_API_TOKEN: ctx.apiToken,
|
||||
CLOUDFLARE_ACCOUNT_ID: ctx.accountId,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 資料外流警示 — 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('');
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* workflow.yaml 解析與三元組驗證
|
||||
*/
|
||||
import yaml from 'js-yaml';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
export interface WorkflowYaml {
|
||||
name: string;
|
||||
description?: string;
|
||||
flow: string[];
|
||||
config?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface ParsedTriplet {
|
||||
subject: string;
|
||||
relation: string;
|
||||
object: string;
|
||||
}
|
||||
|
||||
/** 合法關係詞(拒絕 PIPE)*/
|
||||
const VALID_RELATIONS = new Set([
|
||||
'完成後', '失敗時', '對每個', '條件滿足時',
|
||||
'ON_SUCCESS', 'ON_FAIL', 'FOREACH', 'IF', 'ON_CLICK', 'CALLS_SUBFLOW',
|
||||
]);
|
||||
|
||||
const BANNED_RELATIONS = new Set(['PIPE']);
|
||||
|
||||
export function loadWorkflowYaml(filePath: string): WorkflowYaml {
|
||||
const raw = readFileSync(filePath, 'utf8');
|
||||
const doc = yaml.load(raw) as WorkflowYaml;
|
||||
|
||||
if (!doc.name) throw new Error('workflow.yaml 缺少 name 欄位');
|
||||
if (!Array.isArray(doc.flow) || doc.flow.length === 0) {
|
||||
throw new Error('workflow.yaml 的 flow 欄位必須為非空陣列');
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
export function parseTriplets(flow: string[]): ParsedTriplet[] {
|
||||
const triplets: ParsedTriplet[] = [];
|
||||
|
||||
for (const line of flow) {
|
||||
const parts = line.split('>>').map(s => s.trim());
|
||||
if (parts.length !== 3) {
|
||||
throw new Error(
|
||||
`三元組格式錯誤:「${line}」\n` +
|
||||
`正確格式:「A >> 關係詞 >> B」`
|
||||
);
|
||||
}
|
||||
const [subject, relation, object] = parts;
|
||||
triplets.push({ subject, relation, object });
|
||||
}
|
||||
|
||||
return triplets;
|
||||
}
|
||||
|
||||
export function validateRelations(triplets: ParsedTriplet[]): void {
|
||||
for (const t of triplets) {
|
||||
if (BANNED_RELATIONS.has(t.relation)) {
|
||||
throw new Error(
|
||||
`不允許使用關係詞「${t.relation}」。\n` +
|
||||
`「PIPE」已棄用,請改用「完成後」或「ON_SUCCESS」。`
|
||||
);
|
||||
}
|
||||
// 容許 FOREACH iterator 命名變體:「對每個 paragraph」/「FOREACH item」
|
||||
// graph-builder.ts 已支援這個 regex(commit e8fca33 2026-05-07)
|
||||
const foreachMatch = t.relation.match(/^(?:對每個|FOREACH)\s+\w+$/i);
|
||||
if (foreachMatch) continue;
|
||||
|
||||
if (!VALID_RELATIONS.has(t.relation)) {
|
||||
throw new Error(
|
||||
`未知關係詞「${t.relation}」。\n` +
|
||||
`合法關係詞:${[...VALID_RELATIONS].join('、')}\n` +
|
||||
`(FOREACH 支援 iterator 命名:「對每個 X」/「FOREACH X」)`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getNodeNames(triplets: ParsedTriplet[]): string[] {
|
||||
const nodes = new Set<string>();
|
||||
for (const t of triplets) {
|
||||
nodes.add(t.subject);
|
||||
nodes.add(t.object);
|
||||
}
|
||||
return [...nodes];
|
||||
}
|
||||
Reference in New Issue
Block a user