@agimon-ai/doompi-web 0.0.1-alpha.0 → 0.0.1-alpha.3

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 (320) hide show
  1. package/README.md +210 -28
  2. package/dist/bin/serve.cjs +1 -1
  3. package/dist/bin/serve.cjs.map +1 -1
  4. package/dist/bin/serve.mjs +1 -1
  5. package/dist/bin/serve.mjs.map +1 -1
  6. package/dist/index.d.cts +2 -2
  7. package/dist/index.d.mts +2 -2
  8. package/dist/src/adapters/cockpitContainer.cjs +2 -0
  9. package/dist/src/adapters/cockpitContainer.cjs.map +1 -0
  10. package/dist/src/adapters/cockpitContainer.mjs +2 -0
  11. package/dist/src/adapters/cockpitContainer.mjs.map +1 -0
  12. package/dist/src/adapters/cockpitHandoff.cjs +2 -0
  13. package/dist/src/adapters/cockpitHandoff.cjs.map +1 -0
  14. package/dist/src/adapters/cockpitHandoff.mjs +2 -0
  15. package/dist/src/adapters/cockpitHandoff.mjs.map +1 -0
  16. package/dist/src/adapters/deviceAuth.cjs +2 -0
  17. package/dist/src/adapters/deviceAuth.cjs.map +1 -0
  18. package/dist/src/adapters/deviceAuth.mjs +2 -0
  19. package/dist/src/adapters/deviceAuth.mjs.map +1 -0
  20. package/dist/src/adapters/directoryListing.cjs +1 -1
  21. package/dist/src/adapters/directoryListing.cjs.map +1 -1
  22. package/dist/src/adapters/directoryListing.mjs +1 -1
  23. package/dist/src/adapters/directoryListing.mjs.map +1 -1
  24. package/dist/src/adapters/httpServer.cjs +1 -1
  25. package/dist/src/adapters/httpServer.cjs.map +1 -1
  26. package/dist/src/adapters/httpServer.d.cts +2 -0
  27. package/dist/src/adapters/httpServer.d.cts.map +1 -1
  28. package/dist/src/adapters/httpServer.d.mts +2 -0
  29. package/dist/src/adapters/httpServer.d.mts.map +1 -1
  30. package/dist/src/adapters/httpServer.mjs +1 -1
  31. package/dist/src/adapters/httpServer.mjs.map +1 -1
  32. package/dist/src/adapters/packageApiProxy.cjs +1 -1
  33. package/dist/src/adapters/packageApiProxy.cjs.map +1 -1
  34. package/dist/src/adapters/packageApiProxy.mjs +1 -1
  35. package/dist/src/adapters/packageApiProxy.mjs.map +1 -1
  36. package/dist/src/adapters/providerAuth.cjs +1 -1
  37. package/dist/src/adapters/providerAuth.cjs.map +1 -1
  38. package/dist/src/adapters/providerAuth.mjs +1 -1
  39. package/dist/src/adapters/providerAuth.mjs.map +1 -1
  40. package/dist/src/adapters/registryWatcher.d.cts +1 -0
  41. package/dist/src/adapters/registryWatcher.d.mts +1 -0
  42. package/dist/src/adapters/remoteAccess.cjs +2 -0
  43. package/dist/src/adapters/remoteAccess.cjs.map +1 -0
  44. package/dist/src/adapters/remoteAccess.mjs +2 -0
  45. package/dist/src/adapters/remoteAccess.mjs.map +1 -0
  46. package/dist/src/adapters/remoteAccessStore.cjs +2 -0
  47. package/dist/src/adapters/remoteAccessStore.cjs.map +1 -0
  48. package/dist/src/adapters/remoteAccessStore.mjs +2 -0
  49. package/dist/src/adapters/remoteAccessStore.mjs.map +1 -0
  50. package/dist/src/adapters/remoteGuard.cjs +2 -0
  51. package/dist/src/adapters/remoteGuard.cjs.map +1 -0
  52. package/dist/src/adapters/remoteGuard.mjs +2 -0
  53. package/dist/src/adapters/remoteGuard.mjs.map +1 -0
  54. package/dist/src/adapters/remoteRoutes.cjs +2 -0
  55. package/dist/src/adapters/remoteRoutes.cjs.map +1 -0
  56. package/dist/src/adapters/remoteRoutes.mjs +2 -0
  57. package/dist/src/adapters/remoteRoutes.mjs.map +1 -0
  58. package/dist/src/adapters/serverSpawner.cjs +1 -1
  59. package/dist/src/adapters/serverSpawner.cjs.map +1 -1
  60. package/dist/src/adapters/serverSpawner.d.cts +1 -0
  61. package/dist/src/adapters/serverSpawner.d.mts +1 -0
  62. package/dist/src/adapters/serverSpawner.mjs +1 -1
  63. package/dist/src/adapters/serverSpawner.mjs.map +1 -1
  64. package/dist/src/adapters/sessionHub.cjs +1 -1
  65. package/dist/src/adapters/sessionHub.cjs.map +1 -1
  66. package/dist/src/adapters/sessionHub.d.cts +6 -0
  67. package/dist/src/adapters/sessionHub.d.mts +6 -0
  68. package/dist/src/adapters/sessionHub.mjs +1 -1
  69. package/dist/src/adapters/sessionHub.mjs.map +1 -1
  70. package/dist/src/adapters/sessionRelaunch.cjs +2 -0
  71. package/dist/src/adapters/sessionRelaunch.cjs.map +1 -0
  72. package/dist/src/adapters/sessionRelaunch.mjs +2 -0
  73. package/dist/src/adapters/sessionRelaunch.mjs.map +1 -0
  74. package/dist/src/adapters/sessionSocketClient.cjs +1 -1
  75. package/dist/src/adapters/sessionSocketClient.cjs.map +1 -1
  76. package/dist/src/adapters/sessionSocketClient.mjs +1 -1
  77. package/dist/src/adapters/sessionSocketClient.mjs.map +1 -1
  78. package/dist/src/adapters/settingsRoutes.cjs +2 -0
  79. package/dist/src/adapters/settingsRoutes.cjs.map +1 -0
  80. package/dist/src/adapters/settingsRoutes.mjs +2 -0
  81. package/dist/src/adapters/settingsRoutes.mjs.map +1 -0
  82. package/dist/src/adapters/syncGuard.cjs +1 -1
  83. package/dist/src/adapters/syncGuard.cjs.map +1 -1
  84. package/dist/src/adapters/syncGuard.mjs +1 -1
  85. package/dist/src/adapters/syncGuard.mjs.map +1 -1
  86. package/dist/src/adapters/tunnelProcess.cjs +2 -0
  87. package/dist/src/adapters/tunnelProcess.cjs.map +1 -0
  88. package/dist/src/adapters/tunnelProcess.mjs +2 -0
  89. package/dist/src/adapters/tunnelProcess.mjs.map +1 -0
  90. package/dist/src/adapters/webBundler.cjs +1 -1
  91. package/dist/src/adapters/webBundler.cjs.map +1 -1
  92. package/dist/src/adapters/webBundler.d.cts.map +1 -1
  93. package/dist/src/adapters/webBundler.d.mts.map +1 -1
  94. package/dist/src/adapters/webBundler.mjs +1 -1
  95. package/dist/src/adapters/webBundler.mjs.map +1 -1
  96. package/dist/src/adapters/webTelemetry.cjs +2 -0
  97. package/dist/src/adapters/webTelemetry.cjs.map +1 -0
  98. package/dist/src/adapters/webTelemetry.mjs +2 -0
  99. package/dist/src/adapters/webTelemetry.mjs.map +1 -0
  100. package/dist/src/adapters/webauthn.cjs +2 -0
  101. package/dist/src/adapters/webauthn.cjs.map +1 -0
  102. package/dist/src/adapters/webauthn.mjs +2 -0
  103. package/dist/src/adapters/webauthn.mjs.map +1 -0
  104. package/dist/src/services/deviceSessions.cjs +2 -0
  105. package/dist/src/services/deviceSessions.cjs.map +1 -0
  106. package/dist/src/services/deviceSessions.mjs +2 -0
  107. package/dist/src/services/deviceSessions.mjs.map +1 -0
  108. package/dist/src/services/pairingFlow.cjs +2 -0
  109. package/dist/src/services/pairingFlow.cjs.map +1 -0
  110. package/dist/src/services/pairingFlow.mjs +2 -0
  111. package/dist/src/services/pairingFlow.mjs.map +1 -0
  112. package/dist/src/services/pairingPage.cjs +247 -0
  113. package/dist/src/services/pairingPage.cjs.map +1 -0
  114. package/dist/src/services/pairingPage.mjs +247 -0
  115. package/dist/src/services/pairingPage.mjs.map +1 -0
  116. package/dist/src/services/pathScope.cjs +2 -0
  117. package/dist/src/services/pathScope.cjs.map +1 -0
  118. package/dist/src/services/pathScope.mjs +2 -0
  119. package/dist/src/services/pathScope.mjs.map +1 -0
  120. package/dist/src/services/remoteAccessSettings.cjs +2 -0
  121. package/dist/src/services/remoteAccessSettings.cjs.map +1 -0
  122. package/dist/src/services/remoteAccessSettings.mjs +2 -0
  123. package/dist/src/services/remoteAccessSettings.mjs.map +1 -0
  124. package/dist/src/services/remoteGuardPolicy.cjs +2 -0
  125. package/dist/src/services/remoteGuardPolicy.cjs.map +1 -0
  126. package/dist/src/services/remoteGuardPolicy.mjs +2 -0
  127. package/dist/src/services/remoteGuardPolicy.mjs.map +1 -0
  128. package/dist/src/services/serveOptions.cjs +1 -1
  129. package/dist/src/services/serveOptions.cjs.map +1 -1
  130. package/dist/src/services/serveOptions.d.cts +4 -0
  131. package/dist/src/services/serveOptions.d.cts.map +1 -1
  132. package/dist/src/services/serveOptions.d.mts +4 -0
  133. package/dist/src/services/serveOptions.d.mts.map +1 -1
  134. package/dist/src/services/serveOptions.mjs +1 -1
  135. package/dist/src/services/serveOptions.mjs.map +1 -1
  136. package/dist/src/services/sessionMigration.cjs +2 -0
  137. package/dist/src/services/sessionMigration.cjs.map +1 -0
  138. package/dist/src/services/sessionMigration.mjs +2 -0
  139. package/dist/src/services/sessionMigration.mjs.map +1 -0
  140. package/dist/src/services/staticAssets.cjs +1 -1
  141. package/dist/src/services/staticAssets.cjs.map +1 -1
  142. package/dist/src/services/staticAssets.mjs +1 -1
  143. package/dist/src/services/staticAssets.mjs.map +1 -1
  144. package/dist/src/services/tunnelOutput.cjs +2 -0
  145. package/dist/src/services/tunnelOutput.cjs.map +1 -0
  146. package/dist/src/services/tunnelOutput.mjs +2 -0
  147. package/dist/src/services/tunnelOutput.mjs.map +1 -0
  148. package/dist/src/services/webauthnPolicy.cjs +2 -0
  149. package/dist/src/services/webauthnPolicy.cjs.map +1 -0
  150. package/dist/src/services/webauthnPolicy.mjs +2 -0
  151. package/dist/src/services/webauthnPolicy.mjs.map +1 -0
  152. package/dist/src/types/auth.cjs.map +1 -1
  153. package/dist/src/types/auth.d.cts +6 -0
  154. package/dist/src/types/auth.d.cts.map +1 -1
  155. package/dist/src/types/auth.d.mts +6 -0
  156. package/dist/src/types/auth.d.mts.map +1 -1
  157. package/dist/src/types/auth.mjs.map +1 -1
  158. package/dist/src/types/bridge.d.cts +29 -0
  159. package/dist/src/types/bridge.d.cts.map +1 -1
  160. package/dist/src/types/bridge.d.mts +29 -0
  161. package/dist/src/types/bridge.d.mts.map +1 -1
  162. package/dist/src/types/notification.cjs +2 -0
  163. package/dist/src/types/notification.cjs.map +1 -0
  164. package/dist/src/types/notification.mjs +2 -0
  165. package/dist/src/types/notification.mjs.map +1 -0
  166. package/dist/src/types/remoteAccess.cjs +2 -0
  167. package/dist/src/types/remoteAccess.cjs.map +1 -0
  168. package/dist/src/types/remoteAccess.d.cts +88 -0
  169. package/dist/src/types/remoteAccess.d.cts.map +1 -0
  170. package/dist/src/types/remoteAccess.d.mts +88 -0
  171. package/dist/src/types/remoteAccess.d.mts.map +1 -0
  172. package/dist/src/types/remoteAccess.mjs +2 -0
  173. package/dist/src/types/remoteAccess.mjs.map +1 -0
  174. package/dist/src/types/settings.cjs +2 -0
  175. package/dist/src/types/settings.cjs.map +1 -0
  176. package/dist/src/types/settings.d.cts +1 -0
  177. package/dist/src/types/settings.d.mts +1 -0
  178. package/dist/src/types/settings.mjs +2 -0
  179. package/dist/src/types/settings.mjs.map +1 -0
  180. package/dist/web/assets/CodeEditorView-CP55qjoR.js +15 -0
  181. package/dist/web/assets/CodeEditorView-CP55qjoR.js.map +1 -0
  182. package/dist/web/assets/dist-B0inTUnL.js +2 -0
  183. package/dist/web/assets/dist-B0inTUnL.js.map +1 -0
  184. package/dist/web/assets/dist-Bqd73gXk.js +24 -0
  185. package/dist/web/assets/dist-Bqd73gXk.js.map +1 -0
  186. package/dist/web/assets/dist-C7QLpOTK.js +10 -0
  187. package/dist/web/assets/dist-C7QLpOTK.js.map +1 -0
  188. package/dist/web/assets/dist-CElJaC8s.js +2 -0
  189. package/dist/web/assets/dist-CElJaC8s.js.map +1 -0
  190. package/dist/web/assets/dist-Cwzviw_U.js +7 -0
  191. package/dist/web/assets/dist-Cwzviw_U.js.map +1 -0
  192. package/dist/web/assets/dist-DgVFo7X3.js +2 -0
  193. package/dist/web/assets/dist-DgVFo7X3.js.map +1 -0
  194. package/dist/web/assets/dist-GONoz8to.js +2 -0
  195. package/dist/web/assets/dist-GONoz8to.js.map +1 -0
  196. package/dist/web/assets/dist-Gdc8iniF.js +2 -0
  197. package/dist/web/assets/dist-Gdc8iniF.js.map +1 -0
  198. package/dist/web/assets/dist-TyTwU387.js +2 -0
  199. package/dist/web/assets/dist-TyTwU387.js.map +1 -0
  200. package/dist/web/assets/dist-eZCftY6T.js +1 -0
  201. package/dist/web/assets/dockerfile-BDePf5PZ.js +2 -0
  202. package/dist/web/assets/dockerfile-BDePf5PZ.js.map +1 -0
  203. package/dist/web/assets/index-BqJnbtrI.css +2 -0
  204. package/dist/web/assets/index-Ck8t7U8J.js +98 -0
  205. package/dist/web/assets/index-Ck8t7U8J.js.map +1 -0
  206. package/dist/web/assets/rolldown-runtime-hePW80VL.js +1 -0
  207. package/dist/web/assets/shell-DwuoZtxw.js +2 -0
  208. package/dist/web/assets/shell-DwuoZtxw.js.map +1 -0
  209. package/dist/web/assets/toml-BPTmHmyx.js +2 -0
  210. package/dist/web/assets/toml-BPTmHmyx.js.map +1 -0
  211. package/dist/web/index.html +4 -3
  212. package/llms.txt +6 -1
  213. package/package.json +17 -11
  214. package/src/adapters/cockpitContainer.ts +209 -0
  215. package/src/adapters/cockpitHandoff.ts +66 -0
  216. package/src/adapters/deviceAuth.ts +171 -0
  217. package/src/adapters/directoryListing.ts +25 -8
  218. package/src/adapters/httpServer.ts +689 -83
  219. package/src/adapters/packageApiProxy.ts +14 -3
  220. package/src/adapters/providerAuth.ts +20 -0
  221. package/src/adapters/remoteAccess.ts +510 -0
  222. package/src/adapters/remoteAccessStore.ts +161 -0
  223. package/src/adapters/remoteGuard.ts +179 -0
  224. package/src/adapters/remoteRoutes.ts +481 -0
  225. package/src/adapters/serverSpawner.ts +67 -6
  226. package/src/adapters/sessionHub.ts +251 -90
  227. package/src/adapters/sessionRelaunch.ts +67 -0
  228. package/src/adapters/sessionSocketClient.ts +3 -1
  229. package/src/adapters/settingsRoutes.ts +383 -0
  230. package/src/adapters/syncGuard.ts +27 -7
  231. package/src/adapters/tunnelProcess.ts +377 -0
  232. package/src/adapters/webBundler.ts +6 -0
  233. package/src/adapters/webTelemetry.ts +131 -0
  234. package/src/adapters/webauthn.ts +297 -0
  235. package/src/bin/serve.ts +106 -5
  236. package/src/services/deviceSessions.ts +135 -0
  237. package/src/services/pairingFlow.ts +193 -0
  238. package/src/services/pairingPage.ts +320 -0
  239. package/src/services/pathScope.ts +23 -0
  240. package/src/services/remoteAccessSettings.ts +185 -0
  241. package/src/services/remoteGuardPolicy.ts +209 -0
  242. package/src/services/serveOptions.ts +20 -1
  243. package/src/services/sessionMigration.ts +48 -0
  244. package/src/services/staticAssets.ts +1 -0
  245. package/src/services/tunnelOutput.ts +74 -0
  246. package/src/services/webauthnPolicy.ts +134 -0
  247. package/src/types/auth.ts +3 -0
  248. package/src/types/bridge.ts +27 -0
  249. package/src/types/notification.ts +30 -0
  250. package/src/types/remoteAccess.ts +193 -0
  251. package/src/types/settings.ts +120 -0
  252. package/src/web/app/Providers.tsx +28 -5
  253. package/src/web/app/protocolRuntime.ts +12 -2
  254. package/src/web/app/sessionRuntime.ts +31 -1
  255. package/src/web/components/QrCode.tsx +53 -0
  256. package/src/web/components/RemoteAccessButton.tsx +46 -0
  257. package/src/web/features/activity/ActivityDock.tsx +77 -57
  258. package/src/web/features/dialogs/DialogOverlay.tsx +13 -5
  259. package/src/web/features/leader/CommandPalette.tsx +6 -3
  260. package/src/web/features/remote/HandoverProgress.tsx +25 -0
  261. package/src/web/features/remote/PairedDeviceList.tsx +39 -0
  262. package/src/web/features/remote/PairingApprovalDialog.tsx +58 -0
  263. package/src/web/features/remote/RemoteAccessDialog.tsx +113 -0
  264. package/src/web/features/remote/RemoteAccessOptions.tsx +124 -0
  265. package/src/web/features/remote/RemoteAccessPairing.tsx +53 -0
  266. package/src/web/features/remote/RemoteBanner.tsx +51 -0
  267. package/src/web/features/remote/SandboxWorkspaces.tsx +84 -0
  268. package/src/web/features/selection/SelectionBar.tsx +14 -11
  269. package/src/web/features/session/Composer.tsx +317 -20
  270. package/src/web/features/session/ComposerPrompt.tsx +50 -0
  271. package/src/web/features/session/Timeline.tsx +26 -51
  272. package/src/web/features/sessions/NewSessionDialog.tsx +18 -6
  273. package/src/web/features/sessions/SessionRail.tsx +97 -13
  274. package/src/web/features/sessions/WelcomePanel.tsx +32 -0
  275. package/src/web/features/settings/AppearanceSettings.tsx +1 -1
  276. package/src/web/features/settings/ContributedSettings.tsx +408 -0
  277. package/src/web/features/settings/NotificationSettings.tsx +49 -0
  278. package/src/web/features/settings/PluginSettings.tsx +6 -2
  279. package/src/web/features/settings/ProviderSettings.tsx +11 -4
  280. package/src/web/features/settings/RemoteControlSettings.tsx +40 -0
  281. package/src/web/features/settings/RepositorySettings.tsx +365 -0
  282. package/src/web/features/settings/RepositoryWorkspace.tsx +150 -0
  283. package/src/web/features/settings/SettingsMenu.tsx +97 -26
  284. package/src/web/features/settings/TunnelSettings.tsx +145 -0
  285. package/src/web/features/status/TopBar.tsx +72 -25
  286. package/src/web/index.html +1 -1
  287. package/src/web/lib/authApi.ts +4 -1
  288. package/src/web/lib/browserNotifications.ts +47 -0
  289. package/src/web/lib/browserTelemetry.ts +95 -0
  290. package/src/web/lib/commands.ts +12 -6
  291. package/src/web/lib/composition.ts +15 -3
  292. package/src/web/lib/hubApi.ts +41 -4
  293. package/src/web/lib/installStaleChunkRecovery.ts +43 -0
  294. package/src/web/lib/piTransport.ts +18 -7
  295. package/src/web/lib/pluginRegistry.ts +42 -1
  296. package/src/web/lib/protocolTimeline.ts +3 -1
  297. package/src/web/lib/qrMatrix.ts +33 -0
  298. package/src/web/lib/remoteApi.ts +103 -0
  299. package/src/web/lib/sealedSession.ts +80 -0
  300. package/src/web/lib/sessionModel.ts +89 -21
  301. package/src/web/lib/settingsApi.ts +158 -0
  302. package/src/web/lib/settingsDraft.ts +58 -0
  303. package/src/web/lib/settingsSections.ts +78 -6
  304. package/src/web/lib/stepUp.ts +43 -0
  305. package/src/web/lib/toolPrompt.ts +68 -0
  306. package/src/web/lib/transport.ts +12 -0
  307. package/src/web/lib/webauthnClient.ts +107 -0
  308. package/src/web/lib/wsClient.ts +43 -11
  309. package/src/web/main.tsx +3 -0
  310. package/src/web/routes/CockpitPage.tsx +71 -11
  311. package/src/web/routes/SettingsPage.tsx +97 -18
  312. package/src/web/stores/newSessionStore.ts +29 -0
  313. package/src/web/stores/remoteAccessStore.ts +181 -0
  314. package/src/web/stores/sessionStore.ts +68 -21
  315. package/src/web/stores/sessionsStore.ts +17 -0
  316. package/src/web/stores/toolPromptStore.ts +28 -0
  317. package/src/web/stores/useToolPrompt.ts +32 -0
  318. package/dist/web/assets/index-Cg52ngKv.css +0 -2
  319. package/dist/web/assets/index-DQO7NTIP.js +0 -90
  320. package/dist/web/assets/index-DQO7NTIP.js.map +0 -1
