2746 lines
83 KiB
Markdown
2746 lines
83 KiB
Markdown
|
|
# LetsBe Biz — Component Breakdown
|
|||
|
|
|
|||
|
|
**Date:** February 27, 2026
|
|||
|
|
**Team:** Claude Opus 4.6 Architecture Team
|
|||
|
|
**Document:** 02 of 09
|
|||
|
|
**Status:** Proposal — Competing with independent team
|
|||
|
|
**Companion:** References 01-SYSTEM-ARCHITECTURE.md for high-level context
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Table of Contents
|
|||
|
|
|
|||
|
|
1. [Safety Wrapper](#1-safety-wrapper)
|
|||
|
|
2. [Secrets Proxy](#2-secrets-proxy)
|
|||
|
|
3. [Hub Updates](#3-hub-updates)
|
|||
|
|
4. [Provisioner Updates](#4-provisioner-updates)
|
|||
|
|
5. [Mobile App](#5-mobile-app)
|
|||
|
|
6. [Website & Onboarding](#6-website--onboarding)
|
|||
|
|
7. [Tool Registry & Cheat Sheets](#7-tool-registry--cheat-sheets)
|
|||
|
|
8. [Agent Architecture](#8-agent-architecture)
|
|||
|
|
9. [Interactive Demo](#9-interactive-demo)
|
|||
|
|
10. [Shared Packages](#10-shared-packages)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. Safety Wrapper
|
|||
|
|
|
|||
|
|
**Process:** `letsbe-safety-wrapper` — Node.js 22+ standalone HTTP server
|
|||
|
|
**Port:** `localhost:8200`
|
|||
|
|
**RAM Budget:** ~128MB (separate from OpenClaw's ~384MB + Chromium)
|
|||
|
|
**Language:** TypeScript (strict mode, ESM)
|
|||
|
|
**Repository:** `packages/safety-wrapper/` in monorepo
|
|||
|
|
|
|||
|
|
### 1.1 Responsibility Summary
|
|||
|
|
|
|||
|
|
The Safety Wrapper is the enforcement layer for all five core responsibilities:
|
|||
|
|
|
|||
|
|
| # | Responsibility | Input | Output |
|
|||
|
|
|---|---------------|-------|--------|
|
|||
|
|
| 1 | Command Classification | Tool call from OpenClaw | Tier assignment (GREEN/YELLOW/YELLOW_EXTERNAL/RED/CRITICAL_RED) |
|
|||
|
|
| 2 | Autonomy Gating | Classified command + agent config | EXECUTE or GATE_FOR_APPROVAL |
|
|||
|
|
| 3 | Credential Injection | `SECRET_REF(name)` placeholders | Real values injected locally |
|
|||
|
|
| 4 | Hub Communication | Heartbeat, approval, token usage, config sync | Hub API responses |
|
|||
|
|
| 5 | Token Metering | LLM response headers from OpenRouter | Hourly aggregated buckets |
|
|||
|
|
|
|||
|
|
### 1.2 HTTP API Contract
|
|||
|
|
|
|||
|
|
The Safety Wrapper exposes an HTTP API that OpenClaw calls instead of executing tools directly.
|
|||
|
|
|
|||
|
|
#### 1.2.1 Tool Execution Endpoint
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
POST /api/v1/tools/execute
|
|||
|
|
Authorization: Bearer {SAFETY_WRAPPER_TOKEN}
|
|||
|
|
Content-Type: application/json
|
|||
|
|
|
|||
|
|
Request:
|
|||
|
|
{
|
|||
|
|
"session_id": "string", // OpenClaw session ID
|
|||
|
|
"agent_id": "string", // e.g., "it-admin", "marketing"
|
|||
|
|
"tool_name": "string", // e.g., "shell", "docker", "file_read"
|
|||
|
|
"tool_args": { // Tool-specific arguments
|
|||
|
|
"command": "string",
|
|||
|
|
"timeout": "number?"
|
|||
|
|
},
|
|||
|
|
"context": { // Optional context from OpenClaw
|
|||
|
|
"conversation_id": "string?",
|
|||
|
|
"parent_tool_call_id": "string?",
|
|||
|
|
"is_subagent": "boolean?"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Response (200 — executed):
|
|||
|
|
{
|
|||
|
|
"status": "executed",
|
|||
|
|
"result": "string | object", // Tool output (secrets redacted)
|
|||
|
|
"tool_call_id": "string", // Unique ID for audit trail
|
|||
|
|
"classification": "green", // Classification tier
|
|||
|
|
"duration_ms": 142,
|
|||
|
|
"tokens_input": 0, // Only for LLM-proxied calls
|
|||
|
|
"tokens_output": 0
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Response (202 — gated for approval):
|
|||
|
|
{
|
|||
|
|
"status": "awaiting_approval",
|
|||
|
|
"approval_id": "string", // UUID for tracking
|
|||
|
|
"classification": "red",
|
|||
|
|
"human_readable": "IT Agent wants to delete /nextcloud/data/tmp/* (47 files, 2.3GB)",
|
|||
|
|
"expires_at": "2026-02-28T12:00:00Z",
|
|||
|
|
"poll_endpoint": "/api/v1/approvals/{approval_id}"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Response (403 — denied):
|
|||
|
|
{
|
|||
|
|
"status": "denied",
|
|||
|
|
"reason": "Tool 'docker' not in agent 'marketing' allow list",
|
|||
|
|
"classification": null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Response (429 — rate limited):
|
|||
|
|
{
|
|||
|
|
"status": "rate_limited",
|
|||
|
|
"retry_after_ms": 5000
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 1.2.2 Approval Polling Endpoint
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
GET /api/v1/approvals/{approval_id}
|
|||
|
|
Authorization: Bearer {SAFETY_WRAPPER_TOKEN}
|
|||
|
|
|
|||
|
|
Response (200 — still pending):
|
|||
|
|
{
|
|||
|
|
"status": "pending",
|
|||
|
|
"requested_at": "2026-02-27T11:00:00Z",
|
|||
|
|
"expires_at": "2026-02-28T11:00:00Z"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Response (200 — resolved):
|
|||
|
|
{
|
|||
|
|
"status": "approved", // or "denied" or "expired"
|
|||
|
|
"responded_at": "2026-02-27T11:05:00Z",
|
|||
|
|
"responded_by": "customer", // or "staff"
|
|||
|
|
"result": "string | object" // Tool execution result if approved
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 1.2.3 Health & Registration
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
GET /api/v1/health
|
|||
|
|
→ { "status": "ok", "version": "1.0.0", "hub_connected": true, "uptime_seconds": 86400 }
|
|||
|
|
|
|||
|
|
POST /api/v1/register
|
|||
|
|
→ Called once on first boot. Registers with Hub, receives API key.
|
|||
|
|
Body: { "registration_token": "string", "version": "string" }
|
|||
|
|
Response: { "hub_api_key": "string", "config": { ... } }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 1.2.4 Config Sync (Hub → Safety Wrapper)
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
POST /api/v1/config/sync
|
|||
|
|
Authorization: Bearer {HUB_WEBHOOK_TOKEN}
|
|||
|
|
Content-Type: application/json
|
|||
|
|
|
|||
|
|
Request:
|
|||
|
|
{
|
|||
|
|
"config_version": 42,
|
|||
|
|
"agents": {
|
|||
|
|
"it-admin": {
|
|||
|
|
"autonomy_level": 3,
|
|||
|
|
"tools_allowed": ["shell", "docker", "file_read", ...],
|
|||
|
|
"tools_denied": [],
|
|||
|
|
"external_comms_unlocks": {}
|
|||
|
|
},
|
|||
|
|
"marketing": {
|
|||
|
|
"autonomy_level": null, // uses tenant default
|
|||
|
|
"tools_allowed": ["ghost_api", "listmonk_api", ...],
|
|||
|
|
"tools_denied": ["shell", "docker"],
|
|||
|
|
"external_comms_unlocks": {
|
|||
|
|
"ghost_publish": "autonomous",
|
|||
|
|
"listmonk_send": "gated"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
"tenant": {
|
|||
|
|
"default_autonomy_level": 2,
|
|||
|
|
"token_pool_remaining": 12450000,
|
|||
|
|
"founding_member_multiplier": 2
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Response: { "accepted": true, "config_version": 42 }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.3 Command Classification Engine
|
|||
|
|
|
|||
|
|
The classifier is a deterministic rule engine — no ML, no heuristics. Every tool call maps to exactly one tier.
|
|||
|
|
|
|||
|
|
#### 1.3.1 Classification Rules
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// packages/safety-wrapper/src/classifier/rules.ts
|
|||
|
|
|
|||
|
|
interface ClassificationRule {
|
|||
|
|
tool_name: string;
|
|||
|
|
operation?: string; // Sub-operation (e.g., "delete", "restart")
|
|||
|
|
arg_patterns?: Record<string, RegExp>; // Argument pattern matchers
|
|||
|
|
tier: CommandTier;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type CommandTier = 'green' | 'yellow' | 'yellow_external' | 'red' | 'critical_red';
|
|||
|
|
|
|||
|
|
const CLASSIFICATION_RULES: ClassificationRule[] = [
|
|||
|
|
// GREEN — Non-destructive reads
|
|||
|
|
{ tool_name: 'file_read', tier: 'green' },
|
|||
|
|
{ tool_name: 'env_read', tier: 'green' },
|
|||
|
|
{ tool_name: 'container_stats', tier: 'green' },
|
|||
|
|
{ tool_name: 'container_logs', tier: 'green' },
|
|||
|
|
{ tool_name: 'check_status', tier: 'green' },
|
|||
|
|
{ tool_name: 'dns_lookup', tier: 'green' },
|
|||
|
|
{ tool_name: 'cert_check', tier: 'green' },
|
|||
|
|
{ tool_name: 'umami_read', tier: 'green' },
|
|||
|
|
{ tool_name: 'uptime_check', tier: 'green' },
|
|||
|
|
{ tool_name: 'query_select', tier: 'green' },
|
|||
|
|
|
|||
|
|
// YELLOW — Modifying operations
|
|||
|
|
{ tool_name: 'container_restart', tier: 'yellow' },
|
|||
|
|
{ tool_name: 'file_write', tier: 'yellow' },
|
|||
|
|
{ tool_name: 'env_update', tier: 'yellow' },
|
|||
|
|
{ tool_name: 'nginx_reload', tier: 'yellow' },
|
|||
|
|
{ tool_name: 'chatwoot_assign', tier: 'yellow' },
|
|||
|
|
{ tool_name: 'calcom_create', tier: 'yellow' },
|
|||
|
|
|
|||
|
|
// YELLOW_EXTERNAL — External-facing (independent gate)
|
|||
|
|
{ tool_name: 'ghost_publish', tier: 'yellow_external' },
|
|||
|
|
{ tool_name: 'listmonk_send', tier: 'yellow_external' },
|
|||
|
|
{ tool_name: 'poste_send', tier: 'yellow_external' },
|
|||
|
|
{ tool_name: 'chatwoot_reply_external', tier: 'yellow_external' },
|
|||
|
|
{ tool_name: 'social_post', tier: 'yellow_external' },
|
|||
|
|
{ tool_name: 'documenso_send', tier: 'yellow_external' },
|
|||
|
|
|
|||
|
|
// RED — Destructive operations
|
|||
|
|
{ tool_name: 'file_delete', tier: 'red' },
|
|||
|
|
{ tool_name: 'container_remove', tier: 'red' },
|
|||
|
|
{ tool_name: 'volume_delete', tier: 'red' },
|
|||
|
|
{ tool_name: 'user_revoke', tier: 'red' },
|
|||
|
|
{ tool_name: 'db_drop_table', tier: 'red' },
|
|||
|
|
{ tool_name: 'backup_delete', tier: 'red' },
|
|||
|
|
{ tool_name: 'tool_install', tier: 'red' },
|
|||
|
|
{ tool_name: 'tool_remove', tier: 'red' },
|
|||
|
|
|
|||
|
|
// CRITICAL_RED — Irreversible
|
|||
|
|
{ tool_name: 'db_drop_database', tier: 'critical_red' },
|
|||
|
|
{ tool_name: 'firewall_modify', tier: 'critical_red' },
|
|||
|
|
{ tool_name: 'ssh_config_modify', tier: 'critical_red' },
|
|||
|
|
{ tool_name: 'backup_wipe_all', tier: 'critical_red' },
|
|||
|
|
{ tool_name: 'user_delete_account', tier: 'critical_red' },
|
|||
|
|
{ tool_name: 'ssl_revoke', tier: 'critical_red' },
|
|||
|
|
];
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 1.3.2 Shell Command Classifier
|
|||
|
|
|
|||
|
|
Shell commands (via the `shell` tool) require deeper analysis because the agent sends arbitrary commands:
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// packages/safety-wrapper/src/classifier/shell-classifier.ts
|
|||
|
|
|
|||
|
|
interface ShellClassification {
|
|||
|
|
tier: CommandTier;
|
|||
|
|
binary: string;
|
|||
|
|
args: string[];
|
|||
|
|
is_allowed: boolean;
|
|||
|
|
reason?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ALLOWLISTED BINARIES — everything else is DENIED
|
|||
|
|
const SHELL_ALLOWLIST: Record<string, CommandTier> = {
|
|||
|
|
// Green — read-only
|
|||
|
|
'cat': 'green', 'head': 'green', 'tail': 'green', 'less': 'green',
|
|||
|
|
'ls': 'green', 'find': 'green', 'grep': 'green', 'wc': 'green',
|
|||
|
|
'df': 'green', 'du': 'green', 'free': 'green', 'uptime': 'green',
|
|||
|
|
'whoami': 'green', 'hostname': 'green', 'date': 'green',
|
|||
|
|
'curl': 'green', // Elevated by arg analysis below
|
|||
|
|
'dig': 'green', 'nslookup': 'green', 'ping': 'green',
|
|||
|
|
'docker': 'green', // Elevated by subcommand analysis
|
|||
|
|
'systemctl': 'green', // Elevated by subcommand analysis
|
|||
|
|
|
|||
|
|
// Yellow — modifying
|
|||
|
|
'tee': 'yellow', 'cp': 'yellow', 'mv': 'yellow', 'mkdir': 'yellow',
|
|||
|
|
'chmod': 'yellow', 'chown': 'yellow', 'sed': 'yellow',
|
|||
|
|
'certbot': 'yellow',
|
|||
|
|
'nginx': 'yellow', // Only "-s reload" allowed
|
|||
|
|
|
|||
|
|
// Red — destructive
|
|||
|
|
'rm': 'red', 'rmdir': 'red',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// SHELL METACHARACTER BLOCKING — no pipes, redirects, or command chaining
|
|||
|
|
const BLOCKED_METACHARACTERS = /[|;&`$(){}\\<>]/;
|
|||
|
|
|
|||
|
|
// PATH TRAVERSAL PREVENTION — all paths must resolve under /opt/letsbe
|
|||
|
|
const ALLOWED_ROOT = '/opt/letsbe';
|
|||
|
|
|
|||
|
|
function classifyShellCommand(raw_command: string): ShellClassification {
|
|||
|
|
// 1. Block metacharacters
|
|||
|
|
if (BLOCKED_METACHARACTERS.test(raw_command)) {
|
|||
|
|
return { tier: 'critical_red', binary: '', args: [], is_allowed: false,
|
|||
|
|
reason: 'Shell metacharacters blocked' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. Parse binary + args
|
|||
|
|
const parts = raw_command.trim().split(/\s+/);
|
|||
|
|
const binary = parts[0];
|
|||
|
|
const args = parts.slice(1);
|
|||
|
|
|
|||
|
|
// 3. Check allowlist
|
|||
|
|
if (!(binary in SHELL_ALLOWLIST)) {
|
|||
|
|
return { tier: 'critical_red', binary, args, is_allowed: false,
|
|||
|
|
reason: `Binary '${binary}' not in allowlist` };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let tier = SHELL_ALLOWLIST[binary];
|
|||
|
|
|
|||
|
|
// 4. Subcommand analysis for docker/systemctl/curl
|
|||
|
|
if (binary === 'docker') {
|
|||
|
|
tier = classifyDockerSubcommand(args);
|
|||
|
|
} else if (binary === 'systemctl') {
|
|||
|
|
tier = classifySystemctlSubcommand(args);
|
|||
|
|
} else if (binary === 'curl') {
|
|||
|
|
tier = classifyCurlCommand(args);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 5. Path validation
|
|||
|
|
for (const arg of args) {
|
|||
|
|
if (arg.startsWith('/') && !arg.startsWith(ALLOWED_ROOT)) {
|
|||
|
|
if (!isExemptPath(arg)) {
|
|||
|
|
return { tier: 'critical_red', binary, args, is_allowed: false,
|
|||
|
|
reason: `Path '${arg}' outside allowed root ${ALLOWED_ROOT}` };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { tier, binary, args, is_allowed: true };
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 1.3.3 Docker Subcommand Classifier
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
function classifyDockerSubcommand(args: string[]): CommandTier {
|
|||
|
|
const subcommand = args[0];
|
|||
|
|
switch (subcommand) {
|
|||
|
|
// Green — read-only
|
|||
|
|
case 'ps': case 'images': case 'inspect': case 'logs':
|
|||
|
|
case 'stats': case 'top': case 'port': case 'version':
|
|||
|
|
case 'info': case 'network': case 'volume':
|
|||
|
|
if (args[1] === 'ls' || args[1] === 'inspect') return 'green';
|
|||
|
|
return 'green';
|
|||
|
|
|
|||
|
|
// Yellow — service operations
|
|||
|
|
case 'compose':
|
|||
|
|
if (['ps', 'logs', 'config'].includes(args[1])) return 'green';
|
|||
|
|
if (['up', 'restart', 'pull'].includes(args[1])) return 'yellow';
|
|||
|
|
if (['down', 'rm'].includes(args[1])) return 'red';
|
|||
|
|
return 'red';
|
|||
|
|
case 'restart': case 'start': case 'stop':
|
|||
|
|
return 'yellow';
|
|||
|
|
case 'exec':
|
|||
|
|
return 'yellow';
|
|||
|
|
|
|||
|
|
// Red — destructive
|
|||
|
|
case 'rm': case 'rmi':
|
|||
|
|
return 'red';
|
|||
|
|
case 'system':
|
|||
|
|
if (args[1] === 'prune') return 'red';
|
|||
|
|
return 'green';
|
|||
|
|
|
|||
|
|
// Critical Red — infrastructure
|
|||
|
|
case 'network':
|
|||
|
|
if (['create', 'rm', 'disconnect'].includes(args[1])) return 'critical_red';
|
|||
|
|
return 'green';
|
|||
|
|
|
|||
|
|
default:
|
|||
|
|
return 'red'; // Unknown subcommands default to red
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.4 Autonomy Resolution Engine
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// packages/safety-wrapper/src/autonomy/resolver.ts
|
|||
|
|
|
|||
|
|
interface AutonomyDecision {
|
|||
|
|
action: 'execute' | 'gate';
|
|||
|
|
effective_level: 1 | 2 | 3;
|
|||
|
|
reason: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface AgentAutonomyConfig {
|
|||
|
|
autonomy_level: number | null; // null = use tenant default
|
|||
|
|
external_comms_unlocks: Record<string, 'autonomous' | 'gated'>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resolveAutonomy(
|
|||
|
|
agent_id: string,
|
|||
|
|
tier: CommandTier,
|
|||
|
|
agent_config: AgentAutonomyConfig,
|
|||
|
|
tenant_default_level: number,
|
|||
|
|
): AutonomyDecision {
|
|||
|
|
const effective_level = agent_config.autonomy_level ?? tenant_default_level;
|
|||
|
|
|
|||
|
|
// Yellow+External has its own independent gate
|
|||
|
|
if (tier === 'yellow_external') {
|
|||
|
|
const unlock = agent_config.external_comms_unlocks[/* tool_name */];
|
|||
|
|
if (unlock === 'autonomous') {
|
|||
|
|
// Fall through to normal yellow gating
|
|||
|
|
return resolveStandardGating('yellow', effective_level, agent_id);
|
|||
|
|
}
|
|||
|
|
// Default: gated regardless of autonomy level
|
|||
|
|
return {
|
|||
|
|
action: 'gate',
|
|||
|
|
effective_level,
|
|||
|
|
reason: `External comms gated for agent '${agent_id}' — user must explicitly unlock`,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return resolveStandardGating(tier, effective_level, agent_id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resolveStandardGating(
|
|||
|
|
tier: CommandTier,
|
|||
|
|
level: number,
|
|||
|
|
agent_id: string,
|
|||
|
|
): AutonomyDecision {
|
|||
|
|
const GATING_MATRIX: Record<CommandTier, Record<number, 'execute' | 'gate'>> = {
|
|||
|
|
green: { 1: 'execute', 2: 'execute', 3: 'execute' },
|
|||
|
|
yellow: { 1: 'gate', 2: 'execute', 3: 'execute' },
|
|||
|
|
red: { 1: 'gate', 2: 'gate', 3: 'execute' },
|
|||
|
|
critical_red: { 1: 'gate', 2: 'gate', 3: 'gate' },
|
|||
|
|
yellow_external: { 1: 'gate', 2: 'gate', 3: 'gate' }, // Handled above
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const action = GATING_MATRIX[tier]?.[level] ?? 'gate';
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
action,
|
|||
|
|
effective_level: level as 1 | 2 | 3,
|
|||
|
|
reason: `${tier} at level ${level} → ${action}`,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.5 Tool Executors
|
|||
|
|
|
|||
|
|
Ported from the deprecated sysadmin agent with security patterns preserved.
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// packages/safety-wrapper/src/executors/types.ts
|
|||
|
|
|
|||
|
|
interface ToolExecutor {
|
|||
|
|
name: string;
|
|||
|
|
execute(args: Record<string, unknown>): Promise<ToolResult>;
|
|||
|
|
validate(args: Record<string, unknown>): ValidationResult;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface ToolResult {
|
|||
|
|
success: boolean;
|
|||
|
|
output: string;
|
|||
|
|
duration_ms: number;
|
|||
|
|
error?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface ValidationResult {
|
|||
|
|
valid: boolean;
|
|||
|
|
errors: string[];
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 1.5.1 Shell Executor
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// packages/safety-wrapper/src/executors/shell.ts
|
|||
|
|
|
|||
|
|
import { execFile } from 'node:child_process';
|
|||
|
|
|
|||
|
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|||
|
|
const MAX_OUTPUT_BYTES = 1_048_576; // 1MB
|
|||
|
|
|
|||
|
|
async function executeShell(args: {
|
|||
|
|
command: string;
|
|||
|
|
timeout?: number;
|
|||
|
|
working_dir?: string;
|
|||
|
|
}): Promise<ToolResult> {
|
|||
|
|
const classification = classifyShellCommand(args.command);
|
|||
|
|
if (!classification.is_allowed) {
|
|||
|
|
return { success: false, output: '', duration_ms: 0,
|
|||
|
|
error: classification.reason };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const timeout = Math.min(args.timeout ?? DEFAULT_TIMEOUT_MS, 120_000);
|
|||
|
|
const cwd = resolveSafePath(args.working_dir ?? '/opt/letsbe');
|
|||
|
|
|
|||
|
|
return new Promise((resolve) => {
|
|||
|
|
const start = Date.now();
|
|||
|
|
// Use execFile (not exec) to prevent shell injection
|
|||
|
|
const child = execFile(
|
|||
|
|
classification.binary,
|
|||
|
|
classification.args,
|
|||
|
|
{ timeout, cwd, maxBuffer: MAX_OUTPUT_BYTES, env: getSanitizedEnv() },
|
|||
|
|
(error, stdout, stderr) => {
|
|||
|
|
const duration_ms = Date.now() - start;
|
|||
|
|
if (error) {
|
|||
|
|
resolve({
|
|||
|
|
success: false,
|
|||
|
|
output: truncateOutput(stderr || error.message),
|
|||
|
|
duration_ms,
|
|||
|
|
error: error.code === 'ERR_CHILD_PROCESS_TIMEOUT'
|
|||
|
|
? `Command timed out after ${timeout}ms`
|
|||
|
|
: error.message,
|
|||
|
|
});
|
|||
|
|
} else {
|
|||
|
|
resolve({
|
|||
|
|
success: true,
|
|||
|
|
output: truncateOutput(stdout),
|
|||
|
|
duration_ms,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 1.5.2 Docker Executor
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// packages/safety-wrapper/src/executors/docker.ts
|
|||
|
|
|
|||
|
|
const DOCKER_OPERATIONS = {
|
|||
|
|
stats: { args: (name: string) => ['stats', name, '--no-stream', '--format', 'json'] },
|
|||
|
|
logs: { args: (name: string, lines: number) => ['logs', name, '--tail', String(lines)] },
|
|||
|
|
restart: { args: (name: string) => ['restart', name] },
|
|||
|
|
start: { args: (name: string) => ['start', name] },
|
|||
|
|
stop: { args: (name: string) => ['stop', name] },
|
|||
|
|
inspect: { args: (name: string) => ['inspect', name] },
|
|||
|
|
ps: { args: () => ['ps', '--format', 'json'] },
|
|||
|
|
} as const;
|
|||
|
|
|
|||
|
|
async function executeDocker(args: {
|
|||
|
|
operation: keyof typeof DOCKER_OPERATIONS;
|
|||
|
|
container_name?: string;
|
|||
|
|
lines?: number;
|
|||
|
|
}): Promise<ToolResult> {
|
|||
|
|
const op = DOCKER_OPERATIONS[args.operation];
|
|||
|
|
if (!op) {
|
|||
|
|
return { success: false, output: '', duration_ms: 0,
|
|||
|
|
error: `Unknown docker operation: ${args.operation}` };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Container name validation: alphanumeric, dash, underscore only
|
|||
|
|
if (args.container_name && !/^[a-zA-Z0-9_-]+$/.test(args.container_name)) {
|
|||
|
|
return { success: false, output: '', duration_ms: 0,
|
|||
|
|
error: 'Invalid container name' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const dockerArgs = op.args(args.container_name!, args.lines ?? 100);
|
|||
|
|
return executeShell({ command: `docker ${dockerArgs.join(' ')}` });
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 1.5.3 File Executor
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// packages/safety-wrapper/src/executors/file.ts
|
|||
|
|
|
|||
|
|
import { readFile, writeFile, stat, unlink } from 'node:fs/promises';
|
|||
|
|
import { resolve, normalize } from 'node:path';
|
|||
|
|
|
|||
|
|
const ALLOWED_ROOT = '/opt/letsbe';
|
|||
|
|
const MAX_READ_BYTES = 10_485_760; // 10MB
|
|||
|
|
const MAX_WRITE_BYTES = 10_485_760; // 10MB
|
|||
|
|
|
|||
|
|
function resolveSafePath(requested_path: string): string {
|
|||
|
|
const resolved = resolve(ALLOWED_ROOT, requested_path);
|
|||
|
|
const normalized = normalize(resolved);
|
|||
|
|
|
|||
|
|
if (!normalized.startsWith(ALLOWED_ROOT)) {
|
|||
|
|
throw new Error(`Path traversal blocked: ${requested_path} resolves to ${normalized}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return normalized;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function executeFileRead(args: {
|
|||
|
|
path: string;
|
|||
|
|
encoding?: string;
|
|||
|
|
offset?: number;
|
|||
|
|
limit?: number;
|
|||
|
|
}): Promise<ToolResult> {
|
|||
|
|
const safePath = resolveSafePath(args.path);
|
|||
|
|
const start = Date.now();
|
|||
|
|
|
|||
|
|
const fileInfo = await stat(safePath);
|
|||
|
|
if (fileInfo.size > MAX_READ_BYTES) {
|
|||
|
|
return { success: false, output: '', duration_ms: Date.now() - start,
|
|||
|
|
error: `File too large: ${fileInfo.size} bytes (max ${MAX_READ_BYTES})` };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const content = await readFile(safePath, args.encoding ?? 'utf-8');
|
|||
|
|
const lines = content.split('\n');
|
|||
|
|
const offset = args.offset ?? 0;
|
|||
|
|
const limit = args.limit ?? lines.length;
|
|||
|
|
const slice = lines.slice(offset, offset + limit).join('\n');
|
|||
|
|
|
|||
|
|
return { success: true, output: slice, duration_ms: Date.now() - start };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function executeFileWrite(args: {
|
|||
|
|
path: string;
|
|||
|
|
content: string;
|
|||
|
|
mode?: 'overwrite' | 'append';
|
|||
|
|
}): Promise<ToolResult> {
|
|||
|
|
const safePath = resolveSafePath(args.path);
|
|||
|
|
const start = Date.now();
|
|||
|
|
|
|||
|
|
if (Buffer.byteLength(args.content) > MAX_WRITE_BYTES) {
|
|||
|
|
return { success: false, output: '', duration_ms: Date.now() - start,
|
|||
|
|
error: `Content too large (max ${MAX_WRITE_BYTES} bytes)` };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (args.mode === 'append') {
|
|||
|
|
const existing = await readFile(safePath, 'utf-8').catch(() => '');
|
|||
|
|
await writeFile(safePath, existing + args.content, 'utf-8');
|
|||
|
|
} else {
|
|||
|
|
// Atomic write: write to temp, then rename
|
|||
|
|
const tmpPath = safePath + '.tmp';
|
|||
|
|
await writeFile(tmpPath, args.content, 'utf-8');
|
|||
|
|
const { rename } = await import('node:fs/promises');
|
|||
|
|
await rename(tmpPath, safePath);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { success: true, output: `Written to ${args.path}`, duration_ms: Date.now() - start };
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 1.5.4 Env Executor
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// packages/safety-wrapper/src/executors/env.ts
|
|||
|
|
|
|||
|
|
import { readFile, writeFile, rename } from 'node:fs/promises';
|
|||
|
|
|
|||
|
|
async function executeEnvRead(args: {
|
|||
|
|
path: string;
|
|||
|
|
keys?: string[];
|
|||
|
|
}): Promise<ToolResult> {
|
|||
|
|
const safePath = resolveSafePath(args.path);
|
|||
|
|
const start = Date.now();
|
|||
|
|
const content = await readFile(safePath, 'utf-8');
|
|||
|
|
|
|||
|
|
const parsed = parseEnvFile(content);
|
|||
|
|
|
|||
|
|
if (args.keys && args.keys.length > 0) {
|
|||
|
|
const filtered: Record<string, string> = {};
|
|||
|
|
for (const key of args.keys) {
|
|||
|
|
if (key in parsed) filtered[key] = parsed[key];
|
|||
|
|
}
|
|||
|
|
return { success: true, output: JSON.stringify(filtered, null, 2),
|
|||
|
|
duration_ms: Date.now() - start };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { success: true, output: JSON.stringify(parsed, null, 2),
|
|||
|
|
duration_ms: Date.now() - start };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function executeEnvUpdate(args: {
|
|||
|
|
path: string;
|
|||
|
|
updates: Record<string, string>;
|
|||
|
|
}): Promise<ToolResult> {
|
|||
|
|
const safePath = resolveSafePath(args.path);
|
|||
|
|
const start = Date.now();
|
|||
|
|
|
|||
|
|
const content = await readFile(safePath, 'utf-8');
|
|||
|
|
let updated = content;
|
|||
|
|
|
|||
|
|
for (const [key, value] of Object.entries(args.updates)) {
|
|||
|
|
const regex = new RegExp(`^${escapeRegex(key)}=.*$`, 'm');
|
|||
|
|
if (regex.test(updated)) {
|
|||
|
|
updated = updated.replace(regex, `${key}=${value}`);
|
|||
|
|
} else {
|
|||
|
|
updated += `\n${key}=${value}`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Atomic write: temp → chmod 600 → rename
|
|||
|
|
const tmpPath = safePath + '.tmp';
|
|||
|
|
await writeFile(tmpPath, updated, { mode: 0o600 });
|
|||
|
|
await rename(tmpPath, safePath);
|
|||
|
|
|
|||
|
|
return { success: true, output: `Updated ${Object.keys(args.updates).length} keys`,
|
|||
|
|
duration_ms: Date.now() - start };
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.6 Hub Communication Client
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// packages/safety-wrapper/src/hub/client.ts
|
|||
|
|
|
|||
|
|
interface HubClient {
|
|||
|
|
register(token: string): Promise<{ hub_api_key: string; config: TenantConfig }>;
|
|||
|
|
heartbeat(metrics: HeartbeatPayload): Promise<HeartbeatResponse>;
|
|||
|
|
reportUsage(buckets: TokenUsageBucket[]): Promise<void>;
|
|||
|
|
requestApproval(request: ApprovalRequest): Promise<{ approval_id: string }>;
|
|||
|
|
pollApproval(approval_id: string): Promise<ApprovalResponse>;
|
|||
|
|
reportBackupStatus(status: BackupStatus): Promise<void>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface HeartbeatPayload {
|
|||
|
|
version: string;
|
|||
|
|
openclaw_version: string;
|
|||
|
|
uptime_seconds: number;
|
|||
|
|
agents: AgentStatus[];
|
|||
|
|
active_sessions: number;
|
|||
|
|
pending_approvals: number;
|
|||
|
|
last_token_usage_sync: string; // ISO timestamp
|
|||
|
|
last_backup_check: string; // ISO timestamp
|
|||
|
|
disk_usage_percent: number;
|
|||
|
|
memory_usage_mb: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface HeartbeatResponse {
|
|||
|
|
config_version: number;
|
|||
|
|
config?: TenantConfig; // Only if config_version > local
|
|||
|
|
pending_commands: RemoteCommand[]; // Commands queued by admin
|
|||
|
|
pool_remaining: number; // Token pool remaining
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface TokenUsageBucket {
|
|||
|
|
agent_id: string;
|
|||
|
|
model: string;
|
|||
|
|
bucket_hour: string; // ISO hour (e.g., "2026-02-27T14:00:00Z")
|
|||
|
|
tokens_input: number;
|
|||
|
|
tokens_output: number;
|
|||
|
|
tokens_cache_read: number;
|
|||
|
|
tokens_cache_write: number;
|
|||
|
|
web_search_count: number;
|
|||
|
|
web_fetch_count: number;
|
|||
|
|
estimated_cost_cents: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface ApprovalRequest {
|
|||
|
|
agent_id: string;
|
|||
|
|
command_class: CommandTier;
|
|||
|
|
tool_name: string;
|
|||
|
|
tool_args: Record<string, unknown>;
|
|||
|
|
human_readable: string; // Natural language description
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface ApprovalResponse {
|
|||
|
|
status: 'pending' | 'approved' | 'denied' | 'expired';
|
|||
|
|
responded_at?: string;
|
|||
|
|
responded_by?: string;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 1.6.1 Heartbeat Loop
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// packages/safety-wrapper/src/hub/heartbeat.ts
|
|||
|
|
|
|||
|
|
const HEARTBEAT_INTERVAL_MS = 60_000; // 1 minute
|
|||
|
|
const HEARTBEAT_BACKOFF_MAX_MS = 300_000; // 5 minutes
|
|||
|
|
const USAGE_REPORT_INTERVAL_MS = 300_000; // 5 minutes
|
|||
|
|
|
|||
|
|
class HeartbeatService {
|
|||
|
|
private interval: NodeJS.Timeout | null = null;
|
|||
|
|
private consecutiveFailures = 0;
|
|||
|
|
|
|||
|
|
start(): void {
|
|||
|
|
this.interval = setInterval(() => this.tick(), HEARTBEAT_INTERVAL_MS);
|
|||
|
|
// Also start usage reporting on a separate cadence
|
|||
|
|
setInterval(() => this.reportUsage(), USAGE_REPORT_INTERVAL_MS);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private async tick(): Promise<void> {
|
|||
|
|
try {
|
|||
|
|
const metrics = await this.collectMetrics();
|
|||
|
|
const response = await this.hubClient.heartbeat(metrics);
|
|||
|
|
|
|||
|
|
this.consecutiveFailures = 0;
|
|||
|
|
|
|||
|
|
// Apply config update if newer version available
|
|||
|
|
if (response.config && response.config_version > this.localConfigVersion) {
|
|||
|
|
await this.applyConfig(response.config);
|
|||
|
|
this.localConfigVersion = response.config_version;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Execute any pending remote commands
|
|||
|
|
for (const cmd of response.pending_commands) {
|
|||
|
|
await this.executeRemoteCommand(cmd);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Track pool remaining for local rate limiting
|
|||
|
|
this.poolRemaining = response.pool_remaining;
|
|||
|
|
|
|||
|
|
} catch (error) {
|
|||
|
|
this.consecutiveFailures++;
|
|||
|
|
const backoff = Math.min(
|
|||
|
|
HEARTBEAT_INTERVAL_MS * Math.pow(2, this.consecutiveFailures),
|
|||
|
|
HEARTBEAT_BACKOFF_MAX_MS,
|
|||
|
|
);
|
|||
|
|
// Apply jitter (±15%)
|
|||
|
|
const jitter = backoff * (0.85 + Math.random() * 0.3);
|
|||
|
|
await sleep(jitter);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.7 Token Metering
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// packages/safety-wrapper/src/metering/token-meter.ts
|
|||
|
|
|
|||
|
|
class TokenMeter {
|
|||
|
|
private buckets: Map<string, TokenUsageBucket> = new Map();
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Called after every LLM response. Captures token counts from
|
|||
|
|
* OpenRouter response headers.
|
|||
|
|
*/
|
|||
|
|
record(event: {
|
|||
|
|
agent_id: string;
|
|||
|
|
model: string;
|
|||
|
|
tokens_input: number;
|
|||
|
|
tokens_output: number;
|
|||
|
|
tokens_cache_read: number;
|
|||
|
|
tokens_cache_write: number;
|
|||
|
|
is_web_search: boolean;
|
|||
|
|
is_web_fetch: boolean;
|
|||
|
|
}): void {
|
|||
|
|
const bucketHour = getCurrentHour(); // "2026-02-27T14:00:00Z"
|
|||
|
|
const key = `${event.agent_id}:${event.model}:${bucketHour}`;
|
|||
|
|
|
|||
|
|
const bucket = this.buckets.get(key) ?? {
|
|||
|
|
agent_id: event.agent_id,
|
|||
|
|
model: event.model,
|
|||
|
|
bucket_hour: bucketHour,
|
|||
|
|
tokens_input: 0,
|
|||
|
|
tokens_output: 0,
|
|||
|
|
tokens_cache_read: 0,
|
|||
|
|
tokens_cache_write: 0,
|
|||
|
|
web_search_count: 0,
|
|||
|
|
web_fetch_count: 0,
|
|||
|
|
estimated_cost_cents: 0,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
bucket.tokens_input += event.tokens_input;
|
|||
|
|
bucket.tokens_output += event.tokens_output;
|
|||
|
|
bucket.tokens_cache_read += event.tokens_cache_read;
|
|||
|
|
bucket.tokens_cache_write += event.tokens_cache_write;
|
|||
|
|
if (event.is_web_search) bucket.web_search_count++;
|
|||
|
|
if (event.is_web_fetch) bucket.web_fetch_count++;
|
|||
|
|
bucket.estimated_cost_cents += this.calculateCost(event);
|
|||
|
|
|
|||
|
|
this.buckets.set(key, bucket);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Flush completed hourly buckets to Hub.
|
|||
|
|
* Called every 5 minutes by HeartbeatService.
|
|||
|
|
*/
|
|||
|
|
flush(): TokenUsageBucket[] {
|
|||
|
|
const currentHour = getCurrentHour();
|
|||
|
|
const completed: TokenUsageBucket[] = [];
|
|||
|
|
|
|||
|
|
for (const [key, bucket] of this.buckets.entries()) {
|
|||
|
|
if (bucket.bucket_hour !== currentHour) {
|
|||
|
|
completed.push(bucket);
|
|||
|
|
this.buckets.delete(key);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return completed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
private calculateCost(event: {
|
|||
|
|
model: string;
|
|||
|
|
tokens_input: number;
|
|||
|
|
tokens_output: number;
|
|||
|
|
tokens_cache_read: number;
|
|||
|
|
tokens_cache_write: number;
|
|||
|
|
}): number {
|
|||
|
|
const pricing = MODEL_PRICING[event.model];
|
|||
|
|
if (!pricing) return 0;
|
|||
|
|
|
|||
|
|
return Math.ceil(
|
|||
|
|
(event.tokens_input * pricing.input_per_m / 1_000_000) +
|
|||
|
|
(event.tokens_output * pricing.output_per_m / 1_000_000) +
|
|||
|
|
(event.tokens_cache_read * pricing.cache_read_per_m / 1_000_000) +
|
|||
|
|
(event.tokens_cache_write * pricing.cache_write_per_m / 1_000_000)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.8 Audit Log
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// packages/safety-wrapper/src/audit/logger.ts
|
|||
|
|
|
|||
|
|
interface AuditEntry {
|
|||
|
|
id: string; // UUID
|
|||
|
|
timestamp: string; // ISO 8601
|
|||
|
|
agent_id: string;
|
|||
|
|
session_id: string;
|
|||
|
|
tool_name: string;
|
|||
|
|
tool_args_redacted: Record<string, unknown>; // Args with secrets stripped
|
|||
|
|
classification: CommandTier;
|
|||
|
|
autonomy_decision: 'executed' | 'gated' | 'denied';
|
|||
|
|
effective_level: number;
|
|||
|
|
result_summary: string; // First 500 chars, secrets redacted
|
|||
|
|
duration_ms: number;
|
|||
|
|
approval_id?: string; // If gated
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SQLite table: audit_log
|
|||
|
|
// Append-only. Retained for 90 days. Queryable by agent, tier, date range.
|
|||
|
|
// Reported to Hub via heartbeat for admin dashboard.
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.9 SQLite State Schema
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- packages/safety-wrapper/src/db/schema.sql
|
|||
|
|
|
|||
|
|
-- Secrets registry (encrypted values via ChaCha20-Poly1305)
|
|||
|
|
CREATE TABLE secrets (
|
|||
|
|
id TEXT PRIMARY KEY,
|
|||
|
|
name TEXT NOT NULL UNIQUE, -- e.g., "nextcloud_admin_password"
|
|||
|
|
encrypted_value BLOB NOT NULL, -- ChaCha20-Poly1305 encrypted
|
|||
|
|
service TEXT NOT NULL, -- e.g., "nextcloud"
|
|||
|
|
config_path TEXT, -- e.g., "/opt/letsbe/env/nextcloud.env"
|
|||
|
|
pattern TEXT, -- Regex pattern for safety net matching
|
|||
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|||
|
|
rotated_at TEXT,
|
|||
|
|
rotation_count INTEGER DEFAULT 0
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
-- Pending approval requests
|
|||
|
|
CREATE TABLE approvals (
|
|||
|
|
id TEXT PRIMARY KEY,
|
|||
|
|
agent_id TEXT NOT NULL,
|
|||
|
|
tool_name TEXT NOT NULL,
|
|||
|
|
tool_args TEXT NOT NULL, -- JSON
|
|||
|
|
human_readable TEXT NOT NULL,
|
|||
|
|
classification TEXT NOT NULL,
|
|||
|
|
status TEXT NOT NULL DEFAULT 'pending', -- pending, approved, denied, expired
|
|||
|
|
requested_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|||
|
|
expires_at TEXT NOT NULL,
|
|||
|
|
responded_at TEXT,
|
|||
|
|
responded_by TEXT
|
|||
|
|
);
|
|||
|
|
CREATE INDEX idx_approvals_status ON approvals(status);
|
|||
|
|
|
|||
|
|
-- Audit log (append-only)
|
|||
|
|
CREATE TABLE audit_log (
|
|||
|
|
id TEXT PRIMARY KEY,
|
|||
|
|
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
|||
|
|
agent_id TEXT NOT NULL,
|
|||
|
|
session_id TEXT NOT NULL,
|
|||
|
|
tool_name TEXT NOT NULL,
|
|||
|
|
tool_args_redacted TEXT NOT NULL, -- JSON with secrets stripped
|
|||
|
|
classification TEXT NOT NULL,
|
|||
|
|
decision TEXT NOT NULL, -- executed, gated, denied
|
|||
|
|
effective_level INTEGER NOT NULL,
|
|||
|
|
result_summary TEXT,
|
|||
|
|
duration_ms INTEGER NOT NULL,
|
|||
|
|
approval_id TEXT
|
|||
|
|
);
|
|||
|
|
CREATE INDEX idx_audit_timestamp ON audit_log(timestamp);
|
|||
|
|
CREATE INDEX idx_audit_agent ON audit_log(agent_id);
|
|||
|
|
|
|||
|
|
-- Token usage buckets (aggregated hourly)
|
|||
|
|
CREATE TABLE token_usage (
|
|||
|
|
id TEXT PRIMARY KEY,
|
|||
|
|
agent_id TEXT NOT NULL,
|
|||
|
|
model TEXT NOT NULL,
|
|||
|
|
bucket_hour TEXT NOT NULL,
|
|||
|
|
tokens_input INTEGER DEFAULT 0,
|
|||
|
|
tokens_output INTEGER DEFAULT 0,
|
|||
|
|
tokens_cache_read INTEGER DEFAULT 0,
|
|||
|
|
tokens_cache_write INTEGER DEFAULT 0,
|
|||
|
|
web_search_count INTEGER DEFAULT 0,
|
|||
|
|
web_fetch_count INTEGER DEFAULT 0,
|
|||
|
|
estimated_cost_cents INTEGER DEFAULT 0,
|
|||
|
|
synced_to_hub INTEGER DEFAULT 0, -- Boolean: has this been reported?
|
|||
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|||
|
|
);
|
|||
|
|
CREATE UNIQUE INDEX idx_usage_bucket ON token_usage(agent_id, model, bucket_hour);
|
|||
|
|
|
|||
|
|
-- Hub sync state
|
|||
|
|
CREATE TABLE hub_state (
|
|||
|
|
key TEXT PRIMARY KEY,
|
|||
|
|
value TEXT NOT NULL,
|
|||
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|||
|
|
);
|
|||
|
|
-- Keys: 'config_version', 'hub_api_key', 'last_heartbeat', 'last_usage_sync'
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 1.10 Configuration File
|
|||
|
|
|
|||
|
|
```json5
|
|||
|
|
// /opt/letsbe/config/safety-wrapper.json
|
|||
|
|
{
|
|||
|
|
"server": {
|
|||
|
|
"port": 8200,
|
|||
|
|
"host": "127.0.0.1",
|
|||
|
|
"auth_token": "${SAFETY_WRAPPER_TOKEN}"
|
|||
|
|
},
|
|||
|
|
"hub": {
|
|||
|
|
"url": "${HUB_URL}",
|
|||
|
|
"registration_token": "${REGISTRATION_TOKEN}",
|
|||
|
|
"heartbeat_interval_ms": 60000,
|
|||
|
|
"usage_report_interval_ms": 300000
|
|||
|
|
},
|
|||
|
|
"secrets_proxy": {
|
|||
|
|
"url": "http://127.0.0.1:8100"
|
|||
|
|
},
|
|||
|
|
"openclaw": {
|
|||
|
|
"url": "http://127.0.0.1:18789",
|
|||
|
|
"token": "${OPENCLAW_TOKEN}"
|
|||
|
|
},
|
|||
|
|
"tenant": {
|
|||
|
|
"id": "${TENANT_ID}",
|
|||
|
|
"default_autonomy_level": 2
|
|||
|
|
},
|
|||
|
|
"security": {
|
|||
|
|
"shell_timeout_ms": 60000,
|
|||
|
|
"max_file_size_bytes": 10485760,
|
|||
|
|
"allowed_root": "/opt/letsbe",
|
|||
|
|
"approval_expiry_hours": 24,
|
|||
|
|
"max_pending_approvals": 50,
|
|||
|
|
"rate_limit_per_agent_per_minute": 30
|
|||
|
|
},
|
|||
|
|
"database": {
|
|||
|
|
"path": "/opt/letsbe/data/safety-wrapper.db"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 2. Secrets Proxy
|
|||
|
|
|
|||
|
|
**Process:** `letsbe-secrets-proxy` — Lightweight Node.js HTTP proxy
|
|||
|
|
**Port:** `localhost:8100`
|
|||
|
|
**RAM Budget:** ~64MB
|
|||
|
|
**Language:** TypeScript (strict mode, ESM)
|
|||
|
|
**Repository:** `packages/secrets-proxy/` in monorepo
|
|||
|
|
|
|||
|
|
### 2.1 Responsibility
|
|||
|
|
|
|||
|
|
The Secrets Proxy has ONE job: intercept all outbound LLM traffic and strip secrets before they reach OpenRouter. It sits between OpenClaw and the internet at the transport layer.
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
OpenClaw → (port 8100) Secrets Proxy → (HTTPS) OpenRouter/Anthropic/etc.
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
OpenClaw is configured with `modelProvider.url = "http://127.0.0.1:8100"` so all LLM API calls route through this proxy.
|
|||
|
|
|
|||
|
|
### 2.2 4-Layer Redaction Pipeline
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// packages/secrets-proxy/src/pipeline/redact.ts
|
|||
|
|
|
|||
|
|
interface RedactionResult {
|
|||
|
|
text: string;
|
|||
|
|
redactions: RedactionEntry[];
|
|||
|
|
duration_ms: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface RedactionEntry {
|
|||
|
|
original_length: number; // Length of redacted value (not the value itself)
|
|||
|
|
placeholder: string; // e.g., "[REDACTED:nextcloud_admin_password]"
|
|||
|
|
layer: 1 | 2 | 3 | 4; // Which layer caught it
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function redact(text: string, registry: SecretsRegistry): Promise<RedactionResult> {
|
|||
|
|
const start = Date.now();
|
|||
|
|
const redactions: RedactionEntry[] = [];
|
|||
|
|
let result = text;
|
|||
|
|
|
|||
|
|
// Layer 1 — Aho-Corasick registry lookup (O(n) in text length)
|
|||
|
|
result = registryRedact(result, registry, redactions);
|
|||
|
|
|
|||
|
|
// Layer 2 — Regex safety net for unregistered secrets
|
|||
|
|
result = patternRedact(result, redactions);
|
|||
|
|
|
|||
|
|
// Layer 3 — Shannon entropy filter for high-entropy blobs
|
|||
|
|
result = entropyRedact(result, redactions);
|
|||
|
|
|
|||
|
|
// Layer 4 — JSON key scanning for sensitive field names
|
|||
|
|
result = jsonKeyRedact(result, redactions);
|
|||
|
|
|
|||
|
|
return { text: result, redactions, duration_ms: Date.now() - start };
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 2.2.1 Layer 1 — Aho-Corasick Registry Lookup
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// packages/secrets-proxy/src/pipeline/layer1-registry.ts
|
|||
|
|
|
|||
|
|
import { AhoCorasick } from './aho-corasick';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Builds an Aho-Corasick automaton from all known secret values.
|
|||
|
|
* Matches all secrets in a single pass through the text (O(n)).
|
|||
|
|
* Rebuilds automaton when secrets rotate.
|
|||
|
|
*/
|
|||
|
|
class RegistryMatcher {
|
|||
|
|
private automaton: AhoCorasick;
|
|||
|
|
private secretMap: Map<string, string>; // value → placeholder name
|
|||
|
|
|
|||
|
|
constructor(secrets: Array<{ name: string; value: string }>) {
|
|||
|
|
this.secretMap = new Map();
|
|||
|
|
const patterns: string[] = [];
|
|||
|
|
|
|||
|
|
for (const secret of secrets) {
|
|||
|
|
this.secretMap.set(secret.value, `[REDACTED:${secret.name}]`);
|
|||
|
|
patterns.push(secret.value);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.automaton = new AhoCorasick(patterns);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
redact(text: string): { text: string; matches: number } {
|
|||
|
|
let result = text;
|
|||
|
|
let matches = 0;
|
|||
|
|
|
|||
|
|
// Find all matches in one pass
|
|||
|
|
const found = this.automaton.search(text);
|
|||
|
|
|
|||
|
|
// Replace longest matches first (greedy)
|
|||
|
|
const sorted = found.sort((a, b) => b.length - a.length);
|
|||
|
|
for (const match of sorted) {
|
|||
|
|
const placeholder = this.secretMap.get(match.pattern);
|
|||
|
|
if (placeholder) {
|
|||
|
|
result = result.replaceAll(match.pattern, placeholder);
|
|||
|
|
matches++;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { text: result, matches };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 2.2.2 Layer 2 — Regex Safety Net
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// packages/secrets-proxy/src/pipeline/layer2-patterns.ts
|
|||
|
|
|
|||
|
|
const SECRET_PATTERNS: Array<{ name: string; pattern: RegExp; placeholder: string }> = [
|
|||
|
|
{
|
|||
|
|
name: 'private_key',
|
|||
|
|
pattern: /-----BEGIN\s+[\w\s]*PRIVATE KEY-----[\s\S]*?-----END\s+[\w\s]*PRIVATE KEY-----/g,
|
|||
|
|
placeholder: '[REDACTED:private_key]',
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: 'jwt_token',
|
|||
|
|
pattern: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
|
|||
|
|
placeholder: '[REDACTED:jwt_token]',
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: 'bcrypt_hash',
|
|||
|
|
pattern: /\$2[aby]?\$\d{1,2}\$[./A-Za-z0-9]{53}/g,
|
|||
|
|
placeholder: '[REDACTED:bcrypt_hash]',
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: 'connection_string',
|
|||
|
|
pattern: /:\/\/[^:\s]+:[^@\s]+@[^/\s]+/g,
|
|||
|
|
placeholder: '[REDACTED:connection_string]',
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: 'env_secret',
|
|||
|
|
pattern: /(PASSWORD|SECRET|KEY|TOKEN|CREDENTIAL|AUTH)[\s]*[=:]\s*[^\s]{8,}/gi,
|
|||
|
|
placeholder: '[REDACTED:env_secret]',
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: 'aws_key',
|
|||
|
|
pattern: /AKIA[0-9A-Z]{16}/g,
|
|||
|
|
placeholder: '[REDACTED:aws_access_key]',
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: 'api_key_generic',
|
|||
|
|
pattern: /(?:api[_-]?key|apikey|access[_-]?token)[\s]*[=:]\s*['"]?[a-zA-Z0-9_-]{20,}['"]?/gi,
|
|||
|
|
placeholder: '[REDACTED:api_key]',
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 2.2.3 Layer 3 — Shannon Entropy Filter
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// packages/secrets-proxy/src/pipeline/layer3-entropy.ts
|
|||
|
|
|
|||
|
|
const ENTROPY_THRESHOLD = 4.5; // Shannon bits
|
|||
|
|
const MIN_TOKEN_LENGTH = 32; // Only check strings >= 32 chars
|
|||
|
|
const MAX_TOKEN_LENGTH = 256; // Don't check huge blobs
|
|||
|
|
|
|||
|
|
function shannonEntropy(str: string): number {
|
|||
|
|
const freq = new Map<string, number>();
|
|||
|
|
for (const char of str) {
|
|||
|
|
freq.set(char, (freq.get(char) ?? 0) + 1);
|
|||
|
|
}
|
|||
|
|
let entropy = 0;
|
|||
|
|
for (const count of freq.values()) {
|
|||
|
|
const p = count / str.length;
|
|||
|
|
entropy -= p * Math.log2(p);
|
|||
|
|
}
|
|||
|
|
return entropy;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function entropyRedact(text: string, redactions: RedactionEntry[]): string {
|
|||
|
|
// Split on whitespace and common delimiters
|
|||
|
|
const tokens = text.split(/[\s"',;:=\[\]{}()]+/);
|
|||
|
|
let result = text;
|
|||
|
|
|
|||
|
|
for (const token of tokens) {
|
|||
|
|
if (token.length >= MIN_TOKEN_LENGTH && token.length <= MAX_TOKEN_LENGTH) {
|
|||
|
|
const entropy = shannonEntropy(token);
|
|||
|
|
if (entropy >= ENTROPY_THRESHOLD) {
|
|||
|
|
// High entropy — likely a secret
|
|||
|
|
const placeholder = `[REDACTED:high_entropy_${token.length}chars]`;
|
|||
|
|
result = result.replace(token, placeholder);
|
|||
|
|
redactions.push({
|
|||
|
|
original_length: token.length,
|
|||
|
|
placeholder,
|
|||
|
|
layer: 3,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 2.2.4 Layer 4 — JSON Key Scanning
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// packages/secrets-proxy/src/pipeline/layer4-json.ts
|
|||
|
|
|
|||
|
|
const SENSITIVE_KEYS = new Set([
|
|||
|
|
'password', 'passwd', 'secret', 'token', 'api_key', 'apikey',
|
|||
|
|
'access_token', 'refresh_token', 'private_key', 'auth',
|
|||
|
|
'credential', 'credentials', 'authorization', 'bearer',
|
|||
|
|
'connection_string', 'database_url', 'db_password',
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
function jsonKeyRedact(text: string, redactions: RedactionEntry[]): string {
|
|||
|
|
// Find JSON-like key-value pairs
|
|||
|
|
const jsonPattern = /"([\w_]+)"\s*:\s*"([^"]{4,})"/g;
|
|||
|
|
let result = text;
|
|||
|
|
|
|||
|
|
for (const match of text.matchAll(jsonPattern)) {
|
|||
|
|
const key = match[1].toLowerCase();
|
|||
|
|
const value = match[2];
|
|||
|
|
|
|||
|
|
if (SENSITIVE_KEYS.has(key)) {
|
|||
|
|
const placeholder = `[REDACTED:${key}]`;
|
|||
|
|
result = result.replace(`"${match[2]}"`, `"${placeholder}"`);
|
|||
|
|
redactions.push({
|
|||
|
|
original_length: value.length,
|
|||
|
|
placeholder,
|
|||
|
|
layer: 4,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.3 Proxy HTTP Server
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// packages/secrets-proxy/src/server.ts
|
|||
|
|
|
|||
|
|
import { createServer, IncomingMessage, ServerResponse } from 'node:http';
|
|||
|
|
|
|||
|
|
const UPSTREAM_PROVIDERS: Record<string, string> = {
|
|||
|
|
openrouter: 'https://openrouter.ai',
|
|||
|
|
anthropic: 'https://api.anthropic.com',
|
|||
|
|
openai: 'https://api.openai.com',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|||
|
|
// Only proxy POST requests to /v1/chat/completions and similar
|
|||
|
|
if (req.method !== 'POST') {
|
|||
|
|
res.writeHead(405);
|
|||
|
|
res.end('Method not allowed');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const body = await readBody(req);
|
|||
|
|
const parsed = JSON.parse(body);
|
|||
|
|
|
|||
|
|
// Redact ALL message content before forwarding
|
|||
|
|
for (const message of parsed.messages ?? []) {
|
|||
|
|
if (typeof message.content === 'string') {
|
|||
|
|
const { text } = await redact(message.content, secretsRegistry);
|
|||
|
|
message.content = text;
|
|||
|
|
}
|
|||
|
|
// Handle tool_calls and tool results
|
|||
|
|
if (message.tool_calls) {
|
|||
|
|
for (const tc of message.tool_calls) {
|
|||
|
|
if (tc.function?.arguments) {
|
|||
|
|
const { text } = await redact(
|
|||
|
|
JSON.stringify(tc.function.arguments), secretsRegistry,
|
|||
|
|
);
|
|||
|
|
tc.function.arguments = text;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Forward to upstream provider
|
|||
|
|
const upstream = determineUpstream(req.url!, req.headers);
|
|||
|
|
const upstreamResponse = await fetch(upstream.url + req.url, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: { ...upstream.headers, 'Content-Type': 'application/json' },
|
|||
|
|
body: JSON.stringify(parsed),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Capture token usage from response headers
|
|||
|
|
captureTokenUsage(upstreamResponse.headers, parsed);
|
|||
|
|
|
|||
|
|
// Stream response back to OpenClaw
|
|||
|
|
res.writeHead(upstreamResponse.status, Object.fromEntries(upstreamResponse.headers));
|
|||
|
|
const responseBody = await upstreamResponse.text();
|
|||
|
|
res.end(responseBody);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2.4 Secrets API (for App ↔ Secrets Side Channel)
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// packages/secrets-proxy/src/api/secrets.ts
|
|||
|
|
|
|||
|
|
// These endpoints are called by the mobile app (via Hub relay)
|
|||
|
|
// NOT by the AI — the AI never reaches these endpoints
|
|||
|
|
|
|||
|
|
interface SecretsAPI {
|
|||
|
|
// User provides a new credential value
|
|||
|
|
'POST /secrets/provide': {
|
|||
|
|
body: { secret_id: string; value: string };
|
|||
|
|
response: { success: boolean };
|
|||
|
|
auth: 'hub_session_token';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// User requests to view a credential (tap-to-reveal)
|
|||
|
|
'GET /secrets/reveal': {
|
|||
|
|
query: { secret_id: string };
|
|||
|
|
response: { value: string; expires_in_seconds: 30 };
|
|||
|
|
auth: 'hub_session_token';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Generate a cryptographically secure credential
|
|||
|
|
'POST /secrets/generate': {
|
|||
|
|
body: {
|
|||
|
|
service: string;
|
|||
|
|
key: string;
|
|||
|
|
constraints?: { min_length?: number; require_special?: boolean };
|
|||
|
|
};
|
|||
|
|
response: { secret_id: string; status: 'generated' };
|
|||
|
|
auth: 'hub_session_token';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// List credentials (names + metadata, never values)
|
|||
|
|
'GET /secrets/list': {
|
|||
|
|
response: Array<{
|
|||
|
|
id: string;
|
|||
|
|
name: string;
|
|||
|
|
service: string;
|
|||
|
|
created_at: string;
|
|||
|
|
rotated_at: string | null;
|
|||
|
|
}>;
|
|||
|
|
auth: 'hub_session_token';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Rotate a credential
|
|||
|
|
'POST /secrets/rotate': {
|
|||
|
|
body: { secret_id: string };
|
|||
|
|
response: { success: boolean; new_secret_id: string };
|
|||
|
|
auth: 'hub_session_token';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// View access/rotation audit trail
|
|||
|
|
'GET /secrets/audit': {
|
|||
|
|
query: { secret_id?: string; limit?: number };
|
|||
|
|
response: Array<{
|
|||
|
|
action: 'provide' | 'reveal' | 'generate' | 'rotate' | 'inject';
|
|||
|
|
secret_name: string;
|
|||
|
|
timestamp: string;
|
|||
|
|
actor: string; // 'customer', 'agent:it-admin', 'system'
|
|||
|
|
}>;
|
|||
|
|
auth: 'hub_session_token';
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 3. Hub Updates
|
|||
|
|
|
|||
|
|
**Existing codebase:** ~15,000 LOC, 244 files, 80+ endpoints, 22+ Prisma models
|
|||
|
|
**Strategy:** Retool, not rewrite — add new capabilities alongside existing working systems
|
|||
|
|
**Stack:** Next.js 16.1.1 + Prisma 7.0.0 + PostgreSQL 16
|
|||
|
|
|
|||
|
|
### 3.1 New Prisma Models
|
|||
|
|
|
|||
|
|
#### 3.1.1 Token Usage Bucket
|
|||
|
|
|
|||
|
|
```prisma
|
|||
|
|
model TokenUsageBucket {
|
|||
|
|
id String @id @default(uuid())
|
|||
|
|
userId String
|
|||
|
|
orderId String
|
|||
|
|
agentId String
|
|||
|
|
model String
|
|||
|
|
bucketHour DateTime
|
|||
|
|
tokensInput Int @default(0)
|
|||
|
|
tokensOutput Int @default(0)
|
|||
|
|
tokensCacheRead Int @default(0)
|
|||
|
|
tokensCacheWrite Int @default(0)
|
|||
|
|
webSearchCount Int @default(0)
|
|||
|
|
webFetchCount Int @default(0)
|
|||
|
|
costCents Int @default(0)
|
|||
|
|
isPremiumModel Boolean @default(false)
|
|||
|
|
createdAt DateTime @default(now())
|
|||
|
|
updatedAt DateTime @updatedAt
|
|||
|
|
|
|||
|
|
user User @relation(fields: [userId], references: [id])
|
|||
|
|
order Order @relation(fields: [orderId], references: [id])
|
|||
|
|
|
|||
|
|
@@unique([orderId, agentId, model, bucketHour])
|
|||
|
|
@@index([userId, bucketHour])
|
|||
|
|
@@index([orderId, bucketHour])
|
|||
|
|
@@map("token_usage_buckets")
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.1.2 Billing Period
|
|||
|
|
|
|||
|
|
```prisma
|
|||
|
|
model BillingPeriod {
|
|||
|
|
id String @id @default(uuid())
|
|||
|
|
userId String
|
|||
|
|
subscriptionId String
|
|||
|
|
periodStart DateTime
|
|||
|
|
periodEnd DateTime
|
|||
|
|
tokenAllotment Int // base × founding member multiplier
|
|||
|
|
tokensUsed Int @default(0)
|
|||
|
|
overageTokens Int @default(0)
|
|||
|
|
overageCostCents Int @default(0)
|
|||
|
|
premiumTokensUsed Int @default(0)
|
|||
|
|
premiumCostCents Int @default(0)
|
|||
|
|
stripeInvoiceId String?
|
|||
|
|
status String @default("ACTIVE") // ACTIVE, CLOSED, BILLED
|
|||
|
|
overageOptedIn Boolean @default(false)
|
|||
|
|
poolExhaustedAt DateTime?
|
|||
|
|
createdAt DateTime @default(now())
|
|||
|
|
|
|||
|
|
user User @relation(fields: [userId], references: [id])
|
|||
|
|
subscription Subscription @relation(fields: [subscriptionId], references: [id])
|
|||
|
|
|
|||
|
|
@@index([userId, periodStart])
|
|||
|
|
@@index([status])
|
|||
|
|
@@map("billing_periods")
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.1.3 Founding Member
|
|||
|
|
|
|||
|
|
```prisma
|
|||
|
|
model FoundingMember {
|
|||
|
|
id String @id @default(uuid())
|
|||
|
|
userId String @unique
|
|||
|
|
tokenMultiplier Int @default(2)
|
|||
|
|
startDate DateTime @default(now())
|
|||
|
|
expiresAt DateTime // 12 months from start
|
|||
|
|
isActive Boolean @default(true)
|
|||
|
|
createdAt DateTime @default(now())
|
|||
|
|
|
|||
|
|
user User @relation(fields: [userId], references: [id])
|
|||
|
|
|
|||
|
|
@@map("founding_members")
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.1.4 Agent Config
|
|||
|
|
|
|||
|
|
```prisma
|
|||
|
|
model AgentConfig {
|
|||
|
|
id String @id @default(uuid())
|
|||
|
|
orderId String
|
|||
|
|
agentId String
|
|||
|
|
name String
|
|||
|
|
role String // dispatcher, it-admin, marketing, secretary, sales, custom
|
|||
|
|
soulMd String @db.Text
|
|||
|
|
toolsAllowed String[]
|
|||
|
|
toolsDenied String[]
|
|||
|
|
toolProfile String @default("minimal")
|
|||
|
|
modelPreset String @default("balanced") // basic, balanced, complex
|
|||
|
|
premiumModel String? // e.g., "anthropic/claude-opus-4-6"
|
|||
|
|
autonomyLevel Int? // null = tenant default
|
|||
|
|
externalCommsUnlocks Json? // { "ghost_publish": "autonomous", ... }
|
|||
|
|
isActive Boolean @default(true)
|
|||
|
|
createdAt DateTime @default(now())
|
|||
|
|
updatedAt DateTime @updatedAt
|
|||
|
|
|
|||
|
|
order Order @relation(fields: [orderId], references: [id])
|
|||
|
|
|
|||
|
|
@@unique([orderId, agentId])
|
|||
|
|
@@map("agent_configs")
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.1.5 Command Approval
|
|||
|
|
|
|||
|
|
```prisma
|
|||
|
|
model CommandApproval {
|
|||
|
|
id String @id @default(uuid())
|
|||
|
|
orderId String
|
|||
|
|
agentId String
|
|||
|
|
commandClass String // yellow, yellow_external, red, critical_red
|
|||
|
|
toolName String
|
|||
|
|
toolArgs Json
|
|||
|
|
humanReadable String
|
|||
|
|
status String @default("PENDING") // PENDING, APPROVED, DENIED, EXPIRED
|
|||
|
|
requestedAt DateTime @default(now())
|
|||
|
|
respondedAt DateTime?
|
|||
|
|
respondedBy String?
|
|||
|
|
respondedByType String? // 'staff' or 'customer'
|
|||
|
|
expiresAt DateTime
|
|||
|
|
createdAt DateTime @default(now())
|
|||
|
|
|
|||
|
|
order Order @relation(fields: [orderId], references: [id])
|
|||
|
|
|
|||
|
|
@@index([orderId, status])
|
|||
|
|
@@index([status, expiresAt])
|
|||
|
|
@@map("command_approvals")
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.1.6 Updated ServerConnection
|
|||
|
|
|
|||
|
|
```prisma
|
|||
|
|
model ServerConnection {
|
|||
|
|
id String @id @default(uuid())
|
|||
|
|
orderId String @unique
|
|||
|
|
registrationToken String @unique
|
|||
|
|
hubApiKey String?
|
|||
|
|
hubApiKeyHash String?
|
|||
|
|
safetyWrapperUrl String? // was orchestratorUrl
|
|||
|
|
openclawVersion String?
|
|||
|
|
safetyWrapperVersion String?
|
|||
|
|
lastTokenUsageSync DateTime?
|
|||
|
|
lastBackupReport DateTime?
|
|||
|
|
configVersion Int @default(0)
|
|||
|
|
status String @default("PENDING")
|
|||
|
|
lastHeartbeat DateTime?
|
|||
|
|
|
|||
|
|
order Order @relation(fields: [orderId], references: [id])
|
|||
|
|
|
|||
|
|
@@map("server_connections")
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.2 New API Endpoints
|
|||
|
|
|
|||
|
|
#### 3.2.1 Safety Wrapper Communication (replaces `/api/v1/orchestrator/*`)
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
POST /api/v1/tenant/register
|
|||
|
|
Auth: Registration token in body
|
|||
|
|
Body: { registration_token, version, openclaw_version }
|
|||
|
|
Returns: { hub_api_key, config: TenantConfig }
|
|||
|
|
|
|||
|
|
POST /api/v1/tenant/heartbeat
|
|||
|
|
Auth: Bearer {hubApiKey}
|
|||
|
|
Body: HeartbeatPayload (see Safety Wrapper section)
|
|||
|
|
Returns: HeartbeatResponse (config updates, pending commands, pool status)
|
|||
|
|
|
|||
|
|
POST /api/v1/tenant/usage
|
|||
|
|
Auth: Bearer {hubApiKey}
|
|||
|
|
Body: { buckets: TokenUsageBucket[] }
|
|||
|
|
Returns: { accepted: true, pool_remaining: number }
|
|||
|
|
|
|||
|
|
POST /api/v1/tenant/approval-request
|
|||
|
|
Auth: Bearer {hubApiKey}
|
|||
|
|
Body: ApprovalRequest
|
|||
|
|
Returns: { approval_id: string }
|
|||
|
|
Side effect: Push notification to customer + staff
|
|||
|
|
|
|||
|
|
GET /api/v1/tenant/approval-response/{id}
|
|||
|
|
Auth: Bearer {hubApiKey}
|
|||
|
|
Returns: ApprovalResponse
|
|||
|
|
|
|||
|
|
GET /api/v1/tenant/config
|
|||
|
|
Auth: Bearer {hubApiKey}
|
|||
|
|
Returns: Full TenantConfig (agents, autonomy, tools, classification rules)
|
|||
|
|
|
|||
|
|
POST /api/v1/tenant/backup-status
|
|||
|
|
Auth: Bearer {hubApiKey}
|
|||
|
|
Body: { backup_date, databases: [...], status, errors: [...] }
|
|||
|
|
Returns: { accepted: true }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.2.2 Customer Portal API
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
GET /api/v1/customer/dashboard
|
|||
|
|
Auth: Customer JWT
|
|||
|
|
Returns: { server_status, agents: AgentSummary[], recent_activity, usage_summary }
|
|||
|
|
|
|||
|
|
GET /api/v1/customer/agents
|
|||
|
|
Auth: Customer JWT
|
|||
|
|
Returns: AgentConfig[] with status
|
|||
|
|
|
|||
|
|
GET /api/v1/customer/agents/{id}
|
|||
|
|
Auth: Customer JWT
|
|||
|
|
Returns: Full AgentConfig with activity feed
|
|||
|
|
|
|||
|
|
PATCH /api/v1/customer/agents/{id}
|
|||
|
|
Auth: Customer JWT
|
|||
|
|
Body: { autonomy_level?, soul_md?, model_preset?, premium_model? }
|
|||
|
|
Returns: Updated AgentConfig
|
|||
|
|
Side effect: Pushes config update to Safety Wrapper
|
|||
|
|
|
|||
|
|
PATCH /api/v1/customer/agents/{id}/external-comms
|
|||
|
|
Auth: Customer JWT
|
|||
|
|
Body: { unlocks: Record<string, 'autonomous' | 'gated'> }
|
|||
|
|
Returns: Updated AgentConfig
|
|||
|
|
|
|||
|
|
POST /api/v1/customer/agents
|
|||
|
|
Auth: Customer JWT (custom agent creation)
|
|||
|
|
Body: { name, role: 'custom', soul_md, tools_allowed, tools_denied }
|
|||
|
|
Returns: New AgentConfig
|
|||
|
|
|
|||
|
|
DELETE /api/v1/customer/agents/{id}
|
|||
|
|
Auth: Customer JWT (custom agents only — cannot delete defaults)
|
|||
|
|
Returns: { deleted: true }
|
|||
|
|
|
|||
|
|
GET /api/v1/customer/approvals
|
|||
|
|
Auth: Customer JWT
|
|||
|
|
Returns: CommandApproval[] (pending only by default)
|
|||
|
|
|
|||
|
|
POST /api/v1/customer/approvals/{id}
|
|||
|
|
Auth: Customer JWT
|
|||
|
|
Body: { action: 'approve' | 'deny' }
|
|||
|
|
Returns: Updated CommandApproval
|
|||
|
|
Side effect: Notifies Safety Wrapper of decision
|
|||
|
|
|
|||
|
|
GET /api/v1/customer/usage
|
|||
|
|
Auth: Customer JWT
|
|||
|
|
Returns: {
|
|||
|
|
current_period: BillingPeriod,
|
|||
|
|
by_agent: Record<string, { tokens_used, cost_cents }>,
|
|||
|
|
by_model: Record<string, { tokens_used, cost_cents }>,
|
|||
|
|
daily_trend: Array<{ date, tokens }>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
GET /api/v1/customer/usage/breakdown
|
|||
|
|
Auth: Customer JWT
|
|||
|
|
Query: { period_id?, agent_id?, model? }
|
|||
|
|
Returns: Detailed TokenUsageBucket[] with cost calculations
|
|||
|
|
|
|||
|
|
GET /api/v1/customer/tools
|
|||
|
|
Auth: Customer JWT
|
|||
|
|
Returns: Array<{ name, status, category, has_api, internal_url }>
|
|||
|
|
|
|||
|
|
GET /api/v1/customer/billing
|
|||
|
|
Auth: Customer JWT
|
|||
|
|
Returns: {
|
|||
|
|
current_period: BillingPeriod,
|
|||
|
|
subscription: Subscription,
|
|||
|
|
founding_member?: { multiplier, expires_at },
|
|||
|
|
overage_opted_in: boolean,
|
|||
|
|
stripe_portal_url: string,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
GET /api/v1/customer/backups
|
|||
|
|
Auth: Customer JWT
|
|||
|
|
Returns: { last_backup, status, databases, snapshots }
|
|||
|
|
|
|||
|
|
GET /api/v1/customer/server
|
|||
|
|
Auth: Customer JWT
|
|||
|
|
Returns: { ip, tier, disk_usage, memory_usage, uptime, containers_running }
|
|||
|
|
|
|||
|
|
POST /api/v1/customer/chat
|
|||
|
|
Auth: Customer JWT
|
|||
|
|
Body: { agent_id?, message }
|
|||
|
|
Returns: SSE stream of agent response
|
|||
|
|
Note: Relays through Hub to tenant Safety Wrapper → OpenClaw
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.2.3 Admin Billing & Analytics
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
POST /api/v1/admin/billing/usage
|
|||
|
|
Auth: Bearer {hubApiKey} (called by Safety Wrapper)
|
|||
|
|
Body: { buckets: TokenUsageBucket[] }
|
|||
|
|
Logic: Upsert buckets, update BillingPeriod totals, check pool threshold
|
|||
|
|
|
|||
|
|
GET /api/v1/admin/billing/{customerId}
|
|||
|
|
Auth: Staff JWT
|
|||
|
|
Returns: Customer billing summary with current period, usage, overages
|
|||
|
|
|
|||
|
|
GET /api/v1/admin/billing/{customerId}/history
|
|||
|
|
Auth: Staff JWT
|
|||
|
|
Returns: Historical BillingPeriod[] with totals
|
|||
|
|
|
|||
|
|
POST /api/v1/admin/billing/overages
|
|||
|
|
Auth: Staff JWT or automated cron
|
|||
|
|
Logic: Find customers where tokensUsed > tokenAllotment, create Stripe invoice items
|
|||
|
|
|
|||
|
|
GET /api/v1/admin/analytics/tokens
|
|||
|
|
Auth: Staff JWT
|
|||
|
|
Returns: Global token usage by model, by tier, daily trends
|
|||
|
|
|
|||
|
|
GET /api/v1/admin/analytics/tokens/{orderId}
|
|||
|
|
Auth: Staff JWT
|
|||
|
|
Returns: Per-tenant token breakdown
|
|||
|
|
|
|||
|
|
GET /api/v1/admin/analytics/costs
|
|||
|
|
Auth: Staff JWT
|
|||
|
|
Returns: Cost breakdown by customer, model, agent with margin analysis
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.2.4 Agent Management (Admin)
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
GET /api/v1/admin/agents/templates
|
|||
|
|
Auth: Staff JWT
|
|||
|
|
Returns: Array of agent role templates (dispatcher, it-admin, etc.)
|
|||
|
|
|
|||
|
|
POST /api/v1/admin/orders/{id}/agents
|
|||
|
|
Auth: Staff JWT
|
|||
|
|
Body: AgentConfig creation payload
|
|||
|
|
Returns: New AgentConfig
|
|||
|
|
|
|||
|
|
GET /api/v1/admin/orders/{id}/agents
|
|||
|
|
Auth: Staff JWT
|
|||
|
|
Returns: All AgentConfig[] for this tenant
|
|||
|
|
|
|||
|
|
PATCH /api/v1/admin/orders/{id}/agents/{agentId}
|
|||
|
|
Auth: Staff JWT
|
|||
|
|
Body: Partial AgentConfig update
|
|||
|
|
Returns: Updated AgentConfig
|
|||
|
|
|
|||
|
|
DELETE /api/v1/admin/orders/{id}/agents/{agentId}
|
|||
|
|
Auth: Staff JWT
|
|||
|
|
Returns: { deleted: true }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 3.2.5 DNS Automation
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
POST /api/v1/admin/orders/{id}/dns/create
|
|||
|
|
Auth: Staff JWT
|
|||
|
|
Body: { provider: 'cloudflare' | 'entri', zone_id?: string }
|
|||
|
|
Logic: Auto-create A records for all required subdomains
|
|||
|
|
Returns: { records_created: number, records: DnsRecord[] }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.3 Stripe Billing Integration Updates
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// src/lib/services/billing-service.ts
|
|||
|
|
|
|||
|
|
interface BillingService {
|
|||
|
|
/**
|
|||
|
|
* Create/update billing period on subscription renewal.
|
|||
|
|
* Applies founding member multiplier if active.
|
|||
|
|
*/
|
|||
|
|
createBillingPeriod(userId: string, subscriptionId: string): Promise<BillingPeriod>;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Ingest token usage from Safety Wrapper.
|
|||
|
|
* Updates BillingPeriod totals.
|
|||
|
|
* Triggers alerts at 80%, 90%, 100% pool usage.
|
|||
|
|
*/
|
|||
|
|
ingestUsage(orderId: string, buckets: TokenUsageBucket[]): Promise<{
|
|||
|
|
pool_remaining: number;
|
|||
|
|
alert?: 'approaching_limit' | 'limit_reached';
|
|||
|
|
}>;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Process overages via Stripe Billing Meters.
|
|||
|
|
* Creates invoice items for included model overages and premium usage.
|
|||
|
|
*/
|
|||
|
|
processOverages(billingPeriodId: string): Promise<{
|
|||
|
|
overage_amount_cents: number;
|
|||
|
|
premium_amount_cents: number;
|
|||
|
|
stripe_invoice_item_id?: string;
|
|||
|
|
}>;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Generate Stripe customer portal URL for billing management.
|
|||
|
|
*/
|
|||
|
|
getPortalUrl(userId: string): Promise<string>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Stripe Billing Meter for overage tracking
|
|||
|
|
// Meter: "letsbe_token_overage" — reports token count when pool exhausted
|
|||
|
|
// Meter: "letsbe_premium_usage" — reports premium model token count
|
|||
|
|
|
|||
|
|
// Price: $X per 1M tokens (included model overage, with markup)
|
|||
|
|
// Price: $Y per 1M tokens (premium model, with sliding markup)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.4 Push Notification Service
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// src/lib/services/notification-service.ts
|
|||
|
|
|
|||
|
|
interface NotificationService {
|
|||
|
|
/**
|
|||
|
|
* Send push notification for command approval request.
|
|||
|
|
* Supports: Expo Push (mobile), Web Push, Email fallback.
|
|||
|
|
*/
|
|||
|
|
sendApprovalNotification(params: {
|
|||
|
|
user_id: string;
|
|||
|
|
approval_id: string;
|
|||
|
|
agent_name: string;
|
|||
|
|
human_readable: string;
|
|||
|
|
classification: string;
|
|||
|
|
}): Promise<void>;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Send usage alert notification.
|
|||
|
|
*/
|
|||
|
|
sendUsageAlert(params: {
|
|||
|
|
user_id: string;
|
|||
|
|
percent_used: number;
|
|||
|
|
pool_remaining: number;
|
|||
|
|
}): Promise<void>;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Send server health alert.
|
|||
|
|
*/
|
|||
|
|
sendHealthAlert(params: {
|
|||
|
|
user_id: string;
|
|||
|
|
severity: 'soft' | 'medium' | 'hard';
|
|||
|
|
message: string;
|
|||
|
|
}): Promise<void>;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.5 Chat Relay Service
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// src/lib/services/chat-relay-service.ts
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Relays chat messages between the mobile app/customer portal
|
|||
|
|
* and the tenant's OpenClaw instance via the Safety Wrapper.
|
|||
|
|
*
|
|||
|
|
* Flow: App → Hub WebSocket → Hub → Safety Wrapper HTTP → OpenClaw → response
|
|||
|
|
* Return: OpenClaw → Safety Wrapper → Hub → Hub WebSocket → App
|
|||
|
|
*/
|
|||
|
|
interface ChatRelayService {
|
|||
|
|
/**
|
|||
|
|
* Open a streaming connection to a tenant's agent.
|
|||
|
|
* Returns an SSE stream of agent responses.
|
|||
|
|
*/
|
|||
|
|
relayMessage(params: {
|
|||
|
|
user_id: string;
|
|||
|
|
order_id: string;
|
|||
|
|
agent_id: string; // 'dispatcher' for team chat, specific agent for direct
|
|||
|
|
message: string;
|
|||
|
|
conversation_id?: string; // Resume existing conversation
|
|||
|
|
}): AsyncGenerator<ChatEvent>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type ChatEvent =
|
|||
|
|
| { type: 'text_delta'; content: string }
|
|||
|
|
| { type: 'tool_call_start'; tool_name: string; agent_id: string }
|
|||
|
|
| { type: 'tool_call_result'; tool_name: string; success: boolean }
|
|||
|
|
| { type: 'approval_required'; approval_id: string; human_readable: string }
|
|||
|
|
| { type: 'secret_input_required'; secret_id: string; prompt: string }
|
|||
|
|
| { type: 'secret_card'; secret_id: string; label: string }
|
|||
|
|
| { type: 'done'; conversation_id: string };
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 4. Provisioner Updates
|
|||
|
|
|
|||
|
|
**Existing codebase:** ~4,477 LOC Bash, zero tests
|
|||
|
|
**Strategy:** Update deployment targets, add Safety Wrapper config generation
|
|||
|
|
**Location:** `packages/provisioner/` in monorepo (formerly `letsbe-ansible-runner`)
|
|||
|
|
|
|||
|
|
### 4.1 Updated Provisioning Pipeline
|
|||
|
|
|
|||
|
|
The 10-step pipeline stays. Step 10 changes completely:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
Step 1: System packages (apt-get) [UNCHANGED]
|
|||
|
|
Step 2: Docker CE installation [UNCHANGED]
|
|||
|
|
Step 3: Disable conflicting services [UNCHANGED]
|
|||
|
|
Step 4: nginx + fallback config [UNCHANGED]
|
|||
|
|
Step 5: UFW firewall (80, 443, 22022) [UNCHANGED]
|
|||
|
|
Step 6: Optional admin user + SSH key [UNCHANGED]
|
|||
|
|
Step 7: SSH hardening (port 22022, key-only) [UNCHANGED]
|
|||
|
|
Step 8: Unattended security updates [UNCHANGED]
|
|||
|
|
Step 9: Deploy tool stacks via docker-compose [UPDATED: remove n8n references]
|
|||
|
|
Step 10: Deploy LetsBe AI stack + bootstrap [REWRITTEN: OpenClaw + Safety Wrapper]
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.2 Step 10 — Deploy LetsBe AI Stack
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
#!/bin/bash
|
|||
|
|
# provisioner/scripts/deploy_ai_stack.sh
|
|||
|
|
|
|||
|
|
deploy_ai_stack() {
|
|||
|
|
local DOMAIN="$1"
|
|||
|
|
local TENANT_ID="$2"
|
|||
|
|
local HUB_URL="$3"
|
|||
|
|
local REGISTRATION_TOKEN="$4"
|
|||
|
|
local BUSINESS_TYPE="$5"
|
|||
|
|
|
|||
|
|
log_step "Deploying LetsBe AI Stack"
|
|||
|
|
|
|||
|
|
# 1. Generate Safety Wrapper configuration
|
|||
|
|
generate_safety_wrapper_config "$DOMAIN" "$TENANT_ID" "$HUB_URL" "$REGISTRATION_TOKEN"
|
|||
|
|
|
|||
|
|
# 2. Generate OpenClaw configuration
|
|||
|
|
generate_openclaw_config "$DOMAIN" "$BUSINESS_TYPE"
|
|||
|
|
|
|||
|
|
# 3. Seed secrets registry from env_setup.sh outputs
|
|||
|
|
seed_secrets_registry
|
|||
|
|
|
|||
|
|
# 4. Generate agent SOUL.md files from business type template
|
|||
|
|
generate_agent_configs "$BUSINESS_TYPE"
|
|||
|
|
|
|||
|
|
# 5. Generate tool registry from installed tools
|
|||
|
|
generate_tool_registry "$DOMAIN"
|
|||
|
|
|
|||
|
|
# 6. Deploy containers via docker-compose
|
|||
|
|
deploy_letsbe_containers
|
|||
|
|
|
|||
|
|
# 7. Wait for Safety Wrapper to register with Hub
|
|||
|
|
wait_for_registration "$HUB_URL" "$REGISTRATION_TOKEN"
|
|||
|
|
|
|||
|
|
# 8. Run initial-setup Playwright scenarios via OpenClaw
|
|||
|
|
run_initial_setup_scenarios
|
|||
|
|
|
|||
|
|
# 9. Clean up config.json (CRITICAL: remove plaintext passwords)
|
|||
|
|
cleanup_config_json
|
|||
|
|
|
|||
|
|
log_step "LetsBe AI Stack deployed successfully"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.3 Generated Configuration Files
|
|||
|
|
|
|||
|
|
#### 4.3.1 Safety Wrapper Config
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
generate_safety_wrapper_config() {
|
|||
|
|
cat > /opt/letsbe/config/safety-wrapper.json <<EOF
|
|||
|
|
{
|
|||
|
|
"server": {
|
|||
|
|
"port": 8200,
|
|||
|
|
"host": "127.0.0.1",
|
|||
|
|
"auth_token": "${SAFETY_WRAPPER_TOKEN}"
|
|||
|
|
},
|
|||
|
|
"hub": {
|
|||
|
|
"url": "${HUB_URL}",
|
|||
|
|
"registration_token": "${REGISTRATION_TOKEN}"
|
|||
|
|
},
|
|||
|
|
"secrets_proxy": { "url": "http://127.0.0.1:8100" },
|
|||
|
|
"openclaw": { "url": "http://127.0.0.1:18789", "token": "${OPENCLAW_TOKEN}" },
|
|||
|
|
"tenant": { "id": "${TENANT_ID}", "default_autonomy_level": 2 },
|
|||
|
|
"database": { "path": "/opt/letsbe/data/safety-wrapper.db" }
|
|||
|
|
}
|
|||
|
|
EOF
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
#### 4.3.2 OpenClaw Config
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
generate_openclaw_config() {
|
|||
|
|
cat > /opt/letsbe/config/openclaw.json <<EOF
|
|||
|
|
{
|
|||
|
|
"model": {
|
|||
|
|
"primary": "openrouter/deepseek/deepseek-chat-v3-0324",
|
|||
|
|
"fallbacks": [
|
|||
|
|
"openrouter/google/gemini-2.0-flash-001",
|
|||
|
|
"openrouter/minimax/minimax-m1-80k"
|
|||
|
|
]
|
|||
|
|
},
|
|||
|
|
"provider": {
|
|||
|
|
"url": "http://127.0.0.1:8100",
|
|||
|
|
"key": "${OPENROUTER_API_KEY}"
|
|||
|
|
},
|
|||
|
|
"security": {
|
|||
|
|
"elevated": { "enable": false },
|
|||
|
|
"token": "${OPENCLAW_TOKEN}"
|
|||
|
|
},
|
|||
|
|
"tools": {
|
|||
|
|
"loopDetection": { "enabled": true, "maxCalls": 30 },
|
|||
|
|
"server": {
|
|||
|
|
"url": "http://127.0.0.1:8200",
|
|||
|
|
"token": "${SAFETY_WRAPPER_TOKEN}"
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
"cacheRetention": "long",
|
|||
|
|
"heartbeat": { "every": "55m" },
|
|||
|
|
"logging": { "redactSensitive": "tools" },
|
|||
|
|
"browser": {
|
|||
|
|
"profile": "openclaw-managed",
|
|||
|
|
"ssrf": { "allowlist": ["127.0.0.1", "localhost"] }
|
|||
|
|
},
|
|||
|
|
"agents": {
|
|||
|
|
"list": [/* Generated per business type template */]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
EOF
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.4 Docker Compose — LetsBe Stack
|
|||
|
|
|
|||
|
|
```yaml
|
|||
|
|
# provisioner/stacks/letsbe/docker-compose.yml
|
|||
|
|
version: '3.8'
|
|||
|
|
services:
|
|||
|
|
openclaw:
|
|||
|
|
image: code.letsbe.solutions/letsbe/openclaw:latest
|
|||
|
|
container_name: letsbe-openclaw
|
|||
|
|
restart: unless-stopped
|
|||
|
|
network_mode: host
|
|||
|
|
volumes:
|
|||
|
|
- /opt/letsbe/config/openclaw.json:/home/openclaw/.openclaw/openclaw.json:ro
|
|||
|
|
- /opt/letsbe/agents:/home/openclaw/.openclaw/agents:rw
|
|||
|
|
- /opt/letsbe/skills:/home/openclaw/.openclaw/skills:ro
|
|||
|
|
- /opt/letsbe/references:/home/openclaw/.openclaw/references:ro
|
|||
|
|
- /opt/letsbe/data/openclaw:/home/openclaw/.openclaw/data:rw
|
|||
|
|
- /opt/letsbe/shared-memory:/home/openclaw/.openclaw/shared-memory:rw
|
|||
|
|
environment:
|
|||
|
|
- NODE_ENV=production
|
|||
|
|
mem_limit: 512m
|
|||
|
|
cpus: 2.0
|
|||
|
|
|
|||
|
|
safety-wrapper:
|
|||
|
|
image: code.letsbe.solutions/letsbe/safety-wrapper:latest
|
|||
|
|
container_name: letsbe-safety-wrapper
|
|||
|
|
restart: unless-stopped
|
|||
|
|
network_mode: host
|
|||
|
|
volumes:
|
|||
|
|
- /opt/letsbe/config/safety-wrapper.json:/app/config.json:ro
|
|||
|
|
- /opt/letsbe/data/safety-wrapper.db:/app/data/safety-wrapper.db:rw
|
|||
|
|
environment:
|
|||
|
|
- NODE_ENV=production
|
|||
|
|
mem_limit: 128m
|
|||
|
|
cpus: 0.5
|
|||
|
|
|
|||
|
|
secrets-proxy:
|
|||
|
|
image: code.letsbe.solutions/letsbe/secrets-proxy:latest
|
|||
|
|
container_name: letsbe-secrets-proxy
|
|||
|
|
restart: unless-stopped
|
|||
|
|
network_mode: host
|
|||
|
|
volumes:
|
|||
|
|
- /opt/letsbe/data/safety-wrapper.db:/app/data/secrets.db:ro
|
|||
|
|
environment:
|
|||
|
|
- PORT=8100
|
|||
|
|
- UPSTREAM_URL=https://openrouter.ai
|
|||
|
|
- NODE_ENV=production
|
|||
|
|
mem_limit: 64m
|
|||
|
|
cpus: 0.25
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4.5 n8n Cleanup Manifest
|
|||
|
|
|
|||
|
|
Files requiring n8n reference removal:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
provisioner/stacks/automation/docker-compose.yml -- Remove n8n service
|
|||
|
|
provisioner/stacks/automation/nginx/n8n.conf -- Delete file
|
|||
|
|
provisioner/scripts/env_setup.sh -- Remove n8n variables
|
|||
|
|
provisioner/scripts/setup.sh -- Remove n8n deploy step
|
|||
|
|
provisioner/playwright/n8n-initial-setup.js -- Delete file
|
|||
|
|
provisioner/scripts/backups.sh -- Remove n8n DB backup
|
|||
|
|
provisioner/scripts/restore.sh -- Remove n8n restore
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Replace n8n with Activepieces in all relevant locations.
|
|||
|
|
|
|||
|
|
### 4.6 Secrets Registry Seeding
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
seed_secrets_registry() {
|
|||
|
|
# Read all generated credentials from env_setup.sh outputs
|
|||
|
|
# Insert into Safety Wrapper's SQLite secrets registry
|
|||
|
|
|
|||
|
|
local DB_PATH="/opt/letsbe/data/safety-wrapper.db"
|
|||
|
|
|
|||
|
|
# Create secrets table if not exists (Safety Wrapper will also do this)
|
|||
|
|
sqlite3 "$DB_PATH" "CREATE TABLE IF NOT EXISTS secrets (
|
|||
|
|
id TEXT PRIMARY KEY,
|
|||
|
|
name TEXT NOT NULL UNIQUE,
|
|||
|
|
encrypted_value BLOB NOT NULL,
|
|||
|
|
service TEXT NOT NULL,
|
|||
|
|
config_path TEXT,
|
|||
|
|
pattern TEXT,
|
|||
|
|
created_at TEXT DEFAULT (datetime('now')),
|
|||
|
|
rotated_at TEXT,
|
|||
|
|
rotation_count INTEGER DEFAULT 0
|
|||
|
|
);"
|
|||
|
|
|
|||
|
|
# For each tool's .env file, extract credentials and insert
|
|||
|
|
for env_file in /opt/letsbe/env/*.env; do
|
|||
|
|
local service=$(basename "$env_file" .env)
|
|||
|
|
while IFS='=' read -r key value; do
|
|||
|
|
if is_secret_key "$key"; then
|
|||
|
|
local encrypted=$(encrypt_value "$value" "$SECRETS_ENCRYPTION_KEY")
|
|||
|
|
sqlite3 "$DB_PATH" "INSERT OR REPLACE INTO secrets (id, name, encrypted_value, service, config_path)
|
|||
|
|
VALUES ('$(uuidgen)', '${service}_${key}', X'${encrypted}', '${service}', '${env_file}');"
|
|||
|
|
fi
|
|||
|
|
done < "$env_file"
|
|||
|
|
done
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
is_secret_key() {
|
|||
|
|
local key="$1"
|
|||
|
|
[[ "$key" =~ (PASSWORD|SECRET|KEY|TOKEN|CREDENTIAL|AUTH|API_KEY) ]]
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 5. Mobile App
|
|||
|
|
|
|||
|
|
**Framework:** Expo SDK 52+ (Bare Workflow with EAS Build + EAS Update)
|
|||
|
|
**Language:** TypeScript (strict mode)
|
|||
|
|
**State:** Zustand + TanStack Query v5
|
|||
|
|
**Navigation:** Expo Router (file-based)
|
|||
|
|
**Push Notifications:** Expo Push + APNs/FCM
|
|||
|
|
**Repository:** `apps/mobile/` in monorepo
|
|||
|
|
|
|||
|
|
### 5.1 Screen Architecture
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
App
|
|||
|
|
├── (auth)
|
|||
|
|
│ ├── login.tsx — Email/password login
|
|||
|
|
│ ├── register.tsx — Account creation (from website flow)
|
|||
|
|
│ └── forgot-password.tsx — Password reset
|
|||
|
|
├── (tabs)
|
|||
|
|
│ ├── index.tsx — Dashboard (morning briefing, quick actions)
|
|||
|
|
│ ├── chat.tsx — Team chat (Dispatcher) or direct agent chat
|
|||
|
|
│ ├── activity.tsx — Agent activity feed
|
|||
|
|
│ ├── approvals.tsx — Pending command approvals
|
|||
|
|
│ └── settings.tsx — Account, agents, billing, server
|
|||
|
|
├── chat/
|
|||
|
|
│ ├── [agent_id].tsx — Direct chat with specific agent
|
|||
|
|
│ └── team.tsx — Team chat via Dispatcher
|
|||
|
|
├── agents/
|
|||
|
|
│ ├── index.tsx — Agent roster
|
|||
|
|
│ ├── [id].tsx — Agent detail (config, stats, conversations)
|
|||
|
|
│ └── [id]/edit.tsx — Edit agent (SOUL.md, autonomy, model, external comms)
|
|||
|
|
├── approvals/
|
|||
|
|
│ └── [id].tsx — Approval detail with context
|
|||
|
|
├── usage/
|
|||
|
|
│ ├── index.tsx — Usage dashboard (per-agent, per-model)
|
|||
|
|
│ └── breakdown.tsx — Detailed breakdown
|
|||
|
|
├── server/
|
|||
|
|
│ ├── index.tsx — Server health overview
|
|||
|
|
│ ├── tools.tsx — Installed tools list
|
|||
|
|
│ └── backups.tsx — Backup status
|
|||
|
|
├── billing/
|
|||
|
|
│ ├── index.tsx — Current period, plan, overages
|
|||
|
|
│ └── portal.tsx — Stripe portal WebView
|
|||
|
|
└── secrets/
|
|||
|
|
├── provide.tsx — Secure credential input modal
|
|||
|
|
└── reveal.tsx — Tap-to-reveal credential card
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.2 Core Components
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// apps/mobile/src/components/chat/ChatView.tsx
|
|||
|
|
|
|||
|
|
interface ChatViewProps {
|
|||
|
|
agentId: string; // 'dispatcher' for team, specific agent for direct
|
|||
|
|
conversationId?: string; // Resume existing conversation
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Renders streaming agent responses via SSE from Hub
|
|||
|
|
// Handles special events:
|
|||
|
|
// - approval_required → navigates to approval screen
|
|||
|
|
// - secret_input_required → opens secure modal
|
|||
|
|
// - secret_card → renders tap-to-reveal card
|
|||
|
|
// - tool_call_start/result → shows tool activity indicator
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// apps/mobile/src/components/approvals/ApprovalCard.tsx
|
|||
|
|
|
|||
|
|
interface ApprovalCardProps {
|
|||
|
|
approval: CommandApproval;
|
|||
|
|
onApprove: () => void;
|
|||
|
|
onDeny: () => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Rendered in both:
|
|||
|
|
// 1. Push notification action buttons (one-tap approve/deny)
|
|||
|
|
// 2. In-app approval queue
|
|||
|
|
// Shows: agent name, human-readable description, classification badge, time remaining
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// apps/mobile/src/components/secrets/SecretCard.tsx
|
|||
|
|
|
|||
|
|
interface SecretCardProps {
|
|||
|
|
secretId: string;
|
|||
|
|
label: string; // e.g., "Nextcloud Admin Password"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Tap-to-reveal card:
|
|||
|
|
// 1. Shows masked "••••••••" by default
|
|||
|
|
// 2. On tap: fetches from Secrets Proxy via Hub relay (authenticated)
|
|||
|
|
// 3. Displays for 30 seconds, then auto-clears
|
|||
|
|
// 4. Copy button writes to clipboard, clears after 60 seconds
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.3 Push Notification Handlers
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// apps/mobile/src/notifications/handlers.ts
|
|||
|
|
|
|||
|
|
import * as Notifications from 'expo-notifications';
|
|||
|
|
|
|||
|
|
// Register notification categories with action buttons
|
|||
|
|
Notifications.setNotificationCategoryAsync('approval_request', [
|
|||
|
|
{ identifier: 'approve', buttonTitle: 'Approve', options: { opensAppToForeground: false } },
|
|||
|
|
{ identifier: 'deny', buttonTitle: 'Deny', options: { opensAppToForeground: false } },
|
|||
|
|
{ identifier: 'view', buttonTitle: 'View Details', options: { opensAppToForeground: true } },
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
Notifications.setNotificationCategoryAsync('usage_alert', [
|
|||
|
|
{ identifier: 'view_usage', buttonTitle: 'View Usage', options: { opensAppToForeground: true } },
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
// Handle background notification actions
|
|||
|
|
Notifications.addNotificationResponseReceivedListener(async (response) => {
|
|||
|
|
const { actionIdentifier, notification } = response;
|
|||
|
|
const data = notification.request.content.data;
|
|||
|
|
|
|||
|
|
switch (data.type) {
|
|||
|
|
case 'approval_request':
|
|||
|
|
if (actionIdentifier === 'approve') {
|
|||
|
|
await api.post(`/customer/approvals/${data.approval_id}`, { action: 'approve' });
|
|||
|
|
} else if (actionIdentifier === 'deny') {
|
|||
|
|
await api.post(`/customer/approvals/${data.approval_id}`, { action: 'deny' });
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 5.4 State Management
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// apps/mobile/src/stores/auth-store.ts
|
|||
|
|
interface AuthStore {
|
|||
|
|
token: string | null;
|
|||
|
|
user: User | null;
|
|||
|
|
login: (email: string, password: string) => Promise<void>;
|
|||
|
|
logout: () => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// apps/mobile/src/stores/agent-store.ts
|
|||
|
|
interface AgentStore {
|
|||
|
|
agents: AgentConfig[];
|
|||
|
|
activeChat: string | null; // Currently chatting with
|
|||
|
|
setActiveChat: (agentId: string) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// apps/mobile/src/hooks/use-chat.ts — TanStack Query + SSE
|
|||
|
|
// apps/mobile/src/hooks/use-approvals.ts — TanStack Query with polling
|
|||
|
|
// apps/mobile/src/hooks/use-usage.ts — TanStack Query
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 6. Website & Onboarding
|
|||
|
|
|
|||
|
|
**Framework:** Next.js (separate app in monorepo, NOT part of Hub)
|
|||
|
|
**Location:** `apps/website/` in monorepo
|
|||
|
|
**Hosting:** Vercel or self-hosted (static export + API routes)
|
|||
|
|
**Domain:** letsbe.biz
|
|||
|
|
|
|||
|
|
### 6.1 Architecture Decision: Separate App
|
|||
|
|
|
|||
|
|
The website is a separate Next.js application, not part of the Hub. Reasons:
|
|||
|
|
|
|||
|
|
1. **Different deployment cadence** — Marketing site changes weekly; Hub changes are coordinated releases
|
|||
|
|
2. **Different scaling needs** — Website can be on Vercel CDN; Hub must be near the database
|
|||
|
|
3. **Different audience** — Public visitors vs. authenticated users
|
|||
|
|
4. **SEO optimization** — Full static generation for landing pages, no auth overhead
|
|||
|
|
|
|||
|
|
The website communicates with the Hub via the Public API (`/api/v1/public/*`).
|
|||
|
|
|
|||
|
|
### 6.2 Onboarding Flow
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
Page 1: Landing
|
|||
|
|
├── Hero: "Your AI team. Your server. Your rules."
|
|||
|
|
├── Chat input: "Tell us about your business"
|
|||
|
|
└── CTA: "Get Started"
|
|||
|
|
|
|||
|
|
Page 2: AI Conversation (1-2 messages)
|
|||
|
|
├── AI classifies business type
|
|||
|
|
├── Returns: { business_type, confidence, suggested_tools, suggested_tier }
|
|||
|
|
└── AI model: Gemini 2.0 Flash (cheap, fast, accurate for classification)
|
|||
|
|
|
|||
|
|
Page 3: Tool Recommendation
|
|||
|
|
├── Pre-selected tool bundle based on business type
|
|||
|
|
├── Toggle tools on/off
|
|||
|
|
├── Live resource calculator (RAM, disk, CPU)
|
|||
|
|
└── "X tools selected — requires Build tier or higher"
|
|||
|
|
|
|||
|
|
Page 4: Server Selection
|
|||
|
|
├── Only tiers meeting minimum requirements shown
|
|||
|
|
├── Region selection (EU: Nuremberg, US: Manassas)
|
|||
|
|
├── Monthly price display
|
|||
|
|
└── Founding member badge if available
|
|||
|
|
|
|||
|
|
Page 5: Domain Setup
|
|||
|
|
├── Option A: "I have a domain" → enter domain, show DNS instructions
|
|||
|
|
├── Option B: "I need a domain" → Netcup domain reselling (future)
|
|||
|
|
└── Subdomain preview: files.yourdomain.com, chat.yourdomain.com, etc.
|
|||
|
|
|
|||
|
|
Page 6: Agent Configuration (Optional)
|
|||
|
|
├── Business-type template pre-loaded
|
|||
|
|
├── Quick personality tweaks
|
|||
|
|
├── Model preset selection (Basic/Balanced/Complex)
|
|||
|
|
└── "Customize later in the app"
|
|||
|
|
|
|||
|
|
Page 7: Payment
|
|||
|
|
├── Stripe Checkout
|
|||
|
|
├── Founding member discount display
|
|||
|
|
└── Recurring billing disclosure
|
|||
|
|
|
|||
|
|
Page 8: Provisioning Status
|
|||
|
|
├── Real-time SSE progress (10 steps)
|
|||
|
|
├── Estimated time: 15-20 minutes
|
|||
|
|
├── Email notification on completion
|
|||
|
|
└── App download links (iOS/Android)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 6.3 Business Type Classification
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// apps/website/src/lib/classify.ts
|
|||
|
|
|
|||
|
|
interface ClassificationResult {
|
|||
|
|
business_type: string; // 'freelancer', 'agency', 'ecommerce', 'consulting', ...
|
|||
|
|
confidence: number; // 0.0 - 1.0
|
|||
|
|
suggested_tools: string[]; // Tool IDs from catalog
|
|||
|
|
suggested_tier: string; // 'lite', 'build', 'scale', 'enterprise'
|
|||
|
|
agent_template: string; // Template ID for agent configs
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Uses Gemini 2.0 Flash via OpenRouter
|
|||
|
|
// Single system prompt + user description → structured JSON output
|
|||
|
|
// Cost: ~$0.001 per classification (negligible)
|
|||
|
|
// Latency: <2 seconds
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 6.4 Resource Calculator
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// apps/website/src/lib/resource-calculator.ts
|
|||
|
|
|
|||
|
|
interface ResourceEstimate {
|
|||
|
|
ram_mb: number;
|
|||
|
|
disk_gb: number;
|
|||
|
|
min_tier: 'lite' | 'build' | 'scale' | 'enterprise';
|
|||
|
|
headroom_percent: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const TOOL_RESOURCES: Record<string, { ram_mb: number; disk_gb: number }> = {
|
|||
|
|
nextcloud: { ram_mb: 512, disk_gb: 10 },
|
|||
|
|
chatwoot: { ram_mb: 768, disk_gb: 5 },
|
|||
|
|
ghost: { ram_mb: 256, disk_gb: 3 },
|
|||
|
|
odoo: { ram_mb: 1024, disk_gb: 10 },
|
|||
|
|
calcom: { ram_mb: 256, disk_gb: 2 },
|
|||
|
|
// ... all 28+ tools
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const LETSBE_OVERHEAD_MB = 640; // OpenClaw + Safety Wrapper + Secrets Proxy + nginx
|
|||
|
|
|
|||
|
|
function calculateResources(selectedTools: string[]): ResourceEstimate {
|
|||
|
|
const toolRam = selectedTools.reduce((sum, t) => sum + (TOOL_RESOURCES[t]?.ram_mb ?? 256), 0);
|
|||
|
|
const toolDisk = selectedTools.reduce((sum, t) => sum + (TOOL_RESOURCES[t]?.disk_gb ?? 2), 0);
|
|||
|
|
|
|||
|
|
const totalRam = toolRam + LETSBE_OVERHEAD_MB;
|
|||
|
|
const totalDisk = toolDisk + 20; // OS + Docker images overhead
|
|||
|
|
|
|||
|
|
const min_tier = totalRam <= 7300 ? 'lite'
|
|||
|
|
: totalRam <= 15000 ? 'build'
|
|||
|
|
: totalRam <= 31000 ? 'scale'
|
|||
|
|
: 'enterprise';
|
|||
|
|
|
|||
|
|
return { ram_mb: totalRam, disk_gb: totalDisk, min_tier, headroom_percent: /*...*/ };
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 7. Tool Registry & Cheat Sheets
|
|||
|
|
|
|||
|
|
### 7.1 Registry Schema
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// packages/shared/src/types/tool-registry.ts
|
|||
|
|
|
|||
|
|
interface ToolRegistry {
|
|||
|
|
version: string; // Registry schema version
|
|||
|
|
generated_at: string; // ISO timestamp
|
|||
|
|
tools: Record<string, ToolEntry>;
|
|||
|
|
external_services: Record<string, ExternalServiceEntry>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface ToolEntry {
|
|||
|
|
name: string; // Human-readable name
|
|||
|
|
category: string; // file-storage, communication, project-management, etc.
|
|||
|
|
internal_url: string; // http://127.0.0.1:30XX
|
|||
|
|
external_url: string; // https://files.{{domain}}
|
|||
|
|
api_base?: string; // e.g., "/ocs/v2.php/apps"
|
|||
|
|
api_auth_type: 'basic' | 'header' | 'bearer' | 'none';
|
|||
|
|
api_auth_header?: string; // e.g., "xc-token"
|
|||
|
|
api_auth_ref?: string; // e.g., "SECRET_REF(nocodb_api_token)"
|
|||
|
|
has_api: boolean;
|
|||
|
|
has_webui: boolean;
|
|||
|
|
cheat_sheet?: string; // Path to cheat sheet, e.g., "references/nextcloud.md"
|
|||
|
|
description: string; // One-line description
|
|||
|
|
status?: 'active' | 'stopped' | 'error';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface ExternalServiceEntry {
|
|||
|
|
name: string;
|
|||
|
|
cli: string; // Binary name, e.g., "gog"
|
|||
|
|
skill: string; // Skill name, e.g., "gog"
|
|||
|
|
services: string[]; // Sub-services, e.g., ["gmail", "calendar"]
|
|||
|
|
configured: boolean;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 7.2 Cheat Sheet Standard
|
|||
|
|
|
|||
|
|
Each cheat sheet follows this template:
|
|||
|
|
|
|||
|
|
```markdown
|
|||
|
|
# {Tool Name} API Cheat Sheet
|
|||
|
|
Base: http://127.0.0.1:{port}/{api_base}
|
|||
|
|
Auth: {auth_type} via SECRET_REF({credential_name})
|
|||
|
|
|
|||
|
|
## {Resource 1}
|
|||
|
|
GET /endpoint → Description
|
|||
|
|
POST /endpoint → Description (JSON body: { field: "type" })
|
|||
|
|
...
|
|||
|
|
|
|||
|
|
## {Resource 2}
|
|||
|
|
...
|
|||
|
|
|
|||
|
|
## Common Patterns
|
|||
|
|
- Pattern description with example curl command
|
|||
|
|
- Error handling notes
|
|||
|
|
- Pagination format
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 7.3 Priority Delivery Schedule
|
|||
|
|
|
|||
|
|
| Priority | Tools | Delivery |
|
|||
|
|
|----------|-------|----------|
|
|||
|
|
| P0 (launch-critical) | Portainer, Nextcloud, Chatwoot, Ghost, Cal.com, Stalwart Mail | Phase 2 |
|
|||
|
|
| P1 (core experience) | Odoo, Listmonk, NocoDB, Umami, Keycloak, Activepieces | Phase 3 |
|
|||
|
|
| P2 (full coverage) | Gitea, Uptime Kuma, MinIO, Documenso, VaultWarden, WordPress | Phase 4 |
|
|||
|
|
| P3 (extended) | Windmill, Redash, Penpot, Squidex, Typebot | Post-launch |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 8. Agent Architecture
|
|||
|
|
|
|||
|
|
### 8.1 Default Agent Roster
|
|||
|
|
|
|||
|
|
| Agent | ID | Tool Profile | Primary Tools | Model Preset |
|
|||
|
|
|-------|----|-------------|---------------|-------------|
|
|||
|
|
| Dispatcher | `dispatcher` | messaging | agentToAgent only | balanced |
|
|||
|
|
| IT Admin | `it-admin` | coding | shell, docker, file_*, env_*, browser, portainer | complex |
|
|||
|
|
| Marketing | `marketing` | minimal | ghost, listmonk, umami, file_read, browser, nextcloud | balanced |
|
|||
|
|
| Secretary | `secretary` | messaging | calcom, chatwoot, poste, file_read, nextcloud | basic |
|
|||
|
|
| Sales | `sales` | minimal | chatwoot, odoo, calcom, file_read, nextcloud, documenso | balanced |
|
|||
|
|
|
|||
|
|
### 8.2 SOUL.md Template Structure
|
|||
|
|
|
|||
|
|
```markdown
|
|||
|
|
# {Agent Name}
|
|||
|
|
|
|||
|
|
## Identity
|
|||
|
|
You are the {role} for {business_name}. {1-2 sentences about personality}.
|
|||
|
|
|
|||
|
|
## Expertise
|
|||
|
|
- {Domain knowledge point 1}
|
|||
|
|
- {Domain knowledge point 2}
|
|||
|
|
- ...
|
|||
|
|
|
|||
|
|
## Rules
|
|||
|
|
1. Always check tool-registry.json before accessing any tool
|
|||
|
|
2. Read the cheat sheet for a tool before your first API call
|
|||
|
|
3. Prefer API over browser — faster and fewer tokens
|
|||
|
|
4. All credentials use SECRET_REF() — never hardcode values
|
|||
|
|
5. {Role-specific rules}
|
|||
|
|
|
|||
|
|
## Communication Style
|
|||
|
|
{Brand voice guidelines from user customization}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Estimated size: ~600-800 tokens per SOUL.md. Cached via `cacheRetention: "long"` for 80-99% cost reduction on subsequent calls.
|
|||
|
|
|
|||
|
|
### 8.3 Dispatcher Routing Logic
|
|||
|
|
|
|||
|
|
The Dispatcher uses a simple keyword + context classifier to route messages:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
"Send newsletter" → Marketing
|
|||
|
|
"Why is X slow?" → IT Admin
|
|||
|
|
"Schedule meeting" → Secretary
|
|||
|
|
"Follow up with client" → Sales → Secretary (multi-agent)
|
|||
|
|
"Morning update" → Dispatcher (self-handles, aggregates from all agents)
|
|||
|
|
Ambiguous → Dispatcher asks: "Should I have your {A} or {B} handle this?"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
This is implemented as SOUL.md instructions, not code. The Dispatcher's SOUL.md contains a routing table mapping common intents to agent IDs.
|
|||
|
|
|
|||
|
|
### 8.4 Business Type Agent Templates
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// packages/shared/src/templates/agent-templates.ts
|
|||
|
|
|
|||
|
|
const TEMPLATES: Record<string, AgentTemplate> = {
|
|||
|
|
freelancer: {
|
|||
|
|
agents: ['dispatcher', 'it-admin', 'secretary'],
|
|||
|
|
// No marketing or sales — freelancers handle their own
|
|||
|
|
soul_tweaks: {
|
|||
|
|
secretary: 'Focus on calendar management and client communication.',
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
agency: {
|
|||
|
|
agents: ['dispatcher', 'it-admin', 'marketing', 'secretary', 'sales'],
|
|||
|
|
soul_tweaks: {
|
|||
|
|
marketing: 'Multi-client brand voice management.',
|
|||
|
|
sales: 'Pipeline tracking and proposal automation.',
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
ecommerce: {
|
|||
|
|
agents: ['dispatcher', 'it-admin', 'marketing', 'sales'],
|
|||
|
|
soul_tweaks: {
|
|||
|
|
marketing: 'Focus on product listings, SEO, and campaign automation.',
|
|||
|
|
sales: 'Order tracking and customer support integration.',
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
consulting: {
|
|||
|
|
agents: ['dispatcher', 'it-admin', 'secretary', 'sales'],
|
|||
|
|
soul_tweaks: {
|
|||
|
|
secretary: 'Document management and scheduling focus.',
|
|||
|
|
sales: 'Proposal generation and contract management.',
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 9. Interactive Demo
|
|||
|
|
|
|||
|
|
### 9.1 Architecture: Per-Session Ephemeral Containers
|
|||
|
|
|
|||
|
|
Instead of a shared "Bella's Bakery" VPS (which presents data freshness and multi-user conflict issues), we propose per-session ephemeral demo containers:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
Prospect visits letsbe.biz/demo
|
|||
|
|
→ Spins up lightweight demo container (pre-built Docker image)
|
|||
|
|
→ 15-minute TTL, auto-destroyed
|
|||
|
|
→ Pre-loaded with fake bakery data
|
|||
|
|
→ Connects to shared demo OpenClaw instance with demo-tier model
|
|||
|
|
→ Prospect chats with the AI team in real-time
|
|||
|
|
→ After 15 min: "Liked what you saw? Sign up to get your own."
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 9.2 Demo Container Spec
|
|||
|
|
|
|||
|
|
```yaml
|
|||
|
|
# provisioner/stacks/demo/docker-compose.yml
|
|||
|
|
services:
|
|||
|
|
demo-gateway:
|
|||
|
|
image: code.letsbe.solutions/letsbe/demo:latest
|
|||
|
|
container_name: demo-${SESSION_ID}
|
|||
|
|
environment:
|
|||
|
|
- DEMO_SESSION_ID=${SESSION_ID}
|
|||
|
|
- DEMO_TTL_MINUTES=15
|
|||
|
|
- MODEL=openrouter/google/gemini-2.0-flash-001 # Cheapest model
|
|||
|
|
mem_limit: 256m
|
|||
|
|
labels:
|
|||
|
|
- "letsbe.demo=true"
|
|||
|
|
- "letsbe.demo.expires=${EXPIRY_TIMESTAMP}"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 9.3 Demo Limitations
|
|||
|
|
|
|||
|
|
| Capability | Demo | Full Product |
|
|||
|
|
|-----------|------|-------------|
|
|||
|
|
| Chat with AI agents | Yes (Gemini Flash) | Yes (user's model choice) |
|
|||
|
|
| View tool dashboards | Screenshots only | Live tool access |
|
|||
|
|
| Execute tool commands | Simulated responses | Real execution |
|
|||
|
|
| Data persistence | None (15-min TTL) | Permanent |
|
|||
|
|
| Custom agents | No | Yes |
|
|||
|
|
| Multiple sessions | No | Yes |
|
|||
|
|
|
|||
|
|
### 9.4 Cost per Demo Session
|
|||
|
|
|
|||
|
|
- Model cost: ~$0.01-0.05 per session (Gemini Flash, 10-20 messages)
|
|||
|
|
- Container cost: ~$0.001 per session (256MB × 15 min)
|
|||
|
|
- Total: <$0.06 per prospect demo — highly scalable
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 10. Shared Packages
|
|||
|
|
|
|||
|
|
### 10.1 `packages/shared-types`
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// Shared TypeScript types used across all packages
|
|||
|
|
|
|||
|
|
export type CommandTier = 'green' | 'yellow' | 'yellow_external' | 'red' | 'critical_red';
|
|||
|
|
export type AutonomyLevel = 1 | 2 | 3;
|
|||
|
|
export type AgentRole = 'dispatcher' | 'it-admin' | 'marketing' | 'secretary' | 'sales' | 'custom';
|
|||
|
|
export type ApprovalStatus = 'pending' | 'approved' | 'denied' | 'expired';
|
|||
|
|
export type ServerTier = 'lite' | 'build' | 'scale' | 'enterprise';
|
|||
|
|
|
|||
|
|
export interface TenantConfig {
|
|||
|
|
id: string;
|
|||
|
|
default_autonomy_level: AutonomyLevel;
|
|||
|
|
agents: Record<string, AgentConfig>;
|
|||
|
|
command_classification: Record<CommandTier, string[]>;
|
|||
|
|
external_comms_gate: ExternalCommsGate;
|
|||
|
|
token_pool_remaining: number;
|
|||
|
|
founding_member_multiplier: number;
|
|||
|
|
config_version: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface AgentConfig {
|
|||
|
|
agent_id: string;
|
|||
|
|
name: string;
|
|||
|
|
role: AgentRole;
|
|||
|
|
autonomy_level: AutonomyLevel | null;
|
|||
|
|
tools_allowed: string[];
|
|||
|
|
tools_denied: string[];
|
|||
|
|
external_comms_unlocks: Record<string, 'autonomous' | 'gated'>;
|
|||
|
|
model_preset: 'basic' | 'balanced' | 'complex';
|
|||
|
|
premium_model: string | null;
|
|||
|
|
is_active: boolean;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface ExternalCommsGate {
|
|||
|
|
default: 'gated';
|
|||
|
|
unlocks: Record<string, Record<string, 'autonomous' | 'gated'>>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export interface ToolRegistryEntry {
|
|||
|
|
name: string;
|
|||
|
|
category: string;
|
|||
|
|
internal_url: string;
|
|||
|
|
external_url: string;
|
|||
|
|
api_base?: string;
|
|||
|
|
api_auth_type: 'basic' | 'header' | 'bearer' | 'none';
|
|||
|
|
api_auth_ref?: string;
|
|||
|
|
has_api: boolean;
|
|||
|
|
has_webui: boolean;
|
|||
|
|
cheat_sheet?: string;
|
|||
|
|
description: string;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 10.2 `packages/shared-prisma`
|
|||
|
|
|
|||
|
|
Shared Prisma client generated from the Hub's schema. Used by the Hub app and any services that need to query the central database.
|
|||
|
|
|
|||
|
|
### 10.3 `packages/shared-utils`
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
// Shared utilities
|
|||
|
|
|
|||
|
|
export { shannonEntropy } from './crypto/entropy';
|
|||
|
|
export { encryptChaCha20, decryptChaCha20 } from './crypto/chacha20';
|
|||
|
|
export { generateSecurePassword } from './crypto/password';
|
|||
|
|
export { parseEnvFile, serializeEnvFile } from './env/parser';
|
|||
|
|
export { resolveSafePath, isPathSafe } from './fs/safe-path';
|
|||
|
|
export { createLogger } from './logging/logger';
|
|||
|
|
export { sleep, retryWithBackoff } from './async/helpers';
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Appendix A: Full API Surface Summary
|
|||
|
|
|
|||
|
|
| Component | Endpoints | Auth Method |
|
|||
|
|
|-----------|-----------|-------------|
|
|||
|
|
| Safety Wrapper | 6 | Bearer token |
|
|||
|
|
| Secrets Proxy | 7 | Hub session token |
|
|||
|
|
| Hub — Tenant API | 7 | Hub API key |
|
|||
|
|
| Hub — Customer API | 17 | Customer JWT |
|
|||
|
|
| Hub — Admin Billing | 6 | Staff JWT |
|
|||
|
|
| Hub — Admin Agents | 5 | Staff JWT |
|
|||
|
|
| Hub — Admin DNS | 1 | Staff JWT |
|
|||
|
|
| Hub — Existing | 80+ | Various (see Repo Analysis) |
|
|||
|
|
| **Total new endpoints** | **~49** | |
|
|||
|
|
|
|||
|
|
## Appendix B: Data Model Summary
|
|||
|
|
|
|||
|
|
| Model | Database | New/Existing |
|
|||
|
|
|-------|----------|-------------|
|
|||
|
|
| TokenUsageBucket | Hub PostgreSQL | NEW |
|
|||
|
|
| BillingPeriod | Hub PostgreSQL | NEW |
|
|||
|
|
| FoundingMember | Hub PostgreSQL | NEW |
|
|||
|
|
| AgentConfig | Hub PostgreSQL | NEW |
|
|||
|
|
| CommandApproval | Hub PostgreSQL | NEW |
|
|||
|
|
| ServerConnection | Hub PostgreSQL | UPDATED |
|
|||
|
|
| secrets | Safety Wrapper SQLite | NEW |
|
|||
|
|
| approvals | Safety Wrapper SQLite | NEW |
|
|||
|
|
| audit_log | Safety Wrapper SQLite | NEW |
|
|||
|
|
| token_usage | Safety Wrapper SQLite | NEW |
|
|||
|
|
| hub_state | Safety Wrapper SQLite | NEW |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
*End of Document — 02 Component Breakdown*
|