@bitseek/hermes-webui 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/README.md +213 -0
  2. package/bin/hermes-webui.mjs +588 -0
  3. package/package.json +25 -0
  4. package/scripts/sync-vendor.mjs +74 -0
  5. package/templates/launchd/com.bitseek.hermes-webui.plist +21 -0
  6. package/templates/systemd/hermes-webui.service +13 -0
  7. package/templates/windows/hermes-webui-task.ps1 +3 -0
  8. package/vendor/agent-frontend-shell/.bitseek-source.json +6 -0
  9. package/vendor/agent-frontend-shell/.dockerignore +7 -0
  10. package/vendor/agent-frontend-shell/.env.docker.example +89 -0
  11. package/vendor/agent-frontend-shell/.env.example +34 -0
  12. package/vendor/agent-frontend-shell/.github/FUNDING.yml +3 -0
  13. package/vendor/agent-frontend-shell/.github/workflows/browser-smoke.yml +42 -0
  14. package/vendor/agent-frontend-shell/.github/workflows/docker-smoke.yml +233 -0
  15. package/vendor/agent-frontend-shell/.github/workflows/native-windows-startup.yml +132 -0
  16. package/vendor/agent-frontend-shell/.github/workflows/release.yml +57 -0
  17. package/vendor/agent-frontend-shell/.github/workflows/tests.yml +88 -0
  18. package/vendor/agent-frontend-shell/.vscode/launch.json +59 -0
  19. package/vendor/agent-frontend-shell/.vscode/settings.json +13 -0
  20. package/vendor/agent-frontend-shell/AGENTS.md +80 -0
  21. package/vendor/agent-frontend-shell/ARCHITECTURE.md +1658 -0
  22. package/vendor/agent-frontend-shell/BUGS.md +52 -0
  23. package/vendor/agent-frontend-shell/CHANGELOG.md +7295 -0
  24. package/vendor/agent-frontend-shell/CONTRIBUTING.md +205 -0
  25. package/vendor/agent-frontend-shell/CONTRIBUTORS.md +107 -0
  26. package/vendor/agent-frontend-shell/DESIGN.md +173 -0
  27. package/vendor/agent-frontend-shell/Dockerfile +91 -0
  28. package/vendor/agent-frontend-shell/LICENSE +21 -0
  29. package/vendor/agent-frontend-shell/README-CUSTOM.md +76 -0
  30. package/vendor/agent-frontend-shell/README.md +705 -0
  31. package/vendor/agent-frontend-shell/ROADMAP.md +351 -0
  32. package/vendor/agent-frontend-shell/SPRINTS.md +147 -0
  33. package/vendor/agent-frontend-shell/TESTING.md +1932 -0
  34. package/vendor/agent-frontend-shell/THEMES.md +170 -0
  35. package/vendor/agent-frontend-shell/api/__init__.py +1 -0
  36. package/vendor/agent-frontend-shell/api/agent_health.py +392 -0
  37. package/vendor/agent-frontend-shell/api/agent_sessions.py +782 -0
  38. package/vendor/agent-frontend-shell/api/auth.py +592 -0
  39. package/vendor/agent-frontend-shell/api/background.py +87 -0
  40. package/vendor/agent-frontend-shell/api/clarify.py +238 -0
  41. package/vendor/agent-frontend-shell/api/commands.py +124 -0
  42. package/vendor/agent-frontend-shell/api/compression_anchor.py +134 -0
  43. package/vendor/agent-frontend-shell/api/config.py +5178 -0
  44. package/vendor/agent-frontend-shell/api/dashboard_probe.py +255 -0
  45. package/vendor/agent-frontend-shell/api/extensions.py +253 -0
  46. package/vendor/agent-frontend-shell/api/gateway_chat.py +435 -0
  47. package/vendor/agent-frontend-shell/api/gateway_watcher.py +230 -0
  48. package/vendor/agent-frontend-shell/api/goals.py +608 -0
  49. package/vendor/agent-frontend-shell/api/helpers.py +474 -0
  50. package/vendor/agent-frontend-shell/api/kanban_bridge.py +1255 -0
  51. package/vendor/agent-frontend-shell/api/metering.py +194 -0
  52. package/vendor/agent-frontend-shell/api/models.py +4210 -0
  53. package/vendor/agent-frontend-shell/api/oauth.py +770 -0
  54. package/vendor/agent-frontend-shell/api/onboarding.py +1046 -0
  55. package/vendor/agent-frontend-shell/api/passkeys.py +365 -0
  56. package/vendor/agent-frontend-shell/api/profiles.py +1499 -0
  57. package/vendor/agent-frontend-shell/api/providers.py +2175 -0
  58. package/vendor/agent-frontend-shell/api/request_diagnostics.py +160 -0
  59. package/vendor/agent-frontend-shell/api/rollback.py +320 -0
  60. package/vendor/agent-frontend-shell/api/routes.py +13990 -0
  61. package/vendor/agent-frontend-shell/api/run_journal.py +284 -0
  62. package/vendor/agent-frontend-shell/api/runner_client.py +156 -0
  63. package/vendor/agent-frontend-shell/api/runtime_adapter.py +431 -0
  64. package/vendor/agent-frontend-shell/api/session_discoverability.py +640 -0
  65. package/vendor/agent-frontend-shell/api/session_events.py +45 -0
  66. package/vendor/agent-frontend-shell/api/session_lifecycle.py +208 -0
  67. package/vendor/agent-frontend-shell/api/session_ops.py +207 -0
  68. package/vendor/agent-frontend-shell/api/session_recovery.py +655 -0
  69. package/vendor/agent-frontend-shell/api/skill_usage.py +32 -0
  70. package/vendor/agent-frontend-shell/api/startup.py +128 -0
  71. package/vendor/agent-frontend-shell/api/state_sync.py +187 -0
  72. package/vendor/agent-frontend-shell/api/streaming.py +7048 -0
  73. package/vendor/agent-frontend-shell/api/system_health.py +167 -0
  74. package/vendor/agent-frontend-shell/api/terminal.py +410 -0
  75. package/vendor/agent-frontend-shell/api/turn_journal.py +214 -0
  76. package/vendor/agent-frontend-shell/api/updates.py +1261 -0
  77. package/vendor/agent-frontend-shell/api/upload.py +322 -0
  78. package/vendor/agent-frontend-shell/api/usage.py +26 -0
  79. package/vendor/agent-frontend-shell/api/workspace.py +867 -0
  80. package/vendor/agent-frontend-shell/api/workspace_git.py +1261 -0
  81. package/vendor/agent-frontend-shell/api/worktrees.py +357 -0
  82. package/vendor/agent-frontend-shell/bootstrap.py +492 -0
  83. package/vendor/agent-frontend-shell/ctl.sh +427 -0
  84. package/vendor/agent-frontend-shell/docker-compose.custom.yml +26 -0
  85. package/vendor/agent-frontend-shell/docker-compose.three-container.yml +168 -0
  86. package/vendor/agent-frontend-shell/docker-compose.two-container.yml +147 -0
  87. package/vendor/agent-frontend-shell/docker-compose.yml +57 -0
  88. package/vendor/agent-frontend-shell/docker_init.bash +459 -0
  89. package/vendor/agent-frontend-shell/docs/CONTRACTS.md +207 -0
  90. package/vendor/agent-frontend-shell/docs/EXTENSIONS.md +212 -0
  91. package/vendor/agent-frontend-shell/docs/ISSUES.md +23 -0
  92. package/vendor/agent-frontend-shell/docs/UIUX-GUIDE.md +196 -0
  93. package/vendor/agent-frontend-shell/docs/advanced-chat-setup.md +83 -0
  94. package/vendor/agent-frontend-shell/docs/docker.md +337 -0
  95. package/vendor/agent-frontend-shell/docs/onboarding-agent-checklist.md +207 -0
  96. package/vendor/agent-frontend-shell/docs/onboarding.md +202 -0
  97. package/vendor/agent-frontend-shell/docs/remote-access.md +75 -0
  98. package/vendor/agent-frontend-shell/docs/rfcs/README.md +53 -0
  99. package/vendor/agent-frontend-shell/docs/rfcs/agent-source-boundary.md +70 -0
  100. package/vendor/agent-frontend-shell/docs/rfcs/canonical-session-resolution.md +124 -0
  101. package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +1079 -0
  102. package/vendor/agent-frontend-shell/docs/rfcs/turn-journal.md +195 -0
  103. package/vendor/agent-frontend-shell/docs/rfcs/webui-run-state-consistency-contract.md +157 -0
  104. package/vendor/agent-frontend-shell/docs/supervisor.md +280 -0
  105. package/vendor/agent-frontend-shell/docs/troubleshooting.md +132 -0
  106. package/vendor/agent-frontend-shell/docs/ui-ux/index.html +863 -0
  107. package/vendor/agent-frontend-shell/docs/ui-ux/two-stage-proposal.html +768 -0
  108. package/vendor/agent-frontend-shell/docs/why-hermes.md +489 -0
  109. package/vendor/agent-frontend-shell/docs/workspace-git.md +92 -0
  110. package/vendor/agent-frontend-shell/docs/wsl-autostart.md +126 -0
  111. package/vendor/agent-frontend-shell/eslint.runtime-guard.config.mjs +35 -0
  112. package/vendor/agent-frontend-shell/extensions/bitseek-design-system.md +330 -0
  113. package/vendor/agent-frontend-shell/extensions/branding/assets/apple-touch-icon.png +0 -0
  114. package/vendor/agent-frontend-shell/extensions/branding/assets/empty-logo.svg +739 -0
  115. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-192.png +0 -0
  116. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-32.png +0 -0
  117. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.png +0 -0
  118. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.svg +745 -0
  119. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.ico +0 -0
  120. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.svg +745 -0
  121. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v2.svg +751 -0
  122. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v3.svg +739 -0
  123. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon.svg +745 -0
  124. package/vendor/agent-frontend-shell/extensions/branding/branding.js +112 -0
  125. package/vendor/agent-frontend-shell/extensions/branding/config.json +14 -0
  126. package/vendor/agent-frontend-shell/extensions/branding/manifest.json +53 -0
  127. package/vendor/agent-frontend-shell/extensions/index.js +67 -0
  128. package/vendor/agent-frontend-shell/extensions/loader/hermes-loader.js +77 -0
  129. package/vendor/agent-frontend-shell/extensions/manifest.json +16 -0
  130. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.css +333 -0
  131. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +487 -0
  132. package/vendor/agent-frontend-shell/extensions/pages/manifest.json +6 -0
  133. package/vendor/agent-frontend-shell/extensions/pages/registry.css +56 -0
  134. package/vendor/agent-frontend-shell/extensions/pages/registry.js +302 -0
  135. package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.css +93 -0
  136. package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.js +98 -0
  137. package/vendor/agent-frontend-shell/install.sh +63 -0
  138. package/vendor/agent-frontend-shell/mcp_server.py +567 -0
  139. package/vendor/agent-frontend-shell/package.json +12 -0
  140. package/vendor/agent-frontend-shell/pyproject.toml +56 -0
  141. package/vendor/agent-frontend-shell/pytest.ini +3 -0
  142. package/vendor/agent-frontend-shell/requirements.txt +5 -0
  143. package/vendor/agent-frontend-shell/server.py +624 -0
  144. package/vendor/agent-frontend-shell/start.ps1 +210 -0
  145. package/vendor/agent-frontend-shell/start.sh +65 -0
  146. package/vendor/agent-frontend-shell/static/apple-touch-icon.png +0 -0
  147. package/vendor/agent-frontend-shell/static/boot.js +1990 -0
  148. package/vendor/agent-frontend-shell/static/commands.js +1402 -0
  149. package/vendor/agent-frontend-shell/static/favicon-192.png +0 -0
  150. package/vendor/agent-frontend-shell/static/favicon-32.png +0 -0
  151. package/vendor/agent-frontend-shell/static/favicon-512.png +0 -0
  152. package/vendor/agent-frontend-shell/static/favicon-512.svg +18 -0
  153. package/vendor/agent-frontend-shell/static/favicon.ico +0 -0
  154. package/vendor/agent-frontend-shell/static/favicon.svg +20 -0
  155. package/vendor/agent-frontend-shell/static/i18n.js +15389 -0
  156. package/vendor/agent-frontend-shell/static/icons.js +92 -0
  157. package/vendor/agent-frontend-shell/static/index.html +1506 -0
  158. package/vendor/agent-frontend-shell/static/login.js +177 -0
  159. package/vendor/agent-frontend-shell/static/manifest.json +53 -0
  160. package/vendor/agent-frontend-shell/static/messages.js +3521 -0
  161. package/vendor/agent-frontend-shell/static/onboarding.js +800 -0
  162. package/vendor/agent-frontend-shell/static/panels.js +7995 -0
  163. package/vendor/agent-frontend-shell/static/pwa-startup.js +83 -0
  164. package/vendor/agent-frontend-shell/static/sessions.js +5165 -0
  165. package/vendor/agent-frontend-shell/static/style.css +4774 -0
  166. package/vendor/agent-frontend-shell/static/sw.js +173 -0
  167. package/vendor/agent-frontend-shell/static/terminal.js +632 -0
  168. package/vendor/agent-frontend-shell/static/ui.js +8997 -0
  169. package/vendor/agent-frontend-shell/static/vendor/js-yaml/4.1.0/js-yaml.min.js +2 -0
  170. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.ttf +0 -0
  171. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff +0 -0
  172. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  173. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  174. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  175. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  176. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  177. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  178. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  179. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  180. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  181. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  182. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  183. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  184. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  185. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.ttf +0 -0
  186. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff +0 -0
  187. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff2 +0 -0
  188. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  189. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  190. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  191. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.ttf +0 -0
  192. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff +0 -0
  193. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff2 +0 -0
  194. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.ttf +0 -0
  195. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff +0 -0
  196. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff2 +0 -0
  197. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  198. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  199. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  200. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.ttf +0 -0
  201. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff +0 -0
  202. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff2 +0 -0
  203. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  204. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  205. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  206. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  207. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  208. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  209. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  210. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  211. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  212. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.ttf +0 -0
  213. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff +0 -0
  214. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff2 +0 -0
  215. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.ttf +0 -0
  216. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff +0 -0
  217. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  218. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.ttf +0 -0
  219. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff +0 -0
  220. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  221. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.ttf +0 -0
  222. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff +0 -0
  223. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  224. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.ttf +0 -0
  225. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff +0 -0
  226. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  227. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  228. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  229. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  230. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.css +1 -0
  231. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.js +1 -0
  232. package/vendor/agent-frontend-shell/static/vendor/smd.min.js +29 -0
  233. package/vendor/agent-frontend-shell/static/workspace.js +680 -0
