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,115 @@
|
||||
/**
|
||||
* 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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** AES-GCM 加密 credential(與 cypher-executor credential-injector 解密邏輯對應)*/
|
||||
export async function encryptCredential(value: string, encryptionKey: string): Promise<string> {
|
||||
// 若沒有設定 encryption key,使用 base64 作為 fallback(dev 模式)
|
||||
if (!encryptionKey || encryptionKey.length < 32) {
|
||||
const b64 = Buffer.from(value).toString('base64');
|
||||
return JSON.stringify({ encrypted: b64, iv: 'dev-mode', mode: 'base64' });
|
||||
}
|
||||
|
||||
const keyBytes = hexToUint8Array(encryptionKey);
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ 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');
|
||||
}
|
||||
Reference in New Issue
Block a user