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:
Generated
+2805
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1648
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["@cloudflare/workers-types"],
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -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' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -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 key(hex),透過 wrangler secret set ENCRYPTION_KEY 設定
|
||||
Reference in New Issue
Block a user