@ljwei-stak/dsh-model-route-bot 0.66.4 → 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 (271) 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 +18 -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/package.json +1 -1
  178. package/packages/orchestration/src/binding-product.mjs +19 -0
  179. package/packages/orchestration/src/bot-data-actions.mjs +45 -0
  180. package/packages/orchestration/src/bot-data-chart.mjs +109 -0
  181. package/packages/orchestration/src/bot-data-context.mjs +44 -0
  182. package/packages/orchestration/src/bot-data-engine.mjs +809 -0
  183. package/packages/orchestration/src/bot-data-io.mjs +186 -0
  184. package/packages/orchestration/src/bot-data-sql.mjs +195 -0
  185. package/packages/orchestration/src/bot-data-surface.mjs +144 -0
  186. package/packages/orchestration/src/bot-data-tools.mjs +68 -0
  187. package/packages/orchestration/src/bot-data-worker.mjs +228 -0
  188. package/packages/orchestration/src/bot-independent-execution.mjs +195 -0
  189. package/packages/orchestration/src/bot-independent-stream.mjs +74 -0
  190. package/packages/orchestration/src/bot-loop.mjs +152 -19
  191. package/packages/orchestration/src/bot-notifications.mjs +159 -0
  192. package/packages/orchestration/src/bot-policy.mjs +43 -4
  193. package/packages/orchestration/src/bot-profile.mjs +171 -0
  194. package/packages/orchestration/src/bot-routine.mjs +4 -4
  195. package/packages/orchestration/src/bot-runtime-assembly.mjs +255 -8
  196. package/packages/orchestration/src/bot-runtime-switch.mjs +13 -2
  197. package/packages/orchestration/src/bot-serving.mjs +164 -0
  198. package/packages/orchestration/src/bot-session-store.mjs +105 -9
  199. package/packages/orchestration/src/bot-settings.mjs +50 -11
  200. package/packages/orchestration/src/bot-thread-admission.mjs +84 -0
  201. package/packages/orchestration/src/bot-thread-host.mjs +187 -0
  202. package/packages/orchestration/src/bot-threads.mjs +790 -0
  203. package/packages/orchestration/src/data-product-access.mjs +37 -0
  204. package/packages/orchestration/src/data-tool-contract.mjs +291 -0
  205. package/packages/orchestration/src/desktop-http-carrier.mjs +120 -0
  206. package/packages/orchestration/src/desktop-proxy.mjs +311 -0
  207. package/packages/orchestration/src/desktop-sessions.mjs +43 -9
  208. package/packages/orchestration/src/ds-lifecycle.mjs +31 -4
  209. package/packages/orchestration/src/grant-refresh-queue.mjs +17 -0
  210. package/packages/orchestration/src/independent-chat-page.mjs +39 -0
  211. package/packages/orchestration/src/independent-chat-service.mjs +308 -0
  212. package/packages/orchestration/src/independent-context-state.mjs +44 -0
  213. package/packages/orchestration/src/independent-message-wire.mjs +84 -0
  214. package/packages/orchestration/src/independent-tool-catalog.mjs +175 -0
  215. package/packages/orchestration/src/independent-tool-families.mjs +73 -0
  216. package/packages/orchestration/src/independent-tool-host.mjs +344 -0
  217. package/packages/orchestration/src/main-bot-grant.mjs +15 -0
  218. package/packages/orchestration/src/main-model-routes.mjs +208 -0
  219. package/packages/orchestration/src/model-accounting.mjs +99 -35
  220. package/packages/orchestration/src/model-auxiliary-dispatch.mjs +187 -0
  221. package/packages/orchestration/src/model-loop-frame.mjs +179 -0
  222. package/packages/orchestration/src/model-prepared-generation.mjs +175 -0
  223. package/packages/orchestration/src/model-request-bounds.mjs +111 -0
  224. package/packages/orchestration/src/native-activity-contract.mjs +491 -0
  225. package/packages/orchestration/src/native-activity-service.mjs +102 -0
  226. package/packages/orchestration/src/native-activity-store.mjs +249 -0
  227. package/packages/orchestration/src/native-conversation-tools.mjs +78 -0
  228. package/packages/orchestration/src/native-data-tools.mjs +100 -0
  229. package/packages/orchestration/src/native-remote-actions.mjs +742 -0
  230. package/packages/orchestration/src/native-team-tools.mjs +73 -0
  231. package/packages/orchestration/src/pairing-invite.mjs +37 -0
  232. package/packages/orchestration/src/product-composition.mjs +1559 -1001
  233. package/packages/orchestration/src/remote-machines.mjs +82 -14
  234. package/packages/orchestration/src/routine-execution.mjs +5 -3
  235. package/packages/orchestration/src/routine-session.mjs +19 -13
  236. package/packages/orchestration/src/secure-pairing-host.mjs +112 -0
  237. package/packages/orchestration/src/task-journal.mjs +29 -0
  238. package/packages/orchestration/src/task-product.mjs +17 -0
  239. package/packages/orchestration/src/team-assignment-store.mjs +120 -0
  240. package/packages/orchestration/src/team-bridge.mjs +282 -0
  241. package/packages/orchestration/src/team-scheduler.mjs +83 -0
  242. package/packages/orchestration/src/team-sdk-tools.mjs +84 -0
  243. package/packages/orchestration/src/team-tool-contract.mjs +284 -0
  244. package/packages/orchestration/src/ui-host-namespace.mjs +23 -0
  245. package/packages/orchestration/src/web-panel-actions.mjs +103 -0
  246. package/packages/orchestration/src/web-panel-framing.mjs +227 -0
  247. package/packages/orchestration/src/web-panel-store.mjs +206 -0
  248. package/remote-computer-agent/README.md +39 -29
  249. package/remote-computer-agent/build.mjs +3 -0
  250. package/remote-computer-agent/dist/OPENMAUSBOT_LICENSE +202 -0
  251. package/remote-computer-agent/dist/OPENMAUSBOT_NOTICE +10 -0
  252. package/remote-computer-agent/dist/OPENMAUSBOT_SOURCE.json +68 -0
  253. package/remote-computer-agent/dist/cli-stdio-bridge.mjs +215 -25
  254. package/remote-computer-agent/dist/cli-stdio-bridge.mjs.map +4 -4
  255. package/remote-computer-agent/dist/cli.mjs +1688 -168
  256. package/remote-computer-agent/dist/cli.mjs.map +4 -4
  257. package/remote-computer-agent/dist/index.mjs +1579 -122
  258. package/remote-computer-agent/dist/index.mjs.map +4 -4
  259. package/remote-computer-agent/dist/native-peer.ps1 +51 -3
  260. package/remote-computer-agent/package-lock.json +308 -2
  261. package/remote-computer-agent/package.json +2 -1
  262. package/remote-computer-agent/pair-device.ps1 +20 -0
  263. package/remote-computer-agent/setup.ps1 +38 -16
  264. package/remote-computer-agent/src/cli.ts +24 -3
  265. package/remote-computer-agent/src/desktop-session.ts +11 -3
  266. package/remote-computer-agent/src/e2e-server.ts +81 -0
  267. package/remote-computer-agent/src/pairing-page.ts +35 -0
  268. package/remote-computer-agent/src/protocol.ts +25 -25
  269. package/remote-computer-agent/src/secure-pairing.ts +105 -0
  270. package/remote-computer-agent/src/store.ts +20 -5
  271. package/remote-computer-agent/src/transport.ts +39 -15
