create-workframe 0.1.18 → 0.1.21

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 (136) hide show
  1. package/README.md +1 -1
  2. package/bin/create-workframe.js +73 -9
  3. package/package.json +1 -1
  4. package/scripts/bundle-workframe-ui.mjs +11 -1
  5. package/workframe-api/action_proxy.py +8 -0
  6. package/workframe-api/chat_sessions.py +37 -6
  7. package/workframe-api/chat_stream.py +16 -0
  8. package/workframe-api/db_schema.py +29 -0
  9. package/workframe-api/handler_modules/handler_admin.py +32 -0
  10. package/workframe-api/handler_modules/handler_auth.py +6 -4
  11. package/workframe-api/handler_modules/handler_chat.py +17 -26
  12. package/workframe-api/handler_modules/handler_provider.py +13 -14
  13. package/workframe-api/handler_modules/handler_workspace.py +60 -2
  14. package/workframe-api/hermes_profiles.py +31 -9
  15. package/workframe-api/llm_proxy.py +18 -0
  16. package/workframe-api/mention_invoke.py +29 -24
  17. package/workframe-api/message_reactions.py +139 -0
  18. package/workframe-api/model_surface.py +329 -113
  19. package/workframe-api/package.json +4 -4
  20. package/workframe-api/profile_gateway.py +14 -6
  21. package/workframe-api/provider_bindings.py +118 -17
  22. package/workframe-api/provider_bootstrap.py +74 -38
  23. package/workframe-api/provider_model_catalog.py +261 -0
  24. package/workframe-api/rooms.py +4 -3
  25. package/workframe-api/route_registry.py +4 -0
  26. package/workframe-api/runtime_cohort.py +118 -23
  27. package/workframe-api/scripts/run-python.mjs +28 -0
  28. package/workframe-api/server.py +44 -12
  29. package/workframe-api/test_agent_identity_authority.py +96 -0
  30. package/workframe-api/test_chat_attribution_history.py +59 -0
  31. package/workframe-api/test_enrich_room_messages.py +43 -2
  32. package/workframe-api/test_llm_proxy_encoding.py +33 -0
  33. package/workframe-api/test_message_reactions.py +151 -0
  34. package/workframe-api/test_model_routing_reconcile.py +122 -0
  35. package/workframe-api/test_model_surface_consistency.py +45 -5
  36. package/workframe-api/test_oauth_llm.py +1 -1
  37. package/workframe-api/test_profile_gateway_health.py +37 -0
  38. package/workframe-api/test_provider_bindings.py +115 -1
  39. package/workframe-api/test_provider_model_catalog.py +27 -0
  40. package/workframe-api/test_provider_model_surface.py +2 -2
  41. package/workframe-api/test_runtime_lease_lifecycle.py +93 -0
  42. package/workframe-api/test_server_reexports.py +2 -0
  43. package/workframe-api/test_user_llm_prefs.py +27 -0
  44. package/workframe-api/test_user_theme_prefs.py +65 -0
  45. package/workframe-api/test_workspace_file_actions.py +119 -0
  46. package/workframe-api/turn_credentials.py +41 -0
  47. package/workframe-api/turn_overlay.py +95 -34
  48. package/workframe-api/user_prefs.py +22 -1
  49. package/workframe-api/workspace_bootstrap.py +1 -0
  50. package/workframe-api/workspace_files.py +124 -0
  51. package/workframe-api/zk_auth.py +7 -1
  52. package/workframe-ui/public/assets/{arc-CSddOtMa.js → arc-D_gXvZHy.js} +1 -1
  53. package/workframe-ui/public/assets/architecture-7EHR7CIX-B8ZI_Iqb.js +1 -0
  54. package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-BSiUei3D.js → architectureDiagram-3BPJPVTR-2F65XJkk.js} +1 -1
  55. package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-BhKTULPo.js → blockDiagram-GPEHLZMM-BoURuzr5.js} +1 -1
  56. package/workframe-ui/public/assets/{brave-B885JYnH.svg → brave-BoYMbZi1.svg} +0 -1
  57. package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-Bv_xZi-a.js → c4Diagram-AAUBKEIU-BBtveYSm.js} +1 -1
  58. package/workframe-ui/public/assets/channel-Ca1FvZ7w.js +1 -0
  59. package/workframe-ui/public/assets/{chunk-2J33WTMH-Bh0WiqFE.js → chunk-2J33WTMH-BCuhFg2u.js} +1 -1
  60. package/workframe-ui/public/assets/{chunk-3OPIFGDE-qN09pRuK.js → chunk-3OPIFGDE-gM6Q-rjJ.js} +1 -1
  61. package/workframe-ui/public/assets/{chunk-4BX2VUAB-Dz6FVuMa.js → chunk-4BX2VUAB-D11CJMfF.js} +1 -1
  62. package/workframe-ui/public/assets/{chunk-55IACEB6-DWtPHVfR.js → chunk-55IACEB6-tOvPBzSf.js} +1 -1
  63. package/workframe-ui/public/assets/{chunk-5ZQYHXKU-CyOUeDAo.js → chunk-5ZQYHXKU-Bb1uF7QP.js} +1 -1
  64. package/workframe-ui/public/assets/{chunk-727SXJPM-BrICvWwx.js → chunk-727SXJPM-BvzzLkGB.js} +1 -1
  65. package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CQu2SmLM.js → chunk-AQP2D5EJ-tr5A2PUD.js} +1 -1
  66. package/workframe-ui/public/assets/{chunk-BSJP7CBP-uJbYXNNG.js → chunk-BSJP7CBP-BSFEsidk.js} +1 -1
  67. package/workframe-ui/public/assets/{chunk-CSCIHK7Q-CCS_FtVZ.js → chunk-CSCIHK7Q-Bgf-IHHq.js} +1 -1
  68. package/workframe-ui/public/assets/{chunk-FMBD7UC4-CoLUtPiK.js → chunk-FMBD7UC4-rD27Gf3U.js} +1 -1
  69. package/workframe-ui/public/assets/{chunk-KSCS5N6A-DLW4gvEW.js → chunk-KSCS5N6A-C1MobXen.js} +1 -1
  70. package/workframe-ui/public/assets/{chunk-L5ZTLDWV-CMt0LJAd.js → chunk-L5ZTLDWV-CD2wS3bn.js} +1 -1
  71. package/workframe-ui/public/assets/{chunk-LZXEDZCA-Bn-maIlt.js → chunk-LZXEDZCA-DQxuSIdE.js} +2 -2
  72. package/workframe-ui/public/assets/{chunk-ND2GUHAM-CG7yoApR.js → chunk-ND2GUHAM-DHlBi932.js} +1 -1
  73. package/workframe-ui/public/assets/{chunk-NZK2D7GU-B4dIvbii.js → chunk-NZK2D7GU-MB8bO5o8.js} +1 -1
  74. package/workframe-ui/public/assets/{chunk-O5CBEL6O-B5spDyXy.js → chunk-O5CBEL6O-BmPwoKAr.js} +1 -1
  75. package/workframe-ui/public/assets/chunk-QZHKN3VN-BsuC0swk.js +1 -0
  76. package/workframe-ui/public/assets/chunk-WU5MYG2G-CyfRhD17.js +1 -0
  77. package/workframe-ui/public/assets/{chunk-XPW4576I-D5c0TMX6.js → chunk-XPW4576I-BUCJoags.js} +1 -1
  78. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-DZ_KubnM.js +1 -0
  79. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-DZ_KubnM.js +1 -0
  80. package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-DFRM3DZv.js → cose-bilkent-S5V4N54A-w9hpFlQU.js} +1 -1
  81. package/workframe-ui/public/assets/{dagre-BM42HDAG-CnUdKeJa.js → dagre-BM42HDAG-DEnBMVrR.js} +1 -1
  82. package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ds3-e-hW.js → diagram-2AECGRRQ-CMoYqkVC.js} +1 -1
  83. package/workframe-ui/public/assets/{diagram-5GNKFQAL-B41zx8FF.js → diagram-5GNKFQAL-rS0QQe2R.js} +1 -1
  84. package/workframe-ui/public/assets/{diagram-KO2AKTUF-D83tI9I8.js → diagram-KO2AKTUF-Do63Vbzi.js} +1 -1
  85. package/workframe-ui/public/assets/{diagram-LMA3HP47-h_EuFyLI.js → diagram-LMA3HP47-Bq3e2FDx.js} +1 -1
  86. package/workframe-ui/public/assets/{diagram-OG6HWLK6-CpU-d3RZ.js → diagram-OG6HWLK6-DTESfGaG.js} +1 -1
  87. package/workframe-ui/public/assets/{dist-_igeSJ9r.js → dist-BiIfSARy.js} +1 -1
  88. package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-jLFoncRU.js → erDiagram-TEJ5UH35-C7RA0P76.js} +1 -1
  89. package/workframe-ui/public/assets/eventmodeling-FCH6USID-DChy3wGd.js +1 -0
  90. package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-C9IaNgCy.js → flowDiagram-I6XJVG4X-e47AwJAm.js} +1 -1
  91. package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-A9FhfFc5.js → ganttDiagram-6RSMTGT7-BVytj000.js} +1 -1
  92. package/workframe-ui/public/assets/{gemini-Cq-0-rmr.svg → gemini-DqnZlvMo.svg} +0 -1
  93. package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BqK_IjZb.js → gitGraph-WXDBUCRP-QdT2gCGh.js} +1 -1
  94. package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CDVqxhyW.js → gitGraphDiagram-PVQCEYII-COEavQVV.js} +1 -1
  95. package/workframe-ui/public/assets/index-03WsLxfn.js +133 -0
  96. package/workframe-ui/public/assets/index-BIKn2D6J.css +1 -0
  97. package/workframe-ui/public/assets/{info-J43DQDTF-C2EKRglL.js → info-J43DQDTF-CEyVZ-9w.js} +1 -1
  98. package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-azQtFzpU.js → infoDiagram-5YYISTIA-CV3ftI3x.js} +1 -1
  99. package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-BuDOLv1t.js → ishikawaDiagram-YF4QCWOH-M8CoaInm.js} +1 -1
  100. package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-ypTHFoT0.js → journeyDiagram-JHISSGLW-BtoaC0oj.js} +1 -1
  101. package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-Cgr78J_x.js → kanban-definition-UN3LZRKU-Dke-ozKB.js} +1 -1
  102. package/workframe-ui/public/assets/{line-CcwgaMdv.js → line-CDg_Ms_2.js} +1 -1
  103. package/workframe-ui/public/assets/{linear-CswIlWaW.js → linear-D3OWfPXS.js} +1 -1
  104. package/workframe-ui/public/assets/{mermaid-parser.core-CQYhP37X.js → mermaid-parser.core-CGDFs4AZ.js} +2 -2
  105. package/workframe-ui/public/assets/{mermaid.core-Cz1XNBaK.js → mermaid.core-C9-q_XHF.js} +3 -3
  106. package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-BJLu0829.js → mindmap-definition-RKZ34NQL-BvEiBAWz.js} +1 -1
  107. package/workframe-ui/public/assets/{packet-YPE3B663-AB1qh96V.js → packet-YPE3B663-FF5nKMbE.js} +1 -1
  108. package/workframe-ui/public/assets/{pie-LRSECV5Y-CokIXLdT.js → pie-LRSECV5Y-CZ9ijSae.js} +1 -1
  109. package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-DyrwQn5F.js → pieDiagram-4H26LBE5-kD9o509O.js} +1 -1
  110. package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-C_Fdg3F6.js → quadrantDiagram-W4KKPZXB-d_eA7_-T.js} +1 -1
  111. package/workframe-ui/public/assets/{radar-GUYGQ44K-C21Xfrrg.js → radar-GUYGQ44K-ViyzC1rg.js} +1 -1
  112. package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-ChhaWgaF.js → requirementDiagram-4Y6WPE33-Sq3D_b4Z.js} +1 -1
  113. package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-C5T4sDTt.js → sankeyDiagram-5OEKKPKP-ZZnl3ZwZ.js} +1 -1
  114. package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DRks8i3A.js → sequenceDiagram-3UESZ5HK-Yaz2MgMq.js} +1 -1
  115. package/workframe-ui/public/assets/{src-BQu5waHd.js → src-DBh8rT2Z.js} +1 -1
  116. package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BI-AKYta.js → stateDiagram-AJRCARHV-DVwi_P3W.js} +1 -1
  117. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DRSxEx1_.js +1 -0
  118. package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DnAIWzEc.js → timeline-definition-PNZ67QCA-C88RoMRK.js} +1 -1
  119. package/workframe-ui/public/assets/{treeView-BLDUP644-BEF_sjx7.js → treeView-BLDUP644-2xabSpow.js} +1 -1
  120. package/workframe-ui/public/assets/{treemap-LRROVOQU-BcbL7g5c.js → treemap-LRROVOQU-BA_oRW4e.js} +1 -1
  121. package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-DAoIbCkz.js → vennDiagram-CIIHVFJN-DvyVA588.js} +1 -1
  122. package/workframe-ui/public/assets/{wardley-L42UT6IY-3SOmFBFw.js → wardley-L42UT6IY-BR0suGqb.js} +1 -1
  123. package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DlQV_-LH.js → wardleyDiagram-YWT4CUSO-CXMvBEO9.js} +1 -1
  124. package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-CVoTTRHF.js → xychartDiagram-2RQKCTM6-wM7UrTHx.js} +1 -1
  125. package/workframe-ui/public/index.html +23 -12
  126. package/workframe-ui/public/workframe-build.json +4 -3
  127. package/workframe-ui/public/assets/architecture-7EHR7CIX-CDDNM1ni.js +0 -1
  128. package/workframe-ui/public/assets/channel-R52LrLO3.js +0 -1
  129. package/workframe-ui/public/assets/chunk-QZHKN3VN-C_9MbT10.js +0 -1
  130. package/workframe-ui/public/assets/chunk-WU5MYG2G-VJadzJmf.js +0 -1
  131. package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CpBjaI3g.js +0 -1
  132. package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CpBjaI3g.js +0 -1
  133. package/workframe-ui/public/assets/eventmodeling-FCH6USID-kzOxPvtj.js +0 -1
  134. package/workframe-ui/public/assets/index-Bco9IDMA.css +0 -1
  135. package/workframe-ui/public/assets/index-Do34PZS2.js +0 -132
  136. package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-vJyhFZbx.js +0 -1
