local-operator-ui 0.34.32 → 0.34.34

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 (69) hide show
  1. package/out/main/index.js +855 -501
  2. package/out/preload/index.js +4 -0
  3. package/out/renderer/assets/{_basePickBy-B66HBZHq.js → _basePickBy-CNMIid3L.js} +1 -1
  4. package/out/renderer/assets/{_baseUniq-CSRHNS5X.js → _baseUniq-BesnMR63.js} +1 -1
  5. package/out/renderer/assets/{agent-details-page--_gYi2lc.js → agent-details-page-B1wPZHYw.js} +1 -1
  6. package/out/renderer/assets/{agent-hub-page-B4CYHqQ7.js → agent-hub-page-zLDCcJ0-.js} +1 -1
  7. package/out/renderer/assets/{agents-page-Cjo3iaWD.js → agents-page-8cT_dI_3.js} +3 -3
  8. package/out/renderer/assets/{arc-4AcyjvSp.js → arc-MuuzF-kz.js} +1 -1
  9. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-BE7p-gy-.js → architectureDiagram-IEHRJDOE-Cla6EwFi.js} +1 -1
  10. package/out/renderer/assets/{blockDiagram-JOT3LUYC-DgS-ikSj.js → blockDiagram-JOT3LUYC-Bja9j7dE.js} +1 -1
  11. package/out/renderer/assets/{browser-page-ql5R-cDs.js → browser-page-BYjoph3Z.js} +1 -1
  12. package/out/renderer/assets/{browser-webauthn-prompt-CWr4qL8A.js → browser-webauthn-prompt-B3uYCxFo.js} +1 -1
  13. package/out/renderer/assets/{c4Diagram-VJAJSXHY-CpMtpl2f.js → c4Diagram-VJAJSXHY-DVoi6Fpy.js} +1 -1
  14. package/out/renderer/assets/channel-BQiffY-M.js +1 -0
  15. package/out/renderer/assets/{chunk-4BMEZGHF-85Yusluf.js → chunk-4BMEZGHF-Cih6BNjs.js} +1 -1
  16. package/out/renderer/assets/{chunk-A2AXSNBT-D6BmMNue.js → chunk-A2AXSNBT-DJo0efSa.js} +1 -1
  17. package/out/renderer/assets/{chunk-AEK57VVT-C26q1R--.js → chunk-AEK57VVT-DvwXfXNo.js} +1 -1
  18. package/out/renderer/assets/{chunk-D6G4REZN-Dy4HYhmA.js → chunk-D6G4REZN-HuVYXeWr.js} +1 -1
  19. package/out/renderer/assets/{chunk-RZ5BOZE2-Dnxr3jB8.js → chunk-RZ5BOZE2-BPLh7jSP.js} +1 -1
  20. package/out/renderer/assets/{chunk-XZIHB7SX-BGzUtiqV.js → chunk-XZIHB7SX-Di-TZ5J_.js} +1 -1
  21. package/out/renderer/assets/classDiagram-GIVACNV2-DDObgTCR.js +1 -0
  22. package/out/renderer/assets/classDiagram-v2-COTLJTTW-DDObgTCR.js +1 -0
  23. package/out/renderer/assets/clone-C0j0lwrL.js +1 -0
  24. package/out/renderer/assets/{conversation-input-sync-CBw8uiPT.js → conversation-input-sync-Dvqtl3Fr.js} +247 -243
  25. package/out/renderer/assets/{dagre-OKDRZEBW-D6CK2WVv.js → dagre-OKDRZEBW-BRG1M9oi.js} +1 -1
  26. package/out/renderer/assets/{diagram-SSKATNLV-C3TpIKTy.js → diagram-SSKATNLV-DsHLzFSC.js} +1 -1
  27. package/out/renderer/assets/{diagram-VNBRO52H-z0vxgigp.js → diagram-VNBRO52H-DuDF21bn.js} +1 -1
  28. package/out/renderer/assets/{erDiagram-Q7BY3M3F-DBCh3SrN.js → erDiagram-Q7BY3M3F-BS7Xa-1R.js} +1 -1
  29. package/out/renderer/assets/{flowDiagram-4HSFHLVR-BgKCzSfN.js → flowDiagram-4HSFHLVR-A_BvmOjq.js} +1 -1
  30. package/out/renderer/assets/{ganttDiagram-APWFNJXF-BwsHywQN.js → ganttDiagram-APWFNJXF-BQX036BE.js} +1 -1
  31. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-DYgILe4-.js → gitGraphDiagram-7IBYFJ6S-YpfcZ3NA.js} +1 -1
  32. package/out/renderer/assets/{graph-Vw1kbjlt.js → graph-lKMrX7qR.js} +1 -1
  33. package/out/renderer/assets/{index-DYrqbn3V.js → index-BEYFd0J9.js} +90 -90
  34. package/out/renderer/assets/{index-DBZqVg5O.js → index-CYvymE2l.js} +1 -1
  35. package/out/renderer/assets/{index-CBEysjAK.js → index-DGSndO-f.js} +1 -1
  36. package/out/renderer/assets/{infoDiagram-PH2N3AL5-Cmpi3PDq.js → infoDiagram-PH2N3AL5-Dg-ZnTmu.js} +1 -1
  37. package/out/renderer/assets/{journeyDiagram-U35MCT3I-BVMD25G1.js → journeyDiagram-U35MCT3I-DRzUPcQU.js} +1 -1
  38. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-CVijSM-H.js → kanban-definition-NDS4AKOZ-Q474R71W.js} +1 -1
  39. package/out/renderer/assets/{layout-yGyBho08.js → layout-DTC_hgF6.js} +1 -1
  40. package/out/renderer/assets/{legacy-agents-page-C9F6Anar.js → legacy-agents-page-k28FA6cr.js} +1 -1
  41. package/out/renderer/assets/{mermaid.core-eKt0AAEa.js → mermaid.core-C9rYU-0l.js} +5 -5
  42. package/out/renderer/assets/{mesh-page-V7TXOqL8.js → mesh-page-DfZZjDNB.js} +1 -1
  43. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-7OE97AHg.js → mindmap-definition-ALO5MXBD-Bib16-Jn.js} +1 -1
  44. package/out/renderer/assets/{mini-CAwodgo3.js → mini-BJOZ3ClI.js} +1 -1
  45. package/out/renderer/assets/{org-surface-gate-BnAoMqVG.js → org-surface-gate-C3vaLLOm.js} +1 -1
  46. package/out/renderer/assets/{pieDiagram-IB7DONF6-BXLSPmEJ.js → pieDiagram-IB7DONF6-CFKHjx11.js} +1 -1
  47. package/out/renderer/assets/{projects-page-BHtKptz-.js → projects-page-BCQMniR-.js} +1 -1
  48. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-Csn6f8f3.js → quadrantDiagram-7GDLP6J5-B-9tF5TA.js} +1 -1
  49. package/out/renderer/assets/{radar-MK3ICKWK-BZZUrPnK.js → radar-MK3ICKWK-hPJOSxyO.js} +1 -1
  50. package/out/renderer/assets/{radient-auth-buttons-B0TYTsdi.js → radient-auth-buttons-CGDfKjvU.js} +1 -1
  51. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-CidyP0gb.js → requirementDiagram-KVF5MWMF-DapE7E6j.js} +1 -1
  52. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-RqzFypY6.js → sankeyDiagram-QLVOVGJD-dQnFf6xh.js} +1 -1
  53. package/out/renderer/assets/{schedules-page-gb6zHM13.js → schedules-page-CTF8k7kQ.js} +1 -1
  54. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-hoGno_Xs.js → sequenceDiagram-X6HHIX6F-DpQcD4s7.js} +1 -1
  55. package/out/renderer/assets/{settings-page-D7QXHWhp.js → settings-page-Cjj6lVqM.js} +1 -1
  56. package/out/renderer/assets/{stateDiagram-DGXRK772-CGfBWbub.js → stateDiagram-DGXRK772-BH86RKPp.js} +1 -1
  57. package/out/renderer/assets/{stateDiagram-v2-YXO3MK2T-BuXMQAvq.js → stateDiagram-v2-YXO3MK2T-CPbeGHOQ.js} +1 -1
  58. package/out/renderer/assets/{timeline-definition-BDJGKUSR-CS5EDd47.js → timeline-definition-BDJGKUSR-I9WIfb3F.js} +1 -1
  59. package/out/renderer/assets/{use-download-agent-mutation-DSU0LPqD.js → use-download-agent-mutation-DZKciluh.js} +1 -1
  60. package/out/renderer/assets/{use-memberships-query-CN1cjAr2.js → use-memberships-query-CouwB8Jj.js} +1 -1
  61. package/out/renderer/assets/{use-session-snapshot-Da-uFU-c.js → use-session-snapshot-BwZz7qKM.js} +1 -1
  62. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-D44Ss3rC.js → xychartDiagram-VJFVF3MP-RQXd6xeu.js} +1 -1
  63. package/out/renderer/index.html +2 -2
  64. package/out/renderer/mini.html +3 -3
  65. package/package.json +2 -2
  66. package/out/renderer/assets/channel-BdIPJXOo.js +0 -1
  67. package/out/renderer/assets/classDiagram-GIVACNV2-DiGQhWtx.js +0 -1
  68. package/out/renderer/assets/classDiagram-v2-COTLJTTW-DiGQhWtx.js +0 -1
  69. package/out/renderer/assets/clone-BhPlGAQ6.js +0 -1
