create-workframe 0.1.12 → 0.1.14

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 (188) hide show
  1. package/README.md +1 -1
  2. package/bin/create-workframe.js +2 -2
  3. package/bin/workframe.js +130 -2
  4. package/docs/security.md +2 -2
  5. package/package.json +1 -1
  6. package/scripts/bundle-workframe-ui.mjs +9 -0
  7. package/scripts/sync-canonical-to-package.mjs +1 -0
  8. package/scripts/verify-public-deploy.sh +65 -3
  9. package/workframe-api/action_proxy.py +23 -17
  10. package/workframe-api/activity_feed.py +798 -0
  11. package/workframe-api/api_meta.py +157 -0
  12. package/workframe-api/auth_gate.py +566 -0
  13. package/workframe-api/avatar_registry.py +343 -0
  14. package/workframe-api/broker_audit.py +164 -0
  15. package/workframe-api/cell_authority.py +231 -0
  16. package/workframe-api/chat_bind.py +319 -0
  17. package/workframe-api/chat_sessions.py +509 -0
  18. package/workframe-api/chat_stream.py +694 -0
  19. package/workframe-api/cleanup_dogfood_smoke.py +252 -0
  20. package/workframe-api/credential_broker.py +162 -0
  21. package/workframe-api/credential_resolve.py +202 -0
  22. package/workframe-api/credential_store.py +245 -0
  23. package/workframe-api/crew_registry.py +250 -0
  24. package/workframe-api/db_schema.py +755 -0
  25. package/workframe-api/docker_gateway.py +166 -0
  26. package/workframe-api/doctor_runtime.py +109 -0
  27. package/workframe-api/domain/__init__.py +47 -0
  28. package/workframe-api/domain/entities.py +252 -0
  29. package/workframe-api/domain/schema/workframe-domain.schema.json +278 -0
  30. package/workframe-api/egress_policy.py +42 -0
  31. package/workframe-api/handler_modules/__init__.py +17 -0
  32. package/workframe-api/handler_modules/handler_admin.py +542 -0
  33. package/workframe-api/handler_modules/handler_auth.py +512 -0
  34. package/workframe-api/handler_modules/handler_chat.py +641 -0
  35. package/workframe-api/handler_modules/handler_install.py +178 -0
  36. package/workframe-api/handler_modules/handler_provider.py +325 -0
  37. package/workframe-api/handler_modules/handler_workspace.py +1420 -0
  38. package/workframe-api/health_monitor.py +80 -0
  39. package/workframe-api/hermes_admin.py +756 -0
  40. package/workframe-api/hermes_profiles.py +1328 -0
  41. package/workframe-api/install_api.py +123 -0
  42. package/workframe-api/kanban_cron.py +473 -0
  43. package/workframe-api/lane_bindings.py +423 -0
  44. package/workframe-api/llm_proxy.py +17 -43
  45. package/workframe-api/mention_helpers.py +180 -0
  46. package/workframe-api/mention_invoke.py +431 -0
  47. package/workframe-api/model_surface.py +1139 -0
  48. package/workframe-api/oauth_pending.py +85 -0
  49. package/workframe-api/oauth_redirect.py +381 -0
  50. package/workframe-api/package.json +2 -2
  51. package/workframe-api/profile_api_lifecycle.py +66 -0
  52. package/workframe-api/profile_gateway.py +436 -0
  53. package/workframe-api/provider_bindings.py +673 -0
  54. package/workframe-api/provider_bootstrap.py +347 -0
  55. package/workframe-api/provider_catalog.py +180 -0
  56. package/workframe-api/rooms.py +1613 -0
  57. package/workframe-api/route_registry.py +331 -191
  58. package/workframe-api/run-typecheck.mjs +2 -1
  59. package/workframe-api/run_authority.py +287 -0
  60. package/workframe-api/run_ledger.py +470 -0
  61. package/workframe-api/run_surface_wiring.py +344 -0
  62. package/workframe-api/runtime_cohort.py +752 -0
  63. package/workframe-api/runtime_tokens.py +127 -0
  64. package/workframe-api/server.py +1453 -19513
  65. package/workframe-api/snapshot_feed.py +49 -0
  66. package/workframe-api/stack_config.py +33 -1
  67. package/workframe-api/supervisor_client.py +154 -0
  68. package/workframe-api/test_api_meta_build_stamp.py +34 -0
  69. package/workframe-api/test_cell_authority.py +79 -0
  70. package/workframe-api/test_chat_bind.py +48 -0
  71. package/workframe-api/test_credential_lease_matrix.py +171 -0
  72. package/workframe-api/test_credential_resolve.py +51 -0
  73. package/workframe-api/test_credential_store.py +27 -0
  74. package/workframe-api/test_dogfood_flows.py +346 -0
  75. package/workframe-api/test_domain_entities.py +152 -0
  76. package/workframe-api/test_exception_hygiene.py +12 -0
  77. package/workframe-api/test_hermes_admin.py +72 -0
  78. package/workframe-api/test_install_wizard_resume.py +78 -0
  79. package/workframe-api/test_local_bootstrap.py +67 -0
  80. package/workframe-api/test_mention_helpers.py +35 -0
  81. package/workframe-api/test_mention_invoke.py +26 -0
  82. package/workframe-api/test_model_surface_consistency.py +1 -0
  83. package/workframe-api/test_oauth_pending.py +41 -0
  84. package/workframe-api/test_provider_bindings.py +52 -0
  85. package/workframe-api/test_provider_catalog.py +28 -0
  86. package/workframe-api/test_route_registry.py +171 -35
  87. package/workframe-api/test_run_authority.py +112 -0
  88. package/workframe-api/test_run_ledger.py +157 -0
  89. package/workframe-api/test_run_surface_wiring.py +130 -0
  90. package/workframe-api/test_runtime_cohort.py +85 -0
  91. package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
  92. package/workframe-api/test_server_reexports.py +99 -0
  93. package/workframe-api/test_stack_config_install_smtp.py +7 -0
  94. package/workframe-api/turn_credentials.py +28 -13
  95. package/workframe-api/turn_overlay.py +715 -0
  96. package/workframe-api/updates.py +16 -1
  97. package/workframe-api/user_prefs.py +387 -0
  98. package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
  99. package/workframe-api/workspace_bootstrap.py +536 -0
  100. package/workframe-api/workspace_files.py +344 -0
  101. package/workframe-api/workspace_messaging.py +206 -0
  102. package/workframe-api/zk_auth.py +3 -2
  103. package/workframe-supervisor/server.py +10 -1
  104. package/workframe-supervisor/test_supervisor_negative.py +75 -0
  105. package/workframe-ui/public/assets/{arc-B0OFRGmJ.js → arc-BT9iZUSd.js} +1 -1
  106. package/workframe-ui/public/assets/architecture-7EHR7CIX-m1aPUQ_r.js +1 -0
  107. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DeBNltHS.js → architectureDiagram-3BPJPVTR-CaEDd3np.js} +1 -1
  108. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-BDhCHu7I.js → blockDiagram-GPEHLZMM-DHJhMrIS.js} +1 -1
  109. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-olcDYvtI.js → c4Diagram-AAUBKEIU-Cdyz9hIV.js} +1 -1
  110. package/workframe-ui/public/assets/channel-3M69ekE5.js +1 -0
  111. package/workframe-ui/public/assets/{chunk-2J33WTMH-D0obCBn_.js → chunk-2J33WTMH-BbZx76LX.js} +1 -1
  112. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DX93f-ZZ.js → chunk-3OPIFGDE-DcSxjTIP.js} +1 -1
  113. package/workframe-ui/public/assets/{chunk-4BX2VUAB-BX9B5wUs.js → chunk-4BX2VUAB-B7F-kTY1.js} +1 -1
  114. package/workframe-ui/public/assets/{chunk-55IACEB6-C4DGc6RO.js → chunk-55IACEB6-BwwEiRTK.js} +1 -1
  115. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-Crlja9Lf.js → chunk-5ZQYHXKU-DAi_NksX.js} +1 -1
  116. package/workframe-ui/public/assets/{chunk-727SXJPM-JYSm3szI.js → chunk-727SXJPM-BIaF4XCN.js} +1 -1
  117. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-_KslxVEl.js → chunk-AQP2D5EJ-DjYSk1dG.js} +1 -1
  118. package/workframe-ui/public/assets/{chunk-BSJP7CBP-CpTO-jU8.js → chunk-BSJP7CBP-_twnyfgV.js} +1 -1
  119. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-JGUkCmbI.js → chunk-CSCIHK7Q-CkNmdy5s.js} +2 -2
  120. package/workframe-ui/public/assets/{chunk-FMBD7UC4-BscBwGaC.js → chunk-FMBD7UC4-CjLqd-gl.js} +1 -1
  121. package/workframe-ui/public/assets/{chunk-KSCS5N6A-5ZTZ0bpX.js → chunk-KSCS5N6A-_6oNqFjj.js} +1 -1
  122. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-DxvitYbW.js → chunk-L5ZTLDWV-CeO9Am_D.js} +1 -1
  123. package/workframe-ui/public/assets/chunk-LZXEDZCA-sRM4YeIe.js +2 -0
  124. package/workframe-ui/public/assets/{chunk-ND2GUHAM-Cayl0iV9.js → chunk-ND2GUHAM-CH8tMb21.js} +1 -1
  125. package/workframe-ui/public/assets/{chunk-NZK2D7GU-_7dyBR5G.js → chunk-NZK2D7GU-DAI1bsII.js} +1 -1
  126. package/workframe-ui/public/assets/{chunk-O5CBEL6O--xTpD0yi.js → chunk-O5CBEL6O-B2e-5a2G.js} +1 -1
  127. package/workframe-ui/public/assets/chunk-QZHKN3VN-BMl9ed8P.js +1 -0
  128. package/workframe-ui/public/assets/chunk-WU5MYG2G-C08HH6BU.js +1 -0
  129. package/workframe-ui/public/assets/{chunk-XPW4576I-CwxJQaeK.js → chunk-XPW4576I-DM2-0q37.js} +1 -1
  130. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-BFcOE4Aq.js +1 -0
  131. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-BFcOE4Aq.js +1 -0
  132. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CCspENYv.js → cose-bilkent-S5V4N54A-BnbbewLv.js} +1 -1
  133. package/workframe-ui/public/assets/{dagre-BM42HDAG-Dv8V6R60.js → dagre-BM42HDAG-C4zmB1Vt.js} +1 -1
  134. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Da48hFPT.js → diagram-2AECGRRQ-n-BJmsiC.js} +1 -1
  135. package/workframe-ui/public/assets/{diagram-5GNKFQAL-dTihc7-3.js → diagram-5GNKFQAL-BzQGQnZi.js} +1 -1
  136. package/workframe-ui/public/assets/{diagram-KO2AKTUF-D_Jqu699.js → diagram-KO2AKTUF-Cx_De-wS.js} +1 -1
  137. package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt8PtEaZ.js → diagram-LMA3HP47-CIOI3Lok.js} +1 -1
  138. package/workframe-ui/public/assets/{diagram-OG6HWLK6-BtmPjlh0.js → diagram-OG6HWLK6-DkRuPLWY.js} +1 -1
  139. package/workframe-ui/public/assets/{dist-Cz2turIT.js → dist-XhCT1Q-V.js} +1 -1
  140. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-BxMGCflX.js → erDiagram-TEJ5UH35-C3GwhDU1.js} +1 -1
  141. package/workframe-ui/public/assets/eventmodeling-FCH6USID-DIVBL6CF.js +1 -0
  142. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-ivyroIZt.js → flowDiagram-I6XJVG4X-CLWH8z2e.js} +1 -1
  143. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-B9llwShx.js → ganttDiagram-6RSMTGT7-D4h66Vy3.js} +1 -1
  144. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BFQHk4bw.js → gitGraph-WXDBUCRP-IW5F-Q6c.js} +1 -1
  145. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-ursegLbc.js → gitGraphDiagram-PVQCEYII-BjNIbmRM.js} +1 -1
  146. package/workframe-ui/public/assets/index-DL2vJP4L.css +1 -0
  147. package/workframe-ui/public/assets/index-ElS_D59P.js +130 -0
  148. package/workframe-ui/public/assets/{info-J43DQDTF-DQpifAsB.js → info-J43DQDTF-CcJspM79.js} +1 -1
  149. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-C16XP2Q7.js → infoDiagram-5YYISTIA-DP4xR9jt.js} +1 -1
  150. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-CiJoz7rP.js → ishikawaDiagram-YF4QCWOH-D4cZb21V.js} +1 -1
  151. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CVwEvvUL.js → journeyDiagram-JHISSGLW-DsLuHkIJ.js} +1 -1
  152. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CWQ6hX5i.js → kanban-definition-UN3LZRKU-Wu65nOsR.js} +1 -1
  153. package/workframe-ui/public/assets/{line-CgQsmU35.js → line-ByBDzRpy.js} +1 -1
  154. package/workframe-ui/public/assets/{linear-Bxbc77dL.js → linear-DzXi2vnq.js} +1 -1
  155. package/workframe-ui/public/assets/{mermaid-parser.core-C_jMeF0a.js → mermaid-parser.core-CaB-1Ckn.js} +2 -2
  156. package/workframe-ui/public/assets/{mermaid.core-4RKV9MZP.js → mermaid.core-D7HTHC17.js} +3 -3
  157. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-DZ7y7Sz0.js → mindmap-definition-RKZ34NQL-BfKsC9aj.js} +1 -1
  158. package/workframe-ui/public/assets/{packet-YPE3B663-B9h2I7Vq.js → packet-YPE3B663-ClpU98TO.js} +1 -1
  159. package/workframe-ui/public/assets/{pie-LRSECV5Y-B2mP5uHm.js → pie-LRSECV5Y-KeOyY_-u.js} +1 -1
  160. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-DyYKyKiP.js → pieDiagram-4H26LBE5-CUpjnzbs.js} +1 -1
  161. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-CL_6nej-.js → quadrantDiagram-W4KKPZXB-CJdPTszF.js} +1 -1
  162. package/workframe-ui/public/assets/{radar-GUYGQ44K-CNKNDQ7S.js → radar-GUYGQ44K-C9BpandZ.js} +1 -1
  163. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-eWU_mhFa.js → requirementDiagram-4Y6WPE33-CHDZPwry.js} +1 -1
  164. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DrjcXvEQ.js → sankeyDiagram-5OEKKPKP-CMJJh91s.js} +1 -1
  165. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BHWR1xTJ.js → sequenceDiagram-3UESZ5HK-BsV4GppP.js} +1 -1
  166. package/workframe-ui/public/assets/{src-DfJB8kV1.js → src-WHks82Up.js} +1 -1
  167. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BGJbSd3I.js → stateDiagram-AJRCARHV-eSX9P8z0.js} +1 -1
  168. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-FzMK4PkM.js +1 -0
  169. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DkwoCjiZ.js → timeline-definition-PNZ67QCA-Dg1UkhGo.js} +1 -1
  170. package/workframe-ui/public/assets/{treeView-BLDUP644-GxfhiqoW.js → treeView-BLDUP644-B7yGsBuj.js} +1 -1
  171. package/workframe-ui/public/assets/{treemap-LRROVOQU-DxrOsars.js → treemap-LRROVOQU-j_G2oyQ_.js} +1 -1
  172. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-BsWhpUfr.js → vennDiagram-CIIHVFJN-BRrT0jqC.js} +1 -1
  173. package/workframe-ui/public/assets/{wardley-L42UT6IY-CrFZWMHS.js → wardley-L42UT6IY-DEdUqez2.js} +1 -1
  174. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DV8Xpf5I.js → wardleyDiagram-YWT4CUSO-C-bBK9Bb.js} +1 -1
  175. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DqMqnwdZ.js → xychartDiagram-2RQKCTM6-DhH5xuR2.js} +1 -1
  176. package/workframe-ui/public/index.html +11 -7
  177. package/workframe-ui/public/workframe-build.json +5 -0
  178. package/workframe-ui/public/assets/architecture-7EHR7CIX-Dwwi9yA0.js +0 -1
  179. package/workframe-ui/public/assets/channel-fNHbDpV4.js +0 -1
  180. package/workframe-ui/public/assets/chunk-LZXEDZCA-DMfdMUwz.js +0 -2
  181. package/workframe-ui/public/assets/chunk-QZHKN3VN-ewTgEQK8.js +0 -1
  182. package/workframe-ui/public/assets/chunk-WU5MYG2G-D4Tg-A0l.js +0 -1
  183. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CFAgBpEl.js +0 -1
  184. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CFAgBpEl.js +0 -1
  185. package/workframe-ui/public/assets/eventmodeling-FCH6USID-BIJTP5k-.js +0 -1
  186. package/workframe-ui/public/assets/index-8CuZDEIG.css +0 -1
  187. package/workframe-ui/public/assets/index-xS9lFekI.js +0 -129
  188. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CMLuNyeC.js +0 -1
