alvin-bot 5.3.0 → 5.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +100 -0
- package/CHANGELOG.md +43 -3
- package/README.md +2 -0
- package/alvin-bot.config.example.json +1 -1
- package/dist/config.js +7 -4
- package/dist/handlers/document.js +8 -1
- package/dist/handlers/message.js +102 -17
- package/dist/i18n.js +15 -0
- package/dist/index.js +12 -0
- package/dist/init-data-dir.js +17 -0
- package/dist/middleware/auth.js +19 -1
- package/dist/providers/tool-executor.js +29 -4
- package/dist/services/async-agent-watcher.js +52 -8
- package/dist/services/browser-manager.js +11 -9
- package/dist/services/browser-webfetch.js +47 -13
- package/dist/services/cron-scheduling.js +79 -19
- package/dist/services/cron.js +205 -16
- package/dist/services/delivery-queue.js +19 -0
- package/dist/services/embeddings/index.js +2 -5
- package/dist/services/env-file.js +4 -0
- package/dist/services/personality.js +40 -37
- package/dist/services/session-persistence.js +21 -3
- package/dist/services/session.js +3 -0
- package/dist/services/ssrf-guard.js +162 -0
- package/dist/services/steer-channel.js +7 -2
- package/dist/services/voice.js +0 -3
- package/dist/web/server.js +155 -5
- package/package.json +8 -7
package/.env.example
CHANGED
|
@@ -41,3 +41,103 @@ WEB_PORT=3100
|
|
|
41
41
|
|
|
42
42
|
# === Custom Chrome (for WhatsApp, if not auto-detected) ===
|
|
43
43
|
# CHROME_PATH=/usr/bin/google-chrome
|
|
44
|
+
|
|
45
|
+
# ===================================================================
|
|
46
|
+
# OPTIONAL — Security & Auth
|
|
47
|
+
# ===================================================================
|
|
48
|
+
|
|
49
|
+
# Auth mode for new users trying to talk to the bot.
|
|
50
|
+
# allowlist (default) — only ALLOWED_USERS can use the bot
|
|
51
|
+
# pairing — new users get a 6-digit pairing code; owner approves
|
|
52
|
+
# open — anyone can chat (for public bots)
|
|
53
|
+
# AUTH_MODE=allowlist
|
|
54
|
+
|
|
55
|
+
# Session isolation (how context is scoped):
|
|
56
|
+
# per-user (default) — each user gets their own session
|
|
57
|
+
# per-channel — everyone in the same channel shares a session
|
|
58
|
+
# per-channel-peer — per (channel, user) pair
|
|
59
|
+
# SESSION_MODE=per-user
|
|
60
|
+
|
|
61
|
+
# ===================================================================
|
|
62
|
+
# OPTIONAL — Text-to-Speech (TTS)
|
|
63
|
+
# ===================================================================
|
|
64
|
+
|
|
65
|
+
# TTS backend: "edge" (free, default) or "elevenlabs" (paid, higher quality)
|
|
66
|
+
# TTS_PROVIDER=edge
|
|
67
|
+
|
|
68
|
+
# ElevenLabs — set all three to use ElevenLabs TTS
|
|
69
|
+
# ELEVENLABS_API_KEY=
|
|
70
|
+
# ELEVENLABS_VOICE_ID=iP95p4xoKVk53GoZ742B
|
|
71
|
+
# ELEVENLABS_MODEL_ID=eleven_v3
|
|
72
|
+
|
|
73
|
+
# ===================================================================
|
|
74
|
+
# OPTIONAL — Webhooks
|
|
75
|
+
# ===================================================================
|
|
76
|
+
|
|
77
|
+
# Enable inbound webhook endpoint (POST /api/webhook) for external triggers
|
|
78
|
+
# WEBHOOK_ENABLED=false
|
|
79
|
+
# WEBHOOK_TOKEN=change-me-to-a-random-secret
|
|
80
|
+
|
|
81
|
+
# ===================================================================
|
|
82
|
+
# OPTIONAL — Sub-Agents & Compaction
|
|
83
|
+
# ===================================================================
|
|
84
|
+
|
|
85
|
+
# Maximum number of sub-agents that can run in parallel (default: 4)
|
|
86
|
+
# MAX_SUBAGENTS=4
|
|
87
|
+
|
|
88
|
+
# Sub-agent hard timeout in ms. -1 = unlimited (default: -1)
|
|
89
|
+
# SUBAGENT_TIMEOUT=-1
|
|
90
|
+
|
|
91
|
+
# Context compaction threshold in tokens (default: 80000)
|
|
92
|
+
# COMPACTION_THRESHOLD=80000
|
|
93
|
+
|
|
94
|
+
# ===================================================================
|
|
95
|
+
# OPTIONAL — Browser Automation
|
|
96
|
+
# ===================================================================
|
|
97
|
+
|
|
98
|
+
# Connect to an existing Chrome DevTools Protocol endpoint instead of
|
|
99
|
+
# launching a new browser instance.
|
|
100
|
+
# CDP_URL=ws://localhost:9222
|
|
101
|
+
|
|
102
|
+
# Port for the optional browser HTTP gateway (default: 3800)
|
|
103
|
+
# BROWSE_SERVER_PORT=3800
|
|
104
|
+
|
|
105
|
+
# ===================================================================
|
|
106
|
+
# OPTIONAL — Data Directory
|
|
107
|
+
# ===================================================================
|
|
108
|
+
|
|
109
|
+
# Override where alvin-bot stores its data (default: ~/.alvin-bot)
|
|
110
|
+
# ALVIN_DATA_DIR=/custom/path/to/data
|
|
111
|
+
|
|
112
|
+
# Live steering — inject follow-up instructions mid-generation (default: on)
|
|
113
|
+
# STEERING_ENABLED=true
|
|
114
|
+
|
|
115
|
+
# ===================================================================
|
|
116
|
+
# POWER / OWNER OPT-INS — unlock full capability
|
|
117
|
+
#
|
|
118
|
+
# These are safe-by-default for unconfigured installs. As the owner
|
|
119
|
+
# you can opt in to the full power mode for each feature.
|
|
120
|
+
# ===================================================================
|
|
121
|
+
|
|
122
|
+
# Shell & Python execution security:
|
|
123
|
+
# allowlist (default) — only a curated set of safe binaries (ls, cat, git,
|
|
124
|
+
# python3, node, etc.) can be executed by the bot
|
|
125
|
+
# full — unrestricted shell/Python — full agent power mode; set this
|
|
126
|
+
# when you want the bot to run arbitrary commands on your machine
|
|
127
|
+
# deny — block all exec/python tool calls (read-only agent)
|
|
128
|
+
# EXEC_SECURITY=allowlist
|
|
129
|
+
|
|
130
|
+
# Web UI host binding:
|
|
131
|
+
# 127.0.0.1 (default) — loopback only, not reachable from LAN or internet
|
|
132
|
+
# 0.0.0.0 — listen on all interfaces (expose to LAN/VPS/remote)
|
|
133
|
+
# If you set WEB_HOST=0.0.0.0 (or any non-loopback address), also set
|
|
134
|
+
# WEB_PASSWORD to protect the UI:
|
|
135
|
+
# WEB_HOST=127.0.0.1
|
|
136
|
+
# WEB_PASSWORD=your-strong-password
|
|
137
|
+
|
|
138
|
+
# Allow the bot to fetch localhost / LAN / internal URLs (SSRF guard):
|
|
139
|
+
# unset or 0 (default) — private IPs and loopback are blocked to prevent
|
|
140
|
+
# SSRF attacks from untrusted prompt content
|
|
141
|
+
# 1 — enable, so the bot can reach your local services, dev
|
|
142
|
+
# servers, and internal APIs (owner workflow on your own machine)
|
|
143
|
+
# ALLOW_PRIVATE_FETCH=0
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Alvin Bot are documented here.
|
|
4
4
|
|
|
5
|
+
## [5.4.0] — 2026-05-18
|
|
6
|
+
|
|
7
|
+
### Smoother background tasks — and Alvin always tells you the truth
|
|
8
|
+
|
|
9
|
+
When you ask Alvin to go off and do something longer — research, a
|
|
10
|
+
multi-step job — it now reliably hands control straight back to you so
|
|
11
|
+
you can keep chatting while it works, then delivers the result as its
|
|
12
|
+
own message. And if a task does need to run inline for a moment,
|
|
13
|
+
Alvin says so honestly instead of implying you're free when you're
|
|
14
|
+
not. Talking to Alvin now feels exactly like working with a colleague
|
|
15
|
+
who's already on it: you're never left waiting or guessing.
|
|
16
|
+
|
|
17
|
+
### Safer out of the box — with your full power one setting away
|
|
18
|
+
|
|
19
|
+
Alvin now ships with sensible, safe defaults so a fresh install is
|
|
20
|
+
solid for everyone, including people who just want to try it quickly.
|
|
21
|
+
Nothing about Alvin's capabilities has been taken away: if you want
|
|
22
|
+
the full, unrestricted superadmin experience it's a single documented
|
|
23
|
+
setting — your machine, your rules, your call. The new `.env.example`
|
|
24
|
+
spells out every option, including the "power" switches, in plain
|
|
25
|
+
language. You stay completely in control.
|
|
26
|
+
|
|
27
|
+
### Reliability & robustness across the board
|
|
28
|
+
|
|
29
|
+
A broad pass to make Alvin steadier on long-running setups: no more
|
|
30
|
+
duplicate messages under load, cleaner interplay between stopping,
|
|
31
|
+
steering and background work, more accurate scheduling for custom
|
|
32
|
+
cron expressions, and tighter handling of edge cases throughout.
|
|
33
|
+
Verified end-to-end with a stress test on a clean separate machine.
|
|
34
|
+
|
|
35
|
+
### A leaner, tidier install
|
|
36
|
+
|
|
37
|
+
Roughly 20 MB lighter to install, a calmer first-run experience
|
|
38
|
+
(optional features that aren't configured no longer look like
|
|
39
|
+
errors), better behavior on Windows and for non-German voice notes,
|
|
40
|
+
and a zero-config friendly default so a minimal setup just works.
|
|
41
|
+
|
|
42
|
+
As always, this shipped only after a full multi-pass review and a
|
|
43
|
+
fresh-install + stress verification on a clean second machine.
|
|
44
|
+
|
|
5
45
|
## [5.3.0] — 2026-05-18
|
|
6
46
|
|
|
7
47
|
### Talk to Alvin while it's working — no more interrupting yourself
|
|
@@ -429,8 +469,8 @@ A maintainer's local Mac that had been running alvin-bot under PM2 *before* the
|
|
|
429
469
|
```bash
|
|
430
470
|
pm2 delete polyseus # any other PM2 entries
|
|
431
471
|
pm2 save --force # empty dump
|
|
432
|
-
launchctl unload ~/Library/LaunchAgents/pm2.
|
|
433
|
-
rm -f ~/Library/LaunchAgents/pm2.
|
|
472
|
+
launchctl unload ~/Library/LaunchAgents/pm2.youruser.plist 2>/dev/null
|
|
473
|
+
rm -f ~/Library/LaunchAgents/pm2.youruser.plist
|
|
434
474
|
pm2 kill
|
|
435
475
|
npm uninstall -g pm2
|
|
436
476
|
rm -rf ~/.pm2
|
|
@@ -2332,7 +2372,7 @@ Example:
|
|
|
2332
2372
|
🤖 Alvin Bot v4.8.3
|
|
2333
2373
|
Node v25.9.0 · darwin/arm64
|
|
2334
2374
|
|
|
2335
|
-
📁 Data dir:
|
|
2375
|
+
📁 Data dir: ~/.alvin-bot
|
|
2336
2376
|
.env: ✅ present
|
|
2337
2377
|
Provider: claude-sdk
|
|
2338
2378
|
|
package/README.md
CHANGED
|
@@ -62,6 +62,8 @@ That's it. The setup wizard validates everything:
|
|
|
62
62
|
|
|
63
63
|
**Requires:** Node.js 18+ ([nodejs.org](https://nodejs.org)) · Telegram bot token ([@BotFather](https://t.me/BotFather)) · Your Telegram user ID ([@userinfobot](https://t.me/userinfobot))
|
|
64
64
|
|
|
65
|
+
> **Native build note:** Alvin Bot uses `better-sqlite3` for indexed memory. Prebuilt binaries are included for common macOS and Linux environments so most installs need nothing extra. If your platform doesn't have a prebuilt binary and the optional native compilation is skipped, the bot still runs — semantic memory falls back gracefully to keyword search. A C++ toolchain (Xcode Command Line Tools on macOS, `build-essential` on Ubuntu) and Python 3 are only needed if you hit a build-from-source fallback.
|
|
66
|
+
|
|
65
67
|
Free AI providers available — no credit card needed. **Privacy-first?** Pick the 🔒 **Offline — Gemma 4 E4B** option in setup for a fully local LLM via Ollama (macOS/Linux: automated install; Windows: manual).
|
|
66
68
|
|
|
67
69
|
### 🔐 A note on permission prompts
|
package/dist/config.js
CHANGED
|
@@ -26,8 +26,10 @@ export const config = {
|
|
|
26
26
|
// Agent
|
|
27
27
|
defaultWorkingDir: process.env.WORKING_DIR || os.homedir(),
|
|
28
28
|
maxBudgetUsd: Number(process.env.MAX_BUDGET_USD) || 5.0,
|
|
29
|
-
// Model provider (primary)
|
|
30
|
-
|
|
29
|
+
// Model provider (primary). Default is "groq" — works on a fresh install
|
|
30
|
+
// with only BOT_TOKEN + GROQ_API_KEY. Set PRIMARY_PROVIDER=claude-sdk to
|
|
31
|
+
// use the Claude SDK (requires `claude login` / Claude Max subscription).
|
|
32
|
+
primaryProvider: process.env.PRIMARY_PROVIDER || "groq",
|
|
31
33
|
fallbackProviders: (process.env.FALLBACK_PROVIDERS || "")
|
|
32
34
|
.split(",")
|
|
33
35
|
.map(s => s.trim())
|
|
@@ -80,8 +82,9 @@ export const config = {
|
|
|
80
82
|
// Browser
|
|
81
83
|
cdpUrl: process.env.CDP_URL || "",
|
|
82
84
|
browseServerPort: Number(process.env.BROWSE_SERVER_PORT) || 3800,
|
|
83
|
-
// Exec Security
|
|
84
|
-
|
|
85
|
+
// Exec Security — default is "allowlist" (safe). Set EXEC_SECURITY=full to
|
|
86
|
+
// allow shell pipelines, metacharacters, and arbitrary binaries (opt-in).
|
|
87
|
+
execSecurity: (process.env.EXEC_SECURITY || "allowlist"),
|
|
85
88
|
};
|
|
86
89
|
/**
|
|
87
90
|
* Feature flag: btw live-steering. Default ON — only "false" or "0" disables.
|
|
@@ -74,7 +74,14 @@ export async function handleDocument(ctx) {
|
|
|
74
74
|
// Download the file
|
|
75
75
|
const file = await ctx.api.getFile(doc.file_id);
|
|
76
76
|
const fileUrl = `https://api.telegram.org/file/bot${config.botToken}/${file.file_path}`;
|
|
77
|
-
|
|
77
|
+
// H2: strip any path components from the attacker-controlled file_name
|
|
78
|
+
// to prevent writing outside TEMP_DIR (e.g. file_name="../../../x").
|
|
79
|
+
const safeFilename = path.basename(filename);
|
|
80
|
+
const localPath = path.join(TEMP_DIR, `doc_${Date.now()}_${safeFilename}`);
|
|
81
|
+
// Containment assertion: resolved path must stay inside TEMP_DIR.
|
|
82
|
+
if (!path.resolve(localPath).startsWith(path.resolve(TEMP_DIR))) {
|
|
83
|
+
throw new Error("File path containment violation");
|
|
84
|
+
}
|
|
78
85
|
await downloadFile(fileUrl, localPath);
|
|
79
86
|
const caption = ctx.message?.caption || "";
|
|
80
87
|
const userInstruction = caption || `Analysiere diese Datei: ${filename}`;
|
package/dist/handlers/message.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { InputFile, InlineKeyboard } from "grammy";
|
|
2
2
|
import fs from "fs";
|
|
3
|
+
import crypto from "crypto";
|
|
3
4
|
import { getSession, addToHistory, trackProviderUsage, buildSessionKey, getTelegramWorkspace, markSessionDirty } from "../services/session.js";
|
|
4
5
|
import { resolveWorkspaceOrDefault, getWorkspace } from "../services/workspaces.js";
|
|
5
6
|
import { TelegramStreamer } from "../services/telegram.js";
|
|
@@ -140,10 +141,34 @@ export function decideMidTaskRouting(args) {
|
|
|
140
141
|
return "queue";
|
|
141
142
|
if (args.shouldBypass)
|
|
142
143
|
return "bypass";
|
|
143
|
-
if (args.providerIsClaudeSdk && args.steeringEnabled && args.hasSteerChannel)
|
|
144
|
+
if (args.providerIsClaudeSdk && args.steeringEnabled && args.hasSteerChannel && args.hasLiveSdkQuery)
|
|
144
145
|
return "steer";
|
|
145
146
|
return "queue";
|
|
146
147
|
}
|
|
148
|
+
// ── Cycle-3 P0 — background honesty guard ────────────────────────────────────
|
|
149
|
+
/**
|
|
150
|
+
* Detect when the bot falsely promised "running in the background — you can
|
|
151
|
+
* keep chatting" but actually ran a sync Task/Agent that blocked the session.
|
|
152
|
+
*
|
|
153
|
+
* Returns true when all of the following hold:
|
|
154
|
+
* 1. A Task/Agent chunk arrived WITHOUT `run_in_background: true` (i.e. the
|
|
155
|
+
* stuck-timer entered sync mode — `taskChunkSeenWithoutRunInBackground`).
|
|
156
|
+
* 2. No real background detach happened this turn:
|
|
157
|
+
* • `mcp__alvin__dispatch_agent` was NOT called (`dispatchAgentFired=false`)
|
|
158
|
+
* • `pendingBackgroundCount` did NOT increase (`pendingBackgroundDelta=0`)
|
|
159
|
+
*
|
|
160
|
+
* Exported so it can be unit-tested without a grammy Context mock.
|
|
161
|
+
*/
|
|
162
|
+
export function detectUndetachedBackgroundClaim(args) {
|
|
163
|
+
if (!args.taskChunkSeenWithoutRunInBackground)
|
|
164
|
+
return false;
|
|
165
|
+
// Dead in production wiring (always false there — PATH A is detected via pendingBackgroundDelta); kept for explicit unit-test truth-table coverage.
|
|
166
|
+
if (args.dispatchAgentFired)
|
|
167
|
+
return false;
|
|
168
|
+
if (args.pendingBackgroundDelta > 0)
|
|
169
|
+
return false;
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
147
172
|
/** React to a message with an emoji. Silently fails if reactions aren't supported. */
|
|
148
173
|
async function react(ctx, emoji) {
|
|
149
174
|
try {
|
|
@@ -210,6 +235,7 @@ export async function handleMessage(ctx) {
|
|
|
210
235
|
providerIsClaudeSdk: _midTaskProviderIsSdk,
|
|
211
236
|
steeringEnabled: isSteeringEnabled(),
|
|
212
237
|
hasSteerChannel: !!session._steerChannel,
|
|
238
|
+
hasLiveSdkQuery: !!session._qHandle, // C-H3: require a live SDK query handle
|
|
213
239
|
shouldBypass: _midTaskBypass,
|
|
214
240
|
});
|
|
215
241
|
if (_midTaskRoute === "bypass") {
|
|
@@ -234,16 +260,28 @@ export async function handleMessage(ctx) {
|
|
|
234
260
|
// v5.2 — btw live steering: push mid-task message into the open
|
|
235
261
|
// SteerChannel so the running claude-sdk query picks it up as a
|
|
236
262
|
// streaming-input user message. No abort, no queue.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if (
|
|
263
|
+
// C-L2: push() returns boolean — only 📨/ack when accepted; reply bufferFull otherwise.
|
|
264
|
+
const steerAccepted = session._steerChannel.push(text);
|
|
265
|
+
if (steerAccepted) {
|
|
266
|
+
await react(ctx, "📨");
|
|
267
|
+
if (!session._steerAckSentThisTurn) {
|
|
268
|
+
try {
|
|
269
|
+
await ctx.reply(t("bot.steer.ack", session.language));
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
/* harmless grammy race */
|
|
273
|
+
}
|
|
274
|
+
session._steerAckSentThisTurn = true;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
// Buffer full or channel closed — tell the user honestly
|
|
240
279
|
try {
|
|
241
|
-
await ctx.reply(t("bot.steer.
|
|
280
|
+
await ctx.reply(t("bot.steer.bufferFull", session.language));
|
|
242
281
|
}
|
|
243
282
|
catch {
|
|
244
283
|
/* harmless grammy race */
|
|
245
284
|
}
|
|
246
|
-
session._steerAckSentThisTurn = true;
|
|
247
285
|
}
|
|
248
286
|
return;
|
|
249
287
|
}
|
|
@@ -274,6 +312,13 @@ export async function handleMessage(ctx) {
|
|
|
274
312
|
}
|
|
275
313
|
session.isProcessing = true;
|
|
276
314
|
session.abortController = new AbortController();
|
|
315
|
+
// C-H2 — Stamp a per-turn identity token so the finally block can detect
|
|
316
|
+
// whether a NEW turn has already started before it runs. If requestStop
|
|
317
|
+
// fires mid-turn and allows a new message to start a fresh turn (with its
|
|
318
|
+
// own new abortController + _steerChannel), the old turn's finally sees the
|
|
319
|
+
// token mismatch and skips the clobber — preserving the new turn's state.
|
|
320
|
+
const _thisTurnId = crypto.randomUUID();
|
|
321
|
+
session._turnId = _thisTurnId;
|
|
277
322
|
// v4.12.3 — Clear any stale bypass flag from a previous aborted turn.
|
|
278
323
|
// The flag is set by the bypass path right before it calls abort(),
|
|
279
324
|
// read by the OLD handler's error path, and cleared here by the NEW
|
|
@@ -538,6 +583,13 @@ export async function handleMessage(ctx) {
|
|
|
538
583
|
// (the empty-stream capturedSessionId) and the next turn loops again.
|
|
539
584
|
// This is the second half of the empty-stream-loop fix.
|
|
540
585
|
let sessionResetInStream = false;
|
|
586
|
+
// Cycle-3 P0 — background honesty guard tracking.
|
|
587
|
+
// `syncTaskSeenWithoutRunInBackground`: lifted from the stuckTimer.enterSync
|
|
588
|
+
// site below — true once a Task/Agent chunk arrives with no runInBackground.
|
|
589
|
+
// `pendingBackgroundCountAtTurnStart`: snapshot before the stream so we can
|
|
590
|
+
// compute the delta at turn end (dispatch_agent increments this counter).
|
|
591
|
+
let syncTaskSeenWithoutRunInBackground = false;
|
|
592
|
+
const pendingBackgroundCountAtTurnStart = session.pendingBackgroundCount ?? 0;
|
|
541
593
|
for await (const chunk of registry.queryWithFallback(queryOpts, workspace.provider)) {
|
|
542
594
|
// v5.1 — Bail as soon as requestStop() marks the session. The registry's
|
|
543
595
|
// outer loop already guards against new provider attempts; this guard
|
|
@@ -554,6 +606,8 @@ export async function handleMessage(ctx) {
|
|
|
554
606
|
chunk.toolUseId &&
|
|
555
607
|
chunk.runInBackground !== true) {
|
|
556
608
|
stuckTimer.enterSync(chunk.toolUseId);
|
|
609
|
+
// Cycle-3 P0 — lift the signal for honesty guard (same condition)
|
|
610
|
+
syncTaskSeenWithoutRunInBackground = true;
|
|
557
611
|
}
|
|
558
612
|
else if (chunk.type === "tool_result" && chunk.toolUseId) {
|
|
559
613
|
// Any tool_result may match a pending sync entry. Set.delete is
|
|
@@ -710,6 +764,27 @@ export async function handleMessage(ctx) {
|
|
|
710
764
|
break;
|
|
711
765
|
}
|
|
712
766
|
}
|
|
767
|
+
// Cycle-3 P0 — background honesty guard.
|
|
768
|
+
// If the turn ran a sync Task/Agent (blocking) and no real detach happened
|
|
769
|
+
// (no dispatch_agent, no pendingBackgroundCount increase), append one
|
|
770
|
+
// truthful notice so the user is never left with a false async promise.
|
|
771
|
+
// This fires only on "normal" turn endings — bypass-abort and user-stop
|
|
772
|
+
// are handled below and don't need the notice (neither promises async).
|
|
773
|
+
if (!bypassAborted &&
|
|
774
|
+
!timedOut &&
|
|
775
|
+
!session._stopRequested &&
|
|
776
|
+
detectUndetachedBackgroundClaim({
|
|
777
|
+
taskChunkSeenWithoutRunInBackground: syncTaskSeenWithoutRunInBackground,
|
|
778
|
+
dispatchAgentFired: false, // used purely via pendingBackgroundDelta below
|
|
779
|
+
pendingBackgroundDelta: (session.pendingBackgroundCount ?? 0) - pendingBackgroundCountAtTurnStart,
|
|
780
|
+
})) {
|
|
781
|
+
try {
|
|
782
|
+
await ctx.reply(t("bot.background.syncNotice", session.language));
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
/* harmless — notice is best-effort */
|
|
786
|
+
}
|
|
787
|
+
}
|
|
713
788
|
// v5.1 stop: user stopped this query — do NOT finalize partial output
|
|
714
789
|
// as a successful answer, no 👍, no history commit. The stop trigger
|
|
715
790
|
// (/cancel | /stopall | ⛔ button) already acknowledged to the user.
|
|
@@ -790,18 +865,28 @@ export async function handleMessage(ctx) {
|
|
|
790
865
|
finally {
|
|
791
866
|
stuckTimer.cancel();
|
|
792
867
|
clearInterval(typingInterval);
|
|
793
|
-
|
|
794
|
-
session.
|
|
795
|
-
//
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
//
|
|
799
|
-
|
|
800
|
-
|
|
868
|
+
// C-H2 — Single-writer guard: only reset lifecycle fields if this turn's
|
|
869
|
+
// token still matches the session's current token. If requestStop fired
|
|
870
|
+
// mid-turn and a NEW turn has already started (and stamped a new _turnId),
|
|
871
|
+
// then _turnId !== _thisTurnId and we SKIP the reset — the new turn owns
|
|
872
|
+
// these fields. _qHandle and _stopRequested are included in the gate:
|
|
873
|
+
// requestStop already nulled _qHandle before returning (after interruptQuery),
|
|
874
|
+
// but if a new turn started and re-populated _qHandle via onQueryHandle we
|
|
875
|
+
// must NOT null it here — that would break Cycle-1 stop teeth for the new turn.
|
|
876
|
+
if (session._turnId === _thisTurnId) {
|
|
877
|
+
session.isProcessing = false;
|
|
878
|
+
session.abortController = null;
|
|
879
|
+
// v5.2 — Close and clear the SteerChannel; reset per-turn ack flag.
|
|
880
|
+
try {
|
|
881
|
+
session._steerChannel?.close();
|
|
882
|
+
}
|
|
883
|
+
catch { /* ignore */ }
|
|
884
|
+
session._steerChannel = null;
|
|
885
|
+
session._steerAckSentThisTurn = false;
|
|
886
|
+
session._qHandle = null; // safe: token matches → no newer turn owns this
|
|
887
|
+
session._stopRequested = null; // safe: token matches → no newer turn has set this
|
|
888
|
+
session._turnId = null;
|
|
801
889
|
}
|
|
802
|
-
catch { /* ignore */ }
|
|
803
|
-
session._steerChannel = null;
|
|
804
|
-
session._steerAckSentThisTurn = false;
|
|
805
890
|
// v5.1 — Remove the ⛔ Stop control message (sent at processing start).
|
|
806
891
|
// Best-effort: if it was already deleted or the bot lacks permission, ignore.
|
|
807
892
|
if (stopMsgId !== null) {
|
package/dist/i18n.js
CHANGED
|
@@ -331,6 +331,14 @@ const strings = {
|
|
|
331
331
|
es: "(externo, activo)",
|
|
332
332
|
fr: "(externe, en cours)",
|
|
333
333
|
},
|
|
334
|
+
// background honesty notice — emitted when a sync Task blocked the turn
|
|
335
|
+
// (Cycle-3 P0 fix: don't falsely promise "you can keep chatting")
|
|
336
|
+
"bot.background.syncNotice": {
|
|
337
|
+
en: "ℹ️ That ran inline and took a while — I couldn't take new messages until it finished.",
|
|
338
|
+
de: "ℹ️ Das lief inline und hat eine Weile gedauert — ich konnte währenddessen keine neuen Nachrichten entgegennehmen.",
|
|
339
|
+
es: "ℹ️ Eso se ejecutó en línea y tardó un rato — no pude recibir nuevos mensajes hasta que terminó.",
|
|
340
|
+
fr: "ℹ️ Cela s'est exécuté en ligne et a pris un moment — je ne pouvais pas recevoir de nouveaux messages tant que ce n'était pas terminé.",
|
|
341
|
+
},
|
|
334
342
|
// live steering ack (Task 4 — btw feature)
|
|
335
343
|
"bot.steer.ack": {
|
|
336
344
|
en: "📨 Noted — Alvin will factor that in without restarting.",
|
|
@@ -338,6 +346,13 @@ const strings = {
|
|
|
338
346
|
es: "📨 Anotado — Alvin lo tendrá en cuenta sin reiniciar.",
|
|
339
347
|
fr: "📨 Noté — Alvin en tiendra compte sans redémarrer.",
|
|
340
348
|
},
|
|
349
|
+
// C-L2: steer buffer full — honest reply when the steer cap is reached
|
|
350
|
+
"bot.steer.bufferFull": {
|
|
351
|
+
en: "⚠️ Steer buffer full — this message wasn't queued. Alvin is still running; try again in a moment.",
|
|
352
|
+
de: "⚠️ Steer-Puffer voll — diese Nachricht wurde nicht übernommen. Alvin läuft noch; versuch es gleich nochmal.",
|
|
353
|
+
es: "⚠️ Búfer de dirección lleno — este mensaje no se añadió. Alvin sigue en marcha; inténtalo de nuevo en un momento.",
|
|
354
|
+
fr: "⚠️ Tampon de direction plein — ce message n'a pas été pris en compte. Alvin tourne toujours ; réessaie dans un instant.",
|
|
355
|
+
},
|
|
341
356
|
// /cancel
|
|
342
357
|
"bot.cancel.cancelling": {
|
|
343
358
|
en: "Cancelling request…",
|
package/dist/index.js
CHANGED
|
@@ -81,6 +81,18 @@ import { MEMORY_DIR as SEC_MEM_DIR, DATA_DIR as SEC_DATA_DIR } from "./paths.js"
|
|
|
81
81
|
console.warn(` ${r.path}: ${r.error}`);
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
+
// M5: Ensure DATA_DIR itself is 0700 (owner-only traverse). ensureDataDirs()
|
|
85
|
+
// above handles new installs; this belt-and-suspenders catches the case where
|
|
86
|
+
// the dir was created by a pre-M5 version with 0755 and the bot is restarting.
|
|
87
|
+
if (process.platform !== "win32") {
|
|
88
|
+
try {
|
|
89
|
+
const { chmodSync } = await import("fs");
|
|
90
|
+
chmodSync(SEC_DATA_DIR, 0o700);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Best-effort — network filesystems may not support chmod
|
|
94
|
+
}
|
|
95
|
+
}
|
|
84
96
|
}
|
|
85
97
|
// 4. Crash-loop brake check — if we've crashed N times in a short window,
|
|
86
98
|
// refuse to start, write an alert file, and unload our LaunchAgent so
|
package/dist/init-data-dir.js
CHANGED
|
@@ -9,6 +9,12 @@ import { DATA_DIR, MEMORY_DIR, USERS_DIR, RUNTIME_DIR, WHATSAPP_AUTH, BACKUP_DIR
|
|
|
9
9
|
/**
|
|
10
10
|
* Create the directory structure only (no file seeding).
|
|
11
11
|
* Must run BEFORE migration so directories exist for copying.
|
|
12
|
+
*
|
|
13
|
+
* M5: DATA_DIR is created with mode 0700 (owner-only traverse) so that
|
|
14
|
+
* even before the per-file chmod audit runs, any file written by the bot
|
|
15
|
+
* is not accessible by other users on multi-user systems. On Windows,
|
|
16
|
+
* chmod is a no-op — we skip it silently to avoid alarming log output,
|
|
17
|
+
* mirroring how the file-permissions audit handles win32.
|
|
12
18
|
*/
|
|
13
19
|
export function ensureDataDirs() {
|
|
14
20
|
const dirs = [
|
|
@@ -27,6 +33,17 @@ export function ensureDataDirs() {
|
|
|
27
33
|
fs.mkdirSync(dir, { recursive: true });
|
|
28
34
|
}
|
|
29
35
|
}
|
|
36
|
+
// M5: Ensure the DATA_DIR itself is 0700 (owner-only). New dirs are
|
|
37
|
+
// created without an explicit mode above (inherits umask), so we chmod
|
|
38
|
+
// after creation. Windows doesn't support POSIX modes — skip silently.
|
|
39
|
+
if (process.platform !== "win32") {
|
|
40
|
+
try {
|
|
41
|
+
fs.chmodSync(DATA_DIR, 0o700);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Best-effort — some network filesystems may not support chmod
|
|
45
|
+
}
|
|
46
|
+
}
|
|
30
47
|
}
|
|
31
48
|
/**
|
|
32
49
|
* Seed default files for a fresh install (only if they don't exist yet).
|
package/dist/middleware/auth.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
|
+
import crypto from "crypto";
|
|
2
3
|
import { InlineKeyboard } from "grammy";
|
|
3
4
|
import { config } from "../config.js";
|
|
4
5
|
import { APPROVED_USERS_FILE } from "../paths.js";
|
|
@@ -43,7 +44,7 @@ export function isApprovedUser(userId) {
|
|
|
43
44
|
const MAX_PENDING = 3;
|
|
44
45
|
const pendingPairings = new Map(); // code → pairing
|
|
45
46
|
function generateCode() {
|
|
46
|
-
return String(
|
|
47
|
+
return String(crypto.randomInt(100000, 1000000));
|
|
47
48
|
}
|
|
48
49
|
function cleanExpired() {
|
|
49
50
|
const now = Date.now();
|
|
@@ -211,5 +212,22 @@ export async function authMiddleware(ctx, next) {
|
|
|
211
212
|
return;
|
|
212
213
|
}
|
|
213
214
|
// ── Callback queries (inline keyboards) ─────────
|
|
215
|
+
// Only allowedUsers may trigger admin action callbacks (approve/deny).
|
|
216
|
+
// Other callbacks (e.g. pairing-mode approved users) continue through.
|
|
217
|
+
if (userId && config.allowedUsers.includes(userId)) {
|
|
218
|
+
await next();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
// Unknown users: silently drop admin-action callbacks to prevent
|
|
222
|
+
// approval forgery / self-approval. Non-admin callbacks from pairing-
|
|
223
|
+
// approved users in "pairing" mode are also gated here intentionally;
|
|
224
|
+
// the approve flow is an admin-only action.
|
|
225
|
+
const callbackData = ctx.callbackQuery?.data || "";
|
|
226
|
+
const isAdminCallback = /^(pair|access|wa):(approve|deny|block):/.test(callbackData);
|
|
227
|
+
if (isAdminCallback) {
|
|
228
|
+
// Silently drop — no answer (grammy will time-out the spinner client-side)
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
// Non-admin callbacks from unknown users: pass through (e.g. inline mode)
|
|
214
232
|
await next();
|
|
215
233
|
}
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { execSync } from "child_process";
|
|
11
11
|
import fs from "fs";
|
|
12
|
-
import
|
|
12
|
+
import os from "os";
|
|
13
|
+
import { resolve, join as pathJoin } from "path";
|
|
13
14
|
import { isSelfRestartCommand, scheduleGracefulRestart } from "../services/restart.js";
|
|
15
|
+
import { checkExecAllowed } from "../services/exec-guard.js";
|
|
14
16
|
// ── Tool Definitions (OpenAI function calling format) ───────────────────────
|
|
15
17
|
export const AGENT_TOOLS = [
|
|
16
18
|
{
|
|
@@ -227,7 +229,18 @@ function executeShell(command, cwd) {
|
|
|
227
229
|
scheduleGracefulRestart();
|
|
228
230
|
return { name: "run_shell", result: "Bot restart scheduled. Grammy will commit the Telegram offset before exiting." };
|
|
229
231
|
}
|
|
230
|
-
//
|
|
232
|
+
// Exec-guard: enforce EXEC_SECURITY on this non-SDK provider path.
|
|
233
|
+
// checkExecAllowed reads config.execSecurity (deny → reject all;
|
|
234
|
+
// allowlist → reject metachars + non-allowlisted bins; full → pass).
|
|
235
|
+
const guardResult = checkExecAllowed(command);
|
|
236
|
+
if (!guardResult.allowed) {
|
|
237
|
+
return {
|
|
238
|
+
name: "run_shell",
|
|
239
|
+
result: `Command not allowed: ${guardResult.reason ?? "exec execution denied"}`,
|
|
240
|
+
error: true,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
// Security: block obviously dangerous commands (belt-and-suspenders)
|
|
231
244
|
const blocked = ["rm -rf /", "mkfs", "dd if=/dev/zero", "> /dev/sda"];
|
|
232
245
|
if (blocked.some(b => command.includes(b))) {
|
|
233
246
|
return { name: "run_shell", result: "Command blocked for safety.", error: true };
|
|
@@ -395,9 +408,21 @@ function executeListDirectory(dirPath, recursive, cwd) {
|
|
|
395
408
|
}
|
|
396
409
|
}
|
|
397
410
|
function executePython(code, cwd) {
|
|
411
|
+
// Exec-guard: enforce EXEC_SECURITY before writing or executing anything.
|
|
412
|
+
// Use "python3" as the representative binary — deny blocks all execution;
|
|
413
|
+
// allowlist allows python3 (it is in SAFE_BINS) unless globally denied.
|
|
414
|
+
const guardResult = checkExecAllowed("python3");
|
|
415
|
+
if (!guardResult.allowed) {
|
|
416
|
+
return {
|
|
417
|
+
name: "python_execute",
|
|
418
|
+
result: `Python execution not allowed: ${guardResult.reason ?? "exec execution denied"}`,
|
|
419
|
+
error: true,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
398
422
|
try {
|
|
399
|
-
// Write code to temp file to avoid shell escaping issues
|
|
400
|
-
|
|
423
|
+
// Write code to temp file to avoid shell escaping issues.
|
|
424
|
+
// os.tmpdir() is cross-platform (works on Windows/Linux/macOS).
|
|
425
|
+
const tmpFile = pathJoin(os.tmpdir(), `alvin-bot-py-${Date.now()}.py`);
|
|
401
426
|
fs.writeFileSync(tmpFile, code);
|
|
402
427
|
try {
|
|
403
428
|
const output = execSync(`python3 "${tmpFile}"`, {
|