fraim-hub 2.0.310 → 2.0.312

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.
@@ -24,6 +24,8 @@ const fraim_mcp_latest_launcher_1 = require("../cli/mcp/fraim-mcp-latest-launche
24
24
  const desktop_auto_updater_1 = require("./desktop-auto-updater");
25
25
  const hub_app_materializer_1 = require("./hub-app-materializer");
26
26
  const hub_instance_reconciliation_1 = require("./hub-instance-reconciliation");
27
+ const hub_main_diagnostics_1 = require("./hub-main-diagnostics");
28
+ const devtools_window_1 = require("./devtools-window");
27
29
  // Keep installed, running, and user-pinned Windows shortcuts grouped under the
28
30
  // stable identity declared in packages/fraim-hub/package.json.
29
31
  electron_1.app.setAppUserModelId('ai.fraim.hub');
@@ -163,7 +165,7 @@ async function resolveLatestHubAppVersion() {
163
165
  bundledVersion: runningVersion,
164
166
  bundledEntryPath: __filename,
165
167
  });
166
- return { upgradable: resolved.version !== runningVersion, latest: resolved.version };
168
+ return { upgradable: resolved.version !== runningVersion, latest: resolved.version, error: resolved.materializationError };
167
169
  }
168
170
  // Issue #1415: a second-instance activation (the user re-clicking the icon while the Hub is
169
171
  // already running) is the common case once login-item autostart + hide-to-tray keep the Hub
