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.
- package/README.md +23 -1
- package/agent/agent-event-bus.mjs +31 -2
- package/agent/agent-pool.mjs +251 -11
- package/agent/agent-prompts.mjs +5 -1
- package/agent/agent-supervisor.mjs +22 -0
- package/agent/primary-agent.mjs +115 -5
- package/cli.mjs +3 -2
- package/config/config.mjs +4 -1
- package/desktop/main.mjs +350 -25
- package/desktop/preload.cjs +8 -0
- package/desktop/preload.mjs +19 -0
- package/entrypoint.mjs +332 -0
- package/infra/health-status.mjs +72 -0
- package/infra/library-manager.mjs +58 -1
- package/infra/maintenance.mjs +1 -2
- package/infra/monitor.mjs +25 -7
- package/infra/session-tracker.mjs +30 -3
- package/package.json +10 -4
- package/server/bosun-mcp-server.mjs +1004 -0
- package/server/setup-web-server.mjs +287 -258
- package/server/ui-server.mjs +218 -23
- package/shell/claude-shell.mjs +14 -1
- package/shell/codex-model-profiles.mjs +166 -29
- package/shell/codex-shell.mjs +56 -18
- package/shell/opencode-providers.mjs +20 -8
- package/task/task-executor.mjs +28 -0
- package/task/task-store.mjs +13 -4
- package/tools/list-todos.mjs +7 -1
- package/ui/app.js +3 -2
- package/ui/components/agent-selector.js +127 -0
- package/ui/components/session-list.js +2 -0
- package/ui/demo-defaults.js +6 -6
- package/ui/modules/router.js +2 -0
- package/ui/modules/state.js +13 -5
- package/ui/tabs/chat.js +3 -0
- package/ui/tabs/library.js +284 -52
- package/ui/tabs/tasks.js +5 -13
- package/workflow/workflow-engine.mjs +16 -4
- package/workflow/workflow-nodes/definitions.mjs +37 -0
- package/workflow/workflow-nodes.mjs +489 -153
- package/workflow/workflow-templates.mjs +0 -5
- package/workflow-templates/github.mjs +106 -16
- package/workspace/worktree-manager.mjs +1 -1
package/agent/primary-agent.mjs
CHANGED
|
@@ -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(
|
|
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 =
|
|
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:
|
|
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(
|
|
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 =
|
|
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 (
|
|
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
|
-
//
|
|
1419
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
1440
|
-
//
|
|
1441
|
-
|
|
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, "&")
|
|
1645
|
+
.replace(/</g, "<")
|
|
1646
|
+
.replace(/>/g, ">")
|
|
1647
|
+
.replace(/"/g, """)
|
|
1648
|
+
.replace(/'/g, "'");
|
|
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
|
package/desktop/preload.cjs
CHANGED
|
@@ -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
|
});
|