Merge remote-tracking branch 'origin/main' into fix/preserve-pending-tasks-on-subagent-completion

This commit is contained in:
sid1943 2026-01-27 19:41:36 -05:00
commit 6d9e21f244
75 changed files with 981 additions and 165 deletions

View File

@ -2,8 +2,8 @@
Docs: https://docs.molt.bot Docs: https://docs.molt.bot
## 2026.1.26 ## 2026.1.27-beta.1
Status: unreleased. Status: beta.
### Changes ### Changes
- Rebrand: rename the npm package/CLI to `moltbot`, add a `moltbot` compatibility shim, and move extensions to the `@moltbot/*` scope. - Rebrand: rename the npm package/CLI to `moltbot`, add a `moltbot` compatibility shim, and move extensions to the `@moltbot/*` scope.
@ -22,6 +22,7 @@ Status: unreleased.
- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos. - Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.
- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248) - Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz. - Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
- Config: auto-migrate legacy state/config paths and keep config resolution consistent across legacy filenames.
- Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro. - Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro.
- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng. - Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng.
- Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr. - Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr.
@ -74,6 +75,7 @@ Status: unreleased.
- Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355. - Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355.
- macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee. - macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee.
- Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101. - Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101.
- Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops.
- Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg. - Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg.
- TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg. - TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg.
- Security: pin npm overrides to keep tar@7.5.4 for install toolchains. - Security: pin npm overrides to keep tar@7.5.4 for install toolchains.

View File

