create-workframe 0.1.11 → 0.1.12

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 (100) hide show
  1. package/README.md +1 -1
  2. package/docs/security.md +85 -0
  3. package/package.json +1 -1
  4. package/scripts/sync-canonical-to-package.mjs +8 -0
  5. package/scripts/verify-public-deploy.sh +59 -3
  6. package/workframe-api/openrouter_catalog.py +4 -4
  7. package/workframe-api/package.json +2 -2
  8. package/workframe-api/profile_config_yaml.py +126 -0
  9. package/workframe-api/route_registry.py +330 -0
  10. package/workframe-api/run-typecheck.mjs +25 -0
  11. package/workframe-api/server.py +1523 -1392
  12. package/workframe-api/test_model_surface_consistency.py +116 -0
  13. package/workframe-api/test_profile_model_yaml.py +99 -0
  14. package/workframe-api/test_public_routes.py +41 -0
  15. package/workframe-api/test_route_registry.py +109 -0
  16. package/workframe-api/time-bind-steps.py +75 -0
  17. package/workframe-api/time-perf.py +81 -0
  18. package/workframe-api/time-warm.py +107 -0
  19. package/workframe-ui/public/assets/{arc-DjpS9a2b.js → arc-B0OFRGmJ.js} +1 -1
  20. package/workframe-ui/public/assets/architecture-7EHR7CIX-Dwwi9yA0.js +1 -0
  21. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-puCKmKHV.js → architectureDiagram-3BPJPVTR-DeBNltHS.js} +1 -1
  22. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-C2P0g-eH.js → blockDiagram-GPEHLZMM-BDhCHu7I.js} +1 -1
  23. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DdvYEITH.js → c4Diagram-AAUBKEIU-olcDYvtI.js} +1 -1
  24. package/workframe-ui/public/assets/channel-fNHbDpV4.js +1 -0
  25. package/workframe-ui/public/assets/{chunk-2J33WTMH-Cxe8QsEF.js → chunk-2J33WTMH-D0obCBn_.js} +1 -1
  26. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DPNyTwR3.js → chunk-3OPIFGDE-DX93f-ZZ.js} +1 -1
  27. package/workframe-ui/public/assets/{chunk-4BX2VUAB-XKG9gfRN.js → chunk-4BX2VUAB-BX9B5wUs.js} +1 -1
  28. package/workframe-ui/public/assets/{chunk-55IACEB6-oPMTulgb.js → chunk-55IACEB6-C4DGc6RO.js} +1 -1
  29. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BWwXvpa_.js → chunk-5ZQYHXKU-Crlja9Lf.js} +1 -1
  30. package/workframe-ui/public/assets/{chunk-727SXJPM-Cml2twO_.js → chunk-727SXJPM-JYSm3szI.js} +1 -1
  31. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DzrRBhQu.js → chunk-AQP2D5EJ-_KslxVEl.js} +1 -1
  32. package/workframe-ui/public/assets/{chunk-BSJP7CBP-EZ7STU6h.js → chunk-BSJP7CBP-CpTO-jU8.js} +1 -1
  33. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-BGTQzWYw.js → chunk-CSCIHK7Q-JGUkCmbI.js} +1 -1
  34. package/workframe-ui/public/assets/{chunk-FMBD7UC4-Cm4stfRa.js → chunk-FMBD7UC4-BscBwGaC.js} +1 -1
  35. package/workframe-ui/public/assets/{chunk-KSCS5N6A-exbMijjy.js → chunk-KSCS5N6A-5ZTZ0bpX.js} +1 -1
  36. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-C75kbQwN.js → chunk-L5ZTLDWV-DxvitYbW.js} +1 -1
  37. package/workframe-ui/public/assets/{chunk-LZXEDZCA-CsRdBqUB.js → chunk-LZXEDZCA-DMfdMUwz.js} +2 -2
  38. package/workframe-ui/public/assets/{chunk-ND2GUHAM-D84zyIu2.js → chunk-ND2GUHAM-Cayl0iV9.js} +1 -1
  39. package/workframe-ui/public/assets/{chunk-NZK2D7GU-CUNNo1bj.js → chunk-NZK2D7GU-_7dyBR5G.js} +1 -1
  40. package/workframe-ui/public/assets/{chunk-O5CBEL6O-DN6WEIh-.js → chunk-O5CBEL6O--xTpD0yi.js} +1 -1
  41. package/workframe-ui/public/assets/chunk-QZHKN3VN-ewTgEQK8.js +1 -0
  42. package/workframe-ui/public/assets/chunk-WU5MYG2G-D4Tg-A0l.js +1 -0
  43. package/workframe-ui/public/assets/{chunk-XPW4576I-Ct5VQv2D.js → chunk-XPW4576I-CwxJQaeK.js} +1 -1
  44. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CFAgBpEl.js +1 -0
  45. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CFAgBpEl.js +1 -0
  46. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bj4W5oMk.js → cose-bilkent-S5V4N54A-CCspENYv.js} +1 -1
  47. package/workframe-ui/public/assets/{dagre-BM42HDAG-DAjHBkAy.js → dagre-BM42HDAG-Dv8V6R60.js} +1 -1
  48. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ch2gGFPG.js → diagram-2AECGRRQ-Da48hFPT.js} +1 -1
  49. package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzIIvgYH.js → diagram-5GNKFQAL-dTihc7-3.js} +1 -1
  50. package/workframe-ui/public/assets/{diagram-KO2AKTUF-Df0Q8nv6.js → diagram-KO2AKTUF-D_Jqu699.js} +1 -1
  51. package/workframe-ui/public/assets/{diagram-LMA3HP47-D-G3ekmF.js → diagram-LMA3HP47-Bt8PtEaZ.js} +1 -1
  52. package/workframe-ui/public/assets/{diagram-OG6HWLK6-TBHhT9f5.js → diagram-OG6HWLK6-BtmPjlh0.js} +1 -1
  53. package/workframe-ui/public/assets/{dist-CPbiDkEZ.js → dist-Cz2turIT.js} +1 -1
  54. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-YxkUmLLV.js → erDiagram-TEJ5UH35-BxMGCflX.js} +1 -1
  55. package/workframe-ui/public/assets/eventmodeling-FCH6USID-BIJTP5k-.js +1 -0
  56. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-kfZ7-RSd.js → flowDiagram-I6XJVG4X-ivyroIZt.js} +1 -1
  57. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DmTx3hzR.js → ganttDiagram-6RSMTGT7-B9llwShx.js} +1 -1
  58. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BXa0ewp3.js → gitGraph-WXDBUCRP-BFQHk4bw.js} +1 -1
  59. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CQr2qMxU.js → gitGraphDiagram-PVQCEYII-ursegLbc.js} +1 -1
  60. package/workframe-ui/public/assets/index-8CuZDEIG.css +1 -0
  61. package/workframe-ui/public/assets/index-xS9lFekI.js +129 -0
  62. package/workframe-ui/public/assets/{info-J43DQDTF-t-Qlx8zv.js → info-J43DQDTF-DQpifAsB.js} +1 -1
  63. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CzkYofPx.js → infoDiagram-5YYISTIA-C16XP2Q7.js} +1 -1
  64. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-djK3Oxgb.js → ishikawaDiagram-YF4QCWOH-CiJoz7rP.js} +1 -1
  65. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CWxYXxdF.js → journeyDiagram-JHISSGLW-CVwEvvUL.js} +1 -1
  66. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-D7leCAD9.js → kanban-definition-UN3LZRKU-CWQ6hX5i.js} +1 -1
  67. package/workframe-ui/public/assets/{line-BSFj2OC5.js → line-CgQsmU35.js} +1 -1
  68. package/workframe-ui/public/assets/{linear-pfPwnu-o.js → linear-Bxbc77dL.js} +1 -1
  69. package/workframe-ui/public/assets/{mermaid-parser.core-Dm2L9Ib_.js → mermaid-parser.core-C_jMeF0a.js} +2 -2
  70. package/workframe-ui/public/assets/{mermaid.core-Bevx9bif.js → mermaid.core-4RKV9MZP.js} +3 -3
  71. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-UkjDssmP.js → mindmap-definition-RKZ34NQL-DZ7y7Sz0.js} +1 -1
  72. package/workframe-ui/public/assets/{packet-YPE3B663-JO2ezH3_.js → packet-YPE3B663-B9h2I7Vq.js} +1 -1
  73. package/workframe-ui/public/assets/{pie-LRSECV5Y-DdMFsfR8.js → pie-LRSECV5Y-B2mP5uHm.js} +1 -1
  74. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CEPHFBU1.js → pieDiagram-4H26LBE5-DyYKyKiP.js} +1 -1
  75. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-_RF9_86k.js → quadrantDiagram-W4KKPZXB-CL_6nej-.js} +1 -1
  76. package/workframe-ui/public/assets/{radar-GUYGQ44K-Cb-YfdaA.js → radar-GUYGQ44K-CNKNDQ7S.js} +1 -1
  77. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DBVOxmBn.js → requirementDiagram-4Y6WPE33-eWU_mhFa.js} +1 -1
  78. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BTpxsGUA.js → sankeyDiagram-5OEKKPKP-DrjcXvEQ.js} +1 -1
  79. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DHP_Bq3l.js → sequenceDiagram-3UESZ5HK-BHWR1xTJ.js} +1 -1
  80. package/workframe-ui/public/assets/{src-CY_By-r7.js → src-DfJB8kV1.js} +1 -1
  81. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BVtp_LPR.js → stateDiagram-AJRCARHV-BGJbSd3I.js} +1 -1
  82. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CMLuNyeC.js +1 -0
  83. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-D38HJhop.js → timeline-definition-PNZ67QCA-DkwoCjiZ.js} +1 -1
  84. package/workframe-ui/public/assets/{treeView-BLDUP644-CFCG3Sc_.js → treeView-BLDUP644-GxfhiqoW.js} +1 -1
  85. package/workframe-ui/public/assets/{treemap-LRROVOQU-BeiMs_dF.js → treemap-LRROVOQU-DxrOsars.js} +1 -1
  86. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CKMeT6PW.js → vennDiagram-CIIHVFJN-BsWhpUfr.js} +1 -1
  87. package/workframe-ui/public/assets/{wardley-L42UT6IY-BljDy8ph.js → wardley-L42UT6IY-CrFZWMHS.js} +1 -1
  88. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DEE8D94J.js → wardleyDiagram-YWT4CUSO-DV8Xpf5I.js} +1 -1
  89. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BRaAWV_g.js → xychartDiagram-2RQKCTM6-DqMqnwdZ.js} +1 -1
  90. package/workframe-ui/public/index.html +4 -5
  91. package/workframe-ui/public/assets/architecture-7EHR7CIX-CvhbXEvt.js +0 -1
  92. package/workframe-ui/public/assets/channel-D63s_yN9.js +0 -1
  93. package/workframe-ui/public/assets/chunk-QZHKN3VN-Dj_Yxhgo.js +0 -1
  94. package/workframe-ui/public/assets/chunk-WU5MYG2G-AFchshb6.js +0 -1
  95. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-Opj8t01I.js +0 -1
  96. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-Opj8t01I.js +0 -1
  97. package/workframe-ui/public/assets/eventmodeling-FCH6USID-LQOYL50n.js +0 -1
  98. package/workframe-ui/public/assets/index-B08aShJy.css +0 -1
  99. package/workframe-ui/public/assets/index-DCF3W3G_.js +0 -129
  100. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CvJhS_BJ.js +0 -1
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import base64
12
12
  import hashlib
13
+ import hmac
13
14
  import json
14
15
  import mimetypes
15
16
  import os
@@ -36,7 +37,8 @@ from pathlib import Path
36
37
  from typing import Any, Iterator
37
38
 
38
39
  from email_sender import APP_BASE_URL, send_branded_invite_email, send_email, send_verification_email
39
- import install_api
40
+ import profile_config_yaml
41
+ import route_registry
40
42
  import stack_config
41
43
  import site_meta
42
44
  import google_auth
@@ -50,6 +52,7 @@ import concierge
50
52
  import llm_error_glossary
51
53
  import openrouter_catalog
52
54
  import updates as stack_updates
55
+ import install_api
53
56
 
54
57
  HERMES_DATA = Path(os.environ.get("HERMES_DATA", "/opt/data"))
55
58
  WORKSPACE = Path(os.environ.get("WORKSPACE", "/workspace"))
@@ -197,6 +200,12 @@ DEPLOYMENT_MODE = _resolve_deployment_mode()
197
200
  # ponytail: docker exec per bind was ~10s; cache gateway registration briefly.
198
201
  _gateway_registered_cache: dict[str, tuple[bool, float]] = {}
199
202
  _GATEWAY_REG_TTL_SEC = 45.0
203
+ _profile_health_cache: dict[str, tuple[bool, float]] = {}
204
+ _PROFILE_HEALTH_TTL_SEC = 8.0
205
+ _user_llm_picker_cache: dict[str, tuple[frozenset[str], float]] = {}
206
+ _USER_LLM_PICKER_TTL_SEC = 5.0
207
+ _session_info_cache: dict[str, tuple[dict[str, Any], float]] = {}
208
+ _SESSION_INFO_TTL_SEC = 5.0
200
209
 
201
210
 
202
211
  def _install_window_open() -> bool:
@@ -443,7 +452,7 @@ def _check_auth(request_handler) -> bool:
443
452
  auth_header = request_handler.headers.get("Authorization", "")
444
453
  if auth_header.startswith("Bearer "):
445
454
  token = _get_auth_token()
446
- if auth_header[7:].strip() == token:
455
+ if token and hmac.compare_digest(auth_header[7:].strip(), token):
447
456
  request_handler.auth_user = "service"
448
457
  request_handler.auth_role = "owner" # type: ignore[attr-defined]
449
458
  return True
@@ -782,40 +791,29 @@ _gateway_lifecycle_lock = threading.Lock()
782
791
  # Auth session helpers
783
792
  # ---------------------------------------------------------------------------
784
793
 
785
- # Routes that are public only for GET (POST still needs session)
786
- GET_PUBLIC_ROUTES = {
787
- "/api/files/read",
788
- "/api/files/raw",
789
- "/api/files/tree",
790
- "/api/files/list",
791
- "/api/files/state",
792
- "/api/board",
793
- "/api/content",
794
- "/api/content/get",
795
- "/api/meta",
796
- "/api/agents",
797
- "/api/snapshot",
798
- "/api/health",
799
- "/api/hermes/usage",
800
- "/api/hermes/profile",
801
- "/api/hermes/debug",
802
- "/api/hermes/insights",
803
- "/api/hermes/gquota",
804
- "/api/hermes/models",
805
- "/api/hermes/skills",
806
- "/api/hermes/commands",
807
- "/api/routes",
808
- "/api/chat/session",
809
- "/api/chat/resolve",
810
- "/api/chat/messages",
811
- "/api/profiles",
812
- "/api/setup/status",
813
- "/api/install/status",
814
- "/api/public/site-meta",
815
- "/api/public/link-preview",
816
- "/api/public/manifest.webmanifest",
817
- "/api/auth/google/callback",
818
- }
794
+ # Route auth tiers live in route_registry.py (WF-037).
795
+
796
+
797
+ def _deployment_allows_sessionless_data_get(
798
+ *,
799
+ deployment_mode: str | None = None,
800
+ dev_local_unsafe: bool | None = None,
801
+ ) -> bool:
802
+ mode = deployment_mode if deployment_mode is not None else _resolve_deployment_mode()
803
+ dev = DEV_LOCAL_UNSAFE if dev_local_unsafe is None else dev_local_unsafe
804
+ return route_registry.deployment_allows_sessionless_data_get(mode, dev_local_unsafe=dev)
805
+
806
+
807
+ def _sessionless_get_allowed(
808
+ path: str,
809
+ *,
810
+ deployment_mode: str | None = None,
811
+ dev_local_unsafe: bool | None = None,
812
+ ) -> bool:
813
+ mode = deployment_mode if deployment_mode is not None else _resolve_deployment_mode()
814
+ dev = DEV_LOCAL_UNSAFE if dev_local_unsafe is None else dev_local_unsafe
815
+ return route_registry.sessionless_get_allowed(path, deployment_mode=mode, dev_local_unsafe=dev)
816
+
819
817
 
820
818
  def _is_public_get(path: str) -> bool:
821
819
  """Return True if this GET path is public (no session needed)."""
@@ -823,9 +821,7 @@ def _is_public_get(path: str) -> bool:
823
821
  return True
824
822
  if path.startswith("/static/") or path.startswith("/assets/"):
825
823
  return True
826
- if path.startswith("/api/public/branding/"):
827
- return True
828
- return path in GET_PUBLIC_ROUTES
824
+ return _sessionless_get_allowed(path)
829
825
 
830
826
 
831
827
  def _cookie_session_id(handler: BaseHTTPRequestHandler) -> str:
@@ -851,85 +847,27 @@ def _session_id_from_request(handler: BaseHTTPRequestHandler) -> str:
851
847
 
852
848
 
853
849
  def _auth_check(handler: BaseHTTPRequestHandler) -> bool:
854
- """Return True if the request should be allowed through."""
850
+ """Return True if the request should be allowed through (WF-037 route_registry)."""
855
851
  path = urllib.parse.urlparse(handler.path).path
856
852
  method = handler.command
857
853
  sid = _session_id_from_request(handler)
858
854
 
859
- if method == "GET" and path == "/api/auth/hermes-dashboard-gate":
860
- if sid:
861
- session_info = _zk.validate_session_token(sid)
862
- if session_info:
863
- _apply_session_user(handler, session_info["user_id"])
864
- return True
865
-
866
- if DEV_LOCAL_UNSAFE:
867
- # Still validate session if present, to populate auth_user
868
- if sid:
869
- session_info = _zk.validate_session_token(sid)
870
- if session_info:
871
- _apply_session_user(handler, session_info["user_id"])
872
- return True
873
-
874
- # OPTIONS (CORS preflight) — always allow
875
- if method == "OPTIONS":
876
- return True
877
-
878
- # GET public routes — still attach session user when cookie/header present
879
- # (e.g. /api/hermes/models needs auth_user for per-user provider detection).
880
- if method == "GET" and _is_public_get(path):
881
- if sid:
882
- session_info = _zk.validate_session_token(sid)
883
- if session_info:
884
- _apply_session_user(handler, session_info["user_id"])
885
- return True
886
-
887
- # POST auth endpoints (start/verify/logout/refresh) are public
888
- if method == "POST" and path in (
889
- "/api/auth/start",
890
- "/api/auth/verify",
891
- "/api/auth/logout",
892
- "/api/auth/refresh",
893
- "/api/setup",
894
- ):
895
- return True
896
-
897
- if method == "POST" and path in (
898
- "/api/auth/google/start",
899
- "/api/auth/local-bootstrap",
900
- ):
901
- return True
902
-
903
- if _install_window_open() and method == "POST" and path in (
904
- "/api/install/email/test",
905
- "/api/install/complete",
906
- "/api/install/url/test",
907
- "/api/install/setup-https",
908
- ):
909
- # ponytail: install/complete still needs auth_user in trusted_team — populate when session present
910
- if sid:
911
- session_info = _zk.validate_session_token(sid)
912
- if session_info:
913
- _apply_session_user(handler, session_info["user_id"])
914
- return True
915
-
916
- if _install_window_open() and method in ("GET", "PATCH") and path == "/api/install/stack":
917
- return True
918
-
919
- if _install_window_open() and method == "GET" and path in (
920
- "/api/install/publish-hints",
921
- "/api/install/url/test",
922
- ):
923
- return True
924
-
925
- # Everything else needs a valid session
926
- if not sid:
927
- return False
928
- session_info = _zk.validate_session_token(sid)
929
- if not session_info:
930
- return False
931
- _apply_session_user(handler, session_info["user_id"])
932
- return True
855
+ def _validate(sid_token: str):
856
+ return _zk.validate_session_token(sid_token)
857
+
858
+ def _attach(user_id: str) -> None:
859
+ _apply_session_user(handler, user_id)
860
+
861
+ return route_registry.authorize_request(
862
+ method,
863
+ path,
864
+ sid,
865
+ deployment_mode=_resolve_deployment_mode(),
866
+ dev_local_unsafe=DEV_LOCAL_UNSAFE,
867
+ install_window_open=_install_window_open(),
868
+ validate_session=_validate,
869
+ attach_user=_attach,
870
+ )
933
871
 
934
872
 
935
873
  def _workspace_role_for_user(user_id: str) -> str:
@@ -2417,12 +2355,15 @@ def _sync_oauth_llm_to_profile(profile: str, user_id: str, provider: str) -> boo
2417
2355
  hermes_auth_id = _hermes_auth_id_for_spec(spec)
2418
2356
  if not _hermes_oauth_tokens_present(user_id, hermes_auth_id):
2419
2357
  return False
2358
+ prof = resolve_hermes_profile(profile)
2359
+ auth = _load_profile_auth_json(prof)
2360
+ if _extract_oauth_block_from_auth(auth, hermes_auth_id):
2361
+ # ponytail: steady-state turns — skip user auth read when profile already has oauth
2362
+ return False
2420
2363
  user_auth = _load_user_hermes_auth(user_id)
2421
2364
  if not isinstance(user_auth, dict):
2422
2365
  return False
2423
- prof = resolve_hermes_profile(profile)
2424
2366
  auth_path = _profile_dir(prof) / "auth.json"
2425
- auth = _load_profile_auth_json(prof)
2426
2367
  if not _merge_oauth_auth_into_profile(auth, user_auth, hermes_auth_id):
2427
2368
  return False
2428
2369
  auth["version"] = 1
