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,343 @@
|
|
|
1
|
+
"""WF-032 extract: avatar catalog, preset picks, and agent avatar registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import secrets
|
|
8
|
+
import urllib.parse
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _srv():
|
|
15
|
+
import server as srv
|
|
16
|
+
|
|
17
|
+
return srv
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_avatar_catalog_cache: dict[str, Any] | None = None
|
|
21
|
+
_preset_catalog_cache: dict[str, dict[str, Any]] = {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_avatar_catalog() -> dict[str, Any]:
|
|
25
|
+
global _avatar_catalog_cache
|
|
26
|
+
if _avatar_catalog_cache is not None:
|
|
27
|
+
return _avatar_catalog_cache
|
|
28
|
+
fallback: dict[str, Any] = {"public_base": "/assets/avatars", "avatars": []}
|
|
29
|
+
catalog_path = _srv().AVATAR_CATALOG_JSON
|
|
30
|
+
if not catalog_path.is_file():
|
|
31
|
+
_avatar_catalog_cache = fallback
|
|
32
|
+
return fallback
|
|
33
|
+
try:
|
|
34
|
+
data = json.loads(catalog_path.read_text(encoding="utf-8"))
|
|
35
|
+
_avatar_catalog_cache = data if isinstance(data, dict) else fallback
|
|
36
|
+
except Exception: # noqa: BLE001
|
|
37
|
+
_avatar_catalog_cache = fallback
|
|
38
|
+
return _avatar_catalog_cache
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _avatar_path_from_url(avatar_url: str) -> str:
|
|
42
|
+
raw = str(avatar_url or "").strip()
|
|
43
|
+
if "://" in raw:
|
|
44
|
+
try:
|
|
45
|
+
parsed = urllib.parse.urlparse(raw)
|
|
46
|
+
raw = parsed.path or raw
|
|
47
|
+
except Exception: # noqa: BLE001
|
|
48
|
+
pass
|
|
49
|
+
return raw.split("?", 1)[0].split("#", 1)[0]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _catalog_url_for_id(
|
|
53
|
+
catalog: dict[str, Any],
|
|
54
|
+
avatar_id: str,
|
|
55
|
+
*,
|
|
56
|
+
items_key: str = "avatars",
|
|
57
|
+
default_base: str = "/assets",
|
|
58
|
+
) -> str:
|
|
59
|
+
avatar_id = str(avatar_id or "").strip()
|
|
60
|
+
base = str(catalog.get("public_base") or default_base).rstrip("/")
|
|
61
|
+
for row in catalog.get(items_key) or []:
|
|
62
|
+
if isinstance(row, dict) and str(row.get("id")) == avatar_id:
|
|
63
|
+
file_name = str(row.get("file") or f"{avatar_id}.png")
|
|
64
|
+
return f"{base}/{file_name}"
|
|
65
|
+
return f"{base}/{avatar_id}.png"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _id_from_catalog_url(
|
|
69
|
+
catalog: dict[str, Any],
|
|
70
|
+
avatar_url: str,
|
|
71
|
+
*,
|
|
72
|
+
items_key: str = "avatars",
|
|
73
|
+
) -> str:
|
|
74
|
+
path = _avatar_path_from_url(avatar_url)
|
|
75
|
+
basename = path.rsplit("/", 1)[-1]
|
|
76
|
+
if not basename:
|
|
77
|
+
return ""
|
|
78
|
+
rows = [row for row in catalog.get(items_key) or [] if isinstance(row, dict)]
|
|
79
|
+
base = str(catalog.get("public_base") or "").rstrip("/")
|
|
80
|
+
for row in rows:
|
|
81
|
+
if str(row.get("file") or f"{row.get('id')}.png") == basename:
|
|
82
|
+
return str(row.get("id") or "")
|
|
83
|
+
if base:
|
|
84
|
+
stable = re.match(rf"^{re.escape(base)}/([^/]+)\.png$", path, re.I)
|
|
85
|
+
if stable:
|
|
86
|
+
stem = stable.group(1)
|
|
87
|
+
for row in rows:
|
|
88
|
+
if str(row.get("id") or "") == stem:
|
|
89
|
+
return str(row.get("id") or "")
|
|
90
|
+
file_stem = str(row.get("file") or f"{row.get('id')}.png").replace(".png", "")
|
|
91
|
+
if file_stem == stem:
|
|
92
|
+
return str(row.get("id") or "")
|
|
93
|
+
hashed = re.match(r"/assets/([a-z0-9]+)-[A-Za-z0-9_]+\.png$", path, re.I)
|
|
94
|
+
if hashed:
|
|
95
|
+
prefix = hashed.group(1)
|
|
96
|
+
for row in rows:
|
|
97
|
+
if str(row.get("id") or "") == prefix:
|
|
98
|
+
return str(row.get("id") or "")
|
|
99
|
+
file_stem = str(row.get("file") or f"{row.get('id')}.png").replace(".png", "")
|
|
100
|
+
if file_stem == prefix:
|
|
101
|
+
return str(row.get("id") or "")
|
|
102
|
+
return ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _normalize_catalog_avatar_patch(
|
|
106
|
+
catalog: dict[str, Any],
|
|
107
|
+
avatar_url: str = "",
|
|
108
|
+
avatar_id: str = "",
|
|
109
|
+
*,
|
|
110
|
+
items_key: str = "avatars",
|
|
111
|
+
default_base: str = "/assets",
|
|
112
|
+
include_id: bool = True,
|
|
113
|
+
) -> dict[str, str]:
|
|
114
|
+
aid = str(avatar_id or "").strip()
|
|
115
|
+
raw = str(avatar_url or "").strip()
|
|
116
|
+
if raw.startswith("data:"):
|
|
117
|
+
out = {"avatar_url": raw}
|
|
118
|
+
if include_id:
|
|
119
|
+
out["avatar_id"] = ""
|
|
120
|
+
return out
|
|
121
|
+
path = _avatar_path_from_url(raw)
|
|
122
|
+
if not path and not aid:
|
|
123
|
+
out = {"avatar_url": ""}
|
|
124
|
+
if include_id:
|
|
125
|
+
out["avatar_id"] = ""
|
|
126
|
+
return out
|
|
127
|
+
if not aid and path:
|
|
128
|
+
aid = _id_from_catalog_url(catalog, path, items_key=items_key)
|
|
129
|
+
if aid:
|
|
130
|
+
stable = _catalog_url_for_id(catalog, aid, items_key=items_key, default_base=default_base)
|
|
131
|
+
out = {"avatar_url": stable}
|
|
132
|
+
if include_id:
|
|
133
|
+
out["avatar_id"] = aid
|
|
134
|
+
return out
|
|
135
|
+
out = {"avatar_url": path or raw}
|
|
136
|
+
if include_id:
|
|
137
|
+
out["avatar_id"] = ""
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _avatar_url_for_id(avatar_id: str) -> str:
|
|
142
|
+
return _catalog_url_for_id(
|
|
143
|
+
_load_avatar_catalog(),
|
|
144
|
+
avatar_id,
|
|
145
|
+
items_key="avatars",
|
|
146
|
+
default_base="/assets/avatars",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _avatar_id_from_url(avatar_url: str) -> str:
|
|
151
|
+
return _id_from_catalog_url(_load_avatar_catalog(), avatar_url, items_key="avatars")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _load_preset_catalog(path: Path) -> dict[str, Any]:
|
|
155
|
+
key = str(path)
|
|
156
|
+
if key in _preset_catalog_cache:
|
|
157
|
+
return _preset_catalog_cache[key]
|
|
158
|
+
fallback: dict[str, Any] = {"public_base": "", "avatars": [], "logos": []}
|
|
159
|
+
if not path.is_file():
|
|
160
|
+
_preset_catalog_cache[key] = fallback
|
|
161
|
+
return fallback
|
|
162
|
+
try:
|
|
163
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
164
|
+
_preset_catalog_cache[key] = data if isinstance(data, dict) else fallback
|
|
165
|
+
except Exception: # noqa: BLE001
|
|
166
|
+
_preset_catalog_cache[key] = fallback
|
|
167
|
+
return _preset_catalog_cache[key]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _normalize_user_avatar_url(avatar_url: str) -> str:
|
|
171
|
+
catalog = _load_preset_catalog(_srv().USER_AVATAR_CATALOG_JSON)
|
|
172
|
+
return _normalize_catalog_avatar_patch(
|
|
173
|
+
catalog,
|
|
174
|
+
avatar_url,
|
|
175
|
+
items_key="avatars",
|
|
176
|
+
default_base="/assets/avatars",
|
|
177
|
+
include_id=False,
|
|
178
|
+
)["avatar_url"]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _normalize_logo_url(avatar_url: str) -> str:
|
|
182
|
+
catalog = _load_preset_catalog(_srv().LOGO_CATALOG_JSON)
|
|
183
|
+
return _normalize_catalog_avatar_patch(
|
|
184
|
+
catalog,
|
|
185
|
+
avatar_url,
|
|
186
|
+
items_key="logos",
|
|
187
|
+
default_base="/assets/project-logos",
|
|
188
|
+
include_id=False,
|
|
189
|
+
)["avatar_url"]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _normalize_agent_avatar_patch(
|
|
193
|
+
avatar_url: str = "",
|
|
194
|
+
avatar_id: str = "",
|
|
195
|
+
) -> dict[str, str]:
|
|
196
|
+
"""Persist stable catalog id + nginx path — not vite hash or absolute origin."""
|
|
197
|
+
return _normalize_catalog_avatar_patch(
|
|
198
|
+
_load_avatar_catalog(),
|
|
199
|
+
avatar_url,
|
|
200
|
+
avatar_id,
|
|
201
|
+
items_key="avatars",
|
|
202
|
+
default_base="/assets/avatars",
|
|
203
|
+
include_id=True,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _pick_preset_url(catalog_path: Path, *, items_key: str = "avatars") -> str:
|
|
208
|
+
catalog = _load_preset_catalog(catalog_path)
|
|
209
|
+
base = str(catalog.get("public_base") or "").rstrip("/")
|
|
210
|
+
items = [row for row in catalog.get(items_key) or [] if isinstance(row, dict) and row.get("id")]
|
|
211
|
+
if not items or not base:
|
|
212
|
+
return ""
|
|
213
|
+
pick = secrets.choice(items)
|
|
214
|
+
file_name = str(pick.get("file") or f"{pick['id']}.png")
|
|
215
|
+
return f"{base}/{file_name}"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _pick_logo_url() -> str:
|
|
219
|
+
return _pick_preset_url(_srv().LOGO_CATALOG_JSON, items_key="logos")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _pick_user_avatar_url() -> str:
|
|
223
|
+
return _pick_preset_url(_srv().USER_AVATAR_CATALOG_JSON, items_key="avatars")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _resolve_avatar_fields(row: dict[str, Any]) -> None:
|
|
227
|
+
avatar_id = str(row.get("avatar_id") or "").strip()
|
|
228
|
+
if avatar_id:
|
|
229
|
+
row["avatar_url"] = _avatar_url_for_id(avatar_id)
|
|
230
|
+
return
|
|
231
|
+
explicit = str(row.get("avatar_url") or "").strip()
|
|
232
|
+
if explicit:
|
|
233
|
+
row["avatar_url"] = explicit
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _load_avatar_registry() -> dict[str, Any]:
|
|
237
|
+
registry_path = _srv().AVATAR_REGISTRY_JSON
|
|
238
|
+
if not registry_path.is_file():
|
|
239
|
+
return {"version": 1, "weights": {}, "assignments": {}}
|
|
240
|
+
try:
|
|
241
|
+
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
|
242
|
+
if isinstance(data, dict):
|
|
243
|
+
data.setdefault("weights", {})
|
|
244
|
+
data.setdefault("assignments", {})
|
|
245
|
+
return data
|
|
246
|
+
except Exception: # noqa: BLE001
|
|
247
|
+
pass
|
|
248
|
+
return {"version": 1, "weights": {}, "assignments": {}}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _save_avatar_registry(data: dict[str, Any]) -> None:
|
|
252
|
+
registry_path = _srv().AVATAR_REGISTRY_JSON
|
|
253
|
+
registry_path.parent.mkdir(parents=True, exist_ok=True)
|
|
254
|
+
registry_path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _pick_avatar_id() -> str:
|
|
258
|
+
catalog = _load_avatar_catalog()
|
|
259
|
+
avatars = [row for row in catalog.get("avatars") or [] if isinstance(row, dict) and row.get("id")]
|
|
260
|
+
if not avatars:
|
|
261
|
+
return "steve"
|
|
262
|
+
registry = _load_avatar_registry()
|
|
263
|
+
agents = _srv().load_agent_registry()
|
|
264
|
+
assigned = {str(v) for v in registry.get("assignments", {}).values()}
|
|
265
|
+
for row in agents.values():
|
|
266
|
+
aid = str(row.get("avatar_id") or "").strip()
|
|
267
|
+
if aid:
|
|
268
|
+
assigned.add(aid)
|
|
269
|
+
pool = [row for row in avatars if str(row["id"]) not in assigned] or avatars
|
|
270
|
+
weights = registry.get("weights") or {}
|
|
271
|
+
min_weight = min(int(weights.get(str(row["id"]), 0)) for row in pool)
|
|
272
|
+
candidates = [row for row in pool if int(weights.get(str(row["id"]), 0)) <= min_weight]
|
|
273
|
+
pick = secrets.choice(candidates)
|
|
274
|
+
return str(pick["id"])
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _upsert_agent_registry_row(profile: str, patch: dict[str, Any]) -> None:
|
|
278
|
+
prof_key = _srv().safe_profile_slug(profile)
|
|
279
|
+
if "avatar_url" in patch or "avatar_id" in patch:
|
|
280
|
+
norm = _normalize_agent_avatar_patch(
|
|
281
|
+
str(patch.get("avatar_url") or ""),
|
|
282
|
+
str(patch.get("avatar_id") or ""),
|
|
283
|
+
)
|
|
284
|
+
patch = {**patch, **norm}
|
|
285
|
+
agents_json = _srv().AGENTS_JSON
|
|
286
|
+
agents_json.parent.mkdir(parents=True, exist_ok=True)
|
|
287
|
+
data: dict[str, Any] = {"version": 1, "owner_profile": _srv()._primary_profile(), "agents": {}}
|
|
288
|
+
if agents_json.is_file():
|
|
289
|
+
try:
|
|
290
|
+
loaded = json.loads(agents_json.read_text(encoding="utf-8"))
|
|
291
|
+
if isinstance(loaded, dict):
|
|
292
|
+
data = loaded
|
|
293
|
+
except Exception: # noqa: BLE001
|
|
294
|
+
pass
|
|
295
|
+
agents = data.setdefault("agents", {})
|
|
296
|
+
current = agents.get(prof_key) if isinstance(agents.get(prof_key), dict) else {}
|
|
297
|
+
agents[prof_key] = {**current, **patch, "profile": prof_key}
|
|
298
|
+
data["agents"] = agents
|
|
299
|
+
agents_json.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _avatar_id_for_display_name(display_name: str) -> str:
|
|
303
|
+
"""Map a chosen agent name to catalog id when labels match (e.g. Ada → ada)."""
|
|
304
|
+
needle = str(display_name or "").strip().lower()
|
|
305
|
+
if not needle:
|
|
306
|
+
return ""
|
|
307
|
+
for row in _load_avatar_catalog().get("avatars") or []:
|
|
308
|
+
if not isinstance(row, dict):
|
|
309
|
+
continue
|
|
310
|
+
label = str(row.get("label") or "").strip().lower()
|
|
311
|
+
aid = str(row.get("id") or "").strip().lower()
|
|
312
|
+
if needle in (label, aid):
|
|
313
|
+
return str(row["id"])
|
|
314
|
+
return ""
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _assign_agent_avatar(profile: str, *, display_name: str = "") -> dict[str, str]:
|
|
318
|
+
"""Pick catalog avatar and persist to agents.json + avatar-registry.json."""
|
|
319
|
+
prof = _srv().safe_profile_slug(profile)
|
|
320
|
+
native_slug = _srv().safe_profile_slug(str(_srv().NATIVE_PROFILE or "workframe-agent"))
|
|
321
|
+
name = str(display_name or "").strip() or str(_srv()._agent_registry_row(prof).get("display_name") or "").strip()
|
|
322
|
+
by_name = _avatar_id_for_display_name(name)
|
|
323
|
+
if by_name:
|
|
324
|
+
avatar_id = by_name
|
|
325
|
+
elif prof == native_slug:
|
|
326
|
+
avatar_id = "steve"
|
|
327
|
+
else:
|
|
328
|
+
avatar_id = _pick_avatar_id()
|
|
329
|
+
avatar_url = _avatar_url_for_id(avatar_id)
|
|
330
|
+
_upsert_agent_registry_row(
|
|
331
|
+
prof,
|
|
332
|
+
{
|
|
333
|
+
"avatar_id": avatar_id,
|
|
334
|
+
"avatar_url": avatar_url,
|
|
335
|
+
"updated_at": datetime.now(timezone.utc).isoformat(),
|
|
336
|
+
},
|
|
337
|
+
)
|
|
338
|
+
registry = _load_avatar_registry()
|
|
339
|
+
registry.setdefault("assignments", {})[prof] = avatar_id
|
|
340
|
+
weights = registry.setdefault("weights", {})
|
|
341
|
+
weights[avatar_id] = int(weights.get(avatar_id, 0)) + 1
|
|
342
|
+
_save_avatar_registry(registry)
|
|
343
|
+
return {"avatar_id": avatar_id, "avatar_url": avatar_url}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Structured audit events for credential broker requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sqlite3
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
DATA_DIR = Path(os.environ.get("WORKFRAME_API_DATA_DIR", "/app/data"))
|
|
14
|
+
WORKFRAME_DB = DATA_DIR / "workframe.db"
|
|
15
|
+
|
|
16
|
+
_SCHEMA_READY: set[str] = set()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _connect() -> sqlite3.Connection:
|
|
20
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
conn = sqlite3.connect(str(WORKFRAME_DB), timeout=5.0)
|
|
22
|
+
conn.row_factory = sqlite3.Row
|
|
23
|
+
return conn
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ensure_schema() -> None:
|
|
27
|
+
key = str(WORKFRAME_DB)
|
|
28
|
+
if key in _SCHEMA_READY:
|
|
29
|
+
return
|
|
30
|
+
conn = _connect()
|
|
31
|
+
try:
|
|
32
|
+
conn.execute(
|
|
33
|
+
"""
|
|
34
|
+
CREATE TABLE IF NOT EXISTS broker_audit_events (
|
|
35
|
+
id TEXT PRIMARY KEY,
|
|
36
|
+
created_at TEXT NOT NULL,
|
|
37
|
+
broker_kind TEXT NOT NULL,
|
|
38
|
+
user_id TEXT DEFAULT '',
|
|
39
|
+
workspace_id TEXT DEFAULT '',
|
|
40
|
+
profile_slug TEXT DEFAULT '',
|
|
41
|
+
run_id TEXT DEFAULT '',
|
|
42
|
+
provider TEXT NOT NULL,
|
|
43
|
+
upstream_host TEXT DEFAULT '',
|
|
44
|
+
status INTEGER NOT NULL,
|
|
45
|
+
deny_reason TEXT DEFAULT ''
|
|
46
|
+
)
|
|
47
|
+
"""
|
|
48
|
+
)
|
|
49
|
+
conn.execute(
|
|
50
|
+
"CREATE INDEX IF NOT EXISTS idx_broker_audit_created "
|
|
51
|
+
"ON broker_audit_events(created_at DESC)"
|
|
52
|
+
)
|
|
53
|
+
conn.execute(
|
|
54
|
+
"CREATE INDEX IF NOT EXISTS idx_broker_audit_provider "
|
|
55
|
+
"ON broker_audit_events(provider, created_at DESC)"
|
|
56
|
+
)
|
|
57
|
+
conn.commit()
|
|
58
|
+
finally:
|
|
59
|
+
conn.close()
|
|
60
|
+
_SCHEMA_READY.add(key)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def record_broker_event(
|
|
64
|
+
*,
|
|
65
|
+
broker_kind: str,
|
|
66
|
+
provider: str,
|
|
67
|
+
upstream_host: str,
|
|
68
|
+
status: int,
|
|
69
|
+
deny_reason: str = "",
|
|
70
|
+
lease: dict[str, Any] | None = None,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Persist one broker hop — never raises."""
|
|
73
|
+
try:
|
|
74
|
+
ensure_schema()
|
|
75
|
+
meta = lease or {}
|
|
76
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
77
|
+
conn = _connect()
|
|
78
|
+
try:
|
|
79
|
+
conn.execute(
|
|
80
|
+
"""
|
|
81
|
+
INSERT INTO broker_audit_events (
|
|
82
|
+
id, created_at, broker_kind, user_id, workspace_id,
|
|
83
|
+
profile_slug, run_id, provider, upstream_host, status, deny_reason
|
|
84
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
|
85
|
+
""",
|
|
86
|
+
(
|
|
87
|
+
str(uuid.uuid4()),
|
|
88
|
+
now,
|
|
89
|
+
str(broker_kind or "").strip().lower(),
|
|
90
|
+
str(meta.get("payer_user_id") or ""),
|
|
91
|
+
str(meta.get("workspace_id") or ""),
|
|
92
|
+
str(meta.get("profile_slug") or ""),
|
|
93
|
+
str(meta.get("run_id") or ""),
|
|
94
|
+
str(provider or "").strip().lower(),
|
|
95
|
+
str(upstream_host or "").strip().lower(),
|
|
96
|
+
int(status),
|
|
97
|
+
str(deny_reason or "").strip(),
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
conn.commit()
|
|
101
|
+
finally:
|
|
102
|
+
conn.close()
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def list_broker_events(*, limit: int = 100, provider: str = "") -> list[dict[str, Any]]:
|
|
108
|
+
"""Recent broker events for deploy audit / diagnostics."""
|
|
109
|
+
ensure_schema()
|
|
110
|
+
limit = max(1, min(int(limit or 100), 500))
|
|
111
|
+
conn = _connect()
|
|
112
|
+
try:
|
|
113
|
+
if provider:
|
|
114
|
+
rows = conn.execute(
|
|
115
|
+
"""
|
|
116
|
+
SELECT created_at, broker_kind, user_id, workspace_id, profile_slug,
|
|
117
|
+
run_id, provider, upstream_host, status, deny_reason
|
|
118
|
+
FROM broker_audit_events
|
|
119
|
+
WHERE provider = ?
|
|
120
|
+
ORDER BY created_at DESC
|
|
121
|
+
LIMIT ?
|
|
122
|
+
""",
|
|
123
|
+
(str(provider).strip().lower(), limit),
|
|
124
|
+
).fetchall()
|
|
125
|
+
else:
|
|
126
|
+
rows = conn.execute(
|
|
127
|
+
"""
|
|
128
|
+
SELECT created_at, broker_kind, user_id, workspace_id, profile_slug,
|
|
129
|
+
run_id, provider, upstream_host, status, deny_reason
|
|
130
|
+
FROM broker_audit_events
|
|
131
|
+
ORDER BY created_at DESC
|
|
132
|
+
LIMIT ?
|
|
133
|
+
""",
|
|
134
|
+
(limit,),
|
|
135
|
+
).fetchall()
|
|
136
|
+
finally:
|
|
137
|
+
conn.close()
|
|
138
|
+
return [dict(row) for row in rows]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
import tempfile
|
|
143
|
+
|
|
144
|
+
td = tempfile.mkdtemp()
|
|
145
|
+
os.environ["WORKFRAME_API_DATA_DIR"] = td
|
|
146
|
+
globals()["DATA_DIR"] = Path(td)
|
|
147
|
+
globals()["WORKFRAME_DB"] = Path(td) / "workframe.db"
|
|
148
|
+
_SCHEMA_READY.clear()
|
|
149
|
+
record_broker_event(
|
|
150
|
+
broker_kind="llm",
|
|
151
|
+
provider="openrouter",
|
|
152
|
+
upstream_host="openrouter.ai",
|
|
153
|
+
status=401,
|
|
154
|
+
deny_reason="expired",
|
|
155
|
+
lease={
|
|
156
|
+
"payer_user_id": "u1",
|
|
157
|
+
"workspace_id": "ws1",
|
|
158
|
+
"profile_slug": "u-u1-dev",
|
|
159
|
+
"run_id": "run-1",
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
events = list_broker_events(limit=5)
|
|
163
|
+
assert len(events) == 1 and events[0]["deny_reason"] == "expired"
|
|
164
|
+
print("broker_audit ok")
|