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,715 @@
1
+ """WF-032 extract: per-turn credential leases and LLM proxy profile overlays."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from typing import Any
7
+
8
+ import credential_vault
9
+ import internal_proxy_auth
10
+ import profile_config_yaml
11
+ import provider_catalog
12
+ import turn_credentials
13
+
14
+
15
+ def _srv():
16
+ import server as srv
17
+
18
+ return srv
19
+
20
+
21
+ PROXY_HEADER_TEMPLATE = "${WORKFRAME_PROXY_TOKEN}"
22
+
23
+ def _llm_proxy_base_url(provider: str) -> str:
24
+ return f"{_srv().WORKFRAME_LLM_PROXY_INTERNAL}/internal/llm/{str(provider or 'openrouter').strip().lower()}/v1"
25
+
26
+
27
+ def _upsert_model_default_header(
28
+ lines: list[str],
29
+ header_key: str,
30
+ header_val: str,
31
+ ) -> list[str]:
32
+ """Ensure model.default_headers contains header_key: header_val (single block)."""
33
+ text = "".join(lines)
34
+ if f"{header_key}: {header_val}" in text or f"{header_key}: '{header_val}'" in text:
35
+ return lines
36
+ out: list[str] = []
37
+ in_model = False
38
+ in_headers = False
39
+ wrote = False
40
+ for line in lines:
41
+ stripped = line.lstrip()
42
+ if stripped.startswith("model:"):
43
+ in_model = True
44
+ in_headers = False
45
+ out.append(line)
46
+ continue
47
+ if in_model and stripped.startswith("default_headers:"):
48
+ in_headers = True
49
+ out.append(line)
50
+ continue
51
+ if in_model and in_headers and stripped.startswith(f"{header_key}:"):
52
+ indent = line[: len(line) - len(stripped)]
53
+ out.append(f"{indent}{header_key}: {header_val}\n")
54
+ wrote = True
55
+ continue
56
+ if in_model and in_headers and not wrote:
57
+ # ponytail: sibling model keys + top-level keys end the headers block — append before them
58
+ ends_headers = bool(
59
+ stripped and not line.startswith((" ", "\t")) and not stripped.startswith("#")
60
+ )
61
+ if ends_headers:
62
+ out.append(f" {header_key}: {header_val}\n")
63
+ wrote = True
64
+ in_headers = False
65
+ if in_model and stripped and not line.startswith((" ", "\t", "#")):
66
+ if not wrote and not in_headers:
67
+ out.append(" default_headers:\n")
68
+ out.append(f" {header_key}: {header_val}\n")
69
+ wrote = True
70
+ in_model = False
71
+ in_headers = False
72
+ out.append(line)
73
+ if not wrote:
74
+ rebuilt: list[str] = []
75
+ inserted = False
76
+ for line in out:
77
+ rebuilt.append(line)
78
+ if not inserted and line.lstrip().startswith("model:"):
79
+ rebuilt.append(" default_headers:\n")
80
+ rebuilt.append(f" {header_key}: {header_val}\n")
81
+ inserted = True
82
+ if not inserted:
83
+ rebuilt.insert(
84
+ 0,
85
+ f"model:\n default_headers:\n {header_key}: {header_val}\n",
86
+ )
87
+ out = rebuilt
88
+ return out
89
+
90
+
91
+ def _ensure_profile_proxy_headers(profile: str) -> None:
92
+ """Hermes default_headers: proxy token + profile slug for lease binding at /internal/*."""
93
+ prof = _srv().safe_profile_slug(profile)
94
+ _srv()._normalize_profile_config_yaml(prof)
95
+ try:
96
+ config_path = _srv()._ensure_gateway_config_file(prof)
97
+ except ValueError:
98
+ return
99
+ header_specs: list[tuple[str, str]] = [(internal_proxy_auth.PROFILE_HEADER, prof)]
100
+ if internal_proxy_auth.proxy_token_configured():
101
+ header_specs.append((internal_proxy_auth.PROXY_TOKEN_HEADER, PROXY_HEADER_TEMPLATE))
102
+ try:
103
+ raw = config_path.read_text(encoding="utf-8")
104
+ except OSError:
105
+ return
106
+ lines = raw.splitlines(keepends=True)
107
+ for header_key, header_val in header_specs:
108
+ lines = _upsert_model_default_header(lines, header_key, header_val)
109
+ try:
110
+ config_path.write_text("".join(lines), encoding="utf-8")
111
+ except OSError:
112
+ return
113
+
114
+
115
+ def _set_profile_model_base_url(profile: str, base_url: str) -> tuple[bool, str]:
116
+ _srv()._normalize_profile_config_yaml(profile)
117
+ try:
118
+ config_path = _srv()._ensure_gateway_config_file(profile)
119
+ except ValueError as exc:
120
+ return False, str(exc)
121
+ base_url = str(base_url or "").strip()
122
+ if not base_url:
123
+ return False, "base_url required"
124
+ try:
125
+ profile_config_yaml.update_model_surface(config_path, base_url=base_url)
126
+ except OSError as exc:
127
+ return False, f"write failed: {exc}"
128
+ return True, ""
129
+
130
+
131
+ def _proxy_fallback_chain(profile: str, billing_provider: str) -> None:
132
+ """Runtime fallbacks use provider=custom so Hermes keeps the proxy + lease."""
133
+ if not _srv()._is_runtime_profile_slug(profile):
134
+ return
135
+ block = _srv()._read_model_block(profile)
136
+ chain = block.get("fallback_chain") or []
137
+ billing = str(billing_provider or "openrouter").strip().lower()
138
+ proxied: list[dict[str, str]] = []
139
+ changed = False
140
+ for entry in chain:
141
+ prov = str(entry.get("provider", "")).strip().lower()
142
+ model = str(entry.get("model", "")).strip()
143
+ if not prov or not model:
144
+ continue
145
+ if prov == billing and prov != "custom":
146
+ changed = True
147
+ proxied.append({"provider": "custom", "model": model})
148
+ else:
149
+ proxied.append({"provider": prov, "model": model})
150
+ if not proxied:
151
+ changed = True
152
+ proxied = [
153
+ {"provider": "custom", "model": str(entry.get("model", "")).strip()}
154
+ for entry in _srv().HERMES_DEFAULT_FALLBACK_CHAIN
155
+ if str(entry.get("model", "")).strip()
156
+ ]
157
+ if changed:
158
+ _srv()._write_fallback_chain(profile, proxied)
159
+
160
+
161
+ def _coalesce_profile_model_yaml(profile: str) -> None:
162
+ """Merge duplicate top-level `model:` blocks left by incremental config writers."""
163
+ config_path = _srv()._profile_gateway_config_path(profile)
164
+ if not config_path or not config_path.is_file():
165
+ return
166
+ try:
167
+ lines = config_path.read_text(encoding="utf-8").splitlines(keepends=True)
168
+ except OSError:
169
+ return
170
+ model_headers = sum(
171
+ 1 for line in lines if line.lstrip().startswith("model:") and not line.startswith((" ", "\t"))
172
+ )
173
+ needs_coalesce = model_headers > 1
174
+ if not needs_coalesce:
175
+ in_model = False
176
+ for line in lines:
177
+ stripped = line.lstrip()
178
+ indent = len(line) - len(stripped)
179
+ if indent == 0 and stripped.startswith("model:"):
180
+ in_model = True
181
+ continue
182
+ if indent == 0 and stripped:
183
+ in_model = False
184
+ if in_model and indent == 2 and stripped.startswith("- "):
185
+ needs_coalesce = True
186
+ break
187
+ if not needs_coalesce:
188
+ return
189
+ block = _srv()._read_model_block(profile)
190
+ default = str(block.get("default") or _srv().HERMES_DEFAULT_PRIMARY).strip()
191
+ provider = str(block.get("provider") or "").strip()
192
+ base_url = str(block.get("base_url") or "").strip()
193
+ api_key = str(block.get("api_key") or "").strip()
194
+ chain = block.get("fallback_chain") if isinstance(block.get("fallback_chain"), list) else []
195
+ try:
196
+ profile_config_yaml.update_model_surface(
197
+ config_path,
198
+ default=default,
199
+ provider=provider or None,
200
+ base_url=base_url or None,
201
+ api_key=api_key or None,
202
+ fallback_chain=chain,
203
+ coalesce=True,
204
+ )
205
+ except OSError:
206
+ return
207
+
208
+
209
+ def _profile_llm_proxy_ready(profile: str, provider: str) -> bool:
210
+ prof = _srv().safe_profile_slug(profile)
211
+ block = _srv()._read_model_block(prof)
212
+ llm_provider = str(provider or "openrouter").strip().lower()
213
+ if str(block.get("base_url") or "").strip() != _llm_proxy_base_url(llm_provider):
214
+ return False
215
+ if not _srv()._is_runtime_profile_slug(prof):
216
+ return True
217
+ if str(block.get("provider") or "").strip().lower() != "custom":
218
+ return False
219
+ for entry in block.get("fallback_chain") or []:
220
+ prov = str(entry.get("provider", "")).strip().lower()
221
+ model = str(entry.get("model", "")).strip()
222
+ if model and prov == llm_provider and prov != "custom":
223
+ return False
224
+ return True
225
+
226
+
227
+ def _ensure_profile_llm_proxy(profile: str, provider: str) -> None:
228
+ """Point Hermes at the internal LLM proxy (create/lease paths only — skip when ready)."""
229
+ llm_provider = str(provider or "openrouter").strip().lower()
230
+ prof = _srv().safe_profile_slug(profile)
231
+ if _srv()._oauth_llm_provider_spec(llm_provider):
232
+ _srv()._normalize_profile_config_yaml(prof)
233
+ _srv()._apply_mvp_model_for_provider(prof, llm_provider)
234
+ return
235
+ _srv()._normalize_profile_config_yaml(prof)
236
+ if _profile_llm_proxy_ready(prof, llm_provider):
237
+ _ensure_profile_proxy_headers(prof)
238
+ if _srv()._is_runtime_profile_slug(prof):
239
+ _proxy_fallback_chain(prof, llm_provider)
240
+ return
241
+ _coalesce_profile_model_yaml(prof)
242
+ _set_profile_model_base_url(prof, _llm_proxy_base_url(llm_provider))
243
+ _ensure_profile_proxy_headers(prof)
244
+ if _srv()._is_runtime_profile_slug(prof):
245
+ _srv()._set_profile_model_provider(prof, "custom")
246
+ _proxy_fallback_chain(prof, llm_provider)
247
+
248
+
249
+ def _set_profile_model_api_key(profile: str, api_key: str) -> tuple[bool, str]:
250
+ _srv()._normalize_profile_config_yaml(profile)
251
+ try:
252
+ config_path = _srv()._ensure_gateway_config_file(profile)
253
+ except ValueError as exc:
254
+ return False, str(exc)
255
+ value = str(api_key or "").strip()
256
+ try:
257
+ raw = config_path.read_text(encoding="utf-8")
258
+ except OSError as exc:
259
+ return False, f"read failed: {exc}"
260
+ lines = raw.splitlines(keepends=True)
261
+ out: list[str] = []
262
+ in_model = False
263
+ wrote = False
264
+ for line in lines:
265
+ stripped = line.lstrip()
266
+ if stripped.startswith("model:"):
267
+ if not in_model:
268
+ in_model = True
269
+ out.append(line)
270
+ continue
271
+ if in_model and stripped and not line.startswith((" ", "\t", "#")):
272
+ if not wrote:
273
+ out.append(f" api_key: {_srv()._quote_env_value(value)}\n" if value else " api_key: ''\n")
274
+ wrote = True
275
+ in_model = False
276
+ if in_model and stripped.startswith("api_key:"):
277
+ out.append(
278
+ f" api_key: {_srv()._quote_env_value(value)}\n" if value else " api_key: ''\n"
279
+ )
280
+ wrote = True
281
+ continue
282
+ out.append(line)
283
+ if not wrote:
284
+ rebuilt: list[str] = []
285
+ inserted = False
286
+ for line in out:
287
+ rebuilt.append(line)
288
+ if not inserted and line.lstrip().startswith("model:"):
289
+ rebuilt.append(
290
+ f" api_key: {_srv()._quote_env_value(value)}\n" if value else " api_key: ''\n"
291
+ )
292
+ inserted = True
293
+ if not inserted:
294
+ rebuilt.insert(0, f"model:\n api_key: {_srv()._quote_env_value(value)}\n" if value else "model:\n api_key: ''\n")
295
+ out = rebuilt
296
+ try:
297
+ config_path.write_text("".join(out), encoding="utf-8")
298
+ except OSError as exc:
299
+ return False, f"write failed: {exc}"
300
+ return True, ""
301
+
302
+
303
+ def _clear_profile_model_api_key(profile: str) -> None:
304
+ _set_profile_model_api_key(profile, "")
305
+
306
+
307
+ def _profile_lease_env_var(profile: str, provider: str) -> str:
308
+ prov, _model = _srv()._read_model_from_config(profile)
309
+ if str(prov or "").strip().lower() == "custom":
310
+ return "OPENAI_API_KEY"
311
+ return _srv()._provider_env_var(provider)
312
+
313
+
314
+ def _read_profile_lease_token(profile: str, env_var: str) -> str:
315
+ return str(_srv()._read_env_map(_srv()._profile_dir(profile) / ".env").get(env_var) or "").strip()
316
+
317
+
318
+ def _lease_reusable_for_turn(
319
+ token: str,
320
+ *,
321
+ user_id: str,
322
+ workspace_id: str,
323
+ provider: str,
324
+ binding_id: str,
325
+ ) -> bool:
326
+ lease = turn_credentials.validate_lease(token)
327
+ if not lease:
328
+ return False
329
+ if str(lease.get("payer_user_id") or "") != str(user_id or "").strip():
330
+ return False
331
+ if str(lease.get("workspace_id") or "") != str(workspace_id or "").strip():
332
+ return False
333
+ if str(lease.get("provider") or "").strip().lower() != str(provider or "").strip().lower():
334
+ return False
335
+ lease_binding = str(lease.get("credential_binding_id") or "").strip()
336
+ want_binding = str(binding_id or "").strip()
337
+ if want_binding and lease_binding and lease_binding != want_binding:
338
+ return False
339
+ return True
340
+
341
+
342
+ def _read_config_model_api_key(profile: str) -> str:
343
+ return str(_srv()._read_model_block(profile).get("api_key") or "").strip()
344
+
345
+
346
+
347
+
348
+ def _sync_profile_model_api_key(profile: str, api_key: str) -> None:
349
+ value = str(api_key or "").strip()
350
+ if not value:
351
+ _clear_profile_model_api_key(profile)
352
+ _srv()._restart_runtime_profile_gateway(profile)
353
+ return
354
+ if _read_config_model_api_key(profile) == value:
355
+ return
356
+ ok, err = _set_profile_model_api_key(profile, value)
357
+ if not ok:
358
+ raise ValueError(err or "profile model api_key write failed")
359
+ _srv()._restart_runtime_profile_gateway(profile)
360
+
361
+
362
+ def _apply_turn_credential_lease(
363
+ profile: str,
364
+ user_id: str,
365
+ workspace_id: str,
366
+ provider: str,
367
+ run_id: str,
368
+ ) -> str:
369
+ """Issue per-run lease token into profile env; real secret stays in API vault."""
370
+ provider = str(provider or "openrouter").strip().lower()
371
+ prof = _srv().resolve_hermes_profile(profile)
372
+ if _srv()._is_runtime_profile_slug(prof):
373
+ resolved = _require_runtime_owner_provider(user_id, workspace_id, provider)
374
+ else:
375
+ resolved = _require_user_provider(user_id, workspace_id, provider)
376
+ oauth_spec = _srv()._oauth_llm_provider_spec(provider)
377
+ if oauth_spec and str(resolved.get("credential_type") or "") == "oauth":
378
+ _srv()._sync_oauth_llm_to_profile(prof, user_id, provider)
379
+ if not _srv()._profile_routing_matches_billing(prof, provider):
380
+ current_model = str(_srv()._read_model_from_config(prof)[1] or "").strip()
381
+ if not _srv()._resolve_billing_provider_for_model(current_model, {provider}):
382
+ current_model = str(
383
+ (_srv().PROVIDER_MVP_MODELS.get(provider) or {}).get("primary") or current_model
384
+ ).strip()
385
+ _srv()._apply_model_for_billing_provider(prof, provider, current_model, user_id)
386
+ return ""
387
+ binding_id = str(
388
+ resolved.get("credential_binding_id") or resolved.get("credential_id") or ""
389
+ ).strip()
390
+ vault_id = credential_vault.parse_vault_ref(str(resolved.get("credential_ref") or ""))
391
+ if vault_id:
392
+ binding_id = vault_id
393
+ if not binding_id and _srv()._credential_secret(resolved, user_id):
394
+ binding_id = str(uuid.uuid4())
395
+ credential_vault.store_secret(
396
+ binding_id,
397
+ _srv()._credential_secret(resolved, user_id),
398
+ env_var=str(resolved.get("env_var") or "") or _srv()._provider_env_var(provider),
399
+ provider=provider,
400
+ scope=str(resolved.get("scope") or "user"),
401
+ user_id=user_id,
402
+ workspace_id=workspace_id,
403
+ )
404
+ env_var = _profile_lease_env_var(prof, provider)
405
+ config_lease = _read_config_model_api_key(prof)
406
+ if config_lease and not turn_credentials.validate_lease(config_lease):
407
+ _clear_profile_model_api_key(prof)
408
+ existing_token = _read_profile_lease_token(prof, env_var)
409
+ if existing_token and not turn_credentials.validate_lease(existing_token):
410
+ _strip_profile_llm_env(prof, include_leases=True)
411
+ _clear_profile_model_api_key(prof)
412
+ existing_token = ""
413
+ if _lease_reusable_for_turn(
414
+ existing_token,
415
+ user_id=user_id,
416
+ workspace_id=workspace_id,
417
+ provider=provider,
418
+ binding_id=binding_id,
419
+ ):
420
+ # ponytail: hot path — skip yaml/env rewrite when lease still valid
421
+ if _srv()._is_runtime_profile_slug(prof) and existing_token:
422
+ if _read_config_model_api_key(prof) != existing_token:
423
+ _sync_profile_model_api_key(prof, existing_token)
424
+ return existing_token
425
+
426
+ token = turn_credentials.issue_lease(
427
+ run_id,
428
+ user_id,
429
+ workspace_id,
430
+ provider,
431
+ prof,
432
+ binding_id or None,
433
+ )
434
+ if token != existing_token:
435
+ _srv()._upsert_env_secret(_srv()._profile_dir(prof) / ".env", env_var, token)
436
+ if _srv()._is_runtime_profile_slug(prof):
437
+ _ensure_profile_llm_proxy(prof, provider)
438
+ _sync_profile_model_api_key(prof, token)
439
+ return token
440
+
441
+
442
+ def _action_proxy_base_url(provider_id: str) -> str:
443
+ base = str(_srv().WORKFRAME_LLM_PROXY_INTERNAL or "http://workframe-api:8080").rstrip("/")
444
+ return f"{base}/internal/action/{str(provider_id or '').strip().lower()}"
445
+
446
+
447
+ def _action_proxy_env_var(provider_id: str) -> str:
448
+ return {
449
+ "github": "GITHUB_API_URL",
450
+ "vercel": "VERCEL_API_URL",
451
+ "netlify": "NETLIFY_API_URL",
452
+ }.get(str(provider_id or "").strip().lower(), "")
453
+
454
+
455
+ def _user_action_env_specs() -> list[tuple[str, str]]:
456
+ specs: list[tuple[str, str]] = []
457
+ for row in provider_catalog.PROVIDER_CONNECT_CATALOG:
458
+ if not row.get("user_only"):
459
+ continue
460
+ for env_var in provider_catalog.provider_env_vars(row):
461
+ specs.append((str(row["id"]), env_var))
462
+ return specs
463
+
464
+
465
+ def _overlay_turn_user_env(profile: str, user_id: str, workspace_id: str, run_id: str = "") -> None:
466
+ """Issue per-run lease tokens for user-only tool credentials (never raw PATs on Agents mount)."""
467
+ user = str(user_id or "").strip()
468
+ if not user:
469
+ return
470
+ base_run = str(run_id or "").strip() or str(uuid.uuid4())
471
+ for provider_id, _env_var in _user_action_env_specs():
472
+ sub_run = f"{base_run}::{provider_id}"
473
+ try:
474
+ _apply_action_credential_lease(profile, user, workspace_id, provider_id, sub_run)
475
+ except ValueError:
476
+ continue
477
+
478
+
479
+ def _apply_action_credential_lease(
480
+ profile: str,
481
+ user_id: str,
482
+ workspace_id: str,
483
+ provider_id: str,
484
+ run_id: str,
485
+ ) -> str | None:
486
+ """Lease token for user-only dev tools; upstream secret stays in API vault."""
487
+ provider_id = str(provider_id or "").strip().lower()
488
+ user = str(user_id or "").strip()
489
+ run_id = str(run_id or "").strip()
490
+ if not user or not provider_id or not run_id:
491
+ return None
492
+ prof = _srv().resolve_hermes_profile(profile)
493
+ resolved = _srv()._resolve_credential(user, workspace_id, provider_id, user_only=True)
494
+ if not resolved:
495
+ return None
496
+ if not _srv()._credential_secret(resolved, user):
497
+ return None
498
+ binding_id = str(
499
+ resolved.get("credential_binding_id") or resolved.get("credential_id") or ""
500
+ ).strip()
501
+ if not binding_id:
502
+ binding_id = str(uuid.uuid4())
503
+ credential_vault.store_secret(
504
+ binding_id,
505
+ _srv()._credential_secret(resolved, user),
506
+ env_var=str(resolved.get("env_var") or "") or _srv()._provider_env_var(provider_id),
507
+ provider=provider_id,
508
+ scope="user",
509
+ user_id=user,
510
+ workspace_id=workspace_id,
511
+ )
512
+ token = turn_credentials.issue_lease(
513
+ run_id,
514
+ user,
515
+ workspace_id,
516
+ provider_id,
517
+ prof,
518
+ binding_id or None,
519
+ )
520
+ env_var = str(resolved.get("env_var") or "") or _srv()._provider_env_var(provider_id)
521
+ env_path = _srv()._profile_dir(prof) / ".env"
522
+ _srv()._upsert_env_secret(env_path, env_var, token)
523
+ proxy_env = _action_proxy_env_var(provider_id)
524
+ if proxy_env:
525
+ _srv()._upsert_env_secret(env_path, proxy_env, _action_proxy_base_url(provider_id))
526
+ return token
527
+
528
+
529
+ def _revoke_turn_credential_lease(run_id: str, profile: str) -> None:
530
+ """Revoke lease in DB; clear stale bearer from runtime profile config/gateway."""
531
+ run_id = str(run_id or "").strip()
532
+ if not run_id:
533
+ return
534
+ turn_credentials.revoke_lease(run_id)
535
+ for provider_id, _env_var in _user_action_env_specs():
536
+ turn_credentials.revoke_lease(f"{run_id}::{provider_id}")
537
+ prof = _srv().resolve_hermes_profile(profile)
538
+ if not _srv()._is_runtime_profile_slug(prof):
539
+ return
540
+ config_key = _read_config_model_api_key(prof)
541
+ if config_key.startswith(turn_credentials.LEASE_PREFIX):
542
+ _clear_profile_model_api_key(prof)
543
+ _srv()._restart_runtime_profile_gateway(prof)
544
+
545
+
546
+ def _require_user_provider(user_id: str, workspace_id: str, provider: str) -> dict[str, Any]:
547
+ """LLM billing uses the signed-in user's key only — same rule for chat and runtime agents."""
548
+ return _require_runtime_owner_provider(user_id, workspace_id, provider)
549
+
550
+
551
+ def _require_runtime_owner_provider(user_id: str, workspace_id: str, provider: str) -> dict[str, Any]:
552
+ provider = str(provider or "openrouter").strip().lower()
553
+ oauth_spec = _srv()._oauth_llm_provider_spec(provider)
554
+ if oauth_spec and _srv()._hermes_oauth_tokens_present(user_id, _srv()._hermes_auth_id_for_spec(oauth_spec)):
555
+ hermes_auth_id = _srv()._hermes_auth_id_for_spec(oauth_spec)
556
+ return {
557
+ "credential_binding_id": None,
558
+ "credential_id": None,
559
+ "credential_ref": f"oauth:{hermes_auth_id}",
560
+ "scope": "user",
561
+ "provider": provider,
562
+ "credential_type": "oauth",
563
+ "label": hermes_auth_id,
564
+ "env_var": "",
565
+ "user_id": user_id,
566
+ "workspace_id": None,
567
+ "agent_profile_id": None,
568
+ "created_by": user_id,
569
+ "created_at": None,
570
+ "updated_at": None,
571
+ "expires_at": None,
572
+ }
573
+ resolved = _srv()._resolve_credential(user_id, workspace_id, provider, user_only=True)
574
+ if resolved and _srv()._credential_secret(resolved, user_id):
575
+ return resolved
576
+ if _srv()._workspace_credential_mode(None, workspace_id) != "workspace":
577
+ raise ValueError(
578
+ "no_llm_provider_for_user: Connect an LLM provider under Profile → Connected accts before chatting with agents."
579
+ )
580
+ resolved = _srv()._resolve_credential(user_id, workspace_id, provider, user_only=False)
581
+ if resolved and _srv()._credential_secret(resolved, user_id):
582
+ return resolved
583
+ raise ValueError(
584
+ "no_llm_provider_for_user: Connect an LLM provider under Profile → Connected accts before chatting with agents."
585
+ )
586
+
587
+
588
+ def _strip_profile_llm_env(profile: str, *, include_leases: bool = False) -> bool:
589
+ """Remove shared-stack LLM keys from specialist profile .env."""
590
+ try:
591
+ prof = _srv().resolve_hermes_profile(profile)
592
+ except ValueError:
593
+ return False
594
+ if prof == _srv()._primary_profile() and not _srv()._is_runtime_profile_slug(prof):
595
+ return False
596
+ path = _srv()._profile_dir(prof) / ".env"
597
+ if not path.is_file():
598
+ return False
599
+ drop = set(_srv()._llm_provider_env_vars())
600
+ kept: list[str] = []
601
+ changed = False
602
+ for line in path.read_text(encoding="utf-8").splitlines():
603
+ stripped = line.strip()
604
+ if stripped and not stripped.startswith("#") and "=" in line:
605
+ key = line.partition("=")[0].strip()
606
+ _val = line.partition("=")[2].strip().strip('"').strip("'")
607
+ if include_leases and _val.startswith(turn_credentials.LEASE_PREFIX):
608
+ changed = True
609
+ continue
610
+ if key in drop:
611
+ # ponytail: keep active per-run lease tokens unless explicitly revoking
612
+ if not include_leases and _val.startswith(turn_credentials.LEASE_PREFIX):
613
+ kept.append(line)
614
+ continue
615
+ changed = True
616
+ continue
617
+ kept.append(line)
618
+ if changed:
619
+ path.write_text("\n".join(kept).rstrip() + ("\n" if kept else ""), encoding="utf-8")
620
+ return changed
621
+
622
+
623
+ def _strip_profile_action_env(profile: str) -> bool:
624
+ """Remove dev PAT tokens from specialist profile .env (never shared-stack keys)."""
625
+ try:
626
+ prof = _srv().resolve_hermes_profile(profile)
627
+ except ValueError:
628
+ return False
629
+ if prof == _srv()._primary_profile() and not _srv()._is_runtime_profile_slug(prof):
630
+ return False
631
+ path = _srv()._profile_dir(prof) / ".env"
632
+ if not path.is_file():
633
+ return False
634
+ drop = set()
635
+ for _pid, env_var in _user_action_env_specs():
636
+ drop.add(env_var)
637
+ kept: list[str] = []
638
+ changed = False
639
+ for line in path.read_text(encoding="utf-8").splitlines():
640
+ stripped = line.strip()
641
+ if stripped and not stripped.startswith("#") and "=" in line:
642
+ key = line.partition("=")[0].strip()
643
+ if key in drop:
644
+ changed = True
645
+ continue
646
+ kept.append(line)
647
+ if changed:
648
+ path.write_text("\n".join(kept).rstrip() + ("\n" if kept else ""), encoding="utf-8")
649
+ return changed
650
+
651
+
652
+ def _overlay_turn_provider_env(
653
+ profile: str,
654
+ user_id: str,
655
+ workspace_id: str,
656
+ provider: str,
657
+ run_id: str = "",
658
+ ) -> str:
659
+ """Per-run lease into profile env; upstream key resolved only inside workframe-api."""
660
+ run_id = str(run_id or "").strip() or str(uuid.uuid4())
661
+ prof = _srv().resolve_hermes_profile(profile)
662
+ is_runtime = _srv()._is_runtime_profile_slug(prof)
663
+ config_before = _read_config_model_api_key(prof) if is_runtime else ""
664
+ token = _apply_turn_credential_lease(profile, user_id, workspace_id, provider, run_id)
665
+ if is_runtime and _read_config_model_api_key(prof) != config_before:
666
+ _srv()._invalidate_profile_health_cache(prof)
667
+ # ponytail: gateway restart after lease yaml sync can take ~40s on cold runtime profiles.
668
+ if not _srv()._wait_profile_api_healthy(prof, attempts=160, delay=0.25):
669
+ _srv().ensure_profile_api(prof, user_id, workspace_id, bootstrap_providers=False)
670
+ return token
671
+
672
+
673
+ def _try_overlay_turn_provider_env(
674
+ profile: str,
675
+ user_id: str,
676
+ workspace_id: str,
677
+ provider: str,
678
+ run_id: str = "",
679
+ ) -> bool:
680
+ """Best-effort LLM lease; returns False when the user has no key yet."""
681
+ try:
682
+ _overlay_turn_provider_env(profile, user_id, workspace_id, provider, run_id)
683
+ return True
684
+ except ValueError as exc:
685
+ if "no_llm_provider_for_user" in str(exc):
686
+ return False
687
+ raise
688
+
689
+
690
+ def _sync_profile_provider_env(profile: str, user_id: str = "", workspace_id: str = "") -> bool:
691
+ """Copy the user's own LLM secrets into a specialist profile .env (never stack keys)."""
692
+ prof = _srv().resolve_validated_profile(profile)
693
+ if prof == _srv()._primary_profile():
694
+ return False
695
+ user = str(user_id or "").strip()
696
+ if not user:
697
+ return False
698
+
699
+ target = _srv()._profile_dir(prof) / ".env"
700
+ original = _srv()._read_env_map(target)
701
+ merged = dict(original)
702
+ needed = {key for key in _srv()._llm_provider_env_vars() if not merged.get(key)}
703
+ if not needed:
704
+ return False
705
+
706
+ for key, value in _srv()._read_env_map(_srv()._user_hermes_env_path(user)).items():
707
+ if key in needed and value:
708
+ merged[key] = value
709
+ needed.discard(key)
710
+ if merged == original:
711
+ return False
712
+
713
+ target.parent.mkdir(parents=True, exist_ok=True)
714
+ target.write_text("\n".join(f"{key}={merged[key]}" for key in sorted(merged)) + "\n", encoding="utf-8")
715
+ return True