bosun 0.28.0 → 0.28.2

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,167 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ * Tab: Telemetry — analytics, quality signals, alerts
3
+ * ────────────────────────────────────────────────────────────── */
4
+ import { h } from "preact";
5
+ import { useMemo } from "preact/hooks";
6
+ import htm from "htm";
7
+
8
+ const html = htm.bind(h);
9
+
10
+ import {
11
+ telemetrySummary,
12
+ telemetryErrors,
13
+ telemetryExecutors,
14
+ telemetryAlerts,
15
+ loadTelemetrySummary,
16
+ loadTelemetryErrors,
17
+ loadTelemetryExecutors,
18
+ loadTelemetryAlerts,
19
+ scheduleRefresh,
20
+ } from "../modules/state.js";
21
+ import { Card, EmptyState, SkeletonCard, Badge } from "../components/shared.js";
22
+
23
+ function formatCount(value) {
24
+ if (value == null) return "–";
25
+ return String(value);
26
+ }
27
+
28
+ function formatSeconds(value) {
29
+ if (!value && value !== 0) return "–";
30
+ if (value >= 60) return `${Math.round(value / 60)}m`;
31
+ return `${value}s`;
32
+ }
33
+
34
+ function severityBadge(sev = "medium") {
35
+ const normalized = String(sev).toLowerCase();
36
+ if (normalized === "high" || normalized === "critical") return "danger";
37
+ if (normalized === "medium") return "warning";
38
+ return "info";
39
+ }
40
+
41
+ export function TelemetryTab() {
42
+ const summary = telemetrySummary.value;
43
+ const errors = telemetryErrors.value || [];
44
+ const executors = telemetryExecutors.value || {};
45
+ const alerts = telemetryAlerts.value || [];
46
+
47
+ const hasSummary = summary && summary.total > 0;
48
+
49
+ const executorRows = useMemo(
50
+ () => Object.entries(executors).sort((a, b) => b[1] - a[1]),
51
+ [executors],
52
+ );
53
+
54
+ const alertRows = useMemo(
55
+ () => alerts.slice(-10).reverse(),
56
+ [alerts],
57
+ );
58
+
59
+ return html`
60
+ <section class="telemetry-tab">
61
+ <div class="section-header">
62
+ <h2>Telemetry</h2>
63
+ <button
64
+ class="btn btn-ghost btn-sm"
65
+ onClick=${() => {
66
+ loadTelemetrySummary();
67
+ loadTelemetryErrors();
68
+ loadTelemetryExecutors();
69
+ loadTelemetryAlerts();
70
+ scheduleRefresh(4000);
71
+ }}
72
+ >
73
+ Refresh
74
+ </button>
75
+ </div>
76
+
77
+ ${!hasSummary
78
+ ? html`<${EmptyState}
79
+ title="No telemetry yet"
80
+ description="Telemetry appears here once agents start running."
81
+ />`
82
+ : html`<${Card} title="Summary" class="telemetry-summary">
83
+ <div class="metric-grid">
84
+ <div>
85
+ <div class="metric-label">Sessions</div>
86
+ <div class="metric-value">${formatCount(summary.total)}</div>
87
+ </div>
88
+ <div>
89
+ <div class="metric-label">Success</div>
90
+ <div class="metric-value">
91
+ ${formatCount(summary.success)} (${summary.successRate}%)
92
+ </div>
93
+ </div>
94
+ <div>
95
+ <div class="metric-label">Avg Duration</div>
96
+ <div class="metric-value">${formatSeconds(summary.avgDuration)}</div>
97
+ </div>
98
+ <div>
99
+ <div class="metric-label">Errors</div>
100
+ <div class="metric-value">${formatCount(summary.totalErrors)}</div>
101
+ </div>
102
+ </div>
103
+ </${Card}>`}
104
+
105
+ <div class="telemetry-grid">
106
+ <${Card} title="Top Errors">
107
+ ${errors.length === 0
108
+ ? html`<${EmptyState}
109
+ title="No errors logged"
110
+ description="Errors appear here when failures are detected."
111
+ />`
112
+ : html`<ul class="telemetry-list">
113
+ ${errors.slice(0, 8).map(
114
+ (err) => html`<li>
115
+ <span class="telemetry-label">${err.fingerprint}</span>
116
+ <span class="telemetry-count">${err.count}</span>
117
+ </li>`,
118
+ )}
119
+ </ul>`}
120
+ </${Card}>
121
+
122
+ <${Card} title="Executors">
123
+ ${executorRows.length === 0
124
+ ? html`<${EmptyState}
125
+ title="No executor data"
126
+ description="Run tasks to populate executor usage."
127
+ />`
128
+ : html`<ul class="telemetry-list">
129
+ ${executorRows.map(
130
+ ([name, count]) => html`<li>
131
+ <span class="telemetry-label">${name}</span>
132
+ <span class="telemetry-count">${count}</span>
133
+ </li>`,
134
+ )}
135
+ </ul>`}
136
+ </${Card}>
137
+ </div>
138
+
139
+ <${Card} title="Recent Alerts">
140
+ ${alertRows.length === 0
141
+ ? html`<${EmptyState}
142
+ title="No alerts"
143
+ description="Analyzer alerts will show up here."
144
+ />`
145
+ : html`<ul class="telemetry-alerts">
146
+ ${alertRows.map(
147
+ (alert) => html`<li>
148
+ <div>
149
+ <div class="telemetry-alert-title">
150
+ ${alert.type || "alert"}
151
+ <${Badge} tone=${severityBadge(alert.severity)}>${
152
+ String(alert.severity || "medium").toUpperCase()
153
+ }</${Badge}>
154
+ </div>
155
+ <div class="telemetry-alert-meta">
156
+ ${alert.attempt_id || "unknown"}
157
+ ${alert.executor ? html` · ${alert.executor}` : ""}
158
+ </div>
159
+ </div>
160
+ </li>`,
161
+ )}
162
+ </ul>`}
163
+ </${Card}>
164
+ </section>
165
+ `;
166
+ }
167
+
package/ui-server.mjs CHANGED
@@ -600,6 +600,7 @@ const SETTINGS_KNOWN_KEYS = [
600
600
  "TELEGRAM_COMMAND_CONCURRENCY", "TELEGRAM_VERBOSITY", "TELEGRAM_BATCH_NOTIFICATIONS",
601
601
  "TELEGRAM_BATCH_INTERVAL_SEC", "TELEGRAM_BATCH_MAX_SIZE", "TELEGRAM_IMMEDIATE_PRIORITY",
602
602
  "TELEGRAM_API_BASE_URL", "TELEGRAM_HTTP_TIMEOUT_MS", "TELEGRAM_RETRY_ATTEMPTS",
603
+ "TELEGRAM_HISTORY_RETENTION_DAYS",
603
604
  "PROJECT_NAME", "TELEGRAM_MINIAPP_ENABLED", "TELEGRAM_UI_PORT", "TELEGRAM_UI_HOST",
604
605
  "TELEGRAM_UI_PUBLIC_HOST", "TELEGRAM_UI_BASE_URL", "TELEGRAM_UI_ALLOW_UNSAFE",
605
606
  "TELEGRAM_UI_AUTH_MAX_AGE_SEC", "TELEGRAM_UI_TUNNEL",
@@ -1204,6 +1205,17 @@ async function startTunnel(localPort) {
1204
1205
  return null;
1205
1206
  }
1206
1207
 
1208
+ // ── SECURITY: Block tunnel when auth is disabled ─────────────────────
1209
+ if (isAllowUnsafe()) {
1210
+ console.error(
1211
+ "[telegram-ui] ⛔ REFUSING to start tunnel — TELEGRAM_UI_ALLOW_UNSAFE=true\n" +
1212
+ " A public tunnel with no authentication lets ANYONE on the internet\n" +
1213
+ " control your agents, read secrets, and execute commands.\n" +
1214
+ " Either set TELEGRAM_UI_ALLOW_UNSAFE=false or TELEGRAM_UI_TUNNEL=disabled.",
1215
+ );
1216
+ return null;
1217
+ }
1218
+
1207
1219
  const cfBin = await findCloudflared();
1208
1220
  if (!cfBin) {
1209
1221
  if (tunnelMode === "auto") {
@@ -2202,6 +2214,60 @@ async function tailFile(filePath, lineCount, maxBytes = 1_000_000) {
2202
2214
  };
2203
2215
  }
2204
2216
 
2217
+ async function readJsonlTail(filePath, maxLines = 2000) {
2218
+ if (!existsSync(filePath)) return [];
2219
+ const tail = await tailFile(filePath, maxLines);
2220
+ return (tail.lines || [])
2221
+ .map((line) => {
2222
+ try {
2223
+ return JSON.parse(line);
2224
+ } catch {
2225
+ return null;
2226
+ }
2227
+ })
2228
+ .filter(Boolean);
2229
+ }
2230
+
2231
+ function withinDays(entry, days) {
2232
+ if (!days) return true;
2233
+ const ts = Date.parse(entry?.timestamp || "");
2234
+ if (!Number.isFinite(ts)) return true;
2235
+ return ts >= Date.now() - days * 24 * 60 * 60 * 1000;
2236
+ }
2237
+
2238
+ function summarizeTelemetry(metrics, days) {
2239
+ const filtered = metrics.filter((m) => withinDays(m, days));
2240
+ if (filtered.length === 0) return null;
2241
+ const total = filtered.length;
2242
+ const success = filtered.filter(
2243
+ (m) => m.outcome?.status === "completed" || m.metrics?.success === true,
2244
+ ).length;
2245
+ const durations = filtered.map((m) => m.metrics?.duration_ms || 0);
2246
+ const avgDuration =
2247
+ durations.length > 0
2248
+ ? Math.round(
2249
+ durations.reduce((a, b) => a + b, 0) / durations.length / 1000,
2250
+ )
2251
+ : 0;
2252
+ const totalErrors = filtered.reduce(
2253
+ (sum, m) => sum + (m.error_summary?.total_errors || m.metrics?.errors || 0),
2254
+ 0,
2255
+ );
2256
+ const executors = {};
2257
+ for (const m of filtered) {
2258
+ const exec = m.executor || "unknown";
2259
+ executors[exec] = (executors[exec] || 0) + 1;
2260
+ }
2261
+ return {
2262
+ total,
2263
+ success,
2264
+ successRate: total > 0 ? Math.round((success / total) * 100) : 0,
2265
+ avgDuration,
2266
+ totalErrors,
2267
+ executors,
2268
+ };
2269
+ }
2270
+
2205
2271
  async function listAgentLogFiles(query = "", limit = 60) {
2206
2272
  const entries = [];
2207
2273
  const files = await readdir(agentLogsDir).catch(() => []);
@@ -3172,6 +3238,79 @@ async function handleApi(req, res, url) {
3172
3238
  return;
3173
3239
  }
3174
3240
 
3241
+ if (path === "/api/telemetry/summary") {
3242
+ try {
3243
+ const days = Number(url.searchParams.get("days") || "7");
3244
+ const logDir = resolve(repoRoot, ".cache", "agent-work-logs");
3245
+ const metricsPath = resolve(logDir, "agent-metrics.jsonl");
3246
+ const metrics = await readJsonlTail(metricsPath, 3000);
3247
+ const summary = summarizeTelemetry(metrics, days);
3248
+ jsonResponse(res, 200, { ok: true, data: summary });
3249
+ } catch (err) {
3250
+ jsonResponse(res, 500, { ok: false, error: err.message });
3251
+ }
3252
+ return;
3253
+ }
3254
+
3255
+ if (path === "/api/telemetry/errors") {
3256
+ try {
3257
+ const days = Number(url.searchParams.get("days") || "7");
3258
+ const logDir = resolve(repoRoot, ".cache", "agent-work-logs");
3259
+ const errorsPath = resolve(logDir, "agent-errors.jsonl");
3260
+ const errors = (await readJsonlTail(errorsPath, 2000)).filter((e) =>
3261
+ withinDays(e, days),
3262
+ );
3263
+ const byFingerprint = new Map();
3264
+ for (const e of errors) {
3265
+ const fp = e.data?.error_fingerprint || e.data?.error_message || "unknown";
3266
+ byFingerprint.set(fp, (byFingerprint.get(fp) || 0) + 1);
3267
+ }
3268
+ const top = [...byFingerprint.entries()]
3269
+ .sort((a, b) => b[1] - a[1])
3270
+ .slice(0, 20)
3271
+ .map(([fingerprint, count]) => ({ fingerprint, count }));
3272
+ jsonResponse(res, 200, { ok: true, data: top });
3273
+ } catch (err) {
3274
+ jsonResponse(res, 500, { ok: false, error: err.message });
3275
+ }
3276
+ return;
3277
+ }
3278
+
3279
+ if (path === "/api/telemetry/executors") {
3280
+ try {
3281
+ const days = Number(url.searchParams.get("days") || "7");
3282
+ const logDir = resolve(repoRoot, ".cache", "agent-work-logs");
3283
+ const metricsPath = resolve(logDir, "agent-metrics.jsonl");
3284
+ const metrics = await readJsonlTail(metricsPath, 3000);
3285
+ const summary = summarizeTelemetry(metrics, days);
3286
+ jsonResponse(res, 200, {
3287
+ ok: true,
3288
+ data: summary?.executors || {},
3289
+ });
3290
+ } catch (err) {
3291
+ jsonResponse(res, 500, { ok: false, error: err.message });
3292
+ }
3293
+ return;
3294
+ }
3295
+
3296
+ if (path === "/api/telemetry/alerts") {
3297
+ try {
3298
+ const days = Number(url.searchParams.get("days") || "7");
3299
+ const logDir = resolve(repoRoot, ".cache", "agent-work-logs");
3300
+ const alertsPath = resolve(logDir, "agent-alerts.jsonl");
3301
+ const alerts = (await readJsonlTail(alertsPath, 500)).filter((a) =>
3302
+ withinDays(a, days),
3303
+ );
3304
+ jsonResponse(res, 200, {
3305
+ ok: true,
3306
+ data: alerts.slice(-50),
3307
+ });
3308
+ } catch (err) {
3309
+ jsonResponse(res, 500, { ok: false, error: err.message });
3310
+ }
3311
+ return;
3312
+ }
3313
+
3175
3314
  if (path === "/api/agent-logs/context") {
3176
3315
  try {
3177
3316
  const query = url.searchParams.get("query") || "";
@@ -4808,6 +4947,25 @@ export async function startTelegramUiServer(options = {}) {
4808
4947
  if (uiServerTls) {
4809
4948
  console.log(`[telegram-ui] TLS enabled (self-signed) — Telegram WebApp buttons will use HTTPS`);
4810
4949
  }
4950
+
4951
+ // ── SECURITY: Warn loudly when auth is disabled ──────────────────────
4952
+ if (isAllowUnsafe()) {
4953
+ const tunnelMode = (process.env.TELEGRAM_UI_TUNNEL || "auto").toLowerCase();
4954
+ const tunnelActive = tunnelMode !== "disabled" && tunnelMode !== "off" && tunnelMode !== "0";
4955
+ const border = "═".repeat(68);
4956
+ console.warn(`\n╔${border}╗`);
4957
+ console.warn(`║ ⛔ DANGER: TELEGRAM_UI_ALLOW_UNSAFE=true — ALL AUTH IS DISABLED ║`);
4958
+ console.warn(`║ ║`);
4959
+ console.warn(`║ Anyone with your URL can control agents, read secrets, and ║`);
4960
+ console.warn(`║ execute arbitrary commands on this machine. ║`);
4961
+ if (tunnelActive) {
4962
+ console.warn(`║ ║`);
4963
+ console.warn(`║ 🔴 TUNNEL IS ACTIVE — your UI is exposed to the PUBLIC INTERNET ║`);
4964
+ console.warn(`║ This means ANYONE can discover your URL and take control. ║`);
4965
+ console.warn(`║ Set TELEGRAM_UI_TUNNEL=disabled or TELEGRAM_UI_ALLOW_UNSAFE=false ║`);
4966
+ }
4967
+ console.warn(`╚${border}╝\n`);
4968
+ }
4811
4969
  console.log(`[telegram-ui] LAN access: ${protocol}://${lanIp}:${actualPort}`);
4812
4970
  console.log(`[telegram-ui] Browser access: ${protocol}://${lanIp}:${actualPort}/?token=${sessionToken}`);
4813
4971
 
@@ -412,7 +412,51 @@ export function pullWorkspaceRepos(configDir, workspaceId) {
412
412
  for (const repo of ws.repos || []) {
413
413
  const repoPath = resolve(ws.path, repo.name);
414
414
  if (!existsSync(repoPath)) {
415
- results.push({ name: repo.name, success: false, error: "Directory not found" });
415
+ const repoUrl =
416
+ repo.url ||
417
+ (repo.slug ? `https://github.com/${repo.slug.replace(/\.git$/i, "")}.git` : "");
418
+ if (!repoUrl) {
419
+ results.push({
420
+ name: repo.name,
421
+ success: false,
422
+ error: "Directory not found and repo URL is missing",
423
+ });
424
+ continue;
425
+ }
426
+ try {
427
+ mkdirSync(ws.path, { recursive: true });
428
+ console.log(TAG, `Cloning ${repoUrl} into ${repoPath}...`);
429
+ const clone = spawnSync("git", ["clone", repoUrl, repoPath], {
430
+ encoding: "utf8",
431
+ timeout: 300000,
432
+ stdio: ["pipe", "pipe", "pipe"],
433
+ });
434
+ if (clone.status !== 0) {
435
+ results.push({
436
+ name: repo.name,
437
+ success: false,
438
+ error: `git clone failed: ${
439
+ clone.stderr || clone.stdout || clone.error?.message || "unknown error"
440
+ }`,
441
+ });
442
+ continue;
443
+ }
444
+ console.log(TAG, `Cloned ${repo.name} successfully`);
445
+ } catch (err) {
446
+ results.push({
447
+ name: repo.name,
448
+ success: false,
449
+ error: `git clone failed: ${err.message || err}`,
450
+ });
451
+ continue;
452
+ }
453
+ }
454
+ if (!existsSync(resolve(repoPath, ".git"))) {
455
+ results.push({
456
+ name: repo.name,
457
+ success: false,
458
+ error: "Directory exists but is not a git repository",
459
+ });
416
460
  continue;
417
461
  }
418
462
  try {