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,252 @@
1
+ #!/usr/bin/env python3
2
+ """Remove smoke-test clutter from a dogfood Workframe install.
3
+
4
+ python cleanup_dogfood_smoke.py --base http://127.0.0.1:18644 \\
5
+ --data-dir /path/to/MyBusiness/workframe-api/data
6
+
7
+ ponytail: dogfood on Windows Docker often cannot DELETE rooms via API (sqlite lock);
8
+ falls back to direct DB soft-delete and Hermes filesystem/registry cleanup.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import shutil
15
+ import sqlite3
16
+ import sys
17
+ import time
18
+ import urllib.error
19
+ import urllib.request
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ ROOT = Path(__file__).resolve().parent
24
+ sys.path.insert(0, str(ROOT))
25
+
26
+ from test_dogfood_flows import _mint_session_cookie, _pick_user, _req # noqa: E402
27
+
28
+
29
+ def _smoke_room_ids(conn: sqlite3.Connection) -> list[str]:
30
+ rows = conn.execute(
31
+ """
32
+ SELECT id FROM rooms
33
+ WHERE deleted_at IS NULL AND (
34
+ slug LIKE 'smoke-%'
35
+ OR name LIKE 'Smoke %'
36
+ OR topic = 'smoke test'
37
+ OR (name = 'Smoke Agent' AND slug LIKE 'dm-%')
38
+ )
39
+ """
40
+ ).fetchall()
41
+ return [str(r[0]) for r in rows]
42
+
43
+
44
+ def _smoke_agent_slugs(conn: sqlite3.Connection) -> list[str]:
45
+ rows = conn.execute(
46
+ """
47
+ SELECT slug FROM agent_profiles
48
+ WHERE deleted_at IS NULL AND (
49
+ slug LIKE 'smoke-agent-%' OR display_name = 'Smoke Agent'
50
+ )
51
+ """
52
+ ).fetchall()
53
+ return [str(r[0]) for r in rows if str(r[0]).strip()]
54
+
55
+
56
+ def _smoke_invite_ids(conn: sqlite3.Connection) -> list[str]:
57
+ rows = conn.execute(
58
+ """
59
+ SELECT id FROM workspace_invites
60
+ WHERE deleted_at IS NULL AND email LIKE '%@workframe.test'
61
+ """
62
+ ).fetchall()
63
+ return [str(r[0]) for r in rows]
64
+
65
+
66
+ def _is_smoke_profile(name: str) -> bool:
67
+ return "smoke-agent" in name
68
+
69
+
70
+ def _soft_delete_rooms_db(conn: sqlite3.Connection) -> int:
71
+ now = str(int(time.time()))
72
+ cur = conn.execute(
73
+ """
74
+ UPDATE rooms SET deleted_at = ?
75
+ WHERE deleted_at IS NULL AND (
76
+ slug LIKE 'smoke-%' OR name LIKE 'Smoke %' OR topic = 'smoke test'
77
+ OR (name = 'Smoke Agent' AND slug LIKE 'dm-%')
78
+ )
79
+ """,
80
+ (now,),
81
+ )
82
+ conn.commit()
83
+ return int(cur.rowcount)
84
+
85
+
86
+ def _soft_delete_agents_db(conn: sqlite3.Connection, slugs: list[str]) -> int:
87
+ if not slugs:
88
+ return 0
89
+ now = str(int(time.time()))
90
+ placeholders = ",".join("?" * len(slugs))
91
+ cur = conn.execute(
92
+ f"UPDATE agent_profiles SET deleted_at = ?, updated_at = ? "
93
+ f"WHERE deleted_at IS NULL AND slug IN ({placeholders})",
94
+ [now, now, *slugs],
95
+ )
96
+ conn.commit()
97
+ return int(cur.rowcount)
98
+
99
+
100
+ def _revoke_invites_db(conn: sqlite3.Connection, invite_ids: list[str]) -> int:
101
+ if not invite_ids:
102
+ return 0
103
+ now = str(int(time.time()))
104
+ placeholders = ",".join("?" * len(invite_ids))
105
+ cur = conn.execute(
106
+ f"UPDATE workspace_invites SET deleted_at = ? WHERE id IN ({placeholders}) AND deleted_at IS NULL",
107
+ [now, *invite_ids],
108
+ )
109
+ conn.commit()
110
+ return int(cur.rowcount)
111
+
112
+
113
+ def _agents_root(data_dir: Path) -> Path | None:
114
+ # MyBusiness dogfood: data is workframe-api/data, Agents is sibling of workframe-api.
115
+ candidate = data_dir.parent.parent / "Agents"
116
+ return candidate if candidate.is_dir() else None
117
+
118
+
119
+ def _cleanup_hermes_fs(agents_root: Path) -> None:
120
+ agents_json = agents_root / "workframe" / "agents.json"
121
+ if agents_json.is_file():
122
+ data = json.loads(agents_json.read_text(encoding="utf-8"))
123
+ agents = data.get("agents", {})
124
+ if isinstance(agents, dict):
125
+ data["agents"] = {k: v for k, v in agents.items() if not _is_smoke_profile(k)}
126
+ agents_json.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
127
+
128
+ for rel in ("avatar-registry.json", "routes.json"):
129
+ path = agents_root / "workframe" / rel
130
+ if not path.is_file():
131
+ continue
132
+ payload = json.loads(path.read_text(encoding="utf-8"))
133
+ if rel == "avatar-registry.json":
134
+ assigns = payload.get("assignments", {})
135
+ if isinstance(assigns, dict):
136
+ for key in [k for k in assigns if _is_smoke_profile(k)]:
137
+ del assigns[key]
138
+ else:
139
+ routes = payload.get("routes", [])
140
+ if isinstance(routes, list):
141
+ payload["routes"] = [
142
+ r for r in routes if not _is_smoke_profile(str(r.get("profile", "")))
143
+ ]
144
+ path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
145
+
146
+ profiles = agents_root / "profiles"
147
+ if profiles.is_dir():
148
+ for entry in profiles.iterdir():
149
+ if entry.is_dir() and _is_smoke_profile(entry.name):
150
+ shutil.rmtree(entry, ignore_errors=True)
151
+
152
+ bin_dir = agents_root / ".local" / "bin"
153
+ if bin_dir.is_dir():
154
+ for entry in bin_dir.iterdir():
155
+ if _is_smoke_profile(entry.name):
156
+ entry.unlink(missing_ok=True)
157
+
158
+
159
+ def _prune_lane_registry(data_dir: Path) -> int:
160
+ path = data_dir / "lane-registry.json"
161
+ if not path.is_file():
162
+ return 0
163
+ try:
164
+ data = json.loads(path.read_text(encoding="utf-8"))
165
+ except (OSError, json.JSONDecodeError):
166
+ return 0
167
+ lanes = data.get("lanes")
168
+ if not isinstance(lanes, dict):
169
+ return 0
170
+ removed = 0
171
+ for key in [k for k in lanes if _is_smoke_profile(k)]:
172
+ del lanes[key]
173
+ removed += 1
174
+ if removed:
175
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
176
+ return removed
177
+
178
+
179
+ def main() -> None:
180
+ ap = argparse.ArgumentParser()
181
+ ap.add_argument("--base", default="http://127.0.0.1:18644")
182
+ ap.add_argument("--data-dir", required=True, help="workframe-api/data under your install")
183
+ ap.add_argument("--dry-run", action="store_true")
184
+ args = ap.parse_args()
185
+
186
+ data_dir = Path(args.data_dir)
187
+ db_path = data_dir / "workframe.db"
188
+ conn = sqlite3.connect(db_path, timeout=30)
189
+ room_ids = _smoke_room_ids(conn)
190
+ agent_slugs = _smoke_agent_slugs(conn)
191
+ invite_ids = _smoke_invite_ids(conn)
192
+ print(f"found {len(room_ids)} smoke rooms, {len(agent_slugs)} smoke agents, {len(invite_ids)} test invites")
193
+
194
+ if args.dry_run:
195
+ print("dry-run — no changes")
196
+ conn.close()
197
+ return
198
+
199
+ user_id, email = _pick_user(data_dir)
200
+ cookie, session_id = _mint_session_cookie(data_dir, user_id)
201
+ print(f"session as {email} ({user_id[:8]}…)")
202
+
203
+ def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict[str, Any]]:
204
+ return _req(args.base, method, path, body=body, cookie=cookie, session_id=session_id, timeout=180)
205
+
206
+ api_room_ok = 0
207
+ for rid in room_ids:
208
+ code, payload = api("DELETE", f"/api/rooms/{rid}")
209
+ if code == 200:
210
+ api_room_ok += 1
211
+ else:
212
+ print(f"[warn] api delete room {rid[:8]}… — {payload.get('error')}")
213
+
214
+ if api_room_ok < len(room_ids):
215
+ n = _soft_delete_rooms_db(conn)
216
+ print(f"db soft-deleted {n} smoke rooms (api ok={api_room_ok})")
217
+
218
+ for slug in agent_slugs:
219
+ code, payload = api("POST", "/api/hermes/profiles/delete", body={"profile": slug})
220
+ if code != 200 or not payload.get("ok"):
221
+ print(f"[warn] api delete profile {slug} — {payload.get('error') or payload}")
222
+
223
+ n_agents = _soft_delete_agents_db(conn, agent_slugs)
224
+ print(f"soft-deleted {n_agents} agent_profiles rows")
225
+
226
+ n_inv = _revoke_invites_db(conn, invite_ids)
227
+ print(f"revoked {n_inv} test invites")
228
+
229
+ agents_root = _agents_root(data_dir)
230
+ if agents_root:
231
+ _cleanup_hermes_fs(agents_root)
232
+ print(f"cleaned Hermes data under {agents_root}")
233
+ else:
234
+ print("[warn] Agents mount not found — skipped Hermes filesystem cleanup")
235
+
236
+ pruned = _prune_lane_registry(data_dir)
237
+ if pruned:
238
+ print(f"pruned {pruned} lane-registry entries")
239
+
240
+ remaining_rooms = len(_smoke_room_ids(conn))
241
+ remaining_agents = len(_smoke_agent_slugs(conn))
242
+ remaining_invites = len(_smoke_invite_ids(conn))
243
+ conn.close()
244
+ print(
245
+ f"done — remaining smoke rooms={remaining_rooms}, agents={remaining_agents}, invites={remaining_invites}"
246
+ )
247
+ if remaining_rooms or remaining_agents:
248
+ raise SystemExit(1)
249
+
250
+
251
+ if __name__ == "__main__":
252
+ main()
@@ -0,0 +1,162 @@
1
+ """Provider-neutral credential broker — shared lease validation for internal proxies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from typing import Any, Callable
8
+
9
+ import broker_audit
10
+ import internal_proxy_auth
11
+ import turn_credentials
12
+
13
+ LEASE_PREFIX = turn_credentials.LEASE_PREFIX
14
+
15
+
16
+ def extract_bearer(headers: dict[str, str]) -> str:
17
+ auth = str(headers.get("Authorization") or headers.get("authorization") or "").strip()
18
+ if auth.lower().startswith("bearer "):
19
+ return auth[7:].strip()
20
+ api_key = str(headers.get("X-Api-Key") or headers.get("x-api-key") or "").strip()
21
+ if api_key:
22
+ return api_key
23
+ return ""
24
+
25
+
26
+ def extract_profile_slug(headers: dict[str, str]) -> str:
27
+ return str(
28
+ headers.get(internal_proxy_auth.PROFILE_HEADER)
29
+ or headers.get("x-workframe-profile")
30
+ or ""
31
+ ).strip()
32
+
33
+
34
+ def validate_lease_profile(
35
+ lease: dict[str, Any],
36
+ headers: dict[str, str],
37
+ ) -> tuple[bool, str, int]:
38
+ """Bind bearer lease to calling Hermes profile (0022 N2 / 0023 C1)."""
39
+ want = str(lease.get("profile_slug") or "").strip()
40
+ if not want:
41
+ return True, "", 0
42
+ got = extract_profile_slug(headers)
43
+ if not got:
44
+ return False, "profile header required", 403
45
+ if got != want:
46
+ return False, "profile mismatch", 403
47
+ return True, "", 0
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class BrokerLeaseAuth:
52
+ ok: bool
53
+ lease: dict[str, Any] | None = None
54
+ secret: str = ""
55
+ env_var: str = ""
56
+ status: int = 200
57
+ error: str = ""
58
+ deny_reason: str = ""
59
+
60
+
61
+ def authorize_broker_lease(
62
+ provider: str,
63
+ headers: dict[str, str],
64
+ *,
65
+ resolve_secret: Callable[[str, str, str, str], tuple[str, str]],
66
+ broker_kind: str = "",
67
+ upstream_host: str = "",
68
+ ) -> BrokerLeaseAuth:
69
+ """Validate lease token, provider/profile binding, and vault secret for a broker hop."""
70
+ provider = str(provider or "").strip().lower()
71
+ host = str(upstream_host or "").strip().lower()
72
+ kind = str(broker_kind or "").strip().lower()
73
+
74
+ def _audit(auth: BrokerLeaseAuth, status: int) -> BrokerLeaseAuth:
75
+ if kind:
76
+ broker_audit.record_broker_event(
77
+ broker_kind=kind,
78
+ provider=provider,
79
+ upstream_host=host,
80
+ status=status,
81
+ deny_reason=auth.deny_reason,
82
+ lease=auth.lease,
83
+ )
84
+ return auth
85
+
86
+ token = extract_bearer(headers)
87
+ deny_reason, lease = turn_credentials.inspect_lease(token)
88
+ if deny_reason:
89
+ return _audit(
90
+ BrokerLeaseAuth(
91
+ ok=False,
92
+ status=401,
93
+ error="invalid lease",
94
+ deny_reason=deny_reason,
95
+ lease=lease,
96
+ ),
97
+ 401,
98
+ )
99
+ if not lease:
100
+ return _audit(
101
+ BrokerLeaseAuth(
102
+ ok=False,
103
+ status=401,
104
+ error="invalid lease",
105
+ deny_reason="invalid_lease",
106
+ ),
107
+ 401,
108
+ )
109
+ if str(lease.get("provider") or "").lower() != provider:
110
+ return _audit(
111
+ BrokerLeaseAuth(
112
+ ok=False,
113
+ status=403,
114
+ error="provider mismatch",
115
+ deny_reason="provider_mismatch",
116
+ lease=lease,
117
+ ),
118
+ 403,
119
+ )
120
+
121
+ ok_profile, profile_err, profile_status = validate_lease_profile(lease, headers)
122
+ if not ok_profile:
123
+ reason = "profile_header_required" if profile_status == 403 and "required" in profile_err else "profile_mismatch"
124
+ return _audit(
125
+ BrokerLeaseAuth(
126
+ ok=False,
127
+ status=profile_status,
128
+ error=profile_err,
129
+ deny_reason=reason,
130
+ lease=lease,
131
+ ),
132
+ profile_status,
133
+ )
134
+
135
+ env_var, secret = turn_credentials.resolve_lease_secret(lease, resolve_secret)
136
+ if not secret:
137
+ return _audit(
138
+ BrokerLeaseAuth(
139
+ ok=False,
140
+ status=402,
141
+ error="no credential",
142
+ deny_reason="no_credential",
143
+ lease=lease,
144
+ ),
145
+ 402,
146
+ )
147
+ return _audit(BrokerLeaseAuth(ok=True, lease=lease, secret=secret, env_var=env_var), 200)
148
+
149
+
150
+ def broker_error_body(auth: BrokerLeaseAuth) -> bytes:
151
+ return json.dumps({"error": auth.error}).encode()
152
+
153
+
154
+ if __name__ == "__main__":
155
+ assert extract_bearer({"Authorization": "Bearer wf_rt_abc"}) == "wf_rt_abc"
156
+ assert extract_bearer({"X-Api-Key": "wf_rt_xyz"}) == "wf_rt_xyz"
157
+ lease = {"profile_slug": "u-a-dev", "provider": "openrouter"}
158
+ ok, err, code = validate_lease_profile(lease, {internal_proxy_auth.PROFILE_HEADER: "u-a-dev"})
159
+ assert ok and not err and code == 0
160
+ ok, err, code = validate_lease_profile(lease, {})
161
+ assert not ok and code == 403
162
+ print("credential_broker ok")
@@ -0,0 +1,202 @@
1
+ """WF-032 extract: credential binding resolution and secret materialization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sqlite3
7
+ import time
8
+ from typing import Any
9
+
10
+ import credential_vault
11
+ import provider_catalog
12
+
13
+
14
+ def _srv():
15
+ import server as srv
16
+
17
+ return srv
18
+
19
+
20
+ def _default_credential_env_var(provider: str, credential_type: str) -> str:
21
+ prefix = re.sub(r"[^A-Za-z0-9]+", "_", str(provider or "WORKFRAME").strip()).upper() or "WORKFRAME"
22
+ suffix = {
23
+ "bot_token": "BOT_TOKEN",
24
+ "oauth": "OAUTH_TOKEN",
25
+ }.get(str(credential_type or "api_key"), "API_KEY")
26
+ return f"{prefix}_{suffix}"
27
+ def _provider_env_var(provider: str) -> str:
28
+ spec = provider_catalog.catalog_provider(provider)
29
+ if spec and str(spec.get("env_var") or "").strip():
30
+ return str(spec["env_var"]).strip()
31
+ return _default_credential_env_var(provider, "api_key")
32
+
33
+
34
+ def _credential_secret(resolved: dict[str, Any], user_id: str = "") -> str:
35
+ ref = str(resolved.get("credential_ref") or "")
36
+ binding_id = credential_vault.parse_vault_ref(ref)
37
+ if binding_id:
38
+ return credential_vault.read_secret(binding_id)
39
+ env_var = str(resolved.get("env_var") or "")
40
+ if not env_var and ref.startswith("env:"):
41
+ env_var = ref[4:]
42
+ if not env_var:
43
+ return ""
44
+ scope = str(resolved.get("scope") or "")
45
+ secret = ""
46
+ if scope == "user":
47
+ user = str(user_id or resolved.get("user_id") or "").strip()
48
+ if user:
49
+ secret = _srv()._read_env_map(_srv()._user_hermes_env_path(user)).get(env_var, "")
50
+ if secret:
51
+ bid = str(resolved.get("credential_binding_id") or resolved.get("credential_id") or "").strip()
52
+ if bid:
53
+ credential_vault.store_secret(
54
+ bid,
55
+ secret,
56
+ env_var=env_var,
57
+ provider=str(resolved.get("provider") or ""),
58
+ scope="user",
59
+ user_id=user,
60
+ )
61
+ _srv()._remove_env_secret(_srv()._user_hermes_env_path(user), env_var)
62
+ elif scope == "workspace":
63
+ secret = _srv()._stack_profile_env().get(env_var, "")
64
+ elif scope == "stack":
65
+ secret = _srv()._stack_profile_env().get(env_var, "")
66
+ return secret
67
+
68
+
69
+ def _resolve_secret_for_lease(
70
+ payer_user_id: str,
71
+ workspace_id: str,
72
+ provider: str,
73
+ binding_id: str,
74
+ ) -> tuple[str, str]:
75
+ provider = str(provider or "openrouter").strip().lower()
76
+ binding_id = str(binding_id or "").strip()
77
+ if binding_id:
78
+ secret = credential_vault.read_secret(binding_id)
79
+ if secret:
80
+ return _provider_env_var(provider), secret
81
+ resolved = _resolve_credential(payer_user_id, workspace_id, provider, user_only=True)
82
+ if not resolved:
83
+ resolved = _resolve_credential(payer_user_id, workspace_id, provider, user_only=False)
84
+ if not resolved:
85
+ return "", ""
86
+ env_var = str(resolved.get("env_var") or "") or _provider_env_var(provider)
87
+ return env_var, _credential_secret(resolved, payer_user_id)
88
+ def _credential_binding_payload(row: sqlite3.Row, scope: str) -> dict[str, Any]:
89
+ """Return the safe metadata payload for a resolved credential binding."""
90
+ return {
91
+ "credential_binding_id": row["id"],
92
+ "credential_id": row["id"],
93
+ "credential_ref": row["credential_ref"],
94
+ "scope": scope,
95
+ "provider": row["provider"],
96
+ "credential_type": row["credential_type"],
97
+ "label": row["label"] or "",
98
+ "user_id": row["user_id"],
99
+ "workspace_id": row["workspace_id"],
100
+ "agent_profile_id": row["agent_profile_id"],
101
+ "created_by": row["created_by"],
102
+ "created_at": row["created_at"],
103
+ "updated_at": row["updated_at"],
104
+ "expires_at": row["expires_at"],
105
+ }
106
+
107
+
108
+ def _resolve_credential(
109
+ user_id: str,
110
+ workspace_id: str,
111
+ provider: str,
112
+ *,
113
+ user_only: bool = False,
114
+ ) -> dict[str, Any] | None:
115
+ """Resolve which credential binding to use for an agent run.
116
+
117
+ Resolution order:
118
+ 1. User-owned credential for this user + provider (if active and not expired)
119
+ 2. Workspace credential for this workspace + provider (if active and not expired)
120
+ — skipped when user_only=True (dev/github/vercel tenancy)
121
+ 3. None (caller uses profile config fallback)
122
+ """
123
+ user = str(user_id or "").strip()
124
+ workspace = str(workspace_id or "").strip()
125
+ provider_name = str(provider or "").strip().lower()
126
+ if not provider_name or (not user and not workspace):
127
+ return None
128
+
129
+ now_iso = _srv()._utc_now()
130
+ now_ts = str(int(time.time()))
131
+ conn = None
132
+ try:
133
+ conn = _srv()._workframe_db()
134
+ conn.row_factory = sqlite3.Row
135
+ if user:
136
+ row = conn.execute(
137
+ """
138
+ SELECT id, workspace_id, user_id, agent_profile_id, provider,
139
+ credential_type, credential_ref, label, is_active,
140
+ expires_at, created_by, created_at, updated_at, deleted_at
141
+ FROM credential_bindings
142
+ WHERE user_id = ?
143
+ AND LOWER(provider) = ?
144
+ AND is_active = 1
145
+ AND deleted_at IS NULL
146
+ AND (expires_at IS NULL OR expires_at > ? OR CAST(expires_at AS INTEGER) > ?)
147
+ ORDER BY updated_at DESC, created_at DESC, id DESC
148
+ LIMIT 1
149
+ """,
150
+ (user, provider_name, now_iso, now_ts),
151
+ ).fetchone()
152
+ if row:
153
+ return _credential_binding_payload(row, "user")
154
+
155
+ if workspace and not user_only:
156
+ row = conn.execute(
157
+ """
158
+ SELECT id, workspace_id, user_id, agent_profile_id, provider,
159
+ credential_type, credential_ref, label, is_active,
160
+ expires_at, created_by, created_at, updated_at, deleted_at
161
+ FROM credential_bindings
162
+ WHERE workspace_id = ?
163
+ AND LOWER(provider) = ?
164
+ AND is_active = 1
165
+ AND deleted_at IS NULL
166
+ AND (expires_at IS NULL OR expires_at > ? OR CAST(expires_at AS INTEGER) > ?)
167
+ ORDER BY updated_at DESC, created_at DESC, id DESC
168
+ LIMIT 1
169
+ """,
170
+ (workspace, provider_name, now_iso, now_ts),
171
+ ).fetchone()
172
+ if row:
173
+ return _credential_binding_payload(row, "workspace")
174
+ except sqlite3.Error:
175
+ pass
176
+ finally:
177
+ if conn is not None:
178
+ conn.close()
179
+
180
+ if user:
181
+ spec = provider_catalog.catalog_provider(provider_name)
182
+ if spec and str(spec.get("category") or "") == "llm":
183
+ env_var = str(spec.get("env_var") or "").strip()
184
+ if env_var and _srv()._read_env_map(_srv()._user_hermes_env_path(user)).get(env_var):
185
+ return {
186
+ "credential_binding_id": None,
187
+ "credential_id": None,
188
+ "credential_ref": f"env:{env_var}",
189
+ "scope": "user",
190
+ "provider": provider_name,
191
+ "credential_type": str(spec.get("connect_mode") or "api_key"),
192
+ "label": env_var,
193
+ "env_var": env_var,
194
+ "user_id": user,
195
+ "workspace_id": None,
196
+ "agent_profile_id": None,
197
+ "created_by": user,
198
+ "created_at": None,
199
+ "updated_at": None,
200
+ "expires_at": None,
201
+ }
202
+ return None