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,49 @@
|
|
|
1
|
+
"""WF-032 extract: aggregate Hermes/workspace snapshot for /api/snapshot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import crew_registry
|
|
8
|
+
import health_monitor
|
|
9
|
+
from kanban_cron import cron_data, kanban_data
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _srv():
|
|
13
|
+
import server as srv
|
|
14
|
+
|
|
15
|
+
return srv
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_snapshot() -> dict[str, Any]:
|
|
19
|
+
profiles = _srv()._list_profiles()
|
|
20
|
+
primary = _srv()._primary_profile()
|
|
21
|
+
gateway = _srv().gateway_data(primary) if primary else _srv().gateway_data("")
|
|
22
|
+
crew = crew_registry.crew_data(profiles, primary, gateway)
|
|
23
|
+
activity = _srv().activity_data(profiles, crew)
|
|
24
|
+
health = health_monitor.health_data(primary)
|
|
25
|
+
sessions = _srv().sessions_data(primary) if primary else _srv().sessions_data("")
|
|
26
|
+
kanban = kanban_data()
|
|
27
|
+
cron = cron_data(primary) if primary else cron_data("")
|
|
28
|
+
return {
|
|
29
|
+
"ok": True,
|
|
30
|
+
"generated_at": _srv()._utc_now(),
|
|
31
|
+
"version": _srv().VERSION,
|
|
32
|
+
"project_name": _srv().PROJECT_NAME,
|
|
33
|
+
"native_profile": primary,
|
|
34
|
+
"native_agent_name": _srv()._native_display_name(),
|
|
35
|
+
"native_model": _srv()._profile_model(primary) if primary else "",
|
|
36
|
+
"profiles": profiles,
|
|
37
|
+
"crew": crew,
|
|
38
|
+
"gateway": gateway,
|
|
39
|
+
"sessions": sessions,
|
|
40
|
+
"activity": activity["entries"],
|
|
41
|
+
"agents": activity["agents"],
|
|
42
|
+
"activity_by_day": activity["activity_by_day"],
|
|
43
|
+
"stats": activity["stats"],
|
|
44
|
+
"kanban": kanban,
|
|
45
|
+
"cron": cron,
|
|
46
|
+
"crons": cron,
|
|
47
|
+
"vps": health,
|
|
48
|
+
"health": health,
|
|
49
|
+
}
|
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import json
|
|
10
10
|
import os
|
|
11
|
+
import time
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Any
|
|
13
14
|
|
|
@@ -45,6 +46,11 @@ def _read_raw() -> dict[str, Any]:
|
|
|
45
46
|
return {}
|
|
46
47
|
|
|
47
48
|
|
|
49
|
+
def read_stack_raw() -> dict[str, Any]:
|
|
50
|
+
"""Stack file dict (install wizard reads wizard_step)."""
|
|
51
|
+
return _read_raw()
|
|
52
|
+
|
|
53
|
+
|
|
48
54
|
def _write_raw(data: dict[str, Any]) -> None:
|
|
49
55
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
50
56
|
tmp = CONFIG_PATH.with_suffix(".tmp")
|
|
@@ -123,6 +129,7 @@ def get_stack_config() -> dict[str, Any]:
|
|
|
123
129
|
"port": int(smtp.get("port") or 587),
|
|
124
130
|
"user": str(smtp.get("user") or "").strip(),
|
|
125
131
|
"from": str(smtp.get("from") or smtp.get("from_address") or "").strip(),
|
|
132
|
+
"admin_email": str(smtp.get("admin_email") or "").strip(),
|
|
126
133
|
"secure": str(smtp.get("secure") or "starttls").strip(),
|
|
127
134
|
"has_password": bool(str(smtp.get("password") or "").strip()),
|
|
128
135
|
},
|
|
@@ -156,6 +163,10 @@ def patch_stack_config(body: dict[str, Any]) -> dict[str, Any]:
|
|
|
156
163
|
raw["app_base_url"] = str(body.get("app_base_url") or "").strip().rstrip("/")
|
|
157
164
|
if body.get("install_complete") is True:
|
|
158
165
|
raw["install_complete"] = True
|
|
166
|
+
if "wizard_step" in body:
|
|
167
|
+
step = str(body.get("wizard_step") or "").strip()
|
|
168
|
+
if step:
|
|
169
|
+
raw["wizard_step"] = step
|
|
159
170
|
if "smtp" in body and isinstance(body["smtp"], dict):
|
|
160
171
|
smtp = dict(raw.get("smtp") if isinstance(raw.get("smtp"), dict) else {})
|
|
161
172
|
creds_changed = False
|
|
@@ -348,6 +359,26 @@ def smtp_setup_complete() -> bool:
|
|
|
348
359
|
return bool(str(stack.get("admin_email") or "").strip()) and smtp_has_password() and smtp_configured()
|
|
349
360
|
|
|
350
361
|
|
|
362
|
+
def install_admin_verified() -> bool:
|
|
363
|
+
"""Install admin completed OTP during the install window."""
|
|
364
|
+
stack = _stack_smtp_raw()
|
|
365
|
+
return bool(stack.get("admin_verified_at"))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def mark_install_admin_verified(email: str) -> None:
|
|
369
|
+
"""Persist verified install admin — survives reload; closes anonymous install mutations."""
|
|
370
|
+
normalized = str(email or "").strip().lower()
|
|
371
|
+
if not normalized or "@" not in normalized:
|
|
372
|
+
return
|
|
373
|
+
raw = _read_raw()
|
|
374
|
+
smtp = dict(raw.get("smtp") if isinstance(raw.get("smtp"), dict) else {})
|
|
375
|
+
smtp["admin_email"] = normalized
|
|
376
|
+
smtp["admin_verified_at"] = int(time.time())
|
|
377
|
+
raw["smtp"] = smtp
|
|
378
|
+
raw["wizard_step"] = "workframe"
|
|
379
|
+
_write_raw(raw)
|
|
380
|
+
|
|
381
|
+
|
|
351
382
|
def mark_smtp_tested() -> None:
|
|
352
383
|
raw = _read_raw()
|
|
353
384
|
smtp = dict(raw.get("smtp") if isinstance(raw.get("smtp"), dict) else {})
|
|
@@ -398,11 +429,12 @@ def public_stack_payload() -> dict[str, Any]:
|
|
|
398
429
|
"port": smtp.get("port") or 587,
|
|
399
430
|
"user": smtp.get("user") or "",
|
|
400
431
|
"from": smtp.get("from") or "",
|
|
401
|
-
"admin_email":
|
|
432
|
+
"admin_email": str(_stack_smtp_raw().get("admin_email") or "").strip(),
|
|
402
433
|
"secure": smtp.get("secure") or "starttls",
|
|
403
434
|
"configured": smtp_configured(),
|
|
404
435
|
"tested": smtp_tested(),
|
|
405
436
|
"setup_complete": smtp_setup_complete(),
|
|
437
|
+
"admin_verified": install_admin_verified(),
|
|
406
438
|
"has_password": smtp_has_password(),
|
|
407
439
|
},
|
|
408
440
|
"google_oauth": {
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""WF-032 extract: workframe-supervisor HTTP client and gateway exec proxies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _srv():
|
|
14
|
+
import server as srv
|
|
15
|
+
|
|
16
|
+
return srv
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _supervisor_ready() -> bool:
|
|
20
|
+
return bool(_srv().SUPERVISOR_URL) and bool(_srv().SUPERVISOR_TOKEN)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _supervisor_request(
|
|
24
|
+
method: str,
|
|
25
|
+
path: str,
|
|
26
|
+
body: dict[str, Any] | None = None,
|
|
27
|
+
timeout: float = 30.0,
|
|
28
|
+
) -> tuple[int, Any]:
|
|
29
|
+
"""Proxy a JSON request to workframe-supervisor."""
|
|
30
|
+
if not _srv().SUPERVISOR_URL:
|
|
31
|
+
return 503, {"ok": False, "error": "WORKFRAME_SUPERVISOR_URL not configured"}
|
|
32
|
+
if not _srv().SUPERVISOR_TOKEN:
|
|
33
|
+
return 503, {"ok": False, "error": "WORKFRAME_SUPERVISOR_TOKEN not configured"}
|
|
34
|
+
url = f"{_srv().SUPERVISOR_URL}{path}"
|
|
35
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
36
|
+
headers = {
|
|
37
|
+
"Authorization": f"Bearer {_srv().SUPERVISOR_TOKEN}",
|
|
38
|
+
}
|
|
39
|
+
if body is not None:
|
|
40
|
+
headers["Content-Type"] = "application/json"
|
|
41
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
42
|
+
try:
|
|
43
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
44
|
+
raw = resp.read().decode("utf-8", errors="replace")
|
|
45
|
+
return int(resp.status), json.loads(raw) if raw else {}
|
|
46
|
+
except urllib.error.HTTPError as exc:
|
|
47
|
+
raw = exc.read().decode("utf-8", errors="replace")
|
|
48
|
+
try:
|
|
49
|
+
return int(exc.code), json.loads(raw) if raw else {}
|
|
50
|
+
except json.JSONDecodeError:
|
|
51
|
+
return int(exc.code), {"ok": False, "error": raw}
|
|
52
|
+
except (urllib.error.URLError, TimeoutError) as exc:
|
|
53
|
+
return 503, {
|
|
54
|
+
"ok": False,
|
|
55
|
+
"error": "supervisor_unavailable",
|
|
56
|
+
"hint": "On the server: cd infra/compose/workframe && docker compose up -d",
|
|
57
|
+
"detail": str(exc),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _maybe_sync_compose_public_url(public_url: str, *, restart: bool | None = None) -> dict[str, Any] | None:
|
|
62
|
+
"""Write APP_BASE_URL into host compose .env; optionally recreate API/gateway (not supervisor)."""
|
|
63
|
+
url = str(public_url or "").strip()
|
|
64
|
+
if not url:
|
|
65
|
+
return None
|
|
66
|
+
lowered = url.lower()
|
|
67
|
+
if "127.0.0.1" in lowered or "localhost" in lowered:
|
|
68
|
+
return None
|
|
69
|
+
if not _supervisor_ready():
|
|
70
|
+
return None
|
|
71
|
+
if not str(os.environ.get("WORKFRAME_HOST_COMPOSE_DIR") or "").strip():
|
|
72
|
+
return None
|
|
73
|
+
if restart is None:
|
|
74
|
+
restart = not _srv()._install_window_open()
|
|
75
|
+
status, data = _supervisor_request(
|
|
76
|
+
"POST",
|
|
77
|
+
"/v1/host.set_compose_public_url",
|
|
78
|
+
{"url": url, "restart": restart},
|
|
79
|
+
timeout=180.0,
|
|
80
|
+
)
|
|
81
|
+
if status >= 400:
|
|
82
|
+
return data if isinstance(data, dict) else {"ok": False, "error": "compose_sync_failed"}
|
|
83
|
+
return data if isinstance(data, dict) else {"ok": True}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _supervisor_gateway_exec(profile: str, args: list[str]) -> tuple[int, str]:
|
|
87
|
+
if not _supervisor_ready():
|
|
88
|
+
raise RuntimeError(
|
|
89
|
+
"Docker socket access is disabled in SECURE_MODE; "
|
|
90
|
+
"configure WORKFRAME_SUPERVISOR_URL and WORKFRAME_SUPERVISOR_TOKEN"
|
|
91
|
+
)
|
|
92
|
+
status, data = _supervisor_request(
|
|
93
|
+
"POST",
|
|
94
|
+
"/v1/gateway.exec",
|
|
95
|
+
{"profile": profile, "args": args},
|
|
96
|
+
timeout=120.0,
|
|
97
|
+
)
|
|
98
|
+
if status >= 300:
|
|
99
|
+
err = data.get("error") if isinstance(data, dict) else str(data)
|
|
100
|
+
raise ValueError(err or f"supervisor gateway.exec failed ({status})")
|
|
101
|
+
if not isinstance(data, dict):
|
|
102
|
+
raise ValueError("supervisor gateway.exec returned invalid payload")
|
|
103
|
+
exit_code = data.get("exit_code")
|
|
104
|
+
try:
|
|
105
|
+
code = int(exit_code if exit_code is not None else 1)
|
|
106
|
+
except (TypeError, ValueError):
|
|
107
|
+
code = 1
|
|
108
|
+
return code, str(data.get("output") or "")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _supervisor_container_exec(cmd: list[str], *, detach: bool = False) -> tuple[int, str]:
|
|
112
|
+
if not _supervisor_ready():
|
|
113
|
+
raise RuntimeError(
|
|
114
|
+
"Docker socket access is disabled in SECURE_MODE; "
|
|
115
|
+
"configure WORKFRAME_SUPERVISOR_URL and WORKFRAME_SUPERVISOR_TOKEN"
|
|
116
|
+
)
|
|
117
|
+
status, data = _supervisor_request(
|
|
118
|
+
"POST",
|
|
119
|
+
"/v1/gateway.container_exec",
|
|
120
|
+
{"args": [str(part) for part in cmd], "detach": detach},
|
|
121
|
+
timeout=30.0 if detach else 120.0,
|
|
122
|
+
)
|
|
123
|
+
if status >= 300:
|
|
124
|
+
err = data.get("error") if isinstance(data, dict) else str(data)
|
|
125
|
+
raise ValueError(err or f"supervisor gateway.container_exec failed ({status})")
|
|
126
|
+
if not isinstance(data, dict):
|
|
127
|
+
raise ValueError("supervisor gateway.container_exec returned invalid payload")
|
|
128
|
+
if detach:
|
|
129
|
+
return 0, str(data.get("output") or "")
|
|
130
|
+
exit_code = data.get("exit_code")
|
|
131
|
+
try:
|
|
132
|
+
code = int(exit_code if exit_code is not None else 1)
|
|
133
|
+
except (TypeError, ValueError):
|
|
134
|
+
code = 1
|
|
135
|
+
return code, str(data.get("output") or "")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _supervisor_profile_lifecycle(profile: str, action: str) -> dict[str, Any]:
|
|
139
|
+
if not _supervisor_ready():
|
|
140
|
+
raise RuntimeError(
|
|
141
|
+
"Docker socket access is disabled in SECURE_MODE; "
|
|
142
|
+
"configure WORKFRAME_SUPERVISOR_URL and WORKFRAME_SUPERVISOR_TOKEN"
|
|
143
|
+
)
|
|
144
|
+
if action == "status":
|
|
145
|
+
q = urllib.parse.quote(profile, safe="")
|
|
146
|
+
status, data = _supervisor_request("GET", f"/v1/profile.status?profile={q}", timeout=15.0)
|
|
147
|
+
else:
|
|
148
|
+
status, data = _supervisor_request("POST", f"/v1/profile.{action}", {"profile": profile}, timeout=60.0)
|
|
149
|
+
if status >= 300:
|
|
150
|
+
err = data.get("error") if isinstance(data, dict) else str(data)
|
|
151
|
+
raise ValueError(err or f"supervisor {action} failed ({status})")
|
|
152
|
+
if not isinstance(data, dict):
|
|
153
|
+
raise ValueError(f"supervisor {action} returned invalid payload")
|
|
154
|
+
return data
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""ponytail self-check: /api/meta exposes package + UI build stamp (WF-012).
|
|
2
|
+
|
|
3
|
+
Run: python services/workframe-api/test_api_meta_build_stamp.py
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
12
|
+
|
|
13
|
+
import api_meta # noqa: E402
|
|
14
|
+
|
|
15
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
16
|
+
ui_public = Path(tmp) / "workframe-ui" / "public"
|
|
17
|
+
ui_public.mkdir(parents=True)
|
|
18
|
+
stamp = {
|
|
19
|
+
"package_version": "9.9.9",
|
|
20
|
+
"bundled_at": "2026-07-08T12:00:00.000Z",
|
|
21
|
+
"git_ref": "abc1234",
|
|
22
|
+
}
|
|
23
|
+
(ui_public / "workframe-build.json").write_text(json.dumps(stamp), encoding="utf-8")
|
|
24
|
+
os.environ["WORKFRAME_PROJECT_ROOT"] = tmp
|
|
25
|
+
os.environ["WORKFRAME_API_VERSION"] = "9.9.9"
|
|
26
|
+
try:
|
|
27
|
+
ui = api_meta._ui_build_stamp()
|
|
28
|
+
assert ui["package_version"] == "9.9.9", ui
|
|
29
|
+
assert ui["git_ref"] == "abc1234", ui
|
|
30
|
+
finally:
|
|
31
|
+
os.environ.pop("WORKFRAME_PROJECT_ROOT", None)
|
|
32
|
+
os.environ.pop("WORKFRAME_API_VERSION", None)
|
|
33
|
+
|
|
34
|
+
print("api meta build stamp self-check ok")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""WF-007 CellAuthorityGate self-check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
11
|
+
|
|
12
|
+
from cell_authority import ( # noqa: E402
|
|
13
|
+
evaluate_adopt,
|
|
14
|
+
evaluate_connect,
|
|
15
|
+
evaluate_create,
|
|
16
|
+
evaluate_open,
|
|
17
|
+
evaluate_update,
|
|
18
|
+
load_cell_from_manifest,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_create_deny_nonempty() -> None:
|
|
23
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
24
|
+
root = Path(tmp)
|
|
25
|
+
(root / "existing.txt").write_text("x", encoding="utf-8")
|
|
26
|
+
decision = evaluate_create(root)
|
|
27
|
+
assert decision.decision == "deny"
|
|
28
|
+
assert decision.reason == "target_not_empty"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_create_allow_empty() -> None:
|
|
32
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
33
|
+
root = Path(tmp) / "newcell"
|
|
34
|
+
decision = evaluate_create(root, package_version="0.1.0")
|
|
35
|
+
assert decision.decision == "allow"
|
|
36
|
+
assert decision.mutation_plan
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_open_readonly() -> None:
|
|
40
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
41
|
+
root = Path(tmp)
|
|
42
|
+
(root / "Files").mkdir()
|
|
43
|
+
manifest = {
|
|
44
|
+
"install_id": "cell-1",
|
|
45
|
+
"package_name": "create-workframe",
|
|
46
|
+
"package_version": "0.1.0",
|
|
47
|
+
"layout": {"workspace": "Files", "runtime": "Agents"},
|
|
48
|
+
}
|
|
49
|
+
(root / "workframe-manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
|
50
|
+
cell = load_cell_from_manifest(root)
|
|
51
|
+
assert cell is not None
|
|
52
|
+
decision = evaluate_open(root)
|
|
53
|
+
assert decision.decision == "allow_readonly"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_update_needs_ack() -> None:
|
|
57
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
58
|
+
root = Path(tmp)
|
|
59
|
+
(root / "Files").mkdir()
|
|
60
|
+
(root / "workframe-manifest.json").write_text(
|
|
61
|
+
json.dumps({"layout": {"workspace": "Files"}}),
|
|
62
|
+
encoding="utf-8",
|
|
63
|
+
)
|
|
64
|
+
decision = evaluate_update(root, user_ack=False)
|
|
65
|
+
assert decision.decision == "needs_user_action"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_adopt_denied() -> None:
|
|
69
|
+
assert evaluate_adopt(runtime_kind="cursor").reason == "adapter_not_proven"
|
|
70
|
+
assert evaluate_connect(remote_attested=False).decision == "deny"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
test_create_deny_nonempty()
|
|
75
|
+
test_create_allow_empty()
|
|
76
|
+
test_open_readonly()
|
|
77
|
+
test_update_needs_ack()
|
|
78
|
+
test_adopt_denied()
|
|
79
|
+
print("test_cell_authority: ok")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""WF-032 chat_bind self-check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
9
|
+
|
|
10
|
+
import chat_bind # noqa: E402
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_extract_title() -> None:
|
|
14
|
+
assert chat_bind._extract_title("# Hello World\nbody", "fallback") == "Hello World"
|
|
15
|
+
assert chat_bind._extract_title("no heading", "fallback") == "fallback"
|
|
16
|
+
assert chat_bind._extract_title(" # Indented\n", "x") == "Indented"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_enrich_room_chat_payload_sets_user() -> None:
|
|
20
|
+
out = chat_bind._enrich_room_chat_payload({"text": "hi"}, "user-1")
|
|
21
|
+
assert out["user_id"] == "user-1"
|
|
22
|
+
assert out["text"] == "hi"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_room_chat_bind_delegates_room_id() -> None:
|
|
26
|
+
"""room_chat_bind injects room_id then calls profile_chat_bind."""
|
|
27
|
+
calls: list[tuple] = []
|
|
28
|
+
|
|
29
|
+
orig = chat_bind.profile_chat_bind
|
|
30
|
+
chat_bind.profile_chat_bind = lambda profile, payload, user_id="": (
|
|
31
|
+
calls.append((profile, payload, user_id)) or {"ok": True, "session_id": "s1"}
|
|
32
|
+
)
|
|
33
|
+
try:
|
|
34
|
+
chat_bind.room_chat_bind("room-abc", {"source_id": "ui"}, "u1")
|
|
35
|
+
finally:
|
|
36
|
+
chat_bind.profile_chat_bind = orig
|
|
37
|
+
|
|
38
|
+
assert len(calls) == 1
|
|
39
|
+
assert calls[0][0] == ""
|
|
40
|
+
assert calls[0][1]["room_id"] == "room-abc"
|
|
41
|
+
assert calls[0][2] == "u1"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
test_extract_title()
|
|
46
|
+
test_enrich_room_chat_payload_sets_user()
|
|
47
|
+
test_room_chat_bind_delegates_room_id()
|
|
48
|
+
print("test_chat_bind: ok")
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""WF-028 lease validation test matrix — expired, revoked, mismatch, proxy token.
|
|
2
|
+
|
|
3
|
+
Run: python services/workframe-api/test_credential_lease_matrix.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sqlite3
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
16
|
+
|
|
17
|
+
import broker_audit
|
|
18
|
+
import credential_broker
|
|
19
|
+
import internal_proxy_auth
|
|
20
|
+
import turn_credentials
|
|
21
|
+
from internal_proxy_auth import PROFILE_HEADER, PROXY_TOKEN_ENV, reset_proxy_token_for_tests
|
|
22
|
+
|
|
23
|
+
_TD = tempfile.mkdtemp(prefix="wf-lease-matrix-")
|
|
24
|
+
os.environ["WORKFRAME_API_DATA_DIR"] = _TD
|
|
25
|
+
turn_credentials.WORKFRAME_DB = Path(_TD) / "workframe.db"
|
|
26
|
+
turn_credentials._SCHEMA_READY.clear()
|
|
27
|
+
broker_audit.WORKFRAME_DB = Path(_TD) / "workframe.db"
|
|
28
|
+
broker_audit._SCHEMA_READY.clear()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve_secret(_user: str, _ws: str, _provider: str, _binding: str) -> tuple[str, str]:
|
|
32
|
+
return "OPENROUTER_API_KEY", "sk-test-secret"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _issue(**kwargs: str) -> str:
|
|
36
|
+
return turn_credentials.issue_lease(
|
|
37
|
+
kwargs.get("run_id", "run-test"),
|
|
38
|
+
kwargs.get("user", "user-a"),
|
|
39
|
+
kwargs.get("workspace", "ws-1"),
|
|
40
|
+
kwargs.get("provider", "openrouter"),
|
|
41
|
+
kwargs.get("profile", "u-user-a-dev"),
|
|
42
|
+
kwargs.get("binding", "bind-1"),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _headers(token: str, profile: str = "u-user-a-dev") -> dict[str, str]:
|
|
47
|
+
return {
|
|
48
|
+
"Authorization": f"Bearer {token}",
|
|
49
|
+
PROFILE_HEADER: profile,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_active_lease_ok() -> None:
|
|
54
|
+
tok = _issue(run_id="run-ok")
|
|
55
|
+
auth = credential_broker.authorize_broker_lease(
|
|
56
|
+
"openrouter",
|
|
57
|
+
_headers(tok),
|
|
58
|
+
resolve_secret=_resolve_secret,
|
|
59
|
+
)
|
|
60
|
+
assert auth.ok and auth.secret == "sk-test-secret"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_revoked_lease() -> None:
|
|
64
|
+
tok = _issue(run_id="run-revoked")
|
|
65
|
+
turn_credentials.revoke_lease("run-revoked")
|
|
66
|
+
reason, _ = turn_credentials.inspect_lease(tok)
|
|
67
|
+
assert reason == "revoked"
|
|
68
|
+
auth = credential_broker.authorize_broker_lease(
|
|
69
|
+
"openrouter",
|
|
70
|
+
_headers(tok),
|
|
71
|
+
resolve_secret=_resolve_secret,
|
|
72
|
+
)
|
|
73
|
+
assert not auth.ok and auth.deny_reason == "revoked" and auth.status == 401
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_expired_lease() -> None:
|
|
77
|
+
tok = _issue(run_id="run-expired")
|
|
78
|
+
past = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat()
|
|
79
|
+
conn = sqlite3.connect(str(turn_credentials.WORKFRAME_DB))
|
|
80
|
+
conn.execute(
|
|
81
|
+
"UPDATE turn_credential_leases SET expires_at = ? WHERE run_id = ?",
|
|
82
|
+
(past, "run-expired"),
|
|
83
|
+
)
|
|
84
|
+
conn.commit()
|
|
85
|
+
conn.close()
|
|
86
|
+
reason, _ = turn_credentials.inspect_lease(tok)
|
|
87
|
+
assert reason == "expired"
|
|
88
|
+
auth = credential_broker.authorize_broker_lease(
|
|
89
|
+
"openrouter",
|
|
90
|
+
_headers(tok),
|
|
91
|
+
resolve_secret=_resolve_secret,
|
|
92
|
+
)
|
|
93
|
+
assert not auth.ok and auth.deny_reason == "expired"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_provider_mismatch() -> None:
|
|
97
|
+
tok = _issue(run_id="run-prov", provider="openrouter")
|
|
98
|
+
auth = credential_broker.authorize_broker_lease(
|
|
99
|
+
"anthropic",
|
|
100
|
+
_headers(tok),
|
|
101
|
+
resolve_secret=_resolve_secret,
|
|
102
|
+
)
|
|
103
|
+
assert not auth.ok and auth.deny_reason == "provider_mismatch" and auth.status == 403
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_profile_mismatch() -> None:
|
|
107
|
+
tok = _issue(run_id="run-prof", profile="u-user-a-dev")
|
|
108
|
+
auth = credential_broker.authorize_broker_lease(
|
|
109
|
+
"openrouter",
|
|
110
|
+
_headers(tok, profile="u-other-dev"),
|
|
111
|
+
resolve_secret=_resolve_secret,
|
|
112
|
+
)
|
|
113
|
+
assert not auth.ok and auth.deny_reason == "profile_mismatch" and auth.status == 403
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_missing_profile_header() -> None:
|
|
117
|
+
tok = _issue(run_id="run-noprof", profile="u-user-a-dev")
|
|
118
|
+
auth = credential_broker.authorize_broker_lease(
|
|
119
|
+
"openrouter",
|
|
120
|
+
{"Authorization": f"Bearer {tok}"},
|
|
121
|
+
resolve_secret=_resolve_secret,
|
|
122
|
+
)
|
|
123
|
+
assert not auth.ok and auth.deny_reason == "profile_header_required"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_missing_proxy_token() -> None:
|
|
127
|
+
reset_proxy_token_for_tests()
|
|
128
|
+
os.environ[PROXY_TOKEN_ENV] = "matrix-proxy-token"
|
|
129
|
+
|
|
130
|
+
class _Handler:
|
|
131
|
+
client_address = ("172.19.0.2", 1234)
|
|
132
|
+
headers: dict[str, str] = {}
|
|
133
|
+
|
|
134
|
+
ok, err = internal_proxy_auth.authorize_internal_proxy(_Handler()) # type: ignore[arg-type]
|
|
135
|
+
assert not ok and err == "proxy token required"
|
|
136
|
+
|
|
137
|
+
_Handler.headers = {internal_proxy_auth.PROXY_TOKEN_HEADER: "matrix-proxy-token"}
|
|
138
|
+
ok, err = internal_proxy_auth.authorize_internal_proxy(_Handler()) # type: ignore[arg-type]
|
|
139
|
+
assert ok and not err
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_broker_audit_on_deny() -> None:
|
|
143
|
+
broker_audit._SCHEMA_READY.clear()
|
|
144
|
+
tok = _issue(run_id="run-audit")
|
|
145
|
+
turn_credentials.revoke_lease("run-audit")
|
|
146
|
+
credential_broker.authorize_broker_lease(
|
|
147
|
+
"openrouter",
|
|
148
|
+
_headers(tok),
|
|
149
|
+
resolve_secret=_resolve_secret,
|
|
150
|
+
broker_kind="llm",
|
|
151
|
+
upstream_host="openrouter.ai",
|
|
152
|
+
)
|
|
153
|
+
events = broker_audit.list_broker_events(limit=20)
|
|
154
|
+
denied = [e for e in events if e.get("deny_reason") == "revoked" and e.get("run_id") == "run-audit"]
|
|
155
|
+
assert denied
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def main() -> None:
|
|
159
|
+
test_active_lease_ok()
|
|
160
|
+
test_revoked_lease()
|
|
161
|
+
test_expired_lease()
|
|
162
|
+
test_provider_mismatch()
|
|
163
|
+
test_profile_mismatch()
|
|
164
|
+
test_missing_profile_header()
|
|
165
|
+
test_missing_proxy_token()
|
|
166
|
+
test_broker_audit_on_deny()
|
|
167
|
+
print("credential lease matrix ok")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
main()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""WF-032 credential_resolve self-check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
9
|
+
|
|
10
|
+
import credential_resolve # noqa: E402
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_default_env_var() -> None:
|
|
14
|
+
assert credential_resolve._default_credential_env_var("openrouter", "api_key") == "OPENROUTER_API_KEY"
|
|
15
|
+
assert credential_resolve._default_credential_env_var("discord", "bot_token") == "DISCORD_BOT_TOKEN"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_resolve_empty() -> None:
|
|
19
|
+
assert credential_resolve._resolve_credential("", "", "openrouter") is None
|
|
20
|
+
assert credential_resolve._resolve_credential("user-1", "", "") is None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_binding_payload_shape() -> None:
|
|
24
|
+
class _Row(dict):
|
|
25
|
+
def __getitem__(self, key: str):
|
|
26
|
+
return dict(self).get(key)
|
|
27
|
+
|
|
28
|
+
row = _Row(
|
|
29
|
+
id="bind-1",
|
|
30
|
+
credential_ref="vault:abc",
|
|
31
|
+
provider="openrouter",
|
|
32
|
+
credential_type="api_key",
|
|
33
|
+
label="test",
|
|
34
|
+
user_id="u1",
|
|
35
|
+
workspace_id=None,
|
|
36
|
+
agent_profile_id=None,
|
|
37
|
+
created_by="u1",
|
|
38
|
+
created_at="1",
|
|
39
|
+
updated_at="1",
|
|
40
|
+
expires_at=None,
|
|
41
|
+
)
|
|
42
|
+
payload = credential_resolve._credential_binding_payload(row, "user") # type: ignore[arg-type]
|
|
43
|
+
assert payload["scope"] == "user"
|
|
44
|
+
assert payload["credential_ref"] == "vault:abc"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
test_default_env_var()
|
|
49
|
+
test_resolve_empty()
|
|
50
|
+
test_binding_payload_shape()
|
|
51
|
+
print("test_credential_resolve: ok")
|