mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor(security,config): split oversized files (#13182)
refactor(security,config): split oversized files using dot-naming convention - audit-extra.ts (1,199 LOC) -> barrel (31) + sync (559) + async (668) - schema.ts (1,114 LOC) -> schema (353) + field-metadata (729) - Add tmp-refactoring-strategy.md documenting Wave 1-4 plan PR #13182
This commit is contained in:
738
src/config/schema.field-metadata.ts
Normal file
738
src/config/schema.field-metadata.ts
Normal file
@@ -0,0 +1,738 @@
|
||||
export const GROUP_LABELS: Record<string, string> = {
|
||||
wizard: "Wizard",
|
||||
update: "Update",
|
||||
diagnostics: "Diagnostics",
|
||||
logging: "Logging",
|
||||
gateway: "Gateway",
|
||||
nodeHost: "Node Host",
|
||||
agents: "Agents",
|
||||
tools: "Tools",
|
||||
bindings: "Bindings",
|
||||
audio: "Audio",
|
||||
models: "Models",
|
||||
messages: "Messages",
|
||||
commands: "Commands",
|
||||
session: "Session",
|
||||
cron: "Cron",
|
||||
hooks: "Hooks",
|
||||
ui: "UI",
|
||||
browser: "Browser",
|
||||
talk: "Talk",
|
||||
channels: "Messaging Channels",
|
||||
skills: "Skills",
|
||||
plugins: "Plugins",
|
||||
discovery: "Discovery",
|
||||
presence: "Presence",
|
||||
voicewake: "Voice Wake",
|
||||
};
|
||||
|
||||
export const GROUP_ORDER: Record<string, number> = {
|
||||
wizard: 20,
|
||||
update: 25,
|
||||
diagnostics: 27,
|
||||
gateway: 30,
|
||||
nodeHost: 35,
|
||||
agents: 40,
|
||||
tools: 50,
|
||||
bindings: 55,
|
||||
audio: 60,
|
||||
models: 70,
|
||||
messages: 80,
|
||||
commands: 85,
|
||||
session: 90,
|
||||
cron: 100,
|
||||
hooks: 110,
|
||||
ui: 120,
|
||||
browser: 130,
|
||||
talk: 140,
|
||||
channels: 150,
|
||||
skills: 200,
|
||||
plugins: 205,
|
||||
discovery: 210,
|
||||
presence: 220,
|
||||
voicewake: 230,
|
||||
logging: 900,
|
||||
};
|
||||
|
||||
export const FIELD_LABELS: Record<string, string> = {
|
||||
"meta.lastTouchedVersion": "Config Last Touched Version",
|
||||
"meta.lastTouchedAt": "Config Last Touched At",
|
||||
"update.channel": "Update Channel",
|
||||
"update.checkOnStart": "Update Check on Start",
|
||||
"diagnostics.enabled": "Diagnostics Enabled",
|
||||
"diagnostics.flags": "Diagnostics Flags",
|
||||
"diagnostics.otel.enabled": "OpenTelemetry Enabled",
|
||||
"diagnostics.otel.endpoint": "OpenTelemetry Endpoint",
|
||||
"diagnostics.otel.protocol": "OpenTelemetry Protocol",
|
||||
"diagnostics.otel.headers": "OpenTelemetry Headers",
|
||||
"diagnostics.otel.serviceName": "OpenTelemetry Service Name",
|
||||
"diagnostics.otel.traces": "OpenTelemetry Traces Enabled",
|
||||
"diagnostics.otel.metrics": "OpenTelemetry Metrics Enabled",
|
||||
"diagnostics.otel.logs": "OpenTelemetry Logs Enabled",
|
||||
"diagnostics.otel.sampleRate": "OpenTelemetry Trace Sample Rate",
|
||||
"diagnostics.otel.flushIntervalMs": "OpenTelemetry Flush Interval (ms)",
|
||||
"diagnostics.cacheTrace.enabled": "Cache Trace Enabled",
|
||||
"diagnostics.cacheTrace.filePath": "Cache Trace File Path",
|
||||
"diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages",
|
||||
"diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt",
|
||||
"diagnostics.cacheTrace.includeSystem": "Cache Trace Include System",
|
||||
"agents.list.*.identity.avatar": "Identity Avatar",
|
||||
"agents.list.*.skills": "Agent Skill Filter",
|
||||
"gateway.remote.url": "Remote Gateway URL",
|
||||
"gateway.remote.sshTarget": "Remote Gateway SSH Target",
|
||||
"gateway.remote.sshIdentity": "Remote Gateway SSH Identity",
|
||||
"gateway.remote.token": "Remote Gateway Token",
|
||||
"gateway.remote.password": "Remote Gateway Password",
|
||||
"gateway.remote.tlsFingerprint": "Remote Gateway TLS Fingerprint",
|
||||
"gateway.auth.token": "Gateway Token",
|
||||
"gateway.auth.password": "Gateway Password",
|
||||
"tools.media.image.enabled": "Enable Image Understanding",
|
||||
"tools.media.image.maxBytes": "Image Understanding Max Bytes",
|
||||
"tools.media.image.maxChars": "Image Understanding Max Chars",
|
||||
"tools.media.image.prompt": "Image Understanding Prompt",
|
||||
"tools.media.image.timeoutSeconds": "Image Understanding Timeout (sec)",
|
||||
"tools.media.image.attachments": "Image Understanding Attachment Policy",
|
||||
"tools.media.image.models": "Image Understanding Models",
|
||||
"tools.media.image.scope": "Image Understanding Scope",
|
||||
"tools.media.models": "Media Understanding Shared Models",
|
||||
"tools.media.concurrency": "Media Understanding Concurrency",
|
||||
"tools.media.audio.enabled": "Enable Audio Understanding",
|
||||
"tools.media.audio.maxBytes": "Audio Understanding Max Bytes",
|
||||
"tools.media.audio.maxChars": "Audio Understanding Max Chars",
|
||||
"tools.media.audio.prompt": "Audio Understanding Prompt",
|
||||
"tools.media.audio.timeoutSeconds": "Audio Understanding Timeout (sec)",
|
||||
"tools.media.audio.language": "Audio Understanding Language",
|
||||
"tools.media.audio.attachments": "Audio Understanding Attachment Policy",
|
||||
"tools.media.audio.models": "Audio Understanding Models",
|
||||
"tools.media.audio.scope": "Audio Understanding Scope",
|
||||
"tools.media.video.enabled": "Enable Video Understanding",
|
||||
"tools.media.video.maxBytes": "Video Understanding Max Bytes",
|
||||
"tools.media.video.maxChars": "Video Understanding Max Chars",
|
||||
"tools.media.video.prompt": "Video Understanding Prompt",
|
||||
"tools.media.video.timeoutSeconds": "Video Understanding Timeout (sec)",
|
||||
"tools.media.video.attachments": "Video Understanding Attachment Policy",
|
||||
"tools.media.video.models": "Video Understanding Models",
|
||||
"tools.media.video.scope": "Video Understanding Scope",
|
||||
"tools.links.enabled": "Enable Link Understanding",
|
||||
"tools.links.maxLinks": "Link Understanding Max Links",
|
||||
"tools.links.timeoutSeconds": "Link Understanding Timeout (sec)",
|
||||
"tools.links.models": "Link Understanding Models",
|
||||
"tools.links.scope": "Link Understanding Scope",
|
||||
"tools.profile": "Tool Profile",
|
||||
"tools.alsoAllow": "Tool Allowlist Additions",
|
||||
"agents.list[].tools.profile": "Agent Tool Profile",
|
||||
"agents.list[].tools.alsoAllow": "Agent Tool Allowlist Additions",
|
||||
"tools.byProvider": "Tool Policy by Provider",
|
||||
"agents.list[].tools.byProvider": "Agent Tool Policy by Provider",
|
||||
"tools.exec.applyPatch.enabled": "Enable apply_patch",
|
||||
"tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist",
|
||||
"tools.exec.notifyOnExit": "Exec Notify On Exit",
|
||||
"tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)",
|
||||
"tools.exec.host": "Exec Host",
|
||||
"tools.exec.security": "Exec Security",
|
||||
"tools.exec.ask": "Exec Ask",
|
||||
"tools.exec.node": "Exec Node Binding",
|
||||
"tools.exec.pathPrepend": "Exec PATH Prepend",
|
||||
"tools.exec.safeBins": "Exec Safe Bins",
|
||||
"tools.message.allowCrossContextSend": "Allow Cross-Context Messaging",
|
||||
"tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)",
|
||||
"tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)",
|
||||
"tools.message.crossContext.marker.enabled": "Cross-Context Marker",
|
||||
"tools.message.crossContext.marker.prefix": "Cross-Context Marker Prefix",
|
||||
"tools.message.crossContext.marker.suffix": "Cross-Context Marker Suffix",
|
||||
"tools.message.broadcast.enabled": "Enable Message Broadcast",
|
||||
"tools.web.search.enabled": "Enable Web Search Tool",
|
||||
"tools.web.search.provider": "Web Search Provider",
|
||||
"tools.web.search.apiKey": "Brave Search API Key",
|
||||
"tools.web.search.maxResults": "Web Search Max Results",
|
||||
"tools.web.search.timeoutSeconds": "Web Search Timeout (sec)",
|
||||
"tools.web.search.cacheTtlMinutes": "Web Search Cache TTL (min)",
|
||||
"tools.web.fetch.enabled": "Enable Web Fetch Tool",
|
||||
"tools.web.fetch.maxChars": "Web Fetch Max Chars",
|
||||
"tools.web.fetch.timeoutSeconds": "Web Fetch Timeout (sec)",
|
||||
"tools.web.fetch.cacheTtlMinutes": "Web Fetch Cache TTL (min)",
|
||||
"tools.web.fetch.maxRedirects": "Web Fetch Max Redirects",
|
||||
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
|
||||
"gateway.controlUi.basePath": "Control UI Base Path",
|
||||
"gateway.controlUi.root": "Control UI Assets Root",
|
||||
"gateway.controlUi.allowedOrigins": "Control UI Allowed Origins",
|
||||
"gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth",
|
||||
"gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth",
|
||||
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
|
||||
"gateway.reload.mode": "Config Reload Mode",
|
||||
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
|
||||
"gateway.nodes.browser.mode": "Gateway Node Browser Mode",
|
||||
"gateway.nodes.browser.node": "Gateway Node Browser Pin",
|
||||
"gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)",
|
||||
"gateway.nodes.denyCommands": "Gateway Node Denylist",
|
||||
"nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled",
|
||||
"nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles",
|
||||
"skills.load.watch": "Watch Skills",
|
||||
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
|
||||
"agents.defaults.workspace": "Workspace",
|
||||
"agents.defaults.repoRoot": "Repo Root",
|
||||
"agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars",
|
||||
"agents.defaults.envelopeTimezone": "Envelope Timezone",
|
||||
"agents.defaults.envelopeTimestamp": "Envelope Timestamp",
|
||||
"agents.defaults.envelopeElapsed": "Envelope Elapsed",
|
||||
"agents.defaults.memorySearch": "Memory Search",
|
||||
"agents.defaults.memorySearch.enabled": "Enable Memory Search",
|
||||
"agents.defaults.memorySearch.sources": "Memory Search Sources",
|
||||
"agents.defaults.memorySearch.extraPaths": "Extra Memory Paths",
|
||||
"agents.defaults.memorySearch.experimental.sessionMemory":
|
||||
"Memory Search Session Index (Experimental)",
|
||||
"agents.defaults.memorySearch.provider": "Memory Search Provider",
|
||||
"agents.defaults.memorySearch.remote.baseUrl": "Remote Embedding Base URL",
|
||||
"agents.defaults.memorySearch.remote.apiKey": "Remote Embedding API Key",
|
||||
"agents.defaults.memorySearch.remote.headers": "Remote Embedding Headers",
|
||||
"agents.defaults.memorySearch.remote.batch.concurrency": "Remote Batch Concurrency",
|
||||
"agents.defaults.memorySearch.model": "Memory Search Model",
|
||||
"agents.defaults.memorySearch.fallback": "Memory Search Fallback",
|
||||
"agents.defaults.memorySearch.local.modelPath": "Local Embedding Model Path",
|
||||
"agents.defaults.memorySearch.store.path": "Memory Search Index Path",
|
||||
"agents.defaults.memorySearch.store.vector.enabled": "Memory Search Vector Index",
|
||||
"agents.defaults.memorySearch.store.vector.extensionPath": "Memory Search Vector Extension Path",
|
||||
"agents.defaults.memorySearch.chunking.tokens": "Memory Chunk Tokens",
|
||||
"agents.defaults.memorySearch.chunking.overlap": "Memory Chunk Overlap Tokens",
|
||||
"agents.defaults.memorySearch.sync.onSessionStart": "Index on Session Start",
|
||||
"agents.defaults.memorySearch.sync.onSearch": "Index on Search (Lazy)",
|
||||
"agents.defaults.memorySearch.sync.watch": "Watch Memory Files",
|
||||
"agents.defaults.memorySearch.sync.watchDebounceMs": "Memory Watch Debounce (ms)",
|
||||
"agents.defaults.memorySearch.sync.sessions.deltaBytes": "Session Delta Bytes",
|
||||
"agents.defaults.memorySearch.sync.sessions.deltaMessages": "Session Delta Messages",
|
||||
"agents.defaults.memorySearch.query.maxResults": "Memory Search Max Results",
|
||||
"agents.defaults.memorySearch.query.minScore": "Memory Search Min Score",
|
||||
"agents.defaults.memorySearch.query.hybrid.enabled": "Memory Search Hybrid",
|
||||
"agents.defaults.memorySearch.query.hybrid.vectorWeight": "Memory Search Vector Weight",
|
||||
"agents.defaults.memorySearch.query.hybrid.textWeight": "Memory Search Text Weight",
|
||||
"agents.defaults.memorySearch.query.hybrid.candidateMultiplier":
|
||||
"Memory Search Hybrid Candidate Multiplier",
|
||||
"agents.defaults.memorySearch.cache.enabled": "Memory Search Embedding Cache",
|
||||
"agents.defaults.memorySearch.cache.maxEntries": "Memory Search Embedding Cache Max Entries",
|
||||
memory: "Memory",
|
||||
"memory.backend": "Memory Backend",
|
||||
"memory.citations": "Memory Citations Mode",
|
||||
"memory.qmd.command": "QMD Binary",
|
||||
"memory.qmd.includeDefaultMemory": "QMD Include Default Memory",
|
||||
"memory.qmd.paths": "QMD Extra Paths",
|
||||
"memory.qmd.paths.path": "QMD Path",
|
||||
"memory.qmd.paths.pattern": "QMD Path Pattern",
|
||||
"memory.qmd.paths.name": "QMD Path Name",
|
||||
"memory.qmd.sessions.enabled": "QMD Session Indexing",
|
||||
"memory.qmd.sessions.exportDir": "QMD Session Export Directory",
|
||||
"memory.qmd.sessions.retentionDays": "QMD Session Retention (days)",
|
||||
"memory.qmd.update.interval": "QMD Update Interval",
|
||||
"memory.qmd.update.debounceMs": "QMD Update Debounce (ms)",
|
||||
"memory.qmd.update.onBoot": "QMD Update on Startup",
|
||||
"memory.qmd.update.waitForBootSync": "QMD Wait for Boot Sync",
|
||||
"memory.qmd.update.embedInterval": "QMD Embed Interval",
|
||||
"memory.qmd.update.commandTimeoutMs": "QMD Command Timeout (ms)",
|
||||
"memory.qmd.update.updateTimeoutMs": "QMD Update Timeout (ms)",
|
||||
"memory.qmd.update.embedTimeoutMs": "QMD Embed Timeout (ms)",
|
||||
"memory.qmd.limits.maxResults": "QMD Max Results",
|
||||
"memory.qmd.limits.maxSnippetChars": "QMD Max Snippet Chars",
|
||||
"memory.qmd.limits.maxInjectedChars": "QMD Max Injected Chars",
|
||||
"memory.qmd.limits.timeoutMs": "QMD Search Timeout (ms)",
|
||||
"memory.qmd.scope": "QMD Surface Scope",
|
||||
"auth.profiles": "Auth Profiles",
|
||||
"auth.order": "Auth Profile Order",
|
||||
"auth.cooldowns.billingBackoffHours": "Billing Backoff (hours)",
|
||||
"auth.cooldowns.billingBackoffHoursByProvider": "Billing Backoff Overrides",
|
||||
"auth.cooldowns.billingMaxHours": "Billing Backoff Cap (hours)",
|
||||
"auth.cooldowns.failureWindowHours": "Failover Window (hours)",
|
||||
"agents.defaults.models": "Models",
|
||||
"agents.defaults.model.primary": "Primary Model",
|
||||
"agents.defaults.model.fallbacks": "Model Fallbacks",
|
||||
"agents.defaults.imageModel.primary": "Image Model",
|
||||
"agents.defaults.imageModel.fallbacks": "Image Model Fallbacks",
|
||||
"agents.defaults.humanDelay.mode": "Human Delay Mode",
|
||||
"agents.defaults.humanDelay.minMs": "Human Delay Min (ms)",
|
||||
"agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)",
|
||||
"agents.defaults.cliBackends": "CLI Backends",
|
||||
"commands.native": "Native Commands",
|
||||
"commands.nativeSkills": "Native Skill Commands",
|
||||
"commands.text": "Text Commands",
|
||||
"commands.bash": "Allow Bash Chat Command",
|
||||
"commands.bashForegroundMs": "Bash Foreground Window (ms)",
|
||||
"commands.config": "Allow /config",
|
||||
"commands.debug": "Allow /debug",
|
||||
"commands.restart": "Allow Restart",
|
||||
"commands.useAccessGroups": "Use Access Groups",
|
||||
"commands.ownerAllowFrom": "Command Owners",
|
||||
"commands.allowFrom": "Command Access Allowlist",
|
||||
"ui.seamColor": "Accent Color",
|
||||
"ui.assistant.name": "Assistant Name",
|
||||
"ui.assistant.avatar": "Assistant Avatar",
|
||||
"browser.evaluateEnabled": "Browser Evaluate Enabled",
|
||||
"browser.snapshotDefaults": "Browser Snapshot Defaults",
|
||||
"browser.snapshotDefaults.mode": "Browser Snapshot Mode",
|
||||
"browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)",
|
||||
"browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)",
|
||||
"session.dmScope": "DM Session Scope",
|
||||
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
|
||||
"messages.ackReaction": "Ack Reaction Emoji",
|
||||
"messages.ackReactionScope": "Ack Reaction Scope",
|
||||
"messages.inbound.debounceMs": "Inbound Message Debounce (ms)",
|
||||
"talk.apiKey": "Talk API Key",
|
||||
"channels.whatsapp": "WhatsApp",
|
||||
"channels.telegram": "Telegram",
|
||||
"channels.telegram.customCommands": "Telegram Custom Commands",
|
||||
"channels.discord": "Discord",
|
||||
"channels.slack": "Slack",
|
||||
"channels.mattermost": "Mattermost",
|
||||
"channels.signal": "Signal",
|
||||
"channels.imessage": "iMessage",
|
||||
"channels.bluebubbles": "BlueBubbles",
|
||||
"channels.msteams": "MS Teams",
|
||||
"channels.telegram.botToken": "Telegram Bot Token",
|
||||
"channels.telegram.dmPolicy": "Telegram DM Policy",
|
||||
"channels.telegram.streamMode": "Telegram Draft Stream Mode",
|
||||
"channels.telegram.draftChunk.minChars": "Telegram Draft Chunk Min Chars",
|
||||
"channels.telegram.draftChunk.maxChars": "Telegram Draft Chunk Max Chars",
|
||||
"channels.telegram.draftChunk.breakPreference": "Telegram Draft Chunk Break Preference",
|
||||
"channels.telegram.retry.attempts": "Telegram Retry Attempts",
|
||||
"channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)",
|
||||
"channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)",
|
||||
"channels.telegram.retry.jitter": "Telegram Retry Jitter",
|
||||
"channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily",
|
||||
"channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)",
|
||||
"channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons",
|
||||
"channels.whatsapp.dmPolicy": "WhatsApp DM Policy",
|
||||
"channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode",
|
||||
"channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)",
|
||||
"channels.signal.dmPolicy": "Signal DM Policy",
|
||||
"channels.imessage.dmPolicy": "iMessage DM Policy",
|
||||
"channels.bluebubbles.dmPolicy": "BlueBubbles DM Policy",
|
||||
"channels.discord.dm.policy": "Discord DM Policy",
|
||||
"channels.discord.retry.attempts": "Discord Retry Attempts",
|
||||
"channels.discord.retry.minDelayMs": "Discord Retry Min Delay (ms)",
|
||||
"channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)",
|
||||
"channels.discord.retry.jitter": "Discord Retry Jitter",
|
||||
"channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message",
|
||||
"channels.discord.intents.presence": "Discord Presence Intent",
|
||||
"channels.discord.intents.guildMembers": "Discord Guild Members Intent",
|
||||
"channels.discord.pluralkit.enabled": "Discord PluralKit Enabled",
|
||||
"channels.discord.pluralkit.token": "Discord PluralKit Token",
|
||||
"channels.slack.dm.policy": "Slack DM Policy",
|
||||
"channels.slack.allowBots": "Slack Allow Bot Messages",
|
||||
"channels.discord.token": "Discord Bot Token",
|
||||
"channels.slack.botToken": "Slack Bot Token",
|
||||
"channels.slack.appToken": "Slack App Token",
|
||||
"channels.slack.userToken": "Slack User Token",
|
||||
"channels.slack.userTokenReadOnly": "Slack User Token Read Only",
|
||||
"channels.slack.thread.historyScope": "Slack Thread History Scope",
|
||||
"channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance",
|
||||
"channels.mattermost.botToken": "Mattermost Bot Token",
|
||||
"channels.mattermost.baseUrl": "Mattermost Base URL",
|
||||
"channels.mattermost.chatmode": "Mattermost Chat Mode",
|
||||
"channels.mattermost.oncharPrefixes": "Mattermost Onchar Prefixes",
|
||||
"channels.mattermost.requireMention": "Mattermost Require Mention",
|
||||
"channels.signal.account": "Signal Account",
|
||||
"channels.imessage.cliPath": "iMessage CLI Path",
|
||||
"agents.list[].skills": "Agent Skill Filter",
|
||||
"agents.list[].identity.avatar": "Agent Avatar",
|
||||
"discovery.mdns.mode": "mDNS Discovery Mode",
|
||||
"plugins.enabled": "Enable Plugins",
|
||||
"plugins.allow": "Plugin Allowlist",
|
||||
"plugins.deny": "Plugin Denylist",
|
||||
"plugins.load.paths": "Plugin Load Paths",
|
||||
"plugins.slots": "Plugin Slots",
|
||||
"plugins.slots.memory": "Memory Plugin",
|
||||
"plugins.entries": "Plugin Entries",
|
||||
"plugins.entries.*.enabled": "Plugin Enabled",
|
||||
"plugins.entries.*.config": "Plugin Config",
|
||||
"plugins.installs": "Plugin Install Records",
|
||||
"plugins.installs.*.source": "Plugin Install Source",
|
||||
"plugins.installs.*.spec": "Plugin Install Spec",
|
||||
"plugins.installs.*.sourcePath": "Plugin Install Source Path",
|
||||
"plugins.installs.*.installPath": "Plugin Install Path",
|
||||
"plugins.installs.*.version": "Plugin Install Version",
|
||||
"plugins.installs.*.installedAt": "Plugin Install Time",
|
||||
};
|
||||
|
||||
export const FIELD_HELP: Record<string, string> = {
|
||||
"meta.lastTouchedVersion": "Auto-set when OpenClaw writes the config.",
|
||||
"meta.lastTouchedAt": "ISO timestamp of the last config write (auto-set).",
|
||||
"update.channel": 'Update channel for git + npm installs ("stable", "beta", or "dev").',
|
||||
"update.checkOnStart": "Check for npm updates when the gateway starts (default: true).",
|
||||
"gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).",
|
||||
"gateway.remote.tlsFingerprint":
|
||||
"Expected sha256 TLS fingerprint for the remote gateway (pin to avoid MITM).",
|
||||
"gateway.remote.sshTarget":
|
||||
"Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.",
|
||||
"gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).",
|
||||
"agents.list.*.skills":
|
||||
"Optional allowlist of skills for this agent (omit = all skills; empty = no skills).",
|
||||
"agents.list[].skills":
|
||||
"Optional allowlist of skills for this agent (omit = all skills; empty = no skills).",
|
||||
"agents.list[].identity.avatar":
|
||||
"Avatar image path (relative to the agent workspace only) or a remote URL/data URL.",
|
||||
"discovery.mdns.mode":
|
||||
'mDNS broadcast mode ("minimal" default, "full" includes cliPath/sshPort, "off" disables mDNS).',
|
||||
"gateway.auth.token":
|
||||
"Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.",
|
||||
"gateway.auth.password": "Required for Tailscale funnel.",
|
||||
"gateway.controlUi.basePath":
|
||||
"Optional URL prefix where the Control UI is served (e.g. /openclaw).",
|
||||
"gateway.controlUi.root":
|
||||
"Optional filesystem root for Control UI assets (defaults to dist/control-ui).",
|
||||
"gateway.controlUi.allowedOrigins":
|
||||
"Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com).",
|
||||
"gateway.controlUi.allowInsecureAuth":
|
||||
"Allow Control UI auth over insecure HTTP (token-only; not recommended).",
|
||||
"gateway.controlUi.dangerouslyDisableDeviceAuth":
|
||||
"DANGEROUS. Disable Control UI device identity checks (token/password only).",
|
||||
"gateway.http.endpoints.chatCompletions.enabled":
|
||||
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
|
||||
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',
|
||||
"gateway.reload.debounceMs": "Debounce window (ms) before applying config changes.",
|
||||
"gateway.nodes.browser.mode":
|
||||
'Node browser routing ("auto" = pick single connected browser node, "manual" = require node param, "off" = disable).',
|
||||
"gateway.nodes.browser.node": "Pin browser routing to a specific node id or name (optional).",
|
||||
"gateway.nodes.allowCommands":
|
||||
"Extra node.invoke commands to allow beyond the gateway defaults (array of command strings).",
|
||||
"gateway.nodes.denyCommands":
|
||||
"Commands to block even if present in node claims or default allowlist.",
|
||||
"nodeHost.browserProxy.enabled": "Expose the local browser control server via node proxy.",
|
||||
"nodeHost.browserProxy.allowProfiles":
|
||||
"Optional allowlist of browser profile names exposed via the node proxy.",
|
||||
"diagnostics.flags":
|
||||
'Enable targeted diagnostics logs by flag (e.g. ["telegram.http"]). Supports wildcards like "telegram.*" or "*".',
|
||||
"diagnostics.cacheTrace.enabled":
|
||||
"Log cache trace snapshots for embedded agent runs (default: false).",
|
||||
"diagnostics.cacheTrace.filePath":
|
||||
"JSONL output path for cache trace logs (default: $OPENCLAW_STATE_DIR/logs/cache-trace.jsonl).",
|
||||
"diagnostics.cacheTrace.includeMessages":
|
||||
"Include full message payloads in trace output (default: true).",
|
||||
"diagnostics.cacheTrace.includePrompt": "Include prompt text in trace output (default: true).",
|
||||
"diagnostics.cacheTrace.includeSystem": "Include system prompt in trace output (default: true).",
|
||||
"tools.exec.applyPatch.enabled":
|
||||
"Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.",
|
||||
"tools.exec.applyPatch.allowModels":
|
||||
'Optional allowlist of model ids (e.g. "gpt-5.2" or "openai/gpt-5.2").',
|
||||
"tools.exec.notifyOnExit":
|
||||
"When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.",
|
||||
"tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).",
|
||||
"tools.exec.safeBins":
|
||||
"Allow stdin-only safe binaries to run without explicit allowlist entries.",
|
||||
"tools.message.allowCrossContextSend":
|
||||
"Legacy override: allow cross-context sends across all providers.",
|
||||
"tools.message.crossContext.allowWithinProvider":
|
||||
"Allow sends to other channels within the same provider (default: true).",
|
||||
"tools.message.crossContext.allowAcrossProviders":
|
||||
"Allow sends across different providers (default: false).",
|
||||
"tools.message.crossContext.marker.enabled":
|
||||
"Add a visible origin marker when sending cross-context (default: true).",
|
||||
"tools.message.crossContext.marker.prefix":
|
||||
'Text prefix for cross-context markers (supports "{channel}").',
|
||||
"tools.message.crossContext.marker.suffix":
|
||||
'Text suffix for cross-context markers (supports "{channel}").',
|
||||
"tools.message.broadcast.enabled": "Enable broadcast action (default: true).",
|
||||
"tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).",
|
||||
"tools.web.search.provider": 'Search provider ("brave" or "perplexity").',
|
||||
"tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).",
|
||||
"tools.web.search.maxResults": "Default number of results to return (1-10).",
|
||||
"tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.",
|
||||
"tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.",
|
||||
"tools.web.search.perplexity.apiKey":
|
||||
"Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var).",
|
||||
"tools.web.search.perplexity.baseUrl":
|
||||
"Perplexity base URL override (default: https://openrouter.ai/api/v1 or https://api.perplexity.ai).",
|
||||
"tools.web.search.perplexity.model":
|
||||
'Perplexity model override (default: "perplexity/sonar-pro").',
|
||||
"tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).",
|
||||
"tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).",
|
||||
"tools.web.fetch.maxCharsCap":
|
||||
"Hard cap for web_fetch maxChars (applies to config and tool calls).",
|
||||
"tools.web.fetch.timeoutSeconds": "Timeout in seconds for web_fetch requests.",
|
||||
"tools.web.fetch.cacheTtlMinutes": "Cache TTL in minutes for web_fetch results.",
|
||||
"tools.web.fetch.maxRedirects": "Maximum redirects allowed for web_fetch (default: 3).",
|
||||
"tools.web.fetch.userAgent": "Override User-Agent header for web_fetch requests.",
|
||||
"tools.web.fetch.readability":
|
||||
"Use Readability to extract main content from HTML (fallbacks to basic HTML cleanup).",
|
||||
"tools.web.fetch.firecrawl.enabled": "Enable Firecrawl fallback for web_fetch (if configured).",
|
||||
"tools.web.fetch.firecrawl.apiKey": "Firecrawl API key (fallback: FIRECRAWL_API_KEY env var).",
|
||||
"tools.web.fetch.firecrawl.baseUrl":
|
||||
"Firecrawl base URL (e.g. https://api.firecrawl.dev or custom endpoint).",
|
||||
"tools.web.fetch.firecrawl.onlyMainContent":
|
||||
"When true, Firecrawl returns only the main content (default: true).",
|
||||
"tools.web.fetch.firecrawl.maxAgeMs":
|
||||
"Firecrawl maxAge (ms) for cached results when supported by the API.",
|
||||
"tools.web.fetch.firecrawl.timeoutSeconds": "Timeout in seconds for Firecrawl requests.",
|
||||
"channels.slack.allowBots":
|
||||
"Allow bot-authored messages to trigger Slack replies (default: false).",
|
||||
"channels.slack.thread.historyScope":
|
||||
'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).',
|
||||
"channels.slack.thread.inheritParent":
|
||||
"If true, Slack thread sessions inherit the parent channel transcript (default: false).",
|
||||
"channels.mattermost.botToken":
|
||||
"Bot token from Mattermost System Console -> Integrations -> Bot Accounts.",
|
||||
"channels.mattermost.baseUrl":
|
||||
"Base URL for your Mattermost server (e.g., https://chat.example.com).",
|
||||
"channels.mattermost.chatmode":
|
||||
'Reply to channel messages on mention ("oncall"), on trigger chars (">" or "!") ("onchar"), or on every message ("onmessage").',
|
||||
"channels.mattermost.oncharPrefixes": 'Trigger prefixes for onchar mode (default: [">", "!"]).',
|
||||
"channels.mattermost.requireMention":
|
||||
"Require @mention in channels before responding (default: true).",
|
||||
"auth.profiles": "Named auth profiles (provider + mode + optional email).",
|
||||
"auth.order": "Ordered auth profile IDs per provider (used for automatic failover).",
|
||||
"auth.cooldowns.billingBackoffHours":
|
||||
"Base backoff (hours) when a profile fails due to billing/insufficient credits (default: 5).",
|
||||
"auth.cooldowns.billingBackoffHoursByProvider":
|
||||
"Optional per-provider overrides for billing backoff (hours).",
|
||||
"auth.cooldowns.billingMaxHours": "Cap (hours) for billing backoff (default: 24).",
|
||||
"auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).",
|
||||
"agents.defaults.bootstrapMaxChars":
|
||||
"Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).",
|
||||
"agents.defaults.repoRoot":
|
||||
"Optional repository root shown in the system prompt runtime line (overrides auto-detect).",
|
||||
"agents.defaults.envelopeTimezone":
|
||||
'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).',
|
||||
"agents.defaults.envelopeTimestamp":
|
||||
'Include absolute timestamps in message envelopes ("on" or "off").',
|
||||
"agents.defaults.envelopeElapsed": 'Include elapsed time in message envelopes ("on" or "off").',
|
||||
"agents.defaults.models": "Configured model catalog (keys are full provider/model IDs).",
|
||||
"agents.defaults.memorySearch":
|
||||
"Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).",
|
||||
"agents.defaults.memorySearch.sources":
|
||||
'Sources to index for memory search (default: ["memory"]; add "sessions" to include session transcripts).',
|
||||
"agents.defaults.memorySearch.extraPaths":
|
||||
"Extra paths to include in memory search (directories or .md files; relative paths resolved from workspace).",
|
||||
"agents.defaults.memorySearch.experimental.sessionMemory":
|
||||
"Enable experimental session transcript indexing for memory search (default: false).",
|
||||
"agents.defaults.memorySearch.provider":
|
||||
'Embedding provider ("openai", "gemini", "voyage", or "local").',
|
||||
"agents.defaults.memorySearch.remote.baseUrl":
|
||||
"Custom base URL for remote embeddings (OpenAI-compatible proxies or Gemini overrides).",
|
||||
"agents.defaults.memorySearch.remote.apiKey": "Custom API key for the remote embedding provider.",
|
||||
"agents.defaults.memorySearch.remote.headers":
|
||||
"Extra headers for remote embeddings (merged; remote overrides OpenAI headers).",
|
||||
"agents.defaults.memorySearch.remote.batch.enabled":
|
||||
"Enable batch API for memory embeddings (OpenAI/Gemini/Voyage; default: false).",
|
||||
"agents.defaults.memorySearch.remote.batch.wait":
|
||||
"Wait for batch completion when indexing (default: true).",
|
||||
"agents.defaults.memorySearch.remote.batch.concurrency":
|
||||
"Max concurrent embedding batch jobs for memory indexing (default: 2).",
|
||||
"agents.defaults.memorySearch.remote.batch.pollIntervalMs":
|
||||
"Polling interval in ms for batch status (default: 2000).",
|
||||
"agents.defaults.memorySearch.remote.batch.timeoutMinutes":
|
||||
"Timeout in minutes for batch indexing (default: 60).",
|
||||
"agents.defaults.memorySearch.local.modelPath":
|
||||
"Local GGUF model path or hf: URI (node-llama-cpp).",
|
||||
"agents.defaults.memorySearch.fallback":
|
||||
'Fallback provider when embeddings fail ("openai", "gemini", "local", or "none").',
|
||||
"agents.defaults.memorySearch.store.path":
|
||||
"SQLite index path (default: ~/.openclaw/memory/{agentId}.sqlite).",
|
||||
"agents.defaults.memorySearch.store.vector.enabled":
|
||||
"Enable sqlite-vec extension for vector search (default: true).",
|
||||
"agents.defaults.memorySearch.store.vector.extensionPath":
|
||||
"Optional override path to sqlite-vec extension library (.dylib/.so/.dll).",
|
||||
"agents.defaults.memorySearch.query.hybrid.enabled":
|
||||
"Enable hybrid BM25 + vector search for memory (default: true).",
|
||||
"agents.defaults.memorySearch.query.hybrid.vectorWeight":
|
||||
"Weight for vector similarity when merging results (0-1).",
|
||||
"agents.defaults.memorySearch.query.hybrid.textWeight":
|
||||
"Weight for BM25 text relevance when merging results (0-1).",
|
||||
"agents.defaults.memorySearch.query.hybrid.candidateMultiplier":
|
||||
"Multiplier for candidate pool size (default: 4).",
|
||||
"agents.defaults.memorySearch.cache.enabled":
|
||||
"Cache chunk embeddings in SQLite to speed up reindexing and frequent updates (default: true).",
|
||||
memory: "Memory backend configuration (global).",
|
||||
"memory.backend": 'Memory backend ("builtin" for OpenClaw embeddings, "qmd" for QMD sidecar).',
|
||||
"memory.citations": 'Default citation behavior ("auto", "on", or "off").',
|
||||
"memory.qmd.command": "Path to the qmd binary (default: resolves from PATH).",
|
||||
"memory.qmd.includeDefaultMemory":
|
||||
"Whether to automatically index MEMORY.md + memory/**/*.md (default: true).",
|
||||
"memory.qmd.paths":
|
||||
"Additional directories/files to index with QMD (path + optional glob pattern).",
|
||||
"memory.qmd.paths.path": "Absolute or ~-relative path to index via QMD.",
|
||||
"memory.qmd.paths.pattern": "Glob pattern relative to the path root (default: **/*.md).",
|
||||
"memory.qmd.paths.name":
|
||||
"Optional stable name for the QMD collection (default derived from path).",
|
||||
"memory.qmd.sessions.enabled":
|
||||
"Enable QMD session transcript indexing (experimental, default: false).",
|
||||
"memory.qmd.sessions.exportDir":
|
||||
"Override directory for sanitized session exports before indexing.",
|
||||
"memory.qmd.sessions.retentionDays":
|
||||
"Retention window for exported sessions before pruning (default: unlimited).",
|
||||
"memory.qmd.update.interval":
|
||||
"How often the QMD sidecar refreshes indexes (duration string, default: 5m).",
|
||||
"memory.qmd.update.debounceMs":
|
||||
"Minimum delay between successive QMD refresh runs (default: 15000).",
|
||||
"memory.qmd.update.onBoot": "Run QMD update once on gateway startup (default: true).",
|
||||
"memory.qmd.update.waitForBootSync":
|
||||
"Block startup until the boot QMD refresh finishes (default: false).",
|
||||
"memory.qmd.update.embedInterval":
|
||||
"How often QMD embeddings are refreshed (duration string, default: 60m). Set to 0 to disable periodic embed.",
|
||||
"memory.qmd.update.commandTimeoutMs":
|
||||
"Timeout for QMD maintenance commands like collection list/add (default: 30000).",
|
||||
"memory.qmd.update.updateTimeoutMs": "Timeout for `qmd update` runs (default: 120000).",
|
||||
"memory.qmd.update.embedTimeoutMs": "Timeout for `qmd embed` runs (default: 120000).",
|
||||
"memory.qmd.limits.maxResults": "Max QMD results returned to the agent loop (default: 6).",
|
||||
"memory.qmd.limits.maxSnippetChars": "Max characters per snippet pulled from QMD (default: 700).",
|
||||
"memory.qmd.limits.maxInjectedChars": "Max total characters injected from QMD hits per turn.",
|
||||
"memory.qmd.limits.timeoutMs": "Per-query timeout for QMD searches (default: 4000).",
|
||||
"memory.qmd.scope":
|
||||
"Session/channel scope for QMD recall (same syntax as session.sendPolicy; default: direct-only).",
|
||||
"agents.defaults.memorySearch.cache.maxEntries":
|
||||
"Optional cap on cached embeddings (best-effort).",
|
||||
"agents.defaults.memorySearch.sync.onSearch":
|
||||
"Lazy sync: schedule a reindex on search after changes.",
|
||||
"agents.defaults.memorySearch.sync.watch": "Watch memory files for changes (chokidar).",
|
||||
"agents.defaults.memorySearch.sync.sessions.deltaBytes":
|
||||
"Minimum appended bytes before session transcripts trigger reindex (default: 100000).",
|
||||
"agents.defaults.memorySearch.sync.sessions.deltaMessages":
|
||||
"Minimum appended JSONL lines before session transcripts trigger reindex (default: 50).",
|
||||
"plugins.enabled": "Enable plugin/extension loading (default: true).",
|
||||
"plugins.allow": "Optional allowlist of plugin ids; when set, only listed plugins load.",
|
||||
"plugins.deny": "Optional denylist of plugin ids; deny wins over allowlist.",
|
||||
"plugins.load.paths": "Additional plugin files or directories to load.",
|
||||
"plugins.slots": "Select which plugins own exclusive slots (memory, etc.).",
|
||||
"plugins.slots.memory":
|
||||
'Select the active memory plugin by id, or "none" to disable memory plugins.',
|
||||
"plugins.entries": "Per-plugin settings keyed by plugin id (enable/disable + config payloads).",
|
||||
"plugins.entries.*.enabled": "Overrides plugin enable/disable for this entry (restart required).",
|
||||
"plugins.entries.*.config": "Plugin-defined config payload (schema is provided by the plugin).",
|
||||
"plugins.installs":
|
||||
"CLI-managed install metadata (used by `openclaw plugins update` to locate install sources).",
|
||||
"plugins.installs.*.source": 'Install source ("npm", "archive", or "path").',
|
||||
"plugins.installs.*.spec": "Original npm spec used for install (if source is npm).",
|
||||
"plugins.installs.*.sourcePath": "Original archive/path used for install (if any).",
|
||||
"plugins.installs.*.installPath":
|
||||
"Resolved install directory (usually ~/.openclaw/extensions/<id>).",
|
||||
"plugins.installs.*.version": "Version recorded at install time (if available).",
|
||||
"plugins.installs.*.installedAt": "ISO timestamp of last install/update.",
|
||||
"agents.list.*.identity.avatar":
|
||||
"Agent avatar (workspace-relative path, http(s) URL, or data URI).",
|
||||
"agents.defaults.model.primary": "Primary model (provider/model).",
|
||||
"agents.defaults.model.fallbacks":
|
||||
"Ordered fallback models (provider/model). Used when the primary model fails.",
|
||||
"agents.defaults.imageModel.primary":
|
||||
"Optional image model (provider/model) used when the primary model lacks image input.",
|
||||
"agents.defaults.imageModel.fallbacks": "Ordered fallback image models (provider/model).",
|
||||
"agents.defaults.cliBackends": "Optional CLI backends for text-only fallback (claude-cli, etc.).",
|
||||
"agents.defaults.humanDelay.mode": 'Delay style for block replies ("off", "natural", "custom").',
|
||||
"agents.defaults.humanDelay.minMs": "Minimum delay in ms for custom humanDelay (default: 800).",
|
||||
"agents.defaults.humanDelay.maxMs": "Maximum delay in ms for custom humanDelay (default: 2500).",
|
||||
"commands.native":
|
||||
"Register native commands with channels that support it (Discord/Slack/Telegram).",
|
||||
"commands.nativeSkills":
|
||||
"Register native skill commands (user-invocable skills) with channels that support it.",
|
||||
"commands.text": "Allow text command parsing (slash commands only).",
|
||||
"commands.bash":
|
||||
"Allow bash chat command (`!`; `/bash` alias) to run host shell commands (default: false; requires tools.elevated).",
|
||||
"commands.bashForegroundMs":
|
||||
"How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately).",
|
||||
"commands.config": "Allow /config chat command to read/write config on disk (default: false).",
|
||||
"commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).",
|
||||
"commands.restart": "Allow /restart and gateway restart tool actions (default: false).",
|
||||
"commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.",
|
||||
"commands.ownerAllowFrom":
|
||||
"Explicit owner allowlist for owner-only tools/commands. Use channel-native IDs (optionally prefixed like \"whatsapp:+15551234567\"). '*' is ignored.",
|
||||
"commands.allowFrom":
|
||||
'Per-provider allowlist restricting who can use slash commands. If set, overrides the channel\'s allowFrom for command authorization. Use \'*\' key for global default; provider-specific keys (e.g. \'discord\') override the global. Example: { "*": ["user1"], "discord": ["user:123"] }.',
|
||||
"session.dmScope":
|
||||
'DM session scoping: "main" keeps continuity; "per-peer", "per-channel-peer", or "per-account-channel-peer" isolates DM history (recommended for shared inboxes/multi-account).',
|
||||
"session.identityLinks":
|
||||
"Map canonical identities to provider-prefixed peer IDs for DM session linking (example: telegram:123456).",
|
||||
"channels.telegram.configWrites":
|
||||
"Allow Telegram to write config in response to channel events/commands (default: true).",
|
||||
"channels.slack.configWrites":
|
||||
"Allow Slack to write config in response to channel events/commands (default: true).",
|
||||
"channels.mattermost.configWrites":
|
||||
"Allow Mattermost to write config in response to channel events/commands (default: true).",
|
||||
"channels.discord.configWrites":
|
||||
"Allow Discord to write config in response to channel events/commands (default: true).",
|
||||
"channels.whatsapp.configWrites":
|
||||
"Allow WhatsApp to write config in response to channel events/commands (default: true).",
|
||||
"channels.signal.configWrites":
|
||||
"Allow Signal to write config in response to channel events/commands (default: true).",
|
||||
"channels.imessage.configWrites":
|
||||
"Allow iMessage to write config in response to channel events/commands (default: true).",
|
||||
"channels.msteams.configWrites":
|
||||
"Allow Microsoft Teams to write config in response to channel events/commands (default: true).",
|
||||
"channels.discord.commands.native": 'Override native commands for Discord (bool or "auto").',
|
||||
"channels.discord.commands.nativeSkills":
|
||||
'Override native skill commands for Discord (bool or "auto").',
|
||||
"channels.telegram.commands.native": 'Override native commands for Telegram (bool or "auto").',
|
||||
"channels.telegram.commands.nativeSkills":
|
||||
'Override native skill commands for Telegram (bool or "auto").',
|
||||
"channels.slack.commands.native": 'Override native commands for Slack (bool or "auto").',
|
||||
"channels.slack.commands.nativeSkills":
|
||||
'Override native skill commands for Slack (bool or "auto").',
|
||||
"session.agentToAgent.maxPingPongTurns":
|
||||
"Max reply-back turns between requester and target (0–5).",
|
||||
"channels.telegram.customCommands":
|
||||
"Additional Telegram bot menu commands (merged with native; conflicts ignored).",
|
||||
"messages.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).",
|
||||
"messages.ackReactionScope":
|
||||
'When to send ack reactions ("group-mentions", "group-all", "direct", "all").',
|
||||
"messages.inbound.debounceMs":
|
||||
"Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).",
|
||||
"channels.telegram.dmPolicy":
|
||||
'Direct message access control ("pairing" recommended). "open" requires channels.telegram.allowFrom=["*"].',
|
||||
"channels.telegram.streamMode":
|
||||
"Draft streaming mode for Telegram replies (off | partial | block). Separate from block streaming; requires private topics + sendMessageDraft.",
|
||||
"channels.telegram.draftChunk.minChars":
|
||||
'Minimum chars before emitting a Telegram draft update when channels.telegram.streamMode="block" (default: 200).',
|
||||
"channels.telegram.draftChunk.maxChars":
|
||||
'Target max size for a Telegram draft update chunk when channels.telegram.streamMode="block" (default: 800; clamped to channels.telegram.textChunkLimit).',
|
||||
"channels.telegram.draftChunk.breakPreference":
|
||||
"Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence). Default: paragraph.",
|
||||
"channels.telegram.retry.attempts":
|
||||
"Max retry attempts for outbound Telegram API calls (default: 3).",
|
||||
"channels.telegram.retry.minDelayMs": "Minimum retry delay in ms for Telegram outbound calls.",
|
||||
"channels.telegram.retry.maxDelayMs":
|
||||
"Maximum retry delay cap in ms for Telegram outbound calls.",
|
||||
"channels.telegram.retry.jitter": "Jitter factor (0-1) applied to Telegram retry delays.",
|
||||
"channels.telegram.network.autoSelectFamily":
|
||||
"Override Node autoSelectFamily for Telegram (true=enable, false=disable).",
|
||||
"channels.telegram.timeoutSeconds":
|
||||
"Max seconds before Telegram API requests are aborted (default: 500 per grammY).",
|
||||
"channels.whatsapp.dmPolicy":
|
||||
'Direct message access control ("pairing" recommended). "open" requires channels.whatsapp.allowFrom=["*"].',
|
||||
"channels.whatsapp.selfChatMode": "Same-phone setup (bot uses your personal WhatsApp number).",
|
||||
"channels.whatsapp.debounceMs":
|
||||
"Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable).",
|
||||
"channels.signal.dmPolicy":
|
||||
'Direct message access control ("pairing" recommended). "open" requires channels.signal.allowFrom=["*"].',
|
||||
"channels.imessage.dmPolicy":
|
||||
'Direct message access control ("pairing" recommended). "open" requires channels.imessage.allowFrom=["*"].',
|
||||
"channels.bluebubbles.dmPolicy":
|
||||
'Direct message access control ("pairing" recommended). "open" requires channels.bluebubbles.allowFrom=["*"].',
|
||||
"channels.discord.dm.policy":
|
||||
'Direct message access control ("pairing" recommended). "open" requires channels.discord.dm.allowFrom=["*"].',
|
||||
"channels.discord.retry.attempts":
|
||||
"Max retry attempts for outbound Discord API calls (default: 3).",
|
||||
"channels.discord.retry.minDelayMs": "Minimum retry delay in ms for Discord outbound calls.",
|
||||
"channels.discord.retry.maxDelayMs": "Maximum retry delay cap in ms for Discord outbound calls.",
|
||||
"channels.discord.retry.jitter": "Jitter factor (0-1) applied to Discord retry delays.",
|
||||
"channels.discord.maxLinesPerMessage": "Soft max line count per Discord message (default: 17).",
|
||||
"channels.discord.intents.presence":
|
||||
"Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.",
|
||||
"channels.discord.intents.guildMembers":
|
||||
"Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.",
|
||||
"channels.discord.pluralkit.enabled":
|
||||
"Resolve PluralKit proxied messages and treat system members as distinct senders.",
|
||||
"channels.discord.pluralkit.token":
|
||||
"Optional PluralKit token for resolving private systems or members.",
|
||||
"channels.slack.dm.policy":
|
||||
'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].',
|
||||
};
|
||||
|
||||
export const FIELD_PLACEHOLDERS: Record<string, string> = {
|
||||
"gateway.remote.url": "ws://host:18789",
|
||||
"gateway.remote.tlsFingerprint": "sha256:ab12cd34…",
|
||||
"gateway.remote.sshTarget": "user@host",
|
||||
"gateway.controlUi.basePath": "/openclaw",
|
||||
"gateway.controlUi.root": "dist/control-ui",
|
||||
"gateway.controlUi.allowedOrigins": "https://control.example.com",
|
||||
"channels.mattermost.baseUrl": "https://chat.example.com",
|
||||
"agents.list[].identity.avatar": "avatars/openclaw.png",
|
||||
};
|
||||
|
||||
export const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i];
|
||||
|
||||
export function isSensitivePath(path: string): boolean {
|
||||
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(path));
|
||||
}
|
||||
720
src/security/audit-extra.async.ts
Normal file
720
src/security/audit-extra.async.ts
Normal file
@@ -0,0 +1,720 @@
|
||||
/**
|
||||
* Asynchronous security audit collector functions.
|
||||
*
|
||||
* These functions perform I/O (filesystem, config reads) to detect security issues.
|
||||
*/
|
||||
import JSON5 from "json5";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js";
|
||||
import type { ExecFn } from "./windows-acl.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { loadWorkspaceSkillEntries } from "../agents/skills.js";
|
||||
import { MANIFEST_KEY } from "../compat/legacy-names.js";
|
||||
import { resolveNativeSkillsEnabled } from "../config/commands.js";
|
||||
import { createConfigIO } from "../config/config.js";
|
||||
import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js";
|
||||
import { resolveOAuthDir } from "../config/paths.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import {
|
||||
formatPermissionDetail,
|
||||
formatPermissionRemediation,
|
||||
inspectPathPermissions,
|
||||
safeStat,
|
||||
} from "./audit-fs.js";
|
||||
import { scanDirectoryWithSummary, type SkillScanFinding } from "./skill-scanner.js";
|
||||
|
||||
export type SecurityAuditFinding = {
|
||||
checkId: string;
|
||||
severity: "info" | "warn" | "critical";
|
||||
title: string;
|
||||
detail: string;
|
||||
remediation?: string;
|
||||
};
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
function expandTilde(p: string, env: NodeJS.ProcessEnv): string | null {
|
||||
if (!p.startsWith("~")) {
|
||||
return p;
|
||||
}
|
||||
const home = typeof env.HOME === "string" && env.HOME.trim() ? env.HOME.trim() : null;
|
||||
if (!home) {
|
||||
return null;
|
||||
}
|
||||
if (p === "~") {
|
||||
return home;
|
||||
}
|
||||
if (p.startsWith("~/") || p.startsWith("~\\")) {
|
||||
return path.join(home, p.slice(2));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveIncludePath(baseConfigPath: string, includePath: string): string {
|
||||
return path.normalize(
|
||||
path.isAbsolute(includePath)
|
||||
? includePath
|
||||
: path.resolve(path.dirname(baseConfigPath), includePath),
|
||||
);
|
||||
}
|
||||
|
||||
function listDirectIncludes(parsed: unknown): string[] {
|
||||
const out: string[] = [];
|
||||
const visit = (value: unknown) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
visit(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
const rec = value as Record<string, unknown>;
|
||||
const includeVal = rec[INCLUDE_KEY];
|
||||
if (typeof includeVal === "string") {
|
||||
out.push(includeVal);
|
||||
} else if (Array.isArray(includeVal)) {
|
||||
for (const item of includeVal) {
|
||||
if (typeof item === "string") {
|
||||
out.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const v of Object.values(rec)) {
|
||||
visit(v);
|
||||
}
|
||||
};
|
||||
visit(parsed);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function collectIncludePathsRecursive(params: {
|
||||
configPath: string;
|
||||
parsed: unknown;
|
||||
}): Promise<string[]> {
|
||||
const visited = new Set<string>();
|
||||
const result: string[] = [];
|
||||
|
||||
const walk = async (basePath: string, parsed: unknown, depth: number): Promise<void> => {
|
||||
if (depth > MAX_INCLUDE_DEPTH) {
|
||||
return;
|
||||
}
|
||||
for (const raw of listDirectIncludes(parsed)) {
|
||||
const resolved = resolveIncludePath(basePath, raw);
|
||||
if (visited.has(resolved)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(resolved);
|
||||
result.push(resolved);
|
||||
const rawText = await fs.readFile(resolved, "utf-8").catch(() => null);
|
||||
if (!rawText) {
|
||||
continue;
|
||||
}
|
||||
const nestedParsed = (() => {
|
||||
try {
|
||||
return JSON5.parse(rawText);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (nestedParsed) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await walk(resolved, nestedParsed, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walk(params.configPath, params.parsed, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
function isPathInside(basePath: string, candidatePath: string): boolean {
|
||||
const base = path.resolve(basePath);
|
||||
const candidate = path.resolve(candidatePath);
|
||||
const rel = path.relative(base, candidate);
|
||||
return rel === "" || (!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel));
|
||||
}
|
||||
|
||||
function extensionUsesSkippedScannerPath(entry: string): boolean {
|
||||
const segments = entry.split(/[\\/]+/).filter(Boolean);
|
||||
return segments.some(
|
||||
(segment) =>
|
||||
segment === "node_modules" ||
|
||||
(segment.startsWith(".") && segment !== "." && segment !== ".."),
|
||||
);
|
||||
}
|
||||
|
||||
async function readPluginManifestExtensions(pluginPath: string): Promise<string[]> {
|
||||
const manifestPath = path.join(pluginPath, "package.json");
|
||||
const raw = await fs.readFile(manifestPath, "utf-8").catch(() => "");
|
||||
if (!raw.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<
|
||||
Record<typeof MANIFEST_KEY, { extensions?: unknown }>
|
||||
> | null;
|
||||
const extensions = parsed?.[MANIFEST_KEY]?.extensions;
|
||||
if (!Array.isArray(extensions)) {
|
||||
return [];
|
||||
}
|
||||
return extensions.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
|
||||
}
|
||||
|
||||
function listWorkspaceDirs(cfg: OpenClawConfig): string[] {
|
||||
const dirs = new Set<string>();
|
||||
const list = cfg.agents?.list;
|
||||
if (Array.isArray(list)) {
|
||||
for (const entry of list) {
|
||||
if (entry && typeof entry === "object" && typeof entry.id === "string") {
|
||||
dirs.add(resolveAgentWorkspaceDir(cfg, entry.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
dirs.add(resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)));
|
||||
return [...dirs];
|
||||
}
|
||||
|
||||
function formatCodeSafetyDetails(findings: SkillScanFinding[], rootDir: string): string {
|
||||
return findings
|
||||
.map((finding) => {
|
||||
const relPath = path.relative(rootDir, finding.file);
|
||||
const filePath =
|
||||
relPath && relPath !== "." && !relPath.startsWith("..")
|
||||
? relPath
|
||||
: path.basename(finding.file);
|
||||
const normalizedPath = filePath.replaceAll("\\", "/");
|
||||
return ` - [${finding.ruleId}] ${finding.message} (${normalizedPath}:${finding.line})`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Exported collectors
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export async function collectPluginsTrustFindings(params: {
|
||||
cfg: OpenClawConfig;
|
||||
stateDir: string;
|
||||
}): Promise<SecurityAuditFinding[]> {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const extensionsDir = path.join(params.stateDir, "extensions");
|
||||
const st = await safeStat(extensionsDir);
|
||||
if (!st.ok || !st.isDir) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(extensionsDir, { withFileTypes: true }).catch(() => []);
|
||||
const pluginDirs = entries
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name)
|
||||
.filter(Boolean);
|
||||
if (pluginDirs.length === 0) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const allow = params.cfg.plugins?.allow;
|
||||
const allowConfigured = Array.isArray(allow) && allow.length > 0;
|
||||
if (!allowConfigured) {
|
||||
const hasString = (value: unknown) => typeof value === "string" && value.trim().length > 0;
|
||||
const hasAccountStringKey = (account: unknown, key: string) =>
|
||||
Boolean(
|
||||
account &&
|
||||
typeof account === "object" &&
|
||||
hasString((account as Record<string, unknown>)[key]),
|
||||
);
|
||||
|
||||
const discordConfigured =
|
||||
hasString(params.cfg.channels?.discord?.token) ||
|
||||
Boolean(
|
||||
params.cfg.channels?.discord?.accounts &&
|
||||
Object.values(params.cfg.channels.discord.accounts).some((a) =>
|
||||
hasAccountStringKey(a, "token"),
|
||||
),
|
||||
) ||
|
||||
hasString(process.env.DISCORD_BOT_TOKEN);
|
||||
|
||||
const telegramConfigured =
|
||||
hasString(params.cfg.channels?.telegram?.botToken) ||
|
||||
hasString(params.cfg.channels?.telegram?.tokenFile) ||
|
||||
Boolean(
|
||||
params.cfg.channels?.telegram?.accounts &&
|
||||
Object.values(params.cfg.channels.telegram.accounts).some(
|
||||
(a) => hasAccountStringKey(a, "botToken") || hasAccountStringKey(a, "tokenFile"),
|
||||
),
|
||||
) ||
|
||||
hasString(process.env.TELEGRAM_BOT_TOKEN);
|
||||
|
||||
const slackConfigured =
|
||||
hasString(params.cfg.channels?.slack?.botToken) ||
|
||||
hasString(params.cfg.channels?.slack?.appToken) ||
|
||||
Boolean(
|
||||
params.cfg.channels?.slack?.accounts &&
|
||||
Object.values(params.cfg.channels.slack.accounts).some(
|
||||
(a) => hasAccountStringKey(a, "botToken") || hasAccountStringKey(a, "appToken"),
|
||||
),
|
||||
) ||
|
||||
hasString(process.env.SLACK_BOT_TOKEN) ||
|
||||
hasString(process.env.SLACK_APP_TOKEN);
|
||||
|
||||
const skillCommandsLikelyExposed =
|
||||
(discordConfigured &&
|
||||
resolveNativeSkillsEnabled({
|
||||
providerId: "discord",
|
||||
providerSetting: params.cfg.channels?.discord?.commands?.nativeSkills,
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
})) ||
|
||||
(telegramConfigured &&
|
||||
resolveNativeSkillsEnabled({
|
||||
providerId: "telegram",
|
||||
providerSetting: params.cfg.channels?.telegram?.commands?.nativeSkills,
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
})) ||
|
||||
(slackConfigured &&
|
||||
resolveNativeSkillsEnabled({
|
||||
providerId: "slack",
|
||||
providerSetting: params.cfg.channels?.slack?.commands?.nativeSkills,
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
}));
|
||||
|
||||
findings.push({
|
||||
checkId: "plugins.extensions_no_allowlist",
|
||||
severity: skillCommandsLikelyExposed ? "critical" : "warn",
|
||||
title: "Extensions exist but plugins.allow is not set",
|
||||
detail:
|
||||
`Found ${pluginDirs.length} extension(s) under ${extensionsDir}. Without plugins.allow, any discovered plugin id may load (depending on config and plugin behavior).` +
|
||||
(skillCommandsLikelyExposed
|
||||
? "\nNative skill commands are enabled on at least one configured chat surface; treat unpinned/unallowlisted extensions as high risk."
|
||||
: ""),
|
||||
remediation: "Set plugins.allow to an explicit list of plugin ids you trust.",
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
export async function collectIncludeFilePermFindings(params: {
|
||||
configSnapshot: ConfigFileSnapshot;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
platform?: NodeJS.Platform;
|
||||
execIcacls?: ExecFn;
|
||||
}): Promise<SecurityAuditFinding[]> {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
if (!params.configSnapshot.exists) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const configPath = params.configSnapshot.path;
|
||||
const includePaths = await collectIncludePathsRecursive({
|
||||
configPath,
|
||||
parsed: params.configSnapshot.parsed,
|
||||
});
|
||||
if (includePaths.length === 0) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
for (const p of includePaths) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const perms = await inspectPathPermissions(p, {
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
exec: params.execIcacls,
|
||||
});
|
||||
if (!perms.ok) {
|
||||
continue;
|
||||
}
|
||||
if (perms.worldWritable || perms.groupWritable) {
|
||||
findings.push({
|
||||
checkId: "fs.config_include.perms_writable",
|
||||
severity: "critical",
|
||||
title: "Config include file is writable by others",
|
||||
detail: `${formatPermissionDetail(p, perms)}; another user could influence your effective config.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: p,
|
||||
perms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
} else if (perms.worldReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.config_include.perms_world_readable",
|
||||
severity: "critical",
|
||||
title: "Config include file is world-readable",
|
||||
detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: p,
|
||||
perms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
} else if (perms.groupReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.config_include.perms_group_readable",
|
||||
severity: "warn",
|
||||
title: "Config include file is group-readable",
|
||||
detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: p,
|
||||
perms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
export async function collectStateDeepFilesystemFindings(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
stateDir: string;
|
||||
platform?: NodeJS.Platform;
|
||||
execIcacls?: ExecFn;
|
||||
}): Promise<SecurityAuditFinding[]> {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const oauthDir = resolveOAuthDir(params.env, params.stateDir);
|
||||
|
||||
const oauthPerms = await inspectPathPermissions(oauthDir, {
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
exec: params.execIcacls,
|
||||
});
|
||||
if (oauthPerms.ok && oauthPerms.isDir) {
|
||||
if (oauthPerms.worldWritable || oauthPerms.groupWritable) {
|
||||
findings.push({
|
||||
checkId: "fs.credentials_dir.perms_writable",
|
||||
severity: "critical",
|
||||
title: "Credentials dir is writable by others",
|
||||
detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; another user could drop/modify credential files.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: oauthDir,
|
||||
perms: oauthPerms,
|
||||
isDir: true,
|
||||
posixMode: 0o700,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
} else if (oauthPerms.groupReadable || oauthPerms.worldReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.credentials_dir.perms_readable",
|
||||
severity: "warn",
|
||||
title: "Credentials dir is readable by others",
|
||||
detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; credentials and allowlists can be sensitive.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: oauthDir,
|
||||
perms: oauthPerms,
|
||||
isDir: true,
|
||||
posixMode: 0o700,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const agentIds = Array.isArray(params.cfg.agents?.list)
|
||||
? params.cfg.agents?.list
|
||||
.map((a) => (a && typeof a === "object" && typeof a.id === "string" ? a.id.trim() : ""))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const defaultAgentId = resolveDefaultAgentId(params.cfg);
|
||||
const ids = Array.from(new Set([defaultAgentId, ...agentIds])).map((id) => normalizeAgentId(id));
|
||||
|
||||
for (const agentId of ids) {
|
||||
const agentDir = path.join(params.stateDir, "agents", agentId, "agent");
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const authPerms = await inspectPathPermissions(authPath, {
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
exec: params.execIcacls,
|
||||
});
|
||||
if (authPerms.ok) {
|
||||
if (authPerms.worldWritable || authPerms.groupWritable) {
|
||||
findings.push({
|
||||
checkId: "fs.auth_profiles.perms_writable",
|
||||
severity: "critical",
|
||||
title: "auth-profiles.json is writable by others",
|
||||
detail: `${formatPermissionDetail(authPath, authPerms)}; another user could inject credentials.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: authPath,
|
||||
perms: authPerms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
} else if (authPerms.worldReadable || authPerms.groupReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.auth_profiles.perms_readable",
|
||||
severity: "warn",
|
||||
title: "auth-profiles.json is readable by others",
|
||||
detail: `${formatPermissionDetail(authPath, authPerms)}; auth-profiles.json contains API keys and OAuth tokens.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: authPath,
|
||||
perms: authPerms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const storePath = path.join(params.stateDir, "agents", agentId, "sessions", "sessions.json");
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const storePerms = await inspectPathPermissions(storePath, {
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
exec: params.execIcacls,
|
||||
});
|
||||
if (storePerms.ok) {
|
||||
if (storePerms.worldReadable || storePerms.groupReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.sessions_store.perms_readable",
|
||||
severity: "warn",
|
||||
title: "sessions.json is readable by others",
|
||||
detail: `${formatPermissionDetail(storePath, storePerms)}; routing and transcript metadata can be sensitive.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: storePath,
|
||||
perms: storePerms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const logFile =
|
||||
typeof params.cfg.logging?.file === "string" ? params.cfg.logging.file.trim() : "";
|
||||
if (logFile) {
|
||||
const expanded = logFile.startsWith("~") ? expandTilde(logFile, params.env) : logFile;
|
||||
if (expanded) {
|
||||
const logPath = path.resolve(expanded);
|
||||
const logPerms = await inspectPathPermissions(logPath, {
|
||||
env: params.env,
|
||||
platform: params.platform,
|
||||
exec: params.execIcacls,
|
||||
});
|
||||
if (logPerms.ok) {
|
||||
if (logPerms.worldReadable || logPerms.groupReadable) {
|
||||
findings.push({
|
||||
checkId: "fs.log_file.perms_readable",
|
||||
severity: "warn",
|
||||
title: "Log file is readable by others",
|
||||
detail: `${formatPermissionDetail(logPath, logPerms)}; logs can contain private messages and tool output.`,
|
||||
remediation: formatPermissionRemediation({
|
||||
targetPath: logPath,
|
||||
perms: logPerms,
|
||||
isDir: false,
|
||||
posixMode: 0o600,
|
||||
env: params.env,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
export async function readConfigSnapshotForAudit(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
configPath: string;
|
||||
}): Promise<ConfigFileSnapshot> {
|
||||
return await createConfigIO({
|
||||
env: params.env,
|
||||
configPath: params.configPath,
|
||||
}).readConfigFileSnapshot();
|
||||
}
|
||||
|
||||
export async function collectPluginsCodeSafetyFindings(params: {
|
||||
stateDir: string;
|
||||
}): Promise<SecurityAuditFinding[]> {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const extensionsDir = path.join(params.stateDir, "extensions");
|
||||
const st = await safeStat(extensionsDir);
|
||||
if (!st.ok || !st.isDir) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(extensionsDir, { withFileTypes: true }).catch((err) => {
|
||||
findings.push({
|
||||
checkId: "plugins.code_safety.scan_failed",
|
||||
severity: "warn",
|
||||
title: "Plugin extensions directory scan failed",
|
||||
detail: `Static code scan could not list extensions directory: ${String(err)}`,
|
||||
remediation:
|
||||
"Check file permissions and plugin layout, then rerun `openclaw security audit --deep`.",
|
||||
});
|
||||
return [];
|
||||
});
|
||||
const pluginDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
|
||||
for (const pluginName of pluginDirs) {
|
||||
const pluginPath = path.join(extensionsDir, pluginName);
|
||||
const extensionEntries = await readPluginManifestExtensions(pluginPath).catch(() => []);
|
||||
const forcedScanEntries: string[] = [];
|
||||
const escapedEntries: string[] = [];
|
||||
|
||||
for (const entry of extensionEntries) {
|
||||
const resolvedEntry = path.resolve(pluginPath, entry);
|
||||
if (!isPathInside(pluginPath, resolvedEntry)) {
|
||||
escapedEntries.push(entry);
|
||||
continue;
|
||||
}
|
||||
if (extensionUsesSkippedScannerPath(entry)) {
|
||||
findings.push({
|
||||
checkId: "plugins.code_safety.entry_path",
|
||||
severity: "warn",
|
||||
title: `Plugin "${pluginName}" entry path is hidden or node_modules`,
|
||||
detail: `Extension entry "${entry}" points to a hidden or node_modules path. Deep code scan will cover this entry explicitly, but review this path choice carefully.`,
|
||||
remediation: "Prefer extension entrypoints under normal source paths like dist/ or src/.",
|
||||
});
|
||||
}
|
||||
forcedScanEntries.push(resolvedEntry);
|
||||
}
|
||||
|
||||
if (escapedEntries.length > 0) {
|
||||
findings.push({
|
||||
checkId: "plugins.code_safety.entry_escape",
|
||||
severity: "critical",
|
||||
title: `Plugin "${pluginName}" has extension entry path traversal`,
|
||||
detail: `Found extension entries that escape the plugin directory:\n${escapedEntries.map((entry) => ` - ${entry}`).join("\n")}`,
|
||||
remediation:
|
||||
"Update the plugin manifest so all openclaw.extensions entries stay inside the plugin directory.",
|
||||
});
|
||||
}
|
||||
|
||||
const summary = await scanDirectoryWithSummary(pluginPath, {
|
||||
includeFiles: forcedScanEntries,
|
||||
}).catch((err) => {
|
||||
findings.push({
|
||||
checkId: "plugins.code_safety.scan_failed",
|
||||
severity: "warn",
|
||||
title: `Plugin "${pluginName}" code scan failed`,
|
||||
detail: `Static code scan could not complete: ${String(err)}`,
|
||||
remediation:
|
||||
"Check file permissions and plugin layout, then rerun `openclaw security audit --deep`.",
|
||||
});
|
||||
return null;
|
||||
});
|
||||
if (!summary) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (summary.critical > 0) {
|
||||
const criticalFindings = summary.findings.filter((f) => f.severity === "critical");
|
||||
const details = formatCodeSafetyDetails(criticalFindings, pluginPath);
|
||||
|
||||
findings.push({
|
||||
checkId: "plugins.code_safety",
|
||||
severity: "critical",
|
||||
title: `Plugin "${pluginName}" contains dangerous code patterns`,
|
||||
detail: `Found ${summary.critical} critical issue(s) in ${summary.scannedFiles} scanned file(s):\n${details}`,
|
||||
remediation:
|
||||
"Review the plugin source code carefully before use. If untrusted, remove the plugin from your OpenClaw extensions state directory.",
|
||||
});
|
||||
} else if (summary.warn > 0) {
|
||||
const warnFindings = summary.findings.filter((f) => f.severity === "warn");
|
||||
const details = formatCodeSafetyDetails(warnFindings, pluginPath);
|
||||
|
||||
findings.push({
|
||||
checkId: "plugins.code_safety",
|
||||
severity: "warn",
|
||||
title: `Plugin "${pluginName}" contains suspicious code patterns`,
|
||||
detail: `Found ${summary.warn} warning(s) in ${summary.scannedFiles} scanned file(s):\n${details}`,
|
||||
remediation: `Review the flagged code to ensure it is intentional and safe.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
export async function collectInstalledSkillsCodeSafetyFindings(params: {
|
||||
cfg: OpenClawConfig;
|
||||
stateDir: string;
|
||||
}): Promise<SecurityAuditFinding[]> {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const pluginExtensionsDir = path.join(params.stateDir, "extensions");
|
||||
const scannedSkillDirs = new Set<string>();
|
||||
const workspaceDirs = listWorkspaceDirs(params.cfg);
|
||||
|
||||
for (const workspaceDir of workspaceDirs) {
|
||||
const entries = loadWorkspaceSkillEntries(workspaceDir, { config: params.cfg });
|
||||
for (const entry of entries) {
|
||||
if (entry.skill.source === "openclaw-bundled") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const skillDir = path.resolve(entry.skill.baseDir);
|
||||
if (isPathInside(pluginExtensionsDir, skillDir)) {
|
||||
// Plugin code is already covered by plugins.code_safety checks.
|
||||
continue;
|
||||
}
|
||||
if (scannedSkillDirs.has(skillDir)) {
|
||||
continue;
|
||||
}
|
||||
scannedSkillDirs.add(skillDir);
|
||||
|
||||
const skillName = entry.skill.name;
|
||||
const summary = await scanDirectoryWithSummary(skillDir).catch((err) => {
|
||||
findings.push({
|
||||
checkId: "skills.code_safety.scan_failed",
|
||||
severity: "warn",
|
||||
title: `Skill "${skillName}" code scan failed`,
|
||||
detail: `Static code scan could not complete for ${skillDir}: ${String(err)}`,
|
||||
remediation:
|
||||
"Check file permissions and skill layout, then rerun `openclaw security audit --deep`.",
|
||||
});
|
||||
return null;
|
||||
});
|
||||
if (!summary) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (summary.critical > 0) {
|
||||
const criticalFindings = summary.findings.filter(
|
||||
(finding) => finding.severity === "critical",
|
||||
);
|
||||
const details = formatCodeSafetyDetails(criticalFindings, skillDir);
|
||||
findings.push({
|
||||
checkId: "skills.code_safety",
|
||||
severity: "critical",
|
||||
title: `Skill "${skillName}" contains dangerous code patterns`,
|
||||
detail: `Found ${summary.critical} critical issue(s) in ${summary.scannedFiles} scanned file(s) under ${skillDir}:\n${details}`,
|
||||
remediation: `Review the skill source code before use. If untrusted, remove "${skillDir}".`,
|
||||
});
|
||||
} else if (summary.warn > 0) {
|
||||
const warnFindings = summary.findings.filter((finding) => finding.severity === "warn");
|
||||
const details = formatCodeSafetyDetails(warnFindings, skillDir);
|
||||
findings.push({
|
||||
checkId: "skills.code_safety",
|
||||
severity: "warn",
|
||||
title: `Skill "${skillName}" contains suspicious code patterns`,
|
||||
detail: `Found ${summary.warn} warning(s) in ${summary.scannedFiles} scanned file(s) under ${skillDir}:\n${details}`,
|
||||
remediation: "Review flagged lines to ensure the behavior is intentional and safe.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
618
src/security/audit-extra.sync.ts
Normal file
618
src/security/audit-extra.sync.ts
Normal file
@@ -0,0 +1,618 @@
|
||||
/**
|
||||
* Synchronous security audit collector functions.
|
||||
*
|
||||
* These functions analyze config-based security properties without I/O.
|
||||
*/
|
||||
import type { SandboxToolPolicy } from "../agents/sandbox/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { AgentToolsConfig } from "../config/types.tools.js";
|
||||
import { isToolAllowedByPolicies } from "../agents/pi-tools.policy.js";
|
||||
import {
|
||||
resolveSandboxConfigForAgent,
|
||||
resolveSandboxToolPolicyForAgent,
|
||||
} from "../agents/sandbox.js";
|
||||
import { resolveToolProfilePolicy } from "../agents/tool-policy.js";
|
||||
import { resolveBrowserConfig } from "../browser/config.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { resolveGatewayAuth } from "../gateway/auth.js";
|
||||
|
||||
export type SecurityAuditFinding = {
|
||||
checkId: string;
|
||||
severity: "info" | "warn" | "critical";
|
||||
title: string;
|
||||
detail: string;
|
||||
remediation?: string;
|
||||
};
|
||||
|
||||
const SMALL_MODEL_PARAM_B_MAX = 300;
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
function summarizeGroupPolicy(cfg: OpenClawConfig): {
|
||||
open: number;
|
||||
allowlist: number;
|
||||
other: number;
|
||||
} {
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
if (!channels || typeof channels !== "object") {
|
||||
return { open: 0, allowlist: 0, other: 0 };
|
||||
}
|
||||
let open = 0;
|
||||
let allowlist = 0;
|
||||
let other = 0;
|
||||
for (const value of Object.values(channels)) {
|
||||
if (!value || typeof value !== "object") {
|
||||
continue;
|
||||
}
|
||||
const section = value as Record<string, unknown>;
|
||||
const policy = section.groupPolicy;
|
||||
if (policy === "open") {
|
||||
open += 1;
|
||||
} else if (policy === "allowlist") {
|
||||
allowlist += 1;
|
||||
} else {
|
||||
other += 1;
|
||||
}
|
||||
}
|
||||
return { open, allowlist, other };
|
||||
}
|
||||
|
||||
function isProbablySyncedPath(p: string): boolean {
|
||||
const s = p.toLowerCase();
|
||||
return (
|
||||
s.includes("icloud") ||
|
||||
s.includes("dropbox") ||
|
||||
s.includes("google drive") ||
|
||||
s.includes("googledrive") ||
|
||||
s.includes("onedrive")
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeEnvRef(value: string): boolean {
|
||||
const v = value.trim();
|
||||
return v.startsWith("${") && v.endsWith("}");
|
||||
}
|
||||
|
||||
type ModelRef = { id: string; source: string };
|
||||
|
||||
function addModel(models: ModelRef[], raw: unknown, source: string) {
|
||||
if (typeof raw !== "string") {
|
||||
return;
|
||||
}
|
||||
const id = raw.trim();
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
models.push({ id, source });
|
||||
}
|
||||
|
||||
function collectModels(cfg: OpenClawConfig): ModelRef[] {
|
||||
const out: ModelRef[] = [];
|
||||
addModel(out, cfg.agents?.defaults?.model?.primary, "agents.defaults.model.primary");
|
||||
for (const f of cfg.agents?.defaults?.model?.fallbacks ?? []) {
|
||||
addModel(out, f, "agents.defaults.model.fallbacks");
|
||||
}
|
||||
addModel(out, cfg.agents?.defaults?.imageModel?.primary, "agents.defaults.imageModel.primary");
|
||||
for (const f of cfg.agents?.defaults?.imageModel?.fallbacks ?? []) {
|
||||
addModel(out, f, "agents.defaults.imageModel.fallbacks");
|
||||
}
|
||||
|
||||
const list = Array.isArray(cfg.agents?.list) ? cfg.agents?.list : [];
|
||||
for (const agent of list ?? []) {
|
||||
if (!agent || typeof agent !== "object") {
|
||||
continue;
|
||||
}
|
||||
const id =
|
||||
typeof (agent as { id?: unknown }).id === "string" ? (agent as { id: string }).id : "";
|
||||
const model = (agent as { model?: unknown }).model;
|
||||
if (typeof model === "string") {
|
||||
addModel(out, model, `agents.list.${id}.model`);
|
||||
} else if (model && typeof model === "object") {
|
||||
addModel(out, (model as { primary?: unknown }).primary, `agents.list.${id}.model.primary`);
|
||||
const fallbacks = (model as { fallbacks?: unknown }).fallbacks;
|
||||
if (Array.isArray(fallbacks)) {
|
||||
for (const f of fallbacks) {
|
||||
addModel(out, f, `agents.list.${id}.model.fallbacks`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const LEGACY_MODEL_PATTERNS: Array<{ id: string; re: RegExp; label: string }> = [
|
||||
{ id: "openai.gpt35", re: /\bgpt-3\.5\b/i, label: "GPT-3.5 family" },
|
||||
{ id: "anthropic.claude2", re: /\bclaude-(instant|2)\b/i, label: "Claude 2/Instant family" },
|
||||
{ id: "openai.gpt4_legacy", re: /\bgpt-4-(0314|0613)\b/i, label: "Legacy GPT-4 snapshots" },
|
||||
];
|
||||
|
||||
const WEAK_TIER_MODEL_PATTERNS: Array<{ id: string; re: RegExp; label: string }> = [
|
||||
{ id: "anthropic.haiku", re: /\bhaiku\b/i, label: "Haiku tier (smaller model)" },
|
||||
];
|
||||
|
||||
function inferParamBFromIdOrName(text: string): number | null {
|
||||
const raw = text.toLowerCase();
|
||||
const matches = raw.matchAll(/(?:^|[^a-z0-9])[a-z]?(\d+(?:\.\d+)?)b(?:[^a-z0-9]|$)/g);
|
||||
let best: number | null = null;
|
||||
for (const match of matches) {
|
||||
const numRaw = match[1];
|
||||
if (!numRaw) {
|
||||
continue;
|
||||
}
|
||||
const value = Number(numRaw);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (best === null || value > best) {
|
||||
best = value;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function isGptModel(id: string): boolean {
|
||||
return /\bgpt-/i.test(id);
|
||||
}
|
||||
|
||||
function isGpt5OrHigher(id: string): boolean {
|
||||
return /\bgpt-5(?:\b|[.-])/i.test(id);
|
||||
}
|
||||
|
||||
function isClaudeModel(id: string): boolean {
|
||||
return /\bclaude-/i.test(id);
|
||||
}
|
||||
|
||||
function isClaude45OrHigher(id: string): boolean {
|
||||
// Match claude-*-4-5+, claude-*-45+, claude-*4.5+, or future 5.x+ majors.
|
||||
return /\bclaude-[^\s/]*?(?:-4-?(?:[5-9]|[1-9]\d)\b|4\.(?:[5-9]|[1-9]\d)\b|-[5-9](?:\b|[.-]))/i.test(
|
||||
id,
|
||||
);
|
||||
}
|
||||
|
||||
function extractAgentIdFromSource(source: string): string | null {
|
||||
const match = source.match(/^agents\.list\.([^.]*)\./);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function pickToolPolicy(config?: { allow?: string[]; deny?: string[] }): SandboxToolPolicy | null {
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
const allow = Array.isArray(config.allow) ? config.allow : undefined;
|
||||
const deny = Array.isArray(config.deny) ? config.deny : undefined;
|
||||
if (!allow && !deny) {
|
||||
return null;
|
||||
}
|
||||
return { allow, deny };
|
||||
}
|
||||
|
||||
function resolveToolPolicies(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentTools?: AgentToolsConfig;
|
||||
sandboxMode?: "off" | "non-main" | "all";
|
||||
agentId?: string | null;
|
||||
}): SandboxToolPolicy[] {
|
||||
const policies: SandboxToolPolicy[] = [];
|
||||
const profile = params.agentTools?.profile ?? params.cfg.tools?.profile;
|
||||
const profilePolicy = resolveToolProfilePolicy(profile);
|
||||
if (profilePolicy) {
|
||||
policies.push(profilePolicy);
|
||||
}
|
||||
|
||||
const globalPolicy = pickToolPolicy(params.cfg.tools ?? undefined);
|
||||
if (globalPolicy) {
|
||||
policies.push(globalPolicy);
|
||||
}
|
||||
|
||||
const agentPolicy = pickToolPolicy(params.agentTools);
|
||||
if (agentPolicy) {
|
||||
policies.push(agentPolicy);
|
||||
}
|
||||
|
||||
if (params.sandboxMode === "all") {
|
||||
const sandboxPolicy = resolveSandboxToolPolicyForAgent(params.cfg, params.agentId ?? undefined);
|
||||
policies.push(sandboxPolicy);
|
||||
}
|
||||
|
||||
return policies;
|
||||
}
|
||||
|
||||
function hasWebSearchKey(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
|
||||
const search = cfg.tools?.web?.search;
|
||||
return Boolean(
|
||||
search?.apiKey ||
|
||||
search?.perplexity?.apiKey ||
|
||||
env.BRAVE_API_KEY ||
|
||||
env.PERPLEXITY_API_KEY ||
|
||||
env.OPENROUTER_API_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
function isWebSearchEnabled(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
|
||||
const enabled = cfg.tools?.web?.search?.enabled;
|
||||
if (enabled === false) {
|
||||
return false;
|
||||
}
|
||||
if (enabled === true) {
|
||||
return true;
|
||||
}
|
||||
return hasWebSearchKey(cfg, env);
|
||||
}
|
||||
|
||||
function isWebFetchEnabled(cfg: OpenClawConfig): boolean {
|
||||
const enabled = cfg.tools?.web?.fetch?.enabled;
|
||||
if (enabled === false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isBrowserEnabled(cfg: OpenClawConfig): boolean {
|
||||
try {
|
||||
return resolveBrowserConfig(cfg.browser, cfg).enabled;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function listGroupPolicyOpen(cfg: OpenClawConfig): string[] {
|
||||
const out: string[] = [];
|
||||
const channels = cfg.channels as Record<string, unknown> | undefined;
|
||||
if (!channels || typeof channels !== "object") {
|
||||
return out;
|
||||
}
|
||||
for (const [channelId, value] of Object.entries(channels)) {
|
||||
if (!value || typeof value !== "object") {
|
||||
continue;
|
||||
}
|
||||
const section = value as Record<string, unknown>;
|
||||
if (section.groupPolicy === "open") {
|
||||
out.push(`channels.${channelId}.groupPolicy`);
|
||||
}
|
||||
const accounts = section.accounts;
|
||||
if (accounts && typeof accounts === "object") {
|
||||
for (const [accountId, accountVal] of Object.entries(accounts)) {
|
||||
if (!accountVal || typeof accountVal !== "object") {
|
||||
continue;
|
||||
}
|
||||
const acc = accountVal as Record<string, unknown>;
|
||||
if (acc.groupPolicy === "open") {
|
||||
out.push(`channels.${channelId}.accounts.${accountId}.groupPolicy`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Exported collectors
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export function collectAttackSurfaceSummaryFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
||||
const group = summarizeGroupPolicy(cfg);
|
||||
const elevated = cfg.tools?.elevated?.enabled !== false;
|
||||
const hooksEnabled = cfg.hooks?.enabled === true;
|
||||
const browserEnabled = cfg.browser?.enabled ?? true;
|
||||
|
||||
const detail =
|
||||
`groups: open=${group.open}, allowlist=${group.allowlist}` +
|
||||
`\n` +
|
||||
`tools.elevated: ${elevated ? "enabled" : "disabled"}` +
|
||||
`\n` +
|
||||
`hooks: ${hooksEnabled ? "enabled" : "disabled"}` +
|
||||
`\n` +
|
||||
`browser control: ${browserEnabled ? "enabled" : "disabled"}`;
|
||||
|
||||
return [
|
||||
{
|
||||
checkId: "summary.attack_surface",
|
||||
severity: "info",
|
||||
title: "Attack surface summary",
|
||||
detail,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function collectSyncedFolderFindings(params: {
|
||||
stateDir: string;
|
||||
configPath: string;
|
||||
}): SecurityAuditFinding[] {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
if (isProbablySyncedPath(params.stateDir) || isProbablySyncedPath(params.configPath)) {
|
||||
findings.push({
|
||||
checkId: "fs.synced_dir",
|
||||
severity: "warn",
|
||||
title: "State/config path looks like a synced folder",
|
||||
detail: `stateDir=${params.stateDir}, configPath=${params.configPath}. Synced folders (iCloud/Dropbox/OneDrive/Google Drive) can leak tokens and transcripts onto other devices.`,
|
||||
remediation: `Keep OPENCLAW_STATE_DIR on a local-only volume and re-run "${formatCliCommand("openclaw security audit --fix")}".`,
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function collectSecretsInConfigFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const password =
|
||||
typeof cfg.gateway?.auth?.password === "string" ? cfg.gateway.auth.password.trim() : "";
|
||||
if (password && !looksLikeEnvRef(password)) {
|
||||
findings.push({
|
||||
checkId: "config.secrets.gateway_password_in_config",
|
||||
severity: "warn",
|
||||
title: "Gateway password is stored in config",
|
||||
detail:
|
||||
"gateway.auth.password is set in the config file; prefer environment variables for secrets when possible.",
|
||||
remediation:
|
||||
"Prefer OPENCLAW_GATEWAY_PASSWORD (env) and remove gateway.auth.password from disk.",
|
||||
});
|
||||
}
|
||||
|
||||
const hooksToken = typeof cfg.hooks?.token === "string" ? cfg.hooks.token.trim() : "";
|
||||
if (cfg.hooks?.enabled === true && hooksToken && !looksLikeEnvRef(hooksToken)) {
|
||||
findings.push({
|
||||
checkId: "config.secrets.hooks_token_in_config",
|
||||
severity: "info",
|
||||
title: "Hooks token is stored in config",
|
||||
detail:
|
||||
"hooks.token is set in the config file; keep config perms tight and treat it like an API secret.",
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function collectHooksHardeningFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
if (cfg.hooks?.enabled !== true) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const token = typeof cfg.hooks?.token === "string" ? cfg.hooks.token.trim() : "";
|
||||
if (token && token.length < 24) {
|
||||
findings.push({
|
||||
checkId: "hooks.token_too_short",
|
||||
severity: "warn",
|
||||
title: "Hooks token looks short",
|
||||
detail: `hooks.token is ${token.length} chars; prefer a long random token.`,
|
||||
});
|
||||
}
|
||||
|
||||
const gatewayAuth = resolveGatewayAuth({
|
||||
authConfig: cfg.gateway?.auth,
|
||||
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
|
||||
});
|
||||
const gatewayToken =
|
||||
gatewayAuth.mode === "token" &&
|
||||
typeof gatewayAuth.token === "string" &&
|
||||
gatewayAuth.token.trim()
|
||||
? gatewayAuth.token.trim()
|
||||
: null;
|
||||
if (token && gatewayToken && token === gatewayToken) {
|
||||
findings.push({
|
||||
checkId: "hooks.token_reuse_gateway_token",
|
||||
severity: "warn",
|
||||
title: "Hooks token reuses the Gateway token",
|
||||
detail:
|
||||
"hooks.token matches gateway.auth token; compromise of hooks expands blast radius to the Gateway API.",
|
||||
remediation: "Use a separate hooks.token dedicated to hook ingress.",
|
||||
});
|
||||
}
|
||||
|
||||
const rawPath = typeof cfg.hooks?.path === "string" ? cfg.hooks.path.trim() : "";
|
||||
if (rawPath === "/") {
|
||||
findings.push({
|
||||
checkId: "hooks.path_root",
|
||||
severity: "critical",
|
||||
title: "Hooks base path is '/'",
|
||||
detail: "hooks.path='/' would shadow other HTTP endpoints and is unsafe.",
|
||||
remediation: "Use a dedicated path like '/hooks'.",
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function collectModelHygieneFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const models = collectModels(cfg);
|
||||
if (models.length === 0) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const weakMatches = new Map<string, { model: string; source: string; reasons: string[] }>();
|
||||
const addWeakMatch = (model: string, source: string, reason: string) => {
|
||||
const key = `${model}@@${source}`;
|
||||
const existing = weakMatches.get(key);
|
||||
if (!existing) {
|
||||
weakMatches.set(key, { model, source, reasons: [reason] });
|
||||
return;
|
||||
}
|
||||
if (!existing.reasons.includes(reason)) {
|
||||
existing.reasons.push(reason);
|
||||
}
|
||||
};
|
||||
|
||||
for (const entry of models) {
|
||||
for (const pat of WEAK_TIER_MODEL_PATTERNS) {
|
||||
if (pat.re.test(entry.id)) {
|
||||
addWeakMatch(entry.id, entry.source, pat.label);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isGptModel(entry.id) && !isGpt5OrHigher(entry.id)) {
|
||||
addWeakMatch(entry.id, entry.source, "Below GPT-5 family");
|
||||
}
|
||||
if (isClaudeModel(entry.id) && !isClaude45OrHigher(entry.id)) {
|
||||
addWeakMatch(entry.id, entry.source, "Below Claude 4.5");
|
||||
}
|
||||
}
|
||||
|
||||
const matches: Array<{ model: string; source: string; reason: string }> = [];
|
||||
for (const entry of models) {
|
||||
for (const pat of LEGACY_MODEL_PATTERNS) {
|
||||
if (pat.re.test(entry.id)) {
|
||||
matches.push({ model: entry.id, source: entry.source, reason: pat.label });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length > 0) {
|
||||
const lines = matches
|
||||
.slice(0, 12)
|
||||
.map((m) => `- ${m.model} (${m.reason}) @ ${m.source}`)
|
||||
.join("\n");
|
||||
const more = matches.length > 12 ? `\n…${matches.length - 12} more` : "";
|
||||
findings.push({
|
||||
checkId: "models.legacy",
|
||||
severity: "warn",
|
||||
title: "Some configured models look legacy",
|
||||
detail:
|
||||
"Older/legacy models can be less robust against prompt injection and tool misuse.\n" +
|
||||
lines +
|
||||
more,
|
||||
remediation: "Prefer modern, instruction-hardened models for any bot that can run tools.",
|
||||
});
|
||||
}
|
||||
|
||||
if (weakMatches.size > 0) {
|
||||
const lines = Array.from(weakMatches.values())
|
||||
.slice(0, 12)
|
||||
.map((m) => `- ${m.model} (${m.reasons.join("; ")}) @ ${m.source}`)
|
||||
.join("\n");
|
||||
const more = weakMatches.size > 12 ? `\n…${weakMatches.size - 12} more` : "";
|
||||
findings.push({
|
||||
checkId: "models.weak_tier",
|
||||
severity: "warn",
|
||||
title: "Some configured models are below recommended tiers",
|
||||
detail:
|
||||
"Smaller/older models are generally more susceptible to prompt injection and tool misuse.\n" +
|
||||
lines +
|
||||
more,
|
||||
remediation:
|
||||
"Use the latest, top-tier model for any bot with tools or untrusted inboxes. Avoid Haiku tiers; prefer GPT-5+ and Claude 4.5+.",
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function collectSmallModelRiskFindings(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): SecurityAuditFinding[] {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const models = collectModels(params.cfg).filter((entry) => !entry.source.includes("imageModel"));
|
||||
if (models.length === 0) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const smallModels = models
|
||||
.map((entry) => {
|
||||
const paramB = inferParamBFromIdOrName(entry.id);
|
||||
if (!paramB || paramB > SMALL_MODEL_PARAM_B_MAX) {
|
||||
return null;
|
||||
}
|
||||
return { ...entry, paramB };
|
||||
})
|
||||
.filter((entry): entry is { id: string; source: string; paramB: number } => Boolean(entry));
|
||||
|
||||
if (smallModels.length === 0) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
let hasUnsafe = false;
|
||||
const modelLines: string[] = [];
|
||||
const exposureSet = new Set<string>();
|
||||
for (const entry of smallModels) {
|
||||
const agentId = extractAgentIdFromSource(entry.source);
|
||||
const sandboxMode = resolveSandboxConfigForAgent(params.cfg, agentId ?? undefined).mode;
|
||||
const agentTools =
|
||||
agentId && params.cfg.agents?.list
|
||||
? params.cfg.agents.list.find((agent) => agent?.id === agentId)?.tools
|
||||
: undefined;
|
||||
const policies = resolveToolPolicies({
|
||||
cfg: params.cfg,
|
||||
agentTools,
|
||||
sandboxMode,
|
||||
agentId,
|
||||
});
|
||||
const exposed: string[] = [];
|
||||
if (isWebSearchEnabled(params.cfg, params.env)) {
|
||||
if (isToolAllowedByPolicies("web_search", policies)) {
|
||||
exposed.push("web_search");
|
||||
}
|
||||
}
|
||||
if (isWebFetchEnabled(params.cfg)) {
|
||||
if (isToolAllowedByPolicies("web_fetch", policies)) {
|
||||
exposed.push("web_fetch");
|
||||
}
|
||||
}
|
||||
if (isBrowserEnabled(params.cfg)) {
|
||||
if (isToolAllowedByPolicies("browser", policies)) {
|
||||
exposed.push("browser");
|
||||
}
|
||||
}
|
||||
for (const tool of exposed) {
|
||||
exposureSet.add(tool);
|
||||
}
|
||||
const sandboxLabel = sandboxMode === "all" ? "sandbox=all" : `sandbox=${sandboxMode}`;
|
||||
const exposureLabel = exposed.length > 0 ? ` web=[${exposed.join(", ")}]` : " web=[off]";
|
||||
const safe = sandboxMode === "all" && exposed.length === 0;
|
||||
if (!safe) {
|
||||
hasUnsafe = true;
|
||||
}
|
||||
const statusLabel = safe ? "ok" : "unsafe";
|
||||
modelLines.push(
|
||||
`- ${entry.id} (${entry.paramB}B) @ ${entry.source} (${statusLabel}; ${sandboxLabel};${exposureLabel})`,
|
||||
);
|
||||
}
|
||||
|
||||
const exposureList = Array.from(exposureSet);
|
||||
const exposureDetail =
|
||||
exposureList.length > 0
|
||||
? `Uncontrolled input tools allowed: ${exposureList.join(", ")}.`
|
||||
: "No web/browser tools detected for these models.";
|
||||
|
||||
findings.push({
|
||||
checkId: "models.small_params",
|
||||
severity: hasUnsafe ? "critical" : "info",
|
||||
title: "Small models require sandboxing and web tools disabled",
|
||||
detail:
|
||||
`Small models (<=${SMALL_MODEL_PARAM_B_MAX}B params) detected:\n` +
|
||||
modelLines.join("\n") +
|
||||
`\n` +
|
||||
exposureDetail +
|
||||
`\n` +
|
||||
"Small models are not recommended for untrusted inputs.",
|
||||
remediation:
|
||||
'If you must use small models, enable sandboxing for all sessions (agents.defaults.sandbox.mode="all") and disable web_search/web_fetch/browser (tools.deny=["group:web","browser"]).',
|
||||
});
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function collectExposureMatrixFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const openGroups = listGroupPolicyOpen(cfg);
|
||||
if (openGroups.length === 0) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const elevatedEnabled = cfg.tools?.elevated?.enabled !== false;
|
||||
if (elevatedEnabled) {
|
||||
findings.push({
|
||||
checkId: "security.exposure.open_groups_with_elevated",
|
||||
severity: "critical",
|
||||
title: "Open groupPolicy with elevated tools enabled",
|
||||
detail:
|
||||
`Found groupPolicy="open" at:\n${openGroups.map((p) => `- ${p}`).join("\n")}\n` +
|
||||
"With tools.elevated enabled, a prompt injection in those rooms can become a high-impact incident.",
|
||||
remediation: `Set groupPolicy="allowlist" and keep elevated allowlists extremely tight.`,
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
275
tmp-refactoring-strategy.md
Normal file
275
tmp-refactoring-strategy.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# Refactoring Strategy — Oversized Files
|
||||
|
||||
> **Target:** ~500–700 LOC per file (AGENTS.md guideline)
|
||||
> **Baseline:** 681K total lines across 3,781 code files (avg 180 LOC)
|
||||
> **Problem:** 50+ files exceed 700 LOC; top offenders are 2–4× over target
|
||||
|
||||
---
|
||||
|
||||
## Progress Summary
|
||||
|
||||
| Item | Before | After | Status |
|
||||
| ----------------------------------- | ------ | ------------------------------------ | ------- |
|
||||
| `src/config/schema.ts` | 1,114 | 353 + 729 (field-metadata) | ✅ Done |
|
||||
| `src/security/audit-extra.ts` | 1,199 | 31 barrel + 559 (sync) + 668 (async) | ✅ Done |
|
||||
| `src/infra/session-cost-usage.ts` | 984 | — | Pending |
|
||||
| `src/media-understanding/runner.ts` | 1,232 | — | Pending |
|
||||
|
||||
### All Targets (current LOC)
|
||||
|
||||
| Phase | File | Current LOC | Target |
|
||||
| ----- | -------------------------------- | ----------- | ------ |
|
||||
| 1 | session-cost-usage.ts | 984 | ~700 |
|
||||
| 1 | media-understanding/runner.ts | 1,232 | ~700 |
|
||||
| 2a | heartbeat-runner.ts | 956 | ~560 |
|
||||
| 2a | message-action-runner.ts | 1,082 | ~620 |
|
||||
| 2b | tts/tts.ts | 1,445 | ~950 |
|
||||
| 2b | exec-approvals.ts | 1,437 | ~700 |
|
||||
| 2b | update-cli.ts | 1,245 | ~1,000 |
|
||||
| 3 | memory/manager.ts | 2,280 | ~1,300 |
|
||||
| 3 | bash-tools.exec.ts | 1,546 | ~1,000 |
|
||||
| 3 | ws-connection/message-handler.ts | 970 | ~720 |
|
||||
| 4 | ui/views/usage.ts | 3,076 | ~1,200 |
|
||||
| 4 | ui/views/agents.ts | 1,894 | ~950 |
|
||||
| 4 | ui/views/nodes.ts | 1,118 | ~440 |
|
||||
| 4 | bluebubbles/monitor.ts | 2,348 | ~650 |
|
||||
|
||||
---
|
||||
|
||||
## Naming Convention (Established Pattern)
|
||||
|
||||
The codebase uses **dot-separated module decomposition**: `<base-module>.<concern>.ts`
|
||||
|
||||
**Examples from codebase:**
|
||||
|
||||
- `provider-usage.ts` → `provider-usage.types.ts`, `provider-usage.fetch.ts`, `provider-usage.shared.ts`
|
||||
- `zod-schema.ts` → `zod-schema.core.ts`, `zod-schema.agents.ts`, `zod-schema.session.ts`
|
||||
- `directive-handling.ts` → `directive-handling.parse.ts`, `directive-handling.impl.ts`, `directive-handling.shared.ts`
|
||||
|
||||
**Pattern:**
|
||||
|
||||
- `<base>.ts` — main barrel, re-exports public API
|
||||
- `<base>.types.ts` — type definitions
|
||||
- `<base>.shared.ts` — shared constants/utilities
|
||||
- `<base>.<domain>.ts` — domain-specific implementations
|
||||
|
||||
**Consequences for this refactoring:**
|
||||
|
||||
- ✅ Renamed: `audit-collectors-sync.ts` → `audit-extra.sync.ts`, `audit-collectors-async.ts` → `audit-extra.async.ts`
|
||||
- Use `session-cost-usage.types.ts` (not `session-cost-types.ts`)
|
||||
- Use `runner.binary.ts` (not `binary-resolve.ts`)
|
||||
|
||||
---
|
||||
|
||||
## Triage: What NOT to split
|
||||
|
||||
| File | LOC | Reason to skip |
|
||||
| ---------------------------------------------- | ----- | -------------------------------------------------------------------------------- |
|
||||
| `ui/src/ui/views/usageStyles.ts` | 1,911 | Pure CSS-in-JS data. Zero logic. |
|
||||
| `apps/macos/.../GatewayModels.swift` | 2,790 | Generated/shared protocol models. Splitting fragments the schema. |
|
||||
| `apps/shared/.../GatewayModels.swift` | 2,790 | Same — shared protocol definitions. |
|
||||
| `*.test.ts` files (bot.test, audit.test, etc.) | 1K–3K | Tests naturally grow with the module. Split only if parallel execution needs it. |
|
||||
| `ui/src/ui/app-render.ts` | 1,222 | Mechanical prop-wiring glue. Large but low complexity. Optional. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Low-Risk, High-Impact (Pure Data / Independent Functions)
|
||||
|
||||
These files contain cleanly separable sections with no shared mutable state. Each extraction is a straightforward "move functions + update imports" operation.
|
||||
|
||||
### 1. ✅ `src/config/schema.ts` (1,114 → 353 LOC) — DONE
|
||||
|
||||
| Extract to | What moves | LOC |
|
||||
| --------------------------------- | ------------------------------------------------------------------- | --- |
|
||||
| `config/schema.field-metadata.ts` | `FIELD_LABELS`, `FIELD_HELP`, `FIELD_PLACEHOLDERS`, sensitivity map | 729 |
|
||||
|
||||
**Result:** schema.ts reduced to 353 LOC. Field metadata extracted to schema.field-metadata.ts (729 LOC).
|
||||
|
||||
### 2. ✅ `src/security/audit-extra.ts` (1,199 → 31 LOC barrel) — DONE
|
||||
|
||||
| Extract to | What moves | LOC |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --- |
|
||||
| `security/audit-extra.sync.ts` | 7 sync collectors (config-based, no I/O): attack surface, synced folders, secrets, hooks, model hygiene, small model risk, exposure matrix | 559 |
|
||||
| `security/audit-extra.async.ts` | 6 async collectors (filesystem/plugin checks): plugins trust, include perms, deep filesystem, config snapshot, plugins code safety, skills code safety | 668 |
|
||||
|
||||
**Result:** Used centralized sync vs. async split (2 files) instead of domain scatter (3 files). audit-extra.ts is now a 31-line re-export barrel for backward compatibility. Files renamed to follow `<base>.<concern>.ts` convention.
|
||||
|
||||
### 3. `src/infra/session-cost-usage.ts` (984 → ~700 LOC)
|
||||
|
||||
| Extract to | What moves | LOC |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- |
|
||||
| `infra/session-cost-usage.types.ts` | 20+ exported type definitions | ~130 |
|
||||
| `infra/session-cost-usage.parsers.ts` | `emptyTotals`, `toFiniteNumber`, `extractCostBreakdown`, `parseTimestamp`, `parseTranscriptEntry`, `formatDayKey`, `computeLatencyStats`, `apply*` helpers, `scan*File` helpers | ~240 |
|
||||
|
||||
**Why:** Types + pure parser functions. Zero side effects. Consumers just import them.
|
||||
|
||||
### 4. `src/media-understanding/runner.ts` (1,232 → ~700 LOC)
|
||||
|
||||
| Extract to | What moves | LOC |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ---- |
|
||||
| `media-understanding/runner.binary.ts` | `findBinary`, `hasBinary`, `isExecutable`, `candidateBinaryNames` + caching | ~150 |
|
||||
| `media-understanding/runner.cli.ts` | `extractGeminiResponse`, `extractSherpaOnnxText`, `probeGeminiCli`, `resolveCliOutput` | ~200 |
|
||||
| `media-understanding/runner.entry.ts` | local entry resolvers, `resolveAutoEntries`, `resolveAutoImageModel`, `resolveActiveModelEntry`, `resolveKeyEntry` | ~250 |
|
||||
|
||||
**Why:** Three clean layers (binary discovery → CLI output parsing → entry resolution). One-way dependency flow.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Medium-Risk, Clean Boundaries
|
||||
|
||||
These require converting private methods or closure variables to explicit parameters, but the seams are well-defined.
|
||||
|
||||
### 5. `src/infra/heartbeat-runner.ts` (956 → ~560 LOC)
|
||||
|
||||
| Extract to | What moves | LOC |
|
||||
| ---------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---- |
|
||||
| `infra/heartbeat-runner.config.ts` | Active hours logic, config/agent/session resolution, `resolveHeartbeat*` helpers, `isHeartbeatEnabledForAgent` | ~370 |
|
||||
| `infra/heartbeat-runner.reply.ts` | Reply payload helpers: `resolveHeartbeatReplyPayload`, `normalizeHeartbeatReply`, `restoreHeartbeatUpdatedAt` | ~100 |
|
||||
|
||||
### 6. `src/infra/outbound/message-action-runner.ts` (1,082 → ~620 LOC)
|
||||
|
||||
| Extract to | What moves | LOC |
|
||||
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---- |
|
||||
| `infra/outbound/message-action-runner.media.ts` | Attachment handling (max bytes, filename, base64, sandbox) + hydration (group icon, send attachment) | ~330 |
|
||||
| `infra/outbound/message-action-runner.context.ts` | Cross-context decoration + Slack/Telegram auto-threading | ~190 |
|
||||
|
||||
### 7. `src/tts/tts.ts` (1,445 → ~950 LOC, then follow-up)
|
||||
|
||||
| Extract to | What moves | LOC |
|
||||
| ----------------------- | -------------------------------------------------------- | ---- |
|
||||
| `tts/tts.directives.ts` | `parseTtsDirectives` + related types/constants | ~260 |
|
||||
| `tts/tts.providers.ts` | `elevenLabsTTS`, `openaiTTS`, `edgeTTS`, `summarizeText` | ~200 |
|
||||
| `tts/tts.prefs.ts` | 15 TTS preference get/set functions | ~165 |
|
||||
|
||||
**Note:** Still ~955 LOC after this. A second pass could extract config resolution (~100 LOC) into `tts-config.ts`.
|
||||
|
||||
### 8. `src/infra/exec-approvals.ts` (1,437 → ~700 LOC)
|
||||
|
||||
| Extract to | What moves | LOC |
|
||||
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- |
|
||||
| `infra/exec-approvals.shell.ts` | `iterateQuoteAware`, `splitShellPipeline`, `analyzeWindowsShellCommand`, `tokenizeWindowsSegment`, `analyzeShellCommand`, `analyzeArgvCommand` | ~250 |
|
||||
| `infra/exec-approvals.allowlist.ts` | `matchAllowlist`, `matchesPattern`, `globToRegExp`, `isSafeBinUsage`, `evaluateSegments`, `evaluateExecAllowlist`, `splitCommandChain`, `evaluateShellAllowlist` | ~350 |
|
||||
|
||||
**Note:** Still ~942 LOC. Follow-up: `exec-command-resolution.ts` (~220 LOC) and `exec-approvals-io.ts` (~200 LOC) would bring it under 700.
|
||||
|
||||
### 9. `src/cli/update-cli.ts` (1,245 → ~1,000 LOC)
|
||||
|
||||
| Extract to | What moves | LOC |
|
||||
| --------------------------- | ----------------------------------------------------------------------------------------- | ---- |
|
||||
| `cli/update-cli.helpers.ts` | Version/tag helpers, constants, shell completion, git checkout, global manager resolution | ~340 |
|
||||
|
||||
**Note:** The 3 command functions (`updateCommand`, `updateStatusCommand`, `updateWizardCommand`) are large but procedural with heavy shared context. Deeper splitting needs an interface layer.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Higher Risk / Structural Refactors
|
||||
|
||||
These files need more than "move functions" — they need closure variable threading, class decomposition, or handler-per-method patterns.
|
||||
|
||||
### 10. `src/memory/manager.ts` (2,280 → ~1,300 LOC, then follow-up)
|
||||
|
||||
| Extract to | What moves | LOC |
|
||||
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---- |
|
||||
| `memory/manager.embedding.ts` | `embedChunksWithVoyageBatch`, `embedChunksWithOpenAiBatch`, `embedChunksWithGeminiBatch` (3 functions ~90% identical — **dedup opportunity**) | ~600 |
|
||||
| `memory/manager.batch.ts` | `embedBatchWithRetry`, `runBatchWithFallback`, `runBatchWithTimeoutRetry`, `recordBatchFailure`, `resetBatchFailureCount` | ~300 |
|
||||
| `memory/manager.cache.ts` | `loadEmbeddingCache`, `upsertEmbeddingCache`, `computeProviderKey` | ~150 |
|
||||
|
||||
**Key insight:** The 3 provider embedding methods share ~90% identical structure. After extraction, refactor into a single generic `embedChunksWithProvider(config)` with provider-specific config objects. This is both a size and a logic DRY win.
|
||||
|
||||
**Still ~1,362 LOC** — session sync + search could be a follow-up split.
|
||||
|
||||
### 11. `src/agents/bash-tools.exec.ts` (1,546 → ~1,000 LOC)
|
||||
|
||||
| Extract to | What moves | LOC |
|
||||
| ----------------------------------- | ---------------------------------------------------------------- | ---- |
|
||||
| `agents/bash-tools.exec.process.ts` | `runExecProcess` + supporting spawn helpers | ~400 |
|
||||
| `agents/bash-tools.exec.helpers.ts` | Security constants, `validateHostEnv`, normalizers, PATH helpers | ~200 |
|
||||
|
||||
**Challenge:** `runExecProcess` reads closure variables from `createExecTool`. Extraction requires passing explicit params.
|
||||
|
||||
### 12. `src/gateway/server/ws-connection/message-handler.ts` (970 → ~720 LOC)
|
||||
|
||||
| Extract to | What moves | LOC |
|
||||
| ------------------------------------------ | --------------------------------------- | ---- |
|
||||
| `ws-connection/message-handler.auth.ts` | Device signature/nonce/key verification | ~180 |
|
||||
| `ws-connection/message-handler.pairing.ts` | Pairing flow | ~110 |
|
||||
|
||||
**Challenge:** Everything is inside a single deeply-nested closure sharing `send`, `close`, `frame`, `connectParams`. Extraction requires threading many parameters. Consider refactoring to a class or state machine first.
|
||||
|
||||
---
|
||||
|
||||
## UI Files
|
||||
|
||||
### 13. `ui/src/ui/views/usage.ts` (3,076 → ~1,200 LOC)
|
||||
|
||||
| Extract to | What moves | LOC |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------ | ---- |
|
||||
| `views/usage.aggregation.ts` | Data builders, CSV export, query engine | ~550 |
|
||||
| `views/usage.charts.ts` | `renderDailyChartCompact`, `renderCostBreakdown`, `renderTimeSeriesCompact`, `renderUsageMosaic` | ~600 |
|
||||
| `views/usage.sessions.ts` | `renderSessionsCard`, `renderSessionDetailPanel`, `renderSessionLogsCompact` | ~800 |
|
||||
|
||||
### 14. `ui/src/ui/views/agents.ts` (1,894 → ~950 LOC)
|
||||
|
||||
| Extract to | What moves | LOC |
|
||||
| -------------------------- | ------------------------------------- | ---- |
|
||||
| `views/agents.tools.ts` | Tools panel + policy matching helpers | ~350 |
|
||||
| `views/agents.skills.ts` | Skills panel + grouping logic | ~280 |
|
||||
| `views/agents.channels.ts` | Channels + cron panels | ~380 |
|
||||
|
||||
### 15. `ui/src/ui/views/nodes.ts` (1,118 → ~440 LOC)
|
||||
|
||||
| Extract to | What moves | LOC |
|
||||
| ------------------------------- | ------------------------------------------- | ---- |
|
||||
| `views/nodes.exec-approvals.ts` | Exec approvals rendering + state resolution | ~500 |
|
||||
| `views/nodes.devices.ts` | Device management rendering | ~230 |
|
||||
|
||||
---
|
||||
|
||||
## Extension: BlueBubbles
|
||||
|
||||
### 16. `extensions/bluebubbles/src/monitor.ts` (2,348 → ~650 LOC)
|
||||
|
||||
| Extract to | What moves | LOC |
|
||||
| ---------------------------------- | ----------------------------------------------------------------------------------------------- | ------ |
|
||||
| `monitor.normalize.ts` | `normalizeWebhookMessage`, `normalizeWebhookReaction`, field extractors, participant resolution | ~500 |
|
||||
| `monitor.debounce.ts` | Debounce infrastructure, combine/flush logic | ~200 |
|
||||
| `monitor.webhook.ts` | `handleBlueBubblesWebhookRequest` + registration | ~1,050 |
|
||||
| Merge into existing `reactions.ts` | tapback parsing, reaction normalization | ~120 |
|
||||
|
||||
**Key insight:** Message/reaction normalization share ~300 lines of near-identical field extraction — dedup opportunity similar to memory providers.
|
||||
|
||||
---
|
||||
|
||||
## Execution Plan
|
||||
|
||||
| Wave | Files | Total extractable LOC | Est. effort | Status |
|
||||
| ----------- | -------------------------------------------------------------- | --------------------- | ------------ | ------------------------------------- |
|
||||
| **Wave 1** | #1–#4 (schema, audit-extra, session-cost, media-understanding) | ~2,600 | 1 session | ✅ #1 done, ✅ #2 done, #3–#4 pending |
|
||||
| **Wave 2a** | #5–#6 (heartbeat, message-action-runner) | ~990 | 1 session | Not started |
|
||||
| **Wave 2b** | #7–#9 (tts, exec-approvals, update-cli) | ~1,565 | 1–2 sessions | Not started |
|
||||
| **Wave 3** | #10–#12 (memory, bash-tools, message-handler) | ~1,830 | 2 sessions | Not started |
|
||||
| **Wave 4** | #13–#16 (UI + BlueBubbles) | ~4,560 | 2–3 sessions | Not started |
|
||||
|
||||
### Ground Rules
|
||||
|
||||
1. **No behavior changes.** Every extraction is a pure structural move + import update.
|
||||
2. **Tests must pass.** Run `pnpm test` after each file extraction.
|
||||
3. **Imports only.** New files re-export from old paths if needed to avoid breaking external consumers.
|
||||
4. **Dot-naming convention.** Use `<base>.<concern>.ts` pattern (e.g., `runner.binary.ts`, not `binary-resolve.ts`).
|
||||
5. **Centralized patterns over scatter.** Prefer 2 logical groupings (e.g., sync vs async) over 3-4 domain-specific fragments.
|
||||
6. **Update colocated tests.** If `foo.test.ts` imports from `foo.ts`, update imports to the new module.
|
||||
7. **CI gate.** Each PR must pass `pnpm build && pnpm check && pnpm test`.
|
||||
|
||||
---
|
||||
|
||||
## Metrics
|
||||
|
||||
After all waves complete, the expected result:
|
||||
|
||||
| Metric | Before | After (est.) |
|
||||
| ------------------------------- | ------ | -------------------------- |
|
||||
| Files > 1,000 LOC (non-test TS) | 17 | ~5 |
|
||||
| Files > 700 LOC (non-test TS) | 50+ | ~15–20 |
|
||||
| New files created | 0 | ~35 |
|
||||
| Net LOC change | 0 | ~0 (moves only) |
|
||||
| Largest core `src/` file | 2,280 | ~1,300 (memory/manager.ts) |
|
||||
Reference in New Issue
Block a user