@@ -5107,12 +5048,32 @@ def bootstrap_agent_dm_lane(
5107
5048
  model_id = str(model or "").strip()
5108
5049
  if model_id:
5109
5050
  try:
5110
- code, out = _gateway_exec(runtime, ["model", model_id])
5051
+ applied = hermes_model_set(runtime, model_id, user_id, workspace_id)
5111
5052
  steps.append(
5112
- {"step": "set_runtime_model", "ok": code == 0, "model": model_id, "output": out.strip()},
5053
+ {
5054
+ "step": "set_runtime_model",
5055
+ "ok": bool(applied.get("ok")),
5056
+ "model": model_id,
5057
+ **({"error": applied.get("error")} if not applied.get("ok") else {}),
5058
+ },
5113
5059
  )
5060
+ if not applied.get("ok"):
5061
+ return {
5062
+ "ok": False,
5063
+ "template": template,
5064
+ "runtime": runtime,
5065
+ "steps": steps,
5066
+ "error": str(applied.get("error") or "set_runtime_model_failed"),
5067
+ }
5114
5068
  except Exception as exc:
5115
5069
  steps.append({"step": "set_runtime_model", "ok": False, "error": str(exc)})
5070
+ return {
5071
+ "ok": False,
5072
+ "template": template,
5073
+ "runtime": runtime,
5074
+ "steps": steps,
5075
+ "error": str(exc),
5076
+ }
5116
5077
  else:
5117
5078
  try:
5118
5079
  _bootstrap_profile_providers(runtime, user_id, workspace_id)
@@ -6025,6 +5986,25 @@ def _invalidate_gateway_registered_cache(runtime: str = "") -> None:
6025
5986
  _gateway_registered_cache.clear()
6026
5987
 
6027
5988
 
5989
+ def _invalidate_profile_health_cache(profile: str = "") -> None:
5990
+ if profile:
5991
+ _profile_health_cache.pop(safe_profile_slug(profile), None)
5992
+ else:
5993
+ _profile_health_cache.clear()
5994
+
5995
+
5996
+ def _invalidate_session_info_cache(profile: str = "", session_id: str = "") -> None:
5997
+ if profile and session_id:
5998
+ _session_info_cache.pop(f"{safe_profile_slug(profile)}:{session_id}", None)
5999
+ elif profile:
6000
+ prefix = f"{safe_profile_slug(profile)}:"
6001
+ for key in list(_session_info_cache):
6002
+ if key.startswith(prefix):
6003
+ _session_info_cache.pop(key, None)
6004
+ else:
6005
+ _session_info_cache.clear()
6006
+
6007
+
6028
6008
  def _is_user_credential_home_slug(slug: str) -> bool:
6029
6009
  """UUID dirs under profiles/ hold per-user keys — not Hermes agent profiles."""
6030
6010
  return bool(_AGENT_PROFILE_UUID_RE.fullmatch(str(slug or "").strip()))
@@ -6207,7 +6187,7 @@ def ensure_runtime_profile(
6207
6187
  if not _profile_toolsets_ready(runtime, _chat_toolsets_for_profile(runtime)):
6208
6188
  _ensure_profile_toolsets(runtime)
6209
6189
  _ensure_profile_terminal_cwd(runtime)
6210
- _sync_runtime_model_from_template(runtime, template)
6190
+ # ponytail: do not re-sync template model on every bind — destroys per-user reconcile
6211
6191
  return
6212
6192
  _purge_runtime_profile(runtime)
6213
6193
  _register_runtime_profile(runtime, template)
@@ -10761,15 +10741,23 @@ def _latest_session_id(profile: str) -> str:
10761
10741
 
10762
10742
 
10763
10743
  def _session_info(profile: str, session_id: str) -> dict[str, Any]:
10764
- db = _profile_dir(profile) / "state.db"
10744
+ sid = str(session_id or "").strip()
10745
+ prof = safe_profile_slug(str(profile or "").strip())
10746
+ cache_key = f"{prof}:{sid}"
10747
+ now = time.monotonic()
10748
+ cached = _session_info_cache.get(cache_key)
10749
+ if cached and now - cached[1] < _SESSION_INFO_TTL_SEC:
10750
+ return dict(cached[0])
10751
+ empty: dict[str, Any] = {
10752
+ "session_id": session_id,
10753
+ "title": "",
10754
+ "active": False,
10755
+ "message_count": 0,
10756
+ }
10757
+ db = _profile_dir(prof) / "state.db"
10765
10758
  conn = _ro_sqlite_live(db)
10766
10759
  if not conn:
10767
- return {
10768
- "session_id": session_id,
10769
- "title": "",
10770
- "active": False,
10771
- "message_count": 0,
10772
- }
10760
+ return empty
10773
10761
  try:
10774
10762
  row = conn.execute(
10775
10763
  """
@@ -10779,26 +10767,20 @@ def _session_info(profile: str, session_id: str) -> dict[str, Any]:
10779
10767
  (session_id,),
10780
10768
  ).fetchone()
10781
10769
  if not row:
10782
- return {
10783
- "session_id": session_id,
10784
- "title": "",
10785
- "active": False,
10786
- "message_count": 0,
10787
- }
10788
- return {
10770
+ _session_info_cache[cache_key] = (empty, now)
10771
+ return empty
10772
+ result = {
10789
10773
  "session_id": str(row["id"]),
10790
10774
  "title": (row["title"] or "").strip(),
10791
10775
  "active": row["ended_at"] is None,
10792
10776
  "message_count": int(row["message_count"] or 0),
10793
10777
  "started_at": _iso_from_unix(row["started_at"]),
10794
10778
  }
10779
+ _session_info_cache[cache_key] = (result, now)
10780
+ return result
10795
10781
  except sqlite3.Error:
10796
- return {
10797
- "session_id": session_id,
10798
- "title": "",
10799
- "active": False,
10800
- "message_count": 0,
10801
- }
10782
+ _session_info_cache[cache_key] = (empty, now)
10783
+ return empty
10802
10784
  finally:
10803
10785
  conn.close()
10804
10786
 
@@ -11368,48 +11350,7 @@ def _set_profile_model_base_url(profile: str, base_url: str) -> tuple[bool, str]
11368
11350
  if not base_url:
11369
11351
  return False, "base_url required"
11370
11352
  try:
11371
- raw = config_path.read_text(encoding="utf-8")
11372
- except OSError as exc:
11373
- return False, f"read failed: {exc}"
11374
- lines = raw.splitlines(keepends=True)
11375
- out: list[str] = []
11376
- in_model = False
11377
- wrote = False
11378
- for line in lines:
11379
- stripped = line.lstrip()
11380
- if stripped.startswith("model:"):
11381
- if not in_model:
11382
- in_model = True
11383
- rest = stripped.split(":", 1)[1].strip().strip("'\"")
11384
- if rest:
11385
- out.append("model:\n")
11386
- out.append(f" default: {rest}\n")
11387
- else:
11388
- out.append("model:\n")
11389
- continue
11390
- if in_model and stripped and not line.startswith((" ", "\t", "#")):
11391
- if not wrote:
11392
- out.append(f" base_url: {base_url}\n")
11393
- wrote = True
11394
- in_model = False
11395
- if in_model and stripped.startswith("base_url:"):
11396
- out.append(f" base_url: {base_url}\n")
11397
- wrote = True
11398
- continue
11399
- out.append(line)
11400
- if not wrote:
11401
- rebuilt: list[str] = []
11402
- inserted = False
11403
- for line in out:
11404
- rebuilt.append(line)
11405
- if not inserted and line.lstrip().startswith("model:"):
11406
- rebuilt.append(f" base_url: {base_url}\n")
11407
- inserted = True
11408
- if not inserted:
11409
- rebuilt.insert(0, f"model:\n base_url: {base_url}\n")
11410
- out = rebuilt
11411
- try:
11412
- config_path.write_text("".join(out), encoding="utf-8")
11353
+ profile_config_yaml.update_model_surface(config_path, base_url=base_url)
11413
11354
  except OSError as exc:
11414
11355
  return False, f"write failed: {exc}"
11415
11356
  return True, ""
@@ -11457,73 +11398,38 @@ def _coalesce_profile_model_yaml(profile: str) -> None:
11457
11398
  model_headers = sum(
11458
11399
  1 for line in lines if line.lstrip().startswith("model:") and not line.startswith((" ", "\t"))
11459
11400
  )
11460
- if model_headers <= 1:
11401
+ needs_coalesce = model_headers > 1
11402
+ if not needs_coalesce:
11403
+ in_model = False
11404
+ for line in lines:
11405
+ stripped = line.lstrip()
11406
+ indent = len(line) - len(stripped)
11407
+ if indent == 0 and stripped.startswith("model:"):
11408
+ in_model = True
11409
+ continue
11410
+ if indent == 0 and stripped:
11411
+ in_model = False
11412
+ if in_model and indent == 2 and stripped.startswith("- "):
11413
+ needs_coalesce = True
11414
+ break
11415
+ if not needs_coalesce:
11461
11416
  return
11462
11417
  block = _read_model_block(profile)
11463
- api_key_line = ""
11464
- for line in lines:
11465
- stripped = line.lstrip()
11466
- if stripped.startswith("api_key:"):
11467
- api_key_line = line if line.endswith("\n") else f"{line}\n"
11468
- out: list[str] = []
11469
- skipping_model = False
11470
- skipping_fallback = False
11471
- for line in lines:
11472
- stripped = line.lstrip()
11473
- indent = len(line) - len(stripped)
11474
- if indent == 0 and stripped.startswith("model:"):
11475
- skipping_model = True
11476
- skipping_fallback = False
11477
- continue
11478
- if indent == 0 and stripped.startswith("fallback_providers:"):
11479
- skipping_fallback = True
11480
- skipping_model = False
11481
- continue
11482
- if skipping_model:
11483
- if indent == 0 and stripped:
11484
- if stripped.startswith("- "):
11485
- continue
11486
- if not stripped.startswith("fallback_providers:"):
11487
- skipping_model = False
11488
- else:
11489
- continue
11490
- else:
11491
- continue
11492
- if skipping_fallback:
11493
- if indent == 0 and stripped and not stripped.startswith("fallback_providers"):
11494
- skipping_fallback = False
11495
- else:
11496
- continue
11497
- out.append(line)
11498
- merged: list[str] = ["model:\n"]
11499
11418
  default = str(block.get("default") or HERMES_DEFAULT_PRIMARY).strip()
11500
- merged.append(f" default: {default}\n")
11501
11419
  provider = str(block.get("provider") or "").strip()
11502
- if provider:
11503
- merged.append(f" provider: {provider}\n")
11504
11420
  base_url = str(block.get("base_url") or "").strip()
11505
- if base_url:
11506
- merged.append(f" base_url: {base_url}\n")
11507
- if api_key_line:
11508
- merged.append(api_key_line if api_key_line.startswith(" ") else f" {api_key_line.lstrip()}")
11421
+ api_key = str(block.get("api_key") or "").strip()
11509
11422
  chain = block.get("fallback_chain") if isinstance(block.get("fallback_chain"), list) else []
11510
- if chain:
11511
- merged.append("fallback_providers:\n")
11512
- for entry in chain:
11513
- prov = str(entry.get("provider", "")).strip()
11514
- model = str(entry.get("model", "")).strip()
11515
- if prov and model:
11516
- merged.append(f" - provider: {prov}\n")
11517
- merged.append(f" model: {model}\n")
11518
- if out and not out[0].startswith("model:"):
11519
- insert_at = 0
11520
- while insert_at < len(out) and (not out[insert_at].strip() or out[insert_at].lstrip().startswith("#")):
11521
- insert_at += 1
11522
- out[insert_at:insert_at] = merged
11523
- else:
11524
- out = merged + out
11525
11423
  try:
11526
- config_path.write_text("".join(out), encoding="utf-8")
11424
+ profile_config_yaml.update_model_surface(
11425
+ config_path,
11426
+ default=default,
11427
+ provider=provider or None,
11428
+ base_url=base_url or None,
11429
+ api_key=api_key or None,
11430
+ fallback_chain=chain,
11431
+ coalesce=True,
11432
+ )
11527
11433
  except OSError:
11528
11434
  return
11529
11435
 
@@ -11665,17 +11571,48 @@ def _read_config_model_api_key(profile: str) -> str:
11665
11571
  return str(_read_model_block(profile).get("api_key") or "").strip()
11666
11572
 
11667
11573
 
11668
- def _restart_runtime_profile_gateway(profile: str) -> None:
11669
- """Hermes api_server caches config.yaml — reload after lease rotation."""
11574
+ def _reload_runtime_profile_gateway(profile: str, *, wait_healthy: bool = True) -> None:
11575
+ """Reload Hermes after config.yaml change gateway restart before stop+start."""
11670
11576
  prof = resolve_hermes_profile(profile)
11671
11577
  if not _is_runtime_profile_slug(prof) or prof == _primary_profile():
11672
11578
  return
11673
11579
  with _gateway_lifecycle_lock:
11580
+ if _profile_api_healthy(prof, timeout=0.5, use_cache=False):
11581
+ code, _out = _gateway_exec(prof, ["gateway", "restart"])
11582
+ if code == 0:
11583
+ _invalidate_profile_health_cache(prof)
11584
+ if wait_healthy:
11585
+ _wait_profile_api_healthy(prof, attempts=48, delay=0.25)
11586
+ return
11587
+ _invalidate_profile_health_cache(prof)
11674
11588
  try:
11675
11589
  profile_gateway_lifecycle(prof, "stop", bootstrap_providers=False)
11676
11590
  except ValueError:
11677
11591
  pass
11678
11592
  profile_gateway_lifecycle(prof, "start", bootstrap_providers=False)
11593
+ if wait_healthy:
11594
+ _wait_profile_api_healthy(prof)
11595
+
11596
+
11597
+ def _schedule_gateway_reload(profile: str) -> None:
11598
+ """ponytail: model-save hot path — yaml is sync; reload async (~7s, not ~45s)."""
11599
+ prof = resolve_hermes_profile(profile)
11600
+ if not _profile_api_healthy(prof, timeout=0.5, use_cache=False):
11601
+ return # cold start on next bind via ensure_profile_api
11602
+
11603
+ def _run() -> None:
11604
+ try:
11605
+ with _gateway_lifecycle_lock:
11606
+ _reload_runtime_profile_gateway(prof, wait_healthy=False)
11607
+ except Exception as exc: # noqa: BLE001
11608
+ print(f"[workframe-api] gateway reload failed for {prof}: {exc}")
11609
+
11610
+ threading.Thread(target=_run, name=f"gw-reload-{prof}", daemon=True).start()
11611
+
11612
+
11613
+ def _restart_runtime_profile_gateway(profile: str) -> None:
11614
+ """Hermes api_server caches config.yaml — reload after lease rotation."""
11615
+ _reload_runtime_profile_gateway(profile, wait_healthy=True)
11679
11616
 
11680
11617
 
11681
11618
  def _sync_profile_model_api_key(profile: str, api_key: str) -> None:
@@ -11709,12 +11646,13 @@ def _apply_turn_credential_lease(
11709
11646
  oauth_spec = _oauth_llm_provider_spec(provider)
11710
11647
  if oauth_spec and str(resolved.get("credential_type") or "") == "oauth":
11711
11648
  _sync_oauth_llm_to_profile(prof, user_id, provider)
11712
- current_model = str(_read_model_from_config(prof)[1] or "").strip()
11713
- if not _resolve_billing_provider_for_model(current_model, {provider}):
11714
- current_model = str(
11715
- (PROVIDER_MVP_MODELS.get(provider) or {}).get("primary") or current_model
11716
- ).strip()
11717
- _apply_model_for_billing_provider(prof, provider, current_model, user_id)
11649
+ if not _profile_routing_matches_billing(prof, provider):
11650
+ current_model = str(_read_model_from_config(prof)[1] or "").strip()
11651
+ if not _resolve_billing_provider_for_model(current_model, {provider}):
11652
+ current_model = str(
11653
+ (PROVIDER_MVP_MODELS.get(provider) or {}).get("primary") or current_model
11654
+ ).strip()
11655
+ _apply_model_for_billing_provider(prof, provider, current_model, user_id)
11718
11656
  return ""
11719
11657
  binding_id = str(
11720
11658
  resolved.get("credential_binding_id") or resolved.get("credential_id") or ""
@@ -12115,10 +12053,13 @@ def profile_gateway_lifecycle(profile: str, action: str, *, bootstrap_providers:
12115
12053
  prof = resolve_hermes_profile(profile)
12116
12054
  if action in {"start", "stop", "disable"}:
12117
12055
  _invalidate_gateway_registered_cache(prof)
12056
+ _invalidate_profile_health_cache(prof)
12118
12057
  if action not in {"start", "stop", "status", "disable"}:
12119
12058
  raise ValueError("invalid action")
12120
12059
  port = _profile_api_port(prof)
12121
12060
  if SECURE_MODE and prof != _primary_profile() and action in {"start", "stop", "disable"}:
12061
+ if action == "start":
12062
+ _normalize_profile_config_yaml(prof)
12122
12063
  return _supervisor_profile_lifecycle(prof, action)
12123
12064
  if prof == _primary_profile():
12124
12065
  if action == "disable":
@@ -12557,14 +12498,18 @@ def _is_transient_profile_api_error(exc: Exception) -> bool:
12557
12498
  return any(token in reason for token in ("connection refused", "timed out", "temporarily unavailable"))
12558
12499
 
12559
12500
 
12560
- def _profile_api_healthy(profile: str, timeout: float = 1.5) -> bool:
12501
+ def _profile_api_healthy(profile: str, timeout: float = 1.5, *, use_cache: bool = True) -> bool:
12561
12502
  try:
12562
12503
  prof = resolve_hermes_profile(profile)
12563
12504
  except ValueError:
12564
12505
  return False
12506
+ wait = max(1.0, float(timeout))
12507
+ if use_cache and wait >= 1.0:
12508
+ cached = _profile_health_cache.get(prof)
12509
+ if cached and time.monotonic() - cached[1] < _PROFILE_HEALTH_TTL_SEC:
12510
+ return cached[0]
12565
12511
  port = _profile_api_port(prof)
12566
12512
  key = _profile_api_key(prof)
12567
- wait = max(1.0, float(timeout))
12568
12513
  url = f"http://gateway:{port}/v1/health"
12569
12514
  req = urllib.request.Request(
12570
12515
  url,
@@ -12573,9 +12518,12 @@ def _profile_api_healthy(profile: str, timeout: float = 1.5) -> bool:
12573
12518
  )
12574
12519
  try:
12575
12520
  with urllib.request.urlopen(req, timeout=wait) as resp:
12576
- return int(resp.status) < 300
12521
+ ok = int(resp.status) < 300
12577
12522
  except Exception: # noqa: BLE001
12578
- return False
12523
+ ok = False
12524
+ if use_cache and wait >= 1.0:
12525
+ _profile_health_cache[prof] = (ok, time.monotonic())
12526
+ return ok
12579
12527
 
12580
12528
 
12581
12529
  def _wait_profile_api_healthy(profile: str, attempts: int = 60, delay: float = 0.5) -> bool:
@@ -12680,11 +12628,17 @@ def _user_llm_providers_for_picker(user_id: str) -> set[str]:
12680
12628
  user = str(user_id or "").strip()
12681
12629
  if not user:
12682
12630
  return set()
12683
- return {
12631
+ now = time.monotonic()
12632
+ cached = _user_llm_picker_cache.get(user)
12633
+ if cached and now - cached[1] < _USER_LLM_PICKER_TTL_SEC:
12634
+ return set(cached[0])
12635
+ connected = frozenset(
12684
12636
  str(spec["id"]).lower()
12685
12637
  for spec in PROVIDER_CONNECT_CATALOG
12686
12638
  if str(spec.get("category") or "") == "llm" and _user_provider_connected(user, spec)
12687
- }
12639
+ )
12640
+ _user_llm_picker_cache[user] = (connected, now)
12641
+ return set(connected)
12688
12642
 
12689
12643
 
12690
12644
  def _user_has_llm_provider(user_id: str) -> bool:
@@ -12747,11 +12701,13 @@ def _overlay_chat_llm_env(
12747
12701
 
12748
12702
 
12749
12703
  def _resolve_models_profile(profile: str) -> str:
12750
- """Template profile slug for model picker reads never per-user runtime u-*."""
12704
+ """Hermes profile slug for model picker reads. Use runtime u-* when it exists on disk."""
12751
12705
  raw = str(profile or "").strip()
12752
12706
  if not raw:
12753
12707
  return _primary_profile()
12754
12708
  slug = safe_profile_slug(raw)
12709
+ if _is_runtime_profile_slug(slug) and _runtime_profile_on_disk(slug):
12710
+ return slug
12755
12711
  if _is_runtime_profile_slug(slug):
12756
12712
  return resolve_validated_profile(_runtime_template_slug(slug))
12757
12713
  return resolve_validated_profile(slug)
@@ -12847,10 +12803,11 @@ def _llm_billing_provider(
12847
12803
  *,
12848
12804
  user_id: str = "",
12849
12805
  workspace_id: str = "",
12806
+ block: dict[str, Any] | None = None,
12850
12807
  ) -> str:
12851
12808
  """Map Hermes routing provider (often custom for proxy) to vault/billing provider id."""
12852
12809
  prof = resolve_hermes_profile(profile)
12853
- block = _read_model_block(prof)
12810
+ block = block if block is not None else _read_model_block(prof)
12854
12811
  cfg_provider = str(block.get("provider") or "").strip().lower()
12855
12812
  billing_cfg = _billing_provider_id_from_hermes_config(cfg_provider)
12856
12813
  user = str(user_id or "").strip()
@@ -12873,6 +12830,28 @@ def _profile_llm_proxy_matches_billing(profile: str, billing_provider: str) -> b
12873
12830
  return bool(base) and base == _llm_proxy_base_url(billing_provider)
12874
12831
 
12875
12832
 
12833
+ def _profile_routing_matches_billing(
12834
+ prof: str,
12835
+ billing: str,
12836
+ *,
12837
+ block: dict[str, Any] | None = None,
12838
+ ) -> bool:
12839
+ """True when config.yaml already routes through the requested billing provider."""
12840
+ billing = str(billing or "").strip().lower()
12841
+ if not billing:
12842
+ return False
12843
+ block = block if block is not None else _read_model_block(prof)
12844
+ model = str(block.get("default") or "").strip()
12845
+ if not model:
12846
+ return False
12847
+ cfg = str(block.get("provider") or "").strip().lower()
12848
+ if _oauth_llm_provider_spec(billing):
12849
+ return cfg == _hermes_config_provider_id(billing).lower()
12850
+ if cfg != "custom":
12851
+ return False
12852
+ return _profile_llm_proxy_matches_billing(prof, billing)
12853
+
12854
+
12876
12855
  def _reconcile_profile_llm_for_user(
12877
12856
  profile: str,
12878
12857
  user_id: str,
@@ -12885,20 +12864,44 @@ def _reconcile_profile_llm_for_user(
12885
12864
  if not user:
12886
12865
  return False
12887
12866
  prof = resolve_hermes_profile(profile)
12888
- billing = _llm_billing_provider(prof, user_id=user, workspace_id=workspace_id)
12867
+ block = _read_model_block(prof)
12868
+ model = str(block.get("default") or "").strip()
12869
+ if model:
12870
+ cfg = str(block.get("provider") or "").strip().lower()
12871
+ quick_billing = _billing_provider_id_from_hermes_config(cfg)
12872
+ # Direct oauth routing only — custom proxy may still need rebilling to user's provider.
12873
+ if quick_billing and cfg != "custom" and _profile_routing_matches_billing(
12874
+ prof, quick_billing, block=block,
12875
+ ):
12876
+ return False
12877
+ connected = _user_llm_providers_for_picker(user)
12878
+ current_model = model
12889
12879
  prefer = str(prefer_provider or "").strip().lower()
12890
- if prefer and _user_can_use_llm(user, workspace_id, prefer):
12880
+ model_billing = (
12881
+ _resolve_billing_provider_for_model(current_model, connected, prefer=prefer)
12882
+ if current_model
12883
+ else ""
12884
+ )
12885
+ if model_billing and _user_can_use_llm(user, workspace_id, model_billing):
12886
+ billing = model_billing
12887
+ elif prefer and _user_can_use_llm(user, workspace_id, prefer):
12891
12888
  billing = prefer
12892
- elif not _user_can_use_llm(user, workspace_id, billing):
12893
- connected = sorted(_user_llm_providers_for_picker(user))
12894
- if not connected:
12895
- return False
12896
- billing = connected[0]
12889
+ else:
12890
+ billing = _llm_billing_provider(prof, user_id=user, workspace_id=workspace_id)
12891
+ if not _user_can_use_llm(user, workspace_id, billing):
12892
+ connected_sorted = sorted(connected)
12893
+ if not connected_sorted:
12894
+ return False
12895
+ billing = connected_sorted[0]
12896
+ if _profile_routing_matches_billing(prof, billing):
12897
+ return False
12897
12898
  if _oauth_llm_provider_spec(billing) and _user_can_use_llm(user, workspace_id, billing):
12898
12899
  current = str(_read_model_from_config(prof)[1] or "").strip()
12899
- if not _resolve_billing_provider_for_model(current, {billing}):
12900
+ if not _resolve_billing_provider_for_model(current, {billing}, prefer=billing):
12900
12901
  current = str((PROVIDER_MVP_MODELS.get(billing) or {}).get("primary") or "")
12901
12902
  _apply_model_for_billing_provider(prof, billing, current, user_id=user)
12903
+ if _is_runtime_profile_slug(prof):
12904
+ _reload_runtime_profile_gateway(prof, wait_healthy=True)
12902
12905
  return True
12903
12906
  if _user_can_use_llm(user, workspace_id, billing) and _profile_llm_proxy_matches_billing(prof, billing):
12904
12907
  return False
@@ -12906,39 +12909,29 @@ def _reconcile_profile_llm_for_user(
12906
12909
  block = _read_model_block(prof)
12907
12910
  if _is_runtime_profile_slug(prof) or str(block.get("provider") or "").strip().lower() == "custom":
12908
12911
  _ensure_profile_llm_proxy(prof, billing)
12912
+ if changed and _is_runtime_profile_slug(prof):
12913
+ _reload_runtime_profile_gateway(prof, wait_healthy=True)
12909
12914
  return changed
12910
12915
 
12911
12916
 
12912
- def _bootstrap_profile_providers(profile: str, user_id: str = "", workspace_id: str = "") -> bool:
12913
- """Seed profile auth + MVP model config from user, workspace, or primary stack."""
12917
+ def _ensure_profile_auth_pool(profile: str, user_id: str = "", workspace_id: str = "") -> bool:
12918
+ """Sync auth.json credential pool does not mutate model yaml (safe on GET /models)."""
12914
12919
  prof = resolve_hermes_profile(profile)
12915
12920
  if prof == _primary_profile():
12916
12921
  return False
12917
-
12918
- changed = False
12919
12922
  if _is_runtime_profile_slug(prof):
12920
- changed = _prepare_runtime_profile_credentials(prof, user_id, workspace_id) or False
12923
+ _strip_profile_llm_env(prof)
12924
+ _strip_profile_action_env(prof)
12921
12925
  else:
12922
- changed = _strip_profile_llm_env(prof) or False
12923
- changed = _strip_profile_action_env(prof) or changed
12924
- changed = _sync_profile_provider_env(prof, user_id, workspace_id) or changed
12925
-
12926
- provider, model_name = _read_model_from_config(prof)
12927
- llm_provider = _llm_billing_provider(prof, provider or "")
12928
- if not str(model_name or "").strip():
12929
- changed = _apply_mvp_model_for_provider(prof, llm_provider) or changed
12930
- if _is_runtime_profile_slug(prof):
12931
- _ensure_profile_llm_proxy(prof, llm_provider)
12932
- elif not str(_read_model_from_config(prof)[0] or "").strip():
12933
- ok_provider, _ = _set_profile_model_provider(prof, llm_provider)
12934
- changed = ok_provider or changed
12935
- if _is_runtime_profile_slug(prof):
12936
- _ensure_profile_llm_proxy(prof, llm_provider)
12926
+ _strip_profile_llm_env(prof)
12927
+ _strip_profile_action_env(prof)
12928
+ _sync_profile_provider_env(prof, user_id, workspace_id)
12937
12929
 
12930
+ llm_provider = _llm_billing_provider(prof, user_id=user_id, workspace_id=workspace_id)
12938
12931
  auth_path = _profile_dir(prof) / "auth.json"
12939
12932
  auth = _load_profile_auth_json(prof)
12940
12933
  if _provider_pool_has_entries(auth, llm_provider):
12941
- return changed
12934
+ return False
12942
12935
 
12943
12936
  user = str(user_id or "").strip()
12944
12937
  llm_spec = _catalog_provider_for_llm(llm_provider)
@@ -12952,7 +12945,7 @@ def _bootstrap_profile_providers(profile: str, user_id: str = "", workspace_id:
12952
12945
 
12953
12946
  if _is_runtime_profile_slug(prof):
12954
12947
  if not user or not _user_can_use_llm(user, workspace_id, llm_provider):
12955
- return changed
12948
+ return False
12956
12949
  env_var = _provider_env_var(llm_provider)
12957
12950
  template = _runtime_provider_pool_entry(llm_provider, env_var)
12958
12951
  else:
@@ -12963,7 +12956,7 @@ def _bootstrap_profile_providers(profile: str, user_id: str = "", workspace_id:
12963
12956
  user_only=_provider_user_only(llm_provider),
12964
12957
  )
12965
12958
  if not resolved:
12966
- return changed
12959
+ return False
12967
12960
 
12968
12961
  ref = str(resolved.get("credential_ref") or "")
12969
12962
  env_var = str(resolved.get("env_var") or "") or (ref[4:] if ref.startswith("env:") else _provider_env_var(llm_provider))
@@ -12983,6 +12976,32 @@ def _bootstrap_profile_providers(profile: str, user_id: str = "", workspace_id:
12983
12976
  return True
12984
12977
 
12985
12978
 
12979
+ def _bootstrap_profile_providers(profile: str, user_id: str = "", workspace_id: str = "") -> bool:
12980
+ """Seed profile auth + MVP model config from user, workspace, or primary stack."""
12981
+ prof = resolve_hermes_profile(profile)
12982
+ if prof == _primary_profile():
12983
+ return False
12984
+
12985
+ changed = _ensure_profile_auth_pool(prof, user_id, workspace_id)
12986
+
12987
+ provider, model_name = _read_model_from_config(prof)
12988
+ llm_provider = _llm_billing_provider(prof, provider or "")
12989
+ if not str(model_name or "").strip():
12990
+ changed = _apply_mvp_model_for_provider(prof, llm_provider) or changed
12991
+ if _is_runtime_profile_slug(prof):
12992
+ _ensure_profile_llm_proxy(prof, llm_provider)
12993
+ elif not str(_read_model_from_config(prof)[0] or "").strip():
12994
+ ok_provider, _ = _set_profile_model_provider(prof, llm_provider)
12995
+ changed = ok_provider or changed
12996
+ if _is_runtime_profile_slug(prof):
12997
+ _ensure_profile_llm_proxy(prof, llm_provider)
12998
+
12999
+ if _is_runtime_profile_slug(prof):
13000
+ _prepare_runtime_profile_credentials(prof, user_id, workspace_id)
13001
+
13002
+ return changed
13003
+
13004
+
12986
13005
  def ensure_profile_api(
12987
13006
  profile: str,
12988
13007
  user_id: str = "",
@@ -12995,6 +13014,20 @@ def ensure_profile_api(
12995
13014
  port = _profile_api_port(prof)
12996
13015
  if _profile_api_healthy(prof):
12997
13016
  return {"ok": True, "profile": prof, "api_port": port, "started": False}
13017
+ # ponytail: gateway restart (~7s) before supervisor cold start (~37s).
13018
+ if (
13019
+ _is_runtime_profile_slug(prof)
13020
+ and prof != _primary_profile()
13021
+ and _runtime_profile_on_disk(prof)
13022
+ ):
13023
+ with _gateway_lifecycle_lock:
13024
+ if _profile_api_healthy(prof, use_cache=False):
13025
+ return {"ok": True, "profile": prof, "api_port": port, "started": False}
13026
+ code, _out = _gateway_exec(prof, ["gateway", "restart"])
13027
+ if code == 0:
13028
+ _invalidate_profile_health_cache(prof)
13029
+ if _wait_profile_api_healthy(prof, attempts=48, delay=0.25):
13030
+ return {"ok": True, "profile": prof, "api_port": port, "started": False}
12998
13031
  if prof == _primary_profile():
12999
13032
  ok, out, _port = _configure_profile_api(prof)
13000
13033
  if not ok:
@@ -13079,19 +13112,20 @@ def profile_chat_session(profile: str, payload: dict[str, Any], user_id: str = "
13079
13112
  hermes_prof = ""
13080
13113
  template_prof = ""
13081
13114
  agent_db_id = ""
13115
+ room_conn: sqlite3.Connection | None = None
13082
13116
 
13083
13117
  if room_id:
13084
13118
  if not user_id:
13085
13119
  raise ValueError("room_id requires authenticated user")
13086
- conn = _workframe_db()
13120
+ room_conn = _workframe_db()
13087
13121
  try:
13088
- room = conn.execute(
13122
+ room = room_conn.execute(
13089
13123
  "SELECT * FROM rooms WHERE id = ? AND deleted_at IS NULL",
13090
13124
  (room_id,),
13091
13125
  ).fetchone()
13092
13126
  if not room:
13093
13127
  raise ValueError("room_not_found")
13094
- if not _user_can_access_room(conn, room_id, user_id):
13128
+ if not _user_can_access_room(room_conn, room_id, user_id):
13095
13129
  raise ValueError("room_access_denied")
13096
13130
  if not workspace_id:
13097
13131
  workspace_id = str(room["workspace_id"])
@@ -13105,30 +13139,36 @@ def profile_chat_session(profile: str, payload: dict[str, Any], user_id: str = "
13105
13139
  hermes_prof = _resolve_chat_hermes_profile(
13106
13140
  template_prof, user_id, room_id, workspace_id,
13107
13141
  )
13108
- agent_row = _lookup_agent_profile(conn, workspace_id, template_prof)
13142
+ agent_row = _lookup_agent_profile(room_conn, workspace_id, template_prof)
13109
13143
  if not agent_row:
13110
13144
  raise ValueError("agent_profile_not_found")
13111
13145
  agent_db_id = str(agent_row["id"])
13112
13146
  else:
13113
13147
  _room, template_prof, hermes_prof, agent_db_id, workspace_id = _resolve_room_agent_chat(
13114
- conn, user_id, room_id,
13148
+ room_conn, user_id, room_id,
13115
13149
  )
13116
- finally:
13117
- conn.close()
13150
+ except Exception:
13151
+ if room_conn is not None:
13152
+ room_conn.close()
13153
+ raise
13118
13154
  else:
13119
13155
  hermes_prof, template_prof = _resolve_bind_profile_arg(
13120
13156
  profile, user_id, "", workspace_id,
13121
13157
  )
13122
13158
  payer = user_id
13123
- if payer:
13124
- _reconcile_profile_llm_for_user(hermes_prof, payer, workspace_id)
13125
- llm_provider = _llm_billing_provider(hermes_prof, user_id=payer, workspace_id=workspace_id)
13126
- lifecycle = ensure_profile_api(hermes_prof, user_id, workspace_id)
13159
+ model_block = _read_model_block(hermes_prof) if hermes_prof else {}
13160
+ llm_provider = _llm_billing_provider(
13161
+ hermes_prof, user_id=payer, workspace_id=workspace_id, block=model_block,
13162
+ )
13127
13163
  llm_ready = _user_can_use_llm(payer, workspace_id, llm_provider) if payer else False
13164
+ # ponytail: bind = session lookup only; gateway + billing reconcile happen on send/model-save.
13165
+ api_port = _profile_api_port(hermes_prof) if hermes_prof else 0
13166
+ lifecycle = {"ok": True, "profile": hermes_prof, "api_port": api_port, "started": False}
13128
13167
  session_extra = {"llm_ready": llm_ready, "has_llm_provider": llm_ready}
13129
13168
 
13130
13169
  if room_id:
13131
- conn = _workframe_db()
13170
+ conn = room_conn if room_conn is not None else _workframe_db()
13171
+ close_conn = room_conn is None
13132
13172
  try:
13133
13173
  existing_row = (
13134
13174
  None
@@ -13137,13 +13177,14 @@ def profile_chat_session(profile: str, payload: dict[str, Any], user_id: str = "
13137
13177
  )
13138
13178
  if existing_row:
13139
13179
  sid = str(existing_row["session_id"]).strip()
13140
- if not sid or not _session_exists(hermes_prof, sid):
13180
+ if not sid:
13141
13181
  existing_row = None
13142
13182
  if existing_row:
13143
13183
  sid = str(existing_row["session_id"]).strip()
13144
13184
  gateway_sid = str(existing_row["gateway_session_id"] or f"api:{hermes_prof}:{sid}").strip()
13145
13185
  session_title = str(existing_row["title"] or _default_session_title(template_prof))
13146
- _ensure_hermes_session_title(hermes_prof, sid, session_title)
13186
+ if _is_blank_session_title(str(existing_row["title"] or "")):
13187
+ _ensure_hermes_session_title(hermes_prof, sid, session_title)
13147
13188
  _upsert_room_session(
13148
13189
  conn,
13149
13190
  room_id=room_id,
@@ -13162,6 +13203,11 @@ def profile_chat_session(profile: str, payload: dict[str, Any], user_id: str = "
13162
13203
  sid,
13163
13204
  gateway_sid,
13164
13205
  )
13206
+ display_title = (
13207
+ session_title
13208
+ if not _is_blank_session_title(session_title)
13209
+ else _resolved_session_title(hermes_prof, sid, session_title)
13210
+ )
13165
13211
  return {
13166
13212
  "ok": True,
13167
13213
  "profile": hermes_prof,
@@ -13169,7 +13215,7 @@ def profile_chat_session(profile: str, payload: dict[str, Any], user_id: str = "
13169
13215
  "workspace_id": workspace_id,
13170
13216
  "room_id": room_id,
13171
13217
  "session_id": sid,
13172
- "title": _resolved_session_title(hermes_prof, sid, session_title),
13218
+ "title": display_title,
13173
13219
  "api_port": lifecycle["api_port"],
13174
13220
  "created": False,
13175
13221
  "resumed": str(existing_row["status"]) != "active",
@@ -13192,7 +13238,10 @@ def profile_chat_session(profile: str, payload: dict[str, Any], user_id: str = "
13192
13238
  )
13193
13239
  conn.commit()
13194
13240
  finally:
13195
- conn.close()
13241
+ if close_conn:
13242
+ conn.close()
13243
+ elif room_conn is not None:
13244
+ room_conn.close()
13196
13245
  _sync_lane_binding(
13197
13246
  hermes_prof,
13198
13247
  source_id,
@@ -13744,6 +13793,51 @@ def _emit_stream_chat_error(
13744
13793
  return
13745
13794
 
13746
13795
 
13796
+ def _open_profile_stream(
13797
+ handler: BaseHTTPRequestHandler,
13798
+ prof: str,
13799
+ *,
13800
+ model_used: str,
13801
+ llm_provider: str,
13802
+ api_port: int,
13803
+ ) -> None:
13804
+ """Flush SSE headers + run.started before blocking on gateway/model."""
13805
+ handler.send_response(200)
13806
+ handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
13807
+ handler.send_header("Cache-Control", "no-cache")
13808
+ handler.send_header("Connection", "keep-alive")
13809
+ handler.send_header("X-Workframe-Profile", prof)
13810
+ handler.send_header("X-Workframe-Api-Port", str(api_port))
13811
+ handler.end_headers()
13812
+ handler.wfile.write(
13813
+ (
13814
+ "event: run.started\n"
13815
+ f"data: {json.dumps({'text': 'Contacting model...', 'model': model_used, 'llm_provider': llm_provider})}\n\n"
13816
+ ).encode("utf-8"),
13817
+ )
13818
+ handler.wfile.flush()
13819
+
13820
+
13821
+ def _emit_stream_error_body(
13822
+ handler: BaseHTTPRequestHandler,
13823
+ *,
13824
+ entry: dict[str, Any] | None = None,
13825
+ message: str = "",
13826
+ ) -> None:
13827
+ """Error/done frames when SSE headers were already sent."""
13828
+ payload = llm_error_glossary.notice_payload(entry) if entry else {}
13829
+ if not payload:
13830
+ text = str(message or "").strip() or "Chat failed."
13831
+ payload = {"error": text, "text": text, "message": text}
13832
+ try:
13833
+ body = json.dumps(payload)
13834
+ handler.wfile.write(f"event: error\ndata: {body}\n\n".encode("utf-8"))
13835
+ handler.wfile.write(b"event: done\ndata: {}\n\n")
13836
+ handler.wfile.flush()
13837
+ except (BrokenPipeError, ConnectionResetError, OSError):
13838
+ return
13839
+
13840
+
13747
13841
  def _emit_stream_concierge(handler: BaseHTTPRequestHandler, entry: dict[str, Any]) -> None:
13748
13842
  """Deterministic assistant reply when LLM path is unavailable."""
13749
13843
  payload = llm_error_glossary.notice_payload(entry)
@@ -13802,10 +13896,10 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
13802
13896
  raise ValueError("session_id required")
13803
13897
  if not text:
13804
13898
  raise ValueError("text required")
13805
- if _triggering_user:
13806
- _reconcile_profile_llm_for_user(prof, _triggering_user, _workspace_id)
13807
- llm_provider = _llm_billing_provider(prof, user_id=_triggering_user, workspace_id=_workspace_id)
13808
13899
  model_block = _read_model_block(prof)
13900
+ llm_provider = _llm_billing_provider(
13901
+ prof, user_id=_triggering_user, workspace_id=_workspace_id, block=model_block,
13902
+ )
13809
13903
  model_used = str(model_block.get("default") or "").strip()
13810
13904
  llm_ready = bool(
13811
13905
  _triggering_user
@@ -13816,8 +13910,16 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
13816
13910
  _log_llm_failure(handler, str(entry.get("code") or "no_llm_provider"), provider=llm_provider, profile=prof)
13817
13911
  _emit_stream_concierge(handler, entry)
13818
13912
  return
13913
+ api_port = _profile_api_port(prof)
13914
+ _open_profile_stream(
13915
+ handler,
13916
+ prof,
13917
+ model_used=model_used,
13918
+ llm_provider=llm_provider,
13919
+ api_port=api_port,
13920
+ )
13819
13921
  try:
13820
- lifecycle = ensure_profile_api(
13922
+ ensure_profile_api(
13821
13923
  prof,
13822
13924
  _triggering_user,
13823
13925
  _workspace_id,
@@ -13833,11 +13935,11 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
13833
13935
  if "no_llm_provider_for_user" in err_text:
13834
13936
  entry = concierge.respond(text, situation="no_provider")
13835
13937
  _log_llm_failure(handler, "no_llm_provider", provider=llm_provider, profile=prof)
13836
- _emit_stream_concierge(handler, entry)
13938
+ _emit_stream_error_body(handler, entry=entry)
13837
13939
  return
13838
13940
  entry = llm_error_glossary.classify_exception_text(err_text)
13839
13941
  _log_llm_failure(handler, str(entry.get("code") or ""), provider=llm_provider, profile=prof)
13840
- _emit_stream_chat_error(handler, entry=entry)
13942
+ _emit_stream_error_body(handler, entry=entry)
13841
13943
  return
13842
13944
  upstream_body = json.dumps(_profile_turn_payload(prof, text, _room_id)).encode("utf-8")
13843
13945
 
@@ -13857,8 +13959,7 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
13857
13959
  except Exception:
13858
13960
  pass
13859
13961
 
13860
- port = _profile_api_port(prof)
13861
- url = f"http://gateway:{port}/api/sessions/{urllib.parse.quote(session_id, safe='')}/chat/stream"
13962
+ url = f"http://gateway:{api_port}/api/sessions/{urllib.parse.quote(session_id, safe='')}/chat/stream"
13862
13963
  headers = {
13863
13964
  "Authorization": f"Bearer {_profile_api_key(prof)}",
13864
13965
  "Content-Type": "application/json",
@@ -13872,25 +13973,15 @@ def stream_profile_chat(handler: BaseHTTPRequestHandler, profile: str, payload:
13872
13973
  data = json.loads(raw)
13873
13974
  except Exception: # noqa: BLE001
13874
13975
  data = {"error": raw or f"upstream stream failed: {exc.code}"}
13875
- raise ValueError(f"session chat stream failed: {data}") from exc
13976
+ entry = llm_error_glossary.classify_exception_text(str(data))
13977
+ _emit_stream_error_body(handler, entry=entry)
13978
+ return
13876
13979
  except OSError as exc:
13877
- raise ValueError(f"session chat stream failed: {exc}") from exc
13980
+ entry = llm_error_glossary.classify_exception_text(str(exc))
13981
+ _emit_stream_error_body(handler, entry=entry)
13982
+ return
13878
13983
 
13879
13984
  try:
13880
- handler.send_response(200)
13881
- handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
13882
- handler.send_header("Cache-Control", "no-cache")
13883
- handler.send_header("Connection", "keep-alive")
13884
- handler.send_header("X-Workframe-Profile", prof)
13885
- handler.send_header("X-Workframe-Api-Port", str(lifecycle["api_port"]))
13886
- handler.end_headers()
13887
- handler.wfile.write(
13888
- (
13889
- "event: run.started\n"
13890
- f"data: {json.dumps({'text': 'Contacting model...', 'model': model_used, 'llm_provider': llm_provider})}\n\n"
13891
- ).encode("utf-8")
13892
- )
13893
- handler.wfile.flush()
13894
13985
  buffer = b""
13895
13986
  saw_complete = False
13896
13987
  complete_text = ""
@@ -14819,7 +14910,7 @@ def _model_catalog_rows_for_provider(provider_id: str) -> list[dict[str, str]]:
14819
14910
  seen.add(mid)
14820
14911
  rows.append(
14821
14912
  {
14822
- "provider": label,
14913
+ "provider": provider_key,
14823
14914
  "billing_provider": provider_key,
14824
14915
  "model": mid,
14825
14916
  "label": row_label,
@@ -14860,21 +14951,105 @@ def _suggestions_for_connected_llm_providers(connected: set[str]) -> list[dict[s
14860
14951
  return out
14861
14952
 
14862
14953
 
14863
- def _resolve_billing_provider_for_model(model_id: str, connected: set[str]) -> str:
14954
+ def _model_id_vendor_and_bare(model_id: str) -> tuple[str, str]:
14955
+ """Split vendor/model ids (e.g. openai-codex/gpt-5.4-mini) into billing + bare model."""
14956
+ mid = str(model_id or "").strip()
14957
+ if "/" not in mid:
14958
+ return "", mid
14959
+ prefix, bare = mid.split("/", 1)
14960
+ billing = _billing_provider_id_from_hermes_config(prefix)
14961
+ return billing, bare.strip()
14962
+
14963
+
14964
+ def _persisted_model_id(model_id: str, billing: str) -> str:
14965
+ billing = str(billing or "").strip().lower()
14966
+ _, bare = _model_id_vendor_and_bare(model_id)
14967
+ if _oauth_llm_provider_spec(billing) and bare:
14968
+ return bare
14969
+ return str(model_id or "").strip()
14970
+
14971
+
14972
+ def _apply_model_persisted(
14973
+ profile: str,
14974
+ billing: str,
14975
+ model_id: str,
14976
+ user_id: str = "",
14977
+ *,
14978
+ restart_gateway: bool = True,
14979
+ ) -> tuple[bool, str]:
14980
+ """Write model surface and verify default landed on disk before returning ok."""
14981
+ prof = resolve_hermes_profile(profile)
14982
+ billing = str(billing or "").strip().lower()
14983
+ if billing:
14984
+ if not _apply_model_for_billing_provider(prof, billing, model_id, user_id=user_id):
14985
+ return False, "model apply failed"
14986
+ else:
14987
+ ok, err = _set_profile_model(prof, model_id)
14988
+ if not ok:
14989
+ return False, err or "model apply failed"
14990
+ expected = _persisted_model_id(model_id, billing)
14991
+ got = str(_parse_model_block_from_disk(prof).get("default") or "").strip()
14992
+ if expected and got != expected:
14993
+ return False, f"model not persisted (expected {expected}, got {got or 'empty'})"
14994
+ if restart_gateway and _is_runtime_profile_slug(prof):
14995
+ _reload_runtime_profile_gateway(prof, wait_healthy=True)
14996
+ return True, ""
14997
+
14998
+
14999
+ def _mirror_template_model_to_runtime(
15000
+ user_id: str,
15001
+ template_slug: str,
15002
+ billing: str,
15003
+ model_id: str,
15004
+ ) -> tuple[bool, str]:
15005
+ """After a template model save, apply the same pick to the user's runtime profile."""
15006
+ user = str(user_id or "").strip()
15007
+ template = safe_profile_slug(template_slug)
15008
+ if not user or _is_runtime_profile_slug(template):
15009
+ return True, ""
15010
+ runtime = _runtime_profile_slug(user, template)
15011
+ if not _runtime_profile_on_disk(runtime):
15012
+ return True, ""
15013
+ return _apply_model_persisted(
15014
+ runtime, billing, model_id, user_id=user, restart_gateway=False,
15015
+ )
15016
+
15017
+
15018
+ def _resolve_billing_provider_for_model(
15019
+ model_id: str,
15020
+ connected: set[str],
15021
+ *,
15022
+ prefer: str = "",
15023
+ ) -> str:
14864
15024
  mid = str(model_id or "").strip()
14865
15025
  if not mid or not connected:
14866
15026
  return ""
15027
+ vendor, bare = _model_id_vendor_and_bare(mid)
15028
+ if vendor and vendor in connected:
15029
+ return vendor
15030
+ match_ids = [mid]
15031
+ if bare and bare not in match_ids:
15032
+ match_ids.append(bare)
15033
+ pref = str(prefer or "").strip().lower()
15034
+ if pref and pref in connected:
15035
+ for row in _model_catalog_rows_for_provider(pref):
15036
+ row_model = str(row.get("model") or "").strip()
15037
+ if row_model in match_ids:
15038
+ return pref
14867
15039
  for provider_id in sorted(connected):
14868
15040
  for row in _model_catalog_rows_for_provider(provider_id):
14869
- if str(row.get("model") or "").strip() == mid:
15041
+ row_model = str(row.get("model") or "").strip()
15042
+ if row_model in match_ids:
14870
15043
  return provider_id
14871
- low = mid.lower()
15044
+ low = (bare or mid).lower()
14872
15045
  if low.startswith("gpt-") and "codex" in connected:
14873
15046
  return "codex"
14874
15047
  if low.startswith("gpt-") and "openai" in connected:
14875
15048
  return "openai"
14876
15049
  if "/" in mid and "openrouter" in connected:
14877
- return "openrouter"
15050
+ prefix = mid.split("/", 1)[0].strip().lower()
15051
+ if not _billing_provider_id_from_hermes_config(prefix):
15052
+ return "openrouter"
14878
15053
  if low.startswith("claude") and "anthropic" in connected:
14879
15054
  return "anthropic"
14880
15055
  if "gemini" in low and "google" in connected:
@@ -14919,6 +15094,9 @@ def _apply_model_for_billing_provider(
14919
15094
  user_id: str = "",
14920
15095
  ) -> bool:
14921
15096
  billing = str(billing or "").strip().lower()
15097
+ _, bare = _model_id_vendor_and_bare(model_id)
15098
+ if _oauth_llm_provider_spec(billing) and bare:
15099
+ model_id = bare
14922
15100
  ok, _ = _set_profile_model(profile, model_id)
14923
15101
  if not ok:
14924
15102
  return False
@@ -14929,7 +15107,9 @@ def _apply_model_for_billing_provider(
14929
15107
  _strip_profile_model_proxy_fields(profile)
14930
15108
  fallbacks = PROVIDER_MVP_MODELS.get(billing, {}).get("fallbacks") or []
14931
15109
  if isinstance(fallbacks, list):
14932
- _write_fallback_chain(profile, fallbacks)
15110
+ chain = _read_model_block(profile).get("fallback_chain") or []
15111
+ if not chain:
15112
+ _write_fallback_chain(profile, fallbacks)
14933
15113
  user = str(user_id or "").strip()
14934
15114
  if user:
14935
15115
  _sync_oauth_llm_to_profile(profile, user, billing)
@@ -15087,50 +15267,14 @@ HERMES_MODEL_CATALOG: list[dict[str, str]] = [
15087
15267
 
15088
15268
 
15089
15269
  def _set_profile_model(profile: str, model_id: str) -> tuple[bool, str]:
15090
- """Update `model.default` in config.yaml. Minimal text rewrite — no pyyaml."""
15270
+ """Update `model.default` in config.yaml via profile_config_yaml."""
15091
15271
  _normalize_profile_config_yaml(profile)
15092
15272
  try:
15093
15273
  config_path = _ensure_gateway_config_file(profile)
15094
15274
  except ValueError as exc:
15095
15275
  return False, str(exc)
15096
15276
  try:
15097
- raw = config_path.read_text(encoding="utf-8")
15098
- except OSError as exc:
15099
- return False, f"read failed: {exc}"
15100
-
15101
- lines = raw.splitlines(keepends=True)
15102
- out: list[str] = []
15103
- in_model_block = False
15104
- wrote_default = False
15105
- for line in lines:
15106
- stripped = line.lstrip()
15107
- if stripped.startswith("model:"):
15108
- if not in_model_block:
15109
- in_model_block = True
15110
- rest = stripped.split(":", 1)[1].strip().strip("'\"")
15111
- if rest:
15112
- out.append("model:\n")
15113
- out.append(f" default: {rest}\n")
15114
- else:
15115
- out.append("model:\n")
15116
- continue
15117
- if in_model_block and stripped and not line.startswith((" ", "\t", "#")):
15118
- in_model_block = False
15119
- if in_model_block and stripped.startswith("default:"):
15120
- indent = line[: len(line) - len(stripped)]
15121
- out.append(f"{indent}default: {model_id}\n")
15122
- wrote_default = True
15123
- continue
15124
- out.append(line)
15125
-
15126
- if not wrote_default:
15127
- # No model block existed. Prepend one.
15128
- if not raw.endswith("\n"):
15129
- out.insert(0, "\n")
15130
- out.insert(0, f"model:\n default: {model_id}\n")
15131
-
15132
- try:
15133
- config_path.write_text("".join(out), encoding="utf-8")
15277
+ profile_config_yaml.update_model_surface(config_path, default=model_id)
15134
15278
  except OSError as exc:
15135
15279
  return False, f"write failed: {exc}"
15136
15280
  return True, ""
@@ -15146,54 +15290,7 @@ def _set_profile_model_provider(profile: str, provider: str) -> tuple[bool, str]
15146
15290
  if not provider:
15147
15291
  return False, "provider required"
15148
15292
  try:
15149
- raw = config_path.read_text(encoding="utf-8")
15150
- except OSError as exc:
15151
- return False, f"read failed: {exc}"
15152
-
15153
- lines = raw.splitlines(keepends=True)
15154
- out: list[str] = []
15155
- in_model_block = False
15156
- wrote_provider = False
15157
- for line in lines:
15158
- stripped = line.lstrip()
15159
- if stripped.startswith("model:"):
15160
- if not in_model_block:
15161
- in_model_block = True
15162
- rest = stripped.split(":", 1)[1].strip().strip("'\"")
15163
- if rest:
15164
- out.append("model:\n")
15165
- out.append(f" default: {rest}\n")
15166
- else:
15167
- out.append("model:\n")
15168
- continue
15169
- if in_model_block and stripped and not line.startswith((" ", "\t", "#")):
15170
- if not wrote_provider:
15171
- out.append(f" provider: {provider}\n")
15172
- wrote_provider = True
15173
- in_model_block = False
15174
- if in_model_block and stripped.startswith("provider:"):
15175
- indent = line[: len(line) - len(stripped)]
15176
- out.append(f"{indent}provider: {provider}\n")
15177
- wrote_provider = True
15178
- continue
15179
- out.append(line)
15180
-
15181
- if not wrote_provider:
15182
- if not raw.endswith("\n") and out:
15183
- out.append("\n")
15184
- inserted = False
15185
- rebuilt: list[str] = []
15186
- for line in out:
15187
- rebuilt.append(line)
15188
- if not inserted and line.lstrip().startswith("model:"):
15189
- rebuilt.append(f" provider: {provider}\n")
15190
- inserted = True
15191
- if not inserted:
15192
- rebuilt.insert(0, f"model:\n provider: {provider}\n")
15193
- out = rebuilt
15194
-
15195
- try:
15196
- config_path.write_text("".join(out), encoding="utf-8")
15293
+ profile_config_yaml.update_model_surface(config_path, provider=provider)
15197
15294
  except OSError as exc:
15198
15295
  return False, f"write failed: {exc}"
15199
15296
  return True, ""
@@ -15284,6 +15381,15 @@ def _parse_model_block_from_disk(profile: str) -> dict[str, Any]:
15284
15381
  in_fallback = False
15285
15382
  continue
15286
15383
 
15384
+ if in_fallback and indent == fallback_indent + 2 and ":" in content and not content.startswith("- "):
15385
+ # ponytail: wizard once wrote flat `model:` keys (no list) — tolerate one entry per line
15386
+ k, _, v = content.partition(":")
15387
+ key = k.strip()
15388
+ val = v.strip().strip('"').strip("'")
15389
+ if key == "model" and val:
15390
+ out["fallback_chain"].append({"model": val})
15391
+ continue
15392
+
15287
15393
  if in_fallback and indent == fallback_indent + 2 and content.startswith("- "):
15288
15394
  # Start of a new fallback entry. First key may be inline.
15289
15395
  inline = content[2:].strip()
@@ -15320,14 +15426,17 @@ def _read_model_block(profile: str) -> dict[str, Any]:
15320
15426
  block = _parse_model_block_from_disk(prof)
15321
15427
  if not _is_runtime_profile_slug(prof):
15322
15428
  return block
15429
+ # ponytail: runtime with model+provider on disk — skip template read on bind/chat hot path.
15430
+ if str(block.get("default") or "").strip() and str(block.get("provider") or "").strip():
15431
+ return block
15323
15432
  template = resolve_validated_profile(_runtime_template_slug(prof))
15324
15433
  tblock = _parse_model_block_from_disk(template)
15325
- if str(tblock.get("default") or "").strip():
15434
+ if not str(block.get("default") or "").strip() and str(tblock.get("default") or "").strip():
15326
15435
  block["default"] = tblock["default"]
15327
- if tblock.get("fallback_chain"):
15436
+ if not block.get("fallback_chain") and tblock.get("fallback_chain"):
15328
15437
  block["fallback_chain"] = list(tblock["fallback_chain"])
15329
15438
  tpl_provider = str(tblock.get("provider") or "").strip()
15330
- if tpl_provider:
15439
+ if not str(block.get("provider") or "").strip() and tpl_provider:
15331
15440
  block["provider"] = tpl_provider
15332
15441
  return block
15333
15442
 
@@ -15340,114 +15449,45 @@ def _sync_runtime_model_from_template(runtime: str, template: str) -> None:
15340
15449
  return
15341
15450
  tblock = _parse_model_block_from_disk(template_slug)
15342
15451
  default = str(tblock.get("default") or "").strip()
15343
- if default:
15344
- _set_profile_model(runtime_slug, default)
15452
+ if not default:
15453
+ return
15454
+ connected: set[str] = set()
15455
+ billing = _billing_provider_id_from_hermes_config(str(tblock.get("provider") or ""))
15456
+ if not billing:
15457
+ billing = _resolve_billing_provider_for_model(default, connected) or ""
15458
+ _, bare = _model_id_vendor_and_bare(default)
15459
+ model_id = bare or default
15460
+ if billing:
15461
+ _apply_model_for_billing_provider(runtime_slug, billing, model_id)
15462
+ return
15463
+ _set_profile_model(runtime_slug, model_id)
15345
15464
  chain = [e for e in (tblock.get("fallback_chain") or []) if isinstance(e, dict)]
15346
15465
  if chain:
15347
15466
  _write_fallback_chain(runtime_slug, chain)
15348
- billing = _billing_provider_id_from_hermes_config(str(tblock.get("provider") or ""))
15349
- if billing and default:
15350
- _apply_model_for_billing_provider(runtime_slug, billing, default)
15351
15467
 
15352
15468
 
15353
15469
  def _write_fallback_chain(profile: str, chain: list[dict[str, str]]) -> tuple[bool, str]:
15354
- """Rewrite the `fallback_providers` block in config.yaml to match the
15355
- supplied chain. Preserves all other keys, comments, and ordering.
15356
- Strips ANY existing `fallback_providers:` blocks first — duplicates
15357
- can occur when a profile was migrated multiple times or when the
15358
- block was hand-edited — then inserts the new block at the natural
15359
- position (right after the `model:` section, before the next
15360
- top-level key).
15361
- """
15470
+ """Rewrite fallback_providers via profile_config_yaml (WF-033)."""
15471
+ _normalize_profile_config_yaml(profile)
15362
15472
  config_path = _profile_gateway_config_path(profile)
15363
15473
  if not config_path or not config_path.is_file():
15364
15474
  try:
15365
15475
  config_path = _ensure_gateway_config_file(profile)
15366
15476
  except ValueError as exc:
15367
15477
  return False, str(exc)
15368
- try:
15369
- raw = config_path.read_text(encoding="utf-8")
15370
- except OSError as exc:
15371
- return False, f"read failed: {exc}"
15372
-
15373
- lines = raw.splitlines(keepends=True)
15374
-
15375
- # Pass 1: drop every line that belongs to a `fallback_providers:`
15376
- # block (anywhere it appears in the file). We also remember whether
15377
- # a `model:` section existed so pass 2 can decide where to insert.
15378
- out: list[str] = []
15379
- in_fallback = False
15380
- saw_model = False
15381
- for line in lines:
15382
- stripped = line.rstrip()
15383
- if not stripped.strip():
15384
- out.append(line)
15385
- continue
15386
- indent = len(stripped) - len(stripped.lstrip())
15387
- content = stripped.lstrip()
15388
- if indent == 0 and content.startswith("model:"):
15389
- saw_model = True
15390
- in_fallback = False
15391
- if indent == 0 and content.startswith("fallback_providers:"):
15392
- in_fallback = True
15393
- continue
15394
- if in_fallback:
15395
- # A line is part of the block if it's indented more than 0
15396
- # (sub-keys), starts with `- ` (list item at the same
15397
- # indent as the key — also indent 0 here), or is otherwise
15398
- # not a fresh top-level key. Top-level (indent 0) non-fb
15399
- # keys end the block.
15400
- if indent > 0:
15401
- continue
15402
- if content.startswith("- "):
15403
- continue
15404
- if not content.startswith("fallback_providers"):
15405
- in_fallback = False
15406
- if indent == 0:
15407
- in_fallback = False
15408
- out.append(line)
15409
-
15410
- # Pass 2: build the new block and find the right insertion point.
15411
- new_block: list[str] = ["fallback_providers:\n"]
15478
+ normalized: list[dict[str, str]] = []
15412
15479
  for entry in chain:
15480
+ if not isinstance(entry, dict):
15481
+ continue
15413
15482
  provider = str(entry.get("provider", "")).strip()
15414
15483
  model = str(entry.get("model", "")).strip()
15415
- if not provider or not model:
15416
- continue
15417
- new_block.append(f" - provider: {provider}\n")
15418
- new_block.append(f" model: {model}\n")
15419
-
15420
- if not saw_model:
15421
- # No model section at all — create a minimal one with the
15422
- # canonical primary plus the fallback chain.
15423
- if out and not out[-1].endswith("\n"):
15424
- out.append("\n")
15425
- out.append("model:\n")
15426
- out.append(f" default: {HERMES_DEFAULT_PRIMARY}\n")
15427
- out.extend(new_block)
15428
- else:
15429
- # Insert just after the last line of the `model:` block, which
15430
- # is the first top-level key that follows it.
15431
- insertion = len(out)
15432
- in_model = False
15433
- for idx, line in enumerate(out):
15434
- stripped = line.rstrip()
15435
- if not stripped.strip():
15436
- continue
15437
- indent = len(stripped) - len(stripped.lstrip())
15438
- content = stripped.lstrip()
15439
- if indent == 0 and content.startswith("model:"):
15440
- in_model = True
15441
- continue
15442
- if indent == 0 and in_model:
15443
- insertion = idx
15444
- break
15445
- out[insertion:insertion] = new_block
15446
-
15484
+ if provider and model:
15485
+ normalized.append({"provider": provider, "model": model})
15447
15486
  try:
15448
- config_path.write_text("".join(out), encoding="utf-8")
15487
+ profile_config_yaml.update_model_surface(config_path, fallback_chain=normalized)
15449
15488
  except OSError as exc:
15450
15489
  return False, f"write failed: {exc}"
15490
+ _normalize_profile_config_yaml(profile)
15451
15491
  return True, ""
15452
15492
 
15453
15493
 
@@ -15513,7 +15553,7 @@ def hermes_models(
15513
15553
  except ValueError as exc:
15514
15554
  return {"ok": False, "error": str(exc)}
15515
15555
  if primary_profile:
15516
- _bootstrap_profile_providers(primary_profile, user_id, workspace_id)
15556
+ _ensure_profile_auth_pool(primary_profile, user_id, workspace_id)
15517
15557
  block = _read_model_block(primary_profile) if primary_profile else {
15518
15558
  "default": "", "provider": "", "base_url": "",
15519
15559
  "providers": {}, "fallback_chain": [],
@@ -15523,10 +15563,18 @@ def hermes_models(
15523
15563
  _suggestions_for_connected_llm_providers(connected_llm) if connected_llm else [],
15524
15564
  block,
15525
15565
  )
15526
- if user_id and "openrouter" in connected_llm:
15566
+ primary_model = str(block.get("default") or "").strip()
15567
+ billing = _billing_provider_id_from_hermes_config(active_provider)
15568
+ if not billing and primary_model:
15569
+ billing = _resolve_billing_provider_for_model(primary_model, connected_llm) or ""
15570
+ _, bare_primary = _model_id_vendor_and_bare(primary_model)
15571
+ if _oauth_llm_provider_spec(billing) and bare_primary:
15572
+ primary_model = bare_primary
15573
+ # ponytail: skip OpenRouter live catalog on Codex/other billing — blocks stdlib server up to 45s
15574
+ if user_id and "openrouter" in connected_llm and billing in ("", "openrouter"):
15527
15575
  resolved = _resolve_credential(user_id, workspace_id, "openrouter", user_only=True)
15528
15576
  secret = _credential_secret(resolved or {}, user_id) if resolved else ""
15529
- live = openrouter_catalog.live_suggestions(secret, limit=30)
15577
+ live = openrouter_catalog.live_suggestions(secret, limit=30, timeout=8)
15530
15578
  if live:
15531
15579
  seen = {str(r.get("model") or "") for r in suggestions}
15532
15580
  suggestions = [
@@ -15543,14 +15591,11 @@ def hermes_models(
15543
15591
  seen_models.add(mid)
15544
15592
  deduped.append(row)
15545
15593
  suggestions = deduped
15546
- primary_model = str(block.get("default") or "").strip()
15547
- billing = _billing_provider_id_from_hermes_config(active_provider)
15548
- if not billing and primary_model:
15549
- billing = _resolve_billing_provider_for_model(primary_model, connected_llm) or ""
15550
- if str(active_provider or "").lower() == "custom" and billing:
15551
- display_provider = billing
15552
- else:
15553
- display_provider = active_provider
15594
+ if not billing and user_id and primary_profile:
15595
+ billing = _llm_billing_provider(
15596
+ primary_profile, user_id=user_id, workspace_id=workspace_id,
15597
+ )
15598
+ display_provider = billing
15554
15599
  return {
15555
15600
  "ok": True,
15556
15601
  "profile": primary_profile,
@@ -15598,6 +15643,7 @@ def hermes_model_set(
15598
15643
  workspace_id: str = "",
15599
15644
  *,
15600
15645
  selection_only: bool = False,
15646
+ billing_provider: str = "",
15601
15647
  ) -> dict[str, Any]:
15602
15648
  """Set primary model and align Hermes provider routing to a connected billing provider."""
15603
15649
  user = str(user_id or "").strip()
@@ -15605,7 +15651,9 @@ def hermes_model_set(
15605
15651
  if not user:
15606
15652
  return {"ok": False, "error": "unauthorized"}
15607
15653
  connected = _user_llm_providers_for_picker(user)
15608
- billing = _resolve_billing_provider_for_model(model_id, connected)
15654
+ billing = str(billing_provider or "").strip().lower()
15655
+ if not billing:
15656
+ billing = _resolve_billing_provider_for_model(model_id, connected)
15609
15657
  if not billing and not _user_llm_has_provider(user, workspace_id):
15610
15658
  return {"ok": False, "error": "connect an LLM provider first"}
15611
15659
  _write_user_llm_prefs(user, primary=model_id)
@@ -15625,28 +15673,46 @@ def hermes_model_set(
15625
15673
  user = str(user_id or "").strip()
15626
15674
  ws = str(workspace_id or "").strip()
15627
15675
  connected = _user_llm_providers_for_picker(user) if user else set()
15628
- billing = _resolve_billing_provider_for_model(model_id, connected)
15676
+ billing = str(billing_provider or "").strip().lower()
15677
+ if not billing:
15678
+ prefer = _llm_billing_provider(target, user_id=user, workspace_id=ws) if target else ""
15679
+ billing = _resolve_billing_provider_for_model(model_id, connected, prefer=prefer)
15629
15680
  if billing:
15630
- if not _apply_model_for_billing_provider(target, billing, model_id, user_id=user):
15631
- return {"ok": False, "error": "model apply failed"}
15681
+ ok, err = _apply_model_persisted(
15682
+ target, billing, model_id, user, restart_gateway=False,
15683
+ )
15684
+ if not ok:
15685
+ return {"ok": False, "error": err}
15632
15686
  else:
15633
- ok, err = _set_profile_model(target, model_id)
15687
+ ok, err = _apply_model_persisted(
15688
+ target, "", model_id, user, restart_gateway=False,
15689
+ )
15634
15690
  if not ok:
15635
15691
  return {"ok": False, "error": err}
15636
15692
  tpl = safe_profile_slug(target)
15637
- if user and not _is_runtime_profile_slug(tpl):
15693
+ ok, err = _mirror_template_model_to_runtime(user, tpl, billing, model_id)
15694
+ if not ok:
15695
+ return {"ok": False, "error": err}
15696
+ reload_prof = ""
15697
+ if _is_runtime_profile_slug(target):
15698
+ reload_prof = target
15699
+ elif user:
15638
15700
  runtime = _runtime_profile_slug(user, tpl)
15639
15701
  if _runtime_profile_on_disk(runtime):
15640
- _sync_runtime_model_from_template(runtime, tpl)
15702
+ reload_prof = runtime
15703
+ if reload_prof:
15704
+ _schedule_gateway_reload(reload_prof)
15641
15705
  block = _read_model_block(target)
15706
+ resolved_billing = billing or _billing_provider_id_from_hermes_config(
15707
+ str(block.get("provider") or "")
15708
+ ) or _llm_billing_provider(target, user_id=user, workspace_id=ws)
15709
+ persisted = _persisted_model_id(model_id, billing or resolved_billing)
15642
15710
  return {
15643
15711
  "ok": True,
15644
15712
  "profile": target,
15645
- "model": model_id,
15646
- "provider": str(block.get("provider") or ""),
15647
- "billing_provider": billing or _billing_provider_id_from_hermes_config(
15648
- str(block.get("provider") or "")
15649
- ),
15713
+ "model": persisted,
15714
+ "provider": resolved_billing,
15715
+ "billing_provider": resolved_billing,
15650
15716
  }
15651
15717
 
15652
15718
 
@@ -15690,7 +15756,10 @@ def hermes_fallback_chain_set(
15690
15756
  if user and not _is_runtime_profile_slug(tpl):
15691
15757
  runtime = _runtime_profile_slug(user, tpl)
15692
15758
  if _runtime_profile_on_disk(runtime):
15693
- _sync_runtime_model_from_template(runtime, tpl)
15759
+ ok, err = _write_fallback_chain(runtime, normalized)
15760
+ if not ok:
15761
+ return {"ok": False, "error": err or "runtime fallback apply failed"}
15762
+ _schedule_gateway_reload(runtime)
15694
15763
  return {"ok": True, "profile": target, "fallback_chain": normalized}
15695
15764
 
15696
15765
 
@@ -16386,53 +16455,913 @@ class Handler(BaseHTTPRequestHandler):
16386
16455
  resolve_secret=_resolve_secret_for_lease,
16387
16456
  )
16388
16457
 
16389
- def do_GET(self) -> None: # noqa: N802
16390
- parsed = urllib.parse.urlparse(self.path)
16391
- path = parsed.path
16392
- qs = urllib.parse.parse_qs(parsed.query)
16458
+ # WF-037 data-read GET handlers (registered in route_registry.ROUTES)
16459
+ def _route_get_meta(self, qs: dict[str, list[str]]) -> None:
16460
+ self._json(200, workframe_meta())
16393
16461
 
16394
- if self._handle_internal_llm_proxy("GET", path):
16462
+ def _route_get_health(self, qs: dict[str, list[str]]) -> None:
16463
+ vault_stat = credential_vault.vault_status()
16464
+ self._json(
16465
+ 200,
16466
+ {
16467
+ "ok": True,
16468
+ "version": VERSION,
16469
+ "mode": "dev_unsafe" if DEV_LOCAL_UNSAFE else "secure",
16470
+ "secure_mode": SECURE_MODE,
16471
+ "deployment_mode": DEPLOYMENT_MODE,
16472
+ "admin_updates_enabled": os.environ.get("WORKFRAME_ENABLE_ADMIN_UPDATES") == "1",
16473
+ "docker_sock_on_api": Path("/var/run/docker.sock").exists(),
16474
+ "proxy_token_configured": internal_proxy_auth.proxy_token_configured(),
16475
+ "vault_sealed": vault_stat.get("sealed"),
16476
+ "vault_envelope": not vault_stat.get("sealed"),
16477
+ "install_window_open": _install_window_open(),
16478
+ "workframe_e2e": os.environ.get("WORKFRAME_E2E") == "1",
16479
+ "dev_local_unsafe": DEV_LOCAL_UNSAFE,
16480
+ },
16481
+ )
16482
+
16483
+ def _route_get_setup_status(self, qs: dict[str, list[str]]) -> None:
16484
+ try:
16485
+ db = _workframe_db()
16486
+ ws = db.execute("SELECT id FROM workspaces WHERE slug='default' AND deleted_at IS NULL").fetchone()
16487
+ if not ws:
16488
+ db.close()
16489
+ self._json(200, {"setup_complete": False})
16490
+ return
16491
+ agents = db.execute(
16492
+ "SELECT COUNT(*) AS c FROM agent_profiles WHERE workspace_id = ? AND deleted_at IS NULL",
16493
+ (ws["id"],),
16494
+ ).fetchone()
16495
+ db.close()
16496
+ self._json(200, {"setup_complete": bool(agents and agents["c"] > 0)})
16497
+ except Exception:
16498
+ self._json(200, {"setup_complete": False})
16499
+
16500
+ def _route_get_install_status(self, qs: dict[str, list[str]]) -> None:
16501
+ if install_api.install_window_open(str(_workframe_db_path())):
16502
+ try:
16503
+ _ensure_native_hermes_profile()
16504
+ except Exception:
16505
+ pass
16506
+ payload = install_api.install_status_payload(
16507
+ DEPLOYMENT_MODE,
16508
+ SECURE_MODE,
16509
+ DEV_LOCAL_UNSAFE,
16510
+ str(_workframe_db_path()),
16511
+ )
16512
+ self._json(200, payload)
16513
+
16514
+ def _route_get_public_site_meta(self, qs: dict[str, list[str]]) -> None:
16515
+ self._json(200, _public_site_meta_payload())
16516
+
16517
+ def _route_get_public_link_preview(self, qs: dict[str, list[str]]) -> None:
16518
+ meta = _public_site_meta_payload()
16519
+ html = site_meta.link_preview_html(meta).encode("utf-8")
16520
+ self._send(200, html, "text/html; charset=utf-8")
16521
+
16522
+ def _route_get_public_manifest(self, qs: dict[str, list[str]]) -> None:
16523
+ meta = _public_site_meta_payload()
16524
+ body = json.dumps(site_meta.manifest_payload(meta), indent=2).encode("utf-8")
16525
+ self._send(200, body, "application/manifest+json; charset=utf-8")
16526
+
16527
+ def _route_get_me_cohort(self, qs: dict[str, list[str]]) -> None:
16528
+ user_id = str(getattr(self, "auth_user", "") or "")
16529
+ if not user_id:
16530
+ self._json(401, {"ok": False, "error": "no_session"})
16395
16531
  return
16396
- if self._handle_internal_action_proxy("GET", path):
16532
+ workspace_id = str(qs.get("workspace_id", [""])[0] or "").strip()
16533
+ if not workspace_id:
16534
+ self._json(400, {"ok": False, "error": "workspace_id_required"})
16397
16535
  return
16536
+ ws_id = _resolve_wid(workspace_id) or workspace_id
16537
+ if not _user_is_workspace_member(user_id, ws_id):
16538
+ self._json(403, {"ok": False, "error": "forbidden"})
16539
+ return
16540
+ cohort = ensure_user_agent_cohort(user_id, ws_id)
16541
+ self._json(
16542
+ 200,
16543
+ {
16544
+ "ok": True,
16545
+ "workspace_id": ws_id,
16546
+ "user_id": user_id,
16547
+ "cohort": cohort,
16548
+ },
16549
+ )
16398
16550
 
16399
- # Auth middleware returns 401 if session required and missing/invalid.
16400
- # In DEV_LOCAL_UNSAFE mode this is a no-op.
16401
- if not _auth_check(self):
16402
- return self._json(401, {"ok": False, "error": "no_session"})
16551
+ def _route_get_user_credentials(self, qs: dict[str, list[str]]) -> None:
16552
+ user_id = str(getattr(self, "auth_user", "") or "")
16553
+ if not user_id:
16554
+ if DEV_LOCAL_UNSAFE:
16555
+ user_id = "dev"
16556
+ else:
16557
+ self._json(401, {"ok": False, "error": "no_authenticated_user"})
16558
+ return
16559
+ try:
16560
+ conn = sqlite3.connect(str(AUTH_DB_PATH.parent / "workframe.db"), timeout=3.0)
16561
+ conn.row_factory = sqlite3.Row
16562
+ creds = conn.execute(
16563
+ "SELECT id, workspace_id, user_id, agent_profile_id, provider, credential_type, label, is_active, created_at, updated_at FROM credential_bindings WHERE user_id = ? AND deleted_at IS NULL ORDER BY created_at DESC",
16564
+ (user_id,),
16565
+ ).fetchall()
16566
+ conn.close()
16567
+ except Exception:
16568
+ creds = []
16569
+ self._json(200, {"ok": True, "credentials": [dict(c) for c in creds]})
16403
16570
 
16404
- if SECURE_MODE:
16405
- if not _secure_host_origin_ok(self.command, path, self.headers):
16406
- if not _validate_host(self.headers):
16407
- return self._json(403, {"error": "invalid host"})
16408
- return self._json(403, {"error": "invalid origin"})
16571
+ def _route_get_agents(self, qs: dict[str, list[str]]) -> None:
16572
+ self._json(200, workframe_agents())
16409
16573
 
16410
- try:
16411
- # Serve static files and UI
16412
- if path in ("", "/") or not path.startswith("/api/") and not path.startswith("/assets/") and not path.startswith("/static/"):
16413
- return self._file_public("index.html")
16414
- if path.startswith("/assets/"):
16415
- return self._file_public(path.lstrip("/"))
16416
- if path == "/api/health":
16417
- vault_stat = credential_vault.vault_status()
16418
- return self._json(
16419
- 200,
16420
- {
16421
- "ok": True,
16422
- "version": VERSION,
16423
- "mode": "dev_unsafe" if DEV_LOCAL_UNSAFE else "secure",
16424
- "secure_mode": SECURE_MODE,
16425
- "deployment_mode": DEPLOYMENT_MODE,
16426
- "admin_updates_enabled": os.environ.get("WORKFRAME_ENABLE_ADMIN_UPDATES") == "1",
16427
- "docker_sock_on_api": Path("/var/run/docker.sock").exists(),
16428
- "proxy_token_configured": internal_proxy_auth.proxy_token_configured(),
16429
- "vault_sealed": vault_stat.get("sealed"),
16430
- "vault_envelope": not vault_stat.get("sealed"),
16431
- "install_window_open": _install_window_open(),
16432
- "workframe_e2e": os.environ.get("WORKFRAME_E2E") == "1",
16433
- "dev_local_unsafe": DEV_LOCAL_UNSAFE,
16434
- },
16574
+ def _route_get_snapshot(self, qs: dict[str, list[str]]) -> None:
16575
+ self._json(200, build_snapshot())
16576
+
16577
+ def _route_get_activity_detail(self, qs: dict[str, list[str]]) -> None:
16578
+ profile = qs.get("profile", [""])[0].strip()
16579
+ tool_call_id = qs.get("tool_call_id", [""])[0].strip()
16580
+ session_id = qs.get("session_id", [""])[0].strip()
16581
+ message_id = qs.get("message_id", [""])[0].strip()
16582
+ self._json(200, activity_detail(profile, tool_call_id, session_id, message_id))
16583
+
16584
+ def _route_get_board(self, qs: dict[str, list[str]]) -> None:
16585
+ self._json(200, board_list())
16586
+
16587
+ def _route_get_content(self, qs: dict[str, list[str]]) -> None:
16588
+ self._json(200, content_list())
16589
+
16590
+ def _route_get_content_get(self, qs: dict[str, list[str]]) -> None:
16591
+ rel = qs.get("path", [""])[0]
16592
+ reason = _workspace_protected_reason(rel)
16593
+ if reason:
16594
+ self._json(403, {"ok": False, "error": "protected_file", "reason": reason})
16595
+ return
16596
+ fp = _safe_content_path(rel)
16597
+ if not fp or not fp.is_file():
16598
+ self._json(404, {"error": "not found"})
16599
+ return
16600
+ text = fp.read_text(encoding="utf-8", errors="replace")
16601
+ self._json(200, {"path": rel, "content": text, "text": text})
16602
+
16603
+ def _route_get_files_tree(self, qs: dict[str, list[str]]) -> None:
16604
+ self._json(200, {"root": files_tree()})
16605
+
16606
+ def _route_get_files_list(self, qs: dict[str, list[str]]) -> None:
16607
+ rel = qs.get("path", [""])[0]
16608
+ reason = _workspace_protected_reason(rel)
16609
+ if reason:
16610
+ self._json(403, {"ok": False, "error": "protected_file", "reason": reason})
16611
+ return
16612
+ self._json(200, files_list(rel))
16613
+
16614
+ def _route_get_files_state(self, qs: dict[str, list[str]]) -> None:
16615
+ self._json(200, workspace_state())
16616
+
16617
+ def _route_get_files_read(self, qs: dict[str, list[str]]) -> None:
16618
+ rel = qs.get("path", [""])[0]
16619
+ reason = _workspace_protected_reason(rel)
16620
+ if reason:
16621
+ self._json(403, {"ok": False, "error": "protected_file", "reason": reason})
16622
+ return
16623
+ self._json(200, file_read(rel))
16624
+
16625
+ def _route_get_files_raw(self, qs: dict[str, list[str]]) -> None:
16626
+ rel = qs.get("path", [""])[0]
16627
+ reason = _workspace_protected_reason(rel)
16628
+ if reason:
16629
+ self._json(403, {"ok": False, "error": "protected_file", "reason": reason})
16630
+ return
16631
+ try:
16632
+ data, mime = file_raw(rel)
16633
+ except ValueError:
16634
+ self._json(404, {"error": "not found"})
16635
+ return
16636
+ self._send(200, data, mime)
16637
+
16638
+ def _route_get_routes(self, qs: dict[str, list[str]]) -> None:
16639
+ self._json(200, load_routes())
16640
+
16641
+ def _route_get_chat_messages(self, qs: dict[str, list[str]]) -> None:
16642
+ profile = resolve_hermes_profile(qs.get("profile", [""])[0] or _primary_profile())
16643
+ session = qs.get("session", [""])[0]
16644
+ source_id = qs.get("source", ["ui"])[0]
16645
+ self._json(200, chat_messages(profile, session, source_id))
16646
+
16647
+ def _route_get_chat_session(self, qs: dict[str, list[str]]) -> None:
16648
+ profile = resolve_hermes_profile(qs.get("profile", [""])[0] or _primary_profile())
16649
+ session = qs.get("session", [""])[0]
16650
+ source_id = qs.get("source", ["ui"])[0]
16651
+ self._json(200, chat_session(profile, session, source_id))
16652
+
16653
+ def _route_get_chat_resolve(self, qs: dict[str, list[str]]) -> None:
16654
+ profile = resolve_validated_profile(qs.get("profile", [""])[0] or _primary_profile())
16655
+ source_id = qs.get("source", ["ui"])[0]
16656
+ client_id = qs.get("client", ["default"])[0]
16657
+ self._json(
16658
+ 200,
16659
+ chat_resolve({"profile": profile, "source_id": source_id, "client_id": client_id}),
16660
+ )
16661
+
16662
+ def _route_get_hermes_skills(self, qs: dict[str, list[str]]) -> None:
16663
+ self._json(200, {"skills": hermes_skills()})
16664
+
16665
+ def _route_get_hermes_commands(self, qs: dict[str, list[str]]) -> None:
16666
+ self._json(200, hermes_commands_catalog())
16667
+
16668
+ def _route_get_hermes_usage(self, qs: dict[str, list[str]]) -> None:
16669
+ self._json(200, hermes_usage())
16670
+
16671
+ def _route_get_hermes_profile(self, qs: dict[str, list[str]]) -> None:
16672
+ self._json(200, hermes_profile())
16673
+
16674
+ def _route_get_me(self, qs: dict[str, list[str]]) -> None:
16675
+ user_id = str(getattr(self, "auth_user", "") or "")
16676
+ if not user_id:
16677
+ self._json(401, {"ok": False, "error": "no_session"})
16678
+ return
16679
+ payload = _session_profile_payload(user_id)
16680
+ if not payload:
16681
+ self._json(404, {"ok": False, "error": "user_not_found"})
16682
+ return
16683
+ self._json(200, payload)
16684
+
16685
+ def _route_get_me_onboarding(self, qs: dict[str, list[str]]) -> None:
16686
+ user_id = str(getattr(self, "auth_user", "") or "")
16687
+ if not user_id:
16688
+ self._json(401, {"ok": False, "error": "no_session"})
16689
+ return
16690
+ self._json(200, _onboarding_payload(user_id))
16691
+
16692
+ def _route_get_me_credentials(self, qs: dict[str, list[str]]) -> None:
16693
+ user_id = str(getattr(self, "auth_user", "") or "")
16694
+ if not user_id:
16695
+ self._json(401, {"ok": False, "error": "no_session"})
16696
+ return
16697
+ try:
16698
+ conn = sqlite3.connect(str(_workframe_db_path()), timeout=3.0)
16699
+ conn.row_factory = sqlite3.Row
16700
+ rows = conn.execute(
16701
+ """SELECT id, workspace_id, user_id, agent_profile_id, provider, credential_type,
16702
+ credential_ref, label, is_active, created_at, updated_at
16703
+ FROM credential_bindings
16704
+ WHERE user_id = ? AND deleted_at IS NULL
16705
+ ORDER BY updated_at DESC, created_at DESC""",
16706
+ (user_id,),
16707
+ ).fetchall()
16708
+ conn.close()
16709
+ except sqlite3.Error as exc:
16710
+ self._json(500, {"ok": False, "error": f"db_error: {exc}"})
16711
+ return
16712
+ self._json(200, {"ok": True, "credentials": [dict(row) for row in rows]})
16713
+
16714
+ def _route_get_me_providers(self, qs: dict[str, list[str]]) -> None:
16715
+ user_id = str(getattr(self, "auth_user", "") or "")
16716
+ if not user_id:
16717
+ self._json(401, {"ok": False, "error": "no_session"})
16718
+ return
16719
+ workspace_id = qs.get("workspace_id", [""])[0]
16720
+ self._json(200, list_user_providers(user_id, workspace_id))
16721
+
16722
+ def _route_get_hermes_models(self, qs: dict[str, list[str]]) -> None:
16723
+ profile = qs.get("profile", [""])[0]
16724
+ user_id = str(getattr(self, "auth_user", "") or "")
16725
+ workspace_id = qs.get("workspace_id", [""])[0]
16726
+ selection_only = qs.get("selection_only", ["0"])[0] in {"1", "true", "yes"}
16727
+ payload = hermes_models(profile, user_id, workspace_id, selection_only=selection_only)
16728
+ status = 200 if payload.get("ok", True) else 400
16729
+ self._json(status, payload)
16730
+
16731
+ def _route_get_hermes_debug(self, qs: dict[str, list[str]]) -> None:
16732
+ self._json(200, hermes_debug())
16733
+
16734
+ def _route_get_hermes_insights(self, qs: dict[str, list[str]]) -> None:
16735
+ self._json(200, hermes_insights())
16736
+
16737
+ def _route_get_hermes_gquota(self, qs: dict[str, list[str]]) -> None:
16738
+ self._json(200, hermes_gquota())
16739
+
16740
+ # WF-037 auth-flow + hermes/chat + me credentials POST handlers
16741
+ def _route_post_auth_google_start(self, body: dict) -> None:
16742
+ invite_email = str(body.get("email") or "").strip().lower()
16743
+ invite_token = str(body.get("invite_token") or "")
16744
+ if invite_email:
16745
+ allowed, deny_meta = _email_allowed_to_authenticate(invite_email)
16746
+ if not allowed and not _invite_token_allows_email(invite_token, invite_email):
16747
+ self._log_audit(
16748
+ "login_denied_private",
16749
+ "user",
16750
+ invite_email,
16751
+ str(deny_meta.get("error") or ""),
16435
16752
  )
16753
+ self._json(403, {"ok": False, **deny_meta})
16754
+ return
16755
+ try:
16756
+ payload = google_auth.start_google_auth(invite_email, invite_token)
16757
+ self._json(200, payload)
16758
+ except ValueError as exc:
16759
+ self._json(400, {"ok": False, "error": str(exc)})
16760
+
16761
+ def _route_post_auth_local_bootstrap(self, body: dict) -> None:
16762
+ if DEPLOYMENT_MODE != "single_user_local" or not _install_window_open():
16763
+ self._json(403, {"ok": False, "error": "local_bootstrap_unavailable"})
16764
+ return
16765
+ display = str(body.get("display_name") or "Owner")
16766
+ email = "owner@local.workframe"
16767
+ try:
16768
+ result = _zk.create_session_for_email(email)
16769
+ except Exception as exc:
16770
+ self._json(500, {"ok": False, "error": str(exc)})
16771
+ return
16772
+ user_id = str(result["user_id"])
16773
+ self.auth_user = user_id
16774
+ self._ensure_user(user_id, display, email)
16775
+ self._first_owner_bootstrap(user_id, display, email)
16776
+ use_secure = _session_cookie_secure()
16777
+ cookie_val = _zk.session_cookie_value(result["session_id"], secure=use_secure)
16778
+ me_payload = _session_profile_payload(user_id)
16779
+ self._json(
16780
+ 200,
16781
+ {
16782
+ "ok": True,
16783
+ "user_id": user_id,
16784
+ "session_id": result["session_id"],
16785
+ "refresh_token": result["refresh_token"],
16786
+ **(me_payload or {}),
16787
+ },
16788
+ extra_headers=[("Set-Cookie", cookie_val)],
16789
+ )
16790
+
16791
+ def _route_post_auth_bootstrap(self, body: dict) -> None:
16792
+ if not SECURE_MODE:
16793
+ self._json(501, {"ok": False, "error": "bootstrap_requires_secure_mode"})
16794
+ return
16795
+ data = body if isinstance(body, dict) else {}
16796
+ user_id = str(data.get("user_id", "") or secrets.token_hex(8))
16797
+ user_email = str(data.get("email", ""))
16798
+ user_display = str(data.get("display_name", "") or "Owner")
16799
+ result = self._first_owner_bootstrap(user_id, user_display, user_email)
16800
+ self._log_audit("user_created", "user", user_id, f"first-owner bootstrap: {user_display}")
16801
+ self._json(200, result)
16802
+
16803
+ def _route_post_setup(self, body: dict) -> None:
16804
+ data = body if isinstance(body, dict) else {}
16805
+ agent_name = str(data.get("agent_name", "")).strip()
16806
+ workframe_name = str(data.get("workframe_name", "")).strip()
16807
+ if not agent_name:
16808
+ self._json(400, {"ok": False, "error": "agent_name required"})
16809
+ return
16810
+ try:
16811
+ db = _workframe_db()
16812
+ ws = db.execute("SELECT * FROM workspaces WHERE slug='default' AND deleted_at IS NULL").fetchone()
16813
+ if not ws:
16814
+ ws_id = str(uuid.uuid4())
16815
+ now = str(int(time.time()))
16816
+ workspace_display_name = workframe_name or "Default Workspace"
16817
+ ws_settings = stack_config.github_oauth_for_workspace_settings({})
16818
+ settings_json = json.dumps(ws_settings, sort_keys=True) if ws_settings else "{}"
16819
+ db.execute(
16820
+ "INSERT INTO workspaces (id, slug, display_name, description, owner_id, status, created_at, updated_at, settings_json) VALUES (?, 'default', ?, '', '', 'active', ?, ?, ?)",
16821
+ (ws_id, workspace_display_name, now, now, settings_json),
16822
+ )
16823
+ ws = db.execute("SELECT * FROM workspaces WHERE id = ?", (ws_id,)).fetchone()
16824
+ elif workframe_name and str(ws["display_name"] or "").strip() != workframe_name:
16825
+ now = str(int(time.time()))
16826
+ db.execute(
16827
+ "UPDATE workspaces SET display_name = ?, updated_at = ? WHERE id = ?",
16828
+ (workframe_name, now, ws["id"]),
16829
+ )
16830
+ ws = db.execute("SELECT * FROM workspaces WHERE id = ?", (ws["id"],)).fetchone()
16831
+ ws_id = ws["id"]
16832
+ existing_agents = db.execute("SELECT COUNT(*) AS c FROM agent_profiles WHERE workspace_id = ? AND deleted_at IS NULL", (ws_id,)).fetchone()
16833
+ if existing_agents and existing_agents["c"] > 0:
16834
+ if agent_name:
16835
+ now = str(int(time.time()))
16836
+ db.execute(
16837
+ """
16838
+ UPDATE agent_profiles
16839
+ SET display_name = ?, updated_at = ?
16840
+ WHERE workspace_id = ? AND is_native = 1 AND deleted_at IS NULL
16841
+ """,
16842
+ (agent_name, now, ws_id),
16843
+ )
16844
+ db.commit()
16845
+ db.close()
16846
+ self._json(200, {"ok": True, "already_initialized": True, "workspace_id": ws_id})
16847
+ return
16848
+ import re as _re
16849
+ agent_slug = _re.sub(r"[^a-z0-9-]", "", agent_name.lower().replace(" ", "-"))[:40]
16850
+ agent_id = str(uuid.uuid4())
16851
+ now = str(int(time.time()))
16852
+ db.execute(
16853
+ "INSERT INTO agent_profiles (id, workspace_id, slug, display_name, tagline, role, is_native, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 1, 'active', ?, ?)",
16854
+ (agent_id, ws_id, agent_slug, agent_name, str(data.get("agent_personality", "")), "native", now, now),
16855
+ )
16856
+ room_id = str(uuid.uuid4())
16857
+ db.execute(
16858
+ "INSERT INTO rooms (id, workspace_id, name, slug, topic, room_type, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'channel', 'active', ?, ?)",
16859
+ (room_id, ws_id, "General", "general", "General discussion", now, now),
16860
+ )
16861
+ db.commit()
16862
+ db.close()
16863
+ hermes = _bootstrap_after_setup(str(data.get("agent_personality", "")))
16864
+ self._json(
16865
+ 200,
16866
+ {
16867
+ "ok": True,
16868
+ "workspace_id": ws_id,
16869
+ "agent_id": agent_id,
16870
+ "room_id": room_id,
16871
+ "hermes": hermes,
16872
+ },
16873
+ )
16874
+ except Exception as exc:
16875
+ self._json(500, {"ok": False, "error": str(exc)})
16876
+
16877
+ def _route_post_auth_start(self, body: dict) -> None:
16878
+ email = str(body.get("email", "")).strip().lower()
16879
+ if not email or "@" not in email:
16880
+ self._json(400, {"ok": False, "error": "email required"})
16881
+ return
16882
+ client_ip = str(self.client_address[0] if self.client_address else "")
16883
+ import auth_rate_limit
16884
+
16885
+ if not auth_rate_limit.allow_auth_request("start", client_ip, email):
16886
+ self._json(429, {"ok": False, "error": "too_many_requests"})
16887
+ return
16888
+ allowed, deny_meta = _email_allowed_to_authenticate(email)
16889
+ if not allowed:
16890
+ self._log_audit("login_denied_private", "user", email, str(deny_meta.get("error") or ""))
16891
+ self._json(403, {"ok": False, **deny_meta})
16892
+ return
16893
+ try:
16894
+ base = APP_BASE_URL.strip().lower()
16895
+ loopback = base.startswith("http://127.0.0.1") or base.startswith("http://localhost")
16896
+ expose_otp = (
16897
+ os.environ.get("WORKFRAME_E2E") == "1"
16898
+ and _install_window_open()
16899
+ and loopback
16900
+ )
16901
+ result = _zk.start_email_verification(
16902
+ email,
16903
+ dev_local_unsafe=DEV_LOCAL_UNSAFE,
16904
+ expose_otp=expose_otp,
16905
+ )
16906
+ except Exception as exc:
16907
+ self._json(500, {"ok": False, "error": str(exc)})
16908
+ return
16909
+ if not DEV_LOCAL_UNSAFE and not expose_otp:
16910
+ result.pop("otp_code", None)
16911
+ result.pop("_dev_warning", None)
16912
+ result.pop("_e2e_warning", None)
16913
+ self._log_audit("otp_requested", "user", email, f"challenge={result.get('challenge_id','')}")
16914
+ self._json(200, {"ok": True, "status": "verification_sent", **result})
16915
+
16916
+ def _route_post_auth_verify(self, body: dict) -> None:
16917
+ email = str(body.get("email", "")).strip().lower()
16918
+ code = str(body.get("code", "")).strip()
16919
+ if not email or not code:
16920
+ self._json(400, {"ok": False, "error": "email and code required"})
16921
+ return
16922
+ client_ip = str(self.client_address[0] if self.client_address else "")
16923
+ import auth_rate_limit
16924
+
16925
+ if not auth_rate_limit.allow_auth_request("verify", client_ip, email):
16926
+ self._json(429, {"ok": False, "error": "too_many_requests"})
16927
+ return
16928
+ allowed, deny_meta = _email_allowed_to_authenticate(email)
16929
+ if not allowed:
16930
+ self._log_audit("login_denied_private", "user", email, str(deny_meta.get("error") or ""))
16931
+ self._json(403, {"ok": False, **deny_meta})
16932
+ return
16933
+ try:
16934
+ result = _zk.verify_email_code(email, code)
16935
+ except ValueError as exc:
16936
+ self._log_audit("login_failed", "user", email, f"verify failed: {exc}")
16937
+ self._json(401, {"ok": False, "error": str(exc)})
16938
+ return
16939
+ except Exception as exc:
16940
+ self._json(500, {"ok": False, "error": str(exc)})
16941
+ return
16942
+ use_secure = _session_cookie_secure()
16943
+ cookie_val = _zk.session_cookie_value(result["session_id"], secure=use_secure)
16944
+ self.auth_user = result["user_id"]
16945
+ self._ensure_user(result["user_id"], email, email)
16946
+ self._log_audit("login", "session", result["session_id"],
16947
+ f"user={result['user_id']} new={result['is_new_user']}")
16948
+ me_payload = _session_profile_payload(result["user_id"])
16949
+ self._json(
16950
+ 200,
16951
+ {
16952
+ "ok": True,
16953
+ "user_id": result["user_id"],
16954
+ "session_id": result["session_id"],
16955
+ "refresh_token": result["refresh_token"],
16956
+ "expires_at": result["expires_at"],
16957
+ "is_new_user": result["is_new_user"],
16958
+ **(me_payload or {}),
16959
+ },
16960
+ extra_headers=[("Set-Cookie", cookie_val)],
16961
+ )
16962
+
16963
+ def _route_post_auth_logout(self, body: dict) -> None:
16964
+ sid = _session_id_from_request(self)
16965
+ user_id = str(getattr(self, "auth_user", "") or "")
16966
+ if sid:
16967
+ try:
16968
+ _zk.logout_session(sid)
16969
+ except Exception:
16970
+ pass
16971
+ use_secure = _session_cookie_secure()
16972
+ cookie_val = _zk.clear_session_cookie(secure=use_secure)
16973
+ self._log_audit("logout", "session", sid, f"user={user_id}")
16974
+ self._json(200, {"ok": True}, extra_headers=[("Set-Cookie", cookie_val)])
16975
+
16976
+ def _route_post_auth_refresh(self, body: dict) -> None:
16977
+ refresh_token = str(body.get("refresh_token", "")).strip()
16978
+ if not refresh_token:
16979
+ sid = _session_id_from_request(self)
16980
+ if not sid:
16981
+ self._json(400, {"ok": False, "error": "refresh_token required"})
16982
+ return
16983
+ validated = _zk.validate_session_token(sid)
16984
+ if not validated:
16985
+ self._json(401, {"ok": False, "error": "session_expired"})
16986
+ return
16987
+ use_secure = _session_cookie_secure()
16988
+ cookie_val = _zk.session_cookie_value(validated["session_id"], secure=use_secure)
16989
+ self._json(
16990
+ 200,
16991
+ {
16992
+ "ok": True,
16993
+ "user_id": validated["user_id"],
16994
+ "session_id": validated["session_id"],
16995
+ "expires_at": validated["expires_at"],
16996
+ },
16997
+ extra_headers=[("Set-Cookie", cookie_val)],
16998
+ )
16999
+ return
17000
+ try:
17001
+ result = _zk.refresh_session(refresh_token)
17002
+ except ValueError as exc:
17003
+ self._json(401, {"ok": False, "error": str(exc)})
17004
+ return
17005
+ except Exception as exc:
17006
+ self._json(500, {"ok": False, "error": str(exc)})
17007
+ return
17008
+ use_secure = _session_cookie_secure()
17009
+ cookie_val = _zk.session_cookie_value(result["session_id"], secure=use_secure)
17010
+ self._log_audit("session_refreshed", "session", result["session_id"],
17011
+ f"user={result['user_id']}")
17012
+ self._json(
17013
+ 200,
17014
+ {
17015
+ "ok": True,
17016
+ "user_id": result["user_id"],
17017
+ "session_id": result["session_id"],
17018
+ "refresh_token": result["refresh_token"],
17019
+ "expires_at": result["expires_at"],
17020
+ },
17021
+ extra_headers=[("Set-Cookie", cookie_val)],
17022
+ )
17023
+
17024
+ def _route_post_me_credentials(self, body: dict) -> None:
17025
+ user_id = str(getattr(self, "auth_user", "") or "")
17026
+ if not user_id:
17027
+ self._json(401, {"ok": False, "error": "no_session"})
17028
+ return
17029
+ provider = str(body.get("provider", "")).strip()
17030
+ credential_type = str(body.get("credential_type", "api_key")).strip() or "api_key"
17031
+ secret = str(body.get("secret", "")).strip()
17032
+ spec = _catalog_provider(provider)
17033
+ if spec:
17034
+ credential_type = str(spec.get("connect_mode") or credential_type)
17035
+ env_var = str(body.get("env_var", "")).strip() or (
17036
+ str(spec.get("env_var") or "") if spec else ""
17037
+ ) or _default_credential_env_var(provider, credential_type)
17038
+ label = str(body.get("label", "")).strip() or env_var
17039
+ if not provider:
17040
+ self._json(400, {"ok": False, "error": "provider required"})
17041
+ return
17042
+ if not secret:
17043
+ self._json(400, {"ok": False, "error": "secret required"})
17044
+ return
17045
+ payload = _store_user_credential(user_id, provider, credential_type, secret, env_var, label)
17046
+ cred_ref = str(payload["credential_ref"])
17047
+ now = _utc_now()
17048
+ try:
17049
+ conn = sqlite3.connect(str(_workframe_db_path()), timeout=3.0)
17050
+ conn.row_factory = sqlite3.Row
17051
+ existing = conn.execute(
17052
+ """SELECT id FROM credential_bindings
17053
+ WHERE user_id = ? AND provider = ? AND credential_type = ?
17054
+ AND credential_ref = ? AND deleted_at IS NULL
17055
+ ORDER BY updated_at DESC, created_at DESC LIMIT 1""",
17056
+ (user_id, provider, credential_type, cred_ref),
17057
+ ).fetchone()
17058
+ if existing:
17059
+ cred_id = str(existing["id"])
17060
+ conn.execute(
17061
+ """UPDATE credential_bindings
17062
+ SET label = ?, is_active = 1, updated_at = ?, deleted_at = NULL
17063
+ WHERE id = ?""",
17064
+ (label, now, cred_id),
17065
+ )
17066
+ else:
17067
+ cred_id = str(payload["credential_id"])
17068
+ conn.execute(
17069
+ """INSERT INTO credential_bindings
17070
+ (id, workspace_id, user_id, agent_profile_id, provider, credential_type,
17071
+ credential_ref, label, is_active, created_by, created_at, updated_at)
17072
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
17073
+ (cred_id, None, user_id, None, provider, credential_type, cred_ref, label, 1, user_id, now, now),
17074
+ )
17075
+ conn.execute(
17076
+ """UPDATE credential_bindings
17077
+ SET is_active = 0, deleted_at = ?, updated_at = ?
17078
+ WHERE user_id = ? AND provider = ? AND deleted_at IS NULL AND id != ?""",
17079
+ (now, now, user_id, provider, cred_id),
17080
+ )
17081
+ conn.commit()
17082
+ conn.close()
17083
+ except sqlite3.Error as exc:
17084
+ self._json(500, {"ok": False, "error": f"db_error: {exc}"})
17085
+ return
17086
+ self._log_audit("credential_stored", "credential_binding", cred_id, f"provider={provider}")
17087
+ health: dict[str, Any] = {}
17088
+ if provider == "openrouter":
17089
+ secret_probe = _credential_secret(
17090
+ {
17091
+ "credential_ref": cred_ref,
17092
+ "scope": "user",
17093
+ "user_id": user_id,
17094
+ },
17095
+ user_id,
17096
+ )
17097
+ if secret_probe:
17098
+ health = openrouter_catalog.probe_account(secret_probe)
17099
+ _bootstrap_model_after_llm_connect(user_id, str(body.get("workspace_id") or ""), provider)
17100
+ self._json(200, {
17101
+ "ok": True,
17102
+ "credential_id": cred_id,
17103
+ "provider": provider,
17104
+ "credential_type": credential_type,
17105
+ "label": label,
17106
+ "is_active": 1,
17107
+ "user_id": user_id,
17108
+ "created_at": now,
17109
+ "updated_at": now,
17110
+ "health": health,
17111
+ **payload,
17112
+ })
17113
+
17114
+ def _route_post_me_telegram_link(self, body: dict) -> None:
17115
+ user_id = str(getattr(self, "auth_user", "") or "")
17116
+ if not user_id:
17117
+ self._json(401, {"ok": False, "error": "no_session"})
17118
+ return
17119
+ result = platform_auth.verify_telegram_login(body if isinstance(body, dict) else {})
17120
+ if not result.get("ok"):
17121
+ self._json(400, {"ok": False, "error": result.get("error") or "telegram_link_failed"})
17122
+ return
17123
+ patch = result.get("platform_ids") if isinstance(result.get("platform_ids"), dict) else {}
17124
+ if patch:
17125
+ _merge_user_platform_ids(user_id, {str(k): str(v) for k, v in patch.items()})
17126
+ self._json(200, {"ok": True, "provider": "telegram", "platform_ids": patch})
17127
+
17128
+ def _route_post_hermes_commands_exec(self, body: dict) -> None:
17129
+ if not _check_auth(self):
17130
+ self._json(401, {"error": "unauthorized"})
17131
+ return
17132
+ line = str(body.get("line", "")).strip()
17133
+ if not line:
17134
+ self._json(400, {"error": "line required"})
17135
+ return
17136
+ self._json(200, hermes_commands_exec(line))
17137
+
17138
+ def _route_post_hermes_gateway_exec(self, body: dict) -> None:
17139
+ if not _check_auth(self):
17140
+ self._json(401, {"error": "unauthorized"})
17141
+ return
17142
+ line = str(body.get("line", "")).strip()
17143
+ profile = str(body.get("profile", "")).strip()
17144
+ if not line:
17145
+ self._json(400, {"error": "line required"})
17146
+ return
17147
+ user_id = str(getattr(self, "auth_user", "") or "")
17148
+ workspace_id = str(body.get("workspace_id", "") or "")
17149
+ self._json(
17150
+ 200,
17151
+ hermes_gateway_exec(line, profile, user_id=user_id, workspace_id=workspace_id),
17152
+ )
17153
+
17154
+ def _route_post_chat_stop(self, body: dict) -> None:
17155
+ if not _check_auth(self):
17156
+ self._json(401, {"error": "unauthorized"})
17157
+ return
17158
+ profile = resolve_validated_profile(str(body.get("profile") or _primary_profile()))
17159
+ run_id = str(body.get("run_id") or "").strip()
17160
+ if not run_id:
17161
+ run_id = _latest_active_run_id(profile)
17162
+ if not run_id:
17163
+ self._json(400, {"error": "no active run to stop"})
17164
+ return
17165
+ self._json(200, profile_gateway_stop(profile, run_id))
17166
+
17167
+ def _route_post_chat_steer(self, body: dict) -> None:
17168
+ if not _check_auth(self):
17169
+ self._json(401, {"error": "unauthorized"})
17170
+ return
17171
+ profile = resolve_validated_profile(str(body.get("profile") or _primary_profile()))
17172
+ run_id = str(body.get("run_id") or "").strip()
17173
+ text = str(body.get("text") or "").strip()
17174
+ if not text:
17175
+ self._json(400, {"error": "text required"})
17176
+ return
17177
+ if not run_id:
17178
+ run_id = _latest_active_run_id(profile)
17179
+ self._json(200, profile_gateway_steer(profile, run_id or "", text))
17180
+
17181
+ def _route_post_hermes_model(self, body: dict) -> None:
17182
+ if not _check_auth(self):
17183
+ self._json(401, {"error": "unauthorized"})
17184
+ return
17185
+ model_id = str(body.get("model", "")).strip()
17186
+ profile = str(body.get("profile", "")).strip()
17187
+ user_id = str(getattr(self, "auth_user", "") or "")
17188
+ workspace_id = str(body.get("workspace_id", "")).strip()
17189
+ selection_only = bool(body.get("selection_only"))
17190
+ if not model_id:
17191
+ self._json(400, {"error": "model required"})
17192
+ return
17193
+ billing_provider = str(body.get("billing_provider", "")).strip()
17194
+ self._json(
17195
+ 200,
17196
+ hermes_model_set(
17197
+ profile,
17198
+ model_id,
17199
+ user_id,
17200
+ workspace_id,
17201
+ selection_only=selection_only,
17202
+ billing_provider=billing_provider,
17203
+ ),
17204
+ )
17205
+
17206
+ def _route_post_hermes_model_apply_default(self, body: dict) -> None:
17207
+ if not _check_auth(self):
17208
+ self._json(401, {"error": "unauthorized"})
17209
+ return
17210
+ if not _role_allows(self, OWNER_ADMIN_ROLES):
17211
+ self._json(403, {"ok": False, "error": "forbidden", "required_role": "owner_or_admin"})
17212
+ return
17213
+ self._json(200, hermes_apply_default_model_config())
17214
+
17215
+ def _route_post_hermes_fallback_chain(self, body: dict) -> None:
17216
+ if not _check_auth(self):
17217
+ self._json(401, {"error": "unauthorized"})
17218
+ return
17219
+ profile = str(body.get("profile", "")).strip()
17220
+ chain = body.get("chain", [])
17221
+ selection_only = bool(body.get("selection_only"))
17222
+ user_id = str(getattr(self, "auth_user", "") or "")
17223
+ if not isinstance(chain, list):
17224
+ self._json(400, {"error": "chain must be a list"})
17225
+ return
17226
+ if not selection_only and not _role_allows(self, OWNER_ADMIN_ROLES):
17227
+ self._json(403, {"ok": False, "error": "forbidden", "required_role": "owner_or_admin"})
17228
+ return
17229
+ self._json(
17230
+ 200,
17231
+ hermes_fallback_chain_set(
17232
+ profile,
17233
+ chain,
17234
+ selection_only=selection_only,
17235
+ user_id=user_id,
17236
+ ),
17237
+ )
17238
+
17239
+ def _route_post_board(self, body: dict) -> None:
17240
+ if not _check_auth(self):
17241
+ self._json(401, {"error": "unauthorized"})
17242
+ return
17243
+ self._json(201, board_create(body))
17244
+
17245
+ def _route_post_board_update(self, body: dict) -> None:
17246
+ if not _check_auth(self):
17247
+ self._json(401, {"error": "unauthorized"})
17248
+ return
17249
+ qs = getattr(self, "_post_qs", {})
17250
+ task_id = qs.get("id", [""])[0]
17251
+ if not task_id:
17252
+ self._json(400, {"error": "id required"})
17253
+ return
17254
+ self._json(200, board_update(task_id, body))
17255
+
17256
+ def _route_post_board_delete(self, body: dict) -> None:
17257
+ if not _check_auth(self):
17258
+ self._json(401, {"error": "unauthorized"})
17259
+ return
17260
+ qs = getattr(self, "_post_qs", {})
17261
+ task_id = qs.get("id", [""])[0]
17262
+ if not task_id:
17263
+ self._json(400, {"error": "id required"})
17264
+ return
17265
+ board_delete(task_id)
17266
+ self._json(200, {"ok": True})
17267
+
17268
+ def _route_post_content_save(self, body: dict) -> None:
17269
+ if not _check_auth(self):
17270
+ self._json(401, {"error": "unauthorized"})
17271
+ return
17272
+ rel = str(body.get("path") or "").strip()
17273
+ reason = _workspace_protected_reason(rel)
17274
+ if reason:
17275
+ self._json(403, {"ok": False, "error": "protected_file", "reason": reason})
17276
+ return
17277
+ content = str(body.get("content") or "")
17278
+ fp = _safe_content_path(rel)
17279
+ if not fp:
17280
+ self._json(400, {"error": "invalid path"})
17281
+ return
17282
+ fp.parent.mkdir(parents=True, exist_ok=True)
17283
+ fp.write_text(content, encoding="utf-8")
17284
+ _bump_workspace_state(fp)
17285
+ self._json(200, {"ok": True, "path": rel.replace("\\", "/")})
17286
+
17287
+ def _route_post_content_delete(self, body: dict) -> None:
17288
+ if not _check_auth(self):
17289
+ self._json(401, {"error": "unauthorized"})
17290
+ return
17291
+ qs = getattr(self, "_post_qs", {})
17292
+ rel = qs.get("path", [""])[0] or str(body.get("path") or "")
17293
+ reason = _workspace_protected_reason(rel)
17294
+ if reason:
17295
+ self._json(403, {"ok": False, "error": "protected_file", "reason": reason})
17296
+ return
17297
+ fp = _safe_content_path(rel)
17298
+ if not fp or not fp.is_file():
17299
+ self._json(404, {"error": "not found"})
17300
+ return
17301
+ fp.unlink()
17302
+ _bump_workspace_state(fp.parent)
17303
+ self._json(200, {"ok": True})
17304
+
17305
+ def _route_post_files_write(self, body: dict) -> None:
17306
+ if not _check_auth(self):
17307
+ self._json(401, {"error": "unauthorized"})
17308
+ return
17309
+ rel = str(body.get("path") or "").strip()
17310
+ reason = _workspace_protected_reason(rel)
17311
+ if reason:
17312
+ self._json(403, {"ok": False, "error": "protected_file", "reason": reason})
17313
+ return
17314
+ content = str(body.get("content") or "")
17315
+ self._json(200, file_write(rel, content))
17316
+
17317
+ def _route_post_files_upload(self, body: dict) -> None:
17318
+ if not _check_auth(self):
17319
+ self._json(401, {"error": "unauthorized"})
17320
+ return
17321
+ rel = str(body.get("path") or "").strip()
17322
+ reason = _workspace_protected_reason(rel)
17323
+ if reason:
17324
+ self._json(403, {"ok": False, "error": "protected_file", "reason": reason})
17325
+ return
17326
+ content_b64 = str(body.get("content_base64") or body.get("content") or "")
17327
+ self._json(200, file_upload_binary(rel, content_b64))
17328
+
17329
+ def _route_post_chat_dispatch(self, body: dict) -> None:
17330
+ if not _check_auth(self):
17331
+ self._json(401, {"error": "unauthorized"})
17332
+ return
17333
+ self._json(200, chat_dispatch(body))
17334
+
17335
+ def do_GET(self) -> None: # noqa: N802
17336
+ parsed = urllib.parse.urlparse(self.path)
17337
+ path = parsed.path
17338
+ qs = urllib.parse.parse_qs(parsed.query)
17339
+
17340
+ if self._handle_internal_llm_proxy("GET", path):
17341
+ return
17342
+ if self._handle_internal_action_proxy("GET", path):
17343
+ return
17344
+
17345
+ # Auth middleware — returns 401 if session required and missing/invalid.
17346
+ # In DEV_LOCAL_UNSAFE mode this is a no-op.
17347
+ if not _auth_check(self):
17348
+ return self._json(401, {"ok": False, "error": "no_session"})
17349
+
17350
+ if SECURE_MODE:
17351
+ if not _secure_host_origin_ok(self.command, path, self.headers):
17352
+ if not _validate_host(self.headers):
17353
+ return self._json(403, {"error": "invalid host"})
17354
+ return self._json(403, {"error": "invalid origin"})
17355
+
17356
+ try:
17357
+ if route_registry.dispatch_get(self, path, qs):
17358
+ return
17359
+
17360
+ # Serve static files and UI
17361
+ if path in ("", "/") or not path.startswith("/api/") and not path.startswith("/assets/") and not path.startswith("/static/"):
17362
+ return self._file_public("index.html")
17363
+ if path.startswith("/assets/"):
17364
+ return self._file_public(path.lstrip("/"))
16436
17365
  if path == "/api/admin/vault/status":
16437
17366
  if not _role_allows(self, OWNER_ADMIN_ROLES):
16438
17367
  return self._json(403, {"ok": False, "error": "forbidden", "required_role": "owner_or_admin"})
@@ -16462,44 +17391,6 @@ class Handler(BaseHTTPRequestHandler):
16462
17391
  self.end_headers()
16463
17392
  return
16464
17393
  return self._json(403, {"ok": False, "error": "dashboard_forbidden"})
16465
- if path == "/api/setup/status":
16466
- # Public endpoint: check if default workspace has agents (setup complete?)
16467
- try:
16468
- db = _workframe_db()
16469
- ws = db.execute("SELECT id FROM workspaces WHERE slug='default' AND deleted_at IS NULL").fetchone()
16470
- if not ws:
16471
- db.close()
16472
- return self._json(200, {"setup_complete": False})
16473
- agents = db.execute("SELECT COUNT(*) AS c FROM agent_profiles WHERE workspace_id = ? AND deleted_at IS NULL", (ws["id"],)).fetchone()
16474
- db.close()
16475
- return self._json(200, {"setup_complete": bool(agents and agents["c"] > 0)})
16476
- except Exception:
16477
- return self._json(200, {"setup_complete": False})
16478
- if path == "/api/install/status":
16479
- if install_api.install_window_open(str(_workframe_db_path())):
16480
- try:
16481
- _ensure_native_hermes_profile()
16482
- except Exception:
16483
- pass
16484
- payload = install_api.install_status_payload(
16485
- DEPLOYMENT_MODE,
16486
- SECURE_MODE,
16487
- DEV_LOCAL_UNSAFE,
16488
- str(_workframe_db_path()),
16489
- )
16490
- return self._json(200, payload)
16491
- if path == "/api/public/site-meta":
16492
- return self._json(200, _public_site_meta_payload())
16493
- if path == "/api/public/link-preview":
16494
- meta = _public_site_meta_payload()
16495
- html = site_meta.link_preview_html(meta).encode("utf-8")
16496
- self._send(200, html, "text/html; charset=utf-8")
16497
- return
16498
- if path == "/api/public/manifest.webmanifest":
16499
- meta = _public_site_meta_payload()
16500
- body = json.dumps(site_meta.manifest_payload(meta), indent=2).encode("utf-8")
16501
- self._send(200, body, "application/manifest+json; charset=utf-8")
16502
- return
16503
17394
  branding_match = re.fullmatch(r"/api/public/branding/(og|favicon)", path)
16504
17395
  if branding_match:
16505
17396
  asset = site_meta.branding_asset_bytes(branding_match.group(1))
@@ -16557,61 +17448,18 @@ class Handler(BaseHTTPRequestHandler):
16557
17448
  redirect_to = (
16558
17449
  f"{APP_BASE_URL.rstrip('/')}/?invite_token="
16559
17450
  f"{urllib.parse.quote(invite_token)}&email={urllib.parse.quote(profile['email'])}"
16560
- )
16561
- self.send_response(302)
16562
- self.send_header("Location", redirect_to)
16563
- self.send_header("Set-Cookie", cookie_val)
16564
- cors_origin = _cors_origin_for(self.headers)
16565
- if cors_origin:
16566
- self.send_header("Access-Control-Allow-Origin", cors_origin)
16567
- self.send_header("Vary", "Origin")
16568
- self.end_headers()
16569
- return
16570
- except Exception as exc:
16571
- return self._json(401, {"ok": False, "error": str(exc)})
16572
- if path == "/api/me":
16573
- # zk-auth compatible profile endpoint (GET)
16574
- user_id = str(getattr(self, "auth_user", "") or "")
16575
- if not user_id:
16576
- return self._json(401, {"ok": False, "error": "no_session"})
16577
- payload = _session_profile_payload(user_id)
16578
- if not payload:
16579
- return self._json(404, {"ok": False, "error": "user_not_found"})
16580
- return self._json(200, payload)
16581
-
16582
- if path == "/api/me/onboarding":
16583
- user_id = str(getattr(self, "auth_user", "") or "")
16584
- if not user_id:
16585
- return self._json(401, {"ok": False, "error": "no_session"})
16586
- return self._json(200, _onboarding_payload(user_id))
16587
-
16588
- if path == "/api/me/credentials":
16589
- user_id = str(getattr(self, "auth_user", "") or "")
16590
- if not user_id:
16591
- return self._json(401, {"ok": False, "error": "no_session"})
16592
- try:
16593
- conn = sqlite3.connect(str(_workframe_db_path()), timeout=3.0)
16594
- conn.row_factory = sqlite3.Row
16595
- rows = conn.execute(
16596
- """SELECT id, workspace_id, user_id, agent_profile_id, provider, credential_type,
16597
- credential_ref, label, is_active, created_at, updated_at
16598
- FROM credential_bindings
16599
- WHERE user_id = ? AND deleted_at IS NULL
16600
- ORDER BY updated_at DESC, created_at DESC""",
16601
- (user_id,),
16602
- ).fetchall()
16603
- conn.close()
16604
- except sqlite3.Error as exc:
16605
- return self._json(500, {"ok": False, "error": f"db_error: {exc}"})
16606
- return self._json(200, {"ok": True, "credentials": [dict(row) for row in rows]})
16607
-
16608
- if path == "/api/me/providers":
16609
- user_id = str(getattr(self, "auth_user", "") or "")
16610
- if not user_id:
16611
- return self._json(401, {"ok": False, "error": "no_session"})
16612
- workspace_id = qs.get("workspace_id", [""])[0]
16613
- return self._json(200, list_user_providers(user_id, workspace_id))
16614
-
17451
+ )
17452
+ self.send_response(302)
17453
+ self.send_header("Location", redirect_to)
17454
+ self.send_header("Set-Cookie", cookie_val)
17455
+ cors_origin = _cors_origin_for(self.headers)
17456
+ if cors_origin:
17457
+ self.send_header("Access-Control-Allow-Origin", cors_origin)
17458
+ self.send_header("Vary", "Origin")
17459
+ self.end_headers()
17460
+ return
17461
+ except Exception as exc:
17462
+ return self._json(401, {"ok": False, "error": str(exc)})
16615
17463
  if path.startswith("/api/me/oauth/") and path.endswith("/status"):
16616
17464
  parts = path.strip("/").split("/")
16617
17465
  if len(parts) == 5 and parts[2] == "oauth" and parts[4] == "status":
@@ -16825,27 +17673,6 @@ class Handler(BaseHTTPRequestHandler):
16825
17673
  return self._json(403, {"ok": False, "error": str(exc)})
16826
17674
  return self._json(200, payload)
16827
17675
 
16828
- if path == "/api/me/cohort":
16829
- user_id = str(getattr(self, "auth_user", "") or "")
16830
- if not user_id:
16831
- return self._json(401, {"ok": False, "error": "no_session"})
16832
- workspace_id = str(qs.get("workspace_id", [""])[0] or "").strip()
16833
- if not workspace_id:
16834
- return self._json(400, {"ok": False, "error": "workspace_id_required"})
16835
- ws_id = _resolve_wid(workspace_id) or workspace_id
16836
- if not _user_is_workspace_member(user_id, ws_id):
16837
- return self._json(403, {"ok": False, "error": "forbidden"})
16838
- cohort = ensure_user_agent_cohort(user_id, ws_id)
16839
- return self._json(
16840
- 200,
16841
- {
16842
- "ok": True,
16843
- "workspace_id": ws_id,
16844
- "user_id": user_id,
16845
- "cohort": cohort,
16846
- },
16847
- )
16848
-
16849
17676
  if path.startswith("/api/rooms/") and path.endswith("/sessions"):
16850
17677
  rid = path.strip("/").split("/")[-2]
16851
17678
  user_id = str(getattr(self, "auth_user", "") or "")
@@ -16909,25 +17736,6 @@ class Handler(BaseHTTPRequestHandler):
16909
17736
  return self._json(404, {"ok": False, "error": "workspace_not_found"})
16910
17737
  return self._json(*_list_workspace_members(ws_id, self))
16911
17738
 
16912
- # --- Sprint J: User credentials (GET) ---
16913
- if path == "/api/user/credentials":
16914
- user_id = str(getattr(self, "auth_user", "") or "")
16915
- if not user_id:
16916
- if DEV_LOCAL_UNSAFE:
16917
- user_id = "dev"
16918
- else:
16919
- return self._json(401, {"ok": False, "error": "no_authenticated_user"})
16920
- try:
16921
- conn = sqlite3.connect(str(AUTH_DB_PATH.parent / "workframe.db"), timeout=3.0)
16922
- conn.row_factory = sqlite3.Row
16923
- creds = conn.execute(
16924
- "SELECT id, workspace_id, user_id, agent_profile_id, provider, credential_type, label, is_active, created_at, updated_at FROM credential_bindings WHERE user_id = ? AND deleted_at IS NULL ORDER BY created_at DESC",
16925
- (user_id,)).fetchall()
16926
- conn.close()
16927
- except Exception:
16928
- creds = []
16929
- return self._json(200, {"ok": True, "credentials": [dict(c) for c in creds]})
16930
-
16931
17739
  # --- Sprint I: Memory (GET) ---
16932
17740
  if path.startswith("/api/workspace/") and path.endswith("/memory"):
16933
17741
  parts = path.strip("/").split("/")
@@ -17067,78 +17875,6 @@ class Handler(BaseHTTPRequestHandler):
17067
17875
  pass
17068
17876
  return
17069
17877
 
17070
- if path == "/api/meta":
17071
- return self._json(200, workframe_meta())
17072
- if path == "/api/agents":
17073
- return self._json(200, workframe_agents())
17074
- if path == "/api/snapshot":
17075
- return self._json(200, build_snapshot())
17076
- if path == "/api/activity/detail":
17077
- profile = qs.get("profile", [""])[0].strip()
17078
- tool_call_id = qs.get("tool_call_id", [""])[0].strip()
17079
- session_id = qs.get("session_id", [""])[0].strip()
17080
- message_id = qs.get("message_id", [""])[0].strip()
17081
- return self._json(200, activity_detail(profile, tool_call_id, session_id, message_id))
17082
- if path == "/api/board":
17083
- return self._json(200, board_list())
17084
- if path == "/api/content":
17085
- return self._json(200, content_list())
17086
- if path == "/api/content/get":
17087
- rel = qs.get("path", [""])[0]
17088
- reason = _workspace_protected_reason(rel)
17089
- if reason:
17090
- return self._json(403, {"ok": False, "error": "protected_file", "reason": reason})
17091
- fp = _safe_content_path(rel)
17092
- if not fp or not fp.is_file():
17093
- return self._json(404, {"error": "not found"})
17094
- text = fp.read_text(encoding="utf-8", errors="replace")
17095
- return self._json(200, {"path": rel, "content": text, "text": text})
17096
- if path == "/api/files/tree":
17097
- return self._json(200, {"root": files_tree()})
17098
- if path == "/api/files/list":
17099
- rel = qs.get("path", [""])[0]
17100
- reason = _workspace_protected_reason(rel)
17101
- if reason:
17102
- return self._json(403, {"ok": False, "error": "protected_file", "reason": reason})
17103
- return self._json(200, files_list(rel))
17104
- if path == "/api/files/state":
17105
- return self._json(200, workspace_state())
17106
- if path == "/api/files/read":
17107
- rel = qs.get("path", [""])[0]
17108
- reason = _workspace_protected_reason(rel)
17109
- if reason:
17110
- return self._json(403, {"ok": False, "error": "protected_file", "reason": reason})
17111
- return self._json(200, file_read(rel))
17112
- if path == "/api/files/raw":
17113
- rel = qs.get("path", [""])[0]
17114
- reason = _workspace_protected_reason(rel)
17115
- if reason:
17116
- return self._json(403, {"ok": False, "error": "protected_file", "reason": reason})
17117
- try:
17118
- data, mime = file_raw(rel)
17119
- except ValueError:
17120
- return self._json(404, {"error": "not found"})
17121
- return self._send(200, data, mime)
17122
- if path == "/api/routes":
17123
- return self._json(200, load_routes())
17124
- if path == "/api/chat/messages":
17125
- profile = resolve_hermes_profile(qs.get("profile", [""])[0] or _primary_profile())
17126
- session = qs.get("session", [""])[0]
17127
- source_id = qs.get("source", ["ui"])[0]
17128
- return self._json(200, chat_messages(profile, session, source_id))
17129
- if path == "/api/chat/session":
17130
- profile = resolve_hermes_profile(qs.get("profile", [""])[0] or _primary_profile())
17131
- session = qs.get("session", [""])[0]
17132
- source_id = qs.get("source", ["ui"])[0]
17133
- return self._json(200, chat_session(profile, session, source_id))
17134
- if path == "/api/chat/resolve":
17135
- profile = resolve_validated_profile(qs.get("profile", [""])[0] or _primary_profile())
17136
- source_id = qs.get("source", ["ui"])[0]
17137
- client_id = qs.get("client", ["default"])[0]
17138
- return self._json(
17139
- 200,
17140
- chat_resolve({"profile": profile, "source_id": source_id, "client_id": client_id}),
17141
- )
17142
17878
  if path == "/api/chat/bootstrap":
17143
17879
  if not _role_allows(self, OWNER_ADMIN_ROLES):
17144
17880
  return self._json(403, {"ok": False, "error": "forbidden", "required_role": "owner_or_admin"})
@@ -17150,28 +17886,6 @@ class Handler(BaseHTTPRequestHandler):
17150
17886
  if not _role_allows(self, OWNER_ADMIN_ROLES):
17151
17887
  return self._json(403, {"ok": False, "error": "forbidden", "required_role": "owner_or_admin"})
17152
17888
  return self._json(200, hermes_bootstrap())
17153
- if path == "/api/hermes/skills":
17154
- return self._json(200, {"skills": hermes_skills()})
17155
- if path == "/api/hermes/commands":
17156
- return self._json(200, hermes_commands_catalog())
17157
- if path == "/api/hermes/usage":
17158
- return self._json(200, hermes_usage())
17159
- if path == "/api/hermes/profile":
17160
- return self._json(200, hermes_profile())
17161
- if path == "/api/hermes/debug":
17162
- return self._json(200, hermes_debug())
17163
- if path == "/api/hermes/insights":
17164
- return self._json(200, hermes_insights())
17165
- if path == "/api/hermes/gquota":
17166
- return self._json(200, hermes_gquota())
17167
- if path == "/api/hermes/models":
17168
- profile = qs.get("profile", [""])[0]
17169
- user_id = str(getattr(self, "auth_user", "") or "")
17170
- workspace_id = qs.get("workspace_id", [""])[0]
17171
- selection_only = qs.get("selection_only", ["0"])[0] in {"1", "true", "yes"}
17172
- payload = hermes_models(profile, user_id, workspace_id, selection_only=selection_only)
17173
- status = 200 if payload.get("ok", True) else 400
17174
- return self._json(status, payload)
17175
17889
  if path == "/api/hermes/profiles/status":
17176
17890
  profile = resolve_validated_profile(qs.get("profile", [""])[0] or _primary_profile())
17177
17891
  if SUPERVISOR_URL:
@@ -17311,33 +18025,6 @@ class Handler(BaseHTTPRequestHandler):
17311
18025
  "credentials": [dict(r) for r in rows],
17312
18026
  })
17313
18027
 
17314
- # GET /api/me/credentials — user-scoped credentials
17315
- if path == "/api/me/credentials" and self.command == "GET":
17316
- user_id = str(getattr(self, "auth_user", "") or "")
17317
- if not user_id:
17318
- return self._json(401, {"ok": False, "error": "no_session"})
17319
- try:
17320
- conn = sqlite3.connect(str(_workframe_db_path()), timeout=3.0)
17321
- conn.row_factory = sqlite3.Row
17322
- rows = conn.execute(
17323
- """SELECT id, workspace_id, user_id, agent_profile_id, provider,
17324
- credential_type, credential_ref, label, is_active,
17325
- expires_at, created_by, created_at, updated_at
17326
- FROM credential_bindings
17327
- WHERE user_id = ? AND deleted_at IS NULL
17328
- ORDER BY created_at DESC""",
17329
- (user_id,),
17330
- ).fetchall()
17331
- conn.close()
17332
- except sqlite3.Error as exc:
17333
- return self._json(500, {"ok": False, "error": str(exc)})
17334
- return self._json(200, {
17335
- "ok": True,
17336
- "scope": "user",
17337
- "user_id": user_id,
17338
- "credentials": [dict(r) for r in rows],
17339
- })
17340
-
17341
18028
  # GET /api/agents/:agentId/credentials — agent-profile-scoped
17342
18029
  if path.startswith("/api/agents/") and path.endswith("/credentials") and self.command == "GET":
17343
18030
  parts = path.strip("/").split("/")
@@ -17751,6 +18438,11 @@ class Handler(BaseHTTPRequestHandler):
17751
18438
 
17752
18439
  try:
17753
18440
  body = self._read_json()
18441
+ post_body = body if isinstance(body, dict) else {}
18442
+ self._post_qs = qs
18443
+
18444
+ if route_registry.dispatch_post(self, path, post_body):
18445
+ return
17754
18446
 
17755
18447
  if path == "/api/install/email/test":
17756
18448
  if not _install_window_open():
@@ -17913,365 +18605,6 @@ class Handler(BaseHTTPRequestHandler):
17913
18605
  self._log_audit("vault_wipe", "vault", "secrets", f"deleted={deleted}")
17914
18606
  return self._json(200, {"ok": True, "deleted": deleted, **credential_vault.vault_status()})
17915
18607
 
17916
- if path == "/api/auth/google/start":
17917
- invite_email = str(body.get("email") or "").strip().lower()
17918
- invite_token = str(body.get("invite_token") or "")
17919
- if invite_email:
17920
- allowed, deny_meta = _email_allowed_to_authenticate(invite_email)
17921
- if not allowed and not _invite_token_allows_email(invite_token, invite_email):
17922
- self._log_audit(
17923
- "login_denied_private",
17924
- "user",
17925
- invite_email,
17926
- str(deny_meta.get("error") or ""),
17927
- )
17928
- return self._json(403, {"ok": False, **deny_meta})
17929
- try:
17930
- payload = google_auth.start_google_auth(invite_email, invite_token)
17931
- return self._json(200, payload)
17932
- except ValueError as exc:
17933
- return self._json(400, {"ok": False, "error": str(exc)})
17934
-
17935
- if path == "/api/auth/local-bootstrap":
17936
- if DEPLOYMENT_MODE != "single_user_local" or not _install_window_open():
17937
- return self._json(403, {"ok": False, "error": "local_bootstrap_unavailable"})
17938
- display = str(body.get("display_name") or "Owner")
17939
- email = "owner@local.workframe"
17940
- try:
17941
- result = _zk.create_session_for_email(email)
17942
- except Exception as exc:
17943
- return self._json(500, {"ok": False, "error": str(exc)})
17944
- user_id = str(result["user_id"])
17945
- self.auth_user = user_id
17946
- self._ensure_user(user_id, display, email)
17947
- self._first_owner_bootstrap(user_id, display, email)
17948
- use_secure = _session_cookie_secure()
17949
- cookie_val = _zk.session_cookie_value(result["session_id"], secure=use_secure)
17950
- me_payload = _session_profile_payload(user_id)
17951
- return self._json(
17952
- 200,
17953
- {
17954
- "ok": True,
17955
- "user_id": user_id,
17956
- "session_id": result["session_id"],
17957
- "refresh_token": result["refresh_token"],
17958
- **(me_payload or {}),
17959
- },
17960
- extra_headers=[("Set-Cookie", cookie_val)],
17961
- )
17962
-
17963
- # --- First-owner bootstrap (also wired into /api/auth/start) ---
17964
- if path == "/api/auth/bootstrap":
17965
- if not SECURE_MODE:
17966
- return self._json(501, {"ok": False, "error": "bootstrap_requires_secure_mode"})
17967
- data = body if isinstance(body, dict) else {}
17968
- user_id = str(data.get("user_id", "") or secrets.token_hex(8))
17969
- user_email = str(data.get("email", ""))
17970
- user_display = str(data.get("display_name", "") or "Owner")
17971
- result = self._first_owner_bootstrap(user_id, user_display, user_email)
17972
- self._log_audit("user_created", "user", user_id, f"first-owner bootstrap: {user_display}")
17973
- return self._json(200, result)
17974
-
17975
- # --- Setup endpoint (public, no auth) ---
17976
- if path == "/api/setup":
17977
- data = body if isinstance(body, dict) else {}
17978
- agent_name = str(data.get("agent_name", "")).strip()
17979
- workframe_name = str(data.get("workframe_name", "")).strip()
17980
- if not agent_name:
17981
- return self._json(400, {"ok": False, "error": "agent_name required"})
17982
- try:
17983
- db = _workframe_db()
17984
- ws = db.execute("SELECT * FROM workspaces WHERE slug='default' AND deleted_at IS NULL").fetchone()
17985
- if not ws:
17986
- ws_id = str(uuid.uuid4())
17987
- now = str(int(time.time()))
17988
- workspace_display_name = workframe_name or "Default Workspace"
17989
- ws_settings = stack_config.github_oauth_for_workspace_settings({})
17990
- settings_json = json.dumps(ws_settings, sort_keys=True) if ws_settings else "{}"
17991
- db.execute(
17992
- "INSERT INTO workspaces (id, slug, display_name, description, owner_id, status, created_at, updated_at, settings_json) VALUES (?, 'default', ?, '', '', 'active', ?, ?, ?)",
17993
- (ws_id, workspace_display_name, now, now, settings_json),
17994
- )
17995
- ws = db.execute("SELECT * FROM workspaces WHERE id = ?", (ws_id,)).fetchone()
17996
- elif workframe_name and str(ws["display_name"] or "").strip() != workframe_name:
17997
- now = str(int(time.time()))
17998
- db.execute(
17999
- "UPDATE workspaces SET display_name = ?, updated_at = ? WHERE id = ?",
18000
- (workframe_name, now, ws["id"]),
18001
- )
18002
- ws = db.execute("SELECT * FROM workspaces WHERE id = ?", (ws["id"],)).fetchone()
18003
- ws_id = ws["id"]
18004
- existing_agents = db.execute("SELECT COUNT(*) AS c FROM agent_profiles WHERE workspace_id = ? AND deleted_at IS NULL", (ws_id,)).fetchone()
18005
- if existing_agents and existing_agents["c"] > 0:
18006
- if agent_name:
18007
- now = str(int(time.time()))
18008
- db.execute(
18009
- """
18010
- UPDATE agent_profiles
18011
- SET display_name = ?, updated_at = ?
18012
- WHERE workspace_id = ? AND is_native = 1 AND deleted_at IS NULL
18013
- """,
18014
- (agent_name, now, ws_id),
18015
- )
18016
- db.commit()
18017
- db.close()
18018
- return self._json(200, {"ok": True, "already_initialized": True, "workspace_id": ws_id})
18019
- import re as _re
18020
- agent_slug = _re.sub(r"[^a-z0-9-]", "", agent_name.lower().replace(" ", "-"))[:40]
18021
- agent_id = str(uuid.uuid4())
18022
- now = str(int(time.time()))
18023
- db.execute(
18024
- "INSERT INTO agent_profiles (id, workspace_id, slug, display_name, tagline, role, is_native, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 1, 'active', ?, ?)",
18025
- (agent_id, ws_id, agent_slug, agent_name, str(data.get("agent_personality", "")), "native", now, now),
18026
- )
18027
- room_id = str(uuid.uuid4())
18028
- db.execute(
18029
- "INSERT INTO rooms (id, workspace_id, name, slug, topic, room_type, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'channel', 'active', ?, ?)",
18030
- (room_id, ws_id, "General", "general", "General discussion", now, now),
18031
- )
18032
- db.commit()
18033
- db.close()
18034
- hermes = _bootstrap_after_setup(str(data.get("agent_personality", "")))
18035
- return self._json(
18036
- 200,
18037
- {
18038
- "ok": True,
18039
- "workspace_id": ws_id,
18040
- "agent_id": agent_id,
18041
- "room_id": room_id,
18042
- "hermes": hermes,
18043
- },
18044
- )
18045
- except Exception as exc:
18046
- return self._json(500, {"ok": False, "error": str(exc)})
18047
-
18048
- # --- Auth endpoints (zk-auth compatible) ---
18049
- if path == "/api/auth/start":
18050
- # POST /api/auth/start — email → OTP → send email (or return in dev)
18051
- email = str(body.get("email", "")).strip().lower()
18052
- if not email or "@" not in email:
18053
- return self._json(400, {"ok": False, "error": "email required"})
18054
- client_ip = str(self.client_address[0] if self.client_address else "")
18055
- import auth_rate_limit
18056
-
18057
- if not auth_rate_limit.allow_auth_request("start", client_ip, email):
18058
- return self._json(429, {"ok": False, "error": "too_many_requests"})
18059
- allowed, deny_meta = _email_allowed_to_authenticate(email)
18060
- if not allowed:
18061
- self._log_audit("login_denied_private", "user", email, str(deny_meta.get("error") or ""))
18062
- return self._json(403, {"ok": False, **deny_meta})
18063
- try:
18064
- base = APP_BASE_URL.strip().lower()
18065
- loopback = base.startswith("http://127.0.0.1") or base.startswith("http://localhost")
18066
- expose_otp = (
18067
- os.environ.get("WORKFRAME_E2E") == "1"
18068
- and _install_window_open()
18069
- and loopback
18070
- )
18071
- result = _zk.start_email_verification(
18072
- email,
18073
- dev_local_unsafe=DEV_LOCAL_UNSAFE,
18074
- expose_otp=expose_otp,
18075
- )
18076
- except Exception as exc:
18077
- return self._json(500, {"ok": False, "error": str(exc)})
18078
- if not DEV_LOCAL_UNSAFE and not expose_otp:
18079
- result.pop("otp_code", None)
18080
- result.pop("_dev_warning", None)
18081
- result.pop("_e2e_warning", None)
18082
- self._log_audit("otp_requested", "user", email, f"challenge={result.get('challenge_id','')}")
18083
- return self._json(200, {"ok": True, "status": "verification_sent", **result})
18084
-
18085
- if path == "/api/auth/verify":
18086
- # POST /api/auth/verify — email + OTP → session
18087
- email = str(body.get("email", "")).strip().lower()
18088
- code = str(body.get("code", "")).strip()
18089
- if not email or not code:
18090
- return self._json(400, {"ok": False, "error": "email and code required"})
18091
- client_ip = str(self.client_address[0] if self.client_address else "")
18092
- import auth_rate_limit
18093
-
18094
- if not auth_rate_limit.allow_auth_request("verify", client_ip, email):
18095
- return self._json(429, {"ok": False, "error": "too_many_requests"})
18096
- allowed, deny_meta = _email_allowed_to_authenticate(email)
18097
- if not allowed:
18098
- self._log_audit("login_denied_private", "user", email, str(deny_meta.get("error") or ""))
18099
- return self._json(403, {"ok": False, **deny_meta})
18100
- try:
18101
- result = _zk.verify_email_code(email, code)
18102
- except ValueError as exc:
18103
- self._log_audit("login_failed", "user", email, f"verify failed: {exc}")
18104
- return self._json(401, {"ok": False, "error": str(exc)})
18105
- except Exception as exc:
18106
- return self._json(500, {"ok": False, "error": str(exc)})
18107
- # Set session cookie
18108
- use_secure = _session_cookie_secure()
18109
- cookie_val = _zk.session_cookie_value(result["session_id"], secure=use_secure)
18110
- # Store user_id on handler for role checks
18111
- self.auth_user = result["user_id"]
18112
- self._ensure_user(result["user_id"], email, email)
18113
- self._log_audit("login", "session", result["session_id"],
18114
- f"user={result['user_id']} new={result['is_new_user']}")
18115
- me_payload = _session_profile_payload(result["user_id"])
18116
- return self._json(
18117
- 200,
18118
- {
18119
- "ok": True,
18120
- "user_id": result["user_id"],
18121
- "session_id": result["session_id"],
18122
- "refresh_token": result["refresh_token"],
18123
- "expires_at": result["expires_at"],
18124
- "is_new_user": result["is_new_user"],
18125
- **(me_payload or {}),
18126
- },
18127
- extra_headers=[("Set-Cookie", cookie_val)],
18128
- )
18129
-
18130
- if path == "/api/auth/logout":
18131
- # POST /api/auth/logout — revoke session
18132
- sid = _session_id_from_request(self)
18133
- user_id = str(getattr(self, "auth_user", "") or "")
18134
- if sid:
18135
- try:
18136
- _zk.logout_session(sid)
18137
- except Exception:
18138
- pass
18139
- use_secure = _session_cookie_secure()
18140
- cookie_val = _zk.clear_session_cookie(secure=use_secure)
18141
- self._log_audit("logout", "session", sid, f"user={user_id}")
18142
- return self._json(200, {"ok": True}, extra_headers=[("Set-Cookie", cookie_val)])
18143
-
18144
- if path == "/api/auth/refresh":
18145
- # POST /api/auth/refresh — refresh token rotation or cookie session extend
18146
- refresh_token = str(body.get("refresh_token", "")).strip()
18147
- if not refresh_token:
18148
- sid = _session_id_from_request(self)
18149
- if not sid:
18150
- return self._json(400, {"ok": False, "error": "refresh_token required"})
18151
- validated = _zk.validate_session_token(sid)
18152
- if not validated:
18153
- return self._json(401, {"ok": False, "error": "session_expired"})
18154
- use_secure = _session_cookie_secure()
18155
- cookie_val = _zk.session_cookie_value(validated["session_id"], secure=use_secure)
18156
- return self._json(
18157
- 200,
18158
- {
18159
- "ok": True,
18160
- "user_id": validated["user_id"],
18161
- "session_id": validated["session_id"],
18162
- "expires_at": validated["expires_at"],
18163
- },
18164
- extra_headers=[("Set-Cookie", cookie_val)],
18165
- )
18166
- try:
18167
- result = _zk.refresh_session(refresh_token)
18168
- except ValueError as exc:
18169
- return self._json(401, {"ok": False, "error": str(exc)})
18170
- except Exception as exc:
18171
- return self._json(500, {"ok": False, "error": str(exc)})
18172
- # Set new session cookie
18173
- use_secure = _session_cookie_secure()
18174
- cookie_val = _zk.session_cookie_value(result["session_id"], secure=use_secure)
18175
- self._log_audit("session_refreshed", "session", result["session_id"],
18176
- f"user={result['user_id']}")
18177
- return self._json(
18178
- 200,
18179
- {
18180
- "ok": True,
18181
- "user_id": result["user_id"],
18182
- "session_id": result["session_id"],
18183
- "refresh_token": result["refresh_token"],
18184
- "expires_at": result["expires_at"],
18185
- },
18186
- extra_headers=[("Set-Cookie", cookie_val)],
18187
- )
18188
-
18189
- if path == "/api/me/credentials":
18190
- user_id = str(getattr(self, "auth_user", "") or "")
18191
- if not user_id:
18192
- return self._json(401, {"ok": False, "error": "no_session"})
18193
- provider = str(body.get("provider", "")).strip()
18194
- credential_type = str(body.get("credential_type", "api_key")).strip() or "api_key"
18195
- secret = str(body.get("secret", "")).strip()
18196
- spec = _catalog_provider(provider)
18197
- if spec:
18198
- credential_type = str(spec.get("connect_mode") or credential_type)
18199
- env_var = str(body.get("env_var", "")).strip() or (
18200
- str(spec.get("env_var") or "") if spec else ""
18201
- ) or _default_credential_env_var(provider, credential_type)
18202
- label = str(body.get("label", "")).strip() or env_var
18203
- if not provider:
18204
- return self._json(400, {"ok": False, "error": "provider required"})
18205
- if not secret:
18206
- return self._json(400, {"ok": False, "error": "secret required"})
18207
- payload = _store_user_credential(user_id, provider, credential_type, secret, env_var, label)
18208
- cred_ref = str(payload["credential_ref"])
18209
- now = _utc_now()
18210
- try:
18211
- conn = sqlite3.connect(str(_workframe_db_path()), timeout=3.0)
18212
- conn.row_factory = sqlite3.Row
18213
- existing = conn.execute(
18214
- """SELECT id FROM credential_bindings
18215
- WHERE user_id = ? AND provider = ? AND credential_type = ?
18216
- AND credential_ref = ? AND deleted_at IS NULL
18217
- ORDER BY updated_at DESC, created_at DESC LIMIT 1""",
18218
- (user_id, provider, credential_type, cred_ref),
18219
- ).fetchone()
18220
- if existing:
18221
- cred_id = str(existing["id"])
18222
- conn.execute(
18223
- """UPDATE credential_bindings
18224
- SET label = ?, is_active = 1, updated_at = ?, deleted_at = NULL
18225
- WHERE id = ?""",
18226
- (label, now, cred_id),
18227
- )
18228
- else:
18229
- cred_id = str(payload["credential_id"])
18230
- conn.execute(
18231
- """INSERT INTO credential_bindings
18232
- (id, workspace_id, user_id, agent_profile_id, provider, credential_type,
18233
- credential_ref, label, is_active, created_by, created_at, updated_at)
18234
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
18235
- (cred_id, None, user_id, None, provider, credential_type, cred_ref, label, 1, user_id, now, now),
18236
- )
18237
- conn.execute(
18238
- """UPDATE credential_bindings
18239
- SET is_active = 0, deleted_at = ?, updated_at = ?
18240
- WHERE user_id = ? AND provider = ? AND deleted_at IS NULL AND id != ?""",
18241
- (now, now, user_id, provider, cred_id),
18242
- )
18243
- conn.commit()
18244
- conn.close()
18245
- except sqlite3.Error as exc:
18246
- return self._json(500, {"ok": False, "error": f"db_error: {exc}"})
18247
- self._log_audit("credential_stored", "credential_binding", cred_id, f"provider={provider}")
18248
- health: dict[str, Any] = {}
18249
- if provider == "openrouter":
18250
- secret_probe = _credential_secret(
18251
- {
18252
- "credential_ref": cred_ref,
18253
- "scope": "user",
18254
- "user_id": user_id,
18255
- },
18256
- user_id,
18257
- )
18258
- if secret_probe:
18259
- health = openrouter_catalog.probe_account(secret_probe)
18260
- _bootstrap_model_after_llm_connect(user_id, str(body.get("workspace_id") or ""), provider)
18261
- return self._json(200, {
18262
- "ok": True,
18263
- "credential_id": cred_id,
18264
- "provider": provider,
18265
- "credential_type": credential_type,
18266
- "label": label,
18267
- "is_active": 1,
18268
- "user_id": user_id,
18269
- "created_at": now,
18270
- "updated_at": now,
18271
- "health": health,
18272
- **payload,
18273
- })
18274
-
18275
18608
  if path.startswith("/api/me/oauth/") and path.endswith("/start"):
18276
18609
  parts = path.strip("/").split("/")
18277
18610
  if len(parts) == 5 and parts[2] == "oauth" and parts[4] == "start":
@@ -18282,18 +18615,6 @@ class Handler(BaseHTTPRequestHandler):
18282
18615
  workspace_id = str(body.get("workspace_id") or "")
18283
18616
  return self._json(200, start_user_oauth(user_id, provider_id, workspace_id))
18284
18617
 
18285
- if path == "/api/me/telegram/link":
18286
- user_id = str(getattr(self, "auth_user", "") or "")
18287
- if not user_id:
18288
- return self._json(401, {"ok": False, "error": "no_session"})
18289
- result = platform_auth.verify_telegram_login(body if isinstance(body, dict) else {})
18290
- if not result.get("ok"):
18291
- return self._json(400, {"ok": False, "error": result.get("error") or "telegram_link_failed"})
18292
- patch = result.get("platform_ids") if isinstance(result.get("platform_ids"), dict) else {}
18293
- if patch:
18294
- _merge_user_platform_ids(user_id, {str(k): str(v) for k, v in patch.items()})
18295
- return self._json(200, {"ok": True, "provider": "telegram", "platform_ids": patch})
18296
-
18297
18618
  if path.startswith("/api/me/providers/") and path.endswith("/disconnect"):
18298
18619
  parts = path.strip("/").split("/")
18299
18620
  if len(parts) == 5 and parts[2] == "providers" and parts[4] == "disconnect":
@@ -18429,33 +18750,6 @@ class Handler(BaseHTTPRequestHandler):
18429
18750
  "credentials": [dict(r) for r in rows],
18430
18751
  })
18431
18752
 
18432
- # GET /api/me/credentials — user-scoped credentials
18433
- if path == "/api/me/credentials" and self.command == "GET":
18434
- user_id = str(getattr(self, "auth_user", "") or "")
18435
- if not user_id:
18436
- return self._json(401, {"ok": False, "error": "no_session"})
18437
- try:
18438
- conn = sqlite3.connect(str(_workframe_db_path()), timeout=3.0)
18439
- conn.row_factory = sqlite3.Row
18440
- rows = conn.execute(
18441
- """SELECT id, workspace_id, user_id, agent_profile_id, provider,
18442
- credential_type, credential_ref, label, is_active,
18443
- expires_at, created_by, created_at, updated_at
18444
- FROM credential_bindings
18445
- WHERE user_id = ? AND deleted_at IS NULL
18446
- ORDER BY created_at DESC""",
18447
- (user_id,),
18448
- ).fetchall()
18449
- conn.close()
18450
- except sqlite3.Error as exc:
18451
- return self._json(500, {"ok": False, "error": str(exc)})
18452
- return self._json(200, {
18453
- "ok": True,
18454
- "scope": "user",
18455
- "user_id": user_id,
18456
- "credentials": [dict(r) for r in rows],
18457
- })
18458
-
18459
18753
  # GET /api/agents/:agentId/credentials — agent-profile-scoped credentials
18460
18754
  if path.startswith("/api/agents/") and path.endswith("/credentials") and self.command == "GET":
18461
18755
  parts = path.strip("/").split("/")
@@ -19124,169 +19418,6 @@ class Handler(BaseHTTPRequestHandler):
19124
19418
  self._log_audit("grant_created", "credential_grant", grant_id, f"workspace={ws_id} grantee={grantee_type}:{grantee_id}")
19125
19419
  return self._json(201, {"ok": True, "grant_id": grant_id})
19126
19420
 
19127
- if path == "/api/hermes/commands/exec":
19128
- if not _check_auth(self):
19129
- return self._json(401, {"error": "unauthorized"})
19130
- line = str(body.get("line", "")).strip()
19131
- if not line:
19132
- return self._json(400, {"error": "line required"})
19133
- return self._json(200, hermes_commands_exec(line))
19134
- if path == "/api/hermes/gateway/exec":
19135
- if not _check_auth(self):
19136
- return self._json(401, {"error": "unauthorized"})
19137
- line = str(body.get("line", "")).strip()
19138
- profile = str(body.get("profile", "")).strip()
19139
- if not line:
19140
- return self._json(400, {"error": "line required"})
19141
- user_id = str(getattr(self, "auth_user", "") or "")
19142
- workspace_id = str(body.get("workspace_id", "") or "")
19143
- return self._json(
19144
- 200,
19145
- hermes_gateway_exec(line, profile, user_id=user_id, workspace_id=workspace_id),
19146
- )
19147
- if path == "/api/chat/stop":
19148
- if not _check_auth(self):
19149
- return self._json(401, {"error": "unauthorized"})
19150
- profile = resolve_validated_profile(str(body.get("profile") or _primary_profile()))
19151
- run_id = str(body.get("run_id") or "").strip()
19152
- # If no run_id provided, find the latest active run for this profile
19153
- if not run_id:
19154
- run_id = _latest_active_run_id(profile)
19155
- if not run_id:
19156
- return self._json(400, {"error": "no active run to stop"})
19157
- return self._json(200, profile_gateway_stop(profile, run_id))
19158
- if path == "/api/chat/steer":
19159
- if not _check_auth(self):
19160
- return self._json(401, {"error": "unauthorized"})
19161
- profile = resolve_validated_profile(str(body.get("profile") or _primary_profile()))
19162
- run_id = str(body.get("run_id") or "").strip()
19163
- text = str(body.get("text") or "").strip()
19164
- if not text:
19165
- return self._json(400, {"error": "text required"})
19166
- # If no run_id provided, find the latest active run
19167
- if not run_id:
19168
- run_id = _latest_active_run_id(profile)
19169
- return self._json(200, profile_gateway_steer(profile, run_id or "", text))
19170
- if path == "/api/hermes/model":
19171
- if not _check_auth(self):
19172
- return self._json(401, {"error": "unauthorized"})
19173
- model_id = str(body.get("model", "")).strip()
19174
- profile = str(body.get("profile", "")).strip()
19175
- user_id = str(getattr(self, "auth_user", "") or "")
19176
- workspace_id = str(body.get("workspace_id", "")).strip()
19177
- selection_only = bool(body.get("selection_only"))
19178
- if not model_id:
19179
- return self._json(400, {"error": "model required"})
19180
- if profile and not selection_only:
19181
- try:
19182
- _bootstrap_profile_providers(_resolve_models_profile(profile), user_id, workspace_id)
19183
- except ValueError as exc:
19184
- return self._json(400, {"ok": False, "error": str(exc)})
19185
- return self._json(
19186
- 200,
19187
- hermes_model_set(
19188
- profile,
19189
- model_id,
19190
- user_id,
19191
- workspace_id,
19192
- selection_only=selection_only,
19193
- ),
19194
- )
19195
- if path == "/api/hermes/model/apply-default":
19196
- if not _check_auth(self):
19197
- return self._json(401, {"error": "unauthorized"})
19198
- if not _role_allows(self, OWNER_ADMIN_ROLES):
19199
- return self._json(403, {"ok": False, "error": "forbidden", "required_role": "owner_or_admin"})
19200
- return self._json(200, hermes_apply_default_model_config())
19201
- if path == "/api/hermes/fallback-chain":
19202
- if not _check_auth(self):
19203
- return self._json(401, {"error": "unauthorized"})
19204
- profile = str(body.get("profile", "")).strip()
19205
- chain = body.get("chain", [])
19206
- selection_only = bool(body.get("selection_only"))
19207
- user_id = str(getattr(self, "auth_user", "") or "")
19208
- if not isinstance(chain, list):
19209
- return self._json(400, {"error": "chain must be a list"})
19210
- if not selection_only and not _role_allows(self, OWNER_ADMIN_ROLES):
19211
- return self._json(403, {"ok": False, "error": "forbidden", "required_role": "owner_or_admin"})
19212
- return self._json(
19213
- 200,
19214
- hermes_fallback_chain_set(
19215
- profile,
19216
- chain,
19217
- selection_only=selection_only,
19218
- user_id=user_id,
19219
- ),
19220
- )
19221
- if path == "/api/board":
19222
- if not _check_auth(self):
19223
- return self._json(401, {"error": "unauthorized"})
19224
- return self._json(201, board_create(body))
19225
- if path == "/api/board/update":
19226
- if not _check_auth(self):
19227
- return self._json(401, {"error": "unauthorized"})
19228
- task_id = qs.get("id", [""])[0]
19229
- if not task_id:
19230
- return self._json(400, {"error": "id required"})
19231
- return self._json(200, board_update(task_id, body))
19232
- if path == "/api/board/delete":
19233
- if not _check_auth(self):
19234
- return self._json(401, {"error": "unauthorized"})
19235
- task_id = qs.get("id", [""])[0]
19236
- if not task_id:
19237
- return self._json(400, {"error": "id required"})
19238
- board_delete(task_id)
19239
- return self._json(200, {"ok": True})
19240
- if path == "/api/content/save":
19241
- if not _check_auth(self):
19242
- return self._json(401, {"error": "unauthorized"})
19243
- rel = str(body.get("path") or "").strip()
19244
- reason = _workspace_protected_reason(rel)
19245
- if reason:
19246
- return self._json(403, {"ok": False, "error": "protected_file", "reason": reason})
19247
- content = str(body.get("content") or "")
19248
- fp = _safe_content_path(rel)
19249
- if not fp:
19250
- return self._json(400, {"error": "invalid path"})
19251
- fp.parent.mkdir(parents=True, exist_ok=True)
19252
- fp.write_text(content, encoding="utf-8")
19253
- _bump_workspace_state(fp)
19254
- return self._json(200, {"ok": True, "path": rel.replace("\\", "/")})
19255
- if path == "/api/content/delete":
19256
- if not _check_auth(self):
19257
- return self._json(401, {"error": "unauthorized"})
19258
- rel = qs.get("path", [""])[0] or str(body.get("path") or "")
19259
- reason = _workspace_protected_reason(rel)
19260
- if reason:
19261
- return self._json(403, {"ok": False, "error": "protected_file", "reason": reason})
19262
- fp = _safe_content_path(rel)
19263
- if not fp or not fp.is_file():
19264
- return self._json(404, {"error": "not found"})
19265
- fp.unlink()
19266
- _bump_workspace_state(fp.parent)
19267
- return self._json(200, {"ok": True})
19268
- if path == "/api/files/write":
19269
- if not _check_auth(self):
19270
- return self._json(401, {"error": "unauthorized"})
19271
- rel = str(body.get("path") or "").strip()
19272
- reason = _workspace_protected_reason(rel)
19273
- if reason:
19274
- return self._json(403, {"ok": False, "error": "protected_file", "reason": reason})
19275
- content = str(body.get("content") or "")
19276
- return self._json(200, file_write(rel, content))
19277
- if path == "/api/files/upload":
19278
- if not _check_auth(self):
19279
- return self._json(401, {"error": "unauthorized"})
19280
- rel = str(body.get("path") or "").strip()
19281
- reason = _workspace_protected_reason(rel)
19282
- if reason:
19283
- return self._json(403, {"ok": False, "error": "protected_file", "reason": reason})
19284
- content_b64 = str(body.get("content_base64") or body.get("content") or "")
19285
- return self._json(200, file_upload_binary(rel, content_b64))
19286
- if path == "/api/chat/dispatch":
19287
- if not _check_auth(self):
19288
- return self._json(401, {"error": "unauthorized"})
19289
- return self._json(200, chat_dispatch(body))
19290
19421
  if path == "/api/hermes/profiles/create":
19291
19422
  if not _check_auth(self):
19292
19423
  return self._json(401, {"error": "unauthorized"})