local-operator-ui 0.29.5 → 0.29.7

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 (71) hide show
  1. package/out/main/index.js +833 -13
  2. package/out/preload/index.js +91 -0
  3. package/out/renderer/assets/{_basePickBy-Cz5Hn1h_.js → _basePickBy-DcTwJYO_.js} +1 -1
  4. package/out/renderer/assets/{_baseUniq-DBMiau7H.js → _baseUniq-DvPSd9xZ.js} +1 -1
  5. package/out/renderer/assets/{agent-details-page-BE8L9f8W.js → agent-details-page-DnhScOnI.js} +1 -1
  6. package/out/renderer/assets/{agent-hub-page-Cb66-VC4.js → agent-hub-page-BQ4Wcnxb.js} +2 -2
  7. package/out/renderer/assets/{agents-page-DEhKgzKH.js → agents-page-BCsceEiI.js} +2 -2
  8. package/out/renderer/assets/{arc-B8ZJ0Opl.js → arc-CDMjXyzV.js} +1 -1
  9. package/out/renderer/assets/{architectureDiagram-IEHRJDOE-_QECYpX2.js → architectureDiagram-IEHRJDOE-DAy3J7qv.js} +1 -1
  10. package/out/renderer/assets/{blockDiagram-JOT3LUYC-BSbei2oa.js → blockDiagram-JOT3LUYC-THM4jJLl.js} +1 -1
  11. package/out/renderer/assets/browser-page-BkOVSS3V.js +1 -0
  12. package/out/renderer/assets/browser-webauthn-prompt-CsnZvLQM.js +1 -0
  13. package/out/renderer/assets/{c4Diagram-VJAJSXHY-CXeq7sjB.js → c4Diagram-VJAJSXHY-DmKqFqN2.js} +1 -1
  14. package/out/renderer/assets/channel-D5pOQ8_v.js +1 -0
  15. package/out/renderer/assets/{chunk-4BMEZGHF-B-MieEVG.js → chunk-4BMEZGHF-CTvxe0Qv.js} +1 -1
  16. package/out/renderer/assets/{chunk-A2AXSNBT-DPl3382d.js → chunk-A2AXSNBT-Bs0eNJeb.js} +1 -1
  17. package/out/renderer/assets/{chunk-AEK57VVT-DXC6Ji27.js → chunk-AEK57VVT-7zo4HKxz.js} +1 -1
  18. package/out/renderer/assets/{chunk-D6G4REZN-BGkgJZ89.js → chunk-D6G4REZN-CujTp3N4.js} +1 -1
  19. package/out/renderer/assets/{chunk-RZ5BOZE2-BUqZ1y68.js → chunk-RZ5BOZE2-DagQiTA1.js} +1 -1
  20. package/out/renderer/assets/{chunk-XZIHB7SX-C04aAxL7.js → chunk-XZIHB7SX-EUyPfdjE.js} +1 -1
  21. package/out/renderer/assets/classDiagram-GIVACNV2-C2odzhaf.js +1 -0
  22. package/out/renderer/assets/classDiagram-v2-COTLJTTW-C2odzhaf.js +1 -0
  23. package/out/renderer/assets/clone-Dzzjex5C.js +1 -0
  24. package/out/renderer/assets/{compact-pagination-IeHGr4uH.js → compact-pagination-4Yt2xb-Y.js} +1 -1
  25. package/out/renderer/assets/{dagre-OKDRZEBW-DKL0WkFm.js → dagre-OKDRZEBW-CE_XdZbQ.js} +1 -1
  26. package/out/renderer/assets/{diagram-SSKATNLV-Byh0kPuR.js → diagram-SSKATNLV-Ptt_uQaX.js} +1 -1
  27. package/out/renderer/assets/{diagram-VNBRO52H-mi4_sMEb.js → diagram-VNBRO52H-DUi-Kwq3.js} +1 -1
  28. package/out/renderer/assets/{erDiagram-Q7BY3M3F-0hepDeC4.js → erDiagram-Q7BY3M3F-rWuUD2VL.js} +1 -1
  29. package/out/renderer/assets/{flowDiagram-4HSFHLVR-3uOHMo-Q.js → flowDiagram-4HSFHLVR-DggskyIO.js} +1 -1
  30. package/out/renderer/assets/{ganttDiagram-APWFNJXF-CrQsZiws.js → ganttDiagram-APWFNJXF-BjyQcRgJ.js} +1 -1
  31. package/out/renderer/assets/{gitGraphDiagram-7IBYFJ6S-BAcyUQYe.js → gitGraphDiagram-7IBYFJ6S-D73cnsKt.js} +1 -1
  32. package/out/renderer/assets/{graph-iU7FQXVs.js → graph-Blj6eB_I.js} +1 -1
  33. package/out/renderer/assets/{icon-D1d2UB7T.js → icon-B19NuJYm.js} +31 -31
  34. package/out/renderer/assets/{icon-ChKJ4xic.css → icon-DryJ1IIw.css} +1 -1
  35. package/out/renderer/assets/{index-yeCMZRpk.js → index-C3_lkDT_.js} +1 -1
  36. package/out/renderer/assets/{index-BaouUS0v.js → index-CanR4Tr_.js} +350 -350
  37. package/out/renderer/assets/{index-BPzVu2Vx.js → index-Ci7qsPse.js} +1 -1
  38. package/out/renderer/assets/{infoDiagram-PH2N3AL5-DCmQ4NN3.js → infoDiagram-PH2N3AL5-CZhLBS28.js} +1 -1
  39. package/out/renderer/assets/{installer-DfRiHVwp.js → installer-DL2KRfdq.js} +1 -1
  40. package/out/renderer/assets/{journeyDiagram-U35MCT3I-DgPDU07x.js → journeyDiagram-U35MCT3I-C9vwvYUe.js} +1 -1
  41. package/out/renderer/assets/{kanban-definition-NDS4AKOZ-Cc407HQo.js → kanban-definition-NDS4AKOZ-noOVueME.js} +1 -1
  42. package/out/renderer/assets/{layout-CI-wUNSu.js → layout-BO2FtYI8.js} +1 -1
  43. package/out/renderer/assets/{legacy-agents-page-CPt0LlBn.js → legacy-agents-page-CpvPE5Y4.js} +2 -2
  44. package/out/renderer/assets/{mermaid.core-Dw1AnBwY.js → mermaid.core-BylrYsTL.js} +5 -5
  45. package/out/renderer/assets/{mindmap-definition-ALO5MXBD-QW5mFHZd.js → mindmap-definition-ALO5MXBD-BCMhPSkA.js} +1 -1
  46. package/out/renderer/assets/{page-header-8NSHzS_8.js → page-header-D1a45b0y.js} +1 -1
  47. package/out/renderer/assets/{parseISO-BtaE72sw.js → parseISO-BtKaZLD8.js} +1 -1
  48. package/out/renderer/assets/{pieDiagram-IB7DONF6-DgAVovLV.js → pieDiagram-IB7DONF6-rcahe9BM.js} +1 -1
  49. package/out/renderer/assets/{quadrantDiagram-7GDLP6J5-GHLolX9O.js → quadrantDiagram-7GDLP6J5-DxyeVGPJ.js} +1 -1
  50. package/out/renderer/assets/{radar-MK3ICKWK-BNcBxOZs.js → radar-MK3ICKWK-nm5lycWc.js} +1 -1
  51. package/out/renderer/assets/{radient-auth-buttons-BANSg89w.js → radient-auth-buttons-Ctvl_4_t.js} +1 -1
  52. package/out/renderer/assets/{requirementDiagram-KVF5MWMF-3d05NjvC.js → requirementDiagram-KVF5MWMF-CRS0W_DU.js} +1 -1
  53. package/out/renderer/assets/{sankeyDiagram-QLVOVGJD-B4KYiLzq.js → sankeyDiagram-QLVOVGJD-CQL_jB_D.js} +1 -1
  54. package/out/renderer/assets/{schedules-page-DpuU_roc.js → schedules-page-DMy2NLrj.js} +1 -1
  55. package/out/renderer/assets/{sequenceDiagram-X6HHIX6F-p17TfmOo.js → sequenceDiagram-X6HHIX6F-CLZ4yPPV.js} +1 -1
  56. package/out/renderer/assets/settings-page-D5Dxhcm-.js +317 -0
  57. package/out/renderer/assets/{stateDiagram-DGXRK772-DLFv4NAL.js → stateDiagram-DGXRK772-Bx_8B6PV.js} +1 -1
  58. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-CHFeLpR9.js +1 -0
  59. package/out/renderer/assets/{timeline-definition-BDJGKUSR-u6bEh2-f.js → timeline-definition-BDJGKUSR-D2TdXm7n.js} +1 -1
  60. package/out/renderer/assets/{use-agent-like-mutation-C-WrCoPC.js → use-agent-like-mutation-C-7u11gk.js} +1 -1
  61. package/out/renderer/assets/{xychartDiagram-VJFVF3MP-czliybr_.js → xychartDiagram-VJFVF3MP-ncq15Qkv.js} +1 -1
  62. package/out/renderer/index.html +3 -3
  63. package/out/renderer/installer.html +3 -3
  64. package/package.json +2 -3
  65. package/out/renderer/assets/browser-page-CPulmBST.js +0 -1
  66. package/out/renderer/assets/channel-udGM0o33.js +0 -1
  67. package/out/renderer/assets/classDiagram-GIVACNV2-C7L-JcyP.js +0 -1
  68. package/out/renderer/assets/classDiagram-v2-COTLJTTW-C7L-JcyP.js +0 -1
  69. package/out/renderer/assets/clone-Ceo5LLkI.js +0 -1
  70. package/out/renderer/assets/settings-page-xOfS6Ugo.js +0 -317
  71. package/out/renderer/assets/stateDiagram-v2-YXO3MK2T-BWe4Tret.js +0 -1
