diff --git a/deployments/moltbot-cloud/Dockerfile b/deployments/moltbot-cloud/Dockerfile new file mode 100644 index 0000000..44523a6 --- /dev/null +++ b/deployments/moltbot-cloud/Dockerfile @@ -0,0 +1,73 @@ +# Clawdbot VM Image (Built from Source) +# Full Clawdbot gateway running in Hypercore microVMs +# Usage: hypercore spawn clawdbot/clawdbot-vm:latest + +FROM node:22-bookworm AS builder + +# Install pnpm +RUN npm install -g pnpm + +# Copy source code +WORKDIR /build +COPY . . + +# Install and build (including UI) +RUN pnpm install --frozen-lockfile +RUN pnpm build +RUN pnpm ui:build + +# Create tarball for global install +RUN pnpm pack + +# Runtime image - use node base for native module compatibility +FROM node:22-bookworm-slim + +# Install base packages and build tools for native modules +RUN apt-get update && apt-get install -y \ + curl \ + git \ + unzip \ + ca-certificates \ + sudo \ + build-essential \ + python3 \ + && rm -rf /var/lib/apt/lists/* + +# Copy and install built clawdbot +COPY --from=builder /build/*.tgz /tmp/clawdbot.tgz +RUN npm install -g /tmp/clawdbot.tgz && rm /tmp/clawdbot.tgz + +# Create clawdbot user +RUN useradd -m -s /bin/bash clawdbot && \ + echo "clawdbot ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers + +# Switch to clawdbot user +USER clawdbot +WORKDIR /home/clawdbot +ENV HOME=/home/clawdbot + +# Create config directory +RUN mkdir -p /home/clawdbot/.clawdbot + +# Copy entrypoint script +# When building from clawdbot repo root with -f pointing to this Dockerfile, +# the entrypoint.sh should be copied to the repo root or specified path first. +# Example: cp hypercore/deployments/moltbot-cloud/entrypoint.sh ./entrypoint.sh +# docker build -f path/to/Dockerfile -t clawdbot-vm . +COPY --chown=clawdbot:clawdbot entrypoint.sh /opt/entrypoint.sh +RUN chmod +x /opt/entrypoint.sh + +# Expose gateway port +EXPOSE 18789 + +# Health check - verify gateway is serving the Control UI +HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=3 \ + CMD curl -sf http://localhost:18789/ | grep -q "clawdbot-app" || exit 1 + +# Default environment +ENV CLAWDBOT_PROVIDER=anthropic +ENV CLAWDBOT_MODEL=claude-sonnet-4-20250514 +ENV CLAWDBOT_PORT=18789 + +# Start Clawdbot gateway via entrypoint +CMD ["/opt/entrypoint.sh"] diff --git a/deployments/moltbot-cloud/Dockerfile.npm b/deployments/moltbot-cloud/Dockerfile.npm new file mode 100644 index 0000000..873bfd5 --- /dev/null +++ b/deployments/moltbot-cloud/Dockerfile.npm @@ -0,0 +1,50 @@ +# Clawdbot VM Image (from npm) +# Uses published npm package instead of building from source +# Faster for testing/deployment + +FROM node:22-bookworm-slim + +# Install base packages, jq for JSON handling, and openssl for token generation +RUN apt-get update && apt-get install -y \ + curl \ + git \ + unzip \ + ca-certificates \ + sudo \ + jq \ + openssl \ + && rm -rf /var/lib/apt/lists/* + +# Install clawdbot from npm +RUN npm install -g clawdbot + +# Create clawdbot user +RUN useradd -m -s /bin/bash clawdbot && \ + echo "clawdbot ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers + +# Switch to clawdbot user +USER clawdbot +WORKDIR /home/clawdbot +ENV HOME=/home/clawdbot + +# Create config directory +RUN mkdir -p /home/clawdbot/.clawdbot + +# Copy entrypoint script +COPY --chown=clawdbot:clawdbot entrypoint.sh /opt/entrypoint.sh +RUN chmod +x /opt/entrypoint.sh + +# Expose gateway port +EXPOSE 18789 + +# Health check - verify gateway is serving the Control UI +HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=3 \ + CMD curl -sf http://localhost:18789/ | grep -q "clawdbot-app" || exit 1 + +# Default environment +ENV CLAWDBOT_PROVIDER=anthropic +ENV CLAWDBOT_MODEL=claude-sonnet-4-20250514 +ENV CLAWDBOT_PORT=18789 + +# Start Clawdbot gateway via entrypoint +CMD ["/opt/entrypoint.sh"] diff --git a/deployments/moltbot-cloud/README.md b/deployments/moltbot-cloud/README.md new file mode 100644 index 0000000..6822f8a --- /dev/null +++ b/deployments/moltbot-cloud/README.md @@ -0,0 +1,81 @@ +# Clawdbot Cloud Deployment for Hypercore + +This directory contains Docker images and configuration for deploying Clawdbot on Hypercore microVMs. + +## Images + +### Dockerfile.npm (Recommended) +Builds from the published npm package. Fast and reliable. + +```bash +cd deployments/moltbot-cloud +docker build -f Dockerfile.npm -t clawdbot-vm:npm . +``` + +### Dockerfile (From Source) +Builds from Clawdbot source code. Use this for custom builds. + +**Build from Clawdbot source repo:** +```bash +# In the clawdbot source directory +cp /path/to/hypercore/deployments/moltbot-cloud/entrypoint.sh ./entrypoint.sh +docker build -f /path/to/hypercore/deployments/moltbot-cloud/Dockerfile -t clawdbot-vm:source . +``` + +## Running + +### Docker (Standalone) +```bash +docker run -d \ + --name clawdbot \ + -p 18789:18789 \ + -e ANTHROPIC_API_KEY="your-api-key" \ + -e CLAWDBOT_GATEWAY_TOKEN="your-token" # optional, auto-generated if not set \ + clawdbot-vm:npm +``` + +### Hypercore MicroVM +```bash +# Push to registry +docker tag clawdbot-vm:npm registry.your.domain/clawdbot:latest +docker push registry.your.domain/clawdbot:latest + +# Deploy via Hypercore +hypercore cluster spawn \ + --grpc-bind-addr "$NODE_IP:8000" \ + --ports 443:18789 \ + --image-ref registry.your.domain/clawdbot:latest +``` + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `ANTHROPIC_API_KEY` | Yes | Your Anthropic API key | +| `CLAWDBOT_GATEWAY_TOKEN` | No | Gateway auth token (auto-generated if not set) | +| `CLAWDBOT_PORT` | No | Gateway port (default: 18789) | +| `CLAWDBOT_MODEL` | No | Default model (default: `anthropic/claude-sonnet-4-20250514`) | + +## Health Check + +The container includes a health check that verifies the gateway is serving the Control UI: +- Interval: 10s +- Start period: 30s +- Endpoint: `http://localhost:18789/` + +## Connecting + +Once running, connect to the gateway: +- **Control UI:** `http://your-host:18789/` +- **WebSocket:** `ws://your-host:18789/` + +Pass the token (shown in container logs) via `connect.params.auth.token`. + +## Hypercore Requirements + +Full Hypercore deployment requires: +- **KVM support** (`/dev/kvm` available) +- **dmsetup** for containerd snapshotter +- Static public IP with ports exposed + +Without KVM (e.g., on a VPS that's already a VM), use Docker standalone mode. diff --git a/deployments/moltbot-cloud/entrypoint.sh b/deployments/moltbot-cloud/entrypoint.sh new file mode 100644 index 0000000..547bb80 --- /dev/null +++ b/deployments/moltbot-cloud/entrypoint.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# Clawdbot Cloud Entrypoint +# Fast startup by writing config directly instead of running multiple commands + +set -e + +echo "Clawdbot Cloud: Starting..." + +# Generate a token if not provided (hex only for JSON safety) +GATEWAY_TOKEN="${CLAWDBOT_GATEWAY_TOKEN:-$(openssl rand -hex 16)}" +# Validate token is hex-safe (alphanumeric only, no special chars) +if [[ ! "$GATEWAY_TOKEN" =~ ^[a-zA-Z0-9_-]+$ ]]; then + echo "WARNING: Token contains special characters, generating safe token" + GATEWAY_TOKEN=$(openssl rand -hex 16) +fi + +# Validate port is numeric +GATEWAY_PORT="${CLAWDBOT_PORT:-18789}" +if [[ ! "$GATEWAY_PORT" =~ ^[0-9]+$ ]]; then + echo "WARNING: Invalid port '$GATEWAY_PORT', using default 18789" + GATEWAY_PORT=18789 +fi + +# Default model (can be overridden via env) +CLAWDBOT_MODEL="${CLAWDBOT_MODEL:-anthropic/claude-sonnet-4-20250514}" + +# Create config directory +mkdir -p ~/.clawdbot + +# Write config to clawdbot.json (the config file the gateway reads) +cat > ~/.clawdbot/clawdbot.json << EOF +{ + "gateway": { + "port": ${GATEWAY_PORT}, + "mode": "local", + "bind": "lan", + "auth": { + "mode": "token", + "token": "${GATEWAY_TOKEN}" + } + }, + "agents": { + "defaults": { + "model": { + "primary": "${CLAWDBOT_MODEL}" + }, + "workspace": "/home/clawdbot/workspace" + } + } +} +EOF + +# Create workspace directory +mkdir -p ~/workspace + +echo "" +echo "╔════════════════════════════════════════════════════════════════╗" +echo "║ CLAWDBOT CLOUD READY ║" +echo "╚════════════════════════════════════════════════════════════════╝" +echo "" +echo "📋 Token: $GATEWAY_TOKEN" +echo "" + +# Check API keys and create auth-profiles.json +if [ -n "$ANTHROPIC_API_KEY" ]; then + echo "ANTHROPIC_API_KEY is set (${#ANTHROPIC_API_KEY} chars)" + + # Create auth-profiles directory + mkdir -p ~/.clawdbot/agents/main/agent + + # Write auth-profiles.json with proper JSON escaping + if command -v jq &> /dev/null; then + # Use jq for safe JSON generation + jq -n \ + --arg key "$ANTHROPIC_API_KEY" \ + '{ + version: 1, + profiles: { + "anthropic:default": { + type: "api_key", + provider: "anthropic", + key: $key + } + }, + lastGood: { + anthropic: "anthropic:default" + } + }' > ~/.clawdbot/agents/main/agent/auth-profiles.json + else + # Fallback: validate key has no dangerous chars (Anthropic keys are base64-safe) + if [[ "$ANTHROPIC_API_KEY" =~ ^[a-zA-Z0-9_-]+$ ]]; then + cat > ~/.clawdbot/agents/main/agent/auth-profiles.json << AUTHEOF +{ + "version": 1, + "profiles": { + "anthropic:default": { + "type": "api_key", + "provider": "anthropic", + "key": "${ANTHROPIC_API_KEY}" + } + }, + "lastGood": { + "anthropic": "anthropic:default" + } +} +AUTHEOF + else + echo "ERROR: API key contains invalid characters" + exit 1 + fi + fi + echo "Created auth-profiles.json" +else + echo "WARNING: ANTHROPIC_API_KEY is NOT set" +fi + +# Start the gateway (clawdbot is the actual binary name) +# --allow-unconfigured is needed since we're writing config directly instead of using `clawdbot setup` +exec clawdbot gateway run --bind lan --port "${GATEWAY_PORT}" --allow-unconfigured --token "${GATEWAY_TOKEN}" diff --git a/pkg/cluster/serf.go b/pkg/cluster/serf.go index b660932..d2d0ba5 100644 --- a/pkg/cluster/serf.go +++ b/pkg/cluster/serf.go @@ -811,9 +811,21 @@ func (a *Agent) LogsRequest(id string) (*pb.VmLogsResponse, error) { //nolint:gocognit func (a *Agent) monitorWorkloads() { ticker := time.NewTicker(WorkloadBroadcastPeriod) + gcCounter := 0 for range ticker.C { ctx := a.ctrRepo.GetContext(context.Background()) + // Run CNI garbage collection every 10 iterations (~5 minutes with 30s period) + gcCounter++ + if gcCounter >= 10 { + gcCounter = 0 + if cleaned, err := a.ctrRepo.GarbageCollectCNI(ctx); err != nil { + a.logger.WithError(err).Warn("CNI garbage collection failed") + } else if cleaned > 0 { + a.logger.Infof("CNI garbage collection cleaned %d orphaned IP allocations", cleaned) + } + } + tasks, err := a.ctrRepo.GetTasks(ctx) if err != nil { a.logger.WithError(err).Error("failed to get tasks") diff --git a/pkg/containerd/repo.go b/pkg/containerd/repo.go index da8bcdd..88b5785 100644 --- a/pkg/containerd/repo.go +++ b/pkg/containerd/repo.go @@ -1,9 +1,14 @@ +// +build !darwin +// +build linux + package containerd import ( "context" "fmt" "net" + "os" + "path/filepath" "strings" "syscall" "time" @@ -239,7 +244,7 @@ func (r *Repo) CreateContainer(ctx context.Context, opts CreateContainerOpts) (_ "ipMasq": true, "ipam": { "type": "host-local", - "subnet": "192.168.127.0/24", + "subnet": "10.88.0.0/16", "resolvConf": "/etc/resolv.conf", "routes": [ { "dst": "0.0.0.0/0" } @@ -259,21 +264,32 @@ func (r *Repo) CreateContainer(ctx context.Context, opts CreateContainerOpts) (_ cniPlugins = append(cniPlugins, &libcni.NetworkConfig{Network: &types.NetConf{Type: "tc-redirect-tap"}, Bytes: []byte(tapConfig)}) } - _, err = libcni.NewCNIConfig([]string{"/opt/hypercore/bin", "/opt/cni/bin"}, nil).AddNetworkList( - namespaceCtx, &libcni.NetworkConfigList{ - Name: "hypercore-cni", - CNIVersion: "0.4.0", - Plugins: cniPlugins, - }, &libcni.RuntimeConf{ - ContainerID: containerID, - NetNS: netNs.GetPath(), - IfName: "eth0", - }, - ) + cniConfig := libcni.NewCNIConfig([]string{"/opt/hypercore/bin", "/opt/cni/bin"}, nil) + cniNetworkList := &libcni.NetworkConfigList{ + Name: "hypercore-cni", + CNIVersion: "0.4.0", + Plugins: cniPlugins, + } + cniRuntimeConf := &libcni.RuntimeConf{ + ContainerID: containerID, + NetNS: netNs.GetPath(), + IfName: "eth0", + } + + _, err = cniConfig.AddNetworkList(namespaceCtx, cniNetworkList, cniRuntimeConf) if err != nil { return "", fmt.Errorf("failed to add CNI network list: %w", err) } + // Clean up CNI on error to release IP address + defer func() { + if retErr != nil { + if err := cniConfig.DelNetworkList(namespaceCtx, cniNetworkList, cniRuntimeConf); err != nil { + log.Warnf("failed to cleanup CNI on error for container %s: %v", containerID, err) + } + } + }() + task, err := container.NewTask(namespaceCtx, opts.CioCreator) if err != nil { return "", fmt.Errorf("failed to start task for container %s: %w", containerID, err) @@ -303,6 +319,23 @@ func (r *Repo) DeleteContainer(ctx context.Context, containerID string) (uint32, return 0, fmt.Errorf("failed to load container %s: %w", containerID, err) } + // Get the container spec to find the network namespace path for CNI cleanup + spec, err := container.Spec(namespaceCtx) + if err != nil { + log.WithContext(ctx).Warnf("failed to get spec for container %s: %v", containerID, err) + } + + // Find network namespace path from spec + var netNsPath string + if spec != nil && spec.Linux != nil { + for _, ns := range spec.Linux.Namespaces { + if ns.Type == "network" && ns.Path != "" { + netNsPath = ns.Path + break + } + } + } + task, err := container.Task(namespaceCtx, nil) if err != nil { return 0, fmt.Errorf("failed to get task: %w", err) @@ -344,9 +377,113 @@ func (r *Repo) DeleteContainer(ctx context.Context, containerID string) (uint32, return 0, fmt.Errorf("failed to delete task: %w", err) } + // Clean up CNI network to release IP address + if netNsPath != "" { + ptpConfig := ` + { + "type": "ptp", + "ipMasq": true, + "ipam": { + "type": "host-local", + "subnet": "10.88.0.0/16", + "resolvConf": "/etc/resolv.conf", + "routes": [ + { "dst": "0.0.0.0/0" } + ] + } + } + ` + firewallConfig := `{"type": "firewall"}` + + cniPlugins := []*libcni.NetworkConfig{ + {Network: &types.NetConf{Type: "ptp"}, Bytes: []byte(ptpConfig)}, + {Network: &types.NetConf{Type: "firewall"}, Bytes: []byte(firewallConfig)}, + } + + cniConfig := libcni.NewCNIConfig([]string{"/opt/hypercore/bin", "/opt/cni/bin"}, nil) + if err := cniConfig.DelNetworkList( + namespaceCtx, &libcni.NetworkConfigList{ + Name: "hypercore-cni", + CNIVersion: "0.4.0", + Plugins: cniPlugins, + }, &libcni.RuntimeConf{ + ContainerID: containerID, + NetNS: netNsPath, + IfName: "eth0", + }, + ); err != nil { + log.WithContext(ctx).Warnf("failed to delete CNI network for container %s: %v", containerID, err) + // Don't fail the delete, just log the warning + } else { + log.WithContext(ctx).Infof("released CNI network resources for container %s", containerID) + } + } else { + log.WithContext(ctx).Warnf("no network namespace path found for container %s, skipping CNI cleanup", containerID) + } + if err := container.Delete(namespaceCtx, containerd.WithSnapshotCleanup); err != nil { return 0, fmt.Errorf("failed to delete container %s: %w", containerID, err) } return code, nil } + +// GarbageCollectCNI cleans up orphaned CNI IP allocations that don't have running containers. +// This should be called periodically to handle cases where containers crashed without cleanup. +func (r *Repo) GarbageCollectCNI(ctx context.Context) (int, error) { + cniDataDir := "/var/lib/cni/networks/hypercore-cni" + + // Get all running container IDs + namespaceCtx := namespaces.WithNamespace(ctx, r.config.ContainerNamespace) + tasks, err := r.client.TaskService().List(namespaceCtx, &tasks.ListTasksRequest{}) + if err != nil { + return 0, fmt.Errorf("failed to list tasks: %w", err) + } + + runningContainers := make(map[string]bool) + for _, task := range tasks.GetTasks() { + runningContainers[task.GetID()] = true + } + + // Scan CNI data directory for IP allocations + entries, err := os.ReadDir(cniDataDir) + if err != nil { + if os.IsNotExist(err) { + return 0, nil // No CNI data, nothing to clean + } + return 0, fmt.Errorf("failed to read CNI data dir: %w", err) + } + + cleaned := 0 + for _, entry := range entries { + if entry.IsDir() || entry.Name() == "last_reserved_ip.0" || entry.Name() == "lock" { + continue + } + + // Each file is named by IP and contains the container ID + ipFile := filepath.Join(cniDataDir, entry.Name()) + data, err := os.ReadFile(ipFile) + if err != nil { + continue + } + + // File format: container_id\nifname\n... + lines := strings.Split(string(data), "\n") + if len(lines) == 0 { + continue + } + containerID := strings.TrimSpace(lines[0]) + + // If container is not running, remove the allocation + if !runningContainers[containerID] { + if err := os.Remove(ipFile); err != nil { + log.Warnf("failed to remove orphaned CNI allocation %s: %v", entry.Name(), err) + } else { + log.Infof("cleaned orphaned CNI IP allocation: %s (container: %s)", entry.Name(), containerID) + cleaned++ + } + } + } + + return cleaned, nil +} diff --git a/pkg/containerd/repo_darwin.go b/pkg/containerd/repo_darwin.go new file mode 100644 index 0000000..bdd34f3 --- /dev/null +++ b/pkg/containerd/repo_darwin.go @@ -0,0 +1,72 @@ +// +build darwin + +package containerd + +import ( + "context" + "fmt" + + "github.com/containerd/containerd" + "github.com/containerd/containerd/api/types/task" + "github.com/containerd/containerd/cio" + "github.com/containerd/containerd/namespaces" +) + +// Stub implementations for Mac - cluster commands don't need full containerd functionality + +type CreateContainerOpts struct { + ImageRef string + Snapshotter string + Runtime struct { + Name string + Options interface{} + } + Limits *struct { + CPUFraction float64 + MemoryBytes uint64 + } + Labels map[string]string + CioCreator cio.Creator + Env []string +} + +type Repo struct { + client *containerd.Client + config *Config +} + +func NewMicroVMRepository(cfg *Config) (*Repo, error) { + return nil, fmt.Errorf("containerd operations not supported on Mac - use cluster commands only") +} + +func (r *Repo) CreateContainer(ctx context.Context, opts CreateContainerOpts) (string, error) { + return "", fmt.Errorf("containerd operations not supported on Mac") +} + +func (r *Repo) GetContainer(ctx context.Context, id string) (containerd.Container, error) { + return nil, fmt.Errorf("containerd operations not supported on Mac") +} + +func (r *Repo) DeleteContainer(ctx context.Context, id string) (uint32, error) { + return 0, fmt.Errorf("containerd operations not supported on Mac") +} + +func (r *Repo) GetTasks(ctx context.Context) ([]*task.Process, error) { + return nil, fmt.Errorf("containerd operations not supported on Mac") +} + +func (r *Repo) Attach(ctx context.Context, id string) error { + return fmt.Errorf("containerd operations not supported on Mac") +} + +func (r *Repo) GetContainerPrimaryIP(ctx context.Context, id string) (string, error) { + return "", fmt.Errorf("containerd operations not supported on Mac") +} + +func (r *Repo) GetContext(ctx context.Context) context.Context { + return namespaces.WithNamespace(ctx, r.config.ContainerNamespace) +} + +func (r *Repo) GarbageCollectCNI(ctx context.Context) (int, error) { + return 0, nil // No-op on Mac +} diff --git a/pkg/shim/shim.go b/pkg/shim/shim.go index 45c3467..fa1a332 100644 --- a/pkg/shim/shim.go +++ b/pkg/shim/shim.go @@ -1,3 +1,6 @@ +// +build !darwin +// +build linux + package shim import (