@@ -189,9 +191,12 @@ async function checkForHubAppUpdateOnSecondInstance() {
189
191
  // response reaches the browser (which uses it to start polling for the new instance) before this
190
192
  // process starts tearing down.
191
193
  async function restartToLatestNow() {
192
- const { upgradable, latest } = await resolveLatestHubAppVersion();
193
- if (!upgradable)
194
- return { restarting: false, latest };
194
+ const { upgradable, latest, error } = await resolveLatestHubAppVersion();
195
+ if (!upgradable) {
196
+ if (error)
197
+ console.warn(`[fraim] manual restart requested but could not complete: ${error}`);
198
+ return { restarting: false, latest, error };
199
+ }
195
200
  console.log(`[fraim] manual restart requested; relaunching to FRAIM Hub ${latest}`);
196
201
  setTimeout(() => {
197
202
  electron_1.app.relaunch();
@@ -274,6 +279,14 @@ function shouldConfigureOfficeSideload() {
274
279
  function displayName(runtimeId = process.env.FRAIM_HUB_RUNTIME_ID || 'hub') {
275
280
  return process.env.FRAIM_HUB_DISPLAY_NAME || (runtimeId === 'hub2' ? 'FRAIM Hub 2' : 'FRAIM Hub');
276
281
  }
282
+ async function openMainWindowDevTools(hubUrl) {
283
+ await (0, devtools_window_1.openHubWindowDevTools)({
284
+ hubUrl,
285
+ getWindow: () => mainWindow,
286
+ createWindow,
287
+ recordOpenRequested: () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('hub.devtools.open_requested'),
288
+ });
289
+ }
277
290
  function buildTrayMenu(hubUrl, runtimeId) {
278
291
  const name = displayName(runtimeId);
279
292
  return electron_1.Menu.buildFromTemplate([
@@ -289,6 +302,14 @@ function buildTrayMenu(hubUrl, runtimeId) {
289
302
  }
290
303
  },
291
304
  },
305
+ {
306
+ label: 'Open DevTools',
307
+ click: () => {
308
+ void openMainWindowDevTools(hubUrl).catch((error) => {
309
+ console.warn('[fraim] could not open Hub DevTools:', error);
310
+ });
311
+ },
312
+ },
292
313
  {
293
314
  // #755: surface the running build version so staleness is diagnosable at a glance.
294
315
  label: `About ${name}`,
@@ -345,6 +366,36 @@ function createTray(hubUrl, runtimeId) {
345
366
  // ---------------------------------------------------------------------------
346
367
  // BrowserWindow
347
368
  // ---------------------------------------------------------------------------
369
+ function buildDesktopDebugSnapshot(runtimeId) {
370
+ const win = mainWindow;
371
+ const windowPresent = Boolean(win);
372
+ const isDestroyed = win ? win.isDestroyed() : null;
373
+ const canReadWindow = Boolean(win && !isDestroyed);
374
+ const safeRead = (reader) => {
375
+ if (!canReadWindow)
376
+ return null;
377
+ try {
378
+ return reader();
379
+ }
380
+ catch {
381
+ return null;
382
+ }
383
+ };
384
+ return {
385
+ runtimeId,
386
+ windowPresent,
387
+ isDestroyed,
388
+ isFocused: safeRead(() => win.isFocused()),
389
+ isVisible: safeRead(() => win.isVisible()),
390
+ isMinimized: safeRead(() => win.isMinimized()),
391
+ bounds: safeRead(() => win.getBounds()),
392
+ title: safeRead(() => win.getTitle()),
393
+ url: safeRead(() => win.webContents.getURL()),
394
+ webContentsDestroyed: win ? safeRead(() => win.webContents.isDestroyed()) : null,
395
+ devToolsOpened: safeRead(() => win.webContents.isDevToolsOpened()),
396
+ gpuFeatureStatus: electron_1.app.getGPUFeatureStatus(),
397
+ };
398
+ }
348
399
  async function createWindow(url, runtimeId = process.env.FRAIM_HUB_RUNTIME_ID || 'hub') {
349
400
  const { width, height } = preferredWindowSize();
350
401
  const isMac = process.platform === 'darwin';
@@ -374,6 +425,32 @@ async function createWindow(url, runtimeId = process.env.FRAIM_HUB_RUNTIME_ID ||
374
425
  webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
375
426
  });
376
427
  electron_1.Menu.setApplicationMenu(null);
428
+ mainWindow.on('focus', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.focus'));
429
+ mainWindow.on('blur', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.blur'));
430
+ mainWindow.on('show', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.show'));
431
+ mainWindow.on('hide', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.hide'));
432
+ mainWindow.on('close', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.close', { isQuitting }));
433
+ mainWindow.webContents.on('render-process-gone', (_event, details) => {
434
+ (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('webcontents.render_process_gone', details);
435
+ });
436
+ mainWindow.webContents.on('unresponsive', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('webcontents.unresponsive'));
437
+ mainWindow.webContents.on('responsive', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('webcontents.responsive'));
438
+ mainWindow.webContents.on('did-finish-load', () => {
439
+ (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('webcontents.did_finish_load', {
440
+ url: mainWindow?.webContents.getURL() || '',
441
+ title: mainWindow?.getTitle() || '',
442
+ });
443
+ });
444
+ mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
445
+ (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('webcontents.did_fail_load', {
446
+ errorCode,
447
+ errorDescription,
448
+ validatedURL,
449
+ isMainFrame,
450
+ });
451
+ });
452
+ mainWindow.webContents.on('devtools-opened', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('webcontents.devtools_opened'));
453
+ mainWindow.webContents.on('devtools-closed', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('webcontents.devtools_closed'));
377
454
  try {
378
455
  await mainWindow.webContents.session.clearCache();
379
456
  }
@@ -448,7 +525,10 @@ async function createWindow(url, runtimeId = process.env.FRAIM_HUB_RUNTIME_ID ||
448
525
  mainWindow?.hide();
449
526
  }
450
527
  });
451
- mainWindow.on('closed', () => { mainWindow = null; });
528
+ mainWindow.on('closed', () => {
529
+ (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.closed');
530
+ mainWindow = null;
531
+ });
452
532
  await mainWindow.loadURL(url);
453
533
  mainWindow.webContents.setZoomFactor(1);
454
534
  }
@@ -499,6 +579,9 @@ async function launchDesktopShell(options) {
499
579
  return result.canceled || result.filePaths.length === 0 ? null : result.filePaths[0];
500
580
  },
501
581
  restartToLatest: restartToLatestNow,
582
+ hubMainDiagnostics: {
583
+ snapshot: () => (0, hub_main_diagnostics_1.getHubMainDiagnosticsSnapshot)(buildDesktopDebugSnapshot(runtimeId)),
584
+ },
502
585
  });
503
586
  await server.start(httpPort);
504
587
  const resolvedProjectPath = server.getProjectPath();
@@ -566,6 +649,16 @@ async function launchDesktopShell(options) {
566
649
  async function bootstrap() {
567
650
  const options = parseArgs(process.argv.slice(2));
568
651
  applyUserDataOverride();
652
+ (0, hub_main_diagnostics_1.installHubMainConsoleTee)();
653
+ (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('hub.startup', {
654
+ runtimeId: options.runtimeId,
655
+ preferredPort: options.preferredPort,
656
+ hasProjectPath: Boolean(options.projectPath),
657
+ });
658
+ electron_1.app.on('child-process-gone', (_event, details) => {
659
+ (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('app.child_process_gone', details);
660
+ });
661
+ electron_1.app.on('gpu-info-update', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('app.gpu_info_update'));
569
662
  // Single-instance lock — if another instance is already running, focus it
570
663
  // and exit rather than spawning a second server + window.
571
664
  // Skip when FRAIM_AI_HUB_FAKE_HOST=1 (test mode) so Playwright can launch
@@ -577,6 +670,7 @@ async function bootstrap() {
577
670
  return;
578
671
  }
579
672
  electron_1.app.on('second-instance', () => {
673
+ (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('hub.second_instance');
580
674
  void electron_1.app.whenReady().then(checkForRelaunchAttemptUpdate);
581
675
  if (mainWindow) {
582
676
  mainWindow.show();
@@ -624,6 +718,7 @@ async function bootstrap() {
624
718
  // macOS: clicking dock icon re-shows the window. Unlike Windows/Linux, this never spawns a
625
719
  // second process (so 'second-instance' never fires here) — it is macOS's equivalent
626
720
  // relaunch-attempt event, so it gets the same npm-latest re-check (#1415).
721
+ (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('hub.activate');
627
722
  checkForRelaunchAttemptUpdate();
628
723
  if (mainWindow) {
629
724
  mainWindow.show();
@@ -632,6 +727,7 @@ async function bootstrap() {
632
727
  });
633
728
  electron_1.app.on('before-quit', () => {
634
729
  isQuitting = true;
730
+ (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('hub.shutdown');
635
731
  // #755: clear the runtime file so a later `fraim hub` doesn't treat a
636
732
  // cleanly-exited instance as live.
637
733
  try {
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.openHubWindowDevTools = openHubWindowDevTools;
4
+ async function openHubWindowDevTools(options) {
5
+ options.recordOpenRequested?.();
6
+ let window = options.getWindow();
7
+ if (!window || window.isDestroyed()) {
8
+ await options.createWindow(options.hubUrl);
9
+ window = options.getWindow();
10
+ }
11
+ if (!window || window.isDestroyed())
12
+ return 'unavailable';
13
+ window.show();
14
+ window.focus();
15
+ window.webContents.openDevTools({ mode: 'detach' });
16
+ return 'opened';
17
+ }
@@ -2501,15 +2501,13 @@ class CliHostRuntime {
2501
2501
  return isEmployeeDetectionRefreshing();
2502
2502
  }
2503
2503
  startRun(hostId, projectPath, message, handlers, sessionId, launchContext) {
2504
- // R11: start/startDirect mint sessions rather than resuming one, so they
2505
- // stay outside the continuation queue entirely.
2506
- return this.spawn(hostId, (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(buildStartPlan(hostId, message, sessionId), launchContext?.agent, launchContext?.env), projectPath, handlers);
2504
+ return this.spawnStartAndRegister(hostId, projectPath, handlers, sessionId, (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(buildStartPlan(hostId, message, sessionId), launchContext?.agent, launchContext?.env));
2507
2505
  }
2508
2506
  continueRun(hostId, projectPath, sessionId, message, handlers, launchContext, deliveryIntent) {
2509
2507
  return this.guardedContinue(hostId, sessionId, { projectPath, message, handlers, launchContext, deliveryIntent });
2510
2508
  }
2511
2509
  startDirectRun(hostId, message, projectPath, handlers, sessionId, launchContext) {
2512
- return this.spawn(hostId, (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(buildDirectStartPlan(hostId, message, sessionId), launchContext?.agent, launchContext?.env), projectPath, handlers);
2510
+ return this.spawnStartAndRegister(hostId, projectPath, handlers, sessionId, (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(buildDirectStartPlan(hostId, message, sessionId), launchContext?.agent, launchContext?.env));
2513
2511
  }
2514
2512
  continueDirectRun(hostId, sessionId, message, projectPath, handlers, launchContext, deliveryIntent) {
2515
2513
  // R10: continueDirectRun is protected by the same per-hostId::sessionId
@@ -2538,7 +2536,7 @@ class CliHostRuntime {
2538
2536
  if (active.escalationTimer != null)
2539
2537
  clearTimeout(active.escalationTimer);
2540
2538
  active.pending.splice(0);
2541
- this.activeContinueRuns.delete(key);
2539
+ this.clearActiveRun(active);
2542
2540
  if (active.child.pid == null)
2543
2541
  return false;
2544
2542
  try {
@@ -2559,7 +2557,7 @@ class CliHostRuntime {
2559
2557
  const key = `${hostId}::${sessionId}`;
2560
2558
  const active = this.activeContinueRuns.get(key);
2561
2559
  if (!active) {
2562
- return this.spawnAndRegister(key, hostId, sessionId, entry);
2560
+ return this.spawnAndRegister(hostId, sessionId, entry);
2563
2561
  }
2564
2562
  // R3/R31: an ordinary follow-up queues behind existing work; a course
2565
2563
  // correction (deliveryIntent 'stop') jumps to the front of the queue and
@@ -2579,11 +2577,49 @@ class CliHostRuntime {
2579
2577
  // through `entry.handlers` once it is actually dequeued and spawned.
2580
2578
  return active.child;
2581
2579
  }
2582
- spawnAndRegister(key, hostId, sessionId, entry) {
2580
+ spawnStartAndRegister(hostId, projectPath, handlers, sessionId, plan) {
2581
+ const deferredSessionIds = [];
2582
+ let runEntry = null;
2583
+ const registerSessionId = (value) => {
2584
+ const discoveredSessionId = typeof value === 'string' ? value.trim() : '';
2585
+ if (!discoveredSessionId)
2586
+ return;
2587
+ if (!runEntry) {
2588
+ deferredSessionIds.push(discoveredSessionId);
2589
+ return;
2590
+ }
2591
+ this.registerActiveRun(hostId, discoveredSessionId, runEntry);
2592
+ };
2593
+ const wrappedHandlers = {
2594
+ ...handlers,
2595
+ onEvent: (event, channel) => {
2596
+ registerSessionId(event.sessionId);
2597
+ handlers.onEvent(event, channel);
2598
+ },
2599
+ };
2600
+ const child = this.spawn(hostId, plan, projectPath, wrappedHandlers);
2601
+ runEntry = { child, pending: [], handlers: wrappedHandlers };
2602
+ if (sessionId)
2603
+ registerSessionId(sessionId);
2604
+ for (const discoveredSessionId of deferredSessionIds) {
2605
+ registerSessionId(discoveredSessionId);
2606
+ }
2607
+ child.once('close', () => {
2608
+ if (!runEntry)
2609
+ return;
2610
+ if (runEntry.escalationTimer != null) {
2611
+ clearTimeout(runEntry.escalationTimer);
2612
+ runEntry.escalationTimer = undefined;
2613
+ }
2614
+ this.dequeueNext(hostId, runEntry);
2615
+ });
2616
+ return child;
2617
+ }
2618
+ spawnAndRegister(hostId, sessionId, entry) {
2583
2619
  const plan = (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(entry.direct ? buildDirectContinuePlan(hostId, sessionId, entry.message) : buildContinuePlan(hostId, sessionId, entry.message), entry.launchContext?.agent, entry.launchContext?.env);
2584
2620
  const child = this.spawn(hostId, plan, entry.projectPath, entry.handlers);
2585
2621
  const runEntry = { child, pending: [], handlers: entry.handlers };
2586
- this.activeContinueRuns.set(key, runEntry);
2622
+ this.registerActiveRun(hostId, sessionId, runEntry);
2587
2623
  child.once('close', () => {
2588
2624
  // Issue #1570: a redirect kill that eventually takes (just slower than
2589
2625
  // the escalation window) must not leave its retry timer dangling past
@@ -2592,10 +2628,30 @@ class CliHostRuntime {
2592
2628
  clearTimeout(runEntry.escalationTimer);
2593
2629
  runEntry.escalationTimer = undefined;
2594
2630
  }
2595
- this.dequeueNext(key, hostId, sessionId, runEntry);
2631
+ this.dequeueNext(hostId, runEntry);
2596
2632
  });
2597
2633
  return child;
2598
2634
  }
2635
+ registerActiveRun(hostId, sessionId, runEntry) {
2636
+ const key = `${hostId}::${sessionId}`;
2637
+ const existing = this.activeContinueRuns.get(key);
2638
+ if (existing && existing !== runEntry)
2639
+ return;
2640
+ if (!runEntry.keys)
2641
+ runEntry.keys = new Set();
2642
+ runEntry.keys.add(key);
2643
+ runEntry.sessionId = sessionId;
2644
+ this.activeContinueRuns.set(key, runEntry);
2645
+ }
2646
+ clearActiveRun(runEntry) {
2647
+ const keys = runEntry.keys ? Array.from(runEntry.keys) : [];
2648
+ for (const key of keys) {
2649
+ if (this.activeContinueRuns.get(key) === runEntry) {
2650
+ this.activeContinueRuns.delete(key);
2651
+ }
2652
+ }
2653
+ runEntry.keys?.clear();
2654
+ }
2599
2655
  // Issue #1570 (Defect 1): the redirect kill was previously fire-and-forget
2600
2656
  // — `killTree` was called once, with no way to know whether it actually
2601
2657
  // took, and no ceiling on how long the queued correction could sit behind
@@ -2660,11 +2716,14 @@ class CliHostRuntime {
2660
2716
  // batch uses the LAST entry's projectPath/handlers/launchContext unless a
2661
2717
  // 'stop' entry is present, in which case that correction's context wins —
2662
2718
  // it is the manager's most recent, most urgent instruction.
2663
- dequeueNext(key, hostId, sessionId, runEntry) {
2664
- this.activeContinueRuns.delete(key);
2719
+ dequeueNext(hostId, runEntry) {
2720
+ const sessionId = runEntry.sessionId;
2721
+ this.clearActiveRun(runEntry);
2665
2722
  const batch = runEntry.pending.splice(0);
2666
2723
  if (!batch.length)
2667
2724
  return;
2725
+ if (!sessionId)
2726
+ return;
2668
2727
  const combinedMessage = batch.map((e) => e.message).join('\n\n');
2669
2728
  const primary = batch.find((e) => e.deliveryIntent === 'stop') ?? batch[batch.length - 1];
2670
2729
  try {
@@ -2674,7 +2733,7 @@ class CliHostRuntime {
2674
2733
  // R19: a synchronous spawn failure must not leave the queue permanently
2675
2734
  // stuck behind this key — clear it so the next continueRun call spawns
2676
2735
  // fresh instead of queueing behind a dead entry.
2677
- this.activeContinueRuns.delete(key);
2736
+ this.clearActiveRun(runEntry);
2678
2737
  }
2679
2738
  }
2680
2739
  }
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.HUB_APP_ENTRY_RELATIVE = exports.HUB_APP_PACKAGE_NAME = void 0;
7
+ exports.redactCredentialLikeSubstrings = redactCredentialLikeSubstrings;
7
8
  exports.resolvePackagedCliScriptPath = resolvePackagedCliScriptPath;
8
9
  exports.planHubAppMaterialization = planHubAppMaterialization;
9
10
  exports.resolveHubAppCacheDir = resolveHubAppCacheDir;
@@ -33,6 +34,23 @@ const managed_node_runtime_1 = require("../cli/utils/managed-node-runtime");
33
34
  // offline matrix is unit-testable without touching npm, disk, or Electron — the same separation
34
35
  // `hub-launch-decision.ts` (#755) and `desktop-auto-updater.ts` (#1387) already established for
35
36
  // this exact class of problem.
37
+ // Issue #1627 (security review): the raw materialization failure text below is now surfaced to a
38
+ // new, wider audience than before this issue — a persisted log file and the manual restart-to-
39
+ // latest HTTP response/UI — where previously it was silently discarded. `npm install` output does
40
+ // not normally echo credentials, but a misconfigured corporate registry (a literal token in a
41
+ // `.npmrc` URL, verbose proxy auth errors) is a real enough edge case that this new, wider surface
42
+ // should not forward it as-is. Defense-in-depth only: redact credential-shaped substrings before
43
+ // this text reaches either destination.
44
+ const CREDENTIAL_LIKE_PATTERNS = [
45
+ /:\/\/[^\s/@]+:[^\s/@]+@/g, // scheme://user:pass@ basic-auth-in-URL
46
+ /\b(?:_authToken|authToken|Authorization|api[_-]?key|password)\s*[:=]\s*\S+/gi,
47
+ /\bnpm_[A-Za-z0-9]{20,}\b/g, // npm access-token shape
48
+ /\bBearer\s+[A-Za-z0-9._-]{10,}\b/gi,
49
+ ];
50
+ /** Strip anything that looks like a credential from text before it is logged or surfaced. */
51
+ function redactCredentialLikeSubstrings(text) {
52
+ return CREDENTIAL_LIKE_PATTERNS.reduce((acc, pattern) => acc.replace(pattern, '[redacted]'), text);
53
+ }
36
54
  exports.HUB_APP_PACKAGE_NAME = 'fraim-hub';
37
55
  /** Relative path, from a materialized (or bundled) package root, to the Electron main entry. */
38
56
  exports.HUB_APP_ENTRY_RELATIVE = path_1.default.join('dist', 'src', 'ai-hub', 'desktop-main.js');
@@ -68,12 +86,14 @@ function planHubAppMaterialization(input) {
68
86
  action: 'use-cached',
69
87
  version: newestCached,
70
88
  reason: 'npm registry unreachable; reusing the newest complete cached version',
89
+ degraded: false,
71
90
  };
72
91
  }
73
92
  return {
74
93
  action: 'use-bundled',
75
94
  version: bundledVersion,
76
95
  reason: 'npm registry unreachable and no cached version available; falling back to the version bundled with this installer',
96
+ degraded: true,
77
97
  };
78
98
  }
79
99
  if (latestPublishedVersion === bundledVersion) {
@@ -81,6 +101,7 @@ function planHubAppMaterialization(input) {
81
101
  action: 'use-bundled',
82
102
  version: bundledVersion,
83
103
  reason: 'the installed shell already matches the latest published version',
104
+ degraded: false,
84
105
  };
85
106
  }
86
107
  const alreadyMaterialized = cachedVersions.some((entry) => entry.complete && entry.version === latestPublishedVersion);
@@ -89,12 +110,14 @@ function planHubAppMaterialization(input) {
89
110
  action: 'use-cached',
90
111
  version: latestPublishedVersion,
91
112
  reason: 'latest published version is already materialized on disk',
113
+ degraded: false,
92
114
  };
93
115
  }
94
116
  return {
95
117
  action: 'materialize',
96
118
  version: latestPublishedVersion,
97
119
  reason: `latest published version ${latestPublishedVersion} is not yet materialized`,
120
+ degraded: false,
98
121
  };
99
122
  }
100
123
  // ---------------------------------------------------------------------------
@@ -265,7 +288,11 @@ async function resolveHubAppEntry(options) {
265
288
  cachedVersions,
266
289
  });
267
290
  if (plan.action === 'use-bundled') {
268
- return { entryPath: options.bundledEntryPath, source: 'bundled', version: plan.version };
291
+ const materializationError = plan.degraded ? plan.reason : undefined;
292
+ if (materializationError) {
293
+ console.error(`[fraim] FRAIM Hub update check could not complete: ${materializationError}`);
294
+ }
295
+ return { entryPath: options.bundledEntryPath, source: 'bundled', version: plan.version, materializationError };
269
296
  }
270
297
  if (plan.action === 'use-cached') {
271
298
  return {
@@ -279,7 +306,10 @@ async function resolveHubAppEntry(options) {
279
306
  const entryPath = await ensureMaterializedHubApp(plan.version, { fraimDir, installPackage: options.installPackage });
280
307
  return { entryPath, source: 'cached', version: plan.version };
281
308
  }
282
- catch {
283
- return { entryPath: options.bundledEntryPath, source: 'bundled', version: options.bundledVersion };
309
+ catch (error) {
310
+ const rawMessage = error instanceof Error ? error.message : String(error);
311
+ const materializationError = redactCredentialLikeSubstrings(rawMessage);
312
+ console.error(`[fraim] failed to materialize FRAIM Hub ${plan.version}; falling back to bundled ${options.bundledVersion}: ${materializationError}`);
313
+ return { entryPath: options.bundledEntryPath, source: 'bundled', version: options.bundledVersion, materializationError };
284
314
  }
285
315
  }