@@ -2,6 +2,7 @@ import { randomUUID, createHash } from 'node:crypto'
2
2
  import { mkdirSync } from 'node:fs'
3
3
  import { dirname, resolve } from 'node:path'
4
4
  import { createDesktopSessionHost } from './desktop-sessions.mjs'
5
+ import { connectSecureTransport, normalizeSecureTransport } from '../../../modules/openmaus-protocol/transport.mjs'
5
6
 
6
7
  export const REMOTE_TOOLS = Object.freeze(['machine_ping','fs_list','fs_read','fs_write','shell_exec','container_command_v1','cli_probe_v1','cli_task_v1',
7
8
  'operation_status','operation_cancel','artifact_stat','artifact_read','worktree_create','worktree_diff','worktree_integrate',
@@ -21,16 +22,18 @@ const references = (values, allowed) => {
21
22
  if (!Array.isArray(values) || !values.length || values.length > 64 || new Set(values).size !== values.length || values.some(v => !ref(v) || allowed && !allowed.includes(v))) fail('INVALID_MACHINE_SCOPE')
22
23
  return [...values].sort()
23
24
  }
24
- export function normalizeMachineEndpoint(value, protectedTunnel = false) {
25
+ export function normalizeMachineEndpoint(value, protectedTunnel = false, transport) {
25
26
  let url; try { url = new URL(value) } catch { fail('INVALID_MACHINE_ENDPOINT') }
26
27
  if (!['https:','http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/') fail('INVALID_MACHINE_ENDPOINT')
27
- if (url.protocol === 'http:' && !['127.0.0.1','[::1]','localhost'].includes(url.hostname) && protectedTunnel !== true) fail('MACHINE_TLS_REQUIRED')
28
+ if (transport !== undefined) normalizeSecureTransport(transport)
29
+ if (url.protocol === 'http:' && !['127.0.0.1','[::1]','localhost'].includes(url.hostname) && protectedTunnel !== true && transport?.kind !== 'noise-v1') fail('MACHINE_TLS_REQUIRED')
28
30
  return url.origin
29
31
  }
30
32
 
31
33
  /** Host metadata only. Long-lived credentials stay in the existing DS secret UI. */
32
- export function openRemoteMachines({ Database, file, resolveSecret, botExists, authority, cliProfiles = [], onChanged = () => {}, now = Date.now, fetchImpl = fetch }) {
34
+ export function openRemoteMachines({ Database, file, resolveSecret, botExists, mainBotIds = () => [], authority, desktopProxy, desktopHttp, cliProfiles = [], onChanged = () => {}, now = Date.now, fetchImpl = fetch }) {
33
35
  if (typeof Database !== 'function' || typeof resolveSecret !== 'function' || typeof botExists !== 'function') throw new TypeError('REMOTE_MACHINE_SERVICES_REQUIRED')
36
+ if (typeof mainBotIds !== 'function') throw new TypeError('REMOTE_MACHINE_MAIN_ROLE_REQUIRED')
34
37
  if (!Array.isArray(cliProfiles) || cliProfiles.length > 64 || new Set(cliProfiles.map(p=>`${p?.machineId}/${p?.rootId}`)).size !== cliProfiles.length
35
38
  || cliProfiles.some(p=>!p||!['machineId','rootId','profileId'].every(k=>ref(p[k]))||!Number.isSafeInteger(p.profileRevision)||p.profileRevision<1
36
39
  ||!/^[a-f0-9]{64}$/.test(p.entry?.sha256??'')||!/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(p.entry?.version??''))) throw new TypeError('REMOTE_CLI_PROFILES_INVALID')
@@ -41,6 +44,7 @@ export function openRemoteMachines({ Database, file, resolveSecret, botExists, a
41
44
  db.exec(`CREATE TABLE IF NOT EXISTS remote_machine_component(component TEXT PRIMARY KEY,version INTEGER NOT NULL);
42
45
  CREATE TABLE IF NOT EXISTS remote_machines(id TEXT PRIMARY KEY,revision INTEGER NOT NULL,config TEXT NOT NULL,revoked INTEGER NOT NULL,health TEXT);
43
46
  CREATE TABLE IF NOT EXISTS remote_machine_bindings(machine_id TEXT NOT NULL,bot_id TEXT NOT NULL,revision INTEGER NOT NULL,scope TEXT NOT NULL,revoked INTEGER NOT NULL,PRIMARY KEY(machine_id,bot_id));
47
+ CREATE TABLE IF NOT EXISTS remote_machine_grant_subjects(machine_id TEXT NOT NULL,bot_id TEXT NOT NULL,PRIMARY KEY(machine_id,bot_id));
44
48
  CREATE TABLE IF NOT EXISTS remote_machine_revocations(machine_id TEXT NOT NULL,bot_id TEXT NOT NULL,status TEXT NOT NULL,at_ms INTEGER NOT NULL,request_id TEXT NOT NULL,PRIMARY KEY(machine_id,bot_id));
45
49
  CREATE TABLE IF NOT EXISTS remote_machine_events(sequence INTEGER PRIMARY KEY AUTOINCREMENT,machine_id TEXT NOT NULL,bot_id TEXT,kind TEXT NOT NULL,time INTEGER NOT NULL);`)
46
50
  const version = db.prepare('SELECT version FROM remote_machine_component WHERE component=?').get('machines')
@@ -49,9 +53,28 @@ export function openRemoteMachines({ Database, file, resolveSecret, botExists, a
49
53
  if(!db.pragma('table_info(remote_machine_revocations)').some(column=>column.name==='request_id'))db.exec("ALTER TABLE remote_machine_revocations ADD COLUMN request_id TEXT NOT NULL DEFAULT ''")
50
54
  let closed = false
51
55
  const active = new Set()
56
+ const channels = new Map()
52
57
  const alive = () => { if (closed) fail('MACHINE_DIRECTORY_CLOSED') }
53
58
  const machine = id => { alive(); if (!ref(id)) fail('INVALID_MACHINE_ID'); const r = db.prepare('SELECT * FROM remote_machines WHERE id=?').get(id); if (!r || r.revoked) fail('MACHINE_NOT_PAIRED'); return { ...r, config: JSON.parse(r.config), health: r.health ? JSON.parse(r.health) : null } }
54
- const binding = (machineId,botId) => { const r = db.prepare('SELECT * FROM remote_machine_bindings WHERE machine_id=? AND bot_id=?').get(machineId,botId); if (!r || r.revoked || !botExists(botId)) fail('MACHINE_BOT_DENIED'); return { ...r, scope: JSON.parse(r.scope) } }
59
+ const binding = (machineId,botId) => {
60
+ const row = db.prepare('SELECT * FROM remote_machine_bindings WHERE machine_id=? AND bot_id=?').get(machineId,botId)
61
+ if (!botExists(botId) || row?.revoked) fail('MACHINE_BOT_DENIED')
62
+ if (mainBotIds().includes(botId)) {
63
+ const record = machine(machineId)
64
+ // Main inherits only this paired client's verified ceiling. The remote
65
+ // authority still authenticates the client, Bot and every issued grant.
66
+ return { machine_id: record.id, bot_id: botId, revision: record.revision,
67
+ scope: { roots: [...record.config.roots], tools: [...record.config.tools], mode: 'workspace-write' } }
68
+ }
69
+ if (!row) fail('MACHINE_BOT_DENIED')
70
+ return { ...row, scope: JSON.parse(row.scope) }
71
+ }
72
+ const mainScopes = botId => db.prepare('SELECT * FROM remote_machines WHERE revoked=0').all().flatMap(row => {
73
+ if (db.prepare('SELECT revoked FROM remote_machine_bindings WHERE machine_id=? AND bot_id=?').get(row.id,botId)?.revoked) return []
74
+ const config=JSON.parse(row.config)
75
+ return [{machineId:row.id,revision:row.revision,roots:[...config.roots],tools:[...config.tools],mode:'workspace-write',inheritedMain:true}]
76
+ })
77
+ const inheritedMainScopes = botId => botExists(botId)&&mainBotIds().includes(botId) ? mainScopes(botId) : null
55
78
  const event = (id,botId,kind) => db.prepare('INSERT INTO remote_machine_events(machine_id,bot_id,kind,time) VALUES(?,?,?,?)').run(id,botId,kind,now())
56
79
  const requestRevocation = (id,botId) => {
57
80
  const key=randomUUID()
@@ -75,7 +98,9 @@ export function openRemoteMachines({ Database, file, resolveSecret, botExists, a
75
98
  }
76
99
  const request = async (config,path,token,body,signal) => {
77
100
  try {
78
- return await responseJson(await fetchImpl(`${config.endpoint}${path}`, { method: 'POST', redirect: 'error',
101
+ const channel = config.transport ? await secureChannel(config,signal) : null
102
+ const send = channel ? (url,options) => channel.fetch(path,options) : fetchImpl
103
+ return await responseJson(await send(`${config.endpoint}${path}`, { method: 'POST', redirect: 'error',
79
104
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', 'MCP-Protocol-Version': '2025-11-25' },
80
105
  body: JSON.stringify(body), signal: AbortSignal.any([AbortSignal.timeout(35000), ...(signal ? [signal] : [])]) }))
81
106
  } catch (error) { if (signal?.aborted) throw signal.reason; if (/^[A-Z][A-Z0-9_]{0,80}$/.test(error?.code)) throw error; fail('MACHINE_CONNECTION_FAILED') }
@@ -86,13 +111,36 @@ export function openRemoteMachines({ Database, file, resolveSecret, botExists, a
86
111
  if (typeof secret !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(secret)) fail('MACHINE_SECRET_UNAVAILABLE')
87
112
  return secret
88
113
  }
114
+ const secureChannel = async (config,signal) => {
115
+ alive()
116
+ const token=await secretFor({config})
117
+ const key=JSON.stringify([config.endpoint,config.transport.publicKey,config.secretRef,createHash('sha256').update(token).digest('hex')])
118
+ let entry=channels.get(key)
119
+ if(!entry){
120
+ if(channels.size>=32)fail('MACHINE_CONNECTION_LIMIT')
121
+ const pending=connectSecureTransport({endpoint:config.endpoint,transport:config.transport,token,signal})
122
+ entry={config,pending};channels.set(key,entry)
123
+ void pending.then(channel=>{if(closed){channel.close();return};void channel.closed.then(()=>{if(channels.get(key)===entry)channels.delete(key)})},()=>{if(channels.get(key)===entry)channels.delete(key)})
124
+ }
125
+ const channel=await entry.pending
126
+ signal?.throwIfAborted();alive()
127
+ if(channel.state!=='open')fail('MACHINE_CONNECTION_FAILED')
128
+ return channel
129
+ }
130
+ const closeChannels = config => {
131
+ for(const [key,entry] of channels)if(!config||entry.config.secretRef===config.secretRef&&entry.config.endpoint===config.endpoint){channels.delete(key);void entry.pending.then(channel=>channel.close(),()=>{})}
132
+ }
89
133
  const versionUnsupported = (code,details) => { throw Object.assign(new Error(`${code}: require agentApiMajor=1 and MCP protocol 2025-11-25`), { code, details }) }
90
134
  const issue = async (record,botId,scope,identity,signal) => {
135
+ // Persist the identity before any network await, including pair probes and
136
+ // inherited Main grants that have no per-Bot binding row. Whole-machine
137
+ // revocation must reach every identity for which this Host requested tickets.
138
+ db.prepare('INSERT OR IGNORE INTO remote_machine_grant_subjects VALUES(?,?)').run(record.id,botId)
91
139
  const secret = await secretFor(record)
92
140
  signal?.throwIfAborted()
93
141
  const value = await request(record.config,'/control/v1/grants',secret,{ botId, taskId: identity.taskId, attemptId: identity.attemptId, roots: scope.roots, tools: scope.tools, ttlMs: 60000 },signal)
94
142
  if (value.agentApiMajor !== 1) versionUnsupported('MACHINE_VERSION_UNSUPPORTED', { requiredAgentApiMajor: 1, actualAgentApiMajor: value.agentApiMajor ?? null })
95
- if (value.machineId !== record.id || value.botId !== botId || value.taskId !== identity.taskId || value.attemptId !== identity.attemptId || typeof value.grantToken !== 'string') fail('MACHINE_IDENTITY_MISMATCH')
143
+ if (value.machineId !== record.id || record.config.clientId && value.clientId !== record.config.clientId || value.botId !== botId || value.taskId !== identity.taskId || value.attemptId !== identity.attemptId || typeof value.grantToken !== 'string') fail('MACHINE_IDENTITY_MISMATCH')
96
144
  return value.grantToken
97
145
  }
98
146
  const invoke = async (record,token,name,args,operationId,signal) => {
@@ -115,24 +163,36 @@ export function openRemoteMachines({ Database, file, resolveSecret, botExists, a
115
163
  const compare = (old, expectedRevision) => { if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0 || (old?.revision ?? 0) !== expectedRevision) fail('MACHINE_CONFIG_CONFLICT') }
116
164
  const publicMachine = row => ({ id: row.id, machineId: row.id, revision: row.revision, name: row.config.name, endpoint: row.config.endpoint,
117
165
  secretRef: row.config.secretRef, roots: row.config.roots, tools: row.config.tools, protectedTunnel: row.config.protectedTunnel,
166
+ ...(row.config.transport?{transport:row.config.transport}:{}),
118
167
  status: row.revoked ? 'revoked' : row.health && now()-row.health.lastSeenAt<90000 ? 'online' : 'unverified', lastSeenAt: row.health?.lastSeenAt ?? null,
119
168
  platform: row.health?.platform ?? null, version: row.health?.version ?? null, capabilities: row.health?.capabilities ?? [], desktop: row.health?.desktop ?? {status:'unverified'},
120
169
  revocations:db.prepare('SELECT bot_id,status,at_ms FROM remote_machine_revocations WHERE machine_id=?').all(row.id).map(r=>({botId:r.bot_id,status:r.status,at:r.at_ms})),
121
170
  bindings: db.prepare('SELECT bot_id,revision,scope,revoked FROM remote_machine_bindings WHERE machine_id=?').all(row.id).map(b => ({botId:b.bot_id,revision:b.revision,revoked:Boolean(b.revoked),...JSON.parse(b.scope)})) })
122
- const desktopSessions = createDesktopSessionHost({ machine, binding, authority, issue, request, event, now })
171
+ const desktopSessions = createDesktopSessionHost({ machine, binding, authority, issue, request, event, now, desktopProxy, desktopHttp,
172
+ cleanupRequest:async(record,input,signal)=>request(record.config,'/control/v1/desktop/close',await secretFor(record),input,signal),
173
+ connectDesktop: async (record,signal,origin,options={}) => {
174
+ if(machine(record.id).revision!==record.revision)fail('MACHINE_GRANT_CHANGED')
175
+ const channel=await secureChannel(record.config,signal)
176
+ if(machine(record.id).revision!==record.revision)fail('MACHINE_GRANT_CHANGED')
177
+ return channel.openWebSocket('/desktop/v1/socket',{origin,...options})
178
+ },
179
+ })
123
180
  const store = {
124
181
  schemaVersion:1, file,
125
182
  desktopSession: (subject, input, context) => desktopSessions.open(subject, input, context),
183
+ routineDesktopSession: (subject,input,context) => desktopSessions.openInternal(subject,input,context),
126
184
  renewDesktopSession: (subject, input, context) => desktopSessions.renew(subject, input, context),
127
185
  closeDesktopSession: (subject, input, context) => desktopSessions.close(subject, input, context),
128
186
  closeDesktopSessions: () => desktopSessions.closeAll(),
129
187
  async pair(input,context) {
130
- owner(context); alive(); keys(input,['machineId','name','endpoint','secretRef','protectedTunnel','roots','tools','probeBotId','expectedRevision'])
188
+ owner(context); alive(); keys(input,['machineId','clientId','name','endpoint','secretRef','protectedTunnel','transport','roots','tools','probeBotId','expectedRevision'])
131
189
  if (!ref(input.machineId) || !ref(input.probeBotId) || !botExists(input.probeBotId)) fail('INVALID_MACHINE_ID')
132
190
  if (typeof input.name !== 'string' || !input.name.trim() || input.name.length>128 || /[\x00-\x1f]/.test(input.name)) fail('INVALID_MACHINE_NAME')
133
191
  if (typeof input.secretRef !== 'string' || !/^ds-secret\/[A-Za-z][A-Za-z0-9_]{0,127}$/.test(input.secretRef)) fail('INVALID_MACHINE_SECRET_REF')
134
192
  if (input.protectedTunnel !== undefined && typeof input.protectedTunnel !== 'boolean') fail('INVALID_MACHINE_ENDPOINT')
135
- const config = { name:input.name.trim(), endpoint:normalizeMachineEndpoint(input.endpoint,input.protectedTunnel), secretRef:input.secretRef, protectedTunnel:input.protectedTunnel===true, roots:references(input.roots), tools:references(input.tools,REMOTE_TOOLS) }
193
+ if (input.clientId !== undefined && !ref(input.clientId)) fail('INVALID_MACHINE_ID')
194
+ const transport = input.transport === undefined ? undefined : normalizeSecureTransport(input.transport)
195
+ const config = { name:input.name.trim(), endpoint:normalizeMachineEndpoint(input.endpoint,input.protectedTunnel,transport), secretRef:input.secretRef, protectedTunnel:input.protectedTunnel===true, roots:references(input.roots), tools:references(input.tools,REMOTE_TOOLS), ...(transport?{transport}:{}), ...(input.clientId?{clientId:input.clientId}:{}) }
136
196
  if (!config.tools.includes('machine_ping')) fail('MACHINE_PING_REQUIRED')
137
197
  compare(db.prepare('SELECT revision FROM remote_machines WHERE id=?').get(input.machineId),input.expectedRevision)
138
198
  const record = {id:input.machineId,config}, identity={taskId:`pair-${randomUUID()}`,attemptId:`attempt-${randomUUID()}`}
@@ -173,10 +233,15 @@ export function openRemoteMachines({ Database, file, resolveSecret, botExists, a
173
233
  owner(context); alive(); keys(input,['machineId','botId','expectedRevision'])
174
234
  const record=machine(input.machineId), botId=input.botId??null
175
235
  if(botId!==null&&!ref(botId)) fail('INVALID_MACHINE_ID')
176
- const affected=botId?[botId]:db.prepare('SELECT bot_id FROM remote_machine_bindings WHERE machine_id=? AND revoked=0').all(record.id).map(r=>r.bot_id)
236
+ const affected=botId?[botId]:db.prepare('SELECT bot_id FROM remote_machine_bindings WHERE machine_id=? UNION SELECT bot_id FROM remote_machine_grant_subjects WHERE machine_id=?').all(record.id,record.id).map(r=>r.bot_id)
177
237
  const requests=new Map()
178
238
  db.transaction(()=>{
179
- if(botId){compare(db.prepare('SELECT revision FROM remote_machine_bindings WHERE machine_id=? AND bot_id=?').get(record.id,botId),input.expectedRevision);db.prepare('UPDATE remote_machine_bindings SET revoked=1,revision=revision+1 WHERE machine_id=? AND bot_id=?').run(record.id,botId)}
239
+ if(botId){
240
+ const previous=db.prepare('SELECT revision FROM remote_machine_bindings WHERE machine_id=? AND bot_id=?').get(record.id,botId)
241
+ compare(previous,input.expectedRevision)
242
+ if(previous)db.prepare('UPDATE remote_machine_bindings SET revoked=1,revision=revision+1 WHERE machine_id=? AND bot_id=?').run(record.id,botId)
243
+ else db.prepare('INSERT INTO remote_machine_bindings VALUES(?,?,1,?,1)').run(record.id,botId,JSON.stringify({roots:[],tools:[],mode:'read-only'}))
244
+ }
180
245
  else{compare(record,input.expectedRevision);db.prepare('UPDATE remote_machines SET revoked=1,revision=revision+1 WHERE id=?').run(record.id);db.prepare('UPDATE remote_machine_bindings SET revoked=1,revision=revision+1 WHERE machine_id=?').run(record.id)}
181
246
  event(record.id,botId,'revoked')
182
247
  for(const id of affected)requests.set(id,requestRevocation(record.id,id))
@@ -293,7 +358,7 @@ export function openRemoteMachines({ Database, file, resolveSecret, botExists, a
293
358
  throw error
294
359
  }
295
360
  },
296
- scopes(botId) { alive(); return db.prepare('SELECT machine_id,revision,scope FROM remote_machine_bindings WHERE bot_id=? AND revoked=0').all(botId).flatMap(r=>{try{machine(r.machine_id);return [{machineId:r.machine_id,revision:r.revision,...JSON.parse(r.scope)}]}catch{return []}}) },
361
+ scopes(botId) { alive(); return inheritedMainScopes(botId) ?? db.prepare('SELECT machine_id,revision,scope FROM remote_machine_bindings WHERE bot_id=? AND revoked=0').all(botId).flatMap(r=>{try{machine(r.machine_id);return [{machineId:r.machine_id,revision:r.revision,...JSON.parse(r.scope)}]}catch{return []}}) },
297
362
  /**
298
363
  * A Bot's grants, straight from committed state.
299
364
  *
@@ -301,16 +366,19 @@ export function openRemoteMachines({ Database, file, resolveSecret, botExists, a
301
366
  * must not depend on `scopes()` (which needs the directory alive). This reads the same rows
302
367
  * without the availability check: what was authorised stays authorised across a restart.
303
368
  */
304
- bindingsFor(botId) {
369
+ bindingsFor(botId,{main=mainBotIds().includes(botId)}={}) {
305
370
  alive()
306
371
  if (typeof botId !== 'string' || !botId) return []
372
+ // Only Host grant compilation supplies this override, from the saved role
373
+ // being adopted. The currently committed role may still be the old one.
374
+ if(main)return mainScopes(botId)
307
375
  return db.prepare('SELECT machine_id, revision, scope FROM remote_machine_bindings WHERE bot_id=? AND revoked=0').all(botId).flatMap(row => {
308
376
  try { const scope = JSON.parse(row.scope); return [{ machineId: row.machine_id, revision: row.revision, roots: scope.roots ?? [], tools: scope.tools ?? [], mode: scope.mode ?? 'read-only' }] } catch { return [] }
309
377
  })
310
378
  },
311
379
 
312
380
  readModel() { alive(); return {status:'available',items:db.prepare('SELECT * FROM remote_machines ORDER BY id').all().map(row=>publicMachine({...row,config:JSON.parse(row.config),health:row.health?JSON.parse(row.health):null}))} },
313
- close() { if(!closed){for(const call of active)call.controller.abort(new Error('MACHINE_DIRECTORY_CLOSED'));closed=true;db.close()} },
381
+ close() { if(!closed){for(const call of active)call.controller.abort(new Error('MACHINE_DIRECTORY_CLOSED'));closed=true;closeChannels();db.close()} },
314
382
  }
315
383
  return Object.freeze(store)
316
384
  }
@@ -8,6 +8,7 @@ const failureStatus = (failure, signal) => failure?.status === 'unknown' || fail
8
8
  /** Legacy five-action routines remain usable on the acknowledged desktop session. */
9
9
  export function routineStepInputs(step) {
10
10
  if (step.action === 'input') return [normalizeRoutineInput(step.event)]
11
+ if (step.action === 'release-input') return []
11
12
  if (step.action === 'wait') return []
12
13
  if (step.action === 'click') return ['down', 'up'].map(action => normalizeRoutineInput({ type: 'pointer', action, x: step.x, y: step.y, button: step.button ?? 'left' }))
13
14
  if (step.action === 'type') return [normalizeRoutineInput({ type: 'text', text: step.text })]
@@ -48,7 +49,7 @@ export function createRoutineExecutionService({ machines, store, botIdForSubject
48
49
  const completed = [], cleanup = { inputReleased: false, controlReleased: false, stopConfirmed: false }
49
50
  try {
50
51
  if (controller.signal.aborted) fail('ROUTINE_CANCELLED', { status: 'cancelled' })
51
- ticket = await machines.desktopSession(subject, { machineId, rootId, mode: 'watch', fps: 5,
52
+ ticket = await (machines.routineDesktopSession??machines.desktopSession).call(machines,subject, { machineId, rootId, mode: 'watch', fps: 5,
52
53
  ...(routine.recording ? { displayId: routine.recording.display.displayId } : {}) }, runContext)
53
54
  store.updateRun(botId, row.runId, { sessionId: ticket.sessionId })
54
55
  session = createRoutineDesktopSession({ ticket, WebSocketImpl, signal: controller.signal, now, ackTimeoutMs, renewEveryMs,
@@ -66,10 +67,11 @@ export function createRoutineExecutionService({ machines, store, botIdForSubject
66
67
  await session.acquire()
67
68
  for (currentStep = 0; currentStep < routine.steps.length; currentStep++) {
68
69
  const step = routine.steps[currentStep]
69
- await routineDelay(step.action === 'wait' ? step.ms : step.action === 'input' ? step.delayMs : 0, controller.signal)
70
+ await routineDelay(step.action === 'wait' ? step.ms : ['input','release-input'].includes(step.action) ? step.delayMs : 0, controller.signal)
70
71
  const currentDisplay = normalizeRoutineDisplay(session.metadata)
71
72
  if (['displayId', 'width', 'height', 'coordinateSpace'].some(field => currentDisplay[field] !== display[field])) fail('ROUTINE_DISPLAY_CHANGED')
72
- for (const event of inputs[currentStep]) await session.input(event, { stepIndex: currentStep })
73
+ if (step.action === 'release-input') await session.releaseInput()
74
+ else for (const event of inputs[currentStep]) await session.input(event, { stepIndex: currentStep })
73
75
  completed.push(currentStep)
74
76
  store.updateRun(botId, row.runId, { steps: completed, stoppedAt: currentStep + 1 })
75
77
  await onStep?.(step, currentStep, { runId: row.runId, taskId: row.taskId, attemptId: row.attemptId })
@@ -12,10 +12,9 @@ const deferred = () => {
12
12
  export function createRoutineDesktopSession({ ticket, WebSocketImpl = globalThis.WebSocket, renewTicket, closeTicket,
13
13
  signal, ackTimeoutMs = 5_000, renewEveryMs = 45_000, now = Date.now,
14
14
  onFrame, onMetadata, onState, onBeforeInput, onAcknowledgedInput, onInputFailure } = {}) {
15
- if (!ticket?.sessionId || !ticket?.token || !ticket?.websocketUrl || typeof WebSocketImpl !== 'function') throw error('ROUTINE_DESKTOP_UNAVAILABLE')
15
+ if (!ticket?.sessionId || !ticket?.token || (typeof ticket.openWebSocket!=='function'&&(!ticket.websocketUrl||typeof WebSocketImpl!=='function'))) throw error('ROUTINE_DESKTOP_UNAVAILABLE')
16
16
  if (!Number.isSafeInteger(ackTimeoutMs) || ackTimeoutMs < 1 || ackTimeoutMs > 60_000) throw error('ROUTINE_ACK_TIMEOUT_INVALID')
17
- const socket = new WebSocketImpl(ticket.websocketUrl)
18
- socket.binaryType = 'arraybuffer'
17
+ let socket
19
18
  const ready = deferred(), pending = new Map()
20
19
  let mode = 'watch', metadata = null, nextId = 0, queue = Promise.resolve(), closed = false, fatal = null
21
20
  let renewTimer, currentTicket = ticket
@@ -31,12 +30,12 @@ export function createRoutineDesktopSession({ ticket, WebSocketImpl = globalThis
31
30
  emit({ status: 'error', mode, code })
32
31
  }
33
32
  const send = value => {
34
- if (closed || socket.readyState !== 1) throw error('ROUTINE_DESKTOP_DISCONNECTED')
33
+ if (closed || socket?.readyState !== 1) throw error('ROUTINE_DESKTOP_DISCONNECTED')
35
34
  socket.send(JSON.stringify(value))
36
35
  }
37
36
  const request = (packet, type, id, operationSignal) => {
38
37
  if (operationSignal?.aborted) return Promise.reject(error('ROUTINE_CANCELLED', 'cancelled'))
39
- if (closed || socket.readyState !== 1) return Promise.reject(error('ROUTINE_DESKTOP_DISCONNECTED'))
38
+ if (closed || socket?.readyState !== 1) return Promise.reject(error('ROUTINE_DESKTOP_DISCONNECTED'))
40
39
  const key = `${type}:${id ?? ''}`, result = deferred()
41
40
  const waiter = { ...result, type, id, sent: false, fail: null, finish: null }
42
41
  const clear = () => { clearTimeout(timer); operationSignal?.removeEventListener?.('abort', abort); pending.delete(key) }
@@ -58,17 +57,17 @@ export function createRoutineDesktopSession({ ticket, WebSocketImpl = globalThis
58
57
  if (closed) return
59
58
  if (!renewed || renewed.sessionId !== ticket.sessionId || !Number.isFinite(renewed.expiresAt) || renewed.expiresAt <= now()) throw error('ROUTINE_RENEW_INVALID')
60
59
  currentTicket = renewed; scheduleRenew()
61
- } catch { breakConnection('ROUTINE_AUTHORIZATION_LOST'); try { socket.close() } catch {} }
60
+ } catch { breakConnection('ROUTINE_AUTHORIZATION_LOST'); try { socket?.close() } catch {} }
62
61
  }, Math.max(1, Math.min(renewEveryMs, untilExpiry)))
63
62
  renewTimer.unref?.()
64
63
  }
65
- socket.onopen = () => {
64
+ const onOpen = () => {
66
65
  try { send({ type: 'auth', sessionId: ticket.sessionId, token: ticket.token }); emit({ status: 'authenticating', mode }) }
67
66
  catch (failure) { breakConnection(failure.code ?? 'ROUTINE_AUTH_FAILED') }
68
67
  }
69
- const readyTimer = setTimeout(() => { breakConnection('ROUTINE_READY_TIMEOUT'); try { socket.close() } catch {} }, ackTimeoutMs)
68
+ const readyTimer = setTimeout(() => { breakConnection('ROUTINE_READY_TIMEOUT'); try { socket?.close() } catch {} }, ackTimeoutMs)
70
69
  ready.promise.finally(() => clearTimeout(readyTimer)).catch(() => {})
71
- socket.onmessage = message => {
70
+ const onMessage = message => {
72
71
  if (closed) return
73
72
  if (typeof message.data !== 'string') { onFrame?.(message.data); return }
74
73
  let value
@@ -101,8 +100,15 @@ export function createRoutineDesktopSession({ ticket, WebSocketImpl = globalThis
101
100
  }
102
101
  if (value.type === 'error') breakConnection(value.code ?? 'ROUTINE_DESKTOP_ERROR')
103
102
  }
104
- socket.onerror = () => breakConnection('ROUTINE_DESKTOP_DISCONNECTED')
105
- socket.onclose = () => breakConnection('ROUTINE_DESKTOP_DISCONNECTED')
103
+ const attach = value => {
104
+ value.onerror=()=>breakConnection('ROUTINE_DESKTOP_DISCONNECTED')
105
+ if(closed||fatal){value.close();return}
106
+ socket=value;socket.binaryType='arraybuffer';socket.onopen=onOpen;socket.onmessage=onMessage
107
+ socket.onclose=()=>breakConnection('ROUTINE_DESKTOP_DISCONNECTED')
108
+ if(socket.readyState===1)onOpen()
109
+ }
110
+ if(typeof ticket.openWebSocket==='function')Promise.resolve().then(()=>ticket.openWebSocket()).then(attach,()=>breakConnection('ROUTINE_DESKTOP_UNAVAILABLE'))
111
+ else attach(new WebSocketImpl(ticket.websocketUrl))
106
112
  const serialize = (work, cleanup = false) => {
107
113
  const result = queue.then(async () => {
108
114
  if (!cleanup && fatal) throw fatal
@@ -148,9 +154,9 @@ export function createRoutineDesktopSession({ ticket, WebSocketImpl = globalThis
148
154
  async close() {
149
155
  if (closed) return { stopConfirmed: false, code: 'ROUTINE_ALREADY_CLOSED' }
150
156
  clearTimeout(renewTimer)
151
- try { if (socket.readyState === 1) send({ type: 'close' }) } catch {}
157
+ try { if (socket?.readyState === 1) send({ type: 'close' }) } catch {}
152
158
  closed = true; mode = 'watch'; stopPending(error('ROUTINE_DESKTOP_CLOSED'))
153
- try { socket.close() } catch {}
159
+ try { socket?.close() } catch {}
154
160
  try { return await closeTicket?.(ticket.sessionId) ?? { stopConfirmed: false } }
155
161
  catch (failure) { return { stopConfirmed: false, code: failure.code ?? 'ROUTINE_STOP_UNCONFIRMED' } }
156
162
  },
@@ -0,0 +1,112 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { parseSecureInvite } from './pairing-invite.mjs'
3
+ import { connectSecureTransport, normalizeSecureTransport } from '../../../modules/openmaus-protocol/transport.mjs'
4
+ import { REMOTE_TOOLS } from './remote-machines.mjs'
5
+
6
+ const fail = code => { throw Object.assign(new Error(code), { code }) }
7
+ const hash = value => createHash('sha256').update(value).digest('hex')
8
+ // SHA256(token) is also the Noise credential hash. A recovery check must not
9
+ // persist that reusable authentication material in the Host intent journal.
10
+ const credentialDigest = token => hash(`dsh-pairing-recovery-v1\0${token}`)
11
+ const sameScope = (a,b) => Array.isArray(a) && Array.isArray(b) && JSON.stringify([...a].sort()) === JSON.stringify([...b].sort())
12
+
13
+ /** Durable intent/result only. Raw invite proofs remain in memory; device credentials go straight to Host secrets. */
14
+ export function openSecurePairingHost({ Database, file, saveSecret, resolveSecret, machines, pair, botExists, now = Date.now }) {
15
+ const db = new Database(file)
16
+ db.pragma('busy_timeout=5000'); db.pragma('journal_mode=WAL'); db.pragma('synchronous=FULL')
17
+ db.exec(`CREATE TABLE IF NOT EXISTS secure_pairing_requests (
18
+ request_id TEXT PRIMARY KEY,intent_hash TEXT NOT NULL,machine_id TEXT NOT NULL,client_id TEXT NOT NULL,
19
+ config TEXT NOT NULL,status TEXT NOT NULL,result TEXT,created_at INTEGER NOT NULL
20
+ ); CREATE UNIQUE INDEX IF NOT EXISTS secure_pairing_intent ON secure_pairing_requests(intent_hash)`)
21
+ if (!db.pragma('table_info(secure_pairing_requests)').some(column=>column.name==='credential_digest')) db.exec('ALTER TABLE secure_pairing_requests ADD COLUMN credential_digest TEXT')
22
+ const inFlight = new Set()
23
+ let closed = false
24
+ return Object.freeze({
25
+ async redeem(input,context) {
26
+ if (context?.kind !== 'owner') fail('REMOTE_OWNER_REQUIRED')
27
+ if (closed) fail('PAIRING_SERVICE_CLOSED')
28
+ if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(key => !['invite','code','requestId','probeBotId','expectedRevision'].includes(key))
29
+ || typeof input.requestId !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(input.requestId)
30
+ || typeof input.code !== 'string' || !/^\d{6}$/.test(input.code) || !botExists(input.probeBotId)
31
+ || !Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) fail('PAIRING_REQUEST_INVALID')
32
+ const invite = parseSecureInvite(input.invite), transport = normalizeSecureTransport(invite.transport)
33
+ if (invite.ceiling.tools.some(tool => !REMOTE_TOOLS.includes(tool))) fail('PAIRING_TOOLS_UNSUPPORTED')
34
+ if (!invite.ceiling.hostManagedBots && !invite.ceiling.botIds.includes(input.probeBotId)) fail('MACHINE_BOT_DENIED')
35
+ const { code: _printedCode, token, ...publicInvite } = invite
36
+ const intent = hash(JSON.stringify({ invite: publicInvite, token: hash(token), code: hash(input.code), probeBotId: input.probeBotId, expectedRevision: input.expectedRevision }))
37
+ const secretName = `REMOTE_AGENT_${hash(JSON.stringify([invite.machineId,invite.clientId])).slice(0,40).toUpperCase()}`
38
+ const config = { machineId: invite.machineId, clientId: invite.clientId, name: invite.name, endpoint: invite.endpoint,
39
+ transport, roots: invite.ceiling.roots, tools: invite.ceiling.tools, secretRef: `ds-secret/${secretName}`,
40
+ probeBotId: input.probeBotId, expectedRevision: input.expectedRevision }
41
+ let row = db.prepare('SELECT * FROM secure_pairing_requests WHERE request_id=?').get(input.requestId)
42
+ if (row && row.intent_hash !== intent) fail('PAIRING_REQUEST_CONFLICT')
43
+ if (!row) row = db.prepare('SELECT * FROM secure_pairing_requests WHERE intent_hash=?').get(intent)
44
+ const requestId = row?.request_id ?? input.requestId
45
+ if (row?.status === 'complete') {
46
+ const result=JSON.parse(row.result), live=machines.readModel().items.find(item=>item.id===invite.machineId)
47
+ if(!live||live.status==='revoked'||live.revision!==result.revision||live.transport?.publicKey!==transport.publicKey)fail('PAIRING_CONFIGURATION_CHANGED')
48
+ return { ...result, recovered: true }
49
+ }
50
+ if (inFlight.has(requestId)) fail('PAIRING_REQUEST_PENDING')
51
+ const existing = () => machines.readModel().items.find(item => item.id === invite.machineId)
52
+ const current = existing()
53
+ if (!row && (current?.revision ?? 0) !== input.expectedRevision) fail('MACHINE_CONFIG_CONFLICT')
54
+ if (!row) {
55
+ if (invite.expiresAt <= now() || invite.expiresAt > now() + 180_000) fail('PAIRING_WINDOW_CLOSED')
56
+ db.prepare("INSERT INTO secure_pairing_requests(request_id,intent_hash,machine_id,client_id,config,status,result,created_at) VALUES(?,?,?,?,?,'pending',NULL,?)").run(requestId,intent,invite.machineId,invite.clientId,JSON.stringify(config),now())
57
+ row = { status: 'pending' }
58
+ }
59
+ inFlight.add(requestId)
60
+ let channel
61
+ try {
62
+ // The Secret service and SQLite cannot share a transaction. Persist the
63
+ // verified credential digest first, then reconcile an already committed
64
+ // Secret after a crash without redeeming or minting another device.
65
+ if (row.status !== 'secret-saved' && /^[a-f0-9]{64}$/.test(row.credential_digest??'') && typeof resolveSecret==='function') {
66
+ const saved=await resolveSecret(config.secretRef)
67
+ if (saved!==null && saved!==undefined) {
68
+ if (typeof saved!=='string'||!/^[A-Za-z0-9_-]{43}$/.test(saved)||credentialDigest(saved)!==row.credential_digest) fail('PAIRING_CREDENTIAL_CHANGED')
69
+ db.prepare("UPDATE secure_pairing_requests SET status='secret-saved' WHERE request_id=?").run(requestId)
70
+ row.status='secret-saved'
71
+ }
72
+ }
73
+ if (row.status !== 'secret-saved') {
74
+ if (invite.expiresAt <= now()) fail('PAIRING_RESULT_UNRECOVERABLE')
75
+ context.signal?.throwIfAborted()
76
+ try { channel = await connectSecureTransport({ endpoint: invite.endpoint, transport, token, signal: context.signal }) }
77
+ catch { fail('PAIRING_PEER_UNVERIFIED') }
78
+ let response, result
79
+ try {
80
+ response = await channel.fetch('/pair/v2/redeem', { headers: { 'content-type': 'application/json' },
81
+ body: JSON.stringify({ requestId, code: input.code }), signal: AbortSignal.any([AbortSignal.timeout(15_000),...(context.signal?[context.signal]:[])]) })
82
+ result = await response.json()
83
+ } catch { fail('PAIRING_RESULT_UNKNOWN') }
84
+ if (!response.ok) {
85
+ if (result?.error?.code==='PAIRING_ALREADY_REDEEMED') fail('PAIRING_DEVICE_ALREADY_COMMITTED')
86
+ fail(/^[A-Z][A-Z0-9_]{0,80}$/.test(result?.error?.code) ? result.error.code : 'PAIRING_REDEEM_FAILED')
87
+ }
88
+ if (result.machineId !== invite.machineId || result.clientId !== invite.clientId || typeof result.token !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(result.token)
89
+ || normalizeSecureTransport(result.transport).publicKey !== transport.publicKey || !result.ceiling
90
+ || !sameScope(result.ceiling.roots,invite.ceiling.roots) || !sameScope(result.ceiling.tools,invite.ceiling.tools)
91
+ || !sameScope(result.ceiling.botIds??[],invite.ceiling.botIds) || (result.ceiling.hostManagedBots===true)!==(invite.ceiling.hostManagedBots===true)) fail('PAIRING_IDENTITY_MISMATCH')
92
+ if (row.credential_digest && row.credential_digest!==credentialDigest(result.token)) fail('PAIRING_CREDENTIAL_CHANGED')
93
+ db.prepare("UPDATE secure_pairing_requests SET status='secret-saving',credential_digest=? WHERE request_id=?").run(credentialDigest(result.token),requestId)
94
+ await saveSecret(secretName,result.token)
95
+ db.prepare("UPDATE secure_pairing_requests SET status='secret-saved' WHERE request_id=?").run(requestId)
96
+ }
97
+ context.signal?.throwIfAborted()
98
+ const live = existing()
99
+ let result
100
+ if (live && live.revision === config.expectedRevision + 1 && live.secretRef === config.secretRef
101
+ && live.endpoint === config.endpoint && live.transport?.publicKey === config.transport.publicKey
102
+ && sameScope(live.roots,config.roots) && sameScope(live.tools,config.tools)) {
103
+ result = { ...live, mainBinding: { status: 'blocked', code: 'PAIRING_BINDING_REVIEW_REQUIRED' } }
104
+ } else result = await pair(config,context)
105
+ if (closed) fail('PAIRING_SERVICE_CLOSED')
106
+ db.prepare("UPDATE secure_pairing_requests SET status='complete',result=? WHERE request_id=?").run(JSON.stringify(result),requestId)
107
+ return result
108
+ } finally { channel?.close(); inFlight.delete(requestId) }
109
+ },
110
+ close() { if (!closed) { closed = true; db.close() } },
111
+ })
112
+ }
@@ -647,6 +647,35 @@ export function openTaskJournal(Database, path, {database=null,financialEpoch=nu
647
647
  updated_ms DESC,id LIMIT ?`).all(JSON.stringify(ids),limit))
648
648
  return {items,total,truncated:total>items.length}
649
649
  },
650
+ // A sanitized cross-task event feed for the notification center: it names the
651
+ // safe structured fields only (seq/task/attempt/kind/at plus a validated
652
+ // outcome and code). Raw event detail — run ids, reference/evidence hashes,
653
+ // operation ids, gates — never leaves the journal, so a person-facing
654
+ // notification is built from structured state instead of a leak. Keyset
655
+ // pagination walks the monotonic seq; `maxSeq` is the feed watermark.
656
+ notificationEvents(owners,kinds,{before=null,after=0,limit=64}={}) {
657
+ const ids=[...new Set((Array.isArray(owners)?owners:[]).map(taskRef))]
658
+ const wanted=[...new Set((Array.isArray(kinds)?kinds:[]).filter(kind=>typeof kind==='string'))]
659
+ if(!ids.length||!wanted.length||!Number.isSafeInteger(limit)||limit<1||limit>512)return {items:[],maxSeq:0}
660
+ const bound=value=>Number.isSafeInteger(value)&&value>=0?value:0
661
+ const floor=bound(after),ceil=before===null?null:bound(before)
662
+ const range=ceil===null?'e.seq>?':'e.seq>? AND e.seq<?',params=ceil===null?[floor]:[floor,ceil]
663
+ const ownerJson=JSON.stringify(ids),kindJson=JSON.stringify(wanted)
664
+ const items=stmt(`SELECT e.seq,e.task_id,e.attempt_id,e.kind,e.at_ms,e.detail,t.owner_bot
665
+ FROM task_events e JOIN tasks t ON t.id=e.task_id
666
+ WHERE t.owner_bot IN (SELECT value FROM json_each(?)) AND e.kind IN (SELECT value FROM json_each(?)) AND ${range}
667
+ ORDER BY e.seq DESC LIMIT ?`).all(ownerJson,kindJson,...params,limit).map(row=>{
668
+ let outcome=null,notifyCode=null
669
+ try{const detail=JSON.parse(row.detail)
670
+ outcome=['succeeded','failed','cancelled','skipped','blocked','unknown','waiting-budget','needs-reconciliation','ready','running','admitting','cancelling'].includes(detail?.outcome)?detail.outcome:null
671
+ notifyCode=typeof detail?.code==='string'&&/^[A-Z0-9_:-]{1,128}$/.test(detail.code)?detail.code:null
672
+ }catch{}
673
+ return {seq:row.seq,taskId:row.task_id,ownerBotId:row.owner_bot,attemptId:row.attempt_id,kind:row.kind,at:row.at_ms,outcome,code:notifyCode}
674
+ })
675
+ const maxSeq=stmt(`SELECT COALESCE(MAX(e.seq),0) AS m FROM task_events e JOIN tasks t ON t.id=e.task_id
676
+ WHERE t.owner_bot IN (SELECT value FROM json_each(?)) AND e.kind IN (SELECT value FROM json_each(?))`).get(ownerJson,kindJson).m
677
+ return {items,maxSeq}
678
+ },
650
679
  // One Bot's durable tasks, oldest first; the read model never invents a task.
651
680
  list(owner) {taskRef(owner);return viewsOf(stmt('SELECT * FROM tasks WHERE owner_bot=? ORDER BY created_ms,id LIMIT 128').all(owner))},
652
681
  close() {if(!closed){closed=true;if(borrowed){try{db.pragma(`busy_timeout=${priorTimeout}`)}catch{}}else db.close();onClose()}},
@@ -36,6 +36,23 @@ export function projectProductTask(task) {
36
36
  }
37
37
  }
38
38
 
39
+ /** Bounded owner navigation data; never reads funding receipts or backend inputs. */
40
+ export async function readProductAttention({ runtime, journal, limit = 512 }, payload = {}) {
41
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.keys(payload).length) {
42
+ throw Object.assign(new Error('INVALID_ATTENTION_REQUEST'), { code: 'INVALID_ATTENTION_REQUEST' })
43
+ }
44
+ await runtime.load()
45
+ const loaded = journal.productList(Object.values(runtime.state?.bots ?? {}).map(bot => bot.id), { limit })
46
+ const items = loaded.items.map(task => {
47
+ const view = projectProductTask(task)
48
+ const remote = view.result?.remote
49
+ return { botId: view.botId, taskId: view.taskId, state: view.state,
50
+ ...(remote ? { result: { remote: { stopConfirmed: remote.stopConfirmed, possiblyApplied: remote.possiblyApplied,
51
+ ...(remote.notStarted === true ? { notStarted: true } : {}) } } } : {}) }
52
+ })
53
+ return { schemaVersion: 1, tasks: { status: 'available', items, total: loaded.total, truncated: loaded.truncated } }
54
+ }
55
+
39
56
  /** The service checks the Bot's committed task/read grant and the task's owner first. */
40
57
  export function inspectProductTask({ service, journal, subject, taskId }) {
41
58
  const task = service.get(subject, { taskId })