create-workframe 0.1.12 → 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 +2 -2
- package/package.json +1 -1
- package/scripts/bundle-workframe-ui.mjs +9 -0
- package/scripts/sync-canonical-to-package.mjs +1 -0
- package/scripts/verify-public-deploy.sh +65 -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/package.json +2 -2
- package/workframe-api/profile_api_lifecycle.py +66 -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 +331 -191
- package/workframe-api/run-typecheck.mjs +2 -1
- 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 +1453 -19513
- 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 +1 -0
- package/workframe-api/test_oauth_pending.py +41 -0
- package/workframe-api/test_provider_bindings.py +52 -0
- package/workframe-api/test_provider_catalog.py +28 -0
- package/workframe-api/test_route_registry.py +171 -35
- 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/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-B0OFRGmJ.js → arc-aUUffclm.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DeBNltHS.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-BDhCHu7I.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-olcDYvtI.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
- package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-D0obCBn_.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-DX93f-ZZ.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-BX9B5wUs.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-C4DGc6RO.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-Crlja9Lf.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-JYSm3szI.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-_KslxVEl.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-CpTO-jU8.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-JGUkCmbI.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-BscBwGaC.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-5ZTZ0bpX.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-DxvitYbW.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-Cayl0iV9.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-_7dyBR5G.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O--xTpD0yi.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-CwxJQaeK.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-CCspENYv.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-Dv8V6R60.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-Da48hFPT.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-dTihc7-3.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-D_Jqu699.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt8PtEaZ.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-BtmPjlh0.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
- package/workframe-ui/public/assets/{dist-Cz2turIT.js → dist-mQWmB3z6.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-BxMGCflX.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-ivyroIZt.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-B9llwShx.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BFQHk4bw.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-ursegLbc.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-DQpifAsB.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-C16XP2Q7.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-CiJoz7rP.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CVwEvvUL.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CWQ6hX5i.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
- package/workframe-ui/public/assets/{line-CgQsmU35.js → line-BTCEHb7l.js} +1 -1
- package/workframe-ui/public/assets/{linear-Bxbc77dL.js → linear-CkbydpnK.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-C_jMeF0a.js → mermaid-parser.core-DvrtArpk.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-4RKV9MZP.js → mermaid.core-yj_1x_qj.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-DZ7y7Sz0.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-B9h2I7Vq.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-B2mP5uHm.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-DyYKyKiP.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-CL_6nej-.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-CNKNDQ7S.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-eWU_mhFa.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DrjcXvEQ.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BHWR1xTJ.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
- package/workframe-ui/public/assets/{src-DfJB8kV1.js → src-D6mvlP96.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BGJbSd3I.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-DkwoCjiZ.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-GxfhiqoW.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-DxrOsars.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-BsWhpUfr.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-CrFZWMHS.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DV8Xpf5I.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DqMqnwdZ.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
- package/workframe-ui/public/index.html +7 -5
- package/workframe-ui/public/workframe-build.json +5 -0
- package/workframe-ui/public/assets/architecture-7EHR7CIX-Dwwi9yA0.js +0 -1
- package/workframe-ui/public/assets/channel-fNHbDpV4.js +0 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-DMfdMUwz.js +0 -2
- package/workframe-ui/public/assets/chunk-QZHKN3VN-ewTgEQK8.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-D4Tg-A0l.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CFAgBpEl.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CFAgBpEl.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-BIJTP5k-.js +0 -1
- package/workframe-ui/public/assets/index-8CuZDEIG.css +0 -1
- package/workframe-ui/public/assets/index-xS9lFekI.js +0 -129
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CMLuNyeC.js +0 -1
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Remove smoke-test clutter from a dogfood Workframe install.
|
|
3
|
+
|
|
4
|
+
python cleanup_dogfood_smoke.py --base http://127.0.0.1:18644 \\
|
|
5
|
+
--data-dir /path/to/MyBusiness/workframe-api/data
|
|
6
|
+
|
|
7
|
+
ponytail: dogfood on Windows Docker often cannot DELETE rooms via API (sqlite lock);
|
|
8
|
+
falls back to direct DB soft-delete and Hermes filesystem/registry cleanup.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import shutil
|
|
15
|
+
import sqlite3
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
ROOT = Path(__file__).resolve().parent
|
|
24
|
+
sys.path.insert(0, str(ROOT))
|
|
25
|
+
|
|
26
|
+
from test_dogfood_flows import _mint_session_cookie, _pick_user, _req # noqa: E402
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _smoke_room_ids(conn: sqlite3.Connection) -> list[str]:
|
|
30
|
+
rows = conn.execute(
|
|
31
|
+
"""
|
|
32
|
+
SELECT id FROM rooms
|
|
33
|
+
WHERE deleted_at IS NULL AND (
|
|
34
|
+
slug LIKE 'smoke-%'
|
|
35
|
+
OR name LIKE 'Smoke %'
|
|
36
|
+
OR topic = 'smoke test'
|
|
37
|
+
OR (name = 'Smoke Agent' AND slug LIKE 'dm-%')
|
|
38
|
+
)
|
|
39
|
+
"""
|
|
40
|
+
).fetchall()
|
|
41
|
+
return [str(r[0]) for r in rows]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _smoke_agent_slugs(conn: sqlite3.Connection) -> list[str]:
|
|
45
|
+
rows = conn.execute(
|
|
46
|
+
"""
|
|
47
|
+
SELECT slug FROM agent_profiles
|
|
48
|
+
WHERE deleted_at IS NULL AND (
|
|
49
|
+
slug LIKE 'smoke-agent-%' OR display_name = 'Smoke Agent'
|
|
50
|
+
)
|
|
51
|
+
"""
|
|
52
|
+
).fetchall()
|
|
53
|
+
return [str(r[0]) for r in rows if str(r[0]).strip()]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _smoke_invite_ids(conn: sqlite3.Connection) -> list[str]:
|
|
57
|
+
rows = conn.execute(
|
|
58
|
+
"""
|
|
59
|
+
SELECT id FROM workspace_invites
|
|
60
|
+
WHERE deleted_at IS NULL AND email LIKE '%@workframe.test'
|
|
61
|
+
"""
|
|
62
|
+
).fetchall()
|
|
63
|
+
return [str(r[0]) for r in rows]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _is_smoke_profile(name: str) -> bool:
|
|
67
|
+
return "smoke-agent" in name
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _soft_delete_rooms_db(conn: sqlite3.Connection) -> int:
|
|
71
|
+
now = str(int(time.time()))
|
|
72
|
+
cur = conn.execute(
|
|
73
|
+
"""
|
|
74
|
+
UPDATE rooms SET deleted_at = ?
|
|
75
|
+
WHERE deleted_at IS NULL AND (
|
|
76
|
+
slug LIKE 'smoke-%' OR name LIKE 'Smoke %' OR topic = 'smoke test'
|
|
77
|
+
OR (name = 'Smoke Agent' AND slug LIKE 'dm-%')
|
|
78
|
+
)
|
|
79
|
+
""",
|
|
80
|
+
(now,),
|
|
81
|
+
)
|
|
82
|
+
conn.commit()
|
|
83
|
+
return int(cur.rowcount)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _soft_delete_agents_db(conn: sqlite3.Connection, slugs: list[str]) -> int:
|
|
87
|
+
if not slugs:
|
|
88
|
+
return 0
|
|
89
|
+
now = str(int(time.time()))
|
|
90
|
+
placeholders = ",".join("?" * len(slugs))
|
|
91
|
+
cur = conn.execute(
|
|
92
|
+
f"UPDATE agent_profiles SET deleted_at = ?, updated_at = ? "
|
|
93
|
+
f"WHERE deleted_at IS NULL AND slug IN ({placeholders})",
|
|
94
|
+
[now, now, *slugs],
|
|
95
|
+
)
|
|
96
|
+
conn.commit()
|
|
97
|
+
return int(cur.rowcount)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _revoke_invites_db(conn: sqlite3.Connection, invite_ids: list[str]) -> int:
|
|
101
|
+
if not invite_ids:
|
|
102
|
+
return 0
|
|
103
|
+
now = str(int(time.time()))
|
|
104
|
+
placeholders = ",".join("?" * len(invite_ids))
|
|
105
|
+
cur = conn.execute(
|
|
106
|
+
f"UPDATE workspace_invites SET deleted_at = ? WHERE id IN ({placeholders}) AND deleted_at IS NULL",
|
|
107
|
+
[now, *invite_ids],
|
|
108
|
+
)
|
|
109
|
+
conn.commit()
|
|
110
|
+
return int(cur.rowcount)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _agents_root(data_dir: Path) -> Path | None:
|
|
114
|
+
# MyBusiness dogfood: data is workframe-api/data, Agents is sibling of workframe-api.
|
|
115
|
+
candidate = data_dir.parent.parent / "Agents"
|
|
116
|
+
return candidate if candidate.is_dir() else None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _cleanup_hermes_fs(agents_root: Path) -> None:
|
|
120
|
+
agents_json = agents_root / "workframe" / "agents.json"
|
|
121
|
+
if agents_json.is_file():
|
|
122
|
+
data = json.loads(agents_json.read_text(encoding="utf-8"))
|
|
123
|
+
agents = data.get("agents", {})
|
|
124
|
+
if isinstance(agents, dict):
|
|
125
|
+
data["agents"] = {k: v for k, v in agents.items() if not _is_smoke_profile(k)}
|
|
126
|
+
agents_json.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
127
|
+
|
|
128
|
+
for rel in ("avatar-registry.json", "routes.json"):
|
|
129
|
+
path = agents_root / "workframe" / rel
|
|
130
|
+
if not path.is_file():
|
|
131
|
+
continue
|
|
132
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
133
|
+
if rel == "avatar-registry.json":
|
|
134
|
+
assigns = payload.get("assignments", {})
|
|
135
|
+
if isinstance(assigns, dict):
|
|
136
|
+
for key in [k for k in assigns if _is_smoke_profile(k)]:
|
|
137
|
+
del assigns[key]
|
|
138
|
+
else:
|
|
139
|
+
routes = payload.get("routes", [])
|
|
140
|
+
if isinstance(routes, list):
|
|
141
|
+
payload["routes"] = [
|
|
142
|
+
r for r in routes if not _is_smoke_profile(str(r.get("profile", "")))
|
|
143
|
+
]
|
|
144
|
+
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
145
|
+
|
|
146
|
+
profiles = agents_root / "profiles"
|
|
147
|
+
if profiles.is_dir():
|
|
148
|
+
for entry in profiles.iterdir():
|
|
149
|
+
if entry.is_dir() and _is_smoke_profile(entry.name):
|
|
150
|
+
shutil.rmtree(entry, ignore_errors=True)
|
|
151
|
+
|
|
152
|
+
bin_dir = agents_root / ".local" / "bin"
|
|
153
|
+
if bin_dir.is_dir():
|
|
154
|
+
for entry in bin_dir.iterdir():
|
|
155
|
+
if _is_smoke_profile(entry.name):
|
|
156
|
+
entry.unlink(missing_ok=True)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _prune_lane_registry(data_dir: Path) -> int:
|
|
160
|
+
path = data_dir / "lane-registry.json"
|
|
161
|
+
if not path.is_file():
|
|
162
|
+
return 0
|
|
163
|
+
try:
|
|
164
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
165
|
+
except (OSError, json.JSONDecodeError):
|
|
166
|
+
return 0
|
|
167
|
+
lanes = data.get("lanes")
|
|
168
|
+
if not isinstance(lanes, dict):
|
|
169
|
+
return 0
|
|
170
|
+
removed = 0
|
|
171
|
+
for key in [k for k in lanes if _is_smoke_profile(k)]:
|
|
172
|
+
del lanes[key]
|
|
173
|
+
removed += 1
|
|
174
|
+
if removed:
|
|
175
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
176
|
+
return removed
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main() -> None:
|
|
180
|
+
ap = argparse.ArgumentParser()
|
|
181
|
+
ap.add_argument("--base", default="http://127.0.0.1:18644")
|
|
182
|
+
ap.add_argument("--data-dir", required=True, help="workframe-api/data under your install")
|
|
183
|
+
ap.add_argument("--dry-run", action="store_true")
|
|
184
|
+
args = ap.parse_args()
|
|
185
|
+
|
|
186
|
+
data_dir = Path(args.data_dir)
|
|
187
|
+
db_path = data_dir / "workframe.db"
|
|
188
|
+
conn = sqlite3.connect(db_path, timeout=30)
|
|
189
|
+
room_ids = _smoke_room_ids(conn)
|
|
190
|
+
agent_slugs = _smoke_agent_slugs(conn)
|
|
191
|
+
invite_ids = _smoke_invite_ids(conn)
|
|
192
|
+
print(f"found {len(room_ids)} smoke rooms, {len(agent_slugs)} smoke agents, {len(invite_ids)} test invites")
|
|
193
|
+
|
|
194
|
+
if args.dry_run:
|
|
195
|
+
print("dry-run — no changes")
|
|
196
|
+
conn.close()
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
user_id, email = _pick_user(data_dir)
|
|
200
|
+
cookie, session_id = _mint_session_cookie(data_dir, user_id)
|
|
201
|
+
print(f"session as {email} ({user_id[:8]}…)")
|
|
202
|
+
|
|
203
|
+
def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict[str, Any]]:
|
|
204
|
+
return _req(args.base, method, path, body=body, cookie=cookie, session_id=session_id, timeout=180)
|
|
205
|
+
|
|
206
|
+
api_room_ok = 0
|
|
207
|
+
for rid in room_ids:
|
|
208
|
+
code, payload = api("DELETE", f"/api/rooms/{rid}")
|
|
209
|
+
if code == 200:
|
|
210
|
+
api_room_ok += 1
|
|
211
|
+
else:
|
|
212
|
+
print(f"[warn] api delete room {rid[:8]}… — {payload.get('error')}")
|
|
213
|
+
|
|
214
|
+
if api_room_ok < len(room_ids):
|
|
215
|
+
n = _soft_delete_rooms_db(conn)
|
|
216
|
+
print(f"db soft-deleted {n} smoke rooms (api ok={api_room_ok})")
|
|
217
|
+
|
|
218
|
+
for slug in agent_slugs:
|
|
219
|
+
code, payload = api("POST", "/api/hermes/profiles/delete", body={"profile": slug})
|
|
220
|
+
if code != 200 or not payload.get("ok"):
|
|
221
|
+
print(f"[warn] api delete profile {slug} — {payload.get('error') or payload}")
|
|
222
|
+
|
|
223
|
+
n_agents = _soft_delete_agents_db(conn, agent_slugs)
|
|
224
|
+
print(f"soft-deleted {n_agents} agent_profiles rows")
|
|
225
|
+
|
|
226
|
+
n_inv = _revoke_invites_db(conn, invite_ids)
|
|
227
|
+
print(f"revoked {n_inv} test invites")
|
|
228
|
+
|
|
229
|
+
agents_root = _agents_root(data_dir)
|
|
230
|
+
if agents_root:
|
|
231
|
+
_cleanup_hermes_fs(agents_root)
|
|
232
|
+
print(f"cleaned Hermes data under {agents_root}")
|
|
233
|
+
else:
|
|
234
|
+
print("[warn] Agents mount not found — skipped Hermes filesystem cleanup")
|
|
235
|
+
|
|
236
|
+
pruned = _prune_lane_registry(data_dir)
|
|
237
|
+
if pruned:
|
|
238
|
+
print(f"pruned {pruned} lane-registry entries")
|
|
239
|
+
|
|
240
|
+
remaining_rooms = len(_smoke_room_ids(conn))
|
|
241
|
+
remaining_agents = len(_smoke_agent_slugs(conn))
|
|
242
|
+
remaining_invites = len(_smoke_invite_ids(conn))
|
|
243
|
+
conn.close()
|
|
244
|
+
print(
|
|
245
|
+
f"done — remaining smoke rooms={remaining_rooms}, agents={remaining_agents}, invites={remaining_invites}"
|
|
246
|
+
)
|
|
247
|
+
if remaining_rooms or remaining_agents:
|
|
248
|
+
raise SystemExit(1)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
main()
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Provider-neutral credential broker — shared lease validation for internal proxies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
import broker_audit
|
|
10
|
+
import internal_proxy_auth
|
|
11
|
+
import turn_credentials
|
|
12
|
+
|
|
13
|
+
LEASE_PREFIX = turn_credentials.LEASE_PREFIX
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def extract_bearer(headers: dict[str, str]) -> str:
|
|
17
|
+
auth = str(headers.get("Authorization") or headers.get("authorization") or "").strip()
|
|
18
|
+
if auth.lower().startswith("bearer "):
|
|
19
|
+
return auth[7:].strip()
|
|
20
|
+
api_key = str(headers.get("X-Api-Key") or headers.get("x-api-key") or "").strip()
|
|
21
|
+
if api_key:
|
|
22
|
+
return api_key
|
|
23
|
+
return ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def extract_profile_slug(headers: dict[str, str]) -> str:
|
|
27
|
+
return str(
|
|
28
|
+
headers.get(internal_proxy_auth.PROFILE_HEADER)
|
|
29
|
+
or headers.get("x-workframe-profile")
|
|
30
|
+
or ""
|
|
31
|
+
).strip()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def validate_lease_profile(
|
|
35
|
+
lease: dict[str, Any],
|
|
36
|
+
headers: dict[str, str],
|
|
37
|
+
) -> tuple[bool, str, int]:
|
|
38
|
+
"""Bind bearer lease to calling Hermes profile (0022 N2 / 0023 C1)."""
|
|
39
|
+
want = str(lease.get("profile_slug") or "").strip()
|
|
40
|
+
if not want:
|
|
41
|
+
return True, "", 0
|
|
42
|
+
got = extract_profile_slug(headers)
|
|
43
|
+
if not got:
|
|
44
|
+
return False, "profile header required", 403
|
|
45
|
+
if got != want:
|
|
46
|
+
return False, "profile mismatch", 403
|
|
47
|
+
return True, "", 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class BrokerLeaseAuth:
|
|
52
|
+
ok: bool
|
|
53
|
+
lease: dict[str, Any] | None = None
|
|
54
|
+
secret: str = ""
|
|
55
|
+
env_var: str = ""
|
|
56
|
+
status: int = 200
|
|
57
|
+
error: str = ""
|
|
58
|
+
deny_reason: str = ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def authorize_broker_lease(
|
|
62
|
+
provider: str,
|
|
63
|
+
headers: dict[str, str],
|
|
64
|
+
*,
|
|
65
|
+
resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
|
|
66
|
+
broker_kind: str = "",
|
|
67
|
+
upstream_host: str = "",
|
|
68
|
+
) -> BrokerLeaseAuth:
|
|
69
|
+
"""Validate lease token, provider/profile binding, and vault secret for a broker hop."""
|
|
70
|
+
provider = str(provider or "").strip().lower()
|
|
71
|
+
host = str(upstream_host or "").strip().lower()
|
|
72
|
+
kind = str(broker_kind or "").strip().lower()
|
|
73
|
+
|
|
74
|
+
def _audit(auth: BrokerLeaseAuth, status: int) -> BrokerLeaseAuth:
|
|
75
|
+
if kind:
|
|
76
|
+
broker_audit.record_broker_event(
|
|
77
|
+
broker_kind=kind,
|
|
78
|
+
provider=provider,
|
|
79
|
+
upstream_host=host,
|
|
80
|
+
status=status,
|
|
81
|
+
deny_reason=auth.deny_reason,
|
|
82
|
+
lease=auth.lease,
|
|
83
|
+
)
|
|
84
|
+
return auth
|
|
85
|
+
|
|
86
|
+
token = extract_bearer(headers)
|
|
87
|
+
deny_reason, lease = turn_credentials.inspect_lease(token)
|
|
88
|
+
if deny_reason:
|
|
89
|
+
return _audit(
|
|
90
|
+
BrokerLeaseAuth(
|
|
91
|
+
ok=False,
|
|
92
|
+
status=401,
|
|
93
|
+
error="invalid lease",
|
|
94
|
+
deny_reason=deny_reason,
|
|
95
|
+
lease=lease,
|
|
96
|
+
),
|
|
97
|
+
401,
|
|
98
|
+
)
|
|
99
|
+
if not lease:
|
|
100
|
+
return _audit(
|
|
101
|
+
BrokerLeaseAuth(
|
|
102
|
+
ok=False,
|
|
103
|
+
status=401,
|
|
104
|
+
error="invalid lease",
|
|
105
|
+
deny_reason="invalid_lease",
|
|
106
|
+
),
|
|
107
|
+
401,
|
|
108
|
+
)
|
|
109
|
+
if str(lease.get("provider") or "").lower() != provider:
|
|
110
|
+
return _audit(
|
|
111
|
+
BrokerLeaseAuth(
|
|
112
|
+
ok=False,
|
|
113
|
+
status=403,
|
|
114
|
+
error="provider mismatch",
|
|
115
|
+
deny_reason="provider_mismatch",
|
|
116
|
+
lease=lease,
|
|
117
|
+
),
|
|
118
|
+
403,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
ok_profile, profile_err, profile_status = validate_lease_profile(lease, headers)
|
|
122
|
+
if not ok_profile:
|
|
123
|
+
reason = "profile_header_required" if profile_status == 403 and "required" in profile_err else "profile_mismatch"
|
|
124
|
+
return _audit(
|
|
125
|
+
BrokerLeaseAuth(
|
|
126
|
+
ok=False,
|
|
127
|
+
status=profile_status,
|
|
128
|
+
error=profile_err,
|
|
129
|
+
deny_reason=reason,
|
|
130
|
+
lease=lease,
|
|
131
|
+
),
|
|
132
|
+
profile_status,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
env_var, secret = turn_credentials.resolve_lease_secret(lease, resolve_secret)
|
|
136
|
+
if not secret:
|
|
137
|
+
return _audit(
|
|
138
|
+
BrokerLeaseAuth(
|
|
139
|
+
ok=False,
|
|
140
|
+
status=402,
|
|
141
|
+
error="no credential",
|
|
142
|
+
deny_reason="no_credential",
|
|
143
|
+
lease=lease,
|
|
144
|
+
),
|
|
145
|
+
402,
|
|
146
|
+
)
|
|
147
|
+
return _audit(BrokerLeaseAuth(ok=True, lease=lease, secret=secret, env_var=env_var), 200)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def broker_error_body(auth: BrokerLeaseAuth) -> bytes:
|
|
151
|
+
return json.dumps({"error": auth.error}).encode()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
assert extract_bearer({"Authorization": "Bearer wf_rt_abc"}) == "wf_rt_abc"
|
|
156
|
+
assert extract_bearer({"X-Api-Key": "wf_rt_xyz"}) == "wf_rt_xyz"
|
|
157
|
+
lease = {"profile_slug": "u-a-dev", "provider": "openrouter"}
|
|
158
|
+
ok, err, code = validate_lease_profile(lease, {internal_proxy_auth.PROFILE_HEADER: "u-a-dev"})
|
|
159
|
+
assert ok and not err and code == 0
|
|
160
|
+
ok, err, code = validate_lease_profile(lease, {})
|
|
161
|
+
assert not ok and code == 403
|
|
162
|
+
print("credential_broker ok")
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""WF-032 extract: credential binding resolution and secret materialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sqlite3
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import credential_vault
|
|
11
|
+
import provider_catalog
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _srv():
|
|
15
|
+
import server as srv
|
|
16
|
+
|
|
17
|
+
return srv
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _default_credential_env_var(provider: str, credential_type: str) -> str:
|
|
21
|
+
prefix = re.sub(r"[^A-Za-z0-9]+", "_", str(provider or "WORKFRAME").strip()).upper() or "WORKFRAME"
|
|
22
|
+
suffix = {
|
|
23
|
+
"bot_token": "BOT_TOKEN",
|
|
24
|
+
"oauth": "OAUTH_TOKEN",
|
|
25
|
+
}.get(str(credential_type or "api_key"), "API_KEY")
|
|
26
|
+
return f"{prefix}_{suffix}"
|
|
27
|
+
def _provider_env_var(provider: str) -> str:
|
|
28
|
+
spec = provider_catalog.catalog_provider(provider)
|
|
29
|
+
if spec and str(spec.get("env_var") or "").strip():
|
|
30
|
+
return str(spec["env_var"]).strip()
|
|
31
|
+
return _default_credential_env_var(provider, "api_key")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _credential_secret(resolved: dict[str, Any], user_id: str = "") -> str:
|
|
35
|
+
ref = str(resolved.get("credential_ref") or "")
|
|
36
|
+
binding_id = credential_vault.parse_vault_ref(ref)
|
|
37
|
+
if binding_id:
|
|
38
|
+
return credential_vault.read_secret(binding_id)
|
|
39
|
+
env_var = str(resolved.get("env_var") or "")
|
|
40
|
+
if not env_var and ref.startswith("env:"):
|
|
41
|
+
env_var = ref[4:]
|
|
42
|
+
if not env_var:
|
|
43
|
+
return ""
|
|
44
|
+
scope = str(resolved.get("scope") or "")
|
|
45
|
+
secret = ""
|
|
46
|
+
if scope == "user":
|
|
47
|
+
user = str(user_id or resolved.get("user_id") or "").strip()
|
|
48
|
+
if user:
|
|
49
|
+
secret = _srv()._read_env_map(_srv()._user_hermes_env_path(user)).get(env_var, "")
|
|
50
|
+
if secret:
|
|
51
|
+
bid = str(resolved.get("credential_binding_id") or resolved.get("credential_id") or "").strip()
|
|
52
|
+
if bid:
|
|
53
|
+
credential_vault.store_secret(
|
|
54
|
+
bid,
|
|
55
|
+
secret,
|
|
56
|
+
env_var=env_var,
|
|
57
|
+
provider=str(resolved.get("provider") or ""),
|
|
58
|
+
scope="user",
|
|
59
|
+
user_id=user,
|
|
60
|
+
)
|
|
61
|
+
_srv()._remove_env_secret(_srv()._user_hermes_env_path(user), env_var)
|
|
62
|
+
elif scope == "workspace":
|
|
63
|
+
secret = _srv()._stack_profile_env().get(env_var, "")
|
|
64
|
+
elif scope == "stack":
|
|
65
|
+
secret = _srv()._stack_profile_env().get(env_var, "")
|
|
66
|
+
return secret
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolve_secret_for_lease(
|
|
70
|
+
payer_user_id: str,
|
|
71
|
+
workspace_id: str,
|
|
72
|
+
provider: str,
|
|
73
|
+
binding_id: str,
|
|
74
|
+
) -> tuple[str, str]:
|
|
75
|
+
provider = str(provider or "openrouter").strip().lower()
|
|
76
|
+
binding_id = str(binding_id or "").strip()
|
|
77
|
+
if binding_id:
|
|
78
|
+
secret = credential_vault.read_secret(binding_id)
|
|
79
|
+
if secret:
|
|
80
|
+
return _provider_env_var(provider), secret
|
|
81
|
+
resolved = _resolve_credential(payer_user_id, workspace_id, provider, user_only=True)
|
|
82
|
+
if not resolved:
|
|
83
|
+
resolved = _resolve_credential(payer_user_id, workspace_id, provider, user_only=False)
|
|
84
|
+
if not resolved:
|
|
85
|
+
return "", ""
|
|
86
|
+
env_var = str(resolved.get("env_var") or "") or _provider_env_var(provider)
|
|
87
|
+
return env_var, _credential_secret(resolved, payer_user_id)
|
|
88
|
+
def _credential_binding_payload(row: sqlite3.Row, scope: str) -> dict[str, Any]:
|
|
89
|
+
"""Return the safe metadata payload for a resolved credential binding."""
|
|
90
|
+
return {
|
|
91
|
+
"credential_binding_id": row["id"],
|
|
92
|
+
"credential_id": row["id"],
|
|
93
|
+
"credential_ref": row["credential_ref"],
|
|
94
|
+
"scope": scope,
|
|
95
|
+
"provider": row["provider"],
|
|
96
|
+
"credential_type": row["credential_type"],
|
|
97
|
+
"label": row["label"] or "",
|
|
98
|
+
"user_id": row["user_id"],
|
|
99
|
+
"workspace_id": row["workspace_id"],
|
|
100
|
+
"agent_profile_id": row["agent_profile_id"],
|
|
101
|
+
"created_by": row["created_by"],
|
|
102
|
+
"created_at": row["created_at"],
|
|
103
|
+
"updated_at": row["updated_at"],
|
|
104
|
+
"expires_at": row["expires_at"],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _resolve_credential(
|
|
109
|
+
user_id: str,
|
|
110
|
+
workspace_id: str,
|
|
111
|
+
provider: str,
|
|
112
|
+
*,
|
|
113
|
+
user_only: bool = False,
|
|
114
|
+
) -> dict[str, Any] | None:
|
|
115
|
+
"""Resolve which credential binding to use for an agent run.
|
|
116
|
+
|
|
117
|
+
Resolution order:
|
|
118
|
+
1. User-owned credential for this user + provider (if active and not expired)
|
|
119
|
+
2. Workspace credential for this workspace + provider (if active and not expired)
|
|
120
|
+
— skipped when user_only=True (dev/github/vercel tenancy)
|
|
121
|
+
3. None (caller uses profile config fallback)
|
|
122
|
+
"""
|
|
123
|
+
user = str(user_id or "").strip()
|
|
124
|
+
workspace = str(workspace_id or "").strip()
|
|
125
|
+
provider_name = str(provider or "").strip().lower()
|
|
126
|
+
if not provider_name or (not user and not workspace):
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
now_iso = _srv()._utc_now()
|
|
130
|
+
now_ts = str(int(time.time()))
|
|
131
|
+
conn = None
|
|
132
|
+
try:
|
|
133
|
+
conn = _srv()._workframe_db()
|
|
134
|
+
conn.row_factory = sqlite3.Row
|
|
135
|
+
if user:
|
|
136
|
+
row = conn.execute(
|
|
137
|
+
"""
|
|
138
|
+
SELECT id, workspace_id, user_id, agent_profile_id, provider,
|
|
139
|
+
credential_type, credential_ref, label, is_active,
|
|
140
|
+
expires_at, created_by, created_at, updated_at, deleted_at
|
|
141
|
+
FROM credential_bindings
|
|
142
|
+
WHERE user_id = ?
|
|
143
|
+
AND LOWER(provider) = ?
|
|
144
|
+
AND is_active = 1
|
|
145
|
+
AND deleted_at IS NULL
|
|
146
|
+
AND (expires_at IS NULL OR expires_at > ? OR CAST(expires_at AS INTEGER) > ?)
|
|
147
|
+
ORDER BY updated_at DESC, created_at DESC, id DESC
|
|
148
|
+
LIMIT 1
|
|
149
|
+
""",
|
|
150
|
+
(user, provider_name, now_iso, now_ts),
|
|
151
|
+
).fetchone()
|
|
152
|
+
if row:
|
|
153
|
+
return _credential_binding_payload(row, "user")
|
|
154
|
+
|
|
155
|
+
if workspace and not user_only:
|
|
156
|
+
row = conn.execute(
|
|
157
|
+
"""
|
|
158
|
+
SELECT id, workspace_id, user_id, agent_profile_id, provider,
|
|
159
|
+
credential_type, credential_ref, label, is_active,
|
|
160
|
+
expires_at, created_by, created_at, updated_at, deleted_at
|
|
161
|
+
FROM credential_bindings
|
|
162
|
+
WHERE workspace_id = ?
|
|
163
|
+
AND LOWER(provider) = ?
|
|
164
|
+
AND is_active = 1
|
|
165
|
+
AND deleted_at IS NULL
|
|
166
|
+
AND (expires_at IS NULL OR expires_at > ? OR CAST(expires_at AS INTEGER) > ?)
|
|
167
|
+
ORDER BY updated_at DESC, created_at DESC, id DESC
|
|
168
|
+
LIMIT 1
|
|
169
|
+
""",
|
|
170
|
+
(workspace, provider_name, now_iso, now_ts),
|
|
171
|
+
).fetchone()
|
|
172
|
+
if row:
|
|
173
|
+
return _credential_binding_payload(row, "workspace")
|
|
174
|
+
except sqlite3.Error:
|
|
175
|
+
pass
|
|
176
|
+
finally:
|
|
177
|
+
if conn is not None:
|
|
178
|
+
conn.close()
|
|
179
|
+
|
|
180
|
+
if user:
|
|
181
|
+
spec = provider_catalog.catalog_provider(provider_name)
|
|
182
|
+
if spec and str(spec.get("category") or "") == "llm":
|
|
183
|
+
env_var = str(spec.get("env_var") or "").strip()
|
|
184
|
+
if env_var and _srv()._read_env_map(_srv()._user_hermes_env_path(user)).get(env_var):
|
|
185
|
+
return {
|
|
186
|
+
"credential_binding_id": None,
|
|
187
|
+
"credential_id": None,
|
|
188
|
+
"credential_ref": f"env:{env_var}",
|
|
189
|
+
"scope": "user",
|
|
190
|
+
"provider": provider_name,
|
|
191
|
+
"credential_type": str(spec.get("connect_mode") or "api_key"),
|
|
192
|
+
"label": env_var,
|
|
193
|
+
"env_var": env_var,
|
|
194
|
+
"user_id": user,
|
|
195
|
+
"workspace_id": None,
|
|
196
|
+
"agent_profile_id": None,
|
|
197
|
+
"created_by": user,
|
|
198
|
+
"created_at": None,
|
|
199
|
+
"updated_at": None,
|
|
200
|
+
"expires_at": None,
|
|
201
|
+
}
|
|
202
|
+
return None
|