@forsion/unit 2.12.2 → 2.13.1

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 (204) hide show
  1. package/README.md +61 -0
  2. package/backendWorker.mjs +6 -2
  3. package/main.mjs +991 -497
  4. package/package.json +1 -1
  5. package/release.json +5 -5
  6. package/web/assets/ArtificialView-XwHMLUFb.js +1 -0
  7. package/web/assets/{AutomationBuilder-sxWdUa0L.js → AutomationBuilder-hqs0QhMt.js} +1 -1
  8. package/web/assets/CalendarConfigView-DKib8Oed.js +1 -0
  9. package/web/assets/CalendarView-DJ5iOzvu.js +1 -0
  10. package/web/assets/CapabilityMenu-SwVlZ06x.js +1 -0
  11. package/web/assets/{CheckboxInput-BsaoUHZ0.js → CheckboxInput-BC-5a_C9.js} +1 -1
  12. package/web/assets/Chrome-HdfyYV3i.js +1 -0
  13. package/web/assets/{CodeView-j065KtVh.js → CodeView-CFL6yQ-6.js} +3 -3
  14. package/web/assets/DashboardGridView-B9DCWYVb.js +17 -0
  15. package/web/assets/{EventCard-BmDQkEj9.js → EventCard-B_ndpqH_.js} +1 -1
  16. package/web/assets/ExcalidrawCanvas-BQ4KKLJP.js +1 -0
  17. package/web/assets/{FloatingToc-CENybkAA.js → FloatingToc-Ce9GamvH.js} +1 -1
  18. package/web/assets/HomepageView-RCOfcVbE.js +1 -0
  19. package/web/assets/ImageStudioPanels-lkS_y2uM.js +3 -0
  20. package/web/assets/ImageStudioView-UPv52acl.js +1 -0
  21. package/web/assets/InlineFiles-CQbQumkf.js +1 -0
  22. package/web/assets/MarkdownBlock--__nPQGh.css +1 -0
  23. package/web/assets/MarkdownBlock-CkqiwPXa.js +58 -0
  24. package/web/assets/ModelPill-CY-rU1RU.js +2 -0
  25. package/web/assets/MuseLibraryView-CtTHlVnT.js +2 -0
  26. package/web/assets/{MuseView-d-iBk3sc.js → MuseView-CC2A7MBs.js} +2 -2
  27. package/web/assets/{PageHistoryPanel-CR-k1AM7.js → PageHistoryPanel-KhKT1DLy.js} +2 -2
  28. package/web/assets/PdfAnnotator-C_kglLCA.css +1 -0
  29. package/web/assets/PdfAnnotator-DUyrgY-t.js +505 -0
  30. package/web/assets/{ProductView-u2aezVsl.js → ProductView-CgsIYZqD.js} +1 -1
  31. package/web/assets/RightPanel-BHDgqTFs.js +4 -0
  32. package/web/assets/RunLocationPicker-BE5mxnab.css +1 -0
  33. package/web/assets/RunLocationPicker-DBnXYV5B.js +38 -0
  34. package/web/assets/{SidebarRow-BuSkKH5q.js → SidebarRow-BAJxbMcx.js} +1 -1
  35. package/web/assets/{SketchCard-D87GADyG.js → SketchCard-BGYz-bXP.js} +5 -5
  36. package/web/assets/{StudioGuestSurface-BijU_EEb.js → StudioGuestSurface-lt6znmkl.js} +1 -1
  37. package/web/assets/{TodoListView-DPPVpPUn.js → TodoListView-DPKhee6i.js} +1 -1
  38. package/web/assets/{Tooltip-DY4ISA-e.js → Tooltip-ZxrDqN4p.js} +1 -1
  39. package/web/assets/UnifiedPage-BX6ZQL0h.js +68 -0
  40. package/web/assets/UnifiedPage-CWXc_mo1.css +1 -0
  41. package/web/assets/{amadeus-manual.en-DWaN2fPs.js → amadeus-manual.en-DzRWr4BL.js} +18 -18
  42. package/web/assets/{amadeus-manual.zh--7D5TS8X.js → amadeus-manual.zh-DW32DkL7.js} +18 -18
  43. package/web/assets/{appStore-Dg9WALEA.js → appStore-DWP8UET9.js} +208 -122
  44. package/web/assets/appStore-wtOILggO.css +1 -0
  45. package/web/assets/{arrow-up-right-CpQs92mu.js → arrow-up-right-DrCXuWS7.js} +1 -1
  46. package/web/assets/{astryxBridge-BZhaZPs4.js → astryxBridge-D-m4_kYt.js} +3 -3
  47. package/web/assets/automationListSource-C8BNy6RY.js +1 -0
  48. package/web/assets/book-open-OLNm7OaB.js +1 -0
  49. package/web/assets/{calendarNavStore-C7fGRpoH.js → calendarNavStore-Cylfvv3s.js} +1 -1
  50. package/web/assets/{canvasPrefs-BeG4oEgP.js → canvasPrefs-8taQ42Cz.js} +1 -1
  51. package/web/assets/chatBoxSurface-CIlzs45H.js +1 -0
  52. package/web/assets/{circle-question-mark-WL3vbp-Z.js → circle-question-mark-XbAFplz1.js} +1 -1
  53. package/web/assets/{circle-x-D7s-M5sB.js → circle-x-fcReAZaE.js} +1 -1
  54. package/web/assets/{client-BSKpsyAw.js → client-0MPbodA6.js} +1 -1
  55. package/web/assets/{code-xml-BALV9u_c.js → code-xml-Cuna8Kh4.js} +1 -1
  56. package/web/assets/{codeStudioStore-C2mWfm32.js → codeStudioStore-BBU62GTy.js} +1 -1
  57. package/web/assets/codingNavigation-CZB4v1ZW.css +1 -0
  58. package/web/assets/codingNavigation-DDlPpPk5.js +47 -0
  59. package/web/assets/{create-4xUyKtAF.js → create-BGtEfCcg.js} +4 -4
  60. package/web/assets/dashboardSurface-BYM3C7WG.js +1 -0
  61. package/web/assets/{deskCapture-Df701U8L.js → deskCapture-BiUCmqfc.js} +1 -1
  62. package/web/assets/{diff2html-BV-zCH4d.js → diff2html-BNp2FTbk.js} +1 -1
  63. package/web/assets/{docx-preview-DW4IyU_Q.js → docx-preview-CFznGVOU.js} +1 -1
  64. package/web/assets/{emoji-DGaxpCIu.js → emoji-6zl4yfOS.js} +1 -1
  65. package/web/assets/floatingTocSurface-DreXPCa6.js +1 -0
  66. package/web/assets/{foldActions-B7KByFdN.js → foldActions-05QcQziQ.js} +1 -1
  67. package/web/assets/folder-BpsfOHD4.js +1 -0
  68. package/web/assets/{folder-plus-D1TDb7k9.js → folder-plus-BeQHr4AZ.js} +1 -1
  69. package/web/assets/gestures-DQBCcK4y.js +1 -0
  70. package/web/assets/{hand-Crrw-nSn.js → hand-Bko7ms15.js} +1 -1
  71. package/web/assets/{hard-drive-BbyVSbfP.js → hard-drive-BpuI5ebj.js} +1 -1
  72. package/web/assets/i18n-ecQQxiMQ.css +1 -0
  73. package/web/assets/i18n.generated-BuLjnmY8.js +15 -0
  74. package/web/assets/imageStudio--KF9gwHA.css +1 -0
  75. package/web/assets/imageStudio-BLVNKmEG.js +2 -0
  76. package/web/assets/{index-DKhyDkGB.js → index-BF10xp_K.js} +1 -1
  77. package/web/assets/{index-C7U6T-k8.js → index-BHm6K743.js} +1 -1
  78. package/web/assets/{index-CF4RV4mh.js → index-BM2o7PeV.js} +90 -90
  79. package/web/assets/{index-Brik2pf9.js → index-BgnNNv3e.js} +1 -1
  80. package/web/assets/{index--drOLtq7.js → index-C0Gy2NQ6.js} +1 -1
  81. package/web/assets/{index-CflzrdSt.js → index-C1doZp5H.js} +1 -1
  82. package/web/assets/{index-SZ5waGAq.js → index-C4L6vjd_.js} +1 -1
  83. package/web/assets/{index-Cmr7gVZQ.js → index-CD3yIkOA.js} +1 -1
  84. package/web/assets/{index-BYsdKR_1.js → index-CJeUgjPU.js} +1 -1
  85. package/web/assets/{index-CzM-C2Sd.js → index-CLHoq1w-.js} +1 -1
  86. package/web/assets/{index-tAXBa_fN.js → index-CUZI62qU.js} +1 -1
  87. package/web/assets/{index-C6GJulug.js → index-Cff42k7q.js} +1 -1
  88. package/web/assets/index-Ck_O3Jdd.js +1 -0
  89. package/web/assets/index-CuiYK18N.js +1 -0
  90. package/web/assets/{index-BRW82CsP.js → index-CwcOeZdT.js} +1 -1
  91. package/web/assets/{index-DUWR2nZi.js → index-D6AycQIz.js} +1 -1
  92. package/web/assets/{index-BCZZVJ_w.js → index-D7gVhvNY.js} +1 -1
  93. package/web/assets/{index-DjzSBM0U.js → index-D9UZQy42.js} +1 -1
  94. package/web/assets/{index-KL5Rj9kP.js → index-DuBQ0j2g.js} +1 -1
  95. package/web/assets/{index-Dg9oixs4.js → index-OD1gSX3h.js} +1 -1
  96. package/web/assets/{index-gPSbFSUn.js → index-Uo5tswys.js} +1 -1
  97. package/web/assets/{index-D1f_JwPH.js → index-o8cj0pRH.js} +1 -1
  98. package/web/assets/{index-OHi69jDq.js → index-rHjhbwvm.js} +1 -1
  99. package/web/assets/{index-CqyM6tQ0.js → index-rggq2czF.js} +1 -1
  100. package/web/assets/{index-Czu_bbDM.js → index-sCXThwqZ.js} +1 -1
  101. package/web/assets/{invitePage-lLQ-e0pj.js → invitePage-b0HGvMj6.js} +1 -1
  102. package/web/assets/{ipc-DQOD2UKk.js → ipc-CxAyeo8q.js} +1 -1
  103. package/web/assets/{jszip.min-DZxV9uZ_.js → jszip.min-B94KpE21.js} +1 -1
  104. package/web/assets/launchpad-C2c1MSVX.css +1 -0
  105. package/web/assets/{layout-grid-B_pjJwz4.js → layout-grid-BBVuEdWG.js} +1 -1
  106. package/web/assets/{linkFollow-C0_3k_PC.js → linkFollow-B7ivDZMM.js} +4 -4
  107. package/web/assets/{list-tree-DkAJYEXd.js → list-tree-4-0QDbLu.js} +1 -1
  108. package/web/assets/{listFold-BsOVn9UZ.js → listFold-DLuxXiIj.js} +1 -1
  109. package/web/assets/{lock-BnGHxjeE.js → lock-CnbZm4vo.js} +1 -1
  110. package/web/assets/{log-out-DBuMeqik.js → log-out-BEynWaHa.js} +1 -1
  111. package/web/assets/{main-NgUPPqDn.css → main-BiS_5wrB.css} +1 -1
  112. package/web/assets/main-sIq2N9Z5.js +20 -0
  113. package/web/assets/markdownEditorSurface-ZSJ3RHAv.js +3 -0
  114. package/web/assets/mdMarkStore-DQHDjG7s.js +2 -0
  115. package/web/assets/{mdMarks-Bq7Hl-4C.js → mdMarks-DbIcRa0O.js} +1 -1
  116. package/web/assets/{message-square-plus-BiAZAvcw.js → message-square-plus-CpiKDvle.js} +1 -1
  117. package/web/assets/{messages-BH5MfQdZ.js → messages-_jta1lSI.js} +1 -1
  118. package/web/assets/minus-C9ndHJ9O.js +1 -0
  119. package/web/assets/mobileEntry-CPmU_c4m.js +13 -0
  120. package/web/assets/nativeDownload-BYT082bY.js +1 -0
  121. package/web/assets/{pageChrome-xLNdFaSg.css → pageChrome-CovzZPdb.css} +1 -1
  122. package/web/assets/pageChrome-D3Niv1JM.js +1 -0
  123. package/web/assets/{pageTree-CZH2J6cc.js → pageTree-CgfvpzIj.js} +2 -2
  124. package/web/assets/{pen-tool-lkmBqliH.js → pen-tool-BXEFnU2i.js} +1 -1
  125. package/web/assets/{pencil-Cvk91P_Z.js → pencil-cqvCkekG.js} +1 -1
  126. package/web/assets/pluginChat-BbBukMuN.js +2 -0
  127. package/web/assets/{redo-2-DSi54z4e.js → redo-2-BO7wCDwy.js} +1 -1
  128. package/web/assets/{rotate-ccw-CUlnplr0.js → rotate-ccw-BvipPIjC.js} +1 -1
  129. package/web/assets/{sharePage-MM6UpPJ9.js → sharePage-XbN50US7.js} +2 -2
  130. package/web/assets/{shareViewer-CDZ8lTdy.js → shareViewer-BaYogetW.js} +1 -1
  131. package/web/assets/{star-CwDiGyUW.js → star-BQWMXnn7.js} +1 -1
  132. package/web/assets/tableSurface-BXqqidd1.js +1 -0
  133. package/web/assets/{terminal-DIXogYJY.js → terminal-l9k6RDrH.js} +1 -1
  134. package/web/assets/{terminalView-B3aZSsrQ.js → terminalView-Cpnt5_0P.js} +1 -1
  135. package/web/assets/theme-C6D3tMGV.css +1 -0
  136. package/web/assets/type-CFoaj2eD.js +1 -0
  137. package/web/assets/{undo-2-tUKCv5Gs.js → undo-2-DusxpdLa.js} +1 -1
  138. package/web/assets/{unitAccount-D8lVwaaT.js → unitAccount-DwutFPPw.js} +1 -1
  139. package/web/assets/{unitBridge-D547ublk.js → unitBridge-CcgQpkqV.js} +1 -1
  140. package/web/assets/{unitCloudTransport-BVF6lVp4.js → unitCloudTransport-vA7cLDts.js} +1 -1
  141. package/web/assets/{unitOwnerAccess-BfnEjsZb.js → unitOwnerAccess-LBlJ1YfA.js} +1 -1
  142. package/web/assets/{unitShim-DxetRgmP.js → unitShim-B_M9IrbC.js} +3 -3
  143. package/web/assets/{upload-BbeAsMuD.js → upload-ZrOj6tXA.js} +1 -1
  144. package/web/assets/userSpaces-uJOnsedR.js +1 -0
  145. package/web/assets/viewportLock-D3SNkwS2.js +631 -0
  146. package/web/assets/viewportLock-IP1zaVkz.css +1 -0
  147. package/web/assets/{workflow-DuoVRSSX.js → workflow-DPoG4QE2.js} +1 -1
  148. package/web/assets/{zoom-in-hr5bRpDL.js → zoom-in-lbTgzdQP.js} +1 -1
  149. package/web/index.html +232 -13
  150. package/web/assets/ArtificialView-B4ESD4iK.js +0 -1
  151. package/web/assets/CalendarConfigView-cCfo_EvZ.js +0 -1
  152. package/web/assets/CalendarView-wgLapctk.js +0 -1
  153. package/web/assets/CapabilityMenu-BQBsM638.js +0 -1
  154. package/web/assets/ChatView-BIUk0OJd.css +0 -1
  155. package/web/assets/ChatView-C-jCJnyS.js +0 -47
  156. package/web/assets/Chrome-CwFiDmre.js +0 -1
  157. package/web/assets/DashboardGridView-DXqe0vPd.js +0 -17
  158. package/web/assets/ExcalidrawCanvas-C3egkikA.js +0 -1
  159. package/web/assets/HomepageView-CRdhjLXu.js +0 -1
  160. package/web/assets/ImageStudioPanels-Dke7xf8E.js +0 -3
  161. package/web/assets/ImageStudioView-g38V8gaj.js +0 -1
  162. package/web/assets/InlineFiles-C_hjvn3u.js +0 -1
  163. package/web/assets/MarkdownBlock-DIjYEhTK.js +0 -56
  164. package/web/assets/ModelPill-BPlgovPo.js +0 -1
  165. package/web/assets/MuseLibraryView-CenyTTim.js +0 -2
  166. package/web/assets/PdfAnnotator-CGzC_o1Q.css +0 -1
  167. package/web/assets/PdfAnnotator-D79ip27w.js +0 -560
  168. package/web/assets/RightPanel-C3OJTIqT.js +0 -4
  169. package/web/assets/RunLocationPicker-BNcZ7KKF.css +0 -1
  170. package/web/assets/RunLocationPicker-CTYXUH2z.js +0 -34
  171. package/web/assets/UnifiedPage-CKNtVD_n.js +0 -57
  172. package/web/assets/UnifiedPage-DX_sTpyZ.css +0 -1
  173. package/web/assets/appStore-Di8LXuJ9.css +0 -1
  174. package/web/assets/automationListSource-DP5xZBxo.js +0 -1
  175. package/web/assets/chatBoxSurface-zvcyfrxE.js +0 -1
  176. package/web/assets/dashboardSurface-CFTH5z4c.js +0 -1
  177. package/web/assets/ellipsis-r1EcXsFq.js +0 -1
  178. package/web/assets/floatingTocSurface-C6RJ6i41.js +0 -1
  179. package/web/assets/folder-DlxnPmWY.js +0 -1
  180. package/web/assets/frame-_xBVYe3P.js +0 -1
  181. package/web/assets/gestures-CkW1Kfs9.js +0 -1
  182. package/web/assets/i18n-D-eU58uw.css +0 -1
  183. package/web/assets/i18n.generated-Bbsz16cl.js +0 -15
  184. package/web/assets/imageStudio-BqbNOvpu.css +0 -1
  185. package/web/assets/imageStudio-C_Rs630L.js +0 -2
  186. package/web/assets/index-BCubCwdT.js +0 -1
  187. package/web/assets/index-DZfZTksa.js +0 -1
  188. package/web/assets/main-Dki8NlsT.js +0 -21
  189. package/web/assets/markdownEditorSurface-B4PeUzed.js +0 -3
  190. package/web/assets/mdMarkStore-CsKl7C8O.js +0 -2
  191. package/web/assets/minus-Cyv1YzOU.js +0 -1
  192. package/web/assets/mobileEntry-Bd_nKr0D.js +0 -13
  193. package/web/assets/nativeDownload-D-f1_L2S.js +0 -1
  194. package/web/assets/pageChrome-C9_azUYW.js +0 -1
  195. package/web/assets/panel-left-rOc8As0s.js +0 -1
  196. package/web/assets/pen-line-m6_HvWZ9.js +0 -1
  197. package/web/assets/sticky-note-BIuPUSOU.js +0 -1
  198. package/web/assets/tableSurface-Y_JBQsuz.js +0 -1
  199. package/web/assets/theme-DfEi_DND.css +0 -1
  200. package/web/assets/type-Cqoabh7H.js +0 -1
  201. package/web/assets/userSpaces-DyyAn7vj.js +0 -1
  202. package/web/assets/viewportLock-C4SX-9PO.js +0 -103
  203. package/web/assets/viewportLock-DYSe7Jsn.css +0 -1
  204. package/web/assets/waves-horizontal-B8ZpHQ5H.js +0 -1
