alvin-bot 4.17.0 → 4.18.1

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.
Files changed (79) hide show
  1. package/CHANGELOG.md +40 -2
  2. package/bin/cli.js +1 -1
  3. package/dist/index.js +7 -0
  4. package/dist/services/async-agent-watcher.js +23 -0
  5. package/dist/services/browser-manager.js +11 -0
  6. package/dist/services/embeddings.js +24 -1
  7. package/dist/services/mcp.js +11 -0
  8. package/dist/services/skills.js +4 -2
  9. package/dist/services/subagents.js +38 -0
  10. package/dist/services/users.js +82 -11
  11. package/package.json +3 -1
  12. package/test/allowed-users-gate.test.ts +0 -98
  13. package/test/alvin-dispatch.test.ts +0 -220
  14. package/test/async-agent-chunk-flow.test.ts +0 -244
  15. package/test/async-agent-parser-staleness.test.ts +0 -412
  16. package/test/async-agent-parser-streamjson.test.ts +0 -273
  17. package/test/async-agent-parser.test.ts +0 -322
  18. package/test/async-agent-watcher.test.ts +0 -229
  19. package/test/background-bypass-integration.test.ts +0 -443
  20. package/test/background-bypass-stress.test.ts +0 -417
  21. package/test/background-bypass.test.ts +0 -127
  22. package/test/browser-webfetch.test.ts +0 -121
  23. package/test/claude-sdk-provider.test.ts +0 -115
  24. package/test/claude-sdk-tool-use-id.test.ts +0 -180
  25. package/test/console-timestamps.test.ts +0 -98
  26. package/test/cron-progress-ticker.test.ts +0 -76
  27. package/test/cron-restart-resilience.test.ts +0 -191
  28. package/test/cron-run-resolver.test.ts +0 -133
  29. package/test/cron-runjobnow-throw.test.ts +0 -100
  30. package/test/debounce.test.ts +0 -60
  31. package/test/delivery-registry.test.ts +0 -71
  32. package/test/exec-guard-metachars.test.ts +0 -110
  33. package/test/file-permissions.test.ts +0 -130
  34. package/test/i18n.test.ts +0 -108
  35. package/test/list-subagents-merged.test.ts +0 -172
  36. package/test/memory-extractor.test.ts +0 -151
  37. package/test/memory-layers.test.ts +0 -169
  38. package/test/memory-sdk-injection.test.ts +0 -146
  39. package/test/memory-stress-restart.test.ts +0 -337
  40. package/test/multi-session-stress.test.ts +0 -255
  41. package/test/platform-session-key.test.ts +0 -69
  42. package/test/process-manager.test.ts +0 -186
  43. package/test/registry.test.ts +0 -201
  44. package/test/session-pending-background.test.ts +0 -59
  45. package/test/session-persistence.test.ts +0 -195
  46. package/test/slack-progress-ticker.test.ts +0 -123
  47. package/test/slack-slash-command.test.ts +0 -61
  48. package/test/slack-test-connection.test.ts +0 -176
  49. package/test/stress-scenarios.test.ts +0 -356
  50. package/test/stuck-timer.test.ts +0 -116
  51. package/test/subagent-delivery-markdown-fallback.test.ts +0 -147
  52. package/test/subagent-delivery-platform-routing.test.ts +0 -232
  53. package/test/subagent-delivery.test.ts +0 -273
  54. package/test/subagent-final-text.test.ts +0 -132
  55. package/test/subagent-stats.test.ts +0 -119
  56. package/test/subagent-toolset-allowlist.test.ts +0 -146
  57. package/test/subagents-commands.test.ts +0 -64
  58. package/test/subagents-config.test.ts +0 -114
  59. package/test/subagents-depth.test.ts +0 -58
  60. package/test/subagents-inheritance.test.ts +0 -67
  61. package/test/subagents-name-resolver.test.ts +0 -122
  62. package/test/subagents-priority-reject.test.ts +0 -88
  63. package/test/subagents-queue.test.ts +0 -127
  64. package/test/subagents-shutdown.test.ts +0 -126
  65. package/test/subagents-toolset.test.ts +0 -71
  66. package/test/sync-task-timeout.test.ts +0 -153
  67. package/test/system-prompt-background-hint.test.ts +0 -65
  68. package/test/telegram-error-filter.test.ts +0 -85
  69. package/test/telegram-workspace-command.test.ts +0 -78
  70. package/test/timing-safe-bearer.test.ts +0 -65
  71. package/test/watchdog-brake.test.ts +0 -157
  72. package/test/watcher-pending-count.test.ts +0 -228
  73. package/test/watcher-zombie-fix.test.ts +0 -252
  74. package/test/web-server-integration.test.ts +0 -189
  75. package/test/web-server-resilience.test.ts +0 -118
  76. package/test/web-server-shutdown.test.ts +0 -117
  77. package/test/whatsapp-auth-resilience.test.ts +0 -96
  78. package/test/workspaces.test.ts +0 -196
  79. package/vitest.config.ts +0 -17
