create-workframe 0.1.10 → 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.
- package/README.md +1 -1
- package/docs/security.md +85 -0
- package/package.json +1 -1
- package/scripts/apply-update-workframe.sh +7 -6
- package/scripts/sync-canonical-to-package.mjs +8 -0
- package/scripts/verify-public-deploy.sh +59 -3
- package/workframe-api/openrouter_catalog.py +4 -4
- package/workframe-api/package.json +2 -2
- package/workframe-api/profile_config_yaml.py +126 -0
- package/workframe-api/route_registry.py +330 -0
- package/workframe-api/run-typecheck.mjs +25 -0
- package/workframe-api/server.py +1523 -1392
- package/workframe-api/test_model_surface_consistency.py +116 -0
- package/workframe-api/test_profile_model_yaml.py +99 -0
- package/workframe-api/test_public_routes.py +41 -0
- package/workframe-api/test_route_registry.py +109 -0
- package/workframe-api/time-bind-steps.py +75 -0
- package/workframe-api/time-perf.py +81 -0
- package/workframe-api/time-warm.py +107 -0
- package/workframe-ui/public/assets/{arc-DjpS9a2b.js → arc-B0OFRGmJ.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-Dwwi9yA0.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-puCKmKHV.js → architectureDiagram-3BPJPVTR-DeBNltHS.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-C2P0g-eH.js → blockDiagram-GPEHLZMM-BDhCHu7I.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DdvYEITH.js → c4Diagram-AAUBKEIU-olcDYvtI.js} +1 -1
- package/workframe-ui/public/assets/channel-fNHbDpV4.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-Cxe8QsEF.js → chunk-2J33WTMH-D0obCBn_.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-DPNyTwR3.js → chunk-3OPIFGDE-DX93f-ZZ.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-XKG9gfRN.js → chunk-4BX2VUAB-BX9B5wUs.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-oPMTulgb.js → chunk-55IACEB6-C4DGc6RO.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BWwXvpa_.js → chunk-5ZQYHXKU-Crlja9Lf.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-Cml2twO_.js → chunk-727SXJPM-JYSm3szI.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DzrRBhQu.js → chunk-AQP2D5EJ-_KslxVEl.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-EZ7STU6h.js → chunk-BSJP7CBP-CpTO-jU8.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-BGTQzWYw.js → chunk-CSCIHK7Q-JGUkCmbI.js} +1 -1
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-Cm4stfRa.js → chunk-FMBD7UC4-BscBwGaC.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-exbMijjy.js → chunk-KSCS5N6A-5ZTZ0bpX.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-C75kbQwN.js → chunk-L5ZTLDWV-DxvitYbW.js} +1 -1
- package/workframe-ui/public/assets/{chunk-LZXEDZCA-CsRdBqUB.js → chunk-LZXEDZCA-DMfdMUwz.js} +2 -2
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-D84zyIu2.js → chunk-ND2GUHAM-Cayl0iV9.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-CUNNo1bj.js → chunk-NZK2D7GU-_7dyBR5G.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-DN6WEIh-.js → chunk-O5CBEL6O--xTpD0yi.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-ewTgEQK8.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-D4Tg-A0l.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-Ct5VQv2D.js → chunk-XPW4576I-CwxJQaeK.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CFAgBpEl.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CFAgBpEl.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bj4W5oMk.js → cose-bilkent-S5V4N54A-CCspENYv.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-DAjHBkAy.js → dagre-BM42HDAG-Dv8V6R60.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ch2gGFPG.js → diagram-2AECGRRQ-Da48hFPT.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzIIvgYH.js → diagram-5GNKFQAL-dTihc7-3.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-Df0Q8nv6.js → diagram-KO2AKTUF-D_Jqu699.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-D-G3ekmF.js → diagram-LMA3HP47-Bt8PtEaZ.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-TBHhT9f5.js → diagram-OG6HWLK6-BtmPjlh0.js} +1 -1
- package/workframe-ui/public/assets/{dist-CPbiDkEZ.js → dist-Cz2turIT.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-YxkUmLLV.js → erDiagram-TEJ5UH35-BxMGCflX.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-BIJTP5k-.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-kfZ7-RSd.js → flowDiagram-I6XJVG4X-ivyroIZt.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DmTx3hzR.js → ganttDiagram-6RSMTGT7-B9llwShx.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BXa0ewp3.js → gitGraph-WXDBUCRP-BFQHk4bw.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CQr2qMxU.js → gitGraphDiagram-PVQCEYII-ursegLbc.js} +1 -1
- package/workframe-ui/public/assets/index-8CuZDEIG.css +1 -0
- package/workframe-ui/public/assets/index-xS9lFekI.js +129 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-t-Qlx8zv.js → info-J43DQDTF-DQpifAsB.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CzkYofPx.js → infoDiagram-5YYISTIA-C16XP2Q7.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-djK3Oxgb.js → ishikawaDiagram-YF4QCWOH-CiJoz7rP.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CWxYXxdF.js → journeyDiagram-JHISSGLW-CVwEvvUL.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-D7leCAD9.js → kanban-definition-UN3LZRKU-CWQ6hX5i.js} +1 -1
- package/workframe-ui/public/assets/{line-BSFj2OC5.js → line-CgQsmU35.js} +1 -1
- package/workframe-ui/public/assets/{linear-pfPwnu-o.js → linear-Bxbc77dL.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-Dm2L9Ib_.js → mermaid-parser.core-C_jMeF0a.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-Bevx9bif.js → mermaid.core-4RKV9MZP.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-UkjDssmP.js → mindmap-definition-RKZ34NQL-DZ7y7Sz0.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-JO2ezH3_.js → packet-YPE3B663-B9h2I7Vq.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-DdMFsfR8.js → pie-LRSECV5Y-B2mP5uHm.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CEPHFBU1.js → pieDiagram-4H26LBE5-DyYKyKiP.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-_RF9_86k.js → quadrantDiagram-W4KKPZXB-CL_6nej-.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-Cb-YfdaA.js → radar-GUYGQ44K-CNKNDQ7S.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DBVOxmBn.js → requirementDiagram-4Y6WPE33-eWU_mhFa.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BTpxsGUA.js → sankeyDiagram-5OEKKPKP-DrjcXvEQ.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DHP_Bq3l.js → sequenceDiagram-3UESZ5HK-BHWR1xTJ.js} +1 -1
- package/workframe-ui/public/assets/{src-CY_By-r7.js → src-DfJB8kV1.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BVtp_LPR.js → stateDiagram-AJRCARHV-BGJbSd3I.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CMLuNyeC.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-D38HJhop.js → timeline-definition-PNZ67QCA-DkwoCjiZ.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-CFCG3Sc_.js → treeView-BLDUP644-GxfhiqoW.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-BeiMs_dF.js → treemap-LRROVOQU-DxrOsars.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CKMeT6PW.js → vennDiagram-CIIHVFJN-BsWhpUfr.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-BljDy8ph.js → wardley-L42UT6IY-CrFZWMHS.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DEE8D94J.js → wardleyDiagram-YWT4CUSO-DV8Xpf5I.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BRaAWV_g.js → xychartDiagram-2RQKCTM6-DqMqnwdZ.js} +1 -1
- package/workframe-ui/public/index.html +4 -5
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CvhbXEvt.js +0 -1
- package/workframe-ui/public/assets/channel-D63s_yN9.js +0 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-Dj_Yxhgo.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-AFchshb6.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-Opj8t01I.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-Opj8t01I.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-LQOYL50n.js +0 -1
- package/workframe-ui/public/assets/index-B08aShJy.css +0 -1
- package/workframe-ui/public/assets/index-DCF3W3G_.js +0 -129
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CvJhS_BJ.js +0 -1
package/workframe-api/server.py
CHANGED
|
@@ -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
|
|
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()
|
|
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
|
-
#
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
|
|
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
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
|
|
5051
|
+
applied = hermes_model_set(runtime, model_id, user_id, workspace_id)
|
|
5111
5052
|
steps.append(
|
|
5112
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10783
|
-
|
|
10784
|
-
|
|
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
|
-
|
|
10797
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
11669
|
-
"""Hermes
|
|
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
|
-
|
|
11713
|
-
|
|
11714
|
-
|
|
11715
|
-
|
|
11716
|
-
|
|
11717
|
-
|
|
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
|
-
|
|
12521
|
+
ok = int(resp.status) < 300
|
|
12577
12522
|
except Exception: # noqa: BLE001
|
|
12578
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12893
|
-
|
|
12894
|
-
if not
|
|
12895
|
-
|
|
12896
|
-
|
|
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
|
|
12913
|
-
"""
|
|
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
|
-
|
|
12923
|
+
_strip_profile_llm_env(prof)
|
|
12924
|
+
_strip_profile_action_env(prof)
|
|
12921
12925
|
else:
|
|
12922
|
-
|
|
12923
|
-
|
|
12924
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
13120
|
+
room_conn = _workframe_db()
|
|
13087
13121
|
try:
|
|
13088
|
-
room =
|
|
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(
|
|
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(
|
|
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
|
-
|
|
13148
|
+
room_conn, user_id, room_id,
|
|
13115
13149
|
)
|
|
13116
|
-
|
|
13117
|
-
|
|
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
|
|
13124
|
-
|
|
13125
|
-
|
|
13126
|
-
|
|
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
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
15355
|
-
|
|
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
|
-
|
|
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
|
|
15416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15547
|
-
|
|
15548
|
-
|
|
15549
|
-
|
|
15550
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
15631
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
15646
|
-
"provider":
|
|
15647
|
-
"billing_provider":
|
|
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
|
-
|
|
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
|
-
|
|
16390
|
-
|
|
16391
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16400
|
-
|
|
16401
|
-
if not
|
|
16402
|
-
|
|
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
|
-
|
|
16405
|
-
|
|
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
|
-
|
|
16411
|
-
|
|
16412
|
-
|
|
16413
|
-
|
|
16414
|
-
|
|
16415
|
-
|
|
16416
|
-
|
|
16417
|
-
|
|
16418
|
-
|
|
16419
|
-
|
|
16420
|
-
|
|
16421
|
-
|
|
16422
|
-
|
|
16423
|
-
|
|
16424
|
-
|
|
16425
|
-
|
|
16426
|
-
|
|
16427
|
-
|
|
16428
|
-
|
|
16429
|
-
|
|
16430
|
-
|
|
16431
|
-
|
|
16432
|
-
|
|
16433
|
-
|
|
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"})
|