feat(arcrun): implement arcrun MVP — open-source AI workflow engine
Phase 1-5 complete per .agents/specs/u6u-core-mvp/: **Phase 1 — Cherry-pick & cleanup** - Create arcrun/ from cypher-executor, credentials, builtins, registry - Remove 9 InkStone Service Bindings (KBDB, REGISTRY, CLINIC_*, AICEO, MINI_ME) - Rewrite component-loader: 3-layer (builtin → WASM_BUCKET R2 → error) - Remove autoPublishMissing.ts, proxy.ts (AICEO), execution-logger.ts (KBDB) - Clean all KV namespace IDs and InkStone internal URLs from config files **Phase 2 — contract.yaml completeness** - Add credentials_required to gmail, google_sheets, telegram, line_notify - Add config_example to all 21 components with annotated field descriptions **Phase 3 — Credential injection** - Add credential-injector.ts: AES-GCM decrypt from CREDENTIALS_KV - Integrate into GraphExecutor before WASM execution - Structured errors with repair instructions when credential missing **Phase 4 — CLI (acr)** - cli/package.json: arcrun package, bin: acr, deps: commander/js-yaml/chalk/ora - 8 commands: init, creds push, push, run, validate, parts, list, logs - Standard mode: writes directly to user's CF KV via CF REST API - acr init: interactive setup with arcrun.dev API Key registration **Phase 5 — Open source release prep** - README.md: 5-minute quickstart, component table, workflow YAML syntax - CONTRIBUTING.md: TinyGo dev env, component scaffolding, submission flow - Security audit: no InkStone internal URLs/IDs in committed files - .gitignore: exclude credentials.yaml, .wrangler, *.wasm https://claude.ai/code/session_01BnCdSLVH8tUed9VrrPavgT
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
// createCredential:儲存 credential(name + secret + type),加密後存入 KV
|
||||
import type { Context } from 'hono';
|
||||
import type { Bindings, CredentialRecord } from '../types';
|
||||
import { encrypt } from './crypto';
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || `cred-${Date.now()}`;
|
||||
}
|
||||
|
||||
export async function handleCreateCredential(c: Context<{ Bindings: Bindings }>) {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body) return c.json({ success: false, error: '無效的 JSON body' }, 400);
|
||||
|
||||
const { name, secret, type } = body;
|
||||
if (!name) return c.json({ success: false, error: 'name 必填' }, 400);
|
||||
if (!secret) return c.json({ success: false, error: 'secret 必填' }, 400);
|
||||
if (!type) return c.json({ success: false, error: 'type 必填(例: api_key, bearer_token, google_oauth)' }, 400);
|
||||
|
||||
// 使用 ENCRYPTION_KEY secret;若未設定,使用 fallback(開發環境)
|
||||
const hexKey = c.env.ENCRYPTION_KEY || '0'.repeat(64);
|
||||
|
||||
const { encrypted, iv } = await encrypt(String(secret), hexKey);
|
||||
const id = slugify(String(name));
|
||||
|
||||
const record: CredentialRecord = {
|
||||
id,
|
||||
name: String(name),
|
||||
type: String(type),
|
||||
encrypted_secret: encrypted,
|
||||
iv,
|
||||
created_at: Date.now(),
|
||||
};
|
||||
|
||||
await c.env.CREDENTIALS_KV.put(`cred:${id}`, JSON.stringify(record));
|
||||
|
||||
return c.json({ success: true, data: { id, name: record.name, type: record.type } }, 201);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// crypto:AES-GCM 加解密工具(Web Crypto API)
|
||||
|
||||
/** 從 hex 字串匯入 AES-GCM key */
|
||||
async function importKey(hexKey: string): Promise<CryptoKey> {
|
||||
const raw = new Uint8Array(hexKey.match(/.{1,2}/g)!.map(b => parseInt(b, 16)));
|
||||
return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
/** 加密 plaintext,回傳 { encrypted, iv }(均為 base64) */
|
||||
export async function encrypt(plaintext: string, hexKey: string): Promise<{ encrypted: string; iv: string }> {
|
||||
const key = await importKey(hexKey);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encoded = new TextEncoder().encode(plaintext);
|
||||
const cipherBuf = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded);
|
||||
return {
|
||||
encrypted: btoa(String.fromCharCode(...new Uint8Array(cipherBuf))),
|
||||
iv: btoa(String.fromCharCode(...iv)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 解密,回傳 plaintext */
|
||||
export async function decrypt(encrypted: string, iv: string, hexKey: string): Promise<string> {
|
||||
const key = await importKey(hexKey);
|
||||
const ivBuf = Uint8Array.from(atob(iv), c => c.charCodeAt(0));
|
||||
const cipherBuf = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0));
|
||||
const plainBuf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: ivBuf }, key, cipherBuf);
|
||||
return new TextDecoder().decode(plainBuf);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// deleteCredential:刪除指定 credential(by name or id)
|
||||
import type { Context } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export async function handleDeleteCredential(c: Context<{ Bindings: Bindings }>) {
|
||||
const name = c.req.param('name');
|
||||
const key = `cred:${name}`;
|
||||
|
||||
const existing = await c.env.CREDENTIALS_KV.get(key);
|
||||
if (!existing) {
|
||||
return c.json({ success: false, error: `找不到 credential: ${name}` }, 404);
|
||||
}
|
||||
|
||||
await c.env.CREDENTIALS_KV.delete(key);
|
||||
return c.json({ success: true, data: { deleted: name } });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// getCredentialSecret:解密並回傳 secret(內部使用,Cypher Executor inject 用)
|
||||
// 此端點只接受內部呼叫(需 Authorization: Bearer <INTERNAL_TOKEN>)
|
||||
import type { Context } from 'hono';
|
||||
import type { Bindings, CredentialRecord } from '../types';
|
||||
import { decrypt } from './crypto';
|
||||
|
||||
export async function handleGetSecret(c: Context<{ Bindings: Bindings }>) {
|
||||
const name = c.req.param('name');
|
||||
const raw = await c.env.CREDENTIALS_KV.get(`cred:${name}`);
|
||||
if (!raw) return c.json({ success: false, error: `找不到 credential: ${name}` }, 404);
|
||||
|
||||
const record = JSON.parse(raw) as CredentialRecord;
|
||||
const hexKey = c.env.ENCRYPTION_KEY || '0'.repeat(64);
|
||||
|
||||
try {
|
||||
const secret = await decrypt(record.encrypted_secret, record.iv, hexKey);
|
||||
return c.json({ success: true, data: { id: record.id, type: record.type, secret } });
|
||||
} catch {
|
||||
return c.json({ success: false, error: '解密失敗,請確認 ENCRYPTION_KEY 是否正確' }, 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// listCredentials:列出所有 credential(只回傳 id/name/type,不含 secret)
|
||||
import type { Context } from 'hono';
|
||||
import type { Bindings, CredentialRecord, CredentialSummary } from '../types';
|
||||
|
||||
export async function handleListCredentials(c: Context<{ Bindings: Bindings }>) {
|
||||
const { keys } = await c.env.CREDENTIALS_KV.list({ prefix: 'cred:' });
|
||||
|
||||
const summaries: CredentialSummary[] = [];
|
||||
for (const key of keys) {
|
||||
const raw = await c.env.CREDENTIALS_KV.get(key.name);
|
||||
if (!raw) continue;
|
||||
const record = JSON.parse(raw) as CredentialRecord;
|
||||
summaries.push({ id: record.id, name: record.name, type: record.type, created_at: record.created_at });
|
||||
}
|
||||
|
||||
summaries.sort((a, b) => b.created_at - a.created_at);
|
||||
return c.json({ success: true, data: { credentials: summaries, count: summaries.length } });
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// u6u-credentials Worker — Credential 儲存與注入
|
||||
// index.ts 只做路由宣告,業務邏輯在 actions/(INV Layer 1)
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import type { Bindings } from './types';
|
||||
import { handleCreateCredential } from './actions/createCredential';
|
||||
import { handleListCredentials } from './actions/listCredentials';
|
||||
import { handleDeleteCredential } from './actions/deleteCredential';
|
||||
import { handleGetSecret } from './actions/getCredentialSecret';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
app.use('*', cors());
|
||||
|
||||
// Health check
|
||||
app.get('/', c => c.json({ service: 'u6u-credentials', version: '1.0.0', status: 'ok' }));
|
||||
|
||||
// POST /credentials — 建立 credential(加密存入 KV)
|
||||
// GET /credentials — 列出所有 credential(不含 secret)
|
||||
// DELETE /credentials/:name — 刪除 credential
|
||||
// GET /credentials/:name/secret — 取得解密 secret(Cypher Executor inject 用)
|
||||
app.post ('/credentials', handleCreateCredential);
|
||||
app.get ('/credentials', handleListCredentials);
|
||||
app.delete('/credentials/:name', handleDeleteCredential);
|
||||
app.get ('/credentials/:name/secret', handleGetSecret);
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,24 @@
|
||||
// u6u-credentials Worker 型別定義
|
||||
|
||||
export type Bindings = {
|
||||
CREDENTIALS_KV: KVNamespace;
|
||||
ENCRYPTION_KEY: string; // hex-encoded 256-bit AES key(wrangler secret)
|
||||
ENVIRONMENT: string;
|
||||
};
|
||||
|
||||
export interface CredentialRecord {
|
||||
id: string; // 用 name slugify 生成
|
||||
name: string; // 用戶命名(human-readable)
|
||||
type: string; // api_key / bearer_token / google_oauth / telegram_bot_token / ...
|
||||
encrypted_secret: string; // AES-GCM base64 encrypted
|
||||
iv: string; // base64 IV
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
// 對外回傳(不含 secret)
|
||||
export interface CredentialSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
created_at: number;
|
||||
}
|
||||
Reference in New Issue
Block a user