package/CHANGELOG.md CHANGED
@@ -2,6 +2,44 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [4.18.1] — 2026-04-20
6
+
7
+ ### 🔒 Privacy-Guard: pre-publish check blocks PII leaks in shipped files
8
+
9
+ Adds an automated gate that runs on every `npm publish` and prevents personal information from accidentally shipping. After the 4.18.0 privacy sanitization, this ensures it never happens again.
10
+
11
+ **New:**
12
+ - `scripts/privacy-check.sh` — scans the exact file list that `npm pack` would ship. Case-insensitive regex match against a patterns file. Any hit fails the publish.
13
+ - `scripts/privacy-patterns.default.txt` — bundled, contains only generic patterns (email shape, IP addresses, postal codes, personal task phrasings). No project or person names — so safe to ship.
14
+ - `package.json` `prepublishOnly` hook — runs the check automatically.
15
+ - `npm run privacy-check` — manual run anytime.
16
+
17
+ **Maintainer-local overrides:** Put `~/.alvin-bot/privacy-patterns.txt` with personal/project-specific patterns. That file is gitignored, never leaves your machine, and takes precedence over the bundled defaults.
18
+
19
+ **CI override:** Set `$ALVIN_PRIVACY_PATTERNS` to an absolute path; takes top precedence over both files above.
20
+
21
+ **Hardening: `.npmignore`** — added `test/` and `vitest.config.ts` to the ignore list. Previously the full test suite shipped with every npm tarball, adding ~2 MB and exposing test fixtures that sometimes referenced internal project names.
22
+
23
+ **CLAUDE.md** — documents the rule and the patterns-file lookup order so future maintenance sessions catch new cases proactively.
24
+
25
+ ## [4.18.0] — 2026-04-20
26
+
27
+ ### ⚡ Performance + Hardening: medium-priority cleanups from the stability audit
28
+
29
+ Completes the audit work started in 4.17.0 by addressing the remaining medium-severity findings.
30
+
31
+ **Performance (hot path):**
32
+ - **User profiles now cached in memory** (`src/services/users.ts`). Previously `touchProfile` — called on every inbound message — did a sync `readFileSync` + `writeFileSync` on disk. Now it updates an in-memory cache and schedules a debounced flush (2s batch window). A final flush runs on graceful shutdown so nothing is lost. Drops 2 blocking fs operations per message.
33
+ - **Embeddings index now cached** (`src/services/embeddings.ts`). Semantic search previously re-read + re-parsed the full on-disk index on every query (100+ MB for large memories). Now cached in memory with mtime-based invalidation — external reindexers still picked up without a restart.
34
+ - **Skills no longer force-reload every 5 minutes** (`src/services/skills.ts`). `getSkills()` used to re-scan the disk after 5min even though `fs.watch` already triggers hot-reload on change. Cache is now authoritative.
35
+
36
+ **Hardening (unbounded growth):**
37
+ - **Sub-agents map capped at 1000** (`src/services/subagents.ts`). Hits the 90%-target on overflow and evicts oldest delivered/terminated entries first. Running agents are never evicted.
38
+ - **Async-agent pending map capped at 500** (`src/services/async-agent-watcher.ts`). Same LRU strategy for orphaned `registerPending` entries.
39
+ - **Browser gateway + MCP subprocess stderr now have error handlers** (`browser-manager.ts`, `mcp.ts`). Previously a stream error would throw unhandled and could crash the node process.
40
+
41
+ **Net effect:** message path now does zero blocking fs reads/writes on the profile/skills/embeddings side. Long-running installs can't grow the in-memory state beyond the caps. No API changes.
42
+
5
43
  ## [4.17.0] — 2026-04-20