package/out/main/index.js CHANGED
@@ -4618,6 +4618,55 @@ const serverChannelVerdict = ({
4618
4618
  return { status: "restart-required", state: "restart-required" };
4619
4619
  return { status: "current", state: "current" };
4620
4620
  };
4621
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
4622
+ const isInstallerWheel = (entry, version, platform) => {
4623
+ if (!entry || typeof entry !== "object") return false;
4624
+ const file = entry;
4625
+ if (file.packagetype !== "bdist_wheel" || file.yanked) return false;
4626
+ if (typeof file.url !== "string") return false;
4627
+ let url;
4628
+ try {
4629
+ url = new URL(file.url);
4630
+ } catch {
4631
+ return false;
4632
+ }
4633
+ if (url.protocol !== "https:" || url.hostname !== "files.pythonhosted.org")
4634
+ return false;
4635
+ const digests = file.digests;
4636
+ const sha256 = digests && typeof digests === "object" ? digests.sha256 : null;
4637
+ if (typeof sha256 !== "string" || !SHA256_HEX.test(sha256)) return false;
4638
+ if (typeof file.filename !== "string") return false;
4639
+ if (!file.filename.startsWith(`local_operator-${version}-`)) return false;
4640
+ return platform === "darwin" ? file.filename.endsWith("-py3-none-macosx_11_0_universal2.whl") : file.filename.endsWith("-py3-none-any.whl");
4641
+ };
4642
+ const pypiReleaseReading = (payload, platform) => {
4643
+ const unreadable = {
4644
+ namedVersion: null,
4645
+ readyVersion: null
4646
+ };
4647
+ if (!payload || typeof payload !== "object") return unreadable;
4648
+ const info = payload.info;
4649
+ if (!info || typeof info !== "object") return unreadable;
4650
+ const version = info.version;
4651
+ if (typeof version !== "string" || !version.trim()) return unreadable;
4652
+ const trimmed = version.trim();
4653
+ const downloadable = (files) => Array.isArray(files) && files.some((entry) => isInstallerWheel(entry, trimmed, platform));
4654
+ let ready = downloadable(payload.urls);
4655
+ if (!ready) {
4656
+ const releases = payload.releases;
4657
+ if (releases && typeof releases === "object") {
4658
+ ready = downloadable(releases[trimmed]);
4659
+ }
4660
+ }
4661
+ return { namedVersion: trimmed, readyVersion: ready ? trimmed : null };
4662
+ };
4663
+ const readPypiReleaseBody = (body, platform) => {
4664
+ try {
4665
+ return pypiReleaseReading(JSON.parse(body), platform);
4666
+ } catch {
4667
+ return { namedVersion: null, readyVersion: null };
4668
+ }
4669
+ };
4621
4670
  const updateCheckVerdict = (channels) => ({
4622
4671
  app: channels.app,
4623
4672
  server: channels.server,
@@ -8043,6 +8092,21 @@ async function withUserShellPath(env, userShellPath2) {
8043
8092
  if (!path2) return { ...env };
8044
8093
  return { ...env, PATH: mergeShellPath(path2, env.PATH) };
8045
8094
  }
8095
+ const AGENT_SHELL_ENV = "LOCAL_OPERATOR_AGENT_SHELL";
8096
+ const MAY_DELEGATE_ENV = "LOCAL_OPERATOR_AGENT_MAY_DELEGATE";
8097
+ const ALLOW_NESTED_SESSION_ENV = "LOCAL_OPERATOR_ALLOW_NESTED_SESSION";
8098
+ const AGENT_SHELL_MARKERS = [
8099
+ AGENT_SHELL_ENV,
8100
+ MAY_DELEGATE_ENV,
8101
+ ALLOW_NESTED_SESSION_ENV
8102
+ ];
8103
+ function withoutAgentShellMarker(env) {
8104
+ const stripped = { ...env };
8105
+ for (const name of AGENT_SHELL_MARKERS) {
8106
+ delete stripped[name];
8107
+ }
8108
+ return stripped;
8109
+ }
8046
8110
  const launchEnv = {
8047
8111
  ...process.env
8048
8112
  };
@@ -10540,7 +10604,7 @@ class BackendServiceManager {
10540
10604
  * operator (see the comment on that entry, and `./notification-launch`).
10541
10605
  */
10542
10606
  backendSpawnEnv() {
10543
- return {
10607
+ return withoutAgentShellMarker({
10544
10608
  ...withPythonBytecodeCache(this.shellEnv, this.appDataPath),
10545
10609
  // Managed starts rotate the token; set after the spread so the spawn
10546
10610
  // can never inherit a stale one from the shell environment. `start()`
@@ -10563,7 +10627,7 @@ class BackendServiceManager {
10563
10627
  * unstated key is left alone.
10564
10628
  */
10565
10629
  ...resolveNotificationLaunch(launchEnv)
10566
- };
10630
+ });
10567
10631
  }
10568
10632
  /**
10569
10633
  * Load environment variables from macOS shell configuration files
@@ -14800,12 +14864,14 @@ class BrowserStateWriter {
14800
14864
  this.onError = options.onError ?? (() => {
14801
14865
  });
14802
14866
  this.appVersion = options.appVersion;
14867
+ this.instanceId = options.instanceId;
14803
14868
  }
14804
14869
  now;
14805
14870
  onError;
14806
14871
  timer = null;
14807
14872
  written = false;
14808
14873
  appVersion;
14874
+ instanceId;
14809
14875
  /** Publish the record and start the heartbeat. The heartbeat is the
14810
14876
  * difference between "the app is running and can open a tab on demand" and a
14811
14877
  * record that names a process that died without cleaning up. */
@@ -14837,6 +14903,7 @@ class BrowserStateWriter {
14837
14903
  proto: PROTO_VERSION,
14838
14904
  host: "ui",
14839
14905
  app_version: this.appVersion,
14906
+ ...this.instanceId && { instance_id: this.instanceId },
14840
14907
  profile_dir: facts.profileDir,
14841
14908
  // Advertised from the ONE list the `/health` route also reads (§6.3), so the
14842
14909
  // record a session reads and the probe Python acquits a stale record with
@@ -24117,6 +24184,57 @@ function restrictMode(path2, log2) {
24117
24184
  );
24118
24185
  }
24119
24186
  }
24187
+ class StartupRollback {
24188
+ owners = [];
24189
+ add(cleanup) {
24190
+ this.owners.push(cleanup);
24191
+ }
24192
+ commit() {
24193
+ this.owners = [];
24194
+ }
24195
+ async run(log2) {
24196
+ for (const cleanup of this.owners.splice(0).reverse()) {
24197
+ try {
24198
+ await cleanup();
24199
+ } catch (error) {
24200
+ log2(`[browser] startup cleanup failed: ${String(error)}`);
24201
+ }
24202
+ }
24203
+ }
24204
+ }
24205
+ const MAX_START_ATTEMPTS = 3;
24206
+ async function startWithRetries(options) {
24207
+ let failure = new Error("The browser window closed before startup.");
24208
+ for (let attempt = 1; attempt <= MAX_START_ATTEMPTS; attempt += 1) {
24209
+ if (options.cancelled()) break;
24210
+ options.publish({
24211
+ status: attempt === 1 ? "starting" : "retrying",
24212
+ attempt,
24213
+ maxAttempts: MAX_START_ATTEMPTS,
24214
+ message: null,
24215
+ canRetry: false
24216
+ });
24217
+ try {
24218
+ return await options.start();
24219
+ } catch (error) {
24220
+ failure = error;
24221
+ options.log(
24222
+ `[browser] startup attempt ${attempt}/${MAX_START_ATTEMPTS} failed: ${String(error)}`
24223
+ );
24224
+ }
24225
+ }
24226
+ options.publish({
24227
+ status: "unavailable",
24228
+ attempt: MAX_START_ATTEMPTS,
24229
+ maxAttempts: MAX_START_ATTEMPTS,
24230
+ message: "The browser could not start. Your saved tabs and site approvals are kept; sessions you were signed into may need signing in again.",
24231
+ // The window is still open and no host is running once the sequence
24232
+ // exhausts, so the renderer may offer a retry. `startBrowserHost` is
24233
+ // idempotent on re-registration, which is what makes the control safe.
24234
+ canRetry: true
24235
+ });
24236
+ throw failure;
24237
+ }
24120
24238
  const BROWSER_IPC_CHANNELS = [
24121
24239
  "browser-state",
24122
24240
  "browser-new-tab",
@@ -24138,19 +24256,53 @@ const BROWSER_IPC_CHANNELS = [
24138
24256
  "browser-clear-data",
24139
24257
  "browser-webauthn-respond",
24140
24258
  "browser-webauthn-pending",
24141
- "browser-reveal-downloads"
24259
+ "browser-reveal-downloads",
24260
+ /** The user's own recovery click on the `unavailable` state (round 1, D1a).
24261
+ * Named here so the namespace stays the one place that lists its channels. */
24262
+ "browser-retry"
24142
24263
  ];
24143
24264
  function registerBrowserIpc(options) {
24144
- function authorize(event) {
24265
+ function authorizeSender(event) {
24145
24266
  const owner = options.window();
24146
24267
  if (!owner || owner.isDestroyed() || event.sender !== owner.webContents || event.senderFrame !== owner.webContents.mainFrame || !trustedDesktopFrame(event.senderFrame.url, options.expectedUrl)) {
24147
24268
  throw new Error("This window cannot use the browser controls.");
24148
24269
  }
24270
+ }
24271
+ function authorize(event) {
24272
+ authorizeSender(event);
24149
24273
  const host = options.host();
24150
- if (!host) throw new Error("The browser host is not running.");
24274
+ if (!host)
24275
+ throw new Error("browser_unavailable: The browser is not ready.");
24151
24276
  return host;
24152
24277
  }
24153
- electron.ipcMain.handle("browser-state", (event) => authorize(event).chromeState());
24278
+ electron.ipcMain.handle("browser-state", (event) => {
24279
+ authorizeSender(event);
24280
+ const host = options.host();
24281
+ const availability = options.availability?.();
24282
+ if (host)
24283
+ return { ...host.chromeState(), ...availability && { availability } };
24284
+ return {
24285
+ tabs: [],
24286
+ activeTabId: null,
24287
+ url: "",
24288
+ title: "",
24289
+ loading: false,
24290
+ canGoBack: false,
24291
+ canGoForward: false,
24292
+ pendingConsent: [],
24293
+ approvals: [],
24294
+ navFailure: null,
24295
+ availability: availability ?? {
24296
+ status: "unavailable",
24297
+ attempt: 0,
24298
+ maxAttempts: MAX_START_ATTEMPTS,
24299
+ message: "The browser is not running.",
24300
+ // No host invokes this fallback (it is the pre-start projection), and
24301
+ // there is nothing to retry before a start was asked for.
24302
+ canRetry: false
24303
+ }
24304
+ };
24305
+ });
24154
24306
  electron.ipcMain.handle("browser-new-tab", async (event, sessionId2) => {
24155
24307
  const host = authorize(event);
24156
24308
  return host.newTab(optionalConversationOrThrow(sessionId2));
@@ -24276,6 +24428,13 @@ function registerBrowserIpc(options) {
24276
24428
  );
24277
24429
  return { opened: message === "", message };
24278
24430
  });
24431
+ electron.ipcMain.handle("browser-retry", async (event) => {
24432
+ authorizeSender(event);
24433
+ if (!options.retry)
24434
+ throw new Error("browser_unavailable: Retry is not available.");
24435
+ await options.retry();
24436
+ return { retried: true };
24437
+ });
24279
24438
  }
24280
24439
  function unregisterBrowserIpc() {
24281
24440
  for (const channel of BROWSER_IPC_CHANNELS) electron.ipcMain.removeHandler(channel);
@@ -24698,7 +24857,7 @@ function installBrowserSessionHandlers(browserSession, hooks = {}) {
24698
24857
  return false;
24699
24858
  }
24700
24859
  );
24701
- browserSession.on("will-download", (event, item, webContents) => {
24860
+ const onDownload = (event, item, webContents) => {
24702
24861
  const webContentsId = webContents?.id ?? -1;
24703
24862
  const decision = hooks.onDownload?.(item, webContentsId) ?? {
24704
24863
  cancel: true,
@@ -24718,7 +24877,11 @@ function installBrowserSessionHandlers(browserSession, hooks = {}) {
24718
24877
  );
24719
24878
  }
24720
24879
  hooks.onDownloadDecided?.(outcome);
24721
- });
24880
+ };
24881
+ browserSession.on("will-download", onDownload);
24882
+ return () => {
24883
+ browserSession.removeListener("will-download", onDownload);
24884
+ };
24722
24885
  }
24723
24886
  async function clearBrowsingData(browserSession, what) {
24724
24887
  browserSession.flushStorageData();
@@ -24769,6 +24932,7 @@ function flushBrowserStorage(browserSession) {
24769
24932
  const RPC_PATH = "/rpc";
24770
24933
  const HEALTH_PATH = "/health";
24771
24934
  const KEY_HEADER = "x-bridge-key";
24935
+ const INSTANCE_HEADER = "x-ui-browser-instance";
24772
24936
  const MAX_BODY_BYTES = 1 << 20;
24773
24937
  const LOOPBACK_HOST = "127.0.0.1";
24774
24938
  function send(res, status2, body, contentType = "application/json") {
@@ -24895,6 +25059,7 @@ async function handle(req, res, options) {
24895
25059
  host: "ui",
24896
25060
  proto: PROTO_VERSION,
24897
25061
  pid: process.pid,
25062
+ ...options.instanceId && { instance_id: options.instanceId },
24898
25063
  // Additive: an older reader requires `host` and `pid` and ignores the
24899
25064
  // rest, and a newer one learns whether the console is up without a
24900
25065
  // second probe (design 10.1). `false` when nobody reported a
@@ -24921,6 +25086,11 @@ async function handle(req, res, options) {
24921
25086
  send(res, 401, { detail: "unauthorized" });
24922
25087
  return;
24923
25088
  }
25089
+ const expectedInstance = req.headers[INSTANCE_HEADER];
25090
+ if (expectedInstance !== void 0 && expectedInstance !== options.instanceId) {
25091
+ send(res, 409, { detail: "host instance changed" });
25092
+ return;
25093
+ }
24924
25094
  let raw;
24925
25095
  try {
24926
25096
  raw = JSON.parse(await readBody(req));
@@ -26040,13 +26210,28 @@ function refusesNavigation(rawUrl) {
26040
26210
  let activeHost = null;
26041
26211
  let stopping = null;
26042
26212
  function browserHostStopPending() {
26043
- return activeHost !== null || stopping !== null;
26213
+ return activeHost !== null || stopping !== null || starting !== null;
26044
26214
  }
26045
26215
  async function stopBrowserHost() {
26046
26216
  if (stopping) return stopping;
26217
+ if (starting) {
26218
+ cancelStartup = true;
26219
+ stopping = starting.catch(() => null).then(async () => {
26220
+ const current = activeHost;
26221
+ activeHost = null;
26222
+ if (current) await current.stop();
26223
+ else unregisterBrowserIpc();
26224
+ }).finally(() => {
26225
+ stopping = null;
26226
+ });
26227
+ return stopping;
26228
+ }
26047
26229
  const handle2 = activeHost;
26048
26230
  activeHost = null;
26049
- if (!handle2) return;
26231
+ if (!handle2) {
26232
+ unregisterBrowserIpc();
26233
+ return;
26234
+ }
26050
26235
  stopping = handle2.stop().finally(() => {
26051
26236
  stopping = null;
26052
26237
  });
@@ -26056,524 +26241,650 @@ function browserHostEnabled(env = process.env) {
26056
26241
  const flag = env.LOCAL_OPERATOR_UI_BROWSER_HOST?.trim().toLowerCase();
26057
26242
  return !(flag === "0" || flag === "false" || flag === "off");
26058
26243
  }
26059
- async function startBrowserHost(options) {
26060
- const { log: log2 } = options;
26061
- const browserSession = resolveBrowserSession();
26062
- let registryForDownloads = null;
26063
- const downloads = new DownloadArmer({
26064
- tabForWebContents: (webContentsId) => registryForDownloads?.byWebContents(webContentsId)?.tabId ?? null,
26065
- // WHO OWNED THE TAB, recorded on the decision (review round 2, U10). Read from
26066
- // the registry at the moment the note is written, because the row may render it
26067
- // after the tab is closed — and "· on another tab" about a tab that no longer
26068
- // exists is a marker pointing at nothing. `null` when the lookup cannot answer
26069
- // (a tab already gone), which the row renders the old way rather than guessing.
26070
- ownerKindFor: (tabId) => registryForDownloads?.get(tabId)?.owner ?? null,
26071
- log: log2,
26072
- onActivity: () => {
26073
- if (options.window.isDestroyed()) return;
26074
- options.window.webContents.send("browser-state-changed");
26075
- }
26076
- });
26077
- installBrowserSessionHandlers(browserSession, {
26078
- onPermissionRequested: (details) => {
26079
- log2(
26080
- `[browser] denied a ${details.permission} permission request from ${details.origin || "(unknown origin)"}`
26081
- );
26082
- },
26083
- onDownload: (item, webContentsId) => downloads.decide(item, webContentsId),
26084
- onDownloadDecided: (outcome) => {
26085
- if (outcome.savePath) {
26086
- log2(
26087
- `[browser] saved a download from ${outcome.url} to ${outcome.savePath}`
26088
- );
26089
- }
26090
- },
26091
- log: log2
26092
- });
26093
- configurePslRules(PSL_RULES);
26094
- await installWebauthn({ log: log2 });
26095
- const describeSource = (frame) => {
26096
- if (!frame || typeof frame !== "object") {
26097
- return { tabId: null, pageTitle: null };
26098
- }
26099
- const contents = electron.webContents.fromFrame(frame);
26100
- const record = contents === void 0 ? void 0 : registry.byWebContentsId(contents.id);
26101
- if (!record) return { tabId: null, pageTitle: null };
26102
- const title = record.view.webContents.isDestroyed() ? "" : record.view.webContents.getTitle();
26103
- return { tabId: record.tabId, pageTitle: title || null };
26104
- };
26105
- const webauthn = new WebauthnChooser({
26106
- notify: (request) => {
26107
- if (options.window.isDestroyed()) return;
26108
- options.window.webContents.send("browser-webauthn-request", request);
26109
- },
26110
- /*
26111
- * AND THE OTHER DIRECTION: every settle is pushed, not only every arrival.
26112
- *
26113
- * Without this the renderer cannot tell a live request from one main has
26114
- * already cancelled, so a chooser whose request expired while the user was
26115
- * away stays on screen offering rows that can no longer do anything (design
26116
- * round 1, D2) — and the click is then discarded silently. The outcome travels
26117
- * with the push because the two cases differ for the user: their own answer is
26118
- * already off the screen, an expiry has to be explained.
26119
- */
26120
- onSettled: (requestId2, outcome) => {
26121
- if (options.window.isDestroyed()) return;
26122
- options.window.webContents.send("browser-webauthn-settled", {
26123
- requestId: requestId2,
26124
- outcome
26125
- });
26126
- },
26127
- describeSource,
26128
- log: log2
26129
- });
26130
- const detachWebauthn = attachWebauthnChooser(browserSession, webauthn, log2);
26131
- const cookieJar = createHiddenCookieJarTarget(
26132
- new electron.WebContentsView({
26133
- webPreferences: { ...HIDDEN_COOKIE_JAR_WEB_PREFERENCES }
26134
- })
26135
- );
26136
- const sessionCookies = new SessionCookieVault({
26137
- jar: cookieJar.jar,
26138
- cipher: browserProfileCipher(),
26139
- ...sessionCookiePaths(options.userDataDir),
26140
- flushStore: () => browserSession.cookies.flushStore(),
26141
- clearSessionData: (what) => clearBrowsingData(browserSession, what),
26142
- log: log2
26143
- });
26144
- let restoreReport = null;
26145
- try {
26146
- restoreReport = await sessionCookies.restore();
26147
- } catch (error) {
26148
- log2(`[browser] session cookies: the restore failed (${String(error)})`);
26149
- }
26150
- const cdp = new CdpPool({ log: log2 });
26151
- const views = /* @__PURE__ */ new Map();
26152
- let stateWriter = null;
26153
- const sessionStore = new BrowserSessionStore({
26154
- dir: path$1.join(options.userDataDir, "browser"),
26155
- log: log2
26156
- });
26157
- const consentNotifier = new ConsentNotifier({
26158
- show: options.windowShow,
26159
- /*
26160
- * The click's own rule lives in `consentClickHandler` (see it: the delivered
26161
- * payload, the order, and the raise are all asserted there), and this is the
26162
- * wiring that gives it the real window and the real plan. `windowShow` is the
26163
- * plan the click comes forward under — this module still never decides whether a
26164
- * window comes forward (design 11.4), it hands the plan to `window-raise.ts`.
26165
- */
26166
- onAttention: consentClickHandler({
26167
- /*
26168
- * THE WINDOW IS ASKED FOR, NOT CAPTURED, and the difference is the U2 fix:
26169
- * this host is stopped the moment its window closes (`src/main/index.ts`'s
26170
- * `closed` hook), but a banner already raised is still in Notification
26171
- * Center and its click still lands here. A captured window would be a
26172
- * destroyed one by then, and reading it throws.
26173
- */
26174
- window: () => options.window,
26175
- show: options.windowShow,
26176
- report: options.reportRaise,
26177
- reopen: options.reopenConsent
26178
- }),
26179
- log: log2
26180
- });
26181
- const captureSession = () => {
26182
- sessionStore.record(
26183
- captureTabs(
26184
- registry.list(),
26185
- registry.activeTab?.tabId ?? null,
26186
- host.failedTabIds()
26187
- )
26188
- );
26189
- };
26190
- const notifyChanged = () => {
26191
- stateWriter?.publishNow();
26192
- options.window.webContents.send("browser-state-changed");
26193
- captureSession();
26194
- };
26195
- const registry = new TabRegistry(
26196
- (_createOptions, tabId) => {
26197
- const view = buildView(options.window);
26198
- views.set(tabId, view);
26199
- wireView(view, tabId);
26200
- return view;
26201
- },
26202
- releaseTab,
26203
- notifyChanged
26204
- );
26205
- registryForDownloads = registry;
26206
- function releaseTab(tabId, webContentsId) {
26207
- const view = views.get(tabId);
26208
- views.delete(tabId);
26209
- downloads.forget(tabId);
26210
- void cdp.detach(webContentsId).finally(() => {
26211
- releaseView(options.window, view);
26244
+ const hostInstanceId = node_crypto.randomUUID();
26245
+ let starting = null;
26246
+ let startingWindow = null;
26247
+ let cancelStartup = false;
26248
+ function startBrowserHost(options) {
26249
+ if (starting) {
26250
+ if (startingWindow === options.window) return starting;
26251
+ return starting.catch(() => null).then(async () => {
26252
+ await stopBrowserHost();
26253
+ return startBrowserHost(options);
26212
26254
  });
26213
26255
  }
26214
- const ownership = new OwnershipLedger({
26215
- mayAdopt: (token, requester) => {
26216
- const record = registry.requireSurface(token);
26217
- return record.handedTo === requester.slice("session:".length) && !record.allocationId;
26218
- },
26219
- closeTab: async (token) => {
26220
- try {
26221
- const record = registry.requireSurface(token);
26222
- approvals.forgetDocument(token);
26223
- registry.destroy(record.tabId);
26224
- } catch {
26225
- }
26226
- return true;
26227
- },
26228
- isLive: (token) => {
26229
- try {
26230
- registry.requireSurface(token);
26231
- return true;
26232
- } catch {
26233
- return false;
26234
- }
26235
- }
26236
- });
26237
- const approvals = new ApprovalStore({
26238
- dir: path$1.join(options.userDataDir, "browser"),
26239
- onChanged: () => {
26240
- options.window.webContents.send("browser-consent-changed");
26241
- consentNotifier.announce(approvals.pendingEntries());
26242
- },
26243
- log: log2
26244
- });
26245
- const profileDir = browserStoragePath(browserSession) ?? "";
26246
- const facts = {
26247
- proto: 1,
26248
- appVersion: options.appVersion,
26249
- profileDir,
26250
- profilePersistent: browserSession.isPersistent(),
26251
- userAgent: browserUserAgent(),
26252
- domainScope: domainScopeAvailable()
26256
+ if (stopping) return stopping.then(() => startBrowserHost(options));
26257
+ if (activeHost) return Promise.resolve(activeHost);
26258
+ cancelStartup = false;
26259
+ startingWindow = options.window;
26260
+ let availability = {
26261
+ status: "starting",
26262
+ attempt: 1,
26263
+ maxAttempts: MAX_START_ATTEMPTS,
26264
+ message: null,
26265
+ canRetry: false
26253
26266
  };
26254
- const host = new BrowserHost({
26255
- registry,
26256
- cdp,
26257
- approvals,
26258
- ownership,
26259
- downloads,
26260
- log: log2,
26261
- onChanged: notifyChanged,
26262
- facts: () => facts,
26263
- forgetSiteData: (origin) => clearOriginData(browserSession, origin)
26264
- });
26265
- const recorded = readSession(sessionStore.filePath, log2);
26266
- host.restoreTabs(recorded);
26267
- if (recorded.length) {
26268
- log2(
26269
- `[browser] restored ${recorded.length} tab(s) from ${SESSION_FILENAME}; every restored tab is user-owned and holds no handle (design 7.3)`
26270
- );
26271
- }
26272
- const consoleStartup = await startConsoleHost({
26273
- window: options.window,
26274
- expectedUrl: options.expectedUrl,
26275
- appVersion: options.appVersion,
26276
- notifier: options.notifier,
26277
- /*
26278
- * The capture view's document and preload, forwarded rather than re-derived:
26279
- * only the app knows where its own bundle lives, and a console host that
26280
- * guessed would answer a screenshot with a blank frame rather than with its
26281
- * own typed refusal. WITHOUT THESE TWO the host builds no capture view at all,
26282
- * which the live proof rig caught as `capture_unavailable` on a surface with
26283
- * no displayed pane — the exact shape of a seam that is wired in tests and
26284
- * forgotten in the one call site that matters.
26285
- */
26286
- consoleCaptureUrl: options.consoleCaptureUrl,
26287
- preloadPath: options.preloadPath,
26288
- userShellPath: options.userShellPath,
26289
- log: log2
26290
- });
26291
- const sessionKey = mintSessionKey();
26292
- const server = await startRpcServer({
26293
- key: sessionKey,
26294
- dispatch: async (method, params, requestId2) => {
26295
- if (!isConsoleDispatchMethod(method)) {
26296
- return host.dispatch(method, params, requestId2);
26297
- }
26298
- if (!consoleStartup.ok) {
26299
- throw new ConsoleError(
26300
- "console_unavailable",
26301
- `this app's console is not available (${consoleStartup.reason}): ${consoleStartup.detail}`,
26302
- { reason: consoleStartup.reason }
26303
- );
26304
- }
26305
- return consoleStartup.dispatch(method, params);
26306
- },
26307
- log: log2,
26308
- capabilities: () => ({ console: consoleStartup.ok })
26309
- });
26310
- stateWriter = new BrowserStateWriter(
26311
- stateFilePath(),
26312
- () => {
26313
- const consoleCounts = consoleStartup.ok ? consoleStartup.counts() : { total: 0, agent: 0 };
26314
- return {
26315
- tabs: registry.count(),
26316
- agentTabs: registry.agentTabCount(),
26317
- profileDir,
26318
- console: consoleStartup.ok,
26319
- consoleSurfaces: consoleCounts.total,
26320
- consoleAgentSurfaces: consoleCounts.agent
26321
- };
26322
- },
26323
- {
26324
- appVersion: options.appVersion,
26325
- onError: (error) => log2(`[browser] state file: ${String(error)}`)
26326
- }
26327
- );
26328
- stateWriter.start(server.port, sessionKey);
26267
+ const bindings = {};
26268
+ const notify = () => {
26269
+ if (!options.window.isDestroyed())
26270
+ options.window.webContents.send("browser-state-changed");
26271
+ };
26272
+ unregisterBrowserIpc();
26329
26273
  registerBrowserIpc({
26330
26274
  window: () => options.window,
26331
26275
  expectedUrl: options.expectedUrl,
26332
- host: () => host,
26333
- webauthn: () => webauthn,
26334
- clearData: (what) => sessionCookies.clearBrowsingData(what),
26335
- // THE REVEAL TAKES NO PATH FROM THE RENDERER (§16.4): it opens the directory the
26336
- // host is actually writing into, so the one place a page-derived string could
26337
- // have become a path stays out of the IPC surface as well. `shell.openPath`
26338
- // answers "" on success and a message on failure, which is returned rather than
26339
- // thrown: a reveal that fails is a Finder problem, not a fault in the agent's
26340
- // download.
26276
+ host: () => bindings.host?.() ?? null,
26277
+ webauthn: () => bindings.webauthn?.() ?? null,
26278
+ availability: () => availability,
26279
+ clearData: async (what) => {
26280
+ if (!bindings.clearData)
26281
+ throw new Error("browser_unavailable: The browser is not ready.");
26282
+ return bindings.clearData(what);
26283
+ },
26341
26284
  revealDownloads: async () => {
26342
- const dir = downloads.downloadDir();
26343
- if (!dir) return "no download directory yet";
26344
- return electron.shell.openPath(dir);
26285
+ if (!bindings.revealDownloads)
26286
+ throw new Error("browser_unavailable: The browser is not ready.");
26287
+ return bindings.revealDownloads();
26345
26288
  },
26346
- log: log2
26289
+ // The recovery click on the `unavailable` state (round 1, D1a). It restarts
26290
+ // THIS module's host for the same window — the same call `createWindow`
26291
+ // makes — so the retry travels the one lifecycle path rather than a second,
26292
+ // parallel start. A doubled click needs no guard of its own:
26293
+ // `startBrowserHost` is already single-flight (a second call for the same
26294
+ // window returns the in-flight `starting` promise), which QA's concurrent
26295
+ // probe confirmed — three simultaneous retries produced ONE startup
26296
+ // sequence. Adding a local guard beside that would be a second mechanism
26297
+ // for a rule the module already owns (round 2, Q-R2-2).
26298
+ retry: async () => {
26299
+ await startBrowserHost(options).catch(() => {
26300
+ });
26301
+ },
26302
+ log: options.log
26347
26303
  });
26348
- log2(
26349
- `[browser] host on ${server.address}:${server.port} (proto ${facts.proto}), profile ${profileDir || "(in-memory)"}, agent tabs ${registry.agentTabCount()}/${8}, console ${consoleStartup.ok ? `on (${consoleStartup.counts().total} surface(s))` : `off (${consoleStartup.reason})`}`
26350
- );
26351
- const handle2 = {
26352
- host,
26353
- port: server.port,
26354
- profileDir,
26355
- agentTabs: () => registry.agentTabCount(),
26356
- sessionCookies: () => restoreReport,
26357
- stop: async () => {
26358
- const atStop = captureTabs(
26359
- registry.list(),
26360
- registry.activeTab?.tabId ?? null,
26361
- host.failedTabIds()
26362
- );
26363
- const decision = sessionStore.commitStopCapture(atStop);
26364
- if (!decision.write) {
26365
- log2(
26366
- `[browser] not overwriting ${SESSION_FILENAME} at stop: ${decision.reason}`
26367
- );
26368
- }
26369
- sessionStore.flush();
26370
- registry.destroyAll();
26371
- await cdp.close();
26372
- await server.close();
26373
- await consoleStartup.stop();
26374
- unregisterBrowserIpc();
26375
- detachWebauthn();
26376
- webauthn.dispose();
26377
- stateWriter?.clear();
26378
- approvals.resetPending();
26379
- ownership.clear();
26380
- flushBrowserStorage(browserSession);
26381
- try {
26382
- await sessionCookies.snapshot();
26383
- } catch (error) {
26384
- log2(
26385
- `[browser] session cookies: the snapshot failed (${String(error)})`
26386
- );
26387
- }
26388
- cookieJar.dispose();
26389
- log2("[browser] host stopped");
26304
+ starting = startWithRetries({
26305
+ cancelled: () => cancelStartup || options.window.isDestroyed(),
26306
+ publish: (next) => {
26307
+ availability = next;
26308
+ notify();
26309
+ },
26310
+ log: options.log,
26311
+ start: async () => {
26312
+ const handle2 = await initializeBrowserHost(options, bindings);
26313
+ availability = {
26314
+ ...availability,
26315
+ status: "ready",
26316
+ message: null,
26317
+ canRetry: false
26318
+ };
26319
+ notify();
26320
+ return handle2;
26390
26321
  }
26322
+ }).finally(() => {
26323
+ starting = null;
26324
+ startingWindow = null;
26325
+ });
26326
+ return starting;
26327
+ }
26328
+ async function initializeBrowserHost(options, bindings) {
26329
+ const { log: log2 } = options;
26330
+ const rollback = new StartupRollback();
26331
+ let published = false;
26332
+ const checkpoint = () => {
26333
+ if (cancelStartup || options.window.isDestroyed())
26334
+ throw new Error("The browser window closed during startup.");
26391
26335
  };
26392
- activeHost = handle2;
26393
- return handle2;
26394
- function buildView(window) {
26395
- const view = new electron.WebContentsView({
26396
- webPreferences: {
26397
- partition: BROWSER_PARTITION,
26398
- contextIsolation: true,
26399
- nodeIntegration: false,
26400
- webSecurity: true,
26401
- allowRunningInsecureContent: false,
26402
- sandbox: true
26403
- // No `preload`: a driven page has no bridge to anything.
26404
- }
26405
- });
26406
- window.contentView.addChildView(view);
26407
- view.setBounds(BACKGROUND_VIEWPORT);
26408
- view.setVisible(false);
26409
- return view;
26410
- }
26411
- function wireView(view, tabId) {
26412
- const contents = view.webContents;
26413
- const popupChildren = /* @__PURE__ */ new Set();
26414
- contents.setWindowOpenHandler((details) => {
26415
- const decision = decidePopup(
26416
- { url: details.url, disposition: details.disposition },
26417
- { mode: options.windowShow, live: popupChildren.size }
26418
- );
26419
- if (!decision.allow) {
26336
+ try {
26337
+ let releaseTab = function(tabId, webContentsId) {
26338
+ const view = views.get(tabId);
26339
+ views.delete(tabId);
26340
+ downloads.forget(tabId);
26341
+ const release = cdp.detach(webContentsId).finally(() => releaseView(options.window, view));
26342
+ releases.add(release);
26343
+ void release.catch(() => {
26344
+ }).finally(() => releases.delete(release));
26345
+ }, buildView = function(window) {
26346
+ const view = new electron.WebContentsView({
26347
+ webPreferences: {
26348
+ partition: BROWSER_PARTITION,
26349
+ contextIsolation: true,
26350
+ nodeIntegration: false,
26351
+ webSecurity: true,
26352
+ allowRunningInsecureContent: false,
26353
+ sandbox: true
26354
+ // No `preload`: a driven page has no bridge to anything.
26355
+ }
26356
+ });
26357
+ window.contentView.addChildView(view);
26358
+ view.setBounds(BACKGROUND_VIEWPORT);
26359
+ view.setVisible(false);
26360
+ return view;
26361
+ }, wireView = function(view, tabId) {
26362
+ const contents = view.webContents;
26363
+ const popupChildren = /* @__PURE__ */ new Set();
26364
+ contents.setWindowOpenHandler((details) => {
26365
+ const decision = decidePopup(
26366
+ { url: details.url, disposition: details.disposition },
26367
+ { mode: options.windowShow, live: popupChildren.size }
26368
+ );
26369
+ if (!decision.allow) {
26370
+ log2(
26371
+ `[browser] refused a popup from a driven page: ${details.url} (${decision.reason})`
26372
+ );
26373
+ return { action: "deny" };
26374
+ }
26420
26375
  log2(
26421
- `[browser] refused a popup from a driven page: ${details.url} (${decision.reason})`
26376
+ `[browser] tab ${tabId} opened a popup: ${details.url} (disposition=${details.disposition}, presentation=${decision.presentation})`
26422
26377
  );
26423
- return { action: "deny" };
26424
- }
26425
- log2(
26426
- `[browser] tab ${tabId} opened a popup: ${details.url} (disposition=${details.disposition}, presentation=${decision.presentation})`
26427
- );
26428
- return {
26429
- action: "allow",
26430
- overrideBrowserWindowOptions: {
26431
- width: 640,
26432
- height: 720,
26433
- minWidth: 480,
26434
- minHeight: 560,
26435
- center: true,
26436
- frame: true,
26437
- autoHideMenuBar: true,
26438
- // Nothing shows itself: presentation happens only through
26439
- // `presentPopupWindow` (window-raise.ts), under the launch plan's own
26440
- // gate. Under `never` the window exists hidden and drivable over CDP.
26441
- show: false,
26442
- // Defence in depth beside the plan gate: a window that will never be
26443
- // shown must also not be focusable — the same pairing `window-mode.ts`
26444
- // applies to the main window in `headless`.
26445
- focusable: decision.presentation !== "never",
26446
- backgroundColor: "#FFFFFF",
26447
- webPreferences: {
26448
- /*
26449
- * EXPLICIT, and load-bearing (design 2.3): `partition` is NOT among the
26450
- * security-related prefs Electron merges from the parent, and a popup
26451
- * created in a different session is not RELATED to its opener —
26452
- * Chromium severs `window.opener`, which is the entire contract a
26453
- * popup-based sign-in needs. The `about:blank` case lands in this same
26454
- * session by prefs-copy instead of by this line.
26455
- */
26456
- partition: BROWSER_PARTITION,
26457
- sandbox: true,
26458
- contextIsolation: true,
26459
- nodeIntegration: false,
26460
- webSecurity: true,
26461
- allowRunningInsecureContent: false,
26462
- spellcheck: false,
26463
- navigateOnDragDrop: false,
26464
- // Not security-inherited either: a hidden popup must render like a
26465
- // shown one so a capture of it is evidence (design 2.5).
26466
- backgroundThrottling: options.backgroundThrottling
26467
- // No `preload` — a popup has no bridge to anything, like the view.
26378
+ return {
26379
+ action: "allow",
26380
+ overrideBrowserWindowOptions: {
26381
+ width: 640,
26382
+ height: 720,
26383
+ minWidth: 480,
26384
+ minHeight: 560,
26385
+ center: true,
26386
+ frame: true,
26387
+ autoHideMenuBar: true,
26388
+ // Nothing shows itself: presentation happens only through
26389
+ // `presentPopupWindow` (window-raise.ts), under the launch plan's own
26390
+ // gate. Under `never` the window exists hidden and drivable over CDP.
26391
+ show: false,
26392
+ // Defence in depth beside the plan gate: a window that will never be
26393
+ // shown must also not be focusable — the same pairing `window-mode.ts`
26394
+ // applies to the main window in `headless`.
26395
+ focusable: decision.presentation !== "never",
26396
+ backgroundColor: "#FFFFFF",
26397
+ webPreferences: {
26398
+ /*
26399
+ * EXPLICIT, and load-bearing (design 2.3): `partition` is NOT among the
26400
+ * security-related prefs Electron merges from the parent, and a popup
26401
+ * created in a different session is not RELATED to its opener —
26402
+ * Chromium severs `window.opener`, which is the entire contract a
26403
+ * popup-based sign-in needs. The `about:blank` case lands in this same
26404
+ * session by prefs-copy instead of by this line.
26405
+ */
26406
+ partition: BROWSER_PARTITION,
26407
+ sandbox: true,
26408
+ contextIsolation: true,
26409
+ nodeIntegration: false,
26410
+ webSecurity: true,
26411
+ allowRunningInsecureContent: false,
26412
+ spellcheck: false,
26413
+ navigateOnDragDrop: false,
26414
+ // Not security-inherited either: a hidden popup must render like a
26415
+ // shown one so a capture of it is evidence (design 2.5).
26416
+ backgroundThrottling: options.backgroundThrottling
26417
+ // No `preload` — a popup has no bridge to anything, like the view.
26418
+ }
26468
26419
  }
26420
+ };
26421
+ });
26422
+ contents.on("did-create-window", (child, details) => {
26423
+ wirePopup(child, details, popupChildren);
26424
+ });
26425
+ const onWillNavigate = (event, rawUrl) => {
26426
+ if (refusesNavigation(rawUrl)) {
26427
+ event.preventDefault();
26428
+ log2(`[browser] refused a navigation to ${rawUrl}`);
26469
26429
  }
26470
26430
  };
26471
- });
26472
- contents.on("did-create-window", (child, details) => {
26473
- wirePopup(child, details, popupChildren);
26474
- });
26475
- const onWillNavigate = (event, rawUrl) => {
26476
- if (refusesNavigation(rawUrl)) {
26431
+ contents.on("will-navigate", onWillNavigate);
26432
+ contents.on("will-redirect", onWillNavigate);
26433
+ contents.on("before-input-event", (event, input) => {
26434
+ const direction = paneNavigationDirection(input);
26435
+ if (!direction) return;
26436
+ event.preventDefault();
26437
+ host.historyActive(direction);
26438
+ });
26439
+ const notifyChrome = () => {
26440
+ options.window.webContents.send("browser-state-changed");
26441
+ };
26442
+ contents.on("did-start-loading", () => {
26443
+ host.clearLoadFailure(tabId);
26444
+ notifyChrome();
26445
+ });
26446
+ contents.on("did-stop-loading", notifyChrome);
26447
+ contents.on("page-title-updated", notifyChrome);
26448
+ contents.on(
26449
+ "did-fail-load",
26450
+ (_event, code, description, url, isMainFrame) => {
26451
+ if (!isMainFrame) return;
26452
+ if (isReportableLoadFailure(code)) {
26453
+ host.recordLoadFailure(tabId, {
26454
+ code,
26455
+ description,
26456
+ url
26457
+ });
26458
+ }
26459
+ notifyChrome();
26460
+ }
26461
+ );
26462
+ contents.on("will-attach-webview", (event) => {
26463
+ event.preventDefault();
26464
+ log2("[browser] refused a webview attachment inside a driven page");
26465
+ });
26466
+ contents.on("did-navigate", (_event, url) => {
26467
+ registry.bumpEpoch(tabId);
26468
+ host.clearLoadFailure(tabId);
26469
+ stopLogCapture(contents.id);
26470
+ startLogCapture(contents.id);
26471
+ log2(`[browser] tab ${tabId} navigated to ${url}`);
26472
+ notifyChanged();
26473
+ });
26474
+ contents.on("did-navigate-in-page", (_event, url, isMainFrame) => {
26475
+ if (!isMainFrame) return;
26476
+ registry.bumpEpoch(tabId, false);
26477
+ log2(`[browser] tab ${tabId} navigated in page to ${url}`);
26478
+ notifyChanged();
26479
+ });
26480
+ contents.on("destroyed", () => {
26481
+ const record = registry.get(tabId);
26482
+ if (record) {
26483
+ const token = surfaceToken(record);
26484
+ if (token) approvals.forgetDocument(token);
26485
+ registry.forget(tabId);
26486
+ }
26487
+ host.clearLoadFailure(tabId);
26488
+ releaseTab(tabId, contents.id);
26489
+ });
26490
+ }, wirePopup = function(child, details, popupChildren) {
26491
+ popupChildren.add(child);
26492
+ child.on("closed", () => popupChildren.delete(child));
26493
+ const childContents = child.webContents;
26494
+ childContents.setWindowOpenHandler((childDetails) => {
26495
+ log2(`[browser] refused a popup from a popup: ${childDetails.url}`);
26496
+ return { action: "deny" };
26497
+ });
26498
+ const onWillNavigate = (event, rawUrl) => {
26499
+ if (refusesNavigation(rawUrl)) {
26500
+ event.preventDefault();
26501
+ log2(`[browser] refused a popup navigation to ${rawUrl}`);
26502
+ }
26503
+ };
26504
+ childContents.on("will-navigate", onWillNavigate);
26505
+ childContents.on("will-redirect", onWillNavigate);
26506
+ childContents.on("will-attach-webview", (event) => {
26507
+ event.preventDefault();
26508
+ log2("[browser] refused a webview attachment inside a popup");
26509
+ });
26510
+ childContents.on("before-input-event", (event, input) => {
26511
+ const direction = paneNavigationDirection(input);
26512
+ if (!direction) return;
26477
26513
  event.preventDefault();
26478
- log2(`[browser] refused a navigation to ${rawUrl}`);
26514
+ const navigation = childContents.navigationHistory;
26515
+ if (direction === "back") navigation.goBack();
26516
+ else navigation.goForward();
26517
+ });
26518
+ if (!options.backgroundThrottling) {
26519
+ childContents.setBackgroundThrottling(false);
26479
26520
  }
26521
+ presentPopupWindow(
26522
+ child,
26523
+ effectivePresentation(options.windowShow, details.disposition),
26524
+ { trigger: "popup-open", report: options.reportRaise }
26525
+ );
26480
26526
  };
26481
- contents.on("will-navigate", onWillNavigate);
26482
- contents.on("will-redirect", onWillNavigate);
26483
- contents.on("before-input-event", (event, input) => {
26484
- const direction = paneNavigationDirection(input);
26485
- if (!direction) return;
26486
- event.preventDefault();
26487
- host.historyActive(direction);
26527
+ const browserSession = resolveBrowserSession();
26528
+ let registryForDownloads = null;
26529
+ const downloads = new DownloadArmer({
26530
+ tabForWebContents: (webContentsId) => registryForDownloads?.byWebContents(webContentsId)?.tabId ?? null,
26531
+ // WHO OWNED THE TAB, recorded on the decision (review round 2, U10). Read from
26532
+ // the registry at the moment the note is written, because the row may render it
26533
+ // after the tab is closed — and "· on another tab" about a tab that no longer
26534
+ // exists is a marker pointing at nothing. `null` when the lookup cannot answer
26535
+ // (a tab already gone), which the row renders the old way rather than guessing.
26536
+ ownerKindFor: (tabId) => registryForDownloads?.get(tabId)?.owner ?? null,
26537
+ log: log2,
26538
+ onActivity: () => {
26539
+ if (options.window.isDestroyed()) return;
26540
+ options.window.webContents.send("browser-state-changed");
26541
+ }
26542
+ });
26543
+ const detachSessionHandlers = installBrowserSessionHandlers(
26544
+ browserSession,
26545
+ {
26546
+ onPermissionRequested: (details) => {
26547
+ log2(
26548
+ `[browser] denied a ${details.permission} permission request from ${details.origin || "(unknown origin)"}`
26549
+ );
26550
+ },
26551
+ onDownload: (item, webContentsId) => downloads.decide(item, webContentsId),
26552
+ onDownloadDecided: (outcome) => {
26553
+ if (outcome.savePath) {
26554
+ log2(
26555
+ `[browser] saved a download from ${outcome.url} to ${outcome.savePath}`
26556
+ );
26557
+ }
26558
+ },
26559
+ log: log2
26560
+ }
26561
+ );
26562
+ rollback.add(detachSessionHandlers);
26563
+ configurePslRules(PSL_RULES);
26564
+ await installWebauthn({ log: log2 });
26565
+ checkpoint();
26566
+ const describeSource = (frame) => {
26567
+ if (!frame || typeof frame !== "object") {
26568
+ return { tabId: null, pageTitle: null };
26569
+ }
26570
+ const contents = electron.webContents.fromFrame(frame);
26571
+ const record = contents === void 0 ? void 0 : registry.byWebContentsId(contents.id);
26572
+ if (!record) return { tabId: null, pageTitle: null };
26573
+ const title = record.view.webContents.isDestroyed() ? "" : record.view.webContents.getTitle();
26574
+ return { tabId: record.tabId, pageTitle: title || null };
26575
+ };
26576
+ const webauthn = new WebauthnChooser({
26577
+ notify: (request) => {
26578
+ if (options.window.isDestroyed()) return;
26579
+ options.window.webContents.send("browser-webauthn-request", request);
26580
+ },
26581
+ /*
26582
+ * AND THE OTHER DIRECTION: every settle is pushed, not only every arrival.
26583
+ *
26584
+ * Without this the renderer cannot tell a live request from one main has
26585
+ * already cancelled, so a chooser whose request expired while the user was
26586
+ * away stays on screen offering rows that can no longer do anything (design
26587
+ * round 1, D2) — and the click is then discarded silently. The outcome travels
26588
+ * with the push because the two cases differ for the user: their own answer is
26589
+ * already off the screen, an expiry has to be explained.
26590
+ */
26591
+ onSettled: (requestId2, outcome) => {
26592
+ if (options.window.isDestroyed()) return;
26593
+ options.window.webContents.send("browser-webauthn-settled", {
26594
+ requestId: requestId2,
26595
+ outcome
26596
+ });
26597
+ },
26598
+ describeSource,
26599
+ log: log2
26600
+ });
26601
+ rollback.add(() => webauthn.dispose());
26602
+ const detachWebauthn = attachWebauthnChooser(browserSession, webauthn, log2);
26603
+ rollback.add(detachWebauthn);
26604
+ const cookieJar = createHiddenCookieJarTarget(
26605
+ new electron.WebContentsView({
26606
+ webPreferences: { ...HIDDEN_COOKIE_JAR_WEB_PREFERENCES }
26607
+ })
26608
+ );
26609
+ rollback.add(() => cookieJar.dispose());
26610
+ const sessionCookies = new SessionCookieVault({
26611
+ jar: cookieJar.jar,
26612
+ cipher: browserProfileCipher(),
26613
+ ...sessionCookiePaths(options.userDataDir),
26614
+ flushStore: () => browserSession.cookies.flushStore(),
26615
+ clearSessionData: (what) => clearBrowsingData(browserSession, what),
26616
+ log: log2
26617
+ });
26618
+ let restoreReport = null;
26619
+ try {
26620
+ restoreReport = await sessionCookies.restore();
26621
+ } catch (error) {
26622
+ log2(`[browser] session cookies: the restore failed (${String(error)})`);
26623
+ }
26624
+ checkpoint();
26625
+ const cdp = new CdpPool({ log: log2 });
26626
+ rollback.add(() => cdp.close());
26627
+ const views = /* @__PURE__ */ new Map();
26628
+ const releases = /* @__PURE__ */ new Set();
26629
+ let stateWriter = null;
26630
+ const sessionStore = new BrowserSessionStore({
26631
+ dir: path$1.join(options.userDataDir, "browser"),
26632
+ log: log2
26488
26633
  });
26489
- const notifyChrome = () => {
26634
+ const consentNotifier = new ConsentNotifier({
26635
+ show: options.windowShow,
26636
+ /*
26637
+ * The click's own rule lives in `consentClickHandler` (see it: the delivered
26638
+ * payload, the order, and the raise are all asserted there), and this is the
26639
+ * wiring that gives it the real window and the real plan. `windowShow` is the
26640
+ * plan the click comes forward under — this module still never decides whether a
26641
+ * window comes forward (design 11.4), it hands the plan to `window-raise.ts`.
26642
+ */
26643
+ onAttention: consentClickHandler({
26644
+ /*
26645
+ * THE WINDOW IS ASKED FOR, NOT CAPTURED, and the difference is the U2 fix:
26646
+ * this host is stopped the moment its window closes (`src/main/index.ts`'s
26647
+ * `closed` hook), but a banner already raised is still in Notification
26648
+ * Center and its click still lands here. A captured window would be a
26649
+ * destroyed one by then, and reading it throws.
26650
+ */
26651
+ window: () => options.window,
26652
+ show: options.windowShow,
26653
+ report: options.reportRaise,
26654
+ reopen: options.reopenConsent
26655
+ }),
26656
+ log: log2
26657
+ });
26658
+ const captureSession = () => {
26659
+ if (!published) return;
26660
+ sessionStore.record(
26661
+ captureTabs(
26662
+ registry.list(),
26663
+ registry.activeTab?.tabId ?? null,
26664
+ host.failedTabIds()
26665
+ )
26666
+ );
26667
+ };
26668
+ const notifyChanged = () => {
26669
+ stateWriter?.publishNow();
26490
26670
  options.window.webContents.send("browser-state-changed");
26671
+ captureSession();
26491
26672
  };
26492
- contents.on("did-start-loading", () => {
26493
- host.clearLoadFailure(tabId);
26494
- notifyChrome();
26673
+ const registry = new TabRegistry(
26674
+ (_createOptions, tabId) => {
26675
+ const view = buildView(options.window);
26676
+ views.set(tabId, view);
26677
+ wireView(view, tabId);
26678
+ return view;
26679
+ },
26680
+ releaseTab,
26681
+ notifyChanged
26682
+ );
26683
+ registryForDownloads = registry;
26684
+ rollback.add(async () => {
26685
+ published = false;
26686
+ sessionStore.discard();
26687
+ registry.destroyAll();
26688
+ await Promise.allSettled([...releases]);
26495
26689
  });
26496
- contents.on("did-stop-loading", notifyChrome);
26497
- contents.on("page-title-updated", notifyChrome);
26498
- contents.on(
26499
- "did-fail-load",
26500
- (_event, code, description, url, isMainFrame) => {
26501
- if (!isMainFrame) return;
26502
- if (isReportableLoadFailure(code)) {
26503
- host.recordLoadFailure(tabId, {
26504
- code,
26505
- description,
26506
- url
26507
- });
26690
+ const ownership = new OwnershipLedger({
26691
+ mayAdopt: (token, requester) => {
26692
+ const record = registry.requireSurface(token);
26693
+ return record.handedTo === requester.slice("session:".length) && !record.allocationId;
26694
+ },
26695
+ closeTab: async (token) => {
26696
+ try {
26697
+ const record = registry.requireSurface(token);
26698
+ approvals.forgetDocument(token);
26699
+ registry.destroy(record.tabId);
26700
+ } catch {
26701
+ }
26702
+ return true;
26703
+ },
26704
+ isLive: (token) => {
26705
+ try {
26706
+ registry.requireSurface(token);
26707
+ return true;
26708
+ } catch {
26709
+ return false;
26508
26710
  }
26509
- notifyChrome();
26510
26711
  }
26511
- );
26512
- contents.on("will-attach-webview", (event) => {
26513
- event.preventDefault();
26514
- log2("[browser] refused a webview attachment inside a driven page");
26515
26712
  });
26516
- contents.on("did-navigate", (_event, url) => {
26517
- registry.bumpEpoch(tabId);
26518
- host.clearLoadFailure(tabId);
26519
- stopLogCapture(contents.id);
26520
- startLogCapture(contents.id);
26521
- log2(`[browser] tab ${tabId} navigated to ${url}`);
26522
- notifyChanged();
26713
+ const approvals = new ApprovalStore({
26714
+ dir: path$1.join(options.userDataDir, "browser"),
26715
+ onChanged: () => {
26716
+ options.window.webContents.send("browser-consent-changed");
26717
+ consentNotifier.announce(approvals.pendingEntries());
26718
+ },
26719
+ log: log2
26523
26720
  });
26524
- contents.on("did-navigate-in-page", (_event, url, isMainFrame) => {
26525
- if (!isMainFrame) return;
26526
- registry.bumpEpoch(tabId, false);
26527
- log2(`[browser] tab ${tabId} navigated in page to ${url}`);
26528
- notifyChanged();
26721
+ const profileDir = browserStoragePath(browserSession) ?? "";
26722
+ const facts = {
26723
+ proto: 1,
26724
+ appVersion: options.appVersion,
26725
+ profileDir,
26726
+ profilePersistent: browserSession.isPersistent(),
26727
+ userAgent: browserUserAgent(),
26728
+ domainScope: domainScopeAvailable()
26729
+ };
26730
+ const host = new BrowserHost({
26731
+ registry,
26732
+ cdp,
26733
+ approvals,
26734
+ ownership,
26735
+ downloads,
26736
+ log: log2,
26737
+ onChanged: notifyChanged,
26738
+ facts: () => facts,
26739
+ forgetSiteData: (origin) => clearOriginData(browserSession, origin)
26529
26740
  });
26530
- contents.on("destroyed", () => {
26531
- const record = registry.get(tabId);
26532
- if (record) {
26533
- const token = surfaceToken(record);
26534
- if (token) approvals.forgetDocument(token);
26535
- registry.forget(tabId);
26536
- }
26537
- host.clearLoadFailure(tabId);
26538
- releaseTab(tabId, contents.id);
26741
+ const recorded = readSession(sessionStore.filePath, log2);
26742
+ host.restoreTabs(recorded);
26743
+ if (recorded.length) {
26744
+ log2(
26745
+ `[browser] restored ${recorded.length} tab(s) from ${SESSION_FILENAME}; every restored tab is user-owned and holds no handle (design 7.3)`
26746
+ );
26747
+ }
26748
+ const consoleStartup = await startConsoleHost({
26749
+ window: options.window,
26750
+ expectedUrl: options.expectedUrl,
26751
+ appVersion: options.appVersion,
26752
+ notifier: options.notifier,
26753
+ /*
26754
+ * The capture view's document and preload, forwarded rather than re-derived:
26755
+ * only the app knows where its own bundle lives, and a console host that
26756
+ * guessed would answer a screenshot with a blank frame rather than with its
26757
+ * own typed refusal. WITHOUT THESE TWO the host builds no capture view at all,
26758
+ * which the live proof rig caught as `capture_unavailable` on a surface with
26759
+ * no displayed pane — the exact shape of a seam that is wired in tests and
26760
+ * forgotten in the one call site that matters.
26761
+ */
26762
+ consoleCaptureUrl: options.consoleCaptureUrl,
26763
+ preloadPath: options.preloadPath,
26764
+ userShellPath: options.userShellPath,
26765
+ log: log2
26539
26766
  });
26540
- }
26541
- function wirePopup(child, details, popupChildren) {
26542
- popupChildren.add(child);
26543
- child.on("closed", () => popupChildren.delete(child));
26544
- const childContents = child.webContents;
26545
- childContents.setWindowOpenHandler((childDetails) => {
26546
- log2(`[browser] refused a popup from a popup: ${childDetails.url}`);
26547
- return { action: "deny" };
26767
+ rollback.add(() => consoleStartup.stop());
26768
+ checkpoint();
26769
+ const sessionKey = mintSessionKey();
26770
+ const server = await startRpcServer({
26771
+ key: sessionKey,
26772
+ instanceId: hostInstanceId,
26773
+ dispatch: async (method, params, requestId2) => {
26774
+ if (!isConsoleDispatchMethod(method)) {
26775
+ return host.dispatch(method, params, requestId2);
26776
+ }
26777
+ if (!consoleStartup.ok) {
26778
+ throw new ConsoleError(
26779
+ "console_unavailable",
26780
+ `this app's console is not available (${consoleStartup.reason}): ${consoleStartup.detail}`,
26781
+ { reason: consoleStartup.reason }
26782
+ );
26783
+ }
26784
+ return consoleStartup.dispatch(method, params);
26785
+ },
26786
+ log: log2,
26787
+ capabilities: () => ({ console: consoleStartup.ok })
26548
26788
  });
26549
- const onWillNavigate = (event, rawUrl) => {
26550
- if (refusesNavigation(rawUrl)) {
26551
- event.preventDefault();
26552
- log2(`[browser] refused a popup navigation to ${rawUrl}`);
26789
+ rollback.add(() => server.close());
26790
+ checkpoint();
26791
+ stateWriter = new BrowserStateWriter(
26792
+ stateFilePath(),
26793
+ () => {
26794
+ const consoleCounts = consoleStartup.ok ? consoleStartup.counts() : { total: 0, agent: 0 };
26795
+ return {
26796
+ tabs: registry.count(),
26797
+ agentTabs: registry.agentTabCount(),
26798
+ profileDir,
26799
+ console: consoleStartup.ok,
26800
+ consoleSurfaces: consoleCounts.total,
26801
+ consoleAgentSurfaces: consoleCounts.agent
26802
+ };
26803
+ },
26804
+ {
26805
+ appVersion: options.appVersion,
26806
+ instanceId: hostInstanceId,
26807
+ onError: (error) => log2(`[browser] state file: ${String(error)}`)
26553
26808
  }
26554
- };
26555
- childContents.on("will-navigate", onWillNavigate);
26556
- childContents.on("will-redirect", onWillNavigate);
26557
- childContents.on("will-attach-webview", (event) => {
26558
- event.preventDefault();
26559
- log2("[browser] refused a webview attachment inside a popup");
26560
- });
26561
- childContents.on("before-input-event", (event, input) => {
26562
- const direction = paneNavigationDirection(input);
26563
- if (!direction) return;
26564
- event.preventDefault();
26565
- const navigation = childContents.navigationHistory;
26566
- if (direction === "back") navigation.goBack();
26567
- else navigation.goForward();
26809
+ );
26810
+ rollback.add(() => stateWriter?.clear());
26811
+ stateWriter.start(server.port, sessionKey);
26812
+ Object.assign(bindings, {
26813
+ window: () => options.window,
26814
+ expectedUrl: options.expectedUrl,
26815
+ host: () => host,
26816
+ webauthn: () => webauthn,
26817
+ clearData: (what) => sessionCookies.clearBrowsingData(what),
26818
+ // THE REVEAL TAKES NO PATH FROM THE RENDERER (§16.4): it opens the directory the
26819
+ // host is actually writing into, so the one place a page-derived string could
26820
+ // have become a path stays out of the IPC surface as well. `shell.openPath`
26821
+ // answers "" on success and a message on failure, which is returned rather than
26822
+ // thrown: a reveal that fails is a Finder problem, not a fault in the agent's
26823
+ // download.
26824
+ revealDownloads: async () => {
26825
+ const dir = downloads.downloadDir();
26826
+ if (!dir) return "no download directory yet";
26827
+ return electron.shell.openPath(dir);
26828
+ },
26829
+ log: log2
26568
26830
  });
26569
- if (!options.backgroundThrottling) {
26570
- childContents.setBackgroundThrottling(false);
26571
- }
26572
- presentPopupWindow(
26573
- child,
26574
- effectivePresentation(options.windowShow, details.disposition),
26575
- { trigger: "popup-open", report: options.reportRaise }
26831
+ log2(
26832
+ `[browser] host on ${server.address}:${server.port} (proto ${facts.proto}), profile ${profileDir || "(in-memory)"}, agent tabs ${registry.agentTabCount()}/${8}, console ${consoleStartup.ok ? `on (${consoleStartup.counts().total} surface(s))` : `off (${consoleStartup.reason})`}`
26576
26833
  );
26834
+ const handle2 = {
26835
+ host,
26836
+ port: server.port,
26837
+ profileDir,
26838
+ agentTabs: () => registry.agentTabCount(),
26839
+ sessionCookies: () => restoreReport,
26840
+ stop: async () => {
26841
+ const atStop = captureTabs(
26842
+ registry.list(),
26843
+ registry.activeTab?.tabId ?? null,
26844
+ host.failedTabIds()
26845
+ );
26846
+ const decision = sessionStore.commitStopCapture(atStop);
26847
+ if (!decision.write) {
26848
+ log2(
26849
+ `[browser] not overwriting ${SESSION_FILENAME} at stop: ${decision.reason}`
26850
+ );
26851
+ }
26852
+ sessionStore.flush();
26853
+ registry.destroyAll();
26854
+ await Promise.allSettled([...releases]);
26855
+ await cdp.close();
26856
+ await server.close();
26857
+ await consoleStartup.stop();
26858
+ unregisterBrowserIpc();
26859
+ detachSessionHandlers();
26860
+ detachWebauthn();
26861
+ webauthn.dispose();
26862
+ stateWriter?.clear();
26863
+ approvals.resetPending();
26864
+ ownership.clear();
26865
+ flushBrowserStorage(browserSession);
26866
+ try {
26867
+ await sessionCookies.snapshot();
26868
+ } catch (error) {
26869
+ log2(
26870
+ `[browser] session cookies: the snapshot failed (${String(error)})`
26871
+ );
26872
+ }
26873
+ cookieJar.dispose();
26874
+ log2("[browser] host stopped");
26875
+ }
26876
+ };
26877
+ checkpoint();
26878
+ published = true;
26879
+ activeHost = handle2;
26880
+ rollback.commit();
26881
+ return handle2;
26882
+ } catch (error) {
26883
+ published = false;
26884
+ for (const key of Object.keys(bindings))
26885
+ delete bindings[key];
26886
+ await rollback.run(log2);
26887
+ throw error;
26577
26888
  }
26578
26889
  }
26579
26890
  function releaseView(window, view) {
@@ -37741,6 +38052,15 @@ class UpdateService {
37741
38052
  * so it is kept rather than fetched again on the click (review U17).
37742
38053
  */
37743
38054
  lastPublishedBackendVersion = null;
38055
+ /**
38056
+ * The version PyPI most recently NAMED whose files were not yet
38057
+ * downloadable, or null. Kept beside the published reading because the
38058
+ * manual check's sentence depends on the distinction (UX round 1, U1):
38059
+ * "a release is still publishing" and "the version could not be
38060
+ * determined" are different facts with different next steps, and the
38061
+ * gated read alone collapses them into one null.
38062
+ */
38063
+ pendingPypiVersion = null;
37744
38064
  /** Path of the artifact this run downloaded, when the updater reported one. */
37745
38065
  downloadedArtifactPath = null;
37746
38066
  /**
@@ -40550,8 +40870,17 @@ ${heal.removed.join("\n")}`,
40550
40870
  return (await this.readRunningBackend()).version;
40551
40871
  }
40552
40872
  /**
40553
- * Get the latest version from PyPI
40554
- * @returns Promise resolving to the latest version string or null if unable to fetch
40873
+ * Get the latest READY version from PyPI: the newest release whose
40874
+ * platform wheel an installer can actually download, per
40875
+ * `readPypiReleaseBody`. A version PyPI has named but whose files have
40876
+ * not finished uploading resolves null — the same answer as an
40877
+ * unreadable response, because both mean do not offer yet; the next
40878
+ * scheduled check re-reads. The named-but-not-ready version is kept on
40879
+ * `pendingPypiVersion` so the manual check can say which state it is
40880
+ * in. Reading only `info.version` here was the 2026-10-12 race: the
40881
+ * offer appeared minutes after publication and `lop update` exited 1
40882
+ * against artifacts that were not on PyPI yet.
40883
+ * @returns Promise resolving to the latest ready version string or null if unable to fetch
40555
40884
  */
40556
40885
  getLatestPypiVersion() {
40557
40886
  return new Promise((resolve) => {
@@ -40567,14 +40896,26 @@ ${heal.removed.join("\n")}`,
40567
40896
  { timeout: PYPI_VERSION_READ_TIMEOUT_MS },
40568
40897
  (res) => {
40569
40898
  let data = "";
40899
+ const settleAborted = () => {
40900
+ this.pendingPypiVersion = null;
40901
+ resolve(null);
40902
+ };
40903
+ res.on("error", settleAborted);
40904
+ res.on("aborted", settleAborted);
40570
40905
  res.on("data", (chunk) => {
40571
40906
  data += chunk;
40572
40907
  });
40573
40908
  res.on("end", () => {
40574
40909
  try {
40575
- const packageInfo = JSON.parse(data);
40576
- const latestVersion = packageInfo.info?.version;
40577
- resolve(latestVersion || null);
40910
+ const reading = readPypiReleaseBody(data, process.platform);
40911
+ this.pendingPypiVersion = reading.readyVersion === null ? reading.namedVersion : null;
40912
+ if (reading.readyVersion === null && reading.namedVersion) {
40913
+ logger.info(
40914
+ `PyPI names version ${reading.namedVersion} but its release has no downloadable wheel for this platform yet; not offering it. The next check will re-read.`,
40915
+ LogFileType.UPDATE_SERVICE
40916
+ );
40917
+ }
40918
+ resolve(reading.readyVersion);
40578
40919
  } catch (error) {
40579
40920
  logger.error(
40580
40921
  "Error parsing PyPI response:",
@@ -40594,6 +40935,7 @@ ${heal.removed.join("\n")}`,
40594
40935
  );
40595
40936
  });
40596
40937
  request.on("error", (error) => {
40938
+ this.pendingPypiVersion = null;
40597
40939
  logger.error(
40598
40940
  "Error fetching from PyPI:",
40599
40941
  LogFileType.UPDATE_SERVICE,
@@ -41028,7 +41370,19 @@ ${heal.removed.join("\n")}`,
41028
41370
  );
41029
41371
  if (reportFailure) {
41030
41372
  this.sendToRenderer("backend-update-error", {
41031
- message: "Unable to determine backend version.",
41373
+ /*
41374
+ * The not-ready state earns its own sentence (UX round 1, U1):
41375
+ * PyPI answered and named a release whose files are still
41376
+ * publishing, which is a wait, not a fault — the generic
41377
+ * sentence below reads as an installation problem and sends
41378
+ * the reader to debug one. Same producer-sentence shape as
41379
+ * the unreadable-install branch below. No retry action is
41380
+ * attached: the shared classifier gives authored sentences
41381
+ * no control (it names its own next step), and this sentence
41382
+ * does — check again in a few minutes, from the same control
41383
+ * the reader just used.
41384
+ */
41385
+ message: installedVersion && this.pendingPypiVersion ? `A new server release (${this.pendingPypiVersion}) is still publishing, so no update was offered yet. Check again in a few minutes.` : "Unable to determine backend version.",
41032
41386
  phase: "check",
41033
41387
  logPath: serverUpdateLogPath()
41034
41388
  });