create-workframe 0.1.12 → 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 (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-aUUffclm.js} +1 -1
  106. package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
  107. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DeBNltHS.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
  108. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-BDhCHu7I.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
  109. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-olcDYvtI.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
  110. package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
  111. package/workframe-ui/public/assets/{chunk-2J33WTMH-D0obCBn_.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
  112. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DX93f-ZZ.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
  113. package/workframe-ui/public/assets/{chunk-4BX2VUAB-BX9B5wUs.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
  114. package/workframe-ui/public/assets/{chunk-55IACEB6-C4DGc6RO.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
  115. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-Crlja9Lf.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
  116. package/workframe-ui/public/assets/{chunk-727SXJPM-JYSm3szI.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
  117. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-_KslxVEl.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
  118. package/workframe-ui/public/assets/{chunk-BSJP7CBP-CpTO-jU8.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
  119. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-JGUkCmbI.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
  120. package/workframe-ui/public/assets/{chunk-FMBD7UC4-BscBwGaC.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
  121. package/workframe-ui/public/assets/{chunk-KSCS5N6A-5ZTZ0bpX.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
  122. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-DxvitYbW.js → chunk-L5ZTLDWV-BLZrdIwa.js} +1 -1
  123. package/workframe-ui/public/assets/chunk-LZXEDZCA-DisG0XJT.js +2 -0
  124. package/workframe-ui/public/assets/{chunk-ND2GUHAM-Cayl0iV9.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
  125. package/workframe-ui/public/assets/{chunk-NZK2D7GU-_7dyBR5G.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
  126. package/workframe-ui/public/assets/{chunk-O5CBEL6O--xTpD0yi.js → chunk-O5CBEL6O-DDN-Ifon.js} +1 -1
  127. package/workframe-ui/public/assets/chunk-QZHKN3VN-EVQ0VMd5.js +1 -0
  128. package/workframe-ui/public/assets/chunk-WU5MYG2G-k_-ZsLOS.js +1 -0
  129. package/workframe-ui/public/assets/{chunk-XPW4576I-CwxJQaeK.js → chunk-XPW4576I-B4_iYZ6r.js} +1 -1
  130. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-W9WVQsby.js +1 -0
  131. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-W9WVQsby.js +1 -0
  132. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CCspENYv.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
  133. package/workframe-ui/public/assets/{dagre-BM42HDAG-Dv8V6R60.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
  134. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Da48hFPT.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
  135. package/workframe-ui/public/assets/{diagram-5GNKFQAL-dTihc7-3.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
  136. package/workframe-ui/public/assets/{diagram-KO2AKTUF-D_Jqu699.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
  137. package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt8PtEaZ.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
  138. package/workframe-ui/public/assets/{diagram-OG6HWLK6-BtmPjlh0.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
  139. package/workframe-ui/public/assets/{dist-Cz2turIT.js → dist-mQWmB3z6.js} +1 -1
  140. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-BxMGCflX.js → erDiagram-TEJ5UH35-__40xO-y.js} +1 -1
  141. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Cm1uRTxU.js +1 -0
  142. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-ivyroIZt.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
  143. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-B9llwShx.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
  144. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BFQHk4bw.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
  145. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-ursegLbc.js → gitGraphDiagram-PVQCEYII-DMTxFJxn.js} +1 -1
  146. package/workframe-ui/public/assets/index-BdiM4-lI.js +130 -0
  147. package/workframe-ui/public/assets/index-Fnad47vV.css +1 -0
  148. package/workframe-ui/public/assets/{info-J43DQDTF-DQpifAsB.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
  149. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-C16XP2Q7.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
  150. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-CiJoz7rP.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
  151. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CVwEvvUL.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
  152. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CWQ6hX5i.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
  153. package/workframe-ui/public/assets/{line-CgQsmU35.js → line-BTCEHb7l.js} +1 -1
  154. package/workframe-ui/public/assets/{linear-Bxbc77dL.js → linear-CkbydpnK.js} +1 -1
  155. package/workframe-ui/public/assets/{mermaid-parser.core-C_jMeF0a.js → mermaid-parser.core-DvrtArpk.js} +2 -2
  156. package/workframe-ui/public/assets/{mermaid.core-4RKV9MZP.js → mermaid.core-yj_1x_qj.js} +3 -3
  157. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-DZ7y7Sz0.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
  158. package/workframe-ui/public/assets/{packet-YPE3B663-B9h2I7Vq.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
  159. package/workframe-ui/public/assets/{pie-LRSECV5Y-B2mP5uHm.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
  160. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-DyYKyKiP.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
  161. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-CL_6nej-.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
  162. package/workframe-ui/public/assets/{radar-GUYGQ44K-CNKNDQ7S.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
  163. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-eWU_mhFa.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
  164. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DrjcXvEQ.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
  165. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BHWR1xTJ.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
  166. package/workframe-ui/public/assets/{src-DfJB8kV1.js → src-D6mvlP96.js} +1 -1
  167. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BGJbSd3I.js → stateDiagram-AJRCARHV-CxaTXs02.js} +1 -1
  168. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CXikj9xi.js +1 -0
  169. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DkwoCjiZ.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
  170. package/workframe-ui/public/assets/{treeView-BLDUP644-GxfhiqoW.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
  171. package/workframe-ui/public/assets/{treemap-LRROVOQU-DxrOsars.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
  172. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-BsWhpUfr.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
  173. package/workframe-ui/public/assets/{wardley-L42UT6IY-CrFZWMHS.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
  174. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DV8Xpf5I.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
  175. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DqMqnwdZ.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
  176. package/workframe-ui/public/index.html +7 -5
  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,752 @@
1
+ """WF-032 extract: runtime profiles, user cohort, and delegation grants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ import sqlite3
9
+ import time
10
+ import uuid
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import yaml
15
+
16
+
17
+ # ponytail: docker exec per bind was ~10s; cache gateway registration briefly.
18
+ _gateway_registered_cache: dict[str, tuple[bool, float]] = {}
19
+ _GATEWAY_REG_TTL_SEC = 45.0
20
+
21
+
22
+ def _srv():
23
+ import server as srv
24
+
25
+ return srv
26
+
27
+
28
+ def _runtime_profile_slug(user_id: str, template_slug: str) -> str:
29
+ template_slug = _srv().safe_profile_slug(template_slug)
30
+ user_part = re.sub(r"[^a-z0-9]+", "-", _srv()._user_hermes_dir_slug(user_id).lower()).strip("-")[:20] or "user"
31
+ slug = f"u-{user_part}-{template_slug}"
32
+ if len(slug) > 64:
33
+ slug = slug[:64].rstrip("-")
34
+ return _srv().safe_profile_slug(slug)
35
+
36
+
37
+ def _prepare_runtime_profile_credentials(
38
+ runtime: str,
39
+ user_id: str,
40
+ workspace_id: str = "",
41
+ ) -> bool:
42
+ """Strip stale profile secrets, then overlay the runtime profile owner's keys.
43
+
44
+ Delegation assigns work to another user's runtime slug; provider keys and
45
+ billing follow the profile owner (assignee), not the user who delegated.
46
+ """
47
+ runtime = _srv().safe_profile_slug(str(runtime or "").strip())
48
+ user = str(user_id or "").strip()
49
+ if not runtime or not user or not _srv()._is_runtime_profile_slug(runtime):
50
+ return False
51
+ try:
52
+ _srv().resolve_hermes_profile(runtime)
53
+ except ValueError:
54
+ return False
55
+ _srv()._strip_profile_llm_env(runtime)
56
+ _srv()._strip_profile_action_env(runtime)
57
+ _srv()._reconcile_profile_llm_for_user(runtime, user, workspace_id)
58
+ prov = _srv()._llm_billing_provider(runtime, user_id=user, workspace_id=workspace_id)
59
+ _srv()._ensure_profile_llm_proxy(runtime, prov)
60
+ return _srv()._user_can_use_llm(user, workspace_id, prov)
61
+
62
+
63
+ def _resolve_runtime_owner(runtime: str) -> tuple[str, str] | None:
64
+ """Map a u-* runtime slug to (owner_user_id, workspace_id)."""
65
+ runtime = _srv().safe_profile_slug(str(runtime or "").strip())
66
+ if not _srv()._is_runtime_profile_slug(runtime):
67
+ return None
68
+ conn = _srv()._workframe_db()
69
+ try:
70
+ for row in conn.execute("SELECT id FROM workspaces WHERE deleted_at IS NULL").fetchall():
71
+ wid = str(row["id"])
72
+ uid = _user_id_for_runtime_slug(runtime, wid)
73
+ if uid:
74
+ return uid, wid
75
+ finally:
76
+ conn.close()
77
+ return None
78
+
79
+
80
+ def _user_handle(user_id: str) -> str:
81
+ """Short lowercase handle for cohort aliases (display_name or email local-part)."""
82
+ user_id = str(user_id or "").strip()
83
+ if not user_id:
84
+ return "user"
85
+ conn = _srv()._workframe_db()
86
+ try:
87
+ row = conn.execute(
88
+ "SELECT display_name, email FROM users WHERE id = ?",
89
+ (user_id,),
90
+ ).fetchone()
91
+ finally:
92
+ conn.close()
93
+ if not row:
94
+ return "user"
95
+ display = str(row["display_name"] or "").strip()
96
+ if display:
97
+ handle = re.sub(r"[^a-z0-9]+", "-", display.lower()).strip("-")[:24]
98
+ if handle:
99
+ return handle
100
+ email = str(row["email"] or "").strip().lower()
101
+ if "@" in email:
102
+ handle = re.sub(r"[^a-z0-9]+", "-", email.split("@", 1)[0]).strip("-")[:24]
103
+ if handle:
104
+ return handle
105
+ return "user"
106
+
107
+
108
+ def _user_owner_name(user_id: str) -> str:
109
+ return _user_handle(user_id).replace("-", " ").strip().title() or "User"
110
+
111
+
112
+ def _runtime_display_label(user_id: str, template_slug: str, workspace_id: str = "") -> str:
113
+ template = _srv().safe_profile_slug(str(template_slug or "").strip())
114
+ user_id = str(user_id or "").strip()
115
+ if user_id:
116
+ runtime = _runtime_profile_slug(user_id, template)
117
+ reg_runtime = _srv()._agent_registry_row(runtime)
118
+ if str(reg_runtime.get("display_name") or "").strip():
119
+ return str(reg_runtime["display_name"]).strip()
120
+ custom = _srv()._agent_db_display_name(template, workspace_id)
121
+ if custom:
122
+ return custom
123
+ reg = _srv()._agent_registry_row(template)
124
+ if reg.get("display_name"):
125
+ return str(reg["display_name"])
126
+ if _srv()._is_native_profile(template) or template.endswith("-agent"):
127
+ return f"{_user_owner_name(user_id)}'s {_srv()._native_display_name().replace(' Agent', '')} Agent"
128
+ role = _srv()._agent_db_display_name(template, workspace_id) or _srv()._agent_registry_row(template).get("display_name")
129
+ if not role:
130
+ role = template.replace("-", " ").title()
131
+ return f"{_user_owner_name(user_id)}'s {role}"
132
+
133
+
134
+ def _cohort_alias(user_id: str, template_slug: str) -> str:
135
+ """Human alias for docs/prompts — not the Hermes profile dir slug."""
136
+ template = _srv().safe_profile_slug(template_slug)
137
+ short = _srv()._profile_slug(template).replace("_", "-")
138
+ return f"{_user_handle(user_id)}-{short}"
139
+
140
+
141
+ def resolve_runtime_assignee(template_slug: str, user_id: str, workspace_id: str = "") -> str:
142
+ """Map template specialist slug → per-user runtime profile (has overlaid credentials)."""
143
+ user_id = str(user_id or "").strip()
144
+ template = _srv().safe_profile_slug(str(template_slug or "").strip())
145
+ if not user_id:
146
+ return template
147
+ runtime = _runtime_profile_slug(user_id, template)
148
+ ensure_runtime_profile(runtime, template, user_id, workspace_id)
149
+ return runtime
150
+
151
+
152
+ def _user_id_for_runtime_slug(runtime: str, workspace_id: str) -> str | None:
153
+ """Resolve owning user for a per-user runtime Hermes profile slug."""
154
+ runtime = _srv().safe_profile_slug(str(runtime or "").strip())
155
+ workspace_id = str(workspace_id or "").strip()
156
+ if not _srv()._is_runtime_profile_slug(runtime) or not workspace_id:
157
+ return None
158
+ conn = _srv()._workframe_db()
159
+ try:
160
+ members = [
161
+ str(row["user_id"] or "").strip()
162
+ for row in conn.execute(
163
+ """
164
+ SELECT user_id FROM workspace_memberships
165
+ WHERE workspace_id = ? AND status = 'active' AND deleted_at IS NULL
166
+ """,
167
+ (workspace_id,),
168
+ ).fetchall()
169
+ if str(row["user_id"] or "").strip()
170
+ ]
171
+ templates = [
172
+ str(row["slug"] or "").strip()
173
+ for row in conn.execute(
174
+ """
175
+ SELECT slug FROM agent_profiles
176
+ WHERE workspace_id = ? AND deleted_at IS NULL
177
+ """,
178
+ (workspace_id,),
179
+ ).fetchall()
180
+ if str(row["slug"] or "").strip()
181
+ ]
182
+ finally:
183
+ conn.close()
184
+ for uid in members:
185
+ for template in templates:
186
+ if _runtime_profile_slug(uid, template) == runtime:
187
+ return uid
188
+ return None
189
+
190
+
191
+ def cohort_runtime_slugs(user_id: str, workspace_id: str) -> set[str]:
192
+ """Runtime Hermes slugs this user may assign kanban/cron/delegation to (own cohort)."""
193
+ return {
194
+ str(row.get("runtime_slug") or "").strip()
195
+ for row in ensure_user_agent_cohort(user_id, workspace_id)
196
+ if str(row.get("runtime_slug") or "").strip()
197
+ }
198
+
199
+
200
+ def _user_is_workspace_member(user_id: str, workspace_id: str) -> bool:
201
+ user_id = str(user_id or "").strip()
202
+ workspace_id = str(workspace_id or "").strip()
203
+ if not user_id or not workspace_id:
204
+ return False
205
+ conn = _srv()._workframe_db()
206
+ try:
207
+ return _srv()._workspace_member_role(conn, workspace_id, user_id) is not None
208
+ finally:
209
+ conn.close()
210
+
211
+
212
+ def _delegation_grantor_ids_for_grantee(
213
+ grantee_user_id: str,
214
+ workspace_id: str,
215
+ scope: str | None = None,
216
+ ) -> set[str]:
217
+ grantee_user_id = str(grantee_user_id or "").strip()
218
+ workspace_id = str(workspace_id or "").strip()
219
+ if scope is None:
220
+ scope = _srv().DELEGATION_SCOPE_AGENTS_DELEGATE
221
+ if not grantee_user_id or not workspace_id:
222
+ return set()
223
+ conn = _srv()._workframe_db()
224
+ try:
225
+ rows = conn.execute(
226
+ """
227
+ SELECT grantor_user_id FROM agent_delegation_grants
228
+ WHERE workspace_id = ? AND grantee_user_id = ? AND scope = ?
229
+ AND deleted_at IS NULL
230
+ AND (expires_at IS NULL OR expires_at > datetime('now'))
231
+ """,
232
+ (workspace_id, grantee_user_id, scope),
233
+ ).fetchall()
234
+ finally:
235
+ conn.close()
236
+ return {str(row["grantor_user_id"]).strip() for row in rows if str(row["grantor_user_id"] or "").strip()}
237
+
238
+
239
+ def _delegation_grant_payload(row: sqlite3.Row) -> dict[str, Any]:
240
+ return {
241
+ "id": row["id"],
242
+ "workspace_id": row["workspace_id"],
243
+ "grantor_user_id": row["grantor_user_id"],
244
+ "grantee_user_id": row["grantee_user_id"],
245
+ "scope": row["scope"],
246
+ "expires_at": row["expires_at"],
247
+ "created_at": row["created_at"],
248
+ "updated_at": row["updated_at"],
249
+ }
250
+
251
+
252
+ def list_delegation_grants(workspace_id: str, user_id: str) -> dict[str, Any]:
253
+ workspace_id = str(workspace_id or "").strip()
254
+ user_id = str(user_id or "").strip()
255
+ if not _user_is_workspace_member(user_id, workspace_id):
256
+ raise PermissionError("forbidden")
257
+ conn = _srv()._workframe_db()
258
+ try:
259
+ rows = conn.execute(
260
+ """
261
+ SELECT * FROM agent_delegation_grants
262
+ WHERE workspace_id = ? AND deleted_at IS NULL
263
+ AND (grantor_user_id = ? OR grantee_user_id = ?)
264
+ ORDER BY created_at DESC
265
+ """,
266
+ (workspace_id, user_id, user_id),
267
+ ).fetchall()
268
+ finally:
269
+ conn.close()
270
+ return {
271
+ "ok": True,
272
+ "workspace_id": workspace_id,
273
+ "grants": [_delegation_grant_payload(row) for row in rows],
274
+ }
275
+
276
+
277
+ def create_delegation_grant(
278
+ workspace_id: str,
279
+ grantor_user_id: str,
280
+ grantee_user_id: str,
281
+ scope: str | None = None,
282
+ ) -> dict[str, Any]:
283
+ workspace_id = str(workspace_id or "").strip()
284
+ grantor_user_id = str(grantor_user_id or "").strip()
285
+ grantee_user_id = str(grantee_user_id or "").strip()
286
+ if scope is None:
287
+ scope = _srv().DELEGATION_SCOPE_AGENTS_DELEGATE
288
+ scope = str(scope or _srv().DELEGATION_SCOPE_AGENTS_DELEGATE).strip()
289
+ if not workspace_id or not grantor_user_id or not grantee_user_id:
290
+ raise ValueError("grantor_grantee_required")
291
+ if grantor_user_id == grantee_user_id:
292
+ raise ValueError("self_grant_forbidden")
293
+ if scope != _srv().DELEGATION_SCOPE_AGENTS_DELEGATE:
294
+ raise ValueError("unsupported_scope")
295
+ if not _user_is_workspace_member(grantor_user_id, workspace_id):
296
+ raise PermissionError("forbidden")
297
+ if not _user_is_workspace_member(grantee_user_id, workspace_id):
298
+ raise ValueError("grantee_not_member")
299
+ now = _srv()._utc_now()
300
+ conn = _srv()._workframe_db()
301
+ try:
302
+ existing = conn.execute(
303
+ """
304
+ SELECT id FROM agent_delegation_grants
305
+ WHERE workspace_id = ? AND grantor_user_id = ? AND grantee_user_id = ?
306
+ AND scope = ? AND deleted_at IS NULL
307
+ """,
308
+ (workspace_id, grantor_user_id, grantee_user_id, scope),
309
+ ).fetchone()
310
+ if existing:
311
+ grant_id = str(existing["id"])
312
+ conn.execute(
313
+ "UPDATE agent_delegation_grants SET updated_at = ?, expires_at = NULL WHERE id = ?",
314
+ (now, grant_id),
315
+ )
316
+ else:
317
+ grant_id = str(uuid.uuid4())
318
+ conn.execute(
319
+ """
320
+ INSERT INTO agent_delegation_grants (
321
+ id, workspace_id, grantor_user_id, grantee_user_id, scope,
322
+ expires_at, created_at, updated_at
323
+ ) VALUES (?, ?, ?, ?, ?, NULL, ?, ?)
324
+ """,
325
+ (grant_id, workspace_id, grantor_user_id, grantee_user_id, scope, now, now),
326
+ )
327
+ conn.commit()
328
+ row = conn.execute(
329
+ "SELECT * FROM agent_delegation_grants WHERE id = ?",
330
+ (grant_id,),
331
+ ).fetchone()
332
+ finally:
333
+ conn.close()
334
+ if not row:
335
+ raise ValueError("grant_persist_failed")
336
+ return {"ok": True, "grant": _delegation_grant_payload(row)}
337
+
338
+
339
+ def revoke_delegation_grant(workspace_id: str, grant_id: str, user_id: str) -> dict[str, Any]:
340
+ workspace_id = str(workspace_id or "").strip()
341
+ grant_id = str(grant_id or "").strip()
342
+ user_id = str(user_id or "").strip()
343
+ conn = _srv()._workframe_db()
344
+ try:
345
+ row = conn.execute(
346
+ """
347
+ SELECT * FROM agent_delegation_grants
348
+ WHERE id = ? AND workspace_id = ? AND deleted_at IS NULL
349
+ """,
350
+ (grant_id, workspace_id),
351
+ ).fetchone()
352
+ if not row:
353
+ raise ValueError("not_found")
354
+ if user_id not in {str(row["grantor_user_id"]), str(row["grantee_user_id"])}:
355
+ raise PermissionError("forbidden")
356
+ now = _srv()._utc_now()
357
+ conn.execute(
358
+ "UPDATE agent_delegation_grants SET deleted_at = ?, updated_at = ? WHERE id = ?",
359
+ (now, now, grant_id),
360
+ )
361
+ conn.commit()
362
+ finally:
363
+ conn.close()
364
+ return {"ok": True, "id": grant_id}
365
+
366
+
367
+ def _allowed_runtime_profiles_for_workspace(workspace_id: str) -> set[str]:
368
+ allowed: set[str] = set()
369
+ conn = _srv()._workframe_db()
370
+ try:
371
+ members = [
372
+ str(row["user_id"] or "").strip()
373
+ for row in conn.execute(
374
+ """
375
+ SELECT user_id FROM workspace_memberships
376
+ WHERE workspace_id = ? AND status = 'active' AND deleted_at IS NULL
377
+ """,
378
+ (workspace_id,),
379
+ ).fetchall()
380
+ if str(row["user_id"] or "").strip()
381
+ ]
382
+ templates = [
383
+ str(row["slug"] or "").strip()
384
+ for row in conn.execute(
385
+ """
386
+ SELECT slug FROM agent_profiles
387
+ WHERE workspace_id = ? AND deleted_at IS NULL
388
+ """,
389
+ (workspace_id,),
390
+ ).fetchall()
391
+ if str(row["slug"] or "").strip()
392
+ ]
393
+ finally:
394
+ conn.close()
395
+ for uid in members:
396
+ for template in templates:
397
+ allowed.add(_runtime_profile_slug(uid, template))
398
+ return allowed
399
+
400
+
401
+ def purge_stale_runtime_profiles(workspace_id: str) -> dict[str, Any]:
402
+ workspace_id = str(workspace_id or "").strip()
403
+ allowed = _allowed_runtime_profiles_for_workspace(workspace_id)
404
+ purged: list[str] = []
405
+ for profile in _srv()._list_profiles():
406
+ if not _srv()._is_runtime_profile_slug(profile):
407
+ continue
408
+ if profile in allowed:
409
+ continue
410
+ _purge_runtime_profile(profile)
411
+ purged.append(profile)
412
+ for orphan in ("u-testalan-workframe-agent", "andy"):
413
+ if orphan in _srv()._list_profiles() and orphan not in allowed and orphan not in purged:
414
+ _purge_runtime_profile(orphan)
415
+ purged.append(orphan)
416
+ profiles_dir = _srv().HERMES_DATA / "profiles"
417
+ if profiles_dir.is_dir():
418
+ for entry in profiles_dir.iterdir():
419
+ name = entry.name
420
+ if not entry.is_dir() or name in allowed or name in purged:
421
+ continue
422
+ if name == "andy" or (name.startswith("u-test") and _srv()._is_runtime_profile_slug(name)):
423
+ _purge_runtime_profile(name)
424
+ purged.append(name)
425
+ return {"ok": True, "purged": purged, "allowed_count": len(allowed)}
426
+
427
+
428
+ def _cohort_manifest_markdown(user_id: str, cohort: list[dict[str, Any]]) -> str:
429
+ owner = _user_owner_name(user_id)
430
+ lines = [
431
+ f"# {owner}'s Workframe cohort",
432
+ "",
433
+ "This file is auto-generated by Workframe. Read it before kanban, cron, or delegation.",
434
+ "",
435
+ "## Rules",
436
+ "",
437
+ "- Use **runtime_slug** (column below) for kanban `--assignee`, cron agent targets, and `delegate_task` profile — never bare template names like `architect`.",
438
+ "- Template profiles (`architect`, `dev`, …) are shared disks **without your API keys**; workers exit immediately.",
439
+ "- Only interact with profiles listed here. Do not read other users' `u-*` profile `.env` files.",
440
+ "- Valid kanban `workspace_kind`: `scratch`, `dir:/absolute/path`, `worktree` — not `project`.",
441
+ "- Kanban workers must call `kanban_complete` or `kanban_block` before exit.",
442
+ "- Hermes CLI: `/opt/hermes/.venv/bin/hermes -p <runtime_slug> …`",
443
+ "",
444
+ "## Your agents",
445
+ "",
446
+ "| Role | You call them | runtime_slug (kanban/delegate) | alias |",
447
+ "|------|---------------|--------------------------------|-------|",
448
+ ]
449
+ for row in cohort:
450
+ lines.append(
451
+ f"| {row.get('role', '')} | {row.get('display_name', '')} | `{row.get('runtime_slug', '')}` | `{row.get('alias', '')}` |"
452
+ )
453
+ lines.extend(["", f"Owner user_id: `{user_id}`", ""])
454
+ return "\n".join(lines)
455
+
456
+
457
+ def _write_workframe_cohort_manifest(user_id: str, workspace_id: str, cohort: list[dict[str, Any]]) -> None:
458
+ if not cohort:
459
+ return
460
+ body = _cohort_manifest_markdown(user_id, cohort)
461
+ for row in cohort:
462
+ runtime = str(row.get("runtime_slug") or "").strip()
463
+ if not runtime:
464
+ continue
465
+ path = _srv()._profile_dir(runtime) / "WORKFRAME_COHORT.md"
466
+ try:
467
+ path.parent.mkdir(parents=True, exist_ok=True)
468
+ path.write_text(body, encoding="utf-8")
469
+ except OSError:
470
+ pass
471
+
472
+
473
+ def ensure_user_agent_cohort(user_id: str, workspace_id: str) -> list[dict[str, Any]]:
474
+ """Provision runtime profiles + credentials for every workspace agent this user may orchestrate."""
475
+ user_id = str(user_id or "").strip()
476
+ workspace_id = str(workspace_id or "").strip()
477
+ if not user_id or not workspace_id:
478
+ return []
479
+ cohort: list[dict[str, Any]] = []
480
+ conn = _srv()._workframe_db()
481
+ try:
482
+ rows = conn.execute(
483
+ """
484
+ SELECT slug, display_name, tagline, role, avatar_url, is_native
485
+ FROM agent_profiles
486
+ WHERE workspace_id = ? AND deleted_at IS NULL
487
+ ORDER BY is_native DESC, created_at ASC
488
+ """,
489
+ (workspace_id,),
490
+ ).fetchall()
491
+ finally:
492
+ conn.close()
493
+ for row in rows:
494
+ template = str(row["slug"] or "").strip()
495
+ if not template:
496
+ continue
497
+ runtime = _runtime_profile_slug(user_id, template)
498
+ if not _runtime_profile_ready(runtime):
499
+ try:
500
+ ensure_runtime_profile(runtime, template, user_id, workspace_id)
501
+ except ValueError:
502
+ continue
503
+ ident = _srv()._agent_identity_fields(template, workspace_id, user_id)
504
+ role = str(ident.get("role") or row["display_name"] or template).strip()
505
+ cohort.append(
506
+ {
507
+ "template_slug": template,
508
+ "runtime_slug": runtime,
509
+ "display_name": _runtime_display_label(user_id, template, workspace_id),
510
+ "kanban_assignee": runtime,
511
+ "alias": _cohort_alias(user_id, template),
512
+ "role": role,
513
+ "tagline": str(ident.get("tagline") or row["tagline"] or ""),
514
+ "avatar_url": ident.get("avatar_url"),
515
+ "avatar_id": ident.get("avatar_id"),
516
+ "is_native": bool(int(row["is_native"] or 0)),
517
+ }
518
+ )
519
+ _write_workframe_cohort_manifest(user_id, workspace_id, cohort)
520
+ return cohort
521
+
522
+
523
+ def _runtime_gateway_registered(runtime: str) -> bool:
524
+ runtime = _srv().safe_profile_slug(str(runtime or "").strip())
525
+ if not runtime:
526
+ return False
527
+ now = time.monotonic()
528
+ cached = _gateway_registered_cache.get(runtime)
529
+ if cached and now - cached[1] < _GATEWAY_REG_TTL_SEC:
530
+ return cached[0]
531
+ if _srv().SECURE_MODE and os.environ.get("WORKFRAME_SUPERVISOR_ALLOW_RAW_EXEC", "0") != "1":
532
+ state = str(_srv().gateway_data(runtime).get("state") or "").lower()
533
+ ok = state in {"running", "starting"} or _srv()._profile_api_healthy(runtime, timeout=0.8)
534
+ _gateway_registered_cache[runtime] = (ok, now)
535
+ return ok
536
+ script = (
537
+ "from pathlib import Path\n"
538
+ f"p=Path('/run/service/gateway-{runtime}/run')\n"
539
+ "raise SystemExit(0 if p.is_file() else 1)\n"
540
+ )
541
+ code, _ = _srv()._gateway_container_exec(["/opt/hermes/.venv/bin/python", "-c", script])
542
+ ok = code == 0
543
+ _gateway_registered_cache[runtime] = (ok, now)
544
+ return ok
545
+
546
+
547
+ def _invalidate_gateway_registered_cache(runtime: str = "") -> None:
548
+ if runtime:
549
+ _gateway_registered_cache.pop(_srv().safe_profile_slug(runtime), None)
550
+ else:
551
+ _gateway_registered_cache.clear()
552
+
553
+
554
+ def _runtime_profile_on_disk(runtime: str) -> bool:
555
+ p = _srv()._profile_dir(runtime)
556
+ return p.is_dir() and _srv()._profile_config_path(runtime) is not None
557
+
558
+
559
+ def _runtime_profile_ready(runtime: str) -> bool:
560
+ return _runtime_profile_on_disk(runtime) and _runtime_gateway_registered(runtime)
561
+
562
+
563
+ def _purge_runtime_profile(runtime: str) -> None:
564
+ """Drop Hermes registry entry and on-disk dir (best-effort)."""
565
+ try:
566
+ _srv()._gateway_exec(_srv()._primary_profile(), ["profile", "delete", "-y", runtime])
567
+ except Exception: # noqa: BLE001
568
+ pass
569
+ shutil.rmtree(_srv()._profile_dir(runtime), ignore_errors=True)
570
+
571
+
572
+ def _register_runtime_profile(runtime: str, template: str) -> None:
573
+ cmd = ["profile", "create", "--clone-from", template, runtime]
574
+ code, out = _srv()._gateway_exec(_srv()._primary_profile(), cmd)
575
+ if code == 0:
576
+ _inherit_runtime_profile_config(runtime, template)
577
+ return
578
+ out_l = out.lower()
579
+ if "already exists" in out_l:
580
+ if _srv()._profile_config_path(runtime) is not None:
581
+ return # ponytail: Hermes registry + on-disk clone — adopt, don't recreate
582
+ _purge_runtime_profile(runtime)
583
+ code, out = _srv()._gateway_exec(_srv()._primary_profile(), cmd)
584
+ if code == 0:
585
+ _inherit_runtime_profile_config(runtime, template)
586
+ return
587
+ if code != 0:
588
+ raise ValueError(f"runtime profile create failed: {out.strip()}")
589
+
590
+
591
+ def _copy_tree_missing(src: Path, dst: Path) -> None:
592
+ """Copy files from src into dst only when dst path is absent (never overwrite SOUL edits)."""
593
+ if not src.is_dir():
594
+ return
595
+ dst.mkdir(parents=True, exist_ok=True)
596
+ for item in src.rglob("*"):
597
+ if not item.is_file():
598
+ continue
599
+ rel = item.relative_to(src)
600
+ target = dst / rel
601
+ if target.is_file():
602
+ continue
603
+ target.parent.mkdir(parents=True, exist_ok=True)
604
+ try:
605
+ shutil.copy2(item, target)
606
+ except OSError:
607
+ pass
608
+
609
+
610
+ def _copy_text_without_bom(src: Path, dst: Path) -> None:
611
+ """Copy markdown identity files without UTF-8 BOM (Hermes blocks BOM in context files)."""
612
+ raw = src.read_bytes()
613
+ if raw.startswith(b"\xef\xbb\xbf"):
614
+ raw = raw[3:]
615
+ dst.parent.mkdir(parents=True, exist_ok=True)
616
+ dst.write_bytes(raw)
617
+
618
+
619
+ def _ensure_profile_terminal_cwd(profile: str, *, cwd: str = "/workspace") -> None:
620
+ """Hermes terminal + code_execution project root must be the shared workspace."""
621
+ config_path = _srv()._profile_gateway_config_path(profile)
622
+ if not config_path or not config_path.is_file():
623
+ return
624
+ try:
625
+ raw = config_path.read_text(encoding="utf-8")
626
+ except OSError:
627
+ return
628
+ fixed = re.sub(r"(?m)^ cwd: .*$", f" cwd: {cwd}", raw, count=1)
629
+ if fixed == raw:
630
+ return
631
+ try:
632
+ config_path.write_text(fixed, encoding="utf-8")
633
+ except OSError:
634
+ pass
635
+
636
+
637
+ def _backfill_runtime_identity(runtime: str, template: str) -> None:
638
+ """Fill missing SOUL/skills from template — credentials overlay never touches these."""
639
+ runtime = _srv().safe_profile_slug(runtime)
640
+ template = _srv().safe_profile_slug(template)
641
+ rdir = _srv()._profile_dir(runtime)
642
+ tdir = _srv()._profile_dir(template)
643
+ if not rdir.is_dir() or not tdir.is_dir():
644
+ return
645
+ for name in ("SOUL.md", "AGENTS.md", "SETUP.md"):
646
+ src = tdir / name
647
+ dst = rdir / name
648
+ if name == "SOUL.md":
649
+ if _srv()._write_profile_soul_if_stub(runtime, template):
650
+ continue
651
+ if src.is_file() and not dst.is_file():
652
+ try:
653
+ _copy_text_without_bom(src, dst)
654
+ except OSError:
655
+ pass
656
+ _copy_tree_missing(tdir / "skills", rdir / "skills")
657
+ _ensure_profile_terminal_cwd(runtime)
658
+
659
+
660
+ def _load_profile_yaml_dict(profile: str) -> dict[str, Any]:
661
+ _srv()._normalize_profile_config_yaml(profile)
662
+ cfg = _srv()._profile_config_path(profile)
663
+ if not cfg or not cfg.is_file():
664
+ return {}
665
+ try:
666
+ data = yaml.safe_load(cfg.read_text(encoding="utf-8"))
667
+ return data if isinstance(data, dict) else {}
668
+ except Exception: # noqa: BLE001
669
+ return {}
670
+
671
+
672
+ def _profile_toolsets_ready(profile: str, want: tuple[str, ...] | list[str]) -> bool:
673
+ cfg = _load_profile_yaml_dict(profile)
674
+ if not cfg:
675
+ return False
676
+ names = [str(n) for n in want if str(n).strip()]
677
+ if not names:
678
+ return True
679
+ ts = cfg.get("toolsets") if isinstance(cfg.get("toolsets"), list) else []
680
+ if not all(name in ts for name in names):
681
+ return False
682
+ pts = cfg.get("platform_toolsets") if isinstance(cfg.get("platform_toolsets"), dict) else {}
683
+ for platform in ("api_server", "cli"):
684
+ plat = pts.get(platform) if isinstance(pts.get(platform), list) else []
685
+ if not all(name in plat for name in names):
686
+ return False
687
+ agent = cfg.get("agent") if isinstance(cfg.get("agent"), dict) else {}
688
+ disabled = agent.get("disabled_toolsets") if isinstance(agent.get("disabled_toolsets"), list) else []
689
+ return not any(name in disabled for name in names)
690
+
691
+
692
+ def _inherit_runtime_profile_config(runtime: str, template: str) -> None:
693
+ """Hermes clone-from copies .env/SOUL/skills but may omit profile.yaml on u-* slugs."""
694
+ runtime = _srv().safe_profile_slug(runtime)
695
+ template = _srv().safe_profile_slug(template)
696
+ if _srv()._profile_config_path(runtime) is not None:
697
+ return
698
+ src = _srv()._profile_config_path(template)
699
+ if src is None:
700
+ raise ValueError(f"template profile has no config: {template}")
701
+ dst = _srv()._profile_dir(runtime) / src.name
702
+ dst.parent.mkdir(parents=True, exist_ok=True)
703
+ shutil.copy2(src, dst)
704
+
705
+
706
+ def ensure_runtime_profile(
707
+ runtime_slug: str,
708
+ template_slug: str,
709
+ user_id: str,
710
+ workspace_id: str = "",
711
+ ) -> None:
712
+ runtime = _srv().safe_profile_slug(runtime_slug)
713
+ template = _srv().resolve_validated_profile(template_slug)
714
+ if _runtime_profile_on_disk(runtime):
715
+ if not _runtime_gateway_registered(runtime):
716
+ try:
717
+ _register_runtime_profile(runtime, template)
718
+ except ValueError:
719
+ pass
720
+ ok, out, _port = _srv()._configure_profile_api(runtime)
721
+ if not ok:
722
+ raise ValueError(f"runtime profile api config failed: {out}")
723
+ skills_dir = _srv()._profile_dir(runtime) / "skills"
724
+ if not skills_dir.is_dir() or not any(skills_dir.iterdir()):
725
+ _backfill_runtime_identity(runtime, template)
726
+ if not _profile_toolsets_ready(runtime, _srv()._chat_toolsets_for_profile(runtime)):
727
+ _srv()._ensure_profile_toolsets(runtime)
728
+ _ensure_profile_terminal_cwd(runtime)
729
+ # ponytail: do not re-sync template model on every bind — destroys per-user reconcile
730
+ return
731
+ _purge_runtime_profile(runtime)
732
+ _register_runtime_profile(runtime, template)
733
+ if _srv()._profile_config_path(runtime) is None:
734
+ raise ValueError(f"runtime profile bootstrap failed: {runtime}")
735
+ # SOUL: clone inherits template at profile create; do not auto-overwrite on disk later.
736
+ soul_src = _srv()._profile_dir(template) / "SOUL.md"
737
+ soul_dst = _srv()._profile_dir(runtime) / "SOUL.md"
738
+ if soul_src.is_file() and soul_dst.parent.is_dir() and not soul_dst.is_file():
739
+ try:
740
+ shutil.copy2(soul_src, soul_dst)
741
+ except OSError:
742
+ pass
743
+ _srv()._write_profile_soul_if_stub(runtime, template)
744
+ _backfill_runtime_identity(runtime, template)
745
+ _srv()._strip_profile_llm_env(runtime)
746
+ _srv()._strip_profile_action_env(runtime)
747
+ ok, out, _port = _srv()._configure_profile_api(runtime)
748
+ if not ok:
749
+ raise ValueError(f"runtime profile api config failed: {out}")
750
+ _srv()._ensure_profile_toolsets(runtime)
751
+ _ensure_profile_terminal_cwd(runtime)
752
+ _prepare_runtime_profile_credentials(runtime, user_id, workspace_id)