package/main.mjs CHANGED
@@ -1,13 +1,494 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __esm = (fn, res) => function __init() {
4
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
5
+ };
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+
11
+ // desktop/shared/amadeus/pluginFiles.ts
12
+ function isSafePluginExt(extension) {
13
+ if (typeof extension !== "string") return false;
14
+ const value = extension.trim().toLowerCase();
15
+ if (!value.startsWith(".") || value.length < 2 || value.length > 40) return false;
16
+ if (value === ".md" || value === ".markdown" || value === ".txt") return false;
17
+ return value.endsWith(".md") ? /^\.[a-z0-9][a-z0-9-]*\.md$/.test(value) : /^\.[a-z0-9][a-z0-9.-]*$/.test(value);
18
+ }
19
+ var init_pluginFiles = __esm({
20
+ "desktop/shared/amadeus/pluginFiles.ts"() {
21
+ "use strict";
22
+ }
23
+ });
24
+
25
+ // desktop/shared/nativeFeatures.ts
26
+ function nativeFeatureSpaceIds(features) {
27
+ return [...new Set(features.flatMap((feature) => NATIVE_FEATURE_SPACES[feature]))];
28
+ }
29
+ var NATIVE_FEATURE_IDS, NATIVE_FEATURE_SPACES, isNativeFeatureId;
30
+ var init_nativeFeatures = __esm({
31
+ "desktop/shared/nativeFeatures.ts"() {
32
+ "use strict";
33
+ NATIVE_FEATURE_IDS = ["amadeus", "tangu", "calendar", "automation", "public"];
34
+ NATIVE_FEATURE_SPACES = {
35
+ amadeus: ["amadeus"],
36
+ tangu: ["tangu"],
37
+ calendar: ["calendar"],
38
+ automation: ["automation"],
39
+ public: ["public"]
40
+ };
41
+ isNativeFeatureId = (value) => typeof value === "string" && NATIVE_FEATURE_IDS.includes(value);
42
+ }
43
+ });
44
+
45
+ // unit/packages.ts
46
+ import { readFile as readFile2, readdir, realpath as realpath2, stat } from "node:fs/promises";
47
+ import { isAbsolute, resolve, sep as sep2 } from "node:path";
48
+ function versionAtLeast(actual, minimum) {
49
+ const parse = (v) => /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/.test(v) ? v.split(/[.+-]/).slice(0, 3).map(Number) : null;
50
+ const a = parse(actual), b = parse(minimum);
51
+ if (!a || !b) throw new Error("Invalid semantic version");
52
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
53
+ return true;
54
+ }
55
+ async function packagePath(root, relative2) {
56
+ if (typeof relative2 !== "string" || !relative2 || isAbsolute(relative2) || relative2.split(/[\\/]/).includes("..")) throw new Error("Plugin file escapes its package");
57
+ const file2 = await realpath2(resolve(root, relative2));
58
+ if (!file2.startsWith(root + sep2)) throw new Error("Plugin file escapes its package");
59
+ if (!(await stat(file2)).isFile()) throw new Error("Plugin entry must be a regular file");
60
+ return file2;
61
+ }
62
+ async function readPackage(dir, appVersion) {
63
+ const root = await realpath2(dir);
64
+ const manifest = JSON.parse(await readFile2(await packagePath(root, "manifest.json"), "utf8"));
65
+ if (!validId(manifest.id) || manifest.apiVersion !== 1) throw new Error("Invalid plugin identity or unsupported plugin API");
66
+ versionAtLeast(manifest.version, "0.0.0");
67
+ if (manifest.minAppVersion && appVersion && !versionAtLeast(appVersion, manifest.minAppVersion)) throw new Error(`Plugin ${manifest.id} requires Unit ${manifest.minAppVersion}`);
68
+ if (manifest.requires && (!Array.isArray(manifest.requires) || !manifest.requires.every(validId))) throw new Error("Invalid plugin dependencies");
69
+ const result = { root, manifest, spaces: [], spaceIds: /* @__PURE__ */ new Set() };
70
+ if (manifest.frontend) {
71
+ const { features, services = {} } = manifest.frontend;
72
+ if (!Array.isArray(features) || !features.length || !features.every(isNativeFeatureId) || new Set(features).size !== features.length) throw new Error("Invalid native frontend features");
73
+ for (const [id, service] of Object.entries(services)) {
74
+ if (!["amadeus", "tangu"].includes(id) || !features.includes(id) || !service || service.adapter !== "forsion-cloud-v1" || typeof service.apiBase !== "string" || !/^\/(?:[A-Za-z0-9_-]+\/?)+$/.test(service.apiBase) || id === "tangu" && service.execution !== "fleet" || id === "amadeus" && service.collaboration !== void 0 && typeof service.collaboration !== "boolean") {
75
+ throw new Error("Invalid frontend cloud service");
76
+ }
77
+ }
78
+ for (const id of nativeFeatureSpaceIds(features)) result.spaceIds.add(id);
79
+ }
80
+ if (manifest.main) {
81
+ const code = await readFile2(await packagePath(root, manifest.main), "utf8");
82
+ result.ui = {
83
+ id: manifest.id,
84
+ name: manifest.name,
85
+ nameEn: manifest.nameEn,
86
+ version: manifest.version,
87
+ apiVersion: manifest.apiVersion,
88
+ minAppVersion: manifest.minAppVersion,
89
+ description: manifest.description,
90
+ descriptionEn: manifest.descriptionEn,
91
+ onboarding: manifest.onboarding,
92
+ fileExtensions: Array.isArray(manifest.fileExtensions) ? manifest.fileExtensions.filter(isSafePluginExt).map((v) => v.trim().toLowerCase()).slice(0, 8) : [],
93
+ code
94
+ };
95
+ }
96
+ if (manifest.runtime) {
97
+ if (manifest.runtime.apiVersion !== 1) throw new Error("Unsupported local runtime API");
98
+ if (manifest.runtime.dependencies?.mode === "npm-ci") throw new Error("Local runtime dependencies must be bundled");
99
+ result.runtimeEntry = await packagePath(root, manifest.runtime.main);
100
+ }
101
+ if (manifest.backend) {
102
+ if (manifest.backend.apiVersion !== 1) throw new Error("Unsupported backend plugin API");
103
+ result.backendEntry = await packagePath(root, manifest.backend.main);
104
+ }
105
+ for (const deps of [manifest.backend?.dependencies, manifest.runtime?.dependencies]) {
106
+ if (deps && !["bundled", "npm-ci"].includes(deps.mode)) throw new Error("Unsupported dependency mode");
107
+ const libc = process.platform === "linux" ? process.report?.getReport()?.header?.glibcVersionRuntime ? "glibc" : "musl" : void 0;
108
+ if (deps?.mode === "bundled" && (deps.platform && deps.platform !== process.platform || deps.libc && deps.libc !== libc || deps.arch && deps.arch !== process.arch || deps.nodeAbi && deps.nodeAbi !== process.versions.modules)) {
109
+ throw new Error("Plugin dependencies were built for a different OS, architecture, Node ABI, or libc");
110
+ }
111
+ }
112
+ if (!result.ui && !result.backendEntry && !result.runtimeEntry && !manifest.frontend) throw new Error("Plugin has no UI or backend entry");
113
+ const entries = await readdir(resolve(root, "spaces"), { withFileTypes: true }).catch((error) => {
114
+ if (error.code === "ENOENT") return [];
115
+ throw error;
116
+ });
117
+ for (const entry of entries) {
118
+ if (!entry.isDirectory()) continue;
119
+ const json2 = await readFile2(await packagePath(root, `spaces/${entry.name}/space.json`), "utf8");
120
+ const spec = JSON.parse(json2);
121
+ if (!validId(spec.id) || result.spaceIds.has(spec.id)) throw new Error("Invalid or duplicate Space id");
122
+ result.spaceIds.add(spec.id);
123
+ result.spaces.push({ slug: entry.name, json: json2, plugin: manifest.id });
124
+ }
125
+ return result;
126
+ }
127
+ var validId;
128
+ var init_packages = __esm({
129
+ "unit/packages.ts"() {
130
+ "use strict";
131
+ init_pluginFiles();
132
+ init_nativeFeatures();
133
+ validId = (id) => typeof id === "string" && /^[a-z][a-z0-9-]*$/.test(id);
134
+ }
135
+ });
136
+
137
+ // unit/config.ts
138
+ import { readFile as readFile5, writeFile as writeFile3, rename as rename3, mkdir as mkdir3 } from "node:fs/promises";
139
+ import { dirname, resolve as resolve3 } from "node:path";
140
+ import { randomUUID as randomUUID5 } from "node:crypto";
141
+ async function readConfig(file2) {
142
+ const path = resolve3(file2), root = dirname(path);
143
+ const config = JSON.parse(await readFile5(path, "utf8"));
144
+ if (!Array.isArray(config.plugins)) throw new Error("unit.json plugins must be an array");
145
+ config.webDist = resolve3(root, config.webDist);
146
+ config.dataDir = resolve3(root, config.dataDir || "data");
147
+ if (config.workspace?.path) config.workspace.path = resolve3(root, config.workspace.path);
148
+ if (config.workerFile) config.workerFile = resolve3(root, config.workerFile);
149
+ config.plugins = config.plugins.map((p) => typeof p === "string" ? { path: resolve3(root, p) } : { ...p, path: resolve3(root, p.path) });
150
+ return config;
151
+ }
152
+ async function writeConfig(file2, config) {
153
+ await mkdir3(dirname(resolve3(file2)), { recursive: true, mode: 448 });
154
+ const temp = `${file2}.${randomUUID5()}.tmp`;
155
+ await writeFile3(temp, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
156
+ await rename3(temp, file2);
157
+ }
158
+ var installations;
159
+ var init_config = __esm({
160
+ "unit/config.ts"() {
161
+ "use strict";
162
+ installations = (config) => config.plugins;
163
+ }
164
+ });
165
+
166
+ // unit/control.ts
167
+ import net from "node:net";
168
+ import { chmod, rm as rm2, mkdir as mkdir4 } from "node:fs/promises";
169
+ import { resolve as resolve4, dirname as dirname2 } from "node:path";
170
+ import { tmpdir } from "node:os";
171
+ import { createHash as createHash4 } from "node:crypto";
172
+ async function sendControl(dataDir, command) {
173
+ return new Promise((resolveReply, reject) => {
174
+ const socket = net.connect(controlAddress(dataDir));
175
+ let body = "";
176
+ socket.setTimeout(24e4, () => socket.destroy(new Error("Unit control timed out")));
177
+ socket.once("connect", () => socket.write(JSON.stringify(command) + "\n"));
178
+ socket.on("data", (data) => {
179
+ body += data;
180
+ if (body.length > 1e6) {
181
+ socket.destroy(new Error("Oversized control response"));
182
+ return;
183
+ }
184
+ if (!body.includes("\n")) return;
185
+ try {
186
+ const reply = JSON.parse(body.split("\n")[0]);
187
+ socket.end();
188
+ if (reply.ok) resolveReply(reply.result);
189
+ else reject(new Error(reply.error || "Unit command failed"));
190
+ } catch (error) {
191
+ socket.destroy();
192
+ reject(error);
193
+ }
194
+ });
195
+ socket.once("error", reject);
196
+ socket.once("end", () => {
197
+ if (!body.includes("\n")) reject(new Error("Unit control connection closed"));
198
+ });
199
+ });
200
+ }
201
+ async function startControl(dataDir, dispatch) {
202
+ await mkdir4(dataDir, { recursive: true, mode: 448 });
203
+ try {
204
+ await sendControl(dataDir, { action: "status" });
205
+ throw new Error("This Unit is already running");
206
+ } catch (error) {
207
+ if (!unitIsOffline(error)) throw error;
208
+ }
209
+ const address = controlAddress(dataDir);
210
+ if (process.platform !== "win32") {
211
+ await mkdir4(dirname2(address), { recursive: true, mode: 448 });
212
+ await chmod(dirname2(address), 448);
213
+ }
214
+ if (process.platform !== "win32") await rm2(address, { force: true });
215
+ const clients = /* @__PURE__ */ new Set();
216
+ const server = net.createServer((socket) => {
217
+ clients.add(socket);
218
+ socket.once("close", () => clients.delete(socket));
219
+ let body = "", dispatched = false;
220
+ socket.setTimeout(24e4, () => socket.destroy());
221
+ socket.on("data", (data) => {
222
+ if (dispatched) return;
223
+ body += data;
224
+ if (body.length > 1e6) {
225
+ socket.destroy();
226
+ return;
227
+ }
228
+ if (!body.includes("\n")) return;
229
+ dispatched = true;
230
+ void (async () => {
231
+ try {
232
+ socket.end(JSON.stringify({ ok: true, result: await dispatch(JSON.parse(body.split("\n")[0])) }) + "\n");
233
+ } catch (error) {
234
+ socket.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : "Unit command failed" }) + "\n");
235
+ }
236
+ })();
237
+ });
238
+ socket.on("error", () => {
239
+ });
240
+ });
241
+ await new Promise((ready, reject) => {
242
+ server.once("error", reject);
243
+ server.listen(address, ready);
244
+ });
245
+ if (process.platform !== "win32") await chmod(address, 384);
246
+ return { close: async () => {
247
+ await new Promise((done) => {
248
+ server.close(() => done());
249
+ for (const socket of clients) socket.destroy();
250
+ });
251
+ if (process.platform !== "win32") await rm2(address, { force: true });
252
+ } };
253
+ }
254
+ var controlAddress, unitIsOffline;
255
+ var init_control = __esm({
256
+ "unit/control.ts"() {
257
+ "use strict";
258
+ controlAddress = (dataDir) => process.platform === "win32" ? `\\\\.\\pipe\\forsion-unit-${createHash4("sha256").update(resolve4(dataDir)).digest("hex").slice(0, 24)}` : resolve4(tmpdir(), `forsion-unit-${process.getuid?.() ?? "user"}`, createHash4("sha256").update(resolve4(dataDir)).digest("hex").slice(0, 24) + ".sock");
259
+ unitIsOffline = (error) => ["ENOENT", "ECONNREFUSED"].includes(error?.code || "");
260
+ }
261
+ });
262
+
263
+ // unit/install.ts
264
+ import { cp, mkdir as mkdir5, readdir as readdir3, realpath as realpath4, rm as rm3, lstat, open, readlink, symlink } from "node:fs/promises";
265
+ import { dirname as dirname3, resolve as resolve5, sep as sep4, relative, isAbsolute as isAbsolute3 } from "node:path";
266
+ import { randomUUID as randomUUID6 } from "node:crypto";
267
+ import { spawn } from "node:child_process";
268
+ async function inspectTree(dir, root, seen = /* @__PURE__ */ new Set()) {
269
+ const actual = await realpath4(dir);
270
+ if (seen.has(actual)) return;
271
+ seen.add(actual);
272
+ for (const entry of await readdir3(dir, { withFileTypes: true })) {
273
+ const path = resolve5(dir, entry.name);
274
+ if (entry.name === ".env" || entry.name.startsWith(".env.") && !entry.name.endsWith(".example")) throw new Error("Package contains private environment files");
275
+ if (entry.isSymbolicLink()) {
276
+ const target = await realpath4(path);
277
+ if (!target.startsWith(root + sep4)) throw new Error("Package symlink escapes its directory");
278
+ if ((await lstat(target)).isDirectory()) await inspectTree(target, root, seen);
279
+ } else if (entry.isDirectory()) await inspectTree(path, root, seen);
280
+ }
281
+ }
282
+ async function relocateLinks(dir, source, destination) {
283
+ for (const entry of await readdir3(dir, { withFileTypes: true })) {
284
+ const path = resolve5(dir, entry.name);
285
+ if (entry.isSymbolicLink()) {
286
+ const link = await readlink(path);
287
+ if (isAbsolute3(link)) {
288
+ const relocated = resolve5(destination, relative(source, await realpath4(link)));
289
+ await rm3(path);
290
+ await symlink(relative(dirname3(path), relocated), path);
291
+ }
292
+ } else if (entry.isDirectory()) await relocateLinks(path, source, destination);
293
+ }
294
+ }
295
+ async function copyPackage(source, destination, version) {
296
+ const sourceRoot = await realpath4(source);
297
+ if (!(await lstat(sourceRoot)).isDirectory()) throw new Error("Expected an unpacked plugin directory");
298
+ await readPackage(sourceRoot, version);
299
+ await inspectTree(sourceRoot, sourceRoot);
300
+ await cp(sourceRoot, destination, { recursive: true, verbatimSymlinks: true });
301
+ await relocateLinks(destination, sourceRoot, destination);
302
+ return readPackage(destination, version);
303
+ }
304
+ async function installDependencies(root, backendMain) {
305
+ const backend = resolve5(root, backendMain.split("/")[0]);
306
+ await packagePath(root, backendMain.split("/")[0] + "/package-lock.json");
307
+ await new Promise((done, reject) => {
308
+ const win = process.platform === "win32";
309
+ const child = spawn(win ? "cmd.exe" : "npm", win ? ["/c", "npm", "ci", "--omit=dev"] : ["ci", "--omit=dev"], { cwd: backend, stdio: "inherit", env: { ...process.env, PATH: dirname3(process.execPath) + (win ? ";" : ":") + (process.env.PATH || "") } });
310
+ child.once("error", reject);
311
+ child.once("exit", (code) => code === 0 ? done() : reject(new Error(`Plugin dependency installation failed (${code})`)));
312
+ });
313
+ }
314
+ async function installPackage(configPath, source, options = {}) {
315
+ const file2 = resolve5(configPath), root = dirname3(file2);
316
+ const lockPath = resolve5(root, ".install.lock");
317
+ const lock = await open(lockPath, "wx", 384).catch(() => {
318
+ throw new Error("Another install is in progress");
319
+ });
320
+ let stage, committed = false;
321
+ try {
322
+ const config = await readConfig(file2), sourceRoot = await realpath4(source);
323
+ if (!(await lstat(sourceRoot)).isDirectory()) throw new Error("Install expects an unpacked plugin directory");
324
+ const pack = await readPackage(sourceRoot, config.version);
325
+ const entries = installations(config);
326
+ const existing = (await Promise.all(entries.map(async (entry) => ({ entry, pack: await readPackage(entry.path) })))).find((p) => p.pack.manifest.id === pack.manifest.id);
327
+ if (options.updateId !== void 0) {
328
+ if (pack.manifest.id !== options.updateId) throw new Error("Update changes plugin identity");
329
+ if (!existing) throw new Error(`Plugin is not installed: ${options.updateId}`);
330
+ try {
331
+ await sendControl(config.dataDir, { action: "status" });
332
+ } catch (error) {
333
+ if (unitIsOffline(error)) throw new Error("Unit is not running; start it before updating");
334
+ throw error;
335
+ }
336
+ }
337
+ await inspectTree(sourceRoot, sourceRoot);
338
+ stage = resolve5(root, "plugins", pack.manifest.id, `${pack.manifest.version}-${randomUUID6()}`);
339
+ await mkdir5(dirname3(stage), { recursive: true, mode: 448 });
340
+ await cp(sourceRoot, stage, { recursive: true, verbatimSymlinks: true });
341
+ await relocateLinks(stage, sourceRoot, stage);
342
+ if (options.releaseDigest) {
343
+ const { packageDigest: packageDigest2 } = await Promise.resolve().then(() => (init_releases(), releases_exports));
344
+ if (pack.manifest.backend?.dependencies?.mode === "npm-ci" || pack.manifest.runtime?.dependencies?.mode === "npm-ci" || await packageDigest2(stage) !== options.releaseDigest) throw new Error("Staged release changed during installation");
345
+ }
346
+ if (pack.manifest.backend?.dependencies?.mode === "npm-ci") await installDependencies(stage, pack.manifest.backend.main);
347
+ const ready = await readPackage(stage, config.version);
348
+ if (existing) {
349
+ try {
350
+ if (options.applyUpdate) await options.applyUpdate(pack.manifest.id, stage);
351
+ else await sendControl(config.dataDir, { action: "update", id: pack.manifest.id, path: stage });
352
+ committed = true;
353
+ return { id: ready.manifest.id, version: ready.manifest.version, path: stage };
354
+ } catch (error) {
355
+ if (!unitIsOffline(error)) {
356
+ committed = true;
357
+ throw error;
358
+ }
359
+ if (options.updateId !== void 0) throw new Error("Unit is not running; start it before updating");
360
+ }
361
+ existing.entry.path = stage;
362
+ } else {
363
+ try {
364
+ await sendControl(config.dataDir, { action: "status" });
365
+ throw new Error("Stop the Unit before installing a new plugin; updates can run live");
366
+ } catch (error) {
367
+ if (!unitIsOffline(error)) throw error;
368
+ }
369
+ entries.push({ path: stage, enabled: true });
370
+ }
371
+ if (!config.defaultSpace && ready.spaceIds.size) config.defaultSpace = [...ready.spaceIds][0];
372
+ await writeConfig(file2, config);
373
+ committed = true;
374
+ return { id: ready.manifest.id, version: ready.manifest.version, path: stage };
375
+ } finally {
376
+ await lock.close();
377
+ await rm3(lockPath, { force: true });
378
+ if (!committed && stage) await rm3(stage, { recursive: true, force: true });
379
+ }
380
+ }
381
+ var init_install = __esm({
382
+ "unit/install.ts"() {
383
+ "use strict";
384
+ init_packages();
385
+ init_config();
386
+ init_control();
387
+ }
388
+ });
389
+
390
+ // unit/releases.ts
391
+ var releases_exports = {};
392
+ __export(releases_exports, {
393
+ listReleases: () => listReleases,
394
+ packageDigest: () => packageDigest,
395
+ resolveRelease: () => resolveRelease,
396
+ stageRelease: () => stageRelease,
397
+ validReleaseId: () => validReleaseId
398
+ });
399
+ import { createHash as createHash5, randomUUID as randomUUID7 } from "node:crypto";
400
+ import { createReadStream as createReadStream2 } from "node:fs";
401
+ import { mkdir as mkdir6, readdir as readdir4, readFile as readFile6, writeFile as writeFile4, rename as rename4, rm as rm4, readlink as readlink2 } from "node:fs/promises";
402
+ import { join as join2 } from "node:path";
403
+ async function packageDigest(root) {
404
+ const hash = createHash5("sha256");
405
+ async function visit(dir, prefix = "") {
406
+ const entries = (await readdir4(dir, { withFileTypes: true })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
407
+ for (const entry of entries) {
408
+ const name = prefix + entry.name, path = join2(dir, entry.name);
409
+ if (entry.isDirectory()) {
410
+ hash.update(JSON.stringify(["directory", name]));
411
+ await visit(path, name + "/");
412
+ } else if (entry.isSymbolicLink()) hash.update(JSON.stringify(["link", name, await readlink2(path)]));
413
+ else if (entry.isFile()) {
414
+ const file2 = createHash5("sha256");
415
+ for await (const chunk of createReadStream2(path)) file2.update(chunk);
416
+ hash.update(JSON.stringify(["file", name, file2.digest("hex")]));
417
+ } else throw new Error("Release contains a non-regular file");
418
+ }
419
+ }
420
+ await visit(root);
421
+ return hash.digest("hex");
422
+ }
423
+ async function listReleases(dataDir) {
424
+ const root = join2(dataDir, "releases");
425
+ let names;
426
+ try {
427
+ names = await readdir4(root);
428
+ } catch (e) {
429
+ if (e.code === "ENOENT") return [];
430
+ throw e;
431
+ }
432
+ const result = [];
433
+ for (const name of names.filter(validReleaseId).slice(0, 200)) {
434
+ const item = JSON.parse(await readFile6(join2(root, name, "release.json"), "utf8"));
435
+ if (item.releaseId !== name) throw new Error("Invalid release record");
436
+ result.push({ releaseId: name, pluginId: item.pluginId, version: item.version, sha256: item.sha256, createdAt: item.createdAt });
437
+ }
438
+ return result.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
439
+ }
440
+ async function stageRelease(dataDir, source, version) {
441
+ const root = join2(dataDir, "releases"), id = randomUUID7(), temporary = join2(root, "." + id);
442
+ await mkdir6(root, { recursive: true, mode: 448 });
443
+ if ((await listReleases(dataDir)).length >= 200) throw new Error("Release storage is full; archive unused staged packages locally");
444
+ await mkdir6(temporary, { mode: 448 });
445
+ try {
446
+ const pack = await copyPackage(source, join2(temporary, "package"), version);
447
+ if (pack.manifest.backend?.dependencies?.mode === "npm-ci" || pack.manifest.runtime?.dependencies?.mode === "npm-ci") {
448
+ throw new Error("Stage requires a bundled package; install and seal dependencies on the target platform first");
449
+ }
450
+ const item = {
451
+ releaseId: id,
452
+ pluginId: pack.manifest.id,
453
+ version: pack.manifest.version,
454
+ sha256: await packageDigest(pack.root),
455
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
456
+ };
457
+ await writeFile4(join2(temporary, "release.json"), JSON.stringify(item), { mode: 384 });
458
+ await rename4(temporary, join2(root, id));
459
+ return item;
460
+ } finally {
461
+ await rm4(temporary, { recursive: true, force: true });
462
+ }
463
+ }
464
+ async function resolveRelease(dataDir, id, pluginId, version, verifyContents = true) {
465
+ if (!validReleaseId(id)) throw new Error("Invalid release ID");
466
+ const root = join2(dataDir, "releases", id), path = join2(root, "package");
467
+ const item = JSON.parse(await readFile6(join2(root, "release.json"), "utf8"));
468
+ const pack = await readPackage(path, version);
469
+ if (item.releaseId !== id || item.pluginId !== pluginId || pack.manifest.id !== pluginId || item.version !== pack.manifest.version || pack.manifest.backend?.dependencies?.mode === "npm-ci" || pack.manifest.runtime?.dependencies?.mode === "npm-ci" || verifyContents && item.sha256 !== await packageDigest(path)) throw new Error("Staged release identity or content changed");
470
+ return { item, path };
471
+ }
472
+ var validReleaseId;
473
+ var init_releases = __esm({
474
+ "unit/releases.ts"() {
475
+ "use strict";
476
+ init_install();
477
+ init_packages();
478
+ validReleaseId = (id) => typeof id === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(id);
479
+ }
480
+ });
481
+
1
482
  // unit/main.ts
2
- import { mkdir as mkdir7, readFile as readFile7, open as open2, rm as rm4 } from "node:fs/promises";
483
+ import { mkdir as mkdir9, readFile as readFile9, open as open2, rm as rm5 } from "node:fs/promises";
3
484
  import { dirname as dirname4, resolve as resolve7 } from "node:path";
4
485
  import { fileURLToPath as fileURLToPath2 } from "node:url";
5
- import { randomUUID as randomUUID8 } from "node:crypto";
486
+ import { randomUUID as randomUUID10 } from "node:crypto";
6
487
 
7
488
  // unit/host.ts
8
- import { readFile as readFile5, mkdir as mkdir3 } from "node:fs/promises";
9
- import { resolve as resolve3 } from "node:path";
10
- import { randomUUID as randomUUID5 } from "node:crypto";
489
+ import { readFile as readFile8, mkdir as mkdir8 } from "node:fs/promises";
490
+ import { resolve as resolve6 } from "node:path";
491
+ import { randomUUID as randomUUID9 } from "node:crypto";
11
492
  import { homedir } from "node:os";
12
493
  import { fileURLToPath, pathToFileURL } from "node:url";
13
494
 
@@ -146,6 +627,7 @@ var ENGINE_ROUTES = [
146
627
  { method: "GET", path: "/agent/agents/:slug/avatar", access: "allow", src: "routes/agents.ts", why: "Agent avatar" },
147
628
  { method: "POST", path: "/agent/agents/:slug/avatar", access: "deny-remote", src: "routes/agents.ts", why: "update Agent avatar" },
148
629
  { method: "GET", path: "/agent/agents/:slug/harness", access: "allow", src: "routes/agents.ts", why: "read HARNESS" },
630
+ { method: "POST", path: "/agent/agents/:slug/harness/candidate", access: "deny-remote", src: "routes/agents.ts", why: "adopt / dismiss one harness candidate (writes HARNESS.md as the user; the route itself also refuses remote origin)" },
149
631
  { method: "POST", path: "/agent/agents/:slug/harness/rollback", access: "deny-remote", src: "routes/agents.ts", why: "harness rollback (design appendix C)" },
150
632
  { method: "GET", path: "/agent/agents/:slug/library", access: "allow", src: "routes/agents.ts", why: "Agent library listing" },
151
633
  { method: "DELETE", path: "/agent/agents/:slug/library/file", access: "deny-remote", src: "routes/agents.ts", why: "deletes from the Agent library" },
@@ -237,6 +719,8 @@ var ENGINE_ROUTES = [
237
719
  { method: "GET", path: "/agent/plugins/:id/settings", access: "deny-remote", src: "routes/plugins.ts", why: "plugin settings may hold secrets" },
238
720
  { method: "PUT", path: "/agent/plugins/:id/settings", access: "deny-remote", src: "routes/plugins.ts", why: "engine plugins are local-only" },
239
721
  { method: "GET", path: "/agent/plugins/:id/source", access: "deny-remote", src: "routes/plugins.ts", why: "engine plugins are local-only" },
722
+ { method: "GET", path: "/agent/plugins/external-tools", access: "deny-remote", src: "routes/plugins.ts", why: "plugin tools for the local Desktop MCP only; the handler also refuses remote callers" },
723
+ { method: "POST", path: "/agent/plugins/external-tools/call", access: "deny-remote", src: "routes/plugins.ts", why: "runs a plugin tool; local Desktop MCP only" },
240
724
  { method: "POST", path: "/agent/plugins/install", access: "deny-remote", src: "routes/plugins.ts", why: "installs code (design appendix C)" },
241
725
  { method: "POST", path: "/agent/plugins/rescan", access: "deny-remote", src: "routes/plugins.ts", why: "engine plugins are local-only" },
242
726
  { method: "POST", path: "/agent/reply-segments", access: "allow", src: "routes/plugins.ts", why: "chat bubble segmentation helper (read-only)" },
@@ -255,6 +739,9 @@ var ENGINE_ROUTES = [
255
739
  { method: "GET", path: "/agent/project-context/icon", access: "allow", src: "routes/projectContext.ts", why: "project icon" },
256
740
  { method: "POST", path: "/agent/project-context/icon", access: "deny-remote", src: "routes/projectContext.ts", why: "project icon write" },
257
741
  { method: "POST", path: "/agent/project-context/init", access: "deny-remote", src: "routes/projectContext.ts", why: "project instruction write (design appendix C)" },
742
+ { method: "DELETE", path: "/agent/project-context/memory", access: "deny-remote", src: "routes/projectContext.ts", why: "project memory delete (host-only store; the route itself also refuses remote origin)" },
743
+ { method: "POST", path: "/agent/project-context/memory/candidate", access: "deny-remote", src: "routes/projectContext.ts", why: "adopt / dismiss one pending project-memory candidate (host-only store; the route itself also refuses remote origin)" },
744
+ { method: "POST", path: "/agent/project-context/memory/restore", access: "deny-remote", src: "routes/projectContext.ts", why: "put back one sentence removed by a project-memory compaction (host-only store; the route itself also refuses remote origin)" },
258
745
  { method: "GET", path: "/agent/project-context/settings", access: "allow", src: "routes/projectContext.ts", why: "read project settings" },
259
746
  { method: "PUT", path: "/agent/project-context/settings", access: "deny-remote", src: "routes/projectContext.ts", why: "project settings write (design appendix C)" },
260
747
  { method: "POST", path: "/agent/project-context/skills", access: "deny-remote", src: "routes/projectContext.ts", why: "project skill write (design appendix C)" },
@@ -759,7 +1246,7 @@ function startUnitWeb(deps, opts) {
759
1246
  return null;
760
1247
  }
761
1248
  };
762
- const json = (res, status, body) => {
1249
+ const json2 = (res, status, body) => {
763
1250
  const buf = Buffer.from(JSON.stringify(body));
764
1251
  res.writeHead(status, { "Content-Type": "application/json", "Content-Length": buf.length });
765
1252
  res.end(buf);
@@ -790,7 +1277,7 @@ function startUnitWeb(deps, opts) {
790
1277
  const proxyEngine = (req, res, path, via, caller) => {
791
1278
  const engine = deps.getEngine();
792
1279
  if (!engine.url) {
793
- json(res, 503, { detail: "\u672C\u673A\u5F15\u64CE\u672A\u5C31\u7EEA", code: "ENGINE_NOT_READY" });
1280
+ json2(res, 503, { detail: "\u672C\u673A\u5F15\u64CE\u672A\u5C31\u7EEA", code: "ENGINE_NOT_READY" });
794
1281
  return;
795
1282
  }
796
1283
  const target = new URL(engine.url);
@@ -822,7 +1309,7 @@ function startUnitWeb(deps, opts) {
822
1309
  res.on("close", () => upRes.destroy());
823
1310
  });
824
1311
  up.on("error", (e) => {
825
- if (!res.headersSent) json(res, 502, { detail: `\u672C\u673A\u5F15\u64CE\u4E0D\u53EF\u8FBE: ${e.message}` });
1312
+ if (!res.headersSent) json2(res, 502, { detail: `\u672C\u673A\u5F15\u64CE\u4E0D\u53EF\u8FBE: ${e.message}` });
826
1313
  });
827
1314
  req.pipe(up);
828
1315
  };
@@ -837,13 +1324,13 @@ function startUnitWeb(deps, opts) {
837
1324
  if (!extname(rel)) rel = "/index.html";
838
1325
  const norm = normalize(rel).replace(/^([/\\])+/, "");
839
1326
  if (norm.startsWith("..")) {
840
- json(res, 400, { detail: "bad path" });
1327
+ json2(res, 400, { detail: "bad path" });
841
1328
  return;
842
1329
  }
843
1330
  try {
844
1331
  const [rootReal, fileReal] = await Promise.all([realpath(dist2), realpath(`${dist2}/${norm}`)]);
845
1332
  if (!fileReal.startsWith(rootReal + sep)) {
846
- json(res, 404, { detail: "not found" });
1333
+ json2(res, 404, { detail: "not found" });
847
1334
  return;
848
1335
  }
849
1336
  let buf = await readFile(fileReal);
@@ -866,31 +1353,31 @@ function startUnitWeb(deps, opts) {
866
1353
  res.writeHead(200, h);
867
1354
  res.end(buf);
868
1355
  } catch {
869
- json(res, 404, { detail: "not found" });
1356
+ json2(res, 404, { detail: "not found" });
870
1357
  }
871
1358
  };
872
1359
  const hostFileRequest = (req, res, url) => {
873
1360
  const info = authInfo(req);
874
1361
  if (!info.ok) {
875
- json(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
1362
+ json2(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
876
1363
  return null;
877
1364
  }
878
1365
  return { p: String(new URL(url, "http://x").searchParams.get("path") || ""), via: info.via };
879
1366
  };
880
1367
  const sendHostDownload = async (res, p) => {
881
1368
  if (!deps.openHostFile) {
882
- json(res, 501, { detail: "Host file download is not available on this device", code: "HOST_DOWNLOAD_UNSUPPORTED" });
1369
+ json2(res, 501, { detail: "Host file download is not available on this device", code: "HOST_DOWNLOAD_UNSUPPORTED" });
883
1370
  return;
884
1371
  }
885
1372
  const f = await deps.openHostFile(p);
886
1373
  if (!f) {
887
- json(res, 404, { detail: "not readable" });
1374
+ json2(res, 404, { detail: "not readable" });
888
1375
  return;
889
1376
  }
890
1377
  if (f.size > HOST_DOWNLOAD_MAX_BYTES) {
891
1378
  await f.fh.close().catch(() => {
892
1379
  });
893
- json(res, 413, { detail: `File is larger than the ${HOST_DOWNLOAD_MAX_BYTES / 1048576} MB remote download limit`, code: "HOST_DOWNLOAD_TOO_LARGE", size: f.size, limit: HOST_DOWNLOAD_MAX_BYTES });
1380
+ json2(res, 413, { detail: `File is larger than the ${HOST_DOWNLOAD_MAX_BYTES / 1048576} MB remote download limit`, code: "HOST_DOWNLOAD_TOO_LARGE", size: f.size, limit: HOST_DOWNLOAD_MAX_BYTES });
894
1381
  return;
895
1382
  }
896
1383
  const name = p.split(/[\\/]/).filter(Boolean).pop() || "download";
@@ -935,7 +1422,7 @@ function startUnitWeb(deps, opts) {
935
1422
  if (!ownerProjection && lockedNow()) {
936
1423
  const p = plainPath(req);
937
1424
  if (!(p === "/engine" || p.startsWith("/engine/")) && p !== "/vault/rpc" && !lockedRequestAllowed(req.method || "GET", p)) {
938
- json(res, 423, REMOTE_LOCKED_BODY);
1425
+ json2(res, 423, REMOTE_LOCKED_BODY);
939
1426
  return;
940
1427
  }
941
1428
  }
@@ -956,50 +1443,50 @@ function startUnitWeb(deps, opts) {
956
1443
  if (deps.projection?.mode === "public") {
957
1444
  if (await deps.account?.handle(path, req, res)) return;
958
1445
  if (req.method !== "GET" && req.method !== "HEAD") {
959
- json(res, 405, { detail: "Read-only projection" });
1446
+ json2(res, 405, { detail: "Read-only projection" });
960
1447
  return;
961
1448
  }
962
1449
  if (path === "/unit/whoami") {
963
- json(res, 200, { ok: true, scope: "shell" });
1450
+ json2(res, 200, { ok: true, scope: "shell" });
964
1451
  return;
965
1452
  }
966
1453
  if (path === "/unit/plugins") {
967
- json(res, 200, { appVersion: deps.meta.version, plugins: await deps.readPlugins() });
1454
+ json2(res, 200, { appVersion: deps.meta.version, plugins: await deps.readPlugins() });
968
1455
  return;
969
1456
  }
970
1457
  if (path === "/unit/spaces") {
971
- json(res, 200, { spaces: await deps.readSpaces() });
1458
+ json2(res, 200, { spaces: await deps.readSpaces() });
972
1459
  return;
973
1460
  }
974
1461
  if (path === "/unit/config") {
975
- json(res, 200, { config: {} });
1462
+ json2(res, 200, { config: {} });
976
1463
  return;
977
1464
  }
978
1465
  if (path === "/unit/providers") {
979
- json(res, 200, { providers: [] });
1466
+ json2(res, 200, { providers: [] });
980
1467
  return;
981
1468
  }
982
1469
  if (path === "/unit/meta") {
983
- json(res, 200, { ...deps.meta, pair: false, projection: "public", account: deps.account?.metadata(), capabilities: deps.projection?.capabilities?.() });
1470
+ json2(res, 200, { ...deps.meta, pair: false, projection: "public", account: deps.account?.metadata(), capabilities: deps.projection?.capabilities?.() });
984
1471
  return;
985
1472
  }
986
1473
  if (/^\/(?:unit|vault|engine)(?:\/|$)/.test(path)) {
987
- json(res, 403, { detail: "Host capability is not published" });
1474
+ json2(res, 403, { detail: "Host capability is not published" });
988
1475
  return;
989
1476
  }
990
1477
  }
991
1478
  if (path === "/unit/meta" && req.method === "GET") {
992
- json(res, 200, { ...deps.meta, pair: !deps.projection, ...deps.projection?.mode === "local" ? { projection: "local", localCapabilities: deps.projection.localCapabilities?.() } : {} });
1479
+ json2(res, 200, { ...deps.meta, pair: !deps.projection, ...deps.projection?.mode === "local" ? { projection: "local", localCapabilities: deps.projection.localCapabilities?.() } : {} });
993
1480
  return;
994
1481
  }
995
1482
  if (deps.projection?.mode === "local" && path.startsWith("/unit/pair/")) {
996
- json(res, 403, { detail: "Use the workspace owner access key" });
1483
+ json2(res, 403, { detail: "Use the workspace owner access key" });
997
1484
  return;
998
1485
  }
999
1486
  if (path === "/unit/pair/request" && req.method === "POST") {
1000
1487
  const prevId = pendingByIp.get(ip);
1001
1488
  if (prevId && pending.get(prevId)?.status === "pending") {
1002
- json(res, 429, { detail: "\u5DF2\u6709\u5F85\u786E\u8BA4\u7684\u914D\u5BF9\u8BF7\u6C42", code: "PAIR_PENDING" });
1489
+ json2(res, 429, { detail: "\u5DF2\u6709\u5F85\u786E\u8BA4\u7684\u914D\u5BF9\u8BF7\u6C42", code: "PAIR_PENDING" });
1003
1490
  return;
1004
1491
  }
1005
1492
  let name = "\u672A\u547D\u540D\u8BBE\u5907";
@@ -1017,7 +1504,7 @@ function startUnitWeb(deps, opts) {
1017
1504
  };
1018
1505
  pending.set(p.id, p);
1019
1506
  pendingByIp.set(ip, p.id);
1020
- json(res, 200, { requestId: p.id, code: p.code, ttlMs: PAIR_TTL_MS });
1507
+ json2(res, 200, { requestId: p.id, code: p.code, ttlMs: PAIR_TTL_MS });
1021
1508
  void deps.confirmPair({ name: p.name, code: p.code, ip }).then(async (ok) => {
1022
1509
  const live = pending.get(p.id);
1023
1510
  if (!live || live.status !== "pending" || live.expires < Date.now()) return;
@@ -1037,34 +1524,34 @@ function startUnitWeb(deps, opts) {
1037
1524
  const id = String(new URL(url, "http://x").searchParams.get("id") || "");
1038
1525
  const p = pending.get(id);
1039
1526
  if (!p || p.expires < Date.now()) {
1040
- json(res, 200, { status: "expired" });
1527
+ json2(res, 200, { status: "expired" });
1041
1528
  return;
1042
1529
  }
1043
1530
  if (p.status === "approved" && p.token) {
1044
1531
  const token = p.token;
1045
1532
  pending.delete(id);
1046
1533
  pendingByIp.delete(p.ip);
1047
- json(res, 200, { status: "approved", token });
1534
+ json2(res, 200, { status: "approved", token });
1048
1535
  return;
1049
1536
  }
1050
- json(res, 200, { status: p.status });
1537
+ json2(res, 200, { status: p.status });
1051
1538
  return;
1052
1539
  }
1053
1540
  if (path === "/unit/whoami" && req.method === "GET") {
1054
1541
  if (!authed(req)) {
1055
- json(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
1542
+ json2(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
1056
1543
  return;
1057
1544
  }
1058
- json(res, 200, { ok: true });
1545
+ json2(res, 200, { ok: true });
1059
1546
  return;
1060
1547
  }
1061
1548
  if (path === "/unit/p2p/offer" && req.method === "POST") {
1062
1549
  if (!authed(req)) {
1063
- json(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
1550
+ json2(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
1064
1551
  return;
1065
1552
  }
1066
1553
  if (!deps.p2pAnswer) {
1067
- json(res, 501, { detail: "\u672C\u7AEF\u4E0D\u652F\u6301 P2P \u76F4\u8FDE", code: "P2P_UNSUPPORTED" });
1554
+ json2(res, 501, { detail: "\u672C\u7AEF\u4E0D\u652F\u6301 P2P \u76F4\u8FDE", code: "P2P_UNSUPPORTED" });
1068
1555
  return;
1069
1556
  }
1070
1557
  let offer = "";
@@ -1073,38 +1560,38 @@ function startUnitWeb(deps, opts) {
1073
1560
  } catch {
1074
1561
  }
1075
1562
  if (!offer) {
1076
- json(res, 400, { detail: "\u7F3A offer SDP" });
1563
+ json2(res, 400, { detail: "\u7F3A offer SDP" });
1077
1564
  return;
1078
1565
  }
1079
1566
  try {
1080
- json(res, 200, { sdp: await deps.p2pAnswer(offer) });
1567
+ json2(res, 200, { sdp: await deps.p2pAnswer(offer) });
1081
1568
  } catch (e) {
1082
- json(res, 500, { detail: `P2P \u5E94\u7B54\u5931\u8D25: ${e?.message || e}` });
1569
+ json2(res, 500, { detail: `P2P \u5E94\u7B54\u5931\u8D25: ${e?.message || e}` });
1083
1570
  }
1084
1571
  return;
1085
1572
  }
1086
1573
  if (path === "/unit/plugins" && req.method === "GET") {
1087
1574
  if (!authed(req)) {
1088
- json(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
1575
+ json2(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
1089
1576
  return;
1090
1577
  }
1091
- json(res, 200, { appVersion: deps.meta.version, plugins: await deps.readPlugins() });
1578
+ json2(res, 200, { appVersion: deps.meta.version, plugins: await deps.readPlugins() });
1092
1579
  return;
1093
1580
  }
1094
1581
  if (path === "/unit/spaces" && req.method === "GET") {
1095
1582
  if (!authed(req)) {
1096
- json(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
1583
+ json2(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
1097
1584
  return;
1098
1585
  }
1099
- json(res, 200, { spaces: await deps.readSpaces() });
1586
+ json2(res, 200, { spaces: await deps.readSpaces() });
1100
1587
  return;
1101
1588
  }
1102
1589
  if (path === "/unit/providers" && req.method === "GET") {
1103
1590
  if (!authed(req)) {
1104
- json(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
1591
+ json2(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
1105
1592
  return;
1106
1593
  }
1107
- json(res, 200, { providers: await deps.readProviders() });
1594
+ json2(res, 200, { providers: await deps.readProviders() });
1108
1595
  return;
1109
1596
  }
1110
1597
  if ((path === "/unit/hostfile" || path === "/unit/hostfile/download") && req.method === "GET") {
@@ -1116,78 +1603,78 @@ function startUnitWeb(deps, opts) {
1116
1603
  }
1117
1604
  const f = await deps.readHostFile(q.p, q.via === "tunnel" ? 4 * 1024 * 1024 : void 0);
1118
1605
  if (!f) {
1119
- json(res, 404, { detail: "not readable" });
1606
+ json2(res, 404, { detail: "not readable" });
1120
1607
  return;
1121
1608
  }
1122
- json(res, 200, f);
1609
+ json2(res, 200, f);
1123
1610
  return;
1124
1611
  }
1125
1612
  if ((path === "/unit/hostdir" || path === "/unit/hoststat") && req.method === "GET") {
1126
1613
  if (!authed(req)) {
1127
- json(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
1614
+ json2(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
1128
1615
  return;
1129
1616
  }
1130
1617
  const p = String(new URL(url, "http://x").searchParams.get("path") || "");
1131
1618
  const r = path === "/unit/hostdir" ? await deps.readHostDir(p) : await deps.readHostStat(p);
1132
1619
  if (!r) {
1133
- json(res, 404, { detail: "not readable" });
1620
+ json2(res, 404, { detail: "not readable" });
1134
1621
  return;
1135
1622
  }
1136
- json(res, 200, path === "/unit/hostdir" ? { entries: r } : r);
1623
+ json2(res, 200, path === "/unit/hostdir" ? { entries: r } : r);
1137
1624
  return;
1138
1625
  }
1139
1626
  if (path === "/unit/config" && (req.method === "GET" || req.method === "PUT")) {
1140
1627
  if (!authed(req)) {
1141
- json(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
1628
+ json2(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
1142
1629
  return;
1143
1630
  }
1144
1631
  if (req.method === "GET") {
1145
- json(res, 200, { config: await deps.readConfig() });
1632
+ json2(res, 200, { config: await deps.readConfig() });
1146
1633
  return;
1147
1634
  }
1148
1635
  let patch;
1149
1636
  try {
1150
1637
  patch = JSON.parse(await readBody(req));
1151
1638
  } catch {
1152
- json(res, 400, { detail: "bad body" });
1639
+ json2(res, 400, { detail: "bad body" });
1153
1640
  return;
1154
1641
  }
1155
- json(res, 200, { config: await deps.writeConfig(patch && typeof patch === "object" ? patch : {}) });
1642
+ json2(res, 200, { config: await deps.writeConfig(patch && typeof patch === "object" ? patch : {}) });
1156
1643
  return;
1157
1644
  }
1158
1645
  if (path === "/unit/remote-access" && req.method === "GET" || path === "/unit/remote-access/request" && req.method === "POST") {
1159
1646
  const info = authInfo(req);
1160
1647
  if (!info.ok) {
1161
- json(res, 401, { detail: "Not paired", code: "UNPAIRED" });
1648
+ json2(res, 401, { detail: "Not paired", code: "UNPAIRED" });
1162
1649
  return;
1163
1650
  }
1164
1651
  const rc = resolveCaller(req, info);
1165
1652
  if (!rc.ok) {
1166
- json(res, 403, BAD_CALLER_BODY);
1653
+ json2(res, 403, BAD_CALLER_BODY);
1167
1654
  return;
1168
1655
  }
1169
1656
  const caller = callerOf(info.via, pairInfo(info), rc.caller);
1170
1657
  const ra = deps.remoteAccess;
1171
1658
  if (!ra) {
1172
- json(res, 200, { remoteSessions: false, principal: caller.kind === "paired" ? "lan" : caller.kind, caller: caller.kind === "paired" ? "paired" : "unconfirmed", maxApprovalMode: "auto-edit" });
1659
+ json2(res, 200, { remoteSessions: false, principal: caller.kind === "paired" ? "lan" : caller.kind, caller: caller.kind === "paired" ? "paired" : "unconfirmed", maxApprovalMode: "auto-edit" });
1173
1660
  return;
1174
1661
  }
1175
- json(res, 200, path === "/unit/remote-access" ? ra.status(caller) : await ra.request(caller));
1662
+ json2(res, 200, path === "/unit/remote-access" ? ra.status(caller) : await ra.request(caller));
1176
1663
  return;
1177
1664
  }
1178
1665
  if (path === "/engine" || path.startsWith("/engine/")) {
1179
1666
  const method = req.method || "GET";
1180
1667
  const info = authInfo(req);
1181
1668
  if (!info.ok) {
1182
- json(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
1669
+ json2(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
1183
1670
  return;
1184
1671
  }
1185
1672
  if (isLocalOnlyEnginePath(url)) {
1186
1673
  if (!engineTarget(url.slice("/engine".length))) {
1187
- json(res, 400, { detail: "Ambiguous engine path", code: "BAD_PATH" });
1674
+ json2(res, 400, { detail: "Ambiguous engine path", code: "BAD_PATH" });
1188
1675
  return;
1189
1676
  }
1190
- json(res, 403, LOCAL_ONLY_BODY);
1677
+ json2(res, 403, LOCAL_ONLY_BODY);
1191
1678
  return;
1192
1679
  }
1193
1680
  if (ownerProjection) {
@@ -1196,26 +1683,26 @@ function startUnitWeb(deps, opts) {
1196
1683
  }
1197
1684
  const target = engineTarget(url.slice("/engine".length));
1198
1685
  if (!target) {
1199
- json(res, 400, { detail: "Ambiguous engine path", code: "BAD_PATH" });
1686
+ json2(res, 400, { detail: "Ambiguous engine path", code: "BAD_PATH" });
1200
1687
  return;
1201
1688
  }
1202
1689
  if (engineRouteAccess(method, target.path) !== "allow") {
1203
- json(res, 403, LOCAL_ONLY_BODY);
1690
+ json2(res, 403, LOCAL_ONLY_BODY);
1204
1691
  return;
1205
1692
  }
1206
1693
  const rc = resolveCaller(req, info);
1207
1694
  if (!rc.ok) {
1208
- json(res, 403, BAD_CALLER_BODY);
1695
+ json2(res, 403, BAD_CALLER_BODY);
1209
1696
  return;
1210
1697
  }
1211
1698
  if (lockedNow() && !lockedEngineAllowed(method, target.path)) {
1212
- json(res, 423, REMOTE_LOCKED_BODY);
1699
+ json2(res, 423, REMOTE_LOCKED_BODY);
1213
1700
  return;
1214
1701
  }
1215
1702
  const caller = callerOf(info.via, pairInfo(info), rc.caller);
1216
1703
  const g = deps.remoteAccess ? deps.remoteAccess.gateEngine({ method, path: target.path, via: info.via, caller }) : baseTierOnly(method, target.path);
1217
1704
  if (!g.ok) {
1218
- json(res, g.status, g.body);
1705
+ json2(res, g.status, g.body);
1219
1706
  return;
1220
1707
  }
1221
1708
  proxyEngine(req, res, target.path + target.query, info.via, rc.caller);
@@ -1224,7 +1711,7 @@ function startUnitWeb(deps, opts) {
1224
1711
  if (path.startsWith("/vault/")) {
1225
1712
  const vault = deps.vault();
1226
1713
  if (!vault) {
1227
- json(res, 503, { detail: "\u672C\u673A\u667A\u5E93\u9762\u672A\u5C31\u7EEA", code: "VAULT_NOT_READY" });
1714
+ json2(res, 503, { detail: "\u672C\u673A\u667A\u5E93\u9762\u672A\u5C31\u7EEA", code: "VAULT_NOT_READY" });
1228
1715
  return;
1229
1716
  }
1230
1717
  const q = new URL(url, "http://x").searchParams;
@@ -1232,52 +1719,52 @@ function startUnitWeb(deps, opts) {
1232
1719
  const assetAuthed = () => at !== "" && assetTokenLive(at) || authed(req);
1233
1720
  if (path === "/vault/rpc" && req.method === "POST") {
1234
1721
  if (!authed(req)) {
1235
- json(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
1722
+ json2(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
1236
1723
  return;
1237
1724
  }
1238
1725
  let body;
1239
1726
  try {
1240
1727
  body = JSON.parse(await readBody(req, 32 * 1024 * 1024));
1241
1728
  } catch {
1242
- json(res, 400, { detail: "bad body" });
1729
+ json2(res, 400, { detail: "bad body" });
1243
1730
  return;
1244
1731
  }
1245
1732
  const ch = String(body.ch || "");
1246
1733
  if (!VAULT_RPC_ALLOW.has(ch)) {
1247
- json(res, 400, { detail: `\u901A\u9053\u4E0D\u53EF\u8FDC\u7A0B\u8C03\u7528: ${ch}`, code: "VAULT_CH_DENIED" });
1734
+ json2(res, 400, { detail: `\u901A\u9053\u4E0D\u53EF\u8FDC\u7A0B\u8C03\u7528: ${ch}`, code: "VAULT_CH_DENIED" });
1248
1735
  return;
1249
1736
  }
1250
1737
  if (!ownerProjection && VAULT_RPC_LOCAL_ONLY.has(ch)) {
1251
- json(res, 403, LOCAL_ONLY_BODY);
1738
+ json2(res, 403, LOCAL_ONLY_BODY);
1252
1739
  return;
1253
1740
  }
1254
1741
  if (!ownerProjection && lockedNow() && !VAULT_RPC_READ.has(ch)) {
1255
- json(res, 423, REMOTE_LOCKED_BODY);
1742
+ json2(res, 423, REMOTE_LOCKED_BODY);
1256
1743
  return;
1257
1744
  }
1258
1745
  const origin = String(body.client || "").slice(0, 64) || null;
1259
1746
  try {
1260
1747
  const result = await vault.call(ch, decodeRpcArgs(Array.isArray(body.args) ? body.args : []), origin);
1261
- json(res, 200, { ok: true, result: encodeRpcResult(result) ?? null });
1748
+ json2(res, 200, { ok: true, result: encodeRpcResult(result) ?? null });
1262
1749
  } catch (e) {
1263
- json(res, 200, { ok: false, error: String(e?.message || e) });
1750
+ json2(res, 200, { ok: false, error: String(e?.message || e) });
1264
1751
  }
1265
1752
  return;
1266
1753
  }
1267
1754
  if (path === "/vault/asset-token" && req.method === "POST") {
1268
1755
  const info = authInfo(req);
1269
1756
  if (!info.ok) {
1270
- json(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
1757
+ json2(res, 401, { detail: "\u672A\u914D\u5BF9", code: "UNPAIRED" });
1271
1758
  return;
1272
1759
  }
1273
1760
  const token = randomBytes(16).toString("hex");
1274
1761
  assetTokens.set(token, { exp: Date.now() + ASSET_TTL_MS, pairHash: info.pairHash });
1275
- json(res, 200, { token, ttlSec: ASSET_TTL_MS / 1e3 });
1762
+ json2(res, 200, { token, ttlSec: ASSET_TTL_MS / 1e3 });
1276
1763
  return;
1277
1764
  }
1278
1765
  if (path === "/vault/asset" && (req.method === "GET" || req.method === "HEAD")) {
1279
1766
  if (!assetAuthed()) {
1280
- json(res, 401, { detail: "\u8D44\u6E90\u4EE4\u724C\u65E0\u6548", code: "ASSET_TOKEN" });
1767
+ json2(res, 401, { detail: "\u8D44\u6E90\u4EE4\u724C\u65E0\u6548", code: "ASSET_TOKEN" });
1281
1768
  return;
1282
1769
  }
1283
1770
  const exact = q.get("path");
@@ -1288,12 +1775,12 @@ function startUnitWeb(deps, opts) {
1288
1775
  }
1289
1776
  const real = abs ? await underVaultReal(abs, vault.root()) : null;
1290
1777
  if (!real) {
1291
- json(res, 404, { detail: "not found" });
1778
+ json2(res, 404, { detail: "not found" });
1292
1779
  return;
1293
1780
  }
1294
1781
  const stream = createReadStream(real);
1295
1782
  stream.on("error", () => {
1296
- if (!res.headersSent) json(res, 404, { detail: "not found" });
1783
+ if (!res.headersSent) json2(res, 404, { detail: "not found" });
1297
1784
  else res.destroy();
1298
1785
  });
1299
1786
  stream.once("open", () => {
@@ -1308,7 +1795,7 @@ function startUnitWeb(deps, opts) {
1308
1795
  }
1309
1796
  if (path === "/vault/events" && req.method === "GET") {
1310
1797
  if (!assetAuthed()) {
1311
- json(res, 401, { detail: "\u8D44\u6E90\u4EE4\u724C\u65E0\u6548", code: "ASSET_TOKEN" });
1798
+ json2(res, 401, { detail: "\u8D44\u6E90\u4EE4\u724C\u65E0\u6548", code: "ASSET_TOKEN" });
1312
1799
  return;
1313
1800
  }
1314
1801
  const viaAt = at !== "" && assetTokenLive(at);
@@ -1342,11 +1829,11 @@ function startUnitWeb(deps, opts) {
1342
1829
  });
1343
1830
  return;
1344
1831
  }
1345
- json(res, 404, { detail: "not found" });
1832
+ json2(res, 404, { detail: "not found" });
1346
1833
  return;
1347
1834
  }
1348
1835
  if (req.method !== "GET" && req.method !== "HEAD") {
1349
- json(res, 405, { detail: "method not allowed" });
1836
+ json2(res, 405, { detail: "method not allowed" });
1350
1837
  return;
1351
1838
  }
1352
1839
  await serveStatic(res, path);
@@ -1354,7 +1841,7 @@ function startUnitWeb(deps, opts) {
1354
1841
  const server = http.createServer((req, res) => {
1355
1842
  void handler(req, res).catch((e) => {
1356
1843
  deps.log(`[unit-web] \u8BF7\u6C42\u5904\u7406\u5931\u8D25: ${e?.message || e}`);
1357
- if (!res.headersSent) json(res, 500, { detail: "internal error" });
1844
+ if (!res.headersSent) json2(res, 500, { detail: "internal error" });
1358
1845
  });
1359
1846
  });
1360
1847
  server.requestTimeout = 60 * 60 * 1e3;
@@ -1398,114 +1885,8 @@ var basic_default = {
1398
1885
  unit: true
1399
1886
  };
1400
1887
 
1401
- // unit/packages.ts
1402
- import { readFile as readFile2, readdir, realpath as realpath2, stat } from "node:fs/promises";
1403
- import { isAbsolute, resolve, sep as sep2 } from "node:path";
1404
-
1405
- // desktop/shared/amadeus/pluginFiles.ts
1406
- function isSafePluginExt(extension) {
1407
- if (typeof extension !== "string") return false;
1408
- const value = extension.trim().toLowerCase();
1409
- if (!value.startsWith(".") || value.length < 2 || value.length > 40) return false;
1410
- if (value === ".md" || value === ".markdown" || value === ".txt") return false;
1411
- return value.endsWith(".md") ? /^\.[a-z0-9][a-z0-9-]*\.md$/.test(value) : /^\.[a-z0-9][a-z0-9.-]*$/.test(value);
1412
- }
1413
-
1414
- // desktop/shared/nativeFeatures.ts
1415
- var NATIVE_FEATURE_IDS = ["amadeus", "tangu", "calendar", "automation", "public"];
1416
- var NATIVE_FEATURE_SPACES = {
1417
- amadeus: ["amadeus"],
1418
- tangu: ["tangu"],
1419
- calendar: ["calendar"],
1420
- automation: ["automation"],
1421
- public: ["public"]
1422
- };
1423
- var isNativeFeatureId = (value) => typeof value === "string" && NATIVE_FEATURE_IDS.includes(value);
1424
- function nativeFeatureSpaceIds(features) {
1425
- return [...new Set(features.flatMap((feature) => NATIVE_FEATURE_SPACES[feature]))];
1426
- }
1427
-
1428
- // unit/packages.ts
1429
- var validId = (id) => typeof id === "string" && /^[a-z][a-z0-9-]*$/.test(id);
1430
- function versionAtLeast(actual, minimum) {
1431
- const parse = (v) => /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/.test(v) ? v.split(/[.+-]/).slice(0, 3).map(Number) : null;
1432
- const a = parse(actual), b = parse(minimum);
1433
- if (!a || !b) throw new Error("Invalid semantic version");
1434
- for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
1435
- return true;
1436
- }
1437
- async function packagePath(root, relative2) {
1438
- if (typeof relative2 !== "string" || !relative2 || isAbsolute(relative2) || relative2.split(/[\\/]/).includes("..")) throw new Error("Plugin file escapes its package");
1439
- const file2 = await realpath2(resolve(root, relative2));
1440
- if (!file2.startsWith(root + sep2)) throw new Error("Plugin file escapes its package");
1441
- if (!(await stat(file2)).isFile()) throw new Error("Plugin entry must be a regular file");
1442
- return file2;
1443
- }
1444
- async function readPackage(dir, appVersion) {
1445
- const root = await realpath2(dir);
1446
- const manifest = JSON.parse(await readFile2(await packagePath(root, "manifest.json"), "utf8"));
1447
- if (!validId(manifest.id) || manifest.apiVersion !== 1) throw new Error("Invalid plugin identity or unsupported plugin API");
1448
- versionAtLeast(manifest.version, "0.0.0");
1449
- if (manifest.minAppVersion && appVersion && !versionAtLeast(appVersion, manifest.minAppVersion)) throw new Error(`Plugin ${manifest.id} requires Unit ${manifest.minAppVersion}`);
1450
- if (manifest.requires && (!Array.isArray(manifest.requires) || !manifest.requires.every(validId))) throw new Error("Invalid plugin dependencies");
1451
- const result = { root, manifest, spaces: [], spaceIds: /* @__PURE__ */ new Set() };
1452
- if (manifest.frontend) {
1453
- const { features, services = {} } = manifest.frontend;
1454
- if (!Array.isArray(features) || !features.length || !features.every(isNativeFeatureId) || new Set(features).size !== features.length) throw new Error("Invalid native frontend features");
1455
- for (const [id, service] of Object.entries(services)) {
1456
- if (!["amadeus", "tangu"].includes(id) || !features.includes(id) || !service || service.adapter !== "forsion-cloud-v1" || typeof service.apiBase !== "string" || !/^\/(?:[A-Za-z0-9_-]+\/?)+$/.test(service.apiBase) || id === "tangu" && service.execution !== "fleet" || id === "amadeus" && service.collaboration !== void 0 && typeof service.collaboration !== "boolean") {
1457
- throw new Error("Invalid frontend cloud service");
1458
- }
1459
- }
1460
- for (const id of nativeFeatureSpaceIds(features)) result.spaceIds.add(id);
1461
- }
1462
- if (manifest.main) {
1463
- const code = await readFile2(await packagePath(root, manifest.main), "utf8");
1464
- result.ui = {
1465
- id: manifest.id,
1466
- name: manifest.name,
1467
- nameEn: manifest.nameEn,
1468
- version: manifest.version,
1469
- apiVersion: manifest.apiVersion,
1470
- minAppVersion: manifest.minAppVersion,
1471
- description: manifest.description,
1472
- descriptionEn: manifest.descriptionEn,
1473
- onboarding: manifest.onboarding,
1474
- fileExtensions: Array.isArray(manifest.fileExtensions) ? manifest.fileExtensions.filter(isSafePluginExt).map((v) => v.trim().toLowerCase()).slice(0, 8) : [],
1475
- code
1476
- };
1477
- }
1478
- if (manifest.runtime) {
1479
- if (manifest.runtime.apiVersion !== 1) throw new Error("Unsupported local runtime API");
1480
- if (manifest.runtime.dependencies?.mode === "npm-ci") throw new Error("Local runtime dependencies must be bundled");
1481
- result.runtimeEntry = await packagePath(root, manifest.runtime.main);
1482
- }
1483
- if (manifest.backend) {
1484
- if (manifest.backend.apiVersion !== 1) throw new Error("Unsupported backend plugin API");
1485
- result.backendEntry = await packagePath(root, manifest.backend.main);
1486
- }
1487
- for (const deps of [manifest.backend?.dependencies, manifest.runtime?.dependencies]) {
1488
- if (deps && !["bundled", "npm-ci"].includes(deps.mode)) throw new Error("Unsupported dependency mode");
1489
- const libc = process.platform === "linux" ? process.report?.getReport()?.header?.glibcVersionRuntime ? "glibc" : "musl" : void 0;
1490
- if (deps?.mode === "bundled" && (deps.platform && deps.platform !== process.platform || deps.libc && deps.libc !== libc || deps.arch && deps.arch !== process.arch || deps.nodeAbi && deps.nodeAbi !== process.versions.modules)) {
1491
- throw new Error("Plugin dependencies were built for a different OS, architecture, Node ABI, or libc");
1492
- }
1493
- }
1494
- if (!result.ui && !result.backendEntry && !result.runtimeEntry && !manifest.frontend) throw new Error("Plugin has no UI or backend entry");
1495
- const entries = await readdir(resolve(root, "spaces"), { withFileTypes: true }).catch((error) => {
1496
- if (error.code === "ENOENT") return [];
1497
- throw error;
1498
- });
1499
- for (const entry of entries) {
1500
- if (!entry.isDirectory()) continue;
1501
- const json = await readFile2(await packagePath(root, `spaces/${entry.name}/space.json`), "utf8");
1502
- const spec = JSON.parse(json);
1503
- if (!validId(spec.id) || result.spaceIds.has(spec.id)) throw new Error("Invalid or duplicate Space id");
1504
- result.spaceIds.add(spec.id);
1505
- result.spaces.push({ slug: entry.name, json, plugin: manifest.id });
1506
- }
1507
- return result;
1508
- }
1888
+ // unit/host.ts
1889
+ init_packages();
1509
1890
 
1510
1891
  // unit/backendRunner.ts
1511
1892
  import http2 from "node:http";
@@ -1884,7 +2265,7 @@ var BackendSession = class {
1884
2265
  return;
1885
2266
  }
1886
2267
  try {
1887
- this.worker.postMessage({ type: "account-resolve", requestId, token });
2268
+ this.worker.postMessage({ type: "account-resolve", requestId, token, purpose: options.purpose });
1888
2269
  } catch {
1889
2270
  finish(null, new Error("Account provider unavailable"));
1890
2271
  }
@@ -2062,7 +2443,7 @@ var AccountChanged = class extends Error {
2062
2443
  var preferences = new Set(UNIT_PREFERENCE_KEYS);
2063
2444
  function createAccountHttp(options) {
2064
2445
  const writes = /* @__PURE__ */ new Map();
2065
- const json = (res, status, body) => {
2446
+ const json2 = (res, status, body) => {
2066
2447
  res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
2067
2448
  res.end(JSON.stringify(body));
2068
2449
  };
@@ -2106,33 +2487,37 @@ function createAccountHttp(options) {
2106
2487
  res.once("close", onClose);
2107
2488
  try {
2108
2489
  const bearer = /^Bearer (\S+)$/i.exec(String(req.headers.authorization || ""))?.[1];
2109
- const identity = bearer ? await provider.account.resolve(bearer, { signal: controller.signal }) : null;
2490
+ const identity = bearer ? await provider.account.resolve(bearer, { signal: controller.signal, purpose: "administration" }) : null;
2110
2491
  if (!identity) {
2111
- json(res, 401, { error: "Authentication required" });
2492
+ json2(res, 401, { error: "Authentication required" });
2112
2493
  return true;
2113
2494
  }
2114
2495
  if (options.provider()?.account !== provider.account) {
2115
- json(res, 503, { error: "Account provider changed" });
2496
+ json2(res, 503, { error: "Account provider changed" });
2116
2497
  return true;
2117
2498
  }
2118
2499
  if (path === "/unit/account") {
2119
- json(res, req.method === "GET" ? 200 : 405, req.method === "GET" ? identity : { error: "Method not allowed" });
2500
+ json2(res, req.method === "GET" ? 200 : 405, req.method === "GET" ? identity : { error: "Method not allowed" });
2120
2501
  return true;
2121
2502
  }
2122
2503
  const plugin = path.startsWith("/unit/plugin-data/") ? path.slice("/unit/plugin-data/".length) : null;
2123
2504
  if (plugin !== null && (!/^[a-zA-Z0-9_-]+$/.test(plugin) || !options.pluginActive(plugin))) {
2124
- json(res, 404, { error: "Plugin is not active" });
2505
+ json2(res, 404, { error: "Plugin is not active" });
2125
2506
  return true;
2126
2507
  }
2127
2508
  if (req.method !== "GET" && req.method !== "PUT") {
2128
- json(res, 405, { error: "Method not allowed" });
2509
+ json2(res, 405, { error: "Method not allowed" });
2510
+ return true;
2511
+ }
2512
+ if (identity.role === "ADMIN_SCOPED") {
2513
+ json2(res, 403, { error: "MCP tokens cannot access personal storage" });
2129
2514
  return true;
2130
2515
  }
2131
2516
  const dir = join(options.dataDir, "accounts", digest([provider.id, identity.userId, identity.tenantId, identity.workspaceId]));
2132
2517
  const file2 = join(dir, plugin === null ? "preferences.json" : digest(plugin) + ".json");
2133
2518
  if (req.method === "GET") {
2134
2519
  const value2 = await read(file2);
2135
- json(res, 200, plugin === null ? { config: value2 || {} } : { data: value2 });
2520
+ json2(res, 200, plugin === null ? { config: value2 || {} } : { data: value2 });
2136
2521
  return true;
2137
2522
  }
2138
2523
  let size = 0;
@@ -2140,7 +2525,7 @@ function createAccountHttp(options) {
2140
2525
  for await (const chunk of req) {
2141
2526
  size += Buffer.byteLength(chunk);
2142
2527
  if (size > 1024 * 1024) {
2143
- json(res, 413, { error: "Data exceeds 1 MB" });
2528
+ json2(res, 413, { error: "Data exceeds 1 MB" });
2144
2529
  return true;
2145
2530
  }
2146
2531
  chunks.push(Buffer.from(chunk));
@@ -2149,16 +2534,16 @@ function createAccountHttp(options) {
2149
2534
  try {
2150
2535
  input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
2151
2536
  } catch {
2152
- json(res, 400, { error: "Invalid JSON" });
2537
+ json2(res, 400, { error: "Invalid JSON" });
2153
2538
  return true;
2154
2539
  }
2155
2540
  if (!input || typeof input !== "object" || Array.isArray(input) || plugin !== null && typeof input.data !== "string") {
2156
- json(res, 400, { error: "Invalid data" });
2541
+ json2(res, 400, { error: "Invalid data" });
2157
2542
  return true;
2158
2543
  }
2159
2544
  const guard = async () => {
2160
2545
  controller.signal.throwIfAborted();
2161
- const current = await provider.account.resolve(bearer, { signal: controller.signal });
2546
+ const current = await provider.account.resolve(bearer, { signal: controller.signal, purpose: "administration" });
2162
2547
  if (!current || digest(current) !== digest(identity) || options.provider()?.account !== provider.account) {
2163
2548
  throw new AccountChanged("Account changed");
2164
2549
  }
@@ -2166,10 +2551,10 @@ function createAccountHttp(options) {
2166
2551
  await guard();
2167
2552
  await mkdir(dir, { recursive: true, mode: 448 });
2168
2553
  const value = await write(file2, (previous) => plugin === null ? { ...previous, ...Object.fromEntries(Object.entries(input).filter(([key]) => preferences.has(key))) } : input.data, guard);
2169
- json(res, 200, plugin === null ? { config: value } : { ok: true });
2554
+ json2(res, 200, plugin === null ? { config: value } : { ok: true });
2170
2555
  return true;
2171
2556
  } catch (error) {
2172
- if (!res.headersSent && !res.destroyed) json(
2557
+ if (!res.headersSent && !res.destroyed) json2(
2173
2558
  res,
2174
2559
  error instanceof AccountChanged ? 401 : 503,
2175
2560
  { error: error instanceof AccountChanged ? "Account changed" : "Account service unavailable" }
@@ -2218,75 +2603,303 @@ async function createLocalWorkspace(dataDir, workspaceDir, vaultRoot) {
2218
2603
  const realRoot = await realpath3(root);
2219
2604
  if (actual === realRoot || actual.startsWith(realRoot + sep3)) return actual;
2220
2605
  }
2221
- } catch {
2606
+ } catch {
2607
+ }
2608
+ return null;
2609
+ };
2610
+ return {
2611
+ token,
2612
+ device,
2613
+ readConfig: async () => ({ ...await read(), homeDir: workspaceDir, defaultWorkspaceDir: workspaceDir }),
2614
+ writeConfig: async (patch) => {
2615
+ const job = writes.then(async () => {
2616
+ const next = { ...await read(), ...pickUnitPreferences(patch) };
2617
+ const tmp = `${file2}.${randomUUID4()}.tmp`;
2618
+ await writeFile2(tmp, JSON.stringify(next), { mode: 384 });
2619
+ await rename2(tmp, file2);
2620
+ });
2621
+ writes = job.catch(() => {
2622
+ });
2623
+ await job;
2624
+ return { ...await read(), homeDir: workspaceDir, defaultWorkspaceDir: workspaceDir };
2625
+ },
2626
+ readHostFile: async (path, maxBytes = 50 * 1024 * 1024) => {
2627
+ const real = await scoped(path);
2628
+ if (!real) return null;
2629
+ const st = await stat2(real);
2630
+ if (!st.isFile()) return null;
2631
+ const types = { ".md": "text/markdown", ".txt": "text/plain", ".json": "application/json", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".svg": "image/svg+xml", ".pdf": "application/pdf", ".mp4": "video/mp4", ".mp3": "audio/mpeg", ".wav": "audio/wav" };
2632
+ const mimeType = types[extname2(real).toLowerCase()] || "application/octet-stream";
2633
+ if (st.size > Math.min(maxBytes, 50 * 1024 * 1024)) return { mimeType, content: "", size: st.size, mtimeMs: st.mtimeMs, tooLarge: true };
2634
+ return { mimeType, content: (await readFile4(real)).toString("base64"), size: st.size, mtimeMs: st.mtimeMs };
2635
+ },
2636
+ readHostDir: async (path) => {
2637
+ const real = await scoped(path);
2638
+ if (!real || !(await stat2(real)).isDirectory()) return null;
2639
+ const entries = await readdir2(real, { withFileTypes: true });
2640
+ const values = await Promise.all(entries.slice(0, 2e3).map(async (e) => {
2641
+ const child = await scoped(resolve2(real, e.name));
2642
+ if (!child) return null;
2643
+ const st = await stat2(child);
2644
+ return { name: e.name, isDir: st.isDirectory(), size: st.size, path: child };
2645
+ }));
2646
+ return values.filter((e) => !!e).sort((a, b) => Number(b.isDir) - Number(a.isDir) || a.name.localeCompare(b.name));
2647
+ },
2648
+ readHostStat: async (path) => {
2649
+ const real = await scoped(path);
2650
+ if (!real) return null;
2651
+ const st = await stat2(real);
2652
+ return { isDir: st.isDirectory(), mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs || null };
2653
+ }
2654
+ };
2655
+ }
2656
+
2657
+ // unit/management.ts
2658
+ init_packages();
2659
+ init_releases();
2660
+ import { mkdir as mkdir7, readFile as readFile7, writeFile as writeFile5, rename as rename5 } from "node:fs/promises";
2661
+ import { join as join3 } from "node:path";
2662
+ import { randomUUID as randomUUID8 } from "node:crypto";
2663
+ var MANAGEMENT_PATH = "/api/admin/unit";
2664
+ var actions = ["enable", "disable", "restart", "update", "migrate"];
2665
+ var HttpError = class extends Error {
2666
+ constructor(status, message) {
2667
+ super(message);
2668
+ this.status = status;
2669
+ }
2670
+ };
2671
+ var json = (res, status, value) => {
2672
+ if (res.destroyed || res.writableEnded) return;
2673
+ res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
2674
+ res.end(JSON.stringify(value));
2675
+ };
2676
+ async function createManagement(options) {
2677
+ const file2 = join3(options.dataDir, "management-jobs.json");
2678
+ let jobs = [], saving = Promise.resolve(), busy = false, closing = false, pending;
2679
+ const requests = /* @__PURE__ */ new Set();
2680
+ if (options.enabled) {
2681
+ try {
2682
+ jobs = JSON.parse(await readFile7(file2, "utf8"));
2683
+ } catch (e) {
2684
+ if (e.code !== "ENOENT") throw e;
2685
+ }
2686
+ if (!Array.isArray(jobs) || jobs.length > 100) throw new Error("Invalid Unit job journal");
2687
+ for (const job of jobs) if (job.state === "queued" || job.state === "running") {
2688
+ job.state = "interrupted";
2689
+ job.error = "Unit stopped before completion; inspect current plugin state before retrying";
2690
+ job.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
2691
+ }
2692
+ }
2693
+ const save = () => {
2694
+ const payload = JSON.stringify(jobs);
2695
+ const next = saving.then(async () => {
2696
+ await mkdir7(options.dataDir, { recursive: true, mode: 448 });
2697
+ const temporary = file2 + "." + randomUUID8();
2698
+ await writeFile5(temporary, payload, { mode: 384 });
2699
+ await rename5(temporary, file2);
2700
+ });
2701
+ saving = next.catch(() => {
2702
+ });
2703
+ return next;
2704
+ };
2705
+ if (options.enabled && jobs.length) await save();
2706
+ const handle = async (req, res) => {
2707
+ const controller = new AbortController();
2708
+ const cancel = () => {
2709
+ if (!res.writableEnded) controller.abort();
2710
+ };
2711
+ res.once("close", cancel);
2712
+ try {
2713
+ if (!options.enabled || !options.execute) throw new HttpError(503, "Unit management is disabled; enable management.enabled in private unit.json");
2714
+ if (closing) throw new HttpError(503, "Unit is stopping");
2715
+ const provider = options.provider();
2716
+ if (!provider) throw new HttpError(503, "Account authority is unavailable");
2717
+ const token = /^Bearer (\S+)$/i.exec(String(req.headers.authorization || ""))?.[1];
2718
+ if (!token || token.length > 16384) throw new HttpError(401, "Authentication required");
2719
+ let permission = "unit.read";
2720
+ const authorize = async () => {
2721
+ if (closing) throw new HttpError(503, "Unit is stopping");
2722
+ const identity = await provider.account.resolve(token, { signal: controller.signal, purpose: "administration" });
2723
+ if (!identity) throw new HttpError(401, "Invalid or expired account");
2724
+ if (identity.role !== "ADMIN" && !(identity.role === "ADMIN_SCOPED" && (identity.adminPermissions?.includes("*") || identity.adminPermissions?.includes(permission)))) throw new HttpError(403, "Administrator access required");
2725
+ if (options.provider()?.account !== provider.account) throw new HttpError(503, "Account authority changed");
2726
+ return identity;
2727
+ };
2728
+ const actor = await authorize();
2729
+ const path = new URL(req.url, "http://unit.invalid").pathname;
2730
+ if (req.method === "GET") {
2731
+ if (path === MANAGEMENT_PATH + "/status") json(res, 200, { ...options.status(), management: { enabled: true, actions, busy } });
2732
+ else if (path === MANAGEMENT_PATH + "/releases") json(res, 200, { releases: await listReleases(options.dataDir) });
2733
+ else if (path === MANAGEMENT_PATH + "/operations") json(res, 200, { operations: [...jobs].reverse() });
2734
+ else if (path.startsWith(MANAGEMENT_PATH + "/operations/")) {
2735
+ const job = jobs.find((entry) => entry.id === path.slice((MANAGEMENT_PATH + "/operations/").length));
2736
+ if (!job) throw new HttpError(404, "Operation not found");
2737
+ json(res, 200, job);
2738
+ } else throw new HttpError(404, "Unknown Unit management route");
2739
+ return;
2740
+ }
2741
+ if (req.method !== "POST" || path !== MANAGEMENT_PATH + "/operations") throw new HttpError(405, "Method not allowed");
2742
+ if (!/^application\/json(?:;|$)/i.test(String(req.headers["content-type"] || ""))) throw new HttpError(415, "JSON body required");
2743
+ let size = 0;
2744
+ const chunks = [];
2745
+ req.setTimeout(5e3, () => req.destroy());
2746
+ for await (const chunk of req) {
2747
+ size += Buffer.byteLength(chunk);
2748
+ if (size > 8192) throw new HttpError(413, "Operation body exceeds 8 KB");
2749
+ chunks.push(Buffer.from(chunk));
2750
+ }
2751
+ req.setTimeout(0);
2752
+ let input;
2753
+ try {
2754
+ input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
2755
+ } catch {
2756
+ throw new HttpError(400, "Invalid JSON");
2757
+ }
2758
+ if (!input || Array.isArray(input) || typeof input !== "object" || Object.keys(input).some((key) => !["action", "pluginId", "releaseId", "requestId", "confirm"].includes(key)) || !actions.includes(input.action) || !validId(input.pluginId) || !validReleaseId(input.requestId) || (input.action === "update" ? !validReleaseId(input.releaseId) : input.releaseId !== void 0)) throw new HttpError(400, "Invalid operation arguments");
2759
+ if (input.confirm !== true) throw new HttpError(400, "confirm:true is required for Unit operations");
2760
+ permission = input.action === "update" ? "unit.update" : input.action === "migrate" ? "unit.migrate" : "unit.lifecycle";
2761
+ await authorize();
2762
+ const previous = jobs.find((job) => job.id === input.requestId);
2763
+ if (previous) {
2764
+ if (previous.actor.userId !== actor.userId || previous.action !== input.action || previous.pluginId !== input.pluginId || previous.releaseId !== input.releaseId) throw new HttpError(409, "Request ID is already used for a different operation");
2765
+ json(res, 200, previous);
2766
+ return;
2767
+ }
2768
+ if (busy) throw new HttpError(409, "Another Unit operation is running");
2769
+ if (provider.id === input.pluginId && ["disable", "migrate"].includes(input.action)) throw new HttpError(409, "Use local CLI for maintenance that stops the account authority");
2770
+ busy = true;
2771
+ try {
2772
+ const status = options.status();
2773
+ if (!status.plugins.some((plugin) => plugin.id === input.pluginId)) throw new HttpError(404, "Plugin is not installed");
2774
+ let release;
2775
+ if (input.action === "update") {
2776
+ try {
2777
+ release = await resolveRelease(options.dataDir, input.releaseId, input.pluginId, options.version, false);
2778
+ } catch {
2779
+ throw new HttpError(400, "Release is missing, incompatible, or its identity/content changed");
2780
+ }
2781
+ }
2782
+ await authorize();
2783
+ const job = {
2784
+ id: input.requestId,
2785
+ action: input.action,
2786
+ pluginId: input.pluginId,
2787
+ ...release ? { releaseId: input.releaseId } : {},
2788
+ actor: { userId: actor.userId, username: actor.username },
2789
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2790
+ state: "queued"
2791
+ };
2792
+ const priorJobs = jobs;
2793
+ jobs = [...jobs.slice(-99), job];
2794
+ try {
2795
+ await save();
2796
+ } catch (error) {
2797
+ jobs = priorJobs;
2798
+ throw error;
2799
+ }
2800
+ const run2 = async () => {
2801
+ try {
2802
+ await authorize();
2803
+ job.state = "running";
2804
+ await save();
2805
+ if (release) {
2806
+ job.phase = "verifying-release";
2807
+ await save();
2808
+ const verified = await resolveRelease(options.dataDir, input.releaseId, input.pluginId, options.version);
2809
+ if (verified.item.sha256 !== release.item.sha256) throw new Error("Staged release changed during installation");
2810
+ await authorize();
2811
+ }
2812
+ job.phase = "applying";
2813
+ await save();
2814
+ console.log(`[unit-management] ${job.id} ${job.action} ${job.pluginId} actor=${job.actor.userId}`);
2815
+ await options.execute({ action: job.action, id: job.pluginId, ...release ? { path: release.path, releaseDigest: release.item.sha256 } : {} }, async () => {
2816
+ await authorize();
2817
+ });
2818
+ job.state = "succeeded";
2819
+ } catch (error) {
2820
+ job.state = "failed";
2821
+ const reason = error instanceof Error ? error.message : "";
2822
+ job.error = error instanceof HttpError || [
2823
+ "Disable dependent plugins first",
2824
+ "Disable the plugin before migrating",
2825
+ "Unit is stopping",
2826
+ "Staged release changed during installation"
2827
+ ].includes(reason) ? reason : "Operation failed; inspect plugin status. Updates attempt to restore the previous package.";
2828
+ } finally {
2829
+ job.finishedAt = (/* @__PURE__ */ new Date()).toISOString();
2830
+ busy = false;
2831
+ try {
2832
+ await save();
2833
+ } catch {
2834
+ console.error(`[unit-management] Could not persist result for ${job.id}`);
2835
+ }
2836
+ console.log(`[unit-management] ${job.id} ${job.state}`);
2837
+ }
2838
+ };
2839
+ let launched = false;
2840
+ let done;
2841
+ pending = new Promise((resolve8) => {
2842
+ done = resolve8;
2843
+ });
2844
+ const launch = () => {
2845
+ if (launched) return;
2846
+ launched = true;
2847
+ setTimeout(() => {
2848
+ void run2().finally(done);
2849
+ }, 100);
2850
+ };
2851
+ res.once("finish", launch);
2852
+ res.once("close", launch);
2853
+ if (res.destroyed || res.writableEnded) launch();
2854
+ else json(res, 202, job);
2855
+ } catch (error) {
2856
+ busy = false;
2857
+ throw error;
2858
+ }
2859
+ } catch (error) {
2860
+ json(res, error instanceof HttpError ? error.status : 503, { error: error instanceof HttpError ? error.message : "Unit management is temporarily unavailable" });
2861
+ } finally {
2862
+ res.off("close", cancel);
2222
2863
  }
2223
- return null;
2224
2864
  };
2225
2865
  return {
2226
- token,
2227
- device,
2228
- readConfig: async () => ({ ...await read(), homeDir: workspaceDir, defaultWorkspaceDir: workspaceDir }),
2229
- writeConfig: async (patch) => {
2230
- const job = writes.then(async () => {
2231
- const next = { ...await read(), ...pickUnitPreferences(patch) };
2232
- const tmp = `${file2}.${randomUUID4()}.tmp`;
2233
- await writeFile2(tmp, JSON.stringify(next), { mode: 384 });
2234
- await rename2(tmp, file2);
2235
- });
2236
- writes = job.catch(() => {
2237
- });
2238
- await job;
2239
- return { ...await read(), homeDir: workspaceDir, defaultWorkspaceDir: workspaceDir };
2240
- },
2241
- readHostFile: async (path, maxBytes = 50 * 1024 * 1024) => {
2242
- const real = await scoped(path);
2243
- if (!real) return null;
2244
- const st = await stat2(real);
2245
- if (!st.isFile()) return null;
2246
- const types = { ".md": "text/markdown", ".txt": "text/plain", ".json": "application/json", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".svg": "image/svg+xml", ".pdf": "application/pdf", ".mp4": "video/mp4", ".mp3": "audio/mpeg", ".wav": "audio/wav" };
2247
- const mimeType = types[extname2(real).toLowerCase()] || "application/octet-stream";
2248
- if (st.size > Math.min(maxBytes, 50 * 1024 * 1024)) return { mimeType, content: "", size: st.size, mtimeMs: st.mtimeMs, tooLarge: true };
2249
- return { mimeType, content: (await readFile4(real)).toString("base64"), size: st.size, mtimeMs: st.mtimeMs };
2250
- },
2251
- readHostDir: async (path) => {
2252
- const real = await scoped(path);
2253
- if (!real || !(await stat2(real)).isDirectory()) return null;
2254
- const entries = await readdir2(real, { withFileTypes: true });
2255
- const values = await Promise.all(entries.slice(0, 2e3).map(async (e) => {
2256
- const child = await scoped(resolve2(real, e.name));
2257
- if (!child) return null;
2258
- const st = await stat2(child);
2259
- return { name: e.name, isDir: st.isDirectory(), size: st.size, path: child };
2260
- }));
2261
- return values.filter((e) => !!e).sort((a, b) => Number(b.isDir) - Number(a.isDir) || a.name.localeCompare(b.name));
2866
+ route(req, res) {
2867
+ const path = (req.url || "/").split("?")[0];
2868
+ if (path !== MANAGEMENT_PATH && !path.startsWith(MANAGEMENT_PATH + "/")) return false;
2869
+ const task = handle(req, res);
2870
+ requests.add(task);
2871
+ void task.then(() => requests.delete(task), () => requests.delete(task));
2872
+ return true;
2262
2873
  },
2263
- readHostStat: async (path) => {
2264
- const real = await scoped(path);
2265
- if (!real) return null;
2266
- const st = await stat2(real);
2267
- return { isDir: st.isDirectory(), mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs || null };
2874
+ async close() {
2875
+ closing = true;
2876
+ await Promise.allSettled([...requests]);
2877
+ await pending;
2878
+ await saving;
2268
2879
  }
2269
2880
  };
2270
2881
  }
2271
2882
 
2272
2883
  // unit/host.ts
2884
+ init_packages();
2273
2885
  var matches = (path, prefix) => prefix === "/" || path === prefix || path.startsWith(prefix + "/");
2274
2886
  var pathname = (url) => (url || "/").split("?")[0];
2275
- async function startBasicUnit(config) {
2887
+ async function startBasicUnit(config, managementOptions = {}) {
2276
2888
  if (!config.instanceId || !config.name || !config.version) throw new Error("Unit identity is required");
2277
- await readFile5(resolve3(config.webDist, "index.html"));
2278
- const dataDir = resolve3(config.dataDir || resolve3(homedir(), ".forsion", "units", config.instanceId));
2279
- await mkdir3(dataDir, { recursive: true, mode: 448 });
2889
+ await readFile8(resolve6(config.webDist, "index.html"));
2890
+ const dataDir = resolve6(config.dataDir || resolve6(homedir(), ".forsion", "units", config.instanceId));
2891
+ await mkdir8(dataDir, { recursive: true, mode: 448 });
2280
2892
  const workerFile = config.workerFile || fileURLToPath(new URL("./backendWorker.mjs", import.meta.url));
2281
2893
  if (config.workspace && config.workspace.mode !== "local") throw new Error("Invalid workspace mode");
2282
2894
  const local = config.workspace?.mode === "local";
2283
- const workspaceDir = resolve3(config.workspace?.path || resolve3(dataDir, "workspace"));
2895
+ const workspaceDir = resolve6(config.workspace?.path || resolve6(dataDir, "workspace"));
2284
2896
  const records = /* @__PURE__ */ new Map();
2285
2897
  const runtimeVault = () => [...records.values()].find((r) => usable(r) && r.runtime?.vault)?.runtime?.vault || null;
2286
2898
  const runtimeEngine = () => [...records.values()].find((r) => usable(r) && r.runtime?.engine)?.runtime?.engine;
2287
2899
  const localWorkspace = local ? await createLocalWorkspace(dataDir, workspaceDir, () => runtimeVault()?.root() || null) : null;
2288
2900
  const prefix = config.basePath.replace(/\/$/, "") || "/";
2289
2901
  const protectedPaths = ["unit", "vault", "engine"].map((name) => (prefix === "/" ? "" : prefix) + "/" + name);
2902
+ protectedPaths.push(MANAGEMENT_PATH);
2290
2903
  for (const entry of config.plugins) {
2291
2904
  const installation = typeof entry === "string" ? { path: entry } : { ...entry };
2292
2905
  const pack = await readPackage(installation.path, config.version);
@@ -2376,11 +2989,12 @@ async function startBasicUnit(config) {
2376
2989
  id,
2377
2990
  entry: rec.package.backendEntry,
2378
2991
  packageDir: rec.package.root,
2379
- dataDir: resolve3(dataDir, "plugins", id),
2992
+ dataDir: resolve6(dataDir, "plugins", id),
2380
2993
  config: rec.installation.config || {},
2381
2994
  env: {
2382
2995
  ...rec.installation.config?.env || {},
2383
2996
  ...rec.installation.env,
2997
+ UNIT_DATA_DIR: dataDir,
2384
2998
  PORT: String(web.port),
2385
2999
  UNIT_HTTP_ORIGIN: `http://${localHost}:${web.port}`
2386
3000
  },
@@ -2406,16 +3020,16 @@ async function startBasicUnit(config) {
2406
3020
  let backend, runtime;
2407
3021
  try {
2408
3022
  if (local && rec.package.runtimeEntry) {
2409
- const module = await import(pathToFileURL(rec.package.runtimeEntry).href + "?generation=" + randomUUID5());
3023
+ const module = await import(pathToFileURL(rec.package.runtimeEntry).href + "?generation=" + randomUUID9());
2410
3024
  const factory = module.createRuntime || module.default;
2411
3025
  if (typeof factory !== "function") throw new Error("Local runtime has no factory");
2412
- await mkdir3(resolve3(dataDir, "plugins", id), { recursive: true, mode: 448 });
2413
- runtime = await factory({ packageDir: rec.package.root, dataDir: resolve3(dataDir, "plugins", id), workspaceDir, config: rec.installation.config || {}, log: (m) => console.log(`[plugin:${id}] ${m}`) });
3026
+ await mkdir8(resolve6(dataDir, "plugins", id), { recursive: true, mode: 448 });
3027
+ runtime = await factory({ packageDir: rec.package.root, dataDir: resolve6(dataDir, "plugins", id), workspaceDir, config: rec.installation.config || {}, log: (m) => console.log(`[plugin:${id}] ${m}`) });
2414
3028
  if (!runtime || typeof runtime.close !== "function") throw new Error("Invalid local runtime");
2415
3029
  if (runtime.vault && runtimeVault() || runtime.engine && runtimeEngine()) throw new Error("Local capability already has a provider");
2416
3030
  }
2417
3031
  if (rec.package.backendEntry) {
2418
- await mkdir3(resolve3(dataDir, "plugins", id), { recursive: true, mode: 448 });
3032
+ await mkdir8(resolve6(dataDir, "plugins", id), { recursive: true, mode: 448 });
2419
3033
  backend = await startBackend(options(id, rec));
2420
3034
  if (backend.account && [...records.values()].some((r) => r !== rec && usable(r) && r.backend?.account)) {
2421
3035
  throw new Error("A Unit can activate only one account authority");
@@ -2498,11 +3112,30 @@ async function startBasicUnit(config) {
2498
3112
  return !!rec && usable(rec);
2499
3113
  }
2500
3114
  });
3115
+ const management = await createManagement({
3116
+ enabled: config.management?.enabled === true,
3117
+ dataDir,
3118
+ version: config.version,
3119
+ provider: accountProvider,
3120
+ execute: managementOptions.execute,
3121
+ status: () => ({
3122
+ instanceId: config.instanceId,
3123
+ version: config.version,
3124
+ pid: process.pid,
3125
+ plugins: [...records.values()].map((r) => ({
3126
+ id: r.package.manifest.id,
3127
+ version: r.package.manifest.version,
3128
+ state: r.state,
3129
+ requires: r.package.manifest.requires || []
3130
+ }))
3131
+ })
3132
+ });
2501
3133
  const web = await startUnitWeb({
2502
3134
  account: local ? void 0 : { metadata: () => accountProvider()?.account.metadata, handle: accountHttp },
2503
3135
  meta: { instanceId: config.instanceId, name: config.name, version: config.version },
2504
3136
  projection: { mode: local ? "local" : "public", basePath: config.basePath, product, capabilities, localCapabilities: () => ({ vault: !!runtimeVault(), engine: !!runtimeEngine()?.endpoint().url, host: !!runtimeEngine()?.endpoint().url }) },
2505
3137
  routeRequest: (req, res) => {
3138
+ if (management.route(req, res)) return true;
2506
3139
  const path = pathname(req.url), backend = select(path);
2507
3140
  if (backend) {
2508
3141
  backend.handle(req, res);
@@ -2566,6 +3199,7 @@ async function startBasicUnit(config) {
2566
3199
  }
2567
3200
  return {
2568
3201
  ...web,
3202
+ closeManagement: management.close,
2569
3203
  status: () => [...records.values()].map((r) => ({ id: r.package.manifest.id, version: r.package.manifest.version, state: r.state })),
2570
3204
  enable: (id) => serialize(() => activate(id)),
2571
3205
  disable: (id) => serialize(() => deactivate(id)),
@@ -2602,237 +3236,36 @@ async function startBasicUnit(config) {
2602
3236
  if (!rec.package.backendEntry) throw new Error("Plugin has no backend migration");
2603
3237
  await migrateBackend(options(id, rec));
2604
3238
  }),
2605
- close: () => serialize(async () => {
2606
- if (closing) return;
2607
- closing = true;
2608
- await web.close();
2609
- const errors = [];
2610
- for (const id of [...ordered].reverse()) {
2611
- try {
2612
- await deactivate(id, true);
2613
- } catch (error) {
2614
- errors.push(error);
3239
+ close: async () => {
3240
+ await management.close();
3241
+ return serialize(async () => {
3242
+ if (closing) return;
3243
+ closing = true;
3244
+ await web.close();
3245
+ const errors = [];
3246
+ for (const id of [...ordered].reverse()) {
3247
+ try {
3248
+ await deactivate(id, true);
3249
+ } catch (error) {
3250
+ errors.push(error);
3251
+ }
2615
3252
  }
2616
- }
2617
- if (errors.length) throw new AggregateError(errors, "Some plugins failed to stop cleanly");
2618
- })
3253
+ if (errors.length) throw new AggregateError(errors, "Some plugins failed to stop cleanly");
3254
+ });
3255
+ }
2619
3256
  };
2620
3257
  }
2621
3258
 
2622
- // unit/config.ts
2623
- import { readFile as readFile6, writeFile as writeFile3, rename as rename3, mkdir as mkdir4 } from "node:fs/promises";
2624
- import { dirname, resolve as resolve4 } from "node:path";
2625
- import { randomUUID as randomUUID6 } from "node:crypto";
2626
- async function readConfig(file2) {
2627
- const path = resolve4(file2), root = dirname(path);
2628
- const config = JSON.parse(await readFile6(path, "utf8"));
2629
- if (!Array.isArray(config.plugins)) throw new Error("unit.json plugins must be an array");
2630
- config.webDist = resolve4(root, config.webDist);
2631
- config.dataDir = resolve4(root, config.dataDir || "data");
2632
- if (config.workspace?.path) config.workspace.path = resolve4(root, config.workspace.path);
2633
- if (config.workerFile) config.workerFile = resolve4(root, config.workerFile);
2634
- config.plugins = config.plugins.map((p) => typeof p === "string" ? { path: resolve4(root, p) } : { ...p, path: resolve4(root, p.path) });
2635
- return config;
2636
- }
2637
- async function writeConfig(file2, config) {
2638
- await mkdir4(dirname(resolve4(file2)), { recursive: true, mode: 448 });
2639
- const temp = `${file2}.${randomUUID6()}.tmp`;
2640
- await writeFile3(temp, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
2641
- await rename3(temp, file2);
2642
- }
2643
- var installations = (config) => config.plugins;
2644
-
2645
- // unit/install.ts
2646
- import { cp, mkdir as mkdir6, readdir as readdir3, realpath as realpath4, rm as rm3, lstat, open, readlink, symlink } from "node:fs/promises";
2647
- import { dirname as dirname3, resolve as resolve6, sep as sep4, relative, isAbsolute as isAbsolute3 } from "node:path";
2648
- import { randomUUID as randomUUID7 } from "node:crypto";
2649
- import { spawn } from "node:child_process";
2650
-
2651
- // unit/control.ts
2652
- import net from "node:net";
2653
- import { chmod, rm as rm2, mkdir as mkdir5 } from "node:fs/promises";
2654
- import { resolve as resolve5, dirname as dirname2 } from "node:path";
2655
- import { tmpdir } from "node:os";
2656
- import { createHash as createHash4 } from "node:crypto";
2657
- var controlAddress = (dataDir) => process.platform === "win32" ? `\\\\.\\pipe\\forsion-unit-${createHash4("sha256").update(resolve5(dataDir)).digest("hex").slice(0, 24)}` : resolve5(tmpdir(), `forsion-unit-${process.getuid?.() ?? "user"}`, createHash4("sha256").update(resolve5(dataDir)).digest("hex").slice(0, 24) + ".sock");
2658
- async function sendControl(dataDir, command) {
2659
- return new Promise((resolveReply, reject) => {
2660
- const socket = net.connect(controlAddress(dataDir));
2661
- let body = "";
2662
- socket.setTimeout(24e4, () => socket.destroy(new Error("Unit control timed out")));
2663
- socket.once("connect", () => socket.write(JSON.stringify(command) + "\n"));
2664
- socket.on("data", (data) => {
2665
- body += data;
2666
- if (body.length > 1e6) {
2667
- socket.destroy(new Error("Oversized control response"));
2668
- return;
2669
- }
2670
- if (!body.includes("\n")) return;
2671
- try {
2672
- const reply = JSON.parse(body.split("\n")[0]);
2673
- socket.end();
2674
- if (reply.ok) resolveReply(reply.result);
2675
- else reject(new Error(reply.error || "Unit command failed"));
2676
- } catch (error) {
2677
- socket.destroy();
2678
- reject(error);
2679
- }
2680
- });
2681
- socket.once("error", reject);
2682
- socket.once("end", () => {
2683
- if (!body.includes("\n")) reject(new Error("Unit control connection closed"));
2684
- });
2685
- });
2686
- }
2687
- var unitIsOffline = (error) => ["ENOENT", "ECONNREFUSED"].includes(error?.code || "");
2688
- async function startControl(dataDir, dispatch) {
2689
- await mkdir5(dataDir, { recursive: true, mode: 448 });
2690
- try {
2691
- await sendControl(dataDir, { action: "status" });
2692
- throw new Error("This Unit is already running");
2693
- } catch (error) {
2694
- if (!unitIsOffline(error)) throw error;
2695
- }
2696
- const address = controlAddress(dataDir);
2697
- if (process.platform !== "win32") {
2698
- await mkdir5(dirname2(address), { recursive: true, mode: 448 });
2699
- await chmod(dirname2(address), 448);
2700
- }
2701
- if (process.platform !== "win32") await rm2(address, { force: true });
2702
- const clients = /* @__PURE__ */ new Set();
2703
- const server = net.createServer((socket) => {
2704
- clients.add(socket);
2705
- socket.once("close", () => clients.delete(socket));
2706
- let body = "", dispatched = false;
2707
- socket.setTimeout(24e4, () => socket.destroy());
2708
- socket.on("data", (data) => {
2709
- if (dispatched) return;
2710
- body += data;
2711
- if (body.length > 1e6) {
2712
- socket.destroy();
2713
- return;
2714
- }
2715
- if (!body.includes("\n")) return;
2716
- dispatched = true;
2717
- void (async () => {
2718
- try {
2719
- socket.end(JSON.stringify({ ok: true, result: await dispatch(JSON.parse(body.split("\n")[0])) }) + "\n");
2720
- } catch (error) {
2721
- socket.end(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : "Unit command failed" }) + "\n");
2722
- }
2723
- })();
2724
- });
2725
- socket.on("error", () => {
2726
- });
2727
- });
2728
- await new Promise((ready, reject) => {
2729
- server.once("error", reject);
2730
- server.listen(address, ready);
2731
- });
2732
- if (process.platform !== "win32") await chmod(address, 384);
2733
- return { close: async () => {
2734
- await new Promise((done) => {
2735
- server.close(() => done());
2736
- for (const socket of clients) socket.destroy();
2737
- });
2738
- if (process.platform !== "win32") await rm2(address, { force: true });
2739
- } };
2740
- }
2741
-
2742
- // unit/install.ts
2743
- async function inspectTree(dir, root, seen = /* @__PURE__ */ new Set()) {
2744
- const actual = await realpath4(dir);
2745
- if (seen.has(actual)) return;
2746
- seen.add(actual);
2747
- for (const entry of await readdir3(dir, { withFileTypes: true })) {
2748
- const path = resolve6(dir, entry.name);
2749
- if (entry.name === ".env" || entry.name.startsWith(".env.") && !entry.name.endsWith(".example")) throw new Error("Package contains private environment files");
2750
- if (entry.isSymbolicLink()) {
2751
- const target = await realpath4(path);
2752
- if (!target.startsWith(root + sep4)) throw new Error("Package symlink escapes its directory");
2753
- if ((await lstat(target)).isDirectory()) await inspectTree(target, root, seen);
2754
- } else if (entry.isDirectory()) await inspectTree(path, root, seen);
2755
- }
2756
- }
2757
- async function relocateLinks(dir, source, destination) {
2758
- for (const entry of await readdir3(dir, { withFileTypes: true })) {
2759
- const path = resolve6(dir, entry.name);
2760
- if (entry.isSymbolicLink()) {
2761
- const link = await readlink(path);
2762
- if (isAbsolute3(link)) {
2763
- const relocated = resolve6(destination, relative(source, await realpath4(link)));
2764
- await rm3(path);
2765
- await symlink(relative(dirname3(path), relocated), path);
2766
- }
2767
- } else if (entry.isDirectory()) await relocateLinks(path, source, destination);
2768
- }
2769
- }
2770
- async function installDependencies(root, backendMain) {
2771
- const backend = resolve6(root, backendMain.split("/")[0]);
2772
- await packagePath(root, backendMain.split("/")[0] + "/package-lock.json");
2773
- await new Promise((done, reject) => {
2774
- const win = process.platform === "win32";
2775
- const child = spawn(win ? "cmd.exe" : "npm", win ? ["/c", "npm", "ci", "--omit=dev"] : ["ci", "--omit=dev"], { cwd: backend, stdio: "inherit", env: { ...process.env, PATH: dirname3(process.execPath) + (win ? ";" : ":") + (process.env.PATH || "") } });
2776
- child.once("error", reject);
2777
- child.once("exit", (code) => code === 0 ? done() : reject(new Error(`Plugin dependency installation failed (${code})`)));
2778
- });
2779
- }
2780
- async function installPackage(configPath, source) {
2781
- const file2 = resolve6(configPath), root = dirname3(file2);
2782
- const lockPath = resolve6(root, ".install.lock");
2783
- const lock = await open(lockPath, "wx", 384).catch(() => {
2784
- throw new Error("Another install is in progress");
2785
- });
2786
- let stage, committed = false;
2787
- try {
2788
- const config = await readConfig(file2), sourceRoot = await realpath4(source);
2789
- if (!(await lstat(sourceRoot)).isDirectory()) throw new Error("Install expects an unpacked plugin directory");
2790
- const pack = await readPackage(sourceRoot, config.version);
2791
- await inspectTree(sourceRoot, sourceRoot);
2792
- stage = resolve6(root, "plugins", pack.manifest.id, `${pack.manifest.version}-${randomUUID7()}`);
2793
- await mkdir6(dirname3(stage), { recursive: true, mode: 448 });
2794
- await cp(sourceRoot, stage, { recursive: true, verbatimSymlinks: true });
2795
- await relocateLinks(stage, sourceRoot, stage);
2796
- if (pack.manifest.backend?.dependencies?.mode === "npm-ci") await installDependencies(stage, pack.manifest.backend.main);
2797
- const ready = await readPackage(stage, config.version);
2798
- const entries = installations(config);
2799
- const existing = (await Promise.all(entries.map(async (entry) => ({ entry, pack: await readPackage(entry.path) })))).find((p) => p.pack.manifest.id === pack.manifest.id);
2800
- if (existing) {
2801
- try {
2802
- await sendControl(config.dataDir, { action: "update", id: pack.manifest.id, path: stage });
2803
- committed = true;
2804
- return { id: ready.manifest.id, version: ready.manifest.version, path: stage };
2805
- } catch (error) {
2806
- if (!unitIsOffline(error)) {
2807
- committed = true;
2808
- throw error;
2809
- }
2810
- }
2811
- existing.entry.path = stage;
2812
- } else {
2813
- try {
2814
- await sendControl(config.dataDir, { action: "status" });
2815
- throw new Error("Stop the Unit before installing a new plugin; updates can run live");
2816
- } catch (error) {
2817
- if (!unitIsOffline(error)) throw error;
2818
- }
2819
- entries.push({ path: stage, enabled: true });
2820
- }
2821
- if (!config.defaultSpace && ready.spaceIds.size) config.defaultSpace = [...ready.spaceIds][0];
2822
- await writeConfig(file2, config);
2823
- committed = true;
2824
- return { id: ready.manifest.id, version: ready.manifest.version, path: stage };
2825
- } finally {
2826
- await lock.close();
2827
- await rm3(lockPath, { force: true });
2828
- if (!committed && stage) await rm3(stage, { recursive: true, force: true });
2829
- }
2830
- }
3259
+ // unit/main.ts
3260
+ init_config();
3261
+ init_packages();
3262
+ init_install();
3263
+ init_control();
2831
3264
 
2832
3265
  // desktop/package.json
2833
3266
  var package_default = {
2834
3267
  name: "forsion-desktop",
2835
- version: "2.12.2",
3268
+ version: "2.13.1",
2836
3269
  description: "Forsion Desktop \u2014 AI \u9A71\u52A8\u7684\u5E94\u7528\u751F\u6001\u7CFB\u7EDF\u7684\u684C\u9762\u7AEF(Electron):Tangu Agent \u5F15\u64CE + LCL \u53EF\u505C\u9760\u5DE5\u4F5C\u533A\u6846\u67B6 + \u53EF\u63D2\u62D4\u529F\u80FD\u7A7A\u95F4(\u5BF9\u8BDD/\u7B14\u8BB0/\u65E5\u5386/\u6536\u4EF6\u7BB1/\u7F16\u7801),\u6309\u4EA7\u54C1\u6863\u6848\u88C1\u51FA\u5168\u5BB6\u6876\u4E0E\u5355\u54C1\u53D1\u884C\u7248",
2837
3270
  author: "Forsion",
2838
3271
  private: true,
@@ -2859,6 +3292,9 @@ var package_default = {
2859
3292
  "e2e:dnd": "node scripts/block-dnd.e2e.cjs",
2860
3293
  "e2e:sidebardrop": "node scripts/sidebar-drop.e2e.cjs",
2861
3294
  "e2e:ribbon": "node scripts/ribbon-dnd.e2e.cjs",
3295
+ "e2e:spacewindow": "node scripts/space-window.e2e.cjs",
3296
+ "e2e:spacewindow-input": "node scripts/space-window-input.e2e.cjs",
3297
+ "e2e:ribbon-overflow": "node scripts/ribbon-overflow.e2e.cjs",
2862
3298
  "e2e:mindmap": "node scripts/mindmap.e2e.cjs",
2863
3299
  "e2e:canvas": "node scripts/canvas.e2e.cjs",
2864
3300
  "e2e:daily": "node scripts/daily-template.e2e.cjs",
@@ -2870,6 +3306,7 @@ var package_default = {
2870
3306
  "e2e:recall": "node scripts/recall-rewind.e2e.cjs",
2871
3307
  "e2e:chatevents": "node scripts/chat-events.e2e.cjs",
2872
3308
  "e2e:computerhistory": "node scripts/computer-history.e2e.cjs",
3309
+ "e2e:appdock": "node scripts/app-dock.e2e.cjs",
2873
3310
  "e2e:remotesafety": "node scripts/remote-safety.e2e.cjs",
2874
3311
  "e2e:chatshimmer": "node scripts/chat-shimmer.e2e.cjs",
2875
3312
  "e2e:runstats": "node scripts/chat-runstats.e2e.cjs",
@@ -2881,6 +3318,7 @@ var package_default = {
2881
3318
  "e2e:coreplugins": "node scripts/core-plugins.e2e.cjs",
2882
3319
  "e2e:restart-update": "node scripts/restart-update.e2e.cjs",
2883
3320
  "e2e:market-submissions": "node scripts/market-submissions.e2e.cjs",
3321
+ "e2e:storepages": "node scripts/store-pages.e2e.cjs",
2884
3322
  "e2e:market-auto-update": "node scripts/market-auto-update.e2e.cjs",
2885
3323
  "e2e:account": "node scripts/account-center.shot.cjs",
2886
3324
  "e2e:accountpopups": "node scripts/account-popups.shot.cjs",
@@ -2893,6 +3331,7 @@ var package_default = {
2893
3331
  "live:harness": "cd ../tangu-agent && npm run live:harness",
2894
3332
  "probe:gzipupload": "node scripts/gzip-upload.probe.cjs",
2895
3333
  "e2e:realtimevoice": "node scripts/realtime-voice.e2e.cjs",
3334
+ "e2e:voicestudio": "node scripts/voice-studio.e2e.cjs",
2896
3335
  "e2e:dashdrag": "node scripts/dash3-drag.e2e.cjs",
2897
3336
  "e2e:pathgone": "node scripts/vault-pathgone.e2e.cjs",
2898
3337
  "check:pillbar": "node scripts/pillbar-overflow.check.cjs",
@@ -2907,6 +3346,7 @@ var package_default = {
2907
3346
  "check:caret": "node scripts/smooth-caret.check.cjs",
2908
3347
  "check:spacerestore": "node scripts/space-restore.check.cjs",
2909
3348
  "check:spacerestart": "node scripts/space-restart.check.cjs",
3349
+ "check:spacefallback": "node scripts/space-fallback-persist.check.cjs",
2910
3350
  "check:bluebirdspace": "node scripts/bluebird-space.check.cjs",
2911
3351
  "check:bluebirdtimestamps": "node scripts/bluebird-space.check.cjs --timestamps",
2912
3352
  "check:splitregions": "node scripts/split-regions.check.cjs",
@@ -2971,6 +3411,7 @@ var package_default = {
2971
3411
  "check:hlband": "node scripts/pdf-highlight-geometry.check.cjs",
2972
3412
  "e2e:filecite": "node scripts/file-citation.e2e.cjs",
2973
3413
  "e2e:pathgonefiles": "node scripts/vault-pathgone-files.e2e.cjs",
3414
+ "e2e:ftoverride": "node scripts/filetype-override.e2e.cjs",
2974
3415
  "e2e:pathgoneunmounted": "node scripts/vault-pathgone-unmounted.e2e.cjs",
2975
3416
  "check:textfrag": "electron scripts/text-fragment.check.cjs",
2976
3417
  "check:assetfetch": "electron scripts/asset-fetch.check.cjs",
@@ -2991,6 +3432,8 @@ var package_default = {
2991
3432
  "check:settingsmode": "node scripts/settings-mode.check.cjs",
2992
3433
  "check:themecontrast": "node scripts/theme-contrast.check.cjs",
2993
3434
  "check:themevisual": "node scripts/theme-visual.check.cjs",
3435
+ "check:spaceappearance": "node scripts/space-appearance.e2e.cjs",
3436
+ "check:fps": "node scripts/check-fps.cjs",
2994
3437
  "check:themeapp": "node scripts/theme-app.check.cjs",
2995
3438
  "check:marketui": "node scripts/market-ui.check.cjs",
2996
3439
  "probe:marketdl": "node scripts/market-download.probe.cjs",
@@ -3001,6 +3444,7 @@ var package_default = {
3001
3444
  "check:floatingpanel": "node scripts/floating-panel.check.cjs",
3002
3445
  "check:quietfocus": "node scripts/quiet-focus.check.cjs",
3003
3446
  "check:persistignore": "node scripts/persist-ignore.check.cjs",
3447
+ "check:spaceicon": "node scripts/space-icon.check.cjs",
3004
3448
  "check:onboarding": "node scripts/plugin-onboarding.check.cjs",
3005
3449
  "check:plugindeps": "node scripts/plugin-deps.check.cjs",
3006
3450
  "check:pluginsync": "node scripts/plugin-install-sync.check.cjs",
@@ -3011,18 +3455,22 @@ var package_default = {
3011
3455
  "check:scrollbar": "node scripts/scrollbar-overlay.check.cjs",
3012
3456
  "check:callout": "node scripts/callout-fold.check.cjs",
3013
3457
  "check:toggle": "node scripts/e2e-editor.cjs --check=toggle-block",
3458
+ "check:togglecaret": "node scripts/e2e-editor.cjs --check=toggle-caret",
3014
3459
  "check:togglecontent": "node scripts/e2e-editor.cjs --check=toggle-content",
3015
3460
  "check:togglemotion": "node scripts/e2e-editor.cjs --check=toggle-motion",
3016
3461
  "check:tabindent": "node scripts/e2e-editor.cjs --check=tab-indent",
3017
3462
  "check:srcbtn": "node scripts/source-toggle.check.cjs",
3018
3463
  "check:bsurface": "node scripts/e2e-editor.cjs --check=plugin-blocksurface",
3019
3464
  "build:unitweb": "node scripts/build-unit-web.mjs",
3465
+ "check:unitweb": "node ../web/scripts/docker-context-build.mjs --unit-web",
3020
3466
  pack: "npm run build:unitweb && electron-builder --config electron-builder.config.cjs --dir",
3021
3467
  dist: "npm run build:unitweb && electron-builder --config electron-builder.config.cjs",
3022
3468
  "fetch-python": "node build/fetch-python.cjs",
3023
3469
  "fetch-node": "node build/fetch-node.cjs",
3024
3470
  "fetch-office": "node build/fetch-office.cjs",
3025
3471
  "fetch-git": "node build/fetch-git.cjs",
3472
+ "check:documentagents": "node scripts/e2e-editor.cjs --check=document-agents",
3473
+ "e2e:documentagents": "node scripts/document-agents.electron.cjs",
3026
3474
  typecheck: "tsc --noEmit && node ../tangu-agent/scripts/sync-command-catalog.mjs --check",
3027
3475
  "predev:amadeus": "node scripts/dev-version.check.cjs",
3028
3476
  "dev:amadeus": "FORSION_PRODUCT=amadeus electron-vite dev --noSandbox",
@@ -3042,7 +3490,9 @@ var package_default = {
3042
3490
  "check:hooks": 'eslint --config build/eslint.hooks.mjs "frontend/src/**/*.{ts,tsx}"',
3043
3491
  "check:deskshot": "node scripts/desk-shot.check.cjs",
3044
3492
  "check:splash": "node scripts/splash-loop.check.cjs",
3493
+ "probe:startup-windows": "node scripts/startup-windows.probe.cjs",
3045
3494
  "check:parity": "node scripts/platform-parity.check.cjs",
3495
+ "check:vitecache": "node scripts/vite-cachedir.check.cjs",
3046
3496
  "check:enginetarget": "node scripts/engine-target.check.cjs",
3047
3497
  "check:remotechain": "node scripts/remote-chain.e2e.cjs",
3048
3498
  "check:streamrenew": "node scripts/stream-renew.check.cjs",
@@ -3063,6 +3513,7 @@ var package_default = {
3063
3513
  "check:focusring": "node scripts/focus-ring.check.cjs",
3064
3514
  "check:layoutreset": "node scripts/layout-reset.check.cjs",
3065
3515
  "check:statusbar": "node scripts/status-bar.check.cjs",
3516
+ "check:calm": "node scripts/calm.check.cjs",
3066
3517
  "check:dropfeedback": "node scripts/drop-feedback.check.cjs",
3067
3518
  "check:chatside": "node scripts/chat-sidepanel.check.cjs",
3068
3519
  "check:facade": "node scripts/facade-scope.check.cjs",
@@ -3139,8 +3590,8 @@ var package_default = {
3139
3590
  "@dnd-kit/sortable": "^10.0.0",
3140
3591
  "@dnd-kit/utilities": "^3.2.2",
3141
3592
  "@excalidraw/excalidraw": "npm:@zsviczian/excalidraw@0.18.122",
3142
- "@forsion/extend": "0.7.1",
3143
- "@forsion/tangu-computer-use": "0.6.1",
3593
+ "@forsion/extend": "0.8.1",
3594
+ "@forsion/tangu-computer-use": "0.6.2",
3144
3595
  "@milkdown/kit": "^7.21.2",
3145
3596
  "@milkdown/plugin-math": "^7.5.9",
3146
3597
  "@milkdown/react": "^7.21.2",
@@ -3222,11 +3673,13 @@ var package_default = {
3222
3673
  };
3223
3674
 
3224
3675
  // unit/main.ts
3676
+ init_releases();
3225
3677
  var dist = dirname4(fileURLToPath2(import.meta.url));
3226
3678
  var args = process.argv.slice(2);
3227
- var commands = ["init", "install", "run", "migrate", "enable", "disable", "restart", "status", "access"];
3679
+ var commands = ["init", "install", "stage", "update", "run", "migrate", "enable", "disable", "restart", "status", "access"];
3228
3680
  var action = commands.includes(args[0]) ? args.shift() : "run";
3229
- var file = resolve7(args.shift() || (action === "init" ? "forsion-unit" : "unit.json"));
3681
+ var configArgument = args.shift();
3682
+ var file = resolve7(configArgument || (action === "init" ? "forsion-unit" : "unit.json"));
3230
3683
  var print = (result) => console.log(JSON.stringify(result, null, 2));
3231
3684
  async function findInstallation(config, id) {
3232
3685
  if (!id) throw new Error("A plugin id is required");
@@ -3234,6 +3687,15 @@ async function findInstallation(config, id) {
3234
3687
  throw new Error(`Plugin is not installed: ${id}`);
3235
3688
  }
3236
3689
  async function run() {
3690
+ if (action === "update") {
3691
+ if (!configArgument?.trim() || args.length !== 2 || args.some((arg) => !arg.trim())) {
3692
+ throw new Error("Usage: node main.mjs update <unit.json> <plugin-id> <package-path>");
3693
+ }
3694
+ if (!validId(args[0])) throw new Error("Invalid plugin id");
3695
+ }
3696
+ if (action === "stage" && (!configArgument?.trim() || args.length !== 1 || !args[0]?.trim())) {
3697
+ throw new Error("Usage: node main.mjs stage <unit.json> <bundled-package-path>");
3698
+ }
3237
3699
  if (action === "init") {
3238
3700
  const option = (key, fallback) => {
3239
3701
  const i = args.indexOf(key);
@@ -3243,10 +3705,10 @@ async function run() {
3243
3705
  if (!["public", "local"].includes(mode)) throw new Error("Mode must be public or local");
3244
3706
  const port = Number(option("--port", "3001")), basePath = option("--base", mode === "local" ? "/" : "/admin/");
3245
3707
  if (!Number.isInteger(port) || port < 0 || port > 65535 || !basePath?.startsWith("/")) throw new Error("Invalid port or projection base path");
3246
- await mkdir7(file, { recursive: true, mode: 448 });
3708
+ await mkdir9(file, { recursive: true, mode: 448 });
3247
3709
  const path = resolve7(file, "unit.json");
3248
3710
  const config2 = {
3249
- instanceId: randomUUID8(),
3711
+ instanceId: randomUUID10(),
3250
3712
  name: "Forsion Unit",
3251
3713
  version: package_default.version,
3252
3714
  port,
@@ -3274,6 +3736,15 @@ async function run() {
3274
3736
  }
3275
3737
  const config = await readConfig(file);
3276
3738
  config.workerFile ||= resolve7(dist, "backendWorker.mjs");
3739
+ if (action === "stage") {
3740
+ print(await stageRelease(config.dataDir, resolve7(args[0]), config.version));
3741
+ return;
3742
+ }
3743
+ if (action === "update") {
3744
+ await findInstallation(config, args[0]);
3745
+ print(await installPackage(file, resolve7(args[1]), { updateId: args[0] }));
3746
+ return;
3747
+ }
3277
3748
  if (action === "access") {
3278
3749
  if (config.workspace?.mode !== "local") throw new Error("Public sites use their Account provider");
3279
3750
  const token = await localOwnerToken(config.dataDir);
@@ -3302,7 +3773,7 @@ async function run() {
3302
3773
  const pack = await readPackage(entry.path, config.version);
3303
3774
  if (!pack.backendEntry) throw new Error("Plugin has no backend migration");
3304
3775
  const dataDir = resolve7(config.dataDir, "plugins", pack.manifest.id);
3305
- await mkdir7(dataDir, { recursive: true, mode: 448 });
3776
+ await mkdir9(dataDir, { recursive: true, mode: 448 });
3306
3777
  const migration = new AbortController();
3307
3778
  const cancelMigration = () => migration.abort(new Error("Migration interrupted"));
3308
3779
  for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, cancelMigration);
@@ -3325,14 +3796,14 @@ async function run() {
3325
3796
  return;
3326
3797
  }
3327
3798
  if (process.env.UNIT_PORT) config.port = Number(process.env.UNIT_PORT);
3328
- await mkdir7(config.dataDir, { recursive: true, mode: 448 });
3799
+ await mkdir9(config.dataDir, { recursive: true, mode: 448 });
3329
3800
  const lockPath = resolve7(config.dataDir, "run.lock");
3330
3801
  let lock;
3331
3802
  try {
3332
3803
  lock = await open2(lockPath, "wx", 384);
3333
3804
  } catch (error) {
3334
3805
  if (error.code !== "EEXIST") throw error;
3335
- const pid = Number(await readFile7(lockPath, "utf8"));
3806
+ const pid = Number(await readFile9(lockPath, "utf8"));
3336
3807
  if (!Number.isInteger(pid) || pid <= 0) throw new Error("Unit startup is locked; inspect data/run.lock");
3337
3808
  try {
3338
3809
  process.kill(pid, 0);
@@ -3340,13 +3811,14 @@ async function run() {
3340
3811
  } catch (failure) {
3341
3812
  if (failure.code !== "ESRCH") throw failure;
3342
3813
  }
3343
- await rm4(lockPath);
3814
+ await rm5(lockPath);
3344
3815
  lock = await open2(lockPath, "wx", 384);
3345
3816
  }
3346
3817
  await lock.writeFile(String(process.pid));
3347
3818
  await lock.close();
3348
3819
  let unit;
3349
3820
  let control;
3821
+ let executeManagement;
3350
3822
  let shutdownRequested = false, requestStop;
3351
3823
  const onSignal = () => {
3352
3824
  shutdownRequested = true;
@@ -3354,11 +3826,19 @@ async function run() {
3354
3826
  };
3355
3827
  for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, onSignal);
3356
3828
  try {
3357
- unit = await startBasicUnit(config);
3829
+ unit = await startBasicUnit(config, { execute: async (command, authorize) => {
3830
+ if (!executeManagement) throw new Error("Unit control is not ready");
3831
+ return executeManagement(command, authorize);
3832
+ } });
3358
3833
  let queue = Promise.resolve();
3359
3834
  const status = () => ({ running: true, pid: process.pid, instanceId: config.instanceId, port: unit.port, plugins: unit.status() });
3360
3835
  const dispatch = async (command) => {
3361
3836
  if (command.action === "status") return status();
3837
+ if (command.action === "stage") {
3838
+ if (command.remote && config.management?.enabled !== true) throw new Error("Remote Unit management is disabled");
3839
+ if (!command.path) throw new Error("A prepared package is required");
3840
+ return stageRelease(config.dataDir, command.path, config.version);
3841
+ }
3362
3842
  if (!["enable", "disable", "restart", "update", "migrate"].includes(command.action)) throw new Error("Unknown Unit command");
3363
3843
  const entry = await findInstallation(config, command.id);
3364
3844
  const previous = { ...entry };
@@ -3386,13 +3866,26 @@ async function run() {
3386
3866
  }
3387
3867
  return status();
3388
3868
  };
3389
- control = await startControl(config.dataDir, (command) => {
3390
- const next = queue.then(() => dispatch(command));
3869
+ const enqueue = (run2) => {
3870
+ const next = queue.then(run2);
3391
3871
  queue = next.then(() => {
3392
3872
  }, () => {
3393
3873
  });
3394
3874
  return next;
3395
- });
3875
+ };
3876
+ executeManagement = async (command, authorize) => {
3877
+ const apply = (action2) => enqueue(async () => {
3878
+ await authorize();
3879
+ return dispatch(action2);
3880
+ });
3881
+ if (command.action === "update") return installPackage(file, command.path, {
3882
+ updateId: command.id,
3883
+ releaseDigest: command.releaseDigest,
3884
+ applyUpdate: (id, path) => apply({ action: "update", id, path })
3885
+ });
3886
+ return apply({ action: command.action, id: command.id });
3887
+ };
3888
+ control = await startControl(config.dataDir, (command) => enqueue(() => dispatch(command)));
3396
3889
  print(status());
3397
3890
  console.log(`[unit] ${config.name} http://${config.bindHost || "127.0.0.1"}:${unit.port}${config.basePath}`);
3398
3891
  let stopping = false;
@@ -3401,6 +3894,7 @@ async function run() {
3401
3894
  stopping = true;
3402
3895
  let failure = false;
3403
3896
  try {
3897
+ await unit.closeManagement();
3404
3898
  await control.close();
3405
3899
  await queue;
3406
3900
  await unit.close();
@@ -3408,7 +3902,7 @@ async function run() {
3408
3902
  console.error(error);
3409
3903
  failure = true;
3410
3904
  } finally {
3411
- await rm4(lockPath, { force: true });
3905
+ await rm5(lockPath, { force: true });
3412
3906
  process.exit(failure ? 1 : 0);
3413
3907
  }
3414
3908
  };
@@ -3422,7 +3916,7 @@ async function run() {
3422
3916
  try {
3423
3917
  await unit?.close();
3424
3918
  } finally {
3425
- await rm4(lockPath, { force: true });
3919
+ await rm5(lockPath, { force: true });
3426
3920
  }
3427
3921
  }
3428
3922
  throw error;