impel-cli 0.20.49 → 0.20.50

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/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.20.50 — Restore ChatGPT Tasks safely
4
+
5
+ - Restores one embedded Tasks entry in the managed ChatGPT sidebar while
6
+ keeping Claude's production preload inert and preserving normal ChatGPT
7
+ startup and navigation.
8
+ - Disables the native fallback in the production ChatGPT profile, so Tasks
9
+ cannot create a second control view or cover the vendor app during startup.
10
+ - Debounces sidebar discovery, makes attached-control checks constant-time,
11
+ and removes the outer reinjection retry chain that could repeatedly scan the
12
+ renderer while its sidebar anchor was late.
13
+ - Injects and accepts Tasks navigation only in the main vendor frame; subframes
14
+ cannot open, resize, or bootstrap the authenticated board.
15
+ - Deduplicates paired Electron navigation events and reuses the loaded board,
16
+ keeping one tenant-bound credential exchange across ordinary hide/reopen
17
+ cycles while allowing an expired or failed board session to recover.
18
+ - Advances the managed profile contract to v41 so existing macOS ChatGPT apps
19
+ rewrite and reload the repaired preload without rebuilding vendor bundles.
20
+ - Adds a known-bad `0.20.48` control and a real packaged-app gate with screenshot
21
+ artifacts for the normal surface, one Tasks tab, and the interactive board;
22
+ it also externally observes the board target becoming compositor-hidden when
23
+ host navigation is restored, with zero fallback controls and one bootstrap.
24
+
3
25
  ## 0.20.49 — Restore managed app startup
4
26
 
5
27
  - Restores normal ChatGPT and Claude startup by disabling the experimental
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impel-cli",
3
- "version": "0.20.49",
3
+ "version": "0.20.50",
4
4
  "description": "Prepare isolated Claude and Codex workspaces for every accessible Impel tenant",
5
5
  "type": "module",