package/README.md CHANGED
@@ -1,22 +1,18 @@
1
1
  # @agimon-ai/doompi-web
2
2
 
3
- Web cockpit for DoomPi: a multi-session hub that serves the browser console, discovers every
4
- running [doompi-server](https://www.npmjs.com/package/@agimon-ai/doompi-server) through the session
5
- registry, and multiplexes their authenticated sockets behind one page connection
3
+ Web cockpit for DoomPi. It discovers running [doompi-server](https://www.npmjs.com/package/@agimon-ai/doompi-server) sessions, multiplexes their authenticated sockets, and serves them through one browser connection.
6
4
 
7
5
  This package is a composable [DoomPi](https://www.npmjs.com/package/@agimon-ai/doompi) subsystem.
8
6
  It registers no Pi extension and adds nothing to an interactive session.
9
7
 
10
- ## Enabling it
8
+ ## How it runs
11
9
 
12
- This package is still in development, so `doompi init` does not reference it and there is nothing
13
- to add to `.doom/modes.yaml`. It is a standalone executable you run yourself, as shown under Run
14
- below, or the process `doompi-server --web` starts for you.
10
+ `doompi-web` is a standalone process, not a Pi extension. Do not add it to `.doom/modes.yaml`. Run it directly, or start it through `doompi-server --web`.
15
11
 
16
12
  ## Requirements
17
13
 
18
14
  - Node.js 22.19.0 or newer
19
- - Running `doompi-server` processes registering their session sockets
15
+ - `doompi-server` sessions to attach to. The cockpit can start them itself.
20
16
 
21
17
  ## Install
22
18
 
@@ -30,25 +26,17 @@ npm install -g @agimon-ai/doompi-web
30
26
  doompi-web
31
27
  ```
32
28
 
33
- Then open `http://127.0.0.1:7433`. That is the whole command: no flag is required, and every one
34
- below is an override. `doompi-web` is the hub. It watches the session registry (`~/.doompi/run` by
35
- default), attaches to every registered session, shows them all in the rail, and can start new ones
36
- from the page. Sessions it starts run the `doompi-server` and the agent from this installation, so
37
- the cockpit and the sessions it creates are always the same build.
29
+ Open `http://127.0.0.1:7433`. With no flags, the hub watches `~/.doompi/run`, attaches to registered sessions, and can start new sessions from the page. Sessions started by the cockpit use the server and agent from the same installation.
38
30
 
39
- Running it twice is not an error. The second one finds the first over the port, prints its URL, and
40
- exits, because one hub already serves every session.
31
+ If a hub already answers on the port, a second `doompi-web` process prints its URL and exits.
41
32
 
42
- Working on this repository, one command builds what it needs and starts it:
33
+ To run this repository's build:
43
34
 
44
35
  ```bash
45
36
  pnpm cockpit
46
37
  ```
47
38
 
48
- Nx caches the build, so a warm run is effectively just the hub. Sessions created from the page run
49
- this checkout's builds, which matters: a session on some other `doompi` is a different composition,
50
- and its extensions, commands, and minor modes are not the ones being edited here, so a change looks
51
- like it did nothing.
39
+ The command builds the required packages and starts the hub on port 7433. Sessions created from the page use this checkout's builds.
52
40
 
53
41
  | Flag | Default | Meaning |
54
42
  | ----------------- | --------------- | ------------------------------------------------ |
@@ -57,10 +45,10 @@ like it did nothing.
57
45
  | `--port` | `7433` | HTTP port |
58
46
  | `--host` | `127.0.0.1` | Bind address |
59
47
  | `--assets` | bundled | Override the built SPA directory |
48
+ | `--state-dir` | `~/.doompi/web` | Remote-access settings and the tunnel pid file |
49
+ | `--cloudflared` | from `PATH` | Tunnel binary (also `DOOMPI_CLOUDFLARED`) |
60
50
 
61
- Installing this package gives you `doompi-web` and nothing else. For a session server you run
62
- yourself, install [doompi-server](https://www.npmjs.com/package/@agimon-ai/doompi-server); the hub
63
- does not need it on PATH to create sessions.
51
+ Install [doompi-server](https://www.npmjs.com/package/@agimon-ai/doompi-server) when you also want to run a session server directly. The hub does not require `doompi-server` on `PATH` to create sessions.
64
52
 
65
53
  ## Security
66
54
 
@@ -69,6 +57,24 @@ browser. The server binds loopback by default, so reaching the cockpit already m
69
57
  account on the machine. Remote access means tunnelling the port, for example over SSH; do not bind
70
58
  a public address and call it done.
71
59
 
60
+ Loopback binding is not on its own an authorization boundary, because two request shapes escape it
61
+ and both reach code execution:
62
+
63
+ - **WebSockets are exempt from CORS.** Any page in any browser on this machine can open a socket to
64
+ the cockpit's port, and `session_command` frames carry prompts straight to an agent holding a
65
+ `bash` tool.
66
+ - **A cross-origin POST need not be preflighted.** Sent as `text/plain`, it still parses as JSON
67
+ server-side, so a page could spawn a session in any directory without ever reading a response.
68
+
69
+ Both are refused. Every request and every socket upgrade is checked against an origin and host
70
+ allowlist derived from the port actually bound, plus the vite dev server. A request carrying no
71
+ `Origin` is allowed, because curl, the health probe, and the test harness send none and a browser
72
+ always sends one where it matters; this defends against a hostile web page, not against a hostile
73
+ local program, which can send whatever headers it likes. `DOOMPI_WEB_ALLOW_ORIGIN` takes a
74
+ comma-separated list for setups this package cannot guess. The host check is also the DNS rebinding
75
+ defence, and it is the only check a socket upgrade gets, since the upgrade path never reaches the
76
+ server's own host parsing.
77
+
72
78
  ## What the cockpit shows
73
79
 
74
80
  Everything comes from Pi's RPC protocol, so a bare Pi session and a DoomPi session both work and
@@ -85,13 +91,17 @@ neither is shown facts it did not report:
85
91
  usage, cost, and session identity.
86
92
  - **Dialogs:** the extension UI sub-protocol (`select`, `confirm`, `input`, `editor`), which is how
87
93
  a permission prompt reaches the browser. Cancelling answers the agent rather than stranding it.
94
+ - **Notifications:** live `doom-notification` entries from every attached session, including sessions
95
+ that are not focused. Browser permission is requested only after the user clicks **allow notifications**
96
+ in Settings. Replayed backlog and historical pages update the timeline but never notify.
88
97
  - **Leader Space:** `ctrl+k`, `ctrl+space`, or space in an empty composer. Keys walk the tree the
89
98
  installed web plugins declared (`SPC w r` opens the workflows tab, `SPC g e` toggles goal mode),
90
99
  backspace climbs, and `/` searches whatever `get_commands` reported to run it as a slash prompt.
91
- - **Settings:** the gear at the foot of the rail opens the settings pages. The providers page lists
92
- every model provider Pi knows with whether it is authenticated, signs in with an API key or OAuth
93
- through the hub, and signs out. The hub uses the same `auth.json` as the sessions, so a login
94
- here is a login for every session on the machine.
100
+ - **Settings:** the gear at the foot of the rail opens the settings pages. Notifications shows this
101
+ browser's permission and provides the user-click permission action. Providers lists every model
102
+ provider Pi knows with whether it is authenticated, signs in with an API key or OAuth through the
103
+ hub, and signs out. The hub uses the same `auth.json` as the sessions, so a login here is a login for
104
+ every session on the machine.
95
105
 
96
106
  ## Attach and reattach
97
107
 
@@ -110,6 +120,177 @@ perform the handshake itself: `attach` frames sent from the browser are refused.
110
120
  them.
111
121
  - Subagent, workflow, and runner surfaces report summaries only; per-run detail has no RPC source
112
122
  to render from yet.
123
+ - Browser notifications require a live, connected page. There is no service worker, Web Push, replay,
124
+ or durable queue, so notifications emitted while every page is closed are not delivered later.
125
+
126
+ ## Remote access
127
+
128
+ Working from a phone means putting the cockpit on the internet, and the cockpit's socket carries
129
+ prompts to an agent holding a `bash` tool. Reaching it is therefore equivalent to a shell on this
130
+ machine, and the whole design follows from that.
131
+
132
+ Switching it on binds a **second loopback listener** on an ephemeral port and points `cloudflared`
133
+ at it. Port 7433 is untouched, so working at the keyboard is exactly as before, while everything
134
+ arriving on the tunnel listener has to prove it holds a paired session. The two listeners are the
135
+ mechanism, not an implementation detail: a tunnel connects from `127.0.0.1` like every local client,
136
+ so the socket a connection arrived on is the only thing about it that cannot be forged.
137
+
138
+ Pairing takes **two** things, not one:
139
+
140
+ 1. The host shows a QR encoding `https://<tunnel-host>/pair#c=<code>`. The code rides in the URL
141
+ fragment, which no browser sends to any server, so it never reaches Cloudflare's logs, this
142
+ process's log, or a `Referer`.
143
+ 2. Scanning it raises an approve or deny prompt **on the host**. Nothing is paired until someone at
144
+ the machine answers. A screen is visible over a shoulder and in a screenshare, so the code alone
145
+ is one factor for a credential that ends in shell access.
146
+
147
+ A paired device then has the same powers as the person at the keyboard, with one exception: it
148
+ cannot mint pairing codes, approve devices, or change these settings. A device able to approve
149
+ devices could make its own access permanent, which is precisely what host confirmation prevents.
150
+ Turning remote access off and revoking a device are allowed remotely, because a panic button is
151
+ worth more from the couch than from the desk.
152
+
153
+ Two optional time limits, both **off by default** and both in the dialog:
154
+
155
+ - Close the tunnel automatically, after 60 minutes.
156
+ - Expire paired sessions, after 30 minutes idle or 12 hours total.
157
+
158
+ Either switch takes effect retroactively in both directions, because expiry is evaluated against the
159
+ setting in force at the moment of the check rather than stamped onto a session when it was made.
160
+ Turning expiry on therefore drops sessions that are already idle, and turning it off brings them
161
+ back.
162
+
163
+ With both off, the only expiry is you: a tunnel stays up until it is closed or the hub restarts, and
164
+ a paired device keeps its session until it is revoked or remote access is switched off. Switching
165
+ remote access off always revokes every paired session, and that is not a toggle.
166
+
167
+ Before reporting success, the tunnel is **self-tested through its own public URL**: the pairing page
168
+ must answer 200 and `/api/health` must answer 401. Anything else means the agent is on the public
169
+ internet unguarded, so the tunnel is killed immediately rather than warned about.
170
+
171
+ Requires `cloudflared` on `PATH` (`brew install cloudflared`). A quick tunnel needs no account but
172
+ gets a new hostname on every start, so every restart means scanning again, and Cloudflare does not
173
+ support SSE on quick tunnels, which breaks the workflow and runner-log plugin surfaces. A named
174
+ tunnel on your own domain has neither problem and is the mode to use if you do this daily.
175
+
176
+ ### Passkeys and step-up
177
+
178
+ On a named tunnel, a device can enrol a passkey once and then sign in with a gesture instead of
179
+ another QR. Two actions need a fresh gesture even from a live session: writing or clearing a model
180
+ provider's credential, and spawning a session in a directory of the caller's choosing. Both are
181
+ escalation paths out of "drive the agent" and into "own the machine". Ordinary prompting and tool
182
+ approval are deliberately ungated, because a biometric check in that loop gets trained away in a day.
183
+
184
+ A quick tunnel cannot carry a passkey: `rpID` is bound to the hostname and a quick tunnel's rotates
185
+ on every start, so a credential enrolled today would silently stop working tomorrow. The cockpit says
186
+ so rather than letting the ceremony fail in the browser.
187
+
188
+ ### Tunnel-provider trust and payload privacy
189
+
190
+ Cloudflare is a trusted delivery boundary. It terminates TLS and delivers both the pairing page and the
191
+ cockpit SPA. A browser cannot independently verify its first executable page against the same edge that
192
+ served it: a malicious replacement could omit any in-page verifier, read the QR fragment, and control a
193
+ paired agent. DoomPi therefore does not claim that browser-side bundle signing protects against a
194
+ compromised Cloudflare edge.
195
+
196
+ Within that trust model, **the payload is sealed**. A QR carries an ephemeral P-256 public key alongside the
197
+ pairing code, in the URL fragment, which no browser sends to any server. A returning device that proves a
198
+ passkey receives the current public key in the successful authentication response instead. Both paths then
199
+ complete ECDH and derive AES-256-GCM keys, separate per direction. Socket frames and API bodies travel as
200
+ ciphertext. This limits routine relay visibility, but it still depends on Cloudflare delivering the intended
201
+ pairing page and SPA.
202
+
203
+ The channel implementation lives in `@agimon-ai/doompi-web-security`, which ships the envelope contract,
204
+ a `node:crypto` half, and a WebCrypto half. **A web plugin that calls `fetch` directly sends plaintext to
205
+ the relay**, so plugins use `sealedTransport.fetch` from that package's `./browser` subpath. The host
206
+ cannot enforce this, which is why the import allowlist admits the helper and why it is said here.
207
+
208
+ Cloudflare can still observe the served page and assets, timing, message sizes, connection patterns, and
209
+ tunnel metadata. Do not enable this transport if Cloudflare is outside your trust boundary. Protect a
210
+ named-tunnel account with a passkey or hardware MFA.
211
+
212
+ ### What a paired device may browse
213
+
214
+ While a tunnel is up, the new-session picker answers a request arriving on it from one subtree only:
215
+ the directory `doompi-web` was started in. Unpinned, that route hands a paired device a map of the
216
+ machine, every project and client name and checkout, which is worth more to an attacker than most of
217
+ what the cockpit shows.
218
+
219
+ Only that request is pinned. The person at the keyboard already has a shell, so pinning them would
220
+ cost the picker its usefulness and buy nothing, and a contained cockpit needs none of it because its
221
+ mounts are already the boundary.
222
+
223
+ Session creation itself is not narrowed by this. `POST /api/sessions` still accepts any absolute path
224
+ a caller already knows, which is why the container below exists: it is the boundary, this is only the
225
+ end of casual enumeration.
226
+
227
+ ### Running the cockpit in a container
228
+
229
+ Off by default. A second switch in the same dialog moves the whole cockpit into a container when
230
+ remote access is turned on: the hub, every session server it spawns, every agent, and `cloudflared`
231
+ itself. This is the answer to the blunt fact that a paired device drives an agent holding a shell,
232
+ which every layer above narrows access to but none of them contains.
233
+
234
+ The list of workspaces is the boundary, and it is the only thing on the host the container can see.
235
+ A hub inside the container cannot create a session in a path that is not mounted, so spawning in an
236
+ arbitrary directory is closed by construction rather than by a check. Not mounted, and deliberately
237
+ so: your home directory, `~/.ssh`, `~/.gitconfig`, the container socket, and every repository you
238
+ did not list.
239
+
240
+ What happens when you turn it on:
241
+
242
+ 1. The image is built if this DoomPi version has not built it before, which takes minutes the first
243
+ time. Progress is printed in the terminal that started the cockpit.
244
+ 2. The host cockpit answers the request, then stops serving and releases its port.
245
+ 3. The container starts, publishing the same port on loopback, and becomes the cockpit. Your browser
246
+ reconnects on its own; there is no new address to remember.
247
+ 4. Sessions working inside a mounted workspace are stopped on the host and recreated inside. Sessions
248
+ working anywhere else are named in the terminal and stay on the host, where the contained cockpit
249
+ cannot see them.
250
+
251
+ If the container fails to start, the host cockpit comes back and its sessions are recreated there, so
252
+ a failed move never leaves you with nothing listening. `Ctrl-C` stops the container. A cockpit killed
253
+ outright leaves the container running; the next start finds it through `~/.doompi/web/cockpit-container.json`
254
+ and stops it.
255
+
256
+ Two things carry across the boundary and nothing else does. Provider credentials go through the
257
+ existing broker, which hands the container a per-session token rather than your keys. Git identity is
258
+ passed as `GIT_AUTHOR_*` and `GIT_COMMITTER_*`, so an agent can commit but cannot push, because no
259
+ key crosses.
260
+
261
+ What this does not defend against, stated plainly: anyone who can talk to the container daemon can
262
+ escape any container it runs, so the engine is part of the trusted base. Sessions share one container
263
+ and can read each other's mounted workspaces. A mounted workspace is fully writable, so the agent can
264
+ still destroy the repository you gave it. Network access is unrestricted, exactly as it already is for
265
+ `doompi --sandbox`. And plugin tabs fall back to the built-in set until a bundle is synced inside.
266
+
267
+ Requires `@agimon-ai/doompi-sandbox` in the composition and a container engine on the host.
268
+
269
+ ## Planned: WebRTC transport
270
+
271
+ Today the page reaches the hub over two WebSockets, and any remote access means putting a tunnel in
272
+ front of them, which puts the tunnel provider in the path of everything. A WebRTC data channel would
273
+ replace that, with the tunnel demoted to carrying signaling only. Two independent reasons want it,
274
+ and the first is the stronger.
275
+
276
+ **Realtime voice.** `@agimon-ai/doompi-voice` now captures on the selected browser client, sends
277
+ mono PCM16 through its authenticated session media transport, keeps VAD and Whisper on the agent
278
+ host, and plays narration with browser speech synthesis. Voice activation is when the page asks for
279
+ microphone permission, never on load. A future WebRTC implementation can replace the HTTP media
280
+ adapter with Opus tracks while preserving the same client device and transport contracts.
281
+
282
+ **Confidentiality.** A QR shown on the host can carry the DTLS fingerprint, which authenticates the
283
+ peer connection out-of-band. The data path is then peer-to-peer DTLS: the tunnel provider usually
284
+ never carries the traffic and can never read it. That is close to VPN-grade confidentiality without
285
+ asking anyone to install a VPN.
286
+
287
+ The cost is real and worth stating: a WebRTC stack on the Node side (`node-datachannel` or
288
+ `werift`), a second client transport alongside `wsClient.ts` and `piTransport.ts`, ICE and NAT
289
+ traversal debugging, and a TURN relay fallback for the connections that cannot go direct. TURN
290
+ reintroduces a relay, though still one that cannot decrypt. The WebSocket transport stays as the
291
+ fallback for when a data channel cannot be established.
292
+
293
+ No date. This is a design note, not a commitment.
113
294
 
114
295
  ## Public API
115
296
 
@@ -180,7 +361,8 @@ Plugins are independent. A manifest names no other plugin and `registrationOrder
180
361
  optional tiebreak (default 1000, then plugin id): every relation between two plugins resolves by
181
362
  name once all of them are installed. A plugin opens slots named `<pluginId>.<name>` and renders
182
363
  them with the `renderSlot` and `slotData` props its components receive; any plugin fills them, and
183
- the host's `overlay`, `rail`, `selection-bar`, `activity`, and `activity.<group>` slots, without
364
+ the host's `overlay`, `rail`, `selection-bar`, `composer-actions`, `activity`, and
365
+ `activity.<group>` slots, without
184
366
  knowing whether the owner is installed. A fill into a slot nobody declares, or two plugins wanting
185
367
  one tab id, tool name, group name, or Leader Space leaf, never fails the page: the install resolves
186
368
  it (the first plugin keeps a shared name, a later binding takes a leaf over as the TUI documents)
@@ -1,3 +1,3 @@
1
1
  #!/usr/bin/env node
2
- const e=require("../_virtual/_rolldown/runtime.cjs"),t=require("../src/services/registryStore.cjs"),n=require("../src/adapters/httpServer.cjs"),r=require("../src/services/serveOptions.cjs"),i=require("../src/adapters/hubProbe.cjs");let a=require("node:os");a=e.__toESM(a,1);function o(e){process.stderr.write(`[doompi-web] ${e}\n`)}async function s(){let e=r.parseServeOptions(process.argv.slice(2)),s=`http://${e.host}:${String(e.port)}`;if(await i.hubAnswers(e.host,e.port)){o(`cockpit already running at ${s}`);return}let c;try{c=await n.serveWeb({port:e.port,host:e.host,assetsDir:e.assetsDir,registryDir:t.resolveRegistryDir({flagValue:e.registryDir,envValue:process.env[t.REGISTRY_DIR_ENV],homeDir:a.default.homedir()}),spawnCommand:e.spawnCommand,onNotice:o})}catch(t){if(t.code!==`EADDRINUSE`)throw t;if(await i.hubAnswers(e.host,e.port)){o(`cockpit already running at ${s}`);return}throw Error(`Port ${String(e.port)} is taken by something that is not a DoomPi cockpit; pass --port.`)}let l=()=>{c.close().then(()=>{process.exit(0)})};process.on(`SIGINT`,l),process.on(`SIGTERM`,l)}s().catch(e=>{o(e instanceof Error?e.message:String(e)),process.exitCode=1});
2
+ const e=require("../_virtual/_rolldown/runtime.cjs"),t=require("../src/services/registryStore.cjs"),n=require("../src/adapters/remoteAccessStore.cjs"),r=require("../src/adapters/httpServer.cjs"),i=require("../src/services/serveOptions.cjs"),a=require("../src/adapters/cockpitContainer.cjs"),o=require("../src/adapters/cockpitHandoff.cjs"),s=require("../src/adapters/hubProbe.cjs"),c=require("../src/adapters/sessionRelaunch.cjs");let l=require("node:os");l=e.__toESM(l,1);function u(e){process.stderr.write(`[doompi-web] ${e}\n`)}async function d(){let e=i.parseServeOptions(process.argv.slice(2)),o=`http://${e.host}:${String(e.port)}`,c=e.stateDir??n.defaultRemoteStateDir(),d=a.createCockpitContainer({stateDir:c,onNotice:u});if(await d.reapStale(),i.isLoopbackHost(e.host)||(u(`WARNING: --host ${e.host} is not loopback, so the cockpit is reachable without pairing.`),u(`WARNING: turn on remote access in settings instead, which requires a paired device.`)),await s.hubAnswers(e.host,e.port)){u(`cockpit already running at ${o}`);return}let p=async()=>{},m=async()=>{let n=await r.serveWeb({port:e.port,host:e.host,assetsDir:e.assetsDir,registryDir:t.resolveRegistryDir({flagValue:e.registryDir,envValue:process.env[t.REGISTRY_DIR_ENV],homeDir:l.default.homedir()}),spawnCommand:e.spawnCommand,remoteStateDir:c,cloudflaredPath:e.cloudflaredPath,onNotice:u,onHandover:t=>{f({serving:n,restart:m,container:d,handover:t,port:e.port,host:e.host,adopt:e=>{p=e}})}});return p=async()=>{await n.close()},n};try{await m()}catch(t){if(t.code!==`EADDRINUSE`)throw t;if(await s.hubAnswers(e.host,e.port)){u(`cockpit already running at ${o}`);return}throw Error(`Port ${String(e.port)} is taken by something that is not a DoomPi cockpit; pass --port.`)}let h=()=>{p().then(()=>{process.exit(0)})};process.on(`SIGINT`,h),process.on(`SIGTERM`,h)}async function f(e){await e.serving.close();let t=await e.container.start({workspaces:e.handover.settings.sandbox.workspaces.map(e=>({path:e})),port:e.port,onProgress:u});if(!t.ok){u(`the cockpit could not move into a container: ${t.error}`),u(`serving from the host again`),await e.restart(),await c.relaunchSessions({port:e.port,host:e.host,sessions:e.handover.sessions,onNotice:u});return}e.adopt(async()=>{await t.container.stop()}),await o.handOffRemoteAccess({port:e.port,settings:e.handover.settings,onNotice:u}),await c.relaunchSessions({port:e.port,sessions:e.handover.sessions,onNotice:u}),u(`supervising the cockpit container; Ctrl-C stops it`),await t.container.supervise()}d().catch(e=>{u(e instanceof Error?e.message:String(e)),process.exitCode=1});
3
3
  //# sourceMappingURL=serve.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"serve.cjs","names":["parseServeOptions","hubAnswers","serveWeb","resolveRegistryDir","REGISTRY_DIR_ENV","os"],"sources":["../../src/bin/serve.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport os from 'node:os';\nimport { hubAnswers } from '../adapters/hubProbe.ts';\nimport { serveWeb } from '../adapters/httpServer.ts';\nimport { REGISTRY_DIR_ENV, resolveRegistryDir } from '../services/registryStore.ts';\nimport { parseServeOptions } from '../services/serveOptions.ts';\nimport type { WebServer } from '../types/bridge.ts';\n\nconst ADDRESS_IN_USE = 'EADDRINUSE';\n\nfunction notice(message: string): void {\n process.stderr.write(`[doompi-web] ${message}\\n`);\n}\n\nasync function main(): Promise<void> {\n const options = parseServeOptions(process.argv.slice(2));\n const url = `http://${options.host}:${String(options.port)}`;\n\n // One hub serves every session, so a second start is a request to use the\n // running one, not an error worth a stack trace.\n if (await hubAnswers(options.host, options.port)) {\n notice(`cockpit already running at ${url}`);\n return;\n }\n\n let server: WebServer;\n try {\n server = await serveWeb({\n port: options.port,\n host: options.host,\n assetsDir: options.assetsDir,\n registryDir: resolveRegistryDir({\n flagValue: options.registryDir,\n envValue: process.env[REGISTRY_DIR_ENV],\n homeDir: os.homedir(),\n }),\n spawnCommand: options.spawnCommand,\n onNotice: notice,\n });\n } catch (error) {\n // Two cockpits starting together can both find the port free; the loser\n // settles for the winner rather than reporting a clash nobody caused.\n if ((error as NodeJS.ErrnoException).code !== ADDRESS_IN_USE) throw error;\n if (await hubAnswers(options.host, options.port)) {\n notice(`cockpit already running at ${url}`);\n return;\n }\n throw new Error(`Port ${String(options.port)} is taken by something that is not a DoomPi cockpit; pass --port.`);\n }\n\n const stop = (): void => {\n void server.close().then(() => {\n process.exit(0);\n });\n };\n process.on('SIGINT', stop);\n process.on('SIGTERM', stop);\n}\n\nmain().catch((error: unknown) => {\n notice(error instanceof Error ? error.message : String(error));\n process.exitCode = 1;\n});\n"],"mappings":";kRAWA,SAAS,EAAO,EAAuB,CACrC,QAAQ,OAAO,MAAM,gBAAgB,EAAQ,GAAG,CAClD,CAEA,eAAe,GAAsB,CACnC,IAAM,EAAUA,EAAAA,kBAAkB,QAAQ,KAAK,MAAM,CAAC,CAAC,EACjD,EAAM,UAAU,EAAQ,KAAK,GAAG,OAAO,EAAQ,IAAI,IAIzD,GAAI,MAAMC,EAAAA,WAAW,EAAQ,KAAM,EAAQ,IAAI,EAAG,CAChD,EAAO,8BAA8B,GAAK,EAC1C,MACF,CAEA,IAAI,EACJ,GAAI,CACF,EAAS,MAAMC,EAAAA,SAAS,CACtB,KAAM,EAAQ,KACd,KAAM,EAAQ,KACd,UAAW,EAAQ,UACnB,YAAaC,EAAAA,mBAAmB,CAC9B,UAAW,EAAQ,YACnB,SAAU,QAAQ,IAAIC,EAAAA,kBACtB,QAASC,EAAAA,QAAG,QAAQ,CACtB,CAAC,EACD,aAAc,EAAQ,aACtB,SAAU,CACZ,CAAC,CACH,OAAS,EAAO,CAGd,GAAK,EAAgC,OAAS,aAAgB,MAAM,EACpE,GAAI,MAAMJ,EAAAA,WAAW,EAAQ,KAAM,EAAQ,IAAI,EAAG,CAChD,EAAO,8BAA8B,GAAK,EAC1C,MACF,CACA,MAAU,MAAM,QAAQ,OAAO,EAAQ,IAAI,EAAE,kEAAkE,CACjH,CAEA,IAAM,MAAmB,CACvB,EAAY,MAAM,CAAC,CAAC,SAAW,CAC7B,QAAQ,KAAK,CAAC,CAChB,CAAC,CACH,EACA,QAAQ,GAAG,SAAU,CAAI,EACzB,QAAQ,GAAG,UAAW,CAAI,CAC5B,CAEA,EAAK,CAAC,CAAC,MAAO,GAAmB,CAC/B,EAAO,aAAiB,MAAQ,EAAM,QAAU,OAAO,CAAK,CAAC,EAC7D,QAAQ,SAAW,CACrB,CAAC"}
1
+ {"version":3,"file":"serve.cjs","names":["parseServeOptions","defaultRemoteStateDir","createCockpitContainer","isLoopbackHost","hubAnswers","serveWeb","resolveRegistryDir","REGISTRY_DIR_ENV","os","relaunchSessions","handOffRemoteAccess"],"sources":["../../src/bin/serve.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport os from 'node:os';\nimport { createCockpitContainer } from '../adapters/cockpitContainer.ts';\nimport { handOffRemoteAccess } from '../adapters/cockpitHandoff.ts';\nimport { hubAnswers } from '../adapters/hubProbe.ts';\nimport { serveWeb } from '../adapters/httpServer.ts';\nimport { defaultRemoteStateDir } from '../adapters/remoteAccessStore.ts';\nimport { relaunchSessions } from '../adapters/sessionRelaunch.ts';\nimport { REGISTRY_DIR_ENV, resolveRegistryDir } from '../services/registryStore.ts';\nimport { isLoopbackHost, parseServeOptions } from '../services/serveOptions.ts';\nimport type { WebServer } from '../types/bridge.ts';\nimport type { MigratingSession, RemoteAccessSettings } from '../types/remoteAccess.ts';\n\nconst ADDRESS_IN_USE = 'EADDRINUSE';\n\nfunction notice(message: string): void {\n process.stderr.write(`[doompi-web] ${message}\\n`);\n}\n\nasync function main(): Promise<void> {\n const options = parseServeOptions(process.argv.slice(2));\n const url = `http://${options.host}:${String(options.port)}`;\n const stateDir = options.stateDir ?? defaultRemoteStateDir();\n const container = createCockpitContainer({ stateDir, onNotice: notice });\n // A cockpit that was killed rather than closed leaves its container holding\n // the published port, so the next start would fail to bind for a reason\n // nothing explains.\n await container.reapStale();\n\n // Binding off loopback publishes an unauthenticated cockpit to whoever can\n // reach the address. Remote access exists so nobody has to do this; a warning\n // rather than a refusal, because an existing setup should not break today.\n if (!isLoopbackHost(options.host)) {\n notice(`WARNING: --host ${options.host} is not loopback, so the cockpit is reachable without pairing.`);\n notice('WARNING: turn on remote access in settings instead, which requires a paired device.');\n }\n\n // One hub serves every session, so a second start is a request to use the\n // running one, not an error worth a stack trace.\n if (await hubAnswers(options.host, options.port)) {\n notice(`cockpit already running at ${url}`);\n return;\n }\n\n /**\n * What Ctrl-C stops, which is not the same thing throughout the run.\n *\n * A single indirection rather than swapping signal handlers, so the handover\n * never has to reach for `removeAllListeners` and take out whatever else this\n * process has registered.\n */\n let shutDown: () => Promise<void> = async () => {};\n\n const start = async (): Promise<WebServer> => {\n const server = await serveWeb({\n port: options.port,\n host: options.host,\n assetsDir: options.assetsDir,\n registryDir: resolveRegistryDir({\n flagValue: options.registryDir,\n envValue: process.env[REGISTRY_DIR_ENV],\n homeDir: os.homedir(),\n }),\n spawnCommand: options.spawnCommand,\n remoteStateDir: stateDir,\n cloudflaredPath: options.cloudflaredPath,\n onNotice: notice,\n // Already deferred past its own response by the route that asked, so by\n // the time this runs the caller has its answer and the server can go.\n onHandover: (handover) => {\n void handOver({\n serving: server,\n restart: start,\n container,\n handover,\n port: options.port,\n host: options.host,\n adopt: (next) => {\n shutDown = next;\n },\n });\n },\n });\n shutDown = async () => {\n await server.close();\n };\n return server;\n };\n\n try {\n await start();\n } catch (error) {\n // Two cockpits starting together can both find the port free; the loser\n // settles for the winner rather than reporting a clash nobody caused.\n if ((error as NodeJS.ErrnoException).code !== ADDRESS_IN_USE) throw error;\n if (await hubAnswers(options.host, options.port)) {\n notice(`cockpit already running at ${url}`);\n return;\n }\n throw new Error(`Port ${String(options.port)} is taken by something that is not a DoomPi cockpit; pass --port.`);\n }\n\n const stop = (): void => {\n void shutDown().then(() => {\n process.exit(0);\n });\n };\n process.on('SIGINT', stop);\n process.on('SIGTERM', stop);\n}\n\n/**\n * Hands this cockpit over to one running in a container.\n *\n * The port has to be free before the container can publish onto it, so the\n * order is: stop serving, start the container, and put the host cockpit back if\n * the container does not come up. Leaving the user with nothing listening would\n * be the worst outcome of the three, which is why the rollback also recreates\n * the sessions that were stopped in preparation for a move that did not happen.\n */\nasync function handOver(input: {\n serving: WebServer;\n restart: () => Promise<WebServer>;\n container: ReturnType<typeof createCockpitContainer>;\n handover: { settings: RemoteAccessSettings; sessions: readonly MigratingSession[] };\n port: number;\n host: string;\n adopt: (shutDown: () => Promise<void>) => void;\n}): Promise<void> {\n await input.serving.close();\n const started = await input.container.start({\n workspaces: input.handover.settings.sandbox.workspaces.map((workspace) => ({ path: workspace })),\n port: input.port,\n onProgress: notice,\n });\n if (!started.ok) {\n notice(`the cockpit could not move into a container: ${started.error}`);\n notice('serving from the host again');\n await input.restart();\n await relaunchSessions({\n port: input.port,\n host: input.host,\n sessions: input.handover.sessions,\n onNotice: notice,\n });\n return;\n }\n input.adopt(async () => {\n await started.container.stop();\n });\n // The container answers its own health probe before `start` resolves, so its\n // hub is already there to take the settings and the sessions.\n await handOffRemoteAccess({ port: input.port, settings: input.handover.settings, onNotice: notice });\n await relaunchSessions({ port: input.port, sessions: input.handover.sessions, onNotice: notice });\n // Nothing else holds this process open now that its server is closed, and a\n // supervisor that exits leaves a container nobody will ever stop.\n notice('supervising the cockpit container; Ctrl-C stops it');\n await started.container.supervise();\n}\n\nmain().catch((error: unknown) => {\n notice(error instanceof Error ? error.message : String(error));\n process.exitCode = 1;\n});\n"],"mappings":"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"}
@@ -1,3 +1,3 @@
1
1
  #!/usr/bin/env node
2
- import{REGISTRY_DIR_ENV as e,resolveRegistryDir as t}from"../src/services/registryStore.mjs";import{serveWeb as n}from"../src/adapters/httpServer.mjs";import{parseServeOptions as r}from"../src/services/serveOptions.mjs";import{hubAnswers as i}from"../src/adapters/hubProbe.mjs";import a from"node:os";function o(e){process.stderr.write(`[doompi-web] ${e}\n`)}async function s(){let s=r(process.argv.slice(2)),c=`http://${s.host}:${String(s.port)}`;if(await i(s.host,s.port)){o(`cockpit already running at ${c}`);return}let l;try{l=await n({port:s.port,host:s.host,assetsDir:s.assetsDir,registryDir:t({flagValue:s.registryDir,envValue:process.env[e],homeDir:a.homedir()}),spawnCommand:s.spawnCommand,onNotice:o})}catch(e){if(e.code!==`EADDRINUSE`)throw e;if(await i(s.host,s.port)){o(`cockpit already running at ${c}`);return}throw Error(`Port ${String(s.port)} is taken by something that is not a DoomPi cockpit; pass --port.`)}let u=()=>{l.close().then(()=>{process.exit(0)})};process.on(`SIGINT`,u),process.on(`SIGTERM`,u)}s().catch(e=>{o(e instanceof Error?e.message:String(e)),process.exitCode=1});export{};
2
+ import{REGISTRY_DIR_ENV as e,resolveRegistryDir as t}from"../src/services/registryStore.mjs";import{defaultRemoteStateDir as n}from"../src/adapters/remoteAccessStore.mjs";import{serveWeb as r}from"../src/adapters/httpServer.mjs";import{isLoopbackHost as i,parseServeOptions as a}from"../src/services/serveOptions.mjs";import{createCockpitContainer as o}from"../src/adapters/cockpitContainer.mjs";import{handOffRemoteAccess as s}from"../src/adapters/cockpitHandoff.mjs";import{hubAnswers as c}from"../src/adapters/hubProbe.mjs";import{relaunchSessions as l}from"../src/adapters/sessionRelaunch.mjs";import u from"node:os";function d(e){process.stderr.write(`[doompi-web] ${e}\n`)}async function f(){let s=a(process.argv.slice(2)),l=`http://${s.host}:${String(s.port)}`,f=s.stateDir??n(),m=o({stateDir:f,onNotice:d});if(await m.reapStale(),i(s.host)||(d(`WARNING: --host ${s.host} is not loopback, so the cockpit is reachable without pairing.`),d(`WARNING: turn on remote access in settings instead, which requires a paired device.`)),await c(s.host,s.port)){d(`cockpit already running at ${l}`);return}let h=async()=>{},g=async()=>{let n=await r({port:s.port,host:s.host,assetsDir:s.assetsDir,registryDir:t({flagValue:s.registryDir,envValue:process.env[e],homeDir:u.homedir()}),spawnCommand:s.spawnCommand,remoteStateDir:f,cloudflaredPath:s.cloudflaredPath,onNotice:d,onHandover:e=>{p({serving:n,restart:g,container:m,handover:e,port:s.port,host:s.host,adopt:e=>{h=e}})}});return h=async()=>{await n.close()},n};try{await g()}catch(e){if(e.code!==`EADDRINUSE`)throw e;if(await c(s.host,s.port)){d(`cockpit already running at ${l}`);return}throw Error(`Port ${String(s.port)} is taken by something that is not a DoomPi cockpit; pass --port.`)}let _=()=>{h().then(()=>{process.exit(0)})};process.on(`SIGINT`,_),process.on(`SIGTERM`,_)}async function p(e){await e.serving.close();let t=await e.container.start({workspaces:e.handover.settings.sandbox.workspaces.map(e=>({path:e})),port:e.port,onProgress:d});if(!t.ok){d(`the cockpit could not move into a container: ${t.error}`),d(`serving from the host again`),await e.restart(),await l({port:e.port,host:e.host,sessions:e.handover.sessions,onNotice:d});return}e.adopt(async()=>{await t.container.stop()}),await s({port:e.port,settings:e.handover.settings,onNotice:d}),await l({port:e.port,sessions:e.handover.sessions,onNotice:d}),d(`supervising the cockpit container; Ctrl-C stops it`),await t.container.supervise()}f().catch(e=>{d(e instanceof Error?e.message:String(e)),process.exitCode=1});export{};
3
3
  //# sourceMappingURL=serve.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"serve.mjs","names":[],"sources":["../../src/bin/serve.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport os from 'node:os';\nimport { hubAnswers } from '../adapters/hubProbe.ts';\nimport { serveWeb } from '../adapters/httpServer.ts';\nimport { REGISTRY_DIR_ENV, resolveRegistryDir } from '../services/registryStore.ts';\nimport { parseServeOptions } from '../services/serveOptions.ts';\nimport type { WebServer } from '../types/bridge.ts';\n\nconst ADDRESS_IN_USE = 'EADDRINUSE';\n\nfunction notice(message: string): void {\n process.stderr.write(`[doompi-web] ${message}\\n`);\n}\n\nasync function main(): Promise<void> {\n const options = parseServeOptions(process.argv.slice(2));\n const url = `http://${options.host}:${String(options.port)}`;\n\n // One hub serves every session, so a second start is a request to use the\n // running one, not an error worth a stack trace.\n if (await hubAnswers(options.host, options.port)) {\n notice(`cockpit already running at ${url}`);\n return;\n }\n\n let server: WebServer;\n try {\n server = await serveWeb({\n port: options.port,\n host: options.host,\n assetsDir: options.assetsDir,\n registryDir: resolveRegistryDir({\n flagValue: options.registryDir,\n envValue: process.env[REGISTRY_DIR_ENV],\n homeDir: os.homedir(),\n }),\n spawnCommand: options.spawnCommand,\n onNotice: notice,\n });\n } catch (error) {\n // Two cockpits starting together can both find the port free; the loser\n // settles for the winner rather than reporting a clash nobody caused.\n if ((error as NodeJS.ErrnoException).code !== ADDRESS_IN_USE) throw error;\n if (await hubAnswers(options.host, options.port)) {\n notice(`cockpit already running at ${url}`);\n return;\n }\n throw new Error(`Port ${String(options.port)} is taken by something that is not a DoomPi cockpit; pass --port.`);\n }\n\n const stop = (): void => {\n void server.close().then(() => {\n process.exit(0);\n });\n };\n process.on('SIGINT', stop);\n process.on('SIGTERM', stop);\n}\n\nmain().catch((error: unknown) => {\n notice(error instanceof Error ? error.message : String(error));\n process.exitCode = 1;\n});\n"],"mappings":";6SAWA,SAAS,EAAO,EAAuB,CACrC,QAAQ,OAAO,MAAM,gBAAgB,EAAQ,GAAG,CAClD,CAEA,eAAe,GAAsB,CACnC,IAAM,EAAU,EAAkB,QAAQ,KAAK,MAAM,CAAC,CAAC,EACjD,EAAM,UAAU,EAAQ,KAAK,GAAG,OAAO,EAAQ,IAAI,IAIzD,GAAI,MAAM,EAAW,EAAQ,KAAM,EAAQ,IAAI,EAAG,CAChD,EAAO,8BAA8B,GAAK,EAC1C,MACF,CAEA,IAAI,EACJ,GAAI,CACF,EAAS,MAAM,EAAS,CACtB,KAAM,EAAQ,KACd,KAAM,EAAQ,KACd,UAAW,EAAQ,UACnB,YAAa,EAAmB,CAC9B,UAAW,EAAQ,YACnB,SAAU,QAAQ,IAAI,GACtB,QAAS,EAAG,QAAQ,CACtB,CAAC,EACD,aAAc,EAAQ,aACtB,SAAU,CACZ,CAAC,CACH,OAAS,EAAO,CAGd,GAAK,EAAgC,OAAS,aAAgB,MAAM,EACpE,GAAI,MAAM,EAAW,EAAQ,KAAM,EAAQ,IAAI,EAAG,CAChD,EAAO,8BAA8B,GAAK,EAC1C,MACF,CACA,MAAU,MAAM,QAAQ,OAAO,EAAQ,IAAI,EAAE,kEAAkE,CACjH,CAEA,IAAM,MAAmB,CACvB,EAAY,MAAM,CAAC,CAAC,SAAW,CAC7B,QAAQ,KAAK,CAAC,CAChB,CAAC,CACH,EACA,QAAQ,GAAG,SAAU,CAAI,EACzB,QAAQ,GAAG,UAAW,CAAI,CAC5B,CAEA,EAAK,CAAC,CAAC,MAAO,GAAmB,CAC/B,EAAO,aAAiB,MAAQ,EAAM,QAAU,OAAO,CAAK,CAAC,EAC7D,QAAQ,SAAW,CACrB,CAAC"}
1
+ {"version":3,"file":"serve.mjs","names":[],"sources":["../../src/bin/serve.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport os from 'node:os';\nimport { createCockpitContainer } from '../adapters/cockpitContainer.ts';\nimport { handOffRemoteAccess } from '../adapters/cockpitHandoff.ts';\nimport { hubAnswers } from '../adapters/hubProbe.ts';\nimport { serveWeb } from '../adapters/httpServer.ts';\nimport { defaultRemoteStateDir } from '../adapters/remoteAccessStore.ts';\nimport { relaunchSessions } from '../adapters/sessionRelaunch.ts';\nimport { REGISTRY_DIR_ENV, resolveRegistryDir } from '../services/registryStore.ts';\nimport { isLoopbackHost, parseServeOptions } from '../services/serveOptions.ts';\nimport type { WebServer } from '../types/bridge.ts';\nimport type { MigratingSession, RemoteAccessSettings } from '../types/remoteAccess.ts';\n\nconst ADDRESS_IN_USE = 'EADDRINUSE';\n\nfunction notice(message: string): void {\n process.stderr.write(`[doompi-web] ${message}\\n`);\n}\n\nasync function main(): Promise<void> {\n const options = parseServeOptions(process.argv.slice(2));\n const url = `http://${options.host}:${String(options.port)}`;\n const stateDir = options.stateDir ?? defaultRemoteStateDir();\n const container = createCockpitContainer({ stateDir, onNotice: notice });\n // A cockpit that was killed rather than closed leaves its container holding\n // the published port, so the next start would fail to bind for a reason\n // nothing explains.\n await container.reapStale();\n\n // Binding off loopback publishes an unauthenticated cockpit to whoever can\n // reach the address. Remote access exists so nobody has to do this; a warning\n // rather than a refusal, because an existing setup should not break today.\n if (!isLoopbackHost(options.host)) {\n notice(`WARNING: --host ${options.host} is not loopback, so the cockpit is reachable without pairing.`);\n notice('WARNING: turn on remote access in settings instead, which requires a paired device.');\n }\n\n // One hub serves every session, so a second start is a request to use the\n // running one, not an error worth a stack trace.\n if (await hubAnswers(options.host, options.port)) {\n notice(`cockpit already running at ${url}`);\n return;\n }\n\n /**\n * What Ctrl-C stops, which is not the same thing throughout the run.\n *\n * A single indirection rather than swapping signal handlers, so the handover\n * never has to reach for `removeAllListeners` and take out whatever else this\n * process has registered.\n */\n let shutDown: () => Promise<void> = async () => {};\n\n const start = async (): Promise<WebServer> => {\n const server = await serveWeb({\n port: options.port,\n host: options.host,\n assetsDir: options.assetsDir,\n registryDir: resolveRegistryDir({\n flagValue: options.registryDir,\n envValue: process.env[REGISTRY_DIR_ENV],\n homeDir: os.homedir(),\n }),\n spawnCommand: options.spawnCommand,\n remoteStateDir: stateDir,\n cloudflaredPath: options.cloudflaredPath,\n onNotice: notice,\n // Already deferred past its own response by the route that asked, so by\n // the time this runs the caller has its answer and the server can go.\n onHandover: (handover) => {\n void handOver({\n serving: server,\n restart: start,\n container,\n handover,\n port: options.port,\n host: options.host,\n adopt: (next) => {\n shutDown = next;\n },\n });\n },\n });\n shutDown = async () => {\n await server.close();\n };\n return server;\n };\n\n try {\n await start();\n } catch (error) {\n // Two cockpits starting together can both find the port free; the loser\n // settles for the winner rather than reporting a clash nobody caused.\n if ((error as NodeJS.ErrnoException).code !== ADDRESS_IN_USE) throw error;\n if (await hubAnswers(options.host, options.port)) {\n notice(`cockpit already running at ${url}`);\n return;\n }\n throw new Error(`Port ${String(options.port)} is taken by something that is not a DoomPi cockpit; pass --port.`);\n }\n\n const stop = (): void => {\n void shutDown().then(() => {\n process.exit(0);\n });\n };\n process.on('SIGINT', stop);\n process.on('SIGTERM', stop);\n}\n\n/**\n * Hands this cockpit over to one running in a container.\n *\n * The port has to be free before the container can publish onto it, so the\n * order is: stop serving, start the container, and put the host cockpit back if\n * the container does not come up. Leaving the user with nothing listening would\n * be the worst outcome of the three, which is why the rollback also recreates\n * the sessions that were stopped in preparation for a move that did not happen.\n */\nasync function handOver(input: {\n serving: WebServer;\n restart: () => Promise<WebServer>;\n container: ReturnType<typeof createCockpitContainer>;\n handover: { settings: RemoteAccessSettings; sessions: readonly MigratingSession[] };\n port: number;\n host: string;\n adopt: (shutDown: () => Promise<void>) => void;\n}): Promise<void> {\n await input.serving.close();\n const started = await input.container.start({\n workspaces: input.handover.settings.sandbox.workspaces.map((workspace) => ({ path: workspace })),\n port: input.port,\n onProgress: notice,\n });\n if (!started.ok) {\n notice(`the cockpit could not move into a container: ${started.error}`);\n notice('serving from the host again');\n await input.restart();\n await relaunchSessions({\n port: input.port,\n host: input.host,\n sessions: input.handover.sessions,\n onNotice: notice,\n });\n return;\n }\n input.adopt(async () => {\n await started.container.stop();\n });\n // The container answers its own health probe before `start` resolves, so its\n // hub is already there to take the settings and the sessions.\n await handOffRemoteAccess({ port: input.port, settings: input.handover.settings, onNotice: notice });\n await relaunchSessions({ port: input.port, sessions: input.handover.sessions, onNotice: notice });\n // Nothing else holds this process open now that its server is closed, and a\n // supervisor that exits leaves a container nobody will ever stop.\n notice('supervising the cockpit container; Ctrl-C stops it');\n await started.container.supervise();\n}\n\nmain().catch((error: unknown) => {\n notice(error instanceof Error ? error.message : String(error));\n process.exitCode = 1;\n});\n"],"mappings":";6mBAgBA,SAAS,EAAO,EAAuB,CACrC,QAAQ,OAAO,MAAM,gBAAgB,EAAQ,GAAG,CAClD,CAEA,eAAe,GAAsB,CACnC,IAAM,EAAU,EAAkB,QAAQ,KAAK,MAAM,CAAC,CAAC,EACjD,EAAM,UAAU,EAAQ,KAAK,GAAG,OAAO,EAAQ,IAAI,IACnD,EAAW,EAAQ,UAAY,EAAsB,EACrD,EAAY,EAAuB,CAAE,WAAU,SAAU,CAAO,CAAC,EAgBvE,GAZA,MAAM,EAAU,UAAU,EAKrB,EAAe,EAAQ,IAAI,IAC9B,EAAO,mBAAmB,EAAQ,KAAK,+DAA+D,EACtG,EAAO,qFAAqF,GAK1F,MAAM,EAAW,EAAQ,KAAM,EAAQ,IAAI,EAAG,CAChD,EAAO,8BAA8B,GAAK,EAC1C,MACF,CASA,IAAI,EAAgC,SAAY,CAAC,EAE3C,EAAQ,SAAgC,CAC5C,IAAM,EAAS,MAAM,EAAS,CAC5B,KAAM,EAAQ,KACd,KAAM,EAAQ,KACd,UAAW,EAAQ,UACnB,YAAa,EAAmB,CAC9B,UAAW,EAAQ,YACnB,SAAU,QAAQ,IAAI,GACtB,QAAS,EAAG,QAAQ,CACtB,CAAC,EACD,aAAc,EAAQ,aACtB,eAAgB,EAChB,gBAAiB,EAAQ,gBACzB,SAAU,EAGV,WAAa,GAAa,CACxB,EAAc,CACZ,QAAS,EACT,QAAS,EACT,YACA,WACA,KAAM,EAAQ,KACd,KAAM,EAAQ,KACd,MAAQ,GAAS,CACf,EAAW,CACb,CACF,CAAC,CACH,CACF,CAAC,EAID,MAHA,GAAW,SAAY,CACrB,MAAM,EAAO,MAAM,CACrB,EACO,CACT,EAEA,GAAI,CACF,MAAM,EAAM,CACd,OAAS,EAAO,CAGd,GAAK,EAAgC,OAAS,aAAgB,MAAM,EACpE,GAAI,MAAM,EAAW,EAAQ,KAAM,EAAQ,IAAI,EAAG,CAChD,EAAO,8BAA8B,GAAK,EAC1C,MACF,CACA,MAAU,MAAM,QAAQ,OAAO,EAAQ,IAAI,EAAE,kEAAkE,CACjH,CAEA,IAAM,MAAmB,CACvB,EAAc,CAAC,CAAC,SAAW,CACzB,QAAQ,KAAK,CAAC,CAChB,CAAC,CACH,EACA,QAAQ,GAAG,SAAU,CAAI,EACzB,QAAQ,GAAG,UAAW,CAAI,CAC5B,CAWA,eAAe,EAAS,EAQN,CAChB,MAAM,EAAM,QAAQ,MAAM,EAC1B,IAAM,EAAU,MAAM,EAAM,UAAU,MAAM,CAC1C,WAAY,EAAM,SAAS,SAAS,QAAQ,WAAW,IAAK,IAAe,CAAE,KAAM,CAAU,EAAE,EAC/F,KAAM,EAAM,KACZ,WAAY,CACd,CAAC,EACD,GAAI,CAAC,EAAQ,GAAI,CACf,EAAO,gDAAgD,EAAQ,OAAO,EACtE,EAAO,6BAA6B,EACpC,MAAM,EAAM,QAAQ,EACpB,MAAM,EAAiB,CACrB,KAAM,EAAM,KACZ,KAAM,EAAM,KACZ,SAAU,EAAM,SAAS,SACzB,SAAU,CACZ,CAAC,EACD,MACF,CACA,EAAM,MAAM,SAAY,CACtB,MAAM,EAAQ,UAAU,KAAK,CAC/B,CAAC,EAGD,MAAM,EAAoB,CAAE,KAAM,EAAM,KAAM,SAAU,EAAM,SAAS,SAAU,SAAU,CAAO,CAAC,EACnG,MAAM,EAAiB,CAAE,KAAM,EAAM,KAAM,SAAU,EAAM,SAAS,SAAU,SAAU,CAAO,CAAC,EAGhG,EAAO,oDAAoD,EAC3D,MAAM,EAAQ,UAAU,UAAU,CACpC,CAEA,EAAK,CAAC,CAAC,MAAO,GAAmB,CAC/B,EAAO,aAAiB,MAAQ,EAAM,QAAU,OAAO,CAAK,CAAC,EAC7D,QAAQ,SAAW,CACrB,CAAC"}
package/dist/index.d.cts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { BridgeState, BridgeStatusFrame, SessionFrame } from "./src/types/session.cjs";
2
2
  import { SessionAttachment, WebServer, WebServerOptions } from "./src/types/bridge.cjs";
3
- import { serveWeb } from "./src/adapters/httpServer.cjs";
4
- import { ServeOptions, parseServeOptions } from "./src/services/serveOptions.cjs";
5
3
  import { HUB_PROTOCOL_VERSION, HUB_ROLE, SESSIONS_API_ROUTE, SessionPhase, SessionSummary } from "./src/types/hub.cjs";
6
4
  import { SESSION_RECORD_VERSION, SessionRecord } from "./src/types/registry.cjs";
5
+ import { serveWeb } from "./src/adapters/httpServer.cjs";
6
+ import { ServeOptions, parseServeOptions } from "./src/services/serveOptions.cjs";
7
7
  export { type BridgeState, type BridgeStatusFrame, HUB_PROTOCOL_VERSION, HUB_ROLE, SESSIONS_API_ROUTE, SESSION_RECORD_VERSION, type ServeOptions, type SessionAttachment, type SessionFrame, type SessionPhase, type SessionRecord, type SessionSummary, type WebServer, type WebServerOptions, parseServeOptions, serveWeb };
package/dist/index.d.mts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { BridgeState, BridgeStatusFrame, SessionFrame } from "./src/types/session.mjs";
2
2
  import { SessionAttachment, WebServer, WebServerOptions } from "./src/types/bridge.mjs";
3
- import { serveWeb } from "./src/adapters/httpServer.mjs";
4
- import { ServeOptions, parseServeOptions } from "./src/services/serveOptions.mjs";
5
3
  import { HUB_PROTOCOL_VERSION, HUB_ROLE, SESSIONS_API_ROUTE, SessionPhase, SessionSummary } from "./src/types/hub.mjs";
6
4
  import { SESSION_RECORD_VERSION, SessionRecord } from "./src/types/registry.mjs";
5
+ import { serveWeb } from "./src/adapters/httpServer.mjs";
6
+ import { ServeOptions, parseServeOptions } from "./src/services/serveOptions.mjs";
7
7
  export { type BridgeState, type BridgeStatusFrame, HUB_PROTOCOL_VERSION, HUB_ROLE, SESSIONS_API_ROUTE, SESSION_RECORD_VERSION, type ServeOptions, type SessionAttachment, type SessionFrame, type SessionPhase, type SessionRecord, type SessionSummary, type WebServer, type WebServerOptions, parseServeOptions, serveWeb };
@@ -0,0 +1,2 @@
1
+ const e=require("../../_virtual/_rolldown/runtime.cjs");let t=require("node:fs");t=e.__toESM(t,1);let n=require("node:path");n=e.__toESM(n,1);let r=require("node:module"),i=require("@agimon-ai/doompi-extension-contracts/cockpit-container");const a=[`@agimon-ai/doompi-sandbox`];async function o(e){let t=(0,r.createRequire)(require("url").pathToFileURL(__filename).href);for(let n of a){let r;try{r=t.resolve(`${n}/${i.COCKPIT_HARNESS_EXPORT_SUBPATH.replace(`./`,``)}`)}catch{continue}let a=await import(r);if((0,i.isCockpitContainerHarnessModule)(a))return a;e(`${n} exports a cockpit harness this build does not understand; ignoring it`)}}function s(e){let r=e.onNotice??(()=>{}),i=e.now??(()=>Date.now()),a=n.default.join(e.stateDir,`cockpit-container.json`),s=e.resolveHarness??(async()=>await o(r)),l=n=>{try{t.default.mkdirSync(e.stateDir,{recursive:!0,mode:448}),t.default.writeFileSync(a,JSON.stringify({containerId:n,startedAt:i()}),{mode:384})}catch{}};return{async reapStale(){let e;try{e=JSON.parse(t.default.readFileSync(a,`utf8`))}catch{return}t.default.rmSync(a,{force:!0});let n=typeof e.containerId==`string`?e.containerId:void 0,o=typeof e.startedAt==`number`?e.startedAt:0;if(n===void 0||i()-o>6048e5)return;let c=await s();c!==void 0&&await c.reapCockpitContainer(n)&&r(`stopped a cockpit container left running by a previous process (${n})`)},async start(t){let n=await s();if(n===void 0)return{ok:!1,error:`No installed layer can host the cockpit in a container. Add @agimon-ai/doompi-sandbox.`};let i=await n.startCockpitContainer({workspaces:t.workspaces,port:t.port,environment:process.env,...t.onProgress===void 0?{}:{onProgress:t.onProgress}});return i.ok?(l(i.handle.containerId),r(`cockpit container ${i.handle.containerId} is serving on port ${String(t.port)}`),{ok:!0,container:c(i.handle,a,r,e.supervisePollMs??2e3)}):{ok:!1,error:i.error}}}}function c(e,n,r,i){let a=!1,o=()=>{try{t.default.rmSync(n,{force:!0})}catch{}};process.once(`exit`,o);let s=()=>{process.off(`exit`,o),o()};return{containerId:e.containerId,stop:async()=>{a||(a=!0,s(),await e.stop().catch(e=>{r(`the cockpit container did not stop cleanly: ${e instanceof Error?e.message:String(e)}`)}))},supervise:async()=>{for(;!a;){if(await new Promise(e=>setTimeout(e,i)),a)return;if(!await e.alive()){a=!0,s(),r(`the cockpit container ${e.containerId} stopped on its own`);return}}}}}exports.SUPERVISE_POLL_MS=2e3,exports.createCockpitContainer=s;
2
+ //# sourceMappingURL=cockpitContainer.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cockpitContainer.cjs","names":["require","createRequire","COCKPIT_HARNESS_EXPORT_SUBPATH","isCockpitContainerHarnessModule","path","fs"],"sources":["../../../src/adapters/cockpitContainer.ts"],"sourcesContent":["import { createRequire } from 'node:module';\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport {\n COCKPIT_HARNESS_EXPORT_SUBPATH,\n isCockpitContainerHarnessModule,\n type CockpitContainerHandle,\n type CockpitContainerHarnessModule,\n type CockpitWorkspace,\n} from '@agimon-ai/doompi-extension-contracts/cockpit-container';\n\n/**\n * Owns the container the cockpit hands over to.\n *\n * The harness is resolved from the composition rather than imported, the same\n * way core resolves the interactive sandbox, so this package never depends on a\n * container technology. What lives here is the part the harness cannot own: the\n * container's lifetime relative to *this* process, and the record that lets a\n * later start clean up after a process that did not get to.\n */\n\nconst ID_FILE = 'cockpit-container.json';\nconst FILE_MODE = 0o600;\nconst DIRECTORY_MODE = 0o700;\n/** How often the supervisor checks the container it is holding open for. */\nexport const SUPERVISE_POLL_MS = 2000;\n/** Past this the recorded id is more likely to name something else than the container. */\nconst ID_FILE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;\n/** Layer packages that may host the cockpit, in the order they are tried. */\nconst HARNESS_PACKAGES = ['@agimon-ai/doompi-sandbox'];\n\nexport interface CockpitContainerOptions {\n stateDir: string;\n onNotice?: (message: string) => void;\n /** Test seam: stands in for looking a harness up in the composition. */\n resolveHarness?: () => Promise<CockpitContainerHarnessModule | undefined>;\n now?: () => number;\n /** Test seam: how often the supervisor asks whether the container is still there. */\n supervisePollMs?: number;\n}\n\nexport interface StartedCockpitContainer {\n containerId: string;\n stop: () => Promise<void>;\n /**\n * Keeps this process alive for as long as the container is, and no longer.\n *\n * A cockpit that has handed over has closed its server, so nothing else holds\n * its event loop open: without this it would exit immediately and leave a\n * container running with nobody to stop it. Resolves when the container is\n * gone, whether that was asked for or not.\n */\n supervise: () => Promise<void>;\n}\n\nexport type CockpitContainerOutcome = { ok: true; container: StartedCockpitContainer } | { ok: false; error: string };\n\n/**\n * Finds a layer that can host the cockpit.\n *\n * Dynamic on purpose: a composition without a sandbox layer simply cannot\n * offer this, and saying so is better than a build-time dependency that makes\n * every install carry container code it will never run.\n */\nasync function resolveHarness(onNotice: (message: string) => void): Promise<CockpitContainerHarnessModule | undefined> {\n const require = createRequire(import.meta.url);\n for (const name of HARNESS_PACKAGES) {\n let entry: string;\n try {\n entry = require.resolve(`${name}/${COCKPIT_HARNESS_EXPORT_SUBPATH.replace('./', '')}`);\n } catch {\n continue; // Not installed, or too old to export the subpath.\n }\n const loaded: unknown = await import(entry);\n if (isCockpitContainerHarnessModule(loaded)) return loaded;\n onNotice(`${name} exports a cockpit harness this build does not understand; ignoring it`);\n }\n return undefined;\n}\n\nexport function createCockpitContainer(options: CockpitContainerOptions) {\n const notice = options.onNotice ?? ((): void => {});\n const now = options.now ?? ((): number => Date.now());\n const idPath = path.join(options.stateDir, ID_FILE);\n const findHarness =\n options.resolveHarness ??\n (async (): Promise<CockpitContainerHarnessModule | undefined> => await resolveHarness(notice));\n\n const record = (containerId: string): void => {\n try {\n fs.mkdirSync(options.stateDir, { recursive: true, mode: DIRECTORY_MODE });\n fs.writeFileSync(idPath, JSON.stringify({ containerId, startedAt: now() }), { mode: FILE_MODE });\n } catch {\n // The reaper is a safety net, not a requirement; losing it does not stop\n // the container from coming up.\n }\n };\n\n return {\n /**\n * Stops a container an earlier process left running.\n *\n * A cockpit that was killed rather than closed cannot clean up after\n * itself, and the container it left behind still holds the published port,\n * so the next start would fail to bind for a reason nothing explains.\n */\n async reapStale(): Promise<void> {\n let parsed: { containerId?: unknown; startedAt?: unknown };\n try {\n parsed = JSON.parse(fs.readFileSync(idPath, 'utf8')) as typeof parsed;\n } catch {\n return; // No record is the normal case.\n }\n fs.rmSync(idPath, { force: true });\n const containerId = typeof parsed.containerId === 'string' ? parsed.containerId : undefined;\n const startedAt = typeof parsed.startedAt === 'number' ? parsed.startedAt : 0;\n if (containerId === undefined || now() - startedAt > ID_FILE_MAX_AGE_MS) return;\n const harness = await findHarness();\n if (harness === undefined) return;\n if (await harness.reapCockpitContainer(containerId)) {\n notice(`stopped a cockpit container left running by a previous process (${containerId})`);\n }\n },\n\n async start(input: {\n workspaces: readonly CockpitWorkspace[];\n port: number;\n onProgress?: (message: string) => void;\n }): Promise<CockpitContainerOutcome> {\n const harness = await findHarness();\n if (harness === undefined) {\n return {\n ok: false,\n error: 'No installed layer can host the cockpit in a container. Add @agimon-ai/doompi-sandbox.',\n };\n }\n const started = await harness.startCockpitContainer({\n workspaces: input.workspaces,\n port: input.port,\n environment: process.env,\n ...(input.onProgress === undefined ? {} : { onProgress: input.onProgress }),\n });\n if (!started.ok) return { ok: false, error: started.error };\n record(started.handle.containerId);\n notice(`cockpit container ${started.handle.containerId} is serving on port ${String(input.port)}`);\n return {\n ok: true,\n container: hold(started.handle, idPath, notice, options.supervisePollMs ?? SUPERVISE_POLL_MS),\n };\n },\n };\n}\n\n/**\n * Binds the container's lifetime to this process.\n *\n * The same shape as the tunnel: signals stop it, and a synchronous `exit`\n * handler is the net under `process.exit`, an uncaught exception, and an\n * unhandled rejection, none of which reach a graceful path. A `SIGKILL` still\n * escapes, which is what the id file and `reapStale` are for.\n */\nfunction hold(\n handle: CockpitContainerHandle,\n idPath: string,\n notice: (message: string) => void,\n pollMs: number,\n): StartedCockpitContainer {\n let stopped = false;\n const onExit = (): void => {\n // Synchronous: async work in an exit handler never runs. Best effort, and\n // the id file covers what this cannot.\n try {\n fs.rmSync(idPath, { force: true });\n } catch {\n // Nothing further to do at exit.\n }\n };\n process.once('exit', onExit);\n\n const forget = (): void => {\n process.off('exit', onExit);\n onExit();\n };\n\n return {\n containerId: handle.containerId,\n stop: async () => {\n if (stopped) return;\n stopped = true;\n forget();\n await handle.stop().catch((error: unknown) => {\n notice(`the cockpit container did not stop cleanly: ${error instanceof Error ? error.message : String(error)}`);\n });\n },\n supervise: async () => {\n while (!stopped) {\n await new Promise((resolve) => setTimeout(resolve, pollMs));\n if (stopped) return;\n if (await handle.alive()) continue;\n // Not a clean stop: the container went away on its own, so the record\n // is cleared and the caller is told rather than left waiting.\n stopped = true;\n forget();\n notice(`the cockpit container ${handle.containerId} stopped on its own`);\n return;\n }\n },\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import{createRequire as e}from"node:module";import t from"node:fs";import n from"node:path";import{COCKPIT_HARNESS_EXPORT_SUBPATH as r,isCockpitContainerHarnessModule as i}from"@agimon-ai/doompi-extension-contracts/cockpit-container";const a=2e3,o=[`@agimon-ai/doompi-sandbox`];async function s(t){let n=e(import.meta.url);for(let e of o){let a;try{a=n.resolve(`${e}/${r.replace(`./`,``)}`)}catch{continue}let o=await import(a);if(i(o))return o;t(`${e} exports a cockpit harness this build does not understand; ignoring it`)}}function c(e){let r=e.onNotice??(()=>{}),i=e.now??(()=>Date.now()),a=n.join(e.stateDir,`cockpit-container.json`),o=e.resolveHarness??(async()=>await s(r)),c=n=>{try{t.mkdirSync(e.stateDir,{recursive:!0,mode:448}),t.writeFileSync(a,JSON.stringify({containerId:n,startedAt:i()}),{mode:384})}catch{}};return{async reapStale(){let e;try{e=JSON.parse(t.readFileSync(a,`utf8`))}catch{return}t.rmSync(a,{force:!0});let n=typeof e.containerId==`string`?e.containerId:void 0,s=typeof e.startedAt==`number`?e.startedAt:0;if(n===void 0||i()-s>6048e5)return;let c=await o();c!==void 0&&await c.reapCockpitContainer(n)&&r(`stopped a cockpit container left running by a previous process (${n})`)},async start(t){let n=await o();if(n===void 0)return{ok:!1,error:`No installed layer can host the cockpit in a container. Add @agimon-ai/doompi-sandbox.`};let i=await n.startCockpitContainer({workspaces:t.workspaces,port:t.port,environment:process.env,...t.onProgress===void 0?{}:{onProgress:t.onProgress}});return i.ok?(c(i.handle.containerId),r(`cockpit container ${i.handle.containerId} is serving on port ${String(t.port)}`),{ok:!0,container:l(i.handle,a,r,e.supervisePollMs??2e3)}):{ok:!1,error:i.error}}}}function l(e,n,r,i){let a=!1,o=()=>{try{t.rmSync(n,{force:!0})}catch{}};process.once(`exit`,o);let s=()=>{process.off(`exit`,o),o()};return{containerId:e.containerId,stop:async()=>{a||(a=!0,s(),await e.stop().catch(e=>{r(`the cockpit container did not stop cleanly: ${e instanceof Error?e.message:String(e)}`)}))},supervise:async()=>{for(;!a;){if(await new Promise(e=>setTimeout(e,i)),a)return;if(!await e.alive()){a=!0,s(),r(`the cockpit container ${e.containerId} stopped on its own`);return}}}}}export{a as SUPERVISE_POLL_MS,c as createCockpitContainer};
2
+ //# sourceMappingURL=cockpitContainer.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cockpitContainer.mjs","names":[],"sources":["../../../src/adapters/cockpitContainer.ts"],"sourcesContent":["import { createRequire } from 'node:module';\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport {\n COCKPIT_HARNESS_EXPORT_SUBPATH,\n isCockpitContainerHarnessModule,\n type CockpitContainerHandle,\n type CockpitContainerHarnessModule,\n type CockpitWorkspace,\n} from '@agimon-ai/doompi-extension-contracts/cockpit-container';\n\n/**\n * Owns the container the cockpit hands over to.\n *\n * The harness is resolved from the composition rather than imported, the same\n * way core resolves the interactive sandbox, so this package never depends on a\n * container technology. What lives here is the part the harness cannot own: the\n * container's lifetime relative to *this* process, and the record that lets a\n * later start clean up after a process that did not get to.\n */\n\nconst ID_FILE = 'cockpit-container.json';\nconst FILE_MODE = 0o600;\nconst DIRECTORY_MODE = 0o700;\n/** How often the supervisor checks the container it is holding open for. */\nexport const SUPERVISE_POLL_MS = 2000;\n/** Past this the recorded id is more likely to name something else than the container. */\nconst ID_FILE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;\n/** Layer packages that may host the cockpit, in the order they are tried. */\nconst HARNESS_PACKAGES = ['@agimon-ai/doompi-sandbox'];\n\nexport interface CockpitContainerOptions {\n stateDir: string;\n onNotice?: (message: string) => void;\n /** Test seam: stands in for looking a harness up in the composition. */\n resolveHarness?: () => Promise<CockpitContainerHarnessModule | undefined>;\n now?: () => number;\n /** Test seam: how often the supervisor asks whether the container is still there. */\n supervisePollMs?: number;\n}\n\nexport interface StartedCockpitContainer {\n containerId: string;\n stop: () => Promise<void>;\n /**\n * Keeps this process alive for as long as the container is, and no longer.\n *\n * A cockpit that has handed over has closed its server, so nothing else holds\n * its event loop open: without this it would exit immediately and leave a\n * container running with nobody to stop it. Resolves when the container is\n * gone, whether that was asked for or not.\n */\n supervise: () => Promise<void>;\n}\n\nexport type CockpitContainerOutcome = { ok: true; container: StartedCockpitContainer } | { ok: false; error: string };\n\n/**\n * Finds a layer that can host the cockpit.\n *\n * Dynamic on purpose: a composition without a sandbox layer simply cannot\n * offer this, and saying so is better than a build-time dependency that makes\n * every install carry container code it will never run.\n */\nasync function resolveHarness(onNotice: (message: string) => void): Promise<CockpitContainerHarnessModule | undefined> {\n const require = createRequire(import.meta.url);\n for (const name of HARNESS_PACKAGES) {\n let entry: string;\n try {\n entry = require.resolve(`${name}/${COCKPIT_HARNESS_EXPORT_SUBPATH.replace('./', '')}`);\n } catch {\n continue; // Not installed, or too old to export the subpath.\n }\n const loaded: unknown = await import(entry);\n if (isCockpitContainerHarnessModule(loaded)) return loaded;\n onNotice(`${name} exports a cockpit harness this build does not understand; ignoring it`);\n }\n return undefined;\n}\n\nexport function createCockpitContainer(options: CockpitContainerOptions) {\n const notice = options.onNotice ?? ((): void => {});\n const now = options.now ?? ((): number => Date.now());\n const idPath = path.join(options.stateDir, ID_FILE);\n const findHarness =\n options.resolveHarness ??\n (async (): Promise<CockpitContainerHarnessModule | undefined> => await resolveHarness(notice));\n\n const record = (containerId: string): void => {\n try {\n fs.mkdirSync(options.stateDir, { recursive: true, mode: DIRECTORY_MODE });\n fs.writeFileSync(idPath, JSON.stringify({ containerId, startedAt: now() }), { mode: FILE_MODE });\n } catch {\n // The reaper is a safety net, not a requirement; losing it does not stop\n // the container from coming up.\n }\n };\n\n return {\n /**\n * Stops a container an earlier process left running.\n *\n * A cockpit that was killed rather than closed cannot clean up after\n * itself, and the container it left behind still holds the published port,\n * so the next start would fail to bind for a reason nothing explains.\n */\n async reapStale(): Promise<void> {\n let parsed: { containerId?: unknown; startedAt?: unknown };\n try {\n parsed = JSON.parse(fs.readFileSync(idPath, 'utf8')) as typeof parsed;\n } catch {\n return; // No record is the normal case.\n }\n fs.rmSync(idPath, { force: true });\n const containerId = typeof parsed.containerId === 'string' ? parsed.containerId : undefined;\n const startedAt = typeof parsed.startedAt === 'number' ? parsed.startedAt : 0;\n if (containerId === undefined || now() - startedAt > ID_FILE_MAX_AGE_MS) return;\n const harness = await findHarness();\n if (harness === undefined) return;\n if (await harness.reapCockpitContainer(containerId)) {\n notice(`stopped a cockpit container left running by a previous process (${containerId})`);\n }\n },\n\n async start(input: {\n workspaces: readonly CockpitWorkspace[];\n port: number;\n onProgress?: (message: string) => void;\n }): Promise<CockpitContainerOutcome> {\n const harness = await findHarness();\n if (harness === undefined) {\n return {\n ok: false,\n error: 'No installed layer can host the cockpit in a container. Add @agimon-ai/doompi-sandbox.',\n };\n }\n const started = await harness.startCockpitContainer({\n workspaces: input.workspaces,\n port: input.port,\n environment: process.env,\n ...(input.onProgress === undefined ? {} : { onProgress: input.onProgress }),\n });\n if (!started.ok) return { ok: false, error: started.error };\n record(started.handle.containerId);\n notice(`cockpit container ${started.handle.containerId} is serving on port ${String(input.port)}`);\n return {\n ok: true,\n container: hold(started.handle, idPath, notice, options.supervisePollMs ?? SUPERVISE_POLL_MS),\n };\n },\n };\n}\n\n/**\n * Binds the container's lifetime to this process.\n *\n * The same shape as the tunnel: signals stop it, and a synchronous `exit`\n * handler is the net under `process.exit`, an uncaught exception, and an\n * unhandled rejection, none of which reach a graceful path. A `SIGKILL` still\n * escapes, which is what the id file and `reapStale` are for.\n */\nfunction hold(\n handle: CockpitContainerHandle,\n idPath: string,\n notice: (message: string) => void,\n pollMs: number,\n): StartedCockpitContainer {\n let stopped = false;\n const onExit = (): void => {\n // Synchronous: async work in an exit handler never runs. Best effort, and\n // the id file covers what this cannot.\n try {\n fs.rmSync(idPath, { force: true });\n } catch {\n // Nothing further to do at exit.\n }\n };\n process.once('exit', onExit);\n\n const forget = (): void => {\n process.off('exit', onExit);\n onExit();\n };\n\n return {\n containerId: handle.containerId,\n stop: async () => {\n if (stopped) return;\n stopped = true;\n forget();\n await handle.stop().catch((error: unknown) => {\n notice(`the cockpit container did not stop cleanly: ${error instanceof Error ? error.message : String(error)}`);\n });\n },\n supervise: async () => {\n while (!stopped) {\n await new Promise((resolve) => setTimeout(resolve, pollMs));\n if (stopped) return;\n if (await handle.alive()) continue;\n // Not a clean stop: the container went away on its own, so the record\n // is cleared and the caller is told rather than left waiting.\n stopped = true;\n forget();\n notice(`the cockpit container ${handle.containerId} stopped on its own`);\n return;\n }\n },\n };\n}\n"],"mappings":"0OAqBA,MAIa,EAAoB,IAI3B,EAAmB,CAAC,2BAA2B,EAmCrD,eAAe,EAAe,EAAyF,CACrH,IAAM,EAAU,EAAc,YAAY,GAAG,EAC7C,IAAK,IAAM,KAAQ,EAAkB,CACnC,IAAI,EACJ,GAAI,CACF,EAAQ,EAAQ,QAAQ,GAAG,EAAK,GAAG,EAA+B,QAAQ,KAAM,EAAE,GAAG,CACvF,MAAQ,CACN,QACF,CACA,IAAM,EAAkB,MAAM,OAAO,GACrC,GAAI,EAAgC,CAAM,EAAG,OAAO,EACpD,EAAS,GAAG,EAAK,uEAAuE,CAC1F,CAEF,CAEA,SAAgB,EAAuB,EAAkC,CACvE,IAAM,EAAS,EAAQ,eAAyB,CAAC,GAC3C,EAAM,EAAQ,UAAsB,KAAK,IAAI,GAC7C,EAAS,EAAK,KAAK,EAAQ,SAAU,wBAAO,EAC5C,EACJ,EAAQ,iBACP,SAAgE,MAAM,EAAe,CAAM,GAExF,EAAU,GAA8B,CAC5C,GAAI,CACF,EAAG,UAAU,EAAQ,SAAU,CAAE,UAAW,GAAM,KAAM,GAAe,CAAC,EACxE,EAAG,cAAc,EAAQ,KAAK,UAAU,CAAE,cAAa,UAAW,EAAI,CAAE,CAAC,EAAG,CAAE,KAAM,GAAU,CAAC,CACjG,MAAQ,CAGR,CACF,EAEA,MAAO,CAQL,MAAM,WAA2B,CAC/B,IAAI,EACJ,GAAI,CACF,EAAS,KAAK,MAAM,EAAG,aAAa,EAAQ,MAAM,CAAC,CACrD,MAAQ,CACN,MACF,CACA,EAAG,OAAO,EAAQ,CAAE,MAAO,EAAK,CAAC,EACjC,IAAM,EAAc,OAAO,EAAO,aAAgB,SAAW,EAAO,YAAc,IAAA,GAC5E,EAAY,OAAO,EAAO,WAAc,SAAW,EAAO,UAAY,EAC5E,GAAI,IAAgB,IAAA,IAAa,EAAI,EAAI,EAAY,OAAoB,OACzE,IAAM,EAAU,MAAM,EAAY,EAC9B,IAAY,IAAA,IACZ,MAAM,EAAQ,qBAAqB,CAAW,GAChD,EAAO,mEAAmE,EAAY,EAAE,CAE5F,EAEA,MAAM,MAAM,EAIyB,CACnC,IAAM,EAAU,MAAM,EAAY,EAClC,GAAI,IAAY,IAAA,GACd,MAAO,CACL,GAAI,GACJ,MAAO,wFACT,EAEF,IAAM,EAAU,MAAM,EAAQ,sBAAsB,CAClD,WAAY,EAAM,WAClB,KAAM,EAAM,KACZ,YAAa,QAAQ,IACrB,GAAI,EAAM,aAAe,IAAA,GAAY,CAAC,EAAI,CAAE,WAAY,EAAM,UAAW,CAC3E,CAAC,EAID,OAHK,EAAQ,IACb,EAAO,EAAQ,OAAO,WAAW,EACjC,EAAO,qBAAqB,EAAQ,OAAO,YAAY,sBAAsB,OAAO,EAAM,IAAI,GAAG,EAC1F,CACL,GAAI,GACJ,UAAW,EAAK,EAAQ,OAAQ,EAAQ,EAAQ,EAAQ,iBAAA,GAAoC,CAC9F,GANwB,CAAE,GAAI,GAAO,MAAO,EAAQ,KAAM,CAO5D,CACF,CACF,CAUA,SAAS,EACP,EACA,EACA,EACA,EACyB,CACzB,IAAI,EAAU,GACR,MAAqB,CAGzB,GAAI,CACF,EAAG,OAAO,EAAQ,CAAE,MAAO,EAAK,CAAC,CACnC,MAAQ,CAER,CACF,EACA,QAAQ,KAAK,OAAQ,CAAM,EAE3B,IAAM,MAAqB,CACzB,QAAQ,IAAI,OAAQ,CAAM,EAC1B,EAAO,CACT,EAEA,MAAO,CACL,YAAa,EAAO,YACpB,KAAM,SAAY,CACZ,IACJ,EAAU,GACV,EAAO,EACP,MAAM,EAAO,KAAK,CAAC,CAAC,MAAO,GAAmB,CAC5C,EAAO,+CAA+C,aAAiB,MAAQ,EAAM,QAAU,OAAO,CAAK,GAAG,CAChH,CAAC,EACH,EACA,UAAW,SAAY,CACrB,KAAO,CAAC,GAAS,CAEf,GADA,MAAM,IAAI,QAAS,GAAY,WAAW,EAAS,CAAM,CAAC,EACtD,EAAS,OACT,UAAM,EAAO,MAAM,EAKvB,CAFA,EAAU,GACV,EAAO,EACP,EAAO,yBAAyB,EAAO,YAAY,oBAAoB,EACvE,MADuE,CAEzE,CACF,CACF,CACF"}
@@ -0,0 +1,2 @@
1
+ const e=require("../types/remoteAccess.cjs");async function t(e,t,n){let r=await fetch(e,{method:t,headers:{"content-type":`application/json`},body:n,signal:AbortSignal.timeout(6e4)});return{status:r.status,text:await r.text()}}async function n(n){let r=n.onNotice??(()=>{}),i=n.send??t,a=`http://${n.host??`127.0.0.1`}:${String(n.port)}${e.REMOTE_API_ROUTE}`;try{let e=await i(`${a}/settings`,`PUT`,JSON.stringify(n.settings));if(e.status!==200)return r(`the container would not take the remote-access settings: ${e.text}`),!1;let t=await i(`${a}/enable`,`POST`,`{}`);return t.status!==200&&t.status!==202?(r(`the container could not turn remote access on: ${t.text}`),!1):!0}catch(e){return r(`the container could not turn remote access on: ${e instanceof Error?e.message:String(e)}`),!1}}exports.handOffRemoteAccess=n;
2
+ //# sourceMappingURL=cockpitHandoff.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cockpitHandoff.cjs","names":["REMOTE_API_ROUTE"],"sources":["../../../src/adapters/cockpitHandoff.ts"],"sourcesContent":["import { REMOTE_API_ROUTE, type RemoteAccessSettings } from '../types/remoteAccess.ts';\n\n/**\n * Telling the cockpit that took over what it took over.\n *\n * The container has its own home volume, so it starts from its own settings and\n * knows nothing about the toggle that was just flipped on the host. Two calls\n * over the hub's own control plane close that gap: adopt the settings, then\n * turn remote access on, which is what the user asked for in the first place.\n *\n * Both routes are local-only, and a request to the published loopback port\n * arrives on the container's main listener, which is local by definition. The\n * tunnel listener is a different socket and is not reachable from here.\n */\n\nconst OK = 200;\nconst ACCEPTED = 202;\nconst LOOPBACK = '127.0.0.1';\n/** Enabling starts a tunnel, which waits on cloudflared reaching the edge. */\nconst HANDOFF_TIMEOUT_MS = 60_000;\n\nexport interface CockpitHandoffOptions {\n port: number;\n host?: string;\n settings: RemoteAccessSettings;\n onNotice?: (message: string) => void;\n /** Test seam, so a suite never opens a socket. */\n send?: (url: string, method: string, body: string) => Promise<{ status: number; text: string }>;\n}\n\nasync function sendJson(url: string, method: string, body: string): Promise<{ status: number; text: string }> {\n const response = await fetch(url, {\n method,\n headers: { 'content-type': 'application/json' },\n body,\n signal: AbortSignal.timeout(HANDOFF_TIMEOUT_MS),\n });\n return { status: response.status, text: await response.text() };\n}\n\nexport async function handOffRemoteAccess(options: CockpitHandoffOptions): Promise<boolean> {\n const notice = options.onNotice ?? ((): void => {});\n const send = options.send ?? sendJson;\n const base = `http://${options.host ?? LOOPBACK}:${String(options.port)}${REMOTE_API_ROUTE}`;\n try {\n // Sent whole, sandbox flag included: the contained hub reads its own\n // environment to know it is already contained, so the setting stays true\n // and the dialog keeps telling the truth about how it is running.\n const stored = await send(`${base}/settings`, 'PUT', JSON.stringify(options.settings));\n if (stored.status !== OK) {\n notice(`the container would not take the remote-access settings: ${stored.text}`);\n return false;\n }\n const enabled = await send(`${base}/enable`, 'POST', '{}');\n if (enabled.status !== OK && enabled.status !== ACCEPTED) {\n notice(`the container could not turn remote access on: ${enabled.text}`);\n return false;\n }\n return true;\n } catch (error) {\n // Reported rather than retried: the host is no longer serving, so the user\n // needs to know the tunnel is not up rather than watch a silent loop.\n notice(`the container could not turn remote access on: ${error instanceof Error ? error.message : String(error)}`);\n return false;\n }\n}\n"],"mappings":"6CA8BA,eAAe,EAAS,EAAa,EAAgB,EAAyD,CAC5G,IAAM,EAAW,MAAM,MAAM,EAAK,CAChC,SACA,QAAS,CAAE,eAAgB,kBAAmB,EAC9C,OACA,OAAQ,YAAY,QAAQ,GAAkB,CAChD,CAAC,EACD,MAAO,CAAE,OAAQ,EAAS,OAAQ,KAAM,MAAM,EAAS,KAAK,CAAE,CAChE,CAEA,eAAsB,EAAoB,EAAkD,CAC1F,IAAM,EAAS,EAAQ,eAAyB,CAAC,GAC3C,EAAO,EAAQ,MAAQ,EACvB,EAAO,UAAU,EAAQ,MAAQ,YAAS,GAAG,OAAO,EAAQ,IAAI,IAAIA,EAAAA,mBAC1E,GAAI,CAIF,IAAM,EAAS,MAAM,EAAK,GAAG,EAAK,WAAY,MAAO,KAAK,UAAU,EAAQ,QAAQ,CAAC,EACrF,GAAI,EAAO,SAAW,IAEpB,OADA,EAAO,4DAA4D,EAAO,MAAM,EACzE,GAET,IAAM,EAAU,MAAM,EAAK,GAAG,EAAK,SAAU,OAAQ,IAAI,EAKzD,OAJI,EAAQ,SAAW,KAAM,EAAQ,SAAW,KAC9C,EAAO,kDAAkD,EAAQ,MAAM,EAChE,IAEF,EACT,OAAS,EAAO,CAId,OADA,EAAO,kDAAkD,aAAiB,MAAQ,EAAM,QAAU,OAAO,CAAK,GAAG,EAC1G,EACT,CACF"}
@@ -0,0 +1,2 @@
1
+ import{REMOTE_API_ROUTE as e}from"../types/remoteAccess.mjs";async function t(e,t,n){let r=await fetch(e,{method:t,headers:{"content-type":`application/json`},body:n,signal:AbortSignal.timeout(6e4)});return{status:r.status,text:await r.text()}}async function n(n){let r=n.onNotice??(()=>{}),i=n.send??t,a=`http://${n.host??`127.0.0.1`}:${String(n.port)}${e}`;try{let e=await i(`${a}/settings`,`PUT`,JSON.stringify(n.settings));if(e.status!==200)return r(`the container would not take the remote-access settings: ${e.text}`),!1;let t=await i(`${a}/enable`,`POST`,`{}`);return t.status!==200&&t.status!==202?(r(`the container could not turn remote access on: ${t.text}`),!1):!0}catch(e){return r(`the container could not turn remote access on: ${e instanceof Error?e.message:String(e)}`),!1}}export{n as handOffRemoteAccess};
2
+ //# sourceMappingURL=cockpitHandoff.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cockpitHandoff.mjs","names":[],"sources":["../../../src/adapters/cockpitHandoff.ts"],"sourcesContent":["import { REMOTE_API_ROUTE, type RemoteAccessSettings } from '../types/remoteAccess.ts';\n\n/**\n * Telling the cockpit that took over what it took over.\n *\n * The container has its own home volume, so it starts from its own settings and\n * knows nothing about the toggle that was just flipped on the host. Two calls\n * over the hub's own control plane close that gap: adopt the settings, then\n * turn remote access on, which is what the user asked for in the first place.\n *\n * Both routes are local-only, and a request to the published loopback port\n * arrives on the container's main listener, which is local by definition. The\n * tunnel listener is a different socket and is not reachable from here.\n */\n\nconst OK = 200;\nconst ACCEPTED = 202;\nconst LOOPBACK = '127.0.0.1';\n/** Enabling starts a tunnel, which waits on cloudflared reaching the edge. */\nconst HANDOFF_TIMEOUT_MS = 60_000;\n\nexport interface CockpitHandoffOptions {\n port: number;\n host?: string;\n settings: RemoteAccessSettings;\n onNotice?: (message: string) => void;\n /** Test seam, so a suite never opens a socket. */\n send?: (url: string, method: string, body: string) => Promise<{ status: number; text: string }>;\n}\n\nasync function sendJson(url: string, method: string, body: string): Promise<{ status: number; text: string }> {\n const response = await fetch(url, {\n method,\n headers: { 'content-type': 'application/json' },\n body,\n signal: AbortSignal.timeout(HANDOFF_TIMEOUT_MS),\n });\n return { status: response.status, text: await response.text() };\n}\n\nexport async function handOffRemoteAccess(options: CockpitHandoffOptions): Promise<boolean> {\n const notice = options.onNotice ?? ((): void => {});\n const send = options.send ?? sendJson;\n const base = `http://${options.host ?? LOOPBACK}:${String(options.port)}${REMOTE_API_ROUTE}`;\n try {\n // Sent whole, sandbox flag included: the contained hub reads its own\n // environment to know it is already contained, so the setting stays true\n // and the dialog keeps telling the truth about how it is running.\n const stored = await send(`${base}/settings`, 'PUT', JSON.stringify(options.settings));\n if (stored.status !== OK) {\n notice(`the container would not take the remote-access settings: ${stored.text}`);\n return false;\n }\n const enabled = await send(`${base}/enable`, 'POST', '{}');\n if (enabled.status !== OK && enabled.status !== ACCEPTED) {\n notice(`the container could not turn remote access on: ${enabled.text}`);\n return false;\n }\n return true;\n } catch (error) {\n // Reported rather than retried: the host is no longer serving, so the user\n // needs to know the tunnel is not up rather than watch a silent loop.\n notice(`the container could not turn remote access on: ${error instanceof Error ? error.message : String(error)}`);\n return false;\n }\n}\n"],"mappings":"6DA8BA,eAAe,EAAS,EAAa,EAAgB,EAAyD,CAC5G,IAAM,EAAW,MAAM,MAAM,EAAK,CAChC,SACA,QAAS,CAAE,eAAgB,kBAAmB,EAC9C,OACA,OAAQ,YAAY,QAAQ,GAAkB,CAChD,CAAC,EACD,MAAO,CAAE,OAAQ,EAAS,OAAQ,KAAM,MAAM,EAAS,KAAK,CAAE,CAChE,CAEA,eAAsB,EAAoB,EAAkD,CAC1F,IAAM,EAAS,EAAQ,eAAyB,CAAC,GAC3C,EAAO,EAAQ,MAAQ,EACvB,EAAO,UAAU,EAAQ,MAAQ,YAAS,GAAG,OAAO,EAAQ,IAAI,IAAI,IAC1E,GAAI,CAIF,IAAM,EAAS,MAAM,EAAK,GAAG,EAAK,WAAY,MAAO,KAAK,UAAU,EAAQ,QAAQ,CAAC,EACrF,GAAI,EAAO,SAAW,IAEpB,OADA,EAAO,4DAA4D,EAAO,MAAM,EACzE,GAET,IAAM,EAAU,MAAM,EAAK,GAAG,EAAK,SAAU,OAAQ,IAAI,EAKzD,OAJI,EAAQ,SAAW,KAAM,EAAQ,SAAW,KAC9C,EAAO,kDAAkD,EAAQ,MAAM,EAChE,IAEF,EACT,OAAS,EAAO,CAId,OADA,EAAO,kDAAkD,aAAiB,MAAQ,EAAM,QAAU,OAAO,CAAK,GAAG,EAC1G,EACT,CACF"}
@@ -0,0 +1,2 @@
1
+ const e=require("../services/deviceSessions.cjs");let t=require("node:crypto");function n(e){return(0,t.createHash)(`sha256`).update(e).digest(`hex`)}function r(r){let i=r.now??(()=>Date.now()),a=r.onNotice??(()=>{}),o=new Map,s=new Map,c=e=>{let t=s.get(e);t!==void 0&&clearTimeout(t),s.delete(e)},l=(e,t)=>{let n=o.get(e);if(n!==void 0)return o.delete(e),c(e),r.onDrop?.(n,t),n},u=(t,n)=>{c(t);let a=e.deviceExpiryAt(n,r.settings());if(a===void 0)return;let d=a-i();if(d<=0){l(t,`expired`);return}let f=setTimeout(()=>{let n=o.get(t);n!==void 0&&(e.evaluateDevice(n,i(),r.settings()).ok?u(t,n):l(t,`expired`))},Math.min(d,2147483647));f.unref(),s.set(t,f)};return{enrol(r){let s=(0,t.randomBytes)(32).toString(`base64url`),c=e.sanitizeUserAgent(r.userAgent),l=i(),d={id:(0,t.randomBytes)(8).toString(`hex`),tokenHash:n(s),label:e.deviceLabelFor(c),userAgent:c,createdAt:l,lastSeenAt:l};return o.set(d.tokenHash,d),u(d.tokenHash,d),a(`paired ${d.label} (${d.id})`),{token:s,record:d}},verify(t){if(t===void 0||t===``)return;let a=n(t),s=i(),c=e.evaluateDevice(o.get(a),s,r.settings());if(!c.ok){c.reason!==`unknown`&&l(a,`expired`);return}let d=e.touchDevice(c.record,s);return o.set(a,d),u(a,d),d},revoke(e){for(let[t,n]of o)if(n.id===e)return l(t,`revoked`),a(`revoked ${n.label} (${n.id})`),!0;return!1},revokeAll(){let e=[...o.values()];for(let[e]of o)l(e,`revoked`);return e.length>0&&a(`revoked ${String(e.length)} paired device(s)`),e.length},list:()=>[...o.values()],reschedule(){for(let[e,t]of o)u(e,t)},sweep(){let t=i(),n=r.settings(),a=0;for(let[r,i]of o)e.evaluateDevice(i,t,n).ok||(l(r,`expired`),a+=1);return a}}}exports.createDeviceAuth=r;
2
+ //# sourceMappingURL=deviceAuth.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deviceAuth.cjs","names":["createHash","deviceExpiryAt","evaluateDevice","randomBytes","sanitizeUserAgent","deviceLabelFor","touchDevice"],"sources":["../../../src/adapters/deviceAuth.ts"],"sourcesContent":["import { createHash, randomBytes } from 'node:crypto';\nimport {\n type DeviceRecord,\n deviceExpiryAt,\n deviceLabelFor,\n evaluateDevice,\n sanitizeUserAgent,\n touchDevice,\n} from '../services/deviceSessions.ts';\nimport type { RemoteAccessSettings } from '../types/remoteAccess.ts';\n\n/** 256 bits. The token is scanned or set by the server, never typed, so there is no reason to economise. */\nconst TOKEN_BYTES = 32;\nconst ID_BYTES = 8;\n\nexport interface DeviceAuthOptions {\n /** Read fresh on every check, so a changed toggle takes effect on the next request. */\n settings: () => RemoteAccessSettings;\n now?: () => number;\n onNotice?: (message: string) => void;\n onDrop?: (record: DeviceRecord, reason: 'revoked' | 'expired') => void;\n}\n\nexport interface EnrolledDevice {\n /** Returned exactly once, to be sent as the cookie value. Only its digest is retained. */\n token: string;\n record: DeviceRecord;\n}\n\nexport interface DeviceAuth {\n enrol(input: { userAgent: string | undefined }): EnrolledDevice;\n /** The device this cookie names, or undefined. Bumps last-seen as a side effect. */\n verify(token: string | undefined): DeviceRecord | undefined;\n revoke(id: string): boolean;\n revokeAll(): number;\n list(): readonly DeviceRecord[];\n /** Re-arms exact deadlines after expiry settings change. */\n reschedule(): void;\n /** Drops what has expired under the current settings. Returns how many went. */\n sweep(): number;\n}\n\nfunction digest(token: string): string {\n // No salt and no KDF, deliberately. The input is 256 bits of uniform\n // randomness, so there is no dictionary to attack and a slow hash would only\n // add latency to every request. Do not \"fix\" this into bcrypt.\n return createHash('sha256').update(token).digest('hex');\n}\n\n/**\n * Paired sessions, held in memory only.\n *\n * Keyed by digest rather than scanned, so verification is a map lookup and\n * there is no comparison loop for a timing side channel to live in. That is why\n * `timingSafeEqual` does not appear here: there is nothing to compare. If a\n * future change introduces a scan, it will need one.\n */\nexport function createDeviceAuth(options: DeviceAuthOptions): DeviceAuth {\n const now = options.now ?? ((): number => Date.now());\n const notice = options.onNotice ?? ((): void => {});\n /** Digest to record. The raw token exists only in flight. */\n const devices = new Map<string, DeviceRecord>();\n const timers = new Map<string, ReturnType<typeof setTimeout>>();\n\n const clearDeadline = (hash: string): void => {\n const timer = timers.get(hash);\n if (timer !== undefined) clearTimeout(timer);\n timers.delete(hash);\n };\n\n const drop = (hash: string, reason: 'revoked' | 'expired'): DeviceRecord | undefined => {\n const record = devices.get(hash);\n if (record === undefined) return undefined;\n devices.delete(hash);\n clearDeadline(hash);\n options.onDrop?.(record, reason);\n return record;\n };\n\n const schedule = (hash: string, record: DeviceRecord): void => {\n clearDeadline(hash);\n const deadline = deviceExpiryAt(record, options.settings());\n if (deadline === undefined) return;\n const remaining = deadline - now();\n if (remaining <= 0) {\n drop(hash, 'expired');\n return;\n }\n const timer = setTimeout(\n () => {\n const current = devices.get(hash);\n if (current === undefined) return;\n if (evaluateDevice(current, now(), options.settings()).ok) schedule(hash, current);\n else drop(hash, 'expired');\n },\n Math.min(remaining, 2_147_483_647),\n );\n timer.unref();\n timers.set(hash, timer);\n };\n return {\n enrol(input) {\n const token = randomBytes(TOKEN_BYTES).toString('base64url');\n const userAgent = sanitizeUserAgent(input.userAgent);\n const at = now();\n const record: DeviceRecord = {\n id: randomBytes(ID_BYTES).toString('hex'),\n tokenHash: digest(token),\n label: deviceLabelFor(userAgent),\n userAgent,\n createdAt: at,\n lastSeenAt: at,\n };\n devices.set(record.tokenHash, record);\n schedule(record.tokenHash, record);\n notice(`paired ${record.label} (${record.id})`);\n return { token, record };\n },\n\n verify(token) {\n if (token === undefined || token === '') return undefined;\n const hash = digest(token);\n const at = now();\n const verdict = evaluateDevice(devices.get(hash), at, options.settings());\n if (!verdict.ok) {\n // An expired record is dropped on the way past rather than left to the\n // sweeper, so a revoked-by-time device stops appearing in the list.\n if (verdict.reason !== 'unknown') drop(hash, 'expired');\n return undefined;\n }\n const seen = touchDevice(verdict.record, at);\n devices.set(hash, seen);\n schedule(hash, seen);\n return seen;\n },\n\n revoke(id) {\n for (const [hash, record] of devices) {\n if (record.id !== id) continue;\n drop(hash, 'revoked');\n notice(`revoked ${record.label} (${record.id})`);\n return true;\n }\n return false;\n },\n\n revokeAll() {\n const records = [...devices.values()];\n for (const [hash] of devices) drop(hash, 'revoked');\n if (records.length > 0) notice(`revoked ${String(records.length)} paired device(s)`);\n return records.length;\n },\n\n list: () => [...devices.values()],\n\n reschedule() {\n for (const [hash, record] of devices) schedule(hash, record);\n },\n sweep() {\n const at = now();\n const settings = options.settings();\n let dropped = 0;\n for (const [hash, record] of devices) {\n if (evaluateDevice(record, at, settings).ok) continue;\n drop(hash, 'expired');\n dropped += 1;\n }\n return dropped;\n },\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import{deviceExpiryAt as e,deviceLabelFor as t,evaluateDevice as n,sanitizeUserAgent as r,touchDevice as i}from"../services/deviceSessions.mjs";import{createHash as a,randomBytes as o}from"node:crypto";function s(e){return a(`sha256`).update(e).digest(`hex`)}function c(a){let c=a.now??(()=>Date.now()),l=a.onNotice??(()=>{}),u=new Map,d=new Map,f=e=>{let t=d.get(e);t!==void 0&&clearTimeout(t),d.delete(e)},p=(e,t)=>{let n=u.get(e);if(n!==void 0)return u.delete(e),f(e),a.onDrop?.(n,t),n},m=(t,r)=>{f(t);let i=e(r,a.settings());if(i===void 0)return;let o=i-c();if(o<=0){p(t,`expired`);return}let s=setTimeout(()=>{let e=u.get(t);e!==void 0&&(n(e,c(),a.settings()).ok?m(t,e):p(t,`expired`))},Math.min(o,2147483647));s.unref(),d.set(t,s)};return{enrol(e){let n=o(32).toString(`base64url`),i=r(e.userAgent),a=c(),d={id:o(8).toString(`hex`),tokenHash:s(n),label:t(i),userAgent:i,createdAt:a,lastSeenAt:a};return u.set(d.tokenHash,d),m(d.tokenHash,d),l(`paired ${d.label} (${d.id})`),{token:n,record:d}},verify(e){if(e===void 0||e===``)return;let t=s(e),r=c(),o=n(u.get(t),r,a.settings());if(!o.ok){o.reason!==`unknown`&&p(t,`expired`);return}let l=i(o.record,r);return u.set(t,l),m(t,l),l},revoke(e){for(let[t,n]of u)if(n.id===e)return p(t,`revoked`),l(`revoked ${n.label} (${n.id})`),!0;return!1},revokeAll(){let e=[...u.values()];for(let[e]of u)p(e,`revoked`);return e.length>0&&l(`revoked ${String(e.length)} paired device(s)`),e.length},list:()=>[...u.values()],reschedule(){for(let[e,t]of u)m(e,t)},sweep(){let e=c(),t=a.settings(),r=0;for(let[i,a]of u)n(a,e,t).ok||(p(i,`expired`),r+=1);return r}}}export{c as createDeviceAuth};
2
+ //# sourceMappingURL=deviceAuth.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deviceAuth.mjs","names":[],"sources":["../../../src/adapters/deviceAuth.ts"],"sourcesContent":["import { createHash, randomBytes } from 'node:crypto';\nimport {\n type DeviceRecord,\n deviceExpiryAt,\n deviceLabelFor,\n evaluateDevice,\n sanitizeUserAgent,\n touchDevice,\n} from '../services/deviceSessions.ts';\nimport type { RemoteAccessSettings } from '../types/remoteAccess.ts';\n\n/** 256 bits. The token is scanned or set by the server, never typed, so there is no reason to economise. */\nconst TOKEN_BYTES = 32;\nconst ID_BYTES = 8;\n\nexport interface DeviceAuthOptions {\n /** Read fresh on every check, so a changed toggle takes effect on the next request. */\n settings: () => RemoteAccessSettings;\n now?: () => number;\n onNotice?: (message: string) => void;\n onDrop?: (record: DeviceRecord, reason: 'revoked' | 'expired') => void;\n}\n\nexport interface EnrolledDevice {\n /** Returned exactly once, to be sent as the cookie value. Only its digest is retained. */\n token: string;\n record: DeviceRecord;\n}\n\nexport interface DeviceAuth {\n enrol(input: { userAgent: string | undefined }): EnrolledDevice;\n /** The device this cookie names, or undefined. Bumps last-seen as a side effect. */\n verify(token: string | undefined): DeviceRecord | undefined;\n revoke(id: string): boolean;\n revokeAll(): number;\n list(): readonly DeviceRecord[];\n /** Re-arms exact deadlines after expiry settings change. */\n reschedule(): void;\n /** Drops what has expired under the current settings. Returns how many went. */\n sweep(): number;\n}\n\nfunction digest(token: string): string {\n // No salt and no KDF, deliberately. The input is 256 bits of uniform\n // randomness, so there is no dictionary to attack and a slow hash would only\n // add latency to every request. Do not \"fix\" this into bcrypt.\n return createHash('sha256').update(token).digest('hex');\n}\n\n/**\n * Paired sessions, held in memory only.\n *\n * Keyed by digest rather than scanned, so verification is a map lookup and\n * there is no comparison loop for a timing side channel to live in. That is why\n * `timingSafeEqual` does not appear here: there is nothing to compare. If a\n * future change introduces a scan, it will need one.\n */\nexport function createDeviceAuth(options: DeviceAuthOptions): DeviceAuth {\n const now = options.now ?? ((): number => Date.now());\n const notice = options.onNotice ?? ((): void => {});\n /** Digest to record. The raw token exists only in flight. */\n const devices = new Map<string, DeviceRecord>();\n const timers = new Map<string, ReturnType<typeof setTimeout>>();\n\n const clearDeadline = (hash: string): void => {\n const timer = timers.get(hash);\n if (timer !== undefined) clearTimeout(timer);\n timers.delete(hash);\n };\n\n const drop = (hash: string, reason: 'revoked' | 'expired'): DeviceRecord | undefined => {\n const record = devices.get(hash);\n if (record === undefined) return undefined;\n devices.delete(hash);\n clearDeadline(hash);\n options.onDrop?.(record, reason);\n return record;\n };\n\n const schedule = (hash: string, record: DeviceRecord): void => {\n clearDeadline(hash);\n const deadline = deviceExpiryAt(record, options.settings());\n if (deadline === undefined) return;\n const remaining = deadline - now();\n if (remaining <= 0) {\n drop(hash, 'expired');\n return;\n }\n const timer = setTimeout(\n () => {\n const current = devices.get(hash);\n if (current === undefined) return;\n if (evaluateDevice(current, now(), options.settings()).ok) schedule(hash, current);\n else drop(hash, 'expired');\n },\n Math.min(remaining, 2_147_483_647),\n );\n timer.unref();\n timers.set(hash, timer);\n };\n return {\n enrol(input) {\n const token = randomBytes(TOKEN_BYTES).toString('base64url');\n const userAgent = sanitizeUserAgent(input.userAgent);\n const at = now();\n const record: DeviceRecord = {\n id: randomBytes(ID_BYTES).toString('hex'),\n tokenHash: digest(token),\n label: deviceLabelFor(userAgent),\n userAgent,\n createdAt: at,\n lastSeenAt: at,\n };\n devices.set(record.tokenHash, record);\n schedule(record.tokenHash, record);\n notice(`paired ${record.label} (${record.id})`);\n return { token, record };\n },\n\n verify(token) {\n if (token === undefined || token === '') return undefined;\n const hash = digest(token);\n const at = now();\n const verdict = evaluateDevice(devices.get(hash), at, options.settings());\n if (!verdict.ok) {\n // An expired record is dropped on the way past rather than left to the\n // sweeper, so a revoked-by-time device stops appearing in the list.\n if (verdict.reason !== 'unknown') drop(hash, 'expired');\n return undefined;\n }\n const seen = touchDevice(verdict.record, at);\n devices.set(hash, seen);\n schedule(hash, seen);\n return seen;\n },\n\n revoke(id) {\n for (const [hash, record] of devices) {\n if (record.id !== id) continue;\n drop(hash, 'revoked');\n notice(`revoked ${record.label} (${record.id})`);\n return true;\n }\n return false;\n },\n\n revokeAll() {\n const records = [...devices.values()];\n for (const [hash] of devices) drop(hash, 'revoked');\n if (records.length > 0) notice(`revoked ${String(records.length)} paired device(s)`);\n return records.length;\n },\n\n list: () => [...devices.values()],\n\n reschedule() {\n for (const [hash, record] of devices) schedule(hash, record);\n },\n sweep() {\n const at = now();\n const settings = options.settings();\n let dropped = 0;\n for (const [hash, record] of devices) {\n if (evaluateDevice(record, at, settings).ok) continue;\n drop(hash, 'expired');\n dropped += 1;\n }\n return dropped;\n },\n };\n}\n"],"mappings":"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"}