bosun 0.36.0 → 0.36.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.
Files changed (98) hide show
  1. package/.env.example +98 -16
  2. package/README.md +27 -0
  3. package/agent-event-bus.mjs +5 -5
  4. package/agent-pool.mjs +129 -12
  5. package/agent-prompts.mjs +7 -1
  6. package/agent-sdk.mjs +13 -2
  7. package/agent-supervisor.mjs +2 -2
  8. package/agent-work-report.mjs +1 -1
  9. package/anomaly-detector.mjs +6 -6
  10. package/autofix.mjs +15 -15
  11. package/bosun-skills.mjs +4 -4
  12. package/bosun.schema.json +160 -4
  13. package/claude-shell.mjs +11 -11
  14. package/cli.mjs +21 -21
  15. package/codex-config.mjs +19 -19
  16. package/codex-shell.mjs +180 -29
  17. package/config-doctor.mjs +27 -2
  18. package/config.mjs +60 -7
  19. package/copilot-shell.mjs +4 -4
  20. package/error-detector.mjs +1 -1
  21. package/fleet-coordinator.mjs +2 -2
  22. package/gemini-shell.mjs +692 -0
  23. package/github-oauth-portal.mjs +1 -1
  24. package/github-reconciler.mjs +2 -2
  25. package/kanban-adapter.mjs +741 -168
  26. package/merge-strategy.mjs +25 -25
  27. package/monitor.mjs +123 -105
  28. package/opencode-shell.mjs +22 -22
  29. package/package.json +7 -1
  30. package/postinstall.mjs +22 -22
  31. package/pr-cleanup-daemon.mjs +6 -6
  32. package/prepublish-check.mjs +4 -4
  33. package/presence.mjs +2 -2
  34. package/primary-agent.mjs +85 -7
  35. package/publish.mjs +1 -1
  36. package/review-agent.mjs +1 -1
  37. package/session-tracker.mjs +11 -0
  38. package/setup-web-server.mjs +429 -21
  39. package/setup.mjs +367 -12
  40. package/shared-knowledge.mjs +1 -1
  41. package/startup-service.mjs +9 -9
  42. package/stream-resilience.mjs +58 -4
  43. package/sync-engine.mjs +2 -2
  44. package/task-assessment.mjs +9 -9
  45. package/task-cli.mjs +1 -1
  46. package/task-complexity.mjs +71 -2
  47. package/task-context.mjs +1 -2
  48. package/task-executor.mjs +104 -41
  49. package/telegram-bot.mjs +825 -494
  50. package/telegram-sentinel.mjs +28 -28
  51. package/ui/app.js +256 -23
  52. package/ui/app.monolith.js +1 -1
  53. package/ui/components/agent-selector.js +4 -3
  54. package/ui/components/chat-view.js +101 -28
  55. package/ui/components/diff-viewer.js +3 -3
  56. package/ui/components/kanban-board.js +3 -3
  57. package/ui/components/session-list.js +255 -35
  58. package/ui/components/workspace-switcher.js +3 -3
  59. package/ui/demo.html +209 -194
  60. package/ui/index.html +3 -3
  61. package/ui/modules/icon-utils.js +206 -142
  62. package/ui/modules/icons.js +2 -27
  63. package/ui/modules/settings-schema.js +29 -5
  64. package/ui/modules/streaming.js +30 -2
  65. package/ui/modules/vision-stream.js +275 -0
  66. package/ui/modules/voice-client.js +102 -9
  67. package/ui/modules/voice-fallback.js +62 -6
  68. package/ui/modules/voice-overlay.js +594 -59
  69. package/ui/modules/voice.js +31 -38
  70. package/ui/setup.html +284 -34
  71. package/ui/styles/components.css +47 -0
  72. package/ui/styles/sessions.css +75 -0
  73. package/ui/tabs/agents.js +73 -43
  74. package/ui/tabs/chat.js +37 -40
  75. package/ui/tabs/control.js +2 -2
  76. package/ui/tabs/dashboard.js +1 -1
  77. package/ui/tabs/infra.js +10 -10
  78. package/ui/tabs/library.js +8 -8
  79. package/ui/tabs/logs.js +10 -10
  80. package/ui/tabs/settings.js +20 -20
  81. package/ui/tabs/tasks.js +76 -47
  82. package/ui-server.mjs +1761 -124
  83. package/update-check.mjs +13 -13
  84. package/ve-kanban.mjs +1 -1
  85. package/whatsapp-channel.mjs +5 -5
  86. package/workflow-engine.mjs +20 -1
  87. package/workflow-nodes.mjs +904 -4
  88. package/workflow-templates/agents.mjs +321 -7
  89. package/workflow-templates/ci-cd.mjs +6 -6
  90. package/workflow-templates/github.mjs +156 -84
  91. package/workflow-templates/planning.mjs +8 -8
  92. package/workflow-templates/reliability.mjs +8 -8
  93. package/workflow-templates/security.mjs +3 -3
  94. package/workflow-templates.mjs +15 -9
  95. package/workspace-manager.mjs +85 -1
  96. package/workspace-monitor.mjs +2 -2
  97. package/workspace-registry.mjs +2 -2
  98. package/worktree-manager.mjs +1 -1
package/ui-server.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  import { execSync, spawn, spawnSync } from "node:child_process";
2
- import { createHmac, randomBytes, timingSafeEqual, X509Certificate } from "node:crypto";
2
+ import { argon2, createHash, createHmac, randomBytes, timingSafeEqual, X509Certificate } from "node:crypto";
3
3
  import { existsSync, mkdirSync, readFileSync, chmodSync, createWriteStream, createReadStream, writeFileSync, unlinkSync, watchFile, unwatchFile } from "node:fs";
4
4
  import { open, readFile, readdir, stat, writeFile } from "node:fs/promises";
5
5
  import { createServer } from "node:http";
6
6
  import { get as httpsGet } from "node:https";
7
7
  import { createServer as createHttpsServer } from "node:https";
8
- import { networkInterfaces, homedir } from "node:os";
8
+ import { networkInterfaces, homedir, userInfo as getOsUserInfo } from "node:os";
9
9
  import { connect as netConnect } from "node:net";
10
10
  import { resolve, extname, dirname, basename, relative } from "node:path";