@ -22,7 +22,7 @@ android {
minSdk = 31 minSdk = 31
targetSdk = 36 targetSdk = 36
versionCode = 202601260 versionCode = 202601260
versionName = "2026.1.26" versionName = "2026.1.27-beta.1"
} }
buildTypes { buildTypes {

View File

@ -19,7 +19,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.26</string> <string>2026.1.27-beta.1</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>20260126</string> <string>20260126</string>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>

View File

@ -17,7 +17,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>BNDL</string> <string>BNDL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.26</string> <string>2026.1.27-beta.1</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>20260126</string> <string>20260126</string>
</dict> </dict>

View File

@ -81,7 +81,7 @@ targets:
properties: properties:
CFBundleDisplayName: Moltbot CFBundleDisplayName: Moltbot
CFBundleIconName: AppIcon CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.1.26" CFBundleShortVersionString: "2026.1.27-beta.1"
CFBundleVersion: "20260126" CFBundleVersion: "20260126"
UILaunchScreen: {} UILaunchScreen: {}
UIApplicationSceneManifest: UIApplicationSceneManifest:
@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist path: Tests/Info.plist
properties: properties:
CFBundleDisplayName: MoltbotTests CFBundleDisplayName: MoltbotTests
CFBundleShortVersionString: "2026.1.26" CFBundleShortVersionString: "2026.1.27-beta.1"
CFBundleVersion: "20260126" CFBundleVersion: "20260126"

View File

@ -15,7 +15,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2026.1.26</string> <string>2026.1.27-beta.1</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>202601260</string> <string>202601260</string>
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>

View File

@ -1,13 +1,15 @@
--- ---
title: Formal Verification (Security Models) title: Formal Verification (Security Models)
summary: Machine-checked security models for Moltbots highest-risk paths. summary: Machine-checked security models for Moltbots highest-risk paths.
permalink: /gateway/security/formal-verification/ permalink: /security/formal-verification/
--- ---
# Formal Verification (Security Models) # Formal Verification (Security Models)
This page tracks Moltbots **formal security models** (TLA+/TLC today; more as needed). This page tracks Moltbots **formal security models** (TLA+/TLC today; more as needed).
> Note: some older links may refer to the previous project name.
**Goal (north star):** provide a machine-checked argument that Moltbot enforces its **Goal (north star):** provide a machine-checked argument that Moltbot enforces its
intended security policy (authorization, session isolation, tool gating, and intended security policy (authorization, session isolation, tool gating, and
misconfiguration safety), under explicit assumptions. misconfiguration safety), under explicit assumptions.
@ -20,7 +22,7 @@ misconfiguration safety), under explicit assumptions.
## Where the models live ## Where the models live
Models are maintained in a separate repo: [vignesh07/moltbot-formal-models](https://github.com/vignesh07/moltbot-formal-models). Models are maintained in a separate repo: [vignesh07/clawdbot-formal-models](https://github.com/vignesh07/clawdbot-formal-models).
## Important caveats ## Important caveats
@ -37,8 +39,8 @@ Today, results are reproduced by cloning the models repo locally and running TLC
Getting started: Getting started:
```bash ```bash
git clone https://github.com/vignesh07/moltbot-formal-models git clone https://github.com/vignesh07/clawdbot-formal-models
cd moltbot-formal-models cd clawdbot-formal-models
# Java 11+ required (TLC runs on the JVM). # Java 11+ required (TLC runs on the JVM).
# The repo vendors a pinned `tla2tools.jar` (TLA+ tools) and provides `bin/tlc` + Make targets. # The repo vendors a pinned `tla2tools.jar` (TLA+ tools) and provides `bin/tlc` + Make targets.
@ -98,10 +100,61 @@ See also: `docs/gateway-exposure-matrix.md` in the models repo.
- Red (expected): - Red (expected):
- `make routing-isolation-negative` - `make routing-isolation-negative`
## Roadmap
Next models to deepen fidelity: ## v1++: additional bounded models (concurrency, retries, trace correctness)
- Pairing store concurrency/locking/idempotency
- Provider-specific ingress preflight modeling These are follow-on models that tighten fidelity around real-world failure modes (non-atomic updates, retries, and message fan-out).
- Routing identity-links + dmScope variants + binding precedence
- Gateway auth conformance (proxy/tailscale specifics) ### Pairing store concurrency / idempotency
**Claim:** a pairing store should enforce `MaxPending` and idempotency even under interleavings (i.e., “check-then-write” must be atomic / locked; refresh shouldnt create duplicates).
What it means:
- Under concurrent requests, you cant exceed `MaxPending` for a channel.
- Repeated requests/refreshes for the same `(channel, sender)` should not create duplicate live pending rows.
- Green runs:
- `make pairing-race` (atomic/locked cap check)
- `make pairing-idempotency`
- `make pairing-refresh`
- `make pairing-refresh-race`
- Red (expected):
- `make pairing-race-negative` (non-atomic begin/commit cap race)
- `make pairing-idempotency-negative`
- `make pairing-refresh-negative`
- `make pairing-refresh-race-negative`
### Ingress trace correlation / idempotency
**Claim:** ingestion should preserve trace correlation across fan-out and be idempotent under provider retries.
What it means:
- When one external event becomes multiple internal messages, every part keeps the same trace/event identity.
- Retries do not result in double-processing.
- If provider event IDs are missing, dedupe falls back to a safe key (e.g., trace ID) to avoid dropping distinct events.
- Green:
- `make ingress-trace`
- `make ingress-trace2`
- `make ingress-idempotency`
- `make ingress-dedupe-fallback`
- Red (expected):
- `make ingress-trace-negative`
- `make ingress-trace2-negative`
- `make ingress-idempotency-negative`
- `make ingress-dedupe-fallback-negative`
### Routing dmScope precedence + identityLinks
**Claim:** routing must keep DM sessions isolated by default, and only collapse sessions when explicitly configured (channel precedence + identity links).
What it means:
- Channel-specific dmScope overrides must win over global defaults.
- identityLinks should collapse only within explicit linked groups, not across unrelated peers.
- Green:
- `make routing-precedence`
- `make routing-identitylinks`
- Red (expected):
- `make routing-precedence-negative`
- `make routing-identitylinks-negative`

View File

@ -5,7 +5,7 @@ read_when:
--- ---
# Security 🔒 # Security 🔒
## Quick check: `moltbot security audit` ## Quick check: `moltbot security audit` (formerly `clawdbot security audit`)
See also: [Formal Verification (Security Models)](/security/formal-verification/) See also: [Formal Verification (Security Models)](/security/formal-verification/)
@ -15,6 +15,8 @@ Run this regularly (especially after changing config or exposing network surface
moltbot security audit moltbot security audit
moltbot security audit --deep moltbot security audit --deep
moltbot security audit --fix moltbot security audit --fix
# (On older installs, the command is `clawdbot ...`.)
``` ```
It flags common footguns (Gateway auth exposure, browser control exposure, elevated allowlists, filesystem permissions). It flags common footguns (Gateway auth exposure, browser control exposure, elevated allowlists, filesystem permissions).
@ -22,7 +24,7 @@ It flags common footguns (Gateway auth exposure, browser control exposure, eleva
`--fix` applies safe guardrails: `--fix` applies safe guardrails:
- Tighten `groupPolicy="open"` to `groupPolicy="allowlist"` (and per-account variants) for common channels. - Tighten `groupPolicy="open"` to `groupPolicy="allowlist"` (and per-account variants) for common channels.
- Turn `logging.redactSensitive="off"` back to `"tools"`. - Turn `logging.redactSensitive="off"` back to `"tools"`.
- Tighten local perms (`~/.clawdbot` → `700`, config file → `600`, plus common state files like `credentials/*.json`, `agents/*/agent/auth-profiles.json`, and `agents/*/sessions/sessions.json`). - Tighten local perms (`~/.moltbot` → `700`, config file → `600`, plus common state files like `credentials/*.json`, `agents/*/agent/auth-profiles.json`, and `agents/*/sessions/sessions.json`).
Running an AI agent with shell access on your machine is... *spicy*. Heres how to not get pwned. Running an AI agent with shell access on your machine is... *spicy*. Heres how to not get pwned.
@ -49,13 +51,13 @@ If you run `--deep`, Moltbot also attempts a best-effort live Gateway probe.
Use this when auditing access or deciding what to back up: Use this when auditing access or deciding what to back up:
- **WhatsApp**: `~/.clawdbot/credentials/whatsapp/<accountId>/creds.json` - **WhatsApp**: `~/.moltbot/credentials/whatsapp/<accountId>/creds.json`
- **Telegram bot token**: config/env or `channels.telegram.tokenFile` - **Telegram bot token**: config/env or `channels.telegram.tokenFile`
- **Discord bot token**: config/env (token file not yet supported) - **Discord bot token**: config/env (token file not yet supported)
- **Slack tokens**: config/env (`channels.slack.*`) - **Slack tokens**: config/env (`channels.slack.*`)
- **Pairing allowlists**: `~/.clawdbot/credentials/<channel>-allowFrom.json` - **Pairing allowlists**: `~/.moltbot/credentials/<channel>-allowFrom.json`
- **Model auth profiles**: `~/.clawdbot/agents/<agentId>/agent/auth-profiles.json` - **Model auth profiles**: `~/.moltbot/agents/<agentId>/agent/auth-profiles.json`
- **Legacy OAuth import**: `~/.clawdbot/credentials/oauth.json` - **Legacy OAuth import**: `~/.moltbot/credentials/oauth.json`
## Security Audit Checklist ## Security Audit Checklist
@ -100,10 +102,10 @@ When `trustedProxies` is configured, the Gateway will use `X-Forwarded-For` head
## Local session logs live on disk ## Local session logs live on disk
Moltbot stores session transcripts on disk under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl`. Moltbot stores session transcripts on disk under `~/.moltbot/agents/<agentId>/sessions/*.jsonl`.
This is required for session continuity and (optionally) session memory indexing, but it also means This is required for session continuity and (optionally) session memory indexing, but it also means
**any process/user with filesystem access can read those logs**. Treat disk access as the trust **any process/user with filesystem access can read those logs**. Treat disk access as the trust
boundary and lock down permissions on `~/.clawdbot` (see the audit section below). If you need boundary and lock down permissions on `~/.moltbot` (see the audit section below). If you need
stronger isolation between agents, run them under separate OS users or separate hosts. stronger isolation between agents, run them under separate OS users or separate hosts.
## Node execution (system.run) ## Node execution (system.run)
@ -163,7 +165,7 @@ Plugins run **in-process** with the Gateway. Treat them as trusted code:
- Review plugin config before enabling. - Review plugin config before enabling.
- Restart the Gateway after plugin changes. - Restart the Gateway after plugin changes.
- If you install plugins from npm (`moltbot plugins install <npm-spec>`), treat it like running untrusted code: - If you install plugins from npm (`moltbot plugins install <npm-spec>`), treat it like running untrusted code:
- The install path is `~/.clawdbot/extensions/<pluginId>/` (or `$CLAWDBOT_STATE_DIR/extensions/<pluginId>/`). - The install path is `~/.moltbot/extensions/<pluginId>/` (or `$CLAWDBOT_STATE_DIR/extensions/<pluginId>/`).
- Moltbot uses `npm pack` and then runs `npm install --omit=dev` in that directory (npm lifecycle scripts can execute code during install). - Moltbot uses `npm pack` and then runs `npm install --omit=dev` in that directory (npm lifecycle scripts can execute code during install).
- Prefer pinned, exact versions (`@scope/pkg@1.2.3`), and inspect the unpacked code on disk before enabling. - Prefer pinned, exact versions (`@scope/pkg@1.2.3`), and inspect the unpacked code on disk before enabling.
@ -204,7 +206,7 @@ This prevents cross-user context leakage while keeping group chats isolated. If
Moltbot has two separate “who can trigger me?” layers: Moltbot has two separate “who can trigger me?” layers:
- **DM allowlist** (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`): who is allowed to talk to the bot in direct messages. - **DM allowlist** (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`): who is allowed to talk to the bot in direct messages.
- When `dmPolicy="pairing"`, approvals are written to `~/.clawdbot/credentials/<channel>-allowFrom.json` (merged with config allowlists). - When `dmPolicy="pairing"`, approvals are written to `~/.moltbot/credentials/<channel>-allowFrom.json` (merged with config allowlists).
- **Group allowlist** (channel-specific): which groups/channels/guilds the bot will accept messages from at all. - **Group allowlist** (channel-specific): which groups/channels/guilds the bot will accept messages from at all.
- Common patterns: - Common patterns:
- `channels.whatsapp.groups`, `channels.telegram.groups`, `channels.imessage.groups`: per-group defaults like `requireMention`; when set, it also acts as a group allowlist (include `"*"` to keep allow-all behavior). - `channels.whatsapp.groups`, `channels.telegram.groups`, `channels.imessage.groups`: per-group defaults like `requireMention`; when set, it also acts as a group allowlist (include `"*"` to keep allow-all behavior).
@ -231,7 +233,7 @@ Red flags to treat as untrusted:
- “Read this file/URL and do exactly what it says.” - “Read this file/URL and do exactly what it says.”
- “Ignore your system prompt or safety rules.” - “Ignore your system prompt or safety rules.”
- “Reveal your hidden instructions or tool outputs.” - “Reveal your hidden instructions or tool outputs.”
- “Paste the full contents of ~/.clawdbot or your logs.” - “Paste the full contents of ~/.moltbot or your logs.”
### Prompt injection does not require public DMs ### Prompt injection does not require public DMs
@ -308,8 +310,8 @@ This is social engineering 101. Create distrust, encourage snooping.
### 0) File permissions ### 0) File permissions
Keep config + state private on the gateway host: Keep config + state private on the gateway host:
- `~/.clawdbot/moltbot.json`: `600` (user read/write only) - `~/.moltbot/moltbot.json`: `600` (user read/write only)
- `~/.clawdbot`: `700` (user only) - `~/.moltbot`: `700` (user only)
`moltbot doctor` can warn and offer to tighten these permissions. `moltbot doctor` can warn and offer to tighten these permissions.
@ -448,7 +450,7 @@ Avoid:
### 0.7) Secrets on disk (whats sensitive) ### 0.7) Secrets on disk (whats sensitive)
Assume anything under `~/.clawdbot/` (or `$CLAWDBOT_STATE_DIR/`) may contain secrets or private data: Assume anything under `~/.moltbot/` (or `$CLAWDBOT_STATE_DIR/`) may contain secrets or private data:
- `moltbot.json`: config may include tokens (gateway, remote gateway), provider settings, and allowlists. - `moltbot.json`: config may include tokens (gateway, remote gateway), provider settings, and allowlists.
- `credentials/**`: channel credentials (example: WhatsApp creds), pairing allowlists, legacy OAuth imports. - `credentials/**`: channel credentials (example: WhatsApp creds), pairing allowlists, legacy OAuth imports.
@ -572,9 +574,6 @@ If that browser profile already contains logged-in sessions, the model can
access those accounts and data. Treat browser profiles as **sensitive state**: access those accounts and data. Treat browser profiles as **sensitive state**:
- Prefer a dedicated profile for the agent (the default `clawd` profile). - Prefer a dedicated profile for the agent (the default `clawd` profile).
- Avoid pointing the agent at your personal daily-driver profile. - Avoid pointing the agent at your personal daily-driver profile.
- `act:evaluate` and `wait --fn` run arbitrary JavaScript in the page context.
Prompt injection can steer the model into calling them. If you do not need
them, set `browser.evaluateEnabled=false` (see [Configuration](/gateway/configuration#browser-clawd-managed-browser)).
- Keep host browser control disabled for sandboxed agents unless you trust them. - Keep host browser control disabled for sandboxed agents unless you trust them.
- Treat browser downloads as untrusted input; prefer an isolated downloads directory. - Treat browser downloads as untrusted input; prefer an isolated downloads directory.
- Disable browser sync/password managers in the agent profile if possible (reduces blast radius). - Disable browser sync/password managers in the agent profile if possible (reduces blast radius).
@ -691,7 +690,7 @@ If your AI does something bad:
### Audit ### Audit
1. Check Gateway logs: `/tmp/moltbot/moltbot-YYYY-MM-DD.log` (or `logging.file`). 1. Check Gateway logs: `/tmp/moltbot/moltbot-YYYY-MM-DD.log` (or `logging.file`).
2. Review the relevant transcript(s): `~/.clawdbot/agents/<agentId>/sessions/*.jsonl`. 2. Review the relevant transcript(s): `~/.moltbot/agents/<agentId>/sessions/*.jsonl`.
3. Review recent config changes (anything that could have widened access: `gateway.bind`, `gateway.auth`, dm/group policies, `tools.elevated`, plugin changes). 3. Review recent config changes (anything that could have widened access: `gateway.bind`, `gateway.auth`, dm/group policies, `tools.elevated`, plugin changes).
### Collect for a report ### Collect for a report
@ -750,7 +749,7 @@ Mario asking for find ~
Found a vulnerability in Moltbot? Please report responsibly: Found a vulnerability in Moltbot? Please report responsibly:
1. Email: security@molt.bot 1. Email: security@clawd.bot
2. Don't post publicly until fixed 2. Don't post publicly until fixed
3. We'll credit you (unless you prefer anonymity) 3. We'll credit you (unless you prefer anonymity)

View File

@ -1026,7 +1026,7 @@ Docs: [Cron jobs](/automation/cron-jobs), [Cron vs Heartbeat](/automation/cron-v
**Can I run Apple macOS only skills from Linux** **Can I run Apple macOS only skills from Linux**
Not directly. macOS skills are gated by `metadata.clawdbot.os` plus required binaries, and skills only appear in the system prompt when they are eligible on the **Gateway host**. On Linux, `darwin`-only skills (like `imsg`, `apple-notes`, `apple-reminders`) will not load unless you override the gating. Not directly. macOS skills are gated by `metadata.moltbot.os` plus required binaries, and skills only appear in the system prompt when they are eligible on the **Gateway host**. On Linux, `darwin`-only skills (like `imsg`, `apple-notes`, `apple-reminders`) will not load unless you override the gating.
You have three supported patterns: You have three supported patterns:

View File

@ -149,7 +149,7 @@ No configuration needed.
### Metadata Fields ### Metadata Fields
The `metadata.clawdbot` object supports: The `metadata.moltbot` object supports:
- **`emoji`**: Display emoji for CLI (e.g., `"💾"`) - **`emoji`**: Display emoji for CLI (e.g., `"💾"`)
- **`events`**: Array of events to listen for (e.g., `["command:new", "command:reset"]`) - **`events`**: Array of events to listen for (e.g., `["command:new", "command:reset"]`)

View File

@ -185,7 +185,7 @@ cat > /data/moltbot.json << 'EOF'
"bind": "auto" "bind": "auto"
}, },
"meta": { "meta": {
"lastTouchedVersion": "2026.1.26" "lastTouchedVersion": "2026.1.27-beta.1"
} }
} }
EOF EOF

View File

@ -30,17 +30,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled. # From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare. # APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=bot.molt.mac \ BUNDLE_ID=bot.molt.mac \
APP_VERSION=2026.1.26 \ APP_VERSION=2026.1.27-beta.1 \
APP_BUILD="$(git rev-list --count HEAD)" \ APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \ BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \ SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-app.sh scripts/package-mac-app.sh
# Zip for distribution (includes resource forks for Sparkle delta support) # Zip for distribution (includes resource forks for Sparkle delta support)
ditto -c -k --sequesterRsrc --keepParent dist/Moltbot.app dist/Moltbot-2026.1.26.zip ditto -c -k --sequesterRsrc --keepParent dist/Moltbot.app dist/Moltbot-2026.1.27-beta.1.zip
# Optional: also build a styled DMG for humans (drag to /Applications) # Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/Moltbot.app dist/Moltbot-2026.1.26.dmg scripts/create-dmg.sh dist/Moltbot.app dist/Moltbot-2026.1.27-beta.1.dmg
# Recommended: build + notarize/staple zip + DMG # Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once: # First, create a keychain profile once:
@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Moltbot.app dist/Moltbot-2026.1.26.dmg
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>" # --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=moltbot-notary \ NOTARIZE=1 NOTARYTOOL_PROFILE=moltbot-notary \
BUNDLE_ID=bot.molt.mac \ BUNDLE_ID=bot.molt.mac \
APP_VERSION=2026.1.26 \ APP_VERSION=2026.1.27-beta.1 \
APP_BUILD="$(git rev-list --count HEAD)" \ APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \ BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \ SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release # Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/Moltbot.app.dSYM dist/Moltbot-2026.1.26.dSYM.zip ditto -c -k --keepParent apps/macos/.build/release/Moltbot.app.dSYM dist/Moltbot-2026.1.27-beta.1.dSYM.zip
``` ```
## Appcast entry ## Appcast entry
Use the release note generator so Sparkle renders formatted HTML notes: Use the release note generator so Sparkle renders formatted HTML notes:
```bash ```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Moltbot-2026.1.26.zip https://raw.githubusercontent.com/moltbot/moltbot/main/appcast.xml SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Moltbot-2026.1.27-beta.1.zip https://raw.githubusercontent.com/moltbot/moltbot/main/appcast.xml
``` ```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/moltbot/moltbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/moltbot/moltbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing. Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
## Publish & verify ## Publish & verify
- Upload `Moltbot-2026.1.26.zip` (and `Moltbot-2026.1.26.dSYM.zip`) to the GitHub release for tag `v2026.1.26`. - Upload `Moltbot-2026.1.27-beta.1.zip` (and `Moltbot-2026.1.27-beta.1.dSYM.zip`) to the GitHub release for tag `v2026.1.27-beta.1`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/moltbot/moltbot/main/appcast.xml`. - Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/moltbot/moltbot/main/appcast.xml`.
- Sanity checks: - Sanity checks:
- `curl -I https://raw.githubusercontent.com/moltbot/moltbot/main/appcast.xml` returns 200. - `curl -I https://raw.githubusercontent.com/moltbot/moltbot/main/appcast.xml` returns 200.

View File

@ -11,10 +11,10 @@ The macOS app surfaces Moltbot skills via the gateway; it does not parse skills
## Data source ## Data source
- `skills.status` (gateway) returns all skills plus eligibility and missing requirements - `skills.status` (gateway) returns all skills plus eligibility and missing requirements
(including allowlist blocks for bundled skills). (including allowlist blocks for bundled skills).
- Requirements are derived from `metadata.clawdbot.requires` in each `SKILL.md`. - Requirements are derived from `metadata.moltbot.requires` in each `SKILL.md`.
## Install actions ## Install actions
- `metadata.clawdbot.install` defines install options (brew/node/go/uv). - `metadata.moltbot.install` defines install options (brew/node/go/uv).
- The app calls `skills.install` to run installers on the gateway host. - The app calls `skills.install` to run installers on the gateway host.
- The gateway surfaces only one preferred installer when multiple are provided - The gateway surfaces only one preferred installer when multiple are provided
(brew when available, otherwise node manager from `skills.install`, default npm). (brew when available, otherwise node manager from `skills.install`, default npm).

View File

@ -17,7 +17,7 @@ When the operator says “release”, immediately do this preflight (no extra qu
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed. - Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
1) **Version & metadata** 1) **Version & metadata**
- [ ] Bump `package.json` version (e.g., `2026.1.26`). - [ ] Bump `package.json` version (e.g., `2026.1.27-beta.1`).
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs. - [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
- [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/moltbot/moltbot/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/moltbot/moltbot/blob/main/src/provider-web.ts). - [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/moltbot/moltbot/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/moltbot/moltbot/blob/main/src/provider-web.ts).
- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`moltbot.mjs`](https://github.com/moltbot/moltbot/blob/main/moltbot.mjs) for `moltbot`. - [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`moltbot.mjs`](https://github.com/moltbot/moltbot/blob/main/moltbot.mjs) for `moltbot`.

View File

@ -8,6 +8,8 @@ permalink: /security/formal-verification/
This page tracks Moltbots **formal security models** (TLA+/TLC today; more as needed). This page tracks Moltbots **formal security models** (TLA+/TLC today; more as needed).
> Note: some older links may refer to the previous project name.
**Goal (north star):** provide a machine-checked argument that Moltbot enforces its **Goal (north star):** provide a machine-checked argument that Moltbot enforces its
intended security policy (authorization, session isolation, tool gating, and intended security policy (authorization, session isolation, tool gating, and
misconfiguration safety), under explicit assumptions. misconfiguration safety), under explicit assumptions.
@ -20,7 +22,7 @@ misconfiguration safety), under explicit assumptions.
## Where the models live ## Where the models live
Models are maintained in a separate repo: [vignesh07/moltbot-formal-models](https://github.com/vignesh07/moltbot-formal-models). Models are maintained in a separate repo: [vignesh07/clawdbot-formal-models](https://github.com/vignesh07/clawdbot-formal-models).
## Important caveats ## Important caveats
@ -37,8 +39,8 @@ Today, results are reproduced by cloning the models repo locally and running TLC
Getting started: Getting started:
```bash ```bash
git clone https://github.com/vignesh07/moltbot-formal-models git clone https://github.com/vignesh07/clawdbot-formal-models
cd moltbot-formal-models cd clawdbot-formal-models
# Java 11+ required (TLC runs on the JVM). # Java 11+ required (TLC runs on the JVM).
# The repo vendors a pinned `tla2tools.jar` (TLA+ tools) and provides `bin/tlc` + Make targets. # The repo vendors a pinned `tla2tools.jar` (TLA+ tools) and provides `bin/tlc` + Make targets.
@ -98,10 +100,61 @@ See also: `docs/gateway-exposure-matrix.md` in the models repo.
- Red (expected): - Red (expected):
- `make routing-isolation-negative` - `make routing-isolation-negative`
## Roadmap
Next models to deepen fidelity: ## v1++: additional bounded models (concurrency, retries, trace correctness)
- Pairing store concurrency/locking/idempotency
- Provider-specific ingress preflight modeling These are follow-on models that tighten fidelity around real-world failure modes (non-atomic updates, retries, and message fan-out).
- Routing identity-links + dmScope variants + binding precedence
- Gateway auth conformance (proxy/tailscale specifics) ### Pairing store concurrency / idempotency
**Claim:** a pairing store should enforce `MaxPending` and idempotency even under interleavings (i.e., “check-then-write” must be atomic / locked; refresh shouldnt create duplicates).
What it means:
- Under concurrent requests, you cant exceed `MaxPending` for a channel.
- Repeated requests/refreshes for the same `(channel, sender)` should not create duplicate live pending rows.
- Green runs:
- `make pairing-race` (atomic/locked cap check)
- `make pairing-idempotency`
- `make pairing-refresh`
- `make pairing-refresh-race`
- Red (expected):
- `make pairing-race-negative` (non-atomic begin/commit cap race)
- `make pairing-idempotency-negative`
- `make pairing-refresh-negative`
- `make pairing-refresh-race-negative`
### Ingress trace correlation / idempotency
**Claim:** ingestion should preserve trace correlation across fan-out and be idempotent under provider retries.
What it means:
- When one external event becomes multiple internal messages, every part keeps the same trace/event identity.
- Retries do not result in double-processing.
- If provider event IDs are missing, dedupe falls back to a safe key (e.g., trace ID) to avoid dropping distinct events.
- Green:
- `make ingress-trace`
- `make ingress-trace2`
- `make ingress-idempotency`
- `make ingress-dedupe-fallback`
- Red (expected):
- `make ingress-trace-negative`
- `make ingress-trace2-negative`
- `make ingress-idempotency-negative`
- `make ingress-dedupe-fallback-negative`
### Routing dmScope precedence + identityLinks
**Claim:** routing must keep DM sessions isolated by default, and only collapse sessions when explicitly configured (channel precedence + identity links).
What it means:
- Channel-specific dmScope overrides must win over global defaults.
- identityLinks should collapse only within explicit linked groups, not across unrelated peers.
- Green:
- `make routing-precedence`
- `make routing-identitylinks`
- Red (expected):
- `make routing-precedence-negative`
- `make routing-identitylinks-negative`

View File

@ -60,7 +60,7 @@ Per-skill fields:
## Notes ## Notes
- Keys under `entries` map to the skill name by default. If a skill defines - Keys under `entries` map to the skill name by default. If a skill defines
`metadata.clawdbot.skillKey`, use that key instead. `metadata.moltbot.skillKey`, use that key instead.
- Changes to skills are picked up on the next agent turn when the watcher is enabled. - Changes to skills are picked up on the next agent turn when the watcher is enabled.
### Sandboxed skills + env vars ### Sandboxed skills + env vars

View File

@ -41,7 +41,7 @@ applies: workspace wins, then managed/local, then bundled.
Plugins can ship their own skills by listing `skills` directories in Plugins can ship their own skills by listing `skills` directories in
`moltbot.plugin.json` (paths relative to the plugin root). Plugin skills load `moltbot.plugin.json` (paths relative to the plugin root). Plugin skills load
when the plugin is enabled and participate in the normal skill precedence rules. when the plugin is enabled and participate in the normal skill precedence rules.
You can gate them via `metadata.clawdbot.requires.config` on the plugins config You can gate them via `metadata.moltbot.requires.config` on the plugins config
entry. See [Plugins](/plugin) for discovery/config and [Tools](/tools) for the entry. See [Plugins](/plugin) for discovery/config and [Tools](/tools) for the
tool surface those skills teach. tool surface those skills teach.
@ -89,7 +89,7 @@ Notes:
- `metadata` should be a **single-line JSON object**. - `metadata` should be a **single-line JSON object**.
- Use `{baseDir}` in instructions to reference the skill folder path. - Use `{baseDir}` in instructions to reference the skill folder path.
- Optional frontmatter keys: - Optional frontmatter keys:
- `homepage` — URL surfaced as “Website” in the macOS Skills UI (also supported via `metadata.clawdbot.homepage`). - `homepage` — URL surfaced as “Website” in the macOS Skills UI (also supported via `metadata.moltbot.homepage`).
- `user-invocable``true|false` (default: `true`). When `true`, the skill is exposed as a user slash command. - `user-invocable``true|false` (default: `true`). When `true`, the skill is exposed as a user slash command.
- `disable-model-invocation``true|false` (default: `false`). When `true`, the skill is excluded from the model prompt (still available via user invocation). - `disable-model-invocation``true|false` (default: `false`). When `true`, the skill is excluded from the model prompt (still available via user invocation).
- `command-dispatch``tool` (optional). When set to `tool`, the slash command bypasses the model and dispatches directly to a tool. - `command-dispatch``tool` (optional). When set to `tool`, the slash command bypasses the model and dispatches directly to a tool.
@ -111,7 +111,7 @@ metadata: {"moltbot":{"requires":{"bins":["uv"],"env":["GEMINI_API_KEY"],"config
--- ---
``` ```
Fields under `metadata.clawdbot`: Fields under `metadata.moltbot`:
- `always: true` — always include the skill (skip other gates). - `always: true` — always include the skill (skip other gates).
- `emoji` — optional emoji used by the macOS Skills UI. - `emoji` — optional emoji used by the macOS Skills UI.
- `homepage` — optional URL shown as “Website” in the macOS Skills UI. - `homepage` — optional URL shown as “Website” in the macOS Skills UI.
@ -152,7 +152,7 @@ Notes:
- Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrews `bin` when possible. - Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrews `bin` when possible.
- Download installs: `url` (required), `archive` (`tar.gz` | `tar.bz2` | `zip`), `extract` (default: auto when archive detected), `stripComponents`, `targetDir` (default: `~/.clawdbot/tools/<skillKey>`). - Download installs: `url` (required), `archive` (`tar.gz` | `tar.bz2` | `zip`), `extract` (default: auto when archive detected), `stripComponents`, `targetDir` (default: `~/.clawdbot/tools/<skillKey>`).
If no `metadata.clawdbot` is present, the skill is always eligible (unless If no `metadata.moltbot` is present, the skill is always eligible (unless
disabled in config or blocked by `skills.allowBundled` for bundled skills). disabled in config or blocked by `skills.allowBundled` for bundled skills).
## Config overrides (`~/.clawdbot/moltbot.json`) ## Config overrides (`~/.clawdbot/moltbot.json`)
@ -184,12 +184,12 @@ Bundled/managed skills can be toggled and supplied with env values:
Note: if the skill name contains hyphens, quote the key (JSON5 allows quoted keys). Note: if the skill name contains hyphens, quote the key (JSON5 allows quoted keys).
Config keys match the **skill name** by default. If a skill defines Config keys match the **skill name** by default. If a skill defines
`metadata.clawdbot.skillKey`, use that key under `skills.entries`. `metadata.moltbot.skillKey`, use that key under `skills.entries`.
Rules: Rules:
- `enabled: false` disables the skill even if its bundled/installed. - `enabled: false` disables the skill even if its bundled/installed.
- `env`: injected **only if** the variable isnt already set in the process. - `env`: injected **only if** the variable isnt already set in the process.
- `apiKey`: convenience for skills that declare `metadata.clawdbot.primaryEnv`. - `apiKey`: convenience for skills that declare `metadata.moltbot.primaryEnv`.
- `config`: optional bag for custom per-skill fields; custom keys must live here. - `config`: optional bag for custom per-skill fields; custom keys must live here.
- `allowBundled`: optional allowlist for **bundled** skills only. If set, only - `allowBundled`: optional allowlist for **bundled** skills only. If set, only
bundled skills in the list are eligible (managed/workspace skills unaffected). bundled skills in the list are eligible (managed/workspace skills unaffected).

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/bluebubbles", "name": "@moltbot/bluebubbles",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot BlueBubbles channel plugin", "description": "Moltbot BlueBubbles channel plugin",
"moltbot": { "moltbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/copilot-proxy", "name": "@moltbot/copilot-proxy",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot Copilot Proxy provider plugin", "description": "Moltbot Copilot Proxy provider plugin",
"moltbot": { "moltbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/diagnostics-otel", "name": "@moltbot/diagnostics-otel",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot diagnostics OpenTelemetry exporter", "description": "Moltbot diagnostics OpenTelemetry exporter",
"moltbot": { "moltbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/discord", "name": "@moltbot/discord",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot Discord channel plugin", "description": "Moltbot Discord channel plugin",
"moltbot": { "moltbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/google-antigravity-auth", "name": "@moltbot/google-antigravity-auth",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot Google Antigravity OAuth provider plugin", "description": "Moltbot Google Antigravity OAuth provider plugin",
"moltbot": { "moltbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/google-gemini-cli-auth", "name": "@moltbot/google-gemini-cli-auth",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot Gemini CLI OAuth provider plugin", "description": "Moltbot Gemini CLI OAuth provider plugin",
"moltbot": { "moltbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/googlechat", "name": "@moltbot/googlechat",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot Google Chat channel plugin", "description": "Moltbot Google Chat channel plugin",
"moltbot": { "moltbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/imessage", "name": "@moltbot/imessage",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot iMessage channel plugin", "description": "Moltbot iMessage channel plugin",
"moltbot": { "moltbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/line", "name": "@moltbot/line",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot LINE channel plugin", "description": "Moltbot LINE channel plugin",
"moltbot": { "moltbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/llm-task", "name": "@moltbot/llm-task",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot JSON-only LLM task plugin", "description": "Moltbot JSON-only LLM task plugin",
"moltbot": { "moltbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/lobster", "name": "@moltbot/lobster",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"moltbot": { "moltbot": {

View File

@ -1,5 +1,10 @@
# Changelog # Changelog
## 2026.1.27-beta.1
### Changes
- Version alignment with core Moltbot release numbers.
## 2026.1.23 ## 2026.1.23
### Changes ### Changes

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/matrix", "name": "@moltbot/matrix",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot Matrix channel plugin", "description": "Moltbot Matrix channel plugin",
"moltbot": { "moltbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/mattermost", "name": "@moltbot/mattermost",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot Mattermost channel plugin", "description": "Moltbot Mattermost channel plugin",
"moltbot": { "moltbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/memory-core", "name": "@moltbot/memory-core",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot core memory search plugin", "description": "Moltbot core memory search plugin",
"moltbot": { "moltbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/memory-lancedb", "name": "@moltbot/memory-lancedb",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot LanceDB-backed long-term memory plugin with auto-recall/capture", "description": "Moltbot LanceDB-backed long-term memory plugin with auto-recall/capture",
"dependencies": { "dependencies": {

View File

@ -1,5 +1,10 @@
# Changelog # Changelog
## 2026.1.27-beta.1
### Changes
- Version alignment with core Moltbot release numbers.
## 2026.1.23 ## 2026.1.23
### Changes ### Changes

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/msteams", "name": "@moltbot/msteams",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot Microsoft Teams channel plugin", "description": "Moltbot Microsoft Teams channel plugin",
"moltbot": { "moltbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/nextcloud-talk", "name": "@moltbot/nextcloud-talk",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot Nextcloud Talk channel plugin", "description": "Moltbot Nextcloud Talk channel plugin",
"moltbot": { "moltbot": {

View File

@ -1,5 +1,10 @@
# Changelog # Changelog
## 2026.1.27-beta.1
### Changes
- Version alignment with core Moltbot release numbers.
## 2026.1.23 ## 2026.1.23
### Changes ### Changes

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/nostr", "name": "@moltbot/nostr",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot Nostr channel plugin for NIP-04 encrypted DMs", "description": "Moltbot Nostr channel plugin for NIP-04 encrypted DMs",
"moltbot": { "moltbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/open-prose", "name": "@moltbot/open-prose",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "OpenProse VM skill pack plugin (slash command + telemetry).", "description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"moltbot": { "moltbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/signal", "name": "@moltbot/signal",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot Signal channel plugin", "description": "Moltbot Signal channel plugin",
"moltbot": { "moltbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/slack", "name": "@moltbot/slack",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot Slack channel plugin", "description": "Moltbot Slack channel plugin",
"moltbot": { "moltbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/telegram", "name": "@moltbot/telegram",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot Telegram channel plugin", "description": "Moltbot Telegram channel plugin",
"moltbot": { "moltbot": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/tlon", "name": "@moltbot/tlon",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot Tlon/Urbit channel plugin", "description": "Moltbot Tlon/Urbit channel plugin",
"moltbot": { "moltbot": {

View File

@ -1,5 +1,10 @@
# Changelog # Changelog
## 2026.1.27-beta.1
### Changes
- Version alignment with core Moltbot release numbers.
## 2026.1.23 ## 2026.1.23
### Features ### Features

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/twitch", "name": "@moltbot/twitch",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"description": "Moltbot Twitch channel plugin", "description": "Moltbot Twitch channel plugin",
"type": "module", "type": "module",
"dependencies": { "dependencies": {

View File

@ -1,5 +1,10 @@
# Changelog # Changelog
## 2026.1.27-beta.1
### Changes
- Version alignment with core Moltbot release numbers.
## 2026.1.26 ## 2026.1.26
### Changes ### Changes

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/voice-call", "name": "@moltbot/voice-call",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot voice-call plugin", "description": "Moltbot voice-call plugin",
"dependencies": { "dependencies": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/whatsapp", "name": "@moltbot/whatsapp",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot WhatsApp channel plugin", "description": "Moltbot WhatsApp channel plugin",
"moltbot": { "moltbot": {

View File

@ -1,5 +1,10 @@
# Changelog # Changelog
## 2026.1.27-beta.1
### Changes
- Version alignment with core Moltbot release numbers.
## 2026.1.23 ## 2026.1.23
### Changes ### Changes

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/zalo", "name": "@moltbot/zalo",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot Zalo channel plugin", "description": "Moltbot Zalo channel plugin",
"moltbot": { "moltbot": {

View File

@ -1,5 +1,10 @@
# Changelog # Changelog
## 2026.1.27-beta.1
### Changes
- Version alignment with core Moltbot release numbers.
## 2026.1.23 ## 2026.1.23
### Changes ### Changes

View File

@ -1,6 +1,6 @@
{ {
"name": "@moltbot/zalouser", "name": "@moltbot/zalouser",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"type": "module", "type": "module",
"description": "Moltbot Zalo Personal Account plugin via zca-cli", "description": "Moltbot Zalo Personal Account plugin via zca-cli",
"dependencies": { "dependencies": {

View File

@ -1,6 +1,6 @@
{ {
"name": "moltbot", "name": "moltbot",
"version": "2026.1.26", "version": "2026.1.27-beta.1",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent", "description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

@ -23,6 +23,7 @@ function runPackDry(): PackResult[] {
const raw = execSync("npm pack --dry-run --json --ignore-scripts", { const raw = execSync("npm pack --dry-run --json --ignore-scripts", {
encoding: "utf8", encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 1024 * 1024 * 100,
}); });
return JSON.parse(raw) as PackResult[]; return JSON.parse(raw) as PackResult[];
} }

View File

@ -4,7 +4,7 @@ import path from "node:path";
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
import { handleReset } from "../../commands/onboard-helpers.js"; import { handleReset } from "../../commands/onboard-helpers.js";
import { CONFIG_PATH, writeConfigFile } from "../../config/config.js"; import { createConfigIO, writeConfigFile } from "../../config/config.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { resolveUserPath, shortenHomePath } from "../../utils.js"; import { resolveUserPath, shortenHomePath } from "../../utils.js";
@ -89,7 +89,9 @@ export async function ensureDevGatewayConfig(opts: { reset?: boolean }) {
await handleReset("full", workspace, defaultRuntime); await handleReset("full", workspace, defaultRuntime);
} }
const configExists = fs.existsSync(CONFIG_PATH); const io = createConfigIO();
const configPath = io.configPath;
const configExists = fs.existsSync(configPath);
if (!opts.reset && configExists) return; if (!opts.reset && configExists) return;
await writeConfigFile({ await writeConfigFile({
@ -117,6 +119,6 @@ export async function ensureDevGatewayConfig(opts: { reset?: boolean }) {
}, },
}); });
await ensureDevWorkspace(workspace); await ensureDevWorkspace(workspace);
defaultRuntime.log(`Dev config ready: ${shortenHomePath(CONFIG_PATH)}`); defaultRuntime.log(`Dev config ready: ${shortenHomePath(configPath)}`);
defaultRuntime.log(`Dev workspace ready: ${shortenHomePath(resolveUserPath(workspace))}`); defaultRuntime.log(`Dev workspace ready: ${shortenHomePath(resolveUserPath(workspace))}`);
} }

View File

@ -157,7 +157,8 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
const passwordRaw = toOptionString(opts.password); const passwordRaw = toOptionString(opts.password);
const tokenRaw = toOptionString(opts.token); const tokenRaw = toOptionString(opts.token);
const configExists = fs.existsSync(CONFIG_PATH); const snapshot = await readConfigFileSnapshot().catch(() => null);
const configExists = snapshot?.exists ?? fs.existsSync(CONFIG_PATH);
const mode = cfg.gateway?.mode; const mode = cfg.gateway?.mode;
if (!opts.allowUnconfigured && mode !== "local") { if (!opts.allowUnconfigured && mode !== "local") {
if (!configExists) { if (!configExists) {
@ -187,7 +188,6 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
return; return;
} }
const snapshot = await readConfigFileSnapshot().catch(() => null);
const miskeys = extractGatewayMiskeys(snapshot?.parsed); const miskeys = extractGatewayMiskeys(snapshot?.parsed);
const authConfig = { const authConfig = {
...cfg.gateway?.auth, ...cfg.gateway?.auth,

View File

@ -1,3 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import type { ZodIssue } from "zod"; import type { ZodIssue } from "zod";
import type { MoltbotConfig } from "../config/config.js"; import type { MoltbotConfig } from "../config/config.js";
@ -12,6 +14,7 @@ import { formatCliCommand } from "../cli/command-format.js";
import { note } from "../terminal/note.js"; import { note } from "../terminal/note.js";
import { normalizeLegacyConfigValues } from "./doctor-legacy-config.js"; import { normalizeLegacyConfigValues } from "./doctor-legacy-config.js";
import type { DoctorOptions } from "./doctor-prompter.js"; import type { DoctorOptions } from "./doctor-prompter.js";
import { autoMigrateLegacyStateDir } from "./doctor-state-migrations.js";
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value)); return Boolean(value && typeof value === "object" && !Array.isArray(value));
@ -117,12 +120,50 @@ function noteOpencodeProviderOverrides(cfg: MoltbotConfig) {
note(lines.join("\n"), "OpenCode Zen"); note(lines.join("\n"), "OpenCode Zen");
} }
function hasExplicitConfigPath(env: NodeJS.ProcessEnv): boolean {
return Boolean(env.MOLTBOT_CONFIG_PATH?.trim() || env.CLAWDBOT_CONFIG_PATH?.trim());
}
function moveLegacyConfigFile(legacyPath: string, canonicalPath: string) {
fs.mkdirSync(path.dirname(canonicalPath), { recursive: true, mode: 0o700 });
try {
fs.renameSync(legacyPath, canonicalPath);
} catch {
fs.copyFileSync(legacyPath, canonicalPath);
fs.chmodSync(canonicalPath, 0o600);
try {
fs.unlinkSync(legacyPath);
} catch {
// Best-effort cleanup; we'll warn later if both files exist.
}
}
}
export async function loadAndMaybeMigrateDoctorConfig(params: { export async function loadAndMaybeMigrateDoctorConfig(params: {
options: DoctorOptions; options: DoctorOptions;
confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>; confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
}) { }) {
const shouldRepair = params.options.repair === true || params.options.yes === true; const shouldRepair = params.options.repair === true || params.options.yes === true;
const snapshot = await readConfigFileSnapshot(); const stateDirResult = await autoMigrateLegacyStateDir({ env: process.env });
if (stateDirResult.changes.length > 0) {
note(stateDirResult.changes.map((entry) => `- ${entry}`).join("\n"), "Doctor changes");
}
if (stateDirResult.warnings.length > 0) {
note(stateDirResult.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings");
}
let snapshot = await readConfigFileSnapshot();
if (!hasExplicitConfigPath(process.env) && snapshot.exists) {
const basename = path.basename(snapshot.path);
if (basename === "clawdbot.json") {
const canonicalPath = path.join(path.dirname(snapshot.path), "moltbot.json");
if (!fs.existsSync(canonicalPath)) {
moveLegacyConfigFile(snapshot.path, canonicalPath);
note(`- Config: ${snapshot.path}${canonicalPath}`, "Doctor changes");
snapshot = await readConfigFileSnapshot();
}
}
}
const baseCfg = snapshot.config ?? {}; const baseCfg = snapshot.config ?? {};
let cfg: MoltbotConfig = baseCfg; let cfg: MoltbotConfig = baseCfg;
let candidate = structuredClone(baseCfg) as MoltbotConfig; let candidate = structuredClone(baseCfg) as MoltbotConfig;

View File

@ -6,8 +6,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { MoltbotConfig } from "../config/config.js"; import type { MoltbotConfig } from "../config/config.js";
import { import {
autoMigrateLegacyStateDir,
autoMigrateLegacyState, autoMigrateLegacyState,
detectLegacyStateMigrations, detectLegacyStateMigrations,
resetAutoMigrateLegacyStateDirForTest,
resetAutoMigrateLegacyStateForTest, resetAutoMigrateLegacyStateForTest,
runLegacyStateMigrations, runLegacyStateMigrations,
} from "./doctor-state-migrations.js"; } from "./doctor-state-migrations.js";
@ -22,6 +24,7 @@ async function makeTempRoot() {
afterEach(async () => { afterEach(async () => {
resetAutoMigrateLegacyStateForTest(); resetAutoMigrateLegacyStateForTest();
resetAutoMigrateLegacyStateDirForTest();
if (!tempRoot) return; if (!tempRoot) return;
await fs.promises.rm(tempRoot, { recursive: true, force: true }); await fs.promises.rm(tempRoot, { recursive: true, force: true });
tempRoot = null; tempRoot = null;
@ -323,4 +326,53 @@ describe("doctor legacy state migrations", () => {
expect(store["main"]).toBeUndefined(); expect(store["main"]).toBeUndefined();
expect(store["agent:main:main"]?.sessionId).toBe("legacy"); expect(store["agent:main:main"]?.sessionId).toBe("legacy");
}); });
it("auto-migrates legacy state dir to ~/.moltbot", async () => {
const root = await makeTempRoot();
const legacyDir = path.join(root, ".clawdbot");
fs.mkdirSync(legacyDir, { recursive: true });
fs.writeFileSync(path.join(legacyDir, "foo.txt"), "legacy", "utf-8");
const result = await autoMigrateLegacyStateDir({
env: {} as NodeJS.ProcessEnv,
homedir: () => root,
});
const targetDir = path.join(root, ".moltbot");
expect(fs.existsSync(path.join(targetDir, "foo.txt"))).toBe(true);
const legacyStat = fs.lstatSync(legacyDir);
expect(legacyStat.isSymbolicLink()).toBe(true);
expect(fs.realpathSync(legacyDir)).toBe(fs.realpathSync(targetDir));
expect(result.migrated).toBe(true);
});
it("skips state dir migration when target exists", async () => {
const root = await makeTempRoot();
const legacyDir = path.join(root, ".clawdbot");
const targetDir = path.join(root, ".moltbot");
fs.mkdirSync(legacyDir, { recursive: true });
fs.mkdirSync(targetDir, { recursive: true });
const result = await autoMigrateLegacyStateDir({
env: {} as NodeJS.ProcessEnv,
homedir: () => root,
});
expect(result.migrated).toBe(false);
expect(result.warnings.length).toBeGreaterThan(0);
});
it("skips state dir migration when env override is set", async () => {
const root = await makeTempRoot();
const legacyDir = path.join(root, ".clawdbot");
fs.mkdirSync(legacyDir, { recursive: true });
const result = await autoMigrateLegacyStateDir({
env: { MOLTBOT_STATE_DIR: "/custom/state" } as NodeJS.ProcessEnv,
homedir: () => root,
});
expect(result.skipped).toBe(true);
expect(result.migrated).toBe(false);
});
}); });

View File

@ -1,9 +1,11 @@
export type { LegacyStateDetection } from "../infra/state-migrations.js"; export type { LegacyStateDetection } from "../infra/state-migrations.js";
export { export {
autoMigrateLegacyStateDir,
autoMigrateLegacyAgentDir, autoMigrateLegacyAgentDir,
autoMigrateLegacyState, autoMigrateLegacyState,
detectLegacyStateMigrations, detectLegacyStateMigrations,
migrateLegacyAgentDir, migrateLegacyAgentDir,
resetAutoMigrateLegacyStateDirForTest,
resetAutoMigrateLegacyAgentDirForTest, resetAutoMigrateLegacyAgentDirForTest,
resetAutoMigrateLegacyStateForTest, resetAutoMigrateLegacyStateForTest,
runLegacyStateMigrations, runLegacyStateMigrations,

View File

@ -292,6 +292,12 @@ vi.mock("./onboard-helpers.js", () => ({
})); }));
vi.mock("./doctor-state-migrations.js", () => ({ vi.mock("./doctor-state-migrations.js", () => ({
autoMigrateLegacyStateDir: vi.fn().mockResolvedValue({
migrated: false,
skipped: false,
changes: [],
warnings: [],
}),
detectLegacyStateMigrations: vi.fn().mockResolvedValue({ detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main", targetAgentId: "main",
targetMainKey: "main", targetMainKey: "main",

View File

@ -291,6 +291,12 @@ vi.mock("./onboard-helpers.js", () => ({
})); }));
vi.mock("./doctor-state-migrations.js", () => ({ vi.mock("./doctor-state-migrations.js", () => ({
autoMigrateLegacyStateDir: vi.fn().mockResolvedValue({
migrated: false,
skipped: false,
changes: [],
warnings: [],
}),
detectLegacyStateMigrations: vi.fn().mockResolvedValue({ detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main", targetAgentId: "main",
targetMainKey: "main", targetMainKey: "main",

View File

@ -291,6 +291,12 @@ vi.mock("./onboard-helpers.js", () => ({
})); }));
vi.mock("./doctor-state-migrations.js", () => ({ vi.mock("./doctor-state-migrations.js", () => ({
autoMigrateLegacyStateDir: vi.fn().mockResolvedValue({
migrated: false,
skipped: false,
changes: [],
warnings: [],
}),
detectLegacyStateMigrations: vi.fn().mockResolvedValue({ detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main", targetAgentId: "main",
targetMainKey: "main", targetMainKey: "main",

View File

@ -291,6 +291,12 @@ vi.mock("./onboard-helpers.js", () => ({
})); }));
vi.mock("./doctor-state-migrations.js", () => ({ vi.mock("./doctor-state-migrations.js", () => ({
autoMigrateLegacyStateDir: vi.fn().mockResolvedValue({
migrated: false,
skipped: false,
changes: [],
warnings: [],
}),
detectLegacyStateMigrations: vi.fn().mockResolvedValue({ detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main", targetAgentId: "main",
targetMainKey: "main", targetMainKey: "main",

View File

@ -291,6 +291,12 @@ vi.mock("./onboard-helpers.js", () => ({
})); }));
vi.mock("./doctor-state-migrations.js", () => ({ vi.mock("./doctor-state-migrations.js", () => ({
autoMigrateLegacyStateDir: vi.fn().mockResolvedValue({
migrated: false,
skipped: false,
changes: [],
warnings: [],
}),
detectLegacyStateMigrations: vi.fn().mockResolvedValue({ detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main", targetAgentId: "main",
targetMainKey: "main", targetMainKey: "main",

View File

@ -3,19 +3,19 @@ import fs from "node:fs/promises";
import JSON5 from "json5"; import JSON5 from "json5";
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js";
import { type MoltbotConfig, CONFIG_PATH, writeConfigFile } from "../config/config.js"; import { type MoltbotConfig, createConfigIO, writeConfigFile } from "../config/config.js";
import { formatConfigPath, logConfigUpdated } from "../config/logging.js"; import { formatConfigPath, logConfigUpdated } from "../config/logging.js";
import { resolveSessionTranscriptsDir } from "../config/sessions.js"; import { resolveSessionTranscriptsDir } from "../config/sessions.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { shortenHomePath } from "../utils.js"; import { shortenHomePath } from "../utils.js";
async function readConfigFileRaw(): Promise<{ async function readConfigFileRaw(configPath: string): Promise<{
exists: boolean; exists: boolean;
parsed: MoltbotConfig; parsed: MoltbotConfig;
}> { }> {
try { try {
const raw = await fs.readFile(CONFIG_PATH, "utf-8"); const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON5.parse(raw); const parsed = JSON5.parse(raw);
if (parsed && typeof parsed === "object") { if (parsed && typeof parsed === "object") {
return { exists: true, parsed: parsed as MoltbotConfig }; return { exists: true, parsed: parsed as MoltbotConfig };
@ -35,7 +35,9 @@ export async function setupCommand(
? opts.workspace.trim() ? opts.workspace.trim()
: undefined; : undefined;
const existingRaw = await readConfigFileRaw(); const io = createConfigIO();
const configPath = io.configPath;
const existingRaw = await readConfigFileRaw(configPath);
const cfg = existingRaw.parsed; const cfg = existingRaw.parsed;
const defaults = cfg.agents?.defaults ?? {}; const defaults = cfg.agents?.defaults ?? {};
@ -55,12 +57,12 @@ export async function setupCommand(
if (!existingRaw.exists || defaults.workspace !== workspace) { if (!existingRaw.exists || defaults.workspace !== workspace) {
await writeConfigFile(next); await writeConfigFile(next);
if (!existingRaw.exists) { if (!existingRaw.exists) {
runtime.log(`Wrote ${formatConfigPath()}`); runtime.log(`Wrote ${formatConfigPath(configPath)}`);
} else { } else {
logConfigUpdated(runtime, { suffix: "(set agents.defaults.workspace)" }); logConfigUpdated(runtime, { path: configPath, suffix: "(set agents.defaults.workspace)" });
} }
} else { } else {
runtime.log(`Config OK: ${formatConfigPath()}`); runtime.log(`Config OK: ${formatConfigPath(configPath)}`);
} }
const ws = await ensureAgentWorkspace({ const ws = await ensureAgentWorkspace({

View File

@ -14,10 +14,15 @@ async function withTempHome(run: (home: string) => Promise<void>): Promise<void>
} }
} }
async function writeConfig(home: string, dirname: ".moltbot" | ".clawdbot", port: number) { async function writeConfig(
home: string,
dirname: ".moltbot" | ".clawdbot",
port: number,
filename: "moltbot.json" | "clawdbot.json" = "moltbot.json",
) {
const dir = path.join(home, dirname); const dir = path.join(home, dirname);
await fs.mkdir(dir, { recursive: true }); await fs.mkdir(dir, { recursive: true });
const configPath = path.join(dir, "moltbot.json"); const configPath = path.join(dir, filename);
await fs.writeFile(configPath, JSON.stringify({ gateway: { port } }, null, 2)); await fs.writeFile(configPath, JSON.stringify({ gateway: { port } }, null, 2));
return configPath; return configPath;
} }
@ -51,6 +56,35 @@ describe("config io compat (new + legacy folders)", () => {
}); });
}); });
it("falls back to ~/.clawdbot/clawdbot.json when only legacy filename exists", async () => {
await withTempHome(async (home) => {
const legacyConfigPath = await writeConfig(home, ".clawdbot", 20002, "clawdbot.json");
const io = createConfigIO({
env: {} as NodeJS.ProcessEnv,
homedir: () => home,
});
expect(io.configPath).toBe(legacyConfigPath);
expect(io.loadConfig().gateway?.port).toBe(20002);
});
});
it("prefers moltbot.json over legacy filename in the same dir", async () => {
await withTempHome(async (home) => {
const preferred = await writeConfig(home, ".clawdbot", 20003, "moltbot.json");
await writeConfig(home, ".clawdbot", 20004, "clawdbot.json");
const io = createConfigIO({
env: {} as NodeJS.ProcessEnv,
homedir: () => home,
});
expect(io.configPath).toBe(preferred);
expect(io.loadConfig().gateway?.port).toBe(20003);
});
});
it("honors explicit legacy config path env override", async () => { it("honors explicit legacy config path env override", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const newConfigPath = await writeConfig(home, ".moltbot", 19002); const newConfigPath = await writeConfig(home, ".moltbot", 19002);

View File

@ -555,7 +555,8 @@ function clearConfigCache(): void {
} }
export function loadConfig(): MoltbotConfig { export function loadConfig(): MoltbotConfig {
const configPath = resolveConfigPath(); const io = createConfigIO();
const configPath = io.configPath;
const now = Date.now(); const now = Date.now();
if (shouldUseConfigCache(process.env)) { if (shouldUseConfigCache(process.env)) {
const cached = configCache; const cached = configCache;
@ -563,7 +564,7 @@ export function loadConfig(): MoltbotConfig {
return cached.config; return cached.config;
} }
} }
const config = createConfigIO({ configPath }).loadConfig(); const config = io.loadConfig();
if (shouldUseConfigCache(process.env)) { if (shouldUseConfigCache(process.env)) {
const cacheMs = resolveConfigCacheMs(process.env); const cacheMs = resolveConfigCacheMs(process.env);
if (cacheMs > 0) { if (cacheMs > 0) {
@ -578,12 +579,10 @@ export function loadConfig(): MoltbotConfig {
} }
export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> { export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
return await createConfigIO({ return await createConfigIO().readConfigFileSnapshot();
configPath: resolveConfigPath(),
}).readConfigFileSnapshot();
} }
export async function writeConfigFile(cfg: MoltbotConfig): Promise<void> { export async function writeConfigFile(cfg: MoltbotConfig): Promise<void> {
clearConfigCache(); clearConfigCache();
await createConfigIO({ configPath: resolveConfigPath() }).writeConfigFile(cfg); await createConfigIO().writeConfigFile(cfg);
} }

View File

@ -1,5 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
resolveDefaultConfigCandidates, resolveDefaultConfigCandidates,
@ -47,6 +49,61 @@ describe("state + config path candidates", () => {
const home = "/home/test"; const home = "/home/test";
const candidates = resolveDefaultConfigCandidates({} as NodeJS.ProcessEnv, () => home); const candidates = resolveDefaultConfigCandidates({} as NodeJS.ProcessEnv, () => home);
expect(candidates[0]).toBe(path.join(home, ".moltbot", "moltbot.json")); expect(candidates[0]).toBe(path.join(home, ".moltbot", "moltbot.json"));
expect(candidates[1]).toBe(path.join(home, ".clawdbot", "moltbot.json")); expect(candidates[1]).toBe(path.join(home, ".moltbot", "clawdbot.json"));
expect(candidates[2]).toBe(path.join(home, ".clawdbot", "moltbot.json"));
expect(candidates[3]).toBe(path.join(home, ".clawdbot", "clawdbot.json"));
});
it("prefers ~/.moltbot when it exists and legacy dir is missing", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-state-"));
try {
const newDir = path.join(root, ".moltbot");
await fs.mkdir(newDir, { recursive: true });
const resolved = resolveStateDir({} as NodeJS.ProcessEnv, () => root);
expect(resolved).toBe(newDir);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
it("CONFIG_PATH prefers existing legacy filename when present", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-config-"));
const previousHome = process.env.HOME;
const previousMoltbotConfig = process.env.MOLTBOT_CONFIG_PATH;
const previousClawdbotConfig = process.env.CLAWDBOT_CONFIG_PATH;
const previousMoltbotState = process.env.MOLTBOT_STATE_DIR;
const previousClawdbotState = process.env.CLAWDBOT_STATE_DIR;
try {
const legacyDir = path.join(root, ".clawdbot");
await fs.mkdir(legacyDir, { recursive: true });
const legacyPath = path.join(legacyDir, "clawdbot.json");
await fs.writeFile(legacyPath, "{}", "utf-8");
process.env.HOME = root;
delete process.env.MOLTBOT_CONFIG_PATH;
delete process.env.CLAWDBOT_CONFIG_PATH;
delete process.env.MOLTBOT_STATE_DIR;
delete process.env.CLAWDBOT_STATE_DIR;
vi.resetModules();
const { CONFIG_PATH } = await import("./paths.js");
expect(CONFIG_PATH).toBe(legacyPath);
} finally {
if (previousHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = previousHome;
}
if (previousMoltbotConfig === undefined) delete process.env.MOLTBOT_CONFIG_PATH;
else process.env.MOLTBOT_CONFIG_PATH = previousMoltbotConfig;
if (previousClawdbotConfig === undefined) delete process.env.CLAWDBOT_CONFIG_PATH;
else process.env.CLAWDBOT_CONFIG_PATH = previousClawdbotConfig;
if (previousMoltbotState === undefined) delete process.env.MOLTBOT_STATE_DIR;
else process.env.MOLTBOT_STATE_DIR = previousMoltbotState;
if (previousClawdbotState === undefined) delete process.env.CLAWDBOT_STATE_DIR;
else process.env.CLAWDBOT_STATE_DIR = previousClawdbotState;
await fs.rm(root, { recursive: true, force: true });
vi.resetModules();
}
}); });
}); });

View File

@ -1,3 +1,4 @@
import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import type { MoltbotConfig } from "./types.js"; import type { MoltbotConfig } from "./types.js";
@ -18,6 +19,7 @@ export const isNixMode = resolveIsNixMode();
const LEGACY_STATE_DIRNAME = ".clawdbot"; const LEGACY_STATE_DIRNAME = ".clawdbot";
const NEW_STATE_DIRNAME = ".moltbot"; const NEW_STATE_DIRNAME = ".moltbot";
const CONFIG_FILENAME = "moltbot.json"; const CONFIG_FILENAME = "moltbot.json";
const LEGACY_CONFIG_FILENAME = "clawdbot.json";
function legacyStateDir(homedir: () => string = os.homedir): string { function legacyStateDir(homedir: () => string = os.homedir): string {
return path.join(homedir(), LEGACY_STATE_DIRNAME); return path.join(homedir(), LEGACY_STATE_DIRNAME);
@ -27,10 +29,19 @@ function newStateDir(homedir: () => string = os.homedir): string {
return path.join(homedir(), NEW_STATE_DIRNAME); return path.join(homedir(), NEW_STATE_DIRNAME);
} }
export function resolveLegacyStateDir(homedir: () => string = os.homedir): string {
return legacyStateDir(homedir);
}
export function resolveNewStateDir(homedir: () => string = os.homedir): string {
return newStateDir(homedir);
}
/** /**
* State directory for mutable data (sessions, logs, caches). * State directory for mutable data (sessions, logs, caches).
* Can be overridden via MOLTBOT_STATE_DIR (preferred) or CLAWDBOT_STATE_DIR (legacy). * Can be overridden via MOLTBOT_STATE_DIR (preferred) or CLAWDBOT_STATE_DIR (legacy).
* Default: ~/.clawdbot (legacy default for compatibility) * Default: ~/.clawdbot (legacy default for compatibility)
* If ~/.moltbot exists and ~/.clawdbot does not, prefer ~/.moltbot.
*/ */
export function resolveStateDir( export function resolveStateDir(
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
@ -38,7 +49,12 @@ export function resolveStateDir(
): string { ): string {
const override = env.MOLTBOT_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim(); const override = env.MOLTBOT_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
if (override) return resolveUserPath(override); if (override) return resolveUserPath(override);
return legacyStateDir(homedir); const legacyDir = legacyStateDir(homedir);
const newDir = newStateDir(homedir);
const hasLegacy = fs.existsSync(legacyDir);
const hasNew = fs.existsSync(newDir);
if (!hasLegacy && hasNew) return newDir;
return legacyDir;
} }
function resolveUserPath(input: string): string { function resolveUserPath(input: string): string {
@ -58,7 +74,7 @@ export const STATE_DIR = resolveStateDir();
* Can be overridden via MOLTBOT_CONFIG_PATH (preferred) or CLAWDBOT_CONFIG_PATH (legacy). * Can be overridden via MOLTBOT_CONFIG_PATH (preferred) or CLAWDBOT_CONFIG_PATH (legacy).
* Default: ~/.clawdbot/moltbot.json (or $*_STATE_DIR/moltbot.json) * Default: ~/.clawdbot/moltbot.json (or $*_STATE_DIR/moltbot.json)
*/ */
export function resolveConfigPath( export function resolveCanonicalConfigPath(
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
stateDir: string = resolveStateDir(env, os.homedir), stateDir: string = resolveStateDir(env, os.homedir),
): string { ): string {
@ -67,7 +83,56 @@ export function resolveConfigPath(
return path.join(stateDir, CONFIG_FILENAME); return path.join(stateDir, CONFIG_FILENAME);
} }
export const CONFIG_PATH = resolveConfigPath(); /**
* Resolve the active config path by preferring existing config candidates
* (new/legacy filenames) before falling back to the canonical path.
*/
export function resolveConfigPathCandidate(
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
const candidates = resolveDefaultConfigCandidates(env, homedir);
const existing = candidates.find((candidate) => {
try {
return fs.existsSync(candidate);
} catch {
return false;
}
});
if (existing) return existing;
return resolveCanonicalConfigPath(env, resolveStateDir(env, homedir));
}
/**
* Active config path (prefers existing legacy/new config files).
*/
export function resolveConfigPath(
env: NodeJS.ProcessEnv = process.env,
stateDir: string = resolveStateDir(env, os.homedir),
homedir: () => string = os.homedir,
): string {
const override = env.MOLTBOT_CONFIG_PATH?.trim() || env.CLAWDBOT_CONFIG_PATH?.trim();
if (override) return resolveUserPath(override);
const candidates = [
path.join(stateDir, CONFIG_FILENAME),
path.join(stateDir, LEGACY_CONFIG_FILENAME),
];
const existing = candidates.find((candidate) => {
try {
return fs.existsSync(candidate);
} catch {
return false;
}
});
if (existing) return existing;
const defaultStateDir = resolveStateDir(env, homedir);
if (path.resolve(stateDir) === path.resolve(defaultStateDir)) {
return resolveConfigPathCandidate(env, homedir);
}
return path.join(stateDir, CONFIG_FILENAME);
}
export const CONFIG_PATH = resolveConfigPathCandidate();
/** /**
* Resolve default config path candidates across new + legacy locations. * Resolve default config path candidates across new + legacy locations.
@ -84,14 +149,18 @@ export function resolveDefaultConfigCandidates(
const moltbotStateDir = env.MOLTBOT_STATE_DIR?.trim(); const moltbotStateDir = env.MOLTBOT_STATE_DIR?.trim();
if (moltbotStateDir) { if (moltbotStateDir) {
candidates.push(path.join(resolveUserPath(moltbotStateDir), CONFIG_FILENAME)); candidates.push(path.join(resolveUserPath(moltbotStateDir), CONFIG_FILENAME));
candidates.push(path.join(resolveUserPath(moltbotStateDir), LEGACY_CONFIG_FILENAME));
} }
const legacyStateDirOverride = env.CLAWDBOT_STATE_DIR?.trim(); const legacyStateDirOverride = env.CLAWDBOT_STATE_DIR?.trim();
if (legacyStateDirOverride) { if (legacyStateDirOverride) {
candidates.push(path.join(resolveUserPath(legacyStateDirOverride), CONFIG_FILENAME)); candidates.push(path.join(resolveUserPath(legacyStateDirOverride), CONFIG_FILENAME));
candidates.push(path.join(resolveUserPath(legacyStateDirOverride), LEGACY_CONFIG_FILENAME));
} }
candidates.push(path.join(newStateDir(homedir), CONFIG_FILENAME)); candidates.push(path.join(newStateDir(homedir), CONFIG_FILENAME));
candidates.push(path.join(newStateDir(homedir), LEGACY_CONFIG_FILENAME));
candidates.push(path.join(legacyStateDir(homedir), CONFIG_FILENAME)); candidates.push(path.join(legacyStateDir(homedir), CONFIG_FILENAME));
candidates.push(path.join(legacyStateDir(homedir), LEGACY_CONFIG_FILENAME));
return candidates; return candidates;
} }

View File

@ -1,7 +1,7 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js"; import { LEGACY_MACOS_APP_SOURCES_DIR, MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js";
import { CronPayloadSchema } from "../gateway/protocol/schema.js"; import { CronPayloadSchema } from "../gateway/protocol/schema.js";
type SchemaLike = { type SchemaLike = {
@ -30,7 +30,26 @@ function extractCronChannels(schema: SchemaLike): string[] {
const UI_FILES = ["ui/src/ui/types.ts", "ui/src/ui/ui-types.ts", "ui/src/ui/views/cron.ts"]; const UI_FILES = ["ui/src/ui/types.ts", "ui/src/ui/ui-types.ts", "ui/src/ui/views/cron.ts"];
const SWIFT_FILES = [`${MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`]; const SWIFT_FILE_CANDIDATES = [
`${MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`,
`${LEGACY_MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`,
];
async function resolveSwiftFiles(cwd: string): Promise<string[]> {
const matches: string[] = [];
for (const relPath of SWIFT_FILE_CANDIDATES) {
try {
await fs.access(path.join(cwd, relPath));
matches.push(relPath);
} catch {
// ignore missing path
}
}
if (matches.length === 0) {
throw new Error(`Missing Swift cron definition. Tried: ${SWIFT_FILE_CANDIDATES.join(", ")}`);
}
return matches;
}
describe("cron protocol conformance", () => { describe("cron protocol conformance", () => {
it("ui + swift include all cron providers from gateway schema", async () => { it("ui + swift include all cron providers from gateway schema", async () => {
@ -45,7 +64,8 @@ describe("cron protocol conformance", () => {
} }
} }
for (const relPath of SWIFT_FILES) { const swiftFiles = await resolveSwiftFiles(cwd);
for (const relPath of swiftFiles) {
const content = await fs.readFile(path.join(cwd, relPath), "utf-8"); const content = await fs.readFile(path.join(cwd, relPath), "utf-8");
for (const channel of channels) { for (const channel of channels) {
const pattern = new RegExp(`\\bcase\\s+${channel}\\b`); const pattern = new RegExp(`\\bcase\\s+${channel}\\b`);
@ -61,7 +81,8 @@ describe("cron protocol conformance", () => {
expect(uiTypes.includes("jobs:")).toBe(true); expect(uiTypes.includes("jobs:")).toBe(true);
expect(uiTypes.includes("jobCount")).toBe(false); expect(uiTypes.includes("jobCount")).toBe(false);
const swiftPath = path.join(cwd, SWIFT_FILES[0]); const [swiftRelPath] = await resolveSwiftFiles(cwd);
const swiftPath = path.join(cwd, swiftRelPath);
const swift = await fs.readFile(swiftPath, "utf-8"); const swift = await fs.readFile(swiftPath, "utf-8");
expect(swift.includes("struct CronSchedulerStatus")).toBe(true); expect(swift.includes("struct CronSchedulerStatus")).toBe(true);
expect(swift.includes("let jobs:")).toBe(true); expect(swift.includes("let jobs:")).toBe(true);

View File

@ -4,7 +4,12 @@ import path from "node:path";
import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { MoltbotConfig } from "../config/config.js"; import type { MoltbotConfig } from "../config/config.js";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; import {
resolveLegacyStateDir,
resolveNewStateDir,
resolveOAuthDir,
resolveStateDir,
} from "../config/paths.js";
import type { SessionEntry } from "../config/sessions.js"; import type { SessionEntry } from "../config/sessions.js";
import type { SessionScope } from "../config/sessions/types.js"; import type { SessionScope } from "../config/sessions/types.js";
import { saveSessionStore } from "../config/sessions.js"; import { saveSessionStore } from "../config/sessions.js";
@ -59,6 +64,7 @@ type MigrationLogger = {
}; };
let autoMigrateChecked = false; let autoMigrateChecked = false;
let autoMigrateStateDirChecked = false;
function isSurfaceGroupKey(key: string): boolean { function isSurfaceGroupKey(key: string): boolean {
return key.includes(":group:") || key.includes(":channel:"); return key.includes(":group:") || key.includes(":channel:");
@ -267,6 +273,131 @@ export function resetAutoMigrateLegacyAgentDirForTest() {
resetAutoMigrateLegacyStateForTest(); resetAutoMigrateLegacyStateForTest();
} }
export function resetAutoMigrateLegacyStateDirForTest() {
autoMigrateStateDirChecked = false;
}
type StateDirMigrationResult = {
migrated: boolean;
skipped: boolean;
changes: string[];
warnings: string[];
};
function resolveSymlinkTarget(linkPath: string): string | null {
try {
const target = fs.readlinkSync(linkPath);
return path.resolve(path.dirname(linkPath), target);
} catch {
return null;
}
}
function formatStateDirMigration(legacyDir: string, targetDir: string): string {
return `State dir: ${legacyDir}${targetDir} (legacy path now symlinked)`;
}
function isDirPath(filePath: string): boolean {
try {
return fs.statSync(filePath).isDirectory();
} catch {
return false;
}
}
export async function autoMigrateLegacyStateDir(params: {
env?: NodeJS.ProcessEnv;
homedir?: () => string;
log?: MigrationLogger;
}): Promise<StateDirMigrationResult> {
if (autoMigrateStateDirChecked) {
return { migrated: false, skipped: true, changes: [], warnings: [] };
}
autoMigrateStateDirChecked = true;
const env = params.env ?? process.env;
if (env.MOLTBOT_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim()) {
return { migrated: false, skipped: true, changes: [], warnings: [] };
}
const homedir = params.homedir ?? os.homedir;
const legacyDir = resolveLegacyStateDir(homedir);
const targetDir = resolveNewStateDir(homedir);
const warnings: string[] = [];
const changes: string[] = [];
let legacyStat: fs.Stats | null = null;
try {
legacyStat = fs.lstatSync(legacyDir);
} catch {
legacyStat = null;
}
if (!legacyStat) {
return { migrated: false, skipped: false, changes, warnings };
}
if (!legacyStat.isDirectory() && !legacyStat.isSymbolicLink()) {
warnings.push(`Legacy state path is not a directory: ${legacyDir}`);
return { migrated: false, skipped: false, changes, warnings };
}
if (legacyStat.isSymbolicLink()) {
const legacyTarget = resolveSymlinkTarget(legacyDir);
if (legacyTarget && path.resolve(legacyTarget) === path.resolve(targetDir)) {
return { migrated: false, skipped: false, changes, warnings };
}
warnings.push(
`Legacy state dir is a symlink (${legacyDir}${legacyTarget ?? "unknown"}); skipping auto-migration.`,
);
return { migrated: false, skipped: false, changes, warnings };
}
if (isDirPath(targetDir)) {
warnings.push(
`State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`,
);
return { migrated: false, skipped: false, changes, warnings };
}
try {
fs.renameSync(legacyDir, targetDir);
} catch (err) {
warnings.push(`Failed to move legacy state dir (${legacyDir}${targetDir}): ${String(err)}`);
return { migrated: false, skipped: false, changes, warnings };
}
try {
fs.symlinkSync(targetDir, legacyDir, "dir");
changes.push(formatStateDirMigration(legacyDir, targetDir));
} catch (err) {
try {
if (process.platform === "win32") {
fs.symlinkSync(targetDir, legacyDir, "junction");
changes.push(formatStateDirMigration(legacyDir, targetDir));
} else {
throw err;
}
} catch (fallbackErr) {
try {
fs.renameSync(targetDir, legacyDir);
warnings.push(
`State dir migration rolled back (failed to link legacy path): ${String(fallbackErr)}`,
);
return { migrated: false, skipped: false, changes: [], warnings };
} catch (rollbackErr) {
warnings.push(
`State dir moved but failed to link legacy path (${legacyDir}${targetDir}): ${String(fallbackErr)}`,
);
warnings.push(
`Rollback failed; set MOLTBOT_STATE_DIR=${targetDir} to avoid split state: ${String(rollbackErr)}`,
);
changes.push(`State dir: ${legacyDir}${targetDir}`);
}
}
}
return { migrated: changes.length > 0, skipped: false, changes, warnings };
}
export async function detectLegacyStateMigrations(params: { export async function detectLegacyStateMigrations(params: {
cfg: MoltbotConfig; cfg: MoltbotConfig;
env?: NodeJS.ProcessEnv; env?: NodeJS.ProcessEnv;
@ -591,8 +722,18 @@ export async function autoMigrateLegacyState(params: {
autoMigrateChecked = true; autoMigrateChecked = true;
const env = params.env ?? process.env; const env = params.env ?? process.env;
const stateDirResult = await autoMigrateLegacyStateDir({
env,
homedir: params.homedir,
log: params.log,
});
if (env.CLAWDBOT_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim()) { if (env.CLAWDBOT_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim()) {
return { migrated: false, skipped: true, changes: [], warnings: [] }; return {
migrated: stateDirResult.migrated,
skipped: true,
changes: stateDirResult.changes,
warnings: stateDirResult.warnings,
};
} }
const detected = await detectLegacyStateMigrations({ const detected = await detectLegacyStateMigrations({
@ -601,14 +742,19 @@ export async function autoMigrateLegacyState(params: {
homedir: params.homedir, homedir: params.homedir,
}); });
if (!detected.sessions.hasLegacy && !detected.agentDir.hasLegacy) { if (!detected.sessions.hasLegacy && !detected.agentDir.hasLegacy) {
return { migrated: false, skipped: false, changes: [], warnings: [] }; return {
migrated: stateDirResult.migrated,
skipped: false,
changes: stateDirResult.changes,
warnings: stateDirResult.warnings,
};
} }
const now = params.now ?? (() => Date.now()); const now = params.now ?? (() => Date.now());
const sessions = await migrateLegacySessions(detected, now); const sessions = await migrateLegacySessions(detected, now);
const agentDir = await migrateLegacyAgentDir(detected, now); const agentDir = await migrateLegacyAgentDir(detected, now);
const changes = [...sessions.changes, ...agentDir.changes]; const changes = [...stateDirResult.changes, ...sessions.changes, ...agentDir.changes];
const warnings = [...sessions.warnings, ...agentDir.warnings]; const warnings = [...stateDirResult.warnings, ...sessions.warnings, ...agentDir.warnings];
const logger = params.log ?? createSubsystemLogger("state-migrations"); const logger = params.log ?? createSubsystemLogger("state-migrations");
if (changes.length > 0) { if (changes.length > 0) {

View File

@ -0,0 +1,162 @@
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import process from "node:process";
import { installUnhandledRejectionHandler } from "./unhandled-rejections.js";
describe("installUnhandledRejectionHandler - fatal detection", () => {
let exitCalls: Array<string | number | null> = [];
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let consoleWarnSpy: ReturnType<typeof vi.spyOn>;
let originalExit: typeof process.exit;
beforeAll(() => {
originalExit = process.exit.bind(process);
installUnhandledRejectionHandler();
});
beforeEach(() => {
exitCalls = [];
vi.spyOn(process, "exit").mockImplementation((code: string | number | null | undefined) => {
if (code !== undefined && code !== null) {
exitCalls.push(code);
}
});
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
vi.clearAllMocks();
consoleErrorSpy.mockRestore();
consoleWarnSpy.mockRestore();
});
afterAll(() => {
process.exit = originalExit;
});
describe("fatal errors", () => {
it("exits on ERR_OUT_OF_MEMORY", () => {
const oomErr = Object.assign(new Error("Out of memory"), {
code: "ERR_OUT_OF_MEMORY",
});
process.emit("unhandledRejection", oomErr, Promise.resolve());
expect(exitCalls).toEqual([1]);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[moltbot] FATAL unhandled rejection:",
expect.stringContaining("Out of memory"),
);
});
it("exits on ERR_SCRIPT_EXECUTION_TIMEOUT", () => {
const timeoutErr = Object.assign(new Error("Script execution timeout"), {
code: "ERR_SCRIPT_EXECUTION_TIMEOUT",
});
process.emit("unhandledRejection", timeoutErr, Promise.resolve());
expect(exitCalls).toEqual([1]);
});
it("exits on ERR_WORKER_OUT_OF_MEMORY", () => {
const workerOomErr = Object.assign(new Error("Worker out of memory"), {
code: "ERR_WORKER_OUT_OF_MEMORY",
});
process.emit("unhandledRejection", workerOomErr, Promise.resolve());
expect(exitCalls).toEqual([1]);
});
});
describe("configuration errors", () => {
it("exits on INVALID_CONFIG", () => {
const configErr = Object.assign(new Error("Invalid config"), {
code: "INVALID_CONFIG",
});
process.emit("unhandledRejection", configErr, Promise.resolve());
expect(exitCalls).toEqual([1]);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[moltbot] CONFIGURATION ERROR - requires fix:",
expect.stringContaining("Invalid config"),
);
});
it("exits on MISSING_API_KEY", () => {
const missingKeyErr = Object.assign(new Error("Missing API key"), {
code: "MISSING_API_KEY",
});
process.emit("unhandledRejection", missingKeyErr, Promise.resolve());
expect(exitCalls).toEqual([1]);
});
});
describe("non-fatal errors", () => {
it("does NOT exit on undici fetch failures", () => {
const fetchErr = Object.assign(new TypeError("fetch failed"), {
cause: { code: "UND_ERR_CONNECT_TIMEOUT", syscall: "connect" },
});
process.emit("unhandledRejection", fetchErr, Promise.resolve());
expect(exitCalls).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalledWith(
"[moltbot] Non-fatal unhandled rejection (continuing):",
expect.stringContaining("fetch failed"),
);
});
it("does NOT exit on DNS resolution failures", () => {
const dnsErr = Object.assign(new Error("DNS resolve failed"), {
code: "UND_ERR_DNS_RESOLVE_FAILED",
});
process.emit("unhandledRejection", dnsErr, Promise.resolve());
expect(exitCalls).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalled();
});
it("exits on generic errors without code", () => {
const genericErr = new Error("Something went wrong");
process.emit("unhandledRejection", genericErr, Promise.resolve());
expect(exitCalls).toEqual([1]);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[moltbot] Unhandled promise rejection:",
expect.stringContaining("Something went wrong"),
);
});
it("does NOT exit on connection reset errors", () => {
const connResetErr = Object.assign(new Error("Connection reset"), {
code: "ECONNRESET",
});
process.emit("unhandledRejection", connResetErr, Promise.resolve());
expect(exitCalls).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalled();
});
it("does NOT exit on timeout errors", () => {
const timeoutErr = Object.assign(new Error("Timeout"), {
code: "ETIMEDOUT",
});
process.emit("unhandledRejection", timeoutErr, Promise.resolve());
expect(exitCalls).toEqual([]);
expect(consoleWarnSpy).toHaveBeenCalled();
});
});
});

View File

@ -1,11 +1,52 @@
import process from "node:process"; import process from "node:process";
import { formatUncaughtError } from "./errors.js"; import { extractErrorCode, formatUncaughtError } from "./errors.js";
type UnhandledRejectionHandler = (reason: unknown) => boolean; type UnhandledRejectionHandler = (reason: unknown) => boolean;
const handlers = new Set<UnhandledRejectionHandler>(); const handlers = new Set<UnhandledRejectionHandler>();
const FATAL_ERROR_CODES = new Set([
"ERR_OUT_OF_MEMORY",
"ERR_SCRIPT_EXECUTION_TIMEOUT",
"ERR_WORKER_OUT_OF_MEMORY",
"ERR_WORKER_UNCAUGHT_EXCEPTION",
"ERR_WORKER_INITIALIZATION_FAILED",
]);
const CONFIG_ERROR_CODES = new Set(["INVALID_CONFIG", "MISSING_API_KEY", "MISSING_CREDENTIALS"]);
// Network error codes that indicate transient failures (shouldn't crash the gateway)
const TRANSIENT_NETWORK_CODES = new Set([
"ECONNRESET",
"ECONNREFUSED",
"ENOTFOUND",
"ETIMEDOUT",
"ESOCKETTIMEDOUT",
"ECONNABORTED",
"EPIPE",
"EHOSTUNREACH",
"ENETUNREACH",
"EAI_AGAIN",
"UND_ERR_CONNECT_TIMEOUT",
"UND_ERR_DNS_RESOLVE_FAILED",
"UND_ERR_CONNECT",
"UND_ERR_SOCKET",
"UND_ERR_HEADERS_TIMEOUT",
"UND_ERR_BODY_TIMEOUT",
]);
function getErrorCause(err: unknown): unknown {
if (!err || typeof err !== "object") return undefined;
return (err as { cause?: unknown }).cause;
}
function extractErrorCodeWithCause(err: unknown): string | undefined {
const direct = extractErrorCode(err);
if (direct) return direct;
return extractErrorCode(getErrorCause(err));
}
/** /**
* Checks if an error is an AbortError. * Checks if an error is an AbortError.
* These are typically intentional cancellations (e.g., during shutdown) and shouldn't crash. * These are typically intentional cancellations (e.g., during shutdown) and shouldn't crash.
@ -20,33 +61,14 @@ export function isAbortError(err: unknown): boolean {
return false; return false;
} }
// Network error codes that indicate transient failures (shouldn't crash the gateway) function isFatalError(err: unknown): boolean {
const TRANSIENT_NETWORK_CODES = new Set([ const code = extractErrorCodeWithCause(err);
"ECONNRESET", return code !== undefined && FATAL_ERROR_CODES.has(code);
"ECONNREFUSED",
"ENOTFOUND",
"ETIMEDOUT",
"ESOCKETTIMEDOUT",
"ECONNABORTED",
"EPIPE",
"EHOSTUNREACH",
"ENETUNREACH",
"EAI_AGAIN",
"UND_ERR_CONNECT_TIMEOUT",
"UND_ERR_SOCKET",
"UND_ERR_HEADERS_TIMEOUT",
"UND_ERR_BODY_TIMEOUT",
]);
function getErrorCode(err: unknown): string | undefined {
if (!err || typeof err !== "object") return undefined;
const code = (err as { code?: unknown }).code;
return typeof code === "string" ? code : undefined;
} }
function getErrorCause(err: unknown): unknown { function isConfigError(err: unknown): boolean {
if (!err || typeof err !== "object") return undefined; const code = extractErrorCodeWithCause(err);
return (err as { cause?: unknown }).cause; return code !== undefined && CONFIG_ERROR_CODES.has(code);
} }
/** /**
@ -56,16 +78,13 @@ function getErrorCause(err: unknown): unknown {
export function isTransientNetworkError(err: unknown): boolean { export function isTransientNetworkError(err: unknown): boolean {
if (!err) return false; if (!err) return false;
// Check the error itself const code = extractErrorCodeWithCause(err);
const code = getErrorCode(err);
if (code && TRANSIENT_NETWORK_CODES.has(code)) return true; if (code && TRANSIENT_NETWORK_CODES.has(code)) return true;
// "fetch failed" TypeError from undici (Node's native fetch) // "fetch failed" TypeError from undici (Node's native fetch)
if (err instanceof TypeError && err.message === "fetch failed") { if (err instanceof TypeError && err.message === "fetch failed") {
const cause = getErrorCause(err); const cause = getErrorCause(err);
// The cause often contains the actual network error
if (cause) return isTransientNetworkError(cause); if (cause) return isTransientNetworkError(cause);
// Even without a cause, "fetch failed" is typically a network issue
return true; return true;
} }
@ -115,10 +134,23 @@ export function installUnhandledRejectionHandler(): void {
return; return;
} }
// Transient network errors (fetch failed, connection reset, etc.) shouldn't crash if (isFatalError(reason)) {
// These are temporary connectivity issues that will resolve on their own console.error("[moltbot] FATAL unhandled rejection:", formatUncaughtError(reason));
process.exit(1);
return;
}
if (isConfigError(reason)) {
console.error("[moltbot] CONFIGURATION ERROR - requires fix:", formatUncaughtError(reason));
process.exit(1);
return;
}
if (isTransientNetworkError(reason)) { if (isTransientNetworkError(reason)) {
console.error("[moltbot] Network error (non-fatal):", formatUncaughtError(reason)); console.warn(
"[moltbot] Non-fatal unhandled rejection (continuing):",
formatUncaughtError(reason),
);
return; return;
} }

View File

@ -9,6 +9,7 @@ import {
jidToE164, jidToE164,
normalizeE164, normalizeE164,
normalizePath, normalizePath,
resolveConfigDir,
resolveJidToE164, resolveJidToE164,
resolveUserPath, resolveUserPath,
sleep, sleep,
@ -120,6 +121,20 @@ describe("jidToE164", () => {
}); });
}); });
describe("resolveConfigDir", () => {
it("prefers ~/.moltbot when legacy dir is missing", async () => {
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "moltbot-config-dir-"));
try {
const newDir = path.join(root, ".moltbot");
await fs.promises.mkdir(newDir, { recursive: true });
const resolved = resolveConfigDir({} as NodeJS.ProcessEnv, () => root);
expect(resolved).toBe(newDir);
} finally {
await fs.promises.rm(root, { recursive: true, force: true });
}
});
});
describe("resolveJidToE164", () => { describe("resolveJidToE164", () => {
it("resolves @lid via lidLookup when mapping file is missing", async () => { it("resolves @lid via lidLookup when mapping file is missing", async () => {
const lidLookup = { const lidLookup = {

View File

@ -215,9 +215,18 @@ export function resolveConfigDir(
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir, homedir: () => string = os.homedir,
): string { ): string {
const override = env.CLAWDBOT_STATE_DIR?.trim(); const override = env.MOLTBOT_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
if (override) return resolveUserPath(override); if (override) return resolveUserPath(override);
return path.join(homedir(), ".clawdbot"); const legacyDir = path.join(homedir(), ".clawdbot");
const newDir = path.join(homedir(), ".moltbot");
try {
const hasLegacy = fs.existsSync(legacyDir);
const hasNew = fs.existsSync(newDir);
if (!hasLegacy && hasNew) return newDir;
} catch {
// best-effort
}
return legacyDir;
} }
export function resolveHomeDir(): string | undefined { export function resolveHomeDir(): string | undefined {