@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,284 @@
1
+ /**
2
+ * The ONE description of the four teammate-coordination tools.
3
+ *
4
+ * The SDK registration (team-sdk-tools.mjs) and the native MCP handler factory
5
+ * (native-team-tools.mjs) both read this module, so a tool name, a description, a
6
+ * parameter or an argument projection cannot drift between the two doors: what the
7
+ * model is told and what the coordination service is actually called with are the
8
+ * same facts. This is the data-tool-contract.mjs pattern applied to the team family.
9
+ *
10
+ * Authority stays outside this file. The service that executes a call
11
+ * (createTeamCoordination in team-bridge.mjs) resolves the calling Bot, the roster,
12
+ * the targets, the lineage and the work-thread identity from committed Host state —
13
+ * never from these arguments. `parseTeamToolArgs` refuses every key an entry does not
14
+ * declare, so a model that tries to smuggle a `botId`, an `owner`, a route, a session
15
+ * or a credential through the arguments is stopped here, before any service is
16
+ * reached; and the projection below only ever forwards business content.
17
+ *
18
+ * Two schemas describe the same contract on purpose:
19
+ * - `parameters` is the dsh-tools author map the installed SDK compiles (arrays use
20
+ * the SDK's own `{ type:'array', items:{...} }` node — the same node the vendor
21
+ * browser/goals tools already use);
22
+ * - `inputSchema` is a real MCP JSON Schema object with `additionalProperties:false`,
23
+ * so a native client is told the truth — there is no authority key to pass.
24
+ *
25
+ * This contract is upstream-coordinate_bots-shaped but deliberately narrower, and the
26
+ * narrowing is recorded rather than hidden: no `group_id`/rooms, no `title`/
27
+ * `in_my_folder`, no `answers` (question routing), no `reason` chip, and
28
+ * `assignment_status` exposes no `wait_ms` long-poll (a Bot must not hold a turn, and
29
+ * a long-poll surface would need an owner-shaped endpoint). Those are phased gaps,
30
+ * not promises.
31
+ */
32
+
33
+ /** JSON argument bytes one team call may carry; the same bound the native tools use. */
34
+ export const MAX_ARGS_BYTES = 128_000
35
+
36
+ /** Bounds, chosen to match upstream and the product's own turn-input bound. */
37
+ export const MAX_TARGETS = 4
38
+ export const MAX_MESSAGE_CHARS = 4000
39
+ export const MAX_DONE_WHEN_CHARS = 1000
40
+ export const MAX_REQUEST_KEY_CHARS = 128
41
+ export const MAX_REASON_CHARS = 200
42
+
43
+ /** Assignment modes. */
44
+ export const MODE_WORK = 'work'
45
+ export const MODE_CONSULT = 'consult'
46
+ export const MODE_NOTICE = 'notice'
47
+ export const TEAM_MODES = Object.freeze([MODE_WORK, MODE_CONSULT, MODE_NOTICE])
48
+
49
+ /** Sender reply semantics. */
50
+ export const REPLY_RESUME = 'resume'
51
+ export const REPLY_NONE = 'none'
52
+ export const TEAM_REPLIES = Object.freeze([REPLY_RESUME, REPLY_NONE])
53
+
54
+ /** Durable assignment states (coherent with `outcome`, never overlapping it). */
55
+ export const STATE_QUEUED = 'queued'
56
+ export const STATE_ADMITTING = 'admitting'
57
+ export const STATE_RUNNING = 'running'
58
+ export const STATE_WAITING = 'waiting'
59
+ export const STATE_SETTLED = 'settled'
60
+ export const STATE_CANCELLED = 'cancelled'
61
+ export const STATE_UNKNOWN = 'unknown'
62
+ export const TEAM_STATES = Object.freeze([STATE_QUEUED, STATE_ADMITTING, STATE_RUNNING, STATE_WAITING, STATE_SETTLED, STATE_CANCELLED, STATE_UNKNOWN])
63
+ /** States a reader treats as "this assignment will not change on its own". */
64
+ export const TEAM_TERMINAL_STATES = Object.freeze([STATE_SETTLED, STATE_CANCELLED, STATE_UNKNOWN])
65
+
66
+ /** Outcomes, only meaningful once an assignment is terminal. */
67
+ export const OUTCOME_SUCCEEDED = 'succeeded'
68
+ export const OUTCOME_FAILED = 'failed'
69
+ export const OUTCOME_CANCELLED = 'cancelled'
70
+ export const OUTCOME_UNKNOWN = 'unknown'
71
+ export const TEAM_OUTCOMES = Object.freeze([OUTCOME_SUCCEEDED, OUTCOME_FAILED, OUTCOME_CANCELLED, OUTCOME_UNKNOWN])
72
+
73
+ /** Result-return / sender-resume sub-state: at-most-one automated attempt. */
74
+ export const RESUME_NONE = 'none'
75
+ export const RESUME_WAITING = 'waiting'
76
+ export const RESUME_SENDING = 'sending'
77
+ export const RESUME_DELIVERED = 'delivered'
78
+ export const RESUME_UNCERTAIN = 'uncertain'
79
+ export const TEAM_RESUME_STATES = Object.freeze([RESUME_NONE, RESUME_WAITING, RESUME_SENDING, RESUME_DELIVERED, RESUME_UNCERTAIN])
80
+
81
+ /** Thread references. `standing`/`new` are keywords; anything else is a thread id. */
82
+ export const THREAD_STANDING = 'standing'
83
+ export const THREAD_NEW = 'new'
84
+ const THREAD_ID = /^[A-Za-z0-9_-]{1,128}$/
85
+
86
+ /** Coded refusals and observations, so a model and an owner read the same reason. */
87
+ export const TEAM_CODES = Object.freeze({
88
+ INVALID_ARGS: 'TEAM_INVALID_ARGS',
89
+ CALLER_NOT_COMMITTED: 'TEAM_CALLER_NOT_COMMITTED',
90
+ CALLER_NOT_AUTHORIZED: 'TEAM_CALLER_NOT_AUTHORIZED',
91
+ TARGET_NOT_COMMITTED: 'TEAM_TARGET_NOT_COMMITTED',
92
+ SELF_TARGET: 'TEAM_SELF_TARGET',
93
+ DUPLICATE: 'TEAM_DUPLICATE',
94
+ REQUEST_CONFLICT: 'TEAM_REQUEST_CONFLICT',
95
+ REWORK_NOT_TERMINAL: 'TEAM_REWORK_NOT_TERMINAL',
96
+ CYCLE: 'TEAM_CYCLE',
97
+ TOO_DEEP: 'TEAM_TOO_DEEP',
98
+ FANOUT_LIMIT: 'TEAM_FANOUT_LIMIT',
99
+ THREAD_NOT_OWNED: 'TEAM_THREAD_NOT_OWNED',
100
+ TARGET_BUSY: 'TEAM_TARGET_BUSY',
101
+ ACCESS_CHANGED: 'TEAM_ACCESS_CHANGED',
102
+ ASSIGNMENT_NOT_FOUND: 'TEAM_ASSIGNMENT_NOT_FOUND',
103
+ OUTCOME_UNKNOWN: 'TEAM_OUTCOME_UNKNOWN',
104
+ })
105
+
106
+ const objectSchema = (properties, required = []) => Object.freeze({
107
+ type: 'object',
108
+ properties: Object.freeze(properties),
109
+ required: Object.freeze(required),
110
+ additionalProperties: false,
111
+ })
112
+
113
+ const stringProp = description => ({ type: 'string', description })
114
+ const boundedStringProp = (description, max) => ({ type: 'string', description: `${description}(最多 ${max} 字)`, maxLength: max })
115
+
116
+ /** The SDK author map for `bot_ids`: a bounded, unique array of teammate ids. */
117
+ const BOT_IDS_SDK = Object.freeze({
118
+ type: 'array',
119
+ minItems: 1, maxItems: MAX_TARGETS, uniqueItems: true,
120
+ items: { type: 'string', description: 'A teammate id exactly as list_bots prints it. 1–4 distinct teammates.' },
121
+ required: true,
122
+ description: 'The teammates to send to, by id from list_bots. 1–4 distinct ids; never yourself.',
123
+ })
124
+
125
+ const inputSchemaFor = parameters => {
126
+ const properties = {}
127
+ for (const [key, spec] of Object.entries(parameters)) {
128
+ if (spec.type === 'array') {
129
+ properties[key] = Object.freeze({
130
+ type: 'array',
131
+ items: Object.freeze({ type: spec.items.type, ...(spec.items.description ? { description: spec.items.description } : {}) }),
132
+ ...(spec.description ? { description: spec.description } : {}),
133
+ ...(spec.minItems !== undefined ? { minItems: spec.minItems } : {}),
134
+ ...(spec.maxItems !== undefined ? { maxItems: spec.maxItems } : {}),
135
+ ...(spec.uniqueItems !== undefined ? { uniqueItems: spec.uniqueItems } : {}),
136
+ })
137
+ continue
138
+ }
139
+ const schema = { type: spec.type }
140
+ if (spec.enum) schema.enum = spec.enum
141
+ if (spec.maxLength !== undefined) schema.maxLength = spec.maxLength
142
+ if (spec.minLength !== undefined) schema.minLength = spec.minLength
143
+ schema.description = spec.description
144
+ properties[key] = Object.freeze(schema)
145
+ }
146
+ const required = Object.entries(parameters).filter(([, spec]) => spec.required === true).map(([key]) => key)
147
+ return objectSchema(properties, required)
148
+ }
149
+
150
+ const defineEntry = ({ name, description, parameters, method }) => Object.freeze({
151
+ name,
152
+ description,
153
+ parameters: Object.freeze(parameters),
154
+ inputSchema: inputSchemaFor(parameters),
155
+ capability: 'tool/team',
156
+ method,
157
+ })
158
+
159
+ const ENTRIES = Object.freeze([
160
+ defineEntry({
161
+ name: 'coordinate_bots',
162
+ method: 'coordinateBots',
163
+ description: [
164
+ 'The one way to hand work to teammates: ask 1–4 existing teammates a question or assign them concrete work.',
165
+ 'They see only what you send and run under their OWN model, tools, permissions and funding — never yours.',
166
+ 'thread "standing" (default) reuses this conversation\'s one work thread with each teammate; "new" opens a fresh thread with its own history; a thread id picks one you opened.',
167
+ 'consult true asks a question they answer from what they know, acting on nothing; reply "none" is a one-way notice nothing comes back from.',
168
+ 'Confirmed results resume this original conversation when it is still active and access still permits it. Interrupted or ambiguous delivery is surfaced without automatic resend. After sending END your turn; assignment_status is refused in the same turn.',
169
+ 'Never target yourself. On return, verify the requested outcome and request concrete corrections with rework true before giving one final answer. Never repeat a refused call unchanged.',
170
+ ].join(' '),
171
+ parameters: {
172
+ bot_ids: BOT_IDS_SDK,
173
+ message: { type: 'string', required: true, minLength: 1, maxLength: MAX_MESSAGE_CHARS, description: 'The self-contained question or task. Exact paths, constraints and what must be verified.' },
174
+ thread: { type: 'string', description: '"standing" (default), "new", or a thread id you opened. Results return to this conversation.' },
175
+ reply: { type: 'string', enum: [REPLY_RESUME, REPLY_NONE], description: '"resume" (default): the result resumes you. "none": a one-way notice, nothing comes back.' },
176
+ consult: { type: 'boolean', description: 'True for a question the teammate answers from what it knows, acting on nothing. Omit for work.' },
177
+ rework: { type: 'boolean', description: 'True only to send concrete work again to a teammate whose request already finished or failed: a correction, re-check or retry.' },
178
+ done_when: { type: 'string', maxLength: MAX_DONE_WHEN_CHARS, description: 'What must be true for this to count as done; the teammate is held to it.' },
179
+ request_key: { type: 'string', maxLength: MAX_REQUEST_KEY_CHARS, description: 'Optional idempotency name: a repeat with the same content is not sent again but returns the first receipt; a repeat with other content is refused.' },
180
+ },
181
+ }),
182
+ defineEntry({
183
+ name: 'assignment_status',
184
+ method: 'assignmentStatus',
185
+ description: [
186
+ 'Read-only: one assignment\'s state, outcome and result (give its id from a coordinate_bots receipt or a list_threads row),',
187
+ 'or, with no id, everything this conversation still waits on. A result resumes you on its own, so you never need this to wait:',
188
+ 'use it in a later turn when the person asks how a hand-off is going. It starts nothing and changes nothing.',
189
+ ].join(' '),
190
+ parameters: {
191
+ id: { type: 'string', description: 'The assignment id. Omit to list every outstanding assignment of this conversation.' },
192
+ },
193
+ }),
194
+ defineEntry({
195
+ name: 'list_bots',
196
+ method: 'listBots',
197
+ description: 'List every OTHER teammate you can contact with its Main flag, committed model and current runtime mode. Call this to discover exact teammate ids before coordinate_bots.',
198
+ parameters: {},
199
+ }),
200
+ defineEntry({
201
+ name: 'list_threads',
202
+ method: 'listThreads',
203
+ description: 'Read-only: your own work threads and the threads you opened on teammates, newest first, each with its teammate, title, state and delegation id. This is a read: it starts nothing and changes nothing.',
204
+ parameters: {},
205
+ }),
206
+ ])
207
+
208
+ if (ENTRIES.length !== 4 || ENTRIES.some((entry, index) => entry.name !== ['coordinate_bots', 'assignment_status', 'list_bots', 'list_threads'][index])) {
209
+ throw new Error('TEAM_TOOL_ENTRIES_DIVERGED')
210
+ }
211
+
212
+ export const TEAM_TOOL_NAMES = Object.freeze(ENTRIES.map(entry => entry.name))
213
+ export const TEAM_TOOL_ENTRIES = ENTRIES
214
+ export const teamToolEntry = name => ENTRIES.find(entry => entry.name === name) ?? null
215
+
216
+ const invalid = key => { throw Object.assign(new Error(`${TEAM_CODES.INVALID_ARGS}:${key}`), { code: TEAM_CODES.INVALID_ARGS, field: key }) }
217
+ const isPlainObject = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
218
+ && Object.getPrototypeOf(value) === Object.prototype
219
+ const typeOf = (type, value) => type === 'string' ? typeof value === 'string'
220
+ : type === 'boolean' ? typeof value === 'boolean'
221
+ : type === 'integer' ? Number.isSafeInteger(value)
222
+ : false
223
+
224
+ /** A thread reference: the two keywords, or a bounded thread id. */
225
+ export const threadReferenceOk = value => value === THREAD_STANDING || value === THREAD_NEW
226
+ || (typeof value === 'string' && THREAD_ID.test(value))
227
+
228
+ /**
229
+ * Strictly validate one team call's arguments against the shared contract and return
230
+ * the SAME parsed object. Nothing is coerced and nothing is defaulted here: defaults
231
+ * belong to the service, which is the only place that knows them.
232
+ *
233
+ * Refused: a non-plain object, any key the entry does not declare (an authority key
234
+ * such as botId/owner, or any other key), a missing required key, a wrong type, a
235
+ * value outside an enum, a string past its maxLength, an unsafe integer, and a JSON
236
+ * payload larger than MAX_ARGS_BYTES.
237
+ */
238
+ export function parseTeamToolArgs(name, args) {
239
+ const entry = teamToolEntry(name)
240
+ if (!entry) throw new TypeError(`unknown team tool: ${String(name)}`)
241
+ if (!isPlainObject(args)) invalid('args')
242
+ let bytes
243
+ try { bytes = Buffer.byteLength(JSON.stringify(args), 'utf8') } catch { invalid('args') }
244
+ if (bytes > MAX_ARGS_BYTES) invalid('args')
245
+ const { parameters } = entry
246
+ if (Object.keys(args).some(key => !Object.hasOwn(parameters, key))) invalid('args')
247
+ for (const [key, spec] of Object.entries(parameters)) if (spec.required === true && !Object.hasOwn(args, key)) invalid(key)
248
+ for (const [key, value] of Object.entries(args)) {
249
+ const schema = parameters[key]
250
+ if (schema.type === 'array') {
251
+ if (!Array.isArray(value)) invalid(key)
252
+ if (schema.minItems !== undefined && value.length < schema.minItems) invalid(key)
253
+ if (schema.maxItems !== undefined && value.length > schema.maxItems) invalid(key)
254
+ if (schema.uniqueItems === true && new Set(value).size !== value.length) invalid(key)
255
+ if (value.some(item => typeof item !== schema.items.type || !/^[A-Za-z0-9_-]{1,128}$/.test(item))) invalid(key)
256
+ continue
257
+ }
258
+ if (!typeOf(schema.type, value)) invalid(key)
259
+ if (schema.enum && !schema.enum.includes(value)) invalid(key)
260
+ if (schema.minLength !== undefined && value.length < schema.minLength) invalid(key)
261
+ if (schema.maxLength !== undefined && value.length > schema.maxLength) invalid(key)
262
+ if (key === 'thread' && !threadReferenceOk(value)) invalid(key)
263
+ }
264
+ return args
265
+ }
266
+
267
+ /**
268
+ * Project one validated coordinate_bots call onto the business shape the service
269
+ * consumes. Only the declared business keys are forwarded; there is no botId, owner,
270
+ * route, session or credential to forward, because none was ever accepted.
271
+ */
272
+ export function projectCoordinateArgs(args) {
273
+ const trimmed = String(args.message).replace(/\r\n?/g, '\n').trim()
274
+ return {
275
+ targets: args.bot_ids.map(id => String(id)),
276
+ message: trimmed,
277
+ thread: typeof args.thread === 'string' ? args.thread : THREAD_STANDING,
278
+ reply: args.reply === REPLY_NONE ? REPLY_NONE : REPLY_RESUME,
279
+ consult: args.consult === true,
280
+ rework: args.rework === true,
281
+ ...(typeof args.done_when === 'string' ? { doneWhen: args.done_when } : {}),
282
+ ...(typeof args.request_key === 'string' && args.request_key ? { requestKey: args.request_key.slice(0, MAX_REQUEST_KEY_CHARS) } : {}),
283
+ }
284
+ }
@@ -0,0 +1,23 @@
1
+ import { randomBytes } from 'node:crypto'
2
+
3
+ /** Public UI storage identity, persisted by the Host; never an authentication credential. */
4
+ export function openUiHostNamespace({ Database, file }) {
5
+ const db = new Database(file)
6
+ let closed = false
7
+ try {
8
+ db.pragma('busy_timeout = 5000')
9
+ db.pragma('synchronous = FULL')
10
+ db.exec('CREATE TABLE IF NOT EXISTS product_ui_namespace (scope TEXT PRIMARY KEY, namespace TEXT NOT NULL)')
11
+ db.prepare('INSERT OR IGNORE INTO product_ui_namespace(scope,namespace) VALUES(?,?)').run('owner', `host-${randomBytes(8).toString('hex')}`)
12
+ const hostId = db.prepare('SELECT namespace FROM product_ui_namespace WHERE scope=?').get('owner')?.namespace
13
+ if (!/^host-[a-f0-9]{16}$/.test(hostId ?? '')) throw Object.assign(new Error('宿主界面存储标识无效,请核对插件状态。'), { code: 'UI_NAMESPACE_INVALID' })
14
+ return Object.freeze({
15
+ read(payload = {}) {
16
+ if (closed) throw Object.assign(new Error('宿主界面存储已关闭。'), { code: 'UI_NAMESPACE_CLOSED' })
17
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload) || Object.keys(payload).length) throw Object.assign(new Error('无效的界面标识请求。'), { code: 'INVALID_UI_NAMESPACE_REQUEST' })
18
+ return { schemaVersion: 1, hostId }
19
+ },
20
+ close() { if (!closed) { closed = true; db.close() } },
21
+ })
22
+ } catch (error) { db.close(); throw error }
23
+ }
@@ -0,0 +1,103 @@
1
+ // The owner-only product-channel surface for pinned web panels: what the panel
2
+ // list reads, what a person may change, and the one probe that asks a page
3
+ // whether it can be framed.
4
+ //
5
+ // Every entry here is a console operation, not a Bot capability. Nothing is
6
+ // registered as a model tool, so a Bot cannot ask this Host to fetch a page, and
7
+ // the caller's owner class comes from the authenticated channel context — a
8
+ // payload that claims `kind: 'owner'` is data, and is ignored as such. The only
9
+ // origin used is the one the channel reports as the client's embedding origin.
10
+ //
11
+ // A probe names a stored panel by id. The URL is the owner's own pin, never a
12
+ // string a client hands over on the side, so there is no "fetch this address"
13
+ // door here at all.
14
+
15
+ import { checkFraming, PROBE_TIMEOUT_MS } from './web-panel-framing.mjs'
16
+ import { probeUrlOf } from './web-panel-store.mjs'
17
+
18
+ export const WEB_PANEL_ACTIONS = Object.freeze([
19
+ 'webPanels.list', 'webPanels.probe', 'webPanels.save', 'webPanels.remove', 'webPanels.order',
20
+ ])
21
+
22
+ const fail = code => { throw Object.assign(new Error(code), { code }) }
23
+ const plain = value => value && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
24
+ const payloadOf = (payload, allowed) => {
25
+ if (!plain(payload) || Object.keys(payload).some(key => !allowed.includes(key))) fail('WEB_PANEL_INPUT')
26
+ return payload
27
+ }
28
+ const owner = context => { if (context?.kind !== 'owner') fail('REMOTE_OWNER_REQUIRED') }
29
+
30
+ export function createWebPanelActions({ store, probe = checkFraming, now = Date.now, concurrency = 2, queue = 8, logger = () => {} }) {
31
+ if (!store || typeof store.save !== 'function' || typeof store.read !== 'function') throw new TypeError('WEB_PANEL_STORE_REQUIRED')
32
+ const limit = Number.isSafeInteger(concurrency) && concurrency > 0 ? concurrency : 2
33
+ const waiting = Number.isSafeInteger(queue) && queue >= 0 ? queue : 8
34
+ let active = 0
35
+ const pending = []
36
+
37
+ // Bounded by two gates: only `limit` probes run at once, and only `waiting`
38
+ // may wait behind them. A Host is not a scanner, and a panel that cannot get a
39
+ // slot says so instead of piling requests up behind a slow page.
40
+ const release = () => {
41
+ active--
42
+ const next = pending.shift()
43
+ if (next) { active++; next() }
44
+ }
45
+ const slot = work => new Promise((resolve, reject) => {
46
+ const start = () => { work().then(resolve, reject).finally(release) }
47
+ if (active < limit) { active++; start(); return }
48
+ if (pending.length >= waiting) { reject(Object.assign(new Error('WEB_PANEL_PROBE_BUSY'), { code: 'WEB_PANEL_PROBE_BUSY' })); return }
49
+ pending.push(start)
50
+ })
51
+
52
+ const list = () => {
53
+ const { revision, panels, limits } = store.read()
54
+ return { revision, panels, limits }
55
+ }
56
+
57
+ const provider = async () => {
58
+ const value = list()
59
+ // The read model's contract: a status and an items array, never a bare list.
60
+ return { status: 'available', items: value.panels, revision: value.revision, limits: value.limits }
61
+ }
62
+
63
+ const actions = {
64
+ 'webPanels.list': (payload, context) => { owner(context); payloadOf(payload ?? {}, []); return list() },
65
+ 'webPanels.save': (payload, context) => {
66
+ owner(context)
67
+ const request = payloadOf(payload, ['id', 'name', 'url', 'expectedRevision'])
68
+ const saved = store.save({ id: request.id ?? null, name: request.name, url: request.url,
69
+ expectedRevision: request.expectedRevision, embeddingOrigin: context.clientOrigin ?? '' })
70
+ // The readback is the store's own; this is the channel's confirmation that
71
+ // the row a client is about to be shown is the row that was written.
72
+ const after = store.read()
73
+ if (after.revision !== saved.revision || !after.panels.some(panel => panel.id === saved.panel.id
74
+ && panel.name === saved.panel.name && panel.url === saved.panel.url)) fail('WEB_PANEL_READBACK_MISMATCH')
75
+ return { revision: saved.revision, panel: saved.panel }
76
+ },
77
+ 'webPanels.remove': (payload, context) => {
78
+ owner(context)
79
+ const request = payloadOf(payload, ['id', 'expectedRevision'])
80
+ return store.remove({ id: request.id, expectedRevision: request.expectedRevision })
81
+ },
82
+ 'webPanels.order': (payload, context) => {
83
+ owner(context)
84
+ const request = payloadOf(payload, ['ids', 'expectedRevision'])
85
+ return store.order({ ids: request.ids, expectedRevision: request.expectedRevision })
86
+ },
87
+ 'webPanels.probe': async (payload, context) => {
88
+ owner(context)
89
+ const request = payloadOf(payload, ['id'])
90
+ const panel = store.read().panels.find(entry => entry.id === request.id)
91
+ if (!panel) fail('WEB_PANEL_NOT_FOUND')
92
+ const started = now()
93
+ const result = await slot(() => probe(probeUrlOf(panel.url), context.clientOrigin ?? '',
94
+ { signal: context.signal, timeoutMs: PROBE_TIMEOUT_MS, maxBytes: 1, maxRedirects: 0 }))
95
+ // A URL's query is the owner's business and never a log line; the origin is
96
+ // enough to recognise the page in a diagnostic.
97
+ logger('web-panel-probe', { panelId: panel.id, host: result.host, framing: result.framing, reason: result.reason, ms: now() - started })
98
+ return { panelId: panel.id, framing: result.framing, reason: result.reason, host: result.host, checkedAt: now() }
99
+ },
100
+ }
101
+
102
+ return Object.freeze({ actions, provider, list })
103
+ }
@@ -0,0 +1,227 @@
1
+ // Whether a pinned page lets this app frame it, decided the way a browser
2
+ // decides: HTML's X-Frame-Options processing and CSP Level 3's frame-ancestors.
3
+ // Ported from the upstream product (OpenMausBot server/web-panel-framing.ts,
4
+ // pinned 1bc4749aa8307d7d6998bf86de4ef6478460dd93) so a refusal reads the same
5
+ // here as it does there.
6
+ //
7
+ // Chrome fires `load` on a frame it refused, and the parent page cannot tell
8
+ // that frame from a working cross-origin one, so only the page's own headers say
9
+ // why a pinned panel stays blank.
10
+ //
11
+ // Three deliberate differences from the upstream probe, all of them about scope
12
+ // rather than verdict:
13
+ //
14
+ // 1. Loopback and private addresses are allowed. A pinned panel is very often the
15
+ // owner's own localhost dashboard, so the upstream "never from this computer
16
+ // or a private network" rule would refuse the product's most common page. The
17
+ // URL still comes only from an owner's explicit pin.
18
+ // 2. The request carries no credentials of any kind: no cookie jar, no
19
+ // Authorization header, nothing from this Host's configuration. A redirect or
20
+ // an error answer may therefore be a sign-in page the owner's browser never
21
+ // gets, which is why those read as `unknown` rather than `blocked`.
22
+ // 3. The answer is a verdict, never a page. No body is returned to a client and
23
+ // no HTML is executed on the Host, so a site cannot use the probe as an open
24
+ // proxy or as a way to run its markup here.
25
+ //
26
+ // `embeddingOrigin` is supplied by the Host channel from the real client origin
27
+ // (the native shell's `dsh-app://app`, or the browser origin). It is used only to
28
+ // evaluate frame-ancestors; it grants nothing.
29
+
30
+ import { request as httpRequest } from 'node:http'
31
+ import { request as httpsRequest } from 'node:https'
32
+ import { isIP } from 'node:net'
33
+ import { lookup as dnsLookup } from 'node:dns/promises'
34
+
35
+ /** `unknown` whenever this cannot be sure. */
36
+ export const FRAMING = Object.freeze(['allowed', 'blocked', 'unknown'])
37
+
38
+ /** A closed vocabulary: the panel shows one of these, never a server's own words. */
39
+ export const PROBE_REASONS = Object.freeze([
40
+ 'invalid-url', 'unsupported-scheme', 'credentials-in-url', 'redirect', 'bad-status',
41
+ 'unreachable', 'timeout', 'response-limit', 'aborted',
42
+ ])
43
+
44
+ export const PROBE_TIMEOUT_MS = 8_000
45
+ // Framing is decided by headers. One byte of body is enough to learn that a body
46
+ // exists; the rest is never downloaded, so a hostile page cannot make the Host
47
+ // pull megabytes to answer a yes/no question.
48
+ export const PROBE_MAX_BYTES = 1
49
+ // A redirect is not followed: it may lead to a sign-in page the owner's browser
50
+ // never gets, and following it would turn the probe into a crawler.
51
+ export const PROBE_MAX_REDIRECTS = 0
52
+
53
+ const SCHEME_SOURCE = /^[a-z][a-z0-9+.-]*:$/
54
+ const HOST_SOURCE = /^(?:([a-z][a-z0-9+.-]*):\/\/)?(\*|(?:\*\.)?[a-z0-9-]+(?:\.[a-z0-9-]+)*)(?::(\d+|\*))?(\/[^?#]*)?$/
55
+
56
+ function headerItems(value) {
57
+ return (value === undefined ? [] : [value].flat()).flatMap(item => item.split(','))
58
+ }
59
+
60
+ /** An app on https may sit in a frame that only names http, as CSP lets a scheme upgrade. */
61
+ function schemeMatches(scheme, app) {
62
+ return scheme === app.protocol || (scheme === 'http:' && app.protocol === 'https:')
63
+ }
64
+
65
+ function hostMatches(host, app) {
66
+ if (host === '*') return true
67
+ if (host.startsWith('*.')) return app.hostname.endsWith(host.slice(1))
68
+ return app.hostname === host
69
+ }
70
+
71
+ function sourceMatches(token, page, app) {
72
+ const source = token.toLowerCase()
73
+ if (source === '*') return true
74
+ if (source === "'self'") return app.origin === page.origin
75
+ if (source.startsWith("'")) return false
76
+ if (SCHEME_SOURCE.test(source)) return schemeMatches(source, app)
77
+ const parts = HOST_SOURCE.exec(source)
78
+ if (!parts) return null
79
+ const [, scheme, host, port, path] = parts
80
+ if (!schemeMatches(scheme ? `${scheme}:` : page.protocol, app)) return false
81
+ if (!hostMatches(host, app)) return false
82
+ const defaultPort = app.protocol === 'https:' ? '443' : '80'
83
+ const appPort = app.port || defaultPort
84
+ if (port === '80' && appPort === '443') return null
85
+ if (port !== '*' && (port ?? defaultPort) !== appPort) return false
86
+ return path === undefined || path === '/' ? true : null
87
+ }
88
+
89
+ function ancestorsAllow(sources, page, app) {
90
+ let unsure = false
91
+ for (const source of sources) {
92
+ const matches = sourceMatches(source, page, app)
93
+ if (matches) return true
94
+ if (matches === null) unsure = true
95
+ }
96
+ return unsure ? null : false
97
+ }
98
+
99
+ /** The source list of each enforced policy's first frame-ancestors directive. */
100
+ function frameAncestorLists(value) {
101
+ const lists = []
102
+ for (const policy of headerItems(value)) {
103
+ const directive = policy.split(';').map(part => part.trim().split(/\s+/)).find(([name]) => name.toLowerCase() === 'frame-ancestors')
104
+ if (directive) lists.push(directive.slice(1))
105
+ }
106
+ return lists
107
+ }
108
+
109
+ function xFrameOptionsAllow(value, page, app) {
110
+ const options = new Set(headerItems(value).map(item => item.trim().toLowerCase()))
111
+ if (options.size > 1) return !['deny', 'allowall', 'sameorigin'].some(option => options.has(option))
112
+ if (options.has('deny')) return false
113
+ return !options.has('sameorigin') || page.origin === app.origin
114
+ }
115
+
116
+ export function framingOf(headers, pageUrl, embeddingOrigin) {
117
+ let page, app
118
+ try {
119
+ page = new URL(pageUrl)
120
+ app = new URL(embeddingOrigin)
121
+ } catch {
122
+ return 'unknown'
123
+ }
124
+ const lists = frameAncestorLists(headers['content-security-policy'])
125
+ if (lists.length === 0) return xFrameOptionsAllow(headers['x-frame-options'], page, app) ? 'allowed' : 'blocked'
126
+ let unsure = false
127
+ for (const sources of lists) {
128
+ const allows = ancestorsAllow(sources, page, app)
129
+ if (allows === false) return 'blocked'
130
+ if (allows === null) unsure = true
131
+ }
132
+ return unsure ? 'unknown' : 'allowed'
133
+ }
134
+
135
+ /** The verdict for a page, or the reason it cannot be reached. Never a message, never a path. */
136
+ export function framingResult(framing, reason = null, host = '') {
137
+ return Object.freeze({ framing: FRAMING.includes(framing) ? framing : 'unknown', reason: reason ?? null, host })
138
+ }
139
+
140
+ function requestOnce(url, address, signal, maxBytes) {
141
+ return new Promise((resolve, reject) => {
142
+ const pinned = (_host, options, callback) => {
143
+ if (options.all) callback(null, [address])
144
+ else callback(null, address.address, address.family)
145
+ }
146
+ const send = url.protocol === 'https:' ? httpsRequest : httpRequest
147
+ const req = send(url, {
148
+ agent: false, signal, lookup: pinned,
149
+ // Nothing that identifies this Host: no cookie, no authorization, no
150
+ // referer. A page that behaves differently for a signed-in browser is
151
+ // exactly the case this probe must not speak for.
152
+ headers: { 'user-agent': 'dsh-model-router-web-panel-probe', accept: 'text/html,text/plain;q=0.9,*/*;q=0.1' },
153
+ }, response => {
154
+ const status = response.statusCode ?? 0
155
+ const { headers } = response
156
+ if (status < 200 || status >= 300) {
157
+ response.destroy()
158
+ resolve({ status, location: headers.location, headers })
159
+ return
160
+ }
161
+ let size = 0
162
+ response.on('data', chunk => {
163
+ size += chunk.length
164
+ if (size >= maxBytes) { response.destroy(); resolve({ status, headers, truncated: true }) }
165
+ })
166
+ response.on('end', () => resolve({ status, headers, truncated: false }))
167
+ response.on('error', () => resolve({ status, headers, truncated: true }))
168
+ })
169
+ req.on('error', reject)
170
+ req.end()
171
+ })
172
+ }
173
+
174
+ async function addressesFor(host, lookup) {
175
+ const literal = isIP(host)
176
+ if (literal) return [{ address: host, family: literal }]
177
+ const found = typeof lookup === 'function' ? await lookup(host) : await dnsLookup(host, { all: true })
178
+ return Array.isArray(found) ? found.filter(entry => entry && typeof entry.address === 'string') : []
179
+ }
180
+
181
+ function abortableLookup(host, lookup, signal) {
182
+ return new Promise((resolve, reject) => {
183
+ const done = (settle, value) => { signal.removeEventListener('abort', abort); settle(value) }
184
+ const abort = () => done(reject, signal.reason)
185
+ if (signal.aborted) { reject(signal.reason); return }
186
+ signal.addEventListener('abort', abort, { once: true })
187
+ addressesFor(host, lookup).then(value => done(resolve, value), error => done(reject, error))
188
+ })
189
+ }
190
+
191
+ /**
192
+ * Read one page's headers and decide. `options.lookup` exists so a test can point
193
+ * a name at loopback without touching the machine's resolver.
194
+ */
195
+ export async function checkFraming(rawUrl, embeddingOrigin, options = {}) {
196
+ const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : PROBE_TIMEOUT_MS
197
+ const maxBytes = Number.isSafeInteger(options.maxBytes) && options.maxBytes > 0 ? options.maxBytes : PROBE_MAX_BYTES
198
+ const maxRedirects = Number.isSafeInteger(options.maxRedirects) && options.maxRedirects >= 0 ? options.maxRedirects : PROBE_MAX_REDIRECTS
199
+ const signal = AbortSignal.any([AbortSignal.timeout(timeoutMs), ...(options.signal ? [options.signal] : [])])
200
+ let url
201
+ try { url = new URL(rawUrl) } catch { return framingResult('unknown', 'invalid-url') }
202
+ const host = url.host
203
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return framingResult('unknown', 'unsupported-scheme', host)
204
+ if (url.username || url.password) return framingResult('unknown', 'credentials-in-url', host)
205
+ try {
206
+ for (let hop = 0; ; hop++) {
207
+ signal.throwIfAborted()
208
+ const bare = url.hostname.replace(/^\[|\]$/g, '')
209
+ const addresses = await abortableLookup(bare, options.lookup, signal)
210
+ signal.throwIfAborted()
211
+ if (!addresses.length) return framingResult('unknown', 'unreachable', url.host)
212
+ const answer = await requestOnce(url, addresses[0], signal, maxBytes)
213
+ if (answer.status >= 300 && answer.status < 400) {
214
+ if (!answer.location || hop >= maxRedirects) return framingResult('unknown', 'redirect', url.host)
215
+ url = new URL(answer.location, url)
216
+ continue
217
+ }
218
+ if (answer.status < 200 || answer.status >= 300) return framingResult('unknown', 'bad-status', url.host)
219
+ return framingResult(framingOf(answer.headers, url.href, embeddingOrigin), null, url.host)
220
+ }
221
+ } catch {
222
+ // A cancelled probe is the caller's own business; a deadline is the page's.
223
+ if (options.signal?.aborted) return framingResult('unknown', 'aborted', url.host)
224
+ if (signal.aborted) return framingResult('unknown', 'timeout', url.host)
225
+ return framingResult('unknown', 'unreachable', url.host)
226
+ }
227
+ }