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*
|