package/out/main/index.js CHANGED
@@ -2387,6 +2387,14 @@ const BUNDLED_PYTHON_LAYOUT = {
2387
2387
  checkoutSeedNames,
2388
2388
  machOMagics
2389
2389
  };
2390
+ const embeddedProvisioningProfilePath = "Contents/embedded.provisionprofile";
2391
+ const unrestrictedEntitlementKeys = ["com.apple.security.app-sandbox", "com.apple.security.application-groups", "com.apple.security.get-task-allow", "com.apple.security.inherit", "com.apple.security.print", "com.apple.security.smartcard"];
2392
+ const unrestrictedEntitlementPrefixes = ["com.apple.security.cs.", "com.apple.security.network.", "com.apple.security.files.", "com.apple.security.device.", "com.apple.security.personal-information.", "com.apple.security.assets.", "com.apple.security.automation."];
2393
+ const MACOS_ENTITLEMENT_POLICY = {
2394
+ embeddedProvisioningProfilePath,
2395
+ unrestrictedEntitlementKeys,
2396
+ unrestrictedEntitlementPrefixes
2397
+ };
2390
2398
  const PENDING_INSTALL_MARKER_FILE = "pending-update-install.json";
2391
2399
  const INSTALL_DISK_SLACK_BYTES = 256 * 1024 * 1024;
2392
2400
  const DOWNLOAD_PAGE_URL = "https://local-operator.com/download";
@@ -2688,6 +2696,70 @@ function resolveStagedArtifactPath(input) {
2688
2696
  }
2689
2697
  return null;
2690
2698
  }
2699
+ const EMBEDDED_PROVISIONING_PROFILE_PATH = MACOS_ENTITLEMENT_POLICY.embeddedProvisioningProfilePath;
2700
+ function isProfileBackedEntitlement(key) {
2701
+ if (MACOS_ENTITLEMENT_POLICY.unrestrictedEntitlementKeys.includes(key)) {
2702
+ return false;
2703
+ }
2704
+ return !MACOS_ENTITLEMENT_POLICY.unrestrictedEntitlementPrefixes.some(
2705
+ (prefix) => key.startsWith(prefix)
2706
+ );
2707
+ }
2708
+ function profileBackedEntitlementKeys(plistText) {
2709
+ const keys = [];
2710
+ for (const match of String(plistText ?? "").matchAll(
2711
+ /<key>([^<]+)<\/key>/g
2712
+ )) {
2713
+ const key = match[1];
2714
+ if (isProfileBackedEntitlement(key) && !keys.includes(key)) keys.push(key);
2715
+ }
2716
+ return keys;
2717
+ }
2718
+ function zipListingHasEmbeddedProfile(listing) {
2719
+ const suffix = `/${EMBEDDED_PROVISIONING_PROFILE_PATH}`;
2720
+ return String(listing ?? "").split("\n").some((entry) => entry.trim().endsWith(suffix));
2721
+ }
2722
+ function zipMainExecutableEntry(listing) {
2723
+ for (const raw of String(listing ?? "").split("\n")) {
2724
+ const entry = raw.trim();
2725
+ if (!entry || entry.endsWith("/")) continue;
2726
+ if (/^[^/]+\.app\/Contents\/MacOS\/[^/]+$/.test(entry)) return entry;
2727
+ }
2728
+ return null;
2729
+ }
2730
+ function stagedSignatureBlock(facts) {
2731
+ if (facts.entitlementsPlist == null) {
2732
+ if (facts.embeddedProfile) return null;
2733
+ return {
2734
+ code: "artifact-cannot-launch",
2735
+ heading: "The update couldn't be checked",
2736
+ message: facts.version ? `The update to version ${facts.version} can't be checked for launch, so it wasn't installed.` : "The downloaded update can't be checked for launch, so it wasn't installed.",
2737
+ remedy: {
2738
+ text: "Check for updates again to re-download the release."
2739
+ },
2740
+ detail: `${facts.artifactName} carries no ${EMBEDDED_PROVISIONING_PROFILE_PATH} and its signature could not be read.`,
2741
+ dismissLabel: "Not now"
2742
+ };
2743
+ }
2744
+ const claimed = profileBackedEntitlementKeys(facts.entitlementsPlist);
2745
+ if (claimed.length === 0) return null;
2746
+ if (facts.embeddedProfile) return null;
2747
+ return {
2748
+ code: "artifact-cannot-launch",
2749
+ message: facts.version ? `The update to version ${facts.version} can't be launched by macOS, so it wasn't installed.` : "The downloaded update can't be launched by macOS, so it wasn't installed.",
2750
+ remedy: {
2751
+ text: facts.version ? `Keep using this copy, and skip version ${facts.version} if you download one by hand — the next release will be offered here as usual.` : "Keep using this copy; the next release will be offered here as usual."
2752
+ },
2753
+ // The facts a reader can check, and nothing else: which archive, which
2754
+ // claim, which file macOS found missing. TN3125's rule (a restricted
2755
+ // entitlement needs a profile) is what makes the refusal TRUE, but a
2756
+ // reader who wants the rule wants Apple's document, and 250 characters of
2757
+ // it in a mono column made the details block 44% of the panel (design
2758
+ // round 1, D4). It belongs in this comment, not in the card.
2759
+ detail: `${facts.artifactName} claims ${claimed.join(", ")} and carries no ${EMBEDDED_PROVISIONING_PROFILE_PATH}.`,
2760
+ dismissLabel: "Not now"
2761
+ };
2762
+ }
2691
2763
  function pendingInstallMarkerPath(dir) {
2692
2764
  return path$1.join(dir, PENDING_INSTALL_MARKER_FILE);
2693
2765
  }
@@ -2854,6 +2926,20 @@ function recordInstallFailure(dir, input) {
2854
2926
  fs.renameSync(temp, target2);
2855
2927
  return record;
2856
2928
  }
