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.
- package/README.md +1 -1
- package/bin/create-workframe.js +2 -2
- package/bin/workframe.js +130 -2
- package/docs/security.md +2 -2
- package/package.json +1 -1
- package/scripts/bundle-workframe-ui.mjs +9 -0
- package/scripts/sync-canonical-to-package.mjs +1 -0
- package/scripts/verify-public-deploy.sh +65 -3
- package/workframe-api/action_proxy.py +23 -17
- package/workframe-api/activity_feed.py +798 -0
- package/workframe-api/api_meta.py +157 -0
- package/workframe-api/auth_gate.py +566 -0
- package/workframe-api/avatar_registry.py +343 -0
- package/workframe-api/broker_audit.py +164 -0
- package/workframe-api/cell_authority.py +231 -0
- package/workframe-api/chat_bind.py +319 -0
- package/workframe-api/chat_sessions.py +509 -0
- package/workframe-api/chat_stream.py +694 -0
- package/workframe-api/cleanup_dogfood_smoke.py +252 -0
- package/workframe-api/credential_broker.py +162 -0
- package/workframe-api/credential_resolve.py +202 -0
- package/workframe-api/credential_store.py +245 -0
- package/workframe-api/crew_registry.py +250 -0
- package/workframe-api/db_schema.py +755 -0
- package/workframe-api/docker_gateway.py +166 -0
- package/workframe-api/doctor_runtime.py +109 -0
- package/workframe-api/domain/__init__.py +47 -0
- package/workframe-api/domain/entities.py +252 -0
- package/workframe-api/domain/schema/workframe-domain.schema.json +278 -0
- package/workframe-api/egress_policy.py +42 -0
- package/workframe-api/handler_modules/__init__.py +17 -0
- package/workframe-api/handler_modules/handler_admin.py +542 -0
- package/workframe-api/handler_modules/handler_auth.py +512 -0
- package/workframe-api/handler_modules/handler_chat.py +641 -0
- package/workframe-api/handler_modules/handler_install.py +178 -0
- package/workframe-api/handler_modules/handler_provider.py +325 -0
- package/workframe-api/handler_modules/handler_workspace.py +1420 -0
- package/workframe-api/health_monitor.py +80 -0
- package/workframe-api/hermes_admin.py +756 -0
- package/workframe-api/hermes_profiles.py +1328 -0
- package/workframe-api/install_api.py +123 -0
- package/workframe-api/kanban_cron.py +473 -0
- package/workframe-api/lane_bindings.py +423 -0
- package/workframe-api/llm_proxy.py +17 -43
- package/workframe-api/mention_helpers.py +180 -0
- package/workframe-api/mention_invoke.py +431 -0
- package/workframe-api/model_surface.py +1139 -0
- package/workframe-api/oauth_pending.py +85 -0
- package/workframe-api/oauth_redirect.py +381 -0
- package/workframe-api/package.json +2 -2
- package/workframe-api/profile_api_lifecycle.py +66 -0
- package/workframe-api/profile_gateway.py +436 -0
- package/workframe-api/provider_bindings.py +673 -0
- package/workframe-api/provider_bootstrap.py +347 -0
- package/workframe-api/provider_catalog.py +180 -0
- package/workframe-api/rooms.py +1613 -0
- package/workframe-api/route_registry.py +331 -191
- package/workframe-api/run-typecheck.mjs +2 -1
- package/workframe-api/run_authority.py +287 -0
- package/workframe-api/run_ledger.py +470 -0
- package/workframe-api/run_surface_wiring.py +344 -0
- package/workframe-api/runtime_cohort.py +752 -0
- package/workframe-api/runtime_tokens.py +127 -0
- package/workframe-api/server.py +1453 -19513
- package/workframe-api/snapshot_feed.py +49 -0
- package/workframe-api/stack_config.py +33 -1
- package/workframe-api/supervisor_client.py +154 -0
- package/workframe-api/test_api_meta_build_stamp.py +34 -0
- package/workframe-api/test_cell_authority.py +79 -0
- package/workframe-api/test_chat_bind.py +48 -0
- package/workframe-api/test_credential_lease_matrix.py +171 -0
- package/workframe-api/test_credential_resolve.py +51 -0
- package/workframe-api/test_credential_store.py +27 -0
- package/workframe-api/test_dogfood_flows.py +346 -0
- package/workframe-api/test_domain_entities.py +152 -0
- package/workframe-api/test_exception_hygiene.py +12 -0
- package/workframe-api/test_hermes_admin.py +72 -0
- package/workframe-api/test_install_wizard_resume.py +78 -0
- package/workframe-api/test_local_bootstrap.py +67 -0
- package/workframe-api/test_mention_helpers.py +35 -0
- package/workframe-api/test_mention_invoke.py +26 -0
- package/workframe-api/test_model_surface_consistency.py +1 -0
- package/workframe-api/test_oauth_pending.py +41 -0
- package/workframe-api/test_provider_bindings.py +52 -0
- package/workframe-api/test_provider_catalog.py +28 -0
- package/workframe-api/test_route_registry.py +171 -35
- package/workframe-api/test_run_authority.py +112 -0
- package/workframe-api/test_run_ledger.py +157 -0
- package/workframe-api/test_run_surface_wiring.py +130 -0
- package/workframe-api/test_runtime_cohort.py +85 -0
- package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
- package/workframe-api/test_server_reexports.py +99 -0
- package/workframe-api/test_stack_config_install_smtp.py +7 -0
- package/workframe-api/turn_credentials.py +28 -13
- package/workframe-api/turn_overlay.py +715 -0
- package/workframe-api/updates.py +16 -1
- package/workframe-api/user_prefs.py +387 -0
- package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
- package/workframe-api/workspace_bootstrap.py +536 -0
- package/workframe-api/workspace_files.py +344 -0
- package/workframe-api/workspace_messaging.py +206 -0
- package/workframe-api/zk_auth.py +3 -2
- package/workframe-supervisor/server.py +10 -1
- package/workframe-supervisor/test_supervisor_negative.py +75 -0
- package/workframe-ui/public/assets/{arc-B0OFRGmJ.js → arc-aUUffclm.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-DeBNltHS.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-BDhCHu7I.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-olcDYvtI.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
- package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-D0obCBn_.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-DX93f-ZZ.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-BX9B5wUs.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-C4DGc6RO.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-Crlja9Lf.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-JYSm3szI.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-_KslxVEl.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-CpTO-jU8.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-JGUkCmbI.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-BscBwGaC.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-5ZTZ0bpX.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-DxvitYbW.js → chunk-L5ZTLDWV-BLZrdIwa.js} +1 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-DisG0XJT.js +2 -0
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-Cayl0iV9.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-_7dyBR5G.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O--xTpD0yi.js → chunk-O5CBEL6O-DDN-Ifon.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-EVQ0VMd5.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-k_-ZsLOS.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-CwxJQaeK.js → chunk-XPW4576I-B4_iYZ6r.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-W9WVQsby.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-W9WVQsby.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-CCspENYv.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-Dv8V6R60.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-Da48hFPT.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-dTihc7-3.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-D_Jqu699.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-Bt8PtEaZ.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-BtmPjlh0.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
- package/workframe-ui/public/assets/{dist-Cz2turIT.js → dist-mQWmB3z6.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-BxMGCflX.js → erDiagram-TEJ5UH35-__40xO-y.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Cm1uRTxU.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-ivyroIZt.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-B9llwShx.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BFQHk4bw.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-ursegLbc.js → gitGraphDiagram-PVQCEYII-DMTxFJxn.js} +1 -1
- package/workframe-ui/public/assets/index-BdiM4-lI.js +130 -0
- package/workframe-ui/public/assets/index-Fnad47vV.css +1 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-DQpifAsB.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-C16XP2Q7.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-CiJoz7rP.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CVwEvvUL.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-CWQ6hX5i.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
- package/workframe-ui/public/assets/{line-CgQsmU35.js → line-BTCEHb7l.js} +1 -1
- package/workframe-ui/public/assets/{linear-Bxbc77dL.js → linear-CkbydpnK.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-C_jMeF0a.js → mermaid-parser.core-DvrtArpk.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-4RKV9MZP.js → mermaid.core-yj_1x_qj.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-DZ7y7Sz0.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-B9h2I7Vq.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-B2mP5uHm.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-DyYKyKiP.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-CL_6nej-.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-CNKNDQ7S.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-eWU_mhFa.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-DrjcXvEQ.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-BHWR1xTJ.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
- package/workframe-ui/public/assets/{src-DfJB8kV1.js → src-D6mvlP96.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BGJbSd3I.js → stateDiagram-AJRCARHV-CxaTXs02.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CXikj9xi.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-DkwoCjiZ.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-GxfhiqoW.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-DxrOsars.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-BsWhpUfr.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-CrFZWMHS.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DV8Xpf5I.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-DqMqnwdZ.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
- package/workframe-ui/public/index.html +7 -5
- package/workframe-ui/public/workframe-build.json +5 -0
- package/workframe-ui/public/assets/architecture-7EHR7CIX-Dwwi9yA0.js +0 -1
- package/workframe-ui/public/assets/channel-fNHbDpV4.js +0 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-DMfdMUwz.js +0 -2
- package/workframe-ui/public/assets/chunk-QZHKN3VN-ewTgEQK8.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-D4Tg-A0l.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-CFAgBpEl.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-CFAgBpEl.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-BIJTP5k-.js +0 -1
- package/workframe-ui/public/assets/index-8CuZDEIG.css +0 -1
- package/workframe-ui/public/assets/index-xS9lFekI.js +0 -129
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CMLuNyeC.js +0 -1
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""WF-032 extract: workspace file tree, read/write, revision state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import mimetypes
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
TREE_SKIP_NAMES = {
|
|
13
|
+
".git",
|
|
14
|
+
".next",
|
|
15
|
+
".turbo",
|
|
16
|
+
".venv",
|
|
17
|
+
".workframe-cache",
|
|
18
|
+
"__pycache__",
|
|
19
|
+
"node_modules",
|
|
20
|
+
"dist",
|
|
21
|
+
"build",
|
|
22
|
+
"coverage",
|
|
23
|
+
"users", # ponytail: legacy scaffold dir — hide if present
|
|
24
|
+
"content", # ponytail: legacy nested tree — hide if present
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
PROTECTED_WORKSPACE_FILE_NAMES = {"AGENTS.md", ".hermes.md"}
|
|
28
|
+
PROTECTED_PROFILE_CONFIG_FILE_NAMES = {"config.yaml", "profile.yaml"}
|
|
29
|
+
|
|
30
|
+
_workspace_state_lock = threading.Lock()
|
|
31
|
+
_workspace_state_cache: dict[str, Any] = {
|
|
32
|
+
"ok": True,
|
|
33
|
+
"revision": "0:0:",
|
|
34
|
+
"files": 0,
|
|
35
|
+
"updated_at": "",
|
|
36
|
+
"latest_path": "",
|
|
37
|
+
"generation": 0,
|
|
38
|
+
}
|
|
39
|
+
_workspace_tree_lock = threading.Lock()
|
|
40
|
+
_workspace_tree_cache: dict[str, Any] = {
|
|
41
|
+
"revision": "",
|
|
42
|
+
"root": None,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _srv():
|
|
47
|
+
import server as srv
|
|
48
|
+
|
|
49
|
+
return srv
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _normalized_workspace_rel(rel: str) -> str:
|
|
53
|
+
return (rel or "").replace("\\", "/").lstrip("/")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _workspace_root() -> Path:
|
|
57
|
+
return _srv().WORKSPACE.resolve()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def safe_workspace_path(rel: str) -> Path | None:
|
|
61
|
+
rel = rel.replace("\\", "/").lstrip("/")
|
|
62
|
+
if not rel or ".." in rel.split("/"):
|
|
63
|
+
return None
|
|
64
|
+
root = _workspace_root()
|
|
65
|
+
path = (root / rel).resolve()
|
|
66
|
+
try:
|
|
67
|
+
path.relative_to(root)
|
|
68
|
+
except ValueError:
|
|
69
|
+
return None
|
|
70
|
+
return path
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _workspace_rel(path: Path) -> str:
|
|
74
|
+
return path.resolve().relative_to(_workspace_root()).as_posix()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_env_like_name(name: str) -> bool:
|
|
78
|
+
lower = name.lower()
|
|
79
|
+
return (
|
|
80
|
+
lower == ".env"
|
|
81
|
+
or lower.startswith(".env.")
|
|
82
|
+
or lower.endswith(".env")
|
|
83
|
+
or lower == "environment"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def workspace_protected_reason(rel: str) -> str | None:
|
|
88
|
+
"""Return a stable reason when a workspace-relative path is protected."""
|
|
89
|
+
safe = safe_workspace_path(_normalized_workspace_rel(rel))
|
|
90
|
+
workspace = _srv().WORKSPACE.resolve()
|
|
91
|
+
if safe is None:
|
|
92
|
+
return "invalid_path"
|
|
93
|
+
try:
|
|
94
|
+
rel_path = safe.relative_to(workspace)
|
|
95
|
+
except ValueError:
|
|
96
|
+
return "invalid_path"
|
|
97
|
+
|
|
98
|
+
parts = rel_path.parts
|
|
99
|
+
if not parts:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
for part in parts:
|
|
103
|
+
if part in PROTECTED_WORKSPACE_FILE_NAMES:
|
|
104
|
+
return "protected_workspace_file"
|
|
105
|
+
if _is_env_like_name(part):
|
|
106
|
+
return "protected_env_file"
|
|
107
|
+
|
|
108
|
+
if len(parts) >= 3 and parts[0] == "profiles" and parts[2] in PROTECTED_PROFILE_CONFIG_FILE_NAMES:
|
|
109
|
+
return "protected_profile_config"
|
|
110
|
+
if len(parts) >= 2 and parts[0] == "profiles" and parts[-1] in PROTECTED_PROFILE_CONFIG_FILE_NAMES:
|
|
111
|
+
return "protected_profile_config"
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def safe_content_path(rel: str) -> Path | None:
|
|
116
|
+
return safe_workspace_path(rel)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _list_folder_nodes(folder: Path) -> list[dict[str, Any]]:
|
|
120
|
+
out: list[dict[str, Any]] = []
|
|
121
|
+
try:
|
|
122
|
+
entries = sorted(folder.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))
|
|
123
|
+
except OSError:
|
|
124
|
+
return out
|
|
125
|
+
for p in entries:
|
|
126
|
+
if p.name in TREE_SKIP_NAMES:
|
|
127
|
+
continue
|
|
128
|
+
rel = _workspace_rel(p)
|
|
129
|
+
if workspace_protected_reason(rel):
|
|
130
|
+
continue
|
|
131
|
+
node: dict[str, Any] = {
|
|
132
|
+
"id": rel,
|
|
133
|
+
"name": p.name,
|
|
134
|
+
"type": "folder" if p.is_dir() else "file",
|
|
135
|
+
}
|
|
136
|
+
if p.is_dir():
|
|
137
|
+
node["children"] = []
|
|
138
|
+
node["children_loaded"] = False
|
|
139
|
+
out.append(node)
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _files_tree_root_name() -> str:
|
|
144
|
+
"""Navigator root label follows workspace branding, not WORKFRAME_PROJECT env."""
|
|
145
|
+
try:
|
|
146
|
+
conn = _srv()._workframe_db()
|
|
147
|
+
branding = _srv()._primary_workspace_branding(conn)
|
|
148
|
+
conn.close()
|
|
149
|
+
if branding:
|
|
150
|
+
name = str(branding.get("display_name") or "").strip()
|
|
151
|
+
if name:
|
|
152
|
+
return name
|
|
153
|
+
except Exception: # noqa: BLE001
|
|
154
|
+
pass
|
|
155
|
+
return _srv().PROJECT_NAME
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def files_tree() -> dict[str, Any]:
|
|
159
|
+
workspace = _srv().WORKSPACE
|
|
160
|
+
current_revision = str(workspace_state().get("revision") or "")
|
|
161
|
+
root_name = _files_tree_root_name()
|
|
162
|
+
with _workspace_tree_lock:
|
|
163
|
+
cached_revision = str(_workspace_tree_cache.get("revision") or "")
|
|
164
|
+
cached_root = _workspace_tree_cache.get("root")
|
|
165
|
+
if cached_root and cached_revision == current_revision:
|
|
166
|
+
if str(cached_root.get("name") or "") == root_name:
|
|
167
|
+
return cached_root
|
|
168
|
+
refreshed = dict(cached_root)
|
|
169
|
+
refreshed["name"] = root_name
|
|
170
|
+
_workspace_tree_cache["root"] = refreshed
|
|
171
|
+
return refreshed
|
|
172
|
+
|
|
173
|
+
root = {
|
|
174
|
+
"id": "root",
|
|
175
|
+
"name": root_name,
|
|
176
|
+
"type": "folder",
|
|
177
|
+
"children": [],
|
|
178
|
+
}
|
|
179
|
+
if not workspace.is_dir():
|
|
180
|
+
return root
|
|
181
|
+
root["children"] = _list_folder_nodes(workspace)
|
|
182
|
+
with _workspace_tree_lock:
|
|
183
|
+
_workspace_tree_cache["revision"] = current_revision
|
|
184
|
+
_workspace_tree_cache["root"] = root
|
|
185
|
+
return root
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def files_list(rel: str = "") -> dict[str, Any]:
|
|
189
|
+
workspace = _srv().WORKSPACE
|
|
190
|
+
folder = workspace if not rel.strip() else safe_workspace_path(rel)
|
|
191
|
+
if folder is None or not folder.exists() or not folder.is_dir():
|
|
192
|
+
raise ValueError("folder not found")
|
|
193
|
+
reason = workspace_protected_reason(rel)
|
|
194
|
+
if reason:
|
|
195
|
+
raise PermissionError(f"protected file: {reason}")
|
|
196
|
+
folder_rel = "" if folder == workspace else _workspace_rel(folder)
|
|
197
|
+
return {
|
|
198
|
+
"ok": True,
|
|
199
|
+
"path": folder_rel,
|
|
200
|
+
"children": _list_folder_nodes(folder),
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _scan_workspace_state() -> dict[str, Any]:
|
|
205
|
+
workspace = _srv().WORKSPACE
|
|
206
|
+
files = 0
|
|
207
|
+
latest_mtime = 0.0
|
|
208
|
+
latest_rel = ""
|
|
209
|
+
if workspace.is_dir():
|
|
210
|
+
try:
|
|
211
|
+
for path in workspace.rglob("*"):
|
|
212
|
+
if not path.is_file():
|
|
213
|
+
continue
|
|
214
|
+
if path.name in TREE_SKIP_NAMES:
|
|
215
|
+
continue
|
|
216
|
+
try:
|
|
217
|
+
rel = _workspace_rel(path)
|
|
218
|
+
except ValueError:
|
|
219
|
+
continue
|
|
220
|
+
if workspace_protected_reason(rel):
|
|
221
|
+
continue
|
|
222
|
+
files += 1
|
|
223
|
+
try:
|
|
224
|
+
mtime = float(path.stat().st_mtime)
|
|
225
|
+
except OSError:
|
|
226
|
+
continue
|
|
227
|
+
if mtime >= latest_mtime:
|
|
228
|
+
latest_mtime = mtime
|
|
229
|
+
latest_rel = rel
|
|
230
|
+
except OSError:
|
|
231
|
+
pass
|
|
232
|
+
revision = f"{int(latest_mtime)}:{files}:{latest_rel}"
|
|
233
|
+
return {
|
|
234
|
+
"ok": True,
|
|
235
|
+
"revision": revision,
|
|
236
|
+
"files": files,
|
|
237
|
+
"updated_at": _srv()._iso_from_unix(latest_mtime),
|
|
238
|
+
"latest_path": latest_rel,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _refresh_workspace_state() -> dict[str, Any]:
|
|
243
|
+
state = _scan_workspace_state()
|
|
244
|
+
with _workspace_state_lock:
|
|
245
|
+
generation = int(_workspace_state_cache.get("generation") or 0)
|
|
246
|
+
_workspace_state_cache.update(state)
|
|
247
|
+
_workspace_state_cache["generation"] = generation
|
|
248
|
+
if generation:
|
|
249
|
+
_workspace_state_cache["revision"] = f"{state['revision']}:{generation}"
|
|
250
|
+
return dict(_workspace_state_cache)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def workspace_state() -> dict[str, Any]:
|
|
254
|
+
with _workspace_state_lock:
|
|
255
|
+
return dict(_workspace_state_cache)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def bump_workspace_state(path: Path | None = None) -> None:
|
|
259
|
+
now = time.time()
|
|
260
|
+
rel = ""
|
|
261
|
+
if path is not None:
|
|
262
|
+
try:
|
|
263
|
+
rel = _workspace_rel(path)
|
|
264
|
+
except Exception: # noqa: BLE001
|
|
265
|
+
rel = ""
|
|
266
|
+
with _workspace_state_lock:
|
|
267
|
+
current_files = int(_workspace_state_cache.get("files") or 0)
|
|
268
|
+
generation = int(_workspace_state_cache.get("generation") or 0) + 1
|
|
269
|
+
_workspace_state_cache.update(
|
|
270
|
+
{
|
|
271
|
+
"ok": True,
|
|
272
|
+
"revision": f"{int(now)}:{current_files}:{rel}:{generation}",
|
|
273
|
+
"updated_at": _srv()._iso_from_unix(now),
|
|
274
|
+
"latest_path": rel,
|
|
275
|
+
"generation": generation,
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
with _workspace_tree_lock:
|
|
279
|
+
_workspace_tree_cache["revision"] = ""
|
|
280
|
+
_workspace_tree_cache["root"] = None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def workspace_state_daemon() -> None:
|
|
284
|
+
while True:
|
|
285
|
+
try:
|
|
286
|
+
_refresh_workspace_state()
|
|
287
|
+
except Exception: # noqa: BLE001
|
|
288
|
+
pass
|
|
289
|
+
time.sleep(2)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def file_read(rel: str) -> dict[str, Any]:
|
|
293
|
+
reason = workspace_protected_reason(rel)
|
|
294
|
+
if reason:
|
|
295
|
+
raise PermissionError(f"protected file: {reason}")
|
|
296
|
+
fp = safe_workspace_path(rel)
|
|
297
|
+
if not fp or not fp.exists() or not fp.is_file():
|
|
298
|
+
raise ValueError("not found")
|
|
299
|
+
try:
|
|
300
|
+
text = fp.read_text(encoding="utf-8")
|
|
301
|
+
return {"path": _workspace_rel(fp), "content": text}
|
|
302
|
+
except UnicodeDecodeError:
|
|
303
|
+
return {"path": _workspace_rel(fp), "content": ""}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def file_write(rel: str, content: str) -> dict[str, Any]:
|
|
307
|
+
reason = workspace_protected_reason(rel)
|
|
308
|
+
if reason:
|
|
309
|
+
raise PermissionError(f"protected file: {reason}")
|
|
310
|
+
fp = safe_workspace_path(rel)
|
|
311
|
+
if not fp:
|
|
312
|
+
raise ValueError("invalid path")
|
|
313
|
+
fp.parent.mkdir(parents=True, exist_ok=True)
|
|
314
|
+
fp.write_text(content, encoding="utf-8")
|
|
315
|
+
bump_workspace_state(fp)
|
|
316
|
+
return {"ok": True, "path": _workspace_rel(fp)}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def file_upload_binary(rel: str, content_b64: str) -> dict[str, Any]:
|
|
320
|
+
reason = workspace_protected_reason(rel)
|
|
321
|
+
if reason:
|
|
322
|
+
raise PermissionError(f"protected file: {reason}")
|
|
323
|
+
fp = safe_workspace_path(rel)
|
|
324
|
+
if not fp:
|
|
325
|
+
raise ValueError("invalid path")
|
|
326
|
+
try:
|
|
327
|
+
raw = base64.b64decode(content_b64, validate=True)
|
|
328
|
+
except ValueError as exc:
|
|
329
|
+
raise ValueError("invalid base64") from exc
|
|
330
|
+
fp.parent.mkdir(parents=True, exist_ok=True)
|
|
331
|
+
fp.write_bytes(raw)
|
|
332
|
+
bump_workspace_state(fp)
|
|
333
|
+
return {"ok": True, "path": _workspace_rel(fp), "size": len(raw)}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def file_raw(rel: str) -> tuple[bytes, str]:
|
|
337
|
+
reason = workspace_protected_reason(rel)
|
|
338
|
+
if reason:
|
|
339
|
+
raise PermissionError(f"protected file: {reason}")
|
|
340
|
+
fp = safe_workspace_path(rel)
|
|
341
|
+
if not fp or not fp.is_file():
|
|
342
|
+
raise ValueError("not found")
|
|
343
|
+
mime, _ = mimetypes.guess_type(str(fp))
|
|
344
|
+
return fp.read_bytes(), mime or "application/octet-stream"
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""WF-032 extract: workspace messaging settings and gateway env sync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sqlite3
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
# Hermes gateway reads these from the primary profile .env; vault is source of truth.
|
|
11
|
+
_MESSAGING_GATEWAY_ENV: dict[str, dict[str, str]] = {
|
|
12
|
+
"discord": {
|
|
13
|
+
"token": "DISCORD_BOT_TOKEN",
|
|
14
|
+
"home_channel": "DISCORD_HOME_CHANNEL",
|
|
15
|
+
"allowed_users": "DISCORD_ALLOWED_USERS",
|
|
16
|
+
},
|
|
17
|
+
"telegram": {
|
|
18
|
+
"token": "TELEGRAM_BOT_TOKEN",
|
|
19
|
+
"home_channel": "TELEGRAM_HOME_CHANNEL",
|
|
20
|
+
"allowed_users": "TELEGRAM_ALLOWED_USERS",
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _srv():
|
|
26
|
+
import server as srv
|
|
27
|
+
|
|
28
|
+
return srv
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_messaging_settings_patch(body: dict[str, Any], settings: dict[str, Any]) -> dict[str, Any]:
|
|
32
|
+
"""Merge admin messaging channel/allowlist config into workspace settings."""
|
|
33
|
+
raw = body.get("messaging")
|
|
34
|
+
if not isinstance(raw, dict):
|
|
35
|
+
return settings
|
|
36
|
+
messaging = settings.get("messaging") if isinstance(settings.get("messaging"), dict) else {}
|
|
37
|
+
merged = dict(messaging)
|
|
38
|
+
for provider in ("discord", "telegram"):
|
|
39
|
+
block = raw.get(provider)
|
|
40
|
+
if not isinstance(block, dict):
|
|
41
|
+
continue
|
|
42
|
+
current = merged.get(provider) if isinstance(merged.get(provider), dict) else {}
|
|
43
|
+
row = dict(current)
|
|
44
|
+
if "home_channel" in block:
|
|
45
|
+
row["home_channel"] = str(block.get("home_channel") or "").strip()
|
|
46
|
+
if "allowed_users" in block:
|
|
47
|
+
row["allowed_users"] = str(block.get("allowed_users") or "").strip()
|
|
48
|
+
merged[provider] = row
|
|
49
|
+
settings["messaging"] = merged
|
|
50
|
+
return settings
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _workspace_member_platform_ids(workspace_id: str) -> dict[str, set[str]]:
|
|
54
|
+
"""Collect linked Discord/Telegram user IDs for workspace members."""
|
|
55
|
+
workspace_id = str(workspace_id or "").strip()
|
|
56
|
+
out: dict[str, set[str]] = {"discord": set(), "telegram": set(), "slack": set()}
|
|
57
|
+
if not workspace_id:
|
|
58
|
+
return out
|
|
59
|
+
conn = _srv()._workframe_db()
|
|
60
|
+
try:
|
|
61
|
+
rows = conn.execute(
|
|
62
|
+
"""
|
|
63
|
+
SELECT u.platform_ids
|
|
64
|
+
FROM users u
|
|
65
|
+
JOIN workspace_memberships wm ON wm.user_id = u.id
|
|
66
|
+
WHERE wm.workspace_id = ?
|
|
67
|
+
AND wm.deleted_at IS NULL
|
|
68
|
+
AND u.deleted_at IS NULL
|
|
69
|
+
AND wm.status = 'active'
|
|
70
|
+
""",
|
|
71
|
+
(workspace_id,),
|
|
72
|
+
).fetchall()
|
|
73
|
+
finally:
|
|
74
|
+
conn.close()
|
|
75
|
+
for row in rows:
|
|
76
|
+
raw = row[0] if row else "{}"
|
|
77
|
+
try:
|
|
78
|
+
parsed = json.loads(str(raw or "{}"))
|
|
79
|
+
except (TypeError, json.JSONDecodeError):
|
|
80
|
+
parsed = {}
|
|
81
|
+
if not isinstance(parsed, dict):
|
|
82
|
+
continue
|
|
83
|
+
for platform in out:
|
|
84
|
+
val = str(parsed.get(platform) or "").strip()
|
|
85
|
+
if val:
|
|
86
|
+
out[platform].add(val)
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _merged_messaging_allowed_users(workspace_id: str, provider: str, seed: str) -> str:
|
|
91
|
+
"""Admin seed allowlist + member-linked platform IDs for gateway env."""
|
|
92
|
+
provider = str(provider or "").strip().lower()
|
|
93
|
+
ids: list[str] = []
|
|
94
|
+
seen: set[str] = set()
|
|
95
|
+
for part in re.split(r"[\s,;]+", str(seed or "").strip()):
|
|
96
|
+
token = part.strip()
|
|
97
|
+
if token and token not in seen:
|
|
98
|
+
seen.add(token)
|
|
99
|
+
ids.append(token)
|
|
100
|
+
for member_id in sorted(_workspace_member_platform_ids(workspace_id).get(provider, set())):
|
|
101
|
+
if member_id not in seen:
|
|
102
|
+
seen.add(member_id)
|
|
103
|
+
ids.append(member_id)
|
|
104
|
+
return ",".join(ids)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _workspace_messaging_integrations_payload(workspace_id: str, settings: dict[str, Any]) -> dict[str, Any]:
|
|
108
|
+
messaging = settings.get("messaging") if isinstance(settings.get("messaging"), dict) else {}
|
|
109
|
+
payload: dict[str, Any] = {}
|
|
110
|
+
for provider, env_map in _MESSAGING_GATEWAY_ENV.items():
|
|
111
|
+
block = messaging.get(provider) if isinstance(messaging.get(provider), dict) else {}
|
|
112
|
+
resolved = _srv()._resolve_credential("", workspace_id, provider)
|
|
113
|
+
has_token = bool(resolved and _srv()._credential_secret(resolved, ""))
|
|
114
|
+
payload[provider] = {
|
|
115
|
+
"bot_token_configured": has_token,
|
|
116
|
+
"home_channel": str(block.get("home_channel") or ""),
|
|
117
|
+
"allowed_users": str(block.get("allowed_users") or ""),
|
|
118
|
+
}
|
|
119
|
+
return payload
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _set_primary_messaging_platforms(enabled: dict[str, bool]) -> tuple[bool, str]:
|
|
123
|
+
primary = _srv()._primary_profile()
|
|
124
|
+
if not primary:
|
|
125
|
+
return False, "no_primary_profile"
|
|
126
|
+
flags = {name: bool(enabled.get(name)) for name in ("discord", "telegram")}
|
|
127
|
+
cfg_path = _srv()._profile_gateway_config_path(primary)
|
|
128
|
+
if cfg_path is None:
|
|
129
|
+
return False, "no_primary_profile"
|
|
130
|
+
try:
|
|
131
|
+
import yaml
|
|
132
|
+
|
|
133
|
+
cfg: dict[str, Any] = {}
|
|
134
|
+
if cfg_path.is_file():
|
|
135
|
+
loaded = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
|
136
|
+
cfg = loaded if isinstance(loaded, dict) else {}
|
|
137
|
+
plats = cfg.setdefault("platforms", {})
|
|
138
|
+
if not isinstance(plats, dict):
|
|
139
|
+
plats = {}
|
|
140
|
+
cfg["platforms"] = plats
|
|
141
|
+
for name, on in flags.items():
|
|
142
|
+
row = plats.get(name) if isinstance(plats.get(name), dict) else {}
|
|
143
|
+
row["enabled"] = bool(on)
|
|
144
|
+
plats[name] = row
|
|
145
|
+
cfg_path.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding="utf-8")
|
|
146
|
+
return True, "ok"
|
|
147
|
+
except (OSError, ImportError) as exc:
|
|
148
|
+
return False, str(exc)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _sync_workspace_messaging_gateway(workspace_id: str) -> dict[str, Any]:
|
|
152
|
+
"""Vault → primary profile .env overlay + platform toggles + gateway restart."""
|
|
153
|
+
workspace_id = str(workspace_id or "").strip()
|
|
154
|
+
if not workspace_id:
|
|
155
|
+
return {"ok": False, "error": "workspace_id_required"}
|
|
156
|
+
primary = _srv()._primary_profile()
|
|
157
|
+
if not primary:
|
|
158
|
+
return {"ok": False, "error": "no_primary_profile"}
|
|
159
|
+
try:
|
|
160
|
+
conn = _srv()._workframe_db()
|
|
161
|
+
row = conn.execute(
|
|
162
|
+
"SELECT settings_json FROM workspaces WHERE id = ? AND deleted_at IS NULL",
|
|
163
|
+
(workspace_id,),
|
|
164
|
+
).fetchone()
|
|
165
|
+
conn.close()
|
|
166
|
+
except sqlite3.Error as exc:
|
|
167
|
+
return {"ok": False, "error": f"db_error: {exc}"}
|
|
168
|
+
settings = _srv()._parse_workspace_settings(row) if row else {}
|
|
169
|
+
messaging = settings.get("messaging") if isinstance(settings.get("messaging"), dict) else {}
|
|
170
|
+
env_path = _srv()._profile_dir(primary) / ".env"
|
|
171
|
+
enabled: dict[str, bool] = {}
|
|
172
|
+
for provider, env_map in _MESSAGING_GATEWAY_ENV.items():
|
|
173
|
+
token_var = env_map["token"]
|
|
174
|
+
resolved = _srv()._resolve_credential("", workspace_id, provider)
|
|
175
|
+
secret = _srv()._credential_secret(resolved, "") if resolved else ""
|
|
176
|
+
block = messaging.get(provider) if isinstance(messaging.get(provider), dict) else {}
|
|
177
|
+
if secret:
|
|
178
|
+
_srv()._upsert_env_secret(env_path, token_var, secret)
|
|
179
|
+
enabled[provider] = True
|
|
180
|
+
home = str(block.get("home_channel") or "").strip()
|
|
181
|
+
if home:
|
|
182
|
+
_srv()._upsert_env_secret(env_path, env_map["home_channel"], home)
|
|
183
|
+
else:
|
|
184
|
+
_srv()._remove_env_secret(env_path, env_map["home_channel"])
|
|
185
|
+
allowed = _merged_messaging_allowed_users(
|
|
186
|
+
workspace_id,
|
|
187
|
+
provider,
|
|
188
|
+
str(block.get("allowed_users") or ""),
|
|
189
|
+
)
|
|
190
|
+
if allowed:
|
|
191
|
+
_srv()._upsert_env_secret(env_path, env_map["allowed_users"], allowed)
|
|
192
|
+
else:
|
|
193
|
+
_srv()._remove_env_secret(env_path, env_map["allowed_users"])
|
|
194
|
+
else:
|
|
195
|
+
enabled[provider] = False
|
|
196
|
+
for key in env_map.values():
|
|
197
|
+
_srv()._remove_env_secret(env_path, key)
|
|
198
|
+
ok, out = _set_primary_messaging_platforms(enabled)
|
|
199
|
+
if not ok:
|
|
200
|
+
return {"ok": False, "error": f"messaging_platform_config_failed: {out}"}
|
|
201
|
+
try:
|
|
202
|
+
restart = _srv()._restart_stack_gateway()
|
|
203
|
+
except (ValueError, OSError, RuntimeError) as exc:
|
|
204
|
+
_srv()._log_handler_error("_sync_workspace_messaging_gateway restart", exc)
|
|
205
|
+
return {"ok": False, "error": str(exc)}
|
|
206
|
+
return {"ok": True, "gateway": restart}
|
package/workframe-api/zk_auth.py
CHANGED
|
@@ -165,8 +165,9 @@ def _zk_db() -> sqlite3.Connection:
|
|
|
165
165
|
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
|
166
166
|
conn = sqlite3.connect(db_path, timeout=10.0)
|
|
167
167
|
conn.row_factory = sqlite3.Row
|
|
168
|
-
|
|
169
|
-
conn.execute("PRAGMA
|
|
168
|
+
# ponytail: DELETE only — WAL sidecars fail on Docker Desktop Windows bind mounts.
|
|
169
|
+
conn.execute("PRAGMA journal_mode=DELETE")
|
|
170
|
+
conn.execute("PRAGMA busy_timeout=30000")
|
|
170
171
|
_zk_init_db(conn)
|
|
171
172
|
return conn
|
|
172
173
|
|
|
@@ -272,10 +272,19 @@ def load_routes() -> dict[str, Any]:
|
|
|
272
272
|
if p.is_dir():
|
|
273
273
|
raw_routes.append({"profile": p.name})
|
|
274
274
|
routes = []
|
|
275
|
+
seen: set[str] = set()
|
|
275
276
|
for row in raw_routes:
|
|
276
277
|
slug = str(row.get("profile") or "").strip()
|
|
277
|
-
if slug and profile_exists(slug):
|
|
278
|
+
if slug and profile_exists(slug) and slug not in seen:
|
|
278
279
|
routes.append({"profile": slug})
|
|
280
|
+
seen.add(slug)
|
|
281
|
+
if default_profile and default_profile not in seen and profile_exists(default_profile):
|
|
282
|
+
routes.insert(0, {"profile": default_profile})
|
|
283
|
+
seen.add(default_profile)
|
|
284
|
+
native = safe_profile_slug(NATIVE_PROFILE) if NATIVE_PROFILE else ""
|
|
285
|
+
if native and native not in seen and profile_exists(native):
|
|
286
|
+
routes.insert(0, {"profile": native})
|
|
287
|
+
seen.add(native)
|
|
279
288
|
return {"default_profile": default_profile, "routes": routes}
|
|
280
289
|
|
|
281
290
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""WF-011 supervisor negative tests — docker boundary, profile/path misuse, auth.
|
|
2
|
+
|
|
3
|
+
Run: python services/workframe-supervisor/test_supervisor_negative.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
13
|
+
|
|
14
|
+
import server as supervisor
|
|
15
|
+
from profile_secret_policy import exec_blocked_for_profile, is_secret_read_attempt
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_secret_read_blocked() -> None:
|
|
19
|
+
cmd = ["sh", "-lc", "cat /opt/data/profiles/u-alice-dev/.env"]
|
|
20
|
+
assert is_secret_read_attempt(cmd)
|
|
21
|
+
assert exec_blocked_for_profile(cmd, "u-alice-dev")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_foreign_profile_secrets_blocked() -> None:
|
|
25
|
+
cmd = ["cat", "/opt/data/profiles/u-bob-dev/.env"]
|
|
26
|
+
assert exec_blocked_for_profile(cmd, "u-alice-dev")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_invalid_profile_slug_rejected() -> None:
|
|
30
|
+
for bad in ("", "../etc", "UPPER", "a" * 70):
|
|
31
|
+
try:
|
|
32
|
+
supervisor.safe_profile_slug(bad)
|
|
33
|
+
raise AssertionError(f"expected ValueError for {bad!r}")
|
|
34
|
+
except ValueError:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_supervisor_auth_required() -> None:
|
|
39
|
+
supervisor.SUPERVISOR_TOKEN = "neg-test-token"
|
|
40
|
+
|
|
41
|
+
class _Handler:
|
|
42
|
+
headers: dict[str, str] = {}
|
|
43
|
+
|
|
44
|
+
assert not supervisor._auth_ok(_Handler()) # type: ignore[arg-type]
|
|
45
|
+
_Handler.headers = {"Authorization": "Bearer neg-test-token"}
|
|
46
|
+
assert supervisor._auth_ok(_Handler()) # type: ignore[arg-type]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_raw_container_exec_disabled_by_default() -> None:
|
|
50
|
+
os.environ.pop("WORKFRAME_SUPERVISOR_ALLOW_RAW_EXEC", None)
|
|
51
|
+
assert os.environ.get("WORKFRAME_SUPERVISOR_ALLOW_RAW_EXEC", "0") != "1"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_api_compose_public_has_no_docker_sock() -> None:
|
|
55
|
+
repo = Path(__file__).resolve().parents[2]
|
|
56
|
+
public = (repo / "infra" / "compose" / "workframe" / "docker-compose.public.yml").read_text(
|
|
57
|
+
encoding="utf-8"
|
|
58
|
+
)
|
|
59
|
+
api_block = public.split("workframe-api:")[1].split("workframe-supervisor:")[0]
|
|
60
|
+
assert "/var/run/docker.sock" not in api_block
|
|
61
|
+
assert "WORKFRAME_SUPERVISOR_ALLOW_RAW_EXEC=0" in public
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main() -> None:
|
|
65
|
+
test_secret_read_blocked()
|
|
66
|
+
test_foreign_profile_secrets_blocked()
|
|
67
|
+
test_invalid_profile_slug_rejected()
|
|
68
|
+
test_supervisor_auth_required()
|
|
69
|
+
test_raw_container_exec_disabled_by_default()
|
|
70
|
+
test_api_compose_public_has_no_docker_sock()
|
|
71
|
+
print("supervisor negative tests ok")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
main()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{n as e,t}from"./path-BWPyau1x.js";import{a as n,c as r,d as i,f as a,i as o,l as s,m as c,n as l,o as u,p as d,r as f,u as p}from"./dist-
|
|
1
|
+
import{n as e,t}from"./path-BWPyau1x.js";import{a as n,c as r,d as i,f as a,i as o,l as s,m as c,n as l,o as u,p as d,r as f,u as p}from"./dist-mQWmB3z6.js";function m(e){return e.innerRadius}function h(e){return e.outerRadius}function g(e){return e.startAngle}function _(e){return e.endAngle}function v(e){return e&&e.padAngle}function y(e,t,n,r,i,a,o,s){var c=n-e,l=r-t,u=o-i,d=s-a,f=d*c-u*l;if(!(f*f<1e-12))return f=(u*(t-a)-d*(e-i))/f,[e+f*c,t+f*l]}function b(e,t,n,r,i,a,o){var c=e-n,l=t-r,u=(o?a:-a)/d(c*c+l*l),f=u*l,p=-u*c,m=e+f,h=t+p,g=n+f,_=r+p,v=(m+g)/2,y=(h+_)/2,b=g-m,x=_-h,S=b*b+x*x,C=i-a,w=m*_-g*h,T=(x<0?-1:1)*d(s(0,C*C*S-w*w)),E=(w*x-b*T)/S,D=(-w*b-x*T)/S,O=(w*x+b*T)/S,k=(-w*b+x*T)/S,A=E-v,j=D-y,M=O-v,N=k-y;return A*A+j*j>M*M+N*N&&(E=O,D=k),{cx:E,cy:D,x01:-f,y01:-p,x11:E*(i/C-1),y11:D*(i/C-1)}}function x(){var s=m,x=h,S=e(0),C=null,w=g,T=_,E=v,D=null,O=t(k);function k(){var e,t,m=+s.apply(this,arguments),h=+x.apply(this,arguments),g=w.apply(this,arguments)-r,_=T.apply(this,arguments)-r,v=l(_-g),k=_>g;if(D||=e=O(),h<m&&(t=h,h=m,m=t),!(h>1e-12))D.moveTo(0,0);else if(v>c-1e-12)D.moveTo(h*u(g),h*a(g)),D.arc(0,0,h,g,_,!k),m>1e-12&&(D.moveTo(m*u(_),m*a(_)),D.arc(0,0,m,_,g,k));else{var A=g,j=_,M=g,N=_,P=v,F=v,I=E.apply(this,arguments)/2,L=I>1e-12&&(C?+C.apply(this,arguments):d(m*m+h*h)),R=p(l(h-m)/2,+S.apply(this,arguments)),z=R,B=R,V,H;if(L>1e-12){var U=o(L/m*a(I)),W=o(L/h*a(I));(P-=U*2)>1e-12?(U*=k?1:-1,M+=U,N-=U):(P=0,M=N=(g+_)/2),(F-=W*2)>1e-12?(W*=k?1:-1,A+=W,j-=W):(F=0,A=j=(g+_)/2)}var G=h*u(A),K=h*a(A),q=m*u(N),J=m*a(N);if(R>1e-12){var Y=h*u(j),X=h*a(j),Z=m*u(M),Q=m*a(M),$;if(v<i)if($=y(G,K,Z,Q,Y,X,q,J)){var ee=G-$[0],te=K-$[1],ne=Y-$[0],re=X-$[1],ie=1/a(f((ee*ne+te*re)/(d(ee*ee+te*te)*d(ne*ne+re*re)))/2),ae=d($[0]*$[0]+$[1]*$[1]);z=p(R,(m-ae)/(ie-1)),B=p(R,(h-ae)/(ie+1))}else z=B=0}F>1e-12?B>1e-12?(V=b(Z,Q,G,K,h,B,k),H=b(Y,X,q,J,h,B,k),D.moveTo(V.cx+V.x01,V.cy+V.y01),B<R?D.arc(V.cx,V.cy,B,n(V.y01,V.x01),n(H.y01,H.x01),!k):(D.arc(V.cx,V.cy,B,n(V.y01,V.x01),n(V.y11,V.x11),!k),D.arc(0,0,h,n(V.cy+V.y11,V.cx+V.x11),n(H.cy+H.y11,H.cx+H.x11),!k),D.arc(H.cx,H.cy,B,n(H.y11,H.x11),n(H.y01,H.x01),!k))):(D.moveTo(G,K),D.arc(0,0,h,A,j,!k)):D.moveTo(G,K),!(m>1e-12)||!(P>1e-12)?D.lineTo(q,J):z>1e-12?(V=b(q,J,Y,X,m,-z,k),H=b(G,K,Z,Q,m,-z,k),D.lineTo(V.cx+V.x01,V.cy+V.y01),z<R?D.arc(V.cx,V.cy,z,n(V.y01,V.x01),n(H.y01,H.x01),!k):(D.arc(V.cx,V.cy,z,n(V.y01,V.x01),n(V.y11,V.x11),!k),D.arc(0,0,m,n(V.cy+V.y11,V.cx+V.x11),n(H.cy+H.y11,H.cx+H.x11),k),D.arc(H.cx,H.cy,z,n(H.y11,H.x11),n(H.y01,H.x01),!k))):D.arc(0,0,m,N,M,k)}if(D.closePath(),e)return D=null,e+``||null}return k.centroid=function(){var e=(+s.apply(this,arguments)+ +x.apply(this,arguments))/2,t=(+w.apply(this,arguments)+ +T.apply(this,arguments))/2-i/2;return[u(t)*e,a(t)*e]},k.innerRadius=function(t){return arguments.length?(s=typeof t==`function`?t:e(+t),k):s},k.outerRadius=function(t){return arguments.length?(x=typeof t==`function`?t:e(+t),k):x},k.cornerRadius=function(t){return arguments.length?(S=typeof t==`function`?t:e(+t),k):S},k.padRadius=function(t){return arguments.length?(C=t==null?null:typeof t==`function`?t:e(+t),k):C},k.startAngle=function(t){return arguments.length?(w=typeof t==`function`?t:e(+t),k):w},k.endAngle=function(t){return arguments.length?(T=typeof t==`function`?t:e(+t),k):T},k.padAngle=function(t){return arguments.length?(E=typeof t==`function`?t:e(+t),k):E},k.context=function(e){return arguments.length?(D=e??null,k):D},k}export{x as t};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import"./chunk-NNHCCRGN-DlpIbxXb.js";import{x as e}from"./mermaid-parser.core-DvrtArpk.js";export{e as createArchitectureServices};
|