@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,770 @@
1
+ """In-app OAuth flow implementations for onboarding.
2
+
3
+ The browser receives only WebUI-local flow metadata (flow_id, user_code,
4
+ verification_uri, high-level status). Provider device/auth codes and OAuth
5
+ tokens stay server-side and are persisted to the active Hermes profile's
6
+ ``auth.json`` credential_pool.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import stat
15
+ import threading
16
+ import time
17
+ import uuid
18
+ import urllib.error
19
+ import urllib.parse
20
+ import urllib.request
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # Compatibility for older helper tests and self-heal code that import these.
28
+ AUTH_JSON_PATH = Path.home() / ".hermes" / "auth.json"
29
+
30
+ CODEX_ISSUER = "https://auth.openai.com"
31
+ CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
32
+ CODEX_VERIFICATION_URI = f"{CODEX_ISSUER}/codex/device"
33
+ CODEX_USER_CODE_URL = f"{CODEX_ISSUER}/api/accounts/deviceauth/usercode"
34
+ CODEX_DEVICE_TOKEN_URL = f"{CODEX_ISSUER}/api/accounts/deviceauth/token"
35
+ CODEX_TOKEN_URL = f"{CODEX_ISSUER}/oauth/token"
36
+ CODEX_REDIRECT_URI = f"{CODEX_ISSUER}/deviceauth/callback"
37
+ CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
38
+ CODEX_FLOW_MAX_WAIT_SECONDS = 15 * 60
39
+
40
+ _ALLOWED_ONBOARDING_OAUTH_PROVIDERS = {"openai-codex", "anthropic", "claude", "claude-code"}
41
+ _ANTHROPIC_PROVIDER_ALIASES = {"anthropic", "claude", "claude-code"}
42
+ _REJECTED_ONBOARDING_OAUTH_PROVIDERS = {
43
+ "nous",
44
+ "qwen-oauth",
45
+ "gemini-cli",
46
+ "google-gemini-cli",
47
+ "minimax",
48
+ "minimax-oauth",
49
+ "copilot",
50
+ "copilot-acp",
51
+ }
52
+
53
+ ANTHROPIC_CREDENTIAL_POLL_SECONDS = 5
54
+ ANTHROPIC_FLOW_MAX_WAIT_SECONDS = 15 * 60
55
+ ANTHROPIC_PUBLIC_LINK_ERROR = "Claude Code credential linking failed. Check server logs."
56
+
57
+ _OAUTH_FLOWS: dict[str, dict[str, Any]] = {}
58
+ _OAUTH_FLOWS_LOCK = threading.Lock()
59
+ _ANTHROPIC_ENV_KEYS = ("ANTHROPIC_TOKEN", "ANTHROPIC_API_KEY")
60
+
61
+
62
+ def _clear_process_anthropic_env_values() -> None:
63
+ """Clear Anthropic process env fallbacks under the streaming env lock."""
64
+ from api.streaming import _ENV_LOCK
65
+
66
+ with _ENV_LOCK:
67
+ for key in _ANTHROPIC_ENV_KEYS:
68
+ os.environ.pop(key, None)
69
+
70
+
71
+ def resolve_runtime_provider_with_anthropic_env_lock(resolver, *args, **kwargs):
72
+ """Resolve runtime credentials under the Anthropic onboarding env lock.
73
+
74
+ Request paths must resolve Anthropic env fallbacks per outbound request,
75
+ not cache ANTHROPIC_TOKEN or ANTHROPIC_API_KEY across onboarding. Sharing
76
+ the process-env lock prevents a chat stream from observing one stale
77
+ Anthropic env value while onboarding has already cleared the other.
78
+ """
79
+ from api.streaming import _ENV_LOCK
80
+
81
+ with _ENV_LOCK:
82
+ return resolver(*args, **kwargs)
83
+
84
+
85
+ def _normalize_onboarding_oauth_provider(provider: str) -> str:
86
+ provider = str(provider or "").strip().lower()
87
+ if provider in _ANTHROPIC_PROVIDER_ALIASES:
88
+ return "anthropic"
89
+ return provider or "openai-codex"
90
+
91
+
92
+ def _get_active_hermes_home() -> Path:
93
+ try:
94
+ from api.profiles import get_active_hermes_home
95
+
96
+ return Path(get_active_hermes_home())
97
+ except Exception as exc:
98
+ # Per Opus advisor on stage-296: log the silent fallback so a corrupt
99
+ # profile state ending up writing tokens to ~/.hermes (instead of the
100
+ # active profile) is observable in logs rather than failing silently.
101
+ logger.warning(
102
+ "Falling back to ~/.hermes for OAuth credential storage: "
103
+ "active-profile resolution failed: %s",
104
+ exc,
105
+ )
106
+ return Path.home() / ".hermes"
107
+
108
+
109
+ # ── legacy auth.json helpers ────────────────────────────────────────────────
110
+
111
+ def _read_auth_json(auth_path: Path | None = None) -> dict[str, Any]:
112
+ """Read auth.json and return parsed dict, or an empty compatible store."""
113
+ path = auth_path or AUTH_JSON_PATH
114
+ if path.exists():
115
+ try:
116
+ loaded = json.loads(path.read_text(encoding="utf-8"))
117
+ return loaded if isinstance(loaded, dict) else {}
118
+ except json.JSONDecodeError as exc:
119
+ logger.warning("Failed to parse %s: %s", path, exc)
120
+ return {}
121
+ return {}
122
+
123
+
124
+ def read_auth_json():
125
+ """Public wrapper for streaming credential self-heal code."""
126
+ return _read_auth_json()
127
+
128
+
129
+ def _write_auth_json(data: dict[str, Any], auth_path: Path | None = None) -> Path:
130
+ """Atomically write auth.json with owner-only permissions.
131
+
132
+ OAuth access/refresh tokens live in this file. The temp file is chmod 0600
133
+ before rename so the final path never inherits a permissive process umask.
134
+ """
135
+ path = auth_path or AUTH_JSON_PATH
136
+ path.parent.mkdir(parents=True, exist_ok=True)
137
+ tmp = path.with_name(f"{path.name}.tmp.{os.getpid()}.{uuid.uuid4().hex}")
138
+ try:
139
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
140
+ try:
141
+ tmp.chmod(0o600)
142
+ except OSError as exc:
143
+ logger.warning("Failed to chmod 0600 on %s: %s", tmp, exc)
144
+ tmp.replace(path)
145
+ try:
146
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR)
147
+ except OSError:
148
+ pass
149
+ return path
150
+ finally:
151
+ try:
152
+ if tmp.exists():
153
+ tmp.unlink()
154
+ except OSError:
155
+ pass
156
+
157
+
158
+ def _now_iso() -> str:
159
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
160
+
161
+
162
+ def _persist_codex_credentials(hermes_home: Path, token_data: dict[str, Any]) -> Path:
163
+ """Persist Codex OAuth credentials to active-profile auth.json."""
164
+ access_token = str(token_data.get("access_token") or "").strip()
165
+ refresh_token = str(token_data.get("refresh_token") or "").strip()
166
+ if not access_token:
167
+ raise RuntimeError("Codex token exchange did not return an access_token")
168
+
169
+ auth_path = Path(hermes_home) / "auth.json"
170
+ auth = _read_auth_json(auth_path)
171
+ auth.setdefault("version", 1)
172
+ pool = auth.setdefault("credential_pool", {})
173
+ if not isinstance(pool, dict):
174
+ pool = {}
175
+ auth["credential_pool"] = pool
176
+ entries = pool.setdefault("openai-codex", [])
177
+ if not isinstance(entries, list):
178
+ entries = []
179
+ pool["openai-codex"] = entries
180
+
181
+ now = _now_iso()
182
+ entry = None
183
+ # Per Opus advisor on stage-296: also accept the legacy `source ==
184
+ # "oauth_device"` value so users with prior Codex OAuth credentials
185
+ # (written by older WebUI versions before this PR's source-key change)
186
+ # get their existing entry updated in-place rather than accumulating a
187
+ # stale duplicate pool entry.
188
+ _accept_sources = {"manual:device_code", "oauth_device"}
189
+ for candidate in entries:
190
+ if isinstance(candidate, dict) and candidate.get("source") in _accept_sources:
191
+ entry = candidate
192
+ break
193
+ if entry is None:
194
+ entry = {
195
+ "id": "codex-oauth-" + uuid.uuid4().hex[:12],
196
+ "label": "Codex OAuth",
197
+ "auth_type": "oauth",
198
+ "priority": 0,
199
+ "source": "manual:device_code",
200
+ "base_url": CODEX_BASE_URL,
201
+ "created_at": now,
202
+ }
203
+ entries.insert(0, entry)
204
+
205
+ entry.update(
206
+ {
207
+ "label": "Codex OAuth",
208
+ "auth_type": "oauth",
209
+ "priority": 0,
210
+ "source": "manual:device_code",
211
+ "access_token": access_token,
212
+ "refresh_token": refresh_token,
213
+ "base_url": CODEX_BASE_URL,
214
+ "last_refresh": now,
215
+ "updated_at": now,
216
+ }
217
+ )
218
+ auth["updated_at"] = now
219
+ path = _write_auth_json(auth, auth_path)
220
+
221
+ try:
222
+ from api.config import invalidate_credential_pool_cache
223
+
224
+ invalidate_credential_pool_cache("openai-codex")
225
+ except Exception:
226
+ logger.debug("Failed to invalidate openai-codex credential cache", exc_info=True)
227
+
228
+ return path
229
+
230
+
231
+ # Backward-compatible wrapper used by older code/tests.
232
+ def _save_codex_credentials(token_data):
233
+ return _persist_codex_credentials(_get_active_hermes_home(), token_data)
234
+
235
+
236
+ # ── Anthropic / Claude Code credential linking ─────────────────────────────
237
+
238
+ def _read_claude_code_credentials() -> dict[str, Any] | None:
239
+ """Read Claude Code OAuth credentials from the host without exposing them.
240
+
241
+ Delegates to the agent adapter which knows about ~/.claude/.credentials.json
242
+ and macOS Keychain. Returns the credential dict or None.
243
+ """
244
+ try:
245
+ from agent.anthropic_adapter import (
246
+ is_claude_code_token_valid,
247
+ read_claude_code_credentials,
248
+ )
249
+
250
+ creds = read_claude_code_credentials()
251
+ if creds and (
252
+ is_claude_code_token_valid(creds) or bool(creds.get("refreshToken"))
253
+ ):
254
+ return creds
255
+ except Exception as exc:
256
+ logger.debug("Could not read Claude Code credentials: %s", exc)
257
+ return None
258
+
259
+
260
+ def _clear_anthropic_env_values(hermes_home: Path) -> None:
261
+ """Clear Anthropic API/setup-token env values in the active profile only.
262
+
263
+ The .env write path already clears os.environ while holding the streaming
264
+ env lock. Keep a locked process-env clear here too so import/write failures
265
+ cannot leave or partially clear stale Anthropic fallbacks.
266
+ """
267
+ try:
268
+ from api.providers import _write_env_file
269
+
270
+ _write_env_file(
271
+ Path(hermes_home) / ".env",
272
+ {key: None for key in _ANTHROPIC_ENV_KEYS},
273
+ )
274
+ except Exception as exc:
275
+ logger.warning("Failed to clear Anthropic env values: %s", exc)
276
+ _clear_process_anthropic_env_values()
277
+
278
+
279
+ def _link_anthropic_credentials(hermes_home: Path) -> None:
280
+ """Link Hermes to use Claude Code's credential store.
281
+
282
+ Clears ANTHROPIC_TOKEN and ANTHROPIC_API_KEY from the Hermes .env so
283
+ that resolve_anthropic_token() falls through to reading Claude Code's
284
+ ~/.claude/.credentials.json directly — the same thing the CLI's
285
+ ``use_anthropic_claude_code_credentials()`` does.
286
+
287
+ Also writes a marker entry in auth.json credential_pool so that
288
+ ``_provider_oauth_authenticated("anthropic", ...)`` can detect the
289
+ linked state without touching the actual credential files.
290
+ """
291
+ _clear_anthropic_env_values(hermes_home)
292
+
293
+ # Write a pool marker (no secrets) so onboarding status can detect linkage.
294
+ auth_path = Path(hermes_home) / "auth.json"
295
+ auth = _read_auth_json(auth_path)
296
+ auth.setdefault("version", 1)
297
+ pool = auth.setdefault("credential_pool", {})
298
+ if not isinstance(pool, dict):
299
+ pool = {}
300
+ auth["credential_pool"] = pool
301
+ entries = pool.setdefault("anthropic", [])
302
+ if not isinstance(entries, list):
303
+ entries = []
304
+ pool["anthropic"] = entries
305
+
306
+ now = _now_iso()
307
+ entry = None
308
+ for candidate in entries:
309
+ if isinstance(candidate, dict) and candidate.get("source") == "claude_code_linked":
310
+ entry = candidate
311
+ break
312
+ if entry is None:
313
+ entry = {
314
+ "id": "anthropic-claude-code-" + uuid.uuid4().hex[:12],
315
+ "label": "Claude Code (linked)",
316
+ "auth_type": "oauth",
317
+ "priority": 0,
318
+ "source": "claude_code_linked",
319
+ "created_at": now,
320
+ }
321
+ entries.insert(0, entry)
322
+
323
+ entry.update({
324
+ "label": "Claude Code (linked)",
325
+ "auth_type": "oauth",
326
+ "priority": 0,
327
+ "source": "claude_code_linked",
328
+ "updated_at": now,
329
+ })
330
+ auth["updated_at"] = now
331
+ _write_auth_json(auth, auth_path)
332
+
333
+ try:
334
+ from api.config import invalidate_credential_pool_cache
335
+ invalidate_credential_pool_cache("anthropic")
336
+ except Exception:
337
+ logger.debug("Failed to invalidate anthropic credential cache", exc_info=True)
338
+
339
+
340
+ def _anthropic_public_start_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
341
+ payload: dict[str, Any] = {
342
+ "ok": True,
343
+ "provider": "anthropic",
344
+ "flow_id": flow_id,
345
+ "status": flow.get("status", "pending"),
346
+ "poll_interval_seconds": flow.get("poll_interval_seconds", ANTHROPIC_CREDENTIAL_POLL_SECONDS),
347
+ }
348
+ if flow.get("status") == "pending":
349
+ payload["action_required"] = (
350
+ "Claude Code credentials were not found on this server. "
351
+ "Please run 'claude login' or 'claude setup-token' in a terminal "
352
+ "on the host, then return here — this page will detect the credentials automatically."
353
+ )
354
+ if flow.get("expires_at"):
355
+ payload["expires_at"] = flow["expires_at"]
356
+ return payload
357
+
358
+
359
+ def _anthropic_public_status_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
360
+ payload: dict[str, Any] = {
361
+ "ok": True,
362
+ "provider": "anthropic",
363
+ "flow_id": flow_id,
364
+ "status": flow.get("status", "error"),
365
+ }
366
+ if flow.get("status") == "error" and flow.get("error"):
367
+ payload["error"] = ANTHROPIC_PUBLIC_LINK_ERROR
368
+ return payload
369
+
370
+
371
+ def _spawn_anthropic_credential_worker(flow_id: str) -> None:
372
+ worker = threading.Thread(
373
+ target=_run_anthropic_credential_worker, args=(flow_id,), daemon=True,
374
+ )
375
+ worker.start()
376
+
377
+
378
+ def _run_anthropic_credential_worker(flow_id: str) -> None:
379
+ """Poll for Claude Code credential appearance until found, cancelled, or expired."""
380
+ while True:
381
+ with _OAUTH_FLOWS_LOCK:
382
+ flow = dict(_OAUTH_FLOWS.get(flow_id) or {})
383
+ if not flow:
384
+ return
385
+ if flow.get("status") != "pending":
386
+ return
387
+ if float(flow.get("expires_at") or 0) <= time.time():
388
+ _set_flow_status(flow_id, "expired")
389
+ return
390
+
391
+ time.sleep(max(1, int(flow.get("poll_interval_seconds") or ANTHROPIC_CREDENTIAL_POLL_SECONDS)))
392
+
393
+ # Re-check status under lock (cancel may have arrived during sleep)
394
+ with _OAUTH_FLOWS_LOCK:
395
+ live = _OAUTH_FLOWS.get(flow_id)
396
+ if not live or live.get("status") != "pending":
397
+ return
398
+
399
+ try:
400
+ creds = _read_claude_code_credentials()
401
+ if creds is None:
402
+ continue
403
+
404
+ # Re-check status under lock before linking — cancel must win
405
+ with _OAUTH_FLOWS_LOCK:
406
+ current = _OAUTH_FLOWS.get(flow_id)
407
+ if not current or current.get("status") != "pending":
408
+ return
409
+
410
+ hermes_home = Path(flow["hermes_home"])
411
+ _link_anthropic_credentials(hermes_home)
412
+ with _OAUTH_FLOWS_LOCK:
413
+ current = _OAUTH_FLOWS.get(flow_id)
414
+ if not current or current.get("status") != "pending":
415
+ cancelled = bool(current and current.get("status") == "cancelled")
416
+ else:
417
+ current["status"] = "success"
418
+ current["updated_at"] = time.time()
419
+ _drop_sensitive_flow_fields(current)
420
+ cancelled = False
421
+ if cancelled:
422
+ _remove_anthropic_link_marker(hermes_home)
423
+ return
424
+ except Exception as exc:
425
+ logger.warning("Anthropic credential polling failed: %s", exc)
426
+ with _OAUTH_FLOWS_LOCK:
427
+ current = _OAUTH_FLOWS.get(flow_id)
428
+ if current and current.get("status") == "pending":
429
+ current["status"] = "error"
430
+ current["updated_at"] = time.time()
431
+ current["error"] = str(exc)
432
+ _drop_sensitive_flow_fields(current)
433
+ return
434
+
435
+
436
+ def _remove_anthropic_link_marker(hermes_home: Path) -> None:
437
+ """Remove the secret-free Claude Code linked marker after a cancelled race."""
438
+ auth_path = Path(hermes_home) / "auth.json"
439
+ auth = _read_auth_json(auth_path)
440
+ pool = auth.get("credential_pool")
441
+ if not isinstance(pool, dict):
442
+ return
443
+ entries = pool.get("anthropic")
444
+ if not isinstance(entries, list):
445
+ return
446
+ kept = [entry for entry in entries if not (isinstance(entry, dict) and entry.get("source") == "claude_code_linked")]
447
+ if len(kept) == len(entries):
448
+ return
449
+ if kept:
450
+ pool["anthropic"] = kept
451
+ else:
452
+ pool.pop("anthropic", None)
453
+ auth["updated_at"] = _now_iso()
454
+ _write_auth_json(auth, auth_path)
455
+ try:
456
+ from api.config import invalidate_credential_pool_cache
457
+ invalidate_credential_pool_cache("anthropic")
458
+ except Exception:
459
+ logger.debug("Failed to invalidate anthropic credential cache", exc_info=True)
460
+
461
+
462
+ # ── Codex protocol ──────────────────────────────────────────────────────────
463
+
464
+ def _json_request(url: str, payload: dict[str, Any], *, form: bool = False) -> dict[str, Any]:
465
+ if form:
466
+ data = urllib.parse.urlencode(payload).encode("utf-8")
467
+ content_type = "application/x-www-form-urlencoded"
468
+ else:
469
+ data = json.dumps(payload).encode("utf-8")
470
+ content_type = "application/json"
471
+ req = urllib.request.Request(
472
+ url,
473
+ data=data,
474
+ method="POST",
475
+ headers={"Content-Type": content_type, "Accept": "application/json"},
476
+ )
477
+ with urllib.request.urlopen(req, timeout=15) as resp:
478
+ return json.loads(resp.read().decode("utf-8"))
479
+
480
+
481
+ def _request_codex_user_code() -> dict[str, Any]:
482
+ return _json_request(CODEX_USER_CODE_URL, {"client_id": CODEX_CLIENT_ID})
483
+
484
+
485
+ def _poll_codex_authorization(device_auth_id: str, user_code: str) -> dict[str, Any] | None:
486
+ try:
487
+ return _json_request(
488
+ CODEX_DEVICE_TOKEN_URL,
489
+ {"device_auth_id": device_auth_id, "user_code": user_code},
490
+ )
491
+ except urllib.error.HTTPError as exc:
492
+ if exc.code in (403, 404):
493
+ return None
494
+ raise
495
+
496
+
497
+ def _exchange_codex_authorization(authorization_code: str, code_verifier: str) -> dict[str, Any]:
498
+ return _json_request(
499
+ CODEX_TOKEN_URL,
500
+ {
501
+ "grant_type": "authorization_code",
502
+ "code": authorization_code,
503
+ "redirect_uri": CODEX_REDIRECT_URI,
504
+ "client_id": CODEX_CLIENT_ID,
505
+ "code_verifier": code_verifier,
506
+ },
507
+ form=True,
508
+ )
509
+
510
+
511
+ def _codex_public_start_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
512
+ return {
513
+ "ok": True,
514
+ "provider": "openai-codex",
515
+ "flow_id": flow_id,
516
+ "status": flow.get("status", "pending"),
517
+ "verification_uri": CODEX_VERIFICATION_URI,
518
+ "user_code": flow.get("user_code", ""),
519
+ "expires_at": flow.get("expires_at"),
520
+ "poll_interval_seconds": flow.get("poll_interval_seconds", 5),
521
+ }
522
+
523
+
524
+ def _codex_public_status_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
525
+ payload = {
526
+ "ok": True,
527
+ "provider": "openai-codex",
528
+ "flow_id": flow_id,
529
+ "status": flow.get("status", "error"),
530
+ }
531
+ if flow.get("status") == "error" and flow.get("error"):
532
+ payload["error"] = str(flow.get("error"))[:200]
533
+ return payload
534
+
535
+
536
+ def _public_start_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
537
+ provider = flow.get("provider", "openai-codex")
538
+ if provider == "anthropic":
539
+ return _anthropic_public_start_payload(flow_id, flow)
540
+ return _codex_public_start_payload(flow_id, flow)
541
+
542
+
543
+ def _public_status_payload(flow_id: str, flow: dict[str, Any]) -> dict[str, Any]:
544
+ provider = flow.get("provider", "openai-codex")
545
+ if provider == "anthropic":
546
+ return _anthropic_public_status_payload(flow_id, flow)
547
+ return _codex_public_status_payload(flow_id, flow)
548
+
549
+
550
+ def _drop_sensitive_flow_fields(flow: dict[str, Any]) -> None:
551
+ for key in (
552
+ "device_auth_id",
553
+ "authorization_code",
554
+ "code_verifier",
555
+ "access_token",
556
+ "refresh_token",
557
+ "token_data",
558
+ ):
559
+ flow.pop(key, None)
560
+
561
+
562
+ def _cleanup_oauth_flows(now: float | None = None) -> None:
563
+ now = now or time.time()
564
+ cutoff = now - 300
565
+ with _OAUTH_FLOWS_LOCK:
566
+ for fid, flow in list(_OAUTH_FLOWS.items()):
567
+ status = flow.get("status")
568
+ if status == "pending" and float(flow.get("expires_at") or 0) <= now:
569
+ flow["status"] = "expired"
570
+ _drop_sensitive_flow_fields(flow)
571
+ if status in {"success", "expired", "cancelled", "error"} and float(flow.get("updated_at") or 0) < cutoff:
572
+ _OAUTH_FLOWS.pop(fid, None)
573
+
574
+
575
+ def _spawn_codex_oauth_worker(flow_id: str) -> None:
576
+ worker = threading.Thread(target=_run_codex_oauth_worker, args=(flow_id,), daemon=True)
577
+ worker.start()
578
+
579
+
580
+ def _set_flow_status(flow_id: str, status: str, **fields: Any) -> None:
581
+ with _OAUTH_FLOWS_LOCK:
582
+ flow = _OAUTH_FLOWS.get(flow_id)
583
+ if not flow:
584
+ return
585
+ flow["status"] = status
586
+ flow["updated_at"] = time.time()
587
+ flow.update(fields)
588
+ if status in {"success", "expired", "cancelled", "error"}:
589
+ _drop_sensitive_flow_fields(flow)
590
+
591
+
592
+ def _run_codex_oauth_worker(flow_id: str) -> None:
593
+ while True:
594
+ with _OAUTH_FLOWS_LOCK:
595
+ flow = dict(_OAUTH_FLOWS.get(flow_id) or {})
596
+ if not flow:
597
+ return
598
+ status = flow.get("status")
599
+ if status != "pending":
600
+ return
601
+ if float(flow.get("expires_at") or 0) <= time.time():
602
+ _set_flow_status(flow_id, "expired")
603
+ return
604
+
605
+ time.sleep(max(1, int(flow.get("poll_interval_seconds") or 5)))
606
+
607
+ with _OAUTH_FLOWS_LOCK:
608
+ live = dict(_OAUTH_FLOWS.get(flow_id) or {})
609
+ if live.get("status") != "pending":
610
+ return
611
+ try:
612
+ code_resp = _poll_codex_authorization(
613
+ str(live.get("device_auth_id") or ""),
614
+ str(live.get("user_code") or ""),
615
+ )
616
+ if code_resp is None:
617
+ continue
618
+ authorization_code = str(code_resp.get("authorization_code") or "").strip()
619
+ code_verifier = str(code_resp.get("code_verifier") or "").strip()
620
+ if not authorization_code or not code_verifier:
621
+ raise RuntimeError("Device auth response missing authorization_code or code_verifier")
622
+ tokens = _exchange_codex_authorization(authorization_code, code_verifier)
623
+ # Re-check status under lock before persisting: a cancel/expire that
624
+ # raced with the device-token + token-exchange network calls must
625
+ # win, so we don't persist credentials the user explicitly aborted.
626
+ with _OAUTH_FLOWS_LOCK:
627
+ current = _OAUTH_FLOWS.get(flow_id)
628
+ if not current or current.get("status") != "pending":
629
+ return
630
+ _persist_codex_credentials(Path(live["hermes_home"]), tokens)
631
+ _set_flow_status(flow_id, "success")
632
+ return
633
+ except Exception as exc:
634
+ logger.warning("Codex OAuth onboarding flow failed: %s", exc)
635
+ _set_flow_status(flow_id, "error", error=str(exc))
636
+ return
637
+
638
+
639
+ def _start_anthropic_flow(hermes_home: Path) -> dict[str, Any]:
640
+ """Start or immediately complete the Anthropic credential-linking flow."""
641
+ creds = _read_claude_code_credentials()
642
+ flow_id = uuid.uuid4().hex
643
+
644
+ if creds:
645
+ # Credentials already exist — link and return success immediately.
646
+ _link_anthropic_credentials(hermes_home)
647
+ flow = {
648
+ "provider": "anthropic",
649
+ "status": "success",
650
+ "hermes_home": str(hermes_home),
651
+ "created_at": time.time(),
652
+ "updated_at": time.time(),
653
+ }
654
+ with _OAUTH_FLOWS_LOCK:
655
+ _OAUTH_FLOWS[flow_id] = flow
656
+ return _public_start_payload(flow_id, flow)
657
+
658
+ # No credentials found — create a pending flow that polls for them.
659
+ expires_at = time.time() + ANTHROPIC_FLOW_MAX_WAIT_SECONDS
660
+ flow = {
661
+ "provider": "anthropic",
662
+ "status": "pending",
663
+ "expires_at": expires_at,
664
+ "poll_interval_seconds": ANTHROPIC_CREDENTIAL_POLL_SECONDS,
665
+ "hermes_home": str(hermes_home),
666
+ "created_at": time.time(),
667
+ "updated_at": time.time(),
668
+ }
669
+ with _OAUTH_FLOWS_LOCK:
670
+ _OAUTH_FLOWS[flow_id] = flow
671
+ _spawn_anthropic_credential_worker(flow_id)
672
+ return _public_start_payload(flow_id, flow)
673
+
674
+
675
+ def start_onboarding_oauth_flow(body: dict[str, Any] | None) -> dict[str, Any]:
676
+ """Start the supported onboarding OAuth flow.
677
+
678
+ Supports OpenAI Codex (device-code flow) and Anthropic/Claude Code
679
+ (credential-linking flow). Other providers are rejected.
680
+ """
681
+ _cleanup_oauth_flows()
682
+ provider = str((body or {}).get("provider") or "").strip().lower()
683
+ if provider not in _ALLOWED_ONBOARDING_OAUTH_PROVIDERS:
684
+ if provider in _REJECTED_ONBOARDING_OAUTH_PROVIDERS or provider:
685
+ raise ValueError(
686
+ "Only OpenAI Codex and Anthropic/Claude OAuth are supported "
687
+ "in WebUI onboarding right now"
688
+ )
689
+ raise ValueError("provider is required")
690
+
691
+ # Normalize Claude aliases to canonical "anthropic"
692
+ if provider in _ANTHROPIC_PROVIDER_ALIASES:
693
+ return _start_anthropic_flow(_get_active_hermes_home())
694
+
695
+ # Codex flow
696
+ hermes_home = _get_active_hermes_home()
697
+ try:
698
+ device = _request_codex_user_code()
699
+ except Exception as exc:
700
+ raise RuntimeError(f"Failed to start Codex OAuth: {exc}") from exc
701
+
702
+ user_code = str(device.get("user_code") or "").strip()
703
+ device_auth_id = str(device.get("device_auth_id") or "").strip()
704
+ if not user_code or not device_auth_id:
705
+ raise RuntimeError("Device code response missing required fields")
706
+
707
+ interval = max(3, int(device.get("interval") or 5))
708
+ expires_in = int(device.get("expires_in") or CODEX_FLOW_MAX_WAIT_SECONDS)
709
+ expires_at = time.time() + min(max(expires_in, 60), CODEX_FLOW_MAX_WAIT_SECONDS)
710
+ flow_id = uuid.uuid4().hex
711
+ flow = {
712
+ "provider": "openai-codex",
713
+ "status": "pending",
714
+ "device_auth_id": device_auth_id,
715
+ "user_code": user_code,
716
+ "expires_at": expires_at,
717
+ "poll_interval_seconds": interval,
718
+ "hermes_home": str(hermes_home),
719
+ "created_at": time.time(),
720
+ "updated_at": time.time(),
721
+ }
722
+ with _OAUTH_FLOWS_LOCK:
723
+ _OAUTH_FLOWS[flow_id] = flow
724
+ _spawn_codex_oauth_worker(flow_id)
725
+ return _public_start_payload(flow_id, flow)
726
+
727
+
728
+ def poll_onboarding_oauth_flow(flow_id: str) -> dict[str, Any]:
729
+ _cleanup_oauth_flows()
730
+ fid = str(flow_id or "").strip()
731
+ if not fid:
732
+ raise ValueError("flow_id is required")
733
+ with _OAUTH_FLOWS_LOCK:
734
+ flow = _OAUTH_FLOWS.get(fid)
735
+ if not flow:
736
+ raise KeyError("OAuth flow not found")
737
+ if flow.get("status") == "pending" and float(flow.get("expires_at") or 0) <= time.time():
738
+ flow["status"] = "expired"
739
+ flow["updated_at"] = time.time()
740
+ _drop_sensitive_flow_fields(flow)
741
+ return _public_status_payload(fid, dict(flow))
742
+
743
+
744
+ def cancel_onboarding_oauth_flow(body: dict[str, Any] | None) -> dict[str, Any]:
745
+ fid = str((body or {}).get("flow_id") or "").strip()
746
+ if not fid:
747
+ raise ValueError("flow_id is required")
748
+ requested_provider = _normalize_onboarding_oauth_provider(str((body or {}).get("provider") or ""))
749
+ if requested_provider not in {"openai-codex", "anthropic"}:
750
+ requested_provider = "openai-codex"
751
+ with _OAUTH_FLOWS_LOCK:
752
+ flow = _OAUTH_FLOWS.get(fid)
753
+ if not flow:
754
+ return {"ok": True, "provider": requested_provider, "flow_id": fid, "status": "cancelled"}
755
+ if flow.get("status") == "pending":
756
+ flow["status"] = "cancelled"
757
+ flow["updated_at"] = time.time()
758
+ _drop_sensitive_flow_fields(flow)
759
+ result = _public_status_payload(fid, dict(flow))
760
+ return result
761
+
762
+
763
+ # Backward-compatible names from the abandoned spike. They intentionally do not
764
+ # expose provider device secrets to callers anymore.
765
+ def start_codex_device_code():
766
+ return start_onboarding_oauth_flow({"provider": "openai-codex"})
767
+
768
+
769
+ def poll_codex_token(device_code, interval=5):
770
+ yield {"status": "error", "error": "Use /api/onboarding/oauth/poll with flow_id"}