@ljwei-stak/dsh-model-route-bot 0.66.3 → 0.67.0

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 (272) hide show
  1. package/.dsh-plugin/bot-product.mjs +81 -5
  2. package/.dsh-plugin/client.js +17490 -1791
  3. package/README.i18n.yaml +4 -4
  4. package/README.md +409 -364
  5. package/README.zh.md +409 -364
  6. package/apps/integration-client/README.md +31 -7
  7. package/apps/integration-client/api.mjs +14 -3
  8. package/apps/integration-client/binding-editor.jsx +308 -109
  9. package/apps/integration-client/binding-model.mjs +262 -0
  10. package/apps/integration-client/binding.css +144 -0
  11. package/apps/integration-client/bot-model-status.mjs +18 -0
  12. package/apps/integration-client/bot-profile-identity.jsx +197 -0
  13. package/apps/integration-client/bot-profile.css +57 -0
  14. package/apps/integration-client/bot-profile.mjs +97 -0
  15. package/apps/integration-client/bot-settings-dialog.css +18 -0
  16. package/apps/integration-client/bot-settings-dialog.jsx +111 -0
  17. package/apps/integration-client/bot-thread-header-bridge.css +20 -0
  18. package/apps/integration-client/bot-thread-header.jsx +278 -0
  19. package/apps/integration-client/bot-threads-panel.jsx +378 -0
  20. package/apps/integration-client/bot-threads.css +169 -0
  21. package/apps/integration-client/chat-design.mjs +250 -0
  22. package/apps/integration-client/chat-ui.jsx +175 -0
  23. package/apps/integration-client/chat.css +477 -0
  24. package/apps/integration-client/composer-draft-binding.mjs +50 -0
  25. package/apps/integration-client/composer-draft-bound.jsx +14 -0
  26. package/apps/integration-client/composer-draft-host.jsx +142 -0
  27. package/apps/integration-client/composer-draft-session.jsx +287 -0
  28. package/apps/integration-client/composer-draft-witness.mjs +115 -0
  29. package/apps/integration-client/composer-draft.css +4 -0
  30. package/apps/integration-client/composer-draft.mjs +322 -0
  31. package/apps/integration-client/contact-attention.jsx +54 -0
  32. package/apps/integration-client/data-panel.jsx +439 -0
  33. package/apps/integration-client/data.css +69 -0
  34. package/apps/integration-client/desktop-bridge.mjs +1 -0
  35. package/apps/integration-client/desktop-http-socket.mjs +49 -0
  36. package/apps/integration-client/desktop-keyboard.mjs +32 -0
  37. package/apps/integration-client/desktop-pane.jsx +99 -30
  38. package/apps/integration-client/desktop-stream.mjs +13 -3
  39. package/apps/integration-client/desktop-tools.jsx +19 -0
  40. package/apps/integration-client/desktop-touch.mjs +27 -0
  41. package/apps/integration-client/desktop.css +41 -0
  42. package/apps/integration-client/independent-chat-controller.mjs +224 -0
  43. package/apps/integration-client/independent-chat-entry.jsx +61 -0
  44. package/apps/integration-client/independent-chat-navigation.mjs +21 -0
  45. package/apps/integration-client/independent-chat-page.jsx +148 -0
  46. package/apps/integration-client/independent-chat-rich.jsx +64 -0
  47. package/apps/integration-client/independent-chat-rich.mjs +30 -0
  48. package/apps/integration-client/independent-chat.css +73 -0
  49. package/apps/integration-client/index.jsx +24 -16
  50. package/apps/integration-client/library-panel.jsx +4 -1
  51. package/apps/integration-client/licenses/jsqr-1.4.0-LICENSE.txt +202 -0
  52. package/apps/integration-client/main-route-editor.jsx +94 -0
  53. package/apps/integration-client/main-route-model.mjs +100 -0
  54. package/apps/integration-client/model-picker.css +37 -0
  55. package/apps/integration-client/model-picker.jsx +83 -0
  56. package/apps/integration-client/native-activity.css +141 -0
  57. package/apps/integration-client/native-activity.jsx +276 -0
  58. package/apps/integration-client/native-activity.mjs +384 -0
  59. package/apps/integration-client/notifications-panel.jsx +186 -0
  60. package/apps/integration-client/notifications.css +126 -0
  61. package/apps/integration-client/pairing-qr.jsx +79 -0
  62. package/apps/integration-client/panels.jsx +126 -115
  63. package/apps/integration-client/product-slots.jsx +46 -0
  64. package/apps/integration-client/routine-recorder.mjs +23 -2
  65. package/apps/integration-client/secure-pairing.jsx +84 -0
  66. package/apps/integration-client/tasks-panel.jsx +32 -10
  67. package/apps/integration-client/ui-icons.jsx +17 -0
  68. package/apps/integration-client/usage-explorer.jsx +43 -0
  69. package/apps/integration-client/usage-report.mjs +71 -0
  70. package/apps/integration-client/usage.css +34 -0
  71. package/apps/integration-client/web-panel-model.mjs +140 -0
  72. package/apps/integration-client/web-panel-page.jsx +221 -0
  73. package/apps/integration-client/web-panel-pins-live.jsx +28 -0
  74. package/apps/integration-client/web-panel.css +139 -0
  75. package/apps/integration-client/workspace-model.mjs +67 -0
  76. package/apps/integration-client/workspace-navigation.mjs +56 -0
  77. package/apps/integration-client/workspace-ui.jsx +173 -0
  78. package/apps/integration-client/workspace.css +332 -0
  79. package/modules/bot-runtime/SOURCE.json +176 -50
  80. package/modules/bot-runtime/index.mjs +169 -13
  81. package/modules/bot-runtime/product-overlay/OVERLAY.json +128 -2
  82. package/modules/bot-runtime/product-overlay/base/src/client/header.js +244 -0
  83. package/modules/bot-runtime/product-overlay/base/src/client/learning-card.js +202 -0
  84. package/modules/bot-runtime/product-overlay/base/src/client/model-menu.js +371 -0
  85. package/modules/bot-runtime/product-overlay/base/src/client/secret-card.js +363 -0
  86. package/modules/bot-runtime/product-overlay/base/src/client/sources.js +68 -0
  87. package/modules/bot-runtime/product-overlay/base/src/client/turn-error.js +260 -0
  88. package/modules/bot-runtime/product-overlay/base/src/host/channel.js +647 -0
  89. package/modules/bot-runtime/product-overlay/base/src/host/memory.js +2050 -0
  90. package/modules/bot-runtime/product-overlay/base/src/host/models.js +314 -0
  91. package/modules/bot-runtime/product-overlay/base/src/host/parts.js +1473 -0
  92. package/modules/bot-runtime/product-overlay/base/src/host/privacy.js +368 -0
  93. package/modules/bot-runtime/product-overlay/base/src/host/roster.js +776 -0
  94. package/modules/bot-runtime/product-overlay/base/src/host/store.js +512 -0
  95. package/modules/bot-runtime/product-overlay/base/src/host/usage.js +653 -0
  96. package/modules/bot-runtime/product-overlay/patches/src/client/details-drawer.js.patch +48 -2
  97. package/modules/bot-runtime/product-overlay/patches/src/client/header.js.patch +41 -0
  98. package/modules/bot-runtime/product-overlay/patches/src/client/index.js.patch +33 -1
  99. package/modules/bot-runtime/product-overlay/patches/src/client/learning-card.js.patch +20 -0
  100. package/modules/bot-runtime/product-overlay/patches/src/client/model-menu.js.patch +24 -0
  101. package/modules/bot-runtime/product-overlay/patches/src/client/secret-card.js.patch +25 -0
  102. package/modules/bot-runtime/product-overlay/patches/src/client/sources.js.patch +52 -0
  103. package/modules/bot-runtime/product-overlay/patches/src/client/turn-error.js.patch +23 -0
  104. package/modules/bot-runtime/product-overlay/patches/src/host/channel.js.patch +46 -0
  105. package/modules/bot-runtime/product-overlay/patches/src/host/memory.js.patch +11 -0
  106. package/modules/bot-runtime/product-overlay/patches/src/host/models.js.patch +16 -0
  107. package/modules/bot-runtime/product-overlay/patches/src/host/parts.js.patch +95 -0
  108. package/modules/bot-runtime/product-overlay/patches/src/host/privacy.js.patch +11 -0
  109. package/modules/bot-runtime/product-overlay/patches/src/host/roster.js.patch +145 -0
  110. package/modules/bot-runtime/product-overlay/patches/src/host/store.js.patch +68 -0
  111. package/modules/bot-runtime/product-overlay/patches/src/host/usage.js.patch +11 -0
  112. package/modules/bot-runtime/read-model.mjs +19 -4
  113. package/modules/bot-runtime/vendor/ds-bot/src/client/details-drawer.js +15 -5
  114. package/modules/bot-runtime/vendor/ds-bot/src/client/header.js +9 -2
  115. package/modules/bot-runtime/vendor/ds-bot/src/client/index.js +9 -2
  116. package/modules/bot-runtime/vendor/ds-bot/src/client/learning-card.js +2 -2
  117. package/modules/bot-runtime/vendor/ds-bot/src/client/model-menu.js +5 -3
  118. package/modules/bot-runtime/vendor/ds-bot/src/client/secret-card.js +3 -3
  119. package/modules/bot-runtime/vendor/ds-bot/src/client/sources.js +25 -2
  120. package/modules/bot-runtime/vendor/ds-bot/src/client/turn-error.js +3 -2
  121. package/modules/bot-runtime/vendor/ds-bot/src/host/channel.js +14 -2
  122. package/modules/bot-runtime/vendor/ds-bot/src/host/memory.js +1 -1
  123. package/modules/bot-runtime/vendor/ds-bot/src/host/models.js +7 -0
  124. package/modules/bot-runtime/vendor/ds-bot/src/host/parts.js +26 -9
  125. package/modules/bot-runtime/vendor/ds-bot/src/host/privacy.js +1 -1
  126. package/modules/bot-runtime/vendor/ds-bot/src/host/roster.js +112 -3
  127. package/modules/bot-runtime/vendor/ds-bot/src/host/store.js +34 -1
  128. package/modules/bot-runtime/vendor/ds-bot/src/host/usage.js +1 -1
  129. package/modules/independent-runtime/index.mjs +152 -62
  130. package/modules/independent-runtime/remote-tools.mjs +8 -5
  131. package/modules/openmaus-protocol/NOTICE +10 -0
  132. package/modules/openmaus-protocol/README.md +15 -0
  133. package/modules/openmaus-protocol/SOURCE.json +68 -0
  134. package/modules/openmaus-protocol/dist/protocol.mjs +1203 -0
  135. package/modules/openmaus-protocol/index.mjs +1 -0
  136. package/modules/openmaus-protocol/transport.mjs +77 -0
  137. package/modules/openmaus-protocol/vendor/LICENSE +202 -0
  138. package/modules/openmaus-protocol/vendor/companion/src/e2e/README.md +405 -0
  139. package/modules/openmaus-protocol/vendor/companion/src/e2e/credentials.ts +128 -0
  140. package/modules/openmaus-protocol/vendor/companion/src/e2e/descriptor.ts +84 -0
  141. package/modules/openmaus-protocol/vendor/companion/src/e2e/noise.ts +456 -0
  142. package/modules/openmaus-protocol/vendor/companion/src/e2e/phone.ts +104 -0
  143. package/modules/openmaus-protocol/vendor/companion/src/e2e/session.ts +417 -0
  144. package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.json +2565 -0
  145. package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.sha256 +1 -0
  146. package/modules/openmaus-protocol/vendor/companion/src/e2e/yamux.ts +335 -0
  147. package/package.json +20 -6
  148. package/packages/bot-acp/README.md +156 -0
  149. package/packages/bot-acp/licenses/LICENSE-OpenMausBot +202 -0
  150. package/packages/bot-acp/licenses/NOTICE.md +27 -0
  151. package/packages/bot-acp/src/acp-adapter.mjs +795 -0
  152. package/packages/bot-acp/src/acp-host.mjs +148 -0
  153. package/packages/bot-acp/src/native-launch.mjs +121 -0
  154. package/packages/bot-acp/src/permission-policy.mjs +283 -0
  155. package/packages/bot-acp/src/rpc-codec.mjs +265 -0
  156. package/packages/bot-acp/src/session-core.mjs +581 -0
  157. package/packages/bot-acp/src/stdio-rpc.mjs +329 -0
  158. package/packages/bot-engines/README.md +281 -0
  159. package/packages/bot-engines/licenses/LICENSE-OpenMausBot +202 -0
  160. package/packages/bot-engines/licenses/NOTICE.md +32 -0
  161. package/packages/bot-engines/src/claude-engine.mjs +772 -0
  162. package/packages/bot-engines/src/claude-llm-adapter.mjs +797 -0
  163. package/packages/bot-engines/src/claude-llm-turn.mjs +292 -0
  164. package/packages/bot-engines/src/claude-permission-broker.mjs +130 -0
  165. package/packages/bot-engines/src/claude-permission-proxy.mjs +87 -0
  166. package/packages/bot-engines/src/claude-protocol.mjs +177 -0
  167. package/packages/bot-engines/src/driver-catalog.mjs +334 -0
  168. package/packages/bot-engines/src/host-loop-request.mjs +95 -0
  169. package/packages/bot-engines/src/host-registrations.mjs +105 -0
  170. package/packages/capability-broker/src/cli-bootstrap.mjs +86 -16
  171. package/packages/capability-broker/src/cli-bridge-response.mjs +80 -0
  172. package/packages/capability-broker/src/cli-stdio-bridge.mjs +21 -8
  173. package/packages/cli-runtime/src/codex-session-core.mjs +4 -0
  174. package/packages/cli-runtime/src/native-cli-output.mjs +57 -0
  175. package/packages/cli-runtime/src/native-peer.ps1 +51 -3
  176. package/packages/cli-runtime/src/native-process.mjs +77 -14
  177. package/packages/model-router-plugin/README.md +3 -3
  178. package/packages/model-router-plugin/package.json +1 -1
  179. package/packages/orchestration/src/binding-product.mjs +19 -0
  180. package/packages/orchestration/src/bot-data-actions.mjs +45 -0
  181. package/packages/orchestration/src/bot-data-chart.mjs +109 -0
  182. package/packages/orchestration/src/bot-data-context.mjs +44 -0
  183. package/packages/orchestration/src/bot-data-engine.mjs +809 -0
  184. package/packages/orchestration/src/bot-data-io.mjs +186 -0
  185. package/packages/orchestration/src/bot-data-sql.mjs +195 -0
  186. package/packages/orchestration/src/bot-data-surface.mjs +144 -0
  187. package/packages/orchestration/src/bot-data-tools.mjs +68 -0
  188. package/packages/orchestration/src/bot-data-worker.mjs +228 -0
  189. package/packages/orchestration/src/bot-independent-execution.mjs +195 -0
  190. package/packages/orchestration/src/bot-independent-stream.mjs +74 -0
  191. package/packages/orchestration/src/bot-loop.mjs +152 -19
  192. package/packages/orchestration/src/bot-notifications.mjs +159 -0
  193. package/packages/orchestration/src/bot-policy.mjs +43 -4
  194. package/packages/orchestration/src/bot-profile.mjs +171 -0
  195. package/packages/orchestration/src/bot-routine.mjs +4 -4
  196. package/packages/orchestration/src/bot-runtime-assembly.mjs +255 -8
  197. package/packages/orchestration/src/bot-runtime-switch.mjs +13 -2
  198. package/packages/orchestration/src/bot-serving.mjs +164 -0
  199. package/packages/orchestration/src/bot-session-store.mjs +105 -9
  200. package/packages/orchestration/src/bot-settings.mjs +50 -11
  201. package/packages/orchestration/src/bot-thread-admission.mjs +84 -0
  202. package/packages/orchestration/src/bot-thread-host.mjs +187 -0
  203. package/packages/orchestration/src/bot-threads.mjs +790 -0
  204. package/packages/orchestration/src/data-product-access.mjs +37 -0
  205. package/packages/orchestration/src/data-tool-contract.mjs +291 -0
  206. package/packages/orchestration/src/desktop-http-carrier.mjs +120 -0
  207. package/packages/orchestration/src/desktop-proxy.mjs +311 -0
  208. package/packages/orchestration/src/desktop-sessions.mjs +43 -9
  209. package/packages/orchestration/src/ds-lifecycle.mjs +31 -4
  210. package/packages/orchestration/src/grant-refresh-queue.mjs +17 -0
  211. package/packages/orchestration/src/independent-chat-page.mjs +39 -0
  212. package/packages/orchestration/src/independent-chat-service.mjs +308 -0
  213. package/packages/orchestration/src/independent-context-state.mjs +44 -0
  214. package/packages/orchestration/src/independent-message-wire.mjs +84 -0
  215. package/packages/orchestration/src/independent-tool-catalog.mjs +175 -0
  216. package/packages/orchestration/src/independent-tool-families.mjs +73 -0
  217. package/packages/orchestration/src/independent-tool-host.mjs +344 -0
  218. package/packages/orchestration/src/main-bot-grant.mjs +15 -0
  219. package/packages/orchestration/src/main-model-routes.mjs +208 -0
  220. package/packages/orchestration/src/model-accounting.mjs +99 -35
  221. package/packages/orchestration/src/model-auxiliary-dispatch.mjs +187 -0
  222. package/packages/orchestration/src/model-loop-frame.mjs +179 -0
  223. package/packages/orchestration/src/model-prepared-generation.mjs +175 -0
  224. package/packages/orchestration/src/model-request-bounds.mjs +111 -0
  225. package/packages/orchestration/src/native-activity-contract.mjs +491 -0
  226. package/packages/orchestration/src/native-activity-service.mjs +102 -0
  227. package/packages/orchestration/src/native-activity-store.mjs +249 -0
  228. package/packages/orchestration/src/native-conversation-tools.mjs +78 -0
  229. package/packages/orchestration/src/native-data-tools.mjs +100 -0
  230. package/packages/orchestration/src/native-remote-actions.mjs +742 -0
  231. package/packages/orchestration/src/native-team-tools.mjs +73 -0
  232. package/packages/orchestration/src/pairing-invite.mjs +37 -0
  233. package/packages/orchestration/src/product-composition.mjs +1559 -1001
  234. package/packages/orchestration/src/remote-machines.mjs +82 -14
  235. package/packages/orchestration/src/routine-execution.mjs +5 -3
  236. package/packages/orchestration/src/routine-session.mjs +19 -13
  237. package/packages/orchestration/src/secure-pairing-host.mjs +112 -0
  238. package/packages/orchestration/src/task-journal.mjs +29 -0
  239. package/packages/orchestration/src/task-product.mjs +17 -0
  240. package/packages/orchestration/src/team-assignment-store.mjs +120 -0
  241. package/packages/orchestration/src/team-bridge.mjs +282 -0
  242. package/packages/orchestration/src/team-scheduler.mjs +83 -0
  243. package/packages/orchestration/src/team-sdk-tools.mjs +84 -0
  244. package/packages/orchestration/src/team-tool-contract.mjs +284 -0
  245. package/packages/orchestration/src/ui-host-namespace.mjs +23 -0
  246. package/packages/orchestration/src/web-panel-actions.mjs +103 -0
  247. package/packages/orchestration/src/web-panel-framing.mjs +227 -0
  248. package/packages/orchestration/src/web-panel-store.mjs +206 -0
  249. package/remote-computer-agent/README.md +39 -29
  250. package/remote-computer-agent/build.mjs +3 -0
  251. package/remote-computer-agent/dist/OPENMAUSBOT_LICENSE +202 -0
  252. package/remote-computer-agent/dist/OPENMAUSBOT_NOTICE +10 -0
  253. package/remote-computer-agent/dist/OPENMAUSBOT_SOURCE.json +68 -0
  254. package/remote-computer-agent/dist/cli-stdio-bridge.mjs +215 -25
  255. package/remote-computer-agent/dist/cli-stdio-bridge.mjs.map +4 -4
  256. package/remote-computer-agent/dist/cli.mjs +1688 -168
  257. package/remote-computer-agent/dist/cli.mjs.map +4 -4
  258. package/remote-computer-agent/dist/index.mjs +1579 -122
  259. package/remote-computer-agent/dist/index.mjs.map +4 -4
  260. package/remote-computer-agent/dist/native-peer.ps1 +51 -3
  261. package/remote-computer-agent/package-lock.json +308 -2
  262. package/remote-computer-agent/package.json +2 -1
  263. package/remote-computer-agent/pair-device.ps1 +20 -0
  264. package/remote-computer-agent/setup.ps1 +38 -16
  265. package/remote-computer-agent/src/cli.ts +24 -3
  266. package/remote-computer-agent/src/desktop-session.ts +11 -3
  267. package/remote-computer-agent/src/e2e-server.ts +81 -0
  268. package/remote-computer-agent/src/pairing-page.ts +35 -0
  269. package/remote-computer-agent/src/protocol.ts +25 -25
  270. package/remote-computer-agent/src/secure-pairing.ts +105 -0
  271. package/remote-computer-agent/src/store.ts +20 -5
  272. package/remote-computer-agent/src/transport.ts +39 -15