@@ -3,13 +3,40 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import sys
6
+ import time
6
7
  from pathlib import Path
8
+ from types import SimpleNamespace
7
9
 
8
10
  sys.path.insert(0, str(Path(__file__).resolve().parent))
9
11
 
10
12
  import provider_bindings # noqa: E402
11
13
 
12
14
 
15
+ class _ProviderServerStub:
16
+ PROVIDER_CONNECT_CATALOG = (
17
+ {
18
+ "id": "openrouter",
19
+ "label": "OpenRouter",
20
+ "category": "llm",
21
+ "connect_mode": "api_key",
22
+ "env_var": "OPENROUTER_API_KEY",
23
+ "description": "router",
24
+ },
25
+ )
26
+
27
+ def __init__(self, mode: str) -> None:
28
+ self.mode = mode
29
+
30
+ def _workspace_credential_mode(self, _conn, _workspace_id: str) -> str:
31
+ return self.mode
32
+
33
+ def _credential_secret(self, binding: dict, _user_id: str) -> str:
34
+ return "secret" if binding else ""
35
+
36
+ def _user_hermes_home(self, _user_id: str) -> Path:
37
+ return Path("/profiles/user")
38
+
39
+
13
40
  def test_hermes_oauth_auth_keys() -> None:
14
41
  keys = provider_bindings._hermes_oauth_auth_keys("openai-codex")
