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.
- package/README.md +1 -1
- package/bin/create-workframe.js +73 -9
- package/package.json +1 -1
- package/scripts/bundle-workframe-ui.mjs +11 -1
- package/workframe-api/action_proxy.py +8 -0
- package/workframe-api/chat_sessions.py +37 -6
- package/workframe-api/chat_stream.py +16 -0
- package/workframe-api/db_schema.py +29 -0
- package/workframe-api/handler_modules/handler_admin.py +32 -0
- package/workframe-api/handler_modules/handler_auth.py +6 -4
- package/workframe-api/handler_modules/handler_chat.py +17 -26
- package/workframe-api/handler_modules/handler_provider.py +13 -14
- package/workframe-api/handler_modules/handler_workspace.py +60 -2
- package/workframe-api/hermes_profiles.py +31 -9
- package/workframe-api/llm_proxy.py +18 -0
- package/workframe-api/mention_invoke.py +29 -24
- package/workframe-api/message_reactions.py +139 -0
- package/workframe-api/model_surface.py +329 -113
- package/workframe-api/package.json +4 -4
- package/workframe-api/profile_gateway.py +14 -6
- package/workframe-api/provider_bindings.py +118 -17
- package/workframe-api/provider_bootstrap.py +74 -38
- package/workframe-api/provider_model_catalog.py +261 -0
- package/workframe-api/rooms.py +4 -3
- package/workframe-api/route_registry.py +4 -0
- package/workframe-api/runtime_cohort.py +118 -23
- package/workframe-api/scripts/run-python.mjs +28 -0
- package/workframe-api/server.py +44 -12
- package/workframe-api/test_agent_identity_authority.py +96 -0
- package/workframe-api/test_chat_attribution_history.py +59 -0
- package/workframe-api/test_enrich_room_messages.py +43 -2
- package/workframe-api/test_llm_proxy_encoding.py +33 -0
- package/workframe-api/test_message_reactions.py +151 -0
- package/workframe-api/test_model_routing_reconcile.py +122 -0
- package/workframe-api/test_model_surface_consistency.py +45 -5
- package/workframe-api/test_oauth_llm.py +1 -1
- package/workframe-api/test_profile_gateway_health.py +37 -0
- package/workframe-api/test_provider_bindings.py +115 -1
- package/workframe-api/test_provider_model_catalog.py +27 -0
- package/workframe-api/test_provider_model_surface.py +2 -2
- package/workframe-api/test_runtime_lease_lifecycle.py +93 -0
- package/workframe-api/test_server_reexports.py +2 -0
- package/workframe-api/test_user_llm_prefs.py +27 -0
- package/workframe-api/test_user_theme_prefs.py +65 -0
- package/workframe-api/test_workspace_file_actions.py +119 -0
- package/workframe-api/turn_credentials.py +41 -0
- package/workframe-api/turn_overlay.py +95 -34
- package/workframe-api/user_prefs.py +22 -1
- package/workframe-api/workspace_bootstrap.py +1 -0
- package/workframe-api/workspace_files.py +124 -0
- package/workframe-api/zk_auth.py +7 -1
- package/workframe-ui/public/assets/{arc-CSddOtMa.js → arc-D_gXvZHy.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-B8ZI_Iqb.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-BSiUei3D.js → architectureDiagram-3BPJPVTR-2F65XJkk.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-BhKTULPo.js → blockDiagram-GPEHLZMM-BoURuzr5.js} +1 -1
- package/workframe-ui/public/assets/{brave-B885JYnH.svg → brave-BoYMbZi1.svg} +0 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-Bv_xZi-a.js → c4Diagram-AAUBKEIU-BBtveYSm.js} +1 -1
- package/workframe-ui/public/assets/channel-Ca1FvZ7w.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-Bh0WiqFE.js → chunk-2J33WTMH-BCuhFg2u.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-qN09pRuK.js → chunk-3OPIFGDE-gM6Q-rjJ.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-Dz6FVuMa.js → chunk-4BX2VUAB-D11CJMfF.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-DWtPHVfR.js → chunk-55IACEB6-tOvPBzSf.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-CyOUeDAo.js → chunk-5ZQYHXKU-Bb1uF7QP.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-BrICvWwx.js → chunk-727SXJPM-BvzzLkGB.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-CQu2SmLM.js → chunk-AQP2D5EJ-tr5A2PUD.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-uJbYXNNG.js → chunk-BSJP7CBP-BSFEsidk.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-CCS_FtVZ.js → chunk-CSCIHK7Q-Bgf-IHHq.js} +1 -1
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-CoLUtPiK.js → chunk-FMBD7UC4-rD27Gf3U.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-DLW4gvEW.js → chunk-KSCS5N6A-C1MobXen.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-CMt0LJAd.js → chunk-L5ZTLDWV-CD2wS3bn.js} +1 -1
- package/workframe-ui/public/assets/{chunk-LZXEDZCA-Bn-maIlt.js → chunk-LZXEDZCA-DQxuSIdE.js} +2 -2
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-CG7yoApR.js → chunk-ND2GUHAM-DHlBi932.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-B4dIvbii.js → chunk-NZK2D7GU-MB8bO5o8.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-B5spDyXy.js → chunk-O5CBEL6O-BmPwoKAr.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-BsuC0swk.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-CyfRhD17.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-D5c0TMX6.js → chunk-XPW4576I-BUCJoags.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-DZ_KubnM.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-DZ_KubnM.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-DFRM3DZv.js → cose-bilkent-S5V4N54A-w9hpFlQU.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-CnUdKeJa.js → dagre-BM42HDAG-DEnBMVrR.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ds3-e-hW.js → diagram-2AECGRRQ-CMoYqkVC.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-B41zx8FF.js → diagram-5GNKFQAL-rS0QQe2R.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-D83tI9I8.js → diagram-KO2AKTUF-Do63Vbzi.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-h_EuFyLI.js → diagram-LMA3HP47-Bq3e2FDx.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-CpU-d3RZ.js → diagram-OG6HWLK6-DTESfGaG.js} +1 -1
- package/workframe-ui/public/assets/{dist-_igeSJ9r.js → dist-BiIfSARy.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-jLFoncRU.js → erDiagram-TEJ5UH35-C7RA0P76.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-DChy3wGd.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-C9IaNgCy.js → flowDiagram-I6XJVG4X-e47AwJAm.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-A9FhfFc5.js → ganttDiagram-6RSMTGT7-BVytj000.js} +1 -1
- package/workframe-ui/public/assets/{gemini-Cq-0-rmr.svg → gemini-DqnZlvMo.svg} +0 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BqK_IjZb.js → gitGraph-WXDBUCRP-QdT2gCGh.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CDVqxhyW.js → gitGraphDiagram-PVQCEYII-COEavQVV.js} +1 -1
- package/workframe-ui/public/assets/index-03WsLxfn.js +133 -0
- package/workframe-ui/public/assets/index-BIKn2D6J.css +1 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-C2EKRglL.js → info-J43DQDTF-CEyVZ-9w.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-azQtFzpU.js → infoDiagram-5YYISTIA-CV3ftI3x.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-BuDOLv1t.js → ishikawaDiagram-YF4QCWOH-M8CoaInm.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-ypTHFoT0.js → journeyDiagram-JHISSGLW-BtoaC0oj.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-Cgr78J_x.js → kanban-definition-UN3LZRKU-Dke-ozKB.js} +1 -1
- package/workframe-ui/public/assets/{line-CcwgaMdv.js → line-CDg_Ms_2.js} +1 -1
- package/workframe-ui/public/assets/{linear-CswIlWaW.js → linear-D3OWfPXS.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-CQYhP37X.js → mermaid-parser.core-CGDFs4AZ.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-Cz1XNBaK.js → mermaid.core-C9-q_XHF.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-BJLu0829.js → mindmap-definition-RKZ34NQL-BvEiBAWz.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-AB1qh96V.js → packet-YPE3B663-FF5nKMbE.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-CokIXLdT.js → pie-LRSECV5Y-CZ9ijSae.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-DyrwQn5F.js → pieDiagram-4H26LBE5-kD9o509O.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-C_Fdg3F6.js → quadrantDiagram-W4KKPZXB-d_eA7_-T.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-C21Xfrrg.js → radar-GUYGQ44K-ViyzC1rg.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-ChhaWgaF.js → requirementDiagram-4Y6WPE33-Sq3D_b4Z.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-C5T4sDTt.js → sankeyDiagram-5OEKKPKP-ZZnl3ZwZ.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DRks8i3A.js → sequenceDiagram-3UESZ5HK-Yaz2MgMq.js} +1 -1
- package/workframe-ui/public/assets/{src-BQu5waHd.js → src-DBh8rT2Z.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BI-AKYta.js → stateDiagram-AJRCARHV-DVwi_P3W.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-DRSxEx1_.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DnAIWzEc.js → timeline-definition-PNZ67QCA-C88RoMRK.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-BEF_sjx7.js → treeView-BLDUP644-2xabSpow.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-BcbL7g5c.js → treemap-LRROVOQU-BA_oRW4e.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-DAoIbCkz.js → vennDiagram-CIIHVFJN-DvyVA588.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-3SOmFBFw.js → wardley-L42UT6IY-BR0suGqb.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DlQV_-LH.js → wardleyDiagram-YWT4CUSO-CXMvBEO9.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-CVoTTRHF.js → xychartDiagram-2RQKCTM6-wM7UrTHx.js} +1 -1
- package/workframe-ui/public/index.html +23 -12
- package/workframe-ui/public/workframe-build.json +4 -3
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CDDNM1ni.js +0 -1
- package/workframe-ui/public/assets/channel-R52LrLO3.js +0 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-C_9MbT10.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-VJadzJmf.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CpBjaI3g.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CpBjaI3g.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-kzOxPvtj.js +0 -1
- package/workframe-ui/public/assets/index-Bco9IDMA.css +0 -1
- package/workframe-ui/public/assets/index-Do34PZS2.js +0 -132
- 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
|
|
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-
|
|
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
|
|
@@ -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()
|