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.
Files changed (188) hide show
  1. package/README.md +1 -1
  2. package/bin/create-workframe.js +2 -2
  3. package/bin/workframe.js +130 -2
  4. package/docs/security.md +2 -2
  5. package/package.json +1 -1
  6. package/scripts/bundle-workframe-ui.mjs +9 -0
  7. package/scripts/sync-canonical-to-package.mjs +1 -0
  8. package/scripts/verify-public-deploy.sh +65 -3
  9. package/workframe-api/action_proxy.py +23 -17
  10. package/workframe-api/activity_feed.py +798 -0
  11. package/workframe-api/api_meta.py +157 -0
  12. package/workframe-api/auth_gate.py +566 -0
  13. package/workframe-api/avatar_registry.py +343 -0
  14. package/workframe-api/broker_audit.py +164 -0
  15. package/workframe-api/cell_authority.py +231 -0
  16. package/workframe-api/chat_bind.py +319 -0
  17. package/workframe-api/chat_sessions.py +509 -0
  18. package/workframe-api/chat_stream.py +694 -0
  19. package/workframe-api/cleanup_dogfood_smoke.py +252 -0
  20. package/workframe-api/credential_broker.py +162 -0
  21. package/workframe-api/credential_resolve.py +202 -0
  22. package/workframe-api/credential_store.py +245 -0
  23. package/workframe-api/crew_registry.py +250 -0
  24. package/workframe-api/db_schema.py +755 -0
  25. package/workframe-api/docker_gateway.py +166 -0
  26. package/workframe-api/doctor_runtime.py +109 -0
  27. package/workframe-api/domain/__init__.py +47 -0
  28. package/workframe-api/domain/entities.py +252 -0
  29. package/workframe-api/domain/schema/workframe-domain.schema.json +278 -0
  30. package/workframe-api/egress_policy.py +42 -0
  31. package/workframe-api/handler_modules/__init__.py +17 -0
  32. package/workframe-api/handler_modules/handler_admin.py +542 -0
  33. package/workframe-api/handler_modules/handler_auth.py +512 -0
  34. package/workframe-api/handler_modules/handler_chat.py +641 -0
  35. package/workframe-api/handler_modules/handler_install.py +178 -0
  36. package/workframe-api/handler_modules/handler_provider.py +325 -0
  37. package/workframe-api/handler_modules/handler_workspace.py +1420 -0
  38. package/workframe-api/health_monitor.py +80 -0
  39. package/workframe-api/hermes_admin.py +756 -0
  40. package/workframe-api/hermes_profiles.py +1328 -0
  41. package/workframe-api/install_api.py +123 -0
  42. package/workframe-api/kanban_cron.py +473 -0
  43. package/workframe-api/lane_bindings.py +423 -0
  44. package/workframe-api/llm_proxy.py +17 -43
  45. package/workframe-api/mention_helpers.py +180 -0
  46. package/workframe-api/mention_invoke.py +431 -0
  47. package/workframe-api/model_surface.py +1139 -0
  48. package/workframe-api/oauth_pending.py +85 -0
  49. package/workframe-api/oauth_redirect.py +381 -0
  50. package/workframe-api/package.json +2 -2
  51. package/workframe-api/profile_api_lifecycle.py +66 -0
  52. package/workframe-api/profile_gateway.py +436 -0
  53. package/workframe-api/provider_bindings.py +673 -0
  54. package/workframe-api/provider_bootstrap.py +347 -0
  55. package/workframe-api/provider_catalog.py +180 -0
  56. package/workframe-api/rooms.py +1613 -0
  57. package/workframe-api/route_registry.py +331 -191
  58. package/workframe-api/run-typecheck.mjs +2 -1
  59. package/workframe-api/run_authority.py +287 -0
  60. package/workframe-api/run_ledger.py +470 -0
  61. package/workframe-api/run_surface_wiring.py +344 -0
  62. package/workframe-api/runtime_cohort.py +752 -0
  63. package/workframe-api/runtime_tokens.py +127 -0
  64. package/workframe-api/server.py +1453 -19513
  65. package/workframe-api/snapshot_feed.py +49 -0
  66. package/workframe-api/stack_config.py +33 -1
  67. package/workframe-api/supervisor_client.py +154 -0
  68. package/workframe-api/test_api_meta_build_stamp.py +34 -0
  69. package/workframe-api/test_cell_authority.py +79 -0
  70. package/workframe-api/test_chat_bind.py +48 -0
  71. package/workframe-api/test_credential_lease_matrix.py +171 -0
  72. package/workframe-api/test_credential_resolve.py +51 -0
  73. package/workframe-api/test_credential_store.py +27 -0
  74. package/workframe-api/test_dogfood_flows.py +346 -0
  75. package/workframe-api/test_domain_entities.py +152 -0
  76. package/workframe-api/test_exception_hygiene.py +12 -0
  77. package/workframe-api/test_hermes_admin.py +72 -0
  78. package/workframe-api/test_install_wizard_resume.py +78 -0
  79. package/workframe-api/test_local_bootstrap.py +67 -0
  80. package/workframe-api/test_mention_helpers.py +35 -0
  81. package/workframe-api/test_mention_invoke.py +26 -0
  82. package/workframe-api/test_model_surface_consistency.py +1 -0
  83. package/workframe-api/test_oauth_pending.py +41 -0
  84. package/workframe-api/test_provider_bindings.py +52 -0
  85. package/workframe-api/test_provider_catalog.py +28 -0
  86. package/workframe-api/test_route_registry.py +171 -35
  87. package/workframe-api/test_run_authority.py +112 -0
  88. package/workframe-api/test_run_ledger.py +157 -0
  89. package/workframe-api/test_run_surface_wiring.py +130 -0
  90. package/workframe-api/test_runtime_cohort.py +85 -0
  91. package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
  92. package/workframe-api/test_server_reexports.py +99 -0
  93. package/workframe-api/test_stack_config_install_smtp.py +7 -0
  94. package/workframe-api/turn_credentials.py +28 -13
  95. package/workframe-api/turn_overlay.py +715 -0
  96. package/workframe-api/updates.py +16 -1
  97. package/workframe-api/user_prefs.py +387 -0
  98. package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
  99. package/workframe-api/workspace_bootstrap.py +536 -0
  100. package/workframe-api/workspace_files.py +344 -0
  101. package/workframe-api/workspace_messaging.py +206 -0
  102. package/workframe-api/zk_auth.py +3 -2
  103. package/workframe-supervisor/server.py +10 -1
  104. package/workframe-supervisor/test_supervisor_negative.py +75 -0
  105. package/workframe-ui/public/assets/{arc-B0OFRGmJ.js → arc-aUUffclm.js} +1 -1
  106. package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
  107. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DeBNltHS.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
  108. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-BDhCHu7I.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
  109. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-olcDYvtI.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
  110. package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
  111. package/workframe-ui/public/assets/{chunk-2J33WTMH-D0obCBn_.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
  112. package/workframe-ui/public/assets/{chunk-3OPIFGDE-DX93f-ZZ.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
  113. package/workframe-ui/public/assets/{chunk-4BX2VUAB-BX9B5wUs.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
  114. package/workframe-ui/public/assets/{chunk-55IACEB6-C4DGc6RO.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
  115. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-Crlja9Lf.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
  116. package/workframe-ui/public/assets/{chunk-727SXJPM-JYSm3szI.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
  117. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-_KslxVEl.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
  118. package/workframe-ui/public/assets/{chunk-BSJP7CBP-CpTO-jU8.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
  119. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-JGUkCmbI.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
  120. package/workframe-ui/public/assets/{chunk-FMBD7UC4-BscBwGaC.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
  121. package/workframe-ui/public/assets/{chunk-KSCS5N6A-5ZTZ0bpX.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
  122. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-DxvitYbW.js → chunk-L5ZTLDWV-BLZrdIwa.js} +1 -1
  123. package/workframe-ui/public/assets/chunk-LZXEDZCA-DisG0XJT.js +2 -0
  124. package/workframe-ui/public/assets/{chunk-ND2GUHAM-Cayl0iV9.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
  125. package/workframe-ui/public/assets/{chunk-NZK2D7GU-_7dyBR5G.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
  126. package/workframe-ui/public/assets/{chunk-O5CBEL6O--xTpD0yi.js → chunk-O5CBEL6O-DDN-Ifon.js} +1 -1
  127. package/workframe-ui/public/assets/chunk-QZHKN3VN-EVQ0VMd5.js +1 -0
  128. package/workframe-ui/public/assets/chunk-WU5MYG2G-k_-ZsLOS.js +1 -0
  129. package/workframe-ui/public/assets/{chunk-XPW4576I-CwxJQaeK.js → chunk-XPW4576I-B4_iYZ6r.js} +1 -1
  130. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-W9WVQsby.js +1 -0
  131. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-W9WVQsby.js +1 -0
  132. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CCspENYv.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
  133. package/workframe-ui/public/assets/{dagre-BM42HDAG-Dv8V6R60.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
  134. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Da48hFPT.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
  135. package/workframe-ui/public/assets/{diagram-5GNKFQAL-dTihc7-3.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
  136. package/workframe-ui/public/assets/{diagram-KO2AKTUF-D_Jqu699.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
  137. package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt8PtEaZ.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
  138. package/workframe-ui/public/assets/{diagram-OG6HWLK6-BtmPjlh0.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
  139. package/workframe-ui/public/assets/{dist-Cz2turIT.js → dist-mQWmB3z6.js} +1 -1
  140. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-BxMGCflX.js → erDiagram-TEJ5UH35-__40xO-y.js} +1 -1
  141. package/workframe-ui/public/assets/eventmodeling-FCH6USID-Cm1uRTxU.js +1 -0
  142. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-ivyroIZt.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
  143. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-B9llwShx.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
  144. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BFQHk4bw.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
  145. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-ursegLbc.js → gitGraphDiagram-PVQCEYII-DMTxFJxn.js} +1 -1
  146. package/workframe-ui/public/assets/index-BdiM4-lI.js +130 -0
  147. package/workframe-ui/public/assets/index-Fnad47vV.css +1 -0
  148. package/workframe-ui/public/assets/{info-J43DQDTF-DQpifAsB.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
  149. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-C16XP2Q7.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
  150. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-CiJoz7rP.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
  151. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CVwEvvUL.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
  152. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CWQ6hX5i.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
  153. package/workframe-ui/public/assets/{line-CgQsmU35.js → line-BTCEHb7l.js} +1 -1
  154. package/workframe-ui/public/assets/{linear-Bxbc77dL.js → linear-CkbydpnK.js} +1 -1
  155. package/workframe-ui/public/assets/{mermaid-parser.core-C_jMeF0a.js → mermaid-parser.core-DvrtArpk.js} +2 -2
  156. package/workframe-ui/public/assets/{mermaid.core-4RKV9MZP.js → mermaid.core-yj_1x_qj.js} +3 -3
  157. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-DZ7y7Sz0.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
  158. package/workframe-ui/public/assets/{packet-YPE3B663-B9h2I7Vq.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
  159. package/workframe-ui/public/assets/{pie-LRSECV5Y-B2mP5uHm.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
  160. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-DyYKyKiP.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
  161. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-CL_6nej-.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
  162. package/workframe-ui/public/assets/{radar-GUYGQ44K-CNKNDQ7S.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
  163. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-eWU_mhFa.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
  164. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DrjcXvEQ.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
  165. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BHWR1xTJ.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
  166. package/workframe-ui/public/assets/{src-DfJB8kV1.js → src-D6mvlP96.js} +1 -1
  167. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BGJbSd3I.js → stateDiagram-AJRCARHV-CxaTXs02.js} +1 -1
  168. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CXikj9xi.js +1 -0
  169. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DkwoCjiZ.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
  170. package/workframe-ui/public/assets/{treeView-BLDUP644-GxfhiqoW.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
  171. package/workframe-ui/public/assets/{treemap-LRROVOQU-DxrOsars.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
  172. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-BsWhpUfr.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
  173. package/workframe-ui/public/assets/{wardley-L42UT6IY-CrFZWMHS.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
  174. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DV8Xpf5I.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
  175. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DqMqnwdZ.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
  176. package/workframe-ui/public/index.html +7 -5
  177. package/workframe-ui/public/workframe-build.json +5 -0
  178. package/workframe-ui/public/assets/architecture-7EHR7CIX-Dwwi9yA0.js +0 -1
  179. package/workframe-ui/public/assets/channel-fNHbDpV4.js +0 -1
  180. package/workframe-ui/public/assets/chunk-LZXEDZCA-DMfdMUwz.js +0 -2
  181. package/workframe-ui/public/assets/chunk-QZHKN3VN-ewTgEQK8.js +0 -1
  182. package/workframe-ui/public/assets/chunk-WU5MYG2G-D4Tg-A0l.js +0 -1
  183. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CFAgBpEl.js +0 -1
  184. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CFAgBpEl.js +0 -1
  185. package/workframe-ui/public/assets/eventmodeling-FCH6USID-BIJTP5k-.js +0 -1
  186. package/workframe-ui/public/assets/index-8CuZDEIG.css +0 -1
  187. package/workframe-ui/public/assets/index-xS9lFekI.js +0 -129
  188. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CMLuNyeC.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": smtp.get("admin_email") or "",
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")