@@ -0,0 +1,867 @@
1
+ """
2
+ Hermes Web UI -- Workspace and file system helpers.
3
+
4
+ Workspace lists and last-used workspace are stored per-profile so each
5
+ profile has its own workspace configuration. State files live at
6
+ ``{profile_home}/webui_state/workspaces.json`` and
7
+ ``{profile_home}/webui_state/last_workspace.txt``. The global STATE_DIR
8
+ paths are used as fallback when no profile module is available.
9
+ """
10
+ import hashlib
11
+ import json
12
+ import logging
13
+ import os
14
+ import stat
15
+ import subprocess
16
+ import concurrent.futures
17
+ from pathlib import Path
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ from api.config import (
22
+ WORKSPACES_FILE as _GLOBAL_WS_FILE,
23
+ LAST_WORKSPACE_FILE as _GLOBAL_LW_FILE,
24
+ DEFAULT_WORKSPACE as _BOOT_DEFAULT_WORKSPACE,
25
+ MAX_FILE_BYTES, IMAGE_EXTS, MD_EXTS
26
+ )
27
+
28
+
29
+ # ── Profile-aware path resolution ───────────────────────────────────────────
30
+
31
+ def _profile_state_dir() -> Path:
32
+ """Return the webui_state directory for the active profile.
33
+
34
+ For the default profile, returns the global STATE_DIR (respects
35
+ HERMES_WEBUI_STATE_DIR env var for test isolation).
36
+ For named profiles, returns {profile_home}/webui_state/.
37
+ """
38
+ try:
39
+ from api.profiles import get_active_profile_name, get_active_hermes_home
40
+ name = get_active_profile_name()
41
+ if name and name != 'default':
42
+ d = get_active_hermes_home() / 'webui_state'
43
+ d.mkdir(parents=True, exist_ok=True)
44
+ return d
45
+ except ImportError:
46
+ logger.debug("Failed to import profiles module, using global state dir")
47
+ return _GLOBAL_WS_FILE.parent
48
+
49
+
50
+ def _workspaces_file() -> Path:
51
+ """Return the workspaces.json path for the active profile."""
52
+ return _profile_state_dir() / 'workspaces.json'
53
+
54
+
55
+ def _last_workspace_file() -> Path:
56
+ """Return the last_workspace.txt path for the active profile."""
57
+ return _profile_state_dir() / 'last_workspace.txt'
58
+
59
+
60
+ def _profile_default_workspace() -> str:
61
+ """Read the profile's default workspace from its config.yaml.
62
+
63
+ Checks keys in priority order:
64
+ 1. 'workspace' — explicit webui workspace key
65
+ 2. 'default_workspace' — alternate explicit key
66
+ 3. 'terminal.cwd' — hermes-agent terminal working dir (most common)
67
+
68
+ Falls back to the live DEFAULT_WORKSPACE from api.config.
69
+ """
70
+ try:
71
+ from api.config import get_config
72
+ cfg = get_config()
73
+ # Explicit webui workspace keys first
74
+ for key in ('workspace', 'default_workspace'):
75
+ ws = cfg.get(key)
76
+ if ws:
77
+ p = Path(str(ws)).expanduser().resolve()
78
+ if p.is_dir():
79
+ return str(p)
80
+ # Fall through to terminal.cwd — the agent's configured working directory
81
+ terminal_cfg = cfg.get('terminal', {})
82
+ if isinstance(terminal_cfg, dict):
83
+ cwd = terminal_cfg.get('cwd', '')
84
+ if cwd and str(cwd) not in ('.', ''):
85
+ p = Path(str(cwd)).expanduser().resolve()
86
+ if p.is_dir():
87
+ return str(p)
88
+ except (ImportError, Exception):
89
+ logger.debug("Failed to load profile default workspace config")
90
+ try:
91
+ from api.config import DEFAULT_WORKSPACE as _LIVE_DEFAULT_WORKSPACE
92
+
93
+ return str(Path(_LIVE_DEFAULT_WORKSPACE).expanduser().resolve())
94
+ except Exception:
95
+ return str(Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve())
96
+
97
+
98
+ # ── Public API ──────────────────────────────────────────────────────────────
99
+
100
+ def _clean_workspace_list(workspaces: list) -> list:
101
+ """Sanitize a workspace list:
102
+ - Preserve saved paths even when they are currently missing or inaccessible;
103
+ picker state must not be destroyed by a transient stat/permission failure.
104
+ - Remove entries whose paths live inside another profile's directory
105
+ (e.g. ~/.hermes/profiles/X/... should not appear on a different profile).
106
+ - Rename any entry whose name is literally 'default' to 'Home' (avoids
107
+ confusion with the 'default' profile name).
108
+ Returns the cleaned list (may be empty).
109
+ """
110
+ hermes_profiles = (Path.home() / '.hermes' / 'profiles').resolve()
111
+ result = []
112
+ for w in workspaces:
113
+ path = w.get('path', '')
114
+ name = w.get('name', '')
115
+ if not path:
116
+ continue
117
+ p = _safe_resolve(Path(path).expanduser())
118
+ # Skip paths inside a DIFFERENT profile's directory (cross-profile leak).
119
+ # Allow paths inside the CURRENT profile's own directory (e.g. test workspaces
120
+ # created under ~/.hermes/profiles/webui/webui-mvp-test/).
121
+ try:
122
+ p.relative_to(hermes_profiles)
123
+ # p is under ~/.hermes/profiles/ — only skip if it's under a DIFFERENT profile
124
+ try:
125
+ from api.profiles import get_active_hermes_home
126
+ own_profile_dir = get_active_hermes_home().resolve()
127
+ p.relative_to(own_profile_dir)
128
+ # p is under our own profile dir — keep it
129
+ except (ValueError, Exception):
130
+ continue # under profiles/ but not our own — cross-profile leak, skip
131
+ except ValueError:
132
+ pass # not under profiles/ at all — keep it
133
+ # Rename confusing 'default' label to 'Home'
134
+ if name.lower() == 'default':
135
+ name = 'Home'
136
+ result.append({'path': str(p), 'name': name})
137
+ return result
138
+
139
+
140
+ def _workspace_access_error(candidate: Path, *, missing_label: str = "Path does not exist") -> str | None:
141
+ """Return a user-facing validation error for an unusable workspace path.
142
+
143
+ ``Path.exists()`` can collapse permission/stat failures into a generic falsey
144
+ result on some Python/OS combinations, which produced misleading "does not
145
+ exist" messages for macOS/TCC-denied directories. Probe with ``stat()`` so
146
+ missing paths, non-directories, and permission-denied paths can be reported
147
+ separately.
148
+ """
149
+ try:
150
+ st = candidate.stat()
151
+ except FileNotFoundError:
152
+ return f"{missing_label}: {candidate}"
153
+ except PermissionError as exc:
154
+ return (
155
+ f"Cannot access path: {candidate}. The server process could not inspect "
156
+ f"this directory ({exc}). On macOS, grant Full Disk Access or Files and "
157
+ f"Folders permission to the Hermes/WebUI app or server process, then try again."
158
+ )
159
+ except OSError as exc:
160
+ return f"Cannot access path: {candidate}. The server process could not inspect this path ({exc})."
161
+ if not stat.S_ISDIR(st.st_mode):
162
+ return f"Path is not a directory: {candidate}"
163
+ return None
164
+
165
+
166
+ def _migrate_global_workspaces() -> list:
167
+ """Read the legacy global workspaces.json, clean it, and return the result.
168
+
169
+ This is the migration path for users upgrading from a pre-profile version:
170
+ their global file may contain cross-profile entries, test artifacts, and
171
+ stale paths accumulated over time. We clean it in-place and rewrite it.
172
+ """
173
+ if not _GLOBAL_WS_FILE.exists():
174
+ return []
175
+ try:
176
+ raw = json.loads(_GLOBAL_WS_FILE.read_text(encoding='utf-8'))
177
+ cleaned = _clean_workspace_list(raw)
178
+ if len(cleaned) != len(raw):
179
+ # Rewrite the cleaned version so future reads are already clean
180
+ _GLOBAL_WS_FILE.write_text(
181
+ json.dumps(cleaned, ensure_ascii=False, indent=2), encoding='utf-8'
182
+ )
183
+ return cleaned
184
+ except Exception:
185
+ return []
186
+
187
+
188
+ def load_workspaces() -> list:
189
+ ws_file = _workspaces_file()
190
+ if ws_file.exists():
191
+ try:
192
+ raw = json.loads(ws_file.read_text(encoding='utf-8'))
193
+ cleaned = _clean_workspace_list(raw)
194
+ if len(cleaned) != len(raw):
195
+ # Persist the cleaned version so stale entries don't keep reappearing
196
+ try:
197
+ ws_file.write_text(
198
+ json.dumps(cleaned, ensure_ascii=False, indent=2), encoding='utf-8'
199
+ )
200
+ except Exception:
201
+ logger.debug("Failed to persist cleaned workspace list")
202
+ return cleaned or [{'path': _profile_default_workspace(), 'name': 'Home'}]
203
+ except Exception:
204
+ logger.debug("Failed to load workspaces from %s", ws_file)
205
+ # No profile-local file yet.
206
+ # For the DEFAULT profile: migrate from the legacy global file (one-time cleanup).
207
+ # For NAMED profiles: always start clean with just their own workspace.
208
+ try:
209
+ from api.profiles import get_active_profile_name
210
+ is_default = get_active_profile_name() in ('default', None)
211
+ except ImportError:
212
+ is_default = True
213
+ if is_default:
214
+ migrated = _migrate_global_workspaces()
215
+ if migrated:
216
+ return migrated
217
+ # Fresh start: single entry from the profile's configured workspace, labeled "Home"
218
+ return [{'path': _profile_default_workspace(), 'name': 'Home'}]
219
+
220
+
221
+ def save_workspaces(workspaces: list) -> None:
222
+ ws_file = _workspaces_file()
223
+ ws_file.parent.mkdir(parents=True, exist_ok=True)
224
+ ws_file.write_text(json.dumps(workspaces, ensure_ascii=False, indent=2), encoding='utf-8')
225
+
226
+
227
+ def get_last_workspace() -> str:
228
+ lw_file = _last_workspace_file()
229
+ if lw_file.exists():
230
+ try:
231
+ p = lw_file.read_text(encoding='utf-8').strip()
232
+ if p and Path(p).is_dir():
233
+ return p
234
+ except Exception:
235
+ logger.debug("Failed to read last workspace from %s", lw_file)
236
+ # Fallback: try global file
237
+ if _GLOBAL_LW_FILE.exists():
238
+ try:
239
+ p = _GLOBAL_LW_FILE.read_text(encoding='utf-8').strip()
240
+ if p and Path(p).is_dir():
241
+ return p
242
+ except Exception:
243
+ logger.debug("Failed to read global last workspace")
244
+ return _profile_default_workspace()
245
+
246
+
247
+ def set_last_workspace(path: str) -> None:
248
+ try:
249
+ lw_file = _last_workspace_file()
250
+ lw_file.parent.mkdir(parents=True, exist_ok=True)
251
+ lw_file.write_text(str(path), encoding='utf-8')
252
+ except Exception:
253
+ logger.debug("Failed to set last workspace")
254
+
255
+
256
+ def _safe_resolve(p: Path) -> Path:
257
+ """Path.resolve() that never raises — falls back to the input path on error."""
258
+ try:
259
+ return p.resolve()
260
+ except (OSError, RuntimeError):
261
+ return p
262
+
263
+
264
+ # Per-user temp directories that sit nominally under a "system" prefix but are
265
+ # actually user-writable scratch space. Workspaces registered here (e.g. by
266
+ # pytest's ``tmp_path_factory`` on macOS, which uses ``/var/folders/<hash>/T/``)
267
+ # must remain accepted even though their parent (``/var``) is blocked. These
268
+ # carve-outs apply to BOTH workspace registration and runtime file ops so a
269
+ # symlink target inside the carve-out is also reachable.
270
+ _USER_TMP_PREFIXES: tuple[Path, ...] = (
271
+ Path('/var/folders'), # macOS per-user tmp (literal form)
272
+ Path('/private/var/folders'), # macOS per-user tmp (resolved form)
273
+ Path('/var/tmp'), # Linux/macOS system-wide tmp (user-writable)
274
+ Path('/private/var/tmp'), # macOS resolved form
275
+ )
276
+
277
+
278
+ def _workspace_blocked_roots() -> tuple[Path, ...]:
279
+ """System roots that must never be accepted as workspace candidates.
280
+
281
+ Returns both the literal path and its symlink-resolved canonical form,
282
+ deduped. This matters on macOS where ``/etc``, ``/var``, and ``/tmp``
283
+ are symlinks to ``/private/etc`` etc. Without the resolved forms,
284
+ callers that pass a ``.resolve()``-d candidate (every caller does)
285
+ would compare ``/private/etc`` against literal ``Path('/etc')`` and the
286
+ ``relative_to`` check would miss — letting ``/etc`` through as a
287
+ registered workspace on macOS.
288
+
289
+ Carve-outs for legitimate user-tmp paths nominally under these roots
290
+ (e.g. ``/var/folders/.../T/`` on macOS) are handled by
291
+ :func:`_is_blocked_system_path`, not by exclusion from this list.
292
+ """
293
+ _raw = (
294
+ # Linux / macOS
295
+ '/etc',
296
+ '/usr',
297
+ '/var',
298
+ '/bin',
299
+ '/sbin',
300
+ '/boot',
301
+ '/proc',
302
+ '/sys',
303
+ '/dev',
304
+ '/lib',
305
+ '/lib64',
306
+ '/opt/homebrew',
307
+ '/System',
308
+ '/Library',
309
+ )
310
+ _seen: set[Path] = set()
311
+ _out: list[Path] = []
312
+ for _p in _raw:
313
+ for _form in (Path(_p), _safe_resolve(Path(_p))):
314
+ if _form not in _seen:
315
+ _seen.add(_form)
316
+ _out.append(_form)
317
+ return tuple(_out)
318
+
319
+
320
+ def _is_blocked_system_path(candidate: Path) -> bool:
321
+ """Return True if *candidate* falls under a blocked system root.
322
+
323
+ Honours :data:`_USER_TMP_PREFIXES` carve-outs so per-user tmp directories
324
+ nominally under ``/var`` (``/var/folders`` on macOS, ``/var/tmp`` on
325
+ Linux/macOS) remain valid workspace candidates and reachable file targets.
326
+ """
327
+ for tmp in _USER_TMP_PREFIXES:
328
+ if _is_within(candidate, tmp):
329
+ return False
330
+ for blocked in _workspace_blocked_roots():
331
+ if _is_within(candidate, blocked):
332
+ return True
333
+ return False
334
+
335
+
336
+ def _workspace_blocked_resolved_subtrees() -> tuple[Path, ...]:
337
+ roots = list(_workspace_blocked_roots()) + [Path('/private/etc')]
338
+ resolved: list[Path] = []
339
+ for root in roots:
340
+ try:
341
+ p = root.expanduser().resolve()
342
+ except Exception:
343
+ p = root
344
+ if p not in resolved:
345
+ resolved.append(p)
346
+ return tuple(resolved)
347
+
348
+
349
+ def _workspace_blocked_exact_roots() -> tuple[Path, ...]:
350
+ roots = [Path('/'), Path('/private/var')]
351
+ for root in _workspace_blocked_roots():
352
+ try:
353
+ roots.append(root.expanduser().resolve())
354
+ except Exception:
355
+ roots.append(root)
356
+ unique: list[Path] = []
357
+ for root in roots:
358
+ if root not in unique:
359
+ unique.append(root)
360
+ return tuple(unique)
361
+
362
+
363
+ def _is_blocked_workspace_path(candidate: Path, raw_path: str | Path | None = None) -> bool:
364
+ """Return True when candidate points at a known OS/system directory.
365
+
366
+ Compare both the original spelling and the resolved path. This closes the
367
+ macOS /etc -> /private/etc bypass without globally banning temporary pytest
368
+ paths under /private/var/folders.
369
+ """
370
+ raw = None
371
+ if raw_path not in (None, ""):
372
+ try:
373
+ raw = Path(raw_path).expanduser()
374
+ except Exception:
375
+ raw = None
376
+
377
+ exact = _workspace_blocked_exact_roots()
378
+ if candidate in exact or (raw is not None and raw in _workspace_blocked_roots()):
379
+ return True
380
+
381
+ for tmp in _USER_TMP_PREFIXES:
382
+ if _is_within(candidate, tmp) or (raw is not None and _is_within(raw, tmp)):
383
+ return False
384
+
385
+ # Raw paths under literal roots (e.g. /etc/ssh, /var/db) are always blocked.
386
+ if raw is not None:
387
+ for blocked in _workspace_blocked_roots():
388
+ if _is_within(raw, blocked):
389
+ return True
390
+
391
+ # Resolved subtree checks catch symlink aliases such as /private/etc. The
392
+ # macOS temp root /private/var/folders is intentionally allowed for pytest
393
+ # and per-user temporary workspaces; other direct /private/var system data
394
+ # such as /private/var/db and /private/var/log remains blocked.
395
+ allowed_private_var = (Path('/private/var/folders'), Path('/private/var/tmp'))
396
+ for blocked in _workspace_blocked_resolved_subtrees():
397
+ if blocked == Path('/private/var'):
398
+ if candidate == blocked:
399
+ return True
400
+ if any(_is_within(candidate, allowed) for allowed in allowed_private_var):
401
+ continue
402
+ if _is_within(candidate, blocked):
403
+ return True
404
+ continue
405
+ if _is_within(candidate, blocked):
406
+ return True
407
+ return False
408
+
409
+
410
+ def _is_within(path: Path, root: Path) -> bool:
411
+ try:
412
+ path.relative_to(root)
413
+ return True
414
+ except ValueError:
415
+ return False
416
+
417
+
418
+ def _trusted_workspace_roots() -> list[Path]:
419
+ roots: list[Path] = []
420
+
421
+ def add(candidate: str | Path | None) -> None:
422
+ if candidate in (None, ""):
423
+ return
424
+ try:
425
+ p = Path(candidate).expanduser().resolve()
426
+ except Exception:
427
+ return
428
+ if not p.exists() or not p.is_dir():
429
+ return
430
+ if _is_blocked_workspace_path(p, candidate):
431
+ return
432
+ if p not in roots:
433
+ roots.append(p)
434
+
435
+ add(Path.home())
436
+ add(_BOOT_DEFAULT_WORKSPACE)
437
+ for w in load_workspaces():
438
+ add(w.get("path"))
439
+ roots.sort(key=lambda p: len(str(p)))
440
+ return roots
441
+
442
+
443
+ def list_workspace_suggestions(prefix: str = "", limit: int = 12) -> list[str]:
444
+ """Return workspace path suggestions under trusted roots only.
445
+
446
+ Suggestions are limited to directories under one of:
447
+ - Path.home()
448
+ - the boot default workspace
449
+ - already-saved workspace roots
450
+
451
+ Arbitrary system prefixes return an empty list rather than an error so the
452
+ UI can safely autocomplete while the user types.
453
+ """
454
+ roots = _trusted_workspace_roots()
455
+ if not roots:
456
+ return []
457
+
458
+ raw = (prefix or "").strip()
459
+ if not raw:
460
+ return [str(p) for p in roots[:limit]]
461
+
462
+ if raw.startswith("~"):
463
+ target = Path(raw).expanduser()
464
+ elif Path(raw).is_absolute():
465
+ target = Path(raw)
466
+ else:
467
+ target = Path.home() / raw
468
+
469
+ normalized = str(target)
470
+ normalized_lower = normalized.lower()
471
+ preserve_tilde = raw.startswith("~")
472
+ home_root: Path | None = None
473
+ if preserve_tilde:
474
+ try:
475
+ home_root = Path.home().expanduser().resolve()
476
+ except Exception:
477
+ home_root = None
478
+ suggestions: list[str] = []
479
+
480
+ def format_suggestion(path: Path) -> str:
481
+ if preserve_tilde and home_root is not None:
482
+ try:
483
+ rel = path.resolve().relative_to(home_root)
484
+ if str(rel) == ".":
485
+ return "~"
486
+ return "~/" + rel.as_posix()
487
+ except (OSError, ValueError):
488
+ pass
489
+ return str(path)
490
+
491
+ def add(path: Path) -> None:
492
+ value = format_suggestion(path)
493
+ if value not in suggestions:
494
+ suggestions.append(value)
495
+
496
+ # If the user is typing a partial trusted root like /Users/xuef..., suggest
497
+ # the matching trusted roots without scanning arbitrary system parents.
498
+ for root in roots:
499
+ if str(root).lower().startswith(normalized_lower):
500
+ add(root)
501
+
502
+ in_root = [
503
+ root
504
+ for root in roots
505
+ if normalized == str(root) or normalized.startswith(str(root) + os.sep)
506
+ ]
507
+ if not in_root:
508
+ return suggestions[:limit]
509
+
510
+ anchor_root = max(in_root, key=lambda p: len(str(p)))
511
+ ends_with_sep = raw.endswith(os.sep) or raw.endswith('/')
512
+ parent = target if ends_with_sep else target.parent
513
+ leaf = '' if ends_with_sep else target.name
514
+ show_hidden = leaf.startswith('.')
515
+
516
+ try:
517
+ parent_resolved = parent.expanduser().resolve()
518
+ except Exception:
519
+ return suggestions[:limit]
520
+
521
+ if not parent_resolved.exists() or not parent_resolved.is_dir():
522
+ return suggestions[:limit]
523
+ if not _is_within(parent_resolved, anchor_root):
524
+ return suggestions[:limit]
525
+
526
+ leaf_lower = leaf.lower()
527
+ try:
528
+ children = sorted(parent_resolved.iterdir(), key=lambda p: p.name.lower())
529
+ except OSError:
530
+ return suggestions[:limit]
531
+
532
+ for child in children:
533
+ if not child.is_dir():
534
+ continue
535
+ if child.name.startswith('.') and not show_hidden:
536
+ continue
537
+ if leaf_lower and not child.name.lower().startswith(leaf_lower):
538
+ continue
539
+ add(child.resolve())
540
+ if len(suggestions) >= limit:
541
+ break
542
+ return suggestions[:limit]
543
+
544
+
545
+ def resolve_trusted_workspace(path: str | Path | None = None) -> Path:
546
+ """Resolve and validate a workspace path.
547
+
548
+ A path is trusted if it satisfies at least one of:
549
+ (A) It is under the user's home directory (Path.home()).
550
+ Works cross-platform: ~/... on Linux/macOS, C:\\Users\\... on Windows.
551
+ (B) It is already in the profile's saved workspace list.
552
+ This covers self-hosted deployments where workspaces live outside home
553
+ (e.g. /data/projects, /opt/workspace) — once a workspace is saved by
554
+ an admin, it can be reused without re-validation.
555
+
556
+ Additionally enforced regardless of (A)/(B):
557
+ 1. The path must exist.
558
+ 2. The path must be a directory.
559
+ 3. The path must not be a known system root (/etc, /usr, /var, /bin, /sbin,
560
+ /boot, /proc, /sys, /dev, /root on Linux/macOS; Windows system dirs).
561
+ This prevents even admin-saved workspaces from pointing at OS internals.
562
+
563
+ None/empty path falls back to the boot-time DEFAULT_WORKSPACE, which is always
564
+ trusted (it was validated at server startup).
565
+ """
566
+ if path in (None, ""):
567
+ return Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve()
568
+
569
+ candidate = Path(path).expanduser().resolve()
570
+
571
+ access_error = _workspace_access_error(candidate)
572
+ if access_error:
573
+ raise ValueError(access_error)
574
+
575
+ # (A) Trusted if under the user's home directory — cross-platform via Path.home()
576
+ # Must be checked before system roots to allow symlinks like /var/home.
577
+ _home = Path.home().resolve()
578
+ if _home != Path("/"):
579
+ try:
580
+ candidate.relative_to(_home)
581
+ return candidate
582
+ except ValueError:
583
+ pass
584
+
585
+ # Block known system roots and their children.
586
+ if _is_blocked_workspace_path(candidate, path):
587
+ raise ValueError(f"Path points to a system directory: {candidate}")
588
+
589
+ # (B) Trusted if already in the saved workspace list — covers non-home installs
590
+ try:
591
+ saved = load_workspaces()
592
+ saved_paths = {Path(w["path"]).resolve() for w in saved if w.get("path")}
593
+ if candidate in saved_paths:
594
+ return candidate
595
+ except Exception:
596
+ pass
597
+
598
+ # (C) Trusted if it is equal to or under the boot-time DEFAULT_WORKSPACE.
599
+ # In Docker deployments HERMES_WEBUI_DEFAULT_WORKSPACE is often set to a
600
+ # volume mount outside the user's home (e.g. /data/workspace). That path
601
+ # was already validated at server startup, so any sub-path of it is safe
602
+ # without requiring the user to add it to the workspace list manually.
603
+ try:
604
+ boot_default = Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve()
605
+ candidate.relative_to(boot_default)
606
+ return candidate
607
+ except ValueError:
608
+ pass
609
+
610
+ raise ValueError(
611
+ f"Path is outside the user home directory, not in the saved workspace "
612
+ f"list, and not under the default workspace: {candidate}. "
613
+ f"Add it via Settings → Workspaces first."
614
+ )
615
+
616
+
617
+
618
+
619
+ def _strip_surrounding_quotes(path: str) -> str:
620
+ """Strip a single pair of surrounding single or double quotes from a path string.
621
+
622
+ macOS Finder's "Copy as Pathname" (Cmd+Option+C) returns paths wrapped in
623
+ single quotes, e.g. ``'/Users/x/Documents/foo'``. Other shells and OS file
624
+ managers do similar things with double quotes. Users routinely paste these
625
+ quoted strings into the Add Space input expecting them to "just work" —
626
+ the only reason they didn't was a missing strip.
627
+
628
+ Only paired quotes are stripped (matching opener and closer). One-sided quotes
629
+ are preserved on the slim chance a path legitimately contains a literal quote
630
+ character.
631
+ """
632
+ s = path.strip()
633
+ if len(s) >= 2 and s[0] == s[-1] and s[0] in ("'", '"'):
634
+ return s[1:-1]
635
+ return s
636
+
637
+
638
+ def validate_workspace_to_add(path: str) -> Path:
639
+ """Validate a path for *adding* to the workspace list (less restrictive than resolve_trusted_workspace).
640
+
641
+ When a user explicitly adds a new workspace path, we trust their intent — they
642
+ have console or filesystem access to that path and are consciously registering it.
643
+ We only block: non-existent paths, non-directories, and known system roots.
644
+
645
+ The stricter ``resolve_trusted_workspace`` is used when *using* an existing workspace
646
+ (file reads/writes) to prevent path traversal after the list is built.
647
+
648
+ Surrounding quotes (single or double) are stripped before validation —
649
+ macOS Finder's "Copy as Pathname" wraps paths in single quotes by default,
650
+ and users routinely paste those into the Add Space input.
651
+ """
652
+ path = _strip_surrounding_quotes(path)
653
+ candidate = Path(path).expanduser().resolve()
654
+
655
+ access_error = _workspace_access_error(candidate)
656
+ if access_error:
657
+ raise ValueError(access_error)
658
+
659
+ # Home directory is always trusted regardless of where it lives on disk
660
+ # (e.g. /var/home/... on systemd-homed Fedora/RHEL).
661
+ _home = Path.home().resolve()
662
+ if _home != Path("/") and _is_within(candidate, _home):
663
+ return candidate
664
+
665
+ # Block known system roots and their immediate children.
666
+ if _is_blocked_workspace_path(candidate, path):
667
+ raise ValueError(f"Path points to a system directory: {candidate}")
668
+
669
+ return candidate
670
+
671
+ def safe_resolve_ws(root: Path, requested: str) -> Path:
672
+ """Resolve a relative path inside a workspace root, raising ValueError on traversal.
673
+
674
+ Symlinks whose *unresolved* path is within the workspace root are allowed —
675
+ the user placed them there intentionally. Only raw ``..`` traversal outside
676
+ the root is blocked.
677
+ """
678
+ import os
679
+ unresolved = root / requested
680
+ resolved = unresolved.resolve()
681
+ # Fast path: resolved path is inside root (covers most cases)
682
+ try:
683
+ resolved.relative_to(root.resolve())
684
+ return resolved
685
+ except ValueError:
686
+ pass
687
+ # Symlink path: normalize '..' (without following symlinks) and check
688
+ # os.path.normpath collapses '..' but does NOT follow symlinks.
689
+ norm = Path(os.path.normpath(str(unresolved)))
690
+ try:
691
+ norm.relative_to(root)
692
+ except ValueError:
693
+ raise ValueError(f"Path traversal blocked: {requested}")
694
+ # Symlink points outside workspace root — additionally block system directories.
695
+ # Even if the user placed the symlink intentionally, prevent reads from
696
+ # /etc, /proc, /sys, /dev and other blocked roots (LLM agents can call
697
+ # read_file_content via tool calls, not just human users).
698
+ if _is_blocked_system_path(resolved):
699
+ raise ValueError(f"Path traversal blocked (system dir): {requested}")
700
+ return resolved
701
+
702
+
703
+ def list_dir(workspace: Path, rel: str='.'):
704
+ target = safe_resolve_ws(workspace, rel)
705
+ if not target.is_dir():
706
+ raise FileNotFoundError(f"Not a directory: {rel}")
707
+ ws_resolved = workspace.resolve()
708
+ entries = []
709
+ for item in sorted(target.iterdir(), key=lambda p: (not p.is_symlink(), p.is_file(), p.name.lower())):
710
+ if item.is_symlink():
711
+ # Resolve the symlink target and check if it stays within workspace
712
+ try:
713
+ link_target = item.resolve()
714
+ except OSError:
715
+ continue
716
+ # Cycle detection: skip if symlink points back to current dir,
717
+ # workspace root, or any ancestor of current dir.
718
+ # This must run REGARDLESS of whether target is inside workspace.
719
+ if (link_target == target.resolve() or link_target == target
720
+ or link_target == ws_resolved):
721
+ continue
722
+ try:
723
+ target.resolve().relative_to(link_target)
724
+ # target is under link_target — link_target is an ancestor → cycle
725
+ continue
726
+ except ValueError:
727
+ pass
728
+ # Block symlinks that resolve to system directories.
729
+ if _is_blocked_system_path(link_target):
730
+ continue
731
+ is_dir = link_target.is_dir()
732
+ # Keep the display path relative to workspace (don't follow the link)
733
+ display_path = str(Path(item.name))
734
+ if rel and rel != '.':
735
+ display_path = rel + '/' + display_path
736
+ try:
737
+ item_stat = item.lstat()
738
+ mtime_ns = item_stat.st_mtime_ns
739
+ except OSError:
740
+ mtime_ns = None
741
+ entry = {
742
+ 'name': item.name,
743
+ 'path': display_path,
744
+ 'type': 'symlink',
745
+ 'target': str(link_target),
746
+ 'is_dir': is_dir,
747
+ 'mtime_ns': mtime_ns,
748
+ }
749
+ if not is_dir:
750
+ try:
751
+ entry['size'] = link_target.stat().st_size
752
+ except OSError:
753
+ entry['size'] = None
754
+ entries.append(entry)
755
+ else:
756
+ # Use rel-based path so entries under symlink targets (outside
757
+ # the workspace root) still get a valid workspace-relative path.
758
+ entry_path = item.name
759
+ if rel and rel != '.':
760
+ entry_path = rel + '/' + item.name
761
+ try:
762
+ item_stat = item.stat()
763
+ size = item_stat.st_size if item.is_file() else None
764
+ mtime_ns = item_stat.st_mtime_ns
765
+ except OSError:
766
+ size = None
767
+ mtime_ns = None
768
+ entries.append({
769
+ 'name': item.name,
770
+ 'path': entry_path,
771
+ 'type': 'dir' if item.is_dir() else 'file',
772
+ 'size': size,
773
+ 'mtime_ns': mtime_ns,
774
+ })
775
+ if len(entries) >= 200:
776
+ break
777
+ return entries
778
+
779
+
780
+ def dir_signature(workspace: Path, rel: str = '.', entries: list[dict] | None = None) -> str:
781
+ """Return a cheap, stable signature for a listed workspace directory.
782
+
783
+ The signature is based only on bounded directory-entry metadata already used
784
+ by the workspace tree: names, displayed paths, entry type, file sizes,
785
+ mtimes, and symlink targets. It intentionally does not read file contents.
786
+ """
787
+ if entries is None:
788
+ entries = list_dir(workspace, rel)
789
+ payload = []
790
+ for entry in entries:
791
+ payload.append({
792
+ 'name': entry.get('name'),
793
+ 'path': entry.get('path'),
794
+ 'type': entry.get('type'),
795
+ 'is_dir': entry.get('is_dir'),
796
+ 'size': entry.get('size'),
797
+ 'mtime_ns': entry.get('mtime_ns'),
798
+ 'target': entry.get('target'),
799
+ })
800
+ raw = json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
801
+ return hashlib.sha256(raw.encode('utf-8')).hexdigest()
802
+
803
+
804
+ def read_file_content(workspace: Path, rel: str) -> dict:
805
+ target = safe_resolve_ws(workspace, rel)
806
+ if not target.is_file():
807
+ raise FileNotFoundError(f"Not a file: {rel}")
808
+ size = target.stat().st_size
809
+ if size > MAX_FILE_BYTES:
810
+ raise ValueError(f"File too large ({size} bytes, max {MAX_FILE_BYTES})")
811
+ content = target.read_text(encoding='utf-8', errors='replace')
812
+ return {'path': rel, 'content': content, 'size': size, 'lines': content.count('\n') + 1}
813
+
814
+
815
+ # ── Git detection ──────────────────────────────────────────────────────────
816
+
817
+ def _run_git(args, cwd, timeout=3):
818
+ """Run a git command and return stdout, or None on failure."""
819
+ try:
820
+ r = subprocess.run(
821
+ ['git'] + args, cwd=str(cwd), capture_output=True,
822
+ text=True, timeout=timeout,
823
+ )
824
+ return r.stdout.strip() if r.returncode == 0 else None
825
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
826
+ return None
827
+
828
+
829
+ def git_info_for_workspace(workspace: Path) -> dict:
830
+ """Return git info for a workspace directory, or None if not a git repo."""
831
+ if not (workspace / '.git').exists():
832
+ return None
833
+ branch = _run_git(['rev-parse', '--abbrev-ref', 'HEAD'], workspace)
834
+ if branch is None:
835
+ return None
836
+ # Run the remaining git commands in parallel via threads — they are
837
+ # independent subprocess calls and together can take 50-200ms when run
838
+ # serially. Threading is safe here because each call blocks only on the
839
+ # subprocess pipe, not on the GIL.
840
+ def _ahead():
841
+ r = _run_git(['rev-list', '--count', '@{u}..HEAD'], workspace)
842
+ return int(r) if r and r.isdigit() else 0
843
+ def _behind():
844
+ r = _run_git(['rev-list', '--count', 'HEAD..@{u}'], workspace)
845
+ return int(r) if r and r.isdigit() else 0
846
+ def _status():
847
+ out = _run_git(['status', '--porcelain'], workspace) or ''
848
+ lines = [l for l in out.splitlines() if l]
849
+ modified = sum(1 for l in lines if len(l) >= 2 and (l[0] in 'MAR' or l[1] in 'MAR'))
850
+ untracked = sum(1 for l in lines if l.startswith('??'))
851
+ return len(lines), modified, untracked
852
+ with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
853
+ f_status = pool.submit(_status)
854
+ f_ahead = pool.submit(_ahead)
855
+ f_behind = pool.submit(_behind)
856
+ dirty, modified, untracked = f_status.result()
857
+ ahead = f_ahead.result()
858
+ behind = f_behind.result()
859
+ return {
860
+ 'branch': branch,
861
+ 'dirty': dirty,
862
+ 'modified': modified,
863
+ 'untracked': untracked,
864
+ 'ahead': ahead,
865
+ 'behind': behind,
866
+ 'is_git': True,
867
+ }