diff --git a/scripts/config-lock.sh b/scripts/config-lock.sh new file mode 100755 index 000000000..c1182563d --- /dev/null +++ b/scripts/config-lock.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# ============================================================================= +# Config Lock: Makes clawdbot.json immutable to prevent any writes +# Usage: config-lock.sh [lock|unlock|status] +# ============================================================================= + +# Source unified environment +source "$(dirname "$0")/env.sh" + +lock_config() { + chflags uchg "$CONFIG" + log "🔒 Config LOCKED - write access disabled." +} + +unlock_config() { + chflags nouchg "$CONFIG" + log "🔓 Config UNLOCKED - write access enabled." +} + +check_status() { + if config_is_locked; then + echo "🔒 Config is LOCKED (immutable)" + return 0 + else + echo "🔓 Config is UNLOCKED (writable)" + return 1 + fi +} + +case "${1:-status}" in + lock) + lock_config + ;; + unlock) + unlock_config + ;; + status) + check_status + ;; + *) + echo "Usage: $0 [lock|unlock|status]" + echo " lock - Make config immutable (no writes allowed)" + echo " unlock - Allow writes (for manual edits)" + echo " status - Show current lock status" + ;; +esac diff --git a/scripts/config-watchdog.sh b/scripts/config-watchdog.sh new file mode 100755 index 000000000..d4c50b306 --- /dev/null +++ b/scripts/config-watchdog.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# ============================================================================= +# Config Watchdog: Detects unauthorized changes to model config +# Restores if changed (backup protection if config unlocked) +# ============================================================================= + +# Source unified environment +source "$(dirname "$0")/env.sh" + +EXPECTED_PRIMARY="antigravity/gemini-3-pro-low" +EXPECTED_FALLBACKS='["antigravity/claude-sonnet-4-5","antigravity/gemini-3-flash","antigravity/gemini-3-pro-high","antigravity/claude-opus-4-5","antigravity/claude-sonnet-4-5-thinking","antigravity/claude-opus-4-5-thinking"]' + +log "Config watchdog check..." + +# If config is locked, just verify and exit +if config_is_locked; then + log "✅ Config is LOCKED (immutable) - no changes possible." + exit 0 +fi + +# Config is unlocked - check for tampering +log "⚠️ Config is UNLOCKED - checking for unauthorized changes..." + +CURRENT_PRIMARY=$(jq -r '.agent.model.primary' "$CONFIG" 2>/dev/null) +CURRENT_FALLBACKS=$(jq -c '.agent.model.fallbacks' "$CONFIG" 2>/dev/null) + +CHANGED=false + +if [ "$CURRENT_PRIMARY" != "$EXPECTED_PRIMARY" ]; then + log "⚠️ PRIMARY CHANGED: $CURRENT_PRIMARY → $EXPECTED_PRIMARY" + CHANGED=true +fi + +if [ "$CURRENT_FALLBACKS" != "$EXPECTED_FALLBACKS" ]; then + log "⚠️ FALLBACKS CHANGED!" + CHANGED=true +fi + +if [ "$CHANGED" = true ]; then + log "🔧 RESTORING CONFIG..." + jq --arg primary "$EXPECTED_PRIMARY" \ + --argjson fallbacks "$EXPECTED_FALLBACKS" \ + '.agent.model.primary = $primary | .agent.model.fallbacks = $fallbacks' \ + "$CONFIG" > "${CONFIG}.tmp" && mv "${CONFIG}.tmp" "$CONFIG" + + if [ $? -eq 0 ]; then + log "✅ Config restored. Re-locking..." + "$SCRIPTS_DIR/config-lock.sh" lock + else + log "❌ Failed to restore config!" + fi +else + log "✅ Config OK - re-locking..." + "$SCRIPTS_DIR/config-lock.sh" lock +fi diff --git a/scripts/env.sh b/scripts/env.sh new file mode 100755 index 000000000..b024762b3 --- /dev/null +++ b/scripts/env.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# ============================================================================= +# Unified environment for all clawdbot scripts +# Source this at the top of every script: source "$(dirname "$0")/env.sh" +# ============================================================================= + +# Comprehensive PATH for cron environment +export PATH="/usr/sbin:/usr/bin:/bin:/opt/homebrew/bin:$HOME/.bun/bin:/usr/local/bin:$PATH" + +# Core directories +export CLAWDBOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd)" +export SCRIPTS_DIR="$CLAWDBOT_DIR/scripts" +export CONFIG="$HOME/.clawdbot/clawdbot.json" +export LOG_DIR="$HOME/.clawdbot/logs" + +# Gateway settings +export PORT=18789 + +# Ensure log directory exists +mkdir -p "$LOG_DIR" 2>/dev/null + +# Helper: Check if config is locked +config_is_locked() { + ls -lO "$CONFIG" 2>/dev/null | grep -q "uchg" +} + +# Helper: Log with timestamp +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" +} diff --git a/scripts/keep-alive.sh b/scripts/keep-alive.sh new file mode 100755 index 000000000..a7848ace9 --- /dev/null +++ b/scripts/keep-alive.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# ============================================================================= +# Keep-Alive: Ensures clawdbot gateway is always running +# Runs via cron every 2 minutes +# ============================================================================= + +# Source unified environment +source "$(dirname "$0")/env.sh" + +log "Checking clawdbot status..." + +# Check if gateway is running (port check) +if lsof -i :$PORT > /dev/null 2>&1; then + # Additional health check via HTTP + if curl -sf "http://127.0.0.1:$PORT/health" > /dev/null 2>&1; then + log "✅ Status: ONLINE (Port $PORT active, health OK)" + else + log "⚠️ Status: DEGRADED (Port $PORT active, but health check failed)" + fi + exit 0 +else + log "❌ Status: OFFLINE (Port $PORT closed). Initiating restart..." + "$SCRIPTS_DIR/models.sh" restart + log "Restart command executed." +fi diff --git a/scripts/models.sh b/scripts/models.sh new file mode 100755 index 000000000..508ed076c --- /dev/null +++ b/scripts/models.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# ============================================================================= +# Models: Gateway management and model config display +# Usage: ./scripts/models.sh [edit|restart|show] +# ============================================================================= + +# Source unified environment +source "$(dirname "$0")/env.sh" + +wait_for_port() { + local port=$1 + for i in {1..10}; do + if ! lsof -i :$port > /dev/null 2>&1; then + return 0 + fi + echo "Waiting for port $port to clear... ($i/10)" + sleep 1 + done + return 1 +} + +restart_gateway() { + log "Restarting gateway..." + + # Try graceful kill first + pkill -f "bun.*gateway --port $PORT" 2>/dev/null + pkill -f "node.*gateway.*$PORT" 2>/dev/null + pkill -f "tsx.*gateway.*$PORT" 2>/dev/null + + if ! wait_for_port $PORT; then + log "Port $PORT still in use. Forcing cleanup..." + lsof -ti :$PORT | xargs kill -9 2>/dev/null + sleep 1 + fi + + # Start gateway in background + cd "$CLAWDBOT_DIR" && pnpm clawdbot gateway --port $PORT & + + # Verify start + sleep 3 + if lsof -i :$PORT > /dev/null 2>&1; then + log "✅ Gateway restarted successfully on port $PORT." + + # Auto-lock config after successful restart + "$SCRIPTS_DIR/config-lock.sh" lock + return 0 + else + log "❌ Gateway failed to start. Check logs." + return 1 + fi +} + +case "${1:-show}" in + edit) + # Unlock config for editing + if config_is_locked; then + "$SCRIPTS_DIR/config-lock.sh" unlock + fi + + ${EDITOR:-nano} "$CONFIG" + echo "Config saved." + restart_gateway + ;; + restart) + restart_gateway + ;; + show) + echo "=== Model Priority ===" + echo "Primary: $(jq -r '.agent.model.primary' "$CONFIG")" + echo "" + echo "Fallbacks:" + jq -r '.agent.model.fallbacks[]' "$CONFIG" | nl + echo "" + echo "Config Lock: $(config_is_locked && echo '🔒 LOCKED' || echo '🔓 UNLOCKED')" + ;; + *) + echo "Usage: $0 [edit|restart|show]" + echo " show - Display current model priority (default)" + echo " edit - Edit config and restart gateway" + echo " restart - Just restart gateway" + ;; +esac diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index ee7077fa7..2dfc1188b 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -326,6 +326,68 @@ type EmbeddedRunWaiter = { }; const EMBEDDED_RUN_WAITERS = new Map>(); +// ============================================================================ +// SessionManager Pre-warming Cache +// ============================================================================ + +type SessionManagerCacheEntry = { + sessionFile: string; + loadedAt: number; + lastAccessAt: number; +}; + +const SESSION_MANAGER_CACHE = new Map(); +const DEFAULT_SESSION_MANAGER_TTL_MS = 45_000; // 45 seconds + +function getSessionManagerTtl(): number { + const envTtl = process.env.CLAWDBOT_SESSION_MANAGER_CACHE_TTL_MS; + if (envTtl) { + const parsed = Number.parseInt(envTtl, 10); + if (Number.isFinite(parsed) && parsed >= 0) { + return parsed; + } + } + return DEFAULT_SESSION_MANAGER_TTL_MS; +} + +function isSessionManagerCacheEnabled(): boolean { + const ttl = getSessionManagerTtl(); + return ttl > 0; +} + +function trackSessionManagerAccess(sessionFile: string): void { + if (!isSessionManagerCacheEnabled()) return; + const now = Date.now(); + SESSION_MANAGER_CACHE.set(sessionFile, { + sessionFile, + loadedAt: now, + lastAccessAt: now, + }); +} + +function isSessionManagerCached(sessionFile: string): boolean { + if (!isSessionManagerCacheEnabled()) return false; + const entry = SESSION_MANAGER_CACHE.get(sessionFile); + if (!entry) return false; + const now = Date.now(); + const ttl = getSessionManagerTtl(); + return now - entry.loadedAt <= ttl; +} + +async function prewarmSessionFile(sessionFile: string): Promise { + if (!isSessionManagerCacheEnabled()) return; + if (isSessionManagerCached(sessionFile)) return; + + try { + // Touch the file to bring it into OS page cache + // This is much faster than letting SessionManager.open() do it cold + await fs.stat(sessionFile); + trackSessionManagerAccess(sessionFile); + } catch { + // File doesn't exist yet, SessionManager will create it + } +} + const isAbortError = (err: unknown): boolean => { if (!err || typeof err !== "object") return false; const name = "name" in err ? String(err.name) : ""; @@ -736,7 +798,10 @@ export async function compactEmbeddedPiSession(params: { tools, }); + // Pre-warm session file to bring it into OS page cache + await prewarmSessionFile(params.sessionFile); const sessionManager = SessionManager.open(params.sessionFile); + trackSessionManagerAccess(params.sessionFile); const settingsManager = SettingsManager.create( effectiveWorkspace, agentDir, @@ -1057,7 +1122,10 @@ export async function runEmbeddedPiAgent(params: { tools, }); + // Pre-warm session file to bring it into OS page cache + await prewarmSessionFile(params.sessionFile); const sessionManager = SessionManager.open(params.sessionFile); + trackSessionManagerAccess(params.sessionFile); const settingsManager = SettingsManager.create( effectiveWorkspace, agentDir, diff --git a/src/config/sessions.cache.test.ts b/src/config/sessions.cache.test.ts new file mode 100644 index 000000000..addd68c1e --- /dev/null +++ b/src/config/sessions.cache.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { + loadSessionStore, + saveSessionStore, + clearSessionStoreCacheForTest, + type SessionEntry, +} from "./sessions.js"; + +describe("Session Store Cache", () => { + let testDir: string; + let storePath: string; + + beforeEach(() => { + // Create a temporary directory for test + testDir = path.join(os.tmpdir(), `session-cache-test-${Date.now()}`); + fs.mkdirSync(testDir, { recursive: true }); + storePath = path.join(testDir, "sessions.json"); + + // Clear cache before each test + clearSessionStoreCacheForTest(); + + // Reset environment variable + delete process.env.CLAWDBOT_SESSION_CACHE_TTL_MS; + }); + + afterEach(() => { + // Clean up test directory + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + clearSessionStoreCacheForTest(); + delete process.env.CLAWDBOT_SESSION_CACHE_TTL_MS; + }); + + it("should load session store from disk on first call", async () => { + const testStore: Record = { + "session:1": { + sessionId: "id-1", + updatedAt: Date.now(), + displayName: "Test Session 1", + }, + }; + + // Write test data + await saveSessionStore(storePath, testStore); + + // Load it + const loaded = loadSessionStore(storePath); + expect(loaded).toEqual(testStore); + }); + + it("should cache session store on first load", async () => { + const testStore: Record = { + "session:1": { + sessionId: "id-1", + updatedAt: Date.now(), + displayName: "Test Session 1", + }, + }; + + await saveSessionStore(storePath, testStore); + + // First load - from disk + const loaded1 = loadSessionStore(storePath); + expect(loaded1).toEqual(testStore); + + // Modify file on disk + const modifiedStore: Record = { + "session:2": { + sessionId: "id-2", + updatedAt: Date.now(), + displayName: "Test Session 2", + }, + }; + fs.writeFileSync(storePath, JSON.stringify(modifiedStore, null, 2)); + + // Second load - should still return cached data (not the modified file) + const loaded2 = loadSessionStore(storePath); + expect(loaded2).toEqual(testStore); // Should be original, not modified + }); + + it("should cache multiple calls to the same store path", async () => { + const testStore: Record = { + "session:1": { + sessionId: "id-1", + updatedAt: Date.now(), + displayName: "Test Session 1", + }, + }; + + await saveSessionStore(storePath, testStore); + + // First load - from disk + const loaded1 = loadSessionStore(storePath); + expect(loaded1).toEqual(testStore); + + // Modify file on disk while cache is valid + fs.writeFileSync(storePath, JSON.stringify({ "session:99": { sessionId: "id-99", updatedAt: Date.now() } }, null, 2)); + + // Second load - should still return original cached data + const loaded2 = loadSessionStore(storePath); + expect(loaded2).toEqual(testStore); + expect(loaded2).not.toHaveProperty("session:99"); + }); + + it("should invalidate cache on write", async () => { + const testStore: Record = { + "session:1": { + sessionId: "id-1", + updatedAt: Date.now(), + displayName: "Test Session 1", + }, + }; + + await saveSessionStore(storePath, testStore); + + // Load - should cache + const loaded1 = loadSessionStore(storePath); + expect(loaded1).toEqual(testStore); + + // Update store + const updatedStore: Record = { + "session:1": { + ...testStore["session:1"], + displayName: "Updated Session 1", + }, + }; + + // Save - should invalidate cache + await saveSessionStore(storePath, updatedStore); + + // Load again - should get new data from disk + const loaded2 = loadSessionStore(storePath); + expect(loaded2["session:1"].displayName).toBe("Updated Session 1"); + }); + + it("should respect CLAWDBOT_SESSION_CACHE_TTL_MS=0 to disable cache", async () => { + process.env.CLAWDBOT_SESSION_CACHE_TTL_MS = "0"; + clearSessionStoreCacheForTest(); + + const testStore: Record = { + "session:1": { + sessionId: "id-1", + updatedAt: Date.now(), + displayName: "Test Session 1", + }, + }; + + await saveSessionStore(storePath, testStore); + + // First load + const loaded1 = loadSessionStore(storePath); + expect(loaded1).toEqual(testStore); + + // Modify file on disk + const modifiedStore: Record = { + "session:2": { + sessionId: "id-2", + updatedAt: Date.now(), + displayName: "Test Session 2", + }, + }; + fs.writeFileSync(storePath, JSON.stringify(modifiedStore, null, 2)); + + // Second load - should read from disk (cache disabled) + const loaded2 = loadSessionStore(storePath); + expect(loaded2).toEqual(modifiedStore); // Should be modified, not cached + }); + + it("should handle non-existent store gracefully", () => { + const nonExistentPath = path.join(testDir, "non-existent.json"); + + // Should return empty store + const loaded = loadSessionStore(nonExistentPath); + expect(loaded).toEqual({}); + }); + + it("should handle invalid JSON gracefully", async () => { + // Write invalid JSON + fs.writeFileSync(storePath, "not valid json {"); + + // Should return empty store + const loaded = loadSessionStore(storePath); + expect(loaded).toEqual({}); + }); +}); diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 024761b73..27b100937 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -16,6 +16,50 @@ import { import { normalizeE164 } from "../utils.js"; import { resolveStateDir } from "./paths.js"; +// ============================================================================ +// Session Store Cache with TTL Support +// ============================================================================ + +type SessionStoreCacheEntry = { + store: Record; + loadedAt: number; + storePath: string; +}; + +const SESSION_STORE_CACHE = new Map(); +const DEFAULT_SESSION_STORE_TTL_MS = 45_000; // 45 seconds (between 30-60s) + +function getSessionStoreTtl(): number { + // Allow runtime override via environment variable + const envTtl = process.env.CLAWDBOT_SESSION_CACHE_TTL_MS; + if (envTtl) { + const parsed = Number.parseInt(envTtl, 10); + if (Number.isFinite(parsed) && parsed >= 0) { + return parsed; + } + } + return DEFAULT_SESSION_STORE_TTL_MS; +} + +function isSessionStoreCacheEnabled(): boolean { + const ttl = getSessionStoreTtl(); + return ttl > 0; +} + +function isSessionStoreCacheValid(entry: SessionStoreCacheEntry): boolean { + const now = Date.now(); + const ttl = getSessionStoreTtl(); + return now - entry.loadedAt <= ttl; +} + +function invalidateSessionStoreCache(storePath: string): void { + SESSION_STORE_CACHE.delete(storePath); +} + +export function clearSessionStoreCacheForTest(): void { + SESSION_STORE_CACHE.clear(); +} + export type SessionScope = "per-sender" | "global"; const GROUP_SURFACES = new Set([ @@ -340,22 +384,46 @@ export function resolveGroupSessionKey( export function loadSessionStore( storePath: string, ): Record { + // Check cache first if enabled + if (isSessionStoreCacheEnabled()) { + const cached = SESSION_STORE_CACHE.get(storePath); + if (cached && isSessionStoreCacheValid(cached)) { + // Return a shallow copy to prevent external mutations affecting cache + return { ...cached.store }; + } + } + + // Cache miss or disabled - load from disk + let store: Record = {}; try { const raw = fs.readFileSync(storePath, "utf-8"); const parsed = JSON5.parse(raw); if (parsed && typeof parsed === "object") { - return parsed as Record; + store = parsed as Record; } } catch { // ignore missing/invalid store; we'll recreate it } - return {}; + + // Cache the result if caching is enabled + if (isSessionStoreCacheEnabled()) { + SESSION_STORE_CACHE.set(storePath, { + store: { ...store }, // Store a copy to prevent external mutations + loadedAt: Date.now(), + storePath, + }); + } + + return store; } export async function saveSessionStore( storePath: string, store: Record, ) { + // Invalidate cache on write to ensure consistency + invalidateSessionStoreCache(storePath); + await fs.promises.mkdir(path.dirname(storePath), { recursive: true }); const json = JSON.stringify(store, null, 2); const tmp = `${storePath}.${process.pid}.${crypto.randomUUID()}.tmp`; diff --git a/src/config/types.ts b/src/config/types.ts index e625a5914..4602c1fcd 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -31,6 +31,15 @@ export type SessionSendPolicyConfig = { rules?: SessionSendPolicyRule[]; }; +export type SessionCacheConfig = { + /** Enable session store caching (default: true). Set to false to disable. */ + enabled?: boolean; + /** Session store cache TTL in milliseconds (default: 45000 = 45s). Set to 0 to disable. */ + storeTtlMs?: number; + /** SessionManager cache TTL in milliseconds (default: 45000 = 45s). Set to 0 to disable. */ + managerTtlMs?: number; +}; + export type SessionConfig = { scope?: SessionScope; resetTriggers?: string[]; @@ -41,6 +50,8 @@ export type SessionConfig = { typingMode?: TypingMode; mainKey?: string; sendPolicy?: SessionSendPolicyConfig; + /** Session caching configuration. */ + cache?: SessionCacheConfig; agentToAgent?: { /** Max ping-pong turns between requester/target (0–5). Default: 5. */ maxPingPongTurns?: number;