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:
Claude
2026-04-16 04:06:25 +00:00
commit 2707fca32b
155 changed files with 17413 additions and 0 deletions
+2805
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "arcrun-credentials",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"hono": "^4.7.0"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.14.2",
"@cloudflare/workers-types": "^4.20250219.0",
"fast-check": "^4.6.0",
"typescript": "^5.7.0",
"vitest": "^4.1.4"
}
}
+1648
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
// createCredential:儲存 credentialname + 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);
}
+28
View File
@@ -0,0 +1,28 @@
// cryptoAES-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:刪除指定 credentialby 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 } });
}
+26
View File
@@ -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 — 取得解密 secretCypher Executor inject 用)
app.post ('/credentials', handleCreateCredential);
app.get ('/credentials', handleListCredentials);
app.delete('/credentials/:name', handleDeleteCredential);
app.get ('/credentials/:name/secret', handleGetSecret);
export default app;
+24
View File
@@ -0,0 +1,24 @@
// u6u-credentials Worker 型別定義
export type Bindings = {
CREDENTIALS_KV: KVNamespace;
ENCRYPTION_KEY: string; // hex-encoded 256-bit AES keywrangler 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;
}
@@ -0,0 +1,79 @@
// Preservation Tests — AES-GCM Credential Round-Trip
// Task 2: 確認基線行為(修復前執行,預期通過)
//
// **Validates: Requirements 3.9**
import { SELF } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';
import * as fc from 'fast-check';
// ─────────────────────────────────────────────────────────────────────────────
// Property: Credential round-trip
//
// For all non-zero credential name/secret pairs,
// POST /credentials → GET /credentials/:name/secret returns the same secret.
// This validates AES-GCM encrypt → decrypt round-trip correctness.
//
// **Validates: Requirements 3.9**
// ─────────────────────────────────────────────────────────────────────────────
describe('Preservation: AES-GCM credential round-trip', () => {
it('property: POST /credentials then GET /credentials/:name/secret returns original secret', async () => {
// Generate non-empty name and secret pairs
const nameArb = fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0);
const secretArb = fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.length > 0);
await fc.assert(
fc.asyncProperty(nameArb, secretArb, async (name, secret) => {
// Use a unique suffix to avoid collisions between runs
const uniqueName = `${name}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
// POST /credentials — store encrypted credential
const createRes = await SELF.fetch('http://localhost/credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: uniqueName, secret, type: 'api_key' }),
});
expect(createRes.status).toBe(201);
const created = await createRes.json() as Record<string, unknown>;
expect(created.success).toBe(true);
const credId = (created.data as Record<string, unknown>).id as string;
// GET /credentials/:name/secret — retrieve and decrypt
const getRes = await SELF.fetch(`http://localhost/credentials/${credId}/secret`);
expect(getRes.status).toBe(200);
const retrieved = await getRes.json() as Record<string, unknown>;
expect(retrieved.success).toBe(true);
// The decrypted secret must equal the original
const retrievedSecret = (retrieved.data as Record<string, unknown>).secret as string;
expect(retrievedSecret).toBe(secret);
}),
{ numRuns: 5 }
);
});
it('example: specific name/secret round-trip preserves secret', async () => {
const name = 'preservation-test-key';
const secret = 'my-super-secret-api-key-12345';
const createRes = await SELF.fetch('http://localhost/credentials', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, secret, type: 'bearer_token' }),
});
expect(createRes.status).toBe(201);
const created = await createRes.json() as Record<string, unknown>;
const credId = (created.data as Record<string, unknown>).id as string;
const getRes = await SELF.fetch(`http://localhost/credentials/${credId}/secret`);
expect(getRes.status).toBe(200);
const retrieved = await getRes.json() as Record<string, unknown>;
expect((retrieved.data as Record<string, unknown>).secret).toBe(secret);
});
});
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true
},
"include": ["src/**/*"]
}
+10
View File
@@ -0,0 +1,10 @@
import { cloudflareTest } from '@cloudflare/vitest-pool-workers';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [
cloudflareTest({
wrangler: { configPath: './wrangler.toml' },
}),
],
});
+14
View File
@@ -0,0 +1,14 @@
name = "arcrun-credentials"
main = "src/index.ts"
compatibility_date = "2025-02-19"
compatibility_flags = ["nodejs_compat"]
workers_dev = true
# KV Namespace:加密 credential 儲存
[[kv_namespaces]]
binding = "CREDENTIALS_KV"
id = "" # 填入你的 KV Namespace ID(執行 wrangler kv namespace create CREDENTIALS_KV
[vars]
ENVIRONMENT = "production"
# ENCRYPTION_KEY: 256-bit AES keyhex),透過 wrangler secret set ENCRYPTION_KEY 設定