bosun 0.41.7 → 0.41.9

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 (43) hide show
  1. package/README.md +23 -1
  2. package/agent/agent-event-bus.mjs +31 -2
  3. package/agent/agent-pool.mjs +251 -11
  4. package/agent/agent-prompts.mjs +5 -1
  5. package/agent/agent-supervisor.mjs +22 -0
  6. package/agent/primary-agent.mjs +115 -5
  7. package/cli.mjs +3 -2
  8. package/config/config.mjs +4 -1
  9. package/desktop/main.mjs +350 -25
  10. package/desktop/preload.cjs +8 -0
  11. package/desktop/preload.mjs +19 -0
  12. package/entrypoint.mjs +332 -0
  13. package/infra/health-status.mjs +72 -0
  14. package/infra/library-manager.mjs +58 -1
  15. package/infra/maintenance.mjs +1 -2
  16. package/infra/monitor.mjs +25 -7
  17. package/infra/session-tracker.mjs +30 -3
  18. package/package.json +10 -4
  19. package/server/bosun-mcp-server.mjs +1004 -0
  20. package/server/setup-web-server.mjs +287 -258
  21. package/server/ui-server.mjs +218 -23
  22. package/shell/claude-shell.mjs +14 -1
  23. package/shell/codex-model-profiles.mjs +166 -29
  24. package/shell/codex-shell.mjs +56 -18
  25. package/shell/opencode-providers.mjs +20 -8
  26. package/task/task-executor.mjs +28 -0
  27. package/task/task-store.mjs +13 -4
  28. package/tools/list-todos.mjs +7 -1
  29. package/ui/app.js +3 -2
  30. package/ui/components/agent-selector.js +127 -0
  31. package/ui/components/session-list.js +2 -0
  32. package/ui/demo-defaults.js +6 -6
  33. package/ui/modules/router.js +2 -0
  34. package/ui/modules/state.js +13 -5
  35. package/ui/tabs/chat.js +3 -0
  36. package/ui/tabs/library.js +284 -52
  37. package/ui/tabs/tasks.js +5 -13
  38. package/workflow/workflow-engine.mjs +16 -4
  39. package/workflow/workflow-nodes/definitions.mjs +37 -0
  40. package/workflow/workflow-nodes.mjs +489 -153
  41. package/workflow/workflow-templates.mjs +0 -5
  42. package/workflow-templates/github.mjs +106 -16
  43. package/workspace/worktree-manager.mjs +1 -1
@@ -11,6 +11,7 @@ import { ensureRepoConfigs, printRepoConfigSummary } from "../config/repo-config
11
11
  import { resolveRepoRoot } from "../config/repo-root.mjs";
12
12
  import { getAgentToolConfig, getEffectiveTools } from "./agent-tool-config.mjs";
13
13
  import { getSessionTracker } from "../infra/session-tracker.mjs";
14
+ import { getEntry, getEntryContent, resolveAgentProfileLibraryMetadata } from "../infra/library-manager.mjs";
14
15
  import { execPooledPrompt } from "./agent-pool.mjs";
15
16
  import {
16
17
  execCodexPrompt,
@@ -290,6 +291,108 @@ function buildPrimaryToolCapabilityContract(options = {}) {
290
291
  ].join("\n");
291
292
  }
292
293
 