6
6
  "bin": {
package/src/apps.js CHANGED
@@ -660,9 +660,12 @@ export function appPaths(homeDir = os.homedir(), tenantId = null, {
660
660
  };
661
661
  }
662
662
 
663
- function writeDesktopTasksProfileAssets(root, appUrl) {
663
+ function writeDesktopTasksProfileAssets(root, appUrl, { enableEmbed = false } = {}) {
664
664
  const assets = desktopTasksAssetPaths(root);
665
- writeAtomic(assets.mainPreload, desktopTasksMainPreload(appUrl), 0o600);
665
+ writeAtomic(assets.mainPreload, desktopTasksMainPreload(appUrl, {
666
+ enableEmbed,
667
+ enableNativeFallback: false,
668
+ }), 0o600);
666
669
  writeAtomic(assets.viewPreload, desktopTasksViewPreload(), 0o600);
667
670
  // This exact path is Impel-owned. Remove the former local MCP Apps host when
668
671
  // a profile is regenerated; old released clients keep the immutable server
@@ -879,6 +882,7 @@ export function installManagedAppFiles({
879
882
  writeDesktopTasksProfileAssets(
880
883
  paths[target].root,
881
884
  config.appUrl || RUNTIME_BRAND.controlPlane.defaultOrigin,
885
+ { enableEmbed: target === "chatgpt" },
882
886
  );
883
887
  // Non-bundle targets are the background-refresh / fast-open path: configs,
884
888
  // token helper, catalog, and manifest only. Bundle swaps require the app
@@ -87,6 +87,7 @@ export function parseDesktopTasksCredential(output, tenantId, {
87
87
 
88
88
  const NAV_ANCHOR_RULES_SOURCE = JSON.stringify(DESKTOP_TASKS_NAV_ANCHOR_RULES);
89
89
  const TASKS_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"></rect><path d="M9 3v18"></path><path d="M15 3v18"></path></svg>';
90
+ const NAVIGATION_NONCE_PLACEHOLDER = "IMPEL_DESKTOP_TASKS_NAVIGATION_NONCE_PLACEHOLDER";
90
91
 
91
92
  const NAVIGATION_SOURCE = String.raw`(() => {
92
93
  const NAV_ID = "impel-desktop-tasks-nav";
@@ -104,6 +105,9 @@ const NAVIGATION_SOURCE = String.raw`(() => {
104
105
  let active = false;
105
106
  let lastAnchor = null;
106
107
  let readySignalled = false;
108
+ let ensureTimer = null;
109
+ const signalSequenceKey = "__impelDesktopTasksSignalSequence";
110
+ const navigationNonce = ${JSON.stringify(NAVIGATION_NONCE_PLACEHOLDER)};
107
111
  const roots = () => {
108
112
  const result = [document];
109
113
  for (let index = 0; index < result.length; index += 1) {
@@ -157,6 +161,10 @@ const NAVIGATION_SOURCE = String.raw`(() => {
157
161
  };
158
162
  const signal = (action) => {
159
163
  const query = new URLSearchParams(Object.entries(mainBounds()).map(([key, value]) => [key, String(value)]));
164
+ const signalSequence = Number(window[signalSequenceKey] || 0) + 1;
165
+ window[signalSequenceKey] = signalSequence;
166
+ query.set("event", Date.now().toString(36) + "-" + signalSequence.toString(36));
167
+ query.set("nonce", navigationNonce);
160
168
  location.href = "impel-tasks://" + action + "?" + query;
161
169
  };
162
170
  const select = (selected) => {
@@ -210,6 +218,10 @@ const NAVIGATION_SOURCE = String.raw`(() => {
210
218
  return button;
211
219
  };
212
220
  const ensureNav = () => {
221
+ if (nav?.isConnected) {
222
+ select(active);
223
+ return;
224
+ }
213
225
  let rule = null;
214
226
  let anchor = null;
215
227
  for (const candidate of RULES) {
@@ -253,11 +265,19 @@ const NAVIGATION_SOURCE = String.raw`(() => {
253
265
  const onResize = () => { if (active) signal("layout"); };
254
266
  window.addEventListener("click", onClick, true);
255
267
  window.addEventListener("resize", onResize);
256
- const observer = new MutationObserver(() => ensureNav());
268
+ const scheduleEnsure = () => {
269
+ if (ensureTimer !== null) return;
270
+ ensureTimer = setTimeout(() => {
271
+ ensureTimer = null;
272
+ ensureNav();
273
+ }, 250);
274
+ };
275
+ const observer = new MutationObserver(scheduleEnsure);
257
276
  observer.observe(document.body, { childList: true, subtree: true });
258
277
  ensureNav();
259
278
  window.__impelDesktopTasksCleanup = () => {
260
279
  observer.disconnect();
280
+ if (ensureTimer !== null) clearTimeout(ensureTimer);
261
281
  window.removeEventListener("click", onClick, true);
262
282
  window.removeEventListener("resize", onResize);
263
283
  nav?.remove();
@@ -275,7 +295,10 @@ const NAVIGATION_SOURCE = String.raw`(() => {
275
295
 
276
296
  const NATIVE_NAVIGATION_HTML = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none';script-src 'unsafe-inline';style-src 'unsafe-inline'"><style>:root{color-scheme:light dark;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}*{box-sizing:border-box}html,body{width:100%;height:100%;margin:0;overflow:hidden}button{width:100%;height:100%;display:flex;align-items:center;gap:9px;border:0;border-radius:8px;padding:0 12px;background:color-mix(in srgb,Canvas 92%,CanvasText 8%);color:CanvasText;cursor:pointer}svg{width:16px;height:16px}</style></head><body><button id="tasks" type="button" aria-label="Tasks">${TASKS_ICON}<span>Tasks</span></button><script>const button=document.getElementById("tasks");button.addEventListener("click",()=>{button.dataset.active="true";window.impelDesktopTasks.show()});window.__impelDesktopTasksSetActive=active=>{button.dataset.active=String(Boolean(active))}</script></body></html>`;
277
297
 
278
- export function desktopTasksMainPreload(appUrl, { enableEmbed = false } = {}) {
298
+ export function desktopTasksMainPreload(appUrl, {
299
+ enableEmbed = false,
300
+ enableNativeFallback = false,
301
+ } = {}) {
279
302
  const appOrigin = desktopTasksAppOrigin(appUrl);
280
303
  if (!enableEmbed) return `"use strict";\n`;
281
304
  const parserSource = parseDesktopTasksCredential.toString();
@@ -294,12 +317,16 @@ if (
294
317
  const fs = require("node:fs");
295
318
  const path = require("node:path");
296
319
  const { spawn } = require("node:child_process");
320
+ const { randomBytes } = require("node:crypto");
297
321
  const navSource = ${JSON.stringify(NAVIGATION_SOURCE)};
322
+ const navigationNoncePlaceholder = ${JSON.stringify(NAVIGATION_NONCE_PLACEHOLDER)};
298
323
  const nativeNavUrl = "data:text/html;charset=utf-8," + encodeURIComponent(${JSON.stringify(NATIVE_NAVIGATION_HTML)});
299
324
  const appOrigin = ${JSON.stringify(appOrigin)};
300
325
  const bootstrapUrl = appOrigin + ${JSON.stringify(DESKTOP_TASKS_BOOTSTRAP_PATH)};
301
326
  const tasksUrl = appOrigin + ${JSON.stringify(DESKTOP_TASKS_BOARD_PATH)};
302
327
  const readyMarker = ${JSON.stringify(DESKTOP_TASKS_READY_MARKER)};
328
+ const nativeFallbackEnabled = ${JSON.stringify(enableNativeFallback)};
329
+ const boardSessionMaxAgeMs = 7 * 60 * 60 * 1000;
303
330
  const tenantId = process.env.IMPEL_DESKTOP_TASKS_TENANT;
304
331
  const nativeShowChannel = "impel-desktop-tasks:native-show";
305
332
  const runtimeStatusPath = path.join(__dirname, "runtime-status.json");
@@ -383,7 +410,7 @@ if (
383
410
  const stateFor = (window) => {
384
411
  let state = windows.get(window.id);
385
412
  if (!state) {
386
- state = { view: null, bounds: null, visible: false, nativeNav: null, hasDomNavigation: false, loading: null, bootstrapping: false };
413
+ state = { view: null, bounds: null, visible: false, nativeNav: null, hasDomNavigation: false, loaded: false, loadedAt: 0, loading: null, bootstrapping: false };
387
414
  windows.set(window.id, state);
388
415
  }
389
416
  return state;
@@ -425,7 +452,16 @@ if (
425
452
  const errorDocument = "data:text/html;charset=utf-8," + encodeURIComponent(
426
453
  "<!doctype html><html><body style='font:14px system-ui;padding:32px'>Impel Tasks could not be loaded. Hide Tasks, check your connection, and try again.</body></html>",
427
454
  );
455
+ const loadErrorDocument = async (state) => {
456
+ const contents = state.view?.webContents;
457
+ if (!contents || contents.isDestroyed()) return;
458
+ await contents.loadURL(errorDocument).catch(() => {});
459
+ };
428
460
  const loadBoard = async (state) => {
461
+ const contents = state.view?.webContents;
462
+ if (!contents || contents.isDestroyed()) return;
463
+ state.loaded = false;
464
+ state.loadedAt = 0;
429
465
  state.bootstrapping = true;
430
466
  let pat = null;
431
467
  try {
@@ -439,27 +475,32 @@ if (
439
475
  ].join("\\n"),
440
476
  postData: [{ type: "rawData", bytes: Buffer.alloc(0) }],
441
477
  };
442
- await state.view.webContents.loadURL(bootstrapUrl, request);
443
- if (state.view.webContents.getURL() !== bootstrapUrl) throw new Error("bootstrap redirected");
444
- const ready = await state.view.webContents.executeJavaScript(
478
+ await contents.loadURL(bootstrapUrl, request);
479
+ if (contents.isDestroyed() || contents.getURL() !== bootstrapUrl) throw new Error("bootstrap redirected");
480
+ const ready = await contents.executeJavaScript(
445
481
  "document.body?.textContent?.trim() === " + JSON.stringify(readyMarker),
446
482
  true,
447
483
  );
448
484
  if (!ready) throw new Error("bootstrap marker missing");
449
485
  } catch {
450
486
  record("board-bootstrap-failed");
451
- await state.view.webContents.loadURL(errorDocument).catch(() => {});
487
+ await loadErrorDocument(state);
452
488
  return;
453
489
  } finally {
454
490
  pat = null;
455
491
  state.bootstrapping = false;
456
492
  }
457
493
  try {
458
- await state.view.webContents.loadURL(tasksUrl);
494
+ await contents.loadURL(tasksUrl);
495
+ if (contents.isDestroyed()) return;
496
+ state.loaded = true;
497
+ state.loadedAt = Date.now();
459
498
  record("board-loaded");
460
499
  } catch {
500
+ state.loaded = false;
501
+ state.loadedAt = 0;
461
502
  record("board-load-failed");
462
- await state.view.webContents.loadURL(errorDocument).catch(() => {});
503
+ await loadErrorDocument(state);
463
504
  }
464
505
  };
465
506
  const deactivateNavigation = () => {
@@ -468,9 +509,10 @@ if (
468
509
  }
469
510
  for (const contents of webContents.getAllWebContents()) {
470
511
  if (internalContents.has(contents) || contents.isDestroyed()) continue;
471
- for (const frame of contents.mainFrame?.framesInSubtree || []) {
472
- frame.executeJavaScript("window.__impelDesktopTasksSetActive?.(false)", true).catch(() => {});
473
- }
512
+ contents.mainFrame?.executeJavaScript?.(
513
+ "window.__impelDesktopTasksSetActive?.(false)",
514
+ true,
515
+ )?.catch(() => {});
474
516
  }
475
517
  };
476
518
  const isHostWindow = (window) => {
@@ -498,6 +540,7 @@ if (
498
540
  state.view?.setVisible(false);
499
541
  window.webContents.focus();
500
542
  deactivateNavigation();
543
+ record("board-hidden");
501
544
  };
502
545
  const isPrimaryWindow = (window) => {
503
546
  return isHostWindow(window) && window === primaryHostWindow() && window.isVisible?.() !== false;
@@ -520,6 +563,8 @@ if (
520
563
  },
521
564
  });
522
565
  state.view = view;
566
+ state.loaded = false;
567
+ state.loadedAt = 0;
523
568
  internalContents.add(view.webContents);
524
569
  configureSession(view.webContents.session);
525
570
  window.contentView.addChildView(view);
@@ -547,7 +592,12 @@ if (
547
592
  view.webContents.on("destroyed", () => {
548
593
  internalContents.delete(view.webContents);
549
594
  try { window.contentView.removeChildView(view); } catch {}
550
- if (state.view === view) state.view = null;
595
+ if (state.view === view) {
596
+ state.view = null;
597
+ state.loaded = false;
598
+ state.loadedAt = 0;
599
+ state.loading = null;
600
+ }
551
601
  });
552
602
  return state;
553
603
  };
@@ -560,7 +610,20 @@ if (
560
610
  state.view.webContents.focus();
561
611
  if (state.visible) return;
562
612
  state.visible = true;
563
- state.loading = loadBoard(state).finally(() => { state.loading = null; });
613
+ let boardRoute = false;
614
+ try {
615
+ const current = new URL(state.view.webContents.getURL());
616
+ boardRoute = current.origin === appOrigin
617
+ && (current.pathname === tasksUrl.slice(appOrigin.length)
618
+ || current.pathname.startsWith(tasksUrl.slice(appOrigin.length) + "/"));
619
+ } catch {}
620
+ const reusableBoard = state.loaded
621
+ && Date.now() - state.loadedAt < boardSessionMaxAgeMs
622
+ && boardRoute;
623
+ if (!reusableBoard) state.loaded = false;
624
+ if (!state.loaded && !state.loading) {
625
+ state.loading = loadBoard(state).finally(() => { state.loading = null; });
626
+ }
564
627
  record("board-shown");
565
628
  };
566
629
  const layoutBoard = (window, bounds = null) => {
@@ -661,43 +724,74 @@ if (
661
724
  const attachContents = (contents) => {
662
725
  if (!contents || contents.isDestroyed() || contents.__impelDesktopTasksAttached || internalContents.has(contents)) return;
663
726
  contents.__impelDesktopTasksAttached = true;
664
- const inject = (attempt = 0) => {
727
+ const navigationNonce = randomBytes(32).toString("base64url");
728
+ const contentsNavSource = navSource.replace(
729
+ JSON.stringify(navigationNoncePlaceholder),
730
+ JSON.stringify(navigationNonce),
731
+ );
732
+ const inject = () => {
665
733
  if (internalContents.has(contents)) return;
666
- Promise.all((contents.mainFrame?.framesInSubtree || []).map((frame) => frame.executeJavaScript(navSource, true)))
667
- .then((results) => {
734
+ const frame = contents.mainFrame;
735
+ if (!frame || typeof frame.executeJavaScript !== "function") return;
736
+ frame.executeJavaScript(contentsNavSource, true)
737
+ .then((result) => {
668
738
  const window = ownerWindow(contents);
669
739
  if (!window || window !== primaryHostWindow()) return;
670
740
  const state = stateFor(window);
671
- const shellReady = results.some((result) => result?.shellReady);
672
- if (results.some((result) => result?.anchor)) {
741
+ if (result?.anchor) {
673
742
  markDomNavigation(window);
674
- } else if (!shellReady && attempt < 30) setTimeout(() => inject(attempt + 1), 1000);
675
- else if (shellReady && !state.hasDomNavigation && !hasDomNavigation) ensureNativeNav(window);
676
- else if (!shellReady) record("navigation-shell-unready");
743
+ } else if (result?.shellReady && !state.hasDomNavigation && !hasDomNavigation && nativeFallbackEnabled) {
744
+ ensureNativeNav(window);
745
+ }
677
746
  })
678
747
  .catch(() => record("navigation-injection-failed"));
679
748
  };
680
749
  contents.on("did-finish-load", () => inject());
681
- contents.on("did-frame-finish-load", () => inject());
682
750
  if (!contents.isLoadingMainFrame()) setTimeout(() => inject(), 0);
683
- let handledNavigation = null;
751
+ const handledNavigationEvents = new Set();
752
+ let handledLegacyNavigation = null;
684
753
  const handleNavigation = (event, url) => {
685
754
  if (!String(url).startsWith("impel-tasks://")) return;
686
755
  event.preventDefault();
687
756
  const window = ownerWindow(contents);
688
757
  if (!window || window !== primaryHostWindow()) return;
689
- if (handledNavigation === url) return;
690
- handledNavigation = url;
691
- queueMicrotask(() => { if (handledNavigation === url) handledNavigation = null; });
692
- let action;
693
- try { action = new URL(url).hostname; } catch { return; }
758
+ let navigation;
759
+ try { navigation = new URL(url); } catch { return; }
760
+ if (navigation.searchParams.get("nonce") !== navigationNonce) return;
761
+ const action = navigation.hostname;
762
+ const eventId = navigation.searchParams.get("event");
763
+ if (eventId) {
764
+ const navigationKey = action + ":" + eventId;
765
+ if (handledNavigationEvents.has(navigationKey)) return;
766
+ handledNavigationEvents.add(navigationKey);
767
+ if (handledNavigationEvents.size > 128) {
768
+ handledNavigationEvents.delete(handledNavigationEvents.values().next().value);
769
+ }
770
+ } else {
771
+ if (handledLegacyNavigation === url) return;
772
+ handledLegacyNavigation = url;
773
+ queueMicrotask(() => { if (handledLegacyNavigation === url) handledLegacyNavigation = null; });
774
+ }
694
775
  if (action === "ready") markDomNavigation(window);
695
776
  else if (action === "hide") hide(window);
696
777
  else if (action === "show") show(window, parseBounds(url, window));
697
778
  else if (action === "layout") layoutBoard(window, parseBounds(url, window));
698
779
  };
699
- contents.on("will-navigate", handleNavigation);
700
- contents.on("will-frame-navigate", handleNavigation);
780
+ const eventUrl = (event, deprecatedUrl) => (
781
+ typeof event?.url === "string" ? event.url : String(deprecatedUrl || "")
782
+ );
783
+ contents.on("will-navigate", (event, deprecatedUrl) => {
784
+ handleNavigation(event, eventUrl(event, deprecatedUrl));
785
+ });
786
+ contents.on("will-frame-navigate", (event, deprecatedUrl, _isInPlace, deprecatedIsMainFrame) => {
787
+ const url = eventUrl(event, deprecatedUrl);
788
+ if (!url.startsWith("impel-tasks://")) return;
789
+ event.preventDefault();
790
+ const isMainFrame = typeof event?.isMainFrame === "boolean"
791
+ ? event.isMainFrame
792
+ : deprecatedIsMainFrame === true;
793
+ if (isMainFrame) handleNavigation(event, url);
794
+ });
701
795
  };
702
796
  const attachWindow = (window) => {
703
797
  if (!window || window.isDestroyed() || window.getParentWindow?.() || window.__impelDesktopTasksWindowAttached) return;
@@ -1,6 +1,8 @@
1
1
  // Fleet generation shared by managed-profile writers and upstream clients.
2
2
  // Keep this isolated from apps.js so latency-sensitive transports do not load
3
3
  // desktop bundle machinery just to identify their managed config contract.
4
- // v40 renders the Tasks fallback inside the vendor window and waits for the app
5
- // shell, so no child BrowserWindow can pin ChatGPT's transient startup splash.
6
- export const CURRENT_CONFIG_VERSION = 40;
4
+ // v41 restores ChatGPT's embedded Tasks entry through its real sidebar only.
5
+ // The native fallback stays disabled, and a valid authenticated board is
6
+ // reused across hide/show cycles so it cannot obscure or repeatedly bootstrap
7
+ // the host.
8
+ export const CURRENT_CONFIG_VERSION = 41;