15
42
  assert "openai-codex" in keys
@@ -50,7 +77,93 @@ def test_device_oauth_error_from_log() -> None:
50
77
  "Wait a minute and run the login again."
51
78
  )
52
79
  msg = provider_bindings._device_oauth_error_from_log(text)
53
- assert msg and "429" in msg
80
+ assert msg == "OpenAI is rate-limiting Codex login requests (HTTP 429). Wait a minute and try again."
81
+
82
+
83
+ def test_reusable_device_oauth_session() -> None:
84
+ provider_bindings._oauth_device_sessions.clear()
85
+ provider_bindings._oauth_device_sessions.update(
86
+ {
87
+ "old-error": {
88
+ "user_id": "user-1",
89
+ "provider_id": "openai-codex",
90
+ "status": "error",
91
+ "started_at": time.time(),
92
+ },
93
+ "live": {
94
+ "user_id": "user-1",
95
+ "provider_id": "openai-codex",
96
+ "status": "pending",
97
+ "started_at": time.time(),
98
+ },
99
+ }
100
+ )
101
+ reusable = provider_bindings._reusable_device_oauth_session("user-1", "openai-codex")
102
+ assert reusable and reusable[0] == "live"
103
+ assert provider_bindings._reusable_device_oauth_session("other", "openai-codex") is None
104
+ provider_bindings._oauth_device_sessions.clear()
105
+
106
+
107
+ def test_finalize_device_oauth_invalidates_model_picker_before_bootstrap(monkeypatch) -> None:
108
+ calls: list[tuple[str, str]] = []
109
+ server = SimpleNamespace(
110
+ _invalidate_user_llm_picker_cache=lambda user_id: calls.append(("invalidate", user_id)),
111
+ _bootstrap_model_after_llm_connect=lambda user_id, _workspace_id, provider_id: calls.append(
112
+ ("bootstrap", f"{user_id}:{provider_id}")
113
+ ),
114
+ )
115
+ monkeypatch.setattr(provider_bindings, "_srv", lambda: server)
116
+ monkeypatch.setattr(
117
+ provider_bindings,
118
+ "_sync_user_oauth_provider_to_runtime_profiles",
119
+ lambda user_id, auth_id: calls.append(("sync", f"{user_id}:{auth_id}")),
120
+ )
121
+
122
+ provider_bindings._finalize_hermes_device_oauth(
123
+ "user-1",
124
+ "openai-codex",
125
+ {"hermes_auth_id": "openai-codex"},
126
+ )
127
+
128
+ assert calls == [
129
+ ("sync", "user-1:openai-codex"),
130
+ ("invalidate", "user-1"),
131
+ ("bootstrap", "user-1:openai-codex"),
132
+ ]
133
+
134
+
135
+ def test_workspace_provider_scope_uses_shared_binding(monkeypatch) -> None:
136
+ monkeypatch.setattr(provider_bindings, "_srv", lambda: _ProviderServerStub("workspace"))
137
+ monkeypatch.setattr(provider_bindings, "_user_provider_bindings", lambda _uid: {})
138
+ monkeypatch.setattr(
139
+ provider_bindings,
140
+ "_workspace_provider_bindings",
141
+ lambda _wid: {"openrouter": {"id": "shared-1", "credential_ref": "vault:shared"}},
142
+ )
143
+ monkeypatch.setattr(provider_bindings, "_user_provider_connected", lambda *_args: False)
144
+
145
+ result = provider_bindings.list_user_providers("user-1", "workspace-1", "workspace")
146
+ row = result["providers"][0]
147
+ assert result["credential_scope"] == "workspace"
148
+ assert row["connected"] is True
149
+ assert row["source"] == "workspace"
150
+ assert row["credential_id"] == "shared-1"
151
+
152
+
153
+ def test_byok_effective_scope_does_not_leak_shared_binding(monkeypatch) -> None:
154
+ monkeypatch.setattr(provider_bindings, "_srv", lambda: _ProviderServerStub("byok"))
155
+ monkeypatch.setattr(provider_bindings, "_user_provider_bindings", lambda _uid: {})
156
+ monkeypatch.setattr(
157
+ provider_bindings,
158
+ "_workspace_provider_bindings",
159
+ lambda _wid: {"openrouter": {"id": "shared-1", "credential_ref": "vault:shared"}},
160
+ )
161
+ monkeypatch.setattr(provider_bindings, "_user_provider_connected", lambda *_args: False)
162
+
163
+ row = provider_bindings.list_user_providers("user-1", "workspace-1", "effective")["providers"][0]
164
+ assert row["connected"] is False
165
+ assert row["source"] is None
166
+ assert row["credential_id"] is None
54
167
 