2929
+ function installAttemptSupersededBy(record, runningVersion) {
2930
+ const standing = targetStanding(record.targetVersion, runningVersion);
2931
+ return standing === "reached" || standing === "passed";
2932
+ }
2933
+ function clearLastInstallAttempt(dir) {
2934
+ const path2 = lastInstallAttemptPath(dir);
2935
+ if (!fs.existsSync(path2)) return false;
2936
+ try {
2937
+ fs.rmSync(path2, { force: true });
2938
+ return true;
2939
+ } catch {
2940
+ return false;
2941
+ }
2942
+ }
2857
2943
  const VERSION_TRIPLE_REGEX = /^v?(\d+)\.(\d+)\.(\d+)/;
2858
2944
  function compareVersions(a, b) {
2859
2945
  const parse = (value) => {
@@ -2870,14 +2956,20 @@ function compareVersions(a, b) {
2870
2956
  }
2871
2957
  return 0;
2872
2958
  }
2959
+ function targetStanding(target2, runningVersion) {
2960
+ if (target2 === runningVersion) return "reached";
2961
+ const order = compareVersions(target2, runningVersion);
2962
+ if (order === null) return "unorderable";
2963
+ if (order < 0) return "passed";
2964
+ if (order > 0) return "ahead";
2965
+ return "same-triple";
2966
+ }
2873
2967
  function evaluatePendingInstall(input) {
2874
2968
  const { marker } = input;
2875
2969
  if (marker == null) return { kind: "none" };
2876
- if (marker.targetVersion === input.runningVersion) {
2877
- return { kind: "succeeded", marker };
2878
- }
2879
- const order = compareVersions(marker.targetVersion, input.runningVersion);
2880
- if (order !== null && order < 0) return { kind: "stale", marker };
2970
+ const standing = targetStanding(marker.targetVersion, input.runningVersion);
2971
+ if (standing === "reached") return { kind: "succeeded", marker };
2972
+ if (standing === "passed") return { kind: "stale", marker };
2881
2973
  if (input.installInFlight === true) return { kind: "in-flight", marker };
2882
2974
  return { kind: "failed", marker };
2883
2975
  }
@@ -3700,6 +3792,23 @@ function generationInstallRoot(identity) {
3700
3792
  dir = parent;
3701
3793
  }
3702
3794
  }
3795
+ function installPointerPath(filePath) {
3796
+ if (!filePath) return null;
3797
+ const identity = readInstallIdentity(filePath);
3798
+ const generation = generationInstallRoot(identity);
3799
+ if (!generation) return filePath;
3800
+ let resolved;
3801
+ try {
3802
+ resolved = fs.realpathSync(identity.realPath ?? filePath);
3803
+ } catch {
3804
+ return filePath;
3805
+ }
3806
+ const inside2 = path$1.relative(generation, resolved);
3807
+ if (inside2 === "" || inside2.startsWith("..") || path$1.isAbsolute(inside2)) {
3808
+ return filePath;
3809
+ }
3810
+ return path$1.join(path$1.dirname(path$1.dirname(generation)), "current", inside2);
3811
+ }
3703
3812
  function classifyGlobalInstall(identity) {
3704
3813
  if (!identity.path) return "global-unknown";
3705
3814
  if (identity.editable) return "editable";
@@ -9584,6 +9693,428 @@ class BackendInstaller {
9584
9693
  }
9585
9694
  }
9586
9695
  }