11
11
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -130,10 +130,73 @@ import {
130
130
 
131
131
  const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
132
132
  const repoRoot = resolveRepoRoot();
133
- const uiRootPreferred = resolve(__dirname, "site", "ui");
134
- const uiRootFallback = resolve(__dirname, "ui");
133
+ const uiRootPreferred = resolve(__dirname, "ui");
134
+ const uiRootFallback = resolve(__dirname, "site", "ui");
135
135
  const uiRoot = existsSync(uiRootPreferred) ? uiRootPreferred : uiRootFallback;
136
136
  let libraryInitAttempted = false;
137
+ const MAX_VISION_FRAME_BYTES = Math.max(
138
+ 128_000,
139
+ Number.parseInt(process.env.VISION_FRAME_MAX_BYTES || "", 10) || 2_000_000,
140
+ );
141
+ const DEFAULT_VISION_ANALYSIS_INTERVAL_MS = Math.min(
142
+ 30_000,
143
+ Math.max(
144
+ 500,
145
+ Number.parseInt(process.env.VISION_ANALYSIS_INTERVAL_MS || "", 10) || 1500,
146
+ ),
147
+ );
148
+ const _visionSessionState = new Map();
149
+
150
+ function getVisionSessionState(sessionId) {
151
+ const key = String(sessionId || "").trim();
152
+ if (!key) return null;
153
+ if (!_visionSessionState.has(key)) {
154
+ _visionSessionState.set(key, {
155
+ lastFrameHash: null,
156
+ lastReceiptAt: 0,
157
+ lastAnalyzedHash: null,
158
+ lastAnalyzedAt: 0,
159
+ lastSummary: "",
160
+ inFlight: null,
161
+ });
162
+ }
163
+ return _visionSessionState.get(key);
164
+ }
165
+
166
+ function sanitizeVisionSource(value) {
167
+ const raw = String(value || "").trim().toLowerCase();
168
+ if (raw === "camera") return "camera";
169
+ if (raw === "screen" || raw === "display") return "screen";
170
+ return "screen";
171
+ }
172
+
173
+ function normalizeVisionInterval(rawValue) {
174
+ const parsed = Number.parseInt(String(rawValue || ""), 10);
175
+ if (!Number.isFinite(parsed)) return DEFAULT_VISION_ANALYSIS_INTERVAL_MS;
176
+ return Math.min(30_000, Math.max(300, parsed));
177
+ }
178
+
179
+ function parseVisionFrameDataUrl(dataUrl) {
180
+ const raw = String(dataUrl || "").trim();
181
+ const match = raw.match(/^data:(image\/(?:jpeg|jpg|png|webp));base64,([A-Za-z0-9+/=]+)$/i);
182
+ if (!match) {
183
+ return { ok: false, error: "frameDataUrl must be a base64 image data URL (jpeg/png/webp)" };
184
+ }
185
+ const mimeType = String(match[1] || "").toLowerCase();
186
+ const base64Data = String(match[2] || "");
187
+ const approxBytes = Math.floor((base64Data.length * 3) / 4);
188
+ if (approxBytes <= 0) {
189
+ return { ok: false, error: "frameDataUrl was empty" };
190
+ }
191
+ if (approxBytes > MAX_VISION_FRAME_BYTES) {
192
+ return {
193
+ ok: false,
194
+ statusCode: 413,
195
+ error: `frameDataUrl too large (${approxBytes} bytes > ${MAX_VISION_FRAME_BYTES} bytes limit)`,
196
+ };
197
+ }
198
+ return { ok: true, mimeType, base64Data, approxBytes, raw };
199
+ }
137
200
 
138
201
  function ensureLibraryInitialized() {
139
202
  if (libraryInitAttempted) return;
@@ -162,6 +225,12 @@ let _wfRecommendedInstalled = false;
162
225
  let _wfInitPromise = null;
163
226
  let _wfInitDone = false;
164
227
  let _wfLoadedBase = null;
228
+ let workflowEventDedupWindowMs = (() => {
229
+ const parsed = Number.parseInt(process.env.WORKFLOW_EVENT_DEDUP_WINDOW_MS || "15000", 10);
230
+ if (!Number.isFinite(parsed) || parsed <= 0) return 15_000;
231
+ return Math.min(300_000, Math.max(250, parsed));
232
+ })();
233
+ const workflowEventDedup = new Map();
165
234
 
166
235
  function parseBooleanEnv(rawValue, fallback = false) {
167
236
  if (rawValue === undefined || rawValue === null || String(rawValue).trim() === "") {
@@ -347,10 +416,19 @@ async function getWorkflowEngineModule() {
347
416
  console.warn("[workflows] prompt resolver failed:", err.message);
348
417
  }
349
418
 
419
+ let meetingService = null;
420
+ try {
421
+ const { createMeetingWorkflowService } = await import("./meeting-workflow-service.mjs");
422
+ meetingService = createMeetingWorkflowService();
423
+ } catch (err) {
424
+ console.warn("[workflows] meeting service unavailable:", err.message);
425
+ }
426
+
350
427
  const services = {
351
428
  telegram: telegramService,
352
429
  agentPool: agentPoolService,
353
430
  kanban: kanbanService,
431
+ meeting: meetingService,
354
432
  prompts: promptBundle?.prompts || null,
355
433
  };
356
434
  _wfEngine.getWorkflowEngine({ services });
@@ -445,6 +523,113 @@ async function getWorkflowEngineModule() {
445
523
  return _wfEngine;
446
524
  }
447
525
 
526
+ function allowWorkflowEvent(dedupKey, windowMs = workflowEventDedupWindowMs) {
527
+ if (!dedupKey) return true;
528
+ const now = Date.now();
529
+ const lastSeen = workflowEventDedup.get(dedupKey) || 0;
530
+ if (now - lastSeen < windowMs) {
531
+ return false;
532
+ }
533
+ workflowEventDedup.set(dedupKey, now);
534
+ if (workflowEventDedup.size > 1000) {
535
+ const cutoff = now - windowMs * 2;
536
+ for (const [key, ts] of workflowEventDedup.entries()) {
537
+ if (ts < cutoff) workflowEventDedup.delete(key);
538
+ }
539
+ }
540
+ return true;
541
+ }
542
+
543
+ function buildWorkflowEventPayload(eventType, eventData = {}, triggerSource = "ui-event") {
544
+ const payload = eventData && typeof eventData === "object"
545
+ ? { ...eventData }
546
+ : {};
547
+ payload.eventType = eventType;
548
+ if (String(eventType || "").startsWith("pr.")) {
549
+ const prEvent = String(eventType).slice(3).trim();
550
+ if (prEvent) payload.prEvent = prEvent;
551
+ }
552
+ payload._triggerSource = triggerSource;
553
+ payload._triggerEventType = eventType;
554
+ payload._triggeredAt = new Date().toISOString();
555
+ return payload;
556
+ }
557
+
558
+ async function dispatchWorkflowEvent(eventType, eventData = {}, opts = {}) {
559
+ try {
560
+ if (!parseBooleanEnv(process.env.WORKFLOW_AUTOMATION_ENABLED, true)) {
561
+ return false;
562
+ }
563
+
564
+ const dedupKey = String(opts?.dedupKey || "").trim();
565
+ if (dedupKey && !allowWorkflowEvent(dedupKey)) {
566
+ return false;
567
+ }
568
+
569
+ const wfMod = await getWorkflowEngineModule();
570
+ if (!wfMod?.getWorkflowEngine) return false;
571
+
572
+ const engine = wfMod.getWorkflowEngine();
573
+ if (!engine?.evaluateTriggers || !engine?.execute) return false;
574
+
575
+ const payload = buildWorkflowEventPayload(eventType, eventData, "ui-server");
576
+ let triggered = [];
577
+ try {
578
+ triggered = await engine.evaluateTriggers(eventType, payload);
579
+ } catch (err) {
580
+ console.warn(
581
+ `[workflows] trigger evaluation failed for ${eventType}: ${err?.message || err}`,
582
+ );
583
+ return false;
584
+ }
585
+
586
+ if (!Array.isArray(triggered) || triggered.length === 0) {
587
+ return false;
588
+ }
589
+
590
+ for (const match of triggered) {
591
+ const workflowId = String(match?.workflowId || "").trim();
592
+ if (!workflowId) continue;
593
+
594
+ const runPayload = {
595
+ ...payload,
596
+ _triggeredBy: match?.triggeredBy || null,
597
+ };
598
+
599
+ Promise.resolve()
600
+ .then(() => engine.execute(workflowId, runPayload))
601
+ .then((ctx) => {
602
+ const runStatus =
603
+ Array.isArray(ctx?.errors) && ctx.errors.length > 0
604
+ ? "failed"
605
+ : "completed";
606
+ console.log(
607
+ `[workflows] auto-run ${runStatus} workflow=${workflowId} runId=${ctx?.id || "unknown"} event=${eventType}`,
608
+ );
609
+ })
610
+ .catch((err) => {
611
+ console.warn(
612
+ `[workflows] auto-run failed workflow=${workflowId} event=${eventType}: ${err?.message || err}`,
613
+ );
614
+ });
615
+ }
616
+
617
+ console.log(
618
+ `[workflows] event "${eventType}" triggered ${triggered.length} workflow run(s)`,
619
+ );
620
+ return true;
621
+ } catch (err) {
622
+ console.warn(`[workflows] dispatchWorkflowEvent error for ${eventType}: ${err?.message || err}`);
623
+ return false;
624
+ }
625
+ }
626
+
627
+ function queueWorkflowEvent(eventType, eventData = {}, opts = {}) {
628
+ dispatchWorkflowEvent(eventType, eventData, opts).catch((err) => {
629
+ console.warn(`[workflows] queueWorkflowEvent failure for ${eventType}: ${err?.message || err}`);
630
+ });
631
+ }
632
+
448
633
  // ── Vendor module map ─────────────────────────────────────────────────────────
449
634
  // Served at /vendor/<name>.js — no auth required (public browser libraries).
450
635
  //
@@ -1159,6 +1344,16 @@ const INTERNAL_EXECUTOR_MAP = {
1159
1344
  POLL_MS: ["internalExecutor", "pollIntervalMs"],
1160
1345
  REVIEW_AGENT_ENABLED: ["internalExecutor", "reviewAgentEnabled"],
1161
1346
  REPLENISH_ENABLED: ["internalExecutor", "backlogReplenishment", "enabled"],
1347
+ STREAM_MAX_RETRIES: ["internalExecutor", "stream", "maxRetries"],
1348
+ STREAM_RETRY_BASE_MS: ["internalExecutor", "stream", "retryBaseMs"],
1349
+ STREAM_RETRY_MAX_MS: ["internalExecutor", "stream", "retryMaxMs"],
1350
+ STREAM_FIRST_EVENT_TIMEOUT_MS: [
1351
+ "internalExecutor",
1352
+ "stream",
1353
+ "firstEventTimeoutMs",
1354
+ ],
1355
+ STREAM_MAX_ITEMS_PER_TURN: ["internalExecutor", "stream", "maxItemsPerTurn"],
1356
+ STREAM_MAX_ITEM_CHARS: ["internalExecutor", "stream", "maxItemChars"],
1162
1357
  };
1163
1358
  const CONFIG_PATH_OVERRIDES = {
1164
1359
  EXECUTOR_MODE: ["internalExecutor", "mode"],
@@ -1757,6 +1952,314 @@ function persistLastUiPort(port) {
1757
1952
  }
1758
1953
  }
1759
1954
 
1955
+ const FALLBACK_AUTH_STATE_FILE = "ui-fallback-auth.json";
1956
+ const FALLBACK_AUTH_ALGORITHM = "argon2id";
1957
+ const FALLBACK_AUTH_SALT_BYTES = 16;
1958
+ const FALLBACK_AUTH_TAG_LENGTH = 32;
1959
+ const FALLBACK_AUTH_MEMORY_KIB = 64 * 1024;
1960
+ const FALLBACK_AUTH_PASSES = 3;
1961
+ const FALLBACK_AUTH_PARALLELISM = 1;
1962
+ const FALLBACK_AUTH_MIN_PASSWORD_LENGTH = 10;
1963
+ const FALLBACK_AUTH_MIN_PIN_LENGTH = 6;
1964
+
1965
+ let fallbackAuthRecordCache = null;
1966
+ const fallbackAuthRateLimitByIp = new Map();
1967
+ let fallbackAuthGlobalWindow = { windowStart: 0, count: 0 };
1968
+ const fallbackAuthRuntime = {
1969
+ failedAttempts: 0,
1970
+ lockoutUntil: 0,
1971
+ transientCooldownUntil: 0,
1972
+ lastFailureAt: 0,
1973
+ lastSuccessAt: 0,
1974
+ };
1975
+
1976
+ function getFallbackAuthConfig() {
1977
+ const enabled = parseBooleanEnv(
1978
+ process.env.TELEGRAM_UI_FALLBACK_AUTH_ENABLED,
1979
+ true,
1980
+ );
1981
+ const perIpPerMin = Math.max(
1982
+ 1,
1983
+ Number(process.env.TELEGRAM_UI_FALLBACK_AUTH_RATE_LIMIT_IP_PER_MIN || "10"),
1984
+ );
1985
+ const globalPerMin = Math.max(
1986
+ 1,
1987
+ Number(process.env.TELEGRAM_UI_FALLBACK_AUTH_RATE_LIMIT_GLOBAL_PER_MIN || "60"),
1988
+ );
1989
+ const maxFailures = Math.max(
1990
+ 1,
1991
+ Number(process.env.TELEGRAM_UI_FALLBACK_AUTH_MAX_FAILURES || "5"),
1992
+ );
1993
+ const lockoutMs = Math.max(
1994
+ 10_000,
1995
+ Number(process.env.TELEGRAM_UI_FALLBACK_AUTH_LOCKOUT_MS || "600000"),
1996
+ );
1997
+ const transientCooldownMs = Math.max(
1998
+ 1000,
1999
+ Number(process.env.TELEGRAM_UI_FALLBACK_AUTH_TRANSIENT_COOLDOWN_MS || "5000"),
2000
+ );
2001
+ const rotateDays = Math.max(
2002
+ 1,
2003
+ Number(process.env.TELEGRAM_UI_FALLBACK_AUTH_ROTATE_DAYS || "30"),
2004
+ );
2005
+ return {
2006
+ enabled,
2007
+ perIpPerMin,
2008
+ globalPerMin,
2009
+ maxFailures,
2010
+ lockoutMs,
2011
+ transientCooldownMs,
2012
+ rotateDays,
2013
+ };
2014
+ }
2015
+
2016
+ function getFallbackAuthStatePath() {
2017
+ return resolveUiCachePath(FALLBACK_AUTH_STATE_FILE);
2018
+ }
2019
+
2020
+ function readFallbackAuthRecord() {
2021
+ if (fallbackAuthRecordCache && typeof fallbackAuthRecordCache === "object") {
2022
+ return fallbackAuthRecordCache;
2023
+ }
2024
+ try {
2025
+ const statePath = getFallbackAuthStatePath();
2026
+ if (!existsSync(statePath)) return null;
2027
+ const parsed = JSON.parse(readFileSync(statePath, "utf8"));
2028
+ if (!parsed || typeof parsed !== "object") return null;
2029
+ if (
2030
+ typeof parsed.hash !== "string"
2031
+ || typeof parsed.salt !== "string"
2032
+ || parsed.algorithm !== FALLBACK_AUTH_ALGORITHM
2033
+ ) {
2034
+ return null;
2035
+ }
2036
+ fallbackAuthRecordCache = parsed;
2037
+ return parsed;
2038
+ } catch {
2039
+ return null;
2040
+ }
2041
+ }
2042
+
2043
+ function writeFallbackAuthRecord(record) {
2044
+ fallbackAuthRecordCache = record;
2045
+ try {
2046
+ const statePath = getFallbackAuthStatePath();
2047
+ writeFileSync(statePath, JSON.stringify(record, null, 2), "utf8");
2048
+ } catch {
2049
+ // best effort
2050
+ }
2051
+ }
2052
+
2053
+ function clearFallbackAuthRecord() {
2054
+ fallbackAuthRecordCache = null;
2055
+ try {
2056
+ const statePath = getFallbackAuthStatePath();
2057
+ if (existsSync(statePath)) unlinkSync(statePath);
2058
+ } catch {
2059
+ // best effort
2060
+ }
2061
+ }
2062
+
2063
+ function isFallbackSecretStrong(secret) {
2064
+ const normalized = String(secret || "").trim();
2065
+ if (!normalized) return false;
2066
+ if (/^\d+$/.test(normalized)) {
2067
+ return normalized.length >= FALLBACK_AUTH_MIN_PIN_LENGTH;
2068
+ }
2069
+ return normalized.length >= FALLBACK_AUTH_MIN_PASSWORD_LENGTH;
2070
+ }
2071
+
2072
+ async function deriveFallbackAuthHash(secret, saltBuffer) {
2073
+ return new Promise((resolveHash, rejectHash) => {
2074
+ argon2(
2075
+ FALLBACK_AUTH_ALGORITHM,
2076
+ {
2077
+ message: Buffer.from(String(secret || ""), "utf8"),
2078
+ nonce: saltBuffer,
2079
+ parallelism: FALLBACK_AUTH_PARALLELISM,
2080
+ tagLength: FALLBACK_AUTH_TAG_LENGTH,
2081
+ memory: FALLBACK_AUTH_MEMORY_KIB,
2082
+ passes: FALLBACK_AUTH_PASSES,
2083
+ },
2084
+ (err, hash) => {
2085
+ if (err) return rejectHash(err);
2086
+ resolveHash(hash);
2087
+ },
2088
+ );
2089
+ });
2090
+ }
2091
+
2092
+ async function setFallbackAuthSecret(secret, { actor = "api" } = {}) {
2093
+ if (!isFallbackSecretStrong(secret)) {
2094
+ throw new Error(
2095
+ `Fallback secret must be >= ${FALLBACK_AUTH_MIN_PIN_LENGTH} digits (PIN) or >= ${FALLBACK_AUTH_MIN_PASSWORD_LENGTH} chars (password)`,
2096
+ );
2097
+ }
2098
+ const salt = randomBytes(FALLBACK_AUTH_SALT_BYTES);
2099
+ const hash = await deriveFallbackAuthHash(secret, salt);
2100
+ const existing = readFallbackAuthRecord();
2101
+ const now = Date.now();
2102
+ writeFallbackAuthRecord({
2103
+ version: 1,
2104
+ algorithm: FALLBACK_AUTH_ALGORITHM,
2105
+ hash: hash.toString("base64"),
2106
+ salt: salt.toString("base64"),
2107
+ tagLength: FALLBACK_AUTH_TAG_LENGTH,
2108
+ memoryKiB: FALLBACK_AUTH_MEMORY_KIB,
2109
+ passes: FALLBACK_AUTH_PASSES,
2110
+ parallelism: FALLBACK_AUTH_PARALLELISM,
2111
+ createdAt: Number(existing?.createdAt || now),
2112
+ updatedAt: now,
2113
+ rotatedAt: now,
2114
+ updatedBy: String(actor || "api"),
2115
+ });
2116
+ fallbackAuthRuntime.failedAttempts = 0;
2117
+ fallbackAuthRuntime.lockoutUntil = 0;
2118
+ fallbackAuthRuntime.transientCooldownUntil = 0;
2119
+ }
2120
+
2121
+ function resetFallbackAuthSecret() {
2122
+ clearFallbackAuthRecord();
2123
+ fallbackAuthRuntime.failedAttempts = 0;
2124
+ fallbackAuthRuntime.lockoutUntil = 0;
2125
+ fallbackAuthRuntime.transientCooldownUntil = 0;
2126
+ }
2127
+
2128
+ function getRequestIp(req) {
2129
+ return String(
2130
+ req?.headers?.["x-forwarded-for"]
2131
+ || req?.socket?.remoteAddress
2132
+ || "unknown",
2133
+ ).split(",")[0].trim().toLowerCase();
2134
+ }
2135
+
2136
+ function consumeFallbackRateLimits(req, config) {
2137
+ const now = Date.now();
2138
+ const windowMs = 60_000;
2139
+ const ip = getRequestIp(req);
2140
+
2141
+ const globalBucket = fallbackAuthGlobalWindow;
2142
+ if (!globalBucket.windowStart || now - globalBucket.windowStart > windowMs) {
2143
+ fallbackAuthGlobalWindow = { windowStart: now, count: 0 };
2144
+ }
2145
+ fallbackAuthGlobalWindow.count += 1;
2146
+ if (fallbackAuthGlobalWindow.count > config.globalPerMin) {
2147
+ return { ok: false, reason: "global_rate_limited" };
2148
+ }
2149
+
2150
+ let bucket = fallbackAuthRateLimitByIp.get(ip);
2151
+ if (!bucket || now - bucket.windowStart > windowMs) {
2152
+ bucket = { windowStart: now, count: 0 };
2153
+ fallbackAuthRateLimitByIp.set(ip, bucket);
2154
+ }
2155
+ bucket.count += 1;
2156
+ if (bucket.count > config.perIpPerMin) {
2157
+ return { ok: false, reason: "ip_rate_limited" };
2158
+ }
2159
+ return { ok: true };
2160
+ }
2161
+
2162
+ async function verifyFallbackAuthSecret(secret) {
2163
+ const record = readFallbackAuthRecord();
2164
+ if (!record?.hash || !record?.salt) return false;
2165
+ const storedHash = Buffer.from(String(record.hash || ""), "base64");
2166
+ const salt = Buffer.from(String(record.salt || ""), "base64");
2167
+ const derived = await deriveFallbackAuthHash(secret, salt);
2168
+ if (derived.length !== storedHash.length) return false;
2169
+ return timingSafeEqual(derived, storedHash);
2170
+ }
2171
+
2172
+ async function attemptFallbackAuth(req, secret) {
2173
+ const cfg = getFallbackAuthConfig();
2174
+ const now = Date.now();
2175
+ if (!cfg.enabled) return { ok: false, reason: "disabled" };
2176
+ if (isAllowUnsafe()) return { ok: false, reason: "unsafe_mode_enabled" };
2177
+ if (!readFallbackAuthRecord()) return { ok: false, reason: "missing_credential" };
2178
+
2179
+ if (fallbackAuthRuntime.transientCooldownUntil > now) {
2180
+ return { ok: false, reason: "transient_cooldown" };
2181
+ }
2182
+ if (fallbackAuthRuntime.lockoutUntil > now) {
2183
+ return { ok: false, reason: "locked" };
2184
+ }
2185
+ const rateCheck = consumeFallbackRateLimits(req, cfg);
2186
+ if (!rateCheck.ok) return { ok: false, reason: rateCheck.reason };
2187
+
2188
+ try {
2189
+ const valid = await verifyFallbackAuthSecret(secret);
2190
+ if (valid) {
2191
+ fallbackAuthRuntime.failedAttempts = 0;
2192
+ fallbackAuthRuntime.lockoutUntil = 0;
2193
+ fallbackAuthRuntime.lastSuccessAt = now;
2194
+ ensureSessionToken();
2195
+ return { ok: true, reason: "success" };
2196
+ }
2197
+ } catch {
2198
+ fallbackAuthRuntime.transientCooldownUntil = now + cfg.transientCooldownMs;
2199
+ return { ok: false, reason: "transient_verify_error" };
2200
+ }
2201
+
2202
+ fallbackAuthRuntime.failedAttempts += 1;
2203
+ fallbackAuthRuntime.lastFailureAt = now;
2204
+ if (fallbackAuthRuntime.failedAttempts >= cfg.maxFailures) {
2205
+ fallbackAuthRuntime.lockoutUntil = now + cfg.lockoutMs;
2206
+ fallbackAuthRuntime.failedAttempts = 0;
2207
+ }
2208
+ return { ok: false, reason: "invalid_secret" };
2209
+ }
2210
+
2211
+ function getFallbackAuthStatus() {
2212
+ const cfg = getFallbackAuthConfig();
2213
+ const record = readFallbackAuthRecord();
2214
+ const now = Date.now();
2215
+ const rotationDueAt = record?.updatedAt
2216
+ ? Number(record.updatedAt) + cfg.rotateDays * 24 * 60 * 60 * 1000
2217
+ : 0;
2218
+ const rotationDue = rotationDueAt > 0 && now >= rotationDueAt;
2219
+ const locked = fallbackAuthRuntime.lockoutUntil > now;
2220
+ const remediation = [];
2221
+ if (!record) remediation.push("missing_credential");
2222
+ if (!cfg.enabled) remediation.push("disabled_by_env");
2223
+ if (isAllowUnsafe()) remediation.push("unsafe_mode_enabled");
2224
+ if (locked) remediation.push("locked_temporarily");
2225
+ if (rotationDue) remediation.push("rotation_due");
2226
+ return {
2227
+ configured: Boolean(record?.hash),
2228
+ enabled: cfg.enabled,
2229
+ active: cfg.enabled && Boolean(record?.hash) && !isAllowUnsafe(),
2230
+ locked,
2231
+ lockoutUntil: locked ? fallbackAuthRuntime.lockoutUntil : 0,
2232
+ transientCooldownUntil:
2233
+ fallbackAuthRuntime.transientCooldownUntil > now
2234
+ ? fallbackAuthRuntime.transientCooldownUntil
2235
+ : 0,
2236
+ rotationDue,
2237
+ rotationDueAt,
2238
+ rotateDays: cfg.rotateDays,
2239
+ updatedAt: Number(record?.updatedAt || 0) || 0,
2240
+ updatedBy: String(record?.updatedBy || ""),
2241
+ rateLimits: {
2242
+ perIpPerMin: cfg.perIpPerMin,
2243
+ globalPerMin: cfg.globalPerMin,
2244
+ maxFailures: cfg.maxFailures,
2245
+ lockoutMs: cfg.lockoutMs,
2246
+ },
2247
+ remediation,
2248
+ };
2249
+ }
2250
+
2251
+ setInterval(() => {
2252
+ const now = Date.now();
2253
+ for (const [ip, bucket] of fallbackAuthRateLimitByIp) {
2254
+ if (now - bucket.windowStart > 120_000) {
2255
+ fallbackAuthRateLimitByIp.delete(ip);
2256
+ }
2257
+ }
2258
+ if (now - fallbackAuthGlobalWindow.windowStart > 120_000) {
2259
+ fallbackAuthGlobalWindow = { windowStart: 0, count: 0 };
2260
+ }
2261
+ }, 300_000).unref();
2262
+
1760
2263
  const projectSyncWebhookMetrics = {
1761
2264
  received: 0,
1762
2265
  processed: 0,
@@ -1785,10 +2288,17 @@ const SETTINGS_KNOWN_KEYS = [
1785
2288
  "TELEGRAM_HISTORY_RETENTION_DAYS",
1786
2289
  "PROJECT_NAME", "TELEGRAM_MINIAPP_ENABLED", "TELEGRAM_UI_PORT", "TELEGRAM_UI_HOST",
1787
2290
  "TELEGRAM_UI_PUBLIC_HOST", "TELEGRAM_UI_BASE_URL", "TELEGRAM_UI_ALLOW_UNSAFE",
1788
- "TELEGRAM_UI_AUTH_MAX_AGE_SEC", "TELEGRAM_UI_TUNNEL",
2291
+ "TELEGRAM_UI_AUTH_MAX_AGE_SEC", "TELEGRAM_UI_TUNNEL", "TELEGRAM_UI_ALLOW_QUICK_TUNNEL_FALLBACK",
2292
+ "TELEGRAM_UI_FALLBACK_AUTH_ENABLED", "TELEGRAM_UI_FALLBACK_AUTH_RATE_LIMIT_IP_PER_MIN",
2293
+ "TELEGRAM_UI_FALLBACK_AUTH_RATE_LIMIT_GLOBAL_PER_MIN", "TELEGRAM_UI_FALLBACK_AUTH_MAX_FAILURES",
2294
+ "TELEGRAM_UI_FALLBACK_AUTH_LOCKOUT_MS", "TELEGRAM_UI_FALLBACK_AUTH_ROTATE_DAYS",
2295
+ "TELEGRAM_UI_FALLBACK_AUTH_TRANSIENT_COOLDOWN_MS",
1789
2296
  "EXECUTOR_MODE", "INTERNAL_EXECUTOR_PARALLEL", "INTERNAL_EXECUTOR_SDK",
1790
2297
  "INTERNAL_EXECUTOR_TIMEOUT_MS", "INTERNAL_EXECUTOR_MAX_RETRIES", "INTERNAL_EXECUTOR_POLL_MS",
1791
2298
  "INTERNAL_EXECUTOR_REVIEW_AGENT_ENABLED", "INTERNAL_EXECUTOR_REPLENISH_ENABLED",
2299
+ "INTERNAL_EXECUTOR_STREAM_MAX_RETRIES", "INTERNAL_EXECUTOR_STREAM_RETRY_BASE_MS",
2300
+ "INTERNAL_EXECUTOR_STREAM_RETRY_MAX_MS", "INTERNAL_EXECUTOR_STREAM_FIRST_EVENT_TIMEOUT_MS",
2301
+ "INTERNAL_EXECUTOR_STREAM_MAX_ITEMS_PER_TURN", "INTERNAL_EXECUTOR_STREAM_MAX_ITEM_CHARS",
1792
2302
  "CODEX_SDK_DISABLED", "COPILOT_SDK_DISABLED", "CLAUDE_SDK_DISABLED",
1793
2303
  "PRIMARY_AGENT", "EXECUTORS", "EXECUTOR_DISTRIBUTION", "FAILOVER_STRATEGY",
1794
2304
  "COMPLEXITY_ROUTING_ENABLED", "PROJECT_REQUIREMENTS_PROFILE",
@@ -1812,7 +2322,10 @@ const SETTINGS_KNOWN_KEYS = [
1812
2322
  "GITHUB_PROJECT_SYNC_ALERT_FAILURE_THRESHOLD", "GITHUB_PROJECT_SYNC_RATE_LIMIT_ALERT_THRESHOLD",
1813
2323
  "VK_TARGET_BRANCH", "CODEX_ANALYZE_MERGE_STRATEGY", "DEPENDABOT_AUTO_MERGE",
1814
2324
  "GH_RECONCILE_ENABLED",
1815
- "CLOUDFLARE_TUNNEL_NAME", "CLOUDFLARE_TUNNEL_CREDENTIALS",
2325
+ "CLOUDFLARE_TUNNEL_NAME", "CLOUDFLARE_TUNNEL_CREDENTIALS", "CLOUDFLARE_BASE_DOMAIN",
2326
+ "CLOUDFLARE_TUNNEL_HOSTNAME", "CLOUDFLARE_USERNAME_HOSTNAME_POLICY",
2327
+ "CLOUDFLARE_ZONE_ID", "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_DNS_SYNC_ENABLED",
2328
+ "CLOUDFLARE_DNS_MAX_RETRIES", "CLOUDFLARE_DNS_RETRY_BASE_MS",
1816
2329
  "TELEGRAM_PRESENCE_INTERVAL_SEC", "TELEGRAM_PRESENCE_DISABLED",
1817
2330
  "VE_INSTANCE_LABEL", "VE_COORDINATOR_ELIGIBLE", "VE_COORDINATOR_PRIORITY",
1818
2331
  "FLEET_ENABLED", "FLEET_BUFFER_MULTIPLIER", "FLEET_SYNC_INTERVAL_MS",
@@ -1843,7 +2356,7 @@ const SETTINGS_SENSITIVE_KEYS = new Set([
1843
2356
  "OPENAI_API_KEY", "AZURE_OPENAI_API_KEY", "CODEX_MODEL_PROFILE_XL_API_KEY", "CODEX_MODEL_PROFILE_M_API_KEY",
1844
2357
  "ANTHROPIC_API_KEY", "COPILOT_CLI_TOKEN", "GITHUB_PROJECT_WEBHOOK_SECRET",
1845
2358
  "BOSUN_GITHUB_CLIENT_SECRET", "BOSUN_GITHUB_WEBHOOK_SECRET", "BOSUN_GITHUB_USER_TOKEN",
1846
- "CLOUDFLARE_TUNNEL_CREDENTIALS",
2359
+ "CLOUDFLARE_TUNNEL_CREDENTIALS", "CLOUDFLARE_API_TOKEN",
1847
2360
  ]);
1848
2361
 
1849
2362
  const SETTINGS_KNOWN_SET = new Set(SETTINGS_KNOWN_KEYS);
@@ -2403,29 +2916,467 @@ export async function openFirewallPort(port) {
2403
2916
 
2404
2917
  // ── Cloudflared tunnel for trusted TLS ──────────────────────────────
2405
2918
 
2919
+ const TUNNEL_MODE_NAMED = "named";
2920
+ const TUNNEL_MODE_QUICK = "quick";
2921
+ const TUNNEL_MODE_DISABLED = "disabled";
2922
+ const DEFAULT_TUNNEL_MODE = TUNNEL_MODE_NAMED;
2923
+ const DEFAULT_CLOUDFLARE_DNS_RETRY_MAX = 3;
2924
+ const DEFAULT_CLOUDFLARE_DNS_RETRY_BASE_MS = 750;
2925
+ const RESERVED_HOSTNAME_LABELS = new Set([
2926
+ "www",
2927
+ "api",
2928
+ "admin",
2929
+ "root",
2930
+ "mail",
2931
+ "ftp",
2932
+ "smtp",
2933
+ "autodiscover",
2934
+ "localhost",
2935
+ ]);
2936
+
2406
2937
  let tunnelUrl = null;
2407
2938
  let tunnelProcess = null;
2939
+ let tunnelPublicHostname = "";
2940
+ let tunnelRuntimeState = {
2941
+ mode: TUNNEL_MODE_DISABLED,
2942
+ tunnelName: "",
2943
+ tunnelId: "",
2944
+ hostname: "",
2945
+ dnsAction: "none",
2946
+ dnsStatus: "not_configured",
2947
+ fallbackToQuick: false,
2948
+ lastError: "",
2949
+ };
2950
+ let quickTunnelRestartTimer = null;
2951
+ let quickTunnelRestartAttempts = 0;
2952
+ let quickTunnelRestartSuppressed = false;
2408
2953
 
2409
- /** Return the tunnel URL (e.g. https://xxx.trycloudflare.com) or null. */
2954
+ /** Return the active tunnel URL (named or quick) or null. */
2410
2955
  export function getTunnelUrl() {
2411
2956
  return tunnelUrl;
2412
2957
  }
2413
2958
 
2959
+ export function getTunnelStatus() {
2960
+ return {
2961
+ mode: tunnelRuntimeState.mode,
2962
+ tunnelName: tunnelRuntimeState.tunnelName || null,
2963
+ tunnelId: tunnelRuntimeState.tunnelId || null,
2964
+ hostname: tunnelRuntimeState.hostname || tunnelPublicHostname || null,
2965
+ publicUrl: tunnelUrl || null,
2966
+ dns: {
2967
+ status: tunnelRuntimeState.dnsStatus || "unknown",
2968
+ action: tunnelRuntimeState.dnsAction || "none",
2969
+ },
2970
+ fallbackToQuick: Boolean(tunnelRuntimeState.fallbackToQuick),
2971
+ active: Boolean(tunnelProcess && tunnelUrl),
2972
+ lastError: tunnelRuntimeState.lastError || null,
2973
+ };
2974
+ }
2975
+
2414
2976
  let _tunnelReadyCallbacks = [];
2415
2977
 
2416
2978
  /** Register a callback to be called whenever the tunnel URL changes. */
2417
2979
  export function onTunnelUrlChange(cb) {
2418
- if (typeof cb === 'function') _tunnelReadyCallbacks.push(cb);
2980
+ if (typeof cb === "function") _tunnelReadyCallbacks.push(cb);
2981
+ }
2982
+
2983
+ function setTunnelRuntimeState(next = {}) {
2984
+ tunnelRuntimeState = {
2985
+ ...tunnelRuntimeState,
2986
+ ...next,
2987
+ };
2988
+ }
2989
+
2990
+ export function normalizeTunnelMode(rawValue) {
2991
+ const value = String(rawValue || "").trim().toLowerCase();
2992
+ if (!value) return DEFAULT_TUNNEL_MODE;
2993
+ if (["disabled", "off", "false", "0"].includes(value)) return TUNNEL_MODE_DISABLED;
2994
+ if (["quick", "quick-tunnel", "ephemeral", "trycloudflare"].includes(value)) {
2995
+ return TUNNEL_MODE_QUICK;
2996
+ }
2997
+ if (["cloudflared", "auto", "named", "permanent"].includes(value)) {
2998
+ return TUNNEL_MODE_NAMED;
2999
+ }
3000
+ return DEFAULT_TUNNEL_MODE;
3001
+ }
3002
+
3003
+ function parseBooleanEnvValue(rawValue, fallback = false) {
3004
+ if (rawValue === undefined || rawValue === null || String(rawValue).trim() === "") {
3005
+ return fallback;
3006
+ }
3007
+ const normalized = String(rawValue).trim().toLowerCase();
3008
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
3009
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
3010
+ return fallback;
3011
+ }
3012
+
3013
+ function parseBoundedInt(rawValue, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
3014
+ const parsed = Number(rawValue);
3015
+ if (!Number.isFinite(parsed)) return fallback;
3016
+ return Math.min(max, Math.max(min, Math.trunc(parsed)));
3017
+ }
3018
+
3019
+ function normalizeDomainName(rawValue) {
3020
+ const value = String(rawValue || "").trim().toLowerCase();
3021
+ if (!value) return "";
3022
+ const withoutScheme = value.replace(/^https?:\/\//, "");
3023
+ const withoutPath = withoutScheme.split("/")[0].replace(/:\d+$/, "");
3024
+ return withoutPath.replace(/\.+$/, "");
3025
+ }
3026
+
3027
+ export function sanitizeHostnameLabel(rawValue, fallback = "operator") {
3028
+ const normalized = String(rawValue || "")
3029
+ .trim()
3030
+ .toLowerCase()
3031
+ .replace(/[^a-z0-9-]+/g, "-")
3032
+ .replace(/-+/g, "-")
3033
+ .replace(/^-+/, "")
3034
+ .replace(/-+$/, "");
3035
+ if (!normalized) return fallback;
3036
+ return normalized.slice(0, 63).replace(/-+$/, "") || fallback;
3037
+ }
3038
+
3039
+ function getTunnelIdentity() {
3040
+ const candidates = [
3041
+ process.env.CLOUDFLARE_TUNNEL_USERNAME,
3042
+ process.env.CLOUDFLARE_HOSTNAME_USER,
3043
+ process.env.BOSUN_OPERATOR_ID,
3044
+ process.env.USERNAME,
3045
+ process.env.USER,
3046
+ ];
3047
+ for (const value of candidates) {
3048
+ const trimmed = String(value || "").trim();
3049
+ if (trimmed) return trimmed;
3050
+ }
3051
+ try {
3052
+ const info = getOsUserInfo();
3053
+ if (info?.username) return info.username;
3054
+ } catch {
3055
+ // best effort
3056
+ }
3057
+ return "operator";
3058
+ }
3059
+
3060
+ const HOSTNAME_MAP_FILE = "cloudflare-hostname-map.json";
3061
+ let _hostnameMapCache = null;
3062
+
3063
+ function readHostnameMapStore() {
3064
+ if (_hostnameMapCache && typeof _hostnameMapCache === "object") {
3065
+ return _hostnameMapCache;
3066
+ }
3067
+ const fallback = { version: 1, domains: {} };
3068
+ try {
3069
+ const mapPath = resolveUiCachePath(HOSTNAME_MAP_FILE);
3070
+ if (!existsSync(mapPath)) {
3071
+ _hostnameMapCache = fallback;
3072
+ return fallback;
3073
+ }
3074
+ const parsed = JSON.parse(readFileSync(mapPath, "utf8"));
3075
+ if (!parsed || typeof parsed !== "object") {
3076
+ _hostnameMapCache = fallback;
3077
+ return fallback;
3078
+ }
3079
+ if (!parsed.domains || typeof parsed.domains !== "object") {
3080
+ parsed.domains = {};
3081
+ }
3082
+ _hostnameMapCache = parsed;
3083
+ return parsed;
3084
+ } catch {
3085
+ _hostnameMapCache = fallback;
3086
+ return fallback;
3087
+ }
3088
+ }
3089
+
3090
+ function writeHostnameMapStore(data) {
3091
+ try {
3092
+ const mapPath = resolveUiCachePath(HOSTNAME_MAP_FILE);
3093
+ writeFileSync(mapPath, JSON.stringify(data, null, 2), "utf8");
3094
+ } catch {
3095
+ // best effort
3096
+ }
3097
+ }
3098
+
3099
+ function getDomainMap(store, baseDomain) {
3100
+ const normalizedBaseDomain = normalizeDomainName(baseDomain);
3101
+ if (!normalizedBaseDomain) {
3102
+ return { normalizedBaseDomain: "", map: { byIdentity: {}, byLabel: {} } };
3103
+ }
3104
+ if (!store.domains[normalizedBaseDomain] || typeof store.domains[normalizedBaseDomain] !== "object") {
3105
+ store.domains[normalizedBaseDomain] = {
3106
+ byIdentity: {},
3107
+ byLabel: {},
3108
+ };
3109
+ }
3110
+ const domainMap = store.domains[normalizedBaseDomain];
3111
+ if (!domainMap.byIdentity || typeof domainMap.byIdentity !== "object") {
3112
+ domainMap.byIdentity = {};
3113
+ }
3114
+ if (!domainMap.byLabel || typeof domainMap.byLabel !== "object") {
3115
+ domainMap.byLabel = {};
3116
+ }
3117
+ return { normalizedBaseDomain, map: domainMap };
3118
+ }
3119
+
3120
+ function allocateStableHostnameLabel(domainMap, identity, preferredLabel) {
3121
+ const normalizedIdentity = String(identity || "").trim().toLowerCase();
3122
+ const existing = String(domainMap.byIdentity?.[normalizedIdentity] || "").trim().toLowerCase();
3123
+ if (existing && domainMap.byLabel?.[existing] === normalizedIdentity) {
3124
+ return existing;
3125
+ }
3126
+
3127
+ const safeBaseLabel = sanitizeHostnameLabel(preferredLabel, "operator");
3128
+ let baseLabel = RESERVED_HOSTNAME_LABELS.has(safeBaseLabel)
3129
+ ? `${safeBaseLabel}-user`
3130
+ : safeBaseLabel;
3131
+ baseLabel = sanitizeHostnameLabel(baseLabel, "operator");
3132
+ let candidate = baseLabel;
3133
+ let suffix = 1;
3134
+
3135
+ while (true) {
3136
+ const owner = String(domainMap.byLabel?.[candidate] || "").trim().toLowerCase();
3137
+ const reserved = RESERVED_HOSTNAME_LABELS.has(candidate);
3138
+ if ((!owner || owner === normalizedIdentity) && !reserved) {
3139
+ domainMap.byLabel[candidate] = normalizedIdentity;
3140
+ domainMap.byIdentity[normalizedIdentity] = candidate;
3141
+ return candidate;
3142
+ }
3143
+ suffix += 1;
3144
+ candidate = sanitizeHostnameLabel(`${baseLabel}-${suffix}`, baseLabel);
3145
+ }
3146
+ }
3147
+
3148
+ export function resolveDeterministicTunnelHostname({
3149
+ baseDomain,
3150
+ explicitHostname,
3151
+ username,
3152
+ policy,
3153
+ } = {}) {
3154
+ const normalizedBaseDomain = normalizeDomainName(
3155
+ baseDomain || process.env.CLOUDFLARE_BASE_DOMAIN || process.env.CF_BASE_DOMAIN,
3156
+ );
3157
+ const explicit = normalizeDomainName(
3158
+ explicitHostname || process.env.CLOUDFLARE_TUNNEL_HOSTNAME || process.env.CF_TUNNEL_HOSTNAME,
3159
+ );
3160
+ if (explicit) {
3161
+ return {
3162
+ hostname: explicit,
3163
+ baseDomain: explicit.split(".").slice(1).join("."),
3164
+ label: explicit.split(".")[0] || "operator",
3165
+ identity: "",
3166
+ policy: "explicit",
3167
+ source: "explicit",
3168
+ };
3169
+ }
3170
+ if (!normalizedBaseDomain) {
3171
+ throw new Error(
3172
+ "Missing CLOUDFLARE_BASE_DOMAIN (or CLOUDFLARE_TUNNEL_HOSTNAME) for named tunnel hostname resolution",
3173
+ );
3174
+ }
3175
+ const resolvedPolicy = String(
3176
+ policy || process.env.CLOUDFLARE_USERNAME_HOSTNAME_POLICY || "per-user-fixed",
3177
+ )
3178
+ .trim()
3179
+ .toLowerCase();
3180
+ const perUserFixed = resolvedPolicy !== "fixed";
3181
+ const identityRaw = username || getTunnelIdentity();
3182
+ const identity = sanitizeHostnameLabel(identityRaw, "operator");
3183
+
3184
+ if (!perUserFixed) {
3185
+ const fixedLabel = sanitizeHostnameLabel(
3186
+ process.env.CLOUDFLARE_FIXED_HOST_LABEL || process.env.CF_FIXED_HOST_LABEL || "bosun",
3187
+ "bosun",
3188
+ );
3189
+ const label = RESERVED_HOSTNAME_LABELS.has(fixedLabel)
3190
+ ? sanitizeHostnameLabel(`${fixedLabel}-app`, "bosun")
3191
+ : fixedLabel;
3192
+ return {
3193
+ hostname: `${label}.${normalizedBaseDomain}`,
3194
+ baseDomain: normalizedBaseDomain,
3195
+ label,
3196
+ identity,
3197
+ policy: "fixed",
3198
+ source: "fixed",
3199
+ };
3200
+ }
3201
+
3202
+ const store = readHostnameMapStore();
3203
+ const { map } = getDomainMap(store, normalizedBaseDomain);
3204
+ const label = allocateStableHostnameLabel(map, identity, identity);
3205
+ writeHostnameMapStore(store);
3206
+ return {
3207
+ hostname: `${label}.${normalizedBaseDomain}`,
3208
+ baseDomain: normalizedBaseDomain,
3209
+ label,
3210
+ identity,
3211
+ policy: "per-user-fixed",
3212
+ source: "map",
3213
+ };
2419
3214
  }
2420
3215
 
2421
3216
  function _notifyTunnelChange(url) {
2422
3217
  for (const cb of _tunnelReadyCallbacks) {
2423
- try { cb(url); } catch (err) {
3218
+ try {
3219
+ cb(url);
3220
+ } catch (err) {
2424
3221
  console.warn(`[telegram-ui] tunnel change callback error: ${err.message}`);
2425
3222
  }
2426
3223
  }
2427
3224
  }
2428
3225
 
3226
+ function getCloudflareApiConfig() {
3227
+ const token = String(
3228
+ process.env.CLOUDFLARE_API_TOKEN || process.env.CF_API_TOKEN || "",
3229
+ ).trim();
3230
+ const zoneId = String(
3231
+ process.env.CLOUDFLARE_ZONE_ID || process.env.CF_ZONE_ID || "",
3232
+ ).trim();
3233
+ const enabled = parseBooleanEnvValue(process.env.CLOUDFLARE_DNS_SYNC_ENABLED, true);
3234
+ return {
3235
+ enabled,
3236
+ token,
3237
+ zoneId,
3238
+ baseUrl: "https://api.cloudflare.com/client/v4",
3239
+ };
3240
+ }
3241
+
3242
+ function getCloudflareDnsRetryConfig() {
3243
+ const maxRetries = parseBoundedInt(
3244
+ process.env.CLOUDFLARE_DNS_MAX_RETRIES,
3245
+ DEFAULT_CLOUDFLARE_DNS_RETRY_MAX,
3246
+ { min: 1, max: 8 },
3247
+ );
3248
+ const retryBaseMs = parseBoundedInt(
3249
+ process.env.CLOUDFLARE_DNS_RETRY_BASE_MS,
3250
+ DEFAULT_CLOUDFLARE_DNS_RETRY_BASE_MS,
3251
+ { min: 100, max: 5000 },
3252
+ );
3253
+ return { maxRetries, retryBaseMs };
3254
+ }
3255
+
3256
+ async function sleep(ms) {
3257
+ await new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
3258
+ }
3259
+
3260
+ async function cloudflareApiRequest(api, path, { method = "GET", body = undefined } = {}) {
3261
+ const { maxRetries, retryBaseMs } = getCloudflareDnsRetryConfig();
3262
+ if (!api?.token || !api?.zoneId) {
3263
+ throw new Error("Cloudflare API token/zone not configured");
3264
+ }
3265
+ let lastError = null;
3266
+ for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
3267
+ try {
3268
+ const res = await fetch(`${api.baseUrl}${path}`, {
3269
+ method,
3270
+ headers: {
3271
+ Authorization: `Bearer ${api.token}`,
3272
+ "Content-Type": "application/json",
3273
+ },
3274
+ body: body ? JSON.stringify(body) : undefined,
3275
+ });
3276
+ const payload = await res.json().catch(() => ({}));
3277
+ if (!res.ok || payload?.success === false) {
3278
+ const errMessage = Array.isArray(payload?.errors)
3279
+ ? payload.errors.map((entry) => entry?.message).filter(Boolean).join("; ")
3280
+ : `${res.status} ${res.statusText || ""}`.trim();
3281
+ const err = new Error(`Cloudflare API ${method} ${path} failed: ${errMessage}`);
3282
+ err.status = res.status;
3283
+ throw err;
3284
+ }
3285
+ return payload;
3286
+ } catch (err) {
3287
+ lastError = err;
3288
+ const status = Number(err?.status || 0);
3289
+ const retryable = status === 429 || status >= 500 || status === 0;
3290
+ if (!retryable || attempt >= maxRetries) break;
3291
+ const backoff = retryBaseMs * 2 ** (attempt - 1);
3292
+ const jitter = Math.floor(Math.random() * Math.min(500, Math.max(100, backoff / 3)));
3293
+ await sleep(backoff + jitter);
3294
+ }
3295
+ }
3296
+ throw lastError || new Error(`Cloudflare API ${method} ${path} failed`);
3297
+ }
3298
+
3299
+ export async function ensureCloudflareDnsCname({
3300
+ hostname,
3301
+ target,
3302
+ proxied = true,
3303
+ api = getCloudflareApiConfig(),
3304
+ } = {}) {
3305
+ const normalizedHostname = normalizeDomainName(hostname);
3306
+ const normalizedTarget = normalizeDomainName(target);
3307
+ if (!normalizedHostname || !normalizedTarget) {
3308
+ throw new Error("Missing hostname/target for Cloudflare DNS sync");
3309
+ }
3310
+ if (!api?.enabled) {
3311
+ return { ok: true, changed: false, action: "disabled" };
3312
+ }
3313
+ if (!api.token || !api.zoneId) {
3314
+ return { ok: false, changed: false, action: "missing_credentials" };
3315
+ }
3316
+
3317
+ const query = `/zones/${encodeURIComponent(api.zoneId)}/dns_records?type=CNAME&name=${encodeURIComponent(normalizedHostname)}&per_page=100`;
3318
+ const listed = await cloudflareApiRequest(api, query, { method: "GET" });
3319
+ const records = Array.isArray(listed?.result) ? listed.result : [];
3320
+ const existing = records.find(
3321
+ (record) => String(record?.type || "").toUpperCase() === "CNAME"
3322
+ && String(record?.name || "").toLowerCase() === normalizedHostname,
3323
+ );
3324
+ const payload = {
3325
+ type: "CNAME",
3326
+ name: normalizedHostname,
3327
+ content: normalizedTarget,
3328
+ proxied: Boolean(proxied),
3329
+ ttl: 1,
3330
+ };
3331
+
3332
+ if (existing) {
3333
+ const sameTarget = String(existing.content || "").toLowerCase() === normalizedTarget;
3334
+ const sameProxy = Boolean(existing.proxied) === Boolean(payload.proxied);
3335
+ if (sameTarget && sameProxy) {
3336
+ return { ok: true, changed: false, action: "noop", id: existing.id };
3337
+ }
3338
+ const updated = await cloudflareApiRequest(
3339
+ api,
3340
+ `/zones/${encodeURIComponent(api.zoneId)}/dns_records/${encodeURIComponent(existing.id)}`,
3341
+ { method: "PUT", body: payload },
3342
+ );
3343
+ return {
3344
+ ok: true,
3345
+ changed: true,
3346
+ action: "updated",
3347
+ id: updated?.result?.id || existing.id,
3348
+ };
3349
+ }
3350
+
3351
+ const created = await cloudflareApiRequest(
3352
+ api,
3353
+ `/zones/${encodeURIComponent(api.zoneId)}/dns_records`,
3354
+ { method: "POST", body: payload },
3355
+ );
3356
+ return {
3357
+ ok: true,
3358
+ changed: true,
3359
+ action: "created",
3360
+ id: created?.result?.id || null,
3361
+ };
3362
+ }
3363
+
3364
+ function getTunnelStartupConfig() {
3365
+ return {
3366
+ mode: normalizeTunnelMode(process.env.TELEGRAM_UI_TUNNEL || DEFAULT_TUNNEL_MODE),
3367
+ namedTunnel: String(
3368
+ process.env.CLOUDFLARE_TUNNEL_NAME || process.env.CF_TUNNEL_NAME || "",
3369
+ ).trim(),
3370
+ credentialsPath: String(
3371
+ process.env.CLOUDFLARE_TUNNEL_CREDENTIALS || process.env.CF_TUNNEL_CREDENTIALS || "",
3372
+ ).trim(),
3373
+ allowQuickFallback: parseBooleanEnvValue(
3374
+ process.env.TELEGRAM_UI_ALLOW_QUICK_TUNNEL_FALLBACK,
3375
+ false,
3376
+ ),
3377
+ };
3378
+ }
3379
+
2429
3380
  // ── Cloudflared binary auto-download ─────────────────────────────────
2430
3381
 
2431
3382
  const CF_BIN_NAME = osPlatform() === "win32" ? "cloudflared.exe" : "cloudflared";
@@ -2528,24 +3479,27 @@ async function findCloudflared() {
2528
3479
  /**
2529
3480
  * Start a cloudflared tunnel for the given local URL.
2530
3481
  *
2531
- * Two modes:
2532
- * 1. **Quick tunnel** (default): Free, no account, random *.trycloudflare.com domain.
2533
- * Pros: Zero setup. Cons: URL changes on each restart.
2534
- * 2. **Named tunnel**: Persistent custom domain (e.g., myapp.example.com).
2535
- * Pros: Stable URL, custom domain. Cons: Requires cloudflare account + tunnel setup.
2536
- *
2537
- * Named tunnel setup:
2538
- * 1. Create a tunnel: `cloudflared tunnel create <name>`
2539
- * 2. Create DNS record: `cloudflared tunnel route dns <name> <subdomain.yourdomain.com>`
2540
- * 3. Set env vars:
2541
- * - CLOUDFLARE_TUNNEL_NAME=<name>
2542
- * - CLOUDFLARE_TUNNEL_CREDENTIALS=/path/to/<tunnel-id>.json
3482
+ * Modes:
3483
+ * - named (default): persistent per-user hostname + DNS orchestration
3484
+ * - quick: random trycloudflare hostname (explicit fallback mode)
3485
+ * - disabled: no tunnel
2543
3486
  *
2544
3487
  * Returns the assigned public URL or null on failure.
2545
3488
  */
2546
3489
  async function startTunnel(localPort) {
2547
- const tunnelMode = (process.env.TELEGRAM_UI_TUNNEL || "auto").toLowerCase();
2548
- if (tunnelMode === "disabled" || tunnelMode === "off" || tunnelMode === "0") {
3490
+ const tunnelCfg = getTunnelStartupConfig();
3491
+ setTunnelRuntimeState({
3492
+ mode: tunnelCfg.mode,
3493
+ tunnelName: tunnelCfg.namedTunnel || "",
3494
+ tunnelId: "",
3495
+ hostname: "",
3496
+ dnsAction: "none",
3497
+ dnsStatus: "not_configured",
3498
+ fallbackToQuick: false,
3499
+ lastError: "",
3500
+ });
3501
+
3502
+ if (tunnelCfg.mode === TUNNEL_MODE_DISABLED) {
2549
3503
  console.log("[telegram-ui] tunnel disabled via TELEGRAM_UI_TUNNEL=disabled");
2550
3504
  return null;
2551
3505
  }
@@ -2553,7 +3507,7 @@ async function startTunnel(localPort) {
2553
3507
  // ── SECURITY: Block tunnel when auth is disabled ─────────────────────
2554
3508
  if (isAllowUnsafe()) {
2555
3509
  console.error(
2556
- "[telegram-ui] REFUSING to start Cloudflare tunnel — TELEGRAM_UI_ALLOW_UNSAFE=true\n" +
3510
+ "[telegram-ui] :ban: REFUSING to start Cloudflare tunnel — TELEGRAM_UI_ALLOW_UNSAFE=true\n" +
2557
3511
  " A public tunnel with no authentication lets ANYONE on the internet\n" +
2558
3512
  " control your agents, read secrets, and execute commands.\n" +
2559
3513
  "\n" +
@@ -2568,26 +3522,39 @@ async function startTunnel(localPort) {
2568
3522
 
2569
3523
  const cfBin = await findCloudflared();
2570
3524
  if (!cfBin) {
2571
- if (tunnelMode === "auto") {
2572
- console.log(
2573
- "[telegram-ui] cloudflared unavailable — Telegram Mini App will use self-signed cert (may be rejected by Telegram webview).",
2574
- );
2575
- return null;
2576
- }
2577
- console.warn("[telegram-ui] cloudflared not found but TELEGRAM_UI_TUNNEL=cloudflared requested");
3525
+ setTunnelRuntimeState({ lastError: "cloudflared_not_found" });
3526
+ console.warn("[telegram-ui] cloudflared unavailable; tunnel not started");
2578
3527
  return null;
2579
3528
  }
2580
3529
 
2581
- // Check for named tunnel configuration (persistent URL)
2582
- const namedTunnel = process.env.CLOUDFLARE_TUNNEL_NAME || process.env.CF_TUNNEL_NAME;
2583
- const tunnelCreds = process.env.CLOUDFLARE_TUNNEL_CREDENTIALS || process.env.CF_TUNNEL_CREDENTIALS;
3530
+ if (tunnelCfg.mode === TUNNEL_MODE_QUICK) {
3531
+ return startQuickTunnel(cfBin, localPort);
3532
+ }
2584
3533
 
2585
- if (namedTunnel && tunnelCreds) {
2586
- return startNamedTunnel(cfBin, namedTunnel, tunnelCreds, localPort);
3534
+ const namedTunnelResult = await startNamedTunnel(
3535
+ cfBin,
3536
+ {
3537
+ tunnelName: tunnelCfg.namedTunnel,
3538
+ credentialsPath: tunnelCfg.credentialsPath,
3539
+ },
3540
+ localPort,
3541
+ );
3542
+ if (namedTunnelResult) return namedTunnelResult;
3543
+
3544
+ if (tunnelCfg.allowQuickFallback) {
3545
+ console.warn("[telegram-ui] named tunnel failed; falling back to quick tunnel (explicitly allowed)");
3546
+ setTunnelRuntimeState({
3547
+ fallbackToQuick: true,
3548
+ mode: TUNNEL_MODE_QUICK,
3549
+ });
3550
+ return startQuickTunnel(cfBin, localPort);
2587
3551
  }
2588
3552
 
2589
- // Fall back to quick tunnel (random URL, no persistence)
2590
- return startQuickTunnel(cfBin, localPort);
3553
+ setTunnelRuntimeState({
3554
+ fallbackToQuick: false,
3555
+ lastError: tunnelRuntimeState.lastError || "named_tunnel_failed",
3556
+ });
3557
+ return null;
2591
3558
  }
2592
3559
 
2593
3560
  /**
@@ -2595,7 +3562,7 @@ async function startTunnel(localPort) {
2595
3562
  * Returns the child process or throws after max retries.
2596
3563
  */
2597
3564
  function spawnCloudflared(cfBin, args, maxRetries = 3) {
2598
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
3565
+ for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
2599
3566
  try {
2600
3567
  return spawn(cfBin, args, {
2601
3568
  stdio: ["ignore", "pipe", "pipe"],
@@ -2618,25 +3585,104 @@ function spawnCloudflared(cfBin, args, maxRetries = 3) {
2618
3585
 
2619
3586
  /**
2620
3587
  * Start a cloudflared **named tunnel** with persistent URL.
2621
- * Requires: cloudflared tunnel create + DNS setup.
3588
+ * Requires tunnel credentials + DNS hostname (explicit or deterministic per-user mapping).
2622
3589
  */
2623
- async function startNamedTunnel(cfBin, tunnelName, credentialsPath, localPort) {
2624
- if (!existsSync(credentialsPath)) {
2625
- console.warn(`[telegram-ui] named tunnel credentials not found: ${credentialsPath}`);
2626
- console.warn("[telegram-ui] falling back to quick tunnel (random URL)");
2627
- return startQuickTunnel(cfBin, localPort);
3590
+ function parseNamedTunnelCredentials(credentialsPath) {
3591
+ if (!existsSync(credentialsPath)) return null;
3592
+ try {
3593
+ const parsed = JSON.parse(readFileSync(credentialsPath, "utf8"));
3594
+ const tunnelId = String(parsed?.TunnelID || parsed?.tunnel_id || "").trim();
3595
+ if (!tunnelId) return null;
3596
+ return {
3597
+ tunnelId,
3598
+ credentialsPath,
3599
+ };
3600
+ } catch {
3601
+ return null;
3602
+ }
3603
+ }
3604
+
3605
+ async function startNamedTunnel(cfBin, { tunnelName, credentialsPath }, localPort) {
3606
+ const normalizedTunnelName = String(tunnelName || "").trim();
3607
+ const normalizedCredsPath = String(credentialsPath || "").trim();
3608
+ if (!normalizedTunnelName || !normalizedCredsPath) {
3609
+ setTunnelRuntimeState({
3610
+ lastError: "missing_named_tunnel_config",
3611
+ mode: TUNNEL_MODE_NAMED,
3612
+ });
3613
+ console.warn(
3614
+ "[telegram-ui] named tunnel requires CLOUDFLARE_TUNNEL_NAME + CLOUDFLARE_TUNNEL_CREDENTIALS",
3615
+ );
3616
+ return null;
3617
+ }
3618
+
3619
+ const parsedCreds = parseNamedTunnelCredentials(normalizedCredsPath);
3620
+ if (!parsedCreds) {
3621
+ setTunnelRuntimeState({
3622
+ lastError: "invalid_named_tunnel_credentials",
3623
+ mode: TUNNEL_MODE_NAMED,
3624
+ tunnelName: normalizedTunnelName,
3625
+ });
3626
+ console.warn(`[telegram-ui] named tunnel credentials not found or invalid: ${normalizedCredsPath}`);
3627
+ return null;
3628
+ }
3629
+
3630
+ let hostnameInfo;
3631
+ try {
3632
+ hostnameInfo = resolveDeterministicTunnelHostname();
3633
+ } catch (err) {
3634
+ setTunnelRuntimeState({
3635
+ lastError: "hostname_resolution_failed",
3636
+ mode: TUNNEL_MODE_NAMED,
3637
+ tunnelName: normalizedTunnelName,
3638
+ tunnelId: parsedCreds.tunnelId,
3639
+ });
3640
+ console.warn(`[telegram-ui] named tunnel hostname resolution failed: ${err.message}`);
3641
+ return null;
3642
+ }
3643
+
3644
+ const dnsTarget = `${parsedCreds.tunnelId}.cfargotunnel.com`;
3645
+ const cfApi = getCloudflareApiConfig();
3646
+ let dnsSync = { ok: false, changed: false, action: "missing_credentials" };
3647
+ try {
3648
+ dnsSync = await ensureCloudflareDnsCname({
3649
+ hostname: hostnameInfo.hostname,
3650
+ target: dnsTarget,
3651
+ proxied: true,
3652
+ api: cfApi,
3653
+ });
3654
+ if (dnsSync.ok) {
3655
+ console.log(
3656
+ `[telegram-ui] cloudflare DNS ${dnsSync.action}: ${hostnameInfo.hostname} -> ${dnsTarget}`,
3657
+ );
3658
+ } else if (dnsSync.action === "missing_credentials") {
3659
+ console.warn(
3660
+ "[telegram-ui] Cloudflare DNS sync skipped (missing CLOUDFLARE_API_TOKEN/CLOUDFLARE_ZONE_ID)",
3661
+ );
3662
+ }
3663
+ } catch (err) {
3664
+ setTunnelRuntimeState({
3665
+ lastError: "dns_sync_failed",
3666
+ mode: TUNNEL_MODE_NAMED,
3667
+ tunnelName: normalizedTunnelName,
3668
+ tunnelId: parsedCreds.tunnelId,
3669
+ hostname: hostnameInfo.hostname,
3670
+ dnsAction: "error",
3671
+ dnsStatus: "error",
3672
+ });
3673
+ console.warn(`[telegram-ui] Cloudflare DNS sync failed: ${err.message}`);
3674
+ return null;
2628
3675
  }
2629
3676
 
2630
3677
  // Named tunnels require config file with ingress rules.
2631
- // We'll create a temporary config on the fly.
2632
3678
  const configPath = resolveUiCachePath("cloudflared-config.yml");
2633
-
3679
+ const credsPathYaml = normalizedCredsPath.replace(/\\/g, "/");
2634
3680
  const configYaml = `
2635
- tunnel: ${tunnelName}
2636
- credentials-file: ${credentialsPath}
3681
+ tunnel: ${normalizedTunnelName}
3682
+ credentials-file: ${credsPathYaml}
2637
3683
 
2638
3684
  ingress:
2639
- - hostname: "*"
3685
+ - hostname: "${hostnameInfo.hostname}"
2640
3686
  service: https://localhost:${localPort}
2641
3687
  originRequest:
2642
3688
  noTLSVerify: true
@@ -2645,41 +3691,51 @@ ingress:
2645
3691
 
2646
3692
  writeFileSync(configPath, configYaml, "utf8");
2647
3693
 
2648
- // Read the tunnel ID from credentials to construct the public URL
2649
- let publicUrl = null;
2650
- try {
2651
- const creds = JSON.parse(readFileSync(credentialsPath, "utf8"));
2652
- const tunnelId = creds.TunnelID || creds.tunnel_id;
2653
- if (tunnelId) {
2654
- publicUrl = `https://${tunnelId}.cfargotunnel.com`;
2655
- }
2656
- } catch (err) {
2657
- console.warn(`[telegram-ui] failed to parse tunnel credentials: ${err.message}`);
2658
- }
2659
-
2660
3694
  return new Promise((resolvePromise) => {
2661
3695
  const args = ["tunnel", "--config", configPath, "run"];
2662
- console.log(`[telegram-ui] starting named tunnel: ${tunnelName} → https://localhost:${localPort}`);
3696
+ const publicUrl = `https://${hostnameInfo.hostname}`;
3697
+ console.log(
3698
+ `[telegram-ui] starting named tunnel: ${normalizedTunnelName} -> ${publicUrl} -> https://localhost:${localPort}`,
3699
+ );
2663
3700
 
2664
3701
  let child;
2665
3702
  try {
2666
3703
  child = spawnCloudflared(cfBin, args);
2667
3704
  } catch (err) {
3705
+ setTunnelRuntimeState({
3706
+ lastError: "named_tunnel_spawn_failed",
3707
+ mode: TUNNEL_MODE_NAMED,
3708
+ tunnelName: normalizedTunnelName,
3709
+ tunnelId: parsedCreds.tunnelId,
3710
+ hostname: hostnameInfo.hostname,
3711
+ });
2668
3712
  console.warn(`[telegram-ui] named tunnel spawn failed: ${err.message}`);
2669
3713
  return resolvePromise(null);
2670
3714
  }
2671
3715
 
2672
3716
  let resolved = false;
2673
3717
  let output = "";
2674
- // Named tunnels emit "Connection <UUID> registered" when ready
2675
- const readyPattern = /Connection [a-f0-9-]+ registered/;
3718
+ // Named tunnels emit "Connection <UUID> registered" (or "Registered tunnel connection")
3719
+ const readyPattern = /Connection [a-f0-9-]+ registered|Registered tunnel connection/i;
3720
+ const namedTunnelTimeoutMs = parseBoundedInt(
3721
+ process.env.TELEGRAM_UI_NAMED_TUNNEL_TIMEOUT_MS,
3722
+ 60_000,
3723
+ { min: 10_000, max: 300_000 },
3724
+ );
2676
3725
  const timeout = setTimeout(() => {
2677
3726
  if (!resolved) {
2678
3727
  resolved = true;
2679
- console.warn("[telegram-ui] named tunnel timed out after 60s");
3728
+ setTunnelRuntimeState({
3729
+ lastError: "named_tunnel_timeout",
3730
+ mode: TUNNEL_MODE_NAMED,
3731
+ tunnelName: normalizedTunnelName,
3732
+ tunnelId: parsedCreds.tunnelId,
3733
+ hostname: hostnameInfo.hostname,
3734
+ });
3735
+ console.warn(`[telegram-ui] named tunnel timed out after ${namedTunnelTimeoutMs}ms`);
2680
3736
  resolvePromise(null);
2681
3737
  }
2682
- }, 60_000);
3738
+ }, namedTunnelTimeoutMs);
2683
3739
 
2684
3740
  function parseOutput(chunk) {
2685
3741
  output += chunk;
@@ -2687,9 +3743,21 @@ ingress:
2687
3743
  resolved = true;
2688
3744
  clearTimeout(timeout);
2689
3745
  tunnelUrl = publicUrl;
3746
+ tunnelPublicHostname = hostnameInfo.hostname;
2690
3747
  tunnelProcess = child;
3748
+ quickTunnelRestartAttempts = 0;
3749
+ setTunnelRuntimeState({
3750
+ mode: TUNNEL_MODE_NAMED,
3751
+ tunnelName: normalizedTunnelName,
3752
+ tunnelId: parsedCreds.tunnelId,
3753
+ hostname: hostnameInfo.hostname,
3754
+ dnsAction: dnsSync.action || "none",
3755
+ dnsStatus: dnsSync.ok ? "ok" : "not_configured",
3756
+ fallbackToQuick: false,
3757
+ lastError: "",
3758
+ });
2691
3759
  _notifyTunnelChange(publicUrl);
2692
- console.log(`[telegram-ui] named tunnel active: ${publicUrl || tunnelName}`);
3760
+ console.log(`[telegram-ui] named tunnel active: ${publicUrl}`);
2693
3761
  resolvePromise(publicUrl);
2694
3762
  }
2695
3763
  }
@@ -2701,6 +3769,13 @@ ingress:
2701
3769
  if (!resolved) {
2702
3770
  resolved = true;
2703
3771
  clearTimeout(timeout);
3772
+ setTunnelRuntimeState({
3773
+ lastError: "named_tunnel_runtime_error",
3774
+ mode: TUNNEL_MODE_NAMED,
3775
+ tunnelName: normalizedTunnelName,
3776
+ tunnelId: parsedCreds.tunnelId,
3777
+ hostname: hostnameInfo.hostname,
3778
+ });
2704
3779
  console.warn(`[telegram-ui] named tunnel failed: ${err.message}`);
2705
3780
  resolvePromise(null);
2706
3781
  }
@@ -2709,12 +3784,24 @@ ingress:
2709
3784
  child.on("exit", (code) => {
2710
3785
  tunnelProcess = null;
2711
3786
  tunnelUrl = null;
3787
+ tunnelPublicHostname = "";
3788
+ _notifyTunnelChange(null);
2712
3789
  if (!resolved) {
2713
3790
  resolved = true;
2714
3791
  clearTimeout(timeout);
3792
+ setTunnelRuntimeState({
3793
+ lastError: "named_tunnel_exited_early",
3794
+ mode: TUNNEL_MODE_NAMED,
3795
+ tunnelName: normalizedTunnelName,
3796
+ tunnelId: parsedCreds.tunnelId,
3797
+ hostname: hostnameInfo.hostname,
3798
+ });
2715
3799
  console.warn(`[telegram-ui] named tunnel exited with code ${code}`);
2716
3800
  resolvePromise(null);
2717
3801
  } else if (code !== 0 && code !== null) {
3802
+ setTunnelRuntimeState({
3803
+ lastError: "named_tunnel_exited",
3804
+ });
2718
3805
  console.warn(`[telegram-ui] named tunnel exited (code ${code})`);
2719
3806
  }
2720
3807
  });
@@ -2725,26 +3812,88 @@ ingress:
2725
3812
  * Start a cloudflared **quick tunnel** (random *.trycloudflare.com URL).
2726
3813
  * Quick tunnels are free, require no account, but the URL changes on each restart.
2727
3814
  */
3815
+ function clearQuickTunnelRestartTimer() {
3816
+ if (quickTunnelRestartTimer) {
3817
+ clearTimeout(quickTunnelRestartTimer);
3818
+ quickTunnelRestartTimer = null;
3819
+ }
3820
+ }
3821
+
3822
+ function getQuickTunnelRestartConfig() {
3823
+ const maxAttempts = parseBoundedInt(
3824
+ process.env.TELEGRAM_UI_QUICK_TUNNEL_RESTART_MAX_ATTEMPTS,
3825
+ 6,
3826
+ { min: 1, max: 50 },
3827
+ );
3828
+ const baseDelayMs = parseBoundedInt(
3829
+ process.env.TELEGRAM_UI_QUICK_TUNNEL_RESTART_BASE_DELAY_MS
3830
+ || process.env.TELEGRAM_UI_QUICK_TUNNEL_RESTART_BASE_MS,
3831
+ 5000,
3832
+ { min: 250, max: 60_000 },
3833
+ );
3834
+ const maxDelayMs = parseBoundedInt(
3835
+ process.env.TELEGRAM_UI_QUICK_TUNNEL_RESTART_MAX_DELAY_MS,
3836
+ 120_000,
3837
+ { min: 1000, max: 900_000 },
3838
+ );
3839
+ return { maxAttempts, baseDelayMs, maxDelayMs };
3840
+ }
3841
+
3842
+ function scheduleQuickTunnelRestart(cfBin, localPort) {
3843
+ if (quickTunnelRestartSuppressed) return;
3844
+ const cfg = getQuickTunnelRestartConfig();
3845
+ if (quickTunnelRestartAttempts >= cfg.maxAttempts) {
3846
+ console.warn(
3847
+ `[telegram-ui] quick tunnel restart exhausted after ${quickTunnelRestartAttempts} attempts (max ${cfg.maxAttempts})`,
3848
+ );
3849
+ return;
3850
+ }
3851
+ quickTunnelRestartAttempts += 1;
3852
+ const backoff = Math.min(cfg.maxDelayMs, cfg.baseDelayMs * 2 ** (quickTunnelRestartAttempts - 1));
3853
+ const jitter = Math.floor(Math.random() * Math.max(200, Math.floor(backoff * 0.2)));
3854
+ const restartDelayMs = Math.min(cfg.maxDelayMs, backoff + jitter);
3855
+ clearQuickTunnelRestartTimer();
3856
+ console.warn(
3857
+ `[telegram-ui] quick tunnel restart scheduled (${quickTunnelRestartAttempts}/${cfg.maxAttempts}) in ${restartDelayMs}ms`,
3858
+ );
3859
+ quickTunnelRestartTimer = setTimeout(() => {
3860
+ startQuickTunnel(cfBin, localPort).catch((err) => {
3861
+ console.warn(`[telegram-ui] quick tunnel restart failed: ${err.message}`);
3862
+ });
3863
+ }, restartDelayMs);
3864
+ quickTunnelRestartTimer.unref?.();
3865
+ }
3866
+
2728
3867
  async function startQuickTunnel(cfBin, localPort) {
3868
+ quickTunnelRestartSuppressed = false;
3869
+ clearQuickTunnelRestartTimer();
2729
3870
  return new Promise((resolvePromise) => {
2730
3871
  const localUrl = `https://localhost:${localPort}`;
2731
3872
  const args = ["tunnel", "--url", localUrl, "--no-autoupdate", "--no-tls-verify"];
2732
- console.log(`[telegram-ui] starting quick tunnel ${localUrl}`);
3873
+ console.log(`[telegram-ui] starting quick tunnel -> ${localUrl}`);
2733
3874
 
2734
3875
  let child;
2735
3876
  try {
2736
3877
  child = spawnCloudflared(cfBin, args);
2737
3878
  } catch (err) {
3879
+ setTunnelRuntimeState({
3880
+ mode: TUNNEL_MODE_QUICK,
3881
+ lastError: "quick_tunnel_spawn_failed",
3882
+ });
2738
3883
  console.warn(`[telegram-ui] quick tunnel spawn failed: ${err.message}`);
2739
3884
  return resolvePromise(null);
2740
3885
  }
2741
3886
 
2742
3887
  let resolved = false;
2743
3888
  let output = "";
2744
- const urlPattern = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
3889
+ const urlPattern = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
2745
3890
  const timeout = setTimeout(() => {
2746
3891
  if (!resolved) {
2747
3892
  resolved = true;
3893
+ setTunnelRuntimeState({
3894
+ mode: TUNNEL_MODE_QUICK,
3895
+ lastError: "quick_tunnel_timeout",
3896
+ });
2748
3897
  console.warn("[telegram-ui] quick tunnel timed out after 30s");
2749
3898
  resolvePromise(null);
2750
3899
  }
@@ -2756,9 +3905,21 @@ async function startQuickTunnel(cfBin, localPort) {
2756
3905
  if (match && !resolved) {
2757
3906
  resolved = true;
2758
3907
  clearTimeout(timeout);
2759
- tunnelUrl = match[0];
3908
+ tunnelUrl = String(match[0]).toLowerCase();
3909
+ tunnelPublicHostname = "";
2760
3910
  tunnelProcess = child;
2761
- _notifyTunnelChange(match[0]);
3911
+ quickTunnelRestartAttempts = 0;
3912
+ setTunnelRuntimeState({
3913
+ mode: TUNNEL_MODE_QUICK,
3914
+ tunnelName: "",
3915
+ tunnelId: "",
3916
+ hostname: "",
3917
+ dnsAction: "none",
3918
+ dnsStatus: "disabled",
3919
+ fallbackToQuick: true,
3920
+ lastError: "",
3921
+ });
3922
+ _notifyTunnelChange(tunnelUrl);
2762
3923
  console.log(`[telegram-ui] quick tunnel active: ${tunnelUrl}`);
2763
3924
  resolvePromise(tunnelUrl);
2764
3925
  }
@@ -2771,6 +3932,10 @@ async function startQuickTunnel(cfBin, localPort) {
2771
3932
  if (!resolved) {
2772
3933
  resolved = true;
2773
3934
  clearTimeout(timeout);
3935
+ setTunnelRuntimeState({
3936
+ mode: TUNNEL_MODE_QUICK,
3937
+ lastError: "quick_tunnel_runtime_error",
3938
+ });
2774
3939
  console.warn(`[telegram-ui] quick tunnel failed: ${err.message}`);
2775
3940
  resolvePromise(null);
2776
3941
  }
@@ -2779,13 +3944,24 @@ async function startQuickTunnel(cfBin, localPort) {
2779
3944
  child.on("exit", (code) => {
2780
3945
  tunnelProcess = null;
2781
3946
  tunnelUrl = null;
3947
+ tunnelPublicHostname = "";
3948
+ _notifyTunnelChange(null);
2782
3949
  if (!resolved) {
2783
3950
  resolved = true;
2784
3951
  clearTimeout(timeout);
3952
+ setTunnelRuntimeState({
3953
+ mode: TUNNEL_MODE_QUICK,
3954
+ lastError: "quick_tunnel_exited_early",
3955
+ });
2785
3956
  console.warn(`[telegram-ui] quick tunnel exited with code ${code}`);
2786
3957
  resolvePromise(null);
2787
3958
  } else if (code !== 0 && code !== null) {
3959
+ setTunnelRuntimeState({
3960
+ mode: TUNNEL_MODE_QUICK,
3961
+ lastError: "quick_tunnel_exited",
3962
+ });
2788
3963
  console.warn(`[telegram-ui] quick tunnel exited (code ${code})`);
3964
+ scheduleQuickTunnelRestart(cfBin, localPort);
2789
3965
  }
2790
3966
  });
2791
3967
  });
@@ -2793,13 +3969,29 @@ async function startQuickTunnel(cfBin, localPort) {
2793
3969
 
2794
3970
  /** Stop the tunnel if running. */
2795
3971
  export function stopTunnel() {
3972
+ quickTunnelRestartSuppressed = true;
3973
+ clearQuickTunnelRestartTimer();
2796
3974
  if (tunnelProcess) {
2797
3975
  try {
2798
3976
  tunnelProcess.kill("SIGTERM");
2799
- } catch { /* ignore */ }
3977
+ } catch {
3978
+ /* ignore */
3979
+ }
2800
3980
  tunnelProcess = null;
2801
3981
  tunnelUrl = null;
2802
- }
3982
+ tunnelPublicHostname = "";
3983
+ _notifyTunnelChange(null);
3984
+ }
3985
+ setTunnelRuntimeState({
3986
+ mode: TUNNEL_MODE_DISABLED,
3987
+ tunnelName: "",
3988
+ tunnelId: "",
3989
+ hostname: "",
3990
+ dnsAction: "none",
3991
+ dnsStatus: "not_configured",
3992
+ fallbackToQuick: false,
3993
+ lastError: "",
3994
+ });
2803
3995
  }
2804
3996
 
2805
3997
  export function injectUiDependencies(deps = {}) {
@@ -3018,21 +4210,50 @@ function checkSessionToken(req) {
3018
4210
  return false;
3019
4211
  }
3020
4212
 
3021
- function requireAuth(req) {
3022
- if (isAllowUnsafe()) return true;
3023
- // Session token (browser access)
3024
- if (checkSessionToken(req)) return true;
3025
- // Telegram initData HMAC
3026
- const initData =
4213
+ function getTelegramInitData(req, url = null) {
4214
+ return String(
3027
4215
  req.headers["x-telegram-initdata"] ||
3028
4216
  req.headers["x-telegram-init-data"] ||
3029
4217
  req.headers["x-telegram-init"] ||
3030
4218
  req.headers["x-telegram-webapp"] ||
3031
4219
  req.headers["x-telegram-webapp-data"] ||
3032
- "";
4220
+ url?.searchParams?.get("initData") ||
4221
+ "",
4222
+ );
4223
+ }
4224
+
4225
+ function buildSessionCookieHeader() {
4226
+ const secure = uiServerTls ? "; Secure" : "";
4227
+ const token = ensureSessionToken();
4228
+ return `ve_session=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=86400${secure}`;
4229
+ }
4230
+
4231
+ function getHeaderString(value) {
4232
+ if (Array.isArray(value)) return String(value[0] || "");
4233
+ return String(value || "");
4234
+ }
4235
+
4236
+ async function requireAuth(req) {
4237
+ if (isAllowUnsafe()) return { ok: true, source: "unsafe", issueSessionCookie: false };
4238
+ // Session token (browser access)
4239
+ if (checkSessionToken(req)) return { ok: true, source: "session", issueSessionCookie: false };
4240
+ // Telegram initData HMAC
4241
+ const initData = getTelegramInitData(req);
3033
4242
  const token = process.env.TELEGRAM_BOT_TOKEN || "";
3034
- if (!initData) return false;
3035
- return validateInitData(String(initData), token);
4243
+ if (initData && validateInitData(initData, token)) {
4244
+ return { ok: true, source: "telegram", issueSessionCookie: false };
4245
+ }
4246
+ // Fallback auth header is only evaluated after session + Telegram auth fails.
4247
+ const fallbackSecret = getHeaderString(
4248
+ req.headers["x-bosun-fallback-auth"] || req.headers["x-admin-fallback-auth"],
4249
+ ).trim();
4250
+ if (fallbackSecret) {
4251
+ const result = await attemptFallbackAuth(req, fallbackSecret);
4252
+ if (result.ok) {
4253
+ return { ok: true, source: "fallback", issueSessionCookie: true };
4254
+ }
4255
+ }
4256
+ return { ok: false, source: "unauthorized", issueSessionCookie: false };
3036
4257
  }
3037
4258
 
3038
4259
  function requireWsAuth(req, url) {
@@ -3048,12 +4269,7 @@ function requireWsAuth(req, url) {
3048
4269
  }
3049
4270
  }
3050
4271
  // Telegram initData HMAC
3051
- const initData =
3052
- req.headers["x-telegram-initdata"] ||
3053
- req.headers["x-telegram-init-data"] ||
3054
- req.headers["x-telegram-init"] ||
3055
- url.searchParams.get("initData") ||
3056
- "";
4272
+ const initData = getTelegramInitData(req, url);
3057
4273
  const token = process.env.TELEGRAM_BOT_TOKEN || "";
3058
4274
  if (!initData) return false;
3059
4275
  return validateInitData(String(initData), token);
@@ -4286,30 +5502,71 @@ async function ensurePresenceLoaded() {
4286
5502
  }
4287
5503
 
4288
5504
  async function handleApi(req, res, url) {
5505
+ const path = url.pathname;
4289
5506
  if (req.method === "OPTIONS") {
4290
5507
  res.writeHead(204, {
4291
5508
  "Access-Control-Allow-Origin": "*",
4292
5509
  "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
4293
- "Access-Control-Allow-Headers": "Content-Type,X-Telegram-InitData",
5510
+ "Access-Control-Allow-Headers": "Content-Type,X-Telegram-InitData,X-Bosun-Fallback-Auth",
4294
5511
  });
4295
5512
  res.end();
4296
5513
  return;
4297
5514
  }
4298
5515
 
4299
- if (!requireAuth(req)) {
5516
+ if (path === "/api/auth/fallback/status" && req.method === "GET") {
5517
+ jsonResponse(res, 200, {
5518
+ ok: true,
5519
+ data: {
5520
+ fallbackAuth: getFallbackAuthStatus(),
5521
+ tunnel: getTunnelStatus(),
5522
+ },
5523
+ });
5524
+ return;
5525
+ }
5526
+
5527
+ if (path === "/api/auth/fallback/login" && req.method === "POST") {
5528
+ try {
5529
+ const body = await readJsonBody(req);
5530
+ const secret = String(body?.secret || body?.password || body?.pin || "").trim();
5531
+ const attempt = await attemptFallbackAuth(req, secret);
5532
+ if (!attempt.ok) {
5533
+ jsonResponse(res, 401, {
5534
+ ok: false,
5535
+ error: "Authentication failed.",
5536
+ });
5537
+ return;
5538
+ }
5539
+ res.setHeader("Set-Cookie", buildSessionCookieHeader());
5540
+ jsonResponse(res, 200, {
5541
+ ok: true,
5542
+ tokenIssued: true,
5543
+ auth: "fallback",
5544
+ });
5545
+ } catch {
5546
+ jsonResponse(res, 401, {
5547
+ ok: false,
5548
+ error: "Authentication failed.",
5549
+ });
5550
+ }
5551
+ return;
5552
+ }
5553
+
5554
+ const authResult = await requireAuth(req);
5555
+ if (!authResult?.ok) {
4300
5556
  jsonResponse(res, 401, {
4301
5557
  ok: false,
4302
- error: "Unauthorized. Telegram init data missing or invalid.",
5558
+ error: "Unauthorized.",
4303
5559
  });
4304
5560
  return;
4305
5561
  }
5562
+ if (authResult.issueSessionCookie) {
5563
+ res.setHeader("Set-Cookie", buildSessionCookieHeader());
5564
+ }
4306
5565
 
4307
5566
  if (req.method === "POST" && !checkRateLimit(req, 30)) {
4308
5567
  jsonResponse(res, 429, { ok: false, error: "Rate limit exceeded. Try again later." });
4309
5568
  return;
4310
5569
  }
4311
-
4312
- const path = url.pathname;
4313
5570
  if (path.startsWith("/api/attachments/") && req.method === "GET") {
4314
5571
  const rel = decodeURIComponent(path.slice("/api/attachments/".length));
4315
5572
  const root = ATTACHMENTS_ROOT;
@@ -4342,6 +5599,44 @@ async function handleApi(req, res, url) {
4342
5599
  return;
4343
5600
  }
4344
5601
 
5602
+ if (
5603
+ (path === "/api/auth/fallback/set" || path === "/api/auth/fallback/rotate")
5604
+ && req.method === "POST"
5605
+ ) {
5606
+ try {
5607
+ const body = await readJsonBody(req);
5608
+ const secret = String(body?.secret || body?.password || body?.pin || "").trim();
5609
+ const confirm = String(body?.confirm || "").trim();
5610
+ if (!secret || (confirm && confirm !== secret)) {
5611
+ jsonResponse(res, 400, {
5612
+ ok: false,
5613
+ error: "Invalid fallback credential payload.",
5614
+ });
5615
+ return;
5616
+ }
5617
+ await setFallbackAuthSecret(secret, { actor: "api" });
5618
+ jsonResponse(res, 200, {
5619
+ ok: true,
5620
+ data: getFallbackAuthStatus(),
5621
+ });
5622
+ } catch (err) {
5623
+ jsonResponse(res, 400, {
5624
+ ok: false,
5625
+ error: err?.message || "Failed to set fallback credential.",
5626
+ });
5627
+ }
5628
+ return;
5629
+ }
5630
+
5631
+ if (path === "/api/auth/fallback/reset" && req.method === "POST") {
5632
+ resetFallbackAuthSecret();
5633
+ jsonResponse(res, 200, {
5634
+ ok: true,
5635
+ data: getFallbackAuthStatus(),
5636
+ });
5637
+ return;
5638
+ }
5639
+
4345
5640
  if (path === "/api/executor") {
4346
5641
  const executor = uiDeps.getInternalExecutor?.();
4347
5642
  const mode = uiDeps.getExecutorMode?.() || "internal";
@@ -6623,6 +7918,8 @@ async function handleApi(req, res, url) {
6623
7918
  sdk: process.env.EXECUTOR_SDK || "auto",
6624
7919
  kanbanBackend: runtimeKanbanBackend,
6625
7920
  regions,
7921
+ tunnel: getTunnelStatus(),
7922
+ fallbackAuth: getFallbackAuthStatus(),
6626
7923
  });
6627
7924
  return;
6628
7925
  }
@@ -6681,6 +7978,8 @@ async function handleApi(req, res, url) {
6681
7978
  configExists,
6682
7979
  configSchemaPath: CONFIG_SCHEMA_PATH,
6683
7980
  configSchemaLoaded: Boolean(configSchema),
7981
+ tunnel: getTunnelStatus(),
7982
+ fallbackAuth: getFallbackAuthStatus(),
6684
7983
  },
6685
7984
  });
6686
7985
  } catch (err) {
@@ -6768,6 +8067,8 @@ async function handleApi(req, res, url) {
6768
8067
  updatedConfig: configUpdate.updated || [],
6769
8068
  configPath: configUpdate.path || null,
6770
8069
  configDir,
8070
+ tunnel: getTunnelStatus(),
8071
+ fallbackAuth: getFallbackAuthStatus(),
6771
8072
  });
6772
8073
  } catch (err) {
6773
8074
  jsonResponse(res, 500, { ok: false, error: err.message });
@@ -6827,10 +8128,13 @@ async function handleApi(req, res, url) {
6827
8128
  const webhookSecretSet = Boolean(process.env.BOSUN_GITHUB_WEBHOOK_SECRET);
6828
8129
  const appWebhookPath = getAppWebhookPath();
6829
8130
 
6830
- // Build public URLs from tunnel URL if available, else from request host
6831
- const host = req.headers["x-forwarded-host"] || req.headers.host || "localhost";
6832
- const proto = uiServerTls || req.headers["x-forwarded-proto"] === "https" ? "https" : "http";
6833
- const baseUrl = `${proto}://${host}`;
8131
+ // Build public URLs from tunnel URL if available, else from request host.
8132
+ let baseUrl = String(getTunnelUrl() || "").replace(/\/+$/, "");
8133
+ if (!baseUrl) {
8134
+ const host = req.headers["x-forwarded-host"] || req.headers.host || "localhost";
8135
+ const proto = uiServerTls || req.headers["x-forwarded-proto"] === "https" ? "https" : "http";
8136
+ baseUrl = `${proto}://${host}`;
8137
+ }
6834
8138
 
6835
8139
  jsonResponse(res, 200, {
6836
8140
  ok: true,
@@ -7577,7 +8881,7 @@ async function handleApi(req, res, url) {
7577
8881
  tracker.recordEvent(sessionId, {
7578
8882
  role: "system",
7579
8883
  type: "error",
7580
- content: "⚠️ No agent is available to process this message. The primary agent may not be initialized — try restarting bosun or check the Logs tab for details.",
8884
+ content: ":alert: No agent is available to process this message. The primary agent may not be initialized — try restarting bosun or check the Logs tab for details.",
7581
8885
  timestamp: new Date().toISOString(),
7582
8886
  });
7583
8887
  jsonResponse(res, 200, { ok: true, messageId, warning: "no_agent_available" });
@@ -7718,9 +9022,16 @@ async function handleApi(req, res, url) {
7718
9022
  available: availability.available,
7719
9023
  tier: availability.tier,
7720
9024
  provider: availability.provider,
9025
+ reason: availability.reason || "",
7721
9026
  voiceId: config.voiceId,
7722
9027
  turnDetection: config.turnDetection,
7723
9028
  model: config.model,
9029
+ visionModel: config.visionModel,
9030
+ vision: {
9031
+ enabled: true,
9032
+ frameMaxBytes: MAX_VISION_FRAME_BYTES,
9033
+ defaultIntervalMs: DEFAULT_VISION_ANALYSIS_INTERVAL_MS,
9034
+ },
7724
9035
  fallbackMode: config.fallbackMode,
7725
9036
  connectionInfo,
7726
9037
  });
@@ -7733,9 +9044,19 @@ async function handleApi(req, res, url) {
7733
9044
  // POST /api/voice/token
7734
9045
  if (path === "/api/voice/token" && req.method === "POST") {
7735
9046
  try {
9047
+ const body = await readJsonBody(req).catch(() => ({}));
9048
+ const callContext = {
9049
+ sessionId: String(body?.sessionId || "").trim() || undefined,
9050
+ executor: String(body?.executor || "").trim() || undefined,
9051
+ mode: String(body?.mode || "").trim() || undefined,
9052
+ model: String(body?.model || "").trim() || undefined,
9053
+ };
7736
9054
  const { createEphemeralToken, getVoiceToolDefinitions } = await import("./voice-relay.mjs");
7737
- const tools = await getVoiceToolDefinitions();
7738
- const tokenData = await createEphemeralToken(tools);
9055
+ const delegateOnly =
9056
+ body?.delegateOnly === true ||
9057
+ (body?.delegateOnly !== false && Boolean(callContext.sessionId));
9058
+ const tools = await getVoiceToolDefinitions({ delegateOnly });
9059
+ const tokenData = await createEphemeralToken(tools, callContext);
7739
9060
 
7740
9061
  jsonResponse(res, 200, tokenData);
7741
9062
  } catch (err) {
@@ -7748,13 +9069,33 @@ async function handleApi(req, res, url) {
7748
9069
  if (path === "/api/voice/tool" && req.method === "POST") {
7749
9070
  try {
7750
9071
  const body = await readJsonBody(req);
7751
- const { toolName, args, sessionId: voiceSessionId } = body || {};
7752
- if (!toolName) {
9072
+ const {
9073
+ toolName,
9074
+ args,
9075
+ sessionId: voiceSessionId,
9076
+ executor,
9077
+ mode,
9078
+ model,
9079
+ } = body || {};
9080
+ const normalizedToolName = String(toolName || "").trim();
9081
+ if (!normalizedToolName) {
7753
9082
  jsonResponse(res, 400, { error: "toolName required" });
7754
9083
  return;
7755
9084
  }
7756
9085
  const { executeVoiceTool } = await import("./voice-relay.mjs");
7757
- const result = await executeVoiceTool(toolName, args || {}, { sessionId: voiceSessionId });
9086
+ const context = {
9087
+ sessionId: String(voiceSessionId || "").trim() || undefined,
9088
+ executor: String(executor || "").trim() || undefined,
9089
+ mode: String(mode || "").trim() || undefined,
9090
+ model: String(model || "").trim() || undefined,
9091
+ };
9092
+ if (context.sessionId && normalizedToolName !== "delegate_to_agent") {
9093
+ jsonResponse(res, 400, {
9094
+ error: "Session-bound calls only allow delegate_to_agent",
9095
+ });
9096
+ return;
9097
+ }
9098
+ const result = await executeVoiceTool(normalizedToolName, args || {}, context);
7758
9099
 
7759
9100
  jsonResponse(res, 200, result);
7760
9101
  } catch (err) {
@@ -7763,14 +9104,242 @@ async function handleApi(req, res, url) {
7763
9104
  return;
7764
9105
  }
7765
9106
 
9107
+ // POST /api/voice/transcript
9108
+ if (path === "/api/voice/transcript" && req.method === "POST") {
9109
+ try {
9110
+ const body = await readJsonBody(req);
9111
+ const sessionId = String(body?.sessionId || "").trim();
9112
+ const role = String(body?.role || "").trim().toLowerCase();
9113
+ const content = String(body?.content || "").trim();
9114
+ if (!sessionId) {
9115
+ jsonResponse(res, 400, { ok: false, error: "sessionId required" });
9116
+ return;
9117
+ }
9118
+ if (!["user", "assistant", "system"].includes(role)) {
9119
+ jsonResponse(res, 400, { ok: false, error: "role must be user|assistant|system" });
9120
+ return;
9121
+ }
9122
+ if (!content) {
9123
+ jsonResponse(res, 400, { ok: false, error: "content required" });
9124
+ return;
9125
+ }
9126
+
9127
+ const tracker = getSessionTracker();
9128
+ let session = tracker.getSessionById(sessionId);
9129
+ if (!session) {
9130
+ session = tracker.createSession({
9131
+ id: sessionId,
9132
+ type: "primary",
9133
+ metadata: {
9134
+ agent: String(body?.executor || getPrimaryAgentName() || ""),
9135
+ mode: String(body?.mode || getAgentMode() || ""),
9136
+ model: String(body?.model || "").trim() || undefined,
9137
+ },
9138
+ });
9139
+ }
9140
+
9141
+ tracker.recordEvent(session.id || sessionId, {
9142
+ role,
9143
+ type: "voice_transcript",
9144
+ content,
9145
+ timestamp: new Date().toISOString(),
9146
+ meta: {
9147
+ source: "voice",
9148
+ provider: String(body?.provider || "").trim() || undefined,
9149
+ eventType: String(body?.eventType || "").trim() || undefined,
9150
+ },
9151
+ });
9152
+
9153
+ const provider = String(body?.provider || "").trim() || null;
9154
+ const transcriptEventType = String(body?.eventType || "").trim().toLowerCase() || null;
9155
+ const executor = String(body?.executor || "").trim() || null;
9156
+ const mode = String(body?.mode || "").trim() || null;
9157
+ const model = String(body?.model || "").trim() || null;
9158
+ const normalizedSessionId = String(session.id || sessionId).trim();
9159
+ const contentHash = createHash("sha1")
9160
+ .update(`${role}:${content}`)
9161
+ .digest("hex")
9162
+ .slice(0, 16);
9163
+ const workflowPayload = {
9164
+ sessionId: normalizedSessionId,
9165
+ meetingSessionId: normalizedSessionId,
9166
+ role,
9167
+ content,
9168
+ source: "voice",
9169
+ provider,
9170
+ transcriptEventType,
9171
+ executor,
9172
+ mode,
9173
+ model,
9174
+ };
9175
+
9176
+ queueWorkflowEvent(
9177
+ "meeting.transcript",
9178
+ workflowPayload,
9179
+ {
9180
+ dedupKey: `workflow-event:meeting.transcript:${normalizedSessionId}:${role}:${contentHash}`,
9181
+ },
9182
+ );
9183
+ if (transcriptEventType === "wake_phrase" || transcriptEventType === "wake-phrase") {
9184
+ queueWorkflowEvent(
9185
+ "meeting.wake_phrase",
9186
+ workflowPayload,
9187
+ {
9188
+ dedupKey: `workflow-event:meeting.wake_phrase:${normalizedSessionId}:${role}:${contentHash}`,
9189
+ },
9190
+ );
9191
+ }
9192
+
9193
+ jsonResponse(res, 200, { ok: true });
9194
+ } catch (err) {
9195
+ jsonResponse(res, 500, { ok: false, error: err.message });
9196
+ }
9197
+ return;
9198
+ }
9199
+
9200
+ // POST /api/vision/frame
9201
+ if (path === "/api/vision/frame" && req.method === "POST") {
9202
+ try {
9203
+ const body = await readJsonBody(req);
9204
+ const sessionId = String(body?.sessionId || "").trim();
9205
+ const source = sanitizeVisionSource(body?.source);
9206
+ const frame = parseVisionFrameDataUrl(body?.frameDataUrl);
9207
+ const forceAnalyze = body?.forceAnalyze === true;
9208
+ const minIntervalMs = normalizeVisionInterval(body?.minIntervalMs);
9209
+ const width = Number.isFinite(Number(body?.width)) ? Number(body.width) : null;
9210
+ const height = Number.isFinite(Number(body?.height)) ? Number(body.height) : null;
9211
+
9212
+ if (!sessionId) {
9213
+ jsonResponse(res, 400, { ok: false, error: "sessionId required" });
9214
+ return;
9215
+ }
9216
+ if (!frame.ok) {
9217
+ jsonResponse(res, frame.statusCode || 400, { ok: false, error: frame.error });
9218
+ return;
9219
+ }
9220
+
9221
+ const state = getVisionSessionState(sessionId);
9222
+ if (!state) {
9223
+ jsonResponse(res, 400, { ok: false, error: "Invalid sessionId" });
9224
+ return;
9225
+ }
9226
+
9227
+ const frameHash = createHash("sha1").update(frame.base64Data).digest("hex");
9228
+ const now = Date.now();
9229
+
9230
+ state.lastFrameHash = frameHash;
9231
+ state.lastReceiptAt = now;
9232
+
9233
+ if (!forceAnalyze && state.inFlight) {
9234
+ jsonResponse(res, 202, {
9235
+ ok: true,
9236
+ analyzed: false,
9237
+ skipped: true,
9238
+ reason: "analysis_in_progress",
9239
+ summary: state.lastSummary || undefined,
9240
+ });
9241
+ return;
9242
+ }
9243
+
9244
+ if (!forceAnalyze && frameHash === state.lastAnalyzedHash) {
9245
+ jsonResponse(res, 200, {
9246
+ ok: true,
9247
+ analyzed: false,
9248
+ skipped: true,
9249
+ reason: "duplicate_frame",
9250
+ summary: state.lastSummary || undefined,
9251
+ });
9252
+ return;
9253
+ }
9254
+
9255
+ if (!forceAnalyze && now - state.lastAnalyzedAt < minIntervalMs) {
9256
+ jsonResponse(res, 202, {
9257
+ ok: true,
9258
+ analyzed: false,
9259
+ skipped: true,
9260
+ reason: "throttled",
9261
+ summary: state.lastSummary || undefined,
9262
+ });
9263
+ return;
9264
+ }
9265
+
9266
+ const callContext = {
9267
+ sessionId,
9268
+ executor: String(body?.executor || "").trim() || undefined,
9269
+ mode: String(body?.mode || "").trim() || undefined,
9270
+ model: String(body?.model || "").trim() || undefined,
9271
+ };
9272
+ const prompt = String(body?.prompt || "").trim() || undefined;
9273
+ const model = String(body?.visionModel || "").trim() || undefined;
9274
+
9275
+ const { analyzeVisionFrame } = await import("./voice-relay.mjs");
9276
+ const pending = analyzeVisionFrame(frame.raw, {
9277
+ source,
9278
+ context: callContext,
9279
+ prompt,
9280
+ model,
9281
+ });
9282
+ state.inFlight = pending;
9283
+ let analysis;
9284
+ try {
9285
+ analysis = await pending;
9286
+ } finally {
9287
+ if (state.inFlight === pending) {
9288
+ state.inFlight = null;
9289
+ }
9290
+ }
9291
+
9292
+ const tracker = getSessionTracker();
9293
+ let session = tracker.getSessionById(sessionId);
9294
+ if (!session) {
9295
+ session = tracker.createSession({
9296
+ id: sessionId,
9297
+ type: "primary",
9298
+ metadata: {
9299
+ agent: String(body?.executor || getPrimaryAgentName() || ""),
9300
+ mode: String(body?.mode || getAgentMode() || ""),
9301
+ model: String(body?.model || "").trim() || undefined,
9302
+ },
9303
+ });
9304
+ }
9305
+
9306
+ const dimension = width && height ? ` (${width}x${height})` : "";
9307
+ tracker.recordEvent(session.id || sessionId, {
9308
+ role: "system",
9309
+ content: `[Vision ${source}${dimension}] ${analysis.summary}`,
9310
+ timestamp: new Date().toISOString(),
9311
+ });
9312
+
9313
+ state.lastAnalyzedHash = frameHash;
9314
+ state.lastAnalyzedAt = Date.now();
9315
+ state.lastSummary = String(analysis.summary || "").trim();
9316
+
9317
+ jsonResponse(res, 200, {
9318
+ ok: true,
9319
+ analyzed: true,
9320
+ summary: state.lastSummary,
9321
+ provider: analysis.provider || null,
9322
+ model: analysis.model || null,
9323
+ frameHash,
9324
+ });
9325
+ } catch (err) {
9326
+ jsonResponse(res, 500, { ok: false, error: err.message });
9327
+ }
9328
+ return;
9329
+ }
9330
+
7766
9331
  jsonResponse(res, 404, { ok: false, error: "Unknown API endpoint" });
7767
9332
  }
7768
9333
 
7769
9334
  async function handleStatic(req, res, url) {
7770
- if (!requireAuth(req)) {
9335
+ const authResult = await requireAuth(req);
9336
+ if (!authResult?.ok) {
7771
9337
  textResponse(res, 401, "Unauthorized");
7772
9338
  return;
7773
9339
  }
9340
+ if (authResult.issueSessionCookie) {
9341
+ res.setHeader("Set-Cookie", buildSessionCookieHeader());
9342
+ }
7774
9343
 
7775
9344
  const rawPathname = String(url.pathname || "/").trim() || "/";
7776
9345
  const pathname = rawPathname === "/" ? "/index.html" : rawPathname;
@@ -7915,10 +9484,16 @@ export async function startTelegramUiServer(options = {}) {
7915
9484
  const provided = Buffer.from(qToken);
7916
9485
  const expected = Buffer.from(sessionToken);
7917
9486
  if (provided.length === expected.length && timingSafeEqual(provided, expected)) {
7918
- const secure = uiServerTls ? "; Secure" : "";
9487
+ const cleanUrl = new URL(url.toString());
9488
+ cleanUrl.searchParams.delete("token");
9489
+ const redirectPath =
9490
+ cleanUrl.pathname +
9491
+ (cleanUrl.searchParams.toString()
9492
+ ? `?${cleanUrl.searchParams.toString()}`
9493
+ : "");
7919
9494
  res.writeHead(302, {
7920
- "Set-Cookie": `ve_session=${sessionToken}; HttpOnly; SameSite=Lax; Path=/; Max-Age=86400${secure}`,
7921
- Location: url.pathname || "/",
9495
+ "Set-Cookie": buildSessionCookieHeader(),
9496
+ Location: redirectPath || "/",
7922
9497
  });
7923
9498
  res.end();
7924
9499
  return;
@@ -7972,6 +9547,14 @@ export async function startTelegramUiServer(options = {}) {
7972
9547
  return;
7973
9548
  }
7974
9549
 
9550
+ // /demo and /ui/demo are convenience aliases for /demo.html (the self-contained mock UI demo)
9551
+ if (url.pathname === "/demo" || url.pathname === "/ui/demo") {
9552
+ const qs = url.search || "";
9553
+ res.writeHead(302, { Location: `/demo.html${qs}` });
9554
+ res.end();
9555
+ return;
9556
+ }
9557
+
7975
9558
  // Telegram initData exchange: ?tgWebAppData=... or ?initData=... → set session cookie and redirect
7976
9559
  const initDataQuery =
7977
9560
  url.searchParams.get("tgWebAppData") ||
@@ -7984,14 +9567,13 @@ export async function startTelegramUiServer(options = {}) {
7984
9567
  ) {
7985
9568
  const token = process.env.TELEGRAM_BOT_TOKEN || "";
7986
9569
  if (validateInitData(String(initDataQuery), token)) {
7987
- const secure = uiServerTls ? "; Secure" : "";
7988
9570
  const cleanUrl = new URL(url.toString());
7989
9571
  cleanUrl.searchParams.delete("tgWebAppData");
7990
9572
  cleanUrl.searchParams.delete("initData");
7991
9573
  const redirectPath =
7992
9574
  cleanUrl.pathname + (cleanUrl.searchParams.toString() ? `?${cleanUrl.searchParams.toString()}` : "");
7993
9575
  res.writeHead(302, {
7994
- "Set-Cookie": `ve_session=${sessionToken}; HttpOnly; SameSite=Lax; Path=/; Max-Age=86400${secure}`,
9576
+ "Set-Cookie": buildSessionCookieHeader(),
7995
9577
  Location: redirectPath || "/",
7996
9578
  });
7997
9579
  res.end();
@@ -8059,10 +9641,43 @@ export async function startTelegramUiServer(options = {}) {
8059
9641
  stopLogStream(socket);
8060
9642
  } else if (message?.type === "voice-tool-call") {
8061
9643
  // Voice tool call via WebSocket
8062
- const { toolName, args, callId, sessionId: voiceSessionId } = message;
9644
+ const {
9645
+ toolName,
9646
+ args,
9647
+ callId,
9648
+ sessionId: voiceSessionId,
9649
+ executor,
9650
+ mode,
9651
+ model,
9652
+ } = message;
9653
+ const normalizedToolName = String(toolName || "").trim();
9654
+ if (!normalizedToolName) {
9655
+ sendWsMessage(socket, {
9656
+ type: "voice-tool-result",
9657
+ callId,
9658
+ error: "toolName required",
9659
+ ts: Date.now(),
9660
+ });
9661
+ return;
9662
+ }
9663
+ const normalizedSessionId = String(voiceSessionId || "").trim() || undefined;
9664
+ if (normalizedSessionId && normalizedToolName !== "delegate_to_agent") {
9665
+ sendWsMessage(socket, {
9666
+ type: "voice-tool-result",
9667
+ callId,
9668
+ error: "Session-bound calls only allow delegate_to_agent",
9669
+ ts: Date.now(),
9670
+ });
9671
+ return;
9672
+ }
8063
9673
  import("./voice-relay.mjs").then(async (relay) => {
8064
9674
  try {
8065
- const result = await relay.executeVoiceTool(toolName, args || {}, { sessionId: voiceSessionId });
9675
+ const result = await relay.executeVoiceTool(normalizedToolName, args || {}, {
9676
+ sessionId: normalizedSessionId,
9677
+ executor: String(executor || "").trim() || undefined,
9678
+ mode: String(mode || "").trim() || undefined,
9679
+ model: String(model || "").trim() || undefined,
9680
+ });
8066
9681
  sendWsMessage(socket, {
8067
9682
  type: "voice-tool-result",
8068
9683
  callId,
@@ -8130,12 +9745,34 @@ export async function startTelegramUiServer(options = {}) {
8130
9745
  // Reuse a recent session token when possible so browser sessions survive restarts.
8131
9746
  ensureSessionToken();
8132
9747
 
8133
- await new Promise((resolveReady, rejectReady) => {
8134
- uiServer.once("error", rejectReady);
8135
- uiServer.listen(port, options.host || DEFAULT_HOST, () => {
8136
- resolveReady();
9748
+ const listenHost = options.host || DEFAULT_HOST;
9749
+ const listenOnce = (targetPort) =>
9750
+ new Promise((resolveReady, rejectReady) => {
9751
+ const onError = (err) => {
9752
+ uiServer.off("listening", onListening);
9753
+ rejectReady(err);
9754
+ };
9755
+ const onListening = () => {
9756
+ uiServer.off("error", onError);
9757
+ resolveReady();
9758
+ };
9759
+ uiServer.once("error", onError);
9760
+ uiServer.once("listening", onListening);
9761
+ uiServer.listen(targetPort, listenHost);
8137
9762
  });
8138
- });
9763
+
9764
+ try {
9765
+ await listenOnce(port);
9766
+ } catch (err) {
9767
+ const code = String(err?.code || "").toUpperCase();
9768
+ const canRetryWithEphemeral =
9769
+ allowEphemeralPort && port > 0 && (code === "EADDRINUSE" || code === "EACCES");
9770
+ if (!canRetryWithEphemeral) throw err;
9771
+ console.warn(
9772
+ `[telegram-ui] failed to bind ${listenHost}:${port} (${code || "unknown"}); retrying with ephemeral port`,
9773
+ );
9774
+ await listenOnce(0);
9775
+ }
8139
9776
  } catch (err) {
8140
9777
  releaseUiInstanceLock();
8141
9778
  throw err;
@@ -8176,17 +9813,17 @@ export async function startTelegramUiServer(options = {}) {
8176
9813
 
8177
9814
  // ── SECURITY: Warn loudly when auth is disabled ──────────────────────
8178
9815
  if (isAllowUnsafe()) {
8179
- const tunnelMode = (process.env.TELEGRAM_UI_TUNNEL || "auto").toLowerCase();
8180
- const tunnelActive = tunnelMode !== "disabled" && tunnelMode !== "off" && tunnelMode !== "0";
9816
+ const tunnelMode = normalizeTunnelMode(process.env.TELEGRAM_UI_TUNNEL || DEFAULT_TUNNEL_MODE);
9817
+ const tunnelActive = tunnelMode !== TUNNEL_MODE_DISABLED;
8181
9818
  const border = "═".repeat(68);
8182
9819
  console.warn(`\n╔${border}╗`);
8183
- console.warn(`║ DANGER: TELEGRAM_UI_ALLOW_UNSAFE=true — ALL AUTH IS DISABLED ║`);
9820
+ console.warn(`║ :ban: DANGER: TELEGRAM_UI_ALLOW_UNSAFE=true — ALL AUTH IS DISABLED ║`);
8184
9821
  console.warn(`║ ║`);
8185
9822
  console.warn(`║ Anyone with your URL can control agents, read secrets, and ║`);
8186
9823
  console.warn(`║ execute arbitrary commands on this machine. ║`);
8187
9824
  if (tunnelActive) {
8188
9825
  console.warn(`║ ║`);
8189
- console.warn(`║ 🔴 TUNNEL IS ACTIVE — your UI is exposed to the PUBLIC INTERNET ║`);
9826
+ console.warn(`║ :dot: TUNNEL IS ACTIVE — your UI is exposed to the PUBLIC INTERNET ║`);
8190
9827
  console.warn(`║ This means ANYONE can discover your URL and take control. ║`);
8191
9828
  console.warn(`║ Set TELEGRAM_UI_TUNNEL=disabled or TELEGRAM_UI_ALLOW_UNSAFE=false ║`);
8192
9829
  }
@@ -8250,7 +9887,7 @@ export async function startTelegramUiServer(options = {}) {
8250
9887
  if (firewallState) {
8251
9888
  if (firewallState.blocked) {
8252
9889
  console.warn(
8253
- `[telegram-ui] ⚠️ Port ${actualPort}/tcp appears BLOCKED by ${firewallState.firewall} for LAN access.`,
9890
+ `[telegram-ui] :alert: Port ${actualPort}/tcp appears BLOCKED by ${firewallState.firewall} for LAN access.`,
8254
9891
  );
8255
9892
  console.warn(
8256
9893
  `[telegram-ui] To fix, run: ${firewallState.allowCmd}`,
@@ -8266,7 +9903,7 @@ export async function startTelegramUiServer(options = {}) {
8266
9903
  console.log(`[telegram-ui] Telegram Mini App URL: ${tUrl}`);
8267
9904
  if (firewallState?.blocked) {
8268
9905
  console.log(
8269
- `[telegram-ui] ℹ️ Tunnel active — Telegram Mini App works regardless of firewall. ` +
9906
+ `[telegram-ui] :help: Tunnel active — Telegram Mini App works regardless of firewall. ` +
8270
9907
  `LAN browser access still requires port ${actualPort}/tcp to be open.`,
8271
9908
  );
8272
9909
  }