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,92 @@
|
||||
// G1 假零件偵測(component-gatekeeping SDD,R2)
|
||||
//
|
||||
// 判準(DECISIONS §1):零件若滿足任一,是假零件,該降級成 recipe / 工作流:
|
||||
// (a) contract 或 wasm binary 出現具體外部服務 URL / domain
|
||||
// (b) 宣告能力是 http_request 子集(打某固定 endpoint)
|
||||
//
|
||||
// Q2 決議(richblack 2026-05-29):兩者都「硬擋」(不是只 warn)。
|
||||
// 理由:零件不該連外,連外即 recipe。這兩個 pattern 都是「該是 recipe 的東西偽裝成零件」。
|
||||
//
|
||||
// 排除:auth_* primitive(credential 後端,DECISIONS §3b 不適用假零件判準)、http_request 自己。
|
||||
|
||||
import type { ComponentContract } from '../types';
|
||||
|
||||
// auth primitive 與 http_request 不適用假零件判準
|
||||
const EXEMPT_IDS = new Set([
|
||||
'http_request',
|
||||
'auth_static_key',
|
||||
'auth_oauth2',
|
||||
'auth_service_account',
|
||||
'auth_mtls',
|
||||
]);
|
||||
|
||||
// 外部 URL / domain pattern。
|
||||
// - 明確的 scheme://host
|
||||
// - 裸 domain(api.foo.com / foo.googleapis.com 之類)
|
||||
const URL_SCHEME_RE = /\bhttps?:\/\/[^\s"'`)]+/i;
|
||||
// 裸 domain:至少 host.tld,排除過於通用的誤判(如 stdin.json)——要求含常見 TLD 或 .xxx.yyy 多段
|
||||
const BARE_DOMAIN_RE = /\b[a-z0-9][a-z0-9-]*(\.[a-z0-9-]+)*\.(com|org|net|dev|io|me|click|app|co|googleapis\.com|telegram\.org)\b/i;
|
||||
|
||||
/**
|
||||
* 把 contract 的文字欄位攤平成一個字串,供 URL 掃描。
|
||||
* 掃 description / display_name / input_schema / output_schema / tags / aliases。
|
||||
*/
|
||||
function flattenContractText(contract: ComponentContract): string {
|
||||
const parts: string[] = [
|
||||
contract.display_name ?? '',
|
||||
contract.description ?? '',
|
||||
JSON.stringify(contract.input_schema ?? {}),
|
||||
JSON.stringify(contract.output_schema ?? {}),
|
||||
(contract.tags ?? []).join(' '),
|
||||
(contract.aliases ?? []).join(' '),
|
||||
];
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 偵測投稿零件是否為假零件。
|
||||
* 回 null = 通過;回字串 = 退稿原因(已含指回正路的訊息)。
|
||||
*/
|
||||
export function detectFakeComponent(
|
||||
contract: ComponentContract,
|
||||
wasmBytes: Uint8Array,
|
||||
): string | null {
|
||||
if (EXEMPT_IDS.has(contract.canonical_id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pointToRecipe =
|
||||
'。這該是 API recipe(http_request + 固定設定)或工作流,不是零件。' +
|
||||
'零件 = 封閉邏輯(流程控制 / 資料處理),不連外部服務。見 DECISIONS §1。';
|
||||
|
||||
// (a) contract 文字含外部 URL / domain
|
||||
const contractText = flattenContractText(contract);
|
||||
const schemeHit = contractText.match(URL_SCHEME_RE);
|
||||
if (schemeHit) {
|
||||
return `偵測到 contract 含外部 URL:${schemeHit[0].slice(0, 80)}${pointToRecipe}`;
|
||||
}
|
||||
const domainHit = contractText.match(BARE_DOMAIN_RE);
|
||||
if (domainHit) {
|
||||
return `偵測到 contract 含外部 domain:${domainHit[0]}${pointToRecipe}`;
|
||||
}
|
||||
|
||||
// (a') wasm binary 文字含外部 URL / domain(零件原碼把 endpoint 編進去)
|
||||
// 只掃可印 ASCII 區段以降低誤判;wasm 字串以 UTF-8 存放。
|
||||
const wasmText = new TextDecoder('utf-8').decode(wasmBytes);
|
||||
const wasmSchemeHit = wasmText.match(URL_SCHEME_RE);
|
||||
if (wasmSchemeHit) {
|
||||
return `偵測到 wasm 內嵌外部 URL:${wasmSchemeHit[0].slice(0, 80)}${pointToRecipe}`;
|
||||
}
|
||||
|
||||
// (b) http_request 子集:零件宣告自己只是「打某 API/endpoint」
|
||||
// heuristic:description 描述「打/呼叫 ... API/endpoint」+ input 有 url-like 欄位
|
||||
const desc = (contract.description ?? '') + ' ' + contract.display_name;
|
||||
const inputKeys = Object.keys(contract.input_schema ?? {}).join(' ').toLowerCase();
|
||||
const hasUrlField = /\b(url|endpoint|api_url|base_url|host)\b/.test(inputKeys);
|
||||
const describesHttpCall = /(打|呼叫|call|fetch|request|POST|GET|PUT|DELETE)\s*.*(api|endpoint|url|https?)/i.test(desc);
|
||||
if (hasUrlField && describesHttpCall) {
|
||||
return `偵測到疑似 http_request 子集(input 有 url 欄位 + 描述為打 API)${pointToRecipe}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// ensureTemplate — 確保 SUBMISSIONS_KV 可正常存取(健康檢查用)
|
||||
// Requirements: 12.1
|
||||
//
|
||||
// 原本此模組負責在 KBDB 建立 tpl-component Template Block。
|
||||
// 已改為 SUBMISSIONS_KV 模式後,不再需要預建 Template。
|
||||
// 此函式改為驗證 KV binding 是否正常,供 /init 端點呼叫。
|
||||
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export async function ensureTemplate(env: Bindings): Promise<{ created: boolean; template_id: string }> {
|
||||
// 寫入並讀取一個測試 key,確認 KV binding 正常
|
||||
const testKey = '_init_health_check';
|
||||
await env.SUBMISSIONS_KV.put(testKey, '1', { expirationTtl: 60 });
|
||||
const val = await env.SUBMISSIONS_KV.get(testKey);
|
||||
|
||||
if (val !== '1') {
|
||||
throw new Error('SUBMISSIONS_KV binding 異常:寫入後無法讀取');
|
||||
}
|
||||
|
||||
return { created: true, template_id: 'submissions_kv' };
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// 回傳 Markdown 格式開發指引
|
||||
// Requirements: 11.1, 11.2, 11.3
|
||||
|
||||
export function getGuide(): string {
|
||||
return `# u6u Component Authoring Guide
|
||||
|
||||
## 概覽
|
||||
|
||||
u6u 零件是以 WASI preview1 格式編譯的 WebAssembly 模組,唯一合法的 I/O 模型是 **stdin/stdout JSON**。
|
||||
|
||||
---
|
||||
|
||||
## TinyGo 白名單 {#tinygo-whitelist}
|
||||
|
||||
第一波內部零件使用 TinyGo 開發。**只允許**以下 import:
|
||||
|
||||
\`\`\`go
|
||||
import (
|
||||
"os" // 只用 os.Stdin / os.Stdout / os.Stderr
|
||||
"io" // io.ReadAll(os.Stdin)
|
||||
"encoding/json" // json.Unmarshal / json.Marshal
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
### 允許的操作
|
||||
|
||||
- \`io.ReadAll(os.Stdin)\` 讀取 input JSON
|
||||
- \`json.Unmarshal\` 解析 input
|
||||
- \`json.Marshal\` + \`os.Stdout.Write\` 輸出 output JSON
|
||||
- 基本型別操作(string、int64、float64、bool、[]byte、map[string]interface{})
|
||||
- 錯誤用 stdout 輸出:\`{"error": "說明"}\`,不要 panic
|
||||
|
||||
---
|
||||
|
||||
## 禁止行為 {#forbidden-behaviors}
|
||||
|
||||
以下行為會導致沙盒驗收失敗:
|
||||
|
||||
- **網路 syscall**:\`net/*\`、\`sock_connect\`、\`sock_accept\` 等
|
||||
- **檔案系統 syscall**:\`os.Open\`、\`os.Create\`、\`path_open\` 等
|
||||
- **goroutine / channel**:WASM 環境不支援
|
||||
- **syscall.*\`**:任何直接 syscall
|
||||
- **第三方 module**:只用標準庫
|
||||
- **打包 runtime**:不得打包 QuickJS、Node.js 等
|
||||
- **超過 2MB**:體積上限 2048KB
|
||||
- **混合前後端邏輯**:一個零件只做一件事
|
||||
|
||||
---
|
||||
|
||||
## I/O 模型 {#io-model}
|
||||
|
||||
唯一合法的 I/O 模型:\`stdin_stdout_json\`
|
||||
|
||||
\`\`\`
|
||||
stdin → JSON.stringify(input) → 零件讀取
|
||||
stdout ← JSON.stringify(output) ← 零件輸出
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## TinyGo 最小可運行範例 {#tinygo-example}
|
||||
|
||||
\`\`\`go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Input struct {
|
||||
JsonString string \`json:"json_string"\`
|
||||
}
|
||||
|
||||
type Output struct {
|
||||
Valid bool \`json:"valid"\`
|
||||
Error string \`json:"error,omitempty"\`
|
||||
}
|
||||
|
||||
func main() {
|
||||
data, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
writeError("讀取 stdin 失敗: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var input Input
|
||||
if err := json.Unmarshal(data, &input); err != nil {
|
||||
writeError("解析 input JSON 失敗: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var result interface{}
|
||||
valid := json.Unmarshal([]byte(input.JsonString), &result) == nil
|
||||
|
||||
out := Output{Valid: valid}
|
||||
if !valid {
|
||||
out.Error = "不合法的 JSON 格式"
|
||||
}
|
||||
|
||||
outBytes, _ := json.Marshal(out)
|
||||
os.Stdout.Write(outBytes)
|
||||
}
|
||||
|
||||
func writeError(msg string) {
|
||||
out, _ := json.Marshal(map[string]string{"error": msg})
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## component.contract.yaml 完整範例 {#contract-example}
|
||||
|
||||
\`\`\`yaml
|
||||
canonical_id: "validate_json"
|
||||
display_name: "JSON 格式驗證器"
|
||||
category: "logic"
|
||||
version: "v1"
|
||||
wasi_target: "preview1"
|
||||
stability: "floating"
|
||||
|
||||
runtime_compat:
|
||||
- "cf-workers"
|
||||
- "workerd"
|
||||
- "wazero"
|
||||
|
||||
constraints:
|
||||
max_size_kb: 2048
|
||||
max_cold_start_ms: 50
|
||||
no_network_syscall: true
|
||||
io_model: "stdin_stdout_json"
|
||||
|
||||
input_schema:
|
||||
type: object
|
||||
required: ["json_string"]
|
||||
properties:
|
||||
json_string:
|
||||
type: string
|
||||
description: "待驗證的 JSON 字串"
|
||||
|
||||
output_schema:
|
||||
type: object
|
||||
properties:
|
||||
valid:
|
||||
type: boolean
|
||||
error:
|
||||
type: string
|
||||
|
||||
gherkin_tests:
|
||||
- scenario: "合法 JSON 通過驗證"
|
||||
given: '{"json_string":"{\\"key\\":\\"value\\"}"}'
|
||||
then_contains: '{"valid":true}'
|
||||
- scenario: "非法 JSON 回傳錯誤"
|
||||
given: '{"json_string":"not-json"}'
|
||||
then_contains: '{"valid":false,"error":'
|
||||
|
||||
tags: ["validation", "json", "utility"]
|
||||
description: "驗證輸入字串是否為合法 JSON 格式"
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## 本地測試指令 {#local-testing}
|
||||
|
||||
使用 \`wasmtime\` 在本地測試零件:
|
||||
|
||||
\`\`\`bash
|
||||
# 編譯(TinyGo)
|
||||
tinygo build -o validate_json.wasm -target wasi ./main.go
|
||||
|
||||
# 測試 happy path
|
||||
echo '{"json_string":"{}"}' | wasmtime validate_json.wasm
|
||||
# 預期輸出:{"valid":true}
|
||||
|
||||
# 測試 error path
|
||||
echo '{"json_string":"not-json"}' | wasmtime validate_json.wasm
|
||||
# 預期輸出:{"valid":false,"error":"..."}
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## syscall 限制 {#syscall-constraints}
|
||||
|
||||
沙盒驗收會掃描 .wasm binary 中的 import,以下 syscall 一律拒絕:
|
||||
|
||||
- \`sock_connect\`、\`sock_accept\`、\`sock_recv\`、\`sock_send\`、\`sock_shutdown\`
|
||||
- \`fd_open\`、\`path_open\`、\`path_create_directory\`、\`path_remove_directory\`
|
||||
- \`path_rename\`、\`path_unlink_file\`、\`path_filestat_get\`
|
||||
|
||||
---
|
||||
|
||||
## 常見錯誤與解法 {#common-errors}
|
||||
|
||||
| 錯誤 | 原因 | 解法 |
|
||||
|---|---|---|
|
||||
| \`size_check 失敗\` | .wasm 超過 2048KB | 移除不必要的依賴,使用 TinyGo 而非 Go |
|
||||
| \`syscall_scan 失敗\` | 含有網路/檔案 syscall | 移除 \`net/*\`、\`os.Open\` 等 import |
|
||||
| \`gherkin_tests 失敗\` | 輸出不符合預期 | 確認 stdout 輸出為合法 JSON |
|
||||
| \`contract 驗證失敗\` | 缺少必填欄位 | 參考上方 contract 範例補齊欄位 |
|
||||
|
||||
---
|
||||
|
||||
## contract.yaml JSON Schema {#contract-schema}
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["canonical_id", "display_name", "category", "version", "wasi_target", "stability", "runtime_compat", "constraints", "input_schema", "output_schema", "gherkin_tests"],
|
||||
"properties": {
|
||||
"canonical_id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
|
||||
"display_name": { "type": "string" },
|
||||
"category": { "type": "string", "enum": ["logic", "api", "ui", "style", "anim"] },
|
||||
"version": { "type": "string", "pattern": "^v\\\\d+$" },
|
||||
"wasi_target": { "type": "string", "enum": ["preview1"] },
|
||||
"stability": { "type": "string", "enum": ["floating", "stable", "pinned"] },
|
||||
"runtime_compat": { "type": "array", "items": { "type": "string", "enum": ["cf-workers", "workerd", "wazero"] }, "minItems": 1 },
|
||||
"constraints": {
|
||||
"type": "object",
|
||||
"required": ["max_size_kb", "max_cold_start_ms", "no_network_syscall", "io_model"],
|
||||
"properties": {
|
||||
"max_size_kb": { "type": "number", "maximum": 2048 },
|
||||
"max_cold_start_ms": { "type": "number", "maximum": 50 },
|
||||
"no_network_syscall": { "type": "boolean" },
|
||||
"io_model": { "type": "string", "enum": ["stdin_stdout_json"] }
|
||||
}
|
||||
},
|
||||
"input_schema": { "type": "object" },
|
||||
"output_schema": { "type": "object" },
|
||||
"gherkin_tests": { "type": "array", "minItems": 2 }
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// 零件 metadata-only 索引:只寫 KV,不沙盒、不上 R2
|
||||
// 用途:backfill 既有已部署但未索引的零件(cypher-executor 不從 R2 讀 wasm,零件用獨立 Worker URL)
|
||||
// SDD: matrix/arcrun/.agents/specs/component-registry-canon/design.md Phase 1
|
||||
//
|
||||
// 跟 submitComponent 對照:
|
||||
// submitComponent → 跑沙盒 + 寫 R2 + 寫 KV
|
||||
// indexOnlyComponent → 只寫 KV(hash_id 規則一致:cmp_ + sha256(canonical_id)[:8])
|
||||
//
|
||||
// 冪等:相同 canonical_id + version 不重複寫
|
||||
|
||||
import type { ComponentContract, Bindings } from '../types';
|
||||
|
||||
async function deriveHashId(canonicalId: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(canonicalId);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
return 'cmp_' + hex.slice(0, 8);
|
||||
}
|
||||
|
||||
export interface IndexOnlyResult {
|
||||
success: boolean;
|
||||
component_hash_id: string;
|
||||
canonical_id: string;
|
||||
version: string;
|
||||
already_indexed: boolean;
|
||||
}
|
||||
|
||||
export async function indexOnlyComponent(
|
||||
contract: ComponentContract,
|
||||
env: Bindings,
|
||||
): Promise<IndexOnlyResult> {
|
||||
const hashId = await deriveHashId(contract.canonical_id);
|
||||
const kvKey = `comp:${hashId}:${contract.version}`;
|
||||
|
||||
const existing = await env.SUBMISSIONS_KV.get(kvKey);
|
||||
if (existing) {
|
||||
return {
|
||||
success: true,
|
||||
component_hash_id: hashId,
|
||||
canonical_id: contract.canonical_id,
|
||||
version: contract.version,
|
||||
already_indexed: true,
|
||||
};
|
||||
}
|
||||
|
||||
const record = {
|
||||
component_hash_id: hashId,
|
||||
canonical_id: contract.canonical_id,
|
||||
display_name: contract.display_name,
|
||||
category: contract.category,
|
||||
version: contract.version,
|
||||
wasi_target: contract.wasi_target,
|
||||
stability: contract.stability,
|
||||
runtime_compat: contract.runtime_compat,
|
||||
component_type: contract.component_type ?? 'wasm',
|
||||
constraints: contract.constraints,
|
||||
input_schema: contract.input_schema,
|
||||
output_schema: contract.output_schema,
|
||||
gherkin_tests: contract.gherkin_tests,
|
||||
description: contract.description ?? '',
|
||||
aliases: contract.aliases ?? [],
|
||||
tags: contract.tags ?? [],
|
||||
success_rate: 1,
|
||||
avg_duration_ms: 0,
|
||||
call_count: 0,
|
||||
visibility: 'public' as const,
|
||||
status: 'active' as const,
|
||||
submitted_at: new Date().toISOString(),
|
||||
deprecated_at: null,
|
||||
indexed_only: true,
|
||||
};
|
||||
|
||||
await env.SUBMISSIONS_KV.put(kvKey, JSON.stringify(record));
|
||||
await env.SUBMISSIONS_KV.put(`idx:${contract.canonical_id}`, hashId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
component_hash_id: hashId,
|
||||
canonical_id: contract.canonical_id,
|
||||
version: contract.version,
|
||||
already_indexed: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// queryComponents — 查詢零件合約
|
||||
// 支援兩種查詢 id:
|
||||
// component_hash_id(cmp_xxxxxxxx)— 永久穩定,workflow 引用用
|
||||
// canonical_id(小寫底線) — 可讀名稱,透過 idx: 反查索引解析
|
||||
// Requirements: 12.2, 12.3
|
||||
|
||||
import type { Bindings } from '../types';
|
||||
|
||||
export interface ComponentRecord {
|
||||
component_hash_id: string;
|
||||
canonical_id: string;
|
||||
display_name: string;
|
||||
version: string;
|
||||
category: string;
|
||||
stability: string;
|
||||
status: string;
|
||||
description: string;
|
||||
aliases: string[];
|
||||
tags: string[];
|
||||
success_rate: number;
|
||||
avg_duration_ms: number;
|
||||
call_count: number;
|
||||
wasm_r2_key?: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
// ── id 解析:支援 hash_id 和 canonical_id 兩種格式 ──────────────────────────
|
||||
|
||||
async function resolveHashId(id: string, env: Bindings): Promise<string | null> {
|
||||
// 已經是 hash_id 格式
|
||||
if (id.startsWith('cmp_')) return id;
|
||||
// canonical_id → 透過 idx: 反查索引
|
||||
const hashId = await env.SUBMISSIONS_KV.get(`idx:${id}`);
|
||||
return hashId;
|
||||
}
|
||||
|
||||
// ── 取得零件的所有版本 ────────────────────────────────────────────────────────
|
||||
|
||||
async function listVersions(hashId: string, env: Bindings): Promise<ComponentRecord[]> {
|
||||
const prefix = `comp:${hashId}:`;
|
||||
const list = await env.SUBMISSIONS_KV.list({ prefix });
|
||||
|
||||
const records: ComponentRecord[] = [];
|
||||
for (const key of list.keys) {
|
||||
const raw = await env.SUBMISSIONS_KV.get(key.name);
|
||||
if (!raw) continue;
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
if (v.status === 'tombstone') continue;
|
||||
records.push(toComponentRecord(v));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
// ── 公開 API ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 取得零件最優版本(floating 策略:成功率 × 速度 × log(使用次數)) */
|
||||
export async function getComponent(
|
||||
id: string,
|
||||
env: Bindings,
|
||||
): Promise<ComponentRecord | null> {
|
||||
const hashId = await resolveHashId(id, env);
|
||||
if (!hashId) return null;
|
||||
|
||||
const versions = await listVersions(hashId, env);
|
||||
if (versions.length === 0) return null;
|
||||
|
||||
versions.sort((a, b) => b.score - a.score);
|
||||
return versions[0];
|
||||
}
|
||||
|
||||
/** 取得零件所有版本清單(含評分排序) */
|
||||
export async function getComponentVersions(
|
||||
id: string,
|
||||
env: Bindings,
|
||||
): Promise<ComponentRecord[]> {
|
||||
const hashId = await resolveHashId(id, env);
|
||||
if (!hashId) return [];
|
||||
|
||||
const versions = await listVersions(hashId, env);
|
||||
versions.sort((a, b) => b.score - a.score);
|
||||
return versions.slice(0, 10);
|
||||
}
|
||||
|
||||
/** 關鍵字搜尋(掃描 KV prefix comp:,比對 canonical_id / display_name / description / aliases)
|
||||
*
|
||||
* 注意:這是 Phase 0 的純文字比對版本。
|
||||
* Phase 2 接入 Cloudflare Vectorize 後改為語意搜尋,API 介面不變。
|
||||
*/
|
||||
export async function searchComponents(
|
||||
query: string,
|
||||
env: Bindings,
|
||||
): Promise<ComponentRecord[]> {
|
||||
const q = query.toLowerCase();
|
||||
|
||||
// 列出所有 comp: 前綴的 key(只取最新一頁,最多 1000 個)
|
||||
const list = await env.SUBMISSIONS_KV.list({ prefix: 'comp:' });
|
||||
|
||||
const seen = new Set<string>(); // 每個 hash_id 只取最優版本
|
||||
const candidates: ComponentRecord[] = [];
|
||||
|
||||
for (const key of list.keys) {
|
||||
const raw = await env.SUBMISSIONS_KV.get(key.name);
|
||||
if (!raw) continue;
|
||||
let v: Record<string, unknown>;
|
||||
try { v = JSON.parse(raw); } catch { continue; }
|
||||
|
||||
if (v.status === 'tombstone' || v.visibility !== 'public') continue;
|
||||
|
||||
// 比對:canonical_id / display_name / description / aliases
|
||||
const searchable = [
|
||||
String(v.canonical_id ?? ''),
|
||||
String(v.display_name ?? ''),
|
||||
String(v.description ?? ''),
|
||||
...(Array.isArray(v.aliases) ? v.aliases.map(String) : []),
|
||||
...(Array.isArray(v.tags) ? v.tags.map(String) : []),
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
if (!searchable.includes(q)) continue;
|
||||
|
||||
const hashId = String(v.component_hash_id ?? '');
|
||||
if (seen.has(`${hashId}:${v.version}`)) continue;
|
||||
seen.add(`${hashId}:${v.version}`);
|
||||
candidates.push(toComponentRecord(v));
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => b.score - a.score);
|
||||
return candidates.slice(0, 10);
|
||||
}
|
||||
|
||||
// ── 內部工具函數 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function computeScore(v: Record<string, unknown>): number {
|
||||
const successRate = parseFloat(String(v.success_rate ?? '1'));
|
||||
const avgDuration = parseFloat(String(v.avg_duration_ms ?? '10'));
|
||||
const callCount = parseInt(String(v.call_count ?? '0'), 10);
|
||||
const speedScore = Math.max(0, 1 - avgDuration / 1000);
|
||||
return successRate * speedScore * Math.log(callCount + 2);
|
||||
}
|
||||
|
||||
function toComponentRecord(v: Record<string, unknown>): ComponentRecord {
|
||||
return {
|
||||
component_hash_id: String(v.component_hash_id ?? ''),
|
||||
canonical_id: String(v.canonical_id ?? ''),
|
||||
display_name: String(v.display_name ?? ''),
|
||||
version: String(v.version ?? 'v1'),
|
||||
category: String(v.category ?? 'logic'),
|
||||
stability: String(v.stability ?? 'floating'),
|
||||
status: String(v.status ?? 'active'),
|
||||
description: String(v.description ?? ''),
|
||||
aliases: Array.isArray(v.aliases) ? v.aliases.map(String) : [],
|
||||
tags: Array.isArray(v.tags) ? v.tags.map(String) : [],
|
||||
success_rate: parseFloat(String(v.success_rate ?? '1')),
|
||||
avg_duration_ms: parseFloat(String(v.avg_duration_ms ?? '0')),
|
||||
call_count: parseInt(String(v.call_count ?? '0'), 10),
|
||||
wasm_r2_key: v.wasm_r2_key ? String(v.wasm_r2_key) : undefined,
|
||||
score: computeScore(v),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// 沙盒驗收流程:五個步驟依序執行
|
||||
// Requirements: 2.1, 2.2, 2.3
|
||||
|
||||
import { FORBIDDEN_SYSCALLS } from '../types';
|
||||
import type { ComponentContract, SandboxResult, SandboxStep } from '../types';
|
||||
import { detectFakeComponent } from './detectFakeComponent';
|
||||
import { checkPureWasi } from './wasmImports';
|
||||
// 註:G4 Gherkin 不在 registry 跑(CF Worker 禁止 runtime 編譯 wasm)。
|
||||
// Gherkin 在投稿者本地(CLI/Node)跑,registry 只驗 gherkin_evidence 一致性。
|
||||
// 見 .agents/specs/component-gatekeeping/design.md §4 修訂。
|
||||
|
||||
// ── 步驟 (G1):假零件偵測(最先擋,component-gatekeeping SDD R2)─────────────────
|
||||
|
||||
function checkFakeComponent(wasmBytes: Uint8Array, contract: ComponentContract): string | null {
|
||||
return detectFakeComponent(contract, wasmBytes);
|
||||
}
|
||||
|
||||
// ── 步驟 (a):體積檢查 ────────────────────────────────────────────────────────
|
||||
|
||||
function checkSize(wasmBytes: Uint8Array, contract: ComponentContract): string | null {
|
||||
const maxSizeKb = contract.constraints.max_size_kb;
|
||||
const actualKb = wasmBytes.byteLength / 1024;
|
||||
if (actualKb > maxSizeKb) {
|
||||
return `體積 ${actualKb.toFixed(1)}KB 超過上限 ${maxSizeKb}KB`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 步驟 (b):冷啟動時間(Phase 0 mock 0ms)────────────────────────────────────
|
||||
|
||||
|
||||
// ── 步驟 (c):syscall 掃描 ────────────────────────────────────────────────────
|
||||
|
||||
function scanSyscalls(wasmBytes: Uint8Array): string | null {
|
||||
// G3(R3):import module 白名單 — 只准 wasi_snapshot_preview1 + u6u(避免 runtime 鎖定)。
|
||||
const pureWasiError = checkPureWasi(wasmBytes);
|
||||
if (pureWasiError !== null) {
|
||||
return pureWasiError;
|
||||
}
|
||||
|
||||
// 次要:禁止 syscall 黑名單(網路/檔案系統 import 名)。
|
||||
// WASM binary 中 import section 的函數名稱以 UTF-8 字串形式存在。
|
||||
const text = new TextDecoder('utf-8').decode(wasmBytes);
|
||||
for (const syscall of FORBIDDEN_SYSCALLS) {
|
||||
if (text.includes(syscall)) {
|
||||
return `發現禁止的 syscall:${syscall}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 註:cold_start / runtime_compat 未實作(列 UNIMPLEMENTED_STEPS,不假綠)。
|
||||
// Gherkin 執行不在 registry(CF 跑不了 wasm),在投稿者本地;registry 只驗 evidence。
|
||||
|
||||
// ── 主流程 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface StepDef {
|
||||
name: SandboxStep;
|
||||
run: (wasmBytes: Uint8Array, contract: ComponentContract) => string | null;
|
||||
guideAnchor: string;
|
||||
}
|
||||
|
||||
// registry 端把關步驟(全靜態,CF Worker 可跑;不執行 wasm)。
|
||||
// G4 Gherkin 的「執行」在投稿者本地(CLI/Node),registry 只驗 evidence 一致性。
|
||||
const STEPS: StepDef[] = [
|
||||
{ name: 'fake_component_scan', run: checkFakeComponent, guideAnchor: '#fake-component' },
|
||||
{ name: 'size_check', run: checkSize, guideAnchor: '#common-errors' },
|
||||
{ name: 'syscall_scan', run: scanSyscalls, guideAnchor: '#syscall-constraints' },
|
||||
];
|
||||
|
||||
// 未真正實作 / 未在 registry 端執行的步驟(誠實標記,§3c/§7 禁假綠):
|
||||
// gherkin_tests:在投稿者本地跑,registry 只驗 evidence(不重跑)→ 第一期 evidence 可造假
|
||||
// cold_start / runtime_compat:mock,未實作
|
||||
const UNIMPLEMENTED_STEPS: SandboxStep[] = ['gherkin_tests', 'cold_start', 'runtime_compat'];
|
||||
|
||||
/** 投稿者本地跑 Gherkin 的結果證據(CLI 上傳;registry 存證可審) */
|
||||
export interface GherkinEvidence {
|
||||
scenario: string;
|
||||
given: string;
|
||||
actual_stdout: string;
|
||||
passed: boolean;
|
||||
}
|
||||
|
||||
function fail(step: SandboxStep, reason: string, guideAnchor: string, contract: ComponentContract): SandboxResult {
|
||||
return {
|
||||
success: false,
|
||||
failed_step: step,
|
||||
reason,
|
||||
guide_anchor: guideAnchor,
|
||||
component_hash_id: '',
|
||||
canonical_id: contract.canonical_id,
|
||||
version: contract.version,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 驗 gherkin_evidence 與 contract 一致(不重跑 wasm,CF 跑不了):
|
||||
* - evidence 的 scenario 集合須涵蓋 contract.gherkin_tests 的每個 scenario
|
||||
* - 每個 evidence.passed 須為 true
|
||||
* 回 null = 通過;回字串 = 退稿原因。
|
||||
* evidence 缺省(backfill / 舊投稿)→ 不擋(回 null),但 gherkin_tests 仍列 unimplemented(未驗證)。
|
||||
*/
|
||||
function checkGherkinEvidence(contract: ComponentContract, evidence?: GherkinEvidence[]): string | null {
|
||||
if (!evidence || evidence.length === 0) return null;
|
||||
const evidenceScenarios = new Set(evidence.map(e => e.scenario));
|
||||
for (const test of contract.gherkin_tests) {
|
||||
if (!evidenceScenarios.has(test.scenario)) {
|
||||
return `gherkin_evidence 缺少 scenario「${test.scenario}」的本地測試結果`;
|
||||
}
|
||||
}
|
||||
for (const e of evidence) {
|
||||
if (!e.passed) {
|
||||
return `gherkin_evidence scenario「${e.scenario}」本地未通過(passed=false)`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function runSandboxAcceptance(
|
||||
wasmBytes: Uint8Array,
|
||||
contract: ComponentContract,
|
||||
gherkinEvidence?: GherkinEvidence[],
|
||||
): SandboxResult {
|
||||
// 1. 靜態步驟(假零件 / 體積 / 純WASI;不執行 wasm)
|
||||
for (const step of STEPS) {
|
||||
const error = step.run(wasmBytes, contract);
|
||||
if (error !== null) {
|
||||
return fail(step.name, error, step.guideAnchor, contract);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Gherkin evidence 一致性(投稿者本地已跑;registry 不重跑)
|
||||
const evidenceError = checkGherkinEvidence(contract, gherkinEvidence);
|
||||
if (evidenceError !== null) {
|
||||
return fail('gherkin_tests', evidenceError, '#local-testing', contract);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
unimplemented_steps: UNIMPLEMENTED_STEPS, // gherkin(本地跑,registry未重跑) / cold_start / runtime_compat
|
||||
component_hash_id: '', // 由 submitComponent 在驗收通過後填入
|
||||
canonical_id: contract.canonical_id,
|
||||
version: contract.version,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// 零件提交:沙盒驗收 → 派發 hash id → 寫入 SUBMISSIONS_KV
|
||||
// Requirements: 2.1, 2.2, 2.3
|
||||
// SDD: matrix/arcrun/.agents/specs/component-registry-canon/design.md Phase 1.5
|
||||
//
|
||||
// 2026-05-07:移除 R2 寫入。cypher-executor 已不從 R2 動態載 wasm(每個零件 = 獨立 Worker)。
|
||||
// R2 是 dead storage,移除避免誤導 AI 以為零件部署需要 wasm bytes。
|
||||
//
|
||||
// KV key 設計:
|
||||
// comp:{hash_id}:{version} → 零件元數據 JSON
|
||||
// idx:{canonical_id} → hash_id 反查索引(canonical_id → hash_id)
|
||||
//
|
||||
// hash_id 派發規則:
|
||||
// hash_id = 'cmp_' + sha256(canonical_id).slice(0, 8)
|
||||
// 相同 canonical_id 永遠得到相同 hash_id(冪等)
|
||||
|
||||
import { runSandboxAcceptance } from './sandboxAcceptance';
|
||||
import type { GherkinEvidence } from './sandboxAcceptance';
|
||||
import type { ComponentContract, SandboxResult, Bindings } from '../types';
|
||||
|
||||
// ── G0 人類閘門(component-gatekeeping SDD R4)─────────────────────────────────
|
||||
//
|
||||
// 建零件不是 AI 能自己決定的事。submit 預設拒絕,除非帶人類確認 + 舉證。
|
||||
// 把關點在「建立零件的 API」本身(此處)→ CLI/MCP/Python/JS 四路全收斂到這關。
|
||||
// 誠實限制:AI 技術上能偽造 confirmed_by_human:true。靠 reason 留記錄(軌跡可審)+
|
||||
// mindset 明示「絕不代替人類確認建零件」+ 純 WASI 沙箱框死能力,讓偽造成明確越界。
|
||||
|
||||
export interface HumanConfirmation {
|
||||
confirmed_by_human: true; // 必須為 literal true
|
||||
reason_why_not_workflow: string; // 非空:AI 舉證「為何工作流做不到」
|
||||
confirmed_at: string; // ISO timestamp
|
||||
}
|
||||
|
||||
export interface SubmitOptions {
|
||||
/** backfill 既有零件用:跳過人類閘門 + 沙盒(這些是已驗、已部署的存量) */
|
||||
skip_acceptance?: boolean;
|
||||
/** 人類確認 + 舉證(新投稿必填,除非 skip_acceptance) */
|
||||
human_confirmation?: HumanConfirmation;
|
||||
/** 投稿者本地跑 Gherkin 的結果(CLI 上傳,registry 存證可審) */
|
||||
gherkin_evidence?: GherkinEvidence[];
|
||||
}
|
||||
|
||||
function gateError(contract: ComponentContract, reason: string): SandboxResult {
|
||||
return {
|
||||
success: false,
|
||||
failed_step: 'fake_component_scan', // 復用最前步驟欄位語意:未過閘門
|
||||
reason,
|
||||
guide_anchor: '#human-gate',
|
||||
component_hash_id: '',
|
||||
canonical_id: contract.canonical_id,
|
||||
version: contract.version,
|
||||
};
|
||||
}
|
||||
|
||||
// ── hash id 生成 ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function deriveHashId(canonicalId: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(canonicalId);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
return 'cmp_' + hex.slice(0, 8);
|
||||
}
|
||||
|
||||
// ── 主流程 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function submitComponent(
|
||||
wasmBytes: Uint8Array,
|
||||
contract: ComponentContract,
|
||||
env: Bindings,
|
||||
options: SubmitOptions = {},
|
||||
): Promise<SandboxResult> {
|
||||
const { skip_acceptance, human_confirmation, gherkin_evidence } = options;
|
||||
|
||||
// 0. G0 人類閘門(新投稿必經;backfill 既有存量 skip)
|
||||
if (!skip_acceptance) {
|
||||
if (
|
||||
!human_confirmation ||
|
||||
human_confirmation.confirmed_by_human !== true ||
|
||||
!human_confirmation.reason_why_not_workflow ||
|
||||
human_confirmation.reason_why_not_workflow.trim() === ''
|
||||
) {
|
||||
return gateError(
|
||||
contract,
|
||||
'建零件需人類確認。請用 `acr component create`(會互動式問你),' +
|
||||
'並說明「為何這件事無法用工作流達成」。預設假設工作流 / recipe 能做——先試工作流。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 沙盒驗收(靜態把關 + gherkin_evidence 一致性;backfill 仍可選擇性跳過 wasm 驗收)
|
||||
if (!skip_acceptance) {
|
||||
const sandboxResult = runSandboxAcceptance(wasmBytes, contract, gherkin_evidence);
|
||||
if (!sandboxResult.success) {
|
||||
return sandboxResult;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 派發 hash id
|
||||
const hashId = await deriveHashId(contract.canonical_id);
|
||||
const kvKey = `comp:${hashId}:${contract.version}`;
|
||||
|
||||
// 3. 冪等
|
||||
const existing = await env.SUBMISSIONS_KV.get(kvKey);
|
||||
if (existing) {
|
||||
return {
|
||||
success: true,
|
||||
component_hash_id: hashId,
|
||||
canonical_id: contract.canonical_id,
|
||||
version: contract.version,
|
||||
};
|
||||
}
|
||||
|
||||
// 4. 寫入 metadata
|
||||
const record = {
|
||||
component_hash_id: hashId,
|
||||
canonical_id: contract.canonical_id,
|
||||
display_name: contract.display_name,
|
||||
category: contract.category,
|
||||
version: contract.version,
|
||||
wasi_target: contract.wasi_target,
|
||||
stability: contract.stability,
|
||||
runtime_compat: contract.runtime_compat,
|
||||
component_type: contract.component_type ?? 'wasm',
|
||||
constraints: contract.constraints,
|
||||
input_schema: contract.input_schema,
|
||||
output_schema: contract.output_schema,
|
||||
gherkin_tests: contract.gherkin_tests,
|
||||
description: contract.description ?? '',
|
||||
aliases: contract.aliases ?? [],
|
||||
tags: contract.tags ?? [],
|
||||
success_rate: 1,
|
||||
avg_duration_ms: 0,
|
||||
call_count: 0,
|
||||
visibility: 'public' as const,
|
||||
status: 'active' as const,
|
||||
submitted_at: new Date().toISOString(),
|
||||
deprecated_at: null,
|
||||
// G0 軌跡可審:人類為何建此零件(舉證)+ 投稿者本地 Gherkin 結果證據
|
||||
human_confirmation: human_confirmation ?? null,
|
||||
gherkin_evidence: gherkin_evidence ?? null,
|
||||
};
|
||||
|
||||
await env.SUBMISSIONS_KV.put(kvKey, JSON.stringify(record));
|
||||
await env.SUBMISSIONS_KV.put(`idx:${contract.canonical_id}`, hashId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
component_hash_id: hashId,
|
||||
canonical_id: contract.canonical_id,
|
||||
version: contract.version,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// 驗證 component.contract.yaml 所有必填欄位
|
||||
// Requirements: 1.1, 1.2, 11.5
|
||||
|
||||
import { ComponentContractSchema } from '../types';
|
||||
import type { ComponentContract } from '../types';
|
||||
|
||||
export interface ValidateContractResult {
|
||||
valid: boolean;
|
||||
missing_fields: string[];
|
||||
errors: string[];
|
||||
contract?: ComponentContract;
|
||||
}
|
||||
|
||||
export function validateContract(raw: unknown): ValidateContractResult {
|
||||
const result = ComponentContractSchema.safeParse(raw);
|
||||
|
||||
if (result.success) {
|
||||
return { valid: true, missing_fields: [], errors: [], contract: result.data };
|
||||
}
|
||||
|
||||
const missing_fields: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const issue of result.error.issues) {
|
||||
const path = issue.path.join('.');
|
||||
if (issue.code === 'invalid_type' && issue.received === 'undefined') {
|
||||
missing_fields.push(path || issue.message);
|
||||
} else {
|
||||
errors.push(path ? `${path}: ${issue.message}` : issue.message);
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: false, missing_fields, errors };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// G3 純 WASI 把關(component-gatekeeping SDD,R3)
|
||||
//
|
||||
// 解析 WASM import section,取出所有 import 的 module name。
|
||||
// 把關:只准 wasi_snapshot_preview1 + u6u(host functions)。
|
||||
// 其他 module → runtime 鎖定風險,退稿(DECISIONS §4:避免 runtime 鎖定債)。
|
||||
//
|
||||
// WASM binary 結構(preview1):
|
||||
// magic(4) + version(4) + 一串 section
|
||||
// section: id(1 byte) + size(LEB128) + payload
|
||||
// import section id = 2。payload: count(LEB128) + 每個 import:
|
||||
// module: len(LEB128) + bytes(UTF-8)
|
||||
// name: len(LEB128) + bytes(UTF-8)
|
||||
// kind(1) + 之後依 kind 不同(func: typeidx LEB128 / table / mem / global)
|
||||
|
||||
const ALLOWED_IMPORT_MODULES = new Set([
|
||||
'wasi_snapshot_preview1',
|
||||
'u6u',
|
||||
]);
|
||||
|
||||
/** 讀 unsigned LEB128,回 [value, nextOffset] */
|
||||
function readULEB(buf: Uint8Array, offset: number): [number, number] {
|
||||
let result = 0;
|
||||
let shift = 0;
|
||||
let pos = offset;
|
||||
for (;;) {
|
||||
const byte = buf[pos++];
|
||||
result |= (byte & 0x7f) << shift;
|
||||
if ((byte & 0x80) === 0) break;
|
||||
shift += 7;
|
||||
}
|
||||
return [result, pos];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 wasm import section,回傳所有 import module name 的集合。
|
||||
* 解析失敗(非合法 wasm)回 null。
|
||||
*/
|
||||
export function parseWasmImportModules(wasmBytes: Uint8Array): Set<string> | null {
|
||||
// magic + version
|
||||
if (wasmBytes.length < 8) return null;
|
||||
if (wasmBytes[0] !== 0x00 || wasmBytes[1] !== 0x61 || wasmBytes[2] !== 0x73 || wasmBytes[3] !== 0x6d) {
|
||||
return null; // 不是 \0asm
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const modules = new Set<string>();
|
||||
let offset = 8;
|
||||
|
||||
try {
|
||||
while (offset < wasmBytes.length) {
|
||||
const sectionId = wasmBytes[offset++];
|
||||
const [sectionSize, afterSize] = readULEB(wasmBytes, offset);
|
||||
offset = afterSize;
|
||||
const sectionEnd = offset + sectionSize;
|
||||
|
||||
if (sectionId === 2) {
|
||||
// import section
|
||||
let p = offset;
|
||||
const [count, afterCount] = readULEB(wasmBytes, p);
|
||||
p = afterCount;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const [modLen, afterModLen] = readULEB(wasmBytes, p);
|
||||
p = afterModLen;
|
||||
const moduleName = decoder.decode(wasmBytes.subarray(p, p + modLen));
|
||||
p += modLen;
|
||||
modules.add(moduleName);
|
||||
|
||||
const [nameLen, afterNameLen] = readULEB(wasmBytes, p);
|
||||
p = afterNameLen;
|
||||
p += nameLen; // skip import name
|
||||
|
||||
const kind = wasmBytes[p++];
|
||||
// 依 kind skip descriptor
|
||||
if (kind === 0x00) {
|
||||
// func: typeidx (ULEB)
|
||||
const [, afterType] = readULEB(wasmBytes, p);
|
||||
p = afterType;
|
||||
} else if (kind === 0x01) {
|
||||
// table: reftype(1) + limits
|
||||
p += 1;
|
||||
const [flags, afterFlags] = readULEB(wasmBytes, p);
|
||||
p = afterFlags;
|
||||
const [, afterMin] = readULEB(wasmBytes, p);
|
||||
p = afterMin;
|
||||
if (flags === 0x01) { const [, afterMax] = readULEB(wasmBytes, p); p = afterMax; }
|
||||
} else if (kind === 0x02) {
|
||||
// mem: limits
|
||||
const [flags, afterFlags] = readULEB(wasmBytes, p);
|
||||
p = afterFlags;
|
||||
const [, afterMin] = readULEB(wasmBytes, p);
|
||||
p = afterMin;
|
||||
if (flags === 0x01) { const [, afterMax] = readULEB(wasmBytes, p); p = afterMax; }
|
||||
} else if (kind === 0x03) {
|
||||
// global: valtype(1) + mut(1)
|
||||
p += 2;
|
||||
} else {
|
||||
// 未知 kind,無法安全 skip → 中止解析
|
||||
return modules.size > 0 ? modules : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
offset = sectionEnd;
|
||||
}
|
||||
} catch {
|
||||
// 解析越界等 → 回已收集的(或 null)
|
||||
return modules.size > 0 ? modules : null;
|
||||
}
|
||||
|
||||
return modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* G3 把關:確認 wasm 只 import 白名單 module。
|
||||
* 回 null = 通過;回字串 = 退稿原因。
|
||||
*/
|
||||
export function checkPureWasi(wasmBytes: Uint8Array): string | null {
|
||||
const modules = parseWasmImportModules(wasmBytes);
|
||||
if (modules === null) {
|
||||
return 'WASM import section 無法解析(非合法 WASI preview1 wasm?)';
|
||||
}
|
||||
for (const mod of modules) {
|
||||
if (!ALLOWED_IMPORT_MODULES.has(mod)) {
|
||||
return `偵測到非白名單 import module:「${mod}」。零件只准依賴 wasi_snapshot_preview1 + u6u host functions(避免 runtime 鎖定,見 DECISIONS §4)`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Component Registry Worker — 零件合約管理 HTTP endpoints
|
||||
// index.ts 只做路由宣告,業務邏輯在 actions/(INV Layer 1)
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import type { Bindings } from './types';
|
||||
import guideRoute from './routes/guide';
|
||||
import validateContractRoute from './routes/validateContract';
|
||||
import componentsRoute from './routes/components';
|
||||
import queryRoute from './routes/query';
|
||||
import initRoute from './routes/init';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
app.use('*', cors());
|
||||
|
||||
// Health check
|
||||
app.get('/', c => c.json({ service: 'component-registry', version: '1.0.0', status: 'ok' }));
|
||||
|
||||
// === Component Registry 端點 ===
|
||||
app.route('/components/guide', guideRoute);
|
||||
app.route('/components/validate-contract', validateContractRoute);
|
||||
app.route('/components', queryRoute); // GET /components/search, /:id, /:id/versions
|
||||
app.route('/components', componentsRoute); // POST /components
|
||||
|
||||
// === 初始化端點(建立 tpl-component template)===
|
||||
app.route('/init', initRoute);
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,147 @@
|
||||
// POST /components — 零件提交端點(沙盒驗收流程)
|
||||
// POST /components/index-only — metadata-only 索引(無 wasm、無沙盒,給 backfill 用)
|
||||
// Requirements: 2.1, 2.2, 2.3
|
||||
// SDD: matrix/arcrun/.agents/specs/component-registry-canon/design.md
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { validateContract } from '../actions/validateContract';
|
||||
import { submitComponent } from '../actions/submitComponent';
|
||||
import type { SubmitOptions } from '../actions/submitComponent';
|
||||
import { indexOnlyComponent } from '../actions/indexOnlyComponent';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
app.post('/', async c => {
|
||||
// 接受 multipart/form-data:contract(JSON 字串)+ wasm(binary)
|
||||
let contract: unknown;
|
||||
let wasmBytes: Uint8Array;
|
||||
// G0/G4:人類確認 + 舉證 + 本地 Gherkin evidence(multipart 或 JSON 皆可帶)
|
||||
const submitOptions: SubmitOptions = {};
|
||||
|
||||
const contentType = c.req.header('content-type') ?? '';
|
||||
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
const formData = await c.req.formData();
|
||||
const contractStr = formData.get('contract');
|
||||
const wasmFile = formData.get('wasm');
|
||||
|
||||
if (!contractStr || typeof contractStr !== 'string') {
|
||||
return c.json({ success: false, error: '缺少 contract 欄位' }, 400);
|
||||
}
|
||||
if (!wasmFile || !(wasmFile instanceof File)) {
|
||||
return c.json({ success: false, error: '缺少 wasm 欄位' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
contract = JSON.parse(contractStr);
|
||||
} catch {
|
||||
return c.json({ success: false, error: 'contract 必須為合法 JSON' }, 400);
|
||||
}
|
||||
|
||||
wasmBytes = new Uint8Array(await wasmFile.arrayBuffer());
|
||||
|
||||
const hc = formData.get('human_confirmation');
|
||||
if (typeof hc === 'string') { try { submitOptions.human_confirmation = JSON.parse(hc); } catch { /* ignore */ } }
|
||||
const ge = formData.get('gherkin_evidence');
|
||||
if (typeof ge === 'string') { try { submitOptions.gherkin_evidence = JSON.parse(ge); } catch { /* ignore */ } }
|
||||
if (formData.get('skip_acceptance') === 'true') submitOptions.skip_acceptance = true;
|
||||
} else {
|
||||
// 也支援純 JSON(用於測試,wasm 以 base64 傳入)
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ success: false, error: 'request body 必須為 multipart/form-data 或 JSON' }, 400);
|
||||
}
|
||||
|
||||
contract = body.contract;
|
||||
const wasmBase64 = body.wasm_base64;
|
||||
|
||||
if (!contract) {
|
||||
return c.json({ success: false, error: '缺少 contract 欄位' }, 400);
|
||||
}
|
||||
if (!wasmBase64 || typeof wasmBase64 !== 'string') {
|
||||
return c.json({ success: false, error: '缺少 wasm_base64 欄位' }, 400);
|
||||
}
|
||||
|
||||
// base64 decode
|
||||
const binaryStr = atob(wasmBase64);
|
||||
wasmBytes = new Uint8Array(binaryStr.length);
|
||||
for (let i = 0; i < binaryStr.length; i++) {
|
||||
wasmBytes[i] = binaryStr.charCodeAt(i);
|
||||
}
|
||||
|
||||
if (body.human_confirmation) submitOptions.human_confirmation = body.human_confirmation as SubmitOptions['human_confirmation'];
|
||||
if (body.gherkin_evidence) submitOptions.gherkin_evidence = body.gherkin_evidence as SubmitOptions['gherkin_evidence'];
|
||||
if (body.skip_acceptance === true) submitOptions.skip_acceptance = true;
|
||||
}
|
||||
|
||||
// 驗證 contract 格式
|
||||
const validation = validateContract(contract);
|
||||
if (!validation.valid) {
|
||||
return c.json({
|
||||
success: false,
|
||||
failed_step: 'contract_validation',
|
||||
reason: `合約格式驗證失敗:${validation.errors.join(', ')}`,
|
||||
missing_fields: validation.missing_fields,
|
||||
guide_anchor: '#contract-example',
|
||||
}, 422);
|
||||
}
|
||||
|
||||
// 執行沙盒驗收(含 G0 人類閘門)+ 寫入 metadata
|
||||
const result = await submitComponent(wasmBytes, validation.contract!, c.env, submitOptions);
|
||||
|
||||
if (!result.success) {
|
||||
return c.json(result, 422);
|
||||
}
|
||||
|
||||
return c.json(result, 201);
|
||||
});
|
||||
|
||||
// POST /components/index-only — metadata-only 索引(給 backfill 用)
|
||||
// 只接 contract(JSON),不收 wasm bytes、不沙盒驗收
|
||||
// 用途:cypher-executor 已不從 R2 動態載 wasm(零件用獨立 Worker URL),
|
||||
// 故已部署但未索引的零件,只要把 metadata 寫進 KV 讓 search 找得到即可。
|
||||
app.post('/index-only', async c => {
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ success: false, error: 'request body 必須為 JSON' }, 400);
|
||||
}
|
||||
|
||||
const contract = body.contract;
|
||||
if (!contract) {
|
||||
return c.json({ success: false, error: '缺少 contract 欄位' }, 400);
|
||||
}
|
||||
|
||||
// index-only 是 backfill 用,比 submit 寬鬆:
|
||||
// 既有零件可能沒 gherkin_tests / 沒 description / aliases — 補預設讓索引能進
|
||||
if (typeof contract === 'object' && contract !== null) {
|
||||
const c2 = contract as Record<string, unknown>;
|
||||
if (!c2.gherkin_tests || (Array.isArray(c2.gherkin_tests) && c2.gherkin_tests.length < 2)) {
|
||||
c2.gherkin_tests = [
|
||||
{ scenario: 'placeholder happy', given: '{}', then_contains: '{' },
|
||||
{ scenario: 'placeholder fail', given: '{}', then_contains: '}' },
|
||||
];
|
||||
}
|
||||
if (!c2.description) c2.description = '';
|
||||
if (!c2.tags) c2.tags = [];
|
||||
}
|
||||
|
||||
const validation = validateContract(contract);
|
||||
if (!validation.valid) {
|
||||
return c.json({
|
||||
success: false,
|
||||
failed_step: 'contract_validation',
|
||||
reason: `合約格式驗證失敗:${validation.errors.join(', ')}`,
|
||||
missing_fields: validation.missing_fields,
|
||||
}, 422);
|
||||
}
|
||||
|
||||
const result = await indexOnlyComponent(validation.contract!, c.env);
|
||||
return c.json(result, result.already_indexed ? 200 : 201);
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,15 @@
|
||||
// GET /components/guide
|
||||
// Requirements: 11.1, 11.2, 11.3
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { getGuide } from '../actions/getGuide';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
app.get('/', c => {
|
||||
const markdown = getGuide();
|
||||
return c.text(markdown, 200, { 'Content-Type': 'text/markdown; charset=utf-8' });
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,20 @@
|
||||
// POST /init — 確保 tpl-component template 存在(冪等)
|
||||
// Requirements: 12.1
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { ensureTemplate } from '../actions/ensureTemplate';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
app.post('/', async c => {
|
||||
try {
|
||||
const result = await ensureTemplate(c.env);
|
||||
return c.json({ success: true, ...result });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return c.json({ success: false, error: message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,43 @@
|
||||
// GET /components/:id — 取得零件最優版本合約
|
||||
// GET /components/:id/versions — 取得所有版本清單(含評分)
|
||||
// GET /components/search?q=... — 語意搜尋零件
|
||||
// Requirements: 12.2, 12.3
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { getComponent, getComponentVersions, searchComponents } from '../actions/queryComponents';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
// 語意搜尋(必須在 /:id 之前,避免 "search" 被當作 id)
|
||||
app.get('/search', async c => {
|
||||
const q = c.req.query('q');
|
||||
if (!q || q.trim() === '') {
|
||||
return c.json({ success: false, error: 'q 參數必填' }, 400);
|
||||
}
|
||||
|
||||
const results = await searchComponents(q.trim(), c.env);
|
||||
return c.json({ success: true, data: { results, count: results.length } });
|
||||
});
|
||||
|
||||
// 取得所有版本
|
||||
app.get('/:id/versions', async c => {
|
||||
const id = c.req.param('id');
|
||||
const versions = await getComponentVersions(id, c.env);
|
||||
if (versions.length === 0) {
|
||||
return c.json({ success: false, error: `零件 ${id} 不存在` }, 404);
|
||||
}
|
||||
return c.json({ success: true, data: { versions, count: versions.length } });
|
||||
});
|
||||
|
||||
// 取得最優版本
|
||||
app.get('/:id', async c => {
|
||||
const id = c.req.param('id');
|
||||
const component = await getComponent(id, c.env);
|
||||
if (!component) {
|
||||
return c.json({ success: false, error: `零件 ${id} 不存在` }, 404);
|
||||
}
|
||||
return c.json({ success: true, data: component });
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,31 @@
|
||||
// POST /components/validate-contract
|
||||
// Requirements: 1.1, 1.2, 11.5
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { validateContract } from '../actions/validateContract';
|
||||
|
||||
const app = new Hono<{ Bindings: Bindings }>();
|
||||
|
||||
app.post('/', async c => {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ valid: false, missing_fields: [], errors: ['request body 必須為合法 JSON'] }, 400);
|
||||
}
|
||||
|
||||
const result = validateContract(body);
|
||||
|
||||
if (result.valid) {
|
||||
return c.json({ valid: true, missing_fields: [], errors: [] }, 200);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
valid: false,
|
||||
missing_fields: result.missing_fields,
|
||||
errors: result.errors,
|
||||
}, 422);
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,116 @@
|
||||
// Component Registry Worker 型別定義
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── Cloudflare Bindings ──────────────────────────────────────────────────────
|
||||
|
||||
export type Bindings = {
|
||||
AI: Ai;
|
||||
// KV key 格式:
|
||||
// comp:{hash_id}:{version} → 零件元數據(hash_id = cmp_ + sha256 前 8 碼)
|
||||
// idx:{canonical_id} → canonical_id → hash_id 反查索引
|
||||
SUBMISSIONS_KV: KVNamespace;
|
||||
ANALYTICS_KV: KVNamespace; // 執行統計匯總(key = stats:{hash_id}:{version})
|
||||
ENVIRONMENT: string;
|
||||
};
|
||||
|
||||
// ── Component Contract Schema(Zod)─────────────────────────────────────────
|
||||
|
||||
// max_cold_start_ms 上限放寬至 500(從 50):實測 auth/ai 類零件含 crypto/init 步驟通常 100-300ms
|
||||
// no_network_syscall / no_filesystem_syscall 都改 optional:auth/api 類零件需要網路 syscall
|
||||
export const ConstraintsSchema = z.object({
|
||||
max_size_kb: z.number().positive().max(8192),
|
||||
max_cold_start_ms: z.number().positive().max(500),
|
||||
no_network_syscall: z.boolean().optional(),
|
||||
no_filesystem_syscall: z.boolean().optional(),
|
||||
io_model: z.literal('stdin_stdout_json'),
|
||||
});
|
||||
|
||||
export const GherkinTestSchema = z.object({
|
||||
scenario: z.string().min(1),
|
||||
given: z.string().min(1),
|
||||
then_contains: z.string().min(1),
|
||||
});
|
||||
|
||||
export const ComponentContractSchema = z.object({
|
||||
// canonical_id:提交者填寫的可讀名稱(小寫底線),用於搜尋與 workflow 引用
|
||||
// component_hash_id:由 Registry 在提交時派發,格式 cmp_{8碼hex},workflow 引用此 id 才能保證永久不壞
|
||||
// 兩者都可以在 workflow 中引用,Registry 會互相解析
|
||||
canonical_id: z.string().min(1).regex(/^[a-z][a-z0-9_]*$/, 'canonical_id 必須為小寫底線格式'),
|
||||
display_name: z.string().min(1),
|
||||
// category 擴充:auth (auth primitive)、ai (Claude/AI 推論)、platform (平台底層 crypto/system)
|
||||
category: z.enum(['logic', 'api', 'ui', 'style', 'anim', 'data', 'auth', 'ai', 'platform']),
|
||||
version: z.string().min(1).regex(/^v\d+$/, 'version 格式必須為 vN'),
|
||||
wasi_target: z.literal('preview1'),
|
||||
stability: z.enum(['floating', 'stable', 'pinned']),
|
||||
runtime_compat: z.array(z.enum(['cf-workers', 'workerd', 'wazero'])).min(1),
|
||||
constraints: ConstraintsSchema,
|
||||
input_schema: z.record(z.unknown()),
|
||||
output_schema: z.record(z.unknown()),
|
||||
gherkin_tests: z.array(GherkinTestSchema).min(2, '至少需要一個 happy path 和一個 error path'),
|
||||
// 選填欄位
|
||||
component_type: z.enum(['wasm', 'service_binding']).optional(),
|
||||
max_size_kb: z.number().optional(),
|
||||
max_cold_start_ms: z.number().optional(),
|
||||
no_network_syscall: z.boolean().optional(),
|
||||
service_binding_key: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
// aliases:搜尋同義詞,不作為識別符使用
|
||||
// 從 registry/aliases.yaml 的 scope 同義詞表自動合併,也可在 contract 內手動補充
|
||||
// 未來接入 KBDB 後,canonical_id 將獲得系統派發的唯一 hash id
|
||||
aliases: z.array(z.string()).optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type ComponentContract = z.infer<typeof ComponentContractSchema>;
|
||||
|
||||
// ── 沙盒驗收步驟 ─────────────────────────────────────────────────────────────
|
||||
|
||||
export type SandboxStep = 'fake_component_scan' | 'size_check' | 'cold_start' | 'syscall_scan' | 'gherkin_tests' | 'runtime_compat';
|
||||
|
||||
export interface SandboxResult {
|
||||
success: boolean;
|
||||
failed_step?: SandboxStep;
|
||||
reason?: string;
|
||||
guide_anchor?: string;
|
||||
// 未真正驗證的步驟(mock,禁假綠 §3c/§7)。success 仍可能為 true,但這些步驟沒實際跑。
|
||||
unimplemented_steps?: SandboxStep[];
|
||||
// 驗收通過後回傳兩個 id:
|
||||
component_hash_id: string; // cmp_{8碼hex},workflow 引用用,永久不變
|
||||
canonical_id: string; // 可讀名稱,搜尋用
|
||||
version: string;
|
||||
}
|
||||
|
||||
// ── KBDB Block 格式 ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface KbdbBlock {
|
||||
block_id: string;
|
||||
template_id: string;
|
||||
user_id?: string;
|
||||
page_name?: string;
|
||||
}
|
||||
|
||||
export interface KbdbSlots {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
// ── 禁止的 WASM syscall(網路 + 檔案系統)────────────────────────────────────
|
||||
|
||||
export const FORBIDDEN_SYSCALLS = [
|
||||
'sock_connect',
|
||||
'sock_accept',
|
||||
'sock_recv',
|
||||
'sock_send',
|
||||
'sock_shutdown',
|
||||
'fd_open',
|
||||
'path_open',
|
||||
'path_create_directory',
|
||||
'path_remove_directory',
|
||||
'path_rename',
|
||||
'path_unlink_file',
|
||||
'path_filestat_get',
|
||||
'path_filestat_set_times',
|
||||
'path_link',
|
||||
'path_readlink',
|
||||
'path_symlink',
|
||||
] as const;
|
||||
Reference in New Issue
Block a user