create-workframe 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/create-workframe.js +2 -2
- package/bin/workframe.js +130 -2
- package/docs/security.md +85 -0
- package/package.json +1 -1
- package/scripts/bundle-workframe-ui.mjs +9 -0
- package/scripts/sync-canonical-to-package.mjs +9 -0
- package/scripts/verify-public-deploy.sh +121 -3
- package/workframe-api/action_proxy.py +23 -17
- package/workframe-api/activity_feed.py +798 -0
- package/workframe-api/api_meta.py +157 -0
- package/workframe-api/auth_gate.py +566 -0
- package/workframe-api/avatar_registry.py +343 -0
- package/workframe-api/broker_audit.py +164 -0
- package/workframe-api/cell_authority.py +231 -0
- package/workframe-api/chat_bind.py +319 -0
- package/workframe-api/chat_sessions.py +509 -0
- package/workframe-api/chat_stream.py +694 -0
- package/workframe-api/cleanup_dogfood_smoke.py +252 -0
- package/workframe-api/credential_broker.py +162 -0
- package/workframe-api/credential_resolve.py +202 -0
- package/workframe-api/credential_store.py +245 -0
- package/workframe-api/crew_registry.py +250 -0
- package/workframe-api/db_schema.py +755 -0
- package/workframe-api/docker_gateway.py +166 -0
- package/workframe-api/doctor_runtime.py +109 -0
- package/workframe-api/domain/__init__.py +47 -0
- package/workframe-api/domain/entities.py +252 -0
- package/workframe-api/domain/schema/workframe-domain.schema.json +278 -0
- package/workframe-api/egress_policy.py +42 -0
- package/workframe-api/handler_modules/__init__.py +17 -0
- package/workframe-api/handler_modules/handler_admin.py +542 -0
- package/workframe-api/handler_modules/handler_auth.py +512 -0
- package/workframe-api/handler_modules/handler_chat.py +641 -0
- package/workframe-api/handler_modules/handler_install.py +178 -0
- package/workframe-api/handler_modules/handler_provider.py +325 -0
- package/workframe-api/handler_modules/handler_workspace.py +1420 -0
- package/workframe-api/health_monitor.py +80 -0
- package/workframe-api/hermes_admin.py +756 -0
- package/workframe-api/hermes_profiles.py +1328 -0
- package/workframe-api/install_api.py +123 -0
- package/workframe-api/kanban_cron.py +473 -0
- package/workframe-api/lane_bindings.py +423 -0
- package/workframe-api/llm_proxy.py +17 -43
- package/workframe-api/mention_helpers.py +180 -0
- package/workframe-api/mention_invoke.py +431 -0
- package/workframe-api/model_surface.py +1139 -0
- package/workframe-api/oauth_pending.py +85 -0
- package/workframe-api/oauth_redirect.py +381 -0
- package/workframe-api/openrouter_catalog.py +4 -4
- package/workframe-api/package.json +2 -2
- package/workframe-api/profile_api_lifecycle.py +66 -0
- package/workframe-api/profile_config_yaml.py +126 -0
- package/workframe-api/profile_gateway.py +436 -0
- package/workframe-api/provider_bindings.py +673 -0
- package/workframe-api/provider_bootstrap.py +347 -0
- package/workframe-api/provider_catalog.py +180 -0
- package/workframe-api/rooms.py +1613 -0
- package/workframe-api/route_registry.py +470 -0
- package/workframe-api/run-typecheck.mjs +26 -0
- package/workframe-api/run_authority.py +287 -0
- package/workframe-api/run_ledger.py +470 -0
- package/workframe-api/run_surface_wiring.py +344 -0
- package/workframe-api/runtime_cohort.py +752 -0
- package/workframe-api/runtime_tokens.py +127 -0
- package/workframe-api/server.py +1376 -19305
- package/workframe-api/snapshot_feed.py +49 -0
- package/workframe-api/stack_config.py +33 -1
- package/workframe-api/supervisor_client.py +154 -0
- package/workframe-api/test_api_meta_build_stamp.py +34 -0
- package/workframe-api/test_cell_authority.py +79 -0
- package/workframe-api/test_chat_bind.py +48 -0
- package/workframe-api/test_credential_lease_matrix.py +171 -0
- package/workframe-api/test_credential_resolve.py +51 -0
- package/workframe-api/test_credential_store.py +27 -0
- package/workframe-api/test_dogfood_flows.py +346 -0
- package/workframe-api/test_domain_entities.py +152 -0
- package/workframe-api/test_exception_hygiene.py +12 -0
- package/workframe-api/test_hermes_admin.py +72 -0
- package/workframe-api/test_install_wizard_resume.py +78 -0
- package/workframe-api/test_local_bootstrap.py +67 -0
- package/workframe-api/test_mention_helpers.py +35 -0
- package/workframe-api/test_mention_invoke.py +26 -0
- package/workframe-api/test_model_surface_consistency.py +117 -0
- package/workframe-api/test_oauth_pending.py +41 -0
- package/workframe-api/test_profile_model_yaml.py +99 -0
- package/workframe-api/test_provider_bindings.py +52 -0
- package/workframe-api/test_provider_catalog.py +28 -0
- package/workframe-api/test_public_routes.py +41 -0
- package/workframe-api/test_route_registry.py +245 -0
- package/workframe-api/test_run_authority.py +112 -0
- package/workframe-api/test_run_ledger.py +157 -0
- package/workframe-api/test_run_surface_wiring.py +130 -0
- package/workframe-api/test_runtime_cohort.py +85 -0
- package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
- package/workframe-api/test_server_reexports.py +99 -0
- package/workframe-api/test_stack_config_install_smtp.py +7 -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-api/turn_credentials.py +28 -13
- package/workframe-api/turn_overlay.py +715 -0
- package/workframe-api/updates.py +16 -1
- package/workframe-api/user_prefs.py +387 -0
- package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
- package/workframe-api/workspace_bootstrap.py +536 -0
- package/workframe-api/workspace_files.py +344 -0
- package/workframe-api/workspace_messaging.py +206 -0
- package/workframe-api/zk_auth.py +3 -2
- package/workframe-supervisor/server.py +10 -1
- package/workframe-supervisor/test_supervisor_negative.py +75 -0
- package/workframe-ui/public/assets/{arc-DjpS9a2b.js → arc-aUUffclm.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-puCKmKHV.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-C2P0g-eH.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DdvYEITH.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
- package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-Cxe8QsEF.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-DPNyTwR3.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-XKG9gfRN.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-oPMTulgb.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BWwXvpa_.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-Cml2twO_.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DzrRBhQu.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-EZ7STU6h.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-BGTQzWYw.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-Cm4stfRa.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-exbMijjy.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-C75kbQwN.js → chunk-L5ZTLDWV-BLZrdIwa.js} +1 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-DisG0XJT.js +2 -0
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-D84zyIu2.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-CUNNo1bj.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-DN6WEIh-.js → chunk-O5CBEL6O-DDN-Ifon.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-EVQ0VMd5.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-k_-ZsLOS.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-Ct5VQv2D.js → chunk-XPW4576I-B4_iYZ6r.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-W9WVQsby.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-W9WVQsby.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bj4W5oMk.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-DAjHBkAy.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ch2gGFPG.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzIIvgYH.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-Df0Q8nv6.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-D-G3ekmF.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-TBHhT9f5.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
- package/workframe-ui/public/assets/{dist-CPbiDkEZ.js → dist-mQWmB3z6.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-YxkUmLLV.js → erDiagram-TEJ5UH35-__40xO-y.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Cm1uRTxU.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-kfZ7-RSd.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DmTx3hzR.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BXa0ewp3.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CQr2qMxU.js → gitGraphDiagram-PVQCEYII-DMTxFJxn.js} +1 -1
- package/workframe-ui/public/assets/index-BdiM4-lI.js +130 -0
- package/workframe-ui/public/assets/index-Fnad47vV.css +1 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-t-Qlx8zv.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CzkYofPx.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-djK3Oxgb.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CWxYXxdF.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-D7leCAD9.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
- package/workframe-ui/public/assets/{line-BSFj2OC5.js → line-BTCEHb7l.js} +1 -1
- package/workframe-ui/public/assets/{linear-pfPwnu-o.js → linear-CkbydpnK.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-Dm2L9Ib_.js → mermaid-parser.core-DvrtArpk.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-Bevx9bif.js → mermaid.core-yj_1x_qj.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-UkjDssmP.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-JO2ezH3_.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-DdMFsfR8.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CEPHFBU1.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-_RF9_86k.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-Cb-YfdaA.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DBVOxmBn.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BTpxsGUA.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DHP_Bq3l.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
- package/workframe-ui/public/assets/{src-CY_By-r7.js → src-D6mvlP96.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BVtp_LPR.js → stateDiagram-AJRCARHV-CxaTXs02.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CXikj9xi.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-D38HJhop.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-CFCG3Sc_.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-BeiMs_dF.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CKMeT6PW.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-BljDy8ph.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DEE8D94J.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BRaAWV_g.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
- package/workframe-ui/public/index.html +7 -6
- package/workframe-ui/public/workframe-build.json +5 -0
- 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-LZXEDZCA-CsRdBqUB.js +0 -2
- 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
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
"""WF-032 extract: user provider bindings and OAuth LLM connection state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
import shlex
|
|
9
|
+
import sqlite3
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import provider_bootstrap
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _srv():
|
|
19
|
+
import server as srv
|
|
20
|
+
|
|
21
|
+
return srv
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_load_profile_auth_json = provider_bootstrap._load_profile_auth_json
|
|
25
|
+
|
|
26
|
+
def _user_provider_bindings(user_id: str) -> dict[str, dict[str, Any]]:
|
|
27
|
+
by_provider: dict[str, dict[str, Any]] = {}
|
|
28
|
+
try:
|
|
29
|
+
conn = sqlite3.connect(str(_srv()._workframe_db_path()), timeout=3.0)
|
|
30
|
+
conn.row_factory = sqlite3.Row
|
|
31
|
+
rows = conn.execute(
|
|
32
|
+
"""SELECT id, provider, credential_type, credential_ref, label, is_active, updated_at
|
|
33
|
+
FROM credential_bindings
|
|
34
|
+
WHERE user_id = ? AND deleted_at IS NULL AND is_active = 1
|
|
35
|
+
ORDER BY updated_at DESC, created_at DESC""",
|
|
36
|
+
(user_id,),
|
|
37
|
+
).fetchall()
|
|
38
|
+
conn.close()
|
|
39
|
+
except sqlite3.Error:
|
|
40
|
+
rows = []
|
|
41
|
+
for row in rows:
|
|
42
|
+
provider = str(row["provider"] or "").lower()
|
|
43
|
+
if provider and provider not in by_provider:
|
|
44
|
+
by_provider[provider] = dict(row)
|
|
45
|
+
return by_provider
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_DEVICE_OAUTH_PROVIDER_IDS: frozenset[str] = frozenset({"codex", "nous"})
|
|
49
|
+
_oauth_device_lock = threading.Lock()
|
|
50
|
+
_oauth_device_sessions: dict[str, dict[str, Any]] = {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _strip_ansi(text: str) -> str:
|
|
54
|
+
return re.sub(r"\x1b\[[0-9;]*m", "", str(text or ""))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _hermes_auth_id_for_spec(spec: dict[str, Any]) -> str:
|
|
58
|
+
return str(spec.get("hermes_auth_id") or spec.get("id") or "").strip()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _hermes_oauth_auth_keys(hermes_auth_id: str) -> set[str]:
|
|
62
|
+
raw = str(hermes_auth_id or "").strip().lower()
|
|
63
|
+
if not raw:
|
|
64
|
+
return set()
|
|
65
|
+
keys = {raw, raw.replace("-", ""), raw.replace("-", "_")}
|
|
66
|
+
if "-" in raw:
|
|
67
|
+
keys.add(raw.split("-")[-1])
|
|
68
|
+
return {key for key in keys if key}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _oauth_llm_provider_spec(provider: str) -> dict[str, Any] | None:
|
|
72
|
+
spec = _srv()._catalog_provider_for_llm(provider)
|
|
73
|
+
if spec and str(spec.get("connect_mode") or "") == "oauth":
|
|
74
|
+
return spec
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _auth_json_has_oauth_material(data: dict[str, Any]) -> bool:
|
|
79
|
+
providers = data.get("providers")
|
|
80
|
+
if isinstance(providers, dict) and providers:
|
|
81
|
+
return True
|
|
82
|
+
pool = data.get("credential_pool")
|
|
83
|
+
if isinstance(pool, dict) and any(isinstance(entries, list) and entries for entries in pool.values()):
|
|
84
|
+
return True
|
|
85
|
+
creds = data.get("credentials")
|
|
86
|
+
return isinstance(creds, list) and bool(creds)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _extract_oauth_block_from_auth(loaded: dict[str, Any], hermes_auth_id: str) -> dict[str, Any] | None:
|
|
90
|
+
keys = _hermes_oauth_auth_keys(hermes_auth_id)
|
|
91
|
+
providers = loaded.get("providers")
|
|
92
|
+
if isinstance(providers, dict):
|
|
93
|
+
for key, entry in providers.items():
|
|
94
|
+
if str(key).lower() in keys and isinstance(entry, dict):
|
|
95
|
+
tokens = entry.get("tokens")
|
|
96
|
+
if isinstance(tokens, dict) and any(
|
|
97
|
+
str(tokens.get(field) or "").strip()
|
|
98
|
+
for field in ("access_token", "refresh_token", "api_key", "id_token")
|
|
99
|
+
):
|
|
100
|
+
return entry
|
|
101
|
+
if entry:
|
|
102
|
+
return entry
|
|
103
|
+
creds = loaded.get("credentials")
|
|
104
|
+
if isinstance(creds, list):
|
|
105
|
+
for row in creds:
|
|
106
|
+
if not isinstance(row, dict):
|
|
107
|
+
continue
|
|
108
|
+
pid = str(row.get("provider") or row.get("id") or "").lower()
|
|
109
|
+
if pid in keys:
|
|
110
|
+
return row
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _merge_oauth_auth_into_profile(
|
|
115
|
+
auth: dict[str, Any],
|
|
116
|
+
user_auth: dict[str, Any],
|
|
117
|
+
hermes_auth_id: str,
|
|
118
|
+
) -> bool:
|
|
119
|
+
keys = _hermes_oauth_auth_keys(hermes_auth_id)
|
|
120
|
+
changed = False
|
|
121
|
+
block = _extract_oauth_block_from_auth(user_auth, hermes_auth_id)
|
|
122
|
+
if isinstance(block, dict):
|
|
123
|
+
merged = auth.get("providers") if isinstance(auth.get("providers"), dict) else {}
|
|
124
|
+
merged[hermes_auth_id] = block
|
|
125
|
+
auth["providers"] = merged
|
|
126
|
+
changed = True
|
|
127
|
+
user_pool = user_auth.get("credential_pool")
|
|
128
|
+
if isinstance(user_pool, dict):
|
|
129
|
+
pool = auth.get("credential_pool")
|
|
130
|
+
if not isinstance(pool, dict):
|
|
131
|
+
pool = {}
|
|
132
|
+
auth["credential_pool"] = pool
|
|
133
|
+
for key, entries in user_pool.items():
|
|
134
|
+
if str(key).lower() in keys and isinstance(entries, list) and entries:
|
|
135
|
+
pool[hermes_auth_id] = entries
|
|
136
|
+
changed = True
|
|
137
|
+
return changed
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _sync_oauth_llm_to_profile(profile: str, user_id: str, provider: str) -> bool:
|
|
141
|
+
spec = _oauth_llm_provider_spec(provider)
|
|
142
|
+
if not spec:
|
|
143
|
+
return False
|
|
144
|
+
hermes_auth_id = _hermes_auth_id_for_spec(spec)
|
|
145
|
+
if not _hermes_oauth_tokens_present(user_id, hermes_auth_id):
|
|
146
|
+
return False
|
|
147
|
+
prof = _srv().resolve_hermes_profile(profile)
|
|
148
|
+
auth = _load_profile_auth_json(prof)
|
|
149
|
+
if _extract_oauth_block_from_auth(auth, hermes_auth_id):
|
|
150
|
+
# ponytail: steady-state turns — skip user auth read when profile already has oauth
|
|
151
|
+
return False
|
|
152
|
+
user_auth = _load_user_hermes_auth(user_id)
|
|
153
|
+
if not isinstance(user_auth, dict):
|
|
154
|
+
return False
|
|
155
|
+
auth_path = _srv()._profile_dir(prof) / "auth.json"
|
|
156
|
+
if not _merge_oauth_auth_into_profile(auth, user_auth, hermes_auth_id):
|
|
157
|
+
return False
|
|
158
|
+
auth["version"] = 1
|
|
159
|
+
auth["updated_at"] = _srv()._utc_now()
|
|
160
|
+
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
auth_path.write_text(json.dumps(auth, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
162
|
+
_srv()._publish_profile_gateway_secrets(prof)
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _hermes_oauth_tokens_present(user_id: str, hermes_auth_id: str) -> bool:
|
|
167
|
+
"""True when Hermes auth.json has live OAuth tokens for a provider."""
|
|
168
|
+
hermes_auth_id = str(hermes_auth_id or "").strip()
|
|
169
|
+
if not hermes_auth_id:
|
|
170
|
+
return False
|
|
171
|
+
loaded = _load_user_hermes_auth(user_id)
|
|
172
|
+
if not isinstance(loaded, dict):
|
|
173
|
+
return False
|
|
174
|
+
if _extract_oauth_block_from_auth(loaded, hermes_auth_id):
|
|
175
|
+
return True
|
|
176
|
+
keys = _hermes_oauth_auth_keys(hermes_auth_id)
|
|
177
|
+
pool = loaded.get("credential_pool")
|
|
178
|
+
if isinstance(pool, dict):
|
|
179
|
+
for key, entries in pool.items():
|
|
180
|
+
if str(key).lower() in keys and isinstance(entries, list) and entries:
|
|
181
|
+
return True
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _load_user_hermes_auth(user_id: str) -> dict[str, Any] | None:
|
|
186
|
+
auth_path = _srv()._user_hermes_auth_path(user_id)
|
|
187
|
+
loaded: dict[str, Any] | None = None
|
|
188
|
+
if auth_path.is_file():
|
|
189
|
+
try:
|
|
190
|
+
data = json.loads(auth_path.read_text(encoding="utf-8"))
|
|
191
|
+
if isinstance(data, dict):
|
|
192
|
+
loaded = data
|
|
193
|
+
except (OSError, json.JSONDecodeError):
|
|
194
|
+
loaded = None
|
|
195
|
+
if isinstance(loaded, dict) and _auth_json_has_oauth_material(loaded):
|
|
196
|
+
return loaded
|
|
197
|
+
rel = f"profiles/{_srv()._user_hermes_dir_slug(user_id)}/auth.json"
|
|
198
|
+
text = _read_gateway_data_file(rel)
|
|
199
|
+
if text.strip():
|
|
200
|
+
try:
|
|
201
|
+
data = json.loads(text)
|
|
202
|
+
if isinstance(data, dict):
|
|
203
|
+
return data
|
|
204
|
+
except json.JSONDecodeError:
|
|
205
|
+
pass
|
|
206
|
+
return loaded
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _read_gateway_data_file(rel_path: str) -> str:
|
|
210
|
+
rel_path = str(rel_path or "").strip().lstrip("/")
|
|
211
|
+
if not rel_path or ".." in rel_path.split("/"):
|
|
212
|
+
return ""
|
|
213
|
+
host_path = _srv().HERMES_DATA / rel_path
|
|
214
|
+
if host_path.is_file():
|
|
215
|
+
try:
|
|
216
|
+
return host_path.read_text(encoding="utf-8", errors="replace")
|
|
217
|
+
except OSError:
|
|
218
|
+
return ""
|
|
219
|
+
full = f"/opt/data/{rel_path}"
|
|
220
|
+
try:
|
|
221
|
+
code, out = _srv()._gateway_container_exec(["cat", full])
|
|
222
|
+
return out if code == 0 else ""
|
|
223
|
+
except Exception:
|
|
224
|
+
return ""
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _user_provider_connected(user_id: str, spec: dict[str, Any]) -> bool:
|
|
228
|
+
"""Connected = this user's credential resolves with a live secret (no stack/install bleed)."""
|
|
229
|
+
if str(spec.get("connect_mode") or "") == "oauth":
|
|
230
|
+
if _hermes_oauth_tokens_present(user_id, _hermes_auth_id_for_spec(spec)):
|
|
231
|
+
return True
|
|
232
|
+
provider_id = str(spec["id"])
|
|
233
|
+
if str(spec.get("category") or "") == "llm":
|
|
234
|
+
resolved = _srv()._resolve_credential(user_id, "", provider_id, user_only=True)
|
|
235
|
+
return bool(resolved and _srv()._credential_secret(resolved, user_id))
|
|
236
|
+
bindings = _user_provider_bindings(user_id)
|
|
237
|
+
env_keys = _srv()._user_auth_env_keys(user_id)
|
|
238
|
+
return _srv()._provider_connected_for_user(user_id, spec, bindings, env_keys)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def list_user_providers(user_id: str, workspace_id: str = "") -> dict[str, Any]:
|
|
242
|
+
bindings = _user_provider_bindings(user_id)
|
|
243
|
+
workspace = str(workspace_id or "").strip()
|
|
244
|
+
providers: list[dict[str, Any]] = []
|
|
245
|
+
for spec in _srv().PROVIDER_CONNECT_CATALOG:
|
|
246
|
+
provider_id = str(spec["id"])
|
|
247
|
+
env_var = str(spec.get("env_var") or "")
|
|
248
|
+
binding = bindings.get(provider_id)
|
|
249
|
+
connected = _user_provider_connected(user_id, spec)
|
|
250
|
+
source: str | None = "user" if connected else None
|
|
251
|
+
if not connected and workspace and str(spec.get("category") or "") == "llm":
|
|
252
|
+
resolved = _srv()._resolve_credential("", workspace, provider_id)
|
|
253
|
+
if resolved and _srv()._credential_secret(resolved, user_id):
|
|
254
|
+
connected = True
|
|
255
|
+
source = "workspace"
|
|
256
|
+
oauth_configured = None
|
|
257
|
+
if str(spec.get("connect_mode") or "") == "oauth":
|
|
258
|
+
oauth_name = str(spec.get("oauth_provider") or provider_id)
|
|
259
|
+
if oauth_name == "github":
|
|
260
|
+
oauth_configured = _srv()._github_oauth_configured(workspace_id)
|
|
261
|
+
elif oauth_name == "stripe":
|
|
262
|
+
oauth_configured = _srv()._stripe_connect_configured()
|
|
263
|
+
providers.append({
|
|
264
|
+
**spec,
|
|
265
|
+
"connected": connected,
|
|
266
|
+
"source": source,
|
|
267
|
+
"credential_id": str(binding["id"]) if binding else None,
|
|
268
|
+
"credential_ref": str(binding["credential_ref"]) if binding else (f"env:{env_var}" if env_var and connected else None),
|
|
269
|
+
"profile_home": str(_srv()._user_hermes_home(user_id)),
|
|
270
|
+
"oauth_configured": oauth_configured,
|
|
271
|
+
"user_only": bool(spec.get("user_only")),
|
|
272
|
+
})
|
|
273
|
+
return {"ok": True, "providers": providers, "profile_home": str(_srv()._user_hermes_home(user_id))}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def disconnect_user_credential(user_id: str, credential_id: str) -> dict[str, Any]:
|
|
277
|
+
try:
|
|
278
|
+
conn = sqlite3.connect(str(_srv()._workframe_db_path()), timeout=3.0)
|
|
279
|
+
conn.row_factory = sqlite3.Row
|
|
280
|
+
row = conn.execute(
|
|
281
|
+
"""SELECT id, provider, credential_type, credential_ref
|
|
282
|
+
FROM credential_bindings
|
|
283
|
+
WHERE id = ? AND user_id = ? AND deleted_at IS NULL""",
|
|
284
|
+
(credential_id, user_id),
|
|
285
|
+
).fetchone()
|
|
286
|
+
if not row:
|
|
287
|
+
conn.close()
|
|
288
|
+
return {"ok": False, "error": "credential_not_found"}
|
|
289
|
+
cred_ref = str(row["credential_ref"] or "")
|
|
290
|
+
env_var = cred_ref[4:] if cred_ref.startswith("env:") else ""
|
|
291
|
+
if not env_var:
|
|
292
|
+
spec = _srv()._catalog_provider(str(row["provider"]))
|
|
293
|
+
env_var = str((spec or {}).get("env_var") or "")
|
|
294
|
+
if env_var:
|
|
295
|
+
_srv()._remove_env_secret(_srv()._user_hermes_env_path(user_id), env_var)
|
|
296
|
+
_srv()._remove_auth_metadata(_srv()._user_hermes_auth_path(user_id), cred_ref or f"env:{env_var}")
|
|
297
|
+
now = _srv()._utc_now()
|
|
298
|
+
conn.execute(
|
|
299
|
+
"UPDATE credential_bindings SET is_active = 0, deleted_at = ?, updated_at = ? WHERE id = ?",
|
|
300
|
+
(now, now, credential_id),
|
|
301
|
+
)
|
|
302
|
+
conn.commit()
|
|
303
|
+
conn.close()
|
|
304
|
+
except sqlite3.Error as exc:
|
|
305
|
+
return {"ok": False, "error": f"db_error: {exc}"}
|
|
306
|
+
return {"ok": True, "credential_id": credential_id, "provider": str(row["provider"])}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _remove_hermes_oauth_provider(user_id: str, hermes_auth_id: str) -> None:
|
|
310
|
+
hermes_auth_id = str(hermes_auth_id or "").strip()
|
|
311
|
+
if not hermes_auth_id:
|
|
312
|
+
return
|
|
313
|
+
auth_path = _srv()._user_hermes_auth_path(user_id)
|
|
314
|
+
if auth_path.is_file():
|
|
315
|
+
try:
|
|
316
|
+
loaded = json.loads(auth_path.read_text(encoding="utf-8"))
|
|
317
|
+
except (OSError, json.JSONDecodeError):
|
|
318
|
+
loaded = {}
|
|
319
|
+
if isinstance(loaded, dict):
|
|
320
|
+
providers = loaded.get("providers")
|
|
321
|
+
if isinstance(providers, dict):
|
|
322
|
+
for key in list(providers.keys()):
|
|
323
|
+
if key.lower() in {hermes_auth_id.lower(), hermes_auth_id.replace("-", "").lower()}:
|
|
324
|
+
providers.pop(key, None)
|
|
325
|
+
pool = loaded.get("credential_pool")
|
|
326
|
+
if isinstance(pool, dict):
|
|
327
|
+
for key in list(pool.keys()):
|
|
328
|
+
if key.lower() in {hermes_auth_id.lower(), hermes_auth_id.replace("-", "").lower()}:
|
|
329
|
+
pool.pop(key, None)
|
|
330
|
+
loaded["updated_at"] = _srv()._utc_now()
|
|
331
|
+
auth_path.write_text(json.dumps(loaded, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
332
|
+
user_part = re.sub(r"[^a-z0-9]+", "-", _srv()._user_hermes_dir_slug(user_id).lower()).strip("-")[:20] or "user"
|
|
333
|
+
prefix = f"u-{user_part}-"
|
|
334
|
+
profiles_dir = _srv().HERMES_DATA / "profiles"
|
|
335
|
+
if profiles_dir.is_dir():
|
|
336
|
+
for prof_dir in profiles_dir.iterdir():
|
|
337
|
+
if not prof_dir.is_dir() or not prof_dir.name.startswith(prefix):
|
|
338
|
+
continue
|
|
339
|
+
prof_auth = prof_dir / "auth.json"
|
|
340
|
+
if not prof_auth.is_file():
|
|
341
|
+
continue
|
|
342
|
+
try:
|
|
343
|
+
pdata = json.loads(prof_auth.read_text(encoding="utf-8"))
|
|
344
|
+
except (OSError, json.JSONDecodeError):
|
|
345
|
+
continue
|
|
346
|
+
if not isinstance(pdata, dict):
|
|
347
|
+
continue
|
|
348
|
+
pproviders = pdata.get("providers")
|
|
349
|
+
if isinstance(pproviders, dict):
|
|
350
|
+
for key in list(pproviders.keys()):
|
|
351
|
+
if key.lower() in {hermes_auth_id.lower(), hermes_auth_id.replace("-", "").lower()}:
|
|
352
|
+
pproviders.pop(key, None)
|
|
353
|
+
pdata["updated_at"] = _srv()._utc_now()
|
|
354
|
+
prof_auth.write_text(json.dumps(pdata, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def disconnect_user_provider(user_id: str, provider_id: str) -> dict[str, Any]:
|
|
358
|
+
spec = _srv()._catalog_provider(provider_id)
|
|
359
|
+
if not spec:
|
|
360
|
+
return {"ok": False, "error": "provider_not_found"}
|
|
361
|
+
env_var = str(spec.get("env_var") or "")
|
|
362
|
+
if env_var:
|
|
363
|
+
_srv()._remove_env_secret(_srv()._user_hermes_env_path(user_id), env_var)
|
|
364
|
+
_srv()._remove_auth_metadata(_srv()._user_hermes_auth_path(user_id), f"env:{env_var}")
|
|
365
|
+
if str(spec.get("connect_mode") or "") == "oauth":
|
|
366
|
+
_remove_hermes_oauth_provider(user_id, _hermes_auth_id_for_spec(spec))
|
|
367
|
+
binding = _user_provider_bindings(user_id).get(str(spec["id"]))
|
|
368
|
+
if binding:
|
|
369
|
+
return disconnect_user_credential(user_id, str(binding["id"]))
|
|
370
|
+
return {"ok": True, "provider": provider_id, "disconnected": True}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _hermes_user_shell(user_id: str, script: str, *, timeout: float = 30.0) -> tuple[int, str]:
|
|
374
|
+
home = _srv()._hermes_user_home_container(user_id)
|
|
375
|
+
_srv()._user_hermes_home(user_id).mkdir(parents=True, exist_ok=True)
|
|
376
|
+
shell = (
|
|
377
|
+
f"export HERMES_HOME={shlex.quote(home)} HOME={shlex.quote(home)}; "
|
|
378
|
+
f"mkdir -p {shlex.quote(home)}; cd {shlex.quote(home)}; {script}"
|
|
379
|
+
)
|
|
380
|
+
if _srv().SECURE_MODE:
|
|
381
|
+
if not _srv()._supervisor_ready():
|
|
382
|
+
raise RuntimeError(
|
|
383
|
+
"Docker socket access is disabled in _srv().SECURE_MODE; "
|
|
384
|
+
"configure WORKFRAME_SUPERVISOR_URL and WORKFRAME_SUPERVISOR_TOKEN"
|
|
385
|
+
)
|
|
386
|
+
status, data = _srv()._supervisor_request(
|
|
387
|
+
"POST",
|
|
388
|
+
"/v1/gateway.container_exec",
|
|
389
|
+
{"args": ["sh", "-lc", shell]},
|
|
390
|
+
timeout=timeout,
|
|
391
|
+
)
|
|
392
|
+
if status >= 300:
|
|
393
|
+
err = data.get("error") if isinstance(data, dict) else str(data)
|
|
394
|
+
raise ValueError(err or f"supervisor gateway.container_exec failed ({status})")
|
|
395
|
+
if not isinstance(data, dict):
|
|
396
|
+
raise ValueError("supervisor gateway.container_exec returned invalid payload")
|
|
397
|
+
exit_code = data.get("exit_code")
|
|
398
|
+
try:
|
|
399
|
+
code = int(exit_code if exit_code is not None else 1)
|
|
400
|
+
except (TypeError, ValueError):
|
|
401
|
+
code = 1
|
|
402
|
+
return code, str(data.get("output") or "")
|
|
403
|
+
return _srv()._docker_exec(_srv().GATEWAY_CONTAINER_NAME, ["sh", "-lc", shell])
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _parse_device_oauth_log(text: str) -> dict[str, str | None]:
|
|
407
|
+
clean = _strip_ansi(text)
|
|
408
|
+
verification_uri = None
|
|
409
|
+
for match in re.finditer(r"https?://[^\s\]\)>\"']+", clean):
|
|
410
|
+
url = match.group(0).rstrip(".,;)")
|
|
411
|
+
if any(token in url.lower() for token in ("/device", "/portal", "auth.openai.com", "nousresearch")):
|
|
412
|
+
verification_uri = url
|
|
413
|
+
break
|
|
414
|
+
if not verification_uri:
|
|
415
|
+
url_match = re.search(
|
|
416
|
+
r"Open this URL.*?\n\s*(\S+)",
|
|
417
|
+
clean,
|
|
418
|
+
flags=re.IGNORECASE | re.DOTALL,
|
|
419
|
+
)
|
|
420
|
+
if url_match:
|
|
421
|
+
verification_uri = url_match.group(1).strip()
|
|
422
|
+
user_code = None
|
|
423
|
+
code_match = re.search(
|
|
424
|
+
r"Enter this code.*?\n\s*(\S+)",
|
|
425
|
+
clean,
|
|
426
|
+
flags=re.IGNORECASE | re.DOTALL,
|
|
427
|
+
)
|
|
428
|
+
if code_match:
|
|
429
|
+
user_code = code_match.group(1).strip()
|
|
430
|
+
if not user_code:
|
|
431
|
+
bare = re.search(r"\b([A-Z0-9]{4,8}-[A-Z0-9]{4,8})\b", clean)
|
|
432
|
+
if bare:
|
|
433
|
+
user_code = bare.group(1)
|
|
434
|
+
return {"verification_uri": verification_uri, "user_code": user_code}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _sync_user_oauth_provider_to_runtime_profiles(user_id: str, hermes_auth_id: str) -> None:
|
|
438
|
+
if not _hermes_oauth_tokens_present(user_id, hermes_auth_id):
|
|
439
|
+
return
|
|
440
|
+
user_auth = _load_user_hermes_auth(user_id)
|
|
441
|
+
if not isinstance(user_auth, dict):
|
|
442
|
+
return
|
|
443
|
+
user_part = re.sub(r"[^a-z0-9]+", "-", _srv()._user_hermes_dir_slug(user_id).lower()).strip("-")[:20] or "user"
|
|
444
|
+
prefix = f"u-{user_part}-"
|
|
445
|
+
profiles_dir = _srv().HERMES_DATA / "profiles"
|
|
446
|
+
if not profiles_dir.is_dir():
|
|
447
|
+
return
|
|
448
|
+
for prof_dir in profiles_dir.iterdir():
|
|
449
|
+
if not prof_dir.is_dir() or not prof_dir.name.startswith(prefix):
|
|
450
|
+
continue
|
|
451
|
+
auth_path = prof_dir / "auth.json"
|
|
452
|
+
auth = _load_profile_auth_json(prof_dir.name)
|
|
453
|
+
user_auth = _load_user_hermes_auth(user_id)
|
|
454
|
+
if not isinstance(user_auth, dict):
|
|
455
|
+
continue
|
|
456
|
+
if not _merge_oauth_auth_into_profile(auth, user_auth, hermes_auth_id):
|
|
457
|
+
continue
|
|
458
|
+
auth["version"] = 1
|
|
459
|
+
auth["updated_at"] = _srv()._utc_now()
|
|
460
|
+
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
|
461
|
+
auth_path.write_text(json.dumps(auth, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
462
|
+
_srv()._publish_profile_gateway_secrets(prof_dir.name)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _finalize_hermes_device_oauth(user_id: str, provider_id: str, spec: dict[str, Any]) -> None:
|
|
466
|
+
hermes_auth_id = _hermes_auth_id_for_spec(spec)
|
|
467
|
+
_sync_user_oauth_provider_to_runtime_profiles(user_id, hermes_auth_id)
|
|
468
|
+
_srv()._bootstrap_model_after_llm_connect(user_id, "", provider_id)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _device_oauth_session_get(session_id: str) -> dict[str, Any] | None:
|
|
472
|
+
with _oauth_device_lock:
|
|
473
|
+
row = _oauth_device_sessions.get(str(session_id or "").strip())
|
|
474
|
+
return dict(row) if isinstance(row, dict) else None
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _device_oauth_session_patch(session_id: str, patch: dict[str, Any]) -> None:
|
|
478
|
+
with _oauth_device_lock:
|
|
479
|
+
row = _oauth_device_sessions.get(session_id)
|
|
480
|
+
if isinstance(row, dict):
|
|
481
|
+
row.update(patch)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _spawn_hermes_device_oauth(user_id: str, hermes_auth_id: str, log_container: str) -> tuple[int, str]:
|
|
485
|
+
"""Start long-running `hermes auth add` detached in gateway (survives exec return)."""
|
|
486
|
+
home = _srv()._hermes_user_home_container(user_id)
|
|
487
|
+
_srv()._user_hermes_home(user_id).mkdir(parents=True, exist_ok=True)
|
|
488
|
+
if _srv().SECURE_MODE and _srv()._supervisor_ready():
|
|
489
|
+
status, data = _srv()._supervisor_request(
|
|
490
|
+
"POST",
|
|
491
|
+
"/v1/hermes.device_oauth_start",
|
|
492
|
+
{"home": home, "hermes_auth_id": hermes_auth_id, "log_path": log_container},
|
|
493
|
+
timeout=30.0,
|
|
494
|
+
)
|
|
495
|
+
if status >= 300:
|
|
496
|
+
err = data.get("error") if isinstance(data, dict) else str(data)
|
|
497
|
+
raise ValueError(err or "device_oauth_start_failed")
|
|
498
|
+
if not isinstance(data, dict):
|
|
499
|
+
raise ValueError("device_oauth_start_invalid")
|
|
500
|
+
exit_code = data.get("exit_code")
|
|
501
|
+
try:
|
|
502
|
+
code = int(exit_code if exit_code is not None else 1)
|
|
503
|
+
except (TypeError, ValueError):
|
|
504
|
+
code = 1
|
|
505
|
+
return code, str(data.get("output") or "")
|
|
506
|
+
auth_cmd = " ".join(shlex.quote(part) for part in ["auth", "add", hermes_auth_id])
|
|
507
|
+
shell = (
|
|
508
|
+
f"mkdir -p {shlex.quote(home)}; "
|
|
509
|
+
f"chown -R hermes:hermes {shlex.quote(home)}; "
|
|
510
|
+
f"su -s /bin/sh hermes -c "
|
|
511
|
+
f"'export HERMES_HOME={shlex.quote(home)} HOME={shlex.quote(home)}; "
|
|
512
|
+
f"cd {shlex.quote(home)}; "
|
|
513
|
+
f"/opt/hermes/bin/hermes {auth_cmd} >> {shlex.quote(log_container)} 2>&1'"
|
|
514
|
+
)
|
|
515
|
+
return _srv()._gateway_container_exec_detached(["sh", "-lc", shell])
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _start_device_oauth(user_id: str, provider_id: str, spec: dict[str, Any]) -> dict[str, Any]:
|
|
519
|
+
hermes_auth_id = _hermes_auth_id_for_spec(spec)
|
|
520
|
+
session_id = secrets.token_urlsafe(16)
|
|
521
|
+
log_name = f".oauth-{session_id}.log"
|
|
522
|
+
home = _srv()._hermes_user_home_container(user_id)
|
|
523
|
+
log_container = f"{home}/{log_name}"
|
|
524
|
+
log_host = _srv()._user_hermes_home(user_id) / log_name
|
|
525
|
+
try:
|
|
526
|
+
rc, out = _spawn_hermes_device_oauth(user_id, hermes_auth_id, log_container)
|
|
527
|
+
except (RuntimeError, ValueError) as exc:
|
|
528
|
+
return {"ok": False, "provider": provider_id, "error": str(exc)}
|
|
529
|
+
if rc != 0:
|
|
530
|
+
return {
|
|
531
|
+
"ok": False,
|
|
532
|
+
"provider": provider_id,
|
|
533
|
+
"error": "oauth_start_failed",
|
|
534
|
+
"output": (out or "").strip(),
|
|
535
|
+
}
|
|
536
|
+
with _oauth_device_lock:
|
|
537
|
+
_oauth_device_sessions[session_id] = {
|
|
538
|
+
"user_id": user_id,
|
|
539
|
+
"provider_id": provider_id,
|
|
540
|
+
"hermes_auth_id": hermes_auth_id,
|
|
541
|
+
"log_path": str(log_host),
|
|
542
|
+
"status": "pending",
|
|
543
|
+
"verification_uri": None,
|
|
544
|
+
"user_code": None,
|
|
545
|
+
"finalized": False,
|
|
546
|
+
"started_at": time.time(),
|
|
547
|
+
}
|
|
548
|
+
status = device_oauth_status(user_id, provider_id, session_id)
|
|
549
|
+
return {
|
|
550
|
+
"ok": True,
|
|
551
|
+
"provider": provider_id,
|
|
552
|
+
"hermes_auth_id": hermes_auth_id,
|
|
553
|
+
"flow": "device_code",
|
|
554
|
+
"session_id": session_id,
|
|
555
|
+
"status": status.get("status") or "pending",
|
|
556
|
+
"verification_uri": status.get("verification_uri"),
|
|
557
|
+
"user_code": status.get("user_code"),
|
|
558
|
+
"message": status.get("message"),
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def device_oauth_status(user_id: str, provider_id: str, session_id: str) -> dict[str, Any]:
|
|
563
|
+
sess = _device_oauth_session_get(session_id)
|
|
564
|
+
if not sess or sess.get("user_id") != user_id or sess.get("provider_id") != provider_id:
|
|
565
|
+
return {"ok": False, "error": "session_not_found"}
|
|
566
|
+
spec = _srv()._catalog_provider(provider_id) or {}
|
|
567
|
+
hermes_auth_id = str(sess.get("hermes_auth_id") or _hermes_auth_id_for_spec(spec))
|
|
568
|
+
log_path = Path(str(sess.get("log_path") or ""))
|
|
569
|
+
log_text = ""
|
|
570
|
+
if log_path.is_file():
|
|
571
|
+
try:
|
|
572
|
+
log_text = log_path.read_text(encoding="utf-8", errors="replace")
|
|
573
|
+
except OSError:
|
|
574
|
+
log_text = ""
|
|
575
|
+
if not log_text.strip() and log_path.name:
|
|
576
|
+
log_text = _read_gateway_data_file(f"profiles/{_srv()._user_hermes_dir_slug(user_id)}/{log_path.name}")
|
|
577
|
+
parsed = _parse_device_oauth_log(log_text)
|
|
578
|
+
patch: dict[str, Any] = {}
|
|
579
|
+
if parsed.get("verification_uri"):
|
|
580
|
+
patch["verification_uri"] = parsed["verification_uri"]
|
|
581
|
+
if parsed.get("user_code"):
|
|
582
|
+
patch["user_code"] = parsed["user_code"]
|
|
583
|
+
if patch:
|
|
584
|
+
_device_oauth_session_patch(session_id, patch)
|
|
585
|
+
sess.update(patch)
|
|
586
|
+
if _hermes_oauth_tokens_present(user_id, hermes_auth_id):
|
|
587
|
+
if not sess.get("finalized"):
|
|
588
|
+
_finalize_hermes_device_oauth(user_id, provider_id, spec)
|
|
589
|
+
_device_oauth_session_patch(session_id, {"status": "connected", "finalized": True})
|
|
590
|
+
return {
|
|
591
|
+
"ok": True,
|
|
592
|
+
"provider": provider_id,
|
|
593
|
+
"session_id": session_id,
|
|
594
|
+
"status": "connected",
|
|
595
|
+
"verification_uri": sess.get("verification_uri"),
|
|
596
|
+
"user_code": sess.get("user_code"),
|
|
597
|
+
}
|
|
598
|
+
lowered = log_text.lower()
|
|
599
|
+
if any(token in lowered for token in ("login successful", "auth added", "credentials saved", "successfully authenticated", "logged in")):
|
|
600
|
+
if _hermes_oauth_tokens_present(user_id, hermes_auth_id):
|
|
601
|
+
if not sess.get("finalized"):
|
|
602
|
+
_finalize_hermes_device_oauth(user_id, provider_id, spec)
|
|
603
|
+
_device_oauth_session_patch(session_id, {"status": "connected", "finalized": True})
|
|
604
|
+
return {
|
|
605
|
+
"ok": True,
|
|
606
|
+
"provider": provider_id,
|
|
607
|
+
"session_id": session_id,
|
|
608
|
+
"status": "connected",
|
|
609
|
+
"verification_uri": sess.get("verification_uri"),
|
|
610
|
+
"user_code": sess.get("user_code"),
|
|
611
|
+
}
|
|
612
|
+
if any(token in lowered for token in ("autherror", "login timed out", "login cancelled", "failed")):
|
|
613
|
+
_device_oauth_session_patch(session_id, {"status": "error"})
|
|
614
|
+
return {
|
|
615
|
+
"ok": False,
|
|
616
|
+
"provider": provider_id,
|
|
617
|
+
"session_id": session_id,
|
|
618
|
+
"status": "error",
|
|
619
|
+
"error": "oauth_failed",
|
|
620
|
+
"message": _strip_ansi(log_text).strip()[-500:] or "OAuth failed",
|
|
621
|
+
"verification_uri": sess.get("verification_uri"),
|
|
622
|
+
"user_code": sess.get("user_code"),
|
|
623
|
+
}
|
|
624
|
+
started = float(sess.get("started_at") or 0.0)
|
|
625
|
+
if started and time.time() - started > 16 * 60:
|
|
626
|
+
_device_oauth_session_patch(session_id, {"status": "error"})
|
|
627
|
+
return {
|
|
628
|
+
"ok": False,
|
|
629
|
+
"provider": provider_id,
|
|
630
|
+
"session_id": session_id,
|
|
631
|
+
"status": "error",
|
|
632
|
+
"error": "oauth_timeout",
|
|
633
|
+
"verification_uri": sess.get("verification_uri"),
|
|
634
|
+
"user_code": sess.get("user_code"),
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
"ok": True,
|
|
638
|
+
"provider": provider_id,
|
|
639
|
+
"session_id": session_id,
|
|
640
|
+
"status": "pending",
|
|
641
|
+
"verification_uri": sess.get("verification_uri"),
|
|
642
|
+
"user_code": sess.get("user_code"),
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def start_user_oauth(user_id: str, provider_id: str, workspace_id: str = "") -> dict[str, Any]:
|
|
647
|
+
if str(provider_id).lower() == "discord":
|
|
648
|
+
return _srv()._start_discord_oauth(user_id, workspace_id)
|
|
649
|
+
spec = _srv()._catalog_provider(provider_id)
|
|
650
|
+
if not spec or str(spec.get("connect_mode")) != "oauth":
|
|
651
|
+
return {"ok": False, "error": "not_oauth_provider"}
|
|
652
|
+
oauth_provider = str(spec.get("oauth_provider") or spec["id"]).lower()
|
|
653
|
+
if oauth_provider == "github":
|
|
654
|
+
return {**_srv()._start_github_oauth(user_id, workspace_id, spec), "flow": "redirect"}
|
|
655
|
+
if oauth_provider == "stripe":
|
|
656
|
+
return {**_srv()._start_stripe_oauth(user_id, workspace_id, spec), "flow": "redirect"}
|
|
657
|
+
if str(provider_id).lower() in _DEVICE_OAUTH_PROVIDER_IDS:
|
|
658
|
+
return _start_device_oauth(user_id, provider_id, spec)
|
|
659
|
+
hermes_auth_id = _hermes_auth_id_for_spec(spec)
|
|
660
|
+
rc, out = _srv()._hermes_user_exec(user_id, ["auth", "add", hermes_auth_id])
|
|
661
|
+
redirect_url = None
|
|
662
|
+
for token in re.findall(r"https?://[^\s\])>\"']+", out or ""):
|
|
663
|
+
redirect_url = token.rstrip(".,;)")
|
|
664
|
+
break
|
|
665
|
+
return {
|
|
666
|
+
"ok": rc == 0,
|
|
667
|
+
"provider": provider_id,
|
|
668
|
+
"hermes_auth_id": hermes_auth_id,
|
|
669
|
+
"output": (out or "").strip(),
|
|
670
|
+
"redirect_url": redirect_url,
|
|
671
|
+
"flow": "redirect" if redirect_url else "device_code",
|
|
672
|
+
"error": None if rc == 0 else "oauth_start_failed",
|
|
673
|
+
}
|