diff --git a/external_plugins/cloudflare-clef/.zcode-plugin/plugin.json b/external_plugins/cloudflare-clef/.zcode-plugin/plugin.json new file mode 100644 index 0000000..8429ab2 --- /dev/null +++ b/external_plugins/cloudflare-clef/.zcode-plugin/plugin.json @@ -0,0 +1,16 @@ +{ + "name": "cloudflare-clef", + "version": "0.3.0", + "description": "Decision evaluation on Cloudflare Workers AI CLEF / CLEF Flash, exposed as the MCP tool clef_evaluate. Credentials are DPAPI-encrypted at rest. Images attach by absolute local file path or embedded data URL. Protocol layer ported verbatim from the MIT-licensed OpenClaw plugin openclaw-cloudflare-clef 0.1.3.", + "description_i18n": { + "en": "Decision evaluation on Cloudflare Workers AI CLEF / CLEF Flash, exposed as the MCP tool clef_evaluate. Credentials are DPAPI-encrypted at rest. Images attach by absolute local file path or embedded data URL. Protocol layer ported verbatim from the MIT-licensed OpenClaw plugin openclaw-cloudflare-clef 0.1.3.", + "zh-CN": "基于 Cloudflare Workers AI CLEF / CLEF Flash 的结构化决策评测 MCP 工具 clef_evaluate,凭据经 DPAPI 加密落盘。图片支持绝对路径本地文件或内嵌 data URL 两种挂载方式。协议层逐字节移植自 MIT 许可的 OpenClaw 插件 openclaw-cloudflare-clef 0.1.3。" + }, + "author": { "name": "Ported from OpenClaw openclaw-cloudflare-clef 0.1.3" }, + "mcpServers": { + "cloudflare-clef": { + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/server/launch.mjs"] + } + } +} diff --git a/external_plugins/cloudflare-clef/README.md b/external_plugins/cloudflare-clef/README.md new file mode 100644 index 0000000..fd2fa79 --- /dev/null +++ b/external_plugins/cloudflare-clef/README.md @@ -0,0 +1,78 @@ +# Cloudflare CLEF Decisions (ZCode plugin) + +[中文文档](./README_CN.md) + +Ported from the OpenClaw decision-provider plugin `openclaw-cloudflare-clef` (0.1.3): structured decision evaluation (boolean / choice / score) on Cloudflare Workers AI CLEF-family models, returning per-question answers with calibrated probabilities instead of free-text guesses. + +- The protocol layer (`server/protocol.mjs`) is **byte-identical** to the original; all 15 protocol unit tests pass. +- The OpenClaw host integration is rewritten as a ZCode **MCP stdio server** (zero npm dependencies) exposing the tool `clef_evaluate`. +- Credentials are **DPAPI-encrypted at rest** — no plaintext on disk — filling the gap left by ZCode not shipping a secret store like OpenClaw's. + +## Prerequisites (Cloudflare side) + +1. **Account ID**: Cloudflare dashboard → Workers & Pages overview, right side; 32 hex characters. +2. **API Token**: dashboard avatar → My Profile → API Tokens → Create Token, permission **Workers AI → Run**, scoped to your account. + +## Credentials (DPAPI-encrypted store, recommended) + +Run once in PowerShell from the plugin directory (interactive, token input hidden): + +```powershell +powershell -ExecutionPolicy Bypass -File server\store-secret.ps1 +``` + +Both values are stored DPAPI-encrypted at `%USERPROFILE%\.zcode\secrets\cloudflare-clef.json`, **bound to the current Windows user** — the file is useless if copied to another machine or account, and no plaintext ever touches the disk. Re-run to rotate; delete the file to revoke. + +Then **fully quit and restart ZCode**. Startup chain: `launch.mjs` decrypts the two values via PowerShell and injects them into the process environment (MCP traffic never passes through PowerShell), then loads the server. + +Alternative: set user-level environment variables `CLOUDFLARE_ACCOUNT_ID` / `CLOUDFLARE_API_TOKEN` (plaintext in the registry; not recommended). Real environment variables take precedence over the encrypted store. + +Node.js ≥ 22 must be on PATH. + +**Honest security boundary**: DPAPI protects against file exfiltration (backups, cloud sync, copied disks), not against other processes running as the same Windows user (Windows has no stronger non-interactive local option). Credentials and submitted evidence are never logged; logs additionally redact token/secret/api-key-style keys by pattern. + +## Cost + +- Workers Free is recommended: 10,000 Neurons per UTC day, hard platform limit when exhausted; the free allowance is shared with other Workers AI usage on the account. +- Workers Paid also includes the free allowance but bills beyond it; this plugin does not change your plan and does not guarantee zero spend on Paid accounts. +- Official pricing as of 2026-10-20: CLEF Flash 3,455 Neurons / M input tokens; CLEF 21,818 / M. Input-only billing. + +## Usage + +After installation, ask the agent to call `clef_evaluate`, for example: + +```json +{ + "model": "clef-flash", + "state": "Cargo not yet telex-released, ETA tomorrow, needs origin-port follow-up.", + "questions": { + "urgent": { "type": "boolean", "instructions": "Is this urgent?" }, + "route": { "type": "choice", "criteria": { "customs": "Customs clearance", "ocean": "Ocean freight" } }, + "severity": { "type": "score", "criteria": ["None", "Moderate", "Critical"] } + } +} +``` + +Returns `{model, answers, usage}`; answers are keyed by the original question ids, choice/score include full probability distributions, boolean returns `probabilityTrue`. Omitting `model` uses `clef-flash`. + +Images in state attach three ways: embedded data URLs, `{content_type,base64}` objects, or **absolute local file paths** (`.png/.jpg/.jpeg/.webp`; the string must be exactly the file path). Path-form images are read from disk by the server and embedded as `[image #N]` — chat models cannot reliably emit large base64 blobs, so prefer paths for real screenshots. A missing path or a file over 4 MiB returns a clear unsupported-input error. + +## Translation and boundaries (same as the original) + +- boolean → noul → probabilityTrue; choice keeps all probabilities; score index objects become arrays. +- Question and option ids are remapped on the wire (Chinese and slashes allowed) and restored in results. +- Request URL is fixed to `api.cloudflare.com` (SSRF excluded by construction), redirects disabled; 55 s timeout; MCP request cancellation supported. +- No retries, no background polling, no fallback to paid models; 429 surfaces a rate-limit error (with retry-after hint). +- Text/JSON evidence plus up to 4 images (PNG/JPEG/WebP, 4 MiB each, 8 MiB total, 16 MP limit); absolute-path image files referenced in state are read from local disk at evaluation time (png/jpg/jpeg/webp only, must exist, ≤ 4 MiB). +- Conservative input budget: UTF-8 bytes + 1,024 + 128 per alternative ≤ 24,000 (adapter-side approximation, not a vendor tokenizer). +- Response bodies are read with a 1 MiB cap. + +## Verification + +`node --test server/protocol.test.mjs` covers protocol translation and rejection of malformed responses (15/15 passing). MCP handshake, tools/list, missing-credential guidance, and the DPAPI store→decrypt→call chain (including a real request and 401 mapping) were smoke-tested. **Live end-to-end verification with valid credentials is pending** — after installing and storing credentials, the first real call is that verification. + +## Sources + +- https://developers.cloudflare.com/workers-ai/models/clef-flash/ +- https://developers.cloudflare.com/workers-ai/models/clef/ +- https://developers.cloudflare.com/workers-ai/platform/pricing/ diff --git a/external_plugins/cloudflare-clef/README_CN.md b/external_plugins/cloudflare-clef/README_CN.md new file mode 100644 index 0000000..8cc7aa6 --- /dev/null +++ b/external_plugins/cloudflare-clef/README_CN.md @@ -0,0 +1,76 @@ +# Cloudflare CLEF 决策评测(ZCode 插件) + +移植自 OpenClaw decision-provider 插件 `openclaw-cloudflare-clef`(0.1.3):用 Cloudflare Workers AI 的 CLEF 系列模型做结构化决策评测(boolean / choice / score),返回带校准概率的答案,而不是让聊天模型自由发挥。 + +- 协议层(`server/protocol.mjs`)与原插件**逐字节一致**,15 个协议单测全部通过。 +- OpenClaw 宿主集成层改写为 ZCode 的 **MCP stdio server**(零 npm 依赖),以工具 `clef_evaluate` 暴露。 +- 凭据用 **Windows DPAPI 加密落盘**,静态存储无明文——弥补 ZCode 没有 OpenClaw secret store 的空缺。 + +## 安装前置(Cloudflare 侧) + +1. **Account ID**:Cloudflare 仪表盘 → Workers & Pages 概览页右侧,32 位十六进制。 +2. **API Token**:仪表盘右上角头像 → My Profile → API Tokens → Create Token,权限选 **Workers AI → Run**(作用域限定到你的账户)。 + +## 凭据配置(DPAPI 加密存储,推荐) + +在 PowerShell 里运行插件目录下的 `server\store-secret.ps1`(交互式输入,Token 不回显): + +```powershell +powershell -ExecutionPolicy Bypass -File "<插件安装目录>\server\store-secret.ps1" +``` + +凭据以 DPAPI 密文存于 `%USERPROFILE%\.zcode\secrets\cloudflare-clef.json`,**绑定当前 Windows 用户**——文件被拷到别的机器或别的账户无法解密,磁盘上任何时刻都没有明文。轮换凭据就重跑一次脚本;删除该文件即吊销。 + +之后**完全退出并重启 ZCode**。插件启动链路:`launch.mjs` 启动时经 PowerShell 解密两条凭据注入本进程环境变量(MCP 通信完全不经过 PowerShell),再加载 server。 + +替代方案:也可以设用户级环境变量 `CLOUDFLARE_ACCOUNT_ID` / `CLOUDFLARE_API_TOKEN`(明文落注册表,不推荐);两者同时存在时优先用加密文件。 + +需要 Node.js ≥ 22 在 PATH 中(本机已确认 v26.12.1 可用)。 + +**安全边界(如实说明)**:DPAPI 防的是文件泄露、备份、网盘同步;当前 Windows 用户下的进程仍可解密(Windows 本地无更强的免交互方案)。凭据与提交的证据永不写日志,日志另有按 `token/secret/api-key` 键名的自动脱敏。 + +## 费用 + +- 建议 Workers Free:10,000 Neurons/UTC 日免费,用尽后平台硬限制;免费量与账户内其他 Workers AI 用量共享。 +- Workers Paid 同样有免费量,但超额收费;本插件不改变账户套餐,不保证 Paid 账户零支出。 +- 2026-10-20 官方价:CLEF Flash 3,455 Neurons / M input tokens;CLEF 21,818 Neurons / M input tokens。仅按输入计价。 + +## 用法 + +安装后在会话中让模型调用 `clef_evaluate`,例如: + +```json +{ + "model": "clef-flash", + "state": "货物尚未电放,ETA 明天,需要联系起运港。", + "questions": { + "是否紧急/urgent": { "type": "boolean", "instructions": "Is this urgent?" }, + "route": { "type": "choice", "criteria": { "关务/customs": "Customs clearance", "海运/ocean": "Ocean freight" } }, + "severity": { "type": "score", "criteria": ["None", "Moderate", "Critical"] } + } +} +``` + +返回 `{model, answers, usage}`;答案以原始问题 id 为键,choice/score 附带完整概率分布,boolean 返回 `probabilityTrue`。`model` 省略时用 `clef-flash`。 + +state 中的图片有三种挂载方式:内嵌 data URL、`{content_type,base64}` 对象,或**绝对路径本地文件**(`.png/.jpg/.jpeg/.webp`,字符串须精确等于文件路径)。路径方式由 server 读盘嵌入并以 `[image #N]` 占位——聊天模型无法可靠生成大段 base64,实际截图请优先用路径;路径不存在或超过 4 MiB 会返回明确的 unsupported-input 错误。 + +## 翻译与边界(与原插件一致) + +- boolean → noul → probabilityTrue;choice 保留全部概率;score 的索引对象转数组。 +- 重新映射问题与选项 ID,允许中文、斜杠等标识,恢复结果原 ID。 +- 请求 URL 固定为 `api.cloudflare.com`(构造上排除 SSRF),禁止重定向;55 秒超时;支持 MCP 请求取消。 +- 无重试、无后台轮询、无收费模型自动回退;429 返回限流错误(含 retry-after 提示)。 +- 支持文本/JSON 证据,最多 4 张图片(PNG/JPEG/WebP,单张 4 MiB、合计 8 MiB、16 MP 上限);state 中以绝对路径引用的本地图片文件会在评测时被读取(仅 png/jpg/jpeg/webp,文件须存在且 ≤ 4 MiB)。 +- 保守输入预算:UTF-8 字节 + 1,024 + 每候选 128 ≤ 24,000(接入侧近似保护,非厂商 tokenizer 精确计算)。 +- 响应体读取上限 1 MiB。 + +## 验证 + +`node --test server/protocol.test.mjs` 验证协议转换与拒绝异常响应(15/15 通过)。已另做 MCP 握手、tools/list、无凭据指引、DPAPI 存取全链路(含真实请求与 401 映射)冒烟测试。**未做真实凭据端到端验证**——安装并存储凭据后,首次调用即是端到端验证。 + +## 来源 + +- https://developers.cloudflare.com/workers-ai/models/clef-flash/ +- https://developers.cloudflare.com/workers-ai/models/clef/ +- https://developers.cloudflare.com/workers-ai/platform/pricing/ diff --git a/external_plugins/cloudflare-clef/server/index.mjs b/external_plugins/cloudflare-clef/server/index.mjs new file mode 100644 index 0000000..eda2559 --- /dev/null +++ b/external_plugins/cloudflare-clef/server/index.mjs @@ -0,0 +1,278 @@ +#!/usr/bin/env node +// Cloudflare CLEF decision evaluation as a ZCode MCP tool. +// Ported from the OpenClaw cloudflare-clef decision provider: the OpenClaw host +// seams (registerDecisionProvider, secret store, guarded fetch) are replaced by +// a dependency-free stdio MCP server with credentials from environment variables. + +import { statSync, readFileSync } from "node:fs"; +import { extname, isAbsolute } from "node:path"; + +import { encodeBatch, decodeResponse, UnsupportedInput, InvalidResponse } from "./protocol.mjs"; + +const SERVER_NAME = "cloudflare-clef"; +const SERVER_VERSION = "0.3.0"; +const ACCOUNT_PATTERN = /^[a-fA-F0-9]{32}$/; +const REQUEST_TIMEOUT_MS = 55_000; +const MAX_RESPONSE_BYTES = 1024 * 1024; + +const TOOL = { + name: "clef_evaluate", + description: + "Evaluate structured decision questions against supplied evidence using Cloudflare Workers AI CLEF " + + "(stronger) or CLEF Flash (faster/cheaper). Returns per-question answers with calibrated probabilities " + + "instead of free-text guesses. Use for triage/routing/severity judgments over a concrete state. " + + "Question types: boolean (optional criteria {true,false}); choice (criteria map of 2-255 " + + "label→description entries); score (criteria array of 2-10 ordered level names, rated 0..n-1). " + + "state may be any JSON evidence and may attach up to 4 images as data:image/png|jpeg|webp;base64 URLs, " + + "{content_type,base64} objects, or absolute local file paths (.png/.jpg/.jpeg/.webp) that this server " + + "reads from disk and embeds on the caller's behalf.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["questions"], + properties: { + model: { + type: "string", + enum: ["clef", "clef-flash"], + default: "clef-flash", + description: "CLEF model to run. Omit for clef-flash (faster, cheaper).", + }, + state: { + description: + "The evidence to evaluate, as arbitrary JSON (string, array or object). May attach up to 4 images " + + "as data URLs, {content_type,base64} objects, or absolute local file paths (.png/.jpg/.jpeg/.webp) " + + "which the server reads and embeds; remaining text budget is ~24k bytes.", + }, + questions: { + type: "object", + minProperties: 1, + maxProperties: 64, + description: + "Map of question id → question. Ids may be any string (Chinese, slashes OK) and are echoed " + + "back in the answers. Example: {\"urgent\": {\"type\": \"boolean\", \"instructions\": \"Is this urgent?\"}}", + additionalProperties: { + type: "object", + additionalProperties: true, + required: ["type"], + properties: { + type: { type: "string", enum: ["boolean", "choice", "score"] }, + instructions: { type: "string", description: "Optional evaluation instruction; each type has a default." }, + criteria: { + description: + "boolean: optional {true, false} descriptions. choice: required map of 2-255 label→description. " + + "score: required array of 2-10 ordered level names.", + }, + }, + }, + }, + }, + }, +}; + +class ImagePathError extends Error {} + +const IMAGE_MIME_BY_EXTENSION = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" }; +const MAX_PATH_IMAGE_BYTES = 4 * 1024 * 1024; + +// Chat models cannot reliably emit megabytes of Base64 inside tool arguments, +// so exact absolute paths to existing local image files are resolved here into +// data URLs before encoding; every other string passes through untouched. +function resolveImagePaths(value) { + if (typeof value === "string") { + const mime = IMAGE_MIME_BY_EXTENSION[extname(value).toLowerCase()]; + if (mime === undefined || !isAbsolute(value)) return value; + let info; + try { + info = statSync(value); + } catch { + throw new ImagePathError(`${value}: image file not found; pass an absolute path to an existing local file`); + } + if (!info.isFile()) throw new ImagePathError(`${value}: not a regular file`); + if (info.size > MAX_PATH_IMAGE_BYTES) throw new ImagePathError(`${value}: exceeds the 4 MiB per-image limit`); + return `data:${mime};base64,` + readFileSync(value).toString("base64"); + } + if (Array.isArray(value)) return value.map(resolveImagePaths); + if (value !== null && typeof value === "object") { + // Null-prototype output keeps untrusted "__proto__" keys as plain data. + const out = Object.create(null); + for (const [key, item] of Object.entries(value)) out[key] = resolveImagePaths(item); + return out; + } + return value; +} + +function credentials() { + const accountId = typeof process.env.CLOUDFLARE_ACCOUNT_ID === "string" ? process.env.CLOUDFLARE_ACCOUNT_ID : ""; + const apiToken = typeof process.env.CLOUDFLARE_API_TOKEN === "string" ? process.env.CLOUDFLARE_API_TOKEN : ""; + return { accountId, apiToken, ok: ACCOUNT_PATTERN.test(accountId) && apiToken.trim().length > 0 }; +} + +function errText(message) { + return { isError: true, content: [{ type: "text", text: message }] }; +} + +function okText(text) { + return { content: [{ type: "text", text }] }; +} + +function setupMessage() { + return "cloudflare-clef is not configured: run the plugin's server/store-secret.ps1 once to store " + + "DPAPI-encrypted credentials (or set CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN as user " + + "environment variables), then fully restart ZCode."; +} + +async function cancelBody(response) { + try { await response.body?.cancel(); } catch {} +} + +// Cap successful response bodies too; never log submitted evidence or raw error bodies. +async function readTextLimited(response, limit) { + const reader = response.body.getReader(); + const chunks = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > limit) { + try { await reader.cancel(); } catch {} + throw new Error("Response body exceeds the adapter's read limit"); + } + chunks.push(Buffer.from(value)); + } + return Buffer.concat(chunks).toString("utf8"); +} + +async function evaluate(callId, args) { + const { accountId, apiToken, ok } = credentials(); + if (!ok) return errText(setupMessage()); + + const model = args?.model === "clef" ? "clef" : "clef-flash"; + let state = args?.state; + let request; + try { + if (state !== undefined) state = resolveImagePaths(state); + request = encodeBatch({ state, questions: args?.questions }, model); + } catch (e) { + if (e instanceof UnsupportedInput || e instanceof ImagePathError) return errText(`unsupported-input: ${e.message}`); + throw e; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + activeCalls.set(callId, controller); + try { + // Fixed api.cloudflare.com URL by construction: no user-controlled hosts, no redirects. + const response = await fetch( + "https://api.cloudflare.com/client/v4/accounts/" + accountId + "/ai/run/@cf/cloudflare/" + model, + { + method: "POST", + redirect: "error", + signal: controller.signal, + headers: { Authorization: "Bearer " + apiToken, "Content-Type": "application/json" }, + body: request.body, + } + ); + if (!response.ok) { + await cancelBody(response); + if ([401, 403].includes(response.status)) return errText(`authentication failed (HTTP ${response.status}): check the API token's Workers AI Run permission and account scope`); + if (response.status === 429) { + const retryAfter = Number(response.headers.get("retry-after")); + const suffix = Number.isFinite(retryAfter) && retryAfter >= 0 ? `; retry after up to ${Math.min(retryAfter, 60)}s` : ""; + return errText(`rate-limited (HTTP 429)${suffix}`); + } + if ([400, 413, 422].includes(response.status)) return errText(`unsupported-input: Cloudflare rejected the request (HTTP ${response.status})`); + return errText(`transport: Cloudflare API returned HTTP ${response.status}`); + } + const text = await readTextLimited(response, MAX_RESPONSE_BYTES); + let raw; + try { raw = JSON.parse(text); } catch { return errText("invalid-response: response was not valid JSON"); } + const result = decodeResponse(raw, request); + return okText(JSON.stringify(result, null, 2)); + } catch (e) { + if (e instanceof InvalidResponse) return errText("invalid-response: Cloudflare's decision payload failed validation"); + if (controller.signal.aborted) return errText("transport: request timed out or was cancelled"); + return errText(`transport: ${e && e.message ? e.message : "request failed"}`); + } finally { + clearTimeout(timer); + activeCalls.delete(callId); + } +} + +// --- Minimal dependency-free MCP stdio server (newline-delimited JSON-RPC 2.0). --- + +const activeCalls = new Map(); + +function send(message) { + process.stdout.write(JSON.stringify(message) + "\n"); +} + +function handleRequest(msg) { + const { method, params, id } = msg; + if (method === "initialize") { + send({ + jsonrpc: "2.0", + id, + result: { + protocolVersion: typeof params?.protocolVersion === "string" && params.protocolVersion ? params.protocolVersion : "2024-11-05", + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, + }, + }); + return; + } + if (method === "ping") { + send({ jsonrpc: "2.0", id, result: {} }); + return; + } + if (method === "tools/list") { + send({ jsonrpc: "2.0", id, result: { tools: [TOOL] } }); + return; + } + if (method === "tools/call") { + if (params?.name !== TOOL.name) { + send({ jsonrpc: "2.0", id, error: { code: -32602, message: `Unknown tool: ${String(params?.name)}` } }); + return; + } + evaluate(id, params.arguments ?? {}).then( + (result) => send({ jsonrpc: "2.0", id, result }), + (e) => send({ jsonrpc: "2.0", id, error: { code: -32603, message: e && e.message ? e.message : "Internal error" } }) + ); + return; + } + send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } }); +} + +function handleLine(line) { + let msg; + try { msg = JSON.parse(line); } catch { return; } + if (!msg || typeof msg !== "object" || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") return; + if (msg.id === undefined || msg.id === null) { + if (msg.method === "notifications/cancelled") { + // Cancellation is best-effort: only an in-flight fetch has an aborter wired. + const controller = activeCalls.get(msg.params?.requestId); + if (controller) controller.abort(); + } + return; + } + handleRequest(msg); +} + +let buffer = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let index; + while ((index = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, index); + buffer = buffer.slice(index + 1); + if (line.trim()) handleLine(line); + } +}); +process.stdin.on("end", () => { + // Drain in-flight tool calls before exiting; the per-call timeout is the backstop. + const exitWhenIdle = () => { if (activeCalls.size === 0) process.exit(0); }; + exitWhenIdle(); + setInterval(exitWhenIdle, 100); +}); +process.stdin.resume(); diff --git a/external_plugins/cloudflare-clef/server/launch.mjs b/external_plugins/cloudflare-clef/server/launch.mjs new file mode 100644 index 0000000..70dcf22 --- /dev/null +++ b/external_plugins/cloudflare-clef/server/launch.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +// Entry point: decrypt DPAPI-protected credentials into the environment, then run the +// MCP server in-process. MCP stdio traffic never passes through PowerShell; only two +// short ciphertexts do, at startup. Existing real environment variables win, so the +// secrets file stays optional. +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const secretPath = join(homedir(), ".zcode", "secrets", "cloudflare-clef.json"); + +const psQuote = (value) => "'" + String(value).replace(/'/g, "''") + "'"; + +function haveEnv(name) { + return typeof process.env[name] === "string" && process.env[name].length > 0; +} + +function decryptStored(protectedString) { + // ConvertTo-SecureString without -Key uses DPAPI CurrentUser scope: the ciphertext + // only decrypts for the Windows user that ran store-secret.ps1 on this machine. + const script = [ + "$ErrorActionPreference='Stop'", + "function U($s){$p=ConvertTo-SecureString -String $s;" + + "$b=[Runtime.InteropServices.Marshal]::SecureStringToBSTR($p);" + + "try{[Runtime.InteropServices.Marshal]::PtrToStringBSTR($b)}" + + "finally{[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($b)}}", + "U " + psQuote(protectedString), + ].join("; "); + const result = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { + encoding: "utf8", + windowsHide: true, + timeout: 15_000, + }); + const value = (result.stdout ?? "").trim(); + return result.status === 0 && value ? value : null; +} + +function loadStoredCredentials() { + if (!haveEnv("CLOUDFLARE_ACCOUNT_ID") || !haveEnv("CLOUDFLARE_API_TOKEN")) { + if (existsSync(secretPath)) { + let stored; + try { stored = JSON.parse(readFileSync(secretPath, "utf8")); } catch { stored = null; } + if (stored && typeof stored.accountId === "string" && typeof stored.apiToken === "string") { + const accountId = decryptStored(stored.accountId); + const apiToken = decryptStored(stored.apiToken); + if (accountId && apiToken) { + process.env.CLOUDFLARE_ACCOUNT_ID = accountId; + process.env.CLOUDFLARE_API_TOKEN = apiToken; + } + } + } + } +} + +loadStoredCredentials(); +await import("./index.mjs"); diff --git a/external_plugins/cloudflare-clef/server/protocol.mjs b/external_plugins/cloudflare-clef/server/protocol.mjs new file mode 100644 index 0000000..1ee0691 --- /dev/null +++ b/external_plugins/cloudflare-clef/server/protocol.mjs @@ -0,0 +1,303 @@ + +/** SDK-free translation of OpenClaw's decision contract to CLEF's System One API. */ +export class UnsupportedInput extends Error {} +export class InvalidResponse extends Error {} +const own = (o,k) => Object.hasOwn(o,k); +const object = x => x !== null && typeof x === "object" && !Array.isArray(x); +const probability = x => typeof x === "number" && Number.isFinite(x) && x >= 0 && x <= 1; +const fail = () => { throw new InvalidResponse("Invalid CLEF decision response"); }; +const MAX_IMAGES = 4; +const MAX_IMAGE_BYTES = 4 * 1024 * 1024; +const MAX_TOTAL_IMAGE_BYTES = 8 * 1024 * 1024; +const MAX_IMAGE_PIXELS = 16_000_000; +const MAX_BODY_BYTES = 13 * 1024 * 1024; +const CONTEXT_BUDGET = 24_000; +const MAX_IMAGE_TOKENS = 1024; +const MAX_BASE64_LENGTH = 4 * Math.ceil(MAX_IMAGE_BYTES / 3); +const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +const invalidImage = () => { throw new UnsupportedInput("Invalid or unsupported image data"); }; +const validateDimensions = (width,height) => { + if (!width || !height || width * height > MAX_IMAGE_PIXELS) + throw new UnsupportedInput("Image exceeds CLEF's 16 megapixel limit or has invalid dimensions"); +}; + +// Inspect container headers without expanding pixel data. This bounds memory and +// checks the documented pixel limit; the service still performs full decoding. +function imageDimensions(bytes, contentType) { + let width, height; + if (contentType === "image/png") { + const signature = Buffer.from("89504e470d0a1a0a", "hex"); + if (bytes.length < 33 || !bytes.subarray(0,8).equals(signature) || + bytes.readUInt32BE(8) !== 13 || bytes.toString("ascii",12,16) !== "IHDR") invalidImage(); + width = bytes.readUInt32BE(16); height = bytes.readUInt32BE(20); + validateDimensions(width,height); + let offset = 8, sawData = false, sawEnd = false; + while (offset + 12 <= bytes.length) { + const length = bytes.readUInt32BE(offset); + if (length > bytes.length - offset - 12) invalidImage(); + const type = bytes.toString("ascii",offset + 4,offset + 8); + if (offset !== 8 && type === "IHDR") invalidImage(); + if (type === "IDAT") sawData = true; + if (type === "IEND") { + if (length !== 0 || !sawData || offset + 12 !== bytes.length) invalidImage(); + sawEnd = true; break; + } + offset += length + 12; + } + if (!sawEnd) invalidImage(); + } else if (contentType === "image/jpeg") { + if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) invalidImage(); + let offset = 2, sawScan = false; + while (offset < bytes.length) { + if (bytes[offset++] !== 0xff) invalidImage(); + while (offset < bytes.length && bytes[offset] === 0xff) offset++; + if (offset >= bytes.length) invalidImage(); + const marker = bytes[offset++]; + if (marker === 0xd9 || marker === 0x00 || marker === 0xd8) invalidImage(); + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue; + if (offset + 2 > bytes.length) invalidImage(); + const length = bytes.readUInt16BE(offset); + if (length < 2 || length > bytes.length - offset) invalidImage(); + if ([0xc0,0xc1,0xc2,0xc3,0xc5,0xc6,0xc7,0xc9,0xca,0xcb,0xcd,0xce,0xcf].includes(marker)) { + if (length < 8) invalidImage(); + height = bytes.readUInt16BE(offset + 3); width = bytes.readUInt16BE(offset + 5); + validateDimensions(width,height); + } + if (marker === 0xda) { + if (width === undefined || bytes.lastIndexOf(Buffer.from([0xff,0xd9])) < offset + length) invalidImage(); + sawScan = true; break; + } + offset += length; + } + if (!sawScan) invalidImage(); + } else if (contentType === "image/webp") { + if (bytes.length < 20 || bytes.toString("ascii",0,4) !== "RIFF" || + bytes.toString("ascii",8,12) !== "WEBP" || bytes.readUInt32LE(4) + 8 !== bytes.length) invalidImage(); + let offset = 12, sawImage = false, canvas; + while (offset + 8 <= bytes.length) { + const type = bytes.toString("ascii",offset,offset + 4), length = bytes.readUInt32LE(offset + 4); + const start = offset + 8; + if (length > bytes.length - start || start + length + (length & 1) > bytes.length) invalidImage(); + if (type === "VP8X") { + if (length !== 10) invalidImage(); + canvas = [bytes.readUIntLE(start + 4,3) + 1, bytes.readUIntLE(start + 7,3) + 1]; + validateDimensions(...canvas); + } else if (type === "VP8 ") { + if (length < 10 || bytes[start + 3] !== 0x9d || bytes[start + 4] !== 0x01 || bytes[start + 5] !== 0x2a) invalidImage(); + width = bytes.readUInt16LE(start + 6) & 0x3fff; height = bytes.readUInt16LE(start + 8) & 0x3fff; + validateDimensions(width,height); + sawImage = true; + } else if (type === "VP8L") { + if (length < 5 || bytes[start] !== 0x2f) invalidImage(); + const bits = bytes.readUInt32LE(start + 1); + width = (bits & 0x3fff) + 1; height = ((bits >>> 14) & 0x3fff) + 1; + validateDimensions(width,height); + sawImage = true; + } else if (type === "ANMF") { + if (length < 16) invalidImage(); + validateDimensions(bytes.readUIntLE(start + 6,3) + 1,bytes.readUIntLE(start + 9,3) + 1); + sawImage = true; + } + offset = start + length + (length & 1); + } + if (offset !== bytes.length || !sawImage) invalidImage(); + if (canvas) [width,height] = canvas; + } else invalidImage(); + validateDimensions(width,height); + return {width,height}; +} + +function decodeImageBase64(value, totalBytes) { + // Reject excessive encodings before copying or allocating their decoded bytes. + if (value.length > MAX_BODY_BYTES) throw new UnsupportedInput("Image exceeds CLEF's 4 MiB limit"); + const encoded = value.replace(/[\t\r\n ]/g, ""); + if (encoded.length > MAX_BASE64_LENGTH) throw new UnsupportedInput("Image exceeds CLEF's 4 MiB limit"); + if (!encoded || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) invalidImage(); + const pad = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0; + const chars = encoded.length - pad, remainder = chars % 4; + if (remainder === 1 || (pad && (encoded.length % 4 !== 0 || pad !== 4 - remainder))) invalidImage(); + const last = BASE64_ALPHABET.indexOf(encoded[chars - 1]); + if ((remainder === 2 && (last & 15)) || (remainder === 3 && (last & 3))) invalidImage(); + const size = Math.floor(chars * 3 / 4); + if (size > MAX_IMAGE_BYTES) throw new UnsupportedInput("Image exceeds CLEF's 4 MiB limit"); + if (totalBytes + size > MAX_TOTAL_IMAGE_BYTES) throw new UnsupportedInput("Images exceed CLEF's 8 MiB total limit"); + const canonical = pad ? encoded : encoded + "=".repeat((4 - remainder) % 4); + const bytes = Buffer.from(canonical, "base64"); + if (bytes.length !== size) invalidImage(); + return {canonical,bytes}; +} + +export function encodeBatch(batch, model, checkpoint = () => {}) { + checkpoint(); + if (!["clef","clef-flash"].includes(model) || !object(batch) || !object(batch.questions)) + throw new UnsupportedInput("Unsupported model or questions"); + const entries = Object.entries(batch.questions); + if (!entries.length || entries.length > 64) throw new UnsupportedInput("CLEF accepts 1–64 questions"); + // Images are transported separately from text, with 1-based references in + // the original context. Traverse only fields actually sent to CLEF. + const images = []; + let totalImageBytes = 0, textBytes = 0; + const accountText = value => { + textBytes += Buffer.byteLength(value,"utf8"); + if (textBytes > CONTEXT_BUDGET) + throw new UnsupportedInput("Evidence exceeds this adapter's conservative context budget"); + return value; + }; + const addImage = (contentType, encoded, asObject, requireExactEnd = false) => { + checkpoint(); + contentType = contentType.toLowerCase(); + if (!["image/png","image/jpeg","image/webp"].includes(contentType)) invalidImage(); + if (images.length >= MAX_IMAGES) throw new UnsupportedInput("CLEF accepts at most 4 images"); + const {canonical,bytes} = decodeImageBase64(encoded,totalImageBytes); + checkpoint(); + imageDimensions(bytes,contentType); + // Only use the whitespace-folded interpretation when it consumes the + // complete JPEG; otherwise trailing prose could be mistaken for Base64. + if (requireExactEnd && contentType === "image/jpeg" && + (bytes[bytes.length-2] !== 0xff || bytes[bytes.length-1] !== 0xd9)) invalidImage(); + totalImageBytes += bytes.length; + images.push(asObject ? {content_type:contentType,base64:canonical} : `data:${contentType};base64,${canonical}`); + return `[image #${images.length}]`; + }; + const transform = (x) => { + checkpoint(); + if (typeof x === "string") { + if (x.length > MAX_BODY_BYTES + CONTEXT_BUDGET) + throw new UnsupportedInput("Evidence exceeds CLEF's size limits"); + // Preserve the previously supported standalone, line-wrapped Base64 + // form, while retaining prose that follows a complete data URL. + const start = x.search(/\S/), end = x.trimEnd().length; + const whole = start < 0 ? null : /^data:image\/([A-Za-z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)$/i.exec(x.slice(start,end)); + if (whole && /\s/.test(whole[2])) { + let reference; + try { reference = addImage("image/" + whole[1],whole[2],false,true); } + catch (e) { if (!(e instanceof UnsupportedInput)) throw e; } + if (reference !== undefined) return accountText(x.slice(0,start) + reference + x.slice(end)); + } + const prefix = /data:image\/([A-Za-z0-9.+-]+);base64,/gi; + let match, cursor = 0, result = ""; + while ((match = prefix.exec(x)) !== null) { + const data = /^[A-Za-z0-9+/=]+/.exec(x.slice(prefix.lastIndex,prefix.lastIndex + MAX_BASE64_LENGTH + 1)); + if (!data) invalidImage(); + result += x.slice(cursor,match.index) + addImage("image/" + match[1],data[0],false); + cursor = prefix.lastIndex + data[0].length; + prefix.lastIndex = cursor; + } + return accountText(cursor ? result + x.slice(cursor) : x); + } + if (Array.isArray(x)) { + if (x.length > CONTEXT_BUDGET / 2) throw new UnsupportedInput("Evidence exceeds this adapter's conservative context budget"); + return x.map(transform); + } + if (x !== null && typeof x === "object") { + const keys = Object.keys(x); + if (keys.length > CONTEXT_BUDGET / 4) throw new UnsupportedInput("Evidence exceeds this adapter's conservative context budget"); + if (own(x,"content_type") && own(x,"base64") && + typeof x.content_type === "string" && /^image\//i.test(x.content_type)) { + if (typeof x.base64 !== "string") invalidImage(); + const reference = addImage(x.content_type,x.base64,true); + if (keys.length === 2) return accountText(reference); + const o = Object.create(null); + for (const k of keys) { accountText(k); o[k] = k === "base64" ? accountText(reference) : transform(x[k]); } + return o; + } + const o = Object.create(null); + for (const k of keys) { accountText(k); o[k] = transform(x[k]); } + return o; + } + return x; + }; + // State images precede question images in the array; all placeholders use the + // same order. JSON member order is not required by the CLEF API. + const state = transform(batch.state); + const questions = Object.create(null), mapping = []; + let alternatives = 0; + for (const [id,qRaw] of entries) { + checkpoint(); + if (!object(qRaw)) throw new UnsupportedInput("Unsupported question type"); + const q = {type:qRaw.type}; + const wireId = "q" + mapping.length; + const instructions = transform(qRaw.instructions ?? ( + q.type === "boolean" ? "Does the supplied state satisfy the true criterion rather than the false criterion?" : + q.type === "choice" ? "Choose the best matching criterion for the supplied state." : + "Rate the supplied state on the ordered criteria from lowest to highest." + )); + if (q.type === "boolean") { + if (qRaw.criteria) q.criteria = transform(qRaw.criteria); + questions[wireId] = {type:"noul", instructions, ...(q.criteria ? {criteria:q.criteria}: {})}; + mapping.push({id,wireId,q}); alternatives += 2; + } else if (q.type === "choice") { + const keys = Object.keys(qRaw.criteria ?? {}); + if (keys.length < 2 || keys.length > 255) throw new UnsupportedInput("CLEF accepts 2–255 choices"); + const criteria = Object.create(null); + keys.forEach((key,i) => { criteria["c"+i] = {label:transform(key), description:transform(qRaw.criteria[key])}; }); + questions[wireId] = {type:"choice",instructions,criteria}; + mapping.push({id,wireId,q,keys}); alternatives += keys.length; + } else if (q.type === "score") { + if (!Array.isArray(qRaw.criteria) || qRaw.criteria.length < 2 || qRaw.criteria.length > 10 || qRaw.criteria.some(v=>v===null)) + throw new UnsupportedInput("CLEF accepts 2–10 non-null score criteria"); + q.criteria = transform(qRaw.criteria); + questions[wireId] = {type:"score",instructions,criteria:q.criteria}; + mapping.push({id,wireId,q}); alternatives += q.criteria.length; + } else throw new UnsupportedInput("Unsupported question type"); + } + const textPayload = {model,state,questions}; + // Binary images are visual tokens, not the Base64 characters used on the wire. + // Reserve Cloudflare's documented upper bound of 1,024 tokens per image. + if (Buffer.byteLength(JSON.stringify(textPayload),"utf8") + 1024 + 128*alternatives + MAX_IMAGE_TOKENS*images.length > CONTEXT_BUDGET) + throw new UnsupportedInput("Evidence exceeds this adapter's conservative context budget"); + const payload = {model,...(images.length ? {images}: {}),state,questions}; + checkpoint(); + const body = JSON.stringify(payload); + if (Buffer.byteLength(body,"utf8") > MAX_BODY_BYTES) + throw new UnsupportedInput("Request exceeds CLEF's 13 MiB body limit"); + checkpoint(); + return {payload,body,mapping}; +} +export function decodeResponse(envelope, request) { + if (!object(envelope) || envelope.success === false) return fail(); + const raw = own(envelope,"result") ? envelope.result : envelope; + if (!object(raw) || typeof raw.model !== "string" || raw.model !== request.payload.model || + !object(raw.answers) || Object.keys(raw.answers).length !== request.mapping.length) return fail(); + const answers = Object.create(null); + for (const {id,wireId,q,keys} of request.mapping) { + if (!own(raw.answers,wireId)) return fail(); + const a = raw.answers[wireId]; + if (!object(a)) return fail(); + if (q.type === "boolean") { + if (a.type !== "noul" || !probability(a.noul)) return fail(); + answers[id] = {type:"boolean",probabilityTrue:a.noul}; + } else { + if (a.type !== q.type || !object(a.probabilities)) return fail(); + const expected = q.type === "choice" ? keys.map((_,i)=>"c"+i) : q.criteria.map((_,i)=>String(i)); + if (Object.keys(a.probabilities).length !== expected.length || + expected.some(k=>!own(a.probabilities,k) || !probability(a.probabilities[k]))) return fail(); + const p = expected.map(k=>a.probabilities[k]); + if (Math.abs(p.reduce((s,v)=>s+v,0)-1) > 0.03) return fail(); + if (a.confidence !== undefined && !probability(a.confidence)) return fail(); + const confidence = a.confidence === undefined ? {} : {confidence:a.confidence}; + if (q.type === "choice") { + const index = expected.indexOf(a.choice); + if (index < 0) return fail(); + answers[id] = {type:"choice",choice:keys[index],probabilities:Object.fromEntries(keys.map((k,i)=>[k,p[i]])),...confidence}; + } else { + if (typeof a.score !== "number" || !Number.isFinite(a.score) || a.score < 0 || a.score > q.criteria.length-1) return fail(); + answers[id] = {type:"score",score:a.score,probabilities:p,...confidence}; + } + } + } + const result = {model:raw.model,answers}; + if (raw.usage !== undefined) { + if (!object(raw.usage)) return fail(); + const usage = {}; + for (const [wire,key] of [["input_tokens","inputTokens"],["output_tokens","outputTokens"]]) { + const value = raw.usage[wire]; + if (value !== undefined) { + if (!Number.isSafeInteger(value) || value < 0) return fail(); + usage[key] = value; + } + } + result.usage = usage; + } + return result; +} diff --git a/external_plugins/cloudflare-clef/server/protocol.test.mjs b/external_plugins/cloudflare-clef/server/protocol.test.mjs new file mode 100644 index 0000000..c1a78da --- /dev/null +++ b/external_plugins/cloudflare-clef/server/protocol.test.mjs @@ -0,0 +1,228 @@ + +import test from "node:test"; +import assert from "node:assert/strict"; +import {randomFillSync} from "node:crypto"; +import {deflateSync} from "node:zlib"; +import {encodeBatch,decodeResponse,UnsupportedInput,InvalidResponse} from "./protocol.mjs"; +const batch = { + state:"货物尚未电放,ETA 明天,需要联系起运港。", + questions:{ + "是否紧急/urgent":{type:"boolean",instructions:"Is this urgent?",criteria:{true:"ETA within 2 days with pending release",false:"No near-term impact"}}, + route:{type:"choice",instructions:"Choose a team",criteria:{"关务/customs":"Customs clearance","海运/ocean":"Ocean freight and cargo release"}}, + severity:{type:"score",instructions:"Rate severity",criteria:["None","Moderate","Critical"]} + } +}; +const MiB = 1024 * 1024; +const pngSignature = Buffer.from("89504e470d0a1a0a","hex"); +const crcTable = Uint32Array.from({length:256},(_,n) => { + let c=n; + for (let i=0;i<8;i++) c=(c>>>1)^((c&1)?0xedb88320:0); + return c>>>0; +}); +function pngChunk(type,data) { + const out=Buffer.alloc(data.length+12); + out.writeUInt32BE(data.length,0); out.write(type,4,"ascii"); data.copy(out,8); + let crc=0xffffffff; + for (let i=4;i>>8); + out.writeUInt32BE((crc^0xffffffff)>>>0,out.length-4); + return out; +} +function png(width,height,{targetSize,noise=false}={}) { + const rowBytes=noise ? width*3 : Math.ceil(width/8), stride=rowBytes+1; + const pixels=Buffer.alloc(stride*height); + if (noise) { randomFillSync(pixels); for (let i=0;in+b.length,0)+end.length; + assert.ok(targetSize>=current+12); + // A valid private ancillary chunk makes exact byte boundaries reproducible. + first.push(pngChunk("caNd",Buffer.alloc(targetSize-current-12,65))); + } + return Buffer.concat([...first,end]); +} +const jpeg = Buffer.from("/9j/4AAQSkZJRgABAQAAAAAAAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAACAAMDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAVAQEBAAAAAAAAAAAAAAAAAAAHCf/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/ADoDFU3/2Q==","base64"); +const webp = Buffer.from("UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoDAAIAAgA0JaACdLoB+AADsAD+8MQL/yC5YXXI1/8gP+QH/ID/+PIAAAA=","base64"); +const webpLossless = Buffer.from("UklGRhwAAABXRUJQVlA4TA8AAAAvAkAAAAcQ/Y/+ByKi/wEA","base64"); +const webpExtended = Buffer.from("UklGRmIAAABXRUJQVlA4WAoAAAAQAAAAAgAAAQAAQUxQSAcAAAAAAAAAAP8AAFZQOCA0AAAA0AEAnQEqAwACAAIANCWoAnRHAGSAA1AA/vDECsfS3YM0j/x//NEUzuhMfhH/NEUzugAAAA==","base64"); +const dataUrl = (type,bytes) => `data:image/${type};base64,${bytes.toString("base64")}`; +const imageBatch = state => ({state,questions:{q:{type:"boolean",instructions:"?"}}}); +function response() { + return {success:true,result:{model:"clef-flash",answers:{ + q0:{type:"noul",noul:0.97}, + q1:{type:"choice",choice:"c1",probabilities:{c0:0.08,c1:0.92},confidence:0.8}, + q2:{type:"score",score:1.7,legend:{"0":"None","1":"Moderate","2":"Critical"},probabilities:{"0":0.1,"1":0.1,"2":0.8},confidence:0.7} + },usage:{input_tokens:410,output_tokens:0}}}; +} +test("vendor System One fixture preserves probabilities, score order, unicode identifiers and usage",()=>{ + const req=encodeBatch(batch,"clef-flash"); + assert.deepEqual(Object.keys(req.payload.questions),["q0","q1","q2"]); + assert.equal(req.payload.questions.q0.type,"noul"); + const r=decodeResponse(response(),req); + assert.deepEqual(r.answers["是否紧急/urgent"],{type:"boolean",probabilityTrue:0.97}); + assert.equal(r.answers.route.choice,"海运/ocean"); + assert.deepEqual(r.answers.route.probabilities,{"关务/customs":0.08,"海运/ocean":0.92}); + assert.deepEqual(r.answers.severity.probabilities,[0.1,0.1,0.8]); + assert.deepEqual(r.usage,{inputTokens:410,outputTokens:0}); +}); +test("reject missing probabilities, nonfinite scores and foreign choices instead of inventing certainty",()=>{ + const req=encodeBatch(batch,"clef-flash"); + for (const mutate of [ + r=>{delete r.result.answers.q0;}, + r=>{r.result.answers.q0.noul=1.1;}, + r=>{r.result.answers.q1.choice="unlisted";}, + r=>{r.result.answers.q1.probabilities.c0=0.9;}, + r=>{r.result.answers.q2.score=Infinity;}, + r=>{delete r.result.answers.q2.probabilities["1"];}, + r=>{r.result.model="other";} + ]) { const r=response(); mutate(r); assert.throws(()=>decodeResponse(r,req),InvalidResponse); } +}); +test("reject oversized evidence and unsupported score rubrics before network I/O",()=>{ + assert.throws(()=>encodeBatch({...batch,state:"x".repeat(24576)},"clef-flash"),UnsupportedInput); + assert.throws(()=>encodeBatch({state:"x",questions:{s:{type:"score",criteria:Array(11).fill("x")}}},"clef"),UnsupportedInput); + assert.throws(()=>encodeBatch(batch,"not-a-clef-model"),UnsupportedInput); +}); +test("round-trips untrusted map keys without object prototype mutation",()=>{ + const criteria=JSON.parse('{"__proto__":"A","constructor":"B"}'); + const b={state:"x",questions:JSON.parse('{"__proto__":{"type":"choice","criteria":{"__proto__":"A","constructor":"B"}}}')}; + const req=encodeBatch(b,"clef-flash"); + const r=decodeResponse({model:"clef-flash",answers:{q0:{type:"choice",choice:"c0",probabilities:{c0:0.8,c1:0.2}}}},req); + assert.equal(r.answers["__proto__"].choice,"__proto__"); + assert.equal(Object.getPrototypeOf(r.answers),null); + assert.equal(Object.prototype.polluted,undefined); +}); + +test("extracts real PNG, JPEG and WebP images and preserves text and metadata",()=>{ + const pngUrl=dataUrl("png",png(2,2)), webpUrl=dataUrl("webp",webp); + const input={state:{caption:`Compare ${pngUrl} with ${webpUrl}.`, + photo:{content_type:"image/jpeg",base64:jpeg.toString("base64"),shipment_id:"GN42",description:"front"}, + text:{content_type:"text/plain",base64:"SGVsbG8=",description:"retain"}}, + questions:{q:{type:"boolean",instructions:"Do they match?"}}}; + const req=encodeBatch(input,"clef-flash"); + assert.deepEqual(Object.keys(req.payload),["model","images","state","questions"]); + assert.deepEqual(req.payload.images,[pngUrl,webpUrl,{content_type:"image/jpeg",base64:jpeg.toString("base64")}]); + assert.equal(req.payload.state.caption,"Compare [image #1] with [image #2]."); + assert.equal(req.payload.state.photo.base64,"[image #3]"); + assert.equal(req.payload.state.photo.shipment_id,"GN42"); + assert.equal(req.payload.state.photo.description,"front"); + assert.equal(req.payload.state.photo.content_type,"image/jpeg"); + assert.equal(req.payload.state.text.content_type,"text/plain"); + assert.equal(req.payload.state.text.base64,"SGVsbG8="); + const plain=encodeBatch(imageBatch("data:text/plain;base64,SGVsbG8="),"clef-flash"); + assert.equal("images" in plain.payload,false); + assert.equal(plain.payload.state,"data:text/plain;base64,SGVsbG8="); +}); + +test("normalizes line-wrapped standalone Base64 and preserves surrounding prose",()=>{ + const image=png(2,2), canonical=dataUrl("png",image); + const folded=image.toString("base64").match(/.{1,24}/g).join("\r\n"); + const req=encodeBatch(imageBatch(` data:image/png;base64,${folded} `),"clef"); + assert.equal(req.payload.state," [image #1] "); + assert.deepEqual(req.payload.images,[canonical]); + const trailing=encodeBatch(imageBatch(`${dataUrl("jpeg",jpeg)} and keep this text`),"clef"); + assert.equal(trailing.payload.state,"[image #1] and keep this text"); +}); + +test("passes an actual 2 MiB PNG without counting its Base64 as text",()=>{ + const image=png(900,800,{noise:true}); + assert.ok(image.length>2*MiB && image.length<4*MiB); + const req=encodeBatch(imageBatch(dataUrl("png",image)),"clef-flash"); + assert.equal(req.payload.images.length,1); + assert.equal(req.payload.state,"[image #1]"); + assert.ok(Buffer.byteLength(req.body)>2*MiB); +}); + +test("allows four images and rejects the fifth before sending",()=>{ + const image=dataUrl("png",png(1,1)); + assert.equal(encodeBatch(imageBatch(Array(4).fill(image)),"clef").payload.images.length,4); + assert.throws(()=>encodeBatch(imageBatch(Array(5).fill(image)),"clef"),/at most 4 images/); +}); + +test("enforces individual and aggregate decoded byte limits",()=>{ + const tiny=png(1,1), max=png(1,1,{targetSize:4*MiB}); + const oversized=png(1,1,{targetSize:4*MiB+1}); + const underMax=png(1,1,{targetSize:4*MiB-tiny.length+1}); + const maxUrl=dataUrl("png",max); + assert.equal(encodeBatch(imageBatch(maxUrl),"clef").payload.images.length,1); + assert.throws(()=>encodeBatch(imageBatch(dataUrl("png",oversized)),"clef"),/4 MiB/); + assert.throws(()=>encodeBatch(imageBatch(dataUrl("png",Buffer.alloc(4*MiB+2))),"clef"),/4 MiB/); + const total=encodeBatch(imageBatch([maxUrl,maxUrl]),"clef"); + assert.equal(total.payload.images.length,2); + assert.ok(Buffer.byteLength(total.body)<=13*MiB); + assert.throws(()=>encodeBatch(imageBatch([maxUrl,dataUrl("png",underMax),dataUrl("png",tiny)]),"clef"),/8 MiB/); + assert.throws(()=>encodeBatch(imageBatch("x".repeat(13*MiB+1)),"clef"),/context budget/); +}); + +test("enforces 16 megapixels for PNG, JPEG and extended WebP",()=>{ + assert.equal(encodeBatch(imageBatch(dataUrl("png",png(4000,4000))),"clef").payload.images.length,1); + assert.throws(()=>encodeBatch(imageBatch(dataUrl("png",png(4001,4000))),"clef"),/16 megapixel/); + const jpegTooLarge=Buffer.from(jpeg), sof=jpegTooLarge.indexOf(Buffer.from([0xff,0xc0])); + assert.ok(sof>=0); jpegTooLarge.writeUInt16BE(4000,sof+5); jpegTooLarge.writeUInt16BE(4001,sof+7); + assert.throws(()=>encodeBatch(imageBatch(dataUrl("jpeg",jpegTooLarge)),"clef"),/16 megapixel/); + const webpTooLarge=Buffer.from(webpExtended), vp8x=webpTooLarge.indexOf("VP8X"); + assert.ok(vp8x>=0); webpTooLarge.writeUIntLE(4000,vp8x+12,3); webpTooLarge.writeUIntLE(3999,vp8x+15,3); + assert.throws(()=>encodeBatch(imageBatch(dataUrl("webp",webpTooLarge)),"clef"),/16 megapixel/); + const frameTooLarge=Buffer.from(webpExtended), frame=frameTooLarge.indexOf("VP8 "); + assert.ok(frame>=0); frameTooLarge.writeUInt16LE(5000,frame+14); frameTooLarge.writeUInt16LE(4000,frame+16); + assert.throws(()=>encodeBatch(imageBatch(dataUrl("webp",frameTooLarge)),"clef"),/16 megapixel/); + for (const image of [webp,webpLossless,webpExtended]) + assert.equal(encodeBatch(imageBatch(dataUrl("webp",image)),"clef").payload.images.length,1); +}); + +test("rejects malformed Base64, unsupported image types and mismatched or incomplete files",()=>{ + for (const image of [ + "data:image/png;base64,====", + "data:image/png;base64,AB==", + "data:image/gif;base64,R0lGODlh", + dataUrl("png",jpeg), + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==", + {content_type:"image/jpeg",base64:"Zm9v"}, + {content_type:"image/png",base64:"https://example.com/image.png"} + ]) assert.throws(()=>encodeBatch(imageBatch(image),"clef"),UnsupportedInput); + const unpadded=jpeg.toString("base64").replace(/=+$/,""); + const req=encodeBatch(imageBatch({content_type:"image/jpeg",base64:unpadded}),"clef"); + assert.equal(req.payload.images[0].base64,jpeg.toString("base64")); + assert.equal(encodeBatch(imageBatch("https://example.com/image.png"),"clef").payload.state,"https://example.com/image.png"); +}); + +test("rejects truncated image containers as unsupported input",()=>{ + for (const [type,image] of [["png",png(2,2)],["jpeg",jpeg],["webp",webpExtended]]) { + for (let length=0;lengthencodeBatch(imageBatch(dataUrl(type,image.subarray(0,length))),"clef"),UnsupportedInput); + } + } +}); + +test("extracts only sent fields and keeps state and question references in array order",()=>{ + const a=dataUrl("png",png(1,1)), b=dataUrl("webp",webp), c=dataUrl("jpeg",jpeg); + const input={state:`State ${a} end`,questions:{q:{type:"choice",instructions:`Inspect ${b}!`, + criteria:{yes:`Shown in ${c}`,no:"No"},metadata:{unused:Array(5).fill(a)}}}}; + const req=encodeBatch(input,"clef"); + assert.deepEqual(req.payload.images,[a,b,c]); + assert.equal(req.payload.state,"State [image #1] end"); + assert.equal(req.payload.questions.q0.instructions,"Inspect [image #2]!"); + assert.equal(req.payload.questions.q0.criteria.c0.description,"Shown in [image #3]"); + assert.equal("metadata" in req.payload.questions.q0,false); +}); + +test("extracts image choice labels while returning the original choice key",()=>{ + const image=dataUrl("png",png(1,1)); + const input={state:"Choose a picture",questions:{q:{type:"choice",instructions:"Choose",criteria:{[image]:"picture",none:"no picture"}}}}; + const req=encodeBatch(input,"clef"); + assert.deepEqual(req.payload.images,[image]); + assert.equal(req.payload.questions.q0.criteria.c0.label,"[image #1]"); + const result=decodeResponse({model:"clef",answers:{q0:{type:"choice",choice:"c0",probabilities:{c0:0.9,c1:0.1}}}},req); + assert.equal(result.answers.q.choice,image); + assert.equal(result.answers.q.probabilities[image],0.9); +}); + +test("reserves image tokens in the text context and checks encoding deadlines",()=>{ + const image=dataUrl("png",png(1,1)); + assert.doesNotThrow(()=>encodeBatch(imageBatch("x".repeat(21000)),"clef-flash")); + assert.throws(()=>encodeBatch(imageBatch({text:"x".repeat(21000),images:Array(4).fill(image)}),"clef-flash"),/context budget/); + let checks=0; + assert.throws(()=>encodeBatch(imageBatch(image),"clef",()=>{if (++checks===3) throw new Error("deadline");}),/deadline/); +}); diff --git a/external_plugins/cloudflare-clef/server/store-secret.ps1 b/external_plugins/cloudflare-clef/server/store-secret.ps1 new file mode 100644 index 0000000..ebec1db --- /dev/null +++ b/external_plugins/cloudflare-clef/server/store-secret.ps1 @@ -0,0 +1,44 @@ +# One-time credential setup for the cloudflare-clef plugin. +# Stores the Cloudflare Account ID and API token DPAPI-encrypted (bound to the current +# Windows user) at %USERPROFILE%\.zcode\secrets\cloudflare-clef.json. +# Re-run to rotate credentials; delete that file to revoke them. +# Interactive by default; -AccountId/-Token parameters enable non-interactive automation. +param( + [string]$AccountId, + [string]$Token +) +$ErrorActionPreference = "Stop" + +$secretPath = Join-Path $env:USERPROFILE ".zcode\secrets\cloudflare-clef.json" + +# Non-interactive automation via environment variables: preferred over +# -Token/-AccountId parameters, which are visible in the process command line. +if (-not $AccountId -and $env:CLOUDFLARE_ACCOUNT_ID) { $AccountId = $env:CLOUDFLARE_ACCOUNT_ID } +if (-not $Token -and $env:CLOUDFLARE_API_TOKEN) { $Token = $env:CLOUDFLARE_API_TOKEN } +if (-not $AccountId) { $AccountId = Read-Host "Cloudflare Account ID (32 hex characters)" } +if (-not $Token) { + $tokenSecure = Read-Host "Cloudflare API Token (input hidden)" -AsSecureString +} else { + $tokenSecure = ConvertTo-SecureString $Token -AsPlainText -Force +} + +if ($AccountId -notmatch '^[a-fA-F0-9]{32}$') { throw "Account ID must be exactly 32 hex characters (got: $AccountId)." } +if ($tokenSecure.Length -eq 0) { throw "API token must not be empty." } + +$encAccount = ConvertFrom-SecureString (ConvertTo-SecureString $AccountId -AsPlainText -Force) +$encToken = ConvertFrom-SecureString $tokenSecure + +$dir = Split-Path -Parent $secretPath +New-Item -ItemType Directory -Force -Path $dir | Out-Null +@{ accountId = $encAccount; apiToken = $encToken } | + ConvertTo-Json -Compress | + Set-Content -LiteralPath $secretPath -Encoding ASCII + +# Round-trip check: the same Windows user must be able to decrypt what was just written. +$check = Get-Content -LiteralPath $secretPath -Raw | ConvertFrom-Json +ConvertTo-SecureString -String $check.apiToken | Out-Null +ConvertTo-SecureString -String $check.accountId | Out-Null + +Write-Host "Stored DPAPI-encrypted credentials at $secretPath" +Write-Host "(Bound to this Windows user; the file is useless if copied to another machine or user account.)" +Write-Host "Restart ZCode so the plugin's MCP server picks them up." diff --git a/marketplace.json b/marketplace.json index c7f09a0..c356225 100644 --- a/marketplace.json +++ b/marketplace.json @@ -1443,6 +1443,34 @@ "mcp", "verification" ] + }, + { + "name": "cloudflare-clef", + "source": "./external_plugins/cloudflare-clef", + "displayName": "Cloudflare CLEF Decisions", + "displayName_i18n": { + "en": "Cloudflare CLEF Decisions", + "zh-CN": "Cloudflare CLEF 决策评测" + }, + "description": "Decision evaluation on Cloudflare Workers AI CLEF / CLEF Flash, exposed as the MCP tool clef_evaluate. Credentials are DPAPI-encrypted at rest. Images attach by absolute local file path or embedded data URL. Protocol layer ported verbatim from the MIT-licensed OpenClaw plugin openclaw-cloudflare-clef 0.1.3.", + "description_i18n": { + "en": "Decision evaluation on Cloudflare Workers AI CLEF / CLEF Flash, exposed as the MCP tool clef_evaluate. Credentials are DPAPI-encrypted at rest. Images attach by absolute local file path or embedded data URL. Protocol layer ported verbatim from the MIT-licensed OpenClaw plugin openclaw-cloudflare-clef 0.1.3.", + "zh-CN": "基于 Cloudflare Workers AI CLEF / CLEF Flash 的结构化决策评测 MCP 工具 clef_evaluate,凭据经 DPAPI 加密落盘。图片支持绝对路径本地文件或内嵌 data URL 两种挂载方式。协议层逐字节移植自 MIT 许可的 OpenClaw 插件 openclaw-cloudflare-clef 0.1.3。" + }, + "version": "0.3.0", + "author": { + "name": "TriDefender", + "url": "https://github.com/TriDefender" + }, + "category": "developer-tools", + "keywords": [ + "cloudflare", + "workers-ai", + "clef", + "decision", + "triage", + "mcp" + ] } ] -} \ No newline at end of file +}