294
+ function toStringArray(value) {
295
+ if (!Array.isArray(value)) return [];
296
+ return value.map((item) => String(item || "").trim()).filter(Boolean);
297
+ }
298
+
299
+ function resolveSelectedAgentProfileContext(rootDir, agentProfileId) {
300
+ const id = String(agentProfileId || "").trim();
301
+ if (!id) return null;
302
+ const entry = getEntry(rootDir, id);
303
+ if (!entry || entry.type !== "agent") return null;
304
+ const profile = getEntryContent(rootDir, entry);
305
+ if (!profile || typeof profile !== "object") return null;
306
+
307
+ const metadata = resolveAgentProfileLibraryMetadata(entry, profile);
308
+ const promptEntry = profile?.promptOverride ? getEntry(rootDir, profile.promptOverride) : null;
309
+ const promptContent = promptEntry ? getEntryContent(rootDir, promptEntry) : null;
310
+ const skills = toStringArray(profile?.skills)
311
+ .map((skillId) => {
312
+ const skillEntry = getEntry(rootDir, skillId);
313
+ if (!skillEntry || skillEntry.type !== "skill") return null;
314
+ return {
315
+ id: skillEntry.id,
316
+ name: skillEntry.name || skillEntry.id,
317
+ content: String(getEntryContent(rootDir, skillEntry) || "").trim(),
318
+ };
319
+ })
320
+ .filter(Boolean);
321
+
322
+ return {
323
+ id: entry.id,
324
+ name: entry.name || entry.id,
325
+ description: entry.description || "",
326
+ profile,
327
+ metadata,
328
+ promptOverride: promptEntry
329
+ ? {
330
+ id: promptEntry.id,
331
+ name: promptEntry.name || promptEntry.id,
332
+ content: typeof promptContent === "string"
333
+ ? promptContent.trim()
334
+ : String(promptContent || "").trim(),
335
+ }
336
+ : null,
337
+ skills,
338
+ };
339
+ }
340
+
341
+ function buildPrimaryAgentProfileContract(options = {}) {
342
+ let rootDir = "";
343
+ try {
344
+ rootDir = String(options.cwd || resolveRepoRoot() || process.cwd()).trim();
345
+ } catch {
346
+ rootDir = String(options.cwd || process.cwd()).trim();
347
+ }
348
+ const selected = resolveSelectedAgentProfileContext(rootDir, options.agentProfileId);
349
+ if (!selected) return { block: "", preferredMode: "", preferredModel: "" };
350
+
351
+ const profileInstructions = String(
352
+ selected.profile?.instructions
353
+ || selected.profile?.manualInstructions
354
+ || selected.profile?.voiceInstructions
355
+ || "",
356
+ ).trim();
357
+ const summary = {
358
+ id: selected.id,
359
+ name: selected.name,
360
+ description: selected.description,
361
+ agentCategory: selected.metadata.agentCategory,
362
+ interactiveMode: selected.metadata.interactiveMode,
363
+ interactiveLabel: selected.metadata.interactiveLabel,
364
+ sdk: String(selected.profile?.sdk || "").trim() || null,
365
+ model: String(selected.profile?.model || "").trim() || null,
366
+ showInChatDropdown: selected.metadata.showInChatDropdown,
367
+ skillIds: selected.skills.map((skill) => skill.id),
368
+ };
369
+ const lines = [
370
+ "## Selected Agent Profile",
371
+ "Apply this profile consistently unless the user explicitly overrides it.",
372
+ "```json",
373
+ JSON.stringify(summary, null, 2),
374
+ "```",
375
+ ];
376
+ if (profileInstructions) {
377
+ lines.push("## Profile Instructions", profileInstructions);
378
+ }
379
+ if (selected.promptOverride?.content) {
380
+ lines.push(`## Prompt Override: ${selected.promptOverride.name}`, selected.promptOverride.content);
381
+ }
382
+ if (selected.skills.length > 0) {
383
+ lines.push("## Profile Skills");
384
+ for (const skill of selected.skills) {
385
+ if (!skill.content) continue;
386
+ lines.push(`### ${skill.name}`, skill.content);
387
+ }
388
+ }
389
+ return {
390
+ block: lines.join("\n\n"),
391
+ preferredMode: String(selected.metadata.interactiveMode || "").trim(),
392
+ preferredModel: String(selected.profile?.model || "").trim(),
393
+ };
394
+ }
395
+
293
396
  const ADAPTERS = {
294
397
  "codex-sdk": {
295
398
  name: "codex-sdk",
@@ -933,13 +1036,18 @@ export async function execPrimaryPrompt(userMessage, options = {}) {
933
1036
  if (!initialized) {
934
1037
  await initPrimaryAgent();
935
1038
  }
1039
+ const selectedProfile = buildPrimaryAgentProfileContract(options);
936
1040
  const sessionId =
937
1041
  (options && options.sessionId ? String(options.sessionId) : "") ||
938
1042
  `primary-${activeAdapter.name}`;
939
1043
  const sessionType =
940
1044
  (options && options.sessionType ? String(options.sessionType) : "") ||
941
1045
  "primary";
942
- const effectiveMode = normalizeAgentMode(options.mode || agentMode, agentMode);
1046
+ const effectiveMode = normalizeAgentMode(
1047
+ options.mode || selectedProfile.preferredMode || agentMode,
1048
+ agentMode,
1049
+ );
1050
+ const effectiveModel = options.model || selectedProfile.preferredModel || undefined;
943
1051
  const modePolicy = getModeExecPolicy(effectiveMode);
944
1052
  const timeoutMs = options.timeoutMs || modePolicy?.timeoutMs || PRIMARY_EXEC_TIMEOUT_MS;
945
1053
  const maxFailoverAttempts = Number.isInteger(options.maxFailoverAttempts)
@@ -955,7 +1063,9 @@ export async function execPrimaryPrompt(userMessage, options = {}) {
955
1063
  ? appendAttachmentsToPrompt(userMessage, attachments).message
956
1064
  : userMessage;
957
1065
  const toolContract = buildPrimaryToolCapabilityContract(options);
958
- const messageWithToolContract = `${toolContract}\n\n${messageWithAttachments}`;
1066
+ const messageWithToolContract = [selectedProfile.block, toolContract, messageWithAttachments]
1067
+ .filter(Boolean)
1068
+ .join("\n\n");
959
1069
  const framedMessage = modePrefix ? modePrefix + messageWithToolContract : messageWithToolContract;
960
1070
 
961
1071
  // Record user message (original, without mode prefix)
@@ -974,7 +1084,7 @@ export async function execPrimaryPrompt(userMessage, options = {}) {
974
1084
  onEvent: options.onEvent,
975
1085
  abortController: options.abortController,
976
1086
  cwd: options.cwd,
977
- model: options.model,
1087
+ model: effectiveModel,
978
1088
  sdk: mapAdapterToPoolSdk(activeAdapter.name),
979
1089
  sessionType,
980
1090
  });
@@ -1064,7 +1174,7 @@ export async function execPrimaryPrompt(userMessage, options = {}) {
1064
1174
  }
1065
1175
  }
1066
1176
  const result = await withTimeout(
1067
- adapter.exec(framedMessage, { ...options, sessionId, abortController: timeoutAbort }),
1177
+ adapter.exec(framedMessage, { ...options, sessionId, model: effectiveModel, abortController: timeoutAbort }),
1068
1178
  timeoutMs,
1069
1179
  `${adapterName}.exec`,
1070
1180
  timeoutAbort,
@@ -1133,7 +1243,7 @@ export async function execPrimaryPrompt(userMessage, options = {}) {
1133
1243
  }
1134
1244
  }
1135
1245
  const retryResult = await withTimeout(
1136
- adapter.exec(framedMessage, { ...options, sessionId, abortController: timeoutAbort }),
1246
+ adapter.exec(framedMessage, { ...options, sessionId, model: effectiveModel, abortController: timeoutAbort }),
1137
1247
  timeoutMs,
1138
1248
  `${adapterName}.exec.retry`,
1139
1249
  timeoutAbort,
package/cli.mjs CHANGED
@@ -79,6 +79,7 @@ function showHelp() {
79
79
  COMMANDS
80
80
  workflow list List declarative pipeline workflows
81
81
  workflow run <name> Run a declarative pipeline workflow
82
+ audit <command> Run codebase annotation audit tools (scan|generate|warn|manifest|index|trim|conformity|migrate)
82
83
  --setup Launch the web-based setup wizard (default)
83
84
  --setup-terminal Run the legacy terminal setup wizard
84
85
  --where Show the resolved bosun config directory
@@ -1368,8 +1369,8 @@ async function main() {
1368
1369
  const { runAuditCli } = await import("./lib/codebase-audit.mjs");
1369
1370
  const commandStartIndex = auditCommandIndex >= 0 ? auditCommandIndex : auditFlagIndex;
1370
1371
  const auditArgs = args.slice(commandStartIndex + 1);
1371
- await runAuditCli(auditArgs);
1372
- process.exit(0);
1372
+ const { exitCode } = await runAuditCli(auditArgs);
1373
+ process.exit(exitCode);
1373
1374
  }
1374
1375
 
1375
1376
  if (args[0] === "node:create" || (args[0] === "node" && args[1] === "create")) {
package/config/config.mjs CHANGED
@@ -1217,7 +1217,10 @@ export function loadConfig(argv = process.argv, options = {}) {
1217
1217
  {
1218
1218
  const selPath = selectedRepository?.path || "";
1219
1219
  const selHasGit = selPath && existsSync(resolve(selPath, ".git"));
1220
- repoRoot = (selHasGit ? selPath : null) || getFallbackRepoRoot();
1220
+ repoRoot =
1221
+ explicitRepoRoot ||
1222
+ (selHasGit ? selPath : null) ||
1223
+ getFallbackRepoRoot();
1221
1224
  }
1222
1225
 
1223
1226
  if (
package/desktop/main.mjs CHANGED
@@ -95,9 +95,12 @@ let runtimeConfigLoaded = false;
95
95
  let trayMode = false;
96
96
  /** True when the main window should start hidden (background mode). */
97
97
  let startHidden = false;
98
+ /** True when connected to a user-configured remote Bosun instance. */
99
+ let remoteConnectionActive = false;
98
100
  const DEFAULT_TELEGRAM_UI_PORT = 3080;
99
101
  const DESKTOP_RELEASES_URL = "https://github.com/virtengine/bosun/releases";
100
102
  const STARTUP_UPDATE_REMIND_LATER_MS = 12 * 60 * 60 * 1000;
103
+ const REMOTE_CONNECTION_FILE = "remote-connection.json";
101
104
 
102
105
  /** @type {{
103
106
  * checking: boolean,
@@ -199,18 +202,30 @@ function installDesktopAuthHeaderBridge() {
199
202
  if (!ses) return;
200
203
  ses.webRequest.onBeforeSendHeaders((details, callback) => {
201
204
  try {
202
- const desktopKey = String(process.env.BOSUN_DESKTOP_API_KEY || "").trim();
203
- if (!desktopKey) {
204
- callback({ requestHeaders: details.requestHeaders });
205
- return;
206
- }
207
205
  if (!isTrustedDesktopRequestUrl(details?.url || "")) {
208
206
  callback({ requestHeaders: details.requestHeaders });
209
207
  return;
210
208
  }
211
209
  const headers = { ...(details.requestHeaders || {}) };
212
210
  const existingAuth = String(headers.Authorization || headers.authorization || "").trim();
213
- if (!existingAuth) {
211
+ if (existingAuth) {
212
+ callback({ requestHeaders: headers });
213
+ return;
214
+ }
215
+
216
+ // When connected to a remote instance, use the configured API key
217
+ if (remoteConnectionActive) {
218
+ const remote = readRemoteConnectionConfig();
219
+ if (remote.apiKey) {
220
+ headers["X-API-Key"] = remote.apiKey;
221
+ callback({ requestHeaders: headers });
222
+ return;
223
+ }
224
+ }
225
+
226
+ // Otherwise use the local desktop API key
227
+ const desktopKey = String(process.env.BOSUN_DESKTOP_API_KEY || "").trim();
228
+ if (desktopKey) {
214
229
  headers.Authorization = `Bearer ${desktopKey}`;
215
230
  }
216
231
  callback({ requestHeaders: headers });
@@ -373,6 +388,66 @@ function ensureDesktopApiKeyInEnv() {
373
388
  return current;
374
389
  }
375
390
 
391
+ /* ── Remote-connection config persistence ─────────────────────── */
392
+
393
+ /**
394
+ * Read the persisted remote-connection config.
395
+ * Returns { enabled: boolean, endpoint: string, apiKey: string }.
396
+ */
397
+ function readRemoteConnectionConfig() {
398
+ try {
399
+ const file = resolve(resolveDesktopConfigDir(), REMOTE_CONNECTION_FILE);
400
+ if (!existsSync(file)) return { enabled: false, endpoint: "", apiKey: "" };
401
+ const raw = JSON.parse(readFileSync(file, "utf8"));
402
+ return {
403
+ enabled: !!raw?.enabled,
404
+ endpoint: String(raw?.endpoint || "").trim(),
405
+ apiKey: String(raw?.apiKey || "").trim(),
406
+ };
407
+ } catch {
408
+ return { enabled: false, endpoint: "", apiKey: "" };
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Persist remote-connection config to disk.
414
+ * @param {{ enabled: boolean, endpoint: string, apiKey: string }} config
415
+ */
416
+ function saveRemoteConnectionConfig(config) {
417
+ const dir = resolveDesktopConfigDir();
418
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
419
+ const file = resolve(dir, REMOTE_CONNECTION_FILE);
420
+ const payload = {
421
+ enabled: !!config?.enabled,
422
+ endpoint: String(config?.endpoint || "").trim(),
423
+ apiKey: String(config?.apiKey || "").trim(),
424
+ };
425
+ writeFileSync(file, JSON.stringify(payload, null, 2), "utf8");
426
+ return payload;
427
+ }
428
+
429
+ /**
430
+ * Probe a remote Bosun endpoint. Returns true if reachable.
431
+ */
432
+ async function probeRemoteEndpoint(endpoint, apiKey) {
433
+ try {
434
+ const url = new URL("/api/status", endpoint);
435
+ const headers = {};
436
+ if (apiKey) headers["x-api-key"] = apiKey;
437
+ const mod = url.protocol === "https:" ? await import("node:https") : await import("node:http");
438
+ return new Promise((ok) => {
439
+ const req = mod.get(url, { headers, timeout: 3000, rejectUnauthorized: false }, (res) => {
440
+ ok(res.statusCode >= 200 && res.statusCode < 400);
441
+ res.resume();
442
+ });
443
+ req.on("error", () => ok(false));
444
+ req.on("timeout", () => { req.destroy(); ok(false); });
445
+ });
446
+ } catch {
447
+ return false;
448
+ }
449
+ }
450
+
376
451
  function isProcessAlive(pid) {
377
452
  try {
378
453
  process.kill(pid, 0);
@@ -1261,7 +1336,7 @@ async function loadRuntimeConfig() {
1261
1336
 
1262
1337
  async function loadUiServerModule() {
1263
1338
  if (uiApi) return uiApi;
1264
- uiApi = await loadBosunModule("ui-server.mjs");
1339
+ uiApi = await loadBosunModule("server/ui-server.mjs");
1265
1340
  return uiApi;
1266
1341
  }
1267
1342
 
@@ -1412,18 +1487,37 @@ async function startUiServer() {
1412
1487
 
1413
1488
  async function buildUiUrl() {
1414
1489
  await loadRuntimeConfig();
1490
+
1491
+ // ── Check for active remote connection config ──
1492
+ const remote = readRemoteConnectionConfig();
1493
+ if (remote.enabled && remote.endpoint) {
1494
+ const reachable = await probeRemoteEndpoint(remote.endpoint, remote.apiKey);
1495
+ if (reachable) {
1496
+ console.log("[desktop] using remote Bosun endpoint:", remote.endpoint);
1497
+ remoteConnectionActive = true;
1498
+ const remoteTarget = new URL(remote.endpoint);
1499
+ uiOrigin = remoteTarget.origin;
1500
+ // Do NOT put the API key in the URL — it leaks into logs, window
1501
+ // history, crash reports, and Referer headers. The desktop auth
1502
+ // header bridge (installDesktopAuthHeaderBridge) injects X-API-Key
1503
+ // on every request to this origin automatically.
1504
+ return remoteTarget.toString();
1505
+ }
1506
+ console.warn("[desktop] remote endpoint unreachable, falling back to local");
1507
+ remoteConnectionActive = false;
1508
+ }
1509
+
1415
1510
  const daemonUrl = await resolveDaemonUiUrl();
1416
1511
  if (daemonUrl) {
1417
1512
  uiOrigin = new URL(daemonUrl).origin;
1418
- // Authenticate the initial WebView load against the separately-running
1419
- // daemon using the desktop API key (set during bootstrap).
1513
+ // The desktop auth header bridge injects Authorization: Bearer <key>
1514
+ // on every request, so the initial page load will carry the header.
1515
+ // The server validates the header and sets a ve_session cookie, making
1516
+ // subsequent in-page fetches authenticated without URL secrets.
1420
1517
  const desktopKey = ensureDesktopApiKeyInEnv();
1421
- if (desktopKey) {
1422
- const daemonTarget = new URL(daemonUrl);
1423
- daemonTarget.searchParams.set("desktopKey", desktopKey);
1424
- return daemonTarget.toString();
1518
+ if (!desktopKey) {
1519
+ console.warn("[desktop] BOSUN_DESKTOP_API_KEY unavailable; daemon UI may return 401 until auth bootstrap succeeds");
1425
1520
  }
1426
- console.warn("[desktop] BOSUN_DESKTOP_API_KEY unavailable; daemon UI may return 401 until auth bootstrap succeeds");
1427
1521
  return daemonUrl;
1428
1522
  }
1429
1523
  // Daemon is not reachable — flag it so the window can show an offline banner.
@@ -1436,20 +1530,176 @@ async function buildUiUrl() {
1436
1530
  }
1437
1531
  const targetUrl = new URL(uiServerUrl);
1438
1532
  uiOrigin = targetUrl.origin;
1439
- // Prefer the non-expiring desktop API key over the TTL-based session token.
1440
- // Both result in the server setting a ve_session cookie and redirecting to /.
1441
- const desktopKey = ensureDesktopApiKeyInEnv();
1442
- if (desktopKey) {
1443
- targetUrl.searchParams.set("desktopKey", desktopKey);
1444
- } else {
1445
- const sessionToken = api.getSessionToken();
1446
- if (sessionToken) {
1447
- targetUrl.searchParams.set("token", sessionToken);
1448
- }
1449
- }
1533
+ // Auth is handled by the header bridge. For the local embedded server,
1534
+ // the request arrives with Authorization: Bearer <desktopKey> and the
1535
+ // server validates + sets a ve_session cookie. No URL secrets needed.
1450
1536
  return targetUrl.toString();
1451
1537
  }
1452
1538
 
1539
+ /* ── Launch-time connection dialogs ──────────────────────────── */
1540
+
1541
+ /**
1542
+ * Show a dialog asking the user how to proceed when no local daemon is found.
1543
+ * Returns "local" | "remote" | "offline".
1544
+ */
1545
+ async function showConnectionChoiceDialog() {
1546
+ const { response } = await dialog.showMessageBox(mainWindow, {
1547
+ type: "question",
1548
+ title: "Bosun — No Running Instance Detected",
1549
+ message: "Bosun couldn't find a running daemon.\nHow would you like to proceed?",
1550
+ detail: [
1551
+ "• Start Local — launch Bosun in this process (default)",
1552
+ "• Connect to Remote — connect to an external Bosun instance (Docker, VM, cloud)",
1553
+ "• Continue Offline — open the portal in offline mode",
1554
+ ].join("\n"),
1555
+ buttons: ["Start Local", "Connect to Remote", "Continue Offline"],
1556
+ defaultId: 0,
1557
+ cancelId: 2,
1558
+ noLink: true,
1559
+ });
1560
+ if (response === 1) return "remote";
1561
+ if (response === 2) return "offline";
1562
+ return "local";
1563
+ }
1564
+
1565
+ /**
1566
+ * Prompt the user for a remote Bosun endpoint URL and API key.
1567
+ * Returns { endpoint, apiKey } or null if cancelled.
1568
+ */
1569
+ async function showRemoteConnectionInputDialog() {
1570
+ /* ── Step 1: Endpoint URL ── */
1571
+ const existing = readRemoteConnectionConfig();
1572
+ const endpointPrompt = await dialog.showMessageBox(mainWindow, {
1573
+ type: "question",
1574
+ title: "Remote Bosun — Enter Endpoint",
1575
+ message: "Enter the URL of the remote Bosun instance:",
1576
+ detail: `Example: https://bosun.example.com:3080\n\nCurrent: ${existing.endpoint || "(none)"}`,
1577
+ buttons: ["OK", "Cancel"],
1578
+ defaultId: 0,
1579
+ cancelId: 1,
1580
+ noLink: true,
1581
+ // Electron doesn't have an input dialog builtin, so we use a two-step
1582
+ // approach with a prompt window for the actual text input.
1583
+ });
1584
+ if (endpointPrompt.response === 1) return null;
1585
+
1586
+ // Use a small BrowserWindow as a text-input dialog
1587
+ const endpoint = await showTextInputWindow(
1588
+ "Remote Bosun — Endpoint URL",
1589
+ "Enter the Bosun endpoint URL:",
1590
+ existing.endpoint || "https://",
1591
+ "e.g. https://bosun.example.com:3080",
1592
+ );
1593
+ if (!endpoint) return null;
1594
+
1595
+ /* ── Step 2: API Key ── */
1596
+ const apiKey = await showTextInputWindow(
1597
+ "Remote Bosun — API Key",
1598
+ "Enter the API key (BOSUN_API_KEY) for authentication:",
1599
+ existing.apiKey || "",
1600
+ "Leave empty if none is required",
1601
+ );
1602
+ // apiKey can be empty — that's valid (server may have auth disabled)
1603
+
1604
+ // Validate connectivity before saving
1605
+ const reachable = await probeRemoteEndpoint(endpoint, apiKey || "");
1606
+ if (!reachable) {
1607
+ await dialog.showMessageBox(mainWindow, {
1608
+ type: "warning",
1609
+ title: "Connection Failed",
1610
+ message: `Could not reach ${endpoint}`,
1611
+ detail: "The remote instance may be offline or the URL/API key may be incorrect.\nThe config has been saved — you can update it in Settings.",
1612
+ buttons: ["OK"],
1613
+ });
1614
+ }
1615
+ return { endpoint, apiKey: apiKey || "" };
1616
+ }
1617
+
1618
+ /**
1619
+ * Show a small modal window with a text input field.
1620
+ * Returns the entered string or null if cancelled.
1621
+ */
1622
+ function showTextInputWindow(title, label, defaultValue, placeholder) {
1623
+ return new Promise((resolvePromise) => {
1624
+ const parent = mainWindow || undefined;
1625
+ const inputWin = new BrowserWindow({
1626
+ width: 520,
1627
+ height: 210,
1628
+ resizable: false,
1629
+ minimizable: false,
1630
+ maximizable: false,
1631
+ modal: !!parent,
1632
+ parent,
1633
+ show: false,
1634
+ backgroundColor: "#1a1a2e",
1635
+ autoHideMenuBar: true,
1636
+ webPreferences: {
1637
+ contextIsolation: true,
1638
+ nodeIntegration: false,
1639
+ sandbox: true,
1640
+ },
1641
+ });
1642
+ const escapeHtml = (value) =>
1643
+ String(value ?? "")
1644
+ .replace(/&/g, "&amp;")
1645
+ .replace(/</g, "&lt;")
1646
+ .replace(/>/g, "&gt;")
1647
+ .replace(/"/g, "&quot;")
1648
+ .replace(/'/g, "&#39;");
1649
+ const escapedLabel = escapeHtml(label);
1650
+ const escapedDefault = escapeHtml(defaultValue);
1651
+ const escapedPlaceholder = escapeHtml(placeholder);
1652
+ const html = `data:text/html;charset=utf-8,${encodeURIComponent(`<!DOCTYPE html>
1653
+ <html><head><style>
1654
+ * { margin: 0; padding: 0; box-sizing: border-box; }
1655
+ body { font: 14px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1656
+ background: #1a1a2e; color: #e0e0e0; padding: 20px; display: flex;
1657
+ flex-direction: column; height: 100vh; }
1658
+ label { display: block; margin-bottom: 8px; font-weight: 600; }
1659
+ input { width: 100%; padding: 8px 12px; font-size: 14px; border: 1px solid #444;
1660
+ border-radius: 6px; background: #0f0f23; color: #e0e0e0; outline: none; }
1661
+ input:focus { border-color: #6366f1; }
1662
+ .btns { display: flex; gap: 10px; justify-content: flex-end; margin-top: auto; }
1663
+ button { padding: 8px 20px; border: none; border-radius: 6px; font-size: 14px;
1664
+ cursor: pointer; font-weight: 500; }
1665
+ .ok { background: #4f46e5; color: #fff; }
1666
+ .ok:hover { background: #6366f1; }
1667
+ .cancel { background: #333; color: #ccc; }
1668
+ .cancel:hover { background: #444; }
1669
+ </style></head><body>
1670
+ <label>${escapedLabel}</label>
1671
+ <input id="val" type="text" value="${escapedDefault}" placeholder="${escapedPlaceholder}" autofocus />
1672
+ <div class="btns">
1673
+ <button class="cancel" onclick="close()">Cancel</button>
1674
+ <button class="ok" onclick="submit()">OK</button>
1675
+ </div>
1676
+ <script>
1677
+ const inp = document.getElementById('val');
1678
+ inp.select();
1679
+ function submit() { document.title = 'RESULT:' + inp.value; }
1680
+ function close() { document.title = 'CANCEL'; }
1681
+ inp.addEventListener('keydown', e => { if (e.key === 'Enter') submit(); if (e.key === 'Escape') close(); });
1682
+ </script>
1683
+ </body></html>`)}`;
1684
+
1685
+ inputWin.loadURL(html);
1686
+ inputWin.once("ready-to-show", () => inputWin.show());
1687
+
1688
+ inputWin.webContents.on("page-title-updated", (_ev, newTitle) => {
1689
+ if (newTitle.startsWith("RESULT:")) {
1690
+ const val = newTitle.slice(7).trim();
1691
+ inputWin.destroy();
1692
+ resolvePromise(val || null);
1693
+ } else if (newTitle === "CANCEL") {
1694
+ inputWin.destroy();
1695
+ resolvePromise(null);
1696
+ }
1697
+ });
1698
+
1699
+ inputWin.on("closed", () => resolvePromise(null));
1700
+ });
1701
+ }
1702
+
1453
1703
  async function createMainWindow() {
1454
1704
  if (mainWindow) return;
1455
1705
  const iconPath = resolveDesktopIconPath();
@@ -1530,6 +1780,28 @@ async function createMainWindow() {
1530
1780
  await mainWindow.loadURL(buildLoadingPageUrl("Starting Bosun services..."));
1531
1781
  await setLoadingMessage("Preparing background daemon...");
1532
1782
  await ensureDaemonRunning();
1783
+
1784
+ // ── If daemon is offline, check remote config or prompt user ──
1785
+ if (bosunDaemonWasOffline) {
1786
+ const remote = readRemoteConnectionConfig();
1787
+ const remoteReachable = remote.enabled && remote.endpoint
1788
+ ? await probeRemoteEndpoint(remote.endpoint, remote.apiKey)
1789
+ : false;
1790
+
1791
+ if (!remoteReachable) {
1792
+ await setLoadingMessage("No running Bosun instance detected...");
1793
+ const choice = await showConnectionChoiceDialog();
1794
+ if (choice === "remote") {
1795
+ const config = await showRemoteConnectionInputDialog();
1796
+ if (config) {
1797
+ saveRemoteConnectionConfig({ ...config, enabled: true });
1798
+ }
1799
+ }
1800
+ // "local" choice: we already tried; buildUiUrl will fall back to in-process server
1801
+ // "offline" choice: continue with in-process server
1802
+ }
1803
+ }
1804
+
1533
1805
  await setLoadingMessage("Connecting to Bosun portal...");
1534
1806
  const uiUrl = await buildUiUrl();
1535
1807
  await mainWindow.loadURL(uiUrl);
@@ -2100,6 +2372,56 @@ function registerDesktopIpc() {
2100
2372
  if (!workspaceId) return { ok: false, error: "workspaceId required" };
2101
2373
  return switchWorkspace(workspaceId);
2102
2374
  });
2375
+
2376
+ // ── Connection Settings IPC ──────────────────────────────────────────
2377
+ /** Get the current remote connection config. */
2378
+ ipcMain.handle("bosun:connection:get", () => {
2379
+ const config = readRemoteConnectionConfig();
2380
+ return { ok: true, ...config, active: remoteConnectionActive };
2381
+ });
2382
+
2383
+ /**
2384
+ * Update the remote connection config.
2385
+ * Payload: { enabled: boolean, endpoint: string, apiKey: string }
2386
+ */
2387
+ ipcMain.handle("bosun:connection:set", async (_event, config) => {
2388
+ try {
2389
+ const saved = saveRemoteConnectionConfig(config);
2390
+ // If switching to remote, update origin tracking flag
2391
+ if (saved.enabled && saved.endpoint) {
2392
+ remoteConnectionActive = true;
2393
+ } else {
2394
+ remoteConnectionActive = false;
2395
+ }
2396
+ return { ok: true, ...saved };
2397
+ } catch (err) {
2398
+ return { ok: false, error: err?.message || "Failed to save connection config" };
2399
+ }
2400
+ });
2401
+
2402
+ /**
2403
+ * Test connectivity to a remote Bosun endpoint.
2404
+ * Payload: { endpoint: string, apiKey: string }
2405
+ */
2406
+ ipcMain.handle("bosun:connection:test", async (_event, { endpoint, apiKey } = {}) => {
2407
+ if (!endpoint) return { ok: false, error: "endpoint required" };
2408
+ const reachable = await probeRemoteEndpoint(endpoint, apiKey || "");
2409
+ return { ok: true, reachable };
2410
+ });
2411
+
2412
+ /**
2413
+ * Open the remote connection setup dialog.
2414
+ * Returns the config if set, or null if cancelled.
2415
+ */
2416
+ ipcMain.handle("bosun:connection:setup", async () => {
2417
+ const config = await showRemoteConnectionInputDialog();
2418
+ if (config) {
2419
+ saveRemoteConnectionConfig({ ...config, enabled: true });
2420
+ remoteConnectionActive = true;
2421
+ return { ok: true, ...config, enabled: true };
2422
+ }
2423
+ return { ok: false, cancelled: true };
2424
+ });
2103
2425
  }
2104
2426
 
2105
2427
  async function bootstrap() {
@@ -2108,6 +2430,9 @@ async function bootstrap() {
2108
2430
  // — before any network request is made (config loading, API key probe, etc.).
2109
2431
  // allow-insecure-localhost (set pre-ready above) handles 127.0.0.1; this
2110
2432
  // setCertificateVerifyProc covers LAN IPs (192.168.x.x / 10.x etc.).
2433
+ //
2434
+ // SECURITY: Only bypass TLS verification for localhost and private-network
2435
+ // addresses. Remote/public endpoints must pass standard chain verification.
2111
2436
  session.defaultSession.setCertificateVerifyProc((request, callback) => {
2112
2437
  if (isLocalHost(request.hostname)) {
2113
2438
  callback(0); // 0 = verified OK
@@ -23,4 +23,12 @@ contextBridge.exposeInMainWorld("veDesktop", {
23
23
  resetAll: () => ipcRenderer.invoke("bosun:shortcuts:resetAll"),
24
24
  showDialog: () => ipcRenderer.invoke("bosun:shortcuts:showDialog"),
25
25
  },
26
+
27
+ connection: {
28
+ get: () => ipcRenderer.invoke("bosun:connection:get"),
29
+ set: (config) => ipcRenderer.invoke("bosun:connection:set", config),
30
+ test: (endpoint, apiKey) =>
31
+ ipcRenderer.invoke("bosun:connection:test", { endpoint, apiKey }),
32
+ setup: () => ipcRenderer.invoke("bosun:connection:setup"),
33
+ },
26
34
  });