@@ -0,0 +1,423 @@
1
+ """WF-032 extract: lane_bindings."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import queue
7
+ import re
8
+ import shlex
9
+ import shutil
10
+ import sqlite3
11
+ import threading
12
+ import time
13
+ import uuid
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from http.server import BaseHTTPRequestHandler
18
+
19
+ import profile_config_yaml
20
+ import user_prefs
21
+
22
+
23
+ def _srv():
24
+ import server as srv
25
+
26
+ return srv
27
+
28
+
29
+ def _load_lane_registry() -> dict[str, Any]:
30
+ path = _srv()._lane_registry_json()
31
+ if not path.is_file():
32
+ return {"profiles": {}}
33
+ try:
34
+ data = json.loads(path.read_text(encoding="utf-8"))
35
+ if isinstance(data, dict) and isinstance(data.get("profiles"), dict):
36
+ return data
37
+ except Exception: # noqa: BLE001
38
+ pass
39
+ return {"profiles": {}}
40
+
41
+
42
+ def _save_lane_registry(data: dict[str, Any]) -> None:
43
+ path = _srv()._lane_registry_json()
44
+ path.parent.mkdir(parents=True, exist_ok=True)
45
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
46
+
47
+ def _binding_version(raw: Any) -> int:
48
+ try:
49
+ value = int(raw or 0)
50
+ except (TypeError, ValueError):
51
+ return 0
52
+ return value if value > 0 else 0
53
+
54
+ def _binding_row_for(
55
+ profile: str,
56
+ source_id: str,
57
+ client_id: str,
58
+ binding_version: int = 0,
59
+ ) -> dict[str, Any] | None:
60
+ prof = _srv().resolve_hermes_profile(profile)
61
+ registry = _load_lane_registry()
62
+ bucket = _registry_profile_bucket(registry, prof)
63
+ bindings = bucket.get("bindings") if isinstance(bucket.get("bindings"), dict) else {}
64
+ binding_key = _source_binding_key(source_id, client_id)
65
+ row = bindings.get(binding_key) if isinstance(bindings, dict) else None
66
+ if not isinstance(row, dict):
67
+ return None
68
+ if binding_version and _binding_version(row.get("binding_version")) != binding_version:
69
+ return None
70
+ session_id = str(row.get("session_id") or "").strip()
71
+ if session_id and not _srv()._session_exists(prof, session_id):
72
+ return None
73
+ return row
74
+
75
+ def _binding_session_for(profile: str, source_id: str, client_id: str, binding_version: int = 0) -> str:
76
+ row = _binding_row_for(profile, source_id, client_id, binding_version)
77
+ return str((row or {}).get("session_id") or "").strip()
78
+
79
+ def profile_chat_session(profile: str, payload: dict[str, Any], user_id: str = "") -> dict[str, Any]:
80
+ source_id = str(payload.get("source_id") or "ui").strip() or "ui"
81
+ client_id = str(payload.get("client_id") or "default").strip() or "default"
82
+ binding_version = _binding_version(payload.get("binding_version"))
83
+ force_new = bool(payload.get("new_session") or False)
84
+ room_id = str(payload.get("room_id") or "").strip()
85
+ user_id = str(user_id or payload.get("user_id") or "").strip()
86
+ workspace_id = str(payload.get("workspace_id") or "").strip()
87
+ if room_id and not workspace_id:
88
+ try:
89
+ conn_ws = _srv()._workframe_db()
90
+ row = conn_ws.execute(
91
+ "SELECT workspace_id FROM rooms WHERE id = ? AND deleted_at IS NULL",
92
+ (room_id,),
93
+ ).fetchone()
94
+ conn_ws.close()
95
+ if row:
96
+ workspace_id = str(row["workspace_id"])
97
+ except Exception: # noqa: BLE001
98
+ pass
99
+
100
+ hermes_prof = ""
101
+ template_prof = ""
102
+ agent_db_id = ""
103
+ room_conn: sqlite3.Connection | None = None
104
+
105
+ if room_id:
106
+ if not user_id:
107
+ raise ValueError("room_id requires authenticated user")
108
+ room_conn = _srv()._workframe_db()
109
+ try:
110
+ room = room_conn.execute(
111
+ "SELECT * FROM rooms WHERE id = ? AND deleted_at IS NULL",
112
+ (room_id,),
113
+ ).fetchone()
114
+ if not room:
115
+ raise ValueError("room_not_found")
116
+ if not _srv()._user_can_access_room(room_conn, room_id, user_id):
117
+ raise ValueError("room_access_denied")
118
+ if not workspace_id:
119
+ workspace_id = str(room["workspace_id"])
120
+ if _srv()._is_space_room(str(room["room_type"]), room["agent_profile_id"]):
121
+ raw_prof = str(profile or _srv()._primary_profile()).strip()
122
+ if _srv()._is_runtime_profile_slug(raw_prof):
123
+ template_prof = _srv().resolve_validated_profile(_srv()._runtime_template_slug(raw_prof))
124
+ hermes_prof = raw_prof
125
+ else:
126
+ template_prof = _srv().resolve_validated_profile(raw_prof)
127
+ hermes_prof = _srv()._resolve_chat_hermes_profile(
128
+ template_prof, user_id, room_id, workspace_id,
129
+ )
130
+ agent_row = _srv()._lookup_agent_profile(room_conn, workspace_id, template_prof)
131
+ if not agent_row:
132
+ raise ValueError("agent_profile_not_found")
133
+ agent_db_id = str(agent_row["id"])
134
+ else:
135
+ _room, template_prof, hermes_prof, agent_db_id, workspace_id = _srv()._resolve_room_agent_chat(
136
+ room_conn, user_id, room_id,
137
+ )
138
+ except Exception:
139
+ if room_conn is not None:
140
+ room_conn.close()
141
+ raise
142
+ else:
143
+ hermes_prof, template_prof = _srv()._resolve_bind_profile_arg(
144
+ profile, user_id, "", workspace_id,
145
+ )
146
+ payer = user_id
147
+ model_block = _srv()._read_model_block(hermes_prof) if hermes_prof else {}
148
+ llm_provider = _srv()._llm_billing_provider(
149
+ hermes_prof, user_id=payer, workspace_id=workspace_id, block=model_block,
150
+ )
151
+ llm_ready = _srv()._user_can_use_llm(payer, workspace_id, llm_provider) if payer else False
152
+ # ponytail: bind = session lookup only; gateway + billing reconcile happen on send/model-save.
153
+ api_port = _srv()._profile_api_port(hermes_prof) if hermes_prof else 0
154
+ lifecycle = {"ok": True, "profile": hermes_prof, "api_port": api_port, "started": False}
155
+ session_extra = {"llm_ready": llm_ready, "has_llm_provider": llm_ready}
156
+
157
+ if room_id:
158
+ conn = room_conn if room_conn is not None else _srv()._workframe_db()
159
+ close_conn = room_conn is None
160
+ try:
161
+ existing_row = (
162
+ None
163
+ if force_new
164
+ else _srv()._get_active_room_session(conn, room_id, agent_db_id)
165
+ )
166
+ if existing_row:
167
+ sid = str(existing_row["session_id"]).strip()
168
+ if not sid:
169
+ existing_row = None
170
+ if existing_row:
171
+ sid = str(existing_row["session_id"]).strip()
172
+ gateway_sid = str(existing_row["gateway_session_id"] or f"api:{hermes_prof}:{sid}").strip()
173
+ session_title = str(existing_row["title"] or _srv()._default_session_title(template_prof))
174
+ if _srv()._is_blank_session_title(str(existing_row["title"] or "")):
175
+ _srv()._ensure_hermes_session_title(hermes_prof, sid, session_title)
176
+ _srv()._upsert_room_session(
177
+ conn,
178
+ room_id=room_id,
179
+ agent_profile_id=agent_db_id,
180
+ session_id=sid,
181
+ gateway_session_id=gateway_sid,
182
+ created_by=user_id,
183
+ title=session_title,
184
+ )
185
+ conn.commit()
186
+ _sync_lane_binding(
187
+ hermes_prof,
188
+ source_id,
189
+ client_id,
190
+ binding_version,
191
+ sid,
192
+ gateway_sid,
193
+ )
194
+ display_title = (
195
+ session_title
196
+ if not _srv()._is_blank_session_title(session_title)
197
+ else _srv()._resolved_session_title(hermes_prof, sid, session_title)
198
+ )
199
+ return {
200
+ "ok": True,
201
+ "profile": hermes_prof,
202
+ "template_profile": template_prof,
203
+ "workspace_id": workspace_id,
204
+ "room_id": room_id,
205
+ "session_id": sid,
206
+ "title": display_title,
207
+ "api_port": lifecycle["api_port"],
208
+ "created": False,
209
+ "resumed": str(existing_row["status"]) != "active",
210
+ **session_extra,
211
+ }
212
+ new_id = f"wf_room_{room_id[:8]}_{int(time.time())}_{uuid.uuid4().hex[:6]}"
213
+ requested_title = str(payload.get("title") or "").strip() or _srv()._default_session_title(template_prof)
214
+ status, data, session_title = _srv()._create_profile_session_via_api(hermes_prof, new_id, requested_title)
215
+ if status >= 300:
216
+ raise ValueError(f"create session failed: {data}")
217
+ gateway_sid = f"api:{hermes_prof}:{new_id}"
218
+ _srv()._upsert_room_session(
219
+ conn,
220
+ room_id=room_id,
221
+ agent_profile_id=agent_db_id,
222
+ session_id=new_id,
223
+ gateway_session_id=gateway_sid,
224
+ created_by=user_id,
225
+ title=session_title or requested_title,
226
+ )
227
+ conn.commit()
228
+ finally:
229
+ if close_conn:
230
+ conn.close()
231
+ elif room_conn is not None:
232
+ room_conn.close()
233
+ _sync_lane_binding(
234
+ hermes_prof,
235
+ source_id,
236
+ client_id,
237
+ binding_version,
238
+ new_id,
239
+ gateway_sid,
240
+ )
241
+ return {
242
+ "ok": True,
243
+ "profile": hermes_prof,
244
+ "template_profile": template_prof,
245
+ "workspace_id": workspace_id,
246
+ "room_id": room_id,
247
+ "session_id": new_id,
248
+ "title": session_title,
249
+ "api_port": lifecycle["api_port"],
250
+ "created": True,
251
+ **session_extra,
252
+ }
253
+
254
+ # ponytail: legacy client_id binding when room_id omitted (tests / old clients)
255
+ existing = "" if force_new else _binding_session_for(hermes_prof, source_id, client_id, binding_version)
256
+ if existing and _srv()._session_exists(hermes_prof, existing):
257
+ return {
258
+ "ok": True,
259
+ "profile": hermes_prof,
260
+ "session_id": existing,
261
+ "title": _srv()._session_info(hermes_prof, existing).get("title") or _srv()._default_session_title(template_prof),
262
+ "api_port": lifecycle["api_port"],
263
+ "created": False,
264
+ **session_extra,
265
+ }
266
+ new_id = f"wf_{source_id}_{client_id}_{int(time.time())}_{uuid.uuid4().hex[:6]}"
267
+ requested_title = str(payload.get("title") or "").strip() or _srv()._default_session_title(template_prof)
268
+ status, data, session_title = _srv()._create_profile_session_via_api(hermes_prof, new_id, requested_title)
269
+ if status >= 300:
270
+ raise ValueError(f"create session failed: {data}")
271
+ chat_dispatch(
272
+ {
273
+ "profile": hermes_prof,
274
+ "session_id": new_id,
275
+ "gateway_session_id": f"api:{hermes_prof}:{new_id}",
276
+ "source_id": source_id,
277
+ "client_id": client_id,
278
+ "binding_version": binding_version,
279
+ "text": "",
280
+ }
281
+ )
282
+ return {
283
+ "ok": True,
284
+ "profile": hermes_prof,
285
+ "session_id": new_id,
286
+ "title": session_title,
287
+ "api_port": lifecycle["api_port"],
288
+ "created": True,
289
+ **session_extra,
290
+ }
291
+
292
+ def _sync_lane_binding(
293
+ profile: str,
294
+ source_id: str,
295
+ client_id: str,
296
+ binding_version: int,
297
+ session_id: str,
298
+ gateway_session_id: str,
299
+ text_preview: str = "",
300
+ ) -> None:
301
+ """Keep lane-registry aligned with room_sessions for UI client/source binding."""
302
+ # ponytail: registry bookkeeping — slug only, no Hermes disk gate
303
+ prof = _srv().safe_profile_slug(profile)
304
+ sid = str(session_id or "").strip()
305
+ if not sid:
306
+ return
307
+ gw = str(gateway_session_id or f"api:{prof}:{sid}").strip()
308
+ registry = _load_lane_registry()
309
+ bucket = _registry_profile_bucket(registry, prof)
310
+ binding_key = _source_binding_key(source_id, client_id)
311
+ bindings = bucket.setdefault("bindings", {})
312
+ bindings[binding_key] = {
313
+ "session_id": sid,
314
+ "gateway_session_id": gw,
315
+ "binding_version": binding_version,
316
+ "last_text_preview": str(text_preview or "")[:240],
317
+ "source_id": source_id,
318
+ "client_id": client_id,
319
+ "updated_at": _srv()._utc_now(),
320
+ }
321
+ _save_lane_registry(registry)
322
+
323
+ def _source_binding_key(source_id: str, client_id: str) -> str:
324
+ src = (source_id or "ui").strip().lower()
325
+ cli = (client_id or "default").strip().lower()
326
+ safe_src = re.sub(r"[^a-z0-9._-]+", "-", src)[:64] or "ui"
327
+ safe_cli = re.sub(r"[^a-z0-9._-]+", "-", cli)[:96] or "default"
328
+ return f"{safe_src}:{safe_cli}"
329
+
330
+ def _registry_profile_bucket(registry: dict[str, Any], profile: str) -> dict[str, Any]:
331
+ profiles = registry.setdefault("profiles", {})
332
+ bucket = profiles.setdefault(profile, {})
333
+ bindings = bucket.setdefault("bindings", {})
334
+ if not isinstance(bindings, dict):
335
+ bucket["bindings"] = {}
336
+ return bucket
337
+
338
+ def chat_resolve(payload: dict[str, Any]) -> dict[str, Any]:
339
+ profile = _srv().resolve_hermes_profile(str(payload.get("profile") or ""))
340
+ source_id = str(payload.get("source_id") or "ui").strip() or "ui"
341
+ client_id = str(payload.get("client_id") or "default").strip() or "default"
342
+ binding_version = _binding_version(payload.get("binding_version"))
343
+ binding_key = _source_binding_key(source_id, client_id)
344
+ row = _binding_row_for(profile, source_id, client_id, binding_version)
345
+ session_id = str((row or {}).get("session_id") or "").strip()
346
+ return {
347
+ "ok": True,
348
+ "profile": profile,
349
+ "source_id": source_id,
350
+ "client_id": client_id,
351
+ "binding_key": binding_key,
352
+ "session_id": session_id,
353
+ }
354
+
355
+ def chat_dispatch(payload: dict[str, Any]) -> dict[str, Any]:
356
+ profile = _srv().resolve_hermes_profile(str(payload.get("profile") or ""))
357
+ session_id = str(payload.get("session_id") or "").strip()
358
+ gateway_session_id = str(payload.get("gateway_session_id") or "").strip()
359
+ source_id = str(payload.get("source_id") or "ui").strip() or "ui"
360
+ client_id = str(payload.get("client_id") or "default").strip() or "default"
361
+ binding_version = _binding_version(payload.get("binding_version"))
362
+ room_id = str(payload.get("room_id") or "").strip()
363
+ user_id = str(payload.get("user_id") or "").strip()
364
+ text = str(payload.get("text") or "").strip()
365
+ if not session_id:
366
+ raise ValueError("session_id required")
367
+ if not gateway_session_id:
368
+ raise ValueError("gateway_session_id required")
369
+
370
+ if room_id:
371
+ conn = _srv()._workframe_db()
372
+ try:
373
+ room = conn.execute(
374
+ "SELECT workspace_id, agent_profile_id FROM rooms WHERE id = ? AND deleted_at IS NULL",
375
+ (room_id,),
376
+ ).fetchone()
377
+ agent_profile_id = ""
378
+ if room:
379
+ room_agent = str(room["agent_profile_id"] or "").strip()
380
+ if room_agent:
381
+ agent_profile_id = room_agent
382
+ else:
383
+ agent_row = _srv()._lookup_agent_profile(conn, str(room["workspace_id"]), profile)
384
+ agent_profile_id = str(agent_row["id"]) if agent_row else ""
385
+ if agent_profile_id:
386
+ _srv()._upsert_room_session(
387
+ conn,
388
+ room_id=room_id,
389
+ agent_profile_id=agent_profile_id,
390
+ session_id=session_id,
391
+ gateway_session_id=gateway_session_id,
392
+ created_by=user_id or "system",
393
+ )
394
+ conn.commit()
395
+ finally:
396
+ conn.close()
397
+ _sync_lane_binding(
398
+ profile,
399
+ source_id,
400
+ client_id,
401
+ binding_version,
402
+ session_id,
403
+ gateway_session_id,
404
+ text,
405
+ )
406
+ return {"ok": True, "profile": profile, "room_id": room_id, "session_id": session_id}
407
+
408
+ registry = _load_lane_registry()
409
+ bucket = _registry_profile_bucket(registry, profile)
410
+ binding_key = _source_binding_key(source_id, client_id)
411
+ bindings = bucket.setdefault("bindings", {})
412
+ bindings[binding_key] = {
413
+ "session_id": session_id,
414
+ "gateway_session_id": gateway_session_id,
415
+ "binding_version": binding_version,
416
+ "last_text_preview": text[:240],
417
+ "source_id": source_id,
418
+ "client_id": client_id,
419
+ "updated_at": _srv()._utc_now(),
420
+ }
421
+ _save_lane_registry(registry)
422
+ return {"ok": True, "profile": profile, "session_id": session_id, "binding_key": binding_key}
423
+
@@ -13,6 +13,7 @@ from typing import Any, Callable
13
13
  from http.server import BaseHTTPRequestHandler
14
14
 
15
15
  import internal_proxy_auth
16
+ import credential_broker
16
17
  import turn_credentials
17
18
 
18
19
  LEASE_PREFIX = turn_credentials.LEASE_PREFIX
@@ -52,38 +53,14 @@ def authorize_internal_proxy(handler: BaseHTTPRequestHandler) -> tuple[bool, str
52
53
  return internal_proxy_auth.authorize_internal_proxy(handler)
53
54
 
54
55
 
55
- def extract_profile_slug(headers: dict[str, str]) -> str:
56
- return str(
57
- headers.get(internal_proxy_auth.PROFILE_HEADER)
58
- or headers.get("x-workframe-profile")
59
- or ""
60
- ).strip()
56
+ extract_bearer = credential_broker.extract_bearer
57
+ extract_profile_slug = credential_broker.extract_profile_slug
58
+ validate_lease_profile = credential_broker.validate_lease_profile
61
59
 
62
60
 
63
- def validate_lease_profile(
64
- lease: dict[str, Any],
65
- headers: dict[str, str],
66
- ) -> tuple[bool, str, int]:
67
- """Bind bearer lease to calling Hermes profile (0022 N2 / 0023 C1)."""
68
- want = str(lease.get("profile_slug") or "").strip()
69
- if not want:
70
- return True, "", 0
71
- got = extract_profile_slug(headers)
72
- if not got:
73
- return False, "profile header required", 403
74
- if got != want:
75
- return False, "profile mismatch", 403
76
- return True, "", 0
77
-
78
-
79
- def extract_bearer(headers: dict[str, str]) -> str:
80
- auth = str(headers.get("Authorization") or headers.get("authorization") or "").strip()
81
- if auth.lower().startswith("bearer "):
82
- return auth[7:].strip()
83
- api_key = str(headers.get("X-Api-Key") or headers.get("x-api-key") or "").strip()
84
- if api_key:
85
- return api_key
86
- return ""
61
+ def _upstream_host(provider: str) -> str:
62
+ base = UPSTREAM_BASE.get(str(provider or "").strip().lower(), "")
63
+ return str(base).split("://", 1)[-1].split("/", 1)[0].lower()
87
64
 
88
65
 
89
66
  def upstream_auth_header(provider: str, secret: str) -> dict[str, str]:
@@ -114,20 +91,17 @@ def _build_upstream_request(
114
91
  if not base:
115
92
  return None, _error_response(404, "unknown provider")
116
93
 
117
- token = extract_bearer(headers)
118
- lease = turn_credentials.validate_lease(token)
119
- if not lease:
120
- return None, _error_response(401, "invalid lease")
121
- if str(lease.get("provider") or "").lower() != provider:
122
- return None, _error_response(403, "provider mismatch")
123
-
124
- ok_profile, profile_err, profile_status = validate_lease_profile(lease, headers)
125
- if not ok_profile:
126
- return None, _error_response(profile_status, profile_err)
94
+ auth = credential_broker.authorize_broker_lease(
95
+ provider,
96
+ headers,
97
+ resolve_secret=resolve_secret,
98
+ broker_kind="llm",
99
+ upstream_host=_upstream_host(provider),
100
+ )
101
+ if not auth.ok:
102
+ return None, _error_response(auth.status, auth.error)
127
103
 
128
- _env_var, secret = turn_credentials.resolve_lease_secret(lease, resolve_secret)
129
- if not secret:
130
- return None, _error_response(402, "no credential")
104
+ secret = auth.secret
131
105
 
132
106
  path = normalize_upstream_path(base, subpath)
133
107
  url = f"{base.rstrip('/')}{path}"
@@ -0,0 +1,180 @@
1
+ """WF-032 extract: @mention handle resolution for space rooms."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sqlite3
7
+ import threading
8
+ from typing import Any
9
+
10
+
11
+ def _srv():
12
+ import server as srv
13
+
14
+ return srv
15
+
16
+
17
+ def mention_handle(label: str, email: str = "") -> str:
18
+ label = str(label or "").strip().lower()
19
+ if label:
20
+ handle = re.sub(r"[^a-z0-9]+", "", label.replace(" ", ""))
21
+ if handle:
22
+ return handle[:32]
23
+ normalized = str(email or "").strip().lower()
24
+ if normalized and "@" in normalized:
25
+ local = normalized.split("@", 1)[0]
26
+ handle = re.sub(r"[^a-z0-9]+", "", local)
27
+ if handle:
28
+ return handle[:32]
29
+ return ""
30
+
31
+
32
+ def room_agents_for_mentions(
33
+ conn: sqlite3.Connection,
34
+ room_id: str,
35
+ workspace_id: str,
36
+ ) -> list[dict[str, Any]]:
37
+ rows = conn.execute(
38
+ """
39
+ SELECT ap.id, ap.slug, ap.display_name, ap.model_provider, ap.model_name
40
+ FROM room_memberships rm
41
+ JOIN agent_profiles ap ON ap.id = rm.agent_profile_id
42
+ WHERE rm.room_id = ? AND rm.deleted_at IS NULL AND ap.deleted_at IS NULL
43
+ """,
44
+ (room_id,),
45
+ ).fetchall()
46
+ if rows:
47
+ return [dict(row) for row in rows]
48
+ # ponytail: until membership backfill completes, allow all workspace agents in spaces
49
+ fallback = conn.execute(
50
+ """
51
+ SELECT id, slug, display_name, model_provider, model_name
52
+ FROM agent_profiles
53
+ WHERE workspace_id = ? AND deleted_at IS NULL
54
+ """,
55
+ (workspace_id,),
56
+ ).fetchall()
57
+ return [dict(row) for row in fallback]
58
+
59
+
60
+ def room_users_for_mentions(conn: sqlite3.Connection, room_id: str) -> list[dict[str, Any]]:
61
+ rows = conn.execute(
62
+ """
63
+ SELECT u.id, u.display_name, u.email
64
+ FROM room_memberships rm
65
+ JOIN users u ON u.id = rm.user_id
66
+ WHERE rm.room_id = ? AND rm.deleted_at IS NULL AND rm.user_id IS NOT NULL
67
+ """,
68
+ (room_id,),
69
+ ).fetchall()
70
+ users: list[dict[str, Any]] = []
71
+ for row in rows:
72
+ users.append(
73
+ {
74
+ "id": str(row["id"]),
75
+ "display_name": str(row["display_name"] or ""),
76
+ "email": str(row["email"] or ""),
77
+ "handle": mention_handle(str(row["display_name"] or ""), str(row["email"] or "")),
78
+ }
79
+ )
80
+ return users
81
+
82
+
83
+ def parse_room_mentions(
84
+ text: str,
85
+ agents: list[dict[str, Any]],
86
+ users: list[dict[str, Any]],
87
+ *,
88
+ triggered_by_user_id: str = "",
89
+ workspace_id: str = "",
90
+ ) -> dict[str, list[dict[str, Any]]]:
91
+ """Resolve @handles in a space message to agents (invoke) and users (notify-only)."""
92
+ handles = {m.group(1).lower() for m in re.finditer(r"@([\w-]+)", str(text or ""))}
93
+ if not handles:
94
+ return {"agents": [], "users": []}
95
+ agent_by_handle: dict[str, dict[str, Any]] = {}
96
+ for agent in agents:
97
+ slug = str(agent.get("slug") or "").strip().lower()
98
+ if slug:
99
+ agent_by_handle[slug] = agent
100
+ display = str(agent.get("display_name") or "").strip().lower()
101
+ if display:
102
+ agent_by_handle[display] = agent
103
+ compact = re.sub(r"[^a-z0-9]+", "", display)
104
+ if compact:
105
+ agent_by_handle[compact] = agent
106
+ if triggered_by_user_id and slug:
107
+ personal = _srv()._runtime_display_label(triggered_by_user_id, slug, workspace_id).strip().lower()
108
+ if personal:
109
+ agent_by_handle[personal] = agent
110
+ personal_compact = re.sub(r"[^a-z0-9]+", "", personal)
111
+ if personal_compact:
112
+ agent_by_handle[personal_compact] = agent
113
+ user_by_handle = {str(u["handle"]).lower(): u for u in users if u.get("handle")}
114
+ matched_agents: list[dict[str, Any]] = []
115
+ seen_agents: set[str] = set()
116
+ for handle in handles:
117
+ agent = agent_by_handle.get(handle)
118
+ if agent and str(agent["id"]) not in seen_agents:
119
+ seen_agents.add(str(agent["id"]))
120
+ matched_agents.append(agent)
121
+ matched_users: list[dict[str, Any]] = []
122
+ seen_users: set[str] = set()
123
+ for handle in handles:
124
+ user = user_by_handle.get(handle)
125
+ if user and str(user["id"]) not in seen_users:
126
+ seen_users.add(str(user["id"]))
127
+ matched_users.append(user)
128
+ return {"agents": matched_agents, "users": matched_users}
129
+
130
+
131
+ def parse_mentions(text: str) -> list[dict]:
132
+ """Extract @agent mentions from message text."""
133
+ mentions = []
134
+ for match in re.finditer(r"@(\w+)", text):
135
+ slug = match.group(1)
136
+ mentions.append({"type": "agent", "slug": slug})
137
+ return mentions
138
+
139
+
140
+ def process_space_message_mentions(
141
+ room_id: str,
142
+ workspace_id: str,
143
+ user_id: str,
144
+ content: str,
145
+ message_id: str,
146
+ *,
147
+ invoke_agents: bool = True,
148
+ ) -> dict[str, Any]:
149
+ conn = _srv()._workframe_db()
150
+ try:
151
+ agents = room_agents_for_mentions(conn, room_id, workspace_id)
152
+ users = room_users_for_mentions(conn, room_id)
153
+ finally:
154
+ conn.close()
155
+ mentions = parse_room_mentions(
156
+ content,
157
+ agents,
158
+ users,
159
+ triggered_by_user_id=user_id,
160
+ workspace_id=workspace_id,
161
+ )
162
+ invoked: list[str] = []
163
+ for agent in mentions["agents"]:
164
+ invoked.append(str(agent["slug"]))
165
+ if invoke_agents:
166
+ threading.Thread(
167
+ target=_srv()._invoke_room_agent_mention,
168
+ kwargs={
169
+ "room_id": room_id,
170
+ "workspace_id": workspace_id,
171
+ "agent_row": agent,
172
+ "triggered_by_user_id": user_id,
173
+ "parent_message_id": message_id,
174
+ },
175
+ daemon=True,
176
+ ).start()
177
+ return {
178
+ "agent_mentions": invoked,
179
+ "user_mentions": [str(u.get("handle") or "") for u in mentions["users"]],
180
+ }