create-workframe 0.1.11 → 0.1.13

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 (195) 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 +85 -0
  5. package/package.json +1 -1
  6. package/scripts/bundle-workframe-ui.mjs +9 -0
  7. package/scripts/sync-canonical-to-package.mjs +9 -0
  8. package/scripts/verify-public-deploy.sh +121 -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/openrouter_catalog.py +4 -4
  51. package/workframe-api/package.json +2 -2
  52. package/workframe-api/profile_api_lifecycle.py +66 -0
  53. package/workframe-api/profile_config_yaml.py +126 -0
  54. package/workframe-api/profile_gateway.py +436 -0
  55. package/workframe-api/provider_bindings.py +673 -0
  56. package/workframe-api/provider_bootstrap.py +347 -0
  57. package/workframe-api/provider_catalog.py +180 -0
  58. package/workframe-api/rooms.py +1613 -0
  59. package/workframe-api/route_registry.py +470 -0
  60. package/workframe-api/run-typecheck.mjs +26 -0
  61. package/workframe-api/run_authority.py +287 -0
  62. package/workframe-api/run_ledger.py +470 -0
  63. package/workframe-api/run_surface_wiring.py +344 -0
  64. package/workframe-api/runtime_cohort.py +752 -0
  65. package/workframe-api/runtime_tokens.py +127 -0
  66. package/workframe-api/server.py +1376 -19305
  67. package/workframe-api/snapshot_feed.py +49 -0
  68. package/workframe-api/stack_config.py +33 -1
  69. package/workframe-api/supervisor_client.py +154 -0
  70. package/workframe-api/test_api_meta_build_stamp.py +34 -0
  71. package/workframe-api/test_cell_authority.py +79 -0
  72. package/workframe-api/test_chat_bind.py +48 -0
  73. package/workframe-api/test_credential_lease_matrix.py +171 -0
  74. package/workframe-api/test_credential_resolve.py +51 -0
  75. package/workframe-api/test_credential_store.py +27 -0
  76. package/workframe-api/test_dogfood_flows.py +346 -0
  77. package/workframe-api/test_domain_entities.py +152 -0
  78. package/workframe-api/test_exception_hygiene.py +12 -0
  79. package/workframe-api/test_hermes_admin.py +72 -0
  80. package/workframe-api/test_install_wizard_resume.py +78 -0
  81. package/workframe-api/test_local_bootstrap.py +67 -0
  82. package/workframe-api/test_mention_helpers.py +35 -0
  83. package/workframe-api/test_mention_invoke.py +26 -0
  84. package/workframe-api/test_model_surface_consistency.py +117 -0
  85. package/workframe-api/test_oauth_pending.py +41 -0
  86. package/workframe-api/test_profile_model_yaml.py +99 -0
  87. package/workframe-api/test_provider_bindings.py +52 -0
  88. package/workframe-api/test_provider_catalog.py +28 -0
  89. package/workframe-api/test_public_routes.py +41 -0
  90. package/workframe-api/test_route_registry.py +245 -0
  91. package/workframe-api/test_run_authority.py +112 -0
  92. package/workframe-api/test_run_ledger.py +157 -0
  93. package/workframe-api/test_run_surface_wiring.py +130 -0
  94. package/workframe-api/test_runtime_cohort.py +85 -0
  95. package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
  96. package/workframe-api/test_server_reexports.py +99 -0
  97. package/workframe-api/test_stack_config_install_smtp.py +7 -0
  98. package/workframe-api/time-bind-steps.py +75 -0
  99. package/workframe-api/time-perf.py +81 -0
  100. package/workframe-api/time-warm.py +107 -0
  101. package/workframe-api/turn_credentials.py +28 -13
  102. package/workframe-api/turn_overlay.py +715 -0
  103. package/workframe-api/updates.py +16 -1
  104. package/workframe-api/user_prefs.py +387 -0
  105. package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
  106. package/workframe-api/workspace_bootstrap.py +536 -0
  107. package/workframe-api/workspace_files.py +344 -0
  108. package/workframe-api/workspace_messaging.py +206 -0
  109. package/workframe-api/zk_auth.py +3 -2
  110. package/workframe-supervisor/server.py +10 -1
  111. package/workframe-supervisor/test_supervisor_negative.py +75 -0
  112. package/workframe-ui/public/assets/{arc-DjpS9a2b.js → arc-aUUffclm.js} +1 -1
  113. package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
  114. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-puCKmKHV.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
  115. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-C2P0g-eH.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
  116. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DdvYEITH.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
  117. package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
  118. package/workframe-ui/public/assets/{chunk-2J33WTMH-Cxe8QsEF.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
  119. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DPNyTwR3.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
  120. package/workframe-ui/public/assets/{chunk-4BX2VUAB-XKG9gfRN.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
  121. package/workframe-ui/public/assets/{chunk-55IACEB6-oPMTulgb.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
  122. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BWwXvpa_.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
  123. package/workframe-ui/public/assets/{chunk-727SXJPM-Cml2twO_.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
  124. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DzrRBhQu.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
  125. package/workframe-ui/public/assets/{chunk-BSJP7CBP-EZ7STU6h.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
  126. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-BGTQzWYw.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
  127. package/workframe-ui/public/assets/{chunk-FMBD7UC4-Cm4stfRa.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
  128. package/workframe-ui/public/assets/{chunk-KSCS5N6A-exbMijjy.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
  129. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-C75kbQwN.js → chunk-L5ZTLDWV-BLZrdIwa.js} +1 -1
  130. package/workframe-ui/public/assets/chunk-LZXEDZCA-DisG0XJT.js +2 -0
  131. package/workframe-ui/public/assets/{chunk-ND2GUHAM-D84zyIu2.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
  132. package/workframe-ui/public/assets/{chunk-NZK2D7GU-CUNNo1bj.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
  133. package/workframe-ui/public/assets/{chunk-O5CBEL6O-DN6WEIh-.js → chunk-O5CBEL6O-DDN-Ifon.js} +1 -1
  134. package/workframe-ui/public/assets/chunk-QZHKN3VN-EVQ0VMd5.js +1 -0
  135. package/workframe-ui/public/assets/chunk-WU5MYG2G-k_-ZsLOS.js +1 -0
  136. package/workframe-ui/public/assets/{chunk-XPW4576I-Ct5VQv2D.js → chunk-XPW4576I-B4_iYZ6r.js} +1 -1
  137. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-W9WVQsby.js +1 -0
  138. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-W9WVQsby.js +1 -0
  139. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bj4W5oMk.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
  140. package/workframe-ui/public/assets/{dagre-BM42HDAG-DAjHBkAy.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
  141. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ch2gGFPG.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
  142. package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzIIvgYH.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
  143. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Df0Q8nv6.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
  144. package/workframe-ui/public/assets/{diagram-LMA3HP47-D-G3ekmF.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
  145. package/workframe-ui/public/assets/{diagram-OG6HWLK6-TBHhT9f5.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
  146. package/workframe-ui/public/assets/{dist-CPbiDkEZ.js → dist-mQWmB3z6.js} +1 -1
  147. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-YxkUmLLV.js → erDiagram-TEJ5UH35-__40xO-y.js} +1 -1
  148. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Cm1uRTxU.js +1 -0
  149. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-kfZ7-RSd.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
  150. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DmTx3hzR.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
  151. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BXa0ewp3.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
  152. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CQr2qMxU.js → gitGraphDiagram-PVQCEYII-DMTxFJxn.js} +1 -1
  153. package/workframe-ui/public/assets/index-BdiM4-lI.js +130 -0
  154. package/workframe-ui/public/assets/index-Fnad47vV.css +1 -0
  155. package/workframe-ui/public/assets/{info-J43DQDTF-t-Qlx8zv.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
  156. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CzkYofPx.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
  157. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-djK3Oxgb.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
  158. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CWxYXxdF.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
  159. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-D7leCAD9.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
  160. package/workframe-ui/public/assets/{line-BSFj2OC5.js → line-BTCEHb7l.js} +1 -1
  161. package/workframe-ui/public/assets/{linear-pfPwnu-o.js → linear-CkbydpnK.js} +1 -1
  162. package/workframe-ui/public/assets/{mermaid-parser.core-Dm2L9Ib_.js → mermaid-parser.core-DvrtArpk.js} +2 -2
  163. package/workframe-ui/public/assets/{mermaid.core-Bevx9bif.js → mermaid.core-yj_1x_qj.js} +3 -3
  164. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-UkjDssmP.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
  165. package/workframe-ui/public/assets/{packet-YPE3B663-JO2ezH3_.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
  166. package/workframe-ui/public/assets/{pie-LRSECV5Y-DdMFsfR8.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
  167. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CEPHFBU1.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
  168. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-_RF9_86k.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
  169. package/workframe-ui/public/assets/{radar-GUYGQ44K-Cb-YfdaA.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
  170. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DBVOxmBn.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
  171. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BTpxsGUA.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
  172. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DHP_Bq3l.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
  173. package/workframe-ui/public/assets/{src-CY_By-r7.js → src-D6mvlP96.js} +1 -1
  174. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BVtp_LPR.js → stateDiagram-AJRCARHV-CxaTXs02.js} +1 -1
  175. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CXikj9xi.js +1 -0
  176. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-D38HJhop.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
  177. package/workframe-ui/public/assets/{treeView-BLDUP644-CFCG3Sc_.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
  178. package/workframe-ui/public/assets/{treemap-LRROVOQU-BeiMs_dF.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
  179. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CKMeT6PW.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
  180. package/workframe-ui/public/assets/{wardley-L42UT6IY-BljDy8ph.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
  181. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DEE8D94J.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
  182. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BRaAWV_g.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
  183. package/workframe-ui/public/index.html +7 -6
  184. package/workframe-ui/public/workframe-build.json +5 -0
  185. package/workframe-ui/public/assets/architecture-7EHR7CIX-CvhbXEvt.js +0 -1
  186. package/workframe-ui/public/assets/channel-D63s_yN9.js +0 -1
  187. package/workframe-ui/public/assets/chunk-LZXEDZCA-CsRdBqUB.js +0 -2
  188. package/workframe-ui/public/assets/chunk-QZHKN3VN-Dj_Yxhgo.js +0 -1
  189. package/workframe-ui/public/assets/chunk-WU5MYG2G-AFchshb6.js +0 -1
  190. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-Opj8t01I.js +0 -1
  191. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-Opj8t01I.js +0 -1
  192. package/workframe-ui/public/assets/eventmodeling-FCH6USID-LQOYL50n.js +0 -1
  193. package/workframe-ui/public/assets/index-B08aShJy.css +0 -1
  194. package/workframe-ui/public/assets/index-DCF3W3G_.js +0 -129
  195. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CvJhS_BJ.js +0 -1
@@ -0,0 +1,245 @@
1
+ """WF-032 extract: credential persistence (vault, auth.json, profile .env overlays)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import uuid
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import credential_vault
13
+
14
+
15
+ def _srv():
16
+ import server as srv
17
+
18
+ return srv
19
+
20
+
21
+ def _quote_env_value(value: str) -> str:
22
+ raw = str(value or "")
23
+ if re.fullmatch(r"[A-Za-z0-9_./:@+-]+", raw):
24
+ return raw
25
+ return json.dumps(raw)
26
+
27
+
28
+ def _upsert_env_secret(env_path: Path, key: str, value: str) -> None:
29
+ env_path.parent.mkdir(parents=True, exist_ok=True)
30
+ lines: list[str] = []
31
+ found = False
32
+ if env_path.exists():
33
+ lines = env_path.read_text(encoding="utf-8").splitlines()
34
+ updated: list[str] = []
35
+ for line in lines:
36
+ stripped = line.strip()
37
+ if not stripped or stripped.startswith("#") or "=" not in line:
38
+ updated.append(line)
39
+ continue
40
+ current_key, _sep, _rest = line.partition("=")
41
+ if current_key.strip() == key:
42
+ updated.append(f"{key}={_quote_env_value(value)}")
43
+ found = True
44
+ else:
45
+ updated.append(line)
46
+ if not found:
47
+ if updated and updated[-1].strip():
48
+ updated.append("")
49
+ updated.append(f"{key}={_quote_env_value(value)}")
50
+ env_path.write_text("\n".join(updated).rstrip() + "\n", encoding="utf-8")
51
+ _publish_profile_gateway_secrets(env_path.parent.name)
52
+
53
+
54
+ def _publish_profile_gateway_secrets(profile: str) -> None:
55
+ """Hermes gateway runs as uid hermes; API container writes bind-mount files as root.
56
+
57
+ Runtime profiles only ever get wf_rt_* lease tokens or auth.json pool metadata —
58
+ never raw upstream keys. chmod so the gateway can read overlays; supervisor still
59
+ blocks agent shell exec against these paths.
60
+ """
61
+ slug = _srv().safe_profile_slug(str(profile or "").strip())
62
+ if not _srv()._is_runtime_profile_slug(slug):
63
+ return
64
+ prof_dir = _srv()._profile_dir(slug)
65
+ for name in (".env", "auth.json"):
66
+ path = prof_dir / name
67
+ if not path.is_file():
68
+ continue
69
+ try:
70
+ os.chmod(path, 0o644)
71
+ except OSError:
72
+ pass
73
+
74
+
75
+ def _upsert_auth_metadata(auth_path: Path, payload: dict[str, Any]) -> None:
76
+ auth_path.parent.mkdir(parents=True, exist_ok=True)
77
+ data: dict[str, Any] = {}
78
+ if auth_path.exists():
79
+ try:
80
+ loaded = json.loads(auth_path.read_text(encoding="utf-8"))
81
+ if isinstance(loaded, dict):
82
+ data = loaded
83
+ except Exception:
84
+ data = {}
85
+ bindings = data.get("credentials")
86
+ if not isinstance(bindings, list):
87
+ bindings = []
88
+ credential_ref = str(payload.get("credential_ref") or "")
89
+ bindings = [row for row in bindings if not isinstance(row, dict) or row.get("credential_ref") != credential_ref]
90
+ bindings.append({
91
+ "provider": payload.get("provider"),
92
+ "credential_type": payload.get("credential_type"),
93
+ "credential_ref": credential_ref,
94
+ "env_var": payload.get("env_var"),
95
+ "label": payload.get("label") or "",
96
+ "updated_at": payload.get("updated_at"),
97
+ })
98
+ data["credentials"] = bindings
99
+ data["updated_at"] = payload.get("updated_at")
100
+ auth_path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
101
+
102
+
103
+ def _remove_env_secret(env_path: Path, key: str) -> None:
104
+ if not env_path.is_file():
105
+ return
106
+ kept: list[str] = []
107
+ for line in env_path.read_text(encoding="utf-8").splitlines():
108
+ stripped = line.strip()
109
+ if stripped and not stripped.startswith("#") and "=" in line:
110
+ current_key, _sep, _rest = line.partition("=")
111
+ if current_key.strip() == key:
112
+ continue
113
+ kept.append(line)
114
+ env_path.write_text("\n".join(kept).rstrip() + ("\n" if kept else ""), encoding="utf-8")
115
+
116
+
117
+ def _remove_auth_metadata(auth_path: Path, credential_ref: str) -> None:
118
+ if not auth_path.is_file():
119
+ return
120
+ try:
121
+ loaded = json.loads(auth_path.read_text(encoding="utf-8"))
122
+ except Exception:
123
+ return
124
+ if not isinstance(loaded, dict):
125
+ return
126
+ bindings = loaded.get("credentials")
127
+ if not isinstance(bindings, list):
128
+ return
129
+ loaded["credentials"] = [
130
+ row for row in bindings
131
+ if not isinstance(row, dict) or str(row.get("credential_ref") or "") != credential_ref
132
+ ]
133
+ loaded["updated_at"] = _srv()._utc_now()
134
+ auth_path.write_text(json.dumps(loaded, indent=2, sort_keys=True) + "\n", encoding="utf-8")
135
+
136
+
137
+ def _store_user_credential(
138
+ user_id: str,
139
+ provider: str,
140
+ credential_type: str,
141
+ secret: str,
142
+ env_var: str,
143
+ label: str,
144
+ ) -> dict[str, Any]:
145
+ user_home = _srv()._user_hermes_home(user_id)
146
+ auth_path = _srv()._user_hermes_auth_path(user_id)
147
+ cred_id = str(uuid.uuid4())
148
+ credential_ref = credential_vault.vault_ref(cred_id)
149
+ now = _srv()._utc_now()
150
+ credential_vault.store_secret(
151
+ cred_id,
152
+ secret,
153
+ env_var=env_var,
154
+ provider=provider,
155
+ scope="user",
156
+ user_id=user_id,
157
+ )
158
+ _remove_env_secret(_srv()._user_hermes_env_path(user_id), env_var)
159
+ _upsert_auth_metadata(
160
+ auth_path,
161
+ {
162
+ "provider": provider,
163
+ "credential_type": credential_type,
164
+ "credential_ref": credential_ref,
165
+ "env_var": env_var,
166
+ "label": label,
167
+ "updated_at": now,
168
+ },
169
+ )
170
+ return {
171
+ "profile_home": str(user_home),
172
+ "credential_id": cred_id,
173
+ "auth_path": str(auth_path),
174
+ "credential_ref": credential_ref,
175
+ "updated_at": now,
176
+ }
177
+
178
+
179
+ def _store_workspace_credential(
180
+ workspace_id: str,
181
+ provider: str,
182
+ credential_type: str,
183
+ secret: str,
184
+ env_var: str,
185
+ label: str,
186
+ created_by: str,
187
+ ) -> dict[str, Any]:
188
+ """Company-pay keys live on the primary Hermes profile .env (shared stack)."""
189
+ workspace_id = str(workspace_id or "").strip()
190
+ provider = str(provider or "").strip().lower()
191
+ if not workspace_id or not provider or not secret.strip():
192
+ raise ValueError("workspace_credential_invalid")
193
+ spec = _srv()._catalog_provider(provider)
194
+ if spec and spec.get("user_only"):
195
+ raise ValueError("provider_user_only")
196
+ primary = _srv()._primary_profile()
197
+ if not primary:
198
+ raise ValueError("no_primary_profile")
199
+ cred_id = str(uuid.uuid4())
200
+ credential_ref = credential_vault.vault_ref(cred_id)
201
+ now = _srv()._utc_now()
202
+ credential_vault.store_secret(
203
+ cred_id,
204
+ secret.strip(),
205
+ env_var=env_var,
206
+ provider=provider,
207
+ scope="workspace",
208
+ workspace_id=workspace_id,
209
+ )
210
+ _remove_env_secret(_srv()._profile_dir(primary) / ".env", env_var)
211
+ conn = _srv()._workframe_db()
212
+ try:
213
+ existing = conn.execute(
214
+ """SELECT id FROM credential_bindings
215
+ WHERE workspace_id = ? AND provider = ? AND deleted_at IS NULL
216
+ ORDER BY updated_at DESC LIMIT 1""",
217
+ (workspace_id, provider),
218
+ ).fetchone()
219
+ if existing:
220
+ cred_id = str(existing[0])
221
+ conn.execute(
222
+ """UPDATE credential_bindings
223
+ SET credential_ref = ?, label = ?, is_active = 1, updated_at = ?, deleted_at = NULL
224
+ WHERE id = ?""",
225
+ (credential_ref, label, now, cred_id),
226
+ )
227
+ else:
228
+ conn.execute(
229
+ """INSERT INTO credential_bindings
230
+ (id, workspace_id, user_id, agent_profile_id, provider, credential_type,
231
+ credential_ref, label, is_active, created_by, created_at, updated_at)
232
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
233
+ (
234
+ cred_id, workspace_id, None, None, provider, credential_type,
235
+ credential_ref, label, 1, created_by, now, now,
236
+ ),
237
+ )
238
+ conn.commit()
239
+ finally:
240
+ conn.close()
241
+ return {
242
+ "credential_id": cred_id,
243
+ "credential_ref": credential_ref,
244
+ "updated_at": now,
245
+ }
@@ -0,0 +1,250 @@
1
+ """WF-032 extract: workspace crew registry and agent identity resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sqlite3
7
+ from typing import Any
8
+
9
+
10
+ CREW_COLORS = [
11
+ "#A78BFA",
12
+ "#7DD3FC",
13
+ "#F472B6",
14
+ "#FBBF24",
15
+ "#E879F9",
16
+ "#5EE2B5",
17
+ "#F26D6D",
18
+ "#38BDF8",
19
+ ]
20
+
21
+
22
+ def _srv():
23
+ import server as srv
24
+
25
+ return srv
26
+
27
+
28
+ def load_agent_registry() -> dict[str, dict[str, Any]]:
29
+ agents_json = _srv().AGENTS_JSON
30
+ if not agents_json.is_file():
31
+ return {}
32
+ try:
33
+ data = json.loads(agents_json.read_text(encoding="utf-8"))
34
+ agents = data.get("agents")
35
+ if isinstance(agents, dict):
36
+ return {str(k): v for k, v in agents.items() if isinstance(v, dict)}
37
+ except (OSError, json.JSONDecodeError, TypeError, ValueError): # noqa: BLE001
38
+ pass
39
+ return {}
40
+
41
+
42
+ def _agent_registry_row(profile: str) -> dict[str, Any]:
43
+ slug = _srv().safe_profile_slug(profile)
44
+ return load_agent_registry().get(slug, {})
45
+
46
+
47
+ def _workspace_agent_identities(workspace_id: str | None = None) -> dict[str, dict[str, Any]]:
48
+ """Slug → display_name/tagline/role/avatar from agent_profiles + registry."""
49
+ identities: dict[str, dict[str, Any]] = {}
50
+ try:
51
+ conn = _srv()._workframe_db()
52
+ ws_id = str(workspace_id or "").strip()
53
+ if not ws_id:
54
+ ws = conn.execute(
55
+ "SELECT id FROM workspaces WHERE slug = 'default' AND deleted_at IS NULL LIMIT 1",
56
+ ).fetchone()
57
+ ws_id = str(ws["id"]) if ws else ""
58
+ if ws_id:
59
+ rows = conn.execute(
60
+ """
61
+ SELECT slug, display_name, tagline, role, avatar_url
62
+ FROM agent_profiles
63
+ WHERE workspace_id = ? AND deleted_at IS NULL
64
+ """,
65
+ (ws_id,),
66
+ ).fetchall()
67
+ for row in rows:
68
+ slug = str(row["slug"] or "").strip()
69
+ if not slug:
70
+ continue
71
+ identities[slug] = {
72
+ "display_name": str(row["display_name"] or ""),
73
+ "tagline": str(row["tagline"] or ""),
74
+ "role": str(row["role"] or ""),
75
+ "avatar_url": str(row["avatar_url"] or "").strip() or None,
76
+ }
77
+ conn.close()
78
+ except (sqlite3.Error, OSError): # noqa: BLE001
79
+ pass
80
+ registry = load_agent_registry()
81
+ for _slug, ident in list(identities.items()):
82
+ reg = registry.get(_slug) if isinstance(registry.get(_slug), dict) else {}
83
+ if reg.get("display_name"):
84
+ ident["display_name"] = str(reg["display_name"])
85
+ if reg.get("tagline"):
86
+ ident["tagline"] = str(reg["tagline"])
87
+ if reg.get("role"):
88
+ ident["role"] = str(reg["role"])
89
+ if reg.get("avatar_url"):
90
+ ident["avatar_url"] = str(reg["avatar_url"])
91
+ if reg.get("avatar_id"):
92
+ ident["avatar_id"] = str(reg["avatar_id"])
93
+ return identities
94
+
95
+
96
+ def _agent_identity_fields(profile: str, workspace_id: str | None = None, user_id: str = "") -> dict[str, Any]:
97
+ slug = _srv().safe_profile_slug(str(profile or "").strip())
98
+ template = _srv()._runtime_template_slug(slug) if _srv()._is_runtime_profile_slug(slug) else slug
99
+ reg = _agent_registry_row(slug)
100
+ if user_id and template:
101
+ runtime = _srv()._runtime_profile_slug(user_id, template)
102
+ reg_user = _agent_registry_row(runtime)
103
+ if reg_user.get("display_name") or reg_user.get("tagline") or reg_user.get("avatar_url"):
104
+ reg = {**reg, **{k: v for k, v in reg_user.items() if v}}
105
+ ident = _workspace_agent_identities(workspace_id).get(template, {})
106
+ reg_template = _agent_registry_row(template)
107
+ avatar_url: Any = None
108
+ avatar_id: Any = None
109
+ for src in (reg, ident, reg_template):
110
+ u = str(src.get("avatar_url") or "").strip()
111
+ i = str(src.get("avatar_id") or "").strip()
112
+ if u or i:
113
+ avatar_url, avatar_id = u or None, i or None
114
+ break
115
+ out: dict[str, Any] = {
116
+ "display_name": str(
117
+ reg.get("display_name")
118
+ or ident.get("display_name")
119
+ or reg_template.get("display_name")
120
+ or _srv()._profile_display_name(template)
121
+ ),
122
+ "tagline": str(reg.get("tagline") or ident.get("tagline") or reg_template.get("tagline") or ""),
123
+ "role": str(reg.get("role") or ident.get("role") or reg_template.get("role") or _srv()._profile_role(template)),
124
+ "avatar_url": avatar_url,
125
+ "avatar_id": avatar_id,
126
+ }
127
+ _srv()._resolve_avatar_fields(out)
128
+ return out
129
+
130
+
131
+ def _gateway_platform(gateway: dict[str, Any], native: bool) -> str:
132
+ platforms = gateway.get("platforms") or {}
133
+ if native:
134
+ for name in ("telegram", "discord", "web", "localhost"):
135
+ p = platforms.get(name)
136
+ if isinstance(p, dict) and str(p.get("state") or p.get("status") or "").lower() == "connected":
137
+ return name.replace("localhost", "Hermes").title()
138
+ return "Hermes"
139
+ for name in ("discord", "telegram", "web"):
140
+ p = platforms.get(name)
141
+ if isinstance(p, dict) and str(p.get("state") or p.get("status") or "").lower() == "connected":
142
+ return name.title()
143
+ return "Hermes"
144
+
145
+
146
+ def crew_data(profiles: list[str], primary: str, gateway: dict[str, Any]) -> list[dict[str, Any]]:
147
+ registry = load_agent_registry()
148
+ idents = _workspace_agent_identities()
149
+ ordered: list[str] = []
150
+ if primary and primary in profiles:
151
+ ordered.append(primary)
152
+ for profile in sorted(profiles):
153
+ if profile not in ordered:
154
+ ordered.append(profile)
155
+ crew: list[dict[str, Any]] = []
156
+ for index, profile in enumerate(ordered):
157
+ slug = _srv()._profile_slug(profile).lower()
158
+ display = _srv()._profile_display_name(profile)
159
+ native = _srv()._is_native_profile(profile)
160
+ prof_key = _srv().safe_profile_slug(profile)
161
+ reg = registry.get(prof_key) if isinstance(registry.get(prof_key), dict) else {}
162
+ if not reg:
163
+ legacy_row = registry.get(slug)
164
+ if isinstance(legacy_row, dict):
165
+ reg = legacy_row
166
+ if reg.get("display_name"):
167
+ display = str(reg["display_name"])
168
+ ident = idents.get(prof_key, {})
169
+ if ident.get("display_name"):
170
+ display = str(ident["display_name"])
171
+ role = str(ident.get("role") or reg.get("role") or _srv()._profile_role(profile))
172
+ tagline = str(ident.get("tagline") or reg.get("tagline") or "")
173
+ row: dict[str, Any] = {
174
+ "profile": profile,
175
+ "key": slug,
176
+ "display_name": display,
177
+ "code": _srv()._profile_code(display, slug),
178
+ "role": role,
179
+ "tagline": tagline,
180
+ "color": CREW_COLORS[index % len(CREW_COLORS)],
181
+ "is_native": native,
182
+ "platform": _gateway_platform(gateway, native),
183
+ "route_status": _srv().route_status_for_profile(profile),
184
+ }
185
+ if reg.get("avatar_url"):
186
+ row["avatar_url"] = str(reg["avatar_url"])
187
+ if reg.get("avatar_id"):
188
+ row["avatar_id"] = str(reg["avatar_id"])
189
+ if ident.get("avatar_url"):
190
+ row["avatar_url"] = str(ident["avatar_url"])
191
+ if ident.get("avatar_id"):
192
+ row["avatar_id"] = str(ident["avatar_id"])
193
+ _srv()._resolve_avatar_fields(row)
194
+ crew.append(row)
195
+ return crew
196
+
197
+
198
+ def _workspace_crew_profile_names(workspace_id: str | None = None) -> list[str]:
199
+ """ponytail: rail shows workspace agents + primary Hermes profile, not every stale on-disk profile."""
200
+ primary = _srv()._primary_profile()
201
+ on_disk = set(_srv()._list_profiles())
202
+ profiles: list[str] = []
203
+ rows: list[sqlite3.Row] = []
204
+ try:
205
+ conn = _srv()._workframe_db()
206
+ ws_id = str(workspace_id or "").strip()
207
+ if not ws_id:
208
+ ws = conn.execute(
209
+ "SELECT id FROM workspaces WHERE slug = 'default' AND deleted_at IS NULL LIMIT 1",
210
+ ).fetchone()
211
+ ws_id = str(ws["id"]) if ws else ""
212
+ if ws_id:
213
+ rows = conn.execute(
214
+ """
215
+ SELECT slug, is_native
216
+ FROM agent_profiles
217
+ WHERE workspace_id = ? AND deleted_at IS NULL
218
+ ORDER BY created_at ASC
219
+ """,
220
+ (ws_id,),
221
+ ).fetchall()
222
+ conn.close()
223
+ except (sqlite3.Error, OSError): # noqa: BLE001
224
+ rows = []
225
+ if primary and primary in on_disk:
226
+ profiles.append(primary)
227
+ for row in rows:
228
+ if int(row["is_native"] or 0):
229
+ continue
230
+ slug = str(row["slug"] or "").strip()
231
+ if slug in on_disk and slug not in profiles:
232
+ profiles.append(slug)
233
+ if not profiles:
234
+ if primary and primary in on_disk:
235
+ return [primary]
236
+ return sorted(on_disk)[:1]
237
+ return profiles
238
+
239
+
240
+ def workframe_agents() -> dict[str, Any]:
241
+ profiles = _workspace_crew_profile_names()
242
+ primary = _srv()._primary_profile()
243
+ gateway = _srv().gateway_data(primary) if primary else _srv().gateway_data("")
244
+ crew = crew_data(profiles, primary, gateway)
245
+ return {
246
+ "ok": True,
247
+ "project_name": _srv().PROJECT_NAME,
248
+ "native_profile": primary,
249
+ "crew": crew,
250
+ }