alvin-bot 4.15.2 → 4.16.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.
@@ -0,0 +1,351 @@
1
+ /**
2
+ * CDP Bootstrap — spawns Chromium with remote-debugging-port=9222 independently.
3
+ *
4
+ * Avoids two problems that plague naive CDP setups:
5
+ *
6
+ * 1. **LaunchServices hijack** — invoking /Applications/Google Chrome.app while
7
+ * the user's Chrome is running silently redirects the call to the existing
8
+ * instance without applying --remote-debugging-port. Log symptom:
9
+ * "Wird in einer aktuellen Browsersitzung geöffnet." We avoid it by
10
+ * preferring Playwright's "Google Chrome for Testing" binary, which has a
11
+ * distinct bundle ID.
12
+ *
13
+ * 2. **Stale PID files** — a crashed Chromium leaves chrome-cdp.pid pointing at
14
+ * a dead process; subsequent starts conclude "already running" and fail
15
+ * silently. We verify liveness via both `ps` and a CDP /json/version probe.
16
+ *
17
+ * The module is idempotent: `ensureRunning()` is safe to call repeatedly; if
18
+ * CDP is already healthy it returns immediately.
19
+ */
20
+ import { spawn } from "child_process";
21
+ import fs from "fs";
22
+ import path from "path";
23
+ import os from "os";
24
+ import http from "http";
25
+ import { CDP_PROFILE_DIR, CDP_SCREENSHOTS_DIR, CDP_PID_FILE, CDP_LOG_FILE, } from "../paths.js";
26
+ const CDP_PORT = 9222;
27
+ const CDP_VERSION_URL = `http://127.0.0.1:${CDP_PORT}/json/version`;
28
+ const START_TIMEOUT_MS = 15_000;
29
+ // ── Binary resolution ───────────────────────────────────────────────
30
+ /**
31
+ * Find Playwright's bundled Chromium. Prefers "Google Chrome for Testing"
32
+ * (distinct macOS bundle ID — no LaunchServices conflict with user Chrome),
33
+ * falls back to plain Chromium for older Playwright installs.
34
+ *
35
+ * Returns null if no bundled Chromium is present — callers should then fall
36
+ * back to a user-supplied binary or error out with guidance.
37
+ */
38
+ export function findPlaywrightChromium() {
39
+ const pwRoot = path.join(os.homedir(), "Library", "Caches", "ms-playwright");
40
+ if (!fs.existsSync(pwRoot)) {
41
+ // Linux cache path
42
+ const linuxPwRoot = path.join(os.homedir(), ".cache", "ms-playwright");
43
+ if (fs.existsSync(linuxPwRoot))
44
+ return resolveFromPwRoot(linuxPwRoot);
45
+ return null;
46
+ }
47
+ return resolveFromPwRoot(pwRoot);
48
+ }
49
+ function resolveFromPwRoot(pwRoot) {
50
+ let dirs;
51
+ try {
52
+ dirs = fs.readdirSync(pwRoot).filter((d) => /^chromium-\d+$/.test(d));
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ if (dirs.length === 0)
58
+ return null;
59
+ // Latest version by numeric suffix
60
+ dirs.sort((a, b) => {
61
+ const na = parseInt(a.replace("chromium-", ""), 10);
62
+ const nb = parseInt(b.replace("chromium-", ""), 10);
63
+ return nb - na;
64
+ });
65
+ // Platform-dependent layout; try all known variants
66
+ const candidates = [];
67
+ for (const dir of dirs) {
68
+ const root = path.join(pwRoot, dir);
69
+ for (const arch of ["chrome-mac-arm64", "chrome-mac", "chrome-linux", "chrome-win"]) {
70
+ for (const app of [
71
+ // macOS app bundles
72
+ "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
73
+ "Chromium.app/Contents/MacOS/Chromium",
74
+ // Linux / Windows raw binaries
75
+ "chrome",
76
+ "chrome.exe",
77
+ ]) {
78
+ candidates.push(path.join(root, arch, app));
79
+ }
80
+ }
81
+ }
82
+ for (const c of candidates) {
83
+ try {
84
+ const st = fs.statSync(c);
85
+ if (st.isFile())
86
+ return c;
87
+ }
88
+ catch {
89
+ // not present, keep searching
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+ /**
95
+ * Resolve the browser binary in preference order:
96
+ * 1. Playwright's Chromium (no conflict with user Chrome, preferred)
97
+ * 2. Existing user browser (may trigger LaunchServices hijack — last resort)
98
+ */
99
+ export function resolveBrowserBinary() {
100
+ const pw = findPlaywrightChromium();
101
+ if (pw)
102
+ return { path: pw, origin: "playwright" };
103
+ // System Chrome fallback (macOS path). On Linux/Windows we return null and
104
+ // let callers surface a clear error.
105
+ const sysChrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
106
+ if (fs.existsSync(sysChrome))
107
+ return { path: sysChrome, origin: "system" };
108
+ return null;
109
+ }
110
+ // ── Liveness probes ─────────────────────────────────────────────────
111
+ function pidAlive(pid) {
112
+ try {
113
+ // signal 0 tests existence without actually signaling
114
+ process.kill(pid, 0);
115
+ return true;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ function readPidFile() {
122
+ try {
123
+ const raw = fs.readFileSync(CDP_PID_FILE, "utf8").trim();
124
+ const pid = parseInt(raw, 10);
125
+ return Number.isFinite(pid) ? pid : null;
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ }
131
+ async function cdpReachable(timeoutMs = 2000) {
132
+ return new Promise((resolve) => {
133
+ const req = http.get(CDP_VERSION_URL, (res) => {
134
+ res.resume(); // drain
135
+ resolve(res.statusCode === 200);
136
+ });
137
+ req.on("error", () => resolve(false));
138
+ req.setTimeout(timeoutMs, () => {
139
+ req.destroy();
140
+ resolve(false);
141
+ });
142
+ });
143
+ }
144
+ // ── Process control ─────────────────────────────────────────────────
145
+ let bootstrapLock = null;
146
+ /**
147
+ * Ensure CDP is running on port 9222. Idempotent and safe to call from
148
+ * multiple concurrent code paths — only one spawn happens at a time.
149
+ */
150
+ export async function ensureRunning(opts = {}) {
151
+ const mode = opts.mode || "headless";
152
+ // Already running? Verify both ends (process + endpoint).
153
+ if (await cdpReachable()) {
154
+ return { running: true, pid: readPidFile() || undefined, endpoint: CDP_VERSION_URL };
155
+ }
156
+ // Serialize concurrent bootstrap attempts
157
+ if (bootstrapLock) {
158
+ await bootstrapLock;
159
+ if (await cdpReachable()) {
160
+ return { running: true, pid: readPidFile() || undefined, endpoint: CDP_VERSION_URL };
161
+ }
162
+ }
163
+ bootstrapLock = (async () => {
164
+ // Stale PID cleanup — a PID file pointing at a dead process blocks nothing
165
+ // but is confusing. Remove it before spawning.
166
+ const stalePid = readPidFile();
167
+ if (stalePid && !pidAlive(stalePid)) {
168
+ try {
169
+ fs.unlinkSync(CDP_PID_FILE);
170
+ }
171
+ catch { }
172
+ }
173
+ const binary = resolveBrowserBinary();
174
+ if (!binary) {
175
+ throw new Error("No Chromium binary found. Install Playwright's Chromium: " +
176
+ "cd ~/.alvin-bot && npx playwright install chromium");
177
+ }
178
+ // Ensure data dirs exist
179
+ for (const dir of [CDP_PROFILE_DIR, CDP_SCREENSHOTS_DIR, path.dirname(CDP_PID_FILE)]) {
180
+ fs.mkdirSync(dir, { recursive: true });
181
+ }
182
+ const args = [
183
+ `--remote-debugging-port=${CDP_PORT}`,
184
+ `--user-data-dir=${CDP_PROFILE_DIR}`,
185
+ "--no-first-run",
186
+ "--no-default-browser-check",
187
+ "--disable-features=ChromeWhatsNewUI,PrivacySandboxSettings4",
188
+ ];
189
+ if (mode === "headless") {
190
+ args.push("--headless=new", "--disable-gpu");
191
+ }
192
+ args.push("about:blank");
193
+ const logStream = fs.openSync(CDP_LOG_FILE, "w");
194
+ const child = spawn(binary.path, args, {
195
+ stdio: ["ignore", logStream, logStream],
196
+ detached: true,
197
+ });
198
+ child.unref();
199
+ if (!child.pid) {
200
+ throw new Error("Failed to spawn Chromium (no PID)");
201
+ }
202
+ fs.writeFileSync(CDP_PID_FILE, String(child.pid));
203
+ // Wait until CDP answers
204
+ const deadline = Date.now() + START_TIMEOUT_MS;
205
+ while (Date.now() < deadline) {
206
+ if (await cdpReachable())
207
+ return;
208
+ await new Promise((r) => setTimeout(r, 300));
209
+ }
210
+ // Did not come up — kill and surface a useful error
211
+ try {
212
+ process.kill(child.pid);
213
+ }
214
+ catch { }
215
+ try {
216
+ fs.unlinkSync(CDP_PID_FILE);
217
+ }
218
+ catch { }
219
+ const tail = readLogTail(20);
220
+ throw new Error(`CDP did not come up within ${START_TIMEOUT_MS}ms using ${binary.path}\n` +
221
+ `Log tail:\n${tail}`);
222
+ })();
223
+ try {
224
+ await bootstrapLock;
225
+ }
226
+ finally {
227
+ bootstrapLock = null;
228
+ }
229
+ return {
230
+ running: true,
231
+ pid: readPidFile() || undefined,
232
+ binary: resolveBrowserBinary()?.path,
233
+ endpoint: CDP_VERSION_URL,
234
+ };
235
+ }
236
+ /**
237
+ * Stop the bot-managed Chromium. Does NOT touch the user's own Chrome.
238
+ */
239
+ export async function stop() {
240
+ const pid = readPidFile();
241
+ if (pid && pidAlive(pid)) {
242
+ try {
243
+ process.kill(pid, "SIGTERM");
244
+ }
245
+ catch { }
246
+ // Give it a second to close gracefully, then force-kill
247
+ await new Promise((r) => setTimeout(r, 1000));
248
+ if (pidAlive(pid)) {
249
+ try {
250
+ process.kill(pid, "SIGKILL");
251
+ }
252
+ catch { }
253
+ }
254
+ }
255
+ try {
256
+ fs.unlinkSync(CDP_PID_FILE);
257
+ }
258
+ catch { }
259
+ }
260
+ /**
261
+ * Report current status without starting anything.
262
+ */
263
+ export async function status() {
264
+ const pid = readPidFile();
265
+ const endpoint = CDP_VERSION_URL;
266
+ const binary = resolveBrowserBinary()?.path;
267
+ if (pid && pidAlive(pid) && (await cdpReachable())) {
268
+ return { running: true, pid, binary, endpoint };
269
+ }
270
+ if (pid && !pidAlive(pid)) {
271
+ return { running: false, endpoint, reason: `stale PID ${pid} — process not running` };
272
+ }
273
+ if (pid && !(await cdpReachable())) {
274
+ return { running: false, pid, endpoint, reason: "PID alive but CDP endpoint unreachable" };
275
+ }
276
+ return { running: false, endpoint, reason: "not started" };
277
+ }
278
+ function readLogTail(lines) {
279
+ try {
280
+ const content = fs.readFileSync(CDP_LOG_FILE, "utf8");
281
+ return content.split("\n").slice(-lines).join("\n");
282
+ }
283
+ catch {
284
+ return "(no log file)";
285
+ }
286
+ }
287
+ export async function doctor() {
288
+ const checks = [];
289
+ // 1. Binary
290
+ const binary = resolveBrowserBinary();
291
+ if (binary) {
292
+ checks.push({
293
+ name: "Binary",
294
+ ok: true,
295
+ detail: binary.origin === "playwright"
296
+ ? `Playwright Chromium — ${binary.path}`
297
+ : `System Chrome (fallback — risk of LaunchServices conflict) — ${binary.path}`,
298
+ });
299
+ }
300
+ else {
301
+ checks.push({
302
+ name: "Binary",
303
+ ok: false,
304
+ detail: "No Chromium found. Run: npx playwright install chromium",
305
+ });
306
+ }
307
+ // 2. Port / endpoint
308
+ const reachable = await cdpReachable();
309
+ checks.push({
310
+ name: "CDP endpoint",
311
+ ok: reachable,
312
+ detail: reachable ? `${CDP_VERSION_URL} reachable` : `${CDP_VERSION_URL} not reachable`,
313
+ });
314
+ // 3. PID file
315
+ const pid = readPidFile();
316
+ if (pid === null) {
317
+ checks.push({ name: "PID file", ok: true, detail: "none (OK if CDP not running)" });
318
+ }
319
+ else if (pidAlive(pid)) {
320
+ checks.push({ name: "PID file", ok: true, detail: `PID ${pid} alive` });
321
+ }
322
+ else {
323
+ checks.push({ name: "PID file", ok: false, detail: `stale PID ${pid} — delete ${CDP_PID_FILE}` });
324
+ }
325
+ // 4. Profile lock (only relevant on macOS / Linux)
326
+ const lockPath = path.join(CDP_PROFILE_DIR, "SingletonLock");
327
+ if (fs.existsSync(lockPath)) {
328
+ // Chromium creates SingletonLock while running; only flag if there's no
329
+ // live process associated with it.
330
+ const livePid = pid && pidAlive(pid);
331
+ checks.push({
332
+ name: "Profile lock",
333
+ ok: !!livePid,
334
+ detail: livePid
335
+ ? "held by live process (OK)"
336
+ : `stale lock — delete ${lockPath}`,
337
+ });
338
+ }
339
+ else {
340
+ checks.push({ name: "Profile lock", ok: true, detail: "clean" });
341
+ }
342
+ // 5. Log tail
343
+ if (fs.existsSync(CDP_LOG_FILE)) {
344
+ checks.push({
345
+ name: "Recent log",
346
+ ok: true,
347
+ detail: `last lines (${CDP_LOG_FILE}):\n${readLogTail(5)}`,
348
+ });
349
+ }
350
+ return { ok: checks.every((c) => c.ok), checks };
351
+ }
@@ -77,7 +77,7 @@ function matchProjectsToQuery(projects, query) {
77
77
  }
78
78
  // Also check the first 200 chars of project content — this catches cases
79
79
  // where the user mentions a project's headline term that isn't the
80
- // filename (e.g., "VPS" matching alev-b.md which mentions "VPS:" upfront).
80
+ // filename (e.g., "VPS" matching my-project.md which mentions "VPS:" upfront).
81
81
  const head = p.content.slice(0, 200).toLowerCase();
82
82
  const headWords = head.split(/[\s\W]+/).filter(w => w.length >= 4);
83
83
  if (headWords.some(w => q.includes(w))) {
@@ -129,7 +129,7 @@ you explicitly need the sub-agent's result IN THIS SAME TURN (rare).
129
129
 
130
130
  **After launching a background agent (either tool), you MUST:**
131
131
  1. Tell the user in ONE short sentence what you kicked off.
132
- Example: "Starting SEO audit for gethomes.io in the background —
132
+ Example: "Starting SEO audit for example.com in the background —
133
133
  I'll send the report when it's done."
134
134
  2. End your turn IMMEDIATELY. Do not continue working. Do not wait.
135
135
  3. The bot will deliver the result as a separate message when ready.
@@ -144,7 +144,7 @@ export function trackProviderUsage(key, providerKey, cost, inputTokens, outputTo
144
144
  session.totalOutputTokens += outputTokens;
145
145
  persist();
146
146
  // Soft budget warnings — these NEVER block the bot. They exist purely
147
- // as log signals so the operator (Ali) can notice unusually expensive
147
+ // as log signals so the operator can notice unusually expensive
148
148
  // sessions. Each threshold fires at most once per session (reset on /new).
149
149
  const budget = config.maxBudgetUsd;
150
150
  if (budget > 0) {
@@ -228,13 +228,10 @@ export function matchSkills(userMessage, maxResults = 2) {
228
228
  .map(s => s.skill);
229
229
  }
230
230
  // ── Skill-Asset Mapping ────────────────────────────────
231
- /** Default mapping for skills that don't declare assetCategories in frontmatter. */
232
- const SKILL_ASSET_MAP = {
233
- "job-apply": ["cover-letters", "cv-templates", "photos"],
234
- "cv-update": ["cv-templates", "photos"],
235
- "cover-letter": ["cover-letters", "cv-templates"],
236
- "formal-letter": ["legal", "cv-templates"],
237
- };
231
+ /** Default mapping for skills that don't declare assetCategories in frontmatter.
232
+ * Skills can override this by declaring `assetCategories:` in their SKILL.md
233
+ * frontmatter. This map is only the fallback. */
234
+ const SKILL_ASSET_MAP = {};
238
235
  /**
239
236
  * Find assets relevant to a skill.
240
237
  * Uses frontmatter assetCategories if declared, otherwise falls back to static map.
@@ -13,13 +13,13 @@
13
13
  * Example:
14
14
  *
15
15
  * ---
16
- * purpose: Alev-B consulting website dev
17
- * cwd: ~/Projects/alev-b-website
16
+ * purpose: my-project website dev
17
+ * cwd: ~/Projects/my-project
18
18
  * emoji: "🏢"
19
19
  * color: "#6366f1"
20
- * channels: ["C01ALEVABC"]
20
+ * channels: ["C01EXAMPLE"]
21
21
  * ---
22
- * You are the Alev-B dev assistant. Stack: React + Express + Drizzle + MySQL.
22
+ * You are the my-project dev assistant. Stack: React + Express + Drizzle + MySQL.
23
23
  * Prefer concise, directly actionable answers about deployment...
24
24
  *
25
25
  * If no workspaces are configured or no match is found, a built-in "default"
package/docs/security.md CHANGED
@@ -127,7 +127,7 @@ By default sub-agents inherit the full tool set of the parent. v4.12.2 adds two
127
127
  - **`research`** — `readonly` + `WebSearch`, `WebFetch`. Good for research tasks that need the web but shouldn't touch local files.
128
128
  - **`full`** (default) — all tools.
129
129
 
130
- You cannot (yet) set this from the Telegram `/agent` command — it's only available to plugin code and to Ali's own customizations. A future release (Phase 18) will expose it through the UI.
130
+ You cannot (yet) set this from the Telegram `/agent` command — it's only available to plugin code and to maintainer customizations. A future release (Phase 18) will expose it through the UI.
131
131
 
132
132
  ### 5. Network hardening
133
133
 
@@ -203,10 +203,10 @@ See the README Roadmap → Phase 18 for the full list.
203
203
 
204
204
  ## Reporting security issues
205
205
 
206
- If you find a security vulnerability in Alvin Bot, **please do not open a public GitHub issue**. Email the maintainer directly:
206
+ If you find a security vulnerability in Alvin Bot, **please do not open a public GitHub issue**. Report it privately via GitHub Security Advisories:
207
207
 
208
- - **Email:** levin_ali@icloud.com
209
- - **Subject:** `[SECURITY] alvin-bot — <short description>`
208
+ - **Advisory form:** https://github.com/alvbln/Alvin-Bot/security/advisories/new
209
+ - **Subject line:** `[SECURITY] alvin-bot — <short description>`
210
210
 
211
211
  Include:
212
212
  - Affected version (e.g. `alvin-bot@4.12.1`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "4.15.2",
3
+ "version": "4.16.0",
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",