diff --git a/container/Dockerfile b/container/Dockerfile index 7611fedb93..3a26520069 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -7,6 +7,7 @@ FROM node:22-slim RUN apt-get update && apt-get install -y \ chromium \ fonts-liberation \ + fonts-noto-cjk \ fonts-noto-color-emoji \ libgbm1 \ libnss3 \ diff --git a/container/agent-runner/package-lock.json b/container/agent-runner/package-lock.json index 9fcdfc392b..33639b4c76 100644 --- a/container/agent-runner/package-lock.json +++ b/container/agent-runner/package-lock.json @@ -8,7 +8,7 @@ "name": "nanoclaw-agent-runner", "version": "1.0.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.34", + "@anthropic-ai/claude-agent-sdk": "^0.2.68", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "zod": "^4.0.0" @@ -20,22 +20,23 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.2.34", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.34.tgz", - "integrity": "sha512-QLHd3Nt7bGU7/YH71fXFaztM9fNxGGruzTMrTYJkbm5gYJl5ZyU2zGyoE5VpWC0e1QU0yYdNdBVgqSYDcJGufg==", + "version": "0.2.69", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.69.tgz", + "integrity": "sha512-d1ZadIwC5PUBMQRK4Y/EC/Fm9xv/nvZ4g0XXa+vC0p+vKOCxhf4USdVKjuDzPM0z0f2qqZZZdxMjFuwa7paKRA==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "^0.33.5", - "@img/sharp-darwin-x64": "^0.33.5", - "@img/sharp-linux-arm": "^0.33.5", - "@img/sharp-linux-arm64": "^0.33.5", - "@img/sharp-linux-x64": "^0.33.5", - "@img/sharp-linuxmusl-arm64": "^0.33.5", - "@img/sharp-linuxmusl-x64": "^0.33.5", - "@img/sharp-win32-x64": "^0.33.5" + "@img/sharp-darwin-arm64": "^0.34.2", + "@img/sharp-darwin-x64": "^0.34.2", + "@img/sharp-linux-arm": "^0.34.2", + "@img/sharp-linux-arm64": "^0.34.2", + "@img/sharp-linux-x64": "^0.34.2", + "@img/sharp-linuxmusl-arm64": "^0.34.2", + "@img/sharp-linuxmusl-x64": "^0.34.2", + "@img/sharp-win32-arm64": "^0.34.2", + "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" @@ -496,9 +497,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], @@ -514,13 +515,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], @@ -536,13 +537,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], @@ -556,9 +557,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], @@ -572,9 +573,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], @@ -588,9 +589,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], @@ -604,9 +605,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], @@ -620,9 +621,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], @@ -636,9 +637,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], @@ -652,9 +653,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], @@ -670,13 +671,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], @@ -692,13 +693,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" + "@img/sharp-libvips-linux-arm64": "1.2.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ "x64" ], @@ -714,13 +715,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" + "@img/sharp-libvips-linux-x64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], @@ -736,13 +737,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], @@ -758,13 +759,32 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], diff --git a/container/agent-runner/package.json b/container/agent-runner/package.json index 8c9294ad49..062476d278 100644 --- a/container/agent-runner/package.json +++ b/container/agent-runner/package.json @@ -10,7 +10,7 @@ "test": "vitest run" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.2.34", + "@anthropic-ai/claude-agent-sdk": "^0.2.68", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "zod": "^4.0.0" diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 5f6366205c..b715c34c86 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -632,6 +632,17 @@ async function runQuery( const errors = 'errors' in message ? (message as { errors?: string[] }).errors : undefined; const errorText = errors?.length ? errors.join('; ') : null; log(`Result #${resultCount}: subtype=${message.subtype} is_error=${isError}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}${errorText ? ` errors=${errorText.slice(0, 200)}` : ''}`); + + // Detect stale session errors — throw to trigger fresh session retry in main() + // instead of writing the error to stdout (which the host would treat as final) + if (isError) { + const combinedError = [textResult, errorText].filter(Boolean).join(' '); + if (/No message found with message\.uuid/i.test(combinedError)) { + ipcPolling = false; + throw new Error(combinedError); + } + } + writeOutput({ status: isError ? 'error' : 'success', result: textResult || null, diff --git a/docs/CHANGES.md b/docs/CHANGES.md index a2ebfc87fd..24d19bdaed 100644 --- a/docs/CHANGES.md +++ b/docs/CHANGES.md @@ -217,6 +217,7 @@ Upstream targets macOS with Apple Container. This fork adds full Docker support - **Container shutdown**: `group-queue.ts` explicitly calls `docker stop` with 10s grace period during graceful shutdown - **OAuth credential bind mount**: Host `~/.claude/.credentials.json` is bind-mounted directly into containers (file-level mount overlaying the session directory mount) instead of copied at spawn time. If any host process refreshes the token, containers see it immediately — eliminates stale token failures during long conversations - **Auth error detection**: `orchestrator.ts` detects auth-specific error patterns (expired OAuth, invalid API key) and sends a targeted `[Auth Error]` notification to the user immediately, even when the agent had already sent output earlier in the conversation. Prevents silent failures where auth expires mid-conversation +- **Host networking via `--add-host`**: Containers resolve `host.docker.internal` to the host's bridge IP using Docker's `--add-host=host.docker.internal:host-gateway` flag (added to `extraRunArgs()` in `container-runtime.ts`). This replaces the previous socat bridge approach for host-to-container networking (e.g., claude-mem worker access), eliminating a separate systemd service --- @@ -249,6 +250,24 @@ Upstream targets macOS with Apple Container. This fork adds full Docker support | `CLAUDE.md` | Added security section referencing SECURITY.md | | `groups/main/CLAUDE.md` | Anti-prompt-injection rules | +### Sender allowlist + +Per-chat access control for who can trigger the bot or have their messages stored. Config at `~/.config/nanoclaw/sender-allowlist.json`. Two modes: +- **trigger** (default): messages are stored but only allowed senders can trigger the bot +- **drop**: messages from disallowed senders are not stored at all + +Fail-open: if config file is missing, all senders are allowed (backwards compatible). + +| File | Purpose | +|------|---------| +| `src/sender-allowlist.ts` | Loads config, exports `isSenderAllowed()`, `shouldDropMessage()`, `isTriggerAllowed()` | +| `src/__tests__/sender-allowlist.test.ts` | Test suite (14 tests) | +| `src/config.ts` | `SENDER_ALLOWLIST_PATH` constant | +| `src/orchestrator.ts` | `isTriggerAllowed()` check alongside trigger pattern in both `processGroupMessages()` and `startMessageLoop()` | +| `src/index.ts` | Drop-mode check in `onMessage` callback — skips `storeMessage()` for disallowed senders | + +Port of upstream `4de981b`. + ### How secrets work in containers Upstream passes secrets via environment variables, which are visible to `env` and `/proc/*/environ`. This fork takes a different approach: @@ -406,6 +425,12 @@ These are clean fixes submitted to upstream. If they merge, the divergences coll - **Hook error handling** — `startup()`, `runInboundHooks()`, and `runOutboundHooks()` now catch and log errors per-plugin instead of letting one failing plugin take down the chain (`src/plugin-loader.ts`) - **WhatsApp exponential backoff** — Reconnect logic now uses exponential backoff (2s → 4s → 8s → ... capped at 5min) instead of a single 5s retry. Prevents log floods and resource exhaustion during persistent failures (port of upstream #466) (`plugins/channels/whatsapp/index.js`) - **WhatsApp protocol message filter** — Skip `protocolMessage`, `reactionMessage`, and `editedMessage` early in the handler before JID translation and metadata updates. Saves cycles on system messages that carry no user text (port of upstream #491) (`plugins/channels/whatsapp/index.js`) +- **Atomic task claims** — `runningTaskId` tracking in `GroupState` prevents the scheduler from re-enqueuing a task that's already executing. Previously only `pendingTasks` was checked. (port of upstream `f794185`) (`src/group-queue.ts`) +- **Interval task drift fix** — `computeNextRun()` anchors interval tasks to their scheduled time and skips missed intervals (`while (next <= now) next += ms`) instead of using `Date.now() + ms` which accumulates drift. (port of upstream `f794185`) (`src/task-scheduler.ts`) +- **WhatsApp message handler resilience** — Inner `messages.upsert` loop wrapped in try-catch so one malformed message doesn't crash processing for the entire batch. Error logged with `remoteJid` for debugging. (port of upstream `5e3d8b6`) (`plugins/channels/whatsapp/index.js`) +- **Third-party model env vars** — `readSecrets()` now includes `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` for third-party API providers. (port of upstream `51bb329`) (`src/container-mounts.ts`) +- **CJK fonts** — Added `fonts-noto-cjk` to container Dockerfile for Chinese/Japanese/Korean text rendering. (port of upstream `d48ef91`) (`container/Dockerfile`) +- **SDK update** — Bumped `@anthropic-ai/claude-agent-sdk` from `^0.2.34` to `^0.2.68` in agent-runner. (port of upstream `5955cd6`) (`container/agent-runner/package.json`) --- diff --git a/src/config.test.ts b/src/__tests__/config.test.ts similarity index 74% rename from src/config.test.ts rename to src/__tests__/config.test.ts index d5a247434d..aded4fe321 100644 --- a/src/config.test.ts +++ b/src/__tests__/config.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // to test different env scenarios. // Mock readEnvFile to avoid filesystem dependency -vi.mock('./env.js', () => ({ +vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})), })); @@ -22,13 +22,13 @@ afterEach(() => { describe('CONTAINER_TIMEOUT', () => { it('defaults to 1800000', async () => { delete process.env.CONTAINER_TIMEOUT; - const { CONTAINER_TIMEOUT } = await import('./config.js'); + const { CONTAINER_TIMEOUT } = await import('../config.js'); expect(CONTAINER_TIMEOUT).toBe(1800000); }); it('reads from env override', async () => { process.env.CONTAINER_TIMEOUT = '60000'; - const { CONTAINER_TIMEOUT } = await import('./config.js'); + const { CONTAINER_TIMEOUT } = await import('../config.js'); expect(CONTAINER_TIMEOUT).toBe(60000); }); }); @@ -36,31 +36,31 @@ describe('CONTAINER_TIMEOUT', () => { describe('MAX_CONCURRENT_CONTAINERS', () => { it('defaults to 5', async () => { delete process.env.MAX_CONCURRENT_CONTAINERS; - const { MAX_CONCURRENT_CONTAINERS } = await import('./config.js'); + const { MAX_CONCURRENT_CONTAINERS } = await import('../config.js'); expect(MAX_CONCURRENT_CONTAINERS).toBe(5); }); it('respects env override', async () => { process.env.MAX_CONCURRENT_CONTAINERS = '10'; - const { MAX_CONCURRENT_CONTAINERS } = await import('./config.js'); + const { MAX_CONCURRENT_CONTAINERS } = await import('../config.js'); expect(MAX_CONCURRENT_CONTAINERS).toBe(10); }); it('enforces minimum of 1 for negative values', async () => { process.env.MAX_CONCURRENT_CONTAINERS = '-1'; - const { MAX_CONCURRENT_CONTAINERS } = await import('./config.js'); + const { MAX_CONCURRENT_CONTAINERS } = await import('../config.js'); expect(MAX_CONCURRENT_CONTAINERS).toBe(1); }); it('treats 0 as falsy and falls back to default', async () => { process.env.MAX_CONCURRENT_CONTAINERS = '0'; - const { MAX_CONCURRENT_CONTAINERS } = await import('./config.js'); + const { MAX_CONCURRENT_CONTAINERS } = await import('../config.js'); expect(MAX_CONCURRENT_CONTAINERS).toBe(5); }); it('falls back to 5 for NaN', async () => { process.env.MAX_CONCURRENT_CONTAINERS = 'notanumber'; - const { MAX_CONCURRENT_CONTAINERS } = await import('./config.js'); + const { MAX_CONCURRENT_CONTAINERS } = await import('../config.js'); expect(MAX_CONCURRENT_CONTAINERS).toBe(5); }); }); @@ -68,13 +68,13 @@ describe('MAX_CONCURRENT_CONTAINERS', () => { describe('ASSISTANT_NAME', () => { it('defaults to TARS', async () => { delete process.env.ASSISTANT_NAME; - const { ASSISTANT_NAME } = await import('./config.js'); + const { ASSISTANT_NAME } = await import('../config.js'); expect(ASSISTANT_NAME).toBe('TARS'); }); it('reads from env override', async () => { process.env.ASSISTANT_NAME = 'CustomBot'; - const { ASSISTANT_NAME } = await import('./config.js'); + const { ASSISTANT_NAME } = await import('../config.js'); expect(ASSISTANT_NAME).toBe('CustomBot'); }); }); @@ -82,36 +82,36 @@ describe('ASSISTANT_NAME', () => { describe('CONTAINER_IMAGE', () => { it('defaults to nanoclaw-agent:latest', async () => { delete process.env.CONTAINER_IMAGE; - const { CONTAINER_IMAGE } = await import('./config.js'); + const { CONTAINER_IMAGE } = await import('../config.js'); expect(CONTAINER_IMAGE).toBe('nanoclaw-agent:latest'); }); it('reads from env override', async () => { process.env.CONTAINER_IMAGE = 'my-image:v2'; - const { CONTAINER_IMAGE } = await import('./config.js'); + const { CONTAINER_IMAGE } = await import('../config.js'); expect(CONTAINER_IMAGE).toBe('my-image:v2'); }); }); describe('path constants', () => { it('resolves STORE_DIR relative to cwd', async () => { - const { STORE_DIR } = await import('./config.js'); + const { STORE_DIR } = await import('../config.js'); expect(STORE_DIR).toContain('store'); expect(STORE_DIR).toBe(require('path').resolve(process.cwd(), 'store')); }); it('resolves GROUPS_DIR relative to cwd', async () => { - const { GROUPS_DIR } = await import('./config.js'); + const { GROUPS_DIR } = await import('../config.js'); expect(GROUPS_DIR).toBe(require('path').resolve(process.cwd(), 'groups')); }); it('resolves DATA_DIR relative to cwd', async () => { - const { DATA_DIR } = await import('./config.js'); + const { DATA_DIR } = await import('../config.js'); expect(DATA_DIR).toBe(require('path').resolve(process.cwd(), 'data')); }); it('resolves CHANNELS_DIR under DATA_DIR', async () => { - const { DATA_DIR, CHANNELS_DIR } = await import('./config.js'); + const { DATA_DIR, CHANNELS_DIR } = await import('../config.js'); expect(CHANNELS_DIR).toBe(require('path').join(DATA_DIR, 'channels')); }); }); @@ -119,7 +119,7 @@ describe('path constants', () => { describe('IDLE_TIMEOUT', () => { it('defaults to 1800000', async () => { delete process.env.IDLE_TIMEOUT; - const { IDLE_TIMEOUT } = await import('./config.js'); + const { IDLE_TIMEOUT } = await import('../config.js'); expect(IDLE_TIMEOUT).toBe(1800000); }); }); @@ -127,7 +127,7 @@ describe('IDLE_TIMEOUT', () => { describe('SCHEDULED_TASK_IDLE_TIMEOUT', () => { it('defaults to 30000', async () => { delete process.env.SCHEDULED_TASK_IDLE_TIMEOUT; - const { SCHEDULED_TASK_IDLE_TIMEOUT } = await import('./config.js'); + const { SCHEDULED_TASK_IDLE_TIMEOUT } = await import('../config.js'); expect(SCHEDULED_TASK_IDLE_TIMEOUT).toBe(30000); }); }); diff --git a/src/container-mounts.test.ts b/src/__tests__/container-mounts.test.ts similarity index 89% rename from src/container-mounts.test.ts rename to src/__tests__/container-mounts.test.ts index 816c696d41..c2c6ecd29c 100644 --- a/src/container-mounts.test.ts +++ b/src/__tests__/container-mounts.test.ts @@ -3,26 +3,27 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -vi.mock('./logger.js', () => ({ +vi.mock('../logger.js', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); -vi.mock('./config.js', () => ({ +vi.mock('../config.js', () => ({ DATA_DIR: '/tmp/__replaced__', GROUPS_DIR: '/tmp/__replaced__', })); -vi.mock('./mount-security.js', () => ({ +vi.mock('../mount-security.js', () => ({ + validateMount: vi.fn(() => ({ allowed: false, reason: 'mock reject' })), validateAdditionalMounts: vi.fn(() => []), })); -vi.mock('./env.js', () => ({ +vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})), })); -import * as configMod from './config.js'; -import { validateAdditionalMounts } from './mount-security.js'; -import type { RegisteredGroup } from './types.js'; +import * as configMod from '../config.js'; +import { validateMount, validateAdditionalMounts } from '../mount-security.js'; +import type { RegisteredGroup } from '../types.js'; let tmpDir: string; let projectRoot: string; @@ -75,12 +76,12 @@ afterEach(() => { // --- Core mount tests --- describe('buildVolumeMounts: core mounts', () => { - let buildVolumeMounts: typeof import('./container-mounts.js').buildVolumeMounts; - let setPluginRegistry: typeof import('./container-mounts.js').setPluginRegistry; + let buildVolumeMounts: typeof import('../container-mounts.js').buildVolumeMounts; + let setPluginRegistry: typeof import('../container-mounts.js').setPluginRegistry; beforeEach(async () => { vi.resetModules(); - const mod = await import('./container-mounts.js'); + const mod = await import('../container-mounts.js'); buildVolumeMounts = mod.buildVolumeMounts; setPluginRegistry = mod.setPluginRegistry; }); @@ -173,11 +174,11 @@ describe('buildVolumeMounts: core mounts', () => { // --- Main vs non-main --- describe('buildVolumeMounts: main vs non-main', () => { - let buildVolumeMounts: typeof import('./container-mounts.js').buildVolumeMounts; + let buildVolumeMounts: typeof import('../container-mounts.js').buildVolumeMounts; beforeEach(async () => { vi.resetModules(); - const mod = await import('./container-mounts.js'); + const mod = await import('../container-mounts.js'); buildVolumeMounts = mod.buildVolumeMounts; }); @@ -211,11 +212,11 @@ describe('buildVolumeMounts: main vs non-main', () => { // --- assertPathWithin (path traversal) --- describe('buildVolumeMounts: path traversal protection', () => { - let buildVolumeMounts: typeof import('./container-mounts.js').buildVolumeMounts; + let buildVolumeMounts: typeof import('../container-mounts.js').buildVolumeMounts; beforeEach(async () => { vi.resetModules(); - const mod = await import('./container-mounts.js'); + const mod = await import('../container-mounts.js'); buildVolumeMounts = mod.buildVolumeMounts; }); @@ -231,12 +232,12 @@ describe('buildVolumeMounts: path traversal protection', () => { // --- Plugin skill and hook mounts --- describe('buildVolumeMounts: plugin integration', () => { - let buildVolumeMounts: typeof import('./container-mounts.js').buildVolumeMounts; - let setPluginRegistry: typeof import('./container-mounts.js').setPluginRegistry; + let buildVolumeMounts: typeof import('../container-mounts.js').buildVolumeMounts; + let setPluginRegistry: typeof import('../container-mounts.js').setPluginRegistry; beforeEach(async () => { vi.resetModules(); - const mod = await import('./container-mounts.js'); + const mod = await import('../container-mounts.js'); buildVolumeMounts = mod.buildVolumeMounts; setPluginRegistry = mod.setPluginRegistry; }); @@ -303,24 +304,27 @@ describe('buildVolumeMounts: plugin integration', () => { setupProjectDirs(); setupGroupDirs('main'); - vi.mocked(validateAdditionalMounts).mockReturnValue([ - { hostPath: '/some/path', containerPath: '/workspace/extra/data', readonly: true }, - ]); + vi.mocked(validateMount).mockReturnValue({ + allowed: true, + realHostPath: '/some/path', + effectiveReadonly: true, + resolvedContainerPath: 'data', + } as any); setPluginRegistry({ getSkillPaths: vi.fn(() => []), getContainerHookPaths: vi.fn(() => []), - getContainerMounts: vi.fn(() => [{ hostPath: '/some/path', containerPath: '/workspace/extra/data' }]), + getContainerMounts: vi.fn(() => [{ hostPath: '/some/path', containerPath: 'data' }]), getMergedMcpConfig: vi.fn(() => ({ mcpServers: {} })), getContainerEnvVars: vi.fn(() => ['ANTHROPIC_API_KEY']), } as any); const mounts = await buildVolumeMounts(makeGroup(), true); - expect(validateAdditionalMounts).toHaveBeenCalledWith( - [{ hostPath: '/some/path', containerPath: '/workspace/extra/data', readonly: true }], - 'Main', + expect(validateMount).toHaveBeenCalledWith( + { hostPath: '/some/path', containerPath: 'data', readonly: true }, true, + { allowAbsoluteContainerPath: true }, ); const extra = mounts.find(m => m.containerPath === '/workspace/extra/data'); @@ -332,6 +336,9 @@ describe('buildVolumeMounts: plugin integration', () => { setupProjectDirs(); setupGroupDirs('main'); + // Reset validateMount to reject (previous test set it to allow) + vi.mocked(validateMount).mockReturnValue({ allowed: false, reason: 'mock reject' } as any); + setPluginRegistry({ getSkillPaths: vi.fn(() => []), getContainerHookPaths: vi.fn(() => []), @@ -365,12 +372,12 @@ describe('buildVolumeMounts: plugin integration', () => { // --- Env file construction --- describe('buildVolumeMounts: env file', () => { - let buildVolumeMounts: typeof import('./container-mounts.js').buildVolumeMounts; - let setPluginRegistry: typeof import('./container-mounts.js').setPluginRegistry; + let buildVolumeMounts: typeof import('../container-mounts.js').buildVolumeMounts; + let setPluginRegistry: typeof import('../container-mounts.js').setPluginRegistry; beforeEach(async () => { vi.resetModules(); - const mod = await import('./container-mounts.js'); + const mod = await import('../container-mounts.js'); buildVolumeMounts = mod.buildVolumeMounts; setPluginRegistry = mod.setPluginRegistry; }); @@ -514,11 +521,11 @@ describe('buildVolumeMounts: env file', () => { // --- Additional mounts passthrough --- describe('buildVolumeMounts: additional mounts', () => { - let buildVolumeMounts: typeof import('./container-mounts.js').buildVolumeMounts; + let buildVolumeMounts: typeof import('../container-mounts.js').buildVolumeMounts; beforeEach(async () => { vi.resetModules(); - const mod = await import('./container-mounts.js'); + const mod = await import('../container-mounts.js'); buildVolumeMounts = mod.buildVolumeMounts; }); @@ -551,11 +558,11 @@ describe('buildVolumeMounts: additional mounts', () => { // --- Credentials mount --- describe('buildVolumeMounts: credentials', () => { - let buildVolumeMounts: typeof import('./container-mounts.js').buildVolumeMounts; + let buildVolumeMounts: typeof import('../container-mounts.js').buildVolumeMounts; beforeEach(async () => { vi.resetModules(); - const mod = await import('./container-mounts.js'); + const mod = await import('../container-mounts.js'); buildVolumeMounts = mod.buildVolumeMounts; }); @@ -579,11 +586,11 @@ describe('buildVolumeMounts: credentials', () => { describe('readSecrets', () => { it('delegates to readEnvFile for auth keys', async () => { vi.resetModules(); - const { readEnvFile } = await import('./env.js'); + const { readEnvFile } = await import('../env.js'); vi.mocked(readEnvFile).mockReturnValue({ CLAUDE_CODE_OAUTH_TOKEN: 'tok', ANTHROPIC_API_KEY: 'key' }); - const { readSecrets } = await import('./container-mounts.js'); + const { readSecrets } = await import('../container-mounts.js'); const secrets = readSecrets(); - expect(readEnvFile).toHaveBeenCalledWith(['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']); + expect(readEnvFile).toHaveBeenCalledWith(['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN']); expect(secrets).toEqual({ CLAUDE_CODE_OAUTH_TOKEN: 'tok', ANTHROPIC_API_KEY: 'key' }); }); }); diff --git a/src/container-runner.test.ts b/src/__tests__/container-runner.test.ts similarity index 91% rename from src/container-runner.test.ts rename to src/__tests__/container-runner.test.ts index e5cda99b57..6041374b28 100644 --- a/src/container-runner.test.ts +++ b/src/__tests__/container-runner.test.ts @@ -8,7 +8,7 @@ const OUTPUT_START_MARKER = `---NANOCLAW_OUTPUT_${TEST_NONCE}_START---`; const OUTPUT_END_MARKER = `---NANOCLAW_OUTPUT_${TEST_NONCE}_END---`; // Mock config -vi.mock('./config.js', () => ({ +vi.mock('../config.js', () => ({ CONTAINER_IMAGE: 'nanoclaw-agent:latest', CONTAINER_MAX_OUTPUT_SIZE: 10485760, CONTAINER_TIMEOUT: 1800000, // 30min @@ -30,7 +30,7 @@ vi.mock('crypto', async () => { }); // Mock logger -vi.mock('./logger.js', () => ({ +vi.mock('../logger.js', () => ({ logger: { debug: vi.fn(), info: vi.fn(), @@ -66,13 +66,13 @@ vi.mock('fs', async () => { }); // Mock mount-security -vi.mock('./mount-security.js', () => ({ +vi.mock('../mount-security.js', () => ({ validateAdditionalMounts: vi.fn(() => []), })); // Mock container-mounts: buildVolumeMounts is now async -vi.mock('./container-mounts.js', async () => { - const actual = await vi.importActual('./container-mounts.js'); +vi.mock('../container-mounts.js', async () => { + const actual = await vi.importActual('../container-mounts.js'); return { ...actual, buildVolumeMounts: vi.fn(async () => []), @@ -81,8 +81,8 @@ vi.mock('./container-mounts.js', async () => { }); // Mock container-runtime: fixMountPermissions is async and must resolve immediately in tests -vi.mock('./container-runtime.js', async () => { - const actual = await vi.importActual('./container-runtime.js'); +vi.mock('../container-runtime.js', async () => { + const actual = await vi.importActual('../container-runtime.js'); return { ...actual, fixMountPermissions: vi.fn(() => Promise.resolve()), @@ -121,8 +121,8 @@ vi.mock('child_process', async () => { }; }); -import { runContainerAgent, ContainerOutput } from './container-runner.js'; -import type { RegisteredGroup } from './types.js'; +import { runContainerAgent, ContainerOutput } from '../container-runner.js'; +import type { RegisteredGroup } from '../types.js'; const testGroup: RegisteredGroup = { name: 'Test Group', diff --git a/src/env.test.ts b/src/__tests__/env.test.ts similarity index 98% rename from src/env.test.ts rename to src/__tests__/env.test.ts index d47bb4631a..c5ae9f0a60 100644 --- a/src/env.test.ts +++ b/src/__tests__/env.test.ts @@ -3,7 +3,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import { readEnvFile } from './env.js'; +import { readEnvFile } from '../env.js'; let tmpDir: string; let cwdSpy: ReturnType; diff --git a/src/formatting.test.ts b/src/__tests__/formatting.test.ts similarity index 99% rename from src/formatting.test.ts rename to src/__tests__/formatting.test.ts index 594c5386e3..4db9313883 100644 --- a/src/formatting.test.ts +++ b/src/__tests__/formatting.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect } from 'vitest'; -import { TRIGGER_PATTERN, createTriggerPattern, ASSISTANT_NAME } from './config.js'; +import { TRIGGER_PATTERN, createTriggerPattern, ASSISTANT_NAME } from '../config.js'; import { escapeXml, formatMessages, stripInternalTags, -} from './router.js'; -import { NewMessage } from './types.js'; +} from '../router.js'; +import { NewMessage } from '../types.js'; function makeMsg(overrides: Partial = {}): NewMessage { return { diff --git a/src/group-queue.test.ts b/src/__tests__/group-queue.test.ts similarity index 99% rename from src/group-queue.test.ts rename to src/__tests__/group-queue.test.ts index 12e7365b54..cb4df163a3 100644 --- a/src/group-queue.test.ts +++ b/src/__tests__/group-queue.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { GroupQueue } from './group-queue.js'; +import { GroupQueue } from '../group-queue.js'; // Mock config to control concurrency limit -vi.mock('./config.js', () => ({ +vi.mock('../config.js', () => ({ DATA_DIR: '/tmp/nanoclaw-test-data', MAX_CONCURRENT_CONTAINERS: 2, })); diff --git a/src/mount-security.test.ts b/src/__tests__/mount-security.test.ts similarity index 77% rename from src/mount-security.test.ts rename to src/__tests__/mount-security.test.ts index 32de1c31cf..4e9660cba6 100644 --- a/src/mount-security.test.ts +++ b/src/__tests__/mount-security.test.ts @@ -3,16 +3,16 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -vi.mock('./logger.js', () => ({ +vi.mock('../logger.js', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); // Config mock — MOUNT_ALLOWLIST_PATH will be overridden per test group -vi.mock('./config.js', () => ({ +vi.mock('../config.js', () => ({ MOUNT_ALLOWLIST_PATH: '/tmp/__replaced__', })); -import * as configMod from './config.js'; +import * as configMod from '../config.js'; let tmpDir: string; @@ -48,7 +48,7 @@ describe('loadMountAllowlist', () => { it('returns null when file is missing', async () => { vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = path.join(tmpDir, 'nonexistent.json'); - const { loadMountAllowlist } = await import('./mount-security.js'); + const { loadMountAllowlist } = await import('../mount-security.js'); expect(loadMountAllowlist()).toBeNull(); }); @@ -56,7 +56,7 @@ describe('loadMountAllowlist', () => { const filePath = writeAllowlist(validAllowlist()); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { loadMountAllowlist } = await import('./mount-security.js'); + const { loadMountAllowlist } = await import('../mount-security.js'); const result = loadMountAllowlist(); expect(result).not.toBeNull(); expect(result!.allowedRoots).toHaveLength(1); @@ -67,7 +67,7 @@ describe('loadMountAllowlist', () => { const filePath = writeAllowlist(validAllowlist({ blockedPatterns: ['custom-secret'] })); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { loadMountAllowlist } = await import('./mount-security.js'); + const { loadMountAllowlist } = await import('../mount-security.js'); const result = loadMountAllowlist(); expect(result!.blockedPatterns).toContain('.ssh'); expect(result!.blockedPatterns).toContain('.gnupg'); @@ -79,7 +79,7 @@ describe('loadMountAllowlist', () => { fs.writeFileSync(filePath, 'not json {{{'); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { loadMountAllowlist } = await import('./mount-security.js'); + const { loadMountAllowlist } = await import('../mount-security.js'); expect(loadMountAllowlist()).toBeNull(); }); @@ -87,7 +87,7 @@ describe('loadMountAllowlist', () => { const filePath = writeAllowlist({ allowedRoots: 'bad', blockedPatterns: [], nonMainReadOnly: false }); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { loadMountAllowlist } = await import('./mount-security.js'); + const { loadMountAllowlist } = await import('../mount-security.js'); expect(loadMountAllowlist()).toBeNull(); }); @@ -95,7 +95,7 @@ describe('loadMountAllowlist', () => { const filePath = writeAllowlist({ allowedRoots: [], blockedPatterns: 'bad', nonMainReadOnly: false }); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { loadMountAllowlist } = await import('./mount-security.js'); + const { loadMountAllowlist } = await import('../mount-security.js'); expect(loadMountAllowlist()).toBeNull(); }); @@ -103,7 +103,7 @@ describe('loadMountAllowlist', () => { const filePath = writeAllowlist({ allowedRoots: [], blockedPatterns: [], nonMainReadOnly: 'yes' }); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { loadMountAllowlist } = await import('./mount-security.js'); + const { loadMountAllowlist } = await import('../mount-security.js'); expect(loadMountAllowlist()).toBeNull(); }); @@ -111,7 +111,7 @@ describe('loadMountAllowlist', () => { const filePath = writeAllowlist(validAllowlist()); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { loadMountAllowlist } = await import('./mount-security.js'); + const { loadMountAllowlist } = await import('../mount-security.js'); const first = loadMountAllowlist(); // Modify file — should still return cached fs.writeFileSync(filePath, JSON.stringify({ allowedRoots: [], blockedPatterns: [], nonMainReadOnly: true })); @@ -129,7 +129,7 @@ describe('validateMount', () => { const filePath = writeAllowlist(validAllowlist()); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { validateMount } = await import('./mount-security.js'); + const { validateMount } = await import('../mount-security.js'); const result = validateMount({ hostPath: subDir }, true); expect(result.allowed).toBe(true); expect(result.realHostPath).toBe(subDir); @@ -141,7 +141,7 @@ describe('validateMount', () => { })); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { validateMount } = await import('./mount-security.js'); + const { validateMount } = await import('../mount-security.js'); const result = validateMount({ hostPath: tmpDir }, true); expect(result.allowed).toBe(false); expect(result.reason).toContain('not under any allowed root'); @@ -153,7 +153,7 @@ describe('validateMount', () => { const filePath = writeAllowlist(validAllowlist()); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { validateMount } = await import('./mount-security.js'); + const { validateMount } = await import('../mount-security.js'); const result = validateMount({ hostPath: sshDir }, true); expect(result.allowed).toBe(false); expect(result.reason).toContain('.ssh'); @@ -165,7 +165,7 @@ describe('validateMount', () => { const filePath = writeAllowlist(validAllowlist()); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { validateMount } = await import('./mount-security.js'); + const { validateMount } = await import('../mount-security.js'); const result = validateMount({ hostPath: envDir }, true); expect(result.allowed).toBe(false); expect(result.reason).toContain('.env'); @@ -177,7 +177,7 @@ describe('validateMount', () => { const filePath = writeAllowlist(validAllowlist()); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { validateMount } = await import('./mount-security.js'); + const { validateMount } = await import('../mount-security.js'); const result = validateMount({ hostPath: subDir, containerPath: '../../escape' }, true); expect(result.allowed).toBe(false); expect(result.reason).toContain('..'); @@ -189,7 +189,7 @@ describe('validateMount', () => { const filePath = writeAllowlist(validAllowlist()); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { validateMount } = await import('./mount-security.js'); + const { validateMount } = await import('../mount-security.js'); const result = validateMount({ hostPath: subDir, containerPath: '/etc/shadow' }, true); expect(result.allowed).toBe(false); }); @@ -202,7 +202,7 @@ describe('validateMount', () => { const filePath = writeAllowlist(validAllowlist()); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { validateMount } = await import('./mount-security.js'); + const { validateMount } = await import('../mount-security.js'); const result = validateMount({ hostPath: linkPath }, true); expect(result.allowed).toBe(true); expect(result.realHostPath).toBe(realDir); @@ -219,7 +219,7 @@ describe('validateMount', () => { })); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { validateMount } = await import('./mount-security.js'); + const { validateMount } = await import('../mount-security.js'); const result = validateMount({ hostPath: linkPath }, true); expect(result.allowed).toBe(false); }); @@ -228,7 +228,7 @@ describe('validateMount', () => { const filePath = writeAllowlist(validAllowlist()); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { validateMount } = await import('./mount-security.js'); + const { validateMount } = await import('../mount-security.js'); const result = validateMount({ hostPath: path.join(tmpDir, 'nonexistent') }, true); expect(result.allowed).toBe(false); expect(result.reason).toContain('does not exist'); @@ -242,7 +242,7 @@ describe('validateMount', () => { })); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { validateMount } = await import('./mount-security.js'); + const { validateMount } = await import('../mount-security.js'); // Use a path we know exists under home const result = validateMount({ hostPath: homeDir }, true); expect(result.allowed).toBe(true); @@ -254,7 +254,7 @@ describe('validateMount', () => { const filePath = writeAllowlist(validAllowlist({ nonMainReadOnly: true })); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { validateMount } = await import('./mount-security.js'); + const { validateMount } = await import('../mount-security.js'); const result = validateMount({ hostPath: subDir, readonly: false }, false); expect(result.allowed).toBe(true); expect(result.effectiveReadonly).toBe(true); @@ -266,7 +266,7 @@ describe('validateMount', () => { const filePath = writeAllowlist(validAllowlist({ nonMainReadOnly: true })); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { validateMount } = await import('./mount-security.js'); + const { validateMount } = await import('../mount-security.js'); const result = validateMount({ hostPath: subDir, readonly: false }, true); expect(result.allowed).toBe(true); expect(result.effectiveReadonly).toBe(false); @@ -280,7 +280,7 @@ describe('validateMount', () => { })); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { validateMount } = await import('./mount-security.js'); + const { validateMount } = await import('../mount-security.js'); const result = validateMount({ hostPath: subDir, readonly: false }, true); expect(result.allowed).toBe(true); expect(result.effectiveReadonly).toBe(true); @@ -292,7 +292,7 @@ describe('validateMount', () => { const filePath = writeAllowlist(validAllowlist()); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { validateMount } = await import('./mount-security.js'); + const { validateMount } = await import('../mount-security.js'); const result = validateMount({ hostPath: subDir }, true); expect(result.allowed).toBe(true); expect(result.effectiveReadonly).toBe(true); @@ -304,7 +304,7 @@ describe('validateMount', () => { const filePath = writeAllowlist(validAllowlist()); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { validateMount } = await import('./mount-security.js'); + const { validateMount } = await import('../mount-security.js'); const result = validateMount({ hostPath: subDir }, true); expect(result.resolvedContainerPath).toBe('myproject'); }); @@ -312,11 +312,57 @@ describe('validateMount', () => { it('blocks all mounts when no allowlist exists', async () => { vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = path.join(tmpDir, 'nonexistent.json'); - const { validateMount } = await import('./mount-security.js'); + const { validateMount } = await import('../mount-security.js'); const result = validateMount({ hostPath: tmpDir }, true); expect(result.allowed).toBe(false); expect(result.reason).toContain('No mount allowlist configured'); }); + + it('allows absolute container path with allowAbsoluteContainerPath option', async () => { + const subDir = path.join(tmpDir, 'gogcli'); + fs.mkdirSync(subDir); + const filePath = writeAllowlist(validAllowlist()); + vi.resetModules(); + (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; + const { validateMount } = await import('../mount-security.js'); + const result = validateMount( + { hostPath: subDir, containerPath: '/home/node/.config/gogcli' }, + true, + { allowAbsoluteContainerPath: true }, + ); + expect(result.allowed).toBe(true); + expect(result.resolvedContainerPath).toBe('/home/node/.config/gogcli'); + }); + + it('still rejects traversal in absolute container path', async () => { + const subDir = path.join(tmpDir, 'data'); + fs.mkdirSync(subDir); + const filePath = writeAllowlist(validAllowlist()); + vi.resetModules(); + (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; + const { validateMount } = await import('../mount-security.js'); + const result = validateMount( + { hostPath: subDir, containerPath: '/home/node/../../etc/shadow' }, + true, + { allowAbsoluteContainerPath: true }, + ); + expect(result.allowed).toBe(false); + expect(result.reason).toContain('..'); + }); + + it('rejects absolute container path without the option', async () => { + const subDir = path.join(tmpDir, 'gogcli'); + fs.mkdirSync(subDir); + const filePath = writeAllowlist(validAllowlist()); + vi.resetModules(); + (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; + const { validateMount } = await import('../mount-security.js'); + const result = validateMount( + { hostPath: subDir, containerPath: '/home/node/.config/gogcli' }, + true, + ); + expect(result.allowed).toBe(false); + }); }); // --- validateAdditionalMounts --- @@ -328,7 +374,7 @@ describe('validateAdditionalMounts', () => { const filePath = writeAllowlist(validAllowlist()); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { validateAdditionalMounts } = await import('./mount-security.js'); + const { validateAdditionalMounts } = await import('../mount-security.js'); const result = validateAdditionalMounts( [ { hostPath: goodDir }, @@ -347,7 +393,7 @@ describe('validateAdditionalMounts', () => { const filePath = writeAllowlist(validAllowlist()); vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = filePath; - const { validateAdditionalMounts } = await import('./mount-security.js'); + const { validateAdditionalMounts } = await import('../mount-security.js'); const result = validateAdditionalMounts( [{ hostPath: dir, containerPath: 'custom-name' }], 'test-group', @@ -360,7 +406,7 @@ describe('validateAdditionalMounts', () => { it('returns empty array when no allowlist', async () => { vi.resetModules(); (configMod as any).MOUNT_ALLOWLIST_PATH = path.join(tmpDir, 'nonexistent.json'); - const { validateAdditionalMounts } = await import('./mount-security.js'); + const { validateAdditionalMounts } = await import('../mount-security.js'); const result = validateAdditionalMounts( [{ hostPath: tmpDir }], 'test-group', @@ -375,7 +421,7 @@ describe('validateAdditionalMounts', () => { describe('generateAllowlistTemplate', () => { it('generates valid JSON', async () => { vi.resetModules(); - const { generateAllowlistTemplate } = await import('./mount-security.js'); + const { generateAllowlistTemplate } = await import('../mount-security.js'); const template = generateAllowlistTemplate(); const parsed = JSON.parse(template); expect(parsed.allowedRoots).toBeInstanceOf(Array); @@ -385,7 +431,7 @@ describe('generateAllowlistTemplate', () => { it('includes example roots and patterns', async () => { vi.resetModules(); - const { generateAllowlistTemplate } = await import('./mount-security.js'); + const { generateAllowlistTemplate } = await import('../mount-security.js'); const parsed = JSON.parse(generateAllowlistTemplate()); expect(parsed.allowedRoots.length).toBeGreaterThan(0); expect(parsed.blockedPatterns.length).toBeGreaterThan(0); diff --git a/src/orchestrator.test.ts b/src/__tests__/orchestrator.test.ts similarity index 98% rename from src/orchestrator.test.ts rename to src/__tests__/orchestrator.test.ts index 4732473fd1..220aa85273 100644 --- a/src/orchestrator.test.ts +++ b/src/__tests__/orchestrator.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { EventEmitter } from 'events'; -import { MessageOrchestrator, OrchestratorDeps } from './orchestrator.js'; -import type { RegisteredGroup, NewMessage } from './types.js'; +import { MessageOrchestrator, OrchestratorDeps } from '../orchestrator.js'; +import type { RegisteredGroup, NewMessage } from '../types.js'; function makeDeps(overrides: Partial = {}): OrchestratorDeps { return { diff --git a/src/output-parser.test.ts b/src/__tests__/output-parser.test.ts similarity index 98% rename from src/output-parser.test.ts rename to src/__tests__/output-parser.test.ts index b255a520cc..f5366e5bb7 100644 --- a/src/output-parser.test.ts +++ b/src/__tests__/output-parser.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; -import { createOutputParser, OUTPUT_START_MARKER, OUTPUT_END_MARKER } from './output-parser.js'; -import type { ContainerOutput } from './container-runner.js'; +import { createOutputParser, OUTPUT_START_MARKER, OUTPUT_END_MARKER } from '../output-parser.js'; +import type { ContainerOutput } from '../container-runner.js'; function wrap(json: string): string { return `${OUTPUT_START_MARKER}\n${json}\n${OUTPUT_END_MARKER}\n`; diff --git a/src/plugin-loader.test.ts b/src/__tests__/plugin-loader.test.ts similarity index 99% rename from src/plugin-loader.test.ts rename to src/__tests__/plugin-loader.test.ts index b106abc65c..e082d6cbd2 100644 --- a/src/plugin-loader.test.ts +++ b/src/__tests__/plugin-loader.test.ts @@ -2,11 +2,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import fs from 'fs'; import path from 'path'; -vi.mock('./logger.js', () => ({ +vi.mock('../logger.js', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); -import { parseManifest, collectContainerEnvVars, collectSkillPaths, collectContainerHookPaths, mergeMcpConfigs, PluginRegistry } from './plugin-loader.js'; +import { parseManifest, collectContainerEnvVars, collectSkillPaths, collectContainerHookPaths, mergeMcpConfigs, PluginRegistry } from '../plugin-loader.js'; describe('parseManifest', () => { it('parses a valid manifest-only plugin', () => { diff --git a/src/router.test.ts b/src/__tests__/router.test.ts similarity index 95% rename from src/router.test.ts rename to src/__tests__/router.test.ts index 8bdd807a44..40bb479a35 100644 --- a/src/router.test.ts +++ b/src/__tests__/router.test.ts @@ -1,17 +1,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -vi.mock('./logger.js', () => ({ +vi.mock('../logger.js', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); -vi.mock('./secret-redact.js', () => ({ +vi.mock('../secret-redact.js', () => ({ redactSecrets: vi.fn((s: string) => s), })); -import { isAuthError, routeOutbound, routeOutboundFile } from './router.js'; -import { redactSecrets } from './secret-redact.js'; -import type { Channel } from './types.js'; -import type { PluginRegistry } from './plugin-loader.js'; +import { isAuthError, routeOutbound, routeOutboundFile } from '../router.js'; +import { redactSecrets } from '../secret-redact.js'; +import type { Channel } from '../types.js'; +import type { PluginRegistry } from '../plugin-loader.js'; function makeChannel(overrides: Partial = {}): Channel { return { diff --git a/src/routing.test.ts b/src/__tests__/routing.test.ts similarity index 97% rename from src/routing.test.ts rename to src/__tests__/routing.test.ts index 6ab7ac5404..0391220256 100644 --- a/src/routing.test.ts +++ b/src/__tests__/routing.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { EventEmitter } from 'events'; -import { _initTestDatabase, getAllChats, storeChatMetadata } from './db.js'; -import { MessageOrchestrator, OrchestratorDeps } from './orchestrator.js'; -import type { Channel } from './types.js'; +import { _initTestDatabase, getAllChats, storeChatMetadata } from '../db.js'; +import { MessageOrchestrator, OrchestratorDeps } from '../orchestrator.js'; +import type { Channel } from '../types.js'; /** Mock WhatsApp-like channel that owns @g.us and @s.whatsapp.net JIDs */ function mockWhatsAppChannel(): Channel { diff --git a/src/secret-redact.test.ts b/src/__tests__/secret-redact.test.ts similarity index 95% rename from src/secret-redact.test.ts rename to src/__tests__/secret-redact.test.ts index 0ab23ea55b..2e3e16a9da 100644 --- a/src/secret-redact.test.ts +++ b/src/__tests__/secret-redact.test.ts @@ -8,15 +8,15 @@ let cwdSpy: ReturnType; let homeSpy: ReturnType; // Dynamic import after mocks — fresh module state each test -let loadSecrets: typeof import('./secret-redact.js').loadSecrets; -let redactSecrets: typeof import('./secret-redact.js').redactSecrets; +let loadSecrets: typeof import('../secret-redact.js').loadSecrets; +let redactSecrets: typeof import('../secret-redact.js').redactSecrets; beforeEach(async () => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-redact-test-')); cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmpDir); homeSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmpDir); vi.resetModules(); - const mod = await import('./secret-redact.js'); + const mod = await import('../secret-redact.js'); loadSecrets = mod.loadSecrets; redactSecrets = mod.redactSecrets; }); @@ -179,11 +179,11 @@ describe('redacts all .env values by default (not just named secrets)', () => { }); it('redacts vars without KEY/TOKEN/SECRET in name', () => { - writeEnv('CLAUDE_MEM_URL=http://172.17.0.1:37777'); + writeEnv('CLAUDE_MEM_URL=http://host.docker.internal:37777'); loadSecrets(); // CLAUDE_MEM_URL is not on the safe-list, so its value gets redacted - expect(redactSecrets('http://172.17.0.1:37777')).toBe('[REDACTED]'); + expect(redactSecrets('http://host.docker.internal:37777')).toBe('[REDACTED]'); }); }); @@ -204,7 +204,7 @@ describe('plugin publicEnvVars (additionalSafeVars)', () => { it('merges additionalSafeVars with built-in safe-list', () => { writeEnv([ 'ASSISTANT_NAME=TARS-EXTENDED', - 'CLAUDE_MEM_URL=http://172.17.0.1:37777', + 'CLAUDE_MEM_URL=http://host.docker.internal:37777', 'ANTHROPIC_API_KEY=sk-ant-secret-here', ].join('\n')); loadSecrets(['CLAUDE_MEM_URL']); @@ -212,7 +212,7 @@ describe('plugin publicEnvVars (additionalSafeVars)', () => { // Built-in safe-list expect(redactSecrets('TARS-EXTENDED')).toBe('TARS-EXTENDED'); // Plugin publicEnvVar - expect(redactSecrets('http://172.17.0.1:37777')).toBe('http://172.17.0.1:37777'); + expect(redactSecrets('http://host.docker.internal:37777')).toBe('http://host.docker.internal:37777'); // Secret expect(redactSecrets('sk-ant-secret-here')).toBe('[REDACTED]'); }); diff --git a/src/__tests__/sender-allowlist.test.ts b/src/__tests__/sender-allowlist.test.ts new file mode 100644 index 0000000000..5505cde20d --- /dev/null +++ b/src/__tests__/sender-allowlist.test.ts @@ -0,0 +1,216 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + isSenderAllowed, + isTriggerAllowed, + loadSenderAllowlist, + SenderAllowlistConfig, + shouldDropMessage, +} from '../sender-allowlist.js'; + +let tmpDir: string; + +function cfgPath(name = 'sender-allowlist.json'): string { + return path.join(tmpDir, name); +} + +function writeConfig(config: unknown, name?: string): string { + const p = cfgPath(name); + fs.writeFileSync(p, JSON.stringify(config)); + return p; +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'allowlist-test-')); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('loadSenderAllowlist', () => { + it('returns allow-all defaults when file is missing', () => { + const cfg = loadSenderAllowlist(cfgPath()); + expect(cfg.default.allow).toBe('*'); + expect(cfg.default.mode).toBe('trigger'); + expect(cfg.logDenied).toBe(true); + }); + + it('loads allow=* config', () => { + const p = writeConfig({ + default: { allow: '*', mode: 'trigger' }, + chats: {}, + logDenied: false, + }); + const cfg = loadSenderAllowlist(p); + expect(cfg.default.allow).toBe('*'); + expect(cfg.logDenied).toBe(false); + }); + + it('loads allow=[] (deny all)', () => { + const p = writeConfig({ + default: { allow: [], mode: 'trigger' }, + chats: {}, + }); + const cfg = loadSenderAllowlist(p); + expect(cfg.default.allow).toEqual([]); + }); + + it('loads allow=[list]', () => { + const p = writeConfig({ + default: { allow: ['alice', 'bob'], mode: 'drop' }, + chats: {}, + }); + const cfg = loadSenderAllowlist(p); + expect(cfg.default.allow).toEqual(['alice', 'bob']); + expect(cfg.default.mode).toBe('drop'); + }); + + it('per-chat override beats default', () => { + const p = writeConfig({ + default: { allow: '*', mode: 'trigger' }, + chats: { 'group-a': { allow: ['alice'], mode: 'drop' } }, + }); + const cfg = loadSenderAllowlist(p); + expect(cfg.chats['group-a'].allow).toEqual(['alice']); + expect(cfg.chats['group-a'].mode).toBe('drop'); + }); + + it('returns allow-all on invalid JSON', () => { + const p = cfgPath(); + fs.writeFileSync(p, '{ not valid json }}}'); + const cfg = loadSenderAllowlist(p); + expect(cfg.default.allow).toBe('*'); + }); + + it('returns allow-all on invalid schema', () => { + const p = writeConfig({ default: { oops: true } }); + const cfg = loadSenderAllowlist(p); + expect(cfg.default.allow).toBe('*'); + }); + + it('rejects non-string allow array items', () => { + const p = writeConfig({ + default: { allow: [123, null, true], mode: 'trigger' }, + chats: {}, + }); + const cfg = loadSenderAllowlist(p); + expect(cfg.default.allow).toBe('*'); // falls back to default + }); + + it('skips invalid per-chat entries', () => { + const p = writeConfig({ + default: { allow: '*', mode: 'trigger' }, + chats: { + good: { allow: ['alice'], mode: 'trigger' }, + bad: { allow: 123 }, + }, + }); + const cfg = loadSenderAllowlist(p); + expect(cfg.chats['good']).toBeDefined(); + expect(cfg.chats['bad']).toBeUndefined(); + }); +}); + +describe('isSenderAllowed', () => { + it('allow=* allows any sender', () => { + const cfg: SenderAllowlistConfig = { + default: { allow: '*', mode: 'trigger' }, + chats: {}, + logDenied: true, + }; + expect(isSenderAllowed('g1', 'anyone', cfg)).toBe(true); + }); + + it('allow=[] denies any sender', () => { + const cfg: SenderAllowlistConfig = { + default: { allow: [], mode: 'trigger' }, + chats: {}, + logDenied: true, + }; + expect(isSenderAllowed('g1', 'anyone', cfg)).toBe(false); + }); + + it('allow=[list] allows exact match only', () => { + const cfg: SenderAllowlistConfig = { + default: { allow: ['alice', 'bob'], mode: 'trigger' }, + chats: {}, + logDenied: true, + }; + expect(isSenderAllowed('g1', 'alice', cfg)).toBe(true); + expect(isSenderAllowed('g1', 'eve', cfg)).toBe(false); + }); + + it('uses per-chat entry over default', () => { + const cfg: SenderAllowlistConfig = { + default: { allow: '*', mode: 'trigger' }, + chats: { g1: { allow: ['alice'], mode: 'trigger' } }, + logDenied: true, + }; + expect(isSenderAllowed('g1', 'bob', cfg)).toBe(false); + expect(isSenderAllowed('g2', 'bob', cfg)).toBe(true); + }); +}); + +describe('shouldDropMessage', () => { + it('returns false for trigger mode', () => { + const cfg: SenderAllowlistConfig = { + default: { allow: '*', mode: 'trigger' }, + chats: {}, + logDenied: true, + }; + expect(shouldDropMessage('g1', cfg)).toBe(false); + }); + + it('returns true for drop mode', () => { + const cfg: SenderAllowlistConfig = { + default: { allow: '*', mode: 'drop' }, + chats: {}, + logDenied: true, + }; + expect(shouldDropMessage('g1', cfg)).toBe(true); + }); + + it('per-chat mode override', () => { + const cfg: SenderAllowlistConfig = { + default: { allow: '*', mode: 'trigger' }, + chats: { g1: { allow: '*', mode: 'drop' } }, + logDenied: true, + }; + expect(shouldDropMessage('g1', cfg)).toBe(true); + expect(shouldDropMessage('g2', cfg)).toBe(false); + }); +}); + +describe('isTriggerAllowed', () => { + it('allows trigger for allowed sender', () => { + const cfg: SenderAllowlistConfig = { + default: { allow: ['alice'], mode: 'trigger' }, + chats: {}, + logDenied: false, + }; + expect(isTriggerAllowed('g1', 'alice', cfg)).toBe(true); + }); + + it('denies trigger for disallowed sender', () => { + const cfg: SenderAllowlistConfig = { + default: { allow: ['alice'], mode: 'trigger' }, + chats: {}, + logDenied: false, + }; + expect(isTriggerAllowed('g1', 'eve', cfg)).toBe(false); + }); + + it('logs when logDenied is true', () => { + const cfg: SenderAllowlistConfig = { + default: { allow: ['alice'], mode: 'trigger' }, + chats: {}, + logDenied: true, + }; + isTriggerAllowed('g1', 'eve', cfg); + // Logger.debug is called — we just verify no crash; logger is a real pino instance + }); +}); diff --git a/src/snapshots.test.ts b/src/__tests__/snapshots.test.ts similarity index 97% rename from src/snapshots.test.ts rename to src/__tests__/snapshots.test.ts index dd330be920..ff3613a3ab 100644 --- a/src/snapshots.test.ts +++ b/src/__tests__/snapshots.test.ts @@ -3,13 +3,13 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -vi.mock('./config.js', () => ({ +vi.mock('../config.js', () => ({ DATA_DIR: '/tmp/__will_be_replaced__', })); -import { mapTasksToSnapshot, writeTasksSnapshot, writeGroupsSnapshot } from './snapshots.js'; -import * as configMod from './config.js'; -import type { ScheduledTask } from './types.js'; +import { mapTasksToSnapshot, writeTasksSnapshot, writeGroupsSnapshot } from '../snapshots.js'; +import * as configMod from '../config.js'; +import type { ScheduledTask } from '../types.js'; let tmpDir: string; diff --git a/src/config.ts b/src/config.ts index ff932061a9..ee724049e2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -26,6 +26,12 @@ export const MOUNT_ALLOWLIST_PATH = path.join( 'nanoclaw', 'mount-allowlist.json', ); +export const SENDER_ALLOWLIST_PATH = path.join( + HOME_DIR, + '.config', + 'nanoclaw', + 'sender-allowlist.json', +); export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store'); export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups'); export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); diff --git a/src/container-mounts.ts b/src/container-mounts.ts index d5f60be21c..2d8493e514 100644 --- a/src/container-mounts.ts +++ b/src/container-mounts.ts @@ -9,7 +9,7 @@ import path from 'path'; import { DATA_DIR, GROUPS_DIR } from './config.js'; import { readEnvFile } from './env.js'; import { logger } from './logger.js'; -import { validateAdditionalMounts } from './mount-security.js'; +import { validateAdditionalMounts, validateMount } from './mount-security.js'; import { RegisteredGroup } from './types.js'; import type { PluginRegistry } from './plugin-loader.js'; @@ -127,15 +127,32 @@ export async function buildVolumeMounts( }); } - // Plugin-declared container mounts (read-only, validated against allowlist) + // Plugin-declared container mounts — admin-installed and trusted. + // Unlike group additionalMounts, plugins may use absolute container paths + // (e.g. /home/node/.config/gogcli) since they know where tools expect files. const pluginMounts = pluginRegistry.getContainerMounts(scopeChannel, scopeGroup); - if (pluginMounts.length > 0) { - const validated = validateAdditionalMounts( - pluginMounts.map(pm => ({ hostPath: pm.hostPath, containerPath: pm.containerPath, readonly: true })), - group.name, + for (const pm of pluginMounts) { + const result = validateMount( + { hostPath: pm.hostPath, containerPath: pm.containerPath, readonly: true }, isMain, + { allowAbsoluteContainerPath: true }, ); - mounts.push(...validated); + if (result.allowed) { + // Absolute container paths used as-is; relative ones go under /workspace/extra/ + const containerPath = pm.containerPath.startsWith('/') + ? pm.containerPath + : `/workspace/extra/${result.resolvedContainerPath}`; + mounts.push({ + hostPath: result.realHostPath!, + containerPath, + readonly: result.effectiveReadonly!, + }); + } else { + logger.warn( + { requestedPath: pm.hostPath, containerPath: pm.containerPath, reason: result.reason }, + 'Plugin mount REJECTED', + ); + } } // MCP server config — merge root .mcp.json with plugin mcp.json fragments @@ -378,5 +395,5 @@ async function buildEnvMount( * Secrets are never written to disk or mounted as files. */ export function readSecrets(): Record { - return readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']); + return readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN']); } diff --git a/src/container-runtime.ts b/src/container-runtime.ts index 2cf777ac6f..797ed2c7e3 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -170,6 +170,7 @@ export function extraRunArgs(): string[] { '--cpus=2', '--memory=4g', '--pids-limit=256', + '--add-host=host.docker.internal:host-gateway', ]; } return []; @@ -191,7 +192,7 @@ export function stop( export function fixMountPermissions(hostPath: string): Promise { if (detectRuntime() !== 'docker') return Promise.resolve(); return new Promise((resolve) => { - execFile('chown', ['-R', '1000:1000', hostPath], { stdio: 'pipe' }, (err) => { + execFile('chown', ['-R', '1000:1000', hostPath], (err: Error | null) => { if (err) { logger.warn({ hostPath, err: err.message }, 'chown failed on mount path (container may still work)'); } diff --git a/src/db.test.ts b/src/db/__tests__/db.test.ts similarity index 99% rename from src/db.test.ts rename to src/db/__tests__/db.test.ts index b3c24e1cb1..3f140909f0 100644 --- a/src/db.test.ts +++ b/src/db/__tests__/db.test.ts @@ -21,7 +21,7 @@ import { storeChatMetadata, storeMessage, updateTask, -} from './db.js'; +} from '../index.js'; beforeEach(() => { _initTestDatabase(); diff --git a/src/db/messages.ts b/src/db/messages.ts index 264b2fba43..589f844567 100644 --- a/src/db/messages.ts +++ b/src/db/messages.ts @@ -144,7 +144,7 @@ export function getNewMessages( // Use >= so that messages arriving in the same second as the cursor are // not permanently skipped. Callers must track processed IDs to deduplicate. const sql = ` - SELECT id, chat_jid, sender, sender_name, content, timestamp, reply_context + SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, reply_context FROM messages WHERE timestamp >= ? AND chat_jid IN (${placeholders}) AND is_bot_message = 0 AND content NOT LIKE ? @@ -159,6 +159,7 @@ export function getNewMessages( const messages: NewMessage[] = rows.map((row) => ({ ...row, + is_from_me: !!(row as unknown as Record).is_from_me, reply_context: row.reply_context ? JSON.parse(row.reply_context) : undefined, })); @@ -178,7 +179,7 @@ export function getMessagesSince( // Filter bot messages using both the is_bot_message flag AND the content // prefix as a backstop for messages written before the migration ran. const sql = ` - SELECT id, chat_jid, sender, sender_name, content, timestamp, reply_context + SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, reply_context FROM messages WHERE chat_jid = ? AND timestamp > ? AND is_bot_message = 0 AND content NOT LIKE ? @@ -191,6 +192,7 @@ export function getMessagesSince( >; return rows.map((row) => ({ ...row, + is_from_me: !!(row as unknown as Record).is_from_me, reply_context: row.reply_context ? JSON.parse(row.reply_context) : undefined, })); } diff --git a/src/group-queue.ts b/src/group-queue.ts index fe36652cd9..7eb2619958 100644 --- a/src/group-queue.ts +++ b/src/group-queue.ts @@ -21,6 +21,7 @@ interface GroupState { isTaskContainer: boolean; pendingMessages: boolean; pendingTasks: QueuedTask[]; + runningTaskId: string | null; process: ChildProcess | null; containerName: string | null; groupFolder: string | null; @@ -44,6 +45,7 @@ export class GroupQueue { isTaskContainer: false, pendingMessages: false, pendingTasks: [], + runningTaskId: null, process: null, containerName: null, groupFolder: null, @@ -110,6 +112,12 @@ export class GroupQueue { return; } + // Prevent re-enqueue of currently running task + if (state.runningTaskId === taskId) { + logger.debug({ groupJid, taskId }, 'Task already running, skipping'); + return; + } + if (state.active) { state.pendingTasks.push({ id: taskId, groupJid, fn }); if (state.idleWaiting) { @@ -245,6 +253,7 @@ export class GroupQueue { private async runTask(groupJid: string, task: QueuedTask): Promise { const state = this.getGroup(groupJid); state.active = true; + state.runningTaskId = task.id; this.activeCount++; logger.debug( @@ -266,6 +275,7 @@ export class GroupQueue { state.active = false; state.idleWaiting = false; state.isTaskContainer = false; + state.runningTaskId = null; state.process = null; state.containerName = null; state.groupFolder = null; diff --git a/src/index.ts b/src/index.ts index b093e28d49..b21efcbc22 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,7 +45,7 @@ import { updateTask, deleteTask, getTaskRunLogs, - getRecentTaskRunLogs, + getRecentMessages, } from './db.js'; import { GroupQueue } from './group-queue.js'; @@ -57,6 +57,7 @@ import { loadPlugins, PluginRegistry } from './plugin-loader.js'; import { loadSecrets } from './secret-redact.js'; import { setPluginRegistry } from './container-runner.js'; import { MessageOrchestrator } from './orchestrator.js'; +import { isSenderAllowed, loadSenderAllowlist, shouldDropMessage } from './sender-allowlist.js'; import type { ChannelPluginConfig, PluginContext } from './plugin-types.js'; // Re-export for backwards compatibility during refactor @@ -160,6 +161,9 @@ async function main(): Promise { // Load secret redaction AFTER plugins so publicEnvVars are available loadSecrets(plugins.getPublicEnvVars()); + // Load sender allowlist for drop-mode filtering + const senderAllowlistCfg = loadSenderAllowlist(); + // Graceful shutdown handlers const shutdown = async (signal: string) => { logger.info({ signal }, 'Shutdown signal received'); @@ -218,7 +222,6 @@ async function main(): Promise { updateTask: (id, updates) => updateTask(id, updates), deleteTask: (id) => deleteTask(id), getTaskRunLogs: (taskId, limit) => getTaskRunLogs(taskId, limit), - getRecentTaskRunLogs: (limit) => getRecentTaskRunLogs(limit), // Messages getRecentMessages: (jid, limit) => getRecentMessages(jid, limit ?? 50), @@ -229,6 +232,13 @@ async function main(): Promise { for (const plugin of plugins.getChannelPlugins()) { const channelConfig: ChannelPluginConfig = { onMessage: async (chatJid, msg) => { + // Drop-mode: skip storing messages from disallowed senders entirely + if (shouldDropMessage(chatJid, senderAllowlistCfg) && + !msg.is_from_me && + !isSenderAllowed(chatJid, msg.sender, senderAllowlistCfg)) { + logger.debug({ chatJid, sender: msg.sender }, 'sender-allowlist: dropping message (drop mode)'); + return; + } const transformed = await plugins.runInboundHooks(msg, plugin.manifest.name); storeMessage(transformed); }, @@ -273,6 +283,7 @@ async function main(): Promise { registeredGroups: () => orchestrator.registeredGroups, getSessions: () => orchestrator.sessions, getResumePositions: () => orchestrator.resumePositions, + clearResumePosition: (groupFolder: string) => { delete orchestrator.resumePositions[groupFolder]; }, queue, onProcess: (groupJid, proc, containerName, groupFolder) => queue.registerProcess(groupJid, proc, containerName, groupFolder), sendMessage: async (jid, rawText, sender) => { diff --git a/src/ipc-auth.test.ts b/src/ipc/__tests__/ipc-auth.test.ts similarity index 99% rename from src/ipc-auth.test.ts rename to src/ipc/__tests__/ipc-auth.test.ts index fe89a6dfac..0061ddbe52 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc/__tests__/ipc-auth.test.ts @@ -7,9 +7,9 @@ import { getRegisteredGroup, getTaskById, setRegisteredGroup, -} from './db.js'; -import { processTaskIpc, IpcDeps } from './ipc.js'; -import { RegisteredGroup } from './types.js'; +} from '../../db.js'; +import { processTaskIpc, IpcDeps } from '../index.js'; +import { RegisteredGroup } from '../../types.js'; // Set up registered groups used across tests const MAIN_GROUP: RegisteredGroup = { diff --git a/src/ipc.test.ts b/src/ipc/__tests__/ipc.test.ts similarity index 92% rename from src/ipc.test.ts rename to src/ipc/__tests__/ipc.test.ts index 6dd86c4ef2..8b4eaa0d0b 100644 --- a/src/ipc.test.ts +++ b/src/ipc/__tests__/ipc.test.ts @@ -3,11 +3,11 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -vi.mock('./logger.js', () => ({ +vi.mock('../../logger.js', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); -vi.mock('./config.js', () => ({ +vi.mock('../../config.js', () => ({ DATA_DIR: '/tmp/__replaced__', GROUPS_DIR: '/tmp/__replaced__', IPC_POLL_INTERVAL: 100, @@ -15,18 +15,18 @@ vi.mock('./config.js', () => ({ TIMEZONE: 'UTC', })); -vi.mock('./db.js', () => ({ +vi.mock('../../db.js', () => ({ createTask: vi.fn(), deleteTask: vi.fn(), getTaskById: vi.fn(), updateTask: vi.fn(), })); -import type { IpcDeps } from './ipc.js'; -import * as configMod from './config.js'; -import { createTask, getTaskById, updateTask } from './db.js'; -import { logger } from './logger.js'; -import type { RegisteredGroup } from './types.js'; +import type { IpcDeps } from '../index.js'; +import * as configMod from '../../config.js'; +import { createTask, getTaskById, updateTask } from '../../db.js'; +import { logger } from '../../logger.js'; +import type { RegisteredGroup } from '../../types.js'; let tmpDir: string; @@ -66,7 +66,7 @@ afterEach(() => { describe('startIpcWatcher', () => { it('processes message files and deletes them', async () => { vi.resetModules(); - const { startIpcWatcher } = await import('./ipc.js'); + const { startIpcWatcher } = await import('../index.js'); const deps = makeDeps(); const ipcDir = path.join(tmpDir, 'ipc', 'main', 'messages'); @@ -84,7 +84,7 @@ describe('startIpcWatcher', () => { it('moves broken JSON to errors directory', async () => { vi.resetModules(); - const { startIpcWatcher } = await import('./ipc.js'); + const { startIpcWatcher } = await import('../index.js'); const deps = makeDeps(); const ipcDir = path.join(tmpDir, 'ipc', 'main', 'messages'); @@ -103,7 +103,7 @@ describe('startIpcWatcher', () => { it('processes react messages', async () => { vi.resetModules(); - const { startIpcWatcher } = await import('./ipc.js'); + const { startIpcWatcher } = await import('../index.js'); const deps = makeDeps(); const ipcDir = path.join(tmpDir, 'ipc', 'main', 'messages'); @@ -120,7 +120,7 @@ describe('startIpcWatcher', () => { it('blocks unauthorized messages from non-main groups', async () => { vi.resetModules(); - const { startIpcWatcher } = await import('./ipc.js'); + const { startIpcWatcher } = await import('../index.js'); const deps = makeDeps({ registeredGroups: vi.fn(() => ({ @@ -141,7 +141,7 @@ describe('startIpcWatcher', () => { it('skips non-json files', async () => { vi.resetModules(); - const { startIpcWatcher } = await import('./ipc.js'); + const { startIpcWatcher } = await import('../index.js'); const deps = makeDeps(); const ipcDir = path.join(tmpDir, 'ipc', 'main', 'messages'); @@ -160,7 +160,7 @@ describe('startIpcWatcher', () => { describe('startIpcWatcher: send_file', () => { it('translates container path to host path and sends', async () => { vi.resetModules(); - const { startIpcWatcher } = await import('./ipc.js'); + const { startIpcWatcher } = await import('../index.js'); const deps = makeDeps(); const groupDir = path.join(tmpDir, 'groups', 'main'); @@ -190,7 +190,7 @@ describe('startIpcWatcher: send_file', () => { it('warns when file does not exist', async () => { vi.resetModules(); - const { startIpcWatcher } = await import('./ipc.js'); + const { startIpcWatcher } = await import('../index.js'); const deps = makeDeps(); const ipcDir = path.join(tmpDir, 'ipc', 'main', 'messages'); @@ -211,7 +211,7 @@ describe('startIpcWatcher: send_file', () => { it('infers MIME type from extension', async () => { vi.resetModules(); - const { startIpcWatcher } = await import('./ipc.js'); + const { startIpcWatcher } = await import('../index.js'); const deps = makeDeps(); const dir = path.join(tmpDir, 'groups', 'main', 'docs'); @@ -238,7 +238,7 @@ describe('startIpcWatcher: send_file', () => { it('uses custom fileName when provided', async () => { vi.resetModules(); - const { startIpcWatcher } = await import('./ipc.js'); + const { startIpcWatcher } = await import('../index.js'); const deps = makeDeps(); const dir = path.join(tmpDir, 'groups', 'main'); @@ -267,11 +267,11 @@ describe('startIpcWatcher: send_file', () => { // --- processTaskIpc: update_task schedule recomputation --- describe('processTaskIpc: update_task', () => { - let processTaskIpc: typeof import('./ipc.js').processTaskIpc; + let processTaskIpc: typeof import('../index.js').processTaskIpc; beforeEach(async () => { vi.resetModules(); - const mod = await import('./ipc.js'); + const mod = await import('../index.js'); processTaskIpc = mod.processTaskIpc; }); @@ -437,7 +437,7 @@ describe('processTaskIpc: update_task', () => { describe('startIpcWatcher: task type validation', () => { it('quarantines task file with unknown type', async () => { vi.resetModules(); - const { startIpcWatcher } = await import('./ipc.js'); + const { startIpcWatcher } = await import('../index.js'); const deps = makeDeps(); const tasksDir = path.join(tmpDir, 'ipc', 'main', 'tasks'); @@ -460,7 +460,7 @@ describe('startIpcWatcher: task type validation', () => { it('quarantines task file with missing type field', async () => { vi.resetModules(); - const { startIpcWatcher } = await import('./ipc.js'); + const { startIpcWatcher } = await import('../index.js'); const deps = makeDeps(); const tasksDir = path.join(tmpDir, 'ipc', 'main', 'tasks'); @@ -480,7 +480,7 @@ describe('startIpcWatcher: task type validation', () => { it('processes valid task types normally', async () => { vi.resetModules(); - const { startIpcWatcher } = await import('./ipc.js'); + const { startIpcWatcher } = await import('../index.js'); const deps = makeDeps(); const tasksDir = path.join(tmpDir, 'ipc', 'main', 'tasks'); @@ -513,11 +513,11 @@ describe('startIpcWatcher: task type validation', () => { // --- processTaskIpc: schedule_type validation --- describe('processTaskIpc: schedule_type validation', () => { - let processTaskIpc: typeof import('./ipc.js').processTaskIpc; + let processTaskIpc: typeof import('../index.js').processTaskIpc; beforeEach(async () => { vi.resetModules(); - const mod = await import('./ipc.js'); + const mod = await import('../index.js'); processTaskIpc = mod.processTaskIpc; }); diff --git a/src/mount-security.ts b/src/mount-security.ts index 1519875710..2b4ff3c29e 100644 --- a/src/mount-security.ts +++ b/src/mount-security.ts @@ -220,6 +220,11 @@ export interface MountValidationResult { effectiveReadonly?: boolean; } +export interface ValidateMountOptions { + /** Allow absolute container paths (for admin-installed plugin mounts). */ + allowAbsoluteContainerPath?: boolean; +} + /** * Validate a single additional mount against the allowlist. * Returns validation result with reason. @@ -227,6 +232,7 @@ export interface MountValidationResult { export function validateMount( mount: AdditionalMount, isMain: boolean, + options?: ValidateMountOptions, ): MountValidationResult { const allowlist = loadMountAllowlist(); @@ -242,7 +248,21 @@ export function validateMount( const containerPath = mount.containerPath || path.basename(mount.hostPath); // Validate container path (cheap check) - if (!isValidContainerPath(containerPath)) { + if (options?.allowAbsoluteContainerPath) { + // Plugin mounts are admin-installed — allow absolute paths but still block traversal + if (containerPath.includes('..')) { + return { + allowed: false, + reason: `Container path contains "..": "${containerPath}"`, + }; + } + if (!containerPath || containerPath.trim() === '') { + return { + allowed: false, + reason: 'Container path is empty', + }; + } + } else if (!isValidContainerPath(containerPath)) { return { allowed: false, reason: `Invalid container path: "${containerPath}" - must be relative, non-empty, and not contain ".."`, diff --git a/src/orchestrator.ts b/src/orchestrator.ts index eff4d7ed5e..3ca62cd713 100644 --- a/src/orchestrator.ts +++ b/src/orchestrator.ts @@ -12,6 +12,11 @@ import type { AvailableGroup } from './snapshots.js'; import type { GroupQueue } from './group-queue.js'; import type { ScheduledTask } from './types.js'; import { isAuthError } from './router.js'; +import { + isTriggerAllowed, + loadSenderAllowlist, + type SenderAllowlistConfig, +} from './sender-allowlist.js'; /** Dependency injection interface for the orchestrator. */ export interface OrchestratorDeps { @@ -90,8 +95,11 @@ export class MessageOrchestrator { channels: Channel[] = []; private messageLoopRunning = false; private stopRequested = false; + private senderAllowlist: SenderAllowlistConfig; - constructor(private deps: OrchestratorDeps) {} + constructor(private deps: OrchestratorDeps) { + this.senderAllowlist = loadSenderAllowlist(); + } loadState(): void { this.lastTimestamp = this.deps.getRouterState('last_timestamp') || ''; @@ -209,7 +217,8 @@ export class MessageOrchestrator { if (!isMainGroup && group.requiresTrigger !== false) { const pattern = this.deps.createTriggerPattern(group.trigger); const hasTrigger = missedMessages.some((m) => - pattern.test(m.content.trim()), + pattern.test(m.content.trim()) && + (m.is_from_me || isTriggerAllowed(chatJid, m.sender, this.senderAllowlist)), ); // Also trigger on replies to the bot's messages const hasReplyToBot = missedMessages.some((m) => @@ -469,7 +478,8 @@ export class MessageOrchestrator { if (needsTrigger) { const pattern = this.deps.createTriggerPattern(group.trigger); const hasTrigger = groupMessages.some((m) => - pattern.test(m.content.trim()), + pattern.test(m.content.trim()) && + (m.is_from_me || isTriggerAllowed(chatJid, m.sender, this.senderAllowlist)), ); // Also trigger on replies to the bot's messages const hasReplyToBot = groupMessages.some((m) => diff --git a/src/sender-allowlist.ts b/src/sender-allowlist.ts new file mode 100644 index 0000000000..9cc2bde5a8 --- /dev/null +++ b/src/sender-allowlist.ts @@ -0,0 +1,128 @@ +import fs from 'fs'; + +import { SENDER_ALLOWLIST_PATH } from './config.js'; +import { logger } from './logger.js'; + +export interface ChatAllowlistEntry { + allow: '*' | string[]; + mode: 'trigger' | 'drop'; +} + +export interface SenderAllowlistConfig { + default: ChatAllowlistEntry; + chats: Record; + logDenied: boolean; +} + +const DEFAULT_CONFIG: SenderAllowlistConfig = { + default: { allow: '*', mode: 'trigger' }, + chats: {}, + logDenied: true, +}; + +function isValidEntry(entry: unknown): entry is ChatAllowlistEntry { + if (!entry || typeof entry !== 'object') return false; + const e = entry as Record; + const validAllow = + e.allow === '*' || + (Array.isArray(e.allow) && e.allow.every((v) => typeof v === 'string')); + const validMode = e.mode === 'trigger' || e.mode === 'drop'; + return validAllow && validMode; +} + +export function loadSenderAllowlist( + pathOverride?: string, +): SenderAllowlistConfig { + const filePath = pathOverride ?? SENDER_ALLOWLIST_PATH; + + let raw: string; + try { + raw = fs.readFileSync(filePath, 'utf-8'); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return DEFAULT_CONFIG; + logger.warn( + { err, path: filePath }, + 'sender-allowlist: cannot read config', + ); + return DEFAULT_CONFIG; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + logger.warn({ path: filePath }, 'sender-allowlist: invalid JSON'); + return DEFAULT_CONFIG; + } + + const obj = parsed as Record; + + if (!isValidEntry(obj.default)) { + logger.warn( + { path: filePath }, + 'sender-allowlist: invalid or missing default entry', + ); + return DEFAULT_CONFIG; + } + + const chats: Record = {}; + if (obj.chats && typeof obj.chats === 'object') { + for (const [jid, entry] of Object.entries( + obj.chats as Record, + )) { + if (isValidEntry(entry)) { + chats[jid] = entry; + } else { + logger.warn( + { jid, path: filePath }, + 'sender-allowlist: skipping invalid chat entry', + ); + } + } + } + + return { + default: obj.default as ChatAllowlistEntry, + chats, + logDenied: obj.logDenied !== false, + }; +} + +function getEntry( + chatJid: string, + cfg: SenderAllowlistConfig, +): ChatAllowlistEntry { + return cfg.chats[chatJid] ?? cfg.default; +} + +export function isSenderAllowed( + chatJid: string, + sender: string, + cfg: SenderAllowlistConfig, +): boolean { + const entry = getEntry(chatJid, cfg); + if (entry.allow === '*') return true; + return entry.allow.includes(sender); +} + +export function shouldDropMessage( + chatJid: string, + cfg: SenderAllowlistConfig, +): boolean { + return getEntry(chatJid, cfg).mode === 'drop'; +} + +export function isTriggerAllowed( + chatJid: string, + sender: string, + cfg: SenderAllowlistConfig, +): boolean { + const allowed = isSenderAllowed(chatJid, sender, cfg); + if (!allowed && cfg.logDenied) { + logger.debug( + { chatJid, sender }, + 'sender-allowlist: trigger denied for sender', + ); + } + return allowed; +} diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index 27e62d6ee7..e2d61315b8 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -30,11 +30,37 @@ export interface SchedulerDependencies { registeredGroups: () => Record; getSessions: () => Record; getResumePositions: () => Record; + clearResumePosition: (groupFolder: string) => void; queue: GroupQueue; onProcess: (groupJid: string, proc: ChildProcess, containerName: string, groupFolder: string) => void; sendMessage: (jid: string, text: string, sender?: string) => Promise; } +/** + * Compute the next run time for a task after execution. + * Interval tasks anchor to next_run and skip missed intervals to prevent drift. + */ +export function computeNextRun(task: ScheduledTask): string | null { + if (task.schedule_type === 'cron') { + const interval = CronExpressionParser.parse(task.schedule_value, { + tz: TIMEZONE, + }); + return interval.next().toISOString(); + } + if (task.schedule_type === 'interval') { + const ms = parseInt(task.schedule_value, 10); + const now = Date.now(); + // Anchor to the scheduled time and skip missed intervals + let next = task.next_run + ? new Date(task.next_run).getTime() + ms + : now + ms; + while (next <= now) next += ms; + return new Date(next).toISOString(); + } + // 'once' tasks have no next run + return null; +} + async function runTask( task: ScheduledTask, deps: SchedulerDependencies, @@ -176,6 +202,12 @@ async function runTask( const durationMs = Date.now() - startTime; + // Clear stale resume position on error so subsequent tasks don't hit the same + // "No message found with message.uuid" failure repeatedly + if (error && task.context_mode === 'group') { + deps.clearResumePosition(task.group_folder); + } + // Notify user if the task failed (so they don't get silent failures) if (error && !hadSuccessfulResponse) { const taskLabel = task.prompt.split('\n')[0].slice(0, 60); @@ -203,17 +235,7 @@ async function runTask( error, }); - let nextRun: string | null = null; - if (task.schedule_type === 'cron') { - const interval = CronExpressionParser.parse(task.schedule_value, { - tz: TIMEZONE, - }); - nextRun = interval.next().toISOString(); - } else if (task.schedule_type === 'interval') { - const ms = parseInt(task.schedule_value, 10); - nextRun = new Date(Date.now() + ms).toISOString(); - } - // 'once' tasks have no next run + const nextRun = computeNextRun(task); const resultSummary = error ? `Error: ${error}`