9696
+ const WEBAUTHN_GROUP_SUFFIX = ".webauthn";
9697
+ const WEBAUTHN_PROMPT_REASON = "use a passkey for $1";
9698
+ const WEBAUTHN_CHOOSER_TIMEOUT_MS = 6e4;
9699
+ const CODESIGN_TIMEOUT_MS = 5e3;
9700
+ function webauthnKeychainAccessGroup(teamIdentifier, bundleIdentifier) {
9701
+ return `${teamIdentifier}.${bundleIdentifier}${WEBAUTHN_GROUP_SUFFIX}`;
9702
+ }
9703
+ function appBundlePathFromExecutable(execPath) {
9704
+ const parts = execPath.split("/");
9705
+ const macOs = parts.lastIndexOf("MacOS");
9706
+ if (macOs < 2 || parts[macOs - 1] !== "Contents") {
9707
+ return execPath;
9708
+ }
9709
+ return parts.slice(0, macOs - 1).join("/") || "/";
9710
+ }
9711
+ const TEAM_IDENTIFIER_LINE = /^TeamIdentifier=(.*)$/m;
9712
+ const IDENTIFIER_LINE = /^Identifier=(.*)$/m;
9713
+ function parseTeamIdentifier(codesignVerboseOutput) {
9714
+ const match = TEAM_IDENTIFIER_LINE.exec(codesignVerboseOutput);
9715
+ const value = (match?.[1] ?? "").trim();
9716
+ return value && value !== "not set" ? value : null;
9717
+ }
9718
+ function parseBundleIdentifier(codesignVerboseOutput) {
9719
+ const match = IDENTIFIER_LINE.exec(codesignVerboseOutput);
9720
+ const value = (match?.[1] ?? "").trim();
9721
+ return value || null;
9722
+ }
9723
+ function parseKeychainAccessGroups(entitlementsPlist) {
9724
+ const key = entitlementsPlist.indexOf("<key>keychain-access-groups</key>");
9725
+ if (key === -1) return [];
9726
+ const after = entitlementsPlist.slice(key);
9727
+ const arrayStart = after.indexOf("<array>");
9728
+ const arrayEnd = after.indexOf("</array>", arrayStart + 1);
9729
+ if (arrayStart === -1 || arrayEnd === -1) return [];
9730
+ const body = after.slice(arrayStart + "<array>".length, arrayEnd);
9731
+ const groups = [];
9732
+ const pattern = /<string>([^<]*)<\/string>/g;
9733
+ let match = pattern.exec(body);
9734
+ while (match !== null) {
9735
+ const value = match[1].trim();
9736
+ if (value) groups.push(value);
9737
+ match = pattern.exec(body);
9738
+ }
9739
+ return groups;
9740
+ }
9741
+ function decideWebauthnGate(facts) {
9742
+ if (facts.platform !== "darwin") {
9743
+ return {
9744
+ enabled: false,
9745
+ group: null,
9746
+ reason: "not darwin",
9747
+ detail: `the platform authenticator is macOS only (platform ${facts.platform})`
9748
+ };
9749
+ }
9750
+ if (!facts.packaged) {
9751
+ return {
9752
+ enabled: false,
9753
+ group: null,
9754
+ reason: "unpackaged",
9755
+ detail: "an unpackaged run has no signature to carry the entitlement, so there is no group to configure"
9756
+ };
9757
+ }
9758
+ if (facts.unreadable) {
9759
+ return {
9760
+ enabled: false,
9761
+ group: null,
9762
+ reason: "signature unreadable",
9763
+ detail: facts.unreadable
9764
+ };
9765
+ }
9766
+ if (!facts.bundleIdentifier) {
9767
+ return {
9768
+ enabled: false,
9769
+ group: null,
9770
+ reason: "no bundle id",
9771
+ detail: `codesign reported no Identifier for ${facts.bundlePath}`
9772
+ };
9773
+ }
9774
+ if (!facts.teamIdentifier) {
9775
+ return {
9776
+ enabled: false,
9777
+ group: null,
9778
+ reason: "no team id",
9779
+ detail: `${facts.bundlePath} is not team-signed (an ad-hoc signature has no TeamIdentifier)`
9780
+ };
9781
+ }
9782
+ const group = webauthnKeychainAccessGroup(
9783
+ facts.teamIdentifier,
9784
+ facts.bundleIdentifier
9785
+ );
9786
+ if (facts.keychainAccessGroups.length === 0) {
9787
+ return {
9788
+ enabled: false,
9789
+ group,
9790
+ reason: "entitlement absent",
9791
+ detail: `the signature carries no keychain-access-groups entitlement, so ${group} cannot be used`
9792
+ };
9793
+ }
9794
+ if (!facts.keychainAccessGroups.includes(group)) {
9795
+ return {
9796
+ enabled: false,
9797
+ group,
9798
+ reason: "entitlement mismatch",
9799
+ detail: `the signature's keychain-access-groups are [${facts.keychainAccessGroups.join(", ")}] but the authenticator needs ${group}`
9800
+ };
9801
+ }
9802
+ return {
9803
+ enabled: true,
9804
+ group,
9805
+ reason: "enabled",
9806
+ detail: `the signature carries ${group}`
9807
+ };
9808
+ }
9809
+ const defaultCodesign = (file, args) => new Promise((resolve, reject) => {
9810
+ node_child_process.execFile(
9811
+ file,
9812
+ args,
9813
+ { timeout: CODESIGN_TIMEOUT_MS },
9814
+ (error, stdout, stderr) => {
9815
+ if (error) {
9816
+ reject(
9817
+ new Error(
9818
+ `${error.message}${stderr ? ` (${String(stderr).trim()})` : ""}`
9819
+ )
9820
+ );
9821
+ return;
9822
+ }
9823
+ resolve({ stdout: String(stdout), stderr: String(stderr) });
9824
+ }
9825
+ );
9826
+ });
9827
+ async function readAppSignature(options) {
9828
+ const bundlePath = appBundlePathFromExecutable(options.execPath);
9829
+ const base = {
9830
+ platform: options.platform,
9831
+ packaged: options.packaged,
9832
+ bundlePath,
9833
+ bundleIdentifier: null,
9834
+ teamIdentifier: null,
9835
+ keychainAccessGroups: [],
9836
+ unreadable: null
9837
+ };
9838
+ if (options.platform !== "darwin" || !options.packaged) return base;
9839
+ const run = options.run ?? defaultCodesign;
9840
+ try {
9841
+ const verbose = await run("/usr/bin/codesign", [
9842
+ "-dv",
9843
+ "--verbose=4",
9844
+ bundlePath
9845
+ ]);
9846
+ base.bundleIdentifier = parseBundleIdentifier(verbose.stdout);
9847
+ base.teamIdentifier = parseTeamIdentifier(verbose.stdout);
9848
+ } catch (error) {
9849
+ return { ...base, unreadable: `codesign -dv failed: ${messageOf$1(error)}` };
9850
+ }
9851
+ try {
9852
+ const entitlements = await run("/usr/bin/codesign", [
9853
+ "-d",
9854
+ "--entitlements",
9855
+ "-",
9856
+ "--xml",
9857
+ bundlePath
9858
+ ]);
9859
+ base.keychainAccessGroups = parseKeychainAccessGroups(entitlements.stdout);
9860
+ } catch (error) {
9861
+ return {
9862
+ ...base,
9863
+ unreadable: `codesign -d --entitlements failed: ${messageOf$1(error)}`
9864
+ };
9865
+ }
9866
+ return base;
9867
+ }
9868
+ function messageOf$1(error) {
9869
+ return error instanceof Error ? error.message : String(error);
9870
+ }
9871
+ let ownSignature = null;
9872
+ function ownAppSignature() {
9873
+ ownSignature ??= readAppSignature({
9874
+ platform: process.platform,
9875
+ packaged: electron.app.isPackaged,
9876
+ execPath: process.execPath
9877
+ });
9878
+ return ownSignature;
9879
+ }
9880
+ async function installWebauthn(options) {
9881
+ const facts = await (options.facts ?? ownAppSignature)();
9882
+ const gate = decideWebauthnGate(facts);
9883
+ if (!gate.enabled || !gate.group) {
9884
+ options.log(
9885
+ `[webauthn] Touch ID passkeys are off: ${gate.reason} — ${gate.detail}`
9886
+ );
9887
+ return { gate, configured: false };
9888
+ }
9889
+ const configure = options.configure ?? ((value) => electron.app.configureWebAuthn(value));
9890
+ try {
9891
+ configure({
9892
+ touchID: {
9893
+ keychainAccessGroup: gate.group,
9894
+ promptReason: WEBAUTHN_PROMPT_REASON
9895
+ }
9896
+ });
9897
+ } catch (error) {
9898
+ options.log(
9899
+ `[webauthn] Touch ID passkeys are off: configureWebAuthn failed — ${messageOf$1(error)}`
9900
+ );
9901
+ return { gate: { ...gate, enabled: false }, configured: false };
9902
+ }
9903
+ options.log(
9904
+ `[webauthn] Touch ID platform authenticator enabled (${gate.group}); credentials are device-bound and never leave this Mac`
9905
+ );
9906
+ return { gate, configured: true };
9907
+ }
9908
+ class WebauthnChooser {
9909
+ constructor(options) {
9910
+ this.options = options;
9911
+ }
9912
+ pending = /* @__PURE__ */ new Map();
9913
+ counter = 0;
9914
+ /** Handle the Session event. `accounts` is Electron's own array. */
9915
+ handle(details, callback) {
9916
+ const answer = onceAnswer(callback);
9917
+ try {
9918
+ this.prepare(details, answer);
9919
+ } catch (error) {
9920
+ this.options.log(
9921
+ `[webauthn] a passkey request could not be prepared (${String(
9922
+ error
9923
+ )}); cancelling it`
9924
+ );
9925
+ answer(null);
9926
+ }
9927
+ }
9928
+ /** The pre-timer half of `handle`: everything that can still throw. */
9929
+ prepare(details, answer) {
9930
+ const relyingPartyId = typeof details.relyingPartyId === "string" ? details.relyingPartyId : "";
9931
+ const accounts = Array.isArray(details.accounts) ? details.accounts.filter(
9932
+ (account) => typeof account === "object" && account !== null
9933
+ ).map((account) => toAccountView(account)) : [];
9934
+ if (accounts.length === 0) {
9935
+ this.options.log(
9936
+ `[webauthn] ${relyingPartyId || "a site"} asked for a passkey with no account to choose; cancelling the request`
9937
+ );
9938
+ answer(null);
9939
+ return;
9940
+ }
9941
+ const requestId2 = this.options.nextRequestId ? this.options.nextRequestId() : `webauthn-${++this.counter}-${Date.now().toString(36)}`;
9942
+ const source = this.options.describeSource?.(details.frame) ?? {
9943
+ tabId: null,
9944
+ pageTitle: null
9945
+ };
9946
+ const timer = setTimeout(
9947
+ () => this.expire(requestId2),
9948
+ this.options.timeoutMs ?? WEBAUTHN_CHOOSER_TIMEOUT_MS
9949
+ );
9950
+ timer.unref?.();
9951
+ const request = {
9952
+ requestId: requestId2,
9953
+ relyingPartyId,
9954
+ accounts,
9955
+ tabId: source.tabId,
9956
+ pageTitle: source.pageTitle
9957
+ };
9958
+ this.options.log(
9959
+ `[webauthn] ${relyingPartyId || "a site"} matched ${accounts.length} passkeys; asking the user which one to use (${requestId2})`
9960
+ );
9961
+ this.options.notify(request);
9962
+ this.pending.set(requestId2, {
9963
+ requestId: requestId2,
9964
+ relyingPartyId,
9965
+ accounts,
9966
+ tabId: source.tabId,
9967
+ pageTitle: source.pageTitle,
9968
+ answer,
9969
+ timer
9970
+ });
9971
+ }
9972
+ /**
9973
+ * Answer a surfaced chooser. Returns whether a pending request was settled.
9974
+ *
9975
+ * A `credentialId` that is not one of the offered accounts is treated as a
9976
+ * dismissal rather than passed through: Electron would cancel the request
9977
+ * anyway, and doing it here keeps the log honest about what happened.
9978
+ */
9979
+ respond(requestId2, credentialId) {
9980
+ const entry = this.pending.get(requestId2);
9981
+ if (!entry) {
9982
+ this.options.log(
9983
+ `[webauthn] ignored a choice for an unknown or already-answered request (${requestId2})`
9984
+ );
9985
+ return false;
9986
+ }
9987
+ if (credentialId !== null && !entry.accounts.some((a) => a.credentialId === credentialId)) {
9988
+ this.options.log(
9989
+ `[webauthn] a choice for ${entry.relyingPartyId} named a credential that was not offered; cancelling the request`
9990
+ );
9991
+ this.settle(entry, null, "credential-not-offered");
9992
+ return true;
9993
+ }
9994
+ if (credentialId === null) {
9995
+ this.options.log(
9996
+ `[webauthn] the passkey choice for ${entry.relyingPartyId} was dismissed; cancelling the request`
9997
+ );
9998
+ this.settle(entry, null, "dismissed");
9999
+ return true;
10000
+ }
10001
+ const chosen = entry.accounts.find((a) => a.credentialId === credentialId);
10002
+ this.options.log(
10003
+ `[webauthn] the user chose ${labelOf(chosen)} for ${entry.relyingPartyId}`
10004
+ );
10005
+ this.settle(entry, credentialId, "chosen");
10006
+ return true;
10007
+ }
10008
+ /** Ids still waiting for an answer. Read by tests and by nothing else. */
10009
+ pendingRequestIds() {
10010
+ return [...this.pending.keys()];
10011
+ }
10012
+ /**
10013
+ * Every request still waiting, OLDEST FIRST.
10014
+ *
10015
+ * This is what a surface that mounts late reads instead of a request it never
10016
+ * received (UX round 1, U2: the request was raised while the browser route was
10017
+ * not mounted, so the push had nowhere to go). Order is the map's own insertion
10018
+ * order, which is why it is stated: the renderer offers the oldest request and
10019
+ * names the rest rather than silently replacing one with the next.
10020
+ */
10021
+ pendingRequests() {
10022
+ return [...this.pending.keys()].map((requestId2) => this.view(requestId2));
10023
+ }
10024
+ /** Cancel everything still pending. Called when the host stops, because a
10025
+ * callback left behind would keep the page's promise pending in a window
10026
+ * that is going away. */
10027
+ dispose() {
10028
+ for (const entry of [...this.pending.values()]) {
10029
+ this.options.log(
10030
+ `[webauthn] the passkey request from ${entry.relyingPartyId} was cancelled: the browser host stopped`
10031
+ );
10032
+ this.settle(entry, null, "host-stopped");
10033
+ }
10034
+ }
10035
+ expire(requestId2) {
10036
+ const entry = this.pending.get(requestId2);
10037
+ if (!entry) return;
10038
+ this.options.log(
10039
+ `[webauthn] nobody chose a passkey for ${entry.relyingPartyId} within ${Math.round((this.options.timeoutMs ?? WEBAUTHN_CHOOSER_TIMEOUT_MS) / 1e3)}s; cancelling the request`
10040
+ );
10041
+ this.settle(entry, null, "expired");
10042
+ }
10043
+ /** The wire view of one pending request. Never a credential id in the token
10044
+ * position: `requestId` is this module's own. */
10045
+ view(requestId2) {
10046
+ const entry = this.pending.get(requestId2);
10047
+ if (!entry) {
10048
+ throw new Error(`no pending passkey request ${requestId2}`);
10049
+ }
10050
+ return {
10051
+ requestId: entry.requestId,
10052
+ relyingPartyId: entry.relyingPartyId,
10053
+ accounts: entry.accounts,
10054
+ tabId: entry.tabId,
10055
+ pageTitle: entry.pageTitle
10056
+ };
10057
+ }
10058
+ /** Answer exactly once, then tell the renderer the request is gone.
10059
+ *
10060
+ * Both halves matter. The map entry and the guarded callback are the
10061
+ * exactly-once half; `onSettled` is what stops the renderer offering a choice
10062
+ * for a request main has already answered — the dead-end dialog of design
10063
+ * round 1, D2 — and it fires for EVERY path, including the host's own stop.
10064
+ */
10065
+ settle(entry, credentialId, outcome) {
10066
+ if (!this.pending.has(entry.requestId)) return;
10067
+ this.pending.delete(entry.requestId);
10068
+ clearTimeout(entry.timer);
10069
+ entry.answer(credentialId);
10070
+ this.options.onSettled?.(entry.requestId, outcome);
10071
+ }
10072
+ }
10073
+ const answerableCallbacks = /* @__PURE__ */ new WeakMap();
10074
+ function onceAnswer(callback) {
10075
+ const existing = answerableCallbacks.get(callback);
10076
+ if (existing) return existing;
10077
+ let answered = false;
10078
+ const guarded = (credentialId) => {
10079
+ if (answered) return;
10080
+ answered = true;
10081
+ callback(credentialId ?? void 0);
10082
+ };
10083
+ answerableCallbacks.set(callback, guarded);
10084
+ return guarded;
10085
+ }
10086
+ const chooserListeners = /* @__PURE__ */ new WeakMap();
10087
+ function attachWebauthnChooser(session, chooser, log) {
10088
+ const previous = chooserListeners.get(session);
10089
+ if (previous) {
10090
+ session.removeListener("select-webauthn-account", previous);
10091
+ log(
10092
+ "[webauthn] replaced the previous passkey chooser listener (a second browser host started in this process)"
10093
+ );
10094
+ }
10095
+ const listener = (_event, details, callback) => {
10096
+ chooser.handle(details, callback);
10097
+ };
10098
+ session.on("select-webauthn-account", listener);
10099
+ chooserListeners.set(session, listener);
10100
+ return () => {
10101
+ if (chooserListeners.get(session) !== listener) return;
10102
+ session.removeListener("select-webauthn-account", listener);
10103
+ chooserListeners.delete(session);
10104
+ };
10105
+ }
10106
+ function toAccountView(account) {
10107
+ return {
10108
+ credentialId: typeof account.credentialId === "string" ? account.credentialId : "",
10109
+ name: typeof account.name === "string" ? account.name : null,
10110
+ displayName: typeof account.displayName === "string" ? account.displayName : null,
10111
+ userHandle: typeof account.userHandle === "string" ? account.userHandle : null
10112
+ };
10113
+ }
10114
+ function labelOf(account) {
10115
+ if (!account) return "an unnamed passkey";
10116
+ return account.displayName || account.name || "an unnamed passkey";
10117
+ }
9587
10118
  const PRIVATE_FILE_MODE = 384;