55
168
 
56
169
  if __name__ == "__main__":
@@ -59,4 +172,5 @@ if __name__ == "__main__":
59
172
  test_merge_oauth_auth_into_profile()
60
173
  test_parse_device_oauth_log()
61
174
  test_device_oauth_error_from_log()
175
+ test_reusable_device_oauth_session()
62
176
  print("test_provider_bindings: ok")
@@ -0,0 +1,27 @@
1
+ """Pure model catalog parsing/cache helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import provider_model_catalog
6
+
7
+
8
+ def test_oauth_access_token_provider_layout() -> None:
9
+ auth = {"providers": {"openai-codex": {"tokens": {"access_token": "live-token"}}}}
10
+ assert provider_model_catalog.oauth_access_token(auth, "openai-codex") == "live-token"
11
+
12
+
13
+ def test_oauth_access_token_pool_layout() -> None:
14
+ auth = {"credential_pool": {"openai-codex": [{"accessToken": "pool-token"}]}}
15
+ assert provider_model_catalog.oauth_access_token(auth, "openai-codex") == "pool-token"
16
+
17
+
18
+ def test_discover_without_credential_does_not_call_network() -> None:
19
+ rows, status = provider_model_catalog.discover("openrouter", "")
20
+ assert rows == []
21
+ assert status["status"] == "unauthenticated"
22
+
23
+
24
+ def test_unknown_provider_preserves_custom_model_escape_hatch() -> None:
25
+ rows, status = provider_model_catalog.discover("nous", "oauth")
26
+ assert rows == []
27
+ assert status["status"] == "unsupported"
@@ -11,8 +11,8 @@ import server # noqa: E402
11
11
 
12
12
  codex_rows = server._model_catalog_rows_for_provider("codex")
13
13
  codex_models = {row["model"] for row in codex_rows}
14
- assert "gpt-5.4-medium" in codex_models, codex_models
15
14
  assert "gpt-5.4-mini" in codex_models, codex_models
15
+ assert "gpt-5.4-medium" not in codex_models, codex_models
16
16
  assert all("gemini" not in m for m in codex_models)
17
17
 
18
18
  assert server._resolve_billing_provider_for_model("gpt-5.4-mini", {"codex"}) == "codex"
@@ -23,7 +23,7 @@ assert server._resolve_billing_provider_for_model(
23
23
 
24
24
  stack = server._suggestions_for_connected_llm_providers({"codex", "openrouter"})
25
25
  models = {row["model"] for row in stack}
26
- assert "gpt-5.4-medium" in models
26
+ assert "gpt-5.4-mini" in models
27
27
  assert any(m.startswith("google/") for m in models)
28
28
 
29
29
  assert server._billing_provider_id_from_hermes_config("openai-codex") == "codex"
@@ -0,0 +1,93 @@
1
+ """Regression: LLM broker credentials are runtime-scoped, not chat-turn scoped."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import credential_vault
8
+ import turn_credentials
9
+ import turn_overlay
10
+
11
+
12
+ class _ServerStub:
13
+ def __init__(self, root: Path) -> None:
14
+ self.root = root
15
+ self.restarts = 0
16
+
17
+ def resolve_hermes_profile(self, profile: str) -> str:
18
+ return profile
19
+
20
+ def _read_model_from_config(self, _profile: str) -> tuple[str, str]:
21
+ return "custom", "openrouter/auto"
22
+
23
+ def _read_env_map(self, path: Path) -> dict[str, str]:
24
+ values: dict[str, str] = {}
25
+ if path.is_file():
26
+ for line in path.read_text(encoding="utf-8").splitlines():
27
+ key, sep, value = line.partition("=")
28
+ if sep:
29
+ values[key] = value
30
+ return values
31
+
32
+ def _profile_dir(self, _profile: str) -> Path:
33
+ path = self.root / "profile"
34
+ path.mkdir(parents=True, exist_ok=True)
35
+ return path
36
+
37
+ def _read_model_block(self, _profile: str) -> dict[str, str]:
38
+ path = self._profile_dir(_profile) / "model-key"
39
+ return {"api_key": path.read_text(encoding="utf-8") if path.is_file() else ""}
40
+
41
+ def _provider_env_var(self, _provider: str) -> str:
42
+ return "OPENROUTER_API_KEY"
43
+
44
+ def _is_runtime_profile_slug(self, _profile: str) -> bool:
45
+ return True
46
+
47
+ def _oauth_llm_provider_spec(self, _provider: str):
48
+ return None
49
+
50
+ def _upsert_env_secret(self, path: Path, key: str, value: str) -> None:
51
+ path.parent.mkdir(parents=True, exist_ok=True)
52
+ path.write_text(f"{key}={value}\n", encoding="utf-8")
53
+
54
+
55
+ def test_runtime_llm_lease_survives_chat_turn_cleanup(tmp_path, monkeypatch) -> None:
56
+ db_path = tmp_path / "workframe.db"
57
+ monkeypatch.setattr(turn_credentials, "WORKFRAME_DB", db_path)
58
+ turn_credentials._SCHEMA_READY.clear()
59
+ monkeypatch.setattr(credential_vault, "DATA_DIR", tmp_path)
60
+ monkeypatch.setattr(credential_vault, "VAULT_DB", tmp_path / "credential_vault.db")
61
+ credential_vault._SCHEMA_READY.clear()
62
+ stub = _ServerStub(tmp_path)
63
+ monkeypatch.setattr(turn_overlay, "_srv", lambda: stub)
64
+ monkeypatch.setattr(
65
+ turn_overlay,
66
+ "_require_runtime_owner_provider",
67
+ lambda *_args, **_kwargs: {"credential_binding_id": "binding-1"},
68
+ )
69
+ monkeypatch.setattr(turn_overlay, "_ensure_profile_llm_proxy", lambda *_args: None)
70
+
71
+ def _sync(profile: str, token: str, *, wait_healthy: bool = False) -> None:
72
+ (stub._profile_dir(profile) / "model-key").write_text(token, encoding="utf-8")
73
+ if wait_healthy:
74
+ stub.restarts += 1
75
+
76
+ monkeypatch.setattr(turn_overlay, "_sync_profile_model_api_key", _sync)
77
+ monkeypatch.setattr(turn_overlay, "_user_action_env_specs", lambda: [])
78
+
79
+ token_a = turn_overlay._apply_turn_credential_lease(
80
+ "u-user-agent", "user-1", "workspace-1", "openrouter", "turn-a",
81
+ )
82
+ token_b = turn_overlay._apply_turn_credential_lease(
83
+ "u-user-agent", "user-1", "workspace-1", "openrouter", "turn-b",
84
+ )
85
+
86
+ assert token_a == token_b
87
+ assert stub.restarts == 1
88
+ lease = turn_credentials.validate_lease(token_a)
89
+ assert lease and lease["run_id"].startswith("runtime-llm:")
90
+
91
+ turn_overlay._revoke_turn_credential_lease("turn-b", "u-user-agent")
92
+ assert turn_credentials.validate_lease(token_a)
93
+ assert stub.restarts == 1
@@ -32,6 +32,8 @@ _REQUIRED = (
32
32
  "_session_info",
33
33
  "chat_messages",
34
34
  "chat_bootstrap",
35
+ "files_archive",
36
+ "files_delete",
35
37
  "_gateway_container_exec",
36
38
  "_gateway_container_exec_detached",
37
39
  "_docker_exec",
@@ -95,5 +95,32 @@ def test_user_llm_prefs_infers_primary_from_fallback_chain(llm_prefs_db) -> None
95
95
  assert chain[0]["model"] == "google/gemini-2.5-flash"
96
96
 
97
97
 
98
+ def test_linked_account_save_preserves_llm_preferences(
99
+ llm_prefs_db, monkeypatch: pytest.MonkeyPatch,
100
+ ) -> None: # noqa: ARG001
101
+ import user_prefs # noqa: WPS433
102
+
103
+ stored = json.dumps({
104
+ "discord": "old-discord",
105
+ "llm_primary": "google/gemini-2.5-flash",
106
+ "llm_fallback_chain": json.dumps([
107
+ {"provider": "openrouter", "model": "meta-llama/llama-3.3-70b-instruct:free"},
108
+ ]),
109
+ })
110
+ user_id = _insert_user(email="merge@test.local", display_name="Merge", platform_ids=stored)
111
+ monkeypatch.setattr(user_prefs._zk, "update_profile", lambda *_args, **_kwargs: {})
112
+ monkeypatch.setattr(user_prefs._zk, "get_profile", lambda *_args, **_kwargs: {})
113
+ monkeypatch.setattr(user_prefs, "sync_workframe_user_profile", lambda *_args, **_kwargs: None)
114
+ monkeypatch.setattr(server, "_get_user_workspaces", lambda _user: [])
115
+
116
+ user_prefs.apply_me_profile_updates(user_id, {"platform_ids": {"discord": "new-discord"}})
117
+
118
+ user = user_prefs.get_workframe_user(user_id)
119
+ assert user is not None
120
+ assert user["platform_ids"]["discord"] == "new-discord"
121
+ assert user["platform_ids"]["llm_primary"] == "google/gemini-2.5-flash"
122
+ assert "llama-3.3-70b" in user["platform_ids"]["llm_fallback_chain"]
123
+
124
+
98
125
  if __name__ == "__main__":
99
126
  raise SystemExit(pytest.main([__file__, "-q"]))
@@ -0,0 +1,65 @@
1
+ """Personal appearance preference persists in the authenticated profile."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ import sys
7
+ import tempfile
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ API_DIR = Path(__file__).resolve().parent
14
+ if str(API_DIR) not in sys.path:
15
+ sys.path.insert(0, str(API_DIR))
16
+
17
+ import zk_auth # noqa: E402
18
+
19
+
20
+ def test_theme_profile_round_trip_and_legacy_migration(monkeypatch: pytest.MonkeyPatch) -> None:
21
+ with tempfile.TemporaryDirectory(prefix="wf-theme-prefs-") as tmp:
22
+ data_dir = Path(tmp)
23
+ db_path = data_dir / "zk_auth.db"
24
+ data_dir.mkdir(parents=True, exist_ok=True)
25
+
26
+ legacy = sqlite3.connect(db_path)
27
+ legacy.executescript(
28
+ """
29
+ CREATE TABLE users (
30
+ id TEXT PRIMARY KEY,
31
+ status TEXT NOT NULL,
32
+ created_at TEXT NOT NULL,
33
+ updated_at TEXT NOT NULL
34
+ );
35
+ CREATE TABLE profiles (
36
+ user_id TEXT PRIMARY KEY,
37
+ display_name TEXT,
38
+ avatar_url TEXT,
39
+ tagline TEXT,
40
+ bio TEXT,
41
+ created_at TEXT NOT NULL,
42
+ updated_at TEXT NOT NULL
43
+ );
44
+ """
45
+ )
46
+ now = datetime.now(timezone.utc).isoformat()
47
+ legacy.execute("INSERT INTO users VALUES (?, 'active', ?, ?)", ("user-theme", now, now))
48
+ legacy.commit()
49
+ legacy.close()
50
+
51
+ monkeypatch.setenv("WORKFRAME_API_DATA_DIR", str(data_dir))
52
+ profile = zk_auth.update_profile("user-theme", {"theme": "blueprint"})
53
+
54
+ assert profile is not None
55
+ assert profile["theme"] == "blueprint"
56
+ conn = zk_auth._zk_db()
57
+ try:
58
+ columns = {row[1] for row in conn.execute("PRAGMA table_info(profiles)").fetchall()}
59
+ finally:
60
+ conn.close()
61
+ assert "theme" in columns
62
+
63
+
64
+ if __name__ == "__main__":
65
+ raise SystemExit(pytest.main([__file__, "-q"]))
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import sys
5
+ import zipfile
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ API_DIR = Path(__file__).resolve().parent
11
+ if str(API_DIR) not in sys.path:
12
+ sys.path.insert(0, str(API_DIR))
13
+
14
+ import route_registry # noqa: E402
15
+ import server # noqa: E402
16
+ import workspace_files # noqa: E402
17
+
18
+
19
+ def _workspace(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
20
+ root = tmp_path / "workspace"
21
+ root.mkdir()
22
+ monkeypatch.setattr(server, "WORKSPACE", root)
23
+ return root
24
+
25
+
26
+ def test_files_archive_preserves_relative_paths(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
27
+ root = _workspace(monkeypatch, tmp_path)
28
+ (root / "docs").mkdir()
29
+ (root / "docs" / "notes.txt").write_text("hello", encoding="utf-8")
30
+ (root / "image.bin").write_bytes(b"\x00\x01")
31
+
32
+ payload = workspace_files.files_archive(["docs/notes.txt", "image.bin", "docs/notes.txt"])
33
+ with zipfile.ZipFile(io.BytesIO(payload)) as archive:
34
+ assert archive.namelist() == ["docs/notes.txt", "image.bin"]
35
+ assert archive.read("docs/notes.txt") == b"hello"
36
+ assert archive.read("image.bin") == b"\x00\x01"
37
+
38
+
39
+ def test_files_archive_expands_folders_and_deduplicates_descendants(
40
+ monkeypatch: pytest.MonkeyPatch,
41
+ tmp_path: Path,
42
+ ) -> None:
43
+ root = _workspace(monkeypatch, tmp_path)
44
+ (root / "assets" / "nested").mkdir(parents=True)
45
+ (root / "assets" / "one.txt").write_text("one", encoding="utf-8")
46
+ (root / "assets" / "nested" / "two.txt").write_text("two", encoding="utf-8")
47
+ (root / "assets" / "nested" / ".env").write_text("SECRET=value", encoding="utf-8")
48
+
49
+ payload = workspace_files.files_archive(["assets", "assets/nested/two.txt"])
50
+
51
+ with zipfile.ZipFile(io.BytesIO(payload)) as archive:
52
+ assert archive.namelist() == ["assets/nested/two.txt", "assets/one.txt"]
53
+ assert archive.read("assets/nested/two.txt") == b"two"
54
+ assert ".env" not in "\n".join(archive.namelist())
55
+
56
+
57
+ def test_files_archive_folder_respects_resolved_file_limit(
58
+ monkeypatch: pytest.MonkeyPatch,
59
+ tmp_path: Path,
60
+ ) -> None:
61
+ root = _workspace(monkeypatch, tmp_path)
62
+ (root / "assets").mkdir()
63
+ (root / "assets" / "one.txt").write_text("one", encoding="utf-8")
64
+ (root / "assets" / "two.txt").write_text("two", encoding="utf-8")
65
+ monkeypatch.setattr(workspace_files, "MAX_FILE_ACTION_COUNT", 1)
66
+
67
+ with pytest.raises(ValueError, match="select at most 1 files"):
68
+ workspace_files.files_archive(["assets"])
69
+
70
+
71
+ def test_files_archive_rejects_protected_and_oversized_files(
72
+ monkeypatch: pytest.MonkeyPatch,
73
+ tmp_path: Path,
74
+ ) -> None:
75
+ root = _workspace(monkeypatch, tmp_path)
76
+ (root / ".env").write_text("SECRET=value", encoding="utf-8")
77
+ (root / "large.bin").write_bytes(b"12")
78
+
79
+ with pytest.raises(PermissionError, match="protected_env_file"):
80
+ workspace_files.files_archive([".env"])
81
+
82
+ monkeypatch.setattr(workspace_files, "MAX_ARCHIVE_BYTES", 1)
83
+ with pytest.raises(ValueError, match="100 MB download limit"):
84
+ workspace_files.files_archive(["large.bin"])
85
+
86
+
87
+ def test_files_delete_preflights_complete_batch(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
88
+ root = _workspace(monkeypatch, tmp_path)
89
+ keep = root / "keep.txt"
90
+ keep.write_text("keep", encoding="utf-8")
91
+
92
+ with pytest.raises(ValueError, match="file not found"):
93
+ workspace_files.files_delete(["keep.txt", "missing.txt"])
94
+
95
+ assert keep.is_file()
96
+
97
+
98
+ def test_files_delete_removes_selected_files_and_routes_require_session(
99
+ monkeypatch: pytest.MonkeyPatch,
100
+ tmp_path: Path,
101
+ ) -> None:
102
+ root = _workspace(monkeypatch, tmp_path)
103
+ first = root / "first.txt"
104
+ second = root / "nested" / "second.txt"
105
+ second.parent.mkdir()
106
+ first.write_text("first", encoding="utf-8")
107
+ second.write_text("second", encoding="utf-8")
108
+
109
+ result = workspace_files.files_delete(["first.txt", "nested/second.txt"])
110
+
111
+ assert result == {
112
+ "ok": True,
113
+ "deleted": ["first.txt", "nested/second.txt"],
114
+ "count": 2,
115
+ }
116
+ assert not first.exists()
117
+ assert not second.exists()
118
+ assert route_registry.resolve_auth_level("POST", "/api/files/archive") == route_registry.AuthLevel.SESSION
119
+ assert route_registry.resolve_auth_level("POST", "/api/files/delete") == route_registry.AuthLevel.SESSION
@@ -17,6 +17,11 @@ DATA_DIR = Path(os.environ.get("WORKFRAME_API_DATA_DIR", "/app/data"))
17
17
  WORKFRAME_DB = DATA_DIR / "workframe.db"
18
18
  LEASE_PREFIX = "wf_rt_"
19
19
  DEFAULT_TTL = int(os.environ.get("WORKFRAME_TURN_LEASE_TTL", "900"))
20
+ # LLM authorization belongs to a user-specific runtime profile, not to an
21
+ # individual chat turn. Keep that opaque broker credential long-lived and
22
+ # explicitly revocable; rotating it on every message forces Hermes to restart
23
+ # its gateway and can drop the in-flight response.
24
+ RUNTIME_TTL = int(os.environ.get("WORKFRAME_RUNTIME_LEASE_TTL", "2592000"))
20
25
 
21
26
 
22
27
  def _connect() -> sqlite3.Connection:
@@ -220,6 +225,42 @@ def revoke_lease(run_id: str) -> None:
220
225
  conn.close()
221
226
 
222
227
 
228
+ def revoke_matching_leases(
229
+ *,
230
+ payer_user_id: str = "",
231
+ workspace_id: str = "",
232
+ provider: str = "",
233
+ credential_binding_id: str = "",
234
+ ) -> int:
235
+ """Revoke active broker credentials after an ownership/binding change."""
236
+ clauses = ["revoked_at IS NULL"]
237
+ params: list[str] = []
238
+ for column, value in (
239
+ ("payer_user_id", payer_user_id),
240
+ ("workspace_id", workspace_id),
241
+ ("provider", str(provider or "").strip().lower()),
242
+ ("credential_binding_id", credential_binding_id),
243
+ ):
244
+ normalized = str(value or "").strip()
245
+ if normalized:
246
+ clauses.append(f"{column} = ?")
247
+ params.append(normalized)
248
+ if len(clauses) == 1:
249
+ return 0
250
+ ensure_schema()
251
+ now = datetime.now(timezone.utc).isoformat()
252
+ conn = _connect()
253
+ try:
254
+ cur = conn.execute(
255
+ f"UPDATE turn_credential_leases SET revoked_at = ? WHERE {' AND '.join(clauses)}",
256
+ (now, *params),
257
+ )
258
+ conn.commit()
259
+ return int(cur.rowcount or 0)
260
+ finally:
261
+ conn.close()
262
+
263
+
223
264
  def resolve_lease_secret(lease: dict[str, Any], resolve_secret_fn) -> tuple[str, str]:
224
265
  """resolve_secret_fn(user_id, workspace_id, provider, binding_id) -> (env_var, secret)."""
225
266
  binding_id = str(lease.get("credential_binding_id") or "").strip()