6
44
 
7
45
  ### 🛡️ Hardening: long-running stability audit + leak fixes
@@ -151,8 +189,8 @@ The three aliased entries all route through `ClaudeSDKProvider` with different `
151
189
 
152
190
  ```yaml
153
191
  ---
154
- purpose: Interview prep
155
- cwd: ~/Documents/Interviews
192
+ purpose: my-project
193
+ cwd: ~/Projects/my-project
156
194
  model: sonnet # opus | sonnet | haiku | claude-opus-4-7 | ...
157
195
  ---
158
196
  ```
package/bin/cli.js CHANGED
@@ -1828,7 +1828,7 @@ switch (cmd) {
1828
1828
  const searchQuery = process.argv.slice(3).join(" ");
1829
1829
  if (!searchQuery) {
1830
1830
  console.log("Usage: alvin-bot search <query>");
1831
- console.log('Example: alvin-bot search "cover letter"');
1831
+ console.log('Example: alvin-bot search "tax document 2024"');
1832
1832
  process.exit(1);
1833
1833
  }
1834
1834
  const { searchSelf, formatSearchResults } = await import("../dist/services/self-search.js");
package/dist/index.js CHANGED
@@ -158,6 +158,7 @@ import { discoverTools } from "./services/tool-discovery.js";
158
158
  import { startHeartbeat, stopHeartbeat } from "./services/heartbeat.js";
159
159
  import { stopAutoUpdateLoop } from "./services/updater.js";
160
160
  import { startCleanupLoop, stopCleanupLoop } from "./services/disk-cleanup.js";
161
+ import { flushProfiles } from "./services/users.js";
161
162
  import { initEmbeddings } from "./services/embeddings.js";
162
163
  import { loadSkills } from "./services/skills.js";
163
164
  import { loadHooks } from "./services/hooks.js";
@@ -344,6 +345,12 @@ const shutdown = async () => {
344
345
  // The debounced timer might be pending; flushSessions() cancels it and writes
345
346
  // synchronously so the next boot can rehydrate the latest state.
346
347
  await flushSessions().catch((err) => console.warn("[shutdown] flushSessions failed:", err));
348
+ try {
349
+ flushProfiles();
350
+ }
351
+ catch (err) {
352
+ console.warn("[shutdown] flushProfiles failed:", err);
353
+ }
347
354
  if (queueInterval)
348
355
  clearInterval(queueInterval);
349
356
  if (queueCleanupInterval)
@@ -62,6 +62,28 @@ function getMissingFileFailureMs() {
62
62
  const pending = new Map();
63
63
  let pollTimer = null;
64
64
  let started = false;
65
+ /**
66
+ * Hard cap on the pending-agents map. Without this, a bot that runs many
67
+ * async agents but sees some fail to write their outputFile would see
68
+ * entries linger up to `giveUpAt` (12h default). If the rate of
69
+ * registerPending() outpaces resolutions for days, memory and the disk
70
+ * state file grow unbounded. We evict oldest-first when over the cap.
71
+ */
72
+ const MAX_PENDING_AGENTS = 500;
73
+ function enforcePendingCap() {
74
+ if (pending.size < MAX_PENDING_AGENTS)
75
+ return;
76
+ const entries = [...pending.entries()].sort((a, b) => a[1].startedAt - b[1].startedAt);
77
+ const target = Math.floor(MAX_PENDING_AGENTS * 0.9);
78
+ let toEvict = pending.size - target;
79
+ for (const [id] of entries) {
80
+ if (toEvict <= 0)
81
+ break;
82
+ pending.delete(id);
83
+ toEvict--;
84
+ }
85
+ console.warn(`[async-agent-watcher] pending map hit cap ${MAX_PENDING_AGENTS}, evicted to ${pending.size}`);
86
+ }
65
87
  // ── Persistence ───────────────────────────────────────────────────
66
88
  function loadFromDisk() {
67
89
  try {
@@ -110,6 +132,7 @@ export function registerPendingAgent(input) {
110
132
  sessionKey: input.sessionKey,
111
133
  platform: input.platform,
112
134
  };
135
+ enforcePendingCap();
113
136
  pending.set(input.agentId, entry);
114
137
  saveToDisk();
115
138
  }
@@ -233,6 +233,17 @@ async function ensureGateway() {
233
233
  gatewayProcess.on("exit", () => {
234
234
  gatewayProcess = null;
235
235
  });
236
+ // Surface spawn failures so we don't silently think the gateway is running.
237
+ gatewayProcess.on("error", (err) => {
238
+ log(`gateway spawn error: ${err.message}`);
239
+ gatewayProcess = null;
240
+ });
241
+ // Drain stdio pipes — otherwise stdout/stderr buffer fills and the child
242
+ // blocks on write. We don't care about the content (just that they drain).
243
+ gatewayProcess.stdout?.on("error", () => { });
244
+ gatewayProcess.stderr?.on("error", () => { });
245
+ gatewayProcess.stdout?.resume();
246
+ gatewayProcess.stderr?.resume();
236
247
  // Wait for startup (max 10s)
237
248
  for (let i = 0; i < 20; i++) {
238
249
  await new Promise((r) => setTimeout(r, 500));
@@ -143,12 +143,26 @@ function chunkMarkdown(content, source) {
143
143
  return chunks;
144
144
  }
145
145
  // ── Index Management ────────────────────────────────────
146
+ // In-memory cache for the embedding index. Without this, every query would
147
+ // re-read and re-parse the on-disk index (can be 100+ MB, making searchMemory
148
+ // the slowest step in a message turn). We keep the parsed object and invalidate
149
+ // via mtime check — so external reindexers are still picked up.
150
+ let indexCache = null;
151
+ let indexCacheMtime = 0;
146
152
  function loadIndex() {
147
153
  try {
154
+ const st = fs.statSync(INDEX_FILE);
155
+ if (indexCache && st.mtimeMs === indexCacheMtime) {
156
+ return indexCache;
157
+ }
148
158
  const raw = fs.readFileSync(INDEX_FILE, "utf-8");
149
- return JSON.parse(raw);
159
+ indexCache = JSON.parse(raw);
160
+ indexCacheMtime = st.mtimeMs;
161
+ return indexCache;
150
162
  }
151
163
  catch {
164
+ // File missing or unparseable — return an empty index and don't cache it
165
+ // (next call will retry, so a freshly-written index gets picked up).
152
166
  return {
153
167
  model: EMBEDDING_MODEL,
154
168
  lastReindex: 0,
@@ -159,6 +173,15 @@ function loadIndex() {
159
173
  }
160
174
  function saveIndex(index) {
161
175
  fs.writeFileSync(INDEX_FILE, JSON.stringify(index));
176
+ // Refresh cache immediately so the next loadIndex() sees the new state
177
+ // without a disk round-trip.
178
+ indexCache = index;
179
+ try {
180
+ indexCacheMtime = fs.statSync(INDEX_FILE).mtimeMs;
181
+ }
182
+ catch {
183
+ indexCacheMtime = Date.now();
184
+ }
162
185
  }
163
186
  /**
164
187
  * Recursively walk a directory, returning file paths.
@@ -116,6 +116,17 @@ async function connectStdio(name, config) {
116
116
  proc.stderr.on("data", (data) => {
117
117
  console.error(`MCP ${name} stderr:`, data.toString().trim());
118
118
  });
119
+ // Surface stderr stream errors so we don't silently lose the channel
120
+ // (EPIPE, ECONNRESET etc). Without this, unhandled 'error' on the
121
+ // stream would crash the whole Node process.
122
+ proc.stderr.on("error", (err) => {
123
+ console.error(`MCP ${name} stderr stream error:`, err.message);
124
+ server.connected = false;
125
+ });
126
+ proc.stdout?.on("error", (err) => {
127
+ console.error(`MCP ${name} stdout stream error:`, err.message);
128
+ server.connected = false;
129
+ });
119
130
  proc.on("error", (err) => {
120
131
  console.error(`MCP ${name} process error:`, err);
121
132
  server.connected = false;
@@ -167,10 +167,12 @@ export function loadSkills() {
167
167
  return cachedSkills;
168
168
  }
169
169
  /**
170
- * Get all loaded skills.
170
+ * Get all loaded skills. Cached after the first loadSkills() call; hot-reload
171
+ * happens via fs.watch when files change on disk. We only force a scan here if
172
+ * the cache is empty (init-order edge case).
171
173
  */
172
174
  export function getSkills() {
173
- if (cachedSkills.length === 0 || Date.now() - lastScanAt > 300_000) {
175
+ if (cachedSkills.length === 0) {
174
176
  reloadAllSkills();
175
177
  }
176
178
  return cachedSkills;
@@ -128,6 +128,43 @@ export function setDefaultTimeoutMs(ms) {
128
128
  }
129
129
  // ── State ───────────────────────────────────────────────
130
130
  const activeAgents = new Map();
131
+ /**
132
+ * Hard cap on the activeAgents map. Without this, a long-running bot that
133
+ * spawns many agents (e.g. a chatty cron + manual triggers over months) would
134
+ * accumulate delivered entries indefinitely. The 30-min auto-cleanup inside
135
+ * runSubAgent only fires on graceful completion, so crashed/orphaned entries
136
+ * would linger until the 12h giveUpAt ceiling.
137
+ *
138
+ * Enforcement: whenever we insert a new entry and the map is at-or-over the
139
+ * cap, evict the oldest finished-and-delivered entries first. Running agents
140
+ * are never evicted.
141
+ */
142
+ const MAX_ACTIVE_AGENTS = 1000;
143
+ function enforceAgentCap() {
144
+ if (activeAgents.size < MAX_ACTIVE_AGENTS)
145
+ return;
146
+ // Collect evictable entries (delivered OR terminal status), sort by startedAt
147
+ const evictable = [];
148
+ for (const [id, entry] of activeAgents) {
149
+ const status = entry.info.status;
150
+ const done = entry.delivered || status === "error" || status === "timeout" || status === "cancelled";
151
+ if (done)
152
+ evictable.push([id, entry.info.startedAt]);
153
+ }
154
+ evictable.sort((a, b) => a[1] - b[1]);
155
+ // Evict enough to land 10% below the cap, so we don't oscillate.
156
+ const target = Math.floor(MAX_ACTIVE_AGENTS * 0.9);
157
+ let toEvict = activeAgents.size - target;
158
+ for (const [id] of evictable) {
159
+ if (toEvict <= 0)
160
+ break;
161
+ activeAgents.delete(id);
162
+ toEvict--;
163
+ }
164
+ if (toEvict > 0) {
165
+ console.warn(`[subagents] map at ${activeAgents.size}/${MAX_ACTIVE_AGENTS} — could not evict enough finished entries (too many still running)`);
166
+ }
167
+ }
131
168
  // ── Name resolver (B2) ──────────────────────────────────
132
169
  /**
133
170
  * Return all currently-tracked agents whose *base* name matches `base`.
@@ -563,6 +600,7 @@ export function spawnSubAgent(agentConfig) {
563
600
  nameIndex: resolved.index,
564
601
  queuePosition: willRunImmediately ? undefined : queuedLen + 1,
565
602
  };
603
+ enforceAgentCap();
566
604
  activeAgents.set(id, { info, abort, delivered: false });
567
605
  const queuedSpawn = { id, resolvedName, agentConfig, depth, timeoutId };
568
606
  if (willRunImmediately) {
@@ -8,6 +8,12 @@
8
8
  *
9
9
  * The admin/owner user uses the global docs/memory/ and docs/MEMORY.md.
10
10
  * Additional users get isolated memory spaces.
11
+ *
12
+ * Performance:
13
+ * Profiles are cached in memory after first read. `touchProfile` — called
14
+ * on every inbound message — writes to cache and schedules a debounced
15
+ * disk flush (2s). This avoids two sync fs operations per message on the
16
+ * hot path. A final flush happens on graceful shutdown so nothing is lost.
11
17
  */
12
18
  import fs from "fs";
13
19
  import { resolve } from "path";
@@ -18,6 +24,42 @@ import { USERS_DIR, MEMORY_DIR } from "../paths.js";
18
24
  // Ensure users dir exists
19
25
  if (!fs.existsSync(USERS_DIR))
20
26
  fs.mkdirSync(USERS_DIR, { recursive: true });
27
+ // ── In-memory cache + debounced persistence ─────────────
28
+ const cache = new Map();
29
+ const dirty = new Set();
30
+ let flushTimer = null;
31
+ const FLUSH_DELAY_MS = 2000;
32
+ function schedule_flush() {
33
+ if (flushTimer)
34
+ return;
35
+ flushTimer = setTimeout(() => {
36
+ flushTimer = null;
37
+ flushProfiles();
38
+ }, FLUSH_DELAY_MS);
39
+ flushTimer.unref?.();
40
+ }
41
+ /**
42
+ * Write every dirty profile to disk synchronously. Called by the debounce
43
+ * timer AND by the graceful-shutdown handler so no in-flight updates are
44
+ * lost even if the bot exits between debounce ticks.
45
+ */
46
+ export function flushProfiles() {
47
+ if (dirty.size === 0)
48
+ return;
49
+ for (const userId of dirty) {
50
+ const profile = cache.get(userId);
51
+ if (!profile)
52
+ continue;
53
+ try {
54
+ fs.writeFileSync(profilePath(userId), JSON.stringify(profile, null, 2));
55
+ }
56
+ catch (err) {
57
+ // Don't throw — a persistent error would block future flushes.
58
+ console.warn(`[users] flush ${userId} failed: ${err.message}`);
59
+ }
60
+ }
61
+ dirty.clear();
62
+ }
21
63
  // ── Profile Management ──────────────────────────────────
22
64
  function profilePath(userId) {
23
65
  return resolve(USERS_DIR, `${userId}.json`);
@@ -26,22 +68,32 @@ function userMemoryDir(userId) {
26
68
  return resolve(USERS_DIR, `${userId}`);
27
69
  }
28
70
  /**
29
- * Load a user profile. Returns null if not found.
71
+ * Load a user profile. Returns null if not found. Reads from cache first,
72
+ * falls back to disk on cache miss.
30
73
  */
31
74
  export function loadProfile(userId) {
75
+ const cached = cache.get(userId);
76
+ if (cached)
77
+ return cached;
32
78
  try {
33
79
  const raw = fs.readFileSync(profilePath(userId), "utf-8");
34
- return JSON.parse(raw);
80
+ const profile = JSON.parse(raw);
81
+ cache.set(userId, profile);
82
+ return profile;
35
83
  }
36
84
  catch {
37
85
  return null;
38
86
  }
39
87
  }
40
88
  /**
41
- * Save a user profile.
89
+ * Save a user profile — updates cache and schedules a debounced disk flush.
90
+ * For immediate durability (e.g. during shutdown), call flushProfiles()
91
+ * after this.
42
92
  */
43
93
  export function saveProfile(profile) {
44
- fs.writeFileSync(profilePath(profile.userId), JSON.stringify(profile, null, 2));
94
+ cache.set(profile.userId, profile);
95
+ dirty.add(profile.userId);
96
+ schedule_flush();
45
97
  }
46
98
  /**
47
99
  * Get or create a user profile.
@@ -76,6 +128,9 @@ export function getOrCreateProfile(userId, name, username) {
76
128
  }
77
129
  /**
78
130
  * Update a user's activity (call on each message).
131
+ *
132
+ * Previously this did a sync read + write per message. Now it works purely
133
+ * in memory and lets the debounce timer batch writes to disk.
79
134
  */
80
135
  export function touchProfile(userId, name, username, platform, messageText) {
81
136
  const profile = getOrCreateProfile(userId, name, username);
@@ -95,20 +150,33 @@ export function touchProfile(userId, name, username, platform, messageText) {
95
150
  return profile;
96
151
  }
97
152
  /**
98
- * List all known user profiles.
153
+ * List all known user profiles. Reads from disk; populates cache for
154
+ * subsequent fast access.
99
155
  */
100
156
  export function listProfiles() {
101
157
  const profiles = [];
102
158
  try {
103
159
  const files = fs.readdirSync(USERS_DIR);
104
160
  for (const file of files) {
105
- if (file.endsWith(".json")) {
106
- try {
107
- const raw = fs.readFileSync(resolve(USERS_DIR, file), "utf-8");
108
- profiles.push(JSON.parse(raw));
109
- }
110
- catch { /* skip corrupt */ }
161
+ if (!file.endsWith(".json"))
162
+ continue;
163
+ // Parse user id from filename — skip non-numeric (e.g. stray files)
164
+ const userId = parseInt(file.slice(0, -5), 10);
165
+ if (!Number.isFinite(userId))
166
+ continue;
167
+ // If cached, use that; otherwise read once and cache
168
+ const cached = cache.get(userId);
169
+ if (cached) {
170
+ profiles.push(cached);
171
+ continue;
172
+ }
173
+ try {
174
+ const raw = fs.readFileSync(resolve(USERS_DIR, file), "utf-8");
175
+ const p = JSON.parse(raw);
176
+ cache.set(userId, p);
177
+ profiles.push(p);
111
178
  }
179
+ catch { /* skip corrupt */ }
112
180
  }
113
181
  }
114
182
  catch { /* dir doesn't exist */ }
@@ -145,6 +213,9 @@ export function addUserNote(userId, note) {
145
213
  export function deleteUser(userId) {
146
214
  const deleted = [];
147
215
  const errors = [];
216
+ // 0. Drop from cache + dirty set so the debounce doesn't re-create the file
217
+ cache.delete(userId);
218
+ dirty.delete(userId);
148
219
  // 1. Delete profile JSON
149
220
  const pPath = profilePath(userId);
150
221
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "4.17.0",
3
+ "version": "4.18.1",
4
4
  "description": "Alvin Bot \u2014 Your personal AI agent on Telegram, WhatsApp, Discord, Signal, and Web.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,6 +15,8 @@
15
15
  "test": "vitest run",
16
16
  "test:watch": "vitest",
17
17
  "test:ui": "vitest --ui",
18
+ "privacy-check": "bash scripts/privacy-check.sh",
19
+ "prepublishOnly": "bash scripts/privacy-check.sh",
18
20
  "electron:compile": "tsc -p electron/tsconfig.json",
19
21
  "electron:dev": "npm run electron:compile && electron .",
20
22
  "electron:build": "npm run build && npm run electron:compile && electron-builder --publish never",
@@ -1,98 +0,0 @@
1
- /**
2
- * v4.12.2 — ALLOWED_USERS startup hard-fail gate.
3
- *
4
- * When the Telegram bot token is configured but ALLOWED_USERS is empty,
5
- * starting the bot would leave it open to any Telegram user sending a DM.
6
- * Previously this only emitted a console.warn and the bot started anyway.
7
- *
8
- * v4.12.2 introduces a pure gate function that decides whether to refuse
9
- * startup, with two explicit escape hatches:
10
- * 1. AUTH_MODE=open — user explicitly wants an open bot
11
- * 2. ALVIN_INSECURE_ACKNOWLEDGED=1 — explicit opt-out for test/scripted envs
12
- *
13
- * This test file exercises the pure gate. The actual wiring in src/index.ts
14
- * is a thin if-block that calls process.exit(1) on deny.
15
- */
16
- import { describe, it, expect } from "vitest";
17
- import { checkAllowedUsersGate } from "../src/services/allowed-users-gate.js";
18
-
19
- describe("allowed-users-gate (v4.12.2)", () => {
20
- it("allows startup when ALLOWED_USERS is populated", () => {
21
- const result = checkAllowedUsersGate({
22
- hasTelegram: true,
23
- allowedUsersCount: 1,
24
- authMode: "allowlist",
25
- insecureAcknowledged: false,
26
- });
27
- expect(result.allowed).toBe(true);
28
- expect(result.reason).toBeUndefined();
29
- });
30
-
31
- it("BLOCKS startup when telegram enabled but allowedUsers empty (allowlist mode)", () => {
32
- const result = checkAllowedUsersGate({
33
- hasTelegram: true,
34
- allowedUsersCount: 0,
35
- authMode: "allowlist",
36
- insecureAcknowledged: false,
37
- });
38
- expect(result.allowed).toBe(false);
39
- expect(result.reason).toContain("ALLOWED_USERS");
40
- });
41
-
42
- it("BLOCKS startup when telegram enabled but allowedUsers empty (pairing mode)", () => {
43
- // Pairing mode needs allowedUsers[0] as the admin for approval routing.
44
- // Empty array breaks the whole pairing flow.
45
- const result = checkAllowedUsersGate({
46
- hasTelegram: true,
47
- allowedUsersCount: 0,
48
- authMode: "pairing",
49
- insecureAcknowledged: false,
50
- });
51
- expect(result.allowed).toBe(false);
52
- });
53
-
54
- it("ALLOWS startup when AUTH_MODE=open explicitly", () => {
55
- const result = checkAllowedUsersGate({
56
- hasTelegram: true,
57
- allowedUsersCount: 0,
58
- authMode: "open",
59
- insecureAcknowledged: false,
60
- });
61
- expect(result.allowed).toBe(true);
62
- expect(result.warning).toContain("open");
63
- });
64
-
65
- it("ALLOWS startup when ALVIN_INSECURE_ACKNOWLEDGED=1", () => {
66
- const result = checkAllowedUsersGate({
67
- hasTelegram: true,
68
- allowedUsersCount: 0,
69
- authMode: "allowlist",
70
- insecureAcknowledged: true,
71
- });
72
- expect(result.allowed).toBe(true);
73
- expect(result.warning).toContain("INSECURE");
74
- });
75
-
76
- it("ALLOWS startup when telegram is NOT enabled (bot is WebUI-only)", () => {
77
- // WebUI-only deployments don't have a BOT_TOKEN and don't need
78
- // ALLOWED_USERS — the gate only applies when hasTelegram === true.
79
- const result = checkAllowedUsersGate({
80
- hasTelegram: false,
81
- allowedUsersCount: 0,
82
- authMode: "allowlist",
83
- insecureAcknowledged: false,
84
- });
85
- expect(result.allowed).toBe(true);
86
- });
87
-
88
- it("reason message mentions ~/.alvin-bot/.env and @userinfobot for operator guidance", () => {
89
- const result = checkAllowedUsersGate({
90
- hasTelegram: true,
91
- allowedUsersCount: 0,
92
- authMode: "allowlist",
93
- insecureAcknowledged: false,
94
- });
95
- expect(result.reason).toMatch(/\.env|alvin-bot/i);
96
- expect(result.reason).toMatch(/userinfobot|telegram/i);
97
- });
98
- });