@bitseek/hermes-webui 0.1.0-beta.0 → 0.1.0-beta.1
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/package.json +2 -2
- package/vendor/agent-frontend-shell/.bitseek-source.json +2 -2
- package/vendor/agent-frontend-shell/CHANGELOG.md +178 -1
- package/vendor/agent-frontend-shell/CONTRIBUTORS.md +5 -5
- package/vendor/agent-frontend-shell/api/agent_health.py +134 -0
- package/vendor/agent-frontend-shell/api/config.py +145 -104
- package/vendor/agent-frontend-shell/api/gateway_chat.py +56 -12
- package/vendor/agent-frontend-shell/api/helpers.py +4 -2
- package/vendor/agent-frontend-shell/api/models.py +202 -20
- package/vendor/agent-frontend-shell/api/paths.py +77 -0
- package/vendor/agent-frontend-shell/api/plugins.py +185 -0
- package/vendor/agent-frontend-shell/api/profiles.py +95 -16
- package/vendor/agent-frontend-shell/api/routes.py +831 -30
- package/vendor/agent-frontend-shell/api/run_journal.py +1 -0
- package/vendor/agent-frontend-shell/api/state_sync.py +5 -4
- package/vendor/agent-frontend-shell/api/streaming.py +211 -56
- package/vendor/agent-frontend-shell/api/todo_state.py +122 -0
- package/vendor/agent-frontend-shell/api/updates.py +30 -3
- package/vendor/agent-frontend-shell/api/upload.py +251 -18
- package/vendor/agent-frontend-shell/api/workspace.py +323 -65
- package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_EN.docx +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/BitSeek_Claw_Operation_Manual_ZH.docx +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/00-Installation.md +174 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/01-Overview.md +128 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/02-Page-Operations.md +461 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/README.md +61 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/ai-colleagues.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/chat-area.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/kanban.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/main-page.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-notes.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-profile.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory-soul.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/memory.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/navigation-bar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-appearance.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-conversation.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-plugins.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-preferences.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-providers.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings-system.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/settings.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/sidebar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/skills.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/tasks.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/en/images/workspace-panel.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/md_to_docx.py +351 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/00-/345/256/211/350/243/205/345/220/257/345/212/250.md +174 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/01-/346/225/264/344/275/223/346/246/202/350/247/210.md +128 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/02-/351/241/265/351/235/242/346/223/215/344/275/234.md +463 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/README.md +61 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/ai-colleagues.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/chat-area.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/kanban.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/main-page.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-notes.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-profile.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory-soul.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/memory.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/navigation-bar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-appearance.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-conversation.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-overview.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-plugins.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-preferences.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-providers.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings-system.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/settings.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/sidebar.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/skills.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/tasks.png +0 -0
- package/vendor/agent-frontend-shell/bitseek_docs/zh/images/workspace-panel.png +0 -0
- package/vendor/agent-frontend-shell/build-release.sh +62 -0
- package/vendor/agent-frontend-shell/ctl.sh +1 -0
- package/vendor/agent-frontend-shell/docker-compose.local.yml +33 -0
- package/vendor/agent-frontend-shell/docker-compose.yml +8 -0
- package/vendor/agent-frontend-shell/docker_init.bash +1 -0
- package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +74 -15
- package/vendor/agent-frontend-shell/extensions/common/index.css +6 -0
- package/vendor/agent-frontend-shell/extensions/manifest.json +6 -0
- package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +60 -14
- package/vendor/agent-frontend-shell/readme-simple.md +103 -0
- package/vendor/agent-frontend-shell/requirements.txt +5 -0
- package/vendor/agent-frontend-shell/server.py +7 -0
- package/vendor/agent-frontend-shell/static/boot.js +53 -1
- package/vendor/agent-frontend-shell/static/commands.js +20 -10
- package/vendor/agent-frontend-shell/static/i18n.js +1142 -1016
- package/vendor/agent-frontend-shell/static/index.html +13 -3
- package/vendor/agent-frontend-shell/static/messages.js +48 -3
- package/vendor/agent-frontend-shell/static/panels.js +199 -30
- package/vendor/agent-frontend-shell/static/sessions.js +249 -39
- package/vendor/agent-frontend-shell/static/style.css +46 -2
- package/vendor/agent-frontend-shell/static/ui.js +323 -79
- package/vendor/agent-frontend-shell/static/workspace.js +185 -7
- package/vendor/agent-frontend-shell/README-CUSTOM.md +0 -76
- package/vendor/agent-frontend-shell/docker-compose.custom.yml +0 -26
|
@@ -105,6 +105,50 @@ def _session_counts_toward_pin_quota(session) -> bool:
|
|
|
105
105
|
return not _hide_from_default_sidebar(row)
|
|
106
106
|
|
|
107
107
|
|
|
108
|
+
def _session_row_lineage_root_id(session, sessions_by_id) -> str:
|
|
109
|
+
sid = str(_session_field(session, "session_id", "") or "")
|
|
110
|
+
explicit = _session_field(session, "_lineage_root_id", None)
|
|
111
|
+
if explicit:
|
|
112
|
+
return str(explicit)
|
|
113
|
+
# A branch/fork is an independent, separately-visible session (it carries a
|
|
114
|
+
# parent_session_id purely for provenance), so it must count as its OWN pin
|
|
115
|
+
# lineage — only compression/continuation rows should collapse to a shared
|
|
116
|
+
# root. Without this, two pinned forks of the same parent would collapse to a
|
|
117
|
+
# single quota lineage and let the user exceed pinned_sessions_limit (#3288).
|
|
118
|
+
if _session_field(session, "session_source", None) == "fork":
|
|
119
|
+
return sid
|
|
120
|
+
current = sid
|
|
121
|
+
seen = {sid} if sid else set()
|
|
122
|
+
parent = _session_field(session, "parent_session_id", None)
|
|
123
|
+
while parent:
|
|
124
|
+
parent = str(parent)
|
|
125
|
+
if parent in seen:
|
|
126
|
+
break
|
|
127
|
+
current = parent
|
|
128
|
+
seen.add(parent)
|
|
129
|
+
parent_row = sessions_by_id.get(parent)
|
|
130
|
+
if not parent_row:
|
|
131
|
+
break
|
|
132
|
+
parent = _session_field(parent_row, "parent_session_id", None)
|
|
133
|
+
return current or sid
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _visible_pinned_lineage_ids(session_rows) -> set[str]:
|
|
137
|
+
sessions_by_id = {}
|
|
138
|
+
for row in session_rows:
|
|
139
|
+
sid = str(_session_field(row, "session_id", "") or "")
|
|
140
|
+
if sid:
|
|
141
|
+
sessions_by_id[sid] = row
|
|
142
|
+
roots: set[str] = set()
|
|
143
|
+
for row in session_rows:
|
|
144
|
+
if not _session_counts_toward_pin_quota(row):
|
|
145
|
+
continue
|
|
146
|
+
root = _session_row_lineage_root_id(row, sessions_by_id)
|
|
147
|
+
if root:
|
|
148
|
+
roots.add(root)
|
|
149
|
+
return roots
|
|
150
|
+
|
|
151
|
+
|
|
108
152
|
# ── Profile-scoped session/project filtering (#1611, #1614) ────────────────
|
|
109
153
|
#
|
|
110
154
|
# Sessions and projects are stored in the WebUI sidecar without per-row
|
|
@@ -2801,6 +2845,7 @@ def _keep_latest_messaging_session_per_source(
|
|
|
2801
2845
|
from api.models import (
|
|
2802
2846
|
Session,
|
|
2803
2847
|
get_session,
|
|
2848
|
+
get_session_for_file_ops,
|
|
2804
2849
|
new_session,
|
|
2805
2850
|
all_sessions,
|
|
2806
2851
|
title_from,
|
|
@@ -2819,6 +2864,7 @@ from api.models import (
|
|
|
2819
2864
|
_is_empty_partial_activity_message,
|
|
2820
2865
|
_hide_from_default_sidebar,
|
|
2821
2866
|
prune_session_from_index,
|
|
2867
|
+
agent_session_row_exists,
|
|
2822
2868
|
ensure_cron_project,
|
|
2823
2869
|
is_cron_session,
|
|
2824
2870
|
is_safe_session_id,
|
|
@@ -2828,6 +2874,7 @@ from api.workspace import (
|
|
|
2828
2874
|
save_workspaces,
|
|
2829
2875
|
get_last_workspace,
|
|
2830
2876
|
set_last_workspace,
|
|
2877
|
+
git_info_for_workspace,
|
|
2831
2878
|
list_dir,
|
|
2832
2879
|
dir_signature,
|
|
2833
2880
|
list_workspace_suggestions,
|
|
@@ -2839,12 +2886,13 @@ from api.workspace import (
|
|
|
2839
2886
|
_strip_surrounding_quotes,
|
|
2840
2887
|
_workspace_blocked_roots,
|
|
2841
2888
|
)
|
|
2842
|
-
from api.upload import handle_upload, handle_upload_extract, handle_transcribe
|
|
2889
|
+
from api.upload import handle_upload, handle_upload_extract, handle_transcribe, handle_workspace_upload
|
|
2843
2890
|
from api.streaming import (
|
|
2844
2891
|
_sse,
|
|
2845
2892
|
_run_agent_streaming,
|
|
2846
2893
|
cancel_stream,
|
|
2847
2894
|
_materialize_pending_user_turn_before_error,
|
|
2895
|
+
generate_session_title_for_session,
|
|
2848
2896
|
)
|
|
2849
2897
|
from api.gateway_chat import _run_gateway_chat_streaming, webui_gateway_chat_enabled
|
|
2850
2898
|
from api.run_journal import (
|
|
@@ -2852,6 +2900,7 @@ from api.run_journal import (
|
|
|
2852
2900
|
read_run_events,
|
|
2853
2901
|
stale_interrupted_event,
|
|
2854
2902
|
)
|
|
2903
|
+
from api.todo_state import attach_todo_state
|
|
2855
2904
|
from api.providers import get_providers, get_provider_quota, get_provider_cost_history, set_provider_key, remove_provider_key
|
|
2856
2905
|
from api.onboarding import (
|
|
2857
2906
|
apply_onboarding_setup,
|
|
@@ -3744,6 +3793,322 @@ def _handle_insights(handler, parsed) -> bool:
|
|
|
3744
3793
|
})
|
|
3745
3794
|
|
|
3746
3795
|
|
|
3796
|
+
def _project_os_workspace_read(repo_root: Path, rel: str) -> dict | None:
|
|
3797
|
+
try:
|
|
3798
|
+
return read_file_content(repo_root, rel)
|
|
3799
|
+
except Exception:
|
|
3800
|
+
return None
|
|
3801
|
+
|
|
3802
|
+
|
|
3803
|
+
def _project_os_workspace_json(repo_root: Path, rel: str) -> dict | None:
|
|
3804
|
+
payload = _project_os_workspace_read(repo_root, rel)
|
|
3805
|
+
if not payload or not isinstance(payload.get("content"), str):
|
|
3806
|
+
return None
|
|
3807
|
+
try:
|
|
3808
|
+
parsed = json.loads(payload.get("content") or "")
|
|
3809
|
+
return parsed if isinstance(parsed, dict) else None
|
|
3810
|
+
except Exception:
|
|
3811
|
+
return None
|
|
3812
|
+
|
|
3813
|
+
|
|
3814
|
+
def _project_os_truth_board_slugs(repo_root: Path) -> set[str]:
|
|
3815
|
+
slugs: set[str] = set()
|
|
3816
|
+
|
|
3817
|
+
def add(value) -> None:
|
|
3818
|
+
text = str(value or "").strip()
|
|
3819
|
+
if text:
|
|
3820
|
+
slugs.add(text)
|
|
3821
|
+
|
|
3822
|
+
for rel in (
|
|
3823
|
+
".ax/handoff/current.json",
|
|
3824
|
+
".ax/status/active.json",
|
|
3825
|
+
".ax/status/heartbeat.json",
|
|
3826
|
+
):
|
|
3827
|
+
truth = _project_os_workspace_json(repo_root, rel)
|
|
3828
|
+
if not isinstance(truth, dict):
|
|
3829
|
+
continue
|
|
3830
|
+
raw_board = truth.get("board")
|
|
3831
|
+
if isinstance(raw_board, dict):
|
|
3832
|
+
add(raw_board.get("slug"))
|
|
3833
|
+
add(raw_board.get("id"))
|
|
3834
|
+
add(raw_board.get("name"))
|
|
3835
|
+
add(raw_board.get("display_name"))
|
|
3836
|
+
else:
|
|
3837
|
+
add(raw_board)
|
|
3838
|
+
for key in (
|
|
3839
|
+
"selected_board_slug",
|
|
3840
|
+
"canonical_backlog_board_id",
|
|
3841
|
+
"current_browser_board_id",
|
|
3842
|
+
"active_proof_board_id",
|
|
3843
|
+
"recover_board_id",
|
|
3844
|
+
):
|
|
3845
|
+
add(truth.get(key))
|
|
3846
|
+
return slugs
|
|
3847
|
+
|
|
3848
|
+
|
|
3849
|
+
def _project_os_repo_matches_board(repo_root: Path, board_slug: str | None) -> bool:
|
|
3850
|
+
slug = str(board_slug or "").strip()
|
|
3851
|
+
return bool(slug and slug in _project_os_truth_board_slugs(repo_root))
|
|
3852
|
+
|
|
3853
|
+
|
|
3854
|
+
def _project_os_candidate_repo_roots(workspace_root: Path | None) -> list[Path]:
|
|
3855
|
+
candidates: list[Path] = []
|
|
3856
|
+
seen: set[str] = set()
|
|
3857
|
+
|
|
3858
|
+
def add(path: Path | None) -> None:
|
|
3859
|
+
if path is None:
|
|
3860
|
+
return
|
|
3861
|
+
try:
|
|
3862
|
+
resolved = path.expanduser().resolve()
|
|
3863
|
+
except Exception:
|
|
3864
|
+
return
|
|
3865
|
+
if not resolved.is_dir():
|
|
3866
|
+
return
|
|
3867
|
+
key = str(resolved)
|
|
3868
|
+
if key not in seen:
|
|
3869
|
+
seen.add(key)
|
|
3870
|
+
candidates.append(resolved)
|
|
3871
|
+
|
|
3872
|
+
add(workspace_root)
|
|
3873
|
+
|
|
3874
|
+
scan_root = None
|
|
3875
|
+
if workspace_root is not None:
|
|
3876
|
+
try:
|
|
3877
|
+
scan_root = workspace_root.expanduser().resolve()
|
|
3878
|
+
except Exception:
|
|
3879
|
+
scan_root = None
|
|
3880
|
+
if not scan_root or not scan_root.is_dir():
|
|
3881
|
+
return candidates
|
|
3882
|
+
|
|
3883
|
+
skip_names = {
|
|
3884
|
+
".git",
|
|
3885
|
+
".hg",
|
|
3886
|
+
".svn",
|
|
3887
|
+
".venv",
|
|
3888
|
+
"__pycache__",
|
|
3889
|
+
"node_modules",
|
|
3890
|
+
"vendor",
|
|
3891
|
+
"dist",
|
|
3892
|
+
"build",
|
|
3893
|
+
}
|
|
3894
|
+
queue_dirs: list[tuple[Path, int]] = [(scan_root, 0)]
|
|
3895
|
+
inspected = 0
|
|
3896
|
+
while queue_dirs and inspected < 300:
|
|
3897
|
+
current, depth = queue_dirs.pop(0)
|
|
3898
|
+
inspected += 1
|
|
3899
|
+
if (current / ".ax").is_dir() or (current / "docs" / "project-os").is_dir():
|
|
3900
|
+
add(current)
|
|
3901
|
+
if depth >= 3:
|
|
3902
|
+
continue
|
|
3903
|
+
try:
|
|
3904
|
+
children = sorted(
|
|
3905
|
+
(child for child in current.iterdir() if child.is_dir()),
|
|
3906
|
+
key=lambda child: child.name,
|
|
3907
|
+
)
|
|
3908
|
+
except Exception:
|
|
3909
|
+
continue
|
|
3910
|
+
for child in children:
|
|
3911
|
+
name = child.name
|
|
3912
|
+
if name in skip_names or (name.startswith(".") and name != ".ax"):
|
|
3913
|
+
continue
|
|
3914
|
+
queue_dirs.append((child, depth + 1))
|
|
3915
|
+
add(Path.cwd())
|
|
3916
|
+
return candidates
|
|
3917
|
+
|
|
3918
|
+
|
|
3919
|
+
def _project_os_resolve_repo_root_for_board(repo_root: Path | None, board_slug: str | None) -> Path | None:
|
|
3920
|
+
slug = str(board_slug or "").strip()
|
|
3921
|
+
if not slug:
|
|
3922
|
+
return repo_root if repo_root and repo_root.exists() else None
|
|
3923
|
+
if repo_root and repo_root.exists() and _project_os_repo_matches_board(repo_root, slug):
|
|
3924
|
+
return repo_root
|
|
3925
|
+
for candidate in _project_os_candidate_repo_roots(repo_root):
|
|
3926
|
+
if _project_os_repo_matches_board(candidate, slug):
|
|
3927
|
+
return candidate
|
|
3928
|
+
return repo_root if repo_root and repo_root.exists() else None
|
|
3929
|
+
|
|
3930
|
+
|
|
3931
|
+
def _project_os_goal_summary(project_md: dict | None, handoff: dict | None, status_md: dict | None, board_name: str | None = None, board_desc: str | None = None) -> str:
|
|
3932
|
+
project_text = str((project_md or {}).get("content") or "")
|
|
3933
|
+
for line in project_text.splitlines():
|
|
3934
|
+
text = line.strip().lstrip("- ").strip()
|
|
3935
|
+
if not text or text.startswith("#"):
|
|
3936
|
+
continue
|
|
3937
|
+
return text[:220]
|
|
3938
|
+
handoff_summary = str((handoff or {}).get("goal_summary") or "").strip()
|
|
3939
|
+
if handoff_summary:
|
|
3940
|
+
return handoff_summary[:220]
|
|
3941
|
+
desc = str(board_desc or "").strip()
|
|
3942
|
+
if desc:
|
|
3943
|
+
return desc[:220]
|
|
3944
|
+
status_text = str((status_md or {}).get("content") or "")
|
|
3945
|
+
for line in status_text.splitlines():
|
|
3946
|
+
text = line.strip().lstrip("- ").strip()
|
|
3947
|
+
if not text or text.startswith("#"):
|
|
3948
|
+
continue
|
|
3949
|
+
return text[:220]
|
|
3950
|
+
return str(board_name or "Project OS").strip()[:220]
|
|
3951
|
+
|
|
3952
|
+
|
|
3953
|
+
def _project_os_onboarding_context(repo_root: Path, project_md: dict | None, plan_md: dict | None, status_md: dict | None) -> dict:
|
|
3954
|
+
project_text = str((project_md or {}).get("content") or "")
|
|
3955
|
+
plan_text = str((plan_md or {}).get("content") or "")
|
|
3956
|
+
status_text = str((status_md or {}).get("content") or "")
|
|
3957
|
+
merged = "\n".join([project_text, plan_text, status_text])
|
|
3958
|
+
is_non_git_workspace = not (repo_root / ".git").exists()
|
|
3959
|
+
has_boundary_hold = "TO_BE_VALIDATED_BY_HERMES" in merged
|
|
3960
|
+
child_repo_blocked = (
|
|
3961
|
+
"auto-promoted" in merged
|
|
3962
|
+
or "auto-adopted" in merged
|
|
3963
|
+
or "auto-adoption | `금지`" in merged
|
|
3964
|
+
or "자동 승격 금지" in merged
|
|
3965
|
+
or "canonical repo continuity로 승격하지 않습니다" in merged
|
|
3966
|
+
)
|
|
3967
|
+
workspace_root_confirmed = str(repo_root) in merged
|
|
3968
|
+
if not (is_non_git_workspace and (project_text or plan_text or status_text)):
|
|
3969
|
+
return {
|
|
3970
|
+
"active": False,
|
|
3971
|
+
"doc_source": "project-os",
|
|
3972
|
+
}
|
|
3973
|
+
status_label = "보류(안전)" if has_boundary_hold else "확인됨"
|
|
3974
|
+
summary = "workspace root onboarding 진행 중 · 저장소 경계는 아직 미확정이며 자동 승격은 금지됩니다."
|
|
3975
|
+
next_safe_action = "workspace-root 기준으로 경계만 좁게 검증"
|
|
3976
|
+
if has_boundary_hold:
|
|
3977
|
+
summary = "workspace root onboarding 진행 중 · 저장소 경계는 아직 미확정이며 TO_BE_VALIDATED_BY_HERMES 상태를 유지합니다."
|
|
3978
|
+
return {
|
|
3979
|
+
"active": True,
|
|
3980
|
+
"doc_source": "root",
|
|
3981
|
+
"status_label": status_label,
|
|
3982
|
+
"summary": summary,
|
|
3983
|
+
"next_safe_action": next_safe_action,
|
|
3984
|
+
"workspace_root_confirmed": workspace_root_confirmed,
|
|
3985
|
+
"repo_boundary_status": "TO_BE_VALIDATED_BY_HERMES" if has_boundary_hold else "confirmed",
|
|
3986
|
+
"child_repo_auto_promotion_blocked": bool(child_repo_blocked),
|
|
3987
|
+
"guardrails": [
|
|
3988
|
+
"workspace root 확인됨" if workspace_root_confirmed else "workspace root 확인 필요",
|
|
3989
|
+
"child repo 자동 승격 금지 유지" if child_repo_blocked else "child repo guardrail 확인 필요",
|
|
3990
|
+
"repo boundary 미확정 유지" if has_boundary_hold else "repo boundary confirmed",
|
|
3991
|
+
],
|
|
3992
|
+
}
|
|
3993
|
+
|
|
3994
|
+
|
|
3995
|
+
def _handle_project_os_dashboard(handler, parsed) -> bool:
|
|
3996
|
+
qs = parse_qs(parsed.query or "")
|
|
3997
|
+
requested_board = str((qs.get("board") or [""])[0] or "").strip()
|
|
3998
|
+
workspace_raw = str(get_last_workspace() or "").strip()
|
|
3999
|
+
repo_root = Path(workspace_raw).expanduser() if workspace_raw else None
|
|
4000
|
+
selected_board_meta = None
|
|
4001
|
+
if requested_board:
|
|
4002
|
+
try:
|
|
4003
|
+
from api.kanban_bridge import _kb, _board_meta_dict
|
|
4004
|
+
kb = _kb()
|
|
4005
|
+
for meta in kb.list_boards(include_archived=True) or []:
|
|
4006
|
+
board = _board_meta_dict(meta)
|
|
4007
|
+
if str(board.get("slug") or "") == requested_board:
|
|
4008
|
+
selected_board_meta = board
|
|
4009
|
+
workdir = str(board.get("default_workdir") or "").strip()
|
|
4010
|
+
if workdir:
|
|
4011
|
+
candidate = Path(workdir).expanduser()
|
|
4012
|
+
if candidate.exists():
|
|
4013
|
+
repo_root = candidate
|
|
4014
|
+
break
|
|
4015
|
+
except Exception:
|
|
4016
|
+
selected_board_meta = None
|
|
4017
|
+
repo_root = _project_os_resolve_repo_root_for_board(repo_root, requested_board)
|
|
4018
|
+
if not repo_root or not repo_root.exists():
|
|
4019
|
+
j(handler, {
|
|
4020
|
+
"workspace": None,
|
|
4021
|
+
"repo_root": None,
|
|
4022
|
+
"git": None,
|
|
4023
|
+
"docs": {},
|
|
4024
|
+
"handoff": None,
|
|
4025
|
+
"active": None,
|
|
4026
|
+
"heartbeat": None,
|
|
4027
|
+
"goal_summary": "",
|
|
4028
|
+
})
|
|
4029
|
+
return True
|
|
4030
|
+
|
|
4031
|
+
handoff = _project_os_workspace_json(repo_root, ".ax/handoff/current.json")
|
|
4032
|
+
active = _project_os_workspace_json(repo_root, ".ax/status/active.json")
|
|
4033
|
+
heartbeat = _project_os_workspace_json(repo_root, ".ax/status/heartbeat.json")
|
|
4034
|
+
project_md = _project_os_workspace_read(repo_root, "docs/project-os/PROJECT.md")
|
|
4035
|
+
plan_md = _project_os_workspace_read(repo_root, "docs/project-os/PLAN.md")
|
|
4036
|
+
status_md = _project_os_workspace_read(repo_root, "docs/project-os/STATUS.md")
|
|
4037
|
+
blocker_md = _project_os_workspace_read(repo_root, "docs/project-os/BLOCKER-RESOLVER.md")
|
|
4038
|
+
root_project_md = _project_os_workspace_read(repo_root, "PROJECT.md")
|
|
4039
|
+
root_plan_md = _project_os_workspace_read(repo_root, "PLAN.md")
|
|
4040
|
+
root_status_md = _project_os_workspace_read(repo_root, "STATUS.md")
|
|
4041
|
+
onboarding = _project_os_onboarding_context(repo_root, root_project_md, root_plan_md, root_status_md)
|
|
4042
|
+
if onboarding.get("active"):
|
|
4043
|
+
project_md = root_project_md or project_md
|
|
4044
|
+
plan_md = root_plan_md or plan_md
|
|
4045
|
+
status_md = root_status_md or status_md
|
|
4046
|
+
|
|
4047
|
+
if isinstance(active, dict):
|
|
4048
|
+
original_repo_root = repo_root
|
|
4049
|
+
active_repo_root = str(active.get("repo_root") or "").strip()
|
|
4050
|
+
if active_repo_root:
|
|
4051
|
+
candidate = Path(active_repo_root).expanduser()
|
|
4052
|
+
if candidate.exists():
|
|
4053
|
+
try:
|
|
4054
|
+
repo_root = candidate.resolve()
|
|
4055
|
+
except Exception:
|
|
4056
|
+
repo_root = candidate
|
|
4057
|
+
if repo_root != original_repo_root:
|
|
4058
|
+
handoff = _project_os_workspace_json(repo_root, ".ax/handoff/current.json")
|
|
4059
|
+
active = _project_os_workspace_json(repo_root, ".ax/status/active.json")
|
|
4060
|
+
heartbeat = _project_os_workspace_json(repo_root, ".ax/status/heartbeat.json")
|
|
4061
|
+
project_md = _project_os_workspace_read(repo_root, "docs/project-os/PROJECT.md")
|
|
4062
|
+
plan_md = _project_os_workspace_read(repo_root, "docs/project-os/PLAN.md")
|
|
4063
|
+
status_md = _project_os_workspace_read(repo_root, "docs/project-os/STATUS.md")
|
|
4064
|
+
blocker_md = _project_os_workspace_read(repo_root, "docs/project-os/BLOCKER-RESOLVER.md")
|
|
4065
|
+
root_project_md = _project_os_workspace_read(repo_root, "PROJECT.md")
|
|
4066
|
+
root_plan_md = _project_os_workspace_read(repo_root, "PLAN.md")
|
|
4067
|
+
root_status_md = _project_os_workspace_read(repo_root, "STATUS.md")
|
|
4068
|
+
onboarding = _project_os_onboarding_context(repo_root, root_project_md, root_plan_md, root_status_md)
|
|
4069
|
+
if onboarding.get("active"):
|
|
4070
|
+
project_md = root_project_md or project_md
|
|
4071
|
+
plan_md = root_plan_md or plan_md
|
|
4072
|
+
status_md = root_status_md or status_md
|
|
4073
|
+
|
|
4074
|
+
try:
|
|
4075
|
+
git = git_info_for_workspace(repo_root)
|
|
4076
|
+
except Exception:
|
|
4077
|
+
git = None
|
|
4078
|
+
|
|
4079
|
+
board_name = None
|
|
4080
|
+
board_desc = None
|
|
4081
|
+
if isinstance(handoff, dict):
|
|
4082
|
+
board_dict: dict = {}
|
|
4083
|
+
raw_board = handoff.get("board")
|
|
4084
|
+
if isinstance(raw_board, dict):
|
|
4085
|
+
board_dict = raw_board
|
|
4086
|
+
board_name = board_dict.get("display_name") or board_dict.get("name") or board_dict.get("slug")
|
|
4087
|
+
board_desc = handoff.get("goal_summary") or board_dict.get("repo_corroboration")
|
|
4088
|
+
if selected_board_meta:
|
|
4089
|
+
board_name = board_name or selected_board_meta.get("name") or selected_board_meta.get("slug")
|
|
4090
|
+
board_desc = board_desc or selected_board_meta.get("description")
|
|
4091
|
+
|
|
4092
|
+
j(handler, {
|
|
4093
|
+
"workspace": str(repo_root),
|
|
4094
|
+
"repo_root": str(repo_root),
|
|
4095
|
+
"selected_board_slug": requested_board or (selected_board_meta or {}).get("slug"),
|
|
4096
|
+
"git": git,
|
|
4097
|
+
"docs": {
|
|
4098
|
+
"project": project_md,
|
|
4099
|
+
"plan": plan_md,
|
|
4100
|
+
"status": status_md,
|
|
4101
|
+
"blocker_resolver": blocker_md,
|
|
4102
|
+
},
|
|
4103
|
+
"handoff": handoff,
|
|
4104
|
+
"active": active,
|
|
4105
|
+
"heartbeat": heartbeat,
|
|
4106
|
+
"onboarding": onboarding,
|
|
4107
|
+
"goal_summary": _project_os_goal_summary(project_md, handoff, status_md, board_name, board_desc),
|
|
4108
|
+
})
|
|
4109
|
+
return True
|
|
4110
|
+
|
|
4111
|
+
|
|
3747
4112
|
# ── GET routes ────────────────────────────────────────────────────────────────
|
|
3748
4113
|
|
|
3749
4114
|
|
|
@@ -4012,6 +4377,8 @@ def _plugin_visibility_payload(manager=None) -> dict:
|
|
|
4012
4377
|
manager.discover_and_load(force=False)
|
|
4013
4378
|
|
|
4014
4379
|
plugins = []
|
|
4380
|
+
|
|
4381
|
+
# Hermes Agent lifecycle-hook plugins
|
|
4015
4382
|
raw_plugins = getattr(manager, "_plugins", {}) or {}
|
|
4016
4383
|
for key, loaded in sorted(raw_plugins.items(), key=lambda item: str(item[0])):
|
|
4017
4384
|
manifest = getattr(loaded, "manifest", None)
|
|
@@ -4059,9 +4426,41 @@ def _plugin_visibility_payload(manager=None) -> dict:
|
|
|
4059
4426
|
}
|
|
4060
4427
|
|
|
4061
4428
|
|
|
4429
|
+
# WebUI dashboard plugins (from manifest.json discovery)
|
|
4430
|
+
def _dashboard_plugin_enabled(plugin_name: str) -> bool:
|
|
4431
|
+
"""True if a dashboard plugin is enabled in settings.
|
|
4432
|
+
|
|
4433
|
+
Dashboard plugins are opt-in (default off). Enforced server-side so a
|
|
4434
|
+
disabled plugin's page + asset URLs are fully 404'd, not merely hidden in
|
|
4435
|
+
the Settings UI.
|
|
4436
|
+
"""
|
|
4437
|
+
try:
|
|
4438
|
+
from api.config import load_settings
|
|
4439
|
+
prefs = (load_settings() or {}).get("dashboard_plugins", {}) or {}
|
|
4440
|
+
return bool(prefs.get(plugin_name, False))
|
|
4441
|
+
except Exception:
|
|
4442
|
+
return False
|
|
4443
|
+
|
|
4444
|
+
|
|
4445
|
+
def _webui_plugin_payload() -> list[dict]:
|
|
4446
|
+
try:
|
|
4447
|
+
from api.plugins import get_plugin_metadata
|
|
4448
|
+
return get_plugin_metadata()
|
|
4449
|
+
except Exception:
|
|
4450
|
+
return []
|
|
4451
|
+
|
|
4452
|
+
|
|
4062
4453
|
def _handle_plugins(handler, parsed) -> bool:
|
|
4063
4454
|
try:
|
|
4064
|
-
|
|
4455
|
+
hermes_plugins = _plugin_visibility_payload()
|
|
4456
|
+
webui = _webui_plugin_payload()
|
|
4457
|
+
all_plugins = hermes_plugins["plugins"] + webui
|
|
4458
|
+
return j(handler, {
|
|
4459
|
+
"plugins": all_plugins,
|
|
4460
|
+
"empty": not bool(all_plugins),
|
|
4461
|
+
"supported_hooks": hermes_plugins["supported_hooks"],
|
|
4462
|
+
"read_only": True,
|
|
4463
|
+
})
|
|
4065
4464
|
except Exception as exc:
|
|
4066
4465
|
logger.warning("Failed to build plugin visibility payload: %s", exc)
|
|
4067
4466
|
return j(
|
|
@@ -4318,6 +4717,8 @@ def handle_get(handler, parsed) -> bool:
|
|
|
4318
4717
|
# ── Insights / knowledge status ──
|
|
4319
4718
|
if parsed.path == "/api/insights":
|
|
4320
4719
|
return _handle_insights(handler, parsed)
|
|
4720
|
+
if parsed.path == "/api/project-os/dashboard":
|
|
4721
|
+
return _handle_project_os_dashboard(handler, parsed)
|
|
4321
4722
|
|
|
4322
4723
|
if parsed.path.startswith("/api/kanban/"):
|
|
4323
4724
|
from api.kanban_bridge import handle_kanban_get
|
|
@@ -4678,6 +5079,14 @@ def handle_get(handler, parsed) -> bool:
|
|
|
4678
5079
|
journal,
|
|
4679
5080
|
active=bool(getattr(s, "active_stream_id", None)),
|
|
4680
5081
|
)
|
|
5082
|
+
# Cold-load: derive the latest settled todo snapshot from the full
|
|
5083
|
+
# merged transcript, not the truncated display window. This keeps
|
|
5084
|
+
# the Todos panel correct after refresh even when the latest todo
|
|
5085
|
+
# tool result is outside msg_limit, and treats an explicit empty
|
|
5086
|
+
# todo list as the current state instead of falling through to an
|
|
5087
|
+
# older non-empty write.
|
|
5088
|
+
if load_messages and _all_msgs:
|
|
5089
|
+
attach_todo_state(raw, _all_msgs)
|
|
4681
5090
|
if _merged_last_message_at:
|
|
4682
5091
|
raw["last_message_at"] = max(
|
|
4683
5092
|
float(raw.get("last_message_at") or 0),
|
|
@@ -4753,6 +5162,7 @@ def handle_get(handler, parsed) -> bool:
|
|
|
4753
5162
|
"messages": msgs,
|
|
4754
5163
|
"tool_calls": [],
|
|
4755
5164
|
}
|
|
5165
|
+
attach_todo_state(sess, msgs)
|
|
4756
5166
|
sess = _merge_cli_sidebar_metadata(sess, cli_meta)
|
|
4757
5167
|
return j(handler, {"session": redact_session_data(sess)})
|
|
4758
5168
|
return bad(handler, "Session not found", 404)
|
|
@@ -4822,6 +5232,37 @@ def handle_get(handler, parsed) -> bool:
|
|
|
4822
5232
|
cli = get_cli_sessions()
|
|
4823
5233
|
diag.stage("merge_cli_sessions")
|
|
4824
5234
|
cli_by_id = {s["session_id"]: s for s in cli}
|
|
5235
|
+
# #3238: reconcile orphaned imported-CLI sidecars. When a CLI
|
|
5236
|
+
# session was clicked in WebUI it gets a WebUI-owned sidecar that
|
|
5237
|
+
# all_sessions() returns independently of state.db. If the user
|
|
5238
|
+
# later deletes the backing CLI session from the command line,
|
|
5239
|
+
# the sidecar is never pruned and the stale row lingers in the
|
|
5240
|
+
# sidebar forever (there is no WebUI delete affordance for it).
|
|
5241
|
+
# Drop rows whose backing agent row is genuinely gone. We probe
|
|
5242
|
+
# state.db directly (agent_session_row_exists) rather than trust
|
|
5243
|
+
# cli_by_id absence, because get_cli_sessions() caps at
|
|
5244
|
+
# CLI_VISIBLE_SESSION_LIMIT (20) — an existing session can fall
|
|
5245
|
+
# out of that window and look deleted. Native WebUI sessions
|
|
5246
|
+
# (source == "webui") that merely have a CLI ancestor are never
|
|
5247
|
+
# pruned.
|
|
5248
|
+
_kept_after_orphan_prune = []
|
|
5249
|
+
for s in webui_sessions:
|
|
5250
|
+
_sid = s.get("session_id")
|
|
5251
|
+
if (
|
|
5252
|
+
_sid
|
|
5253
|
+
and is_cli_session_row(s)
|
|
5254
|
+
and not _session_source_is_webui(s)
|
|
5255
|
+
and _sid not in cli_by_id
|
|
5256
|
+
and not agent_session_row_exists(_sid, profile=s.get("profile"))
|
|
5257
|
+
):
|
|
5258
|
+
try:
|
|
5259
|
+
prune_session_from_index(_sid)
|
|
5260
|
+
except Exception:
|
|
5261
|
+
logger.debug("Failed to prune orphaned CLI sidecar %s", _sid, exc_info=True)
|
|
5262
|
+
diag.stage("prune_orphaned_cli_sidecar")
|
|
5263
|
+
continue
|
|
5264
|
+
_kept_after_orphan_prune.append(s)
|
|
5265
|
+
webui_sessions = _kept_after_orphan_prune
|
|
4825
5266
|
for s in webui_sessions:
|
|
4826
5267
|
meta = cli_by_id.get(s.get("session_id"))
|
|
4827
5268
|
if not meta:
|
|
@@ -5421,6 +5862,130 @@ def handle_get(handler, parsed) -> bool:
|
|
|
5421
5862
|
logger.exception("rollback/diff failed")
|
|
5422
5863
|
return bad(handler, str(e), status=500)
|
|
5423
5864
|
|
|
5865
|
+
# ── Plugin shared assets (e.g. /plugins/plugin.css) ──
|
|
5866
|
+
# Restricted to shared plugin assets only — no cross-plugin file access.
|
|
5867
|
+
if parsed.path.startswith("/plugins/"):
|
|
5868
|
+
from api.plugins import _get_plugin_base
|
|
5869
|
+
plugin_base = _get_plugin_base()
|
|
5870
|
+
rel = parsed.path[len("/plugins/"):]
|
|
5871
|
+
allowed = {"plugin.css"}
|
|
5872
|
+
if rel not in allowed:
|
|
5873
|
+
return False # 404
|
|
5874
|
+
safe = (plugin_base / rel).resolve()
|
|
5875
|
+
try:
|
|
5876
|
+
safe.relative_to(plugin_base.resolve())
|
|
5877
|
+
except ValueError:
|
|
5878
|
+
return False # path traversal — 404
|
|
5879
|
+
if safe.is_file():
|
|
5880
|
+
import os as _os
|
|
5881
|
+
data = safe.read_bytes()
|
|
5882
|
+
ext = _os.path.splitext(rel.lower())[1]
|
|
5883
|
+
ct = {
|
|
5884
|
+
".css": "text/css; charset=utf-8",
|
|
5885
|
+
".js": "application/javascript; charset=utf-8",
|
|
5886
|
+
".json": "application/json; charset=utf-8",
|
|
5887
|
+
".png": "image/png",
|
|
5888
|
+
".svg": "image/svg+xml",
|
|
5889
|
+
}.get(ext, "application/octet-stream")
|
|
5890
|
+
handler.send_response(200)
|
|
5891
|
+
handler.send_header("Content-Type", ct)
|
|
5892
|
+
handler.send_header("Content-Length", str(len(data)))
|
|
5893
|
+
handler.end_headers()
|
|
5894
|
+
handler.wfile.write(data)
|
|
5895
|
+
return True
|
|
5896
|
+
|
|
5897
|
+
# ── Plugin static assets ──
|
|
5898
|
+
if parsed.path.startswith("/dashboard-plugins/"):
|
|
5899
|
+
parts = parsed.path.split("/", 3)
|
|
5900
|
+
if len(parts) >= 3:
|
|
5901
|
+
plugin_name = parts[2]
|
|
5902
|
+
rel_path = parts[3] if len(parts) > 3 else ""
|
|
5903
|
+
# Server-side enable-gate: a plugin disabled in Settings must have its
|
|
5904
|
+
# entire URL surface shut off, not merely hidden in the UI.
|
|
5905
|
+
if not _dashboard_plugin_enabled(plugin_name):
|
|
5906
|
+
return False # 404 — disabled plugins serve nothing
|
|
5907
|
+
from api.plugins import serve_plugin_static
|
|
5908
|
+
result = serve_plugin_static(plugin_name, rel_path)
|
|
5909
|
+
if result:
|
|
5910
|
+
data, content_type = result
|
|
5911
|
+
handler.send_response(200)
|
|
5912
|
+
handler.send_header("Content-Type", content_type)
|
|
5913
|
+
# Defense-in-depth: plugin-controlled assets are served from the
|
|
5914
|
+
# WebUI's own origin. Sandbox them (null origin) so a plugin's
|
|
5915
|
+
# .html/.svg can't run privileged same-origin script if navigated
|
|
5916
|
+
# to directly (the in-panel iframe sandbox doesn't cover direct
|
|
5917
|
+
# navigation). nosniff prevents content-type confusion.
|
|
5918
|
+
handler.send_header("Content-Security-Policy", "sandbox allow-scripts allow-forms allow-popups")
|
|
5919
|
+
handler.send_header("X-Content-Type-Options", "nosniff")
|
|
5920
|
+
handler.send_header("Content-Length", str(len(data)))
|
|
5921
|
+
handler.end_headers()
|
|
5922
|
+
handler.wfile.write(data)
|
|
5923
|
+
return True
|
|
5924
|
+
|
|
5925
|
+
# ── Plugin pages (HTML shell) ──
|
|
5926
|
+
from api.plugins import PLUGIN_MANIFESTS, _PLUGIN_STATIC_ROOTS
|
|
5927
|
+
for name, manifest in PLUGIN_MANIFESTS.items():
|
|
5928
|
+
tab = manifest.get("tab", {})
|
|
5929
|
+
tab_path = tab.get("path", f"/{name}")
|
|
5930
|
+
if parsed.path == tab_path:
|
|
5931
|
+
# Server-side enable-gate (opt-in): a disabled plugin's page 404s.
|
|
5932
|
+
if not _dashboard_plugin_enabled(name):
|
|
5933
|
+
return False
|
|
5934
|
+
dashboard_dir = _PLUGIN_STATIC_ROOTS.get(name)
|
|
5935
|
+
if dashboard_dir:
|
|
5936
|
+
# 1) dashboard/dist/index.html (full SPA build)
|
|
5937
|
+
index_html = dashboard_dir / "dist" / "index.html"
|
|
5938
|
+
if index_html.is_file():
|
|
5939
|
+
data = index_html.read_bytes()
|
|
5940
|
+
handler.send_response(200)
|
|
5941
|
+
handler.send_header("Content-Type", "text/html; charset=utf-8")
|
|
5942
|
+
handler.send_header("Content-Security-Policy", "sandbox allow-scripts allow-forms allow-popups")
|
|
5943
|
+
handler.send_header("Content-Length", str(len(data)))
|
|
5944
|
+
handler.end_headers()
|
|
5945
|
+
handler.wfile.write(data)
|
|
5946
|
+
return True
|
|
5947
|
+
# 2) static/index.html in plugin root (content page for IIFE loader)
|
|
5948
|
+
plugin_root = dashboard_dir.parent
|
|
5949
|
+
static_html = plugin_root / "static" / "index.html"
|
|
5950
|
+
if static_html.is_file():
|
|
5951
|
+
data = static_html.read_bytes()
|
|
5952
|
+
handler.send_response(200)
|
|
5953
|
+
handler.send_header("Content-Type", "text/html; charset=utf-8")
|
|
5954
|
+
handler.send_header("Content-Security-Policy", "sandbox allow-scripts allow-forms allow-popups")
|
|
5955
|
+
handler.send_header("Content-Length", str(len(data)))
|
|
5956
|
+
handler.end_headers()
|
|
5957
|
+
handler.wfile.write(data)
|
|
5958
|
+
return True
|
|
5959
|
+
# 3) Fallback: generate shell that loads the IIFE bundle
|
|
5960
|
+
index_js = dashboard_dir / "dist" / "index.js"
|
|
5961
|
+
if index_js.is_file():
|
|
5962
|
+
import html
|
|
5963
|
+
label = html.escape(manifest.get("label") or name)
|
|
5964
|
+
css = html.escape(manifest.get("css", ""))
|
|
5965
|
+
name_escaped = html.escape(name)
|
|
5966
|
+
css_tag = f'<link rel="stylesheet" href="/dashboard-plugins/{name_escaped}/{css}">' if css else ""
|
|
5967
|
+
html_content = (
|
|
5968
|
+
f"<!doctype html>\n"
|
|
5969
|
+
f"<html lang=\"en\">\n"
|
|
5970
|
+
f"<head>\n"
|
|
5971
|
+
f" <meta charset=\"utf-8\">\n"
|
|
5972
|
+
f" <title>{label}</title>\n"
|
|
5973
|
+
f" {css_tag}\n"
|
|
5974
|
+
f"</head>\n"
|
|
5975
|
+
f"<body>\n"
|
|
5976
|
+
f' <div id="pluginPageContainer"></div>\n'
|
|
5977
|
+
f' <script src="/dashboard-plugins/{name_escaped}/dist/index.js"></script>\n'
|
|
5978
|
+
f"</body>\n"
|
|
5979
|
+
f"</html>\n"
|
|
5980
|
+
).encode("utf-8")
|
|
5981
|
+
handler.send_response(200)
|
|
5982
|
+
handler.send_header("Content-Type", "text/html; charset=utf-8")
|
|
5983
|
+
handler.send_header("Content-Security-Policy", "sandbox allow-scripts allow-forms allow-popups")
|
|
5984
|
+
handler.send_header("Content-Length", str(len(html_content)))
|
|
5985
|
+
handler.end_headers()
|
|
5986
|
+
handler.wfile.write(html_content)
|
|
5987
|
+
return True
|
|
5988
|
+
|
|
5424
5989
|
return False # 404
|
|
5425
5990
|
|
|
5426
5991
|
|
|
@@ -5457,10 +6022,15 @@ def handle_post(handler, parsed) -> bool:
|
|
|
5457
6022
|
return handle_upload(handler)
|
|
5458
6023
|
if parsed.path == "/api/upload/extract":
|
|
5459
6024
|
return handle_upload_extract(handler)
|
|
6025
|
+
if parsed.path == "/api/workspace/upload":
|
|
6026
|
+
return handle_workspace_upload(handler)
|
|
5460
6027
|
|
|
5461
6028
|
if parsed.path == "/api/transcribe":
|
|
5462
6029
|
return handle_transcribe(handler)
|
|
5463
6030
|
|
|
6031
|
+
if parsed.path == "/api/tts":
|
|
6032
|
+
return _handle_tts(handler, parsed)
|
|
6033
|
+
|
|
5464
6034
|
if parsed.path == "/api/client-events/log":
|
|
5465
6035
|
if diag:
|
|
5466
6036
|
diag.stage("read_client_event_body")
|
|
@@ -5737,6 +6307,27 @@ def handle_post(handler, parsed) -> bool:
|
|
|
5737
6307
|
if parsed.path == "/api/sessions/cleanup_zero_message":
|
|
5738
6308
|
return _handle_sessions_cleanup(handler, body, zero_only=True)
|
|
5739
6309
|
|
|
6310
|
+
def _sync_session_title_to_insights(session):
|
|
6311
|
+
"""Write title-only session metadata updates through to state.db when enabled."""
|
|
6312
|
+
try:
|
|
6313
|
+
if not load_settings().get("sync_to_insights"):
|
|
6314
|
+
return
|
|
6315
|
+
from api.state_sync import sync_session_usage
|
|
6316
|
+
|
|
6317
|
+
messages = getattr(session, "messages", None) or []
|
|
6318
|
+
sync_session_usage(
|
|
6319
|
+
session_id=session.session_id,
|
|
6320
|
+
input_tokens=getattr(session, "input_tokens", None) or 0,
|
|
6321
|
+
output_tokens=getattr(session, "output_tokens", None) or 0,
|
|
6322
|
+
estimated_cost=getattr(session, "estimated_cost", 0.0),
|
|
6323
|
+
model=getattr(session, "model", ""),
|
|
6324
|
+
title=session.title,
|
|
6325
|
+
message_count=len(messages),
|
|
6326
|
+
profile=getattr(session, "profile", None),
|
|
6327
|
+
)
|
|
6328
|
+
except Exception:
|
|
6329
|
+
logger.debug("Failed to update session title in state.db", exc_info=True)
|
|
6330
|
+
|
|
5740
6331
|
if parsed.path == "/api/session/rename":
|
|
5741
6332
|
try:
|
|
5742
6333
|
require(body, "session_id", "title")
|
|
@@ -5753,6 +6344,37 @@ def handle_post(handler, parsed) -> bool:
|
|
|
5753
6344
|
publish_session_list_changed("session_rename")
|
|
5754
6345
|
return j(handler, {"session": s.compact()})
|
|
5755
6346
|
|
|
6347
|
+
|
|
6348
|
+
if parsed.path == "/api/session/title/regenerate":
|
|
6349
|
+
try:
|
|
6350
|
+
require(body, "session_id")
|
|
6351
|
+
except ValueError as e:
|
|
6352
|
+
return bad(handler, str(e))
|
|
6353
|
+
sid = body["session_id"]
|
|
6354
|
+
prefer_latest = bool(body.get("prefer_latest", False))
|
|
6355
|
+
try:
|
|
6356
|
+
s = get_session(sid)
|
|
6357
|
+
s = _ensure_full_session_before_mutation(sid, s)
|
|
6358
|
+
except KeyError:
|
|
6359
|
+
return bad(handler, "Session not found", 404)
|
|
6360
|
+
if getattr(s, "read_only", False) or getattr(s, "is_imported", False):
|
|
6361
|
+
return bad(handler, "Read-only imported sessions cannot be renamed", 403)
|
|
6362
|
+
next_title, reason, raw_preview = generate_session_title_for_session(s, prefer_latest=prefer_latest)
|
|
6363
|
+
if not next_title:
|
|
6364
|
+
return bad(handler, f"Could not generate a better title ({reason or 'empty'})", 422)
|
|
6365
|
+
with _get_session_agent_lock(sid):
|
|
6366
|
+
s.title = str(next_title).strip()[:80] or "Untitled"
|
|
6367
|
+
s.llm_title_generated = True
|
|
6368
|
+
s.save(touch_updated_at=False)
|
|
6369
|
+
_sync_session_title_to_insights(s)
|
|
6370
|
+
publish_session_list_changed("session_title_regenerate")
|
|
6371
|
+
return j(handler, {
|
|
6372
|
+
"session": s.compact(),
|
|
6373
|
+
"title": s.title,
|
|
6374
|
+
"status": reason,
|
|
6375
|
+
"raw_preview": (raw_preview or "")[:240],
|
|
6376
|
+
})
|
|
6377
|
+
|
|
5756
6378
|
if parsed.path == "/api/personality/set":
|
|
5757
6379
|
try:
|
|
5758
6380
|
require(body, "session_id")
|
|
@@ -6057,13 +6679,28 @@ def handle_post(handler, parsed) -> bool:
|
|
|
6057
6679
|
return bad(handler, "Session not found", 404)
|
|
6058
6680
|
keep = int(body["keep_count"])
|
|
6059
6681
|
with _get_session_agent_lock(body["session_id"]):
|
|
6682
|
+
old_msg_count = len(s.messages or [])
|
|
6683
|
+
old_ctx_count = len(getattr(s, 'context_messages', None) or [])
|
|
6060
6684
|
s.messages = s.messages[:keep]
|
|
6685
|
+
# Truncate context_messages in sync with messages so the agent's
|
|
6686
|
+
# model-facing context doesn't retain rows the user removed via
|
|
6687
|
+
# Edit / Regenerate. Without this, context_messages still contains
|
|
6688
|
+
# the full pre-truncation history and the agent sees "deleted"
|
|
6689
|
+
# turns on the next turn (#2914).
|
|
6690
|
+
if isinstance(getattr(s, 'context_messages', None), list):
|
|
6691
|
+
s.context_messages = s.context_messages[:keep]
|
|
6061
6692
|
try:
|
|
6062
6693
|
from api.session_ops import _truncation_watermark_for
|
|
6063
6694
|
s.truncation_watermark = _truncation_watermark_for(s.messages)
|
|
6064
6695
|
except Exception:
|
|
6065
6696
|
s.truncation_watermark = 0.0
|
|
6066
6697
|
s.save()
|
|
6698
|
+
logger.info(
|
|
6699
|
+
"truncate %s: messages %d→%d, context_messages %d→%d, watermark=%.2f",
|
|
6700
|
+
body["session_id"], old_msg_count, len(s.messages or []),
|
|
6701
|
+
old_ctx_count, len(getattr(s, 'context_messages', None) or []),
|
|
6702
|
+
s.truncation_watermark or 0,
|
|
6703
|
+
)
|
|
6067
6704
|
return j(
|
|
6068
6705
|
handler, {"ok": True, "session": s.compact() | {"messages": s.messages}}
|
|
6069
6706
|
)
|
|
@@ -6702,23 +7339,34 @@ def handle_post(handler, parsed) -> bool:
|
|
|
6702
7339
|
if pin_requested and not getattr(s, "pinned", False):
|
|
6703
7340
|
# Pre-snapshot from persisted index (acquires LOCK internally,
|
|
6704
7341
|
# so must run outside our own LOCK acquire below).
|
|
6705
|
-
|
|
6706
|
-
|
|
7342
|
+
persisted_rows = [
|
|
7343
|
+
existing for existing in all_sessions()
|
|
6707
7344
|
if _session_counts_toward_pin_quota(existing)
|
|
6708
|
-
|
|
7345
|
+
]
|
|
6709
7346
|
with LOCK:
|
|
6710
|
-
# Final authoritative count: merge persisted
|
|
6711
|
-
# in-memory SESSIONS snapshot.
|
|
6712
|
-
#
|
|
6713
|
-
#
|
|
6714
|
-
|
|
6715
|
-
|
|
6716
|
-
|
|
7347
|
+
# Final authoritative count: merge persisted pinned rows with the
|
|
7348
|
+
# in-memory SESSIONS snapshot. Count logical sidebar-visible pin
|
|
7349
|
+
# lineages rather than raw session rows so continuation siblings
|
|
7350
|
+
# in the same visible lineage do not consume extra pin quota.
|
|
7351
|
+
candidate_rows = list(persisted_rows)
|
|
7352
|
+
candidate_rows.extend(
|
|
7353
|
+
existing.compact() for existing in SESSIONS.values()
|
|
6717
7354
|
if _session_counts_toward_pin_quota(existing)
|
|
6718
7355
|
)
|
|
6719
|
-
|
|
7356
|
+
target_row = s.compact()
|
|
7357
|
+
candidate_rows.append(target_row)
|
|
7358
|
+
pinned_lineage_ids = _visible_pinned_lineage_ids(candidate_rows)
|
|
7359
|
+
target_lineage = _session_row_lineage_root_id(
|
|
7360
|
+
target_row,
|
|
7361
|
+
{
|
|
7362
|
+
str(_session_field(row, "session_id", "") or ""): row
|
|
7363
|
+
for row in candidate_rows
|
|
7364
|
+
if _session_field(row, "session_id", None)
|
|
7365
|
+
},
|
|
7366
|
+
)
|
|
7367
|
+
pinned_lineage_ids.discard(target_lineage)
|
|
6720
7368
|
pinned_sessions_limit = int(load_settings().get("pinned_sessions_limit", 3) or 3)
|
|
6721
|
-
if len(
|
|
7369
|
+
if len(pinned_lineage_ids) >= pinned_sessions_limit:
|
|
6722
7370
|
return bad(handler, f"Up to {pinned_sessions_limit} sessions can be pinned. Unpin one before pinning another.", 400)
|
|
6723
7371
|
# Mark in-memory pin state under LOCK so concurrent pin
|
|
6724
7372
|
# requests see the increment immediately, even before
|
|
@@ -6824,14 +7472,20 @@ def handle_post(handler, parsed) -> bool:
|
|
|
6824
7472
|
target_pid = body.get("project_id") or None
|
|
6825
7473
|
if target_pid:
|
|
6826
7474
|
from api.profiles import get_active_profile_name
|
|
6827
|
-
|
|
7475
|
+
# Use the session's own profile for authorization, not the global
|
|
7476
|
+
# active profile. A session belongs to a specific profile set at
|
|
7477
|
+
# creation; projects from that profile should always be assignable,
|
|
7478
|
+
# regardless of which profile is "active" at the process level.
|
|
7479
|
+
# Matches the same principle as the profile chip fix — prefer
|
|
7480
|
+
# session-scoped state over global active profile. (#3325 follow-up)
|
|
7481
|
+
_session_profile = getattr(s, 'profile', None) or get_active_profile_name()
|
|
6828
7482
|
target = next(
|
|
6829
7483
|
(p for p in load_projects() if p["project_id"] == target_pid),
|
|
6830
7484
|
None,
|
|
6831
7485
|
)
|
|
6832
7486
|
if not target:
|
|
6833
7487
|
return bad(handler, "Project not found", 404)
|
|
6834
|
-
if not _profiles_match(target.get("profile"),
|
|
7488
|
+
if not _profiles_match(target.get("profile"), _session_profile):
|
|
6835
7489
|
return bad(handler, "Project not found", 404)
|
|
6836
7490
|
with _get_session_agent_lock(body["session_id"]):
|
|
6837
7491
|
s.project_id = target_pid
|
|
@@ -6855,11 +7509,20 @@ def handle_post(handler, parsed) -> bool:
|
|
|
6855
7509
|
if color and not _re.match(r"^#[0-9a-fA-F]{3,8}$", color):
|
|
6856
7510
|
return bad(handler, "Invalid color format")
|
|
6857
7511
|
projects = load_projects()
|
|
7512
|
+
# #3331 follow-up (Codex+Opus gate): validate the optional client-supplied
|
|
7513
|
+
# `profile` before stamping it, mirroring /api/profile/switch — otherwise a
|
|
7514
|
+
# client could create a project tagged with an arbitrary/unknown profile,
|
|
7515
|
+
# producing hidden cross-profile rows that can't be managed normally.
|
|
7516
|
+
_requested_profile = str(body.get('profile') or "").strip()
|
|
7517
|
+
if _requested_profile and _requested_profile != "default":
|
|
7518
|
+
from api.profiles import _PROFILE_ID_RE
|
|
7519
|
+
if not _PROFILE_ID_RE.fullmatch(_requested_profile):
|
|
7520
|
+
return bad(handler, "invalid profile")
|
|
6858
7521
|
proj = {
|
|
6859
7522
|
"project_id": uuid.uuid4().hex[:12],
|
|
6860
7523
|
"name": name,
|
|
6861
7524
|
"color": color,
|
|
6862
|
-
"profile": get_active_profile_name() or 'default',
|
|
7525
|
+
"profile": _requested_profile or get_active_profile_name() or 'default',
|
|
6863
7526
|
"created_at": time.time(),
|
|
6864
7527
|
}
|
|
6865
7528
|
projects.append(proj)
|
|
@@ -8076,6 +8739,143 @@ def _serve_file_bytes(handler, target: Path, mime: str, disposition: str, cache_
|
|
|
8076
8739
|
return True
|
|
8077
8740
|
|
|
8078
8741
|
|
|
8742
|
+
|
|
8743
|
+
def _handle_tts(handler, parsed):
|
|
8744
|
+
"""Generate TTS audio via Edge TTS. POST JSON body only.
|
|
8745
|
+
|
|
8746
|
+
Design note addressing deep review blocker #4 (synchronous I/O):
|
|
8747
|
+
The server uses ThreadingHTTPServer (see server.py:173), so each request
|
|
8748
|
+
already runs in its own dedicated thread. A TTS request therefore occupies
|
|
8749
|
+
only its own thread during Microsoft network I/O + streaming; other clients
|
|
8750
|
+
are unaffected. Combined with early auth, a strict per-client 2 s rate
|
|
8751
|
+
limit, 5000-char cap, and voice allowlist, the blocking cost is bounded and
|
|
8752
|
+
intentional. Streaming chunks directly via stream_sync() keeps memory
|
|
8753
|
+
usage low. A cross-thread pool + queue would add complexity and wfile
|
|
8754
|
+
thread-safety issues with no practical gain under the current model.
|
|
8755
|
+
If the HTTP layer ever moves to asyncio we can adopt edge_tts's native
|
|
8756
|
+
async API at that time.
|
|
8757
|
+
"""
|
|
8758
|
+
text = ""
|
|
8759
|
+
voice = "zh-CN-XiaoxiaoNeural"
|
|
8760
|
+
rate_str = ""
|
|
8761
|
+
pitch_str = ""
|
|
8762
|
+
|
|
8763
|
+
if handler.command != "POST":
|
|
8764
|
+
from api.helpers import bad as _bad
|
|
8765
|
+
return _bad(handler, "POST required for /api/tts", 405)
|
|
8766
|
+
|
|
8767
|
+
try:
|
|
8768
|
+
data = read_body(handler)
|
|
8769
|
+
text = (data.get("text") or "").strip()
|
|
8770
|
+
voice = data.get("voice") or voice
|
|
8771
|
+
rate_str = data.get("rate") or ""
|
|
8772
|
+
pitch_str = data.get("pitch") or ""
|
|
8773
|
+
except Exception:
|
|
8774
|
+
from api.helpers import bad as _bad
|
|
8775
|
+
return _bad(handler, "invalid request body", 400)
|
|
8776
|
+
|
|
8777
|
+
if not text:
|
|
8778
|
+
from api.helpers import bad as _bad
|
|
8779
|
+
return _bad(handler, "text is required", 400)
|
|
8780
|
+
if len(text) > 5000:
|
|
8781
|
+
from api.helpers import bad as _bad
|
|
8782
|
+
return _bad(handler, "text too long (max 5000 characters)", 400)
|
|
8783
|
+
|
|
8784
|
+
from api.auth import is_auth_enabled, parse_cookie, verify_session
|
|
8785
|
+
cv = None
|
|
8786
|
+
if is_auth_enabled():
|
|
8787
|
+
cv = parse_cookie(handler)
|
|
8788
|
+
if not (cv and verify_session(cv)):
|
|
8789
|
+
from api.helpers import bad as _bad
|
|
8790
|
+
return _bad(handler, "unauthorized", 401)
|
|
8791
|
+
|
|
8792
|
+
# High-quality per-client rate limiting for TTS.
|
|
8793
|
+
if not hasattr(_handle_tts, "_tts_limiter"):
|
|
8794
|
+
import time as _time, threading as _threading
|
|
8795
|
+
class _TtsRateLimiter:
|
|
8796
|
+
def __init__(self, window_seconds=2.0, prune_interval=50):
|
|
8797
|
+
self.window = window_seconds
|
|
8798
|
+
self.prune_interval = prune_interval
|
|
8799
|
+
self._hits = {}
|
|
8800
|
+
self._lock = _threading.Lock()
|
|
8801
|
+
self._checks = 0
|
|
8802
|
+
|
|
8803
|
+
def _get_client_key(self, h):
|
|
8804
|
+
for hdr in ("X-Forwarded-For", "X-Real-IP", "Forwarded"):
|
|
8805
|
+
val = h.headers.get(hdr)
|
|
8806
|
+
if val:
|
|
8807
|
+
ip = val.split(",")[0].strip().split(";")[0].strip()
|
|
8808
|
+
if ip:
|
|
8809
|
+
return ip
|
|
8810
|
+
return getattr(h, "client_address", ("unknown",))[0]
|
|
8811
|
+
|
|
8812
|
+
def check(self, handler, session_cookie=None):
|
|
8813
|
+
key = self._get_client_key(handler)
|
|
8814
|
+
if session_cookie and "." in str(session_cookie):
|
|
8815
|
+
key = str(session_cookie).split(".", 1)[0]
|
|
8816
|
+
now = _time.time()
|
|
8817
|
+
with self._lock:
|
|
8818
|
+
self._checks += 1
|
|
8819
|
+
if self._checks % self.prune_interval == 0:
|
|
8820
|
+
cutoff = now - (self.window * 10)
|
|
8821
|
+
self._hits = {k: v for k, v in self._hits.items() if v > cutoff}
|
|
8822
|
+
last = self._hits.get(key, 0)
|
|
8823
|
+
if now - last < self.window:
|
|
8824
|
+
return False
|
|
8825
|
+
self._hits[key] = now
|
|
8826
|
+
return True
|
|
8827
|
+
|
|
8828
|
+
_handle_tts._tts_limiter = _TtsRateLimiter(window_seconds=2.0)
|
|
8829
|
+
|
|
8830
|
+
limiter = _handle_tts._tts_limiter
|
|
8831
|
+
if not limiter.check(handler, cv):
|
|
8832
|
+
logger.warning("TTS rate limit hit for client=%s", limiter._get_client_key(handler))
|
|
8833
|
+
from api.helpers import bad as _bad
|
|
8834
|
+
return _bad(handler, "rate limit exceeded — please wait", 429)
|
|
8835
|
+
|
|
8836
|
+
allowed = {
|
|
8837
|
+
"zh-CN-XiaoxiaoNeural", "zh-CN-XiaoyiNeural", "zh-CN-YunxiNeural",
|
|
8838
|
+
"zh-CN-YunjianNeural", "zh-CN-YunyangNeural",
|
|
8839
|
+
"en-US-AriaNeural", "en-US-GuyNeural"
|
|
8840
|
+
}
|
|
8841
|
+
if voice not in allowed:
|
|
8842
|
+
from api.helpers import bad as _bad
|
|
8843
|
+
return _bad(handler, "invalid voice", 400)
|
|
8844
|
+
|
|
8845
|
+
try:
|
|
8846
|
+
try:
|
|
8847
|
+
import edge_tts
|
|
8848
|
+
except ImportError:
|
|
8849
|
+
from api.helpers import bad as _bad
|
|
8850
|
+
return _bad(handler, "Edge TTS engine not installed on the server. Install it with: pip install edge-tts", 503)
|
|
8851
|
+
|
|
8852
|
+
kwargs = {}
|
|
8853
|
+
if rate_str:
|
|
8854
|
+
kwargs["rate"] = rate_str
|
|
8855
|
+
if pitch_str:
|
|
8856
|
+
kwargs["pitch"] = pitch_str
|
|
8857
|
+
|
|
8858
|
+
comm = edge_tts.Communicate(text, voice, **kwargs)
|
|
8859
|
+
|
|
8860
|
+
handler.send_response(200)
|
|
8861
|
+
handler.send_header("Content-Type", "audio/mpeg")
|
|
8862
|
+
handler.send_header("Cache-Control", "no-store")
|
|
8863
|
+
handler.end_headers()
|
|
8864
|
+
|
|
8865
|
+
for chunk in comm.stream_sync():
|
|
8866
|
+
if chunk.get("type") == "audio" and chunk.get("data"):
|
|
8867
|
+
try:
|
|
8868
|
+
handler.wfile.write(chunk["data"])
|
|
8869
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
8870
|
+
return True
|
|
8871
|
+
return True
|
|
8872
|
+
|
|
8873
|
+
except BrokenPipeError:
|
|
8874
|
+
return True
|
|
8875
|
+
except Exception:
|
|
8876
|
+
logger.exception("Edge TTS generation failed")
|
|
8877
|
+
from api.helpers import bad as _bad
|
|
8878
|
+
return _bad(handler, "TTS generation failed", 500)
|
|
8079
8879
|
def _html_preview_with_blank_base(raw: bytes) -> bytes:
|
|
8080
8880
|
base = '<base target="_blank">'
|
|
8081
8881
|
text = raw.decode("utf-8", errors="replace")
|
|
@@ -8579,7 +9379,7 @@ def _handle_folder_download(handler, parsed):
|
|
|
8579
9379
|
if not sid:
|
|
8580
9380
|
return bad(handler, "session_id is required")
|
|
8581
9381
|
try:
|
|
8582
|
-
s =
|
|
9382
|
+
s = get_session_for_file_ops(sid)
|
|
8583
9383
|
except KeyError:
|
|
8584
9384
|
return bad(handler, "Session not found", 404)
|
|
8585
9385
|
|
|
@@ -8652,7 +9452,7 @@ def _handle_file_raw(handler, parsed):
|
|
|
8652
9452
|
if not sid:
|
|
8653
9453
|
return bad(handler, "session_id is required")
|
|
8654
9454
|
try:
|
|
8655
|
-
s =
|
|
9455
|
+
s = get_session_for_file_ops(sid)
|
|
8656
9456
|
except KeyError:
|
|
8657
9457
|
return bad(handler, "Session not found", 404)
|
|
8658
9458
|
rel = qs.get("path", [""])[0]
|
|
@@ -8677,10 +9477,11 @@ def _handle_file_raw(handler, parsed):
|
|
|
8677
9477
|
# CSP sandbox directive applies the same isolation server-side: without
|
|
8678
9478
|
# allow-same-origin, the document is treated as a unique opaque origin and
|
|
8679
9479
|
# cannot read WebUI cookies, localStorage, or postMessage to the parent.
|
|
8680
|
-
|
|
9480
|
+
sandbox_csp = "sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox"
|
|
9481
|
+
csp = sandbox_csp if (inline_preview and not force_download and disposition == "inline") else None
|
|
8681
9482
|
# _serve_file_bytes sends Content-Security-Policy when csp is set.
|
|
8682
9483
|
if html_inline_ok:
|
|
8683
|
-
return _serve_inline_html_preview(handler, target, "no-store", csp=
|
|
9484
|
+
return _serve_inline_html_preview(handler, target, "no-store", csp=sandbox_csp)
|
|
8684
9485
|
return _serve_file_bytes(handler, target, mime, disposition, "no-store", csp=csp)
|
|
8685
9486
|
|
|
8686
9487
|
|
|
@@ -8690,7 +9491,7 @@ def _handle_file_read(handler, parsed):
|
|
|
8690
9491
|
if not sid:
|
|
8691
9492
|
return bad(handler, "session_id is required")
|
|
8692
9493
|
try:
|
|
8693
|
-
s =
|
|
9494
|
+
s = get_session_for_file_ops(sid)
|
|
8694
9495
|
except KeyError:
|
|
8695
9496
|
return bad(handler, "Session not found", 404)
|
|
8696
9497
|
rel = qs.get("path", [""])[0]
|
|
@@ -11011,7 +11812,7 @@ def _handle_file_delete(handler, body):
|
|
|
11011
11812
|
except ValueError as e:
|
|
11012
11813
|
return bad(handler, str(e))
|
|
11013
11814
|
try:
|
|
11014
|
-
s =
|
|
11815
|
+
s = get_session_for_file_ops(body["session_id"])
|
|
11015
11816
|
except KeyError:
|
|
11016
11817
|
return bad(handler, "Session not found", 404)
|
|
11017
11818
|
try:
|
|
@@ -11035,7 +11836,7 @@ def _handle_file_save(handler, body):
|
|
|
11035
11836
|
except ValueError as e:
|
|
11036
11837
|
return bad(handler, str(e))
|
|
11037
11838
|
try:
|
|
11038
|
-
s =
|
|
11839
|
+
s = get_session_for_file_ops(body["session_id"])
|
|
11039
11840
|
except KeyError:
|
|
11040
11841
|
return bad(handler, "Session not found", 404)
|
|
11041
11842
|
try:
|
|
@@ -11058,7 +11859,7 @@ def _handle_file_create(handler, body):
|
|
|
11058
11859
|
except ValueError as e:
|
|
11059
11860
|
return bad(handler, str(e))
|
|
11060
11861
|
try:
|
|
11061
|
-
s =
|
|
11862
|
+
s = get_session_for_file_ops(body["session_id"])
|
|
11062
11863
|
except KeyError:
|
|
11063
11864
|
return bad(handler, "Session not found", 404)
|
|
11064
11865
|
try:
|
|
@@ -11080,7 +11881,7 @@ def _handle_file_rename(handler, body):
|
|
|
11080
11881
|
except ValueError as e:
|
|
11081
11882
|
return bad(handler, str(e))
|
|
11082
11883
|
try:
|
|
11083
|
-
s =
|
|
11884
|
+
s = get_session_for_file_ops(body["session_id"])
|
|
11084
11885
|
except KeyError:
|
|
11085
11886
|
return bad(handler, "Session not found", 404)
|
|
11086
11887
|
try:
|
|
@@ -11106,7 +11907,7 @@ def _handle_create_dir(handler, body):
|
|
|
11106
11907
|
except ValueError as e:
|
|
11107
11908
|
return bad(handler, str(e))
|
|
11108
11909
|
try:
|
|
11109
|
-
s =
|
|
11910
|
+
s = get_session_for_file_ops(body["session_id"])
|
|
11110
11911
|
except KeyError:
|
|
11111
11912
|
return bad(handler, "Session not found", 404)
|
|
11112
11913
|
try:
|
|
@@ -11127,7 +11928,7 @@ def _handle_file_reveal(handler, body):
|
|
|
11127
11928
|
except ValueError as e:
|
|
11128
11929
|
return bad(handler, str(e))
|
|
11129
11930
|
try:
|
|
11130
|
-
s =
|
|
11931
|
+
s = get_session_for_file_ops(body["session_id"])
|
|
11131
11932
|
except KeyError:
|
|
11132
11933
|
return bad(handler, "Session not found", 404)
|
|
11133
11934
|
try:
|
|
@@ -11174,7 +11975,7 @@ def _handle_file_path(handler, body):
|
|
|
11174
11975
|
except ValueError as e:
|
|
11175
11976
|
return bad(handler, str(e))
|
|
11176
11977
|
try:
|
|
11177
|
-
s =
|
|
11978
|
+
s = get_session_for_file_ops(body["session_id"])
|
|
11178
11979
|
except KeyError:
|
|
11179
11980
|
return bad(handler, "Session not found", 404)
|
|
11180
11981
|
try:
|
|
@@ -11204,7 +12005,7 @@ def _handle_file_open_vscode(handler, body):
|
|
|
11204
12005
|
except ValueError as e:
|
|
11205
12006
|
return bad(handler, str(e))
|
|
11206
12007
|
try:
|
|
11207
|
-
s =
|
|
12008
|
+
s = get_session_for_file_ops(body["session_id"])
|
|
11208
12009
|
except KeyError:
|
|
11209
12010
|
return bad(handler, "Session not found", 404)
|
|
11210
12011
|
try:
|