@@ -0,0 +1,311 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
2
+ import { WebSocketServer } from 'ws'
3
+
4
+ export const DESKTOP_PROXY_PATH = '/bot-product/desktop/v1/socket'
5
+ export const DESKTOP_PROXY_LIMITS = Object.freeze({ maxConnections: 16, maxTickets: 32, authTimeoutMs: 5000,
6
+ maxPayload: 9 * 1024 * 1024, maxBufferedBytes: 12 * 1024 * 1024, maxQueuedFrames: 64, maxTicketMs: 300_000 })
7
+ export const DESKTOP_PROXY_UPSTREAM_OPTIONS = Object.freeze({ autoPong: false, perMessageDeflate: false, maxPayload: DESKTOP_PROXY_LIMITS.maxPayload })
8
+
9
+ const fail = code => { throw Object.assign(new Error(code), { code }) }
10
+ const identifier = value => typeof value === 'string' && /^[-A-Za-z0-9_.]{1,128}$/u.test(value)
11
+ const token = value => typeof value === 'string' && /^[A-Za-z0-9_-]{43}$/u.test(value)
12
+ const digest = value => createHash('sha256').update(value).digest()
13
+ const matches = (value, expected) => token(value) && timingSafeEqual(digest(value), expected)
14
+ const isSignal = signal => signal && typeof signal.addEventListener === 'function' && typeof signal.removeEventListener === 'function'
15
+ const owner = peer => peer?.principal?.kind === 'owner' && isSignal(peer.signal) && !peer.signal.aborted
16
+ const originOf = value => {
17
+ if (typeof value !== 'string') fail('DESKTOP_PROXY_ORIGIN_DENIED')
18
+ try {
19
+ const url = new URL(value)
20
+ if (!['http:', 'https:'].includes(url.protocol) || url.origin !== value || url.username || url.password) fail('DESKTOP_PROXY_ORIGIN_DENIED')
21
+ return value
22
+ } catch { fail('DESKTOP_PROXY_ORIGIN_DENIED') }
23
+ }
24
+ const synchronous = value => {
25
+ if (value && typeof value.then === 'function') {
26
+ Promise.resolve(value).catch(() => {})
27
+ fail('DESKTOP_PROXY_AUTHORITY_INVALID')
28
+ }
29
+ return value
30
+ }
31
+ const bufferOf = data => Buffer.isBuffer(data) ? data : Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data)
32
+
33
+ export function desktopTicketMetadata(ticket) {
34
+ const result = { sessionId: ticket.sessionId, expiresAt: ticket.expiresAt }
35
+ for (const key of ['taskId', 'attemptId', 'displayId', 'coordinateSpace']) {
36
+ if (typeof ticket[key] === 'string' && ticket[key].length <= 256 && !/[\u0000-\u001f]/u.test(ticket[key])) result[key] = ticket[key]
37
+ }
38
+ if (['watch', 'control'].includes(ticket.mode)) result.mode = ticket.mode
39
+ for (const key of ['canControl', 'controlAllowed']) if (typeof ticket[key] === 'boolean') result[key] = ticket[key]
40
+ for (const key of ['width', 'height', 'fps']) if (Number.isSafeInteger(ticket[key]) && ticket[key] > 0) result[key] = ticket[key]
41
+ if (Array.isArray(ticket.displays)) result.displays = Object.freeze(ticket.displays.slice(0, 32).map(display => {
42
+ const row = {}
43
+ for (const key of ['id', 'name']) if (typeof display?.[key] === 'string' && display[key].length <= 256 && !/[\u0000-\u001f]/u.test(display[key])) row[key] = display[key]
44
+ for (const key of ['width', 'height', 'x', 'y', 'dpi', 'scaleFactor']) if (Number.isFinite(display?.[key])) row[key] = display[key]
45
+ if (typeof display?.primary === 'boolean') row.primary = display.primary
46
+ return Object.freeze(row)
47
+ }))
48
+ return result
49
+ }
50
+
51
+ /**
52
+ * Host-only desktop carrier. validate is synchronous and closes over committed authority;
53
+ * connect creates a paired upstream Node ws with DESKTOP_PROXY_UPSTREAM_OPTIONS.
54
+ * ownerPeer must be the exact Peer admitted for the ticket's originating owner action.
55
+ */
56
+ export function createDesktopProxy({ webServer, connection, now = Date.now }) {
57
+ if (typeof webServer?.registerUpgrade !== 'function' || !['http:', 'https:'].includes(webServer.protocol)
58
+ || typeof connection?.admitConnection !== 'function' || typeof connection?.admit !== 'function' || typeof now !== 'function') fail('DESKTOP_PROXY_REQUIRES_HOST')
59
+ const limits = DESKTOP_PROXY_LIMITS, rows = new Map(), states = new Set(), signalWatches = new Map(), releases = new Set()
60
+ const sockets = new WebSocketServer({ noServer: true, autoPong: false, perMessageDeflate: false,
61
+ maxPayload: limits.maxPayload, maxFragments: 1024, maxBufferedChunks: 1024 })
62
+ sockets.on('error', () => {})
63
+ let closing = false, closed
64
+
65
+ const watch = (signal, callback) => {
66
+ if (!signal || typeof signal.addEventListener !== 'function' || typeof signal.removeEventListener !== 'function') fail('DESKTOP_PROXY_AUTHORITY_INVALID')
67
+ let entry = signalWatches.get(signal)
68
+ if (!entry) {
69
+ entry = { callbacks: new Set(), abort: () => { for (const fn of [...entry.callbacks]) fn() } }
70
+ signalWatches.set(signal, entry); signal.addEventListener('abort', entry.abort, { once: true })
71
+ }
72
+ entry.callbacks.add(callback)
73
+ return () => {
74
+ entry.callbacks.delete(callback)
75
+ if (!entry.callbacks.size) { signal.removeEventListener('abort', entry.abort); signalWatches.delete(signal) }
76
+ }
77
+ }
78
+ const release = state => {
79
+ if (!state.admission || state.released) return
80
+ state.released = true
81
+ let result
82
+ try { result = state.admission.release() } catch { return }
83
+ const pending = Promise.resolve(result).catch(() => {})
84
+ releases.add(pending); void pending.finally(() => releases.delete(pending))
85
+ }
86
+ const endRow = row => {
87
+ if (row.ended) return
88
+ row.ended = true; row.secret = null; row.connect = null; row.validate = null; row.active = null
89
+ for (const off of row.unwatch) off()
90
+ row.unwatch = []
91
+ }
92
+ const rejectHttp = (state, status) => {
93
+ if (state.raw.destroyed) return
94
+ const reason = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 409: 'Conflict', 429: 'Too Many Requests', 503: 'Service Unavailable' }[status] ?? 'Forbidden'
95
+ const text = `HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`
96
+ const timer = setTimeout(() => state.raw.destroy(), 250); timer.unref()
97
+ state.raw.once('close', () => clearTimeout(timer))
98
+ try { state.raw.end(text, () => state.raw.destroy()) } catch { state.raw.destroy() }
99
+ }
100
+ const stop = (state, code = 'DESKTOP_PROXY_DISCONNECTED', closeCode = 1008, httpStatus) => {
101
+ if (state.ended) return
102
+ state.ended = true; states.delete(state); clearTimeout(state.timer); state.controller.abort()
103
+ state.unwatch?.(); state.unwatch = null
104
+ if (state.row) endRow(state.row)
105
+ for (const channel of [state.toBrowser, state.toAgent]) { channel.queue = []; channel.bytes = 0; channel.count = 0 }
106
+ if (state.upstream) {
107
+ try { state.upstream.terminate() } catch {}
108
+ }
109
+ if (state.browser) {
110
+ const ws = state.browser
111
+ if (ws.readyState === 1) {
112
+ try {
113
+ if (ws.bufferedAmount <= 2048) ws.send(JSON.stringify({ type: 'error', code }), { compress: false }, () => {})
114
+ ws.close(closeCode, code)
115
+ } catch { ws.terminate() }
116
+ } else if (ws.readyState !== 3) ws.terminate()
117
+ if (ws.readyState !== 3) { const timer = setTimeout(() => ws.terminate(), 250); timer.unref(); ws.once('close', () => clearTimeout(timer)) }
118
+ } else if (httpStatus) rejectHttp(state, httpStatus)
119
+ else state.raw.destroy()
120
+ release(state)
121
+ }
122
+ const checkRow = row => {
123
+ if (closing || row.ended || !owner(row.ownerPeer) || row.signal?.aborted || row.expiresAt <= now()) fail('DESKTOP_PROXY_REVOKED')
124
+ if (synchronous(row.validate({ sessionId: row.sessionId, ownerPeer: row.ownerPeer })) === false) fail('DESKTOP_PROXY_REVOKED')
125
+ }
126
+ const check = state => {
127
+ if (state.ended || closing || !owner(state.admission?.peer) || state.deadline !== null && state.deadline <= now()) fail('DESKTOP_PROXY_REVOKED')
128
+ const admission = synchronous(connection.admit(state.request))
129
+ if (!admission || 'rejection' in admission || admission.peer !== state.admission.peer || !owner(admission.peer)) fail('DESKTOP_PROXY_REVOKED')
130
+ if (state.row) checkRow(state.row)
131
+ }
132
+ const channelOf = () => ({ target: null, queue: [], bytes: 0, count: 0, sending: false })
133
+ const pump = (state, channel) => {
134
+ if (state.ended || channel.sending || channel.target?.readyState !== 1 || !channel.queue.length) return
135
+ try { check(state) } catch { stop(state, 'DESKTOP_PROXY_REVOKED'); return }
136
+ const item = channel.queue.shift(), target = channel.target
137
+ channel.sending = true
138
+ const done = error => {
139
+ if (state.ended) return
140
+ channel.sending = false; channel.bytes -= item.data.length; channel.count -= 1
141
+ if (error) stop(state, 'DESKTOP_PROXY_DISCONNECTED', 1011)
142
+ else pump(state, channel)
143
+ }
144
+ try {
145
+ if (item.kind === 'message') target.send(item.data, { binary: item.binary, compress: false }, done)
146
+ else target[item.kind](item.data, undefined, done)
147
+ } catch { stop(state, 'DESKTOP_PROXY_DISCONNECTED', 1011) }
148
+ }
149
+ const enqueue = (state, channel, kind, data, binary = false) => {
150
+ if (state.ended) return
151
+ try {
152
+ check(state)
153
+ const bytes = bufferOf(data), size = bytes.length, buffered = channel.target?.bufferedAmount ?? 0
154
+ if (size > limits.maxPayload || kind !== 'message' && size > 125 || kind === 'message' && !binary && size > 65536) {
155
+ stop(state, 'DESKTOP_PROXY_MESSAGE_LIMIT', 1009); return
156
+ }
157
+ if (!Number.isFinite(buffered) || Math.max(channel.bytes, buffered) + size > limits.maxBufferedBytes || channel.count >= limits.maxQueuedFrames) {
158
+ stop(state, 'DESKTOP_PROXY_BACKPRESSURE', 1013); return
159
+ }
160
+ channel.queue.push({ kind, data: bytes, binary }); channel.bytes += size; channel.count += 1
161
+ pump(state, channel)
162
+ } catch { stop(state, 'DESKTOP_PROXY_REVOKED') }
163
+ }
164
+ const attachUpstream = (state, upstream) => {
165
+ if (!upstream || typeof upstream.on !== 'function' || typeof upstream.terminate !== 'function') fail('DESKTOP_PROXY_UNAVAILABLE')
166
+ upstream.on('error', () => stop(state, 'DESKTOP_PROXY_DISCONNECTED', 1011))
167
+ if (state.ended) { upstream.terminate(); return }
168
+ state.upstream = upstream
169
+ if (![0, 1].includes(upstream.readyState) || ['send', 'ping', 'pong'].some(name => typeof upstream[name] !== 'function')) fail('DESKTOP_PROXY_UNAVAILABLE')
170
+ upstream.once('close', () => stop(state, 'DESKTOP_PROXY_DISCONNECTED', 1011))
171
+ upstream.on('message', (data, binary) => enqueue(state, state.toBrowser, 'message', data, binary))
172
+ // DS Desktop uses the pong after a JPEG as its receipt. Neither proxy leg may auto-pong.
173
+ upstream.on('ping', data => enqueue(state, state.toBrowser, 'ping', data))
174
+ upstream.on('pong', data => enqueue(state, state.toBrowser, 'pong', data))
175
+ let opened = false
176
+ const open = () => {
177
+ if (opened || state.ended) return
178
+ opened = true
179
+ try { check(state) } catch { stop(state, 'DESKTOP_PROXY_REVOKED'); return }
180
+ clearTimeout(state.timer); state.deadline = null; state.phase = 'forwarding'
181
+ state.toAgent.target = upstream; pump(state, state.toAgent)
182
+ }
183
+ upstream.once('open', open)
184
+ if (upstream.readyState === 1) open()
185
+ }
186
+ const authenticate = (state, data, binary) => {
187
+ let row
188
+ try {
189
+ if (binary || bufferOf(data).length > 2048) fail('DESKTOP_PROXY_AUTH_INVALID')
190
+ const value = JSON.parse(bufferOf(data).toString('utf8'))
191
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !['type', 'sessionId', 'token'].includes(key))
192
+ || value.type !== 'auth' || !identifier(value.sessionId) || !token(value.token)) fail('DESKTOP_PROXY_AUTH_INVALID')
193
+ row = rows.get(value.sessionId)
194
+ if (!row || row.used || row.ended || row.ownerPeer !== state.admission.peer || row.clientOrigin !== state.origin || !matches(value.token, row.proxyDigest)) fail('DESKTOP_PROXY_AUTH_INVALID')
195
+ check(state); checkRow(row)
196
+ } catch { stop(state, 'DESKTOP_PROXY_AUTH_INVALID'); return }
197
+ row.used = true; row.active = state; state.row = row; state.phase = 'connecting'
198
+ clearTimeout(state.timer); state.deadline = now() + limits.authTimeoutMs
199
+ state.timer = setTimeout(() => stop(state, 'DESKTOP_PROXY_CONNECT_TIMEOUT', 1011), limits.authTimeoutMs); state.timer.unref()
200
+ const connect = row.connect, auth = Buffer.from(JSON.stringify({ type: 'auth', sessionId: row.sessionId, token: row.secret }))
201
+ row.secret = null
202
+ enqueue(state, state.toAgent, 'message', auth)
203
+ if (state.ended) return
204
+ try {
205
+ Promise.resolve(connect({ signal: state.controller.signal, sessionId: row.sessionId, webSocketOptions: DESKTOP_PROXY_UPSTREAM_OPTIONS }))
206
+ .then(upstream => { try { attachUpstream(state, upstream) } catch { stop(state, 'DESKTOP_PROXY_UNAVAILABLE', 1011) } }, () => stop(state, 'DESKTOP_PROXY_UNAVAILABLE', 1011))
207
+ } catch { stop(state, 'DESKTOP_PROXY_UNAVAILABLE', 1011) }
208
+ }
209
+ const accept = (state, browser) => {
210
+ if (state.ended) { browser.on('error', () => {}); browser.terminate(); return }
211
+ state.browser = browser; state.toBrowser.target = browser
212
+ browser.on('error', () => stop(state, 'DESKTOP_PROXY_DISCONNECTED', 1011))
213
+ browser.once('close', () => stop(state, 'DESKTOP_PROXY_DISCONNECTED', 1011))
214
+ browser.on('message', (data, binary) => state.phase === 'auth' ? authenticate(state, data, binary) : enqueue(state, state.toAgent, 'message', data, binary))
215
+ for (const kind of ['ping', 'pong']) browser.on(kind, data => {
216
+ if (state.phase === 'auth') stop(state, 'DESKTOP_PROXY_AUTH_INVALID')
217
+ else enqueue(state, state.toAgent, kind, data)
218
+ })
219
+ state.raw.resume()
220
+ }
221
+ const handleUpgrade = async (request, raw, head) => {
222
+ const state = { raw, browser: null, upstream: null, row: null, ended: false, released: false, admission: null,
223
+ phase: 'auth', controller: new AbortController(), toBrowser: channelOf(), toAgent: channelOf(), deadline: now() + limits.authTimeoutMs }
224
+ raw.on('error', () => stop(state, 'DESKTOP_PROXY_DISCONNECTED', 1011))
225
+ raw.once('close', () => stop(state, 'DESKTOP_PROXY_DISCONNECTED', 1011))
226
+ raw.pause()
227
+ state.timer = setTimeout(() => stop(state, 'DESKTOP_PROXY_AUTH_REQUIRED', 1008, 401), limits.authTimeoutMs); state.timer.unref()
228
+ if (closing || states.size >= limits.maxConnections) { stop(state, 'DESKTOP_PROXY_CONNECTION_LIMIT', 1013, closing ? 503 : 429); return }
229
+ states.add(state)
230
+ try {
231
+ if (request.url !== DESKTOP_PROXY_PATH || head.length > 8192 || request.headers['sec-websocket-protocol'] !== undefined) { stop(state, 'DESKTOP_PROXY_REQUEST_INVALID', 1008, 400); return }
232
+ state.origin = originOf(request.headers.origin)
233
+ const host = request.headers.host
234
+ if (typeof host !== 'string' || new URL(`${webServer.protocol}//${host}`).origin !== state.origin) { stop(state, 'DESKTOP_PROXY_ORIGIN_DENIED', 1008, 403); return }
235
+ state.request = { headers: Object.freeze(Object.fromEntries(Object.entries(request.headers).map(([key, value]) => [key, Array.isArray(value) ? Object.freeze([...value]) : value]))) }
236
+ const admission = await connection.admitConnection(state.request)
237
+ if (admission && !('rejection' in admission) && typeof admission.release === 'function') state.admission = admission
238
+ if (state.ended) { release(state); return }
239
+ if (!state.admission || !owner(admission.peer)) { stop(state, 'DESKTOP_PROXY_AUTH_INVALID', 1008, admission?.rejection === 401 ? 401 : admission?.rejection === 409 ? 409 : 403); return }
240
+ check(state)
241
+ state.unwatch = watch(admission.peer.signal, () => stop(state, 'DESKTOP_PROXY_REVOKED'))
242
+ if (admission.peer.signal.aborted || state.ended) { stop(state, 'DESKTOP_PROXY_REVOKED', 1008, 403); return }
243
+ sockets.handleUpgrade(request, raw, head, browser => accept(state, browser))
244
+ } catch { stop(state, 'DESKTOP_PROXY_AUTH_INVALID', 1008, 403) }
245
+ }
246
+ const unregister = webServer.registerUpgrade({ path: DESKTOP_PROXY_PATH, handler: handleUpgrade })
247
+ const sweep = setInterval(() => {
248
+ for (const state of [...states]) {
249
+ if (state.deadline !== null && state.deadline <= now()) { stop(state, state.phase === 'auth' ? 'DESKTOP_PROXY_AUTH_REQUIRED' : 'DESKTOP_PROXY_CONNECT_TIMEOUT', 1008, state.browser ? undefined : 401); continue }
250
+ if (state.admission) try { check(state) } catch { stop(state, 'DESKTOP_PROXY_REVOKED') }
251
+ }
252
+ for (const [key, row] of rows) {
253
+ if (row.expiresAt <= now()) { if (row.active) stop(row.active, 'DESKTOP_PROXY_EXPIRED'); endRow(row); rows.delete(key) }
254
+ else if (!row.ended) try { checkRow(row) } catch { if (row.active) stop(row.active, 'DESKTOP_PROXY_REVOKED'); endRow(row) }
255
+ }
256
+ }, 100)
257
+ sweep.unref()
258
+
259
+ return Object.freeze({
260
+ wrapTicket(ticket, { ownerPeer, clientOrigin, connect, validate, signal } = {}) {
261
+ if (closing) fail('DESKTOP_PROXY_CLOSED')
262
+ if (!ticket || !identifier(ticket.sessionId) || !token(ticket.token) || !Number.isSafeInteger(ticket.expiresAt)
263
+ || ticket.expiresAt <= now() || ticket.expiresAt > now() + limits.maxTicketMs
264
+ || typeof connect !== 'function' || typeof validate !== 'function' || !owner(ownerPeer) || signal !== undefined && (!isSignal(signal) || signal.aborted)) fail('DESKTOP_PROXY_TICKET_INVALID')
265
+ const origin = originOf(clientOrigin), sourceDigest = digest(ticket.token), previous = rows.get(ticket.sessionId)
266
+ if (previous) {
267
+ if (previous.ended || previous.expiresAt <= now() || previous.ownerPeer !== ownerPeer || previous.clientOrigin !== origin
268
+ || !timingSafeEqual(previous.sourceDigest, sourceDigest)) fail('DESKTOP_PROXY_TICKET_INVALID')
269
+ const candidate = { ...previous, expiresAt: ticket.expiresAt, connect, validate, signal }
270
+ checkRow(candidate)
271
+ previous.expiresAt = ticket.expiresAt; previous.connect = connect; previous.validate = validate
272
+ if (signal !== previous.signal) {
273
+ for (const off of previous.unwatch) off()
274
+ previous.signal = signal
275
+ previous.unwatch = [watch(ownerPeer.signal, () => { if (previous.active) stop(previous.active, 'DESKTOP_PROXY_REVOKED'); endRow(previous) })]
276
+ if (signal) previous.unwatch.push(watch(signal, () => { if (previous.active) stop(previous.active, 'DESKTOP_PROXY_REVOKED'); endRow(previous) }))
277
+ }
278
+ return Object.freeze({ ...desktopTicketMetadata(ticket), websocketPath: DESKTOP_PROXY_PATH, token: previous.proxyToken })
279
+ }
280
+ for (const [key, row] of rows) if (row.expiresAt <= now()) { if (row.active) stop(row.active, 'DESKTOP_PROXY_EXPIRED'); endRow(row); rows.delete(key) }
281
+ if (rows.size >= limits.maxTickets) fail('DESKTOP_PROXY_TICKET_LIMIT')
282
+ const proxyToken = randomBytes(32).toString('base64url')
283
+ const row = { sessionId: ticket.sessionId, expiresAt: ticket.expiresAt, ownerPeer, clientOrigin: origin, connect, validate, signal,
284
+ secret: ticket.token, sourceDigest, proxyToken, proxyDigest: digest(proxyToken), used: false, ended: false, active: null, unwatch: [] }
285
+ checkRow(row)
286
+ const revoke = () => { if (row.active) stop(row.active, 'DESKTOP_PROXY_REVOKED'); endRow(row) }
287
+ row.unwatch.push(watch(ownerPeer.signal, revoke))
288
+ if (signal) row.unwatch.push(watch(signal, revoke))
289
+ rows.set(row.sessionId, row)
290
+ if (ownerPeer.signal.aborted || signal?.aborted) { revoke(); fail('DESKTOP_PROXY_REVOKED') }
291
+ return Object.freeze({ ...desktopTicketMetadata(ticket), websocketPath: DESKTOP_PROXY_PATH, token: proxyToken })
292
+ },
293
+ revoke(sessionId) {
294
+ const row = rows.get(sessionId)
295
+ if (!row) return false
296
+ if (row.active) stop(row.active, 'DESKTOP_PROXY_REVOKED')
297
+ endRow(row)
298
+ return true
299
+ },
300
+ close() {
301
+ if (closed) return closed
302
+ closing = true; clearInterval(sweep)
303
+ try { unregister?.() } catch {}
304
+ for (const state of [...states]) stop(state, 'DESKTOP_PROXY_CLOSED', 1001)
305
+ for (const row of rows.values()) endRow(row)
306
+ rows.clear()
307
+ closed = Promise.allSettled([...releases, new Promise(resolve => sockets.close(() => resolve()))]).then(() => undefined)
308
+ return closed
309
+ },
310
+ })
311
+ }
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto'
2
2
 
