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,673 @@
1
+ """WF-032 extract: user provider bindings and OAuth LLM connection state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import secrets
8
+ import shlex
9
+ import sqlite3
10
+ import threading
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import provider_bootstrap
16
+
17
+
18
+ def _srv():
19
+ import server as srv
20
+
21
+ return srv
22
+
23
+
24
+ _load_profile_auth_json = provider_bootstrap._load_profile_auth_json
25
+
26
+ def _user_provider_bindings(user_id: str) -> dict[str, dict[str, Any]]:
27
+ by_provider: dict[str, dict[str, Any]] = {}
28
+ try:
29
+ conn = sqlite3.connect(str(_srv()._workframe_db_path()), timeout=3.0)
30
+ conn.row_factory = sqlite3.Row
31
+ rows = conn.execute(
32
+ """SELECT id, provider, credential_type, credential_ref, label, is_active, updated_at
33
+ FROM credential_bindings
34
+ WHERE user_id = ? AND deleted_at IS NULL AND is_active = 1
35
+ ORDER BY updated_at DESC, created_at DESC""",
36
+ (user_id,),
37
+ ).fetchall()
38
+ conn.close()
39
+ except sqlite3.Error:
40
+ rows = []
41
+ for row in rows:
42
+ provider = str(row["provider"] or "").lower()
43
+ if provider and provider not in by_provider:
44
+ by_provider[provider] = dict(row)
45
+ return by_provider
46
+
47
+
48
+ _DEVICE_OAUTH_PROVIDER_IDS: frozenset[str] = frozenset({"codex", "nous"})
49
+ _oauth_device_lock = threading.Lock()
50
+ _oauth_device_sessions: dict[str, dict[str, Any]] = {}
51
+
52
+
53
+ def _strip_ansi(text: str) -> str:
54
+ return re.sub(r"\x1b\[[0-9;]*m", "", str(text or ""))
55
+
56
+
57
+ def _hermes_auth_id_for_spec(spec: dict[str, Any]) -> str:
58
+ return str(spec.get("hermes_auth_id") or spec.get("id") or "").strip()
59
+
60
+
61
+ def _hermes_oauth_auth_keys(hermes_auth_id: str) -> set[str]:
62
+ raw = str(hermes_auth_id or "").strip().lower()
63
+ if not raw:
64
+ return set()
65
+ keys = {raw, raw.replace("-", ""), raw.replace("-", "_")}
66
+ if "-" in raw:
67
+ keys.add(raw.split("-")[-1])
68
+ return {key for key in keys if key}
69
+
70
+
71
+ def _oauth_llm_provider_spec(provider: str) -> dict[str, Any] | None:
72
+ spec = _srv()._catalog_provider_for_llm(provider)
73
+ if spec and str(spec.get("connect_mode") or "") == "oauth":
74
+ return spec
75
+ return None
76
+
77
+
78
+ def _auth_json_has_oauth_material(data: dict[str, Any]) -> bool:
79
+ providers = data.get("providers")
80
+ if isinstance(providers, dict) and providers:
81
+ return True
82
+ pool = data.get("credential_pool")
83
+ if isinstance(pool, dict) and any(isinstance(entries, list) and entries for entries in pool.values()):
84
+ return True
85
+ creds = data.get("credentials")
86
+ return isinstance(creds, list) and bool(creds)
87
+
88
+
89
+ def _extract_oauth_block_from_auth(loaded: dict[str, Any], hermes_auth_id: str) -> dict[str, Any] | None:
90
+ keys = _hermes_oauth_auth_keys(hermes_auth_id)
91
+ providers = loaded.get("providers")
92
+ if isinstance(providers, dict):
93
+ for key, entry in providers.items():
94
+ if str(key).lower() in keys and isinstance(entry, dict):
95
+ tokens = entry.get("tokens")
96
+ if isinstance(tokens, dict) and any(
97
+ str(tokens.get(field) or "").strip()
98
+ for field in ("access_token", "refresh_token", "api_key", "id_token")
99
+ ):
100
+ return entry
101
+ if entry:
102
+ return entry
103
+ creds = loaded.get("credentials")
104
+ if isinstance(creds, list):
105
+ for row in creds:
106
+ if not isinstance(row, dict):
107
+ continue
108
+ pid = str(row.get("provider") or row.get("id") or "").lower()
109
+ if pid in keys:
110
+ return row
111
+ return None
112
+
113
+
114
+ def _merge_oauth_auth_into_profile(
115
+ auth: dict[str, Any],
116
+ user_auth: dict[str, Any],
117
+ hermes_auth_id: str,
118
+ ) -> bool:
119
+ keys = _hermes_oauth_auth_keys(hermes_auth_id)
120
+ changed = False
121
+ block = _extract_oauth_block_from_auth(user_auth, hermes_auth_id)
122
+ if isinstance(block, dict):
123
+ merged = auth.get("providers") if isinstance(auth.get("providers"), dict) else {}
124
+ merged[hermes_auth_id] = block
125
+ auth["providers"] = merged
126
+ changed = True
127
+ user_pool = user_auth.get("credential_pool")
128
+ if isinstance(user_pool, dict):
129
+ pool = auth.get("credential_pool")
130
+ if not isinstance(pool, dict):
131
+ pool = {}
132
+ auth["credential_pool"] = pool
133
+ for key, entries in user_pool.items():
134
+ if str(key).lower() in keys and isinstance(entries, list) and entries:
135
+ pool[hermes_auth_id] = entries
136
+ changed = True
137
+ return changed
138
+
139
+
140
+ def _sync_oauth_llm_to_profile(profile: str, user_id: str, provider: str) -> bool:
141
+ spec = _oauth_llm_provider_spec(provider)
142
+ if not spec:
143
+ return False
144
+ hermes_auth_id = _hermes_auth_id_for_spec(spec)
145
+ if not _hermes_oauth_tokens_present(user_id, hermes_auth_id):
146
+ return False
147
+ prof = _srv().resolve_hermes_profile(profile)
148
+ auth = _load_profile_auth_json(prof)
149
+ if _extract_oauth_block_from_auth(auth, hermes_auth_id):
150
+ # ponytail: steady-state turns — skip user auth read when profile already has oauth
151
+ return False
152
+ user_auth = _load_user_hermes_auth(user_id)
153
+ if not isinstance(user_auth, dict):
154
+ return False
155
+ auth_path = _srv()._profile_dir(prof) / "auth.json"
156
+ if not _merge_oauth_auth_into_profile(auth, user_auth, hermes_auth_id):
157
+ return False
158
+ auth["version"] = 1
159
+ auth["updated_at"] = _srv()._utc_now()
160
+ auth_path.parent.mkdir(parents=True, exist_ok=True)
161
+ auth_path.write_text(json.dumps(auth, indent=2, sort_keys=True) + "\n", encoding="utf-8")
162
+ _srv()._publish_profile_gateway_secrets(prof)
163
+ return True
164
+
165
+
166
+ def _hermes_oauth_tokens_present(user_id: str, hermes_auth_id: str) -> bool:
167
+ """True when Hermes auth.json has live OAuth tokens for a provider."""
168
+ hermes_auth_id = str(hermes_auth_id or "").strip()
169
+ if not hermes_auth_id:
170
+ return False
171
+ loaded = _load_user_hermes_auth(user_id)
172
+ if not isinstance(loaded, dict):
173
+ return False
174
+ if _extract_oauth_block_from_auth(loaded, hermes_auth_id):
175
+ return True
176
+ keys = _hermes_oauth_auth_keys(hermes_auth_id)
177
+ pool = loaded.get("credential_pool")
178
+ if isinstance(pool, dict):
179
+ for key, entries in pool.items():
180
+ if str(key).lower() in keys and isinstance(entries, list) and entries:
181
+ return True
182
+ return False
183
+
184
+
185
+ def _load_user_hermes_auth(user_id: str) -> dict[str, Any] | None:
186
+ auth_path = _srv()._user_hermes_auth_path(user_id)
187
+ loaded: dict[str, Any] | None = None
188
+ if auth_path.is_file():
189
+ try:
190
+ data = json.loads(auth_path.read_text(encoding="utf-8"))
191
+ if isinstance(data, dict):
192
+ loaded = data
193
+ except (OSError, json.JSONDecodeError):
194
+ loaded = None
195
+ if isinstance(loaded, dict) and _auth_json_has_oauth_material(loaded):
196
+ return loaded
197
+ rel = f"profiles/{_srv()._user_hermes_dir_slug(user_id)}/auth.json"
198
+ text = _read_gateway_data_file(rel)
199
+ if text.strip():
200
+ try:
201
+ data = json.loads(text)
202
+ if isinstance(data, dict):
203
+ return data
204
+ except json.JSONDecodeError:
205
+ pass
206
+ return loaded
207
+
208
+
209
+ def _read_gateway_data_file(rel_path: str) -> str:
210
+ rel_path = str(rel_path or "").strip().lstrip("/")
211
+ if not rel_path or ".." in rel_path.split("/"):
212
+ return ""
213
+ host_path = _srv().HERMES_DATA / rel_path
214
+ if host_path.is_file():
215
+ try:
216
+ return host_path.read_text(encoding="utf-8", errors="replace")
217
+ except OSError:
218
+ return ""
219
+ full = f"/opt/data/{rel_path}"
220
+ try:
221
+ code, out = _srv()._gateway_container_exec(["cat", full])
222
+ return out if code == 0 else ""
223
+ except Exception:
224
+ return ""
225
+
226
+
227
+ def _user_provider_connected(user_id: str, spec: dict[str, Any]) -> bool:
228
+ """Connected = this user's credential resolves with a live secret (no stack/install bleed)."""
229
+ if str(spec.get("connect_mode") or "") == "oauth":
230
+ if _hermes_oauth_tokens_present(user_id, _hermes_auth_id_for_spec(spec)):
231
+ return True
232
+ provider_id = str(spec["id"])
233
+ if str(spec.get("category") or "") == "llm":
234
+ resolved = _srv()._resolve_credential(user_id, "", provider_id, user_only=True)
235
+ return bool(resolved and _srv()._credential_secret(resolved, user_id))
236
+ bindings = _user_provider_bindings(user_id)
237
+ env_keys = _srv()._user_auth_env_keys(user_id)
238
+ return _srv()._provider_connected_for_user(user_id, spec, bindings, env_keys)
239
+
240
+
241
+ def list_user_providers(user_id: str, workspace_id: str = "") -> dict[str, Any]:
242
+ bindings = _user_provider_bindings(user_id)
243
+ workspace = str(workspace_id or "").strip()
244
+ providers: list[dict[str, Any]] = []
245
+ for spec in _srv().PROVIDER_CONNECT_CATALOG:
246
+ provider_id = str(spec["id"])
247
+ env_var = str(spec.get("env_var") or "")
248
+ binding = bindings.get(provider_id)
249
+ connected = _user_provider_connected(user_id, spec)
250
+ source: str | None = "user" if connected else None
251
+ if not connected and workspace and str(spec.get("category") or "") == "llm":
252
+ resolved = _srv()._resolve_credential("", workspace, provider_id)
253
+ if resolved and _srv()._credential_secret(resolved, user_id):
254
+ connected = True
255
+ source = "workspace"
256
+ oauth_configured = None
257
+ if str(spec.get("connect_mode") or "") == "oauth":
258
+ oauth_name = str(spec.get("oauth_provider") or provider_id)
259
+ if oauth_name == "github":
260
+ oauth_configured = _srv()._github_oauth_configured(workspace_id)
261
+ elif oauth_name == "stripe":
262
+ oauth_configured = _srv()._stripe_connect_configured()
263
+ providers.append({
264
+ **spec,
265
+ "connected": connected,
266
+ "source": source,
267
+ "credential_id": str(binding["id"]) if binding else None,
268
+ "credential_ref": str(binding["credential_ref"]) if binding else (f"env:{env_var}" if env_var and connected else None),
269
+ "profile_home": str(_srv()._user_hermes_home(user_id)),
270
+ "oauth_configured": oauth_configured,
271
+ "user_only": bool(spec.get("user_only")),
272
+ })
273
+ return {"ok": True, "providers": providers, "profile_home": str(_srv()._user_hermes_home(user_id))}
274
+
275
+
276
+ def disconnect_user_credential(user_id: str, credential_id: str) -> dict[str, Any]:
277
+ try:
278
+ conn = sqlite3.connect(str(_srv()._workframe_db_path()), timeout=3.0)
279
+ conn.row_factory = sqlite3.Row
280
+ row = conn.execute(
281
+ """SELECT id, provider, credential_type, credential_ref
282
+ FROM credential_bindings
283
+ WHERE id = ? AND user_id = ? AND deleted_at IS NULL""",
284
+ (credential_id, user_id),
285
+ ).fetchone()
286
+ if not row:
287
+ conn.close()
288
+ return {"ok": False, "error": "credential_not_found"}
289
+ cred_ref = str(row["credential_ref"] or "")
290
+ env_var = cred_ref[4:] if cred_ref.startswith("env:") else ""
291
+ if not env_var:
292
+ spec = _srv()._catalog_provider(str(row["provider"]))
293
+ env_var = str((spec or {}).get("env_var") or "")
294
+ if env_var:
295
+ _srv()._remove_env_secret(_srv()._user_hermes_env_path(user_id), env_var)
296
+ _srv()._remove_auth_metadata(_srv()._user_hermes_auth_path(user_id), cred_ref or f"env:{env_var}")
297
+ now = _srv()._utc_now()
298
+ conn.execute(
299
+ "UPDATE credential_bindings SET is_active = 0, deleted_at = ?, updated_at = ? WHERE id = ?",
300
+ (now, now, credential_id),
301
+ )
302
+ conn.commit()
303
+ conn.close()
304
+ except sqlite3.Error as exc:
305
+ return {"ok": False, "error": f"db_error: {exc}"}
306
+ return {"ok": True, "credential_id": credential_id, "provider": str(row["provider"])}
307
+
308
+
309
+ def _remove_hermes_oauth_provider(user_id: str, hermes_auth_id: str) -> None:
310
+ hermes_auth_id = str(hermes_auth_id or "").strip()
311
+ if not hermes_auth_id:
312
+ return
313
+ auth_path = _srv()._user_hermes_auth_path(user_id)
314
+ if auth_path.is_file():
315
+ try:
316
+ loaded = json.loads(auth_path.read_text(encoding="utf-8"))
317
+ except (OSError, json.JSONDecodeError):
318
+ loaded = {}
319
+ if isinstance(loaded, dict):
320
+ providers = loaded.get("providers")
321
+ if isinstance(providers, dict):
322
+ for key in list(providers.keys()):
323
+ if key.lower() in {hermes_auth_id.lower(), hermes_auth_id.replace("-", "").lower()}:
324
+ providers.pop(key, None)
325
+ pool = loaded.get("credential_pool")
326
+ if isinstance(pool, dict):
327
+ for key in list(pool.keys()):
328
+ if key.lower() in {hermes_auth_id.lower(), hermes_auth_id.replace("-", "").lower()}:
329
+ pool.pop(key, None)
330
+ loaded["updated_at"] = _srv()._utc_now()
331
+ auth_path.write_text(json.dumps(loaded, indent=2, sort_keys=True) + "\n", encoding="utf-8")
332
+ user_part = re.sub(r"[^a-z0-9]+", "-", _srv()._user_hermes_dir_slug(user_id).lower()).strip("-")[:20] or "user"
333
+ prefix = f"u-{user_part}-"
334
+ profiles_dir = _srv().HERMES_DATA / "profiles"
335
+ if profiles_dir.is_dir():
336
+ for prof_dir in profiles_dir.iterdir():
337
+ if not prof_dir.is_dir() or not prof_dir.name.startswith(prefix):
338
+ continue
339
+ prof_auth = prof_dir / "auth.json"
340
+ if not prof_auth.is_file():
341
+ continue
342
+ try:
343
+ pdata = json.loads(prof_auth.read_text(encoding="utf-8"))
344
+ except (OSError, json.JSONDecodeError):
345
+ continue
346
+ if not isinstance(pdata, dict):
347
+ continue
348
+ pproviders = pdata.get("providers")
349
+ if isinstance(pproviders, dict):
350
+ for key in list(pproviders.keys()):
351
+ if key.lower() in {hermes_auth_id.lower(), hermes_auth_id.replace("-", "").lower()}:
352
+ pproviders.pop(key, None)
353
+ pdata["updated_at"] = _srv()._utc_now()
354
+ prof_auth.write_text(json.dumps(pdata, indent=2, sort_keys=True) + "\n", encoding="utf-8")
355
+
356
+
357
+ def disconnect_user_provider(user_id: str, provider_id: str) -> dict[str, Any]:
358
+ spec = _srv()._catalog_provider(provider_id)
359
+ if not spec:
360
+ return {"ok": False, "error": "provider_not_found"}
361
+ env_var = str(spec.get("env_var") or "")
362
+ if env_var:
363
+ _srv()._remove_env_secret(_srv()._user_hermes_env_path(user_id), env_var)
364
+ _srv()._remove_auth_metadata(_srv()._user_hermes_auth_path(user_id), f"env:{env_var}")
365
+ if str(spec.get("connect_mode") or "") == "oauth":
366
+ _remove_hermes_oauth_provider(user_id, _hermes_auth_id_for_spec(spec))
367
+ binding = _user_provider_bindings(user_id).get(str(spec["id"]))
368
+ if binding:
369
+ return disconnect_user_credential(user_id, str(binding["id"]))
370
+ return {"ok": True, "provider": provider_id, "disconnected": True}
371
+
372
+
373
+ def _hermes_user_shell(user_id: str, script: str, *, timeout: float = 30.0) -> tuple[int, str]:
374
+ home = _srv()._hermes_user_home_container(user_id)
375
+ _srv()._user_hermes_home(user_id).mkdir(parents=True, exist_ok=True)
376
+ shell = (
377
+ f"export HERMES_HOME={shlex.quote(home)} HOME={shlex.quote(home)}; "
378
+ f"mkdir -p {shlex.quote(home)}; cd {shlex.quote(home)}; {script}"
379
+ )
380
+ if _srv().SECURE_MODE:
381
+ if not _srv()._supervisor_ready():
382
+ raise RuntimeError(
383
+ "Docker socket access is disabled in _srv().SECURE_MODE; "
384
+ "configure WORKFRAME_SUPERVISOR_URL and WORKFRAME_SUPERVISOR_TOKEN"
385
+ )
386
+ status, data = _srv()._supervisor_request(
387
+ "POST",
388
+ "/v1/gateway.container_exec",
389
+ {"args": ["sh", "-lc", shell]},
390
+ timeout=timeout,
391
+ )
392
+ if status >= 300:
393
+ err = data.get("error") if isinstance(data, dict) else str(data)
394
+ raise ValueError(err or f"supervisor gateway.container_exec failed ({status})")
395
+ if not isinstance(data, dict):
396
+ raise ValueError("supervisor gateway.container_exec returned invalid payload")
397
+ exit_code = data.get("exit_code")
398
+ try:
399
+ code = int(exit_code if exit_code is not None else 1)
400
+ except (TypeError, ValueError):
401
+ code = 1
402
+ return code, str(data.get("output") or "")
403
+ return _srv()._docker_exec(_srv().GATEWAY_CONTAINER_NAME, ["sh", "-lc", shell])
404
+
405
+
406
+ def _parse_device_oauth_log(text: str) -> dict[str, str | None]:
407
+ clean = _strip_ansi(text)
408
+ verification_uri = None
409
+ for match in re.finditer(r"https?://[^\s\]\)>\"']+", clean):
410
+ url = match.group(0).rstrip(".,;)")
411
+ if any(token in url.lower() for token in ("/device", "/portal", "auth.openai.com", "nousresearch")):
412
+ verification_uri = url
413
+ break
414
+ if not verification_uri:
415
+ url_match = re.search(
416
+ r"Open this URL.*?\n\s*(\S+)",
417
+ clean,
418
+ flags=re.IGNORECASE | re.DOTALL,
419
+ )
420
+ if url_match:
421
+ verification_uri = url_match.group(1).strip()
422
+ user_code = None
423
+ code_match = re.search(
424
+ r"Enter this code.*?\n\s*(\S+)",
425
+ clean,
426
+ flags=re.IGNORECASE | re.DOTALL,
427
+ )
428
+ if code_match:
429
+ user_code = code_match.group(1).strip()
430
+ if not user_code:
431
+ bare = re.search(r"\b([A-Z0-9]{4,8}-[A-Z0-9]{4,8})\b", clean)
432
+ if bare:
433
+ user_code = bare.group(1)
434
+ return {"verification_uri": verification_uri, "user_code": user_code}
435
+
436
+
437
+ def _sync_user_oauth_provider_to_runtime_profiles(user_id: str, hermes_auth_id: str) -> None:
438
+ if not _hermes_oauth_tokens_present(user_id, hermes_auth_id):
439
+ return
440
+ user_auth = _load_user_hermes_auth(user_id)
441
+ if not isinstance(user_auth, dict):
442
+ return
443
+ user_part = re.sub(r"[^a-z0-9]+", "-", _srv()._user_hermes_dir_slug(user_id).lower()).strip("-")[:20] or "user"
444
+ prefix = f"u-{user_part}-"
445
+ profiles_dir = _srv().HERMES_DATA / "profiles"
446
+ if not profiles_dir.is_dir():
447
+ return
448
+ for prof_dir in profiles_dir.iterdir():
449
+ if not prof_dir.is_dir() or not prof_dir.name.startswith(prefix):
450
+ continue
451
+ auth_path = prof_dir / "auth.json"
452
+ auth = _load_profile_auth_json(prof_dir.name)
453
+ user_auth = _load_user_hermes_auth(user_id)
454
+ if not isinstance(user_auth, dict):
455
+ continue
456
+ if not _merge_oauth_auth_into_profile(auth, user_auth, hermes_auth_id):
457
+ continue
458
+ auth["version"] = 1
459
+ auth["updated_at"] = _srv()._utc_now()
460
+ auth_path.parent.mkdir(parents=True, exist_ok=True)
461
+ auth_path.write_text(json.dumps(auth, indent=2, sort_keys=True) + "\n", encoding="utf-8")
462
+ _srv()._publish_profile_gateway_secrets(prof_dir.name)
463
+
464
+
465
+ def _finalize_hermes_device_oauth(user_id: str, provider_id: str, spec: dict[str, Any]) -> None:
466
+ hermes_auth_id = _hermes_auth_id_for_spec(spec)
467
+ _sync_user_oauth_provider_to_runtime_profiles(user_id, hermes_auth_id)
468
+ _srv()._bootstrap_model_after_llm_connect(user_id, "", provider_id)
469
+
470
+
471
+ def _device_oauth_session_get(session_id: str) -> dict[str, Any] | None:
472
+ with _oauth_device_lock:
473
+ row = _oauth_device_sessions.get(str(session_id or "").strip())
474
+ return dict(row) if isinstance(row, dict) else None
475
+
476
+
477
+ def _device_oauth_session_patch(session_id: str, patch: dict[str, Any]) -> None:
478
+ with _oauth_device_lock:
479
+ row = _oauth_device_sessions.get(session_id)
480
+ if isinstance(row, dict):
481
+ row.update(patch)
482
+
483
+
484
+ def _spawn_hermes_device_oauth(user_id: str, hermes_auth_id: str, log_container: str) -> tuple[int, str]:
485
+ """Start long-running `hermes auth add` detached in gateway (survives exec return)."""
486
+ home = _srv()._hermes_user_home_container(user_id)
487
+ _srv()._user_hermes_home(user_id).mkdir(parents=True, exist_ok=True)
488
+ if _srv().SECURE_MODE and _srv()._supervisor_ready():
489
+ status, data = _srv()._supervisor_request(
490
+ "POST",
491
+ "/v1/hermes.device_oauth_start",
492
+ {"home": home, "hermes_auth_id": hermes_auth_id, "log_path": log_container},
493
+ timeout=30.0,
494
+ )
495
+ if status >= 300:
496
+ err = data.get("error") if isinstance(data, dict) else str(data)
497
+ raise ValueError(err or "device_oauth_start_failed")
498
+ if not isinstance(data, dict):
499
+ raise ValueError("device_oauth_start_invalid")
500
+ exit_code = data.get("exit_code")
501
+ try:
502
+ code = int(exit_code if exit_code is not None else 1)
503
+ except (TypeError, ValueError):
504
+ code = 1
505
+ return code, str(data.get("output") or "")
506
+ auth_cmd = " ".join(shlex.quote(part) for part in ["auth", "add", hermes_auth_id])
507
+ shell = (
508
+ f"mkdir -p {shlex.quote(home)}; "
509
+ f"chown -R hermes:hermes {shlex.quote(home)}; "
510
+ f"su -s /bin/sh hermes -c "
511
+ f"'export HERMES_HOME={shlex.quote(home)} HOME={shlex.quote(home)}; "
512
+ f"cd {shlex.quote(home)}; "
513
+ f"/opt/hermes/bin/hermes {auth_cmd} >> {shlex.quote(log_container)} 2>&1'"
514
+ )
515
+ return _srv()._gateway_container_exec_detached(["sh", "-lc", shell])
516
+
517
+
518
+ def _start_device_oauth(user_id: str, provider_id: str, spec: dict[str, Any]) -> dict[str, Any]:
519
+ hermes_auth_id = _hermes_auth_id_for_spec(spec)
520
+ session_id = secrets.token_urlsafe(16)
521
+ log_name = f".oauth-{session_id}.log"
522
+ home = _srv()._hermes_user_home_container(user_id)
523
+ log_container = f"{home}/{log_name}"
524
+ log_host = _srv()._user_hermes_home(user_id) / log_name
525
+ try:
526
+ rc, out = _spawn_hermes_device_oauth(user_id, hermes_auth_id, log_container)
527
+ except (RuntimeError, ValueError) as exc:
528
+ return {"ok": False, "provider": provider_id, "error": str(exc)}
529
+ if rc != 0:
530
+ return {
531
+ "ok": False,
532
+ "provider": provider_id,
533
+ "error": "oauth_start_failed",
534
+ "output": (out or "").strip(),
535
+ }
536
+ with _oauth_device_lock:
537
+ _oauth_device_sessions[session_id] = {
538
+ "user_id": user_id,
539
+ "provider_id": provider_id,
540
+ "hermes_auth_id": hermes_auth_id,
541
+ "log_path": str(log_host),
542
+ "status": "pending",
543
+ "verification_uri": None,
544
+ "user_code": None,
545
+ "finalized": False,
546
+ "started_at": time.time(),
547
+ }
548
+ status = device_oauth_status(user_id, provider_id, session_id)
549
+ return {
550
+ "ok": True,
551
+ "provider": provider_id,
552
+ "hermes_auth_id": hermes_auth_id,
553
+ "flow": "device_code",
554
+ "session_id": session_id,
555
+ "status": status.get("status") or "pending",
556
+ "verification_uri": status.get("verification_uri"),
557
+ "user_code": status.get("user_code"),
558
+ "message": status.get("message"),
559
+ }
560
+
561
+
562
+ def device_oauth_status(user_id: str, provider_id: str, session_id: str) -> dict[str, Any]:
563
+ sess = _device_oauth_session_get(session_id)
564
+ if not sess or sess.get("user_id") != user_id or sess.get("provider_id") != provider_id:
565
+ return {"ok": False, "error": "session_not_found"}
566
+ spec = _srv()._catalog_provider(provider_id) or {}
567
+ hermes_auth_id = str(sess.get("hermes_auth_id") or _hermes_auth_id_for_spec(spec))
568
+ log_path = Path(str(sess.get("log_path") or ""))
569
+ log_text = ""
570
+ if log_path.is_file():
571
+ try:
572
+ log_text = log_path.read_text(encoding="utf-8", errors="replace")
573
+ except OSError:
574
+ log_text = ""
575
+ if not log_text.strip() and log_path.name:
576
+ log_text = _read_gateway_data_file(f"profiles/{_srv()._user_hermes_dir_slug(user_id)}/{log_path.name}")
577
+ parsed = _parse_device_oauth_log(log_text)
578
+ patch: dict[str, Any] = {}
579
+ if parsed.get("verification_uri"):
580
+ patch["verification_uri"] = parsed["verification_uri"]
581
+ if parsed.get("user_code"):
582
+ patch["user_code"] = parsed["user_code"]
583
+ if patch:
584
+ _device_oauth_session_patch(session_id, patch)
585
+ sess.update(patch)
586
+ if _hermes_oauth_tokens_present(user_id, hermes_auth_id):
587
+ if not sess.get("finalized"):
588
+ _finalize_hermes_device_oauth(user_id, provider_id, spec)
589
+ _device_oauth_session_patch(session_id, {"status": "connected", "finalized": True})
590
+ return {
591
+ "ok": True,
592
+ "provider": provider_id,
593
+ "session_id": session_id,
594
+ "status": "connected",
595
+ "verification_uri": sess.get("verification_uri"),
596
+ "user_code": sess.get("user_code"),
597
+ }
598
+ lowered = log_text.lower()
599
+ if any(token in lowered for token in ("login successful", "auth added", "credentials saved", "successfully authenticated", "logged in")):
600
+ if _hermes_oauth_tokens_present(user_id, hermes_auth_id):
601
+ if not sess.get("finalized"):
602
+ _finalize_hermes_device_oauth(user_id, provider_id, spec)
603
+ _device_oauth_session_patch(session_id, {"status": "connected", "finalized": True})
604
+ return {
605
+ "ok": True,
606
+ "provider": provider_id,
607
+ "session_id": session_id,
608
+ "status": "connected",
609
+ "verification_uri": sess.get("verification_uri"),
610
+ "user_code": sess.get("user_code"),
611
+ }
612
+ if any(token in lowered for token in ("autherror", "login timed out", "login cancelled", "failed")):
613
+ _device_oauth_session_patch(session_id, {"status": "error"})
614
+ return {
615
+ "ok": False,
616
+ "provider": provider_id,
617
+ "session_id": session_id,
618
+ "status": "error",
619
+ "error": "oauth_failed",
620
+ "message": _strip_ansi(log_text).strip()[-500:] or "OAuth failed",
621
+ "verification_uri": sess.get("verification_uri"),
622
+ "user_code": sess.get("user_code"),
623
+ }
624
+ started = float(sess.get("started_at") or 0.0)
625
+ if started and time.time() - started > 16 * 60:
626
+ _device_oauth_session_patch(session_id, {"status": "error"})
627
+ return {
628
+ "ok": False,
629
+ "provider": provider_id,
630
+ "session_id": session_id,
631
+ "status": "error",
632
+ "error": "oauth_timeout",
633
+ "verification_uri": sess.get("verification_uri"),
634
+ "user_code": sess.get("user_code"),
635
+ }
636
+ return {
637
+ "ok": True,
638
+ "provider": provider_id,
639
+ "session_id": session_id,
640
+ "status": "pending",
641
+ "verification_uri": sess.get("verification_uri"),
642
+ "user_code": sess.get("user_code"),
643
+ }
644
+
645
+
646
+ def start_user_oauth(user_id: str, provider_id: str, workspace_id: str = "") -> dict[str, Any]:
647
+ if str(provider_id).lower() == "discord":
648
+ return _srv()._start_discord_oauth(user_id, workspace_id)
649
+ spec = _srv()._catalog_provider(provider_id)
650
+ if not spec or str(spec.get("connect_mode")) != "oauth":
651
+ return {"ok": False, "error": "not_oauth_provider"}
652
+ oauth_provider = str(spec.get("oauth_provider") or spec["id"]).lower()
653
+ if oauth_provider == "github":
654
+ return {**_srv()._start_github_oauth(user_id, workspace_id, spec), "flow": "redirect"}
655
+ if oauth_provider == "stripe":
656
+ return {**_srv()._start_stripe_oauth(user_id, workspace_id, spec), "flow": "redirect"}
657
+ if str(provider_id).lower() in _DEVICE_OAUTH_PROVIDER_IDS:
658
+ return _start_device_oauth(user_id, provider_id, spec)
659
+ hermes_auth_id = _hermes_auth_id_for_spec(spec)
660
+ rc, out = _srv()._hermes_user_exec(user_id, ["auth", "add", hermes_auth_id])
661
+ redirect_url = None
662
+ for token in re.findall(r"https?://[^\s\])>\"']+", out or ""):
663
+ redirect_url = token.rstrip(".,;)")
664
+ break
665
+ return {
666
+ "ok": rc == 0,
667
+ "provider": provider_id,
668
+ "hermes_auth_id": hermes_auth_id,
669
+ "output": (out or "").strip(),
670
+ "redirect_url": redirect_url,
671
+ "flow": "redirect" if redirect_url else "device_code",
672
+ "error": None if rc == 0 else "oauth_start_failed",
673
+ }