UNPKG

@gguf/claw

Version:

Multi-channel AI gateway with extensible messaging integrations

1,622 lines (1,601 loc) 83.5 kB
#!/usr/bin/env node import { spawn } from "node:child_process"; import process$1 from "node:process"; import os from "node:os"; import path from "node:path"; import fs from "node:fs"; import chalk, { Chalk } from "chalk"; import { Logger } from "tslog"; import JSON5 from "json5"; import util from "node:util"; //#region src/infra/home-dir.ts function normalize(value) { const trimmed = value?.trim(); return trimmed ? trimmed : void 0; } function resolveEffectiveHomeDir(env = process.env, homedir = os.homedir) { const raw = resolveRawHomeDir(env, homedir); return raw ? path.resolve(raw) : void 0; } function resolveRawHomeDir(env, homedir) { const explicitHome = normalize(env.OPENCLAW_HOME); if (explicitHome) { if (explicitHome === "~" || explicitHome.startsWith("~/") || explicitHome.startsWith("~\\")) { const fallbackHome = normalize(env.HOME) ?? normalize(env.USERPROFILE) ?? normalizeSafe(homedir); if (fallbackHome) return explicitHome.replace(/^~(?=$|[\\/])/, fallbackHome); return; } return explicitHome; } const envHome = normalize(env.HOME); if (envHome) return envHome; const userProfile = normalize(env.USERPROFILE); if (userProfile) return userProfile; return normalizeSafe(homedir); } function normalizeSafe(homedir) { try { return normalize(homedir()); } catch { return; } } function resolveRequiredHomeDir(env = process.env, homedir = os.homedir) { return resolveEffectiveHomeDir(env, homedir) ?? path.resolve(process.cwd()); } function expandHomePrefix(input, opts) { if (!input.startsWith("~")) return input; const home = normalize(opts?.home) ?? resolveEffectiveHomeDir(opts?.env ?? process.env, opts?.homedir ?? os.homedir); if (!home) return input; return input.replace(/^~(?=$|[\\/])/, home); } //#endregion //#region src/cli/profile-utils.ts const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i; function isValidProfileName(value) { if (!value) return false; return PROFILE_NAME_RE.test(value); } function normalizeProfileName(raw) { const profile = raw?.trim(); if (!profile) return null; if (profile.toLowerCase() === "default") return null; if (!isValidProfileName(profile)) return null; return profile; } //#endregion //#region src/cli/profile.ts function takeValue(raw, next) { if (raw.includes("=")) { const [, value] = raw.split("=", 2); return { value: (value ?? "").trim() || null, consumedNext: false }; } return { value: (next ?? "").trim() || null, consumedNext: Boolean(next) }; } function parseCliProfileArgs(argv) { if (argv.length < 2) return { ok: true, profile: null, argv }; const out = argv.slice(0, 2); let profile = null; let sawDev = false; let sawCommand = false; const args = argv.slice(2); for (let i = 0; i < args.length; i += 1) { const arg = args[i]; if (arg === void 0) continue; if (sawCommand) { out.push(arg); continue; } if (arg === "--dev") { if (profile && profile !== "dev") return { ok: false, error: "Cannot combine --dev with --profile" }; sawDev = true; profile = "dev"; continue; } if (arg === "--profile" || arg.startsWith("--profile=")) { if (sawDev) return { ok: false, error: "Cannot combine --dev with --profile" }; const next = args[i + 1]; const { value, consumedNext } = takeValue(arg, next); if (consumedNext) i += 1; if (!value) return { ok: false, error: "--profile requires a value" }; if (!isValidProfileName(value)) return { ok: false, error: "Invalid --profile (use letters, numbers, \"_\", \"-\" only)" }; profile = value; continue; } if (!arg.startsWith("-")) { sawCommand = true; out.push(arg); continue; } out.push(arg); } return { ok: true, profile, argv: out }; } function resolveProfileStateDir(profile, env, homedir) { const suffix = profile.toLowerCase() === "default" ? "" : `-${profile}`; return path.join(resolveRequiredHomeDir(env, homedir), `.openclaw${suffix}`); } function applyCliProfileEnv(params) { const env = params.env ?? process.env; const homedir = params.homedir ?? os.homedir; const profile = params.profile.trim(); if (!profile) return; env.OPENCLAW_PROFILE = profile; const stateDir = env.OPENCLAW_STATE_DIR?.trim() || resolveProfileStateDir(profile, env, homedir); if (!env.OPENCLAW_STATE_DIR?.trim()) env.OPENCLAW_STATE_DIR = stateDir; if (!env.OPENCLAW_CONFIG_PATH?.trim()) env.OPENCLAW_CONFIG_PATH = path.join(stateDir, "openclaw.json"); if (profile === "dev" && !env.OPENCLAW_GATEWAY_PORT?.trim()) env.OPENCLAW_GATEWAY_PORT = "19001"; } //#endregion //#region src/cli/argv.ts const HELP_FLAGS = new Set(["-h", "--help"]); const VERSION_FLAGS = new Set(["-V", "--version"]); const ROOT_VERSION_ALIAS_FLAG = "-v"; const ROOT_BOOLEAN_FLAGS = new Set(["--dev", "--no-color"]); const ROOT_VALUE_FLAGS = new Set(["--profile"]); const FLAG_TERMINATOR = "--"; function hasHelpOrVersion(argv) { return argv.some((arg) => HELP_FLAGS.has(arg) || VERSION_FLAGS.has(arg)) || hasRootVersionAlias(argv); } function isValueToken(arg) { if (!arg) return false; if (arg === FLAG_TERMINATOR) return false; if (!arg.startsWith("-")) return true; return /^-\d+(?:\.\d+)?$/.test(arg); } function parsePositiveInt(value) { const parsed = Number.parseInt(value, 10); if (Number.isNaN(parsed) || parsed <= 0) return; return parsed; } function hasFlag(argv, name) { const args = argv.slice(2); for (const arg of args) { if (arg === FLAG_TERMINATOR) break; if (arg === name) return true; } return false; } function hasRootVersionAlias(argv) { const args = argv.slice(2); let hasAlias = false; for (let i = 0; i < args.length; i += 1) { const arg = args[i]; if (!arg) continue; if (arg === FLAG_TERMINATOR) break; if (arg === ROOT_VERSION_ALIAS_FLAG) { hasAlias = true; continue; } if (ROOT_BOOLEAN_FLAGS.has(arg)) continue; if (arg.startsWith("--profile=")) continue; if (ROOT_VALUE_FLAGS.has(arg)) { const next = args[i + 1]; if (isValueToken(next)) i += 1; continue; } if (arg.startsWith("-")) continue; return false; } return hasAlias; } function getFlagValue(argv, name) { const args = argv.slice(2); for (let i = 0; i < args.length; i += 1) { const arg = args[i]; if (arg === FLAG_TERMINATOR) break; if (arg === name) { const next = args[i + 1]; return isValueToken(next) ? next : null; } if (arg.startsWith(`${name}=`)) { const value = arg.slice(name.length + 1); return value ? value : null; } } } function getVerboseFlag(argv, options) { if (hasFlag(argv, "--verbose")) return true; if (options?.includeDebug && hasFlag(argv, "--debug")) return true; return false; } function getPositiveIntFlagValue(argv, name) { const raw = getFlagValue(argv, name); if (raw === null || raw === void 0) return raw; return parsePositiveInt(raw); } function getCommandPath(argv, depth = 2) { const args = argv.slice(2); const path = []; for (let i = 0; i < args.length; i += 1) { const arg = args[i]; if (!arg) continue; if (arg === "--") break; if (arg.startsWith("-")) continue; path.push(arg); if (path.length >= depth) break; } return path; } function getPrimaryCommand(argv) { const [primary] = getCommandPath(argv, 1); return primary ?? null; } function buildParseArgv(params) { const baseArgv = params.rawArgs && params.rawArgs.length > 0 ? params.rawArgs : params.fallbackArgv && params.fallbackArgv.length > 0 ? params.fallbackArgv : process.argv; const programName = params.programName ?? ""; const normalizedArgv = programName && baseArgv[0] === programName ? baseArgv.slice(1) : baseArgv[0]?.endsWith("openclaw") ? baseArgv.slice(1) : baseArgv; const executable = (normalizedArgv[0]?.split(/[/\\]/).pop() ?? "").toLowerCase(); if (normalizedArgv.length >= 2 && (isNodeExecutable(executable) || isBunExecutable(executable))) return normalizedArgv; return [ "node", programName || "openclaw", ...normalizedArgv ]; } const nodeExecutablePattern = /^node-\d+(?:\.\d+)*(?:\.exe)?$/; function isNodeExecutable(executable) { return executable === "node" || executable === "node.exe" || executable === "nodejs" || executable === "nodejs.exe" || nodeExecutablePattern.test(executable); } function isBunExecutable(executable) { return executable === "bun" || executable === "bun.exe"; } function shouldMigrateStateFromPath(path) { if (path.length === 0) return true; const [primary, secondary] = path; if (primary === "health" || primary === "status" || primary === "sessions") return false; if (primary === "config" && (secondary === "get" || secondary === "unset")) return false; if (primary === "models" && (secondary === "list" || secondary === "status")) return false; if (primary === "memory" && secondary === "status") return false; if (primary === "agent") return false; return true; } //#endregion //#region src/cli/respawn-policy.ts function shouldSkipRespawnForArgv(argv) { return hasHelpOrVersion(argv); } //#endregion //#region src/cli/windows-argv.ts function normalizeWindowsArgv(argv) { if (process.platform !== "win32") return argv; if (argv.length < 2) return argv; const stripControlChars = (value) => { let out = ""; for (let i = 0; i < value.length; i += 1) { const code = value.charCodeAt(i); if (code >= 32 && code !== 127) out += value[i]; } return out; }; const normalizeArg = (value) => stripControlChars(value).replace(/^['"]+|['"]+$/g, "").trim(); const normalizeCandidate = (value) => normalizeArg(value).replace(/^\\\\\\?\\/, ""); const execPath = normalizeCandidate(process.execPath); const execPathLower = execPath.toLowerCase(); const execBase = path.basename(execPath).toLowerCase(); const isExecPath = (value) => { if (!value) return false; const normalized = normalizeCandidate(value); if (!normalized) return false; const lower = normalized.toLowerCase(); return lower === execPathLower || path.basename(lower) === execBase || lower.endsWith("\\node.exe") || lower.endsWith("/node.exe") || lower.includes("node.exe") || path.basename(lower) === "node.exe" && fs.existsSync(normalized); }; const next = [...argv]; for (let i = 1; i <= 3 && i < next.length;) { if (isExecPath(next[i])) { next.splice(i, 1); continue; } i += 1; } const filtered = next.filter((arg, index) => index === 0 || !isExecPath(arg)); if (filtered.length < 3) return filtered; const cleaned = [...filtered]; for (let i = 2; i < cleaned.length;) { const arg = cleaned[i]; if (!arg || arg.startsWith("-")) { i += 1; continue; } if (isExecPath(arg)) { cleaned.splice(i, 1); continue; } break; } return cleaned; } //#endregion //#region src/hooks/internal-hooks.ts /** Registry of hook handlers by event key */ const handlers = /* @__PURE__ */ new Map(); /** * Register a hook handler for a specific event type or event:action combination * * @param eventKey - Event type (e.g., 'command') or specific action (e.g., 'command:new') * @param handler - Function to call when the event is triggered * * @example * ```ts * // Listen to all command events * registerInternalHook('command', async (event) => { * console.log('Command:', event.action); * }); * * // Listen only to /new commands * registerInternalHook('command:new', async (event) => { * await saveSessionToMemory(event); * }); * ``` */ function registerInternalHook(eventKey, handler) { if (!handlers.has(eventKey)) handlers.set(eventKey, []); handlers.get(eventKey).push(handler); } /** * Clear all registered hooks (useful for testing) */ function clearInternalHooks() { handlers.clear(); } /** * Trigger a hook event * * Calls all handlers registered for: * 1. The general event type (e.g., 'command') * 2. The specific event:action combination (e.g., 'command:new') * * Handlers are called in registration order. Errors are caught and logged * but don't prevent other handlers from running. * * @param event - The event to trigger */ async function triggerInternalHook(event) { const typeHandlers = handlers.get(event.type) ?? []; const specificHandlers = handlers.get(`${event.type}:${event.action}`) ?? []; const allHandlers = [...typeHandlers, ...specificHandlers]; if (allHandlers.length === 0) return; for (const handler of allHandlers) try { await handler(event); } catch (err) { console.error(`Hook error [${event.type}:${event.action}]:`, err instanceof Error ? err.message : String(err)); } } /** * Create a hook event with common fields filled in * * @param type - The event type * @param action - The action within that type * @param sessionKey - The session key * @param context - Additional context */ function createInternalHookEvent(type, action, sessionKey, context = {}) { return { type, action, sessionKey, context, timestamp: /* @__PURE__ */ new Date(), messages: [] }; } //#endregion //#region src/config/paths.ts /** * Nix mode detection: When OPENCLAW_NIX_MODE=1, the gateway is running under Nix. * In this mode: * - No auto-install flows should be attempted * - Missing dependencies should produce actionable Nix-specific error messages * - Config is managed externally (read-only from Nix perspective) */ function resolveIsNixMode(env = process.env) { return env.OPENCLAW_NIX_MODE === "1"; } const isNixMode = resolveIsNixMode(); const LEGACY_STATE_DIRNAMES = [ ".clawdbot", ".moldbot", ".moltbot" ]; const NEW_STATE_DIRNAME = ".openclaw"; const CONFIG_FILENAME = "openclaw.json"; const LEGACY_CONFIG_FILENAMES = [ "clawdbot.json", "moldbot.json", "moltbot.json" ]; function resolveDefaultHomeDir() { return resolveRequiredHomeDir(process.env, os.homedir); } /** Build a homedir thunk that respects OPENCLAW_HOME for the given env. */ function envHomedir(env) { return () => resolveRequiredHomeDir(env, os.homedir); } function legacyStateDirs(homedir = resolveDefaultHomeDir) { return LEGACY_STATE_DIRNAMES.map((dir) => path.join(homedir(), dir)); } function newStateDir(homedir = resolveDefaultHomeDir) { return path.join(homedir(), NEW_STATE_DIRNAME); } function resolveLegacyStateDirs(homedir = resolveDefaultHomeDir) { return legacyStateDirs(homedir); } function resolveNewStateDir(homedir = resolveDefaultHomeDir) { return newStateDir(homedir); } /** * State directory for mutable data (sessions, logs, caches). * Can be overridden via OPENCLAW_STATE_DIR. * Default: ~/.openclaw */ function resolveStateDir(env = process.env, homedir = envHomedir(env)) { const effectiveHomedir = () => resolveRequiredHomeDir(env, homedir); const override = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim(); if (override) return resolveUserPath$1(override, env, effectiveHomedir); const newDir = newStateDir(effectiveHomedir); const legacyDirs = legacyStateDirs(effectiveHomedir); if (fs.existsSync(newDir)) return newDir; const existingLegacy = legacyDirs.find((dir) => { try { return fs.existsSync(dir); } catch { return false; } }); if (existingLegacy) return existingLegacy; return newDir; } function resolveUserPath$1(input, env = process.env, homedir = envHomedir(env)) { const trimmed = input.trim(); if (!trimmed) return trimmed; if (trimmed.startsWith("~")) { const expanded = expandHomePrefix(trimmed, { home: resolveRequiredHomeDir(env, homedir), env, homedir }); return path.resolve(expanded); } return path.resolve(trimmed); } const STATE_DIR = resolveStateDir(); /** * Config file path (JSON5). * Can be overridden via OPENCLAW_CONFIG_PATH. * Default: ~/.openclaw/openclaw.json (or $OPENCLAW_STATE_DIR/openclaw.json) */ function resolveCanonicalConfigPath(env = process.env, stateDir = resolveStateDir(env, envHomedir(env))) { const override = env.OPENCLAW_CONFIG_PATH?.trim() || env.CLAWDBOT_CONFIG_PATH?.trim(); if (override) return resolveUserPath$1(override, env, envHomedir(env)); return path.join(stateDir, CONFIG_FILENAME); } /** * Resolve the active config path by preferring existing config candidates * before falling back to the canonical path. */ function resolveConfigPathCandidate(env = process.env, homedir = envHomedir(env)) { const existing = resolveDefaultConfigCandidates(env, homedir).find((candidate) => { try { return fs.existsSync(candidate); } catch { return false; } }); if (existing) return existing; return resolveCanonicalConfigPath(env, resolveStateDir(env, homedir)); } /** * Active config path (prefers existing config files). */ function resolveConfigPath(env = process.env, stateDir = resolveStateDir(env, envHomedir(env)), homedir = envHomedir(env)) { const override = env.OPENCLAW_CONFIG_PATH?.trim(); if (override) return resolveUserPath$1(override, env, homedir); const stateOverride = env.OPENCLAW_STATE_DIR?.trim(); const existing = [path.join(stateDir, CONFIG_FILENAME), ...LEGACY_CONFIG_FILENAMES.map((name) => path.join(stateDir, name))].find((candidate) => { try { return fs.existsSync(candidate); } catch { return false; } }); if (existing) return existing; if (stateOverride) return path.join(stateDir, CONFIG_FILENAME); const defaultStateDir = resolveStateDir(env, homedir); if (path.resolve(stateDir) === path.resolve(defaultStateDir)) return resolveConfigPathCandidate(env, homedir); return path.join(stateDir, CONFIG_FILENAME); } const CONFIG_PATH = resolveConfigPathCandidate(); /** * Resolve default config path candidates across default locations. * Order: explicit config path → state-dir-derived paths → new default. */ function resolveDefaultConfigCandidates(env = process.env, homedir = envHomedir(env)) { const effectiveHomedir = () => resolveRequiredHomeDir(env, homedir); const explicit = env.OPENCLAW_CONFIG_PATH?.trim() || env.CLAWDBOT_CONFIG_PATH?.trim(); if (explicit) return [resolveUserPath$1(explicit, env, effectiveHomedir)]; const candidates = []; const openclawStateDir = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim(); if (openclawStateDir) { const resolved = resolveUserPath$1(openclawStateDir, env, effectiveHomedir); candidates.push(path.join(resolved, CONFIG_FILENAME)); candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(resolved, name))); } const defaultDirs = [newStateDir(effectiveHomedir), ...legacyStateDirs(effectiveHomedir)]; for (const dir of defaultDirs) { candidates.push(path.join(dir, CONFIG_FILENAME)); candidates.push(...LEGACY_CONFIG_FILENAMES.map((name) => path.join(dir, name))); } return candidates; } const DEFAULT_GATEWAY_PORT = 18789; /** * Gateway lock directory (ephemeral). * Default: os.tmpdir()/openclaw-<uid> (uid suffix when available). */ function resolveGatewayLockDir(tmpdir = os.tmpdir) { const base = tmpdir(); const uid = typeof process.getuid === "function" ? process.getuid() : void 0; const suffix = uid != null ? `openclaw-${uid}` : "openclaw"; return path.join(base, suffix); } const OAUTH_FILENAME = "oauth.json"; /** * OAuth credentials storage directory. * * Precedence: * - `OPENCLAW_OAUTH_DIR` (explicit override) * - `$*_STATE_DIR/credentials` (canonical server/default) */ function resolveOAuthDir(env = process.env, stateDir = resolveStateDir(env, envHomedir(env))) { const override = env.OPENCLAW_OAUTH_DIR?.trim(); if (override) return resolveUserPath$1(override, env, envHomedir(env)); return path.join(stateDir, "credentials"); } function resolveOAuthPath(env = process.env, stateDir = resolveStateDir(env, envHomedir(env))) { return path.join(resolveOAuthDir(env, stateDir), OAUTH_FILENAME); } function resolveGatewayPort(cfg, env = process.env) { const envRaw = env.OPENCLAW_GATEWAY_PORT?.trim() || env.CLAWDBOT_GATEWAY_PORT?.trim(); if (envRaw) { const parsed = Number.parseInt(envRaw, 10); if (Number.isFinite(parsed) && parsed > 0) return parsed; } const configPort = cfg?.gateway?.port; if (typeof configPort === "number" && Number.isFinite(configPort)) { if (configPort > 0) return configPort; } return DEFAULT_GATEWAY_PORT; } //#endregion //#region src/infra/tmp-openclaw-dir.ts const POSIX_OPENCLAW_TMP_DIR = "/tmp/openclaw"; function isNodeErrorWithCode(err, code) { return typeof err === "object" && err !== null && "code" in err && err.code === code; } function resolvePreferredOpenClawTmpDir(options = {}) { const accessSync = options.accessSync ?? fs.accessSync; const lstatSync = options.lstatSync ?? fs.lstatSync; const mkdirSync = options.mkdirSync ?? fs.mkdirSync; const getuid = options.getuid ?? (() => { try { return typeof process.getuid === "function" ? process.getuid() : void 0; } catch { return; } }); const tmpdir = options.tmpdir ?? os.tmpdir; const uid = getuid(); const isSecureDirForUser = (st) => { if (uid === void 0) return true; if (typeof st.uid === "number" && st.uid !== uid) return false; if (typeof st.mode === "number" && (st.mode & 18) !== 0) return false; return true; }; const fallback = () => { const base = tmpdir(); const suffix = uid === void 0 ? "openclaw" : `openclaw-${uid}`; return path.join(base, suffix); }; try { const preferred = lstatSync(POSIX_OPENCLAW_TMP_DIR); if (!preferred.isDirectory() || preferred.isSymbolicLink()) return fallback(); accessSync(POSIX_OPENCLAW_TMP_DIR, fs.constants.W_OK | fs.constants.X_OK); if (!isSecureDirForUser(preferred)) return fallback(); return POSIX_OPENCLAW_TMP_DIR; } catch (err) { if (!isNodeErrorWithCode(err, "ENOENT")) return fallback(); } try { accessSync("/tmp", fs.constants.W_OK | fs.constants.X_OK); mkdirSync(POSIX_OPENCLAW_TMP_DIR, { recursive: true, mode: 448 }); try { const preferred = lstatSync(POSIX_OPENCLAW_TMP_DIR); if (!preferred.isDirectory() || preferred.isSymbolicLink()) return fallback(); if (!isSecureDirForUser(preferred)) return fallback(); } catch { return fallback(); } return POSIX_OPENCLAW_TMP_DIR; } catch { return fallback(); } } //#endregion //#region src/logging/config.ts function readLoggingConfig() { const configPath = resolveConfigPath(); try { if (!fs.existsSync(configPath)) return; const raw = fs.readFileSync(configPath, "utf-8"); const logging = JSON5.parse(raw)?.logging; if (!logging || typeof logging !== "object" || Array.isArray(logging)) return; return logging; } catch { return; } } //#endregion //#region src/logging/levels.ts const ALLOWED_LOG_LEVELS = [ "silent", "fatal", "error", "warn", "info", "debug", "trace" ]; function normalizeLogLevel(level, fallback = "info") { const candidate = (level ?? fallback).trim(); return ALLOWED_LOG_LEVELS.includes(candidate) ? candidate : fallback; } function levelToMinLevel(level) { return { fatal: 0, error: 1, warn: 2, info: 3, debug: 4, trace: 5, silent: Number.POSITIVE_INFINITY }[level]; } //#endregion //#region src/logging/state.ts const loggingState = { cachedLogger: null, cachedSettings: null, cachedConsoleSettings: null, overrideSettings: null, consolePatched: false, forceConsoleToStderr: false, consoleTimestampPrefix: false, consoleSubsystemFilter: null, resolvingConsoleSettings: false, streamErrorHandlersInstalled: false, rawConsole: null }; //#endregion //#region src/logging/logger.ts const DEFAULT_LOG_DIR = resolvePreferredOpenClawTmpDir(); const DEFAULT_LOG_FILE = path.join(DEFAULT_LOG_DIR, "openclaw.log"); const LOG_PREFIX = "openclaw"; const LOG_SUFFIX = ".log"; const MAX_LOG_AGE_MS = 1440 * 60 * 1e3; function resolveNodeRequire$1() { const getBuiltinModule = process.getBuiltinModule; if (typeof getBuiltinModule !== "function") return null; try { const moduleNamespace = getBuiltinModule("module"); return typeof moduleNamespace.createRequire === "function" ? moduleNamespace.createRequire : null; } catch { return null; } } const requireConfig$1 = resolveNodeRequire$1()?.(import.meta.url) ?? null; const externalTransports = /* @__PURE__ */ new Set(); function attachExternalTransport(logger, transport) { logger.attachTransport((logObj) => { if (!externalTransports.has(transport)) return; try { transport(logObj); } catch {} }); } function resolveSettings() { let cfg = loggingState.overrideSettings ?? readLoggingConfig(); if (!cfg) try { cfg = (requireConfig$1?.("../config/config.js"))?.loadConfig?.().logging; } catch { cfg = void 0; } const defaultLevel = process.env.VITEST === "true" && process.env.OPENCLAW_TEST_FILE_LOG !== "1" ? "silent" : "info"; return { level: normalizeLogLevel(cfg?.level, defaultLevel), file: cfg?.file ?? defaultRollingPathForToday() }; } function settingsChanged(a, b) { if (!a) return true; return a.level !== b.level || a.file !== b.file; } function isFileLogLevelEnabled(level) { const settings = loggingState.cachedSettings ?? resolveSettings(); if (!loggingState.cachedSettings) loggingState.cachedSettings = settings; if (settings.level === "silent") return false; return levelToMinLevel(level) <= levelToMinLevel(settings.level); } function buildLogger(settings) { fs.mkdirSync(path.dirname(settings.file), { recursive: true }); if (isRollingPath(settings.file)) pruneOldRollingLogs(path.dirname(settings.file)); const logger = new Logger({ name: "openclaw", minLevel: levelToMinLevel(settings.level), type: "hidden" }); logger.attachTransport((logObj) => { try { const time = logObj.date?.toISOString?.() ?? (/* @__PURE__ */ new Date()).toISOString(); const line = JSON.stringify({ ...logObj, time }); fs.appendFileSync(settings.file, `${line}\n`, { encoding: "utf8" }); } catch {} }); for (const transport of externalTransports) attachExternalTransport(logger, transport); return logger; } function getLogger() { const settings = resolveSettings(); const cachedLogger = loggingState.cachedLogger; const cachedSettings = loggingState.cachedSettings; if (!cachedLogger || settingsChanged(cachedSettings, settings)) { loggingState.cachedLogger = buildLogger(settings); loggingState.cachedSettings = settings; } return loggingState.cachedLogger; } function getChildLogger(bindings, opts) { const base = getLogger(); const minLevel = opts?.level ? levelToMinLevel(opts.level) : void 0; const name = bindings ? JSON.stringify(bindings) : void 0; return base.getSubLogger({ name, minLevel, prefix: bindings ? [name ?? ""] : [] }); } function toPinoLikeLogger(logger, level) { const buildChild = (bindings) => toPinoLikeLogger(logger.getSubLogger({ name: bindings ? JSON.stringify(bindings) : void 0 }), level); return { level, child: buildChild, trace: (...args) => logger.trace(...args), debug: (...args) => logger.debug(...args), info: (...args) => logger.info(...args), warn: (...args) => logger.warn(...args), error: (...args) => logger.error(...args), fatal: (...args) => logger.fatal(...args) }; } function getResolvedLoggerSettings() { return resolveSettings(); } function formatLocalDate(date) { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; } function defaultRollingPathForToday() { const today = formatLocalDate(/* @__PURE__ */ new Date()); return path.join(DEFAULT_LOG_DIR, `${LOG_PREFIX}-${today}${LOG_SUFFIX}`); } function isRollingPath(file) { const base = path.basename(file); return base.startsWith(`${LOG_PREFIX}-`) && base.endsWith(LOG_SUFFIX) && base.length === `${LOG_PREFIX}-YYYY-MM-DD${LOG_SUFFIX}`.length; } function pruneOldRollingLogs(dir) { try { const entries = fs.readdirSync(dir, { withFileTypes: true }); const cutoff = Date.now() - MAX_LOG_AGE_MS; for (const entry of entries) { if (!entry.isFile()) continue; if (!entry.name.startsWith(`${LOG_PREFIX}-`) || !entry.name.endsWith(LOG_SUFFIX)) continue; const fullPath = path.join(dir, entry.name); try { if (fs.statSync(fullPath).mtimeMs < cutoff) fs.rmSync(fullPath, { force: true }); } catch {} } } catch {} } //#endregion //#region src/terminal/palette.ts const LOBSTER_PALETTE = { accent: "#FF5A2D", accentBright: "#FF7A3D", accentDim: "#D14A22", info: "#FF8A5B", success: "#2FBF71", warn: "#FFB020", error: "#E23D2D", muted: "#8B7F77" }; //#endregion //#region src/terminal/theme.ts const hasForceColor = typeof process.env.FORCE_COLOR === "string" && process.env.FORCE_COLOR.trim().length > 0 && process.env.FORCE_COLOR.trim() !== "0"; const baseChalk = process.env.NO_COLOR && !hasForceColor ? new Chalk({ level: 0 }) : chalk; const hex = (value) => baseChalk.hex(value); const theme = { accent: hex(LOBSTER_PALETTE.accent), accentBright: hex(LOBSTER_PALETTE.accentBright), accentDim: hex(LOBSTER_PALETTE.accentDim), info: hex(LOBSTER_PALETTE.info), success: hex(LOBSTER_PALETTE.success), warn: hex(LOBSTER_PALETTE.warn), error: hex(LOBSTER_PALETTE.error), muted: hex(LOBSTER_PALETTE.muted), heading: baseChalk.bold.hex(LOBSTER_PALETTE.accent), command: hex(LOBSTER_PALETTE.accentBright), option: hex(LOBSTER_PALETTE.warn) }; const isRich = () => Boolean(baseChalk.level > 0); const colorize = (rich, color, value) => rich ? color(value) : value; //#endregion //#region src/globals.ts let globalVerbose = false; let globalYes = false; function setVerbose(v) { globalVerbose = v; } function isVerbose() { return globalVerbose; } function shouldLogVerbose() { return globalVerbose || isFileLogLevelEnabled("debug"); } function logVerbose(message) { if (!shouldLogVerbose()) return; try { getLogger().debug({ message }, "verbose"); } catch {} if (!globalVerbose) return; console.log(theme.muted(message)); } function logVerboseConsole(message) { if (!globalVerbose) return; console.log(theme.muted(message)); } function isYes() { return globalYes; } const success = theme.success; const warn = theme.warn; const info = theme.info; const danger = theme.error; //#endregion //#region src/infra/plain-object.ts /** * Strict plain-object guard (excludes arrays and host objects). */ function isPlainObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value) && Object.prototype.toString.call(value) === "[object Object]"; } //#endregion //#region src/utils.ts async function ensureDir(dir) { await fs.promises.mkdir(dir, { recursive: true }); } /** * Check if a file or directory exists at the given path. */ async function pathExists(targetPath) { try { await fs.promises.access(targetPath); return true; } catch { return false; } } function clampNumber(value, min, max) { return Math.max(min, Math.min(max, value)); } function clampInt(value, min, max) { return clampNumber(Math.floor(value), min, max); } /** Alias for clampNumber (shorter, more common name) */ const clamp = clampNumber; /** * Escapes special regex characters in a string so it can be used in a RegExp constructor. */ function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** * Safely parse JSON, returning null on error instead of throwing. */ function safeParseJson(raw) { try { return JSON.parse(raw); } catch { return null; } } /** * Type guard for Record<string, unknown> (less strict than isPlainObject). * Accepts any non-null object that isn't an array. */ function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function normalizeE164(number) { const digits = number.replace(/^whatsapp:/, "").trim().replace(/[^\d+]/g, ""); if (digits.startsWith("+")) return `+${digits.slice(1)}`; return `+${digits}`; } /** * "Self-chat mode" heuristic (single phone): the gateway is logged in as the owner's own WhatsApp account, * and `channels.whatsapp.allowFrom` includes that same number. Used to avoid side-effects that make no sense when the * "bot" and the human are the same WhatsApp identity (e.g. auto read receipts, @mention JID triggers). */ function isSelfChatMode(selfE164, allowFrom) { if (!selfE164) return false; if (!Array.isArray(allowFrom) || allowFrom.length === 0) return false; const normalizedSelf = normalizeE164(selfE164); return allowFrom.some((n) => { if (n === "*") return false; try { return normalizeE164(String(n)) === normalizedSelf; } catch { return false; } }); } function toWhatsappJid(number) { const withoutPrefix = number.replace(/^whatsapp:/, "").trim(); if (withoutPrefix.includes("@")) return withoutPrefix; return `${normalizeE164(withoutPrefix).replace(/\D/g, "")}@s.whatsapp.net`; } function resolveLidMappingDirs(opts) { const dirs = /* @__PURE__ */ new Set(); const addDir = (dir) => { if (!dir) return; dirs.add(resolveUserPath(dir)); }; addDir(opts?.authDir); for (const dir of opts?.lidMappingDirs ?? []) addDir(dir); addDir(resolveOAuthDir()); addDir(path.join(CONFIG_DIR, "credentials")); return [...dirs]; } function readLidReverseMapping(lid, opts) { const mappingFilename = `lid-mapping-${lid}_reverse.json`; const mappingDirs = resolveLidMappingDirs(opts); for (const dir of mappingDirs) { const mappingPath = path.join(dir, mappingFilename); try { const data = fs.readFileSync(mappingPath, "utf8"); const phone = JSON.parse(data); if (phone === null || phone === void 0) continue; return normalizeE164(String(phone)); } catch {} } return null; } function jidToE164(jid, opts) { const match = jid.match(/^(\d+)(?::\d+)?@(s\.whatsapp\.net|hosted)$/); if (match) return `+${match[1]}`; const lidMatch = jid.match(/^(\d+)(?::\d+)?@(lid|hosted\.lid)$/); if (lidMatch) { const lid = lidMatch[1]; const phone = readLidReverseMapping(lid, opts); if (phone) return phone; if (opts?.logMissing ?? shouldLogVerbose()) logVerbose(`LID mapping not found for ${lid}; skipping inbound message`); } return null; } async function resolveJidToE164(jid, opts) { if (!jid) return null; const direct = jidToE164(jid, opts); if (direct) return direct; if (!/(@lid|@hosted\.lid)$/.test(jid)) return null; if (!opts?.lidLookup?.getPNForLID) return null; try { const pnJid = await opts.lidLookup.getPNForLID(jid); if (!pnJid) return null; return jidToE164(pnJid, opts); } catch (err) { if (shouldLogVerbose()) logVerbose(`LID mapping lookup failed for ${jid}: ${String(err)}`); return null; } } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function isHighSurrogate(codeUnit) { return codeUnit >= 55296 && codeUnit <= 56319; } function isLowSurrogate(codeUnit) { return codeUnit >= 56320 && codeUnit <= 57343; } function sliceUtf16Safe(input, start, end) { const len = input.length; let from = start < 0 ? Math.max(len + start, 0) : Math.min(start, len); let to = end === void 0 ? len : end < 0 ? Math.max(len + end, 0) : Math.min(end, len); if (to < from) { const tmp = from; from = to; to = tmp; } if (from > 0 && from < len) { if (isLowSurrogate(input.charCodeAt(from)) && isHighSurrogate(input.charCodeAt(from - 1))) from += 1; } if (to > 0 && to < len) { if (isHighSurrogate(input.charCodeAt(to - 1)) && isLowSurrogate(input.charCodeAt(to))) to -= 1; } return input.slice(from, to); } function truncateUtf16Safe(input, maxLen) { const limit = Math.max(0, Math.floor(maxLen)); if (input.length <= limit) return input; return sliceUtf16Safe(input, 0, limit); } function resolveUserPath(input) { const trimmed = input.trim(); if (!trimmed) return trimmed; if (trimmed.startsWith("~")) { const expanded = expandHomePrefix(trimmed, { home: resolveRequiredHomeDir(process.env, os.homedir), env: process.env, homedir: os.homedir }); return path.resolve(expanded); } return path.resolve(trimmed); } function resolveConfigDir(env = process.env, homedir = os.homedir) { const override = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim(); if (override) return resolveUserPath(override); const newDir = path.join(resolveRequiredHomeDir(env, homedir), ".openclaw"); try { if (fs.existsSync(newDir)) return newDir; } catch {} return newDir; } function resolveHomeDir() { return resolveEffectiveHomeDir(process.env, os.homedir); } function resolveHomeDisplayPrefix() { const home = resolveHomeDir(); if (!home) return; if (process.env.OPENCLAW_HOME?.trim()) return { home, prefix: "$OPENCLAW_HOME" }; return { home, prefix: "~" }; } function shortenHomePath(input) { if (!input) return input; const display = resolveHomeDisplayPrefix(); if (!display) return input; const { home, prefix } = display; if (input === home) return prefix; if (input.startsWith(`${home}/`) || input.startsWith(`${home}\\`)) return `${prefix}${input.slice(home.length)}`; return input; } function shortenHomeInString(input) { if (!input) return input; const display = resolveHomeDisplayPrefix(); if (!display) return input; return input.split(display.home).join(display.prefix); } function displayPath(input) { return shortenHomePath(input); } function displayString(input) { return shortenHomeInString(input); } function formatTerminalLink(label, url, opts) { const esc = "\x1B"; const safeLabel = label.replaceAll(esc, ""); const safeUrl = url.replaceAll(esc, ""); if (!(opts?.force === true ? true : opts?.force === false ? false : Boolean(process.stdout.isTTY))) return opts?.fallback ?? `${safeLabel} (${safeUrl})`; return `\u001b]8;;${safeUrl}\u0007${safeLabel}\u001b]8;;\u0007`; } const CONFIG_DIR = resolveConfigDir(); //#endregion //#region src/plugins/commands.ts const pluginCommands = /* @__PURE__ */ new Map(); let registryLocked = false; const MAX_ARGS_LENGTH = 4096; /** * Reserved command names that plugins cannot override. * These are built-in commands from commands-registry.data.ts. */ const RESERVED_COMMANDS = new Set([ "help", "commands", "status", "whoami", "context", "stop", "restart", "reset", "new", "compact", "config", "debug", "allowlist", "activation", "skill", "subagents", "kill", "steer", "tell", "model", "models", "queue", "send", "bash", "exec", "think", "verbose", "reasoning", "elevated", "usage" ]); /** * Validate a command name. * Returns an error message if invalid, or null if valid. */ function validateCommandName(name) { const trimmed = name.trim().toLowerCase(); if (!trimmed) return "Command name cannot be empty"; if (!/^[a-z][a-z0-9_-]*$/.test(trimmed)) return "Command name must start with a letter and contain only letters, numbers, hyphens, and underscores"; if (RESERVED_COMMANDS.has(trimmed)) return `Command name "${trimmed}" is reserved by a built-in command`; return null; } /** * Register a plugin command. * Returns an error if the command name is invalid or reserved. */ function registerPluginCommand(pluginId, command) { if (registryLocked) return { ok: false, error: "Cannot register commands while processing is in progress" }; if (typeof command.handler !== "function") return { ok: false, error: "Command handler must be a function" }; const validationError = validateCommandName(command.name); if (validationError) return { ok: false, error: validationError }; const key = `/${command.name.toLowerCase()}`; if (pluginCommands.has(key)) { const existing = pluginCommands.get(key); return { ok: false, error: `Command "${command.name}" already registered by plugin "${existing.pluginId}"` }; } pluginCommands.set(key, { ...command, pluginId }); logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`); return { ok: true }; } /** * Clear all registered plugin commands. * Called during plugin reload. */ function clearPluginCommands() { pluginCommands.clear(); } /** * Check if a command body matches a registered plugin command. * Returns the command definition and parsed args if matched. * * Note: If a command has `acceptsArgs: false` and the user provides arguments, * the command will not match. This allows the message to fall through to * built-in handlers or the agent. Document this behavior to plugin authors. */ function matchPluginCommand(commandBody) { const trimmed = commandBody.trim(); if (!trimmed.startsWith("/")) return null; const spaceIndex = trimmed.indexOf(" "); const commandName = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex); const args = spaceIndex === -1 ? void 0 : trimmed.slice(spaceIndex + 1).trim(); const key = commandName.toLowerCase(); const command = pluginCommands.get(key); if (!command) return null; if (args && !command.acceptsArgs) return null; return { command, args: args || void 0 }; } /** * Sanitize command arguments to prevent injection attacks. * Removes control characters and enforces length limits. */ function sanitizeArgs(args) { if (!args) return; if (args.length > MAX_ARGS_LENGTH) return args.slice(0, MAX_ARGS_LENGTH); let sanitized = ""; for (const char of args) { const code = char.charCodeAt(0); if (!(code <= 31 && code !== 9 && code !== 10 || code === 127)) sanitized += char; } return sanitized; } /** * Execute a plugin command handler. * * Note: Plugin authors should still validate and sanitize ctx.args for their * specific use case. This function provides basic defense-in-depth sanitization. */ async function executePluginCommand(params) { const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params; if (command.requireAuth !== false && !isAuthorizedSender) { logVerbose(`Plugin command /${command.name} blocked: unauthorized sender ${senderId || "<unknown>"}`); return { text: "⚠️ This command requires authorization." }; } const sanitizedArgs = sanitizeArgs(args); const ctx = { senderId, channel, channelId: params.channelId, isAuthorizedSender, args: sanitizedArgs, commandBody, config, from: params.from, to: params.to, accountId: params.accountId, messageThreadId: params.messageThreadId }; registryLocked = true; try { const result = await command.handler(ctx); logVerbose(`Plugin command /${command.name} executed successfully for ${senderId || "unknown"}`); return result; } catch (err) { const error = err; logVerbose(`Plugin command /${command.name} error: ${error.message}`); return { text: "⚠️ Command failed. Please try again later." }; } finally { registryLocked = false; } } /** * List all registered plugin commands. * Used for /help and /commands output. */ function listPluginCommands() { return Array.from(pluginCommands.values()).map((cmd) => ({ name: cmd.name, description: cmd.description, pluginId: cmd.pluginId })); } /** * Get plugin command specs for native command registration (e.g., Telegram). */ function getPluginCommandSpecs() { return Array.from(pluginCommands.values()).map((cmd) => ({ name: cmd.name, description: cmd.description })); } //#endregion //#region src/plugins/http-path.ts function normalizePluginHttpPath(path, fallback) { const trimmed = path?.trim(); if (!trimmed) { const fallbackTrimmed = fallback?.trim(); if (!fallbackTrimmed) return null; return fallbackTrimmed.startsWith("/") ? fallbackTrimmed : `/${fallbackTrimmed}`; } return trimmed.startsWith("/") ? trimmed : `/${trimmed}`; } //#endregion //#region src/plugins/registry.ts function createEmptyPluginRegistry() { return { plugins: [], tools: [], hooks: [], typedHooks: [], channels: [], providers: [], gatewayHandlers: {}, httpHandlers: [], httpRoutes: [], cliRegistrars: [], services: [], commands: [], diagnostics: [] }; } function createPluginRegistry(registryParams) { const registry = createEmptyPluginRegistry(); const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {})); const pushDiagnostic = (diag) => { registry.diagnostics.push(diag); }; const registerTool = (record, tool, opts) => { const names = opts?.names ?? (opts?.name ? [opts.name] : []); const optional = opts?.optional === true; const factory = typeof tool === "function" ? tool : (_ctx) => tool; if (typeof tool !== "function") names.push(tool.name); const normalized = names.map((name) => name.trim()).filter(Boolean); if (normalized.length > 0) record.toolNames.push(...normalized); registry.tools.push({ pluginId: record.id, factory, names: normalized, optional, source: record.source }); }; const registerHook = (record, events, handler, opts, config) => { const normalizedEvents = (Array.isArray(events) ? events : [events]).map((event) => event.trim()).filter(Boolean); const entry = opts?.entry ?? null; const name = entry?.hook.name ?? opts?.name?.trim(); if (!name) { pushDiagnostic({ level: "warn", pluginId: record.id, source: record.source, message: "hook registration missing name" }); return; } const description = entry?.hook.description ?? opts?.description ?? ""; const hookEntry = entry ? { ...entry, hook: { ...entry.hook, name, description, source: "openclaw-plugin", pluginId: record.id }, metadata: { ...entry.metadata, events: normalizedEvents } } : { hook: { name, description, source: "openclaw-plugin", pluginId: record.id, filePath: record.source, baseDir: path.dirname(record.source), handlerPath: record.source }, frontmatter: {}, metadata: { events: normalizedEvents }, invocation: { enabled: true } }; record.hookNames.push(name); registry.hooks.push({ pluginId: record.id, entry: hookEntry, events: normalizedEvents, source: record.source }); if (!(config?.hooks?.internal?.enabled === true) || opts?.register === false) return; for (const event of normalizedEvents) registerInternalHook(event, handler); }; const registerGatewayMethod = (record, method, handler) => { const trimmed = method.trim(); if (!trimmed) return; if (coreGatewayMethods.has(trimmed) || registry.gatewayHandlers[trimmed]) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, message: `gateway method already registered: ${trimmed}` }); return; } registry.gatewayHandlers[trimmed] = handler; record.gatewayMethods.push(trimmed); }; const registerHttpHandler = (record, handler) => { record.httpHandlers += 1; registry.httpHandlers.push({ pluginId: record.id, handler, source: record.source }); }; const registerHttpRoute = (record, params) => { const normalizedPath = normalizePluginHttpPath(params.path); if (!normalizedPath) { pushDiagnostic({ level: "warn", pluginId: record.id, source: record.source, message: "http route registration missing path" }); return; } if (registry.httpRoutes.some((entry) => entry.path === normalizedPath)) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, message: `http route already registered: ${normalizedPath}` }); return; } record.httpHandlers += 1; registry.httpRoutes.push({ pluginId: record.id, path: normalizedPath, handler: params.handler, source: record.source }); }; const registerChannel = (record, registration) => { const normalized = typeof registration.plugin === "object" ? registration : { plugin: registration }; const plugin = normalized.plugin; const id = typeof plugin?.id === "string" ? plugin.id.trim() : String(plugin?.id ?? "").trim(); if (!id) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, message: "channel registration missing id" }); return; } record.channelIds.push(id); registry.channels.push({ pluginId: record.id, plugin, dock: normalized.dock, source: record.source }); }; const registerProvider = (record, provider) => { const id = typeof provider?.id === "string" ? provider.id.trim() : ""; if (!id) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, message: "provider registration missing id" }); return; } const existing = registry.providers.find((entry) => entry.provider.id === id); if (existing) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, message: `provider already registered: ${id} (${existing.pluginId})` }); return; } record.providerIds.push(id); registry.providers.push({ pluginId: record.id, provider, source: record.source }); }; const registerCli = (record, registrar, opts) => { const commands = (opts?.commands ?? []).map((cmd) => cmd.trim()).filter(Boolean); record.cliCommands.push(...commands); registry.cliRegistrars.push({ pluginId: record.id, register: registrar, commands, source: record.source }); }; const registerService = (record, service) => { const id = service.id.trim(); if (!id) return; record.services.push(id); registry.services.push({ pluginId: record.id, service, source: record.source }); }; const registerCommand = (record, command) => { const name = command.name.trim(); if (!name) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, message: "command registration missing name" }); return; } const result = registerPluginCommand(record.id, command); if (!result.ok) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, message: `command registration failed: ${result.error}` }); return; } record.commands.push(name); registry.commands.push({ pluginId: record.id, command, source: record.source }); }; const registerTypedHook = (record, hookName, handler, opts) => { record.hookCount += 1; registry.typedHooks.push({ pluginId: record.id, hookName, handler, priority: opts?.priority, source: record.source }); }; const normalizeLogger = (logger) => ({ info: logger.info, warn: logger.warn, error: logger.error, debug: logger.debug }); const createApi = (record, params) => { return { id: record.id, name: record.name, version: record.version, description: record.description, source: record.source, config: params.config, pluginConfig: params.pluginConfig, runtime: registryParams.runtime, logger: normalizeLogger(registryParams.logger), registerTool