3
3
  const INPUT_TOOLS = ['desktop_click', 'desktop_key', 'desktop_type', 'desktop_scroll']
4
+ const NODE_CARRIER=Symbol('host-only-desktop-carrier')
4
5
  const fail = code => { throw Object.assign(new Error(code), { code }) }
5
6
  const id = value => typeof value === 'string' && /^[-A-Za-z0-9_.]{1,128}$/u.test(value)
6
7
  const closed = (value, names) => {
@@ -8,7 +9,7 @@ const closed = (value, names) => {
8
9
  }
9
10
 
10
11
  /** Only owner actions mint session tickets. Pairing secrets and Agent grants never reach the viewer. */
11
- export function createDesktopSessionHost({ machine, binding, authority, issue, request, event, now = Date.now }) {
12
+ export function createDesktopSessionHost({ machine, binding, authority, issue, request, cleanupRequest, event, desktopProxy, desktopHttp, connectDesktop, now = Date.now }) {
12
13
  const sessions = new Map()
13
14
  const check = (subject, row) => {
14
15
  const botId = authority.describe(subject).botId
@@ -20,6 +21,7 @@ export function createDesktopSessionHost({ machine, binding, authority, issue, r
20
21
  const tools = ['desktop_screenshot', ...INPUT_TOOLS.filter(tool => grant.scope.mode === 'workspace-write' && grant.scope.tools.includes(tool)
21
22
  && (() => { try { authority.assertTool(subject, `remote/${tool}`); return true } catch { return false } })())]
22
23
  if (row.mode === 'control' && INPUT_TOOLS.some(tool => !tools.includes(tool))) fail('MACHINE_BOT_DENIED')
24
+ if (row.tools?.some(tool => !tools.includes(tool))) fail('MACHINE_BOT_DENIED')
23
25
  return { record, grant, tools }
24
26
  }
25
27
  const remember = (row, value) => {
@@ -29,10 +31,27 @@ export function createDesktopSessionHost({ machine, binding, authority, issue, r
29
31
  sessions.set(value.sessionId, { ...row, sessionId: value.sessionId, expiresAt: value.expiresAt })
30
32
  const url = new URL(value.path, row.record.config.endpoint)
31
33
  url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
32
- return { sessionId: value.sessionId, token: value.token, expiresAt: value.expiresAt, websocketUrl: url.href,
34
+ const ticket = { sessionId: value.sessionId, token: value.token, expiresAt: value.expiresAt, websocketUrl: url.href,
33
35
  mode: row.mode, taskId: row.identity.taskId, attemptId: row.identity.attemptId,
34
36
  canControl: INPUT_TOOLS.every(tool => row.tools.includes(tool)),
35
37
  displays: Array.isArray(value.displays) ? value.displays : [], displayId: value.displayId ?? row.displayId ?? null }
38
+ if (!row.record.config.transport) return ticket
39
+ if(row.internal)return {...ticket,websocketUrl:undefined,openWebSocket:async()=>{
40
+ check(row.subject,row)
41
+ const socket=await connectDesktop(row.record,undefined,undefined,{autoPong:true})
42
+ const validate=()=>{const live=sessions.get(value.sessionId);if(!live||live.expiresAt<=now())fail('DESKTOP_SESSION_EXPIRED');check(live.subject,live)}
43
+ try{validate()}catch(error){socket.terminate();throw error}
44
+ const send=socket.send.bind(socket)
45
+ socket.send=(...args)=>{try{validate()}catch(error){socket.terminate();throw error};return send(...args)}
46
+ const sweep=setInterval(()=>{try{const live=sessions.get(row.sessionId??value.sessionId);if(!live)fail('DESKTOP_SESSION_EXPIRED');check(live.subject,live);if(live.expiresAt<=now())fail('DESKTOP_SESSION_EXPIRED')}catch{socket.terminate()}},100)
47
+ sweep.unref();socket.once('close',()=>clearInterval(sweep))
48
+ return socket
49
+ }}
50
+ const carrier=row.clientOrigin==='dsh-app://app'?desktopHttp:desktopProxy
51
+ return carrier.wrapTicket(ticket, { ownerPeer: row.ownerPeer, botId: row.botId, clientOrigin: row.clientOrigin,
52
+ validate: () => { check(row.subject,row); if (ticket.expiresAt<=now()) fail('DESKTOP_SESSION_EXPIRED'); return true },
53
+ connect: async ({signal}) => connectDesktop(row.record,signal,row.clientOrigin),
54
+ })
36
55
  }
37
56
  const current = (subject, input) => {
38
57
  closed(input, ['sessionId'])
@@ -43,7 +62,9 @@ export function createDesktopSessionHost({ machine, binding, authority, issue, r
43
62
  return row
44
63
  }
45
64
  return Object.freeze({
46
- async open(subject, input, { signal, clientOrigin, taskId, attemptId } = {}) {
65
+ openInternal(subject,input,context={}) { if(!id(context.taskId)||!id(context.attemptId))fail('INVALID_DESKTOP_IDENTITY');return this.open(subject,input,{...context,[NODE_CARRIER]:true}) },
66
+ async open(subject, input, context = {}) {
67
+ const {signal,clientOrigin,taskId,attemptId,peer}=context
47
68
  closed(input, ['machineId', 'rootId', 'mode', 'displayId', 'fps'])
48
69
  if (!id(input.machineId) || !id(input.rootId) || input.mode !== undefined && !['watch', 'control'].includes(input.mode)
49
70
  || input.fps !== undefined && (!Number.isInteger(input.fps) || input.fps < 5 || input.fps > 30)
@@ -54,6 +75,9 @@ export function createDesktopSessionHost({ machine, binding, authority, issue, r
54
75
  const row = { ...input, mode: input.mode ?? 'watch', botId: authority.describe(subject).botId,
55
76
  identity: { taskId: taskId ?? `owner-desktop-${randomUUID()}`, attemptId: attemptId ?? `attempt-${randomUUID()}` } }
56
77
  const { record, grant, tools } = check(subject, row)
78
+ row.internal=context[NODE_CARRIER]===true
79
+ if (record.config.transport && (typeof connectDesktop!=='function'||!row.internal&&(!(clientOrigin==='dsh-app://app'?desktopHttp:desktopProxy)||!peer))) fail('DESKTOP_PROXY_UNAVAILABLE')
80
+ row.subject=subject;row.ownerPeer=peer;row.clientOrigin=clientOrigin
57
81
  row.record = record; row.machineRevision = record.revision; row.bindingRevision = grant.revision; row.tools = tools
58
82
  row.grantToken = await issue(record, row.botId, { roots: [row.rootId], tools }, row.identity, signal)
59
83
  check(subject, row)
@@ -64,25 +88,35 @@ export function createDesktopSessionHost({ machine, binding, authority, issue, r
64
88
  event(record.id, row.botId, 'desktop-session-opened')
65
89
  return remember(row, value)
66
90
  },
67
- async renew(subject, input, { signal, clientOrigin } = {}) {
91
+ async renew(subject, input, { signal, clientOrigin, peer, taskId, attemptId } = {}) {
68
92
  const row = current(subject, input), { record, tools } = check(subject, row)
93
+ if(row.internal&&(taskId!==row.identity.taskId||attemptId!==row.identity.attemptId))fail('DESKTOP_SESSION_OWNER_MISMATCH')
94
+ if (record.config.transport && !row.internal && (peer!==row.ownerPeer || clientOrigin!==row.clientOrigin)) fail('MACHINE_BOT_DENIED')
69
95
  const grantToken = await issue(record, row.botId, { roots: [row.rootId], tools }, row.identity, signal)
70
96
  const value = await request(record.config, '/desktop/v1/session/renew', grantToken,
71
97
  { sessionId: row.sessionId, ...(clientOrigin ? { origin: clientOrigin } : {}) }, signal)
72
98
  check(subject, row)
73
99
  return remember({ ...row, grantToken, tools }, value)
74
100
  },
75
- async close(subject, input, { signal } = {}) {
76
- const row = current(subject, input)
77
- const value = await request(row.record.config, '/desktop/v1/session/close', row.grantToken, { sessionId: row.sessionId }, signal)
101
+ async close(subject, input, { signal, taskId, attemptId } = {}) {
102
+ closed(input,['sessionId']);if(!id(input.sessionId))fail('INVALID_DESKTOP_REQUEST')
103
+ const row=sessions.get(input.sessionId)
104
+ if(!row||authority.describe(subject).botId!==row.botId)fail('MACHINE_BOT_DENIED')
105
+ if(row.internal&&(taskId!==row.identity.taskId||attemptId!==row.identity.attemptId))fail('DESKTOP_SESSION_OWNER_MISMATCH')
106
+ desktopProxy?.revoke(row.sessionId)
107
+ desktopHttp?.revoke(row.sessionId)
108
+ const value = cleanupRequest?await cleanupRequest(row.record,{sessionId:row.sessionId,botId:row.botId,...row.identity},signal)
109
+ :await request(row.record.config, '/desktop/v1/session/close', row.grantToken, { sessionId: row.sessionId }, signal)
78
110
  if (value?.stopConfirmed !== true) fail('DESKTOP_STOP_UNCONFIRMED')
79
111
  sessions.delete(row.sessionId); event(row.machineId, row.botId, 'desktop-session-closed')
80
112
  return { stopConfirmed: true }
81
113
  },
82
114
  async closeAll() {
83
115
  return Promise.allSettled([...sessions.values()].map(async row => {
84
- const value = await request(row.record.config, '/desktop/v1/session/close', row.grantToken,
85
- { sessionId: row.sessionId }, AbortSignal.timeout(2500))
116
+ desktopProxy?.revoke(row.sessionId)
117
+ desktopHttp?.revoke(row.sessionId)
118
+ const value = cleanupRequest?await cleanupRequest(row.record,{sessionId:row.sessionId,botId:row.botId,...row.identity},AbortSignal.timeout(2500))
119
+ :await request(row.record.config, '/desktop/v1/session/close', row.grantToken,{ sessionId: row.sessionId }, AbortSignal.timeout(2500))
86
120
  if (value?.stopConfirmed !== true) fail('DESKTOP_STOP_UNCONFIRMED')
87
121
  sessions.delete(row.sessionId)
88
122
  event(row.machineId, row.botId, 'desktop-session-closed')
@@ -5,6 +5,8 @@ import { PolicyError } from '../../capability-broker/src/authority.mjs'
5
5
  const id = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value)
6
6
  const table = value => value && typeof value === 'object' && !Array.isArray(value)
7
7
  const fail = code => { throw new PolicyError(code) }
8
+ // The same ceiling the vendor roster enforces when it records a thread.
9
+ export const MAX_THREADS_PER_BOT = 64
8
10
 
9
11
  function normalizeGrant(grant) {
10
12
  if (!table(grant) || !Array.isArray(grant.tools) || !Array.isArray(grant.modelRoutes)) fail('INVALID_GRANT')
@@ -19,6 +21,8 @@ function normalizeGrant(grant) {
19
21
  /** Compile only active own/group Sessions; archived parts never gain authority. */
20
22
  export function compileDsOwnership(saved, grantFor) {
21
23
  if (!table(saved) || saved.version !== 1 || !Number.isSafeInteger(saved.revision) || saved.revision < 0 || !table(saved.bots) || !table(saved.rooms)) fail('INVALID_DS_STATE')
24
+ const mainBotIds = saved.mainBotIds ?? []
25
+ if (!Array.isArray(mainBotIds)) fail('INVALID_DS_MAIN_BOT')
22
26
  const bots = new Map(), sessions = new Map(),botRecords=new Map()
23
27
  const add = (sessionId, botId) => {
24
28
  if (!id(sessionId) || sessions.has(sessionId)) fail('AMBIGUOUS_SESSION_OWNER')
@@ -26,9 +30,29 @@ export function compileDsOwnership(saved, grantFor) {
26
30
  }
27
31
  for (const [botId,bot] of Object.entries(saved.bots)) {
28
32
  if (!id(botId) || !table(bot) || bot.id !== botId) fail('INVALID_DS_BOT')
29
- bots.set(botId,normalizeGrant(grantFor(structuredClone(bot))))
33
+ bots.set(botId,normalizeGrant(grantFor(structuredClone(bot), { main: mainBotIds.includes(botId) })))
30
34
  botRecords.set(botId,structuredClone(bot))
31
35
  add(bot.sessionId ?? botId,botId)
36
+ // A Bot's threads are further own chats of the same Bot, so a thread Session
37
+ // is authority-bearing exactly like the Bot's own chat is — same grant, same
38
+ // money, same tools. Only committed state can say so: nothing here reads a
39
+ // model argument, a request body or a client-supplied id. `add` already
40
+ // refuses a Session two Bots claim, and `commit` refuses one that moves
41
+ // between Bots, so a restored thread can only ever be its own Bot's again.
42
+ // An archived thread keeps its record and its readable history and gains no
43
+ // authority at all.
44
+ if (bot.threads !== undefined) {
45
+ if (!Array.isArray(bot.threads) || bot.threads.length > MAX_THREADS_PER_BOT) fail('INVALID_DS_BOT_THREADS')
46
+ const seenThreads = new Set(), seenSessions = new Set()
47
+ for (const thread of bot.threads) {
48
+ if (!table(thread) || !id(thread.threadId) || !id(thread.sessionId)) fail('INVALID_DS_BOT_THREAD')
49
+ if (seenThreads.has(thread.threadId) || seenSessions.has(thread.sessionId)) fail('AMBIGUOUS_THREAD_IDENTITY')
50
+ if (thread.archivedAt !== undefined && thread.archivedAt !== null && !Number.isSafeInteger(thread.archivedAt)) fail('INVALID_DS_BOT_THREAD')
51
+ seenThreads.add(thread.threadId)
52
+ seenSessions.add(thread.sessionId)
53
+ if (thread.archivedAt === undefined || thread.archivedAt === null) add(thread.sessionId,botId)
54
+ }
55
+ }
32
56
  }
33
57
  for (const room of Object.values(saved.rooms)) {
34
58
  if (!table(room) || !Array.isArray(room.members) || !table(room.sessions ?? {})) fail('INVALID_DS_ROOM')
@@ -38,8 +62,7 @@ export function compileDsOwnership(saved, grantFor) {
38
62
  }
39
63
  }
40
64
  if (bots.size > 4096 || sessions.size > 16384) fail('DS_STATE_LIMIT')
41
- const mainBotIds=saved.mainBotIds??[]
42
- if(!Array.isArray(mainBotIds)||mainBotIds.some(botId=>!bots.has(botId)))fail('INVALID_DS_MAIN_BOT')
65
+ if(mainBotIds.some(botId=>!bots.has(botId)))fail('INVALID_DS_MAIN_BOT')
43
66
  return { revision:saved.revision,bots,sessions,botRecords,mainBotIds:[...mainBotIds],sourceStamp:createHash('sha256').update(JSON.stringify(saved)).digest('hex') }
44
67
  }
45
68
 
@@ -175,9 +198,13 @@ export function attachDsLifecycle({ runtime, directory, grantFor, readSaved = ()
175
198
  return Object.freeze({
176
199
  observeAgent,
177
200
  committedBot(botId) { return healthy&&!closed&&committedBots.has(botId)?structuredClone(committedBots.get(botId)):null },
201
+ committedGrant(botId) { return healthy&&!closed&&records.has(botId)?structuredClone(records.get(botId).grant):null },
178
202
  committedBots() { return healthy&&!closed?[...committedBots.values()].map(bot=>structuredClone(bot)):[] },
179
203
  committedMainBotIds() { return healthy&&!closed?[...committedMainBotIds]:[] },
180
- async refreshGrants() { adoptDisk(await readSaved()) },
204
+ async refreshGrants() {
205
+ try { adoptDisk(await readSaved()) }
206
+ catch (cause) { suspend(); throw cause }
207
+ },
181
208
  status:()=>({healthy,closed,revision,bots:records.size,activeSessions:ownership.size,boundAgents:bindings.size}),
182
209
  close() {
183
210
  if (closed) return
@@ -0,0 +1,17 @@
1
+ /** Coalesce synchronous grant notifications and drain their reads before Host teardown. */
2
+ export function createGrantRefreshQueue({ lifecycle, onError = () => {} }) {
3
+ let closed = false, dirty = false, active = null
4
+ const request = () => {
5
+ if (closed) return
6
+ dirty = true
7
+ if (active) return
8
+ active = Promise.resolve().then(async () => {
9
+ while (dirty && !closed) {
10
+ dirty = false
11
+ try { await lifecycle()?.refreshGrants() }
12
+ catch (cause) { try { onError(cause) } catch { /* logging cannot leak a rejection */ } }
13
+ }
14
+ }).finally(() => { active = null })
15
+ }
16
+ return Object.freeze({ request, async close() { closed = true; dirty = false; await active } })
17
+ }
@@ -0,0 +1,39 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { join } from 'node:path'
3
+ import { openBotSessionStore } from './bot-session-store.mjs'
4
+
5
+ const fail = code => { throw Object.assign(new Error(code), { code }) }
6
+
7
+ /** Read-through view of the runtime's own transcript; one get is one snapshot. */
8
+ export function createIndependentConversationReader({ dir, maxBytes = 8 * 1024 * 1024 } = {}) {
9
+ if (typeof dir !== 'string' || !dir || !Number.isSafeInteger(maxBytes) || maxBytes < 256) fail('INDEPENDENT_CHAT_READER_INVALID')
10
+ const store = openBotSessionStore({ dir: join(dir, 'sessions') })
11
+ return (botId, { cursor = null, limit = 40 } = {}) => {
12
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || cursor !== null && (typeof cursor !== 'string' || !cursor || cursor.length > 256)) fail('INDEPENDENT_CHAT_PAGE_INVALID')
13
+ const snapshot = store.get(botId), messages = snapshot.messages
14
+ const ids = messages.map(row => row.messageId)
15
+ if (ids.some(id => typeof id !== 'string' || !id) || new Set(ids).size !== ids.length) fail('INDEPENDENT_CHAT_MESSAGE_ID_INVALID')
16
+ const end = cursor === null ? messages.length : ids.indexOf(cursor)
17
+ if (end < 0) fail('INDEPENDENT_CHAT_CURSOR_EXPIRED')
18
+ const items = []
19
+ let bytes = 0, start = end
20
+ while (start > 0 && items.length < limit) {
21
+ const row = messages[start - 1]
22
+ const item = { messageId: row.messageId, role: row.role, content: row.content, at: row.at,
23
+ ...(row.contextId ? { contextId: row.contextId } : {}),
24
+ ...(row.name ? { name: row.name } : {}),
25
+ ...(row.modelSource ? { modelSource: row.modelSource } : {}),
26
+ ...(row.toolCalls ? { toolCalls: row.toolCalls } : row.toolCall ? { toolCalls: [row.toolCall] } : {}),
27
+ ...(row.toolCallId || row.role === 'tool' && row.toolCall?.id ? { toolCallId: row.toolCallId ?? row.toolCall.id } : {}) }
28
+ const size = Buffer.byteLength(JSON.stringify(item))
29
+ if (bytes + size > maxBytes) {
30
+ if (!items.length) fail('INDEPENDENT_CHAT_MESSAGE_TOO_LARGE')
31
+ break
32
+ }
33
+ items.unshift(item); bytes += size; start--
34
+ }
35
+ return { schemaVersion: 1, botId, items, nextCursor: start > 0 ? ids[start] : null,
36
+ snapshotId: createHash('sha256').update(JSON.stringify([ids, snapshot.turns, snapshot.updatedAt])).digest('hex'),
37
+ truncated: snapshot.truncated, turns: snapshot.turns }
38
+ }
39
+ }