@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,1499 @@
1
+ """
2
+ Hermes Web UI -- Profile state management.
3
+ Wraps hermes_cli.profiles to provide profile switching for the web UI.
4
+
5
+ The web UI maintains a process-level "active profile" that determines which
6
+ HERMES_HOME directory is used for config, skills, memory, cron, and API keys.
7
+ Profile switches update os.environ['HERMES_HOME'] and monkey-patch module-level
8
+ cached paths in hermes-agent modules (skills_tool, skill_manager_tool,
9
+ cron/jobs) that snapshot HERMES_HOME at import time.
10
+ """
11
+ import json
12
+ import logging
13
+ import os
14
+ import re
15
+ import shutil
16
+ import sys
17
+ import threading
18
+ from contextlib import contextmanager
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+ from api.session_events import publish_session_list_changed
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # ── Constants (match hermes_cli.profiles upstream) ─────────────────────────
27
+ _PROFILE_ID_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,63}$')
28
+ _PROFILE_DIRS = [
29
+ 'memories', 'sessions', 'skills', 'skins',
30
+ 'logs', 'plans', 'workspace', 'cron',
31
+ ]
32
+ _CLONE_CONFIG_FILES = ['config.yaml', '.env', 'SOUL.md']
33
+
34
+ # ── Module state ────────────────────────────────────────────────────────────
35
+ _active_profile = 'default'
36
+ _profile_lock = threading.Lock()
37
+ _loaded_profile_env_keys: set[str] = set()
38
+
39
+ # Thread-local profile context: set per-request by server.py, cleared after.
40
+ # Enables per-client profile isolation (issue #798) — each HTTP request thread
41
+ # reads its own profile from the hermes_profile cookie instead of the
42
+ # process-global _active_profile.
43
+ _tls = threading.local()
44
+
45
+ _SKILL_HOME_MODULES = ("tools.skills_tool", "tools.skill_manager_tool")
46
+
47
+
48
+ def snapshot_skill_home_modules() -> dict[str, dict[str, object]]:
49
+ """Snapshot imported skill-module path globals before a temporary patch."""
50
+ snapshot: dict[str, dict[str, object]] = {}
51
+ for module_name in _SKILL_HOME_MODULES:
52
+ module = sys.modules.get(module_name)
53
+ if module is None:
54
+ snapshot[module_name] = {"module_present": False}
55
+ continue
56
+ snapshot[module_name] = {
57
+ "module_present": True,
58
+ "has_HERMES_HOME": hasattr(module, "HERMES_HOME"),
59
+ "HERMES_HOME": getattr(module, "HERMES_HOME", None),
60
+ "has_SKILLS_DIR": hasattr(module, "SKILLS_DIR"),
61
+ "SKILLS_DIR": getattr(module, "SKILLS_DIR", None),
62
+ }
63
+ return snapshot
64
+
65
+
66
+ def patch_skill_home_modules(home: Path) -> None:
67
+ """Patch imported skill modules that cache HERMES_HOME at import time."""
68
+ for module_name in _SKILL_HOME_MODULES:
69
+ module = sys.modules.get(module_name)
70
+ if module is None:
71
+ continue
72
+ try:
73
+ module.HERMES_HOME = home
74
+ module.SKILLS_DIR = home / "skills"
75
+ except AttributeError:
76
+ logger.debug("Failed to patch %s module", module_name)
77
+
78
+
79
+ def restore_skill_home_modules(snapshot: dict[str, dict[str, object]]) -> None:
80
+ """Restore skill-module globals captured by snapshot_skill_home_modules()."""
81
+ for module_name, values in snapshot.items():
82
+ module = sys.modules.get(module_name)
83
+ if not values.get("module_present"):
84
+ if module is not None:
85
+ sys.modules.pop(module_name, None)
86
+ parent_name, _, child_name = module_name.rpartition(".")
87
+ parent = sys.modules.get(parent_name)
88
+ if parent is not None:
89
+ try:
90
+ delattr(parent, child_name)
91
+ except AttributeError:
92
+ pass
93
+ continue
94
+ if module is None:
95
+ continue
96
+ for attr in ("HERMES_HOME", "SKILLS_DIR"):
97
+ has_attr = bool(values.get(f"has_{attr}"))
98
+ try:
99
+ if has_attr:
100
+ setattr(module, attr, values.get(attr))
101
+ else:
102
+ try:
103
+ delattr(module, attr)
104
+ except AttributeError:
105
+ pass
106
+ except AttributeError:
107
+ logger.debug("Failed to restore %s.%s", module_name, attr)
108
+
109
+
110
+ def _unwrap_profile_home_to_base(home: Path) -> Path:
111
+ """Return the base Hermes home when *home* is already a named profile dir."""
112
+ if home.parent.name == 'profiles':
113
+ return home.parent.parent
114
+ return home
115
+
116
+
117
+ def _resolve_base_hermes_home() -> Path:
118
+ """Return the BASE ~/.hermes directory — the root that contains profiles/.
119
+
120
+ This is intentionally distinct from HERMES_HOME, which tracks the *active
121
+ profile's* home and changes on every profile switch. The base dir must
122
+ always point to the top-level .hermes regardless of which profile is active.
123
+
124
+ Resolution order:
125
+ 1. HERMES_BASE_HOME env var (set explicitly, highest priority)
126
+ 2. HERMES_HOME env var — but only if it does NOT look like a profile subdir
127
+ (i.e. its parent is not named 'profiles'). This handles test isolation
128
+ where HERMES_HOME is set to an isolated test state dir.
129
+ 3. ~/.hermes (always-correct default)
130
+
131
+ The bug this prevents: if HERMES_HOME has already been mutated to
132
+ /home/user/.hermes/profiles/webui (by init_profile_state at startup),
133
+ reading it here would make _DEFAULT_HERMES_HOME point to that subdir,
134
+ causing switch_profile('webui') to look for
135
+ /home/user/.hermes/profiles/webui/profiles/webui — which doesn't exist.
136
+
137
+ HERMES_BASE_HOME normally points at the base home already, but isolated
138
+ single-profile WebUI deployments can provide /base/profiles/<name> there as
139
+ well. Normalize both env vars through the same helper so active-profile
140
+ and per-request resolution share one base-root contract (#749).
141
+ """
142
+ # Explicit override for tests or unusual setups
143
+ base_override = os.getenv('HERMES_BASE_HOME', '').strip()
144
+ if base_override:
145
+ return _unwrap_profile_home_to_base(Path(base_override).expanduser())
146
+
147
+ hermes_home = os.getenv('HERMES_HOME', '').strip()
148
+ if hermes_home:
149
+ p = Path(hermes_home).expanduser()
150
+ # If HERMES_HOME points to a profiles/ subdir, walk up two levels to the base
151
+ return _unwrap_profile_home_to_base(p)
152
+
153
+ # Platform default. On Windows this includes the #2905 migration-safety
154
+ # fallback (prefer the populated legacy %USERPROFILE%\.hermes over an
155
+ # empty %LOCALAPPDATA%\hermes). Delegate to config so the base-home
156
+ # resolution used for the active-profile pointer can never drift from the
157
+ # one config.STATE_DIR is derived from.
158
+ try:
159
+ from api.config import _platform_default_hermes_home
160
+ return _platform_default_hermes_home()
161
+ except ImportError:
162
+ # Defensive: never let a config import problem break profile resolution.
163
+ # Scoped to ImportError so a real bug inside the helper still surfaces.
164
+ if os.name == 'nt':
165
+ local_app_data = os.getenv('LOCALAPPDATA', '').strip()
166
+ if local_app_data:
167
+ return Path(local_app_data) / 'hermes'
168
+ return Path.home() / '.hermes'
169
+
170
+ _DEFAULT_HERMES_HOME = _resolve_base_hermes_home()
171
+
172
+
173
+ def _read_active_profile_file() -> str:
174
+ """Read the sticky active profile from ~/.hermes/active_profile."""
175
+ ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
176
+ if ap_file.exists():
177
+ try:
178
+ name = ap_file.read_text(encoding="utf-8").strip()
179
+ if name:
180
+ return name
181
+ except Exception:
182
+ logger.debug("Failed to read active profile file")
183
+ return 'default'
184
+
185
+
186
+ # ── Public API ──────────────────────────────────────────────────────────────
187
+
188
+ # ── Root-profile resolution (#1612) ────────────────────────────────────────
189
+ #
190
+ # Hermes Agent allows the root/default profile (~/.hermes itself) to have a
191
+ # display name other than the legacy literal 'default'. When that happens,
192
+ # WebUI must NOT resolve the display name as ~/.hermes/profiles/<name> — that
193
+ # directory doesn't exist, and every site that does `if name == 'default':`
194
+ # will fall through to the wrong filesystem path.
195
+ #
196
+ # `_is_root_profile(name)` answers "does this name resolve to ~/.hermes?" and
197
+ # is the canonical replacement for scattered `if name == 'default':` checks
198
+ # in switch_profile, get_active_hermes_home, _validate_profile_name, etc.
199
+ #
200
+ # Cost note: list_profiles_api() shells out via hermes_cli (non-trivial), so
201
+ # we memoize the lookup. The cache is invalidated whenever profiles are
202
+ # created, deleted, renamed, or cloned — i.e. on every mutation site we
203
+ # control.
204
+ _root_profile_name_cache: set[str] = {'default'}
205
+ _root_profile_name_cache_lock = threading.Lock()
206
+ _root_profile_name_cache_loaded = False
207
+
208
+
209
+ def _invalidate_root_profile_cache() -> None:
210
+ """Drop the memoized root-profile-name set.
211
+
212
+ Called whenever profile metadata might have changed: create, clone,
213
+ delete, rename. The next _is_root_profile() call repopulates from
214
+ list_profiles_api().
215
+ """
216
+ global _root_profile_name_cache_loaded
217
+ with _root_profile_name_cache_lock:
218
+ _root_profile_name_cache.clear()
219
+ _root_profile_name_cache.add('default')
220
+ _root_profile_name_cache_loaded = False
221
+
222
+
223
+ def _is_root_profile(name: str) -> bool:
224
+ """True if *name* resolves to the Hermes Agent root profile (~/.hermes).
225
+
226
+ Matches the legacy 'default' alias plus any name where list_profiles_api()
227
+ reports is_default=True. Memoized; call _invalidate_root_profile_cache()
228
+ after mutating profile metadata.
229
+ """
230
+ global _root_profile_name_cache_loaded
231
+ if not name:
232
+ return False
233
+ if name == 'default':
234
+ return True
235
+ with _root_profile_name_cache_lock:
236
+ if _root_profile_name_cache_loaded:
237
+ return name in _root_profile_name_cache
238
+ # Cache miss — populate from list_profiles_api(). Done outside the lock to
239
+ # avoid holding it across a hermes_cli subprocess call.
240
+ try:
241
+ infos = list_profiles_api()
242
+ except Exception:
243
+ logger.debug("Failed to list profiles for root-profile lookup", exc_info=True)
244
+ return False
245
+ with _root_profile_name_cache_lock:
246
+ _root_profile_name_cache.clear()
247
+ _root_profile_name_cache.add('default')
248
+ for p in infos:
249
+ try:
250
+ if p.get('is_default') and p.get('name'):
251
+ _root_profile_name_cache.add(p['name'])
252
+ except (AttributeError, TypeError):
253
+ continue
254
+ _root_profile_name_cache_loaded = True
255
+ return name in _root_profile_name_cache
256
+
257
+
258
+ def _profiles_match(row_profile, active_profile) -> bool:
259
+ """Return True if a session/project row's profile matches the active profile.
260
+
261
+ Treats both the literal alias 'default' and any renamed-root display name
262
+ (per _is_root_profile) as equivalent, so legacy rows tagged 'default'
263
+ still surface when the user has renamed the root profile to e.g. 'kinni',
264
+ and vice versa.
265
+
266
+ A row with no profile (`None` or empty string) is treated as belonging to
267
+ the root profile — that's the convention used by the legacy backfill at
268
+ api/models.py::all_sessions, and matches the default seen in
269
+ `static/sessions.js` (`S.activeProfile||'default'`).
270
+
271
+ Originally lived in api/routes.py; relocated here so both routes.py and
272
+ out-of-process consumers (mcp_server.py) can import the canonical helper
273
+ instead of duplicating the body. See #1614 for the visibility model.
274
+ """
275
+ row = row_profile or 'default'
276
+ active = active_profile or 'default'
277
+ if row == active:
278
+ return True
279
+ # Cross-alias the renamed root.
280
+ if _is_root_profile(row) and _is_root_profile(active):
281
+ return True
282
+ return False
283
+
284
+
285
+ def get_active_profile_name() -> str:
286
+ """Return the currently active profile name.
287
+
288
+ Priority:
289
+ 1. Thread-local (set per-request from hermes_profile cookie) — issue #798
290
+ 2. Process-level default (_active_profile)
291
+ """
292
+ tls_name = getattr(_tls, 'profile', None)
293
+ if tls_name is not None:
294
+ return tls_name
295
+ return _active_profile
296
+
297
+
298
+ def set_request_profile(name: str) -> None:
299
+ """Set the per-request profile context for this thread.
300
+
301
+ Called by server.py at the start of each request when a hermes_profile
302
+ cookie is present. Always paired with clear_request_profile() in a
303
+ finally block so the thread-local is released after the request.
304
+ """
305
+ _tls.profile = name
306
+
307
+
308
+ def clear_request_profile() -> None:
309
+ """Clear the per-request profile context for this thread.
310
+
311
+ Called by server.py in the finally block of do_GET / do_POST.
312
+ Safe to call even if set_request_profile() was never called.
313
+ """
314
+ _tls.profile = None
315
+
316
+
317
+ def _resolve_profile_home_for_name(name: str) -> Path:
318
+ """Resolve a logical profile name to its Hermes home path.
319
+
320
+ Root/default aliases resolve to _DEFAULT_HERMES_HOME. Valid named profiles
321
+ resolve to _DEFAULT_HERMES_HOME/profiles/<name> even when the directory has
322
+ not been created yet; the agent layer may create it on first use. Invalid
323
+ names fall back to the base home so traversal-shaped cookie values cannot
324
+ influence filesystem paths.
325
+ """
326
+ if not name or _is_root_profile(name):
327
+ return _DEFAULT_HERMES_HOME
328
+ if not _PROFILE_ID_RE.fullmatch(name):
329
+ return _DEFAULT_HERMES_HOME
330
+ return _resolve_named_profile_home(name)
331
+
332
+
333
+ def get_active_hermes_home() -> Path:
334
+ """Return the HERMES_HOME path for the currently active profile.
335
+
336
+ Uses get_active_profile_name() so per-request TLS context (issue #798)
337
+ is respected, not just the process-level global.
338
+ """
339
+ return _resolve_profile_home_for_name(get_active_profile_name())
340
+
341
+
342
+
343
+ # ── Cron-call profile isolation (issue: Scheduled jobs ignored active profile) ─
344
+ # `cron.jobs` reads HERMES_HOME from os.environ (process-global) at function-
345
+ # call time. That bypasses our per-request thread-local profile, so the
346
+ # `/api/crons*` endpoints always returned the process-default profile's jobs.
347
+ # This context manager swaps HERMES_HOME (and the cached module-level constants
348
+ # in cron.jobs) for the duration of a cron call, serialized by a lock so
349
+ # concurrent requests from different profiles don't race on the global env var.
350
+ #
351
+ # Thread-safety note on os.environ mutation:
352
+ # CPython's os.environ assignment is GIL-protected at the bytecode level, but
353
+ # multi-step read-modify-write sequences (snapshot prev → assign new → restore
354
+ # on exit) are NOT atomic without explicit serialization. The _cron_env_lock
355
+ # below makes the entire context-manager body run-to-completion serially, so
356
+ # all webui access to HERMES_HOME goes through one thread at a time. Any
357
+ # subprocess.Popen() call inside `run_job` inherits the env at fork time,
358
+ # which is also under the lock — so child processes always see a consistent
359
+ # (own-profile) HERMES_HOME, never a half-swapped state.
360
+ _cron_env_lock = threading.Lock()
361
+
362
+
363
+ def _cron_profile_context_depth() -> int:
364
+ return int(getattr(_tls, 'cron_profile_depth', 0) or 0)
365
+
366
+
367
+ def _push_cron_profile_context_depth() -> None:
368
+ _tls.cron_profile_depth = _cron_profile_context_depth() + 1
369
+
370
+
371
+ def _pop_cron_profile_context_depth() -> None:
372
+ depth = _cron_profile_context_depth()
373
+ _tls.cron_profile_depth = max(0, depth - 1)
374
+
375
+
376
+ def _home_for_scheduled_cron_job(job: dict) -> Path:
377
+ """Resolve the profile home an auto-fired scheduler job should execute in.
378
+
379
+ Legacy jobs with no profile keep the scheduler's server-default profile.
380
+ Jobs pinned to a named profile execute under that profile's HERMES_HOME, so
381
+ an in-process WebUI scheduler thread does not leak process-global config or
382
+ .env into the agent run. If a profile was deleted after the job was saved,
383
+ fall back to the server default rather than crashing every scheduler tick.
384
+ """
385
+ raw = str((job or {}).get('profile') or '').strip()
386
+ if not raw:
387
+ return get_active_hermes_home()
388
+ if _is_root_profile(raw):
389
+ return _DEFAULT_HERMES_HOME
390
+ if not _PROFILE_ID_RE.fullmatch(raw):
391
+ logger.warning(
392
+ "Cron job %s has invalid profile %r; falling back to server default",
393
+ (job or {}).get('id', '?'), raw,
394
+ )
395
+ return get_active_hermes_home()
396
+ home = _resolve_named_profile_home(raw)
397
+ if not home.is_dir():
398
+ logger.warning(
399
+ "Cron job %s references missing profile %r; falling back to server default",
400
+ (job or {}).get('id', '?'), raw,
401
+ )
402
+ return get_active_hermes_home()
403
+ return home
404
+
405
+
406
+ def install_cron_scheduler_profile_isolation() -> None:
407
+ """Patch cron.scheduler.run_job for WebUI in-process scheduler safety.
408
+
409
+ Standard WebUI deployments do not start the scheduler thread in-process, but
410
+ if a future/single-process deployment calls cron.scheduler.tick() from the
411
+ WebUI worker, tick's background job path has no request TLS context. Wrap
412
+ run_job so each auto-fired job's persisted ``profile`` field gets the same
413
+ HERMES_HOME isolation as the manual /api/crons/run path.
414
+ """
415
+ try:
416
+ import cron.scheduler as _cs
417
+ except ImportError:
418
+ logger.debug("install_cron_scheduler_profile_isolation: cron.scheduler unavailable")
419
+ return
420
+
421
+ original = getattr(_cs, 'run_job', None)
422
+ if original is None or getattr(original, '_webui_profile_isolated', False):
423
+ return
424
+
425
+ def _webui_profile_isolated_run_job(job, *args, **kwargs):
426
+ # Manual WebUI runs already enter cron_profile_context_for_home before
427
+ # calling run_job. Avoid nesting the non-reentrant env lock or changing
428
+ # the explicitly selected manual execution profile.
429
+ if _cron_profile_context_depth() > 0:
430
+ return original(job, *args, **kwargs)
431
+ try:
432
+ with cron_profile_context_for_home(_home_for_scheduled_cron_job(job)):
433
+ return original(job, *args, **kwargs)
434
+ finally:
435
+ publish_session_list_changed("cron_complete")
436
+
437
+ _webui_profile_isolated_run_job._webui_profile_isolated = True
438
+ _webui_profile_isolated_run_job._webui_original_run_job = original
439
+ _cs.run_job = _webui_profile_isolated_run_job
440
+
441
+
442
+ class cron_profile_context_for_home:
443
+ """Context manager that pins HERMES_HOME to an explicit profile home path.
444
+
445
+ Use this variant from worker threads that don't have TLS context (e.g. the
446
+ background thread started by /api/crons/run). The HTTP-side variant below
447
+ resolves the home via TLS.
448
+ """
449
+
450
+ def __init__(self, home: Path):
451
+ self._home = Path(home)
452
+
453
+ def __enter__(self):
454
+ _cron_env_lock.acquire()
455
+ _push_cron_profile_context_depth()
456
+ try:
457
+ self._prev_env = os.environ.get('HERMES_HOME')
458
+ os.environ['HERMES_HOME'] = str(self._home)
459
+
460
+ # Re-patch cron.jobs module-level constants (see main context manager
461
+ # below for the rationale).
462
+ self._prev_cj = None
463
+ try:
464
+ import cron.jobs as _cj
465
+ self._prev_cj = (_cj.HERMES_DIR, _cj.CRON_DIR, _cj.JOBS_FILE, _cj.OUTPUT_DIR)
466
+ _cj.HERMES_DIR = self._home
467
+ _cj.CRON_DIR = self._home / 'cron'
468
+ _cj.JOBS_FILE = _cj.CRON_DIR / 'jobs.json'
469
+ _cj.OUTPUT_DIR = _cj.CRON_DIR / 'output'
470
+ except (ImportError, AttributeError):
471
+ logger.debug("cron_profile_context_for_home: cron.jobs unavailable")
472
+
473
+ # cron.scheduler snapshots _hermes_home at import time and run_job()
474
+ # reads config/.env from that module global. Patch it alongside
475
+ # cron.jobs so manual WebUI runs actually execute under the selected
476
+ # profile, not merely write output metadata there (#617).
477
+ self._prev_cs = None
478
+ try:
479
+ import cron.scheduler as _cs
480
+ self._prev_cs = (
481
+ getattr(_cs, '_hermes_home', None),
482
+ getattr(_cs, '_LOCK_DIR', None),
483
+ getattr(_cs, '_LOCK_FILE', None),
484
+ )
485
+ _cs._hermes_home = self._home
486
+ _cs._LOCK_DIR = self._home / 'cron'
487
+ _cs._LOCK_FILE = _cs._LOCK_DIR / '.tick.lock'
488
+ except (ImportError, AttributeError):
489
+ logger.debug("cron_profile_context_for_home: cron.scheduler unavailable")
490
+ except Exception:
491
+ _pop_cron_profile_context_depth()
492
+ _cron_env_lock.release()
493
+ raise
494
+ return self
495
+
496
+ def __exit__(self, exc_type, exc_val, exc_tb):
497
+ try:
498
+ if self._prev_env is None:
499
+ os.environ.pop('HERMES_HOME', None)
500
+ else:
501
+ os.environ['HERMES_HOME'] = self._prev_env
502
+ if self._prev_cj is not None:
503
+ try:
504
+ import cron.jobs as _cj
505
+ _cj.HERMES_DIR, _cj.CRON_DIR, _cj.JOBS_FILE, _cj.OUTPUT_DIR = self._prev_cj
506
+ except (ImportError, AttributeError):
507
+ pass
508
+ if getattr(self, '_prev_cs', None) is not None:
509
+ try:
510
+ import cron.scheduler as _cs
511
+ _cs._hermes_home, _cs._LOCK_DIR, _cs._LOCK_FILE = self._prev_cs
512
+ except (ImportError, AttributeError):
513
+ pass
514
+ finally:
515
+ _pop_cron_profile_context_depth()
516
+ _cron_env_lock.release()
517
+ return False
518
+
519
+
520
+ class cron_profile_context:
521
+ """Context manager that pins HERMES_HOME to the TLS-active profile.
522
+
523
+ Usage:
524
+ with cron_profile_context():
525
+ from cron.jobs import list_jobs
526
+ jobs = list_jobs(include_disabled=True)
527
+
528
+ Serializes cron API calls across profiles (cron API is low-frequency;
529
+ serialization cost is negligible compared to correctness).
530
+ """
531
+
532
+ def __enter__(self):
533
+ _cron_env_lock.acquire()
534
+ _push_cron_profile_context_depth()
535
+ try:
536
+ self._prev_env = os.environ.get('HERMES_HOME')
537
+ home = get_active_hermes_home()
538
+ os.environ['HERMES_HOME'] = str(home)
539
+
540
+ # Re-patch cron.jobs module-level constants. They are snapshot at
541
+ # import time (line 68-71 of cron/jobs.py) and don't participate in
542
+ # the module's __getattr__ lazy path, so env-var alone is not enough
543
+ # for callers that reference the module constants directly.
544
+ self._prev_cj = None
545
+ try:
546
+ import cron.jobs as _cj
547
+ self._prev_cj = (_cj.HERMES_DIR, _cj.CRON_DIR, _cj.JOBS_FILE, _cj.OUTPUT_DIR)
548
+ _cj.HERMES_DIR = home
549
+ _cj.CRON_DIR = home / 'cron'
550
+ _cj.JOBS_FILE = _cj.CRON_DIR / 'jobs.json'
551
+ _cj.OUTPUT_DIR = _cj.CRON_DIR / 'output'
552
+ except (ImportError, AttributeError):
553
+ logger.debug("cron_profile_context: cron.jobs unavailable; env-var only")
554
+
555
+ self._prev_cs = None
556
+ try:
557
+ import cron.scheduler as _cs
558
+ self._prev_cs = (
559
+ getattr(_cs, '_hermes_home', None),
560
+ getattr(_cs, '_LOCK_DIR', None),
561
+ getattr(_cs, '_LOCK_FILE', None),
562
+ )
563
+ _cs._hermes_home = home
564
+ _cs._LOCK_DIR = home / 'cron'
565
+ _cs._LOCK_FILE = _cs._LOCK_DIR / '.tick.lock'
566
+ except (ImportError, AttributeError):
567
+ logger.debug("cron_profile_context: cron.scheduler unavailable; env-var only")
568
+ except Exception:
569
+ _pop_cron_profile_context_depth()
570
+ _cron_env_lock.release()
571
+ raise
572
+ return self
573
+
574
+ def __exit__(self, exc_type, exc_val, exc_tb):
575
+ try:
576
+ # Restore env var
577
+ if self._prev_env is None:
578
+ os.environ.pop('HERMES_HOME', None)
579
+ else:
580
+ os.environ['HERMES_HOME'] = self._prev_env
581
+
582
+ # Restore cron.jobs module constants
583
+ if self._prev_cj is not None:
584
+ try:
585
+ import cron.jobs as _cj
586
+ _cj.HERMES_DIR, _cj.CRON_DIR, _cj.JOBS_FILE, _cj.OUTPUT_DIR = self._prev_cj
587
+ except (ImportError, AttributeError):
588
+ pass
589
+ if getattr(self, '_prev_cs', None) is not None:
590
+ try:
591
+ import cron.scheduler as _cs
592
+ _cs._hermes_home, _cs._LOCK_DIR, _cs._LOCK_FILE = self._prev_cs
593
+ except (ImportError, AttributeError):
594
+ pass
595
+ finally:
596
+ _pop_cron_profile_context_depth()
597
+ _cron_env_lock.release()
598
+ return False
599
+
600
+
601
+ def get_hermes_home_for_profile(name: str) -> Path:
602
+ """Return the HERMES_HOME Path for *name* without mutating any process state.
603
+
604
+ Safe to call from per-request context (streaming, session creation) because
605
+ it reads only the filesystem — it never touches os.environ, module-level
606
+ cached paths, or the process-level _active_profile global.
607
+
608
+ Falls back to _DEFAULT_HERMES_HOME (same as 'default') when *name* is None,
609
+ empty, 'default', or does not match the profile-name format (rejects path
610
+ traversal such as '../../etc').
611
+ """
612
+ return _resolve_profile_home_for_name(name)
613
+
614
+
615
+ _TERMINAL_ENV_MAPPINGS = {
616
+ 'backend': 'TERMINAL_ENV',
617
+ 'env_type': 'TERMINAL_ENV',
618
+ 'cwd': 'TERMINAL_CWD',
619
+ 'timeout': 'TERMINAL_TIMEOUT',
620
+ 'lifetime_seconds': 'TERMINAL_LIFETIME_SECONDS',
621
+ 'modal_mode': 'TERMINAL_MODAL_MODE',
622
+ 'docker_image': 'TERMINAL_DOCKER_IMAGE',
623
+ 'docker_forward_env': 'TERMINAL_DOCKER_FORWARD_ENV',
624
+ 'docker_env': 'TERMINAL_DOCKER_ENV',
625
+ 'docker_mount_cwd_to_workspace': 'TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE',
626
+ 'singularity_image': 'TERMINAL_SINGULARITY_IMAGE',
627
+ 'modal_image': 'TERMINAL_MODAL_IMAGE',
628
+ 'daytona_image': 'TERMINAL_DAYTONA_IMAGE',
629
+ 'container_cpu': 'TERMINAL_CONTAINER_CPU',
630
+ 'container_memory': 'TERMINAL_CONTAINER_MEMORY',
631
+ 'container_disk': 'TERMINAL_CONTAINER_DISK',
632
+ 'container_persistent': 'TERMINAL_CONTAINER_PERSISTENT',
633
+ 'docker_volumes': 'TERMINAL_DOCKER_VOLUMES',
634
+ 'persistent_shell': 'TERMINAL_PERSISTENT_SHELL',
635
+ 'ssh_host': 'TERMINAL_SSH_HOST',
636
+ 'ssh_user': 'TERMINAL_SSH_USER',
637
+ 'ssh_port': 'TERMINAL_SSH_PORT',
638
+ 'ssh_key': 'TERMINAL_SSH_KEY',
639
+ 'ssh_persistent': 'TERMINAL_SSH_PERSISTENT',
640
+ 'local_persistent': 'TERMINAL_LOCAL_PERSISTENT',
641
+ }
642
+
643
+
644
+ def _stringify_env_value(value) -> str:
645
+ if isinstance(value, bool):
646
+ return 'true' if value else 'false'
647
+ if isinstance(value, (list, dict)):
648
+ return json.dumps(value)
649
+ return str(value)
650
+
651
+
652
+ def get_profile_runtime_env(home: Path) -> dict[str, str]:
653
+ """Return env vars needed to run an agent turn for a profile home.
654
+
655
+ WebUI profile switching is per-client/cookie scoped, so it intentionally
656
+ does not call ``switch_profile(..., process_wide=True)`` for every browser.
657
+ Agent/tool code still consumes terminal backend settings through
658
+ environment variables (matching ``hermes -p <profile>``), so streaming must
659
+ apply the selected profile's terminal config and ``.env`` for the duration
660
+ of that run.
661
+ """
662
+ home = Path(home).expanduser()
663
+ env: dict[str, str] = {}
664
+
665
+ try:
666
+ import yaml as _yaml
667
+
668
+ cfg_path = home / 'config.yaml'
669
+ cfg = _yaml.safe_load(cfg_path.read_text(encoding='utf-8')) if cfg_path.exists() else {}
670
+ if not isinstance(cfg, dict):
671
+ cfg = {}
672
+ except Exception:
673
+ cfg = {}
674
+
675
+ terminal_cfg = cfg.get('terminal', {}) if isinstance(cfg, dict) else {}
676
+ if isinstance(terminal_cfg, dict):
677
+ for key, env_key in _TERMINAL_ENV_MAPPINGS.items():
678
+ if key in terminal_cfg and terminal_cfg[key] is not None:
679
+ env[env_key] = _stringify_env_value(terminal_cfg[key])
680
+
681
+ env_path = home / '.env'
682
+ if env_path.exists():
683
+ try:
684
+ for line in env_path.read_text(encoding='utf-8').splitlines():
685
+ line = line.strip()
686
+ if line and not line.startswith('#') and '=' in line:
687
+ k, v = line.split('=', 1)
688
+ k = k.strip()
689
+ v = v.strip().strip('"').strip("'")
690
+ if k and v:
691
+ env[k] = v
692
+ except Exception:
693
+ logger.debug("Failed to read runtime env from %s", env_path)
694
+
695
+ return env
696
+
697
+
698
+ @contextmanager
699
+ def profile_env_for_background_worker(
700
+ session,
701
+ purpose: str = "background worker",
702
+ logger_override: Optional[logging.Logger] = None,
703
+ ):
704
+ """Temporarily route detached worker config reads through a profile.
705
+
706
+ Background WebUI workers run outside the request/streaming thread that
707
+ established the profile-scoped environment. Workers that read agent config,
708
+ runtime provider settings, or skill paths must temporarily apply the
709
+ session/request profile env or they can fall back to the server-default
710
+ profile. Pass either a session-like object with `.profile` or a profile name.
711
+ """
712
+ log = logger_override or logger
713
+ raw_profile = session if isinstance(session, str) else getattr(session, "profile", "")
714
+ profile = str(raw_profile or "").strip()
715
+ if not profile or profile == "default":
716
+ yield
717
+ return
718
+
719
+ try:
720
+ # Lazy imports avoid a module-load cycle: streaming imports this helper.
721
+ from api.config import _clear_thread_env, _set_thread_env, _thread_ctx
722
+ from api.streaming import _ENV_LOCK
723
+
724
+ profile_home_path = Path(get_hermes_home_for_profile(profile))
725
+ runtime_env = get_profile_runtime_env(profile_home_path)
726
+ except Exception:
727
+ log.debug(
728
+ "Failed to resolve profile env for %s profile %s; falling back to current env",
729
+ purpose,
730
+ profile,
731
+ exc_info=True,
732
+ )
733
+ yield
734
+ return
735
+
736
+ thread_env = dict(runtime_env)
737
+ thread_env["HERMES_HOME"] = str(profile_home_path)
738
+ # Hybrid profile routing: keep the broad runtime env in WebUI's thread-local
739
+ # channel for WebUI helpers, and also mirror it into process env for the
740
+ # worker body because several production Hermes readers still call
741
+ # os.getenv() directly for provider credentials. Keep the _ENV_LOCK scope
742
+ # narrow: serialize only setup/restore, not the whole worker body.
743
+ skill_home_snapshot = None
744
+ old_runtime_env: dict[str, Optional[str]] = {}
745
+ old_hermes_home = None
746
+ had_hermes_home = False
747
+ previous_thread_env = getattr(_thread_ctx, "env", {}).copy()
748
+ try:
749
+ _set_thread_env(**thread_env)
750
+ with _ENV_LOCK:
751
+ old_runtime_env = {key: os.environ.get(key) for key in runtime_env}
752
+ had_hermes_home = "HERMES_HOME" in os.environ
753
+ old_hermes_home = os.environ.get("HERMES_HOME")
754
+ skill_home_snapshot = snapshot_skill_home_modules()
755
+ os.environ.update(runtime_env)
756
+ os.environ["HERMES_HOME"] = str(profile_home_path)
757
+ try:
758
+ patch_skill_home_modules(profile_home_path)
759
+ except Exception:
760
+ log.debug(
761
+ "Failed to patch skill modules for %s profile %s",
762
+ purpose,
763
+ profile,
764
+ exc_info=True,
765
+ )
766
+ yield
767
+ finally:
768
+ if previous_thread_env:
769
+ _set_thread_env(**previous_thread_env)
770
+ else:
771
+ _clear_thread_env()
772
+ with _ENV_LOCK:
773
+ for key, old_value in old_runtime_env.items():
774
+ if old_value is None:
775
+ os.environ.pop(key, None)
776
+ else:
777
+ os.environ[key] = old_value
778
+ if had_hermes_home:
779
+ os.environ["HERMES_HOME"] = old_hermes_home or ""
780
+ else:
781
+ os.environ.pop("HERMES_HOME", None)
782
+ if skill_home_snapshot is not None:
783
+ restore_skill_home_modules(skill_home_snapshot)
784
+
785
+
786
+ def _set_hermes_home(home: Path):
787
+ """Set HERMES_HOME env var and monkey-patch cached module-level paths."""
788
+ os.environ['HERMES_HOME'] = str(home)
789
+
790
+ patch_skill_home_modules(home)
791
+
792
+ # Patch cron/jobs module-level cache
793
+ try:
794
+ import cron.jobs as _cj
795
+ _cj.HERMES_DIR = home
796
+ _cj.CRON_DIR = home / 'cron'
797
+ _cj.JOBS_FILE = _cj.CRON_DIR / 'jobs.json'
798
+ _cj.OUTPUT_DIR = _cj.CRON_DIR / 'output'
799
+ except (ImportError, AttributeError):
800
+ logger.debug("Failed to patch cron.jobs module")
801
+
802
+ try:
803
+ import cron.scheduler as _cs
804
+ _cs._hermes_home = home
805
+ _cs._LOCK_DIR = home / 'cron'
806
+ _cs._LOCK_FILE = _cs._LOCK_DIR / '.tick.lock'
807
+ except (ImportError, AttributeError):
808
+ logger.debug("Failed to patch cron.scheduler module")
809
+
810
+
811
+ def _reload_dotenv(home: Path):
812
+ """Load .env from the profile dir into os.environ with profile isolation.
813
+
814
+ Clears env vars that were loaded from the previously active profile before
815
+ applying the current profile's .env. This prevents API keys and other
816
+ profile-scoped secrets from leaking across profile switches.
817
+ """
818
+ global _loaded_profile_env_keys
819
+
820
+ # Remove keys loaded from the previous profile first.
821
+ for key in list(_loaded_profile_env_keys):
822
+ os.environ.pop(key, None)
823
+ _loaded_profile_env_keys = set()
824
+
825
+ env_path = home / '.env'
826
+ if not env_path.exists():
827
+ return
828
+ try:
829
+ loaded_keys: set[str] = set()
830
+ for line in env_path.read_text(encoding="utf-8").splitlines():
831
+ line = line.strip()
832
+ if line and not line.startswith('#') and '=' in line:
833
+ k, v = line.split('=', 1)
834
+ k = k.strip()
835
+ v = v.strip().strip('"').strip("'")
836
+ if k and v:
837
+ os.environ[k] = v
838
+ loaded_keys.add(k)
839
+ _loaded_profile_env_keys = loaded_keys
840
+ except Exception:
841
+ _loaded_profile_env_keys = set()
842
+ logger.debug("Failed to reload dotenv from %s", env_path)
843
+
844
+
845
+ def init_profile_state() -> None:
846
+ """Initialize profile state at server startup.
847
+
848
+ Reads ~/.hermes/active_profile, sets HERMES_HOME env var, patches
849
+ module-level cached paths. Called once from config.py after imports.
850
+ """
851
+ global _active_profile
852
+ _active_profile = _read_active_profile_file()
853
+ home = get_active_hermes_home()
854
+ _set_hermes_home(home)
855
+ install_cron_scheduler_profile_isolation()
856
+ _reload_dotenv(home)
857
+
858
+
859
+ def switch_profile(name: str, *, process_wide: bool = True) -> dict:
860
+ """Switch the active profile.
861
+
862
+ Validates the profile exists, updates process state, patches module caches,
863
+ reloads .env, and reloads config.yaml.
864
+
865
+ Args:
866
+ name: Profile name to switch to.
867
+ process_wide: If True (default), updates the process-global
868
+ _active_profile. Set to False for per-client switches from the
869
+ WebUI where the profile is managed via cookie + thread-local (#798).
870
+
871
+ Returns: {'profiles': [...], 'active': name}
872
+ Raises ValueError if profile doesn't exist or agent is busy.
873
+ """
874
+ global _active_profile
875
+
876
+ # Import here to avoid circular import at module load
877
+ from api.config import STREAMS, STREAMS_LOCK, reload_config
878
+
879
+ # Process-wide profile switches mutate HERMES_HOME, module-level path caches,
880
+ # os.environ-backed .env keys, and the global config cache. Keep those blocked
881
+ # while any agent stream is active. Per-client WebUI switches are cookie/TLS
882
+ # scoped (process_wide=False) and do not mutate those globals, so users can
883
+ # leave a running session in one profile and start work in another (#1700).
884
+ if process_wide:
885
+ with STREAMS_LOCK:
886
+ if len(STREAMS) > 0:
887
+ raise RuntimeError(
888
+ 'Cannot switch profiles while an agent is running. '
889
+ 'Cancel or wait for it to finish.'
890
+ )
891
+
892
+ # Resolve profile directory
893
+ if _is_root_profile(name):
894
+ home = _DEFAULT_HERMES_HOME
895
+ else:
896
+ home = _resolve_named_profile_home(name)
897
+ if not home.is_dir():
898
+ raise ValueError(f"Profile '{name}' does not exist.")
899
+
900
+ with _profile_lock:
901
+ if process_wide:
902
+ global _active_profile
903
+ _active_profile = name
904
+ _set_hermes_home(home)
905
+ _reload_dotenv(home)
906
+
907
+ if process_wide:
908
+ # Write sticky default for CLI consistency
909
+ try:
910
+ ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
911
+ ap_file.write_text('' if _is_root_profile(name) else name, encoding='utf-8')
912
+ except Exception:
913
+ logger.debug("Failed to write active profile file")
914
+
915
+ # Reload config.yaml from the new profile
916
+ reload_config()
917
+
918
+ # Return profile-specific defaults so frontend can apply them.
919
+ # For process_wide=False (per-client switch), read the target profile's
920
+ # config.yaml directly from disk rather than from _cfg_cache (process-global),
921
+ # since reload_config() was intentionally skipped.
922
+ if process_wide:
923
+ from api.config import get_config
924
+ cfg = get_config()
925
+ else:
926
+ # Direct disk read — does not touch _cfg_cache
927
+ try:
928
+ import yaml as _yaml
929
+ cfg_path = home / 'config.yaml'
930
+ cfg = _yaml.safe_load(cfg_path.read_text(encoding='utf-8')) if cfg_path.exists() else {}
931
+ if not isinstance(cfg, dict):
932
+ cfg = {}
933
+ except Exception:
934
+ cfg = {}
935
+ model_cfg = cfg.get('model', {})
936
+ default_model = None
937
+ default_model_provider = None
938
+ if isinstance(model_cfg, str):
939
+ default_model = model_cfg
940
+ elif isinstance(model_cfg, dict):
941
+ default_model = model_cfg.get('default')
942
+ default_model_provider = model_cfg.get('provider')
943
+
944
+ # Read the target profile's workspace directly from *home* rather than via
945
+ # get_last_workspace() which routes through the thread-local/process-global active
946
+ # profile — both of which still point to the OLD profile during process_wide=False
947
+ # switches (the Set-Cookie has been sent but hasn't been processed by a new request
948
+ # yet). We derive workspace in priority order:
949
+ # 1. {home}/webui_state/last_workspace.txt (previously chosen workspace for this profile)
950
+ # 2. cfg terminal.cwd / workspace / default_workspace keys
951
+ # 3. Boot-time DEFAULT_WORKSPACE constant
952
+ # Use the module-level ``Path`` (imported at line 17) rather than re-importing
953
+ # it locally — keeps the exception fallback simple and avoids a latent NameError
954
+ # if a future refactor moves the inner imports.
955
+ default_workspace = None
956
+ try:
957
+ from api.config import DEFAULT_WORKSPACE as _DW
958
+ lw_file = home / 'webui_state' / 'last_workspace.txt'
959
+ if lw_file.exists():
960
+ _p = lw_file.read_text(encoding='utf-8').strip()
961
+ if _p:
962
+ _pp = Path(_p).expanduser()
963
+ if _pp.is_dir():
964
+ default_workspace = str(_pp.resolve())
965
+ if default_workspace is None:
966
+ for _key in ('workspace', 'default_workspace'):
967
+ _v = cfg.get(_key)
968
+ if _v:
969
+ _pp = Path(str(_v)).expanduser().resolve()
970
+ if _pp.is_dir():
971
+ default_workspace = str(_pp)
972
+ break
973
+ if default_workspace is None:
974
+ _tc = cfg.get('terminal', {})
975
+ if isinstance(_tc, dict):
976
+ _cwd = _tc.get('cwd', '')
977
+ if _cwd and str(_cwd) not in ('.', ''):
978
+ _pp = Path(str(_cwd)).expanduser().resolve()
979
+ if _pp.is_dir():
980
+ default_workspace = str(_pp)
981
+ if default_workspace is None:
982
+ default_workspace = str(_DW)
983
+ except Exception:
984
+ try:
985
+ from api.config import DEFAULT_WORKSPACE as _DW2
986
+ default_workspace = str(_DW2)
987
+ except Exception:
988
+ default_workspace = str(Path.home())
989
+
990
+ return {
991
+ 'profiles': list_profiles_api(),
992
+ 'active': name,
993
+ 'default_model': default_model,
994
+ 'default_model_provider': default_model_provider,
995
+ 'default_workspace': default_workspace,
996
+ }
997
+
998
+
999
+ def list_profiles_api() -> list:
1000
+ """List all profiles with metadata, serialized for JSON response."""
1001
+ try:
1002
+ from hermes_cli.profiles import list_profiles
1003
+ infos = list_profiles()
1004
+ except ImportError:
1005
+ # hermes_cli not available -- return just the default
1006
+ return [_default_profile_dict()]
1007
+
1008
+ active = get_active_profile_name()
1009
+ result = []
1010
+ for p in infos:
1011
+ result.append({
1012
+ 'name': p.name,
1013
+ 'path': str(p.path),
1014
+ 'is_default': p.is_default,
1015
+ 'is_active': p.name == active,
1016
+ 'gateway_running': p.gateway_running,
1017
+ 'model': p.model,
1018
+ 'provider': p.provider,
1019
+ 'has_env': p.has_env,
1020
+ 'skill_count': p.skill_count,
1021
+ })
1022
+ return result
1023
+
1024
+
1025
+ def _default_profile_dict() -> dict:
1026
+ """Fallback profile dict when hermes_cli is not importable."""
1027
+ return {
1028
+ 'name': 'default',
1029
+ 'path': str(_DEFAULT_HERMES_HOME),
1030
+ 'is_default': True,
1031
+ 'is_active': True,
1032
+ 'gateway_running': False,
1033
+ 'model': None,
1034
+ 'provider': None,
1035
+ 'has_env': (_DEFAULT_HERMES_HOME / '.env').exists(),
1036
+ 'skill_count': 0,
1037
+ }
1038
+
1039
+
1040
+ def _validate_profile_name(name: str):
1041
+ """Validate profile name format (matches hermes_cli.profiles upstream)."""
1042
+ if name == 'default':
1043
+ raise ValueError("Cannot create a profile named 'default' -- it is the built-in profile.")
1044
+ # Use fullmatch (not match) so a trailing newline can't sneak past the $ anchor
1045
+ if not _PROFILE_ID_RE.fullmatch(name):
1046
+ raise ValueError(
1047
+ f"Invalid profile name {name!r}. "
1048
+ "Must match [a-z0-9][a-z0-9_-]{0,63}"
1049
+ )
1050
+
1051
+
1052
+ def _profiles_root() -> Path:
1053
+ """Return the canonical root that contains named profiles."""
1054
+ return (_DEFAULT_HERMES_HOME / 'profiles').resolve()
1055
+
1056
+
1057
+ def _resolve_named_profile_home(name: str) -> Path:
1058
+ """Resolve a named profile to a directory under the profiles root.
1059
+
1060
+ Validates *name* as a logical profile identifier first, then resolves the
1061
+ final filesystem path and enforces containment under ~/.hermes/profiles.
1062
+ """
1063
+ _validate_profile_name(name)
1064
+ profiles_root = _profiles_root()
1065
+ candidate = (profiles_root / name).resolve()
1066
+ candidate.relative_to(profiles_root)
1067
+ return candidate
1068
+
1069
+
1070
+ def _create_profile_fallback(name: str, clone_from: str = None,
1071
+ clone_config: bool = False) -> Path:
1072
+ """Create a profile directory without hermes_cli (Docker/standalone fallback)."""
1073
+ profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
1074
+ if profile_dir.exists():
1075
+ raise FileExistsError(f"Profile '{name}' already exists.")
1076
+
1077
+ # Bootstrap directory structure (exist_ok=False so a concurrent create raises)
1078
+ profile_dir.mkdir(parents=True, exist_ok=False)
1079
+ for subdir in _PROFILE_DIRS:
1080
+ (profile_dir / subdir).mkdir(parents=True, exist_ok=True)
1081
+
1082
+ # Clone config files from source profile if requested
1083
+ if clone_config and clone_from:
1084
+ if _is_root_profile(clone_from):
1085
+ source_dir = _DEFAULT_HERMES_HOME
1086
+ else:
1087
+ source_dir = _DEFAULT_HERMES_HOME / 'profiles' / clone_from
1088
+ if source_dir.is_dir():
1089
+ for filename in _CLONE_CONFIG_FILES:
1090
+ src = source_dir / filename
1091
+ if src.exists():
1092
+ shutil.copy2(src, profile_dir / filename)
1093
+
1094
+ return profile_dir
1095
+
1096
+
1097
+ # Provider → .env variable name mapping.
1098
+ # When a user supplies an API key during profile creation in the WebUI,
1099
+ # the key must be written to the profile's .env file so that Hermes Agent's
1100
+ # provider layer can read it — config.yaml model.api_key is not consumed.
1101
+ _PROVIDER_ENV_MAP: dict[str, str] = {
1102
+ "kimi-coding": "KIMI_API_KEY",
1103
+ "kimi-coding-cn": "KIMI_CN_API_KEY",
1104
+ "deepseek": "DEEPSEEK_API_KEY",
1105
+ "openai": "OPENAI_API_KEY",
1106
+ "anthropic": "ANTHROPIC_API_KEY",
1107
+ "openrouter": "OPENROUTER_API_KEY",
1108
+ "google": "GEMINI_API_KEY",
1109
+ "gemini": "GEMINI_API_KEY",
1110
+ "xai": "XAI_API_KEY",
1111
+ "groq": "GROQ_API_KEY",
1112
+ "minimax": "MINIMAX_API_KEY",
1113
+ "minimax-cn": "MINIMAX_CN_API_KEY",
1114
+ "mistral": "MISTRAL_API_KEY",
1115
+ "zai": "ZAI_API_KEY",
1116
+ "dashscope": "DASHSCOPE_API_KEY",
1117
+ "kilocode": "KILOCODE_API_KEY",
1118
+ "cerebras": "CEREBRAS_API_KEY",
1119
+ "github-copilot": "COPILOT_GITHUB_TOKEN",
1120
+ "nous": "NOUS_API_KEY",
1121
+ }
1122
+
1123
+
1124
+ def _resolve_env_var_for_provider(provider: Optional[str]) -> Optional[str]:
1125
+ """Return the .env variable name for *provider*, or the generic fallback."""
1126
+ if not provider:
1127
+ return None
1128
+ return _PROVIDER_ENV_MAP.get(str(provider).strip().lower())
1129
+
1130
+
1131
+ def _upsert_dotenv_line(env_path: Path, key: str, value: str) -> None:
1132
+ """Write or replace a KEY=value line in a dotenv file.
1133
+
1134
+ Reads existing lines; if *key* already exists its value is replaced.
1135
+ Otherwise a new line is appended. The file (and parent dirs) are created
1136
+ when they do not exist yet.
1137
+ """
1138
+ env_path.parent.mkdir(parents=True, exist_ok=True)
1139
+
1140
+ try:
1141
+ lines = env_path.read_text(encoding="utf-8").splitlines() if env_path.exists() else []
1142
+ except Exception:
1143
+ lines = []
1144
+
1145
+ new_line = f"{key}={value}"
1146
+ found = False
1147
+ new_lines: list[str] = []
1148
+ for line in lines:
1149
+ stripped = line.strip()
1150
+ if stripped and not stripped.startswith("#") and "=" in stripped:
1151
+ k, _ = stripped.split("=", 1)
1152
+ if k.strip() == key:
1153
+ new_lines.append(new_line)
1154
+ found = True
1155
+ continue
1156
+ new_lines.append(line)
1157
+
1158
+ if not found:
1159
+ new_lines.append(new_line)
1160
+
1161
+ try:
1162
+ env_path.write_text("\n".join(new_lines).rstrip("\n") + "\n", encoding="utf-8")
1163
+ except Exception as exc:
1164
+ logger.error("Failed to write %s to %s: %s", key, env_path, exc)
1165
+ raise
1166
+
1167
+
1168
+ def _write_api_key_to_dotenv(
1169
+ profile_dir: Path,
1170
+ api_key: str,
1171
+ model_provider: Optional[str] = None,
1172
+ ) -> None:
1173
+ """Write *api_key* to the profile's .env under the correct variable name.
1174
+
1175
+ If *model_provider* is known, the key is stored under the provider-specific
1176
+ env var (e.g. ``KIMI_API_KEY``); otherwise it falls back to a generic
1177
+ ``HERMES_API_KEY`` that the user can rename later.
1178
+ """
1179
+ env_var = _resolve_env_var_for_provider(model_provider)
1180
+ if not env_var:
1181
+ env_var = "HERMES_API_KEY"
1182
+ logger.info(
1183
+ "No provider→env mapping for %r; writing API key as %s",
1184
+ model_provider,
1185
+ env_var,
1186
+ )
1187
+
1188
+ env_path = profile_dir / ".env"
1189
+ _upsert_dotenv_line(env_path, env_var, api_key)
1190
+
1191
+ # Tighten permissions so the key isn't world-readable.
1192
+ try:
1193
+ env_path.chmod(0o600)
1194
+ except Exception:
1195
+ logger.debug("Failed to chmod 0o600 on %s", env_path)
1196
+
1197
+
1198
+ def _write_endpoint_to_config(profile_dir: Path, base_url: str = None, api_key: str = None) -> None:
1199
+ """Write base_url into config.yaml for a profile.
1200
+
1201
+ API keys are intentionally NOT written to config.yaml — they belong in
1202
+ the profile's .env file instead (see ``_write_api_key_to_dotenv``).
1203
+ The *api_key* parameter is accepted for backward compatibility with
1204
+ callers that still pass it; it is silently dropped here (the caller
1205
+ should have already called ``_write_api_key_to_dotenv``).
1206
+ """
1207
+ if not base_url:
1208
+ return
1209
+ config_path = profile_dir / 'config.yaml'
1210
+ try:
1211
+ import yaml as _yaml
1212
+ except ImportError:
1213
+ return
1214
+ cfg = {}
1215
+ if config_path.exists():
1216
+ try:
1217
+ loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8"))
1218
+ if isinstance(loaded, dict):
1219
+ cfg = loaded
1220
+ except Exception:
1221
+ logger.debug("Failed to load config from %s", config_path)
1222
+ model_section = cfg.get('model', {})
1223
+ if not isinstance(model_section, dict):
1224
+ model_section = {}
1225
+ if base_url:
1226
+ model_section['base_url'] = base_url
1227
+ cfg['model'] = model_section
1228
+ config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8')
1229
+
1230
+
1231
+ def _clean_profile_config_value(value: Optional[str], field: str) -> Optional[str]:
1232
+ """Return a safe single-line config value or raise ValueError."""
1233
+ if value is None:
1234
+ return None
1235
+ cleaned = str(value).strip()
1236
+ if not cleaned:
1237
+ return None
1238
+ if any(ch in cleaned for ch in ("\x00", "\r", "\n")):
1239
+ raise ValueError(f"{field} must be a single-line value")
1240
+ if len(cleaned) > 512:
1241
+ raise ValueError(f"{field} is too long")
1242
+ return cleaned
1243
+
1244
+
1245
+ def _split_webui_provider_model_value(default_model: Optional[str], model_provider: Optional[str]) -> tuple[Optional[str], Optional[str]]:
1246
+ """Normalize WebUI-internal @provider:model picker values for config.yaml."""
1247
+ model = _clean_profile_config_value(default_model, "default_model")
1248
+ provider = _clean_profile_config_value(model_provider, "model_provider")
1249
+ if model and model.startswith("@") and ":" in model:
1250
+ provider_part, model_part = model[1:].rsplit(":", 1)
1251
+ provider = provider or _clean_profile_config_value(provider_part, "model_provider")
1252
+ model = _clean_profile_config_value(model_part, "default_model")
1253
+ return model, provider
1254
+
1255
+
1256
+ def _strip_webui_provider_prefix(model_id: object) -> str:
1257
+ value = str(model_id or "").strip()
1258
+ if value.startswith("@") and ":" in value:
1259
+ return value.rsplit(":", 1)[1]
1260
+ return value
1261
+
1262
+
1263
+ def _profile_model_selection_exists(
1264
+ available_models: object,
1265
+ default_model: Optional[str],
1266
+ model_provider: Optional[str],
1267
+ ) -> bool:
1268
+ """Return True when a profile default model/provider exists in /api/models."""
1269
+ if not default_model and not model_provider:
1270
+ return True
1271
+ if not isinstance(available_models, dict):
1272
+ return False
1273
+
1274
+ provider_seen = False
1275
+ model_seen = False
1276
+ for group in available_models.get("groups", []) or []:
1277
+ if not isinstance(group, dict):
1278
+ continue
1279
+ provider_id = str(group.get("provider_id") or "").strip()
1280
+ if model_provider and provider_id != model_provider:
1281
+ continue
1282
+ if model_provider and provider_id == model_provider:
1283
+ provider_seen = True
1284
+ for model in group.get("models", []) or []:
1285
+ if not isinstance(model, dict):
1286
+ continue
1287
+ model_id = str(model.get("id") or "").strip()
1288
+ if not model_id:
1289
+ continue
1290
+ if default_model and (
1291
+ model_id == default_model
1292
+ or _strip_webui_provider_prefix(model_id) == default_model
1293
+ ):
1294
+ model_seen = True
1295
+ if model_provider:
1296
+ return True
1297
+ if not default_model and provider_seen:
1298
+ return True
1299
+
1300
+ if model_provider and not provider_seen:
1301
+ return False
1302
+ return bool(model_seen)
1303
+
1304
+
1305
+ def _get_available_models_for_profile_validation() -> dict:
1306
+ from api.config import get_available_models
1307
+
1308
+ return get_available_models()
1309
+
1310
+
1311
+ def _validate_profile_model_selection(
1312
+ default_model: Optional[str],
1313
+ model_provider: Optional[str],
1314
+ available_models: Optional[dict] = None,
1315
+ ) -> None:
1316
+ """Reject profile model defaults that do not exist in the server catalog."""
1317
+ if not default_model and not model_provider:
1318
+ return
1319
+ catalog = (
1320
+ available_models
1321
+ if available_models is not None
1322
+ else _get_available_models_for_profile_validation()
1323
+ )
1324
+ if _profile_model_selection_exists(catalog, default_model, model_provider):
1325
+ return
1326
+ if default_model and model_provider:
1327
+ raise ValueError(
1328
+ f"Selected model '{default_model}' is not available for provider '{model_provider}'"
1329
+ )
1330
+ if default_model:
1331
+ raise ValueError(f"Selected model '{default_model}' is not available")
1332
+ raise ValueError(f"Selected model provider '{model_provider}' is not available")
1333
+
1334
+
1335
+ def _write_model_defaults_to_config(
1336
+ profile_dir: Path,
1337
+ *,
1338
+ default_model: Optional[str] = None,
1339
+ model_provider: Optional[str] = None,
1340
+ ) -> None:
1341
+ """Write model default/provider fields into config.yaml for a profile."""
1342
+ default_model, model_provider = _split_webui_provider_model_value(default_model, model_provider)
1343
+ if not default_model and not model_provider:
1344
+ return
1345
+ config_path = profile_dir / 'config.yaml'
1346
+ try:
1347
+ import yaml as _yaml
1348
+ except ImportError:
1349
+ return
1350
+ cfg = {}
1351
+ if config_path.exists():
1352
+ try:
1353
+ loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8"))
1354
+ if isinstance(loaded, dict):
1355
+ cfg = loaded
1356
+ except Exception:
1357
+ logger.debug("Failed to load config from %s", config_path)
1358
+ model_section = cfg.get('model', {})
1359
+ if not isinstance(model_section, dict):
1360
+ model_section = {}
1361
+ if default_model:
1362
+ model_section['default'] = default_model
1363
+ if model_provider:
1364
+ model_section['provider'] = model_provider
1365
+ cfg['model'] = model_section
1366
+ config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8')
1367
+
1368
+
1369
+ def create_profile_api(name: str, clone_from: str = None,
1370
+ clone_config: bool = False,
1371
+ base_url: str = None,
1372
+ api_key: str = None,
1373
+ default_model: str = None,
1374
+ model_provider: str = None) -> dict:
1375
+ """Create a new profile. Returns the new profile info dict."""
1376
+ _validate_profile_name(name)
1377
+ # Defense-in-depth: validate clone_from here too, even though routes.py
1378
+ # also validates it. Any caller that bypasses the HTTP layer gets protection.
1379
+ if clone_from is not None and not _is_root_profile(clone_from):
1380
+ _validate_profile_name(clone_from)
1381
+ default_model, model_provider = _split_webui_provider_model_value(default_model, model_provider)
1382
+ _validate_profile_model_selection(default_model, model_provider)
1383
+
1384
+ try:
1385
+ from hermes_cli.profiles import create_profile
1386
+ create_profile(
1387
+ name,
1388
+ clone_from=clone_from,
1389
+ clone_config=clone_config,
1390
+ clone_all=False,
1391
+ no_alias=True,
1392
+ )
1393
+ except ImportError:
1394
+ _create_profile_fallback(name, clone_from, clone_config)
1395
+
1396
+ # Resolve the profile directory from the profile list when possible.
1397
+ # hermes_cli and the webui runtime do not always agree on the exact root,
1398
+ # so we prefer the path returned by list_profiles_api() and fall back to the
1399
+ # standard profile location only if the profile cannot be found there yet.
1400
+ profile_path = _DEFAULT_HERMES_HOME / 'profiles' / name
1401
+ for p in list_profiles_api():
1402
+ if p['name'] == name:
1403
+ try:
1404
+ profile_path = Path(p.get('path') or profile_path)
1405
+ except Exception:
1406
+ logger.debug("Failed to parse profile path")
1407
+ break
1408
+
1409
+ profile_path.mkdir(parents=True, exist_ok=True)
1410
+
1411
+ # Seed bundled skills for non-cloned profiles (#2305).
1412
+ # Cloned profiles should preserve the clone-source behaviour and must not
1413
+ # receive a second bundled-skill overlay.
1414
+ if clone_from is None:
1415
+ try:
1416
+ from hermes_cli.profiles import seed_profile_skills
1417
+ seed_profile_skills(profile_path, quiet=True)
1418
+ except ImportError:
1419
+ logger.debug(
1420
+ 'seed_profile_skills unavailable — bundled skills not seeded '
1421
+ 'for profile %s (hermes_cli not in path)',
1422
+ name,
1423
+ )
1424
+ except Exception:
1425
+ logger.warning(
1426
+ 'Bundled skills could not be seeded for profile %s; '
1427
+ 'profile created successfully anyway',
1428
+ name,
1429
+ exc_info=True,
1430
+ )
1431
+
1432
+ _write_endpoint_to_config(profile_path, base_url=base_url)
1433
+ if api_key:
1434
+ _write_api_key_to_dotenv(
1435
+ profile_path,
1436
+ api_key=api_key,
1437
+ model_provider=model_provider,
1438
+ )
1439
+ _write_model_defaults_to_config(
1440
+ profile_path,
1441
+ default_model=default_model,
1442
+ model_provider=model_provider,
1443
+ )
1444
+
1445
+ # Invalidate cached root-profile-name lookup; create_profile may have added
1446
+ # a new profile that flips is_default semantics on the agent side (#1612).
1447
+ _invalidate_root_profile_cache()
1448
+
1449
+ # Find and return the newly created profile info.
1450
+ # When hermes_cli is not importable, list_profiles_api() also falls back
1451
+ # to the stub default-only list and won't find the new profile by name.
1452
+ # In that case, return a complete profile dict directly.
1453
+ for p in list_profiles_api():
1454
+ if p['name'] == name:
1455
+ return p
1456
+ return {
1457
+ 'name': name,
1458
+ 'path': str(profile_path),
1459
+ 'is_default': False,
1460
+ 'is_active': _active_profile == name,
1461
+ 'gateway_running': False,
1462
+ 'model': None,
1463
+ 'provider': None,
1464
+ 'has_env': (profile_path / '.env').exists(),
1465
+ 'skill_count': 0,
1466
+ }
1467
+
1468
+
1469
+ def delete_profile_api(name: str) -> dict:
1470
+ """Delete a profile. Switches to default first if it's the active one."""
1471
+ if _is_root_profile(name):
1472
+ raise ValueError("Cannot delete the default profile.")
1473
+ _validate_profile_name(name)
1474
+
1475
+ # If deleting the active profile, switch to default first
1476
+ if _active_profile == name:
1477
+ try:
1478
+ switch_profile('default')
1479
+ except RuntimeError:
1480
+ raise RuntimeError(
1481
+ f"Cannot delete active profile '{name}' while an agent is running. "
1482
+ "Cancel or wait for it to finish."
1483
+ )
1484
+
1485
+ try:
1486
+ from hermes_cli.profiles import delete_profile
1487
+ delete_profile(name, yes=True)
1488
+ except ImportError:
1489
+ # Manual fallback: just remove the directory
1490
+ import shutil
1491
+ profile_dir = _resolve_named_profile_home(name)
1492
+ if profile_dir.is_dir():
1493
+ shutil.rmtree(str(profile_dir))
1494
+ else:
1495
+ raise ValueError(f"Profile '{name}' does not exist.")
1496
+
1497
+ # Drop cached root-profile-name lookup — list_profiles_api() shape changed.
1498
+ _invalidate_root_profile_cache()
1499
+ return {'ok': True, 'name': name}