9588
10119
  const PRIVATE_DIR_MODE = 448;
9589
10120
  function writeJsonAtomic(path2, value) {
@@ -11000,14 +11531,48 @@ function settle(contents, timeoutMs = 3e4) {
11000
11531
  function permittedScheme(url) {
11001
11532
  return url.protocol === "http:" || url.protocol === "https:";
11002
11533
  }
11534
+ async function resolveMainFrameId(ctx, contents) {
11535
+ let pageEnabled = false;
11536
+ try {
11537
+ await ctx.cdp.send(contents, "Page.enable", {});
11538
+ pageEnabled = true;
11539
+ const tree = await ctx.cdp.send(contents, "Page.getFrameTree", {});
11540
+ const id2 = tree?.frameTree?.frame?.id;
11541
+ return {
11542
+ mainFrameId: typeof id2 === "string" && id2 ? id2 : null,
11543
+ pageEnabled
11544
+ };
11545
+ } catch {
11546
+ return { mainFrameId: null, pageEnabled };
11547
+ }
11548
+ }
11003
11549
  async function withOriginGate(ctx, view, requester, approved, operation) {
11004
11550
  const contents = view.webContents;
11005
11551
  const blocked = [];
11006
11552
  const seen = /* @__PURE__ */ new Set();
11553
+ const frameTree = await resolveMainFrameId(ctx, contents);
11554
+ const mainFrameId = frameTree.mainFrameId;
11555
+ if (mainFrameId === null) {
11556
+ ctx.log(
11557
+ `[browser] could not read the frame tree for ${requester}; this navigation is gated on every Document request, which may refuse cross-origin frames the page needs${// The domain's state travels with the fallback because it is not
11558
+ // inferable from the failure: an enable that resolved and a read that
11559
+ // did not left `Page` armed, an enable that never resolved did not
11560
+ // (agent review round 2, R3).
11561
+ frameTree.pageEnabled ? " (the Page domain may still be enabled)" : ""}`
11562
+ );
11563
+ }
11564
+ let unattributed = 0;
11007
11565
  const unsubscribe = ctx.cdp.subscribe(contents.id, (method, params) => {
11008
11566
  if (method !== "Fetch.requestPaused") return;
11009
11567
  const requestId2 = typeof params.requestId === "string" ? params.requestId : "";
11010
11568
  if (!requestId2) return;
11569
+ const frameId = typeof params.frameId === "string" ? params.frameId : "";
11570
+ if (mainFrameId !== null && frameId && frameId !== mainFrameId) {
11571
+ void ctx.cdp.send(contents, "Fetch.continueRequest", { requestId: requestId2 }).catch(() => {
11572
+ });
11573
+ return;
11574
+ }
11575
+ if (mainFrameId !== null && !frameId) unattributed += 1;
11011
11576
  const rawUrl = typeof params.request === "object" && params.request !== null ? params.request.url : void 0;
11012
11577
  let url = null;
11013
11578
  try {
@@ -11056,10 +11621,21 @@ async function withOriginGate(ctx, view, requester, approved, operation) {
11056
11621
  throw error;
11057
11622
  } finally {
11058
11623
  unsubscribe();
11624
+ if (unattributed > 0) {
11625
+ ctx.log(
11626
+ `[browser] ${unattributed} paused document request(s) during ${requester}'s navigation carried no frame id; they were gated as navigation hops`
11627
+ );
11628
+ }
11059
11629
  try {
11060
11630
  await ctx.cdp.send(contents, "Fetch.disable", {});
11061
11631
  } catch {
11062
11632
  }
11633
+ if (frameTree.pageEnabled) {
11634
+ try {
11635
+ await ctx.cdp.send(contents, "Page.disable", {});
11636
+ } catch {
11637
+ }
11638
+ }
11063
11639
  }
11064
11640
  }
11065
11641
  async function navigateView(ctx, view, url, requester, approved, timeoutMs = 3e4) {
@@ -11681,6 +12257,20 @@ class TabRegistry {
11681
12257
  list() {
11682
12258
  return [...this.tabs.values()].sort((a, b) => a.tabId - b.tabId);
11683
12259
  }
12260
+ /**
12261
+ * The tab whose view owns this webContents id, if any.
12262
+ *
12263
+ * The passkey chooser needs it to name the page a request came from: Electron
12264
+ * hands the initiating frame, `webContents.fromFrame` turns that into a
12265
+ * webContents, and this is the mapping from there to a tab of THIS host — the
12266
+ * frame may belong to no tab here at all, which is why the answer is optional
12267
+ * rather than an error.
12268
+ */
12269
+ byWebContentsId(webContentsId) {
12270
+ return this.list().find(
12271
+ (record) => !record.view.webContents.isDestroyed() && record.view.webContents.id === webContentsId
12272
+ );
12273
+ }
11684
12274
  get activeTab() {
11685
12275
  return this.activeTabId === null ? null : this.tabs.get(this.activeTabId) ?? null;
11686
12276
  }
@@ -13027,7 +13617,9 @@ const BROWSER_IPC_CHANNELS = [
13027
13617
  "browser-revoke-approval",
13028
13618
  "browser-revoke-all-approvals",
13029
13619
  "browser-forget-site",
13030
- "browser-clear-data"
13620
+ "browser-clear-data",
13621
+ "browser-webauthn-respond",
13622
+ "browser-webauthn-pending"
13031
13623
  ];
13032
13624
  function registerBrowserIpc(options) {
13033
13625
  function authorize(event) {
@@ -13097,6 +13689,25 @@ function registerBrowserIpc(options) {
13097
13689
  const host = authorize(event);
13098
13690
  return host.revokeHandOver(numberOrThrow(tabId, "tabId"));
13099
13691
  });
13692
+ electron.ipcMain.handle(
13693
+ "browser-webauthn-respond",
13694
+ (event, requestId2, credentialId) => {
13695
+ authorize(event);
13696
+ const chooser = options.webauthn();
13697
+ if (!chooser) throw new Error("The browser host is not running.");
13698
+ if (typeof requestId2 !== "string" || !requestId2) {
13699
+ throw new Error("A pending passkey request id is required.");
13700
+ }
13701
+ if (credentialId !== null && typeof credentialId !== "string") {
13702
+ throw new Error("A passkey choice must name a credential or nothing.");
13703
+ }
13704
+ return chooser.respond(requestId2, credentialId);
13705
+ }
13706
+ );
13707
+ electron.ipcMain.handle("browser-webauthn-pending", (event) => {
13708
+ authorize(event);
13709
+ return options.webauthn()?.pendingRequests() ?? [];
13710
+ });
13100
13711
  electron.ipcMain.handle(
13101
13712
  "browser-consent-respond",
13102
13713
  (event, entryId, decision) => {
@@ -13488,8 +14099,9 @@ class OwnershipLedger {
13488
14099
  const BROWSER_PARTITION = "persist:local-operator-browser";
13489
14100
  function browserUserAgent() {
13490
14101
  const chrome = process.versions.chrome ?? "0";
14102
+ const major = chrome.split(".")[0] || "0";
13491
14103
  const platform = process.platform === "darwin" ? "Macintosh; Intel Mac OS X 10_15_7" : process.platform === "win32" ? "Windows NT 10.0; Win64; x64" : "X11; Linux x86_64";
13492
- return `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome} Safari/537.36 LocalOperator/${electron.app.getVersion()}`;
14104
+ return `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`;
13493
14105
  }
13494
14106
  function resolveBrowserSession() {
13495
14107
  return electron.session.fromPartition(BROWSER_PARTITION);
@@ -14930,6 +15542,43 @@ async function startBrowserHost(options) {
14930
15542
  log
14931
15543
  });
14932
15544
  configurePslRules(PSL_RULES);
15545
+ await installWebauthn({ log });
15546
+ const describeSource = (frame) => {
15547
+ if (!frame || typeof frame !== "object") {
15548
+ return { tabId: null, pageTitle: null };
15549
+ }
15550
+ const contents = electron.webContents.fromFrame(frame);
15551
+ const record = contents === void 0 ? void 0 : registry.byWebContentsId(contents.id);
15552
+ if (!record) return { tabId: null, pageTitle: null };
15553
+ const title = record.view.webContents.isDestroyed() ? "" : record.view.webContents.getTitle();
15554
+ return { tabId: record.tabId, pageTitle: title || null };
15555
+ };
15556
+ const webauthn = new WebauthnChooser({
15557
+ notify: (request) => {
15558
+ if (options.window.isDestroyed()) return;
15559
+ options.window.webContents.send("browser-webauthn-request", request);
15560
+ },
15561
+ /*
15562
+ * AND THE OTHER DIRECTION: every settle is pushed, not only every arrival.
15563
+ *
15564
+ * Without this the renderer cannot tell a live request from one main has
15565
+ * already cancelled, so a chooser whose request expired while the user was
15566
+ * away stays on screen offering rows that can no longer do anything (design
15567
+ * round 1, D2) — and the click is then discarded silently. The outcome travels
15568
+ * with the push because the two cases differ for the user: their own answer is
15569
+ * already off the screen, an expiry has to be explained.
15570
+ */
15571
+ onSettled: (requestId2, outcome) => {
15572
+ if (options.window.isDestroyed()) return;
15573
+ options.window.webContents.send("browser-webauthn-settled", {
15574
+ requestId: requestId2,
15575
+ outcome
15576
+ });
15577
+ },
15578
+ describeSource,
15579
+ log
15580
+ });
15581
+ const detachWebauthn = attachWebauthnChooser(browserSession, webauthn, log);
14933
15582
  const cookieJar = createHiddenCookieJarTarget(
14934
15583
  new electron.WebContentsView({
14935
15584
  webPreferences: { ...HIDDEN_COOKIE_JAR_WEB_PREFERENCES }
@@ -15074,6 +15723,7 @@ async function startBrowserHost(options) {
15074
15723
  window: () => options.window,
15075
15724
  expectedUrl: options.expectedUrl,
15076
15725
  host: () => host,
15726
+ webauthn: () => webauthn,
15077
15727
  clearData: (what) => sessionCookies.clearBrowsingData(what),
15078
15728
  log
15079
15729
  });
@@ -15102,6 +15752,8 @@ async function startBrowserHost(options) {
15102
15752
  await cdp.close();
15103
15753
  await server.close();
15104
15754
  unregisterBrowserIpc();
15755
+ detachWebauthn();
15756
+ webauthn.dispose();
15105
15757
  stateWriter?.clear();
15106
15758
  approvals.resetPending();
15107
15759
  ownership.clear();
@@ -17075,6 +17727,7 @@ const INSTALL_IN_FLIGHT_RECHECK_MS = 1e4;
17075
17727
  const LAUNCHCTL_NOT_LOADED_REGEX = /could not find|no such process/i;
17076
17728
  const APP_OWNED_PUBLISH_HEADROOM_BYTES = 450 * 1024 * 1024;
17077
17729
  const SEAL_PROBE_TIMEOUT_MS = 45e3;
17730
+ const STAGED_SIGNATURE_PROBE_TIMEOUT_MS = 2e4;
17078
17731
  const STARTUP_SEAL_PROBE_DELAY_MS = 15e3;
17079
17732
  const POST_WAKE_CHECK_DELAY_MS = 2e4;
17080
17733
  function versionSuffix(version) {
@@ -17767,6 +18420,7 @@ class UpdateService {
17767
18420
  );
17768
18421
  this.reapWatchdog(outcome.marker, true);
17769
18422
  clearPendingInstallMarker(this.markerDir());
18423
+ this.retireSupersededInstallRecord();
17770
18424
  return;
17771
18425
  case "stale":
17772
18426
  logger.info(
@@ -17775,6 +18429,7 @@ class UpdateService {
17775
18429
  );
17776
18430
  this.reapWatchdog(outcome.marker, true);
17777
18431
  clearPendingInstallMarker(this.markerDir());
18432
+ this.retireSupersededInstallRecord();
17778
18433
  return;
17779
18434
  case "in-flight": {
17780
18435
  this.installWasInFlight = true;
@@ -18551,10 +19206,123 @@ ${heal.removed.join("\n")}`,
18551
19206
  `Staged artifact verified: ${artifactPath} (${verdict.size} bytes, sha512 matches, ${required} bytes required free, ${installedBundleSize ?? "unknown"} byte app).`,
18552
19207
  LogFileType.UPDATE_SERVICE
18553
19208
  );
18554
- return null;
19209
+ return this.stagedSignatureBlock(artifactPath, info);
18555
19210
  }
18556
19211
  return verdict.block;
18557
19212
  }
19213
+ /**
19214
+ * Refuse a staged artifact macOS would not launch, before the app quits.
19215
+ *
19216
+ * Why this is worth a probe of its own. On 2026-09-19 the operator's install
19217
+ * updated itself to 0.29.6, which was Developer-ID signed, notarized, stapled,
19218
+ * `codesign --verify --deep --strict`-clean and `spctl -t exec`-accepted - and
19219
+ * completely unlaunchable: every executable in the bundle claimed the
19220
+ * restricted `keychain-access-groups` entitlement while the bundle embedded no
19221
+ * provisioning profile to authorize it, so amfid refused every Mach-O at exec
19222
+ * (`AppleMobileFileIntegrityError Code=-413 "No matching profile found"`).
19223
+ * `open` reported `RBSRequestErrorDomain Code=5 / NSPOSIXErrorDomain Code=163
19224
+ * "Launchd job spawn failed"`. The ShipIt that should have relaunched the app
19225
+ * was refused for the same reason, so the install completed and left the user
19226
+ * with no app, no message and no automatic way back.
19227
+ *
19228
+ * The signature check is done on the STAGED artifact rather than on the
19229
+ * installed bundle because by the time the installed bundle is the broken one,
19230
+ * there is nothing left running to report it.
19231
+ *
19232
+ * A probe that could not read the artifact at all is not a refusal by itself -
19233
+ * `stagedSignatureBlock` decides that - and neither is a non-zip artifact: the
19234
+ * macOS in-app update is a `.zip`, and an artifact this cannot read is reported
19235
+ * as such rather than treated as clean.
19236
+ */
19237
+ async stagedSignatureBlock(artifactPath, info) {
19238
+ const facts = await this.inspectStagedSignature(artifactPath, info);
19239
+ if (!facts) return null;
19240
+ const block = stagedSignatureBlock(facts);
19241
+ if (!block) {
19242
+ logger.info(
19243
+ `Staged artifact signature checked: ${facts.artifactName} claims no entitlement macOS needs a provisioning profile for.`,
19244
+ LogFileType.UPDATE_SERVICE
19245
+ );
19246
+ return null;
19247
+ }
19248
+ logger.warn(
19249
+ `Refusing the update to ${info?.version ?? "an unknown version"}: ${block.detail}`,
19250
+ LogFileType.UPDATE_SERVICE
19251
+ );
19252
+ return block;
19253
+ }
19254
+ /**
19255
+ * The facts `stagedSignatureBlock` decides on, read out of the archive.
19256
+ *
19257
+ * Two members of the zip and one `codesign`, which is cheap because the file
19258
+ * that carries the entitlements is the bundle's LAUNCHER rather than the app
19259
+ * (`Contents/MacOS/Local Operator` is ~54 KB beside a ~376 MB bundle). The
19260
+ * listing comes from the central directory, so neither the interpreter nor the
19261
+ * frameworks are ever decompressed.
19262
+ *
19263
+ * Returns null when there is nothing this can ask about - not a zip, or a zip
19264
+ * whose listing could not be read - and says so in the log. That is a
19265
+ * deliberate "we could not tell", which is different from "we looked and it is
19266
+ * clean"; the caller logs the clean case itself.
19267
+ */
19268
+ async inspectStagedSignature(artifactPath, info) {
19269
+ const artifactName = path$1.basename(artifactPath);
19270
+ if (!artifactPath.toLowerCase().endsWith(".zip")) {
19271
+ logger.info(
19272
+ `Staged artifact ${artifactName} is not a zip; its signature was not inspected before the install.`,
19273
+ LogFileType.UPDATE_SERVICE
19274
+ );
19275
+ return null;
19276
+ }
19277
+ const listing = await runCommand("/usr/bin/unzip", ["-Z1", artifactPath], {
19278
+ timeoutMs: STAGED_SIGNATURE_PROBE_TIMEOUT_MS
19279
+ });
19280
+ if (!listing.ran || listing.exitCode !== 0) {
19281
+ logger.warn(
19282
+ `Could not list ${artifactName} to check its signature (exit ${listing.exitCode}): ${listing.stderr.trim() || "no output"}`,
19283
+ LogFileType.UPDATE_SERVICE
19284
+ );
19285
+ return null;
19286
+ }
19287
+ const embeddedProfile = zipListingHasEmbeddedProfile(listing.stdout);
19288
+ const entry = zipMainExecutableEntry(listing.stdout);
19289
+ const base = {
19290
+ entitlementsPlist: null,
19291
+ embeddedProfile,
19292
+ artifactName,
19293
+ version: info?.version ?? null
19294
+ };
19295
+ if (!entry) {
19296
+ logger.warn(
19297
+ `${artifactName} has no Contents/MacOS entry to read a signature from; its entitlements were not inspected before the install.`,
19298
+ LogFileType.UPDATE_SERVICE
19299
+ );
19300
+ return base;
19301
+ }
19302
+ const scratch = fs.mkdtempSync(path$1.join(os.tmpdir(), "lo-staged-signature-"));
19303
+ try {
19304
+ const extracted = await runCommand(
19305
+ "/usr/bin/unzip",
19306
+ ["-o", "-q", artifactPath, entry, "-d", scratch],
19307
+ { timeoutMs: STAGED_SIGNATURE_PROBE_TIMEOUT_MS }
19308
+ );
19309
+ if (!extracted.ran || extracted.exitCode !== 0) {
19310
+ logger.warn(
19311
+ `Could not extract ${entry} from ${artifactName} (exit ${extracted.exitCode}): ${extracted.stderr.trim() || "no output"}`,
19312
+ LogFileType.UPDATE_SERVICE
19313
+ );
19314
+ return base;
19315
+ }
19316
+ const signature = await runCommand(
19317
+ "/usr/bin/codesign",
19318
+ ["-d", "--entitlements", "-", "--xml", path$1.join(scratch, entry)],
19319
+ { timeoutMs: STAGED_SIGNATURE_PROBE_TIMEOUT_MS }
19320
+ );
19321
+ return signature.ran && signature.exitCode === 0 ? { ...base, entitlementsPlist: signature.stdout } : base;
19322
+ } finally {
19323
+ fs.rmSync(scratch, { recursive: true, force: true });
19324
+ }
19325
+ }
18558
19326
  /**
18559
19327
  * Everything that has to hold before the app is allowed to quit.
18560
19328
  *
@@ -18772,9 +19540,39 @@ ${heal.removed.join("\n")}`,
18772
19540
  * a dismiss: the panel that reports a failure is not the only place the user
18773
19541
  * can find out what happened, which is what made Dismiss an information loss
18774
19542
  * (reviews U1, D3).
19543
+ *
19544
+ * AND IT RETIRES ONCE THE MACHINE HAS ARRIVED, which is the other half of that
19545
+ * promise: a record that survives a dismiss must not survive the state it
19546
+ * describes, or "Version 0.28.2 is running" is printed to a user on 0.29.1 for
19547
+ * ever (the operator's report, 2026-09-18 - a 0.28.3 record against a running
19548
+ * 0.29.1). The test is read-time because the fact becomes true without this
19549
+ * process doing anything: the installer ran, the user relaunched, and the app
19550
+ * that comes up is the one that answers every reader.
18775
19551
  */
18776
19552
  lastInstallAttempt() {
18777
- return readLastInstallAttempt(this.markerDir());
19553
+ return this.retireSupersededInstallRecord();
19554
+ }
19555
+ /**
19556
+ * The record, retired first when the version now running has reached its target.
19557
+ *
19558
+ * One function rather than the same test written at each site, because there are two
19559
+ * moments the fact becomes true - the launch that reads the record, and a start-up
19560
+ * that observes an install having succeeded or been superseded - and two spellings of
19561
+ * one rule is how the arms drift apart. The predicate keeps a record whose versions
19562
+ * cannot be ordered, so nothing is dropped on a guess (`installAttemptSupersededBy`).
19563
+ */
19564
+ retireSupersededInstallRecord() {
19565
+ const record = readLastInstallAttempt(this.markerDir());
19566
+ if (!record) return null;
19567
+ const running = electron.app.getVersion();
19568
+ if (!installAttemptSupersededBy(record, running)) return record;
19569
+ if (clearLastInstallAttempt(this.markerDir())) {
19570
+ logger.info(
19571
+ `Retired the recorded install failure for ${record.targetVersion}: this app is running ${running}, so the target has been reached and the record describes a state the machine has left`,
19572
+ LogFileType.UPDATE_SERVICE
19573
+ );
19574
+ }
19575
+ return null;
18778
19576
  }
18779
19577
  /**
18780
19578
  * Note that a check was asked for, and let an explicit one re-offer a release
@@ -19612,9 +20410,15 @@ ${heal.removed.join("\n")}`,
19612
20410
  * uv tool root), so a prefix held from minutes ago can name a tree nothing is
19613
20411
  * using any more. The reading is what `didSourceRebuildLand` compares, in both
19614
20412
  * directions.
20413
+ *
20414
+ * Read through the install's own pointer for the same reason the version is (see
20415
+ * `readInstallVersionAt`): a rebuild keeps `pyproject.toml`'s version and moves the
20416
+ * `.lop-source` ref, so the marker is the ONLY evidence of that move on this route -
20417
+ * and a marker read from the frozen `generations/<id>` the daemon started from cannot
20418
+ * show it any more than the version could.
19615
20419
  */
19616
20420
  readRebuildMarkerState(installPath) {
19617
- const identity = readInstallIdentity(installPath);
20421
+ const identity = readInstallIdentity(installPointerPath(installPath));
19618
20422
  return readSourceMarkerState(identity.venvPrefix ?? null);
19619
20423
  }
19620
20424
  /**
@@ -20400,9 +21204,16 @@ ${heal.removed.join("\n")}`,
20400
21204
  * than into a plausible number. The one caller with a record and no path - the
20401
21205
  * reconciliation reading a marker an older build wrote - names its fallback explicitly
20402
21206
  * and logs it.
21207
+ *
21208
+ * AND THE INSTALL IS READ THROUGH ITS OWN POINTER, because on a generation layout the
21209
+ * install it names cannot move under it: the tree `/health` reports as the install root
21210
+ * is `generations/<id>`, an install lands BESIDE it, and only `<stable>/current` moves.
21211
+ * Reading the concrete path here is what produced the false "did not take effect" verdict
21212
+ * of 2026-09-18; `installPointerPath` is the same install named so the flip is visible,
21213
+ * and it is a no-op on every other layout.
20403
21214
  */
20404
21215
  readInstallVersionAt(installPath) {
20405
- return readInstallIdentity(installPath)?.version ?? null;
21216
+ return readInstallIdentity(installPointerPath(installPath))?.version ?? null;
20406
21217
  }
20407
21218
  /**
20408
21219
  * Run the install's own front end: `<resolved shim> update`.
@@ -20632,8 +21443,17 @@ ${heal.removed.join("\n")}`,
20632
21443
  * The install this attempt runs, recorded so a later launch does not have to resolve
20633
21444
  * one of its own: the reconciliation reads this, and a marker without it (an older
20634
21445
  * build's) is the only case that falls back.
21446
+ *
21447
+ * RECORDED THROUGH ITS OWN POINTER where the layout has one, so the record outlives
21448
+ * the tree it names: on a generation install the flip an install performs is visible
21449
+ * only through `<stable>/current`, and the generation this attempt started from is
21450
+ * unreferenced the moment it is superseded - which makes it prunable, and a record
21451
+ * naming a pruned tree reads as "nothing moved" for an install that landed. A record
21452
+ * an older build wrote carries the concrete generation path instead, which the reader
21453
+ * resolves through the same pointer (`installPointerPath` is idempotent, so both
21454
+ * spellings land in the same place).
20635
21455
  */
20636
- installPath
21456
+ installPath: installPointerPath(installPath)
20637
21457
  };
20638
21458
  writePendingServerUpdateMarker(this.markerDir(), marker);
20639
21459
  const run = await this.runGlobalUpdate(
@@ -20655,7 +21475,7 @@ ${heal.removed.join("\n")}`,
20655
21475
  deadlineAt: marker.deadlineAt,
20656
21476
  groupPid: pid,
20657
21477
  groupStartedAt: readProcessStartStamp(pid),
20658
- installPath
21478
+ installPath: installPointerPath(installPath)
20659
21479
  });
20660